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 /// guards the one-time lazy startup load for ACP mode
277 startup_model_load_started: Arc<AtomicBool>,
278 /// set if the startup load failed
279 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
280 /// true when the startup model isn't cached yet
281 startup_needs_download: bool,
282 /// for progress UI
283 startup_model_name: String,
284 /// for download-progress polling
285 startup_model_id: String,
286 }
287
288 impl SiGitAgent {
289 fn new(
290 engine: Arc<ChatEngine>,
291 notification_tx: mpsc::Sender<SessionNotification>,
292 initial_model: GgufModelConfig,
293 model_ready: Arc<AtomicBool>,
294 startup_model_load_started: Arc<AtomicBool>,
295 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
296 startup_needs_download: bool,
297 ) -> Self {
298 let startup_model_name = initial_model.display_name.clone();
299 let startup_model_id = initial_model.model_id.clone();
300 Self {
301 engine,
302 notification_tx,
303 session_cwd: std::sync::Mutex::new(None),
304 current_model: std::sync::Mutex::new(initial_model),
305 model_ready,
306 startup_model_load_started,
307 model_load_error,
308 startup_needs_download,
309 startup_model_name,
310 startup_model_id,
311 }
312 }
313
314 fn start_startup_model_load_if_needed(&self) {
315 if self
316 .startup_model_load_started
317 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
318 .is_err()
319 {
320 return;
321 }
322
323 self.model_ready.store(false, Ordering::Release);
324 if let Ok(mut guard) = self.model_load_error.lock() {
325 *guard = None;
326 }
327
328 let startup_config = self.current_model.lock().unwrap().clone();
329 let (max_tokens, tool_calling) = models::build_model_picker_items()
330 .into_iter()
331 .find(|item| {
332 item.config.model_id == startup_config.model_id
333 && item
334 .config
335 .files
336 .first()
337 .zip(startup_config.files.first())
338 .map(|(left, right)| left == right)
339 .unwrap_or(false)
340 })
341 .map(|item| (item.max_tokens, item.tool_calling))
342 .unwrap_or((4096, false));
343
344 let sampling = SamplingConfig {
345 max_tokens: Some(max_tokens),
346 ..SamplingConfig::default()
347 };
348
349 let loader_engine = Arc::clone(&self.engine);
350 let loader_system_prompt = system_prompt_for_model(tool_calling).to_string();
351 let model_ready = Arc::clone(&self.model_ready);
352 let model_load_error = Arc::clone(&self.model_load_error);
353
354 std::thread::spawn(move || {
355 let result = tokio::runtime::Runtime::new()
356 .map_err(|error| error.to_string())
357 .and_then(|rt| {
358 rt.block_on(loader_engine.load_gguf_model(
359 startup_config,
360 Some(loader_system_prompt),
361 Some(sampling),
362 ))
363 .map(|_| ())
364 .map_err(|error| error.to_string())
365 });
366
367 if let Ok(mut guard) = model_load_error.lock() {
368 *guard = result.err();
369 }
370 model_ready.store(true, Ordering::Release);
371 });
372 }
373
374 /// block until the startup model is ready, showing progress in the session.
375 async fn await_model_ready(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
376 if self.model_ready.load(Ordering::Acquire) {
377 // already done — might be a stored error from earlier
378 if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
379 return Err(agent_client_protocol::Error::new(
380 -32603,
381 format!("model load failed: {err}"),
382 ));
383 }
384 return Ok(());
385 }
386
387 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
388
389 let tool_call_id = format!("startup-load-{}", uuid::Uuid::new_v4());
390 let title = if self.startup_needs_download {
391 format!("Downloading {}", self.startup_model_name)
392 } else {
393 format!("Loading {}", self.startup_model_name)
394 };
395
396 self.send_tool_call_update(
397 session_id.clone(),
398 SessionUpdate::ToolCall(
399 ToolCall::new(tool_call_id.clone(), &title)
400 .kind(ToolKind::Think)
401 .status(ToolCallStatus::InProgress)
402 .content(vec![format!("{}…", title).into()]),
403 ),
404 )
405 .await;
406
407 let expected_bytes = if self.startup_needs_download {
408 onde::inference::models::SUPPORTED_MODEL_INFO
409 .iter()
410 .find(|m| m.id == self.startup_model_id)
411 .map(|m| m.expected_size_bytes)
412 .unwrap_or(0)
413 } else {
414 0
415 };
416
417 let load_start = std::time::Instant::now();
418 let mut tick: usize = 0;
419 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
420 interval.tick().await;
421
422 loop {
423 interval.tick().await;
424 tick += 1;
425
426 if self.model_ready.load(Ordering::Acquire) {
427 break;
428 }
429
430 let frame = SPINNER[tick % SPINNER.len()];
431 let elapsed = load_start.elapsed();
432 let elapsed_str = if elapsed.as_secs() >= 60 {
433 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
434 } else {
435 format!("{}s", elapsed.as_secs())
436 };
437
438 let (update_title, update_content) =
439 if self.startup_needs_download && expected_bytes > 0 {
440 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
441 let downloaded = cache_path
442 .as_ref()
443 .filter(|p| p.exists())
444 .map(|p| dir_size_recursive(p))
445 .unwrap_or(0);
446 let pct = ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
447 let bar = progress_bar(pct, 20);
448 let size_hint = format!(" (~{})", format_size_human(expected_bytes));
449 (
450 format!(
451 "{frame} Downloading {}{size_hint} ({pct}%)",
452 self.startup_model_name
453 ),
454 format!(
455 "{} — {bar} {pct}% ({} / {})",
456 self.startup_model_name,
457 format_size_human(downloaded),
458 format_size_human(expected_bytes),
459 ),
460 )
461 } else if self.startup_needs_download {
462 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
463 let downloaded = cache_path
464 .as_ref()
465 .filter(|p| p.exists())
466 .map(|p| dir_size_recursive(p))
467 .unwrap_or(0);
468 (
469 format!("{frame} Downloading {}", self.startup_model_name),
470 format!(
471 "{} — {} downloaded… ({elapsed_str})",
472 self.startup_model_name,
473 format_size_human(downloaded),
474 ),
475 )
476 } else {
477 (
478 format!("{frame} Loading {}", self.startup_model_name),
479 format!(
480 "{frame} Loading {}… ({elapsed_str})",
481 self.startup_model_name
482 ),
483 )
484 };
485
486 self.send_tool_call_update(
487 session_id.clone(),
488 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
489 tool_call_id.clone(),
490 ToolCallUpdateFields::new()
491 .title(update_title)
492 .status(ToolCallStatus::InProgress)
493 .content(vec![update_content.into()]),
494 )),
495 )
496 .await;
497 }
498
499 // done — check if it blew up
500 let load_error = self.model_load_error.lock().unwrap().clone();
501 if let Some(err) = load_error {
502 self.send_tool_call_update(
503 session_id.clone(),
504 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
505 tool_call_id,
506 ToolCallUpdateFields::new()
507 .title("Model load failed".to_string())
508 .status(ToolCallStatus::Failed)
509 .content(vec![format!("error: {err}").into()]),
510 )),
511 )
512 .await;
513
514 return Err(agent_client_protocol::Error::new(
515 -32603,
516 format!("model load failed: {err}"),
517 ));
518 }
519
520 let done_title = if self.startup_needs_download {
521 format!("✓ {} downloaded and loaded", self.startup_model_name)
522 } else {
523 format!("✓ {} loaded", self.startup_model_name)
524 };
525
526 self.send_tool_call_update(
527 session_id.clone(),
528 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
529 tool_call_id,
530 ToolCallUpdateFields::new()
531 .title(done_title)
532 .status(ToolCallStatus::Completed),
533 )),
534 )
535 .await;
536
537 Ok(())
538 }
539
540 async fn send_assistant_message(&self, session_id: SessionId, text: impl Into<String>) {
541 let notification = SessionNotification::new(
542 session_id,
543 SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))),
544 );
545 if self.notification_tx.send(notification).await.is_err() {
546 log::warn!("notification channel closed");
547 }
548 }
549
550 async fn send_tool_call_update(&self, session_id: SessionId, update: SessionUpdate) {
551 let notification = SessionNotification::new(session_id, update);
552 if self.notification_tx.send(notification).await.is_err() {
553 log::warn!("notification channel closed");
554 }
555 }
556
557 async fn switch_model_by_id(
558 &self,
559 model_id: &str,
560 ) -> agent_client_protocol::Result<GgufModelConfig> {
561 let (new_config, max_tokens, new_tool_calling) = resolve_model_config(model_id)
562 .ok_or_else(|| {
563 agent_client_protocol::Error::new(
564 -32602,
565 format!("unknown or unavailable model: {model_id}"),
566 )
567 })?;
568
569 log::info!(
570 "switching model to {} (max_tokens={max_tokens})",
571 new_config.display_name
572 );
573
574 let sampling = SamplingConfig {
575 max_tokens: Some(max_tokens),
576 ..SamplingConfig::default()
577 };
578
579 // block_in_place inside spawn_local panics, so run the load on a
580 // dedicated thread with its own runtime (same trick as startup)
581 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
582 let loader_engine = Arc::clone(&self.engine);
583 let loader_config = new_config.clone();
584 let loader_system_prompt = system_prompt_for_model(new_tool_calling).to_string();
585 let loader_sampling = sampling;
586
587 std::thread::spawn(move || {
588 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
589 let result = rt.block_on(async move {
590 // load_gguf_model already unloads the old model internally;
591 // calling unload first would leave a gap where prompts fail
592 loader_engine
593 .load_gguf_model(
594 loader_config,
595 Some(loader_system_prompt),
596 Some(loader_sampling),
597 )
598 .await
599 });
600 let _ = result_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
601 });
602
603 result_rx
604 .await
605 .map_err(|_| agent_client_protocol::Error::new(-32603, "model loader thread crashed"))?
606 .map_err(|error| {
607 log::error!("model switch failed: {error}");
608 agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}"))
609 })?;
610
611 self.startup_model_load_started
612 .store(true, Ordering::Release);
613 self.model_ready.store(true, Ordering::Release);
614 if let Ok(mut guard) = self.model_load_error.lock() {
615 *guard = None;
616 }
617
618 if let Some(item) = models::build_model_picker_items()
619 .iter()
620 .find(|item| item.config.model_id == new_config.model_id)
621 && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
622 model_id: item.config.model_id.clone(),
623 gguf_file: item.config.files.first().cloned().unwrap_or_default(),
624 })
625 {
626 log::warn!("failed to persist model selection: {err}");
627 }
628
629 {
630 let mut guard = self.current_model.lock().unwrap();
631 *guard = new_config.clone();
632 }
633
634 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
635 self.engine
636 .push_history(onde::inference::ChatMessage::system(format!(
637 "The user's project working directory is {}. \
638 Always use absolute paths under this directory for all file \
639 and directory operations. This is the root of the project \
640 the user has open in their editor.",
641 cwd.display()
642 )))
643 .await;
644 }
645
646 Ok(new_config)
647 }
648 }
649
650 /// config option ID for the model picker in Zed's agent panel
651 const MODEL_CONFIG_ID: &str = "sigit-model";
652
653 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
654 let items = models::build_model_picker_items();
655
656 let options: Vec<SessionConfigSelectOption> = items
657 .iter()
658 .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
659 .map(|item| {
660 let mut desc_parts = Vec::new();
661 if item.tool_calling {
662 desc_parts.push("tool calling".to_string());
663 }
664 desc_parts.push(item.description.clone());
665 if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
666 desc_parts.push("↓ download on select".to_string());
667 }
668 let description = desc_parts.join(" - ");
669 let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
670 " [↓ Onde]"
671 } else {
672 match item.source_label.as_str() {
673 "Onde" => " [◉ Onde]",
674 "HuggingFace" => " [○ HuggingFace]",
675 _ => "",
676 }
677 };
678 let name = format!("{}{}", item.display_name, source_badge);
679 SessionConfigSelectOption::new(
680 SessionConfigValueId::new(item.config.model_id.as_str()),
681 name,
682 )
683 .description(description)
684 })
685 .collect();
686
687 if options.is_empty() {
688 return vec![];
689 }
690
691 let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
692
693 vec![
694 SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
695 .category(SessionConfigOptionCategory::Model)
696 .description("Select the local LLM model for inference"),
697 ]
698 }
699
700 /// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
701 fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
702 let items = models::build_model_picker_items();
703 items
704 .into_iter()
705 .find(|item| {
706 item.config.model_id == model_id
707 && item.cache_health != setup::ModelCacheHealth::Incomplete
708 })
709 .map(|item| (item.config, item.max_tokens, item.tool_calling))
710 }
711
712 #[derive(Debug, Clone)]
713 enum SlashCommand {
714 Help,
715 Clear,
716 Status,
717 Models(Option<usize>),
718 Exit,
719 Unknown(String),
720 }
721
722 fn parse_slash(input: &str) -> Option<SlashCommand> {
723 let trimmed = input.trim();
724 if !trimmed.starts_with('/') {
725 return None;
726 }
727 let mut parts = trimmed.splitn(2, char::is_whitespace);
728 let command = parts.next().unwrap_or("");
729 let argument = parts.next().map(str::trim);
730 Some(match command {
731 "/help" => SlashCommand::Help,
732 "/clear" => SlashCommand::Clear,
733 "/status" => SlashCommand::Status,
734 "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
735 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
736 other => SlashCommand::Unknown(other.to_string()),
737 })
738 }
739
740 fn format_models_list(current_model: &GgufModelConfig) -> String {
741 let items = models::build_model_picker_items();
742 if items.is_empty() {
743 return "No local models found. siGit will use the platform default model.".to_string();
744 }
745
746 let mut lines = vec!["Available models:".to_string()];
747 let mut last_source: Option<&str> = None;
748
749 for (index, item) in items.iter().enumerate() {
750 let source_key = match item.source_label.as_str() {
751 "Onde" => "Onde",
752 "HuggingFace" => "HuggingFace",
753 _ => "Fallback",
754 };
755
756 if last_source != Some(source_key) {
757 if last_source.is_some() {
758 lines.push(String::new());
759 }
760 let section = match source_key {
761 "Onde" => "Onde Inference",
762 "HuggingFace" => "Hugging Face cache",
763 _ => "Fallback",
764 };
765 lines.push(section.to_string());
766 last_source = Some(source_key);
767 }
768
769 let number = index + 1;
770 let current_badge = if item.config.model_id == current_model.model_id {
771 " <- current"
772 } else {
773 ""
774 };
775 let tool_badge = if item.tool_calling {
776 " tool calling"
777 } else {
778 ""
779 };
780 let health_badge = match item.cache_health {
781 setup::ModelCacheHealth::Complete => "",
782 setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
783 setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
784 };
785 let source = match source_key {
786 "Onde" => " [Onde]",
787 "HuggingFace" => " [HuggingFace]",
788 _ => " [default]",
789 };
790
791 lines.push(format!(
792 "{number}. {} {}{}{}{}{}",
793 item.display_name, item.description, tool_badge, health_badge, current_badge, source,
794 ));
795 }
796
797 lines.push(String::new());
798 lines.push("Use /models N to switch models.".to_string());
799 lines.join("\n")
800 }
801
802 async fn exec_slash_acp(
803 agent: &SiGitAgent,
804 session_id: SessionId,
805 command: SlashCommand,
806 ) -> agent_client_protocol::Result<PromptResponse> {
807 match command {
808 SlashCommand::Help => {
809 agent
810 .send_assistant_message(
811 session_id,
812 "/help - show this message\n\
813 /models - list available models\n\
814 /models N - switch to model N\n\
815 /clear - wipe conversation history\n\
816 /status - show engine status\n\
817 /exit - end this turn",
818 )
819 .await;
820 }
821 SlashCommand::Clear => {
822 let cleared = agent.engine.clear_history().await;
823 agent
824 .send_assistant_message(
825 session_id,
826 format!("Cleared {cleared} turn(s). History is empty."),
827 )
828 .await;
829 }
830 SlashCommand::Status => {
831 let info = agent.engine.info().await;
832 let model = info.model_name.as_deref().unwrap_or("(none)");
833 let memory = info.approx_memory.as_deref().unwrap_or("unknown");
834 agent
835 .send_assistant_message(
836 session_id,
837 format!(
838 "status: {:?} model: {} memory: {} history: {} turns",
839 info.status, model, memory, info.history_length,
840 ),
841 )
842 .await;
843 }
844 SlashCommand::Models(None) => {
845 let current_model = agent.current_model.lock().unwrap().clone();
846 agent
847 .send_assistant_message(session_id, format_models_list(&current_model))
848 .await;
849 }
850 SlashCommand::Models(Some(number)) => {
851 let items = models::build_model_picker_items();
852 let index = number.saturating_sub(1);
853 match items.get(index).cloned() {
854 None => {
855 agent
856 .send_assistant_message(
857 session_id,
858 format!("error: no model #{number} - type /models to see the list."),
859 )
860 .await;
861 }
862 Some(model) => {
863 if model.cache_health == setup::ModelCacheHealth::Incomplete {
864 agent
865 .send_assistant_message(
866 session_id,
867 format!(
868 "error: {} has an incomplete local cache and cannot be selected yet.",
869 model.display_name
870 ),
871 )
872 .await;
873 } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
874 agent
875 .send_assistant_message(
876 session_id.clone(),
877 format!(
878 "Downloading and loading {} ({})… this may take a few minutes.",
879 model.display_name, model.description
880 ),
881 )
882 .await;
883
884 match agent.switch_model_by_id(&model.config.model_id).await {
885 Ok(new_config) => {
886 agent.engine.clear_history().await;
887 agent
888 .send_assistant_message(
889 session_id,
890 format!(
891 "✓ Downloaded and switched to {}",
892 new_config.display_name
893 ),
894 )
895 .await;
896 }
897 Err(err) => {
898 agent
899 .send_assistant_message(
900 session_id,
901 format!("error downloading model: {}", err.message),
902 )
903 .await;
904 }
905 }
906 } else {
907 agent
908 .send_assistant_message(
909 session_id.clone(),
910 format!("Loading {}...", model.display_name),
911 )
912 .await;
913
914 let switched = agent.switch_model_by_id(&model.config.model_id).await?;
915 agent.engine.clear_history().await;
916
917 agent
918 .send_assistant_message(
919 session_id,
920 format!("Switched to {}.", switched.display_name),
921 )
922 .await;
923 }
924 }
925 }
926 }
927 SlashCommand::Exit => {
928 agent
929 .send_assistant_message(
930 session_id,
931 "Use the panel controls to close or switch threads.",
932 )
933 .await;
934 }
935 SlashCommand::Unknown(command) => {
936 agent
937 .send_assistant_message(session_id, format!("unknown command: {command}"))
938 .await;
939 }
940 }
941
942 Ok(PromptResponse::new(StopReason::EndTurn))
943 }
944
945 #[async_trait::async_trait(?Send)]
946 impl Agent for SiGitAgent {
947 async fn initialize(
948 &self,
949 _args: InitializeRequest,
950 ) -> agent_client_protocol::Result<InitializeResponse> {
951 log::info!("initialize");
952
953 Ok(InitializeResponse::new(ProtocolVersion::V1)
954 .agent_info(
955 Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
956 .title("siGit — AI Coding Agent"),
957 )
958 .auth_methods(vec![AuthMethod::Agent(AuthMethodAgent::new(
959 "sigit", "siGit",
960 ))])
961 .agent_capabilities(
962 AgentCapabilities::default()
963 .load_session(true)
964 .session_capabilities(
965 SessionCapabilities::new().fork(SessionForkCapabilities::new()),
966 ),
967 )
968 .meta(initialize_meta()))
969 }
970
971 async fn authenticate(
972 &self,
973 _args: AuthenticateRequest,
974 ) -> agent_client_protocol::Result<AuthenticateResponse> {
975 log::info!("authenticate");
976 Ok(AuthenticateResponse::default())
977 }
978
979 async fn load_session(
980 &self,
981 args: LoadSessionRequest,
982 ) -> agent_client_protocol::Result<LoadSessionResponse> {
983 log::info!(
984 "load_session: id={}, cwd={}, additional_directories={:?}",
985 args.session_id,
986 args.cwd.display(),
987 args.additional_directories
988 .iter()
989 .map(|p| p.display().to_string())
990 .collect::<Vec<_>>()
991 );
992
993 if let Ok(mut guard) = self.session_cwd.lock() {
994 *guard = Some(args.cwd.clone());
995 }
996
997 // tool calls use relative paths, so we need to match the editor's cwd
998 if args.cwd.is_dir()
999 && let Err(err) = std::env::set_current_dir(&args.cwd)
1000 {
1001 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1002 }
1003
1004 // no session persistence, so "load" just resets
1005 self.engine.clear_history().await;
1006
1007 self.engine
1008 .push_history(onde::inference::ChatMessage::system(format!(
1009 "The user's project working directory is {}. \
1010 Always use absolute paths under this directory for all file \
1011 and directory operations. This is the root of the project \
1012 the user has open in their editor.",
1013 args.cwd.display()
1014 )))
1015 .await;
1016
1017 let config_options = {
1018 let guard = self.current_model.lock().unwrap();
1019 build_model_config_options(&guard)
1020 };
1021
1022 Ok(LoadSessionResponse::new().config_options(config_options))
1023 }
1024
1025 async fn fork_session(
1026 &self,
1027 args: ForkSessionRequest,
1028 ) -> agent_client_protocol::Result<ForkSessionResponse> {
1029 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1030 log::info!(
1031 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
1032 args.session_id,
1033 args.cwd.display(),
1034 args.additional_directories
1035 .iter()
1036 .map(|p| p.display().to_string())
1037 .collect::<Vec<_>>()
1038 );
1039
1040 if let Ok(mut guard) = self.session_cwd.lock() {
1041 *guard = Some(args.cwd.clone());
1042 }
1043 if args.cwd.is_dir()
1044 && let Err(err) = std::env::set_current_dir(&args.cwd)
1045 {
1046 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1047 }
1048
1049 // no persistence, so fork == fresh session
1050 self.engine.clear_history().await;
1051
1052 self.engine
1053 .push_history(onde::inference::ChatMessage::system(format!(
1054 "The user's project working directory is {}. \
1055 Always use absolute paths under this directory for all file \
1056 and directory operations. This is the root of the project \
1057 the user has open in their editor.",
1058 args.cwd.display()
1059 )))
1060 .await;
1061
1062 let config_options = {
1063 let guard = self.current_model.lock().unwrap();
1064 build_model_config_options(&guard)
1065 };
1066
1067 Ok(ForkSessionResponse::new(new_id).config_options(config_options))
1068 }
1069
1070 async fn new_session(
1071 &self,
1072 args: NewSessionRequest,
1073 ) -> agent_client_protocol::Result<NewSessionResponse> {
1074 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1075 log::info!(
1076 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
1077 args.cwd.display(),
1078 args.additional_directories
1079 .iter()
1080 .map(|p| p.display().to_string())
1081 .collect::<Vec<_>>()
1082 );
1083
1084 if let Ok(mut guard) = self.session_cwd.lock() {
1085 *guard = Some(args.cwd.clone());
1086 }
1087 if args.cwd.is_dir()
1088 && let Err(err) = std::env::set_current_dir(&args.cwd)
1089 {
1090 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1091 }
1092
1093 self.engine.clear_history().await;
1094
1095 self.engine
1096 .push_history(onde::inference::ChatMessage::system(format!(
1097 "The user's project working directory is {}. \
1098 Always use absolute paths under this directory for all file \
1099 and directory operations. This is the root of the project \
1100 the user has open in their editor.",
1101 args.cwd.display()
1102 )))
1103 .await;
1104
1105 let config_options = {
1106 let guard = self.current_model.lock().unwrap();
1107 build_model_config_options(&guard)
1108 };
1109
1110 Ok(NewSessionResponse::new(session_id).config_options(config_options))
1111 }
1112
1113 async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> {
1114 let session_id = args.session_id.clone();
1115
1116 // log every block so we can debug @ references and file context
1117 for (i, block) in args.prompt.iter().enumerate() {
1118 match block {
1119 ContentBlock::Text(t) => {
1120 log::info!(
1121 "prompt({}) block[{}]: Text({} chars) = \"{}\"",
1122 session_id,
1123 i,
1124 t.text.len(),
1125 t.text.chars().take(200).collect::<String>()
1126 );
1127 }
1128 ContentBlock::Resource(embedded) => {
1129 log::info!(
1130 "prompt({}) block[{}]: EmbeddedResource = {:?}",
1131 session_id,
1132 i,
1133 match &embedded.resource {
1134 agent_client_protocol::EmbeddedResourceResource::TextResourceContents(t) =>
1135 format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
1136 agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(b) =>
1137 format!("BlobResource(uri={})", b.uri),
1138 _ => "Unknown".to_string(),
1139 }
1140 );
1141 }
1142 ContentBlock::ResourceLink(link) => {
1143 log::info!(
1144 "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
1145 session_id,
1146 i,
1147 link.name,
1148 link.uri,
1149 link.title,
1150 link.description
1151 );
1152 }
1153 other => {
1154 log::info!(
1155 "prompt({}) block[{}]: Other({:?})",
1156 session_id,
1157 i,
1158 std::mem::discriminant(other)
1159 );
1160 }
1161 }
1162 }
1163
1164 let mut parts: Vec<String> = Vec::new();
1165
1166 for block in &args.prompt {
1167 match block {
1168 ContentBlock::Text(t) => {
1169 parts.push(t.text.clone());
1170 }
1171 ContentBlock::Resource(embedded) => {
1172 // editor inlined the file content already
1173 match &embedded.resource {
1174 agent_client_protocol::EmbeddedResourceResource::TextResourceContents(
1175 text_resource,
1176 ) => {
1177 parts.push(format!(
1178 "\n--- {} ---\n{}\n--- end {} ---",
1179 text_resource.uri, text_resource.text, text_resource.uri
1180 ));
1181 }
1182 agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(
1183 blob,
1184 ) => {
1185 parts.push(format!("[binary resource: {}]", blob.uri));
1186 }
1187 _ => {
1188 log::debug!("ignoring unsupported embedded resource variant");
1189 }
1190 }
1191 }
1192 ContentBlock::ResourceLink(link) => {
1193 // reference without content; read the file ourselves
1194 let label = link.name.clone();
1195
1196 if let Some(raw_path) = link.uri.strip_prefix("file://") {
1197 let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') {
1198 let fragment = &raw_path[hash_pos + 1..];
1199 let path = &raw_path[..hash_pos];
1200 // Parse "L207:219" or "L207-219" → (207, 219)
1201 let range = fragment.strip_prefix('L').and_then(|rest| {
1202 let sep = if rest.contains(':') { ':' } else { '-' };
1203 let mut parts = rest.splitn(2, sep);
1204 let start = parts.next()?.parse::<usize>().ok()?;
1205 let end = parts.next()?.parse::<usize>().ok()?;
1206 Some((start, end))
1207 });
1208 (path, range)
1209 } else {
1210 (raw_path, None)
1211 };
1212
1213 match std::fs::read_to_string(file_path) {
1214 Ok(contents) => {
1215 let extracted = if let Some((start, end)) = line_range {
1216 let selected: Vec<&str> = contents
1217 .lines()
1218 .enumerate()
1219 .filter(|(i, _)| {
1220 let line_num = i + 1;
1221 line_num >= start && line_num <= end
1222 })
1223 .map(|(_, line)| line)
1224 .collect();
1225 format!(
1226 "\n--- {label} ({file_path} lines {start}-{end}) ---\n{}\n--- end {label} ---",
1227 selected.join("\n")
1228 )
1229 } else {
1230 format!(
1231 "\n--- {label} ({file_path}) ---\n{contents}\n--- end {label} ---"
1232 )
1233 };
1234 parts.push(extracted);
1235 }
1236 Err(err) => {
1237 log::warn!("could not read ResourceLink {}: {err}", link.uri);
1238 parts.push(format!("[referenced file: {label} ({file_path})]"));
1239 }
1240 }
1241 } else {
1242 parts.push(format!("[resource link: {label} ({})]", link.uri));
1243 }
1244 }
1245 _ => {
1246 log::debug!("ignoring unsupported content block type in prompt");
1247 }
1248 }
1249 }
1250
1251 let user_text = parts.join("\n");
1252
1253 if user_text.trim().is_empty() {
1254 return Ok(PromptResponse::new(StopReason::EndTurn));
1255 }
1256
1257 if let Some(command) = parse_slash(&user_text) {
1258 return exec_slash_acp(self, session_id, command).await;
1259 }
1260
1261 log::info!(
1262 "prompt({}): \"{}\"",
1263 session_id,
1264 user_text.chars().take(80).collect::<String>()
1265 );
1266
1267 // load the default ACP model lazily so initialize/session/new stay clean
1268 // for registry validation and editor startup.
1269 self.start_startup_model_load_if_needed();
1270 self.await_model_ready(&session_id).await?;
1271
1272 // ── tool-calling loop ────────────────────────────────────────────
1273 // send message → execute any tool calls → feed results back
1274 // repeat up to MAX_TOOL_ROUNDS, then force a text reply
1275
1276 let onde_tools = agent_tools_as_onde();
1277
1278 let mut result = self
1279 .engine
1280 .send_message_with_tools(&user_text, &onde_tools)
1281 .await
1282 .map_err(|error| {
1283 log::error!("send_message_with_tools failed: {error}");
1284 agent_client_protocol::Error::new(-32603, format!("inference failed: {error}"))
1285 })?;
1286
1287 let mut round = 0;
1288
1289 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1290 round += 1;
1291 log::info!(
1292 "prompt({}) tool round {} — {} call(s)",
1293 session_id,
1294 round,
1295 result.tool_calls.len()
1296 );
1297
1298 let mut tool_results = Vec::new();
1299
1300 for tc in &result.tool_calls {
1301 log::info!(
1302 " → {}({})",
1303 tc.function_name,
1304 tc.arguments.chars().take(120).collect::<String>()
1305 );
1306
1307 let output = tools::execute_tool(&tc.function_name, &tc.arguments).await;
1308
1309 log::info!(" ← {} chars", output.len());
1310
1311 tool_results.push(ToolResult {
1312 tool_call_id: tc.id.clone(),
1313 content: output,
1314 });
1315 }
1316
1317 let next_tools = if round < MAX_TOOL_ROUNDS {
1318 Some(onde_tools.as_slice())
1319 } else {
1320 None // last round: force text
1321 };
1322
1323 result = self
1324 .engine
1325 .send_tool_results(tool_results, next_tools)
1326 .await
1327 .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
1328 }
1329
1330 // ── Send the final text response ─────────────────────────────────
1331 let reply_text = result.text.trim().to_string();
1332
1333 let final_text = if reply_text.is_empty() {
1334 if round > 0 {
1335 log::warn!(
1336 "prompt({}) — model returned empty reply after {} tool round(s)",
1337 session_id,
1338 round
1339 );
1340 "Something went wrong — the edits didn't go through. Try rephrasing what you need, or point me at the specific lines.".to_string()
1341 } else {
1342 log::warn!(
1343 "prompt({}) — model returned empty reply (no tool rounds)",
1344 session_id
1345 );
1346 String::new()
1347 }
1348 } else {
1349 // strip <think> blocks so reasoning tokens stay hidden
1350 let (_think, visible) = chat::strip_think_blocks(&reply_text);
1351 visible
1352 };
1353
1354 if !final_text.is_empty() {
1355 let notification = SessionNotification::new(
1356 session_id.clone(),
1357 SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(final_text))),
1358 );
1359 if self.notification_tx.send(notification).await.is_err() {
1360 log::warn!("notification channel closed");
1361 }
1362 }
1363
1364 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
1365 Ok(PromptResponse::new(StopReason::EndTurn))
1366 }
1367
1368 async fn cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
1369 log::info!("cancel requested for session {}", args.session_id);
1370 Ok(())
1371 }
1372
1373 async fn set_session_config_option(
1374 &self,
1375 args: SetSessionConfigOptionRequest,
1376 ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
1377 log::info!(
1378 "set_session_config_option: config_id={}, value={:?}",
1379 args.config_id,
1380 args.value
1381 );
1382
1383 if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
1384 return Err(agent_client_protocol::Error::new(
1385 -32602,
1386 format!("unknown config option: {}", args.config_id.0),
1387 ));
1388 }
1389
1390 let model_id = args.value.0.as_ref();
1391
1392 // can't switch while the startup model is still loading — the old
1393 // weights are in GPU memory and the new load gets "does not fit"
1394 if self.startup_model_load_started.load(Ordering::Acquire)
1395 && !self.model_ready.load(Ordering::Acquire)
1396 {
1397 log::info!("set_session_config_option: waiting for startup model to finish loading");
1398 while !self.model_ready.load(Ordering::Acquire) {
1399 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1400 }
1401 }
1402
1403 // Zed re-fires the last selection on connect; no-op if it's already loaded
1404 {
1405 let current = self.current_model.lock().unwrap();
1406 if current.model_id == model_id {
1407 log::info!(
1408 "set_session_config_option: {} is already the active model, skipping",
1409 current.display_name
1410 );
1411 let config_options = build_model_config_options(&current);
1412 return Ok(SetSessionConfigOptionResponse::new(config_options));
1413 }
1414 }
1415
1416 let needs_download = models::build_model_picker_items()
1417 .into_iter()
1418 .find(|item| item.config.model_id == model_id)
1419 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
1420 .unwrap_or(false);
1421
1422 // tells the progress poller to stop
1423 let stop_flag = Arc::new(AtomicBool::new(false));
1424
1425 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
1426
1427 if needs_download {
1428 let model_id_owned = model_id.to_string();
1429 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
1430 .iter()
1431 .find(|m| m.id == model_id_owned)
1432 .map(|m| m.expected_size_bytes)
1433 .unwrap_or(0);
1434
1435 let display_name = models::build_model_picker_items()
1436 .into_iter()
1437 .find(|item| item.config.model_id == model_id_owned)
1438 .map(|item| item.display_name.clone())
1439 .unwrap_or_else(|| model_id_owned.clone());
1440
1441 let size_hint = if expected_bytes > 0 {
1442 format!(" (~{})", format_size_human(expected_bytes))
1443 } else {
1444 String::new()
1445 };
1446
1447 self.send_tool_call_update(
1448 args.session_id.clone(),
1449 SessionUpdate::ToolCall(
1450 ToolCall::new(
1451 tool_call_id.clone(),
1452 format!("⏬ Downloading {display_name}{size_hint}"),
1453 )
1454 .kind(ToolKind::Think)
1455 .status(ToolCallStatus::InProgress)
1456 .content(vec![
1457 format!(
1458 "Preparing download for {display_name}. This may take a few minutes."
1459 )
1460 .into(),
1461 ]),
1462 ),
1463 )
1464 .await;
1465
1466 // poll download progress and update the spinner in Zed
1467 let poller_tx = self.notification_tx.clone();
1468 let poller_session = args.session_id.clone();
1469 let poller_model_id = model_id_owned.clone();
1470 let poller_stop = Arc::clone(&stop_flag);
1471 let poller_tool_call_id = tool_call_id.clone();
1472
1473 tokio::task::spawn_local(async move {
1474 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1475 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
1476 let mut tick: usize = 0;
1477 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
1478 interval.tick().await; // consume the immediate first tick
1479
1480 while !poller_stop.load(Ordering::Relaxed) {
1481 interval.tick().await;
1482
1483 if poller_stop.load(Ordering::Relaxed) {
1484 break;
1485 }
1486
1487 let downloaded = cache_path
1488 .as_ref()
1489 .filter(|p| p.exists())
1490 .map(|p| dir_size_recursive(p))
1491 .unwrap_or(0);
1492
1493 let frame = SPINNER[tick % SPINNER.len()];
1494 tick += 1;
1495
1496 let title = if expected_bytes > 0 {
1497 let pct =
1498 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1499 format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
1500 } else {
1501 format!("{frame} Downloading {display_name}{size_hint}")
1502 };
1503
1504 let msg = if expected_bytes > 0 {
1505 let pct =
1506 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1507 let bar = progress_bar(pct, 20);
1508 format!(
1509 "{display_name} — {bar} {pct}% ({} / {})",
1510 format_size_human(downloaded),
1511 format_size_human(expected_bytes),
1512 )
1513 } else {
1514 format!(
1515 "{display_name} — {} downloaded…",
1516 format_size_human(downloaded)
1517 )
1518 };
1519
1520 let notification = SessionNotification::new(
1521 poller_session.clone(),
1522 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1523 poller_tool_call_id.clone(),
1524 ToolCallUpdateFields::new()
1525 .title(title)
1526 .status(ToolCallStatus::InProgress)
1527 .content(vec![msg.into()]),
1528 )),
1529 );
1530 if poller_tx.send(notification).await.is_err() {
1531 break;
1532 }
1533 }
1534 });
1535 }
1536
1537 // cached models still take 10-30s to load weights; show a spinner
1538 if !needs_download {
1539 let cached_display_name = models::build_model_picker_items()
1540 .into_iter()
1541 .find(|item| item.config.model_id == model_id)
1542 .map(|item| item.display_name.clone())
1543 .unwrap_or_else(|| model_id.to_string());
1544
1545 self.send_tool_call_update(
1546 args.session_id.clone(),
1547 SessionUpdate::ToolCall(
1548 ToolCall::new(
1549 tool_call_id.clone(),
1550 format!("Loading {cached_display_name}"),
1551 )
1552 .kind(ToolKind::Think)
1553 .status(ToolCallStatus::InProgress)
1554 .content(vec![format!("Loading {cached_display_name}…").into()]),
1555 ),
1556 )
1557 .await;
1558
1559 // tick every 5s so the user knows we haven't frozen
1560 let spinner_tx = self.notification_tx.clone();
1561 let spinner_session = args.session_id.clone();
1562 let spinner_name = cached_display_name.clone();
1563 let spinner_stop = Arc::clone(&stop_flag);
1564 let spinner_tool_call_id = tool_call_id.clone();
1565 let load_start = std::time::Instant::now();
1566
1567 tokio::task::spawn_local(async move {
1568 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1569 let mut tick: usize = 0;
1570 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
1571 interval.tick().await; // consume the immediate first tick
1572
1573 while !spinner_stop.load(Ordering::Relaxed) {
1574 interval.tick().await;
1575
1576 if spinner_stop.load(Ordering::Relaxed) {
1577 break;
1578 }
1579
1580 let elapsed = load_start.elapsed();
1581 let elapsed_str = if elapsed.as_secs() >= 60 {
1582 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
1583 } else {
1584 format!("{}s", elapsed.as_secs())
1585 };
1586 let frame = SPINNER[tick % SPINNER.len()];
1587 tick += 1;
1588
1589 let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
1590 let notification = SessionNotification::new(
1591 spinner_session.clone(),
1592 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1593 spinner_tool_call_id.clone(),
1594 ToolCallUpdateFields::new()
1595 .status(ToolCallStatus::InProgress)
1596 .content(vec![msg.into()]),
1597 )),
1598 );
1599 if spinner_tx.send(notification).await.is_err() {
1600 break;
1601 }
1602 }
1603 });
1604 }
1605
1606 let switch_result = self.switch_model_by_id(model_id).await;
1607
1608 stop_flag.store(true, Ordering::Relaxed);
1609
1610 match switch_result {
1611 Ok(new_config) => {
1612 let completion_title = if needs_download {
1613 format!("✓ {} downloaded and loaded", new_config.display_name)
1614 } else {
1615 format!("✓ Switched to {}", new_config.display_name)
1616 };
1617 let completion_body = if needs_download {
1618 format!("✓ {} downloaded and loaded.", new_config.display_name)
1619 } else {
1620 format!("✓ Switched to {}.", new_config.display_name)
1621 };
1622
1623 self.send_tool_call_update(
1624 args.session_id.clone(),
1625 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1626 tool_call_id,
1627 ToolCallUpdateFields::new()
1628 .title(completion_title)
1629 .status(ToolCallStatus::Completed)
1630 .content(vec![completion_body.into()]),
1631 )),
1632 )
1633 .await;
1634
1635 let config_options = {
1636 let guard = self.current_model.lock().unwrap();
1637 build_model_config_options(&guard)
1638 };
1639
1640 log::info!("model switch complete");
1641 Ok(SetSessionConfigOptionResponse::new(config_options))
1642 }
1643 Err(err) => {
1644 self.send_tool_call_update(
1645 args.session_id.clone(),
1646 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1647 tool_call_id,
1648 ToolCallUpdateFields::new()
1649 .title("Model switch failed".to_string())
1650 .status(ToolCallStatus::Failed)
1651 .content(vec![format!("error loading model: {}", err.message).into()]),
1652 )),
1653 )
1654 .await;
1655
1656 Err(err)
1657 }
1658 }
1659 }
1660 }
1661
1662 // ── Download progress helpers ─────────────────────────────────────────────────
1663
1664 /// total bytes on disk under `path`. needed because hf-hub uses staging
1665 /// names during download, so we can't just stat the final blobs.
1666 fn dir_size_recursive(path: &std::path::Path) -> u64 {
1667 let mut total: u64 = 0;
1668 let Ok(entries) = std::fs::read_dir(path) else {
1669 return 0;
1670 };
1671 for entry in entries.flatten() {
1672 let entry_path = entry.path();
1673 if entry_path.is_dir() {
1674 total += dir_size_recursive(&entry_path);
1675 } else if let Ok(meta) = entry_path.metadata() {
1676 total += meta.len();
1677 }
1678 }
1679 total
1680 }
1681
1682 fn format_size_human(bytes: u64) -> String {
1683 const GB: u64 = 1_073_741_824;
1684 const MB: u64 = 1_048_576;
1685 const KB: u64 = 1_024;
1686 if bytes >= GB {
1687 format!("{:.2} GB", bytes as f64 / GB as f64)
1688 } else if bytes >= MB {
1689 format!("{:.1} MB", bytes as f64 / MB as f64)
1690 } else if bytes >= KB {
1691 format!("{:.0} KB", bytes as f64 / KB as f64)
1692 } else {
1693 format!("{bytes} B")
1694 }
1695 }
1696
1697 fn progress_bar(pct: u8, width: usize) -> String {
1698 let filled = ((pct as usize) * width) / 100;
1699 let empty = width.saturating_sub(filled);
1700 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
1701 }
1702
1703 // ── Output capture ────────────────────────────────────────────────────────────
1704
1705 /// redirect stdout+stderr to `$TMPDIR/sigit.log` at the fd level so
1706 /// mistralrs/tracing noise never hits the terminal. returns two dup'd
1707 /// fds to the real tty: one for ratatui, one for cleanup (ratatui 0.29
1708 /// doesn't expose `writer_mut()`).
1709 #[cfg(unix)]
1710 fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
1711 let log_path = std::env::temp_dir().join("sigit.log");
1712 let log_file = std::fs::File::create(&log_path)?;
1713 let log_fd = log_file.as_raw_fd();
1714
1715 // two copies: ratatui needs one, cleanup needs another
1716 let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) };
1717 anyhow::ensure!(
1718 saved_tui >= 0,
1719 "dup(stdout) for tui failed: {}",
1720 std::io::Error::last_os_error()
1721 );
1722 let saved_cleanup = unsafe { libc::dup(libc::STDOUT_FILENO) };
1723 anyhow::ensure!(
1724 saved_cleanup >= 0,
1725 "dup(stdout) for cleanup failed: {}",
1726 std::io::Error::last_os_error()
1727 );
1728
1729 unsafe {
1730 libc::dup2(log_fd, libc::STDOUT_FILENO);
1731 libc::dup2(log_fd, libc::STDERR_FILENO);
1732 }
1733
1734 // safe to drop log_file; dup2 keeps the fd alive via stdout/stderr
1735
1736 Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe {
1737 std::fs::File::from_raw_fd(saved_cleanup)
1738 }))
1739 }
1740
1741 // ── Logging ───────────────────────────────────────────────────────────────────
1742
1743 /// in TUI mode stderr is the log file (redirected earlier);
1744 /// in ACP mode it's real stderr. either way, write there.
1745 fn init_logging(is_tty: bool) {
1746 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
1747 let _ = tracing_fmt::Subscriber::builder()
1748 .with_env_filter(filter)
1749 .with_writer(std::io::stderr)
1750 .with_ansi(!is_tty)
1751 .try_init();
1752 }
1753
1754 // ── Interactive TUI mode ──────────────────────────────────────────────────────
1755
1756 /// boot the TUI and load the model on a background thread.
1757 /// `tty` goes to ratatui; `cleanup_tty` is a separate fd for
1758 /// LeaveAlternateScreen (ratatui 0.29 hides `writer_mut()`).
1759 #[cfg(unix)]
1760 async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
1761 let engine = Arc::new(ChatEngine::new());
1762
1763 let startup_selection = setup::startup_model_selection();
1764 let startup_model_name = startup_selection
1765 .as_ref()
1766 .map(|selection| selection.display_name.clone())
1767 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
1768
1769 let config = startup_selection
1770 .as_ref()
1771 .and_then(|selection| {
1772 models::build_model_picker_items()
1773 .into_iter()
1774 .find(|item| {
1775 selection
1776 .selected_model
1777 .as_ref()
1778 .map(|selected| {
1779 item.config.model_id == selected.model_id
1780 && item
1781 .config
1782 .files
1783 .iter()
1784 .any(|file| file == &selected.gguf_file)
1785 })
1786 .unwrap_or(false)
1787 })
1788 .map(|item| item.config)
1789 })
1790 .unwrap_or_else(GgufModelConfig::qwen25_3b);
1791 let sampling = SamplingConfig {
1792 max_tokens: Some(8192),
1793 ..SamplingConfig::default()
1794 };
1795
1796 // std::sync::mpsc on a real thread so model loading can't starve the TUI
1797 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
1798
1799 let loader_engine = Arc::clone(&engine);
1800 let tool_calling = models::build_model_picker_items()
1801 .iter()
1802 .find(|item| item.config.model_id == config.model_id)
1803 .map(|item| item.tool_calling)
1804 .unwrap_or(false);
1805 let system_prompt = system_prompt_for_model(tool_calling).to_string();
1806 std::thread::spawn(move || {
1807 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1808 let result =
1809 rt.block_on(loader_engine.load_gguf_model(config, Some(system_prompt), Some(sampling)));
1810 let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
1811 });
1812
1813 crossterm::terminal::enable_raw_mode()?;
1814 let mut tty = BufWriter::new(tty);
1815 crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
1816 let backend = ratatui::backend::CrosstermBackend::new(tty);
1817 let mut terminal = ratatui::Terminal::new(backend)?;
1818
1819 // polls load_rx with try_recv() each tick, no blocking
1820 let chat_result = chat::run_with(&mut terminal, engine, load_rx, startup_model_name).await;
1821
1822 // cleanup fd because backend's writer is private
1823 crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
1824 cleanup_tty.flush()?;
1825 crossterm::terminal::disable_raw_mode()?;
1826
1827 // restore real stdout/stderr for post-TUI error output
1828 #[cfg(unix)]
1829 {
1830 let cleanup_fd = cleanup_tty.as_raw_fd();
1831 unsafe {
1832 libc::dup2(cleanup_fd, libc::STDOUT_FILENO);
1833 libc::dup2(cleanup_fd, libc::STDERR_FILENO);
1834 }
1835 }
1836
1837 chat_result
1838 }
1839
1840 // ── ACP server mode ───────────────────────────────────────────────────────────
1841
1842 async fn run_acp_server() -> anyhow::Result<()> {
1843 log::info!("ACP mode — starting agent server");
1844
1845 let startup_selection = setup::startup_model_selection();
1846 let config = startup_selection
1847 .as_ref()
1848 .and_then(|selection| {
1849 selection.selected_model.as_ref().and_then(|selected| {
1850 models::build_model_picker_items()
1851 .into_iter()
1852 .find(|item| {
1853 item.config.model_id == selected.model_id
1854 && item
1855 .config
1856 .files
1857 .iter()
1858 .any(|file| file == &selected.gguf_file)
1859 })
1860 .map(|item| item.config)
1861 })
1862 })
1863 .unwrap_or_else(GgufModelConfig::qwen25_3b);
1864
1865 let needs_download = models::build_model_picker_items()
1866 .iter()
1867 .find(|item| item.config.model_id == config.model_id)
1868 .map(|item| item.cache_health != setup::ModelCacheHealth::Complete)
1869 .unwrap_or(true);
1870
1871 log::info!(
1872 "ACP startup model selected: {} ({})",
1873 config.display_name,
1874 if needs_download {
1875 "needs download"
1876 } else {
1877 "cached"
1878 }
1879 );
1880
1881 let engine = Arc::new(ChatEngine::new());
1882
1883 // Delay model loading until the first real prompt so initialize/session/new
1884 // stay lightweight and registry auth checks don't trip over model startup.
1885 let model_ready = Arc::new(AtomicBool::new(true));
1886 let startup_model_load_started = Arc::new(AtomicBool::new(false));
1887 let model_load_error: Arc<std::sync::Mutex<Option<String>>> =
1888 Arc::new(std::sync::Mutex::new(None));
1889
1890 let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
1891 let agent = SiGitAgent::new(
1892 engine,
1893 notification_tx,
1894 config,
1895 model_ready,
1896 startup_model_load_started,
1897 model_load_error,
1898 needs_download,
1899 );
1900
1901 // AgentSideConnection needs futures-io
1902 let stdin = tokio::io::stdin().compat();
1903 let stdout = tokio::io::stdout().compat_write();
1904
1905 // ACP futures are !Send
1906 let local = tokio::task::LocalSet::new();
1907
1908 local
1909 .run_until(async move {
1910 let (conn, io_task) = AgentSideConnection::new(
1911 agent,
1912 stdout,
1913 stdin,
1914 |fut: LocalBoxFuture<'static, ()>| {
1915 tokio::task::spawn_local(fut);
1916 },
1917 );
1918
1919 tokio::task::spawn_local(async move {
1920 while let Some(notification) = notification_rx.recv().await {
1921 if let Err(err) = conn.session_notification(notification).await {
1922 log::warn!("session_notification failed: {err}");
1923 }
1924 }
1925 });
1926
1927 if let Err(err) = io_task.await {
1928 log::error!("ACP IO error: {err}");
1929 }
1930 })
1931 .await;
1932
1933 log::info!("siGit shutting down");
1934 Ok(())
1935 }
1936
1937 // ── Entry point ──────────────────────────────────────────────────────────────
1938
1939 #[tokio::main]
1940 async fn main() -> anyhow::Result<()> {
1941 let is_tty = std::io::stdin().is_terminal();
1942
1943 if is_tty {
1944 // must redirect before any library code touches stdout
1945 #[cfg(unix)]
1946 {
1947 let (tty, cleanup_tty) = redirect_output_to_log()?;
1948 init_logging(true);
1949 setup::setup_shared_model_cache();
1950 run_interactive(tty, cleanup_tty).await
1951 }
1952 #[cfg(not(unix))]
1953 {
1954 anyhow::bail!("interactive mode requires Unix (macOS / Linux)");
1955 }
1956 } else {
1957 // ACP mode: keep stdout untouched for protocol JSON only.
1958 // Logs already go to stderr via `init_logging(false)`.
1959 init_logging(false);
1960 setup::setup_shared_model_cache();
1961 log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
1962 run_acp_server().await
1963 }
1964 }