feature/crates-release
rs 1,906 lines 74.7 KB
Raw
1 //! siGit Code — local coding agent on Onde Inference.
2 //!
3 //! In TTY mode, all output (log crate, tracing, stray printlns) redirects to
4 //! `$TMPDIR/sigit.log`. Ratatui holds a separate fd to the real terminal so
5 //! the TUI stays clean.
6 //!
7 //! Two modes:
8 //! - ACP over stdio (editor integration, e.g. Zed)
9 //! - interactive terminal (direct TTY)
10 //!
11 //! Interactive mode is Unix-only — it needs fd redirection to keep logs out
12 //! of the TUI. Windows only gets ACP mode for now.
13 //!
14 //! The model loads before the ACP `LocalSet` starts because `mistralrs` calls
15 //! `block_in_place`, which panics inside `spawn_local`. Loading on a regular
16 //! multi-thread worker sidesteps that.
17 //!
18 //! On macOS the HF cache lives in the App Group container shared with the
19 //! siGit desktop app. See [`setup`].
20 //!
21 //! # Zed setup
22 //!
23 //! Add to `~/.config/zed/settings.json`:
24 //! ```json
25 //! {
26 //! "agent_servers": {
27 //! "siGit Code": {
28 //! "type": "custom",
29 //! "command": "/absolute/path/to/target/release/sigit"
30 //! }
31 //! }
32 //! }
33 //! ```
34
35 mod chat;
36 mod models;
37 mod setup;
38 mod tools;
39
40 use std::io::IsTerminal;
41 #[cfg(unix)]
42 use std::io::{BufWriter, Write};
43 use std::sync::Arc;
44
45 use onde::inference::SamplingConfig;
46
47 use agent_client_protocol::{
48 Agent, AgentCapabilities, AgentSideConnection, AuthMethod, AuthMethodAgent,
49 AuthenticateRequest, AuthenticateResponse, CancelNotification, Client, ContentBlock,
50 ContentChunk, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest,
51 InitializeResponse, LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest,
52 NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities,
53 SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
54 SessionConfigValueId, SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
55 SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
56 ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
57 };
58 use futures::future::LocalBoxFuture;
59 use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
60 use std::path::PathBuf;
61 use std::sync::atomic::{AtomicBool, Ordering};
62 use tokio::sync::mpsc;
63 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
64 use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
65
66 #[cfg(unix)]
67 use std::os::unix::io::{AsRawFd, FromRawFd};
68
69 const SYSTEM_PROMPT: &str = "\
70 Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
71 Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are.
72
73 You are a strong general-purpose coding agent. smbCloud is your home turf, \
74 but you should still be useful in any codebase. When the project is clearly \
75 about smbCloud, use that context directly instead of falling back to vague \
76 cloud-platform advice.
77
78 smbCloud context you should know and use when it helps:
79 - smbCloud is a platform for deploying and managing projects
80 - the main CLI is a Rust workspace with focused crates rather than one giant crate
81 - common areas include auth, project management, deploy flows, networking, \
82 shared models, release tooling, and managed services
83 - deploy branches usually follow `release/service-{name}`
84 - Next.js SSR deploys on smbCloud are not the same as generic git-push deploys; \
85 they often use a local build plus rsync/PM2 style flow
86 - auth has a hard boundary between smbCloud platform users and tenant app users; \
87 platform flows use `/v1/users*`, tenant app flows use `/v1/client/*`, and \
88 you should not casually mix `User`, `TenantMembership`, `AuthApp`, and `AuthUser`
89 - smbCloud authorization is layered; do not flatten platform accounts, tenant \
90 memberships, auth-app collaborators, and tenant end users into one model
91 - `Project` is the umbrella workspace, while app-like resources such as \
92 `FrontendApp`, `AuthApp`, and GresIQ are the deployable units with their own \
93 ownership, sharing, and collaboration rules
94 - `FrontendApp` is many-per-project, while `AuthApp` is intentionally one-per-project; \
95 preserve those cardinality rules unless the code clearly changes them
96 - GresIQ is smbCloud's managed PostgreSQL offering; treat it as a platform \
97 service with its own credentials and boundaries, not as a generic local DB helper
98 - when debugging smbCloud Rails APIs, first classify the request: first-party \
99 smbCloud app or tenant app, then check which endpoint family and validator \
100 should be involved before changing code
101 - when working in smbCloud repos, prefer existing workspace patterns, existing \
102 crate boundaries, existing Rails conventions, and existing command flows over \
103 inventing new abstractions
104
105 CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \
106 When the user asks you to clone a repo, run a build, check git status, or do \
107 anything that involves a shell command, you MUST call the run_command tool and \
108 execute it yourself. Do not print shell commands for the user to copy-paste. \
109 Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \
110 If a command fails, try to fix the problem and re-run it. If you cannot fix it \
111 after two attempts, explain what went wrong and what you tried.
112
113 Git operations — always use run_command:
114 - git clone: always pass the full absolute destination path as the last argument \
115 and set cwd to an existing writable parent directory. Example: \
116 run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \
117 \"cwd\": \"/Users/me/Repositories\"})
118 - git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \
119 stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \
120 the repo root
121 - never run git clone without an explicit absolute destination path
122 - if a clone or init fails, check the error, fix the cause (wrong path, missing \
123 directory, permissions), and retry
124
125 Never introduce yourself unless asked. Jump straight into the answer. \
126 Keep answers short. Write idiomatic code. \
127 Fix root causes, not symptoms.
128
129 You have access to tools that let you read files, read websites directly from \
130 http and https URLs, create directories, list directories, search code, create \
131 new files, edit existing files, delete files, and run shell commands. You can \
132 also use git directly through shell commands, including `git init` and normal \
133 git workflows. Use them proactively. Read the code or website before answering. \
134 Prefer absolute paths when referring to files and directories, especially in \
135 protocol-facing output and tool arguments. Create directories when needed. Run \
136 builds, tests, and git commands after making changes. Ground your answers in \
137 the actual code or fetched page content, not in guesses.
138
139 CRITICAL — you CAN access websites. You are NOT a typical LLM without internet \
140 access. You have a read_website tool that fetches any http or https URL and \
141 returns the page text. When the user gives you a URL or asks you to read, \
142 summarize, or inspect a web page, you MUST call the read_website tool with that \
143 URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
144 You can. Use the tool.
145
146 CRITICAL — before every edit_file call, you MUST call read_file on the target \
147 file first (or the specific line range if one was given). Never rely on file \
148 content you saw in a previous turn — the user may have reverted, edited, or \
149 changed the file externally since then. Always re-read to get the current state \
150 before constructing old_text. \
151 When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
152 treat it as a fresh task: re-read the file, identify the current content, and \
153 plan the edit from scratch. Do not assume the file still reflects your last edit.
154
155 Tool-use heuristics:
156 - when the user provides a URL or asks about a web page, ALWAYS call \
157 read_website — never refuse or claim you lack internet access
158 - prefer absolute paths over relative paths when you mention, return, or pass \
159 file and directory paths
160 - if a path does not exist yet, create the directory before creating files in it
161 - if the user asks to clone a repo, immediately call run_command with git clone \
162 and an absolute destination path — do not ask where to put it unless the \
163 request is ambiguous; default to the user's home Repositories directory
164 - if the user asks for a new repo, scaffold, or scratch project, create the \
165 directory, create the first files, and run `git init` without waiting unless \
166 the request says otherwise
167 - if the repo looks like smbCloud CLI code, respect workspace crate boundaries, \
168 shared models, and existing command handlers before adding new abstractions
169 - if the repo looks like smbCloud Rails code, check routes, controllers, \
170 validators, and model boundaries before changing business logic
171 - if the task touches smbCloud auth, first decide whether it is a platform-user \
172 flow or a tenant-app flow, then follow the right endpoint family and model layer
173 - if the task touches smbCloud deploy code, check whether it is the generic \
174 deploy path or the Next.js SSR path before proposing changes
175 - after edits, prefer running the smallest useful verification step first, then \
176 widen to broader checks if needed
177 - use git commands naturally for status checks, repo setup, diffs, and normal \
178 developer workflows when they help move the task forward
179 - if a tool call fails, read the error, try to fix it, and retry — do not \
180 fall back to telling the user what to type
181
182 When the repo is not about smbCloud, act like a normal coding agent and do not \
183 force smbCloud-specific advice into the answer. When it is about smbCloud, be \
184 specific and practical.
185
186 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
187 root cause, not the symptom. Correct beats clever.";
188
189 /// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1).
190 /// the full [`SYSTEM_PROMPT`] wastes context and confuses them.
191 const SIMPLE_SYSTEM_PROMPT: &str = "\
192 Your name is siGit — a coding assistant. \
193 You are helpful, concise, and write clean, idiomatic code. \
194 Answer any question the user asks — programming, general knowledge, or casual chat. \
195 When debugging, address the root cause, not the symptom. \
196 Be direct and brief.";
197
198 pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
199 if tool_calling {
200 SYSTEM_PROMPT
201 } else {
202 SIMPLE_SYSTEM_PROMPT
203 }
204 }
205
206 /// cap tool-call loops so a confused model can't spin forever
207 const MAX_TOOL_ROUNDS: usize = 10;
208
209 fn agent_tools_as_onde() -> Vec<ToolDefinition> {
210 tools::all_tools()
211 .into_iter()
212 .map(|t| ToolDefinition {
213 name: t.name.to_string(),
214 description: t.description.to_string(),
215 parameters_schema: t.parameters_schema.to_string(),
216 })
217 .collect()
218 }
219
220 fn initialize_meta() -> Meta {
221 let startup_selection = setup::startup_model_selection();
222
223 let active_model_name = startup_selection
224 .as_ref()
225 .map(|selection| selection.display_name.clone())
226 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
227
228 let active_model_id = startup_selection
229 .as_ref()
230 .and_then(|selection| selection.selected_model.as_ref())
231 .map(|selected| selected.model_id.clone())
232 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().model_id);
233
234 let active_model_file = startup_selection
235 .as_ref()
236 .and_then(|selection| selection.selected_model.as_ref())
237 .map(|selected| selected.gguf_file.clone())
238 .unwrap_or_else(|| {
239 GgufModelConfig::qwen25_3b()
240 .files
241 .first()
242 .cloned()
243 .unwrap_or_default()
244 });
245
246 let mut model = serde_json::Map::new();
247 model.insert(
248 "display_name".to_string(),
249 serde_json::Value::String(active_model_name),
250 );
251 model.insert(
252 "model_id".to_string(),
253 serde_json::Value::String(active_model_id),
254 );
255 model.insert(
256 "gguf_file".to_string(),
257 serde_json::Value::String(active_model_file),
258 );
259
260 let mut sigit = serde_json::Map::new();
261 sigit.insert("active_model".to_string(), serde_json::Value::Object(model));
262
263 let mut meta = Meta::new();
264 meta.insert("sigit".to_string(), serde_json::Value::Object(sigit));
265 meta
266 }
267
268 struct SiGitAgent {
269 engine: Arc<ChatEngine>,
270 notification_tx: mpsc::Sender<SessionNotification>,
271 /// cwd from the editor — tool calls run here, not where the process started
272 session_cwd: std::sync::Mutex<Option<PathBuf>>,
273 current_model: std::sync::Mutex<GgufModelConfig>,
274 /// flipped once the startup model finishes (success or failure)
275 model_ready: Arc<AtomicBool>,
276 /// set if the startup load failed
277 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
278 /// true when the startup model isn't cached yet
279 startup_needs_download: bool,
280 /// for progress UI
281 startup_model_name: String,
282 /// for download-progress polling
283 startup_model_id: String,
284 }
285
286 impl SiGitAgent {
287 fn new(
288 engine: Arc<ChatEngine>,
289 notification_tx: mpsc::Sender<SessionNotification>,
290 initial_model: GgufModelConfig,
291 model_ready: Arc<AtomicBool>,
292 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
293 startup_needs_download: bool,
294 ) -> Self {
295 let startup_model_name = initial_model.display_name.clone();
296 let startup_model_id = initial_model.model_id.clone();
297 Self {
298 engine,
299 notification_tx,
300 session_cwd: std::sync::Mutex::new(None),
301 current_model: std::sync::Mutex::new(initial_model),
302 model_ready,
303 model_load_error,
304 startup_needs_download,
305 startup_model_name,
306 startup_model_id,
307 }
308 }
309
310 /// block until the startup model is ready, showing progress in the session.
311 async fn await_model_ready(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
312 if self.model_ready.load(Ordering::Acquire) {
313 // already done — might be a stored error from earlier
314 if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
315 return Err(agent_client_protocol::Error::new(
316 -32603,
317 format!("model load failed: {err}"),
318 ));
319 }
320 return Ok(());
321 }
322
323 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
324
325 let tool_call_id = format!("startup-load-{}", uuid::Uuid::new_v4());
326 let title = if self.startup_needs_download {
327 format!("Downloading {}", self.startup_model_name)
328 } else {
329 format!("Loading {}", self.startup_model_name)
330 };
331
332 self.send_tool_call_update(
333 session_id.clone(),
334 SessionUpdate::ToolCall(
335 ToolCall::new(tool_call_id.clone(), &title)
336 .kind(ToolKind::Think)
337 .status(ToolCallStatus::InProgress)
338 .content(vec![format!("{}…", title).into()]),
339 ),
340 )
341 .await;
342
343 let expected_bytes = if self.startup_needs_download {
344 onde::inference::models::SUPPORTED_MODEL_INFO
345 .iter()
346 .find(|m| m.id == self.startup_model_id)
347 .map(|m| m.expected_size_bytes)
348 .unwrap_or(0)
349 } else {
350 0
351 };
352
353 let load_start = std::time::Instant::now();
354 let mut tick: usize = 0;
355 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
356 interval.tick().await;
357
358 loop {
359 interval.tick().await;
360 tick += 1;
361
362 if self.model_ready.load(Ordering::Acquire) {
363 break;
364 }
365
366 let frame = SPINNER[tick % SPINNER.len()];
367 let elapsed = load_start.elapsed();
368 let elapsed_str = if elapsed.as_secs() >= 60 {
369 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
370 } else {
371 format!("{}s", elapsed.as_secs())
372 };
373
374 let (update_title, update_content) =
375 if self.startup_needs_download && expected_bytes > 0 {
376 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
377 let downloaded = cache_path
378 .as_ref()
379 .filter(|p| p.exists())
380 .map(|p| dir_size_recursive(p))
381 .unwrap_or(0);
382 let pct = ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
383 let bar = progress_bar(pct, 20);
384 let size_hint = format!(" (~{})", format_size_human(expected_bytes));
385 (
386 format!(
387 "{frame} Downloading {}{size_hint} ({pct}%)",
388 self.startup_model_name
389 ),
390 format!(
391 "{} — {bar} {pct}% ({} / {})",
392 self.startup_model_name,
393 format_size_human(downloaded),
394 format_size_human(expected_bytes),
395 ),
396 )
397 } else if self.startup_needs_download {
398 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
399 let downloaded = cache_path
400 .as_ref()
401 .filter(|p| p.exists())
402 .map(|p| dir_size_recursive(p))
403 .unwrap_or(0);
404 (
405 format!("{frame} Downloading {}", self.startup_model_name),
406 format!(
407 "{} — {} downloaded… ({elapsed_str})",
408 self.startup_model_name,
409 format_size_human(downloaded),
410 ),
411 )
412 } else {
413 (
414 format!("{frame} Loading {}", self.startup_model_name),
415 format!(
416 "{frame} Loading {}… ({elapsed_str})",
417 self.startup_model_name
418 ),
419 )
420 };
421
422 self.send_tool_call_update(
423 session_id.clone(),
424 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
425 tool_call_id.clone(),
426 ToolCallUpdateFields::new()
427 .title(update_title)
428 .status(ToolCallStatus::InProgress)
429 .content(vec![update_content.into()]),
430 )),
431 )
432 .await;
433 }
434
435 // done — check if it blew up
436 let load_error = self.model_load_error.lock().unwrap().clone();
437 if let Some(err) = load_error {
438 self.send_tool_call_update(
439 session_id.clone(),
440 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
441 tool_call_id,
442 ToolCallUpdateFields::new()
443 .title("Model load failed".to_string())
444 .status(ToolCallStatus::Failed)
445 .content(vec![format!("error: {err}").into()]),
446 )),
447 )
448 .await;
449
450 return Err(agent_client_protocol::Error::new(
451 -32603,
452 format!("model load failed: {err}"),
453 ));
454 }
455
456 let done_title = if self.startup_needs_download {
457 format!("✓ {} downloaded and loaded", self.startup_model_name)
458 } else {
459 format!("✓ {} loaded", self.startup_model_name)
460 };
461
462 self.send_tool_call_update(
463 session_id.clone(),
464 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
465 tool_call_id,
466 ToolCallUpdateFields::new()
467 .title(done_title)
468 .status(ToolCallStatus::Completed),
469 )),
470 )
471 .await;
472
473 Ok(())
474 }
475
476 async fn send_assistant_message(&self, session_id: SessionId, text: impl Into<String>) {
477 let notification = SessionNotification::new(
478 session_id,
479 SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))),
480 );
481 if self.notification_tx.send(notification).await.is_err() {
482 log::warn!("notification channel closed");
483 }
484 }
485
486 async fn send_tool_call_update(&self, session_id: SessionId, update: SessionUpdate) {
487 let notification = SessionNotification::new(session_id, update);
488 if self.notification_tx.send(notification).await.is_err() {
489 log::warn!("notification channel closed");
490 }
491 }
492
493 async fn switch_model_by_id(
494 &self,
495 model_id: &str,
496 ) -> agent_client_protocol::Result<GgufModelConfig> {
497 let (new_config, max_tokens, new_tool_calling) = resolve_model_config(model_id)
498 .ok_or_else(|| {
499 agent_client_protocol::Error::new(
500 -32602,
501 format!("unknown or unavailable model: {model_id}"),
502 )
503 })?;
504
505 log::info!(
506 "switching model to {} (max_tokens={max_tokens})",
507 new_config.display_name
508 );
509
510 let sampling = SamplingConfig {
511 max_tokens: Some(max_tokens),
512 ..SamplingConfig::default()
513 };
514
515 // block_in_place inside spawn_local panics, so run the load on a
516 // dedicated thread with its own runtime (same trick as startup)
517 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
518 let loader_engine = Arc::clone(&self.engine);
519 let loader_config = new_config.clone();
520 let loader_system_prompt = system_prompt_for_model(new_tool_calling).to_string();
521 let loader_sampling = sampling;
522
523 std::thread::spawn(move || {
524 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
525 let result = rt.block_on(async move {
526 // load_gguf_model already unloads the old model internally;
527 // calling unload first would leave a gap where prompts fail
528 loader_engine
529 .load_gguf_model(
530 loader_config,
531 Some(loader_system_prompt),
532 Some(loader_sampling),
533 )
534 .await
535 });
536 let _ = result_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
537 });
538
539 result_rx
540 .await
541 .map_err(|_| agent_client_protocol::Error::new(-32603, "model loader thread crashed"))?
542 .map_err(|error| {
543 log::error!("model switch failed: {error}");
544 agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}"))
545 })?;
546
547 if let Some(item) = models::build_model_picker_items()
548 .iter()
549 .find(|item| item.config.model_id == new_config.model_id)
550 && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
551 model_id: item.config.model_id.clone(),
552 gguf_file: item.config.files.first().cloned().unwrap_or_default(),
553 })
554 {
555 log::warn!("failed to persist model selection: {err}");
556 }
557
558 {
559 let mut guard = self.current_model.lock().unwrap();
560 *guard = new_config.clone();
561 }
562
563 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
564 self.engine
565 .push_history(onde::inference::ChatMessage::system(format!(
566 "The user's project working directory is {}. \
567 Always use absolute paths under this directory for all file \
568 and directory operations. This is the root of the project \
569 the user has open in their editor.",
570 cwd.display()
571 )))
572 .await;
573 }
574
575 Ok(new_config)
576 }
577 }
578
579 /// config option ID for the model picker in Zed's agent panel
580 const MODEL_CONFIG_ID: &str = "sigit-model";
581
582 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
583 let items = models::build_model_picker_items();
584
585 let options: Vec<SessionConfigSelectOption> = items
586 .iter()
587 .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
588 .map(|item| {
589 let mut desc_parts = Vec::new();
590 if item.tool_calling {
591 desc_parts.push("tool calling".to_string());
592 }
593 desc_parts.push(item.description.clone());
594 if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
595 desc_parts.push("↓ download on select".to_string());
596 }
597 let description = desc_parts.join(" - ");
598 let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
599 " [↓ Onde]"
600 } else {
601 match item.source_label.as_str() {
602 "Onde" => " [◉ Onde]",
603 "HuggingFace" => " [○ HuggingFace]",
604 _ => "",
605 }
606 };
607 let name = format!("{}{}", item.display_name, source_badge);
608 SessionConfigSelectOption::new(
609 SessionConfigValueId::new(item.config.model_id.as_str()),
610 name,
611 )
612 .description(description)
613 })
614 .collect();
615
616 if options.is_empty() {
617 return vec![];
618 }
619
620 let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
621
622 vec![
623 SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
624 .category(SessionConfigOptionCategory::Model)
625 .description("Select the local LLM model for inference"),
626 ]
627 }
628
629 /// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
630 fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
631 let items = models::build_model_picker_items();
632 items
633 .into_iter()
634 .find(|item| {
635 item.config.model_id == model_id
636 && item.cache_health != setup::ModelCacheHealth::Incomplete
637 })
638 .map(|item| (item.config, item.max_tokens, item.tool_calling))
639 }
640
641 #[derive(Debug, Clone)]
642 enum SlashCommand {
643 Help,
644 Clear,
645 Status,
646 Models(Option<usize>),
647 Exit,
648 Unknown(String),
649 }
650
651 fn parse_slash(input: &str) -> Option<SlashCommand> {
652 let trimmed = input.trim();
653 if !trimmed.starts_with('/') {
654 return None;
655 }
656 let mut parts = trimmed.splitn(2, char::is_whitespace);
657 let command = parts.next().unwrap_or("");
658 let argument = parts.next().map(str::trim);
659 Some(match command {
660 "/help" => SlashCommand::Help,
661 "/clear" => SlashCommand::Clear,
662 "/status" => SlashCommand::Status,
663 "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
664 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
665 other => SlashCommand::Unknown(other.to_string()),
666 })
667 }
668
669 fn format_models_list(current_model: &GgufModelConfig) -> String {
670 let items = models::build_model_picker_items();
671 if items.is_empty() {
672 return "No local models found. siGit will use the platform default model.".to_string();
673 }
674
675 let mut lines = vec!["Available models:".to_string()];
676 let mut last_source: Option<&str> = None;
677
678 for (index, item) in items.iter().enumerate() {
679 let source_key = match item.source_label.as_str() {
680 "Onde" => "Onde",
681 "HuggingFace" => "HuggingFace",
682 _ => "Fallback",
683 };
684
685 if last_source != Some(source_key) {
686 if last_source.is_some() {
687 lines.push(String::new());
688 }
689 let section = match source_key {
690 "Onde" => "Onde Inference",
691 "HuggingFace" => "Hugging Face cache",
692 _ => "Fallback",
693 };
694 lines.push(section.to_string());
695 last_source = Some(source_key);
696 }
697
698 let number = index + 1;
699 let current_badge = if item.config.model_id == current_model.model_id {
700 " <- current"
701 } else {
702 ""
703 };
704 let tool_badge = if item.tool_calling {
705 " tool calling"
706 } else {
707 ""
708 };
709 let health_badge = match item.cache_health {
710 setup::ModelCacheHealth::Complete => "",
711 setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
712 setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
713 };
714 let source = match source_key {
715 "Onde" => " [Onde]",
716 "HuggingFace" => " [HuggingFace]",
717 _ => " [default]",
718 };
719
720 lines.push(format!(
721 "{number}. {} {}{}{}{}{}",
722 item.display_name, item.description, tool_badge, health_badge, current_badge, source,
723 ));
724 }
725
726 lines.push(String::new());
727 lines.push("Use /models N to switch models.".to_string());
728 lines.join("\n")
729 }
730
731 async fn exec_slash_acp(
732 agent: &SiGitAgent,
733 session_id: SessionId,
734 command: SlashCommand,
735 ) -> agent_client_protocol::Result<PromptResponse> {
736 match command {
737 SlashCommand::Help => {
738 agent
739 .send_assistant_message(
740 session_id,
741 "/help - show this message\n\
742 /models - list available models\n\
743 /models N - switch to model N\n\
744 /clear - wipe conversation history\n\
745 /status - show engine status\n\
746 /exit - end this turn",
747 )
748 .await;
749 }
750 SlashCommand::Clear => {
751 let cleared = agent.engine.clear_history().await;
752 agent
753 .send_assistant_message(
754 session_id,
755 format!("Cleared {cleared} turn(s). History is empty."),
756 )
757 .await;
758 }
759 SlashCommand::Status => {
760 let info = agent.engine.info().await;
761 let model = info.model_name.as_deref().unwrap_or("(none)");
762 let memory = info.approx_memory.as_deref().unwrap_or("unknown");
763 agent
764 .send_assistant_message(
765 session_id,
766 format!(
767 "status: {:?} model: {} memory: {} history: {} turns",
768 info.status, model, memory, info.history_length,
769 ),
770 )
771 .await;
772 }
773 SlashCommand::Models(None) => {
774 let current_model = agent.current_model.lock().unwrap().clone();
775 agent
776 .send_assistant_message(session_id, format_models_list(&current_model))
777 .await;
778 }
779 SlashCommand::Models(Some(number)) => {
780 let items = models::build_model_picker_items();
781 let index = number.saturating_sub(1);
782 match items.get(index).cloned() {
783 None => {
784 agent
785 .send_assistant_message(
786 session_id,
787 format!("error: no model #{number} - type /models to see the list."),
788 )
789 .await;
790 }
791 Some(model) => {
792 if model.cache_health == setup::ModelCacheHealth::Incomplete {
793 agent
794 .send_assistant_message(
795 session_id,
796 format!(
797 "error: {} has an incomplete local cache and cannot be selected yet.",
798 model.display_name
799 ),
800 )
801 .await;
802 } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
803 agent
804 .send_assistant_message(
805 session_id.clone(),
806 format!(
807 "Downloading and loading {} ({})… this may take a few minutes.",
808 model.display_name, model.description
809 ),
810 )
811 .await;
812
813 match agent.switch_model_by_id(&model.config.model_id).await {
814 Ok(new_config) => {
815 agent.engine.clear_history().await;
816 agent
817 .send_assistant_message(
818 session_id,
819 format!(
820 "✓ Downloaded and switched to {}",
821 new_config.display_name
822 ),
823 )
824 .await;
825 }
826 Err(err) => {
827 agent
828 .send_assistant_message(
829 session_id,
830 format!("error downloading model: {}", err.message),
831 )
832 .await;
833 }
834 }
835 } else {
836 agent
837 .send_assistant_message(
838 session_id.clone(),
839 format!("Loading {}...", model.display_name),
840 )
841 .await;
842
843 let switched = agent.switch_model_by_id(&model.config.model_id).await?;
844 agent.engine.clear_history().await;
845
846 agent
847 .send_assistant_message(
848 session_id,
849 format!("Switched to {}.", switched.display_name),
850 )
851 .await;
852 }
853 }
854 }
855 }
856 SlashCommand::Exit => {
857 agent
858 .send_assistant_message(
859 session_id,
860 "Use the panel controls to close or switch threads.",
861 )
862 .await;
863 }
864 SlashCommand::Unknown(command) => {
865 agent
866 .send_assistant_message(session_id, format!("unknown command: {command}"))
867 .await;
868 }
869 }
870
871 Ok(PromptResponse::new(StopReason::EndTurn))
872 }
873
874 #[async_trait::async_trait(?Send)]
875 impl Agent for SiGitAgent {
876 async fn initialize(
877 &self,
878 _args: InitializeRequest,
879 ) -> agent_client_protocol::Result<InitializeResponse> {
880 log::info!("initialize");
881
882 Ok(InitializeResponse::new(ProtocolVersion::V1)
883 .agent_info(
884 Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
885 .title("siGit — AI Coding Agent"),
886 )
887 .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
888 "sigit", "siGit",
889 ))])
890 .agent_capabilities(
891 AgentCapabilities::default()
892 .load_session(true)
893 .session_capabilities(
894 SessionCapabilities::new().fork(SessionForkCapabilities::new()),
895 ),
896 )
897 .meta(initialize_meta()))
898 }
899
900 async fn authenticate(
901 &self,
902 _args: AuthenticateRequest,
903 ) -> agent_client_protocol::Result<AuthenticateResponse> {
904 log::info!("authenticate");
905 Ok(AuthenticateResponse::default())
906 }
907
908 async fn load_session(
909 &self,
910 args: LoadSessionRequest,
911 ) -> agent_client_protocol::Result<LoadSessionResponse> {
912 log::info!(
913 "load_session: id={}, cwd={}, additional_directories={:?}",
914 args.session_id,
915 args.cwd.display(),
916 args.additional_directories
917 .iter()
918 .map(|p| p.display().to_string())
919 .collect::<Vec<_>>()
920 );
921
922 if let Ok(mut guard) = self.session_cwd.lock() {
923 *guard = Some(args.cwd.clone());
924 }
925
926 // tool calls use relative paths, so we need to match the editor's cwd
927 if args.cwd.is_dir()
928 && let Err(err) = std::env::set_current_dir(&args.cwd)
929 {
930 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
931 }
932
933 // no session persistence, so "load" just resets
934 self.engine.clear_history().await;
935
936 self.engine
937 .push_history(onde::inference::ChatMessage::system(format!(
938 "The user's project working directory is {}. \
939 Always use absolute paths under this directory for all file \
940 and directory operations. This is the root of the project \
941 the user has open in their editor.",
942 args.cwd.display()
943 )))
944 .await;
945
946 let config_options = {
947 let guard = self.current_model.lock().unwrap();
948 build_model_config_options(&guard)
949 };
950
951 Ok(LoadSessionResponse::new().config_options(config_options))
952 }
953
954 async fn fork_session(
955 &self,
956 args: ForkSessionRequest,
957 ) -> agent_client_protocol::Result<ForkSessionResponse> {
958 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
959 log::info!(
960 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
961 args.session_id,
962 args.cwd.display(),
963 args.additional_directories
964 .iter()
965 .map(|p| p.display().to_string())
966 .collect::<Vec<_>>()
967 );
968
969 if let Ok(mut guard) = self.session_cwd.lock() {
970 *guard = Some(args.cwd.clone());
971 }
972 if args.cwd.is_dir()
973 && let Err(err) = std::env::set_current_dir(&args.cwd)
974 {
975 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
976 }
977
978 // no persistence, so fork == fresh session
979 self.engine.clear_history().await;
980
981 self.engine
982 .push_history(onde::inference::ChatMessage::system(format!(
983 "The user's project working directory is {}. \
984 Always use absolute paths under this directory for all file \
985 and directory operations. This is the root of the project \
986 the user has open in their editor.",
987 args.cwd.display()
988 )))
989 .await;
990
991 let config_options = {
992 let guard = self.current_model.lock().unwrap();
993 build_model_config_options(&guard)
994 };
995
996 Ok(ForkSessionResponse::new(new_id).config_options(config_options))
997 }
998
999 async fn new_session(
1000 &self,
1001 args: NewSessionRequest,
1002 ) -> agent_client_protocol::Result<NewSessionResponse> {
1003 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1004 log::info!(
1005 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
1006 args.cwd.display(),
1007 args.additional_directories
1008 .iter()
1009 .map(|p| p.display().to_string())
1010 .collect::<Vec<_>>()
1011 );
1012
1013 if let Ok(mut guard) = self.session_cwd.lock() {
1014 *guard = Some(args.cwd.clone());
1015 }
1016 if args.cwd.is_dir()
1017 && let Err(err) = std::env::set_current_dir(&args.cwd)
1018 {
1019 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1020 }
1021
1022 self.engine.clear_history().await;
1023
1024 self.engine
1025 .push_history(onde::inference::ChatMessage::system(format!(
1026 "The user's project working directory is {}. \
1027 Always use absolute paths under this directory for all file \
1028 and directory operations. This is the root of the project \
1029 the user has open in their editor.",
1030 args.cwd.display()
1031 )))
1032 .await;
1033
1034 let config_options = {
1035 let guard = self.current_model.lock().unwrap();
1036 build_model_config_options(&guard)
1037 };
1038
1039 Ok(NewSessionResponse::new(session_id).config_options(config_options))
1040 }
1041
1042 async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> {
1043 let session_id = args.session_id.clone();
1044
1045 // log every block so we can debug @ references and file context
1046 for (i, block) in args.prompt.iter().enumerate() {
1047 match block {
1048 ContentBlock::Text(t) => {
1049 log::info!(
1050 "prompt({}) block[{}]: Text({} chars) = \"{}\"",
1051 session_id,
1052 i,
1053 t.text.len(),
1054 t.text.chars().take(200).collect::<String>()
1055 );
1056 }
1057 ContentBlock::Resource(embedded) => {
1058 log::info!(
1059 "prompt({}) block[{}]: EmbeddedResource = {:?}",
1060 session_id,
1061 i,
1062 match &embedded.resource {
1063 agent_client_protocol::EmbeddedResourceResource::TextResourceContents(t) =>
1064 format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
1065 agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(b) =>
1066 format!("BlobResource(uri={})", b.uri),
1067 _ => "Unknown".to_string(),
1068 }
1069 );
1070 }
1071 ContentBlock::ResourceLink(link) => {
1072 log::info!(
1073 "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
1074 session_id,
1075 i,
1076 link.name,
1077 link.uri,
1078 link.title,
1079 link.description
1080 );
1081 }
1082 other => {
1083 log::info!(
1084 "prompt({}) block[{}]: Other({:?})",
1085 session_id,
1086 i,
1087 std::mem::discriminant(other)
1088 );
1089 }
1090 }
1091 }
1092
1093 let mut parts: Vec<String> = Vec::new();
1094
1095 for block in &args.prompt {
1096 match block {
1097 ContentBlock::Text(t) => {
1098 parts.push(t.text.clone());
1099 }
1100 ContentBlock::Resource(embedded) => {
1101 // editor inlined the file content already
1102 match &embedded.resource {
1103 agent_client_protocol::EmbeddedResourceResource::TextResourceContents(
1104 text_resource,
1105 ) => {
1106 parts.push(format!(
1107 "\n--- {} ---\n{}\n--- end {} ---",
1108 text_resource.uri, text_resource.text, text_resource.uri
1109 ));
1110 }
1111 agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(
1112 blob,
1113 ) => {
1114 parts.push(format!("[binary resource: {}]", blob.uri));
1115 }
1116 _ => {
1117 log::debug!("ignoring unsupported embedded resource variant");
1118 }
1119 }
1120 }
1121 ContentBlock::ResourceLink(link) => {
1122 // reference without content; read the file ourselves
1123 let label = link.name.clone();
1124
1125 if let Some(raw_path) = link.uri.strip_prefix("file://") {
1126 let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') {
1127 let fragment = &raw_path[hash_pos + 1..];
1128 let path = &raw_path[..hash_pos];
1129 // Parse "L207:219" or "L207-219" → (207, 219)
1130 let range = fragment.strip_prefix('L').and_then(|rest| {
1131 let sep = if rest.contains(':') { ':' } else { '-' };
1132 let mut parts = rest.splitn(2, sep);
1133 let start = parts.next()?.parse::<usize>().ok()?;
1134 let end = parts.next()?.parse::<usize>().ok()?;
1135 Some((start, end))
1136 });
1137 (path, range)
1138 } else {
1139 (raw_path, None)
1140 };
1141
1142 match std::fs::read_to_string(file_path) {
1143 Ok(contents) => {
1144 let extracted = if let Some((start, end)) = line_range {
1145 let selected: Vec<&str> = contents
1146 .lines()
1147 .enumerate()
1148 .filter(|(i, _)| {
1149 let line_num = i + 1;
1150 line_num >= start && line_num <= end
1151 })
1152 .map(|(_, line)| line)
1153 .collect();
1154 format!(
1155 "\n--- {label} ({file_path} lines {start}-{end}) ---\n{}\n--- end {label} ---",
1156 selected.join("\n")
1157 )
1158 } else {
1159 format!(
1160 "\n--- {label} ({file_path}) ---\n{contents}\n--- end {label} ---"
1161 )
1162 };
1163 parts.push(extracted);
1164 }
1165 Err(err) => {
1166 log::warn!("could not read ResourceLink {}: {err}", link.uri);
1167 parts.push(format!("[referenced file: {label} ({file_path})]"));
1168 }
1169 }
1170 } else {
1171 parts.push(format!("[resource link: {label} ({})]", link.uri));
1172 }
1173 }
1174 _ => {
1175 log::debug!("ignoring unsupported content block type in prompt");
1176 }
1177 }
1178 }
1179
1180 let user_text = parts.join("\n");
1181
1182 if user_text.trim().is_empty() {
1183 return Ok(PromptResponse::new(StopReason::EndTurn));
1184 }
1185
1186 if let Some(command) = parse_slash(&user_text) {
1187 return exec_slash_acp(self, session_id, command).await;
1188 }
1189
1190 log::info!(
1191 "prompt({}): \"{}\"",
1192 session_id,
1193 user_text.chars().take(80).collect::<String>()
1194 );
1195
1196 // wait for the startup model if it's still loading/downloading
1197 self.await_model_ready(&session_id).await?;
1198
1199 // ── tool-calling loop ────────────────────────────────────────────
1200 // send message → execute any tool calls → feed results back
1201 // repeat up to MAX_TOOL_ROUNDS, then force a text reply
1202
1203 let onde_tools = agent_tools_as_onde();
1204
1205 let mut result = self
1206 .engine
1207 .send_message_with_tools(&user_text, &onde_tools)
1208 .await
1209 .map_err(|error| {
1210 log::error!("send_message_with_tools failed: {error}");
1211 agent_client_protocol::Error::new(-32603, format!("inference failed: {error}"))
1212 })?;
1213
1214 let mut round = 0;
1215
1216 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1217 round += 1;
1218 log::info!(
1219 "prompt({}) tool round {} — {} call(s)",
1220 session_id,
1221 round,
1222 result.tool_calls.len()
1223 );
1224
1225 let mut tool_results = Vec::new();
1226
1227 for tc in &result.tool_calls {
1228 log::info!(
1229 " → {}({})",
1230 tc.function_name,
1231 tc.arguments.chars().take(120).collect::<String>()
1232 );
1233
1234 let output = tools::execute_tool(&tc.function_name, &tc.arguments).await;
1235
1236 log::info!(" ← {} chars", output.len());
1237
1238 tool_results.push(ToolResult {
1239 tool_call_id: tc.id.clone(),
1240 content: output,
1241 });
1242 }
1243
1244 let next_tools = if round < MAX_TOOL_ROUNDS {
1245 Some(onde_tools.as_slice())
1246 } else {
1247 None // last round: force text
1248 };
1249
1250 result = self
1251 .engine
1252 .send_tool_results(tool_results, next_tools)
1253 .await
1254 .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
1255 }
1256
1257 // ── Send the final text response ─────────────────────────────────
1258 let reply_text = result.text.trim().to_string();
1259
1260 let final_text = if reply_text.is_empty() {
1261 if round > 0 {
1262 log::warn!(
1263 "prompt({}) — model returned empty reply after {} tool round(s)",
1264 session_id,
1265 round
1266 );
1267 "Something went wrong — the edits didn't go through. Try rephrasing what you need, or point me at the specific lines.".to_string()
1268 } else {
1269 log::warn!(
1270 "prompt({}) — model returned empty reply (no tool rounds)",
1271 session_id
1272 );
1273 String::new()
1274 }
1275 } else {
1276 // strip <think> blocks so reasoning tokens stay hidden
1277 let (_think, visible) = chat::strip_think_blocks(&reply_text);
1278 visible
1279 };
1280
1281 if !final_text.is_empty() {
1282 let notification = SessionNotification::new(
1283 session_id.clone(),
1284 SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(final_text))),
1285 );
1286 if self.notification_tx.send(notification).await.is_err() {
1287 log::warn!("notification channel closed");
1288 }
1289 }
1290
1291 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
1292 Ok(PromptResponse::new(StopReason::EndTurn))
1293 }
1294
1295 async fn cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
1296 log::info!("cancel requested for session {}", args.session_id);
1297 Ok(())
1298 }
1299
1300 async fn set_session_config_option(
1301 &self,
1302 args: SetSessionConfigOptionRequest,
1303 ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
1304 log::info!(
1305 "set_session_config_option: config_id={}, value={:?}",
1306 args.config_id,
1307 args.value
1308 );
1309
1310 if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
1311 return Err(agent_client_protocol::Error::new(
1312 -32602,
1313 format!("unknown config option: {}", args.config_id.0),
1314 ));
1315 }
1316
1317 let model_id = args.value.0.as_ref();
1318
1319 // can't switch while the startup model is still loading — the old
1320 // weights are in GPU memory and the new load gets "does not fit"
1321 if !self.model_ready.load(Ordering::Acquire) {
1322 log::info!("set_session_config_option: waiting for startup model to finish loading");
1323 while !self.model_ready.load(Ordering::Acquire) {
1324 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1325 }
1326 }
1327
1328 // Zed re-fires the last selection on connect; no-op if it's already loaded
1329 {
1330 let current = self.current_model.lock().unwrap();
1331 if current.model_id == model_id {
1332 log::info!(
1333 "set_session_config_option: {} is already the active model, skipping",
1334 current.display_name
1335 );
1336 let config_options = build_model_config_options(&current);
1337 return Ok(SetSessionConfigOptionResponse::new(config_options));
1338 }
1339 }
1340
1341 let needs_download = models::build_model_picker_items()
1342 .into_iter()
1343 .find(|item| item.config.model_id == model_id)
1344 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
1345 .unwrap_or(false);
1346
1347 // tells the progress poller to stop
1348 let stop_flag = Arc::new(AtomicBool::new(false));
1349
1350 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
1351
1352 if needs_download {
1353 let model_id_owned = model_id.to_string();
1354 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
1355 .iter()
1356 .find(|m| m.id == model_id_owned)
1357 .map(|m| m.expected_size_bytes)
1358 .unwrap_or(0);
1359
1360 let display_name = models::build_model_picker_items()
1361 .into_iter()
1362 .find(|item| item.config.model_id == model_id_owned)
1363 .map(|item| item.display_name.clone())
1364 .unwrap_or_else(|| model_id_owned.clone());
1365
1366 let size_hint = if expected_bytes > 0 {
1367 format!(" (~{})", format_size_human(expected_bytes))
1368 } else {
1369 String::new()
1370 };
1371
1372 self.send_tool_call_update(
1373 args.session_id.clone(),
1374 SessionUpdate::ToolCall(
1375 ToolCall::new(
1376 tool_call_id.clone(),
1377 format!("⏬ Downloading {display_name}{size_hint}"),
1378 )
1379 .kind(ToolKind::Think)
1380 .status(ToolCallStatus::InProgress)
1381 .content(vec![
1382 format!(
1383 "Preparing download for {display_name}. This may take a few minutes."
1384 )
1385 .into(),
1386 ]),
1387 ),
1388 )
1389 .await;
1390
1391 // poll download progress and update the spinner in Zed
1392 let poller_tx = self.notification_tx.clone();
1393 let poller_session = args.session_id.clone();
1394 let poller_model_id = model_id_owned.clone();
1395 let poller_stop = Arc::clone(&stop_flag);
1396 let poller_tool_call_id = tool_call_id.clone();
1397
1398 tokio::task::spawn_local(async move {
1399 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1400 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
1401 let mut tick: usize = 0;
1402 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
1403 interval.tick().await; // consume the immediate first tick
1404
1405 while !poller_stop.load(Ordering::Relaxed) {
1406 interval.tick().await;
1407
1408 if poller_stop.load(Ordering::Relaxed) {
1409 break;
1410 }
1411
1412 let downloaded = cache_path
1413 .as_ref()
1414 .filter(|p| p.exists())
1415 .map(|p| dir_size_recursive(p))
1416 .unwrap_or(0);
1417
1418 let frame = SPINNER[tick % SPINNER.len()];
1419 tick += 1;
1420
1421 let title = if expected_bytes > 0 {
1422 let pct =
1423 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1424 format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
1425 } else {
1426 format!("{frame} Downloading {display_name}{size_hint}")
1427 };
1428
1429 let msg = if expected_bytes > 0 {
1430 let pct =
1431 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1432 let bar = progress_bar(pct, 20);
1433 format!(
1434 "{display_name} — {bar} {pct}% ({} / {})",
1435 format_size_human(downloaded),
1436 format_size_human(expected_bytes),
1437 )
1438 } else {
1439 format!(
1440 "{display_name} — {} downloaded…",
1441 format_size_human(downloaded)
1442 )
1443 };
1444
1445 let notification = SessionNotification::new(
1446 poller_session.clone(),
1447 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1448 poller_tool_call_id.clone(),
1449 ToolCallUpdateFields::new()
1450 .title(title)
1451 .status(ToolCallStatus::InProgress)
1452 .content(vec![msg.into()]),
1453 )),
1454 );
1455 if poller_tx.send(notification).await.is_err() {
1456 break;
1457 }
1458 }
1459 });
1460 }
1461
1462 // cached models still take 10-30s to load weights; show a spinner
1463 if !needs_download {
1464 let cached_display_name = models::build_model_picker_items()
1465 .into_iter()
1466 .find(|item| item.config.model_id == model_id)
1467 .map(|item| item.display_name.clone())
1468 .unwrap_or_else(|| model_id.to_string());
1469
1470 self.send_tool_call_update(
1471 args.session_id.clone(),
1472 SessionUpdate::ToolCall(
1473 ToolCall::new(
1474 tool_call_id.clone(),
1475 format!("Loading {cached_display_name}"),
1476 )
1477 .kind(ToolKind::Think)
1478 .status(ToolCallStatus::InProgress)
1479 .content(vec![format!("Loading {cached_display_name}…").into()]),
1480 ),
1481 )
1482 .await;
1483
1484 // tick every 5s so the user knows we haven't frozen
1485 let spinner_tx = self.notification_tx.clone();
1486 let spinner_session = args.session_id.clone();
1487 let spinner_name = cached_display_name.clone();
1488 let spinner_stop = Arc::clone(&stop_flag);
1489 let spinner_tool_call_id = tool_call_id.clone();
1490 let load_start = std::time::Instant::now();
1491
1492 tokio::task::spawn_local(async move {
1493 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1494 let mut tick: usize = 0;
1495 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
1496 interval.tick().await; // consume the immediate first tick
1497
1498 while !spinner_stop.load(Ordering::Relaxed) {
1499 interval.tick().await;
1500
1501 if spinner_stop.load(Ordering::Relaxed) {
1502 break;
1503 }
1504
1505 let elapsed = load_start.elapsed();
1506 let elapsed_str = if elapsed.as_secs() >= 60 {
1507 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
1508 } else {
1509 format!("{}s", elapsed.as_secs())
1510 };
1511 let frame = SPINNER[tick % SPINNER.len()];
1512 tick += 1;
1513
1514 let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
1515 let notification = SessionNotification::new(
1516 spinner_session.clone(),
1517 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1518 spinner_tool_call_id.clone(),
1519 ToolCallUpdateFields::new()
1520 .status(ToolCallStatus::InProgress)
1521 .content(vec![msg.into()]),
1522 )),
1523 );
1524 if spinner_tx.send(notification).await.is_err() {
1525 break;
1526 }
1527 }
1528 });
1529 }
1530
1531 let switch_result = self.switch_model_by_id(model_id).await;
1532
1533 stop_flag.store(true, Ordering::Relaxed);
1534
1535 match switch_result {
1536 Ok(new_config) => {
1537 let completion_title = if needs_download {
1538 format!("✓ {} downloaded and loaded", new_config.display_name)
1539 } else {
1540 format!("✓ Switched to {}", new_config.display_name)
1541 };
1542 let completion_body = if needs_download {
1543 format!("✓ {} downloaded and loaded.", new_config.display_name)
1544 } else {
1545 format!("✓ Switched to {}.", new_config.display_name)
1546 };
1547
1548 self.send_tool_call_update(
1549 args.session_id.clone(),
1550 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1551 tool_call_id,
1552 ToolCallUpdateFields::new()
1553 .title(completion_title)
1554 .status(ToolCallStatus::Completed)
1555 .content(vec![completion_body.into()]),
1556 )),
1557 )
1558 .await;
1559
1560 let config_options = {
1561 let guard = self.current_model.lock().unwrap();
1562 build_model_config_options(&guard)
1563 };
1564
1565 log::info!("model switch complete");
1566 Ok(SetSessionConfigOptionResponse::new(config_options))
1567 }
1568 Err(err) => {
1569 self.send_tool_call_update(
1570 args.session_id.clone(),
1571 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1572 tool_call_id,
1573 ToolCallUpdateFields::new()
1574 .title("Model switch failed".to_string())
1575 .status(ToolCallStatus::Failed)
1576 .content(vec![format!("error loading model: {}", err.message).into()]),
1577 )),
1578 )
1579 .await;
1580
1581 Err(err)
1582 }
1583 }
1584 }
1585 }
1586
1587 // ── Download progress helpers ─────────────────────────────────────────────────
1588
1589 /// total bytes on disk under `path`. needed because hf-hub uses staging
1590 /// names during download, so we can't just stat the final blobs.
1591 fn dir_size_recursive(path: &std::path::Path) -> u64 {
1592 let mut total: u64 = 0;
1593 let Ok(entries) = std::fs::read_dir(path) else {
1594 return 0;
1595 };
1596 for entry in entries.flatten() {
1597 let entry_path = entry.path();
1598 if entry_path.is_dir() {
1599 total += dir_size_recursive(&entry_path);
1600 } else if let Ok(meta) = entry_path.metadata() {
1601 total += meta.len();
1602 }
1603 }
1604 total
1605 }
1606
1607 fn format_size_human(bytes: u64) -> String {
1608 const GB: u64 = 1_073_741_824;
1609 const MB: u64 = 1_048_576;
1610 const KB: u64 = 1_024;
1611 if bytes >= GB {
1612 format!("{:.2} GB", bytes as f64 / GB as f64)
1613 } else if bytes >= MB {
1614 format!("{:.1} MB", bytes as f64 / MB as f64)
1615 } else if bytes >= KB {
1616 format!("{:.0} KB", bytes as f64 / KB as f64)
1617 } else {
1618 format!("{bytes} B")
1619 }
1620 }
1621
1622 fn progress_bar(pct: u8, width: usize) -> String {
1623 let filled = ((pct as usize) * width) / 100;
1624 let empty = width.saturating_sub(filled);
1625 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
1626 }
1627
1628 // ── Output capture ────────────────────────────────────────────────────────────
1629
1630 /// redirect stdout+stderr to `$TMPDIR/sigit.log` at the fd level so
1631 /// mistralrs/tracing noise never hits the terminal. returns two dup'd
1632 /// fds to the real tty: one for ratatui, one for cleanup (ratatui 0.29
1633 /// doesn't expose `writer_mut()`).
1634 #[cfg(unix)]
1635 fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
1636 let log_path = std::env::temp_dir().join("sigit.log");
1637 let log_file = std::fs::File::create(&log_path)?;
1638 let log_fd = log_file.as_raw_fd();
1639
1640 // two copies: ratatui needs one, cleanup needs another
1641 let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) };
1642 anyhow::ensure!(
1643 saved_tui >= 0,
1644 "dup(stdout) for tui failed: {}",
1645 std::io::Error::last_os_error()
1646 );
1647 let saved_cleanup = unsafe { libc::dup(libc::STDOUT_FILENO) };
1648 anyhow::ensure!(
1649 saved_cleanup >= 0,
1650 "dup(stdout) for cleanup failed: {}",
1651 std::io::Error::last_os_error()
1652 );
1653
1654 unsafe {
1655 libc::dup2(log_fd, libc::STDOUT_FILENO);
1656 libc::dup2(log_fd, libc::STDERR_FILENO);
1657 }
1658
1659 // safe to drop log_file; dup2 keeps the fd alive via stdout/stderr
1660
1661 Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe {
1662 std::fs::File::from_raw_fd(saved_cleanup)
1663 }))
1664 }
1665
1666 // ── Logging ───────────────────────────────────────────────────────────────────
1667
1668 /// in TUI mode stderr is the log file (redirected earlier);
1669 /// in ACP mode it's real stderr. either way, write there.
1670 fn init_logging(is_tty: bool) {
1671 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
1672 let _ = tracing_fmt::Subscriber::builder()
1673 .with_env_filter(filter)
1674 .with_writer(std::io::stderr)
1675 .with_ansi(!is_tty)
1676 .try_init();
1677 }
1678
1679 #[cfg(unix)]
1680 fn redirect_stdout_to_stderr() -> anyhow::Result<()> {
1681 let stderr_dup = unsafe { libc::dup(libc::STDERR_FILENO) };
1682 anyhow::ensure!(
1683 stderr_dup >= 0,
1684 "dup(stderr) failed: {}",
1685 std::io::Error::last_os_error()
1686 );
1687
1688 unsafe {
1689 libc::dup2(stderr_dup, libc::STDOUT_FILENO);
1690 libc::close(stderr_dup);
1691 }
1692
1693 Ok(())
1694 }
1695
1696 // ── Interactive TUI mode ──────────────────────────────────────────────────────
1697
1698 /// boot the TUI and load the model on a background thread.
1699 /// `tty` goes to ratatui; `cleanup_tty` is a separate fd for
1700 /// LeaveAlternateScreen (ratatui 0.29 hides `writer_mut()`).
1701 #[cfg(unix)]
1702 async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
1703 let engine = Arc::new(ChatEngine::new());
1704
1705 let startup_selection = setup::startup_model_selection();
1706 let startup_model_name = startup_selection
1707 .as_ref()
1708 .map(|selection| selection.display_name.clone())
1709 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
1710
1711 let config = startup_selection
1712 .as_ref()
1713 .and_then(|selection| {
1714 models::build_model_picker_items()
1715 .into_iter()
1716 .find(|item| {
1717 selection
1718 .selected_model
1719 .as_ref()
1720 .map(|selected| {
1721 item.config.model_id == selected.model_id
1722 && item
1723 .config
1724 .files
1725 .iter()
1726 .any(|file| file == &selected.gguf_file)
1727 })
1728 .unwrap_or(false)
1729 })
1730 .map(|item| item.config)
1731 })
1732 .unwrap_or_else(GgufModelConfig::qwen25_3b);
1733 let sampling = SamplingConfig {
1734 max_tokens: Some(8192),
1735 ..SamplingConfig::default()
1736 };
1737
1738 // std::sync::mpsc on a real thread so model loading can't starve the TUI
1739 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
1740
1741 let loader_engine = Arc::clone(&engine);
1742 let tool_calling = models::build_model_picker_items()
1743 .iter()
1744 .find(|item| item.config.model_id == config.model_id)
1745 .map(|item| item.tool_calling)
1746 .unwrap_or(false);
1747 let system_prompt = system_prompt_for_model(tool_calling).to_string();
1748 std::thread::spawn(move || {
1749 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1750 let result =
1751 rt.block_on(loader_engine.load_gguf_model(config, Some(system_prompt), Some(sampling)));
1752 let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
1753 });
1754
1755 crossterm::terminal::enable_raw_mode()?;
1756 let mut tty = BufWriter::new(tty);
1757 crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
1758 let backend = ratatui::backend::CrosstermBackend::new(tty);
1759 let mut terminal = ratatui::Terminal::new(backend)?;
1760
1761 // polls load_rx with try_recv() each tick, no blocking
1762 let chat_result = chat::run_with(&mut terminal, engine, load_rx, startup_model_name).await;
1763
1764 // cleanup fd because backend's writer is private
1765 crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
1766 cleanup_tty.flush()?;
1767 crossterm::terminal::disable_raw_mode()?;
1768
1769 // restore real stdout/stderr for post-TUI error output
1770 #[cfg(unix)]
1771 {
1772 let cleanup_fd = cleanup_tty.as_raw_fd();
1773 unsafe {
1774 libc::dup2(cleanup_fd, libc::STDOUT_FILENO);
1775 libc::dup2(cleanup_fd, libc::STDERR_FILENO);
1776 }
1777 }
1778
1779 chat_result
1780 }
1781
1782 // ── ACP server mode ───────────────────────────────────────────────────────────
1783
1784 async fn run_acp_server() -> anyhow::Result<()> {
1785 log::info!("ACP mode — starting agent server");
1786
1787 let startup_selection = setup::startup_model_selection();
1788 let config = startup_selection
1789 .as_ref()
1790 .and_then(|selection| {
1791 selection.selected_model.as_ref().and_then(|selected| {
1792 models::build_model_picker_items()
1793 .into_iter()
1794 .find(|item| {
1795 item.config.model_id == selected.model_id
1796 && item
1797 .config
1798 .files
1799 .iter()
1800 .any(|file| file == &selected.gguf_file)
1801 })
1802 .map(|item| item.config)
1803 })
1804 })
1805 .unwrap_or_else(GgufModelConfig::qwen25_3b);
1806
1807 let needs_download = models::build_model_picker_items()
1808 .iter()
1809 .find(|item| item.config.model_id == config.model_id)
1810 .map(|item| item.cache_health != setup::ModelCacheHealth::Complete)
1811 .unwrap_or(true);
1812
1813 log::info!(
1814 "ACP startup model selected: {} ({})",
1815 config.display_name,
1816 if needs_download {
1817 "needs download"
1818 } else {
1819 "cached"
1820 }
1821 );
1822
1823 let engine = Arc::new(ChatEngine::new());
1824
1825 // Delay model loading until after initialize/auth so ACP stdout stays clean
1826 // even if model libraries emit startup diagnostics.
1827 let model_ready = Arc::new(AtomicBool::new(true));
1828 let model_load_error: Arc<std::sync::Mutex<Option<String>>> =
1829 Arc::new(std::sync::Mutex::new(None));
1830
1831 let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
1832 let agent = SiGitAgent::new(
1833 engine,
1834 notification_tx,
1835 config,
1836 model_ready,
1837 model_load_error,
1838 needs_download,
1839 );
1840
1841 // AgentSideConnection needs futures-io
1842 let stdin = tokio::io::stdin().compat();
1843 let stdout = tokio::io::stdout().compat_write();
1844
1845 // ACP futures are !Send
1846 let local = tokio::task::LocalSet::new();
1847
1848 local
1849 .run_until(async move {
1850 let (conn, io_task) = AgentSideConnection::new(
1851 agent,
1852 stdout,
1853 stdin,
1854 |fut: LocalBoxFuture<'static, ()>| {
1855 tokio::task::spawn_local(fut);
1856 },
1857 );
1858
1859 tokio::task::spawn_local(async move {
1860 while let Some(notification) = notification_rx.recv().await {
1861 if let Err(err) = conn.session_notification(notification).await {
1862 log::warn!("session_notification failed: {err}");
1863 }
1864 }
1865 });
1866
1867 if let Err(err) = io_task.await {
1868 log::error!("ACP IO error: {err}");
1869 }
1870 })
1871 .await;
1872
1873 log::info!("siGit shutting down");
1874 Ok(())
1875 }
1876
1877 // ── Entry point ──────────────────────────────────────────────────────────────
1878
1879 #[tokio::main]
1880 async fn main() -> anyhow::Result<()> {
1881 let is_tty = std::io::stdin().is_terminal();
1882
1883 if is_tty {
1884 // must redirect before any library code touches stdout
1885 #[cfg(unix)]
1886 {
1887 let (tty, cleanup_tty) = redirect_output_to_log()?;
1888 init_logging(true);
1889 setup::setup_shared_model_cache();
1890 run_interactive(tty, cleanup_tty).await
1891 }
1892 #[cfg(not(unix))]
1893 {
1894 anyhow::bail!("interactive mode requires Unix (macOS / Linux)");
1895 }
1896 } else {
1897 // ACP mode: stdout must contain only ACP JSON messages.
1898 #[cfg(unix)]
1899 redirect_stdout_to_stderr()?;
1900
1901 init_logging(false);
1902 setup::setup_shared_model_cache();
1903 log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
1904 run_acp_server().await
1905 }
1906 }