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