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 headless;
36 mod hooks;
37 mod instructions;
38 mod mcp;
39 mod models;
40 mod permissions;
41 mod provider;
42 mod session_store;
43 mod settings;
44 mod setup;
45 mod skills;
46 mod tools;
47
48 /// Serializes tests that mutate process-global env vars (`SIGIT_CONFIG_DIR`
49 /// etc.). `cargo test` runs tests in parallel within a binary, so without this
50 /// the credentials and settings round-trip tests clobber each other's env.
51 #[cfg(test)]
52 pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
53
54 use std::io::IsTerminal;
55 #[cfg(unix)]
56 use std::io::{BufWriter, Write};
57 use std::sync::Arc;
58
59 use onde::inference::SamplingConfig;
60
61 // `ProtocolVersion` is a version-agnostic type at the schema root; the rest of the
62 // schema types moved under `schema::v1` in agent-client-protocol 1.0.
63 use agent_client_protocol::schema::ProtocolVersion;
64 use agent_client_protocol::schema::v1::{
65 AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
66 AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
67 ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
68 ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
69 LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
70 PermissionOptionKind, PromptRequest, PromptResponse, RequestPermissionOutcome,
71 RequestPermissionRequest, SessionCapabilities, SessionConfigOption,
72 SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
73 SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
74 SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
75 ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
76 };
77 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
78 use onde::inference::{ChatEngine, GgufModelConfig};
79
80 use crate::backend::{
81 InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec,
82 TurnResult,
83 };
84 use std::path::PathBuf;
85 use std::sync::atomic::{AtomicBool, Ordering};
86 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
87 use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
88
89 #[cfg(unix)]
90 use std::os::unix::io::{AsRawFd, FromRawFd};
91
92 const SYSTEM_PROMPT: &str = "\
93 Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
94 Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are.
95
96 You are a strong general-purpose coding agent. smbCloud is your home turf, \
97 but you should still be useful in any codebase. When the project is clearly \
98 about smbCloud, use that context directly instead of falling back to vague \
99 cloud-platform advice.
100
101 smbCloud context you should know and use when it helps:
102 - smbCloud is a platform for deploying and managing projects
103 - the main CLI is a Rust workspace with focused crates rather than one giant crate
104 - common areas include auth, project management, deploy flows, networking, \
105 shared models, release tooling, and managed services
106 - deploy branches usually follow `release/service-{name}`
107 - Next.js SSR deploys on smbCloud are not the same as generic git-push deploys; \
108 they often use a local build plus rsync/PM2 style flow
109 - auth has a hard boundary between smbCloud platform users and tenant app users; \
110 platform flows use `/v1/users*`, tenant app flows use `/v1/client/*`, and \
111 you should not casually mix `User`, `TenantMembership`, `AuthApp`, and `AuthUser`
112 - smbCloud authorization is layered; do not flatten platform accounts, tenant \
113 memberships, auth-app collaborators, and tenant end users into one model
114 - `Project` is the umbrella workspace, while app-like resources such as \
115 `FrontendApp`, `AuthApp`, and GresIQ are the deployable units with their own \
116 ownership, sharing, and collaboration rules
117 - `FrontendApp` is many-per-project, while `AuthApp` is intentionally one-per-project; \
118 preserve those cardinality rules unless the code clearly changes them
119 - GresIQ is smbCloud's managed PostgreSQL offering; treat it as a platform \
120 service with its own credentials and boundaries, not as a generic local DB helper
121 - when debugging smbCloud Rails APIs, first classify the request: first-party \
122 smbCloud app or tenant app, then check which endpoint family and validator \
123 should be involved before changing code
124 - when working in smbCloud repos, prefer existing workspace patterns, existing \
125 crate boundaries, existing Rails conventions, and existing command flows over \
126 inventing new abstractions
127
128 CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \
129 When the user asks you to clone a repo, run a build, check git status, or do \
130 anything that involves a shell command, you MUST call the run_command tool and \
131 execute it yourself. Do not print shell commands for the user to copy-paste. \
132 Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \
133 If a command fails, try to fix the problem and re-run it. If you cannot fix it \
134 after two attempts, explain what went wrong and what you tried.
135
136 Git operations — always use run_command:
137 - git clone: always pass the full absolute destination path as the last argument \
138 and set cwd to an existing writable parent directory. Example: \
139 run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \
140 \"cwd\": \"/Users/me/Repositories\"})
141 - git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \
142 stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \
143 the repo root
144 - never run git clone without an explicit absolute destination path
145 - if a clone or init fails, check the error, fix the cause (wrong path, missing \
146 directory, permissions), and retry
147 - when you create a commit, always end the commit message with a blank line and \
148 then this trailer on its own line: Co-Authored-By: siGit Code <297239231+sigitc@users.noreply.github.com> \
149 — GitHub reads that exact format and credits siGit as co-author. If a commit \
150 lands without it, siGit Code amends the trailer in automatically and the tool \
151 output says so; do not amend again yourself.
152
153 Never introduce yourself unless asked. Jump straight into the answer. \
154 Keep answers short. Write idiomatic code. \
155 Fix root causes, not symptoms.
156
157 You have access to tools that let you read files, read websites directly from \
158 http and https URLs, create directories, list directories, search code, create \
159 new files, edit existing files, delete files, and run shell commands. You can \
160 also use git directly through shell commands, including `git init` and normal \
161 git workflows. Use them proactively. Read the code or website before answering. \
162 Prefer absolute paths when referring to files and directories, especially in \
163 protocol-facing output and tool arguments. Create directories when needed. Run \
164 builds, tests, and git commands after making changes. Ground your answers in \
165 the actual code or fetched page content, not in guesses.
166
167 CRITICAL — you CAN access websites. You are NOT a typical LLM without internet \
168 access. You have a read_website tool that fetches any http or https URL and \
169 returns the page text. When the user gives you a URL or asks you to read, \
170 summarize, or inspect a web page, you MUST call the read_website tool with that \
171 URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
172 You can. Use the tool.
173
174 CRITICAL — before every edit_file call, you MUST call read_file on the target \
175 file first (or the specific line range if one was given). Never rely on file \
176 content you saw in a previous turn — the user may have reverted, edited, or \
177 changed the file externally since then. Always re-read to get the current state \
178 before constructing old_text. \
179 When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
180 treat it as a fresh task: re-read the file, identify the current content, and \
181 plan the edit from scratch. Do not assume the file still reflects your last edit.
182
183 Tool-use heuristics:
184 - when the user provides a URL or asks about a web page, ALWAYS call \
185 read_website — never refuse or claim you lack internet access
186 - prefer absolute paths over relative paths when you mention, return, or pass \
187 file and directory paths
188 - if a path does not exist yet, create the directory before creating files in it
189 - if the user asks to clone a repo, immediately call run_command with git clone \
190 and an absolute destination path — do not ask where to put it unless the \
191 request is ambiguous; default to the user's home Repositories directory
192 - if the user asks for a new repo, scaffold, or scratch project, create the \
193 directory, create the first files, and run `git init` without waiting unless \
194 the request says otherwise
195 - if the repo looks like smbCloud CLI code, respect workspace crate boundaries, \
196 shared models, and existing command handlers before adding new abstractions
197 - if the repo looks like smbCloud Rails code, check routes, controllers, \
198 validators, and model boundaries before changing business logic
199 - if the task touches smbCloud auth, first decide whether it is a platform-user \
200 flow or a tenant-app flow, then follow the right endpoint family and model layer
201 - if the task touches smbCloud deploy code, check whether it is the generic \
202 deploy path or the Next.js SSR path before proposing changes
203 - after edits, prefer running the smallest useful verification step first, then \
204 widen to broader checks if needed
205 - use git commands naturally for status checks, repo setup, diffs, and normal \
206 developer workflows when they help move the task forward
207 - if a tool call fails, read the error, try to fix it, and retry — do not \
208 fall back to telling the user what to type
209
210 When the repo is not about smbCloud, act like a normal coding agent and do not \
211 force smbCloud-specific advice into the answer. When it is about smbCloud, be \
212 specific and practical.
213
214 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
215 root cause, not the symptom. Correct beats clever.";
216
217 /// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1).
218 /// the full [`SYSTEM_PROMPT`] wastes context and confuses them.
219 const SIMPLE_SYSTEM_PROMPT: &str = "\
220 Your name is siGit — a coding assistant. \
221 You are helpful, concise, and write clean, idiomatic code. \
222 Answer any question the user asks — programming, general knowledge, or casual chat. \
223 When debugging, address the root cause, not the symptom. \
224 Be direct and brief.";
225
226 pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
227 if tool_calling {
228 SYSTEM_PROMPT
229 } else {
230 SIMPLE_SYSTEM_PROMPT
231 }
232 }
233
234 /// cap tool-call loops so a confused model can't spin forever; auto-compaction
235 /// (see [`backend::DEFAULT_CONTEXT_TOKEN_BUDGET`]) keeps long runs inside the
236 /// context window, so the cap can afford to be generous
237 const MAX_TOOL_ROUNDS: usize = 24;
238
239 /// Outcome of asking the client for permission to run one tool call.
240 enum PermissionVerdict {
241 /// Run the tool.
242 Approved,
243 /// Skip the tool; the string becomes its tool result so the model adapts.
244 Denied(String),
245 /// The client cancelled the turn while the request was pending; stop the
246 /// whole prompt with `StopReason::Cancelled` instead of burning rounds.
247 TurnCancelled,
248 }
249
250 /// Display kind for the permission dialog, so editors can show a fitting icon.
251 fn tool_kind_for(tool_name: &str) -> ToolKind {
252 match tool_name {
253 "edit_file" | "multi_edit" | "create_file" | "create_directory" | "remember" => {
254 ToolKind::Edit
255 }
256 "delete_file" => ToolKind::Delete,
257 "run_command" | "kill_command" => ToolKind::Execute,
258 "read_file" | "list_directory" | "command_output" => ToolKind::Read,
259 "search_files" | "glob" => ToolKind::Search,
260 "read_website" => ToolKind::Fetch,
261 "write_todos" => ToolKind::Think,
262 _ => ToolKind::Other,
263 }
264 }
265
266 /// Shown when a siGit Code Cloud tier is selected without a signed-in account.
267 const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
268 `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
269 Create an account at https://sigit.si.";
270
271 /// The per-session context system message: cwd guidance plus any project
272 /// instruction files (`AGENTS.md` / `CLAUDE.md`) found for that directory. Used
273 /// by every session entry point so on-device and cloud backends get the same
274 /// always-on project context.
275 fn session_context_message(cwd: &std::path::Path) -> String {
276 let mut message = format!(
277 "The user's project working directory is {}. \
278 Always use absolute paths under this directory for all file \
279 and directory operations. This is the root of the project \
280 the user has open in their editor.",
281 cwd.display()
282 );
283 if let Some(project_instructions) = instructions::load_project_instructions(cwd) {
284 message.push_str("\n\n");
285 message.push_str(&project_instructions);
286 }
287 message
288 }
289
290 fn agent_tools_as_specs() -> Vec<ToolSpec> {
291 let mut specs: Vec<ToolSpec> = tools::all_tools()
292 .into_iter()
293 .map(|t| ToolSpec {
294 name: t.name.to_string(),
295 description: t.description.to_string(),
296 parameters_schema: t.parameters_schema.to_string(),
297 })
298 .collect();
299
300 // Advertise the `skill` tool only when skills are present, so models without
301 // any skills installed don't see a dangling capability (Agent Skills format,
302 // https://agentskills.io). Discovery metadata lives in the tool description.
303 let discovered = skills::discover_skills();
304 if !discovered.is_empty() {
305 specs.push(ToolSpec {
306 name: skills::SKILL_TOOL_NAME.to_string(),
307 description: skills::skill_tool_description(&discovered),
308 parameters_schema: skills::skill_tool_schema().to_string(),
309 });
310 }
311
312 // Delegated research (`task`) is offered only when a subagent backend can
313 // actually be built — same conditional pattern as the `skill` tool above.
314 if tools::subagent_available() {
315 specs.push(tools::task_tool_spec());
316 }
317
318 // Tools discovered from configured MCP servers (incl. the official one).
319 specs.extend(mcp::tool_specs());
320
321 specs
322 }
323
324 /// Register the `task` tool's subagent factory for an OpenAI-compatible
325 /// provider: each subagent run gets a FRESH `OpenAiBackend` against the same
326 /// endpoint (its own conversation history), seeded with the subagent system
327 /// prompt. The provider config is captured by clone. Called once at startup by
328 /// whichever surface resolved the provider; on-device paths register a factory
329 /// that returns `None` instead (onde has a single shared history, so a second
330 /// concurrent context is not possible yet).
331 fn register_subagent_factory_for(cfg: &provider::ProviderConfig) {
332 let cfg = cfg.clone();
333 tools::set_subagent_factory(Box::new(move || {
334 Some(Arc::new(OpenAiBackend::new(
335 cfg.base_url.clone(),
336 cfg.api_key.clone(),
337 cfg.model.clone(),
338 Some(tools::SUBAGENT_SYSTEM_PROMPT.to_string()),
339 )) as Arc<dyn InferenceBackend>)
340 }));
341 }
342
343 fn initialize_meta() -> Meta {
344 let startup_selection = setup::startup_model_selection();
345
346 let active_model_name = startup_selection
347 .as_ref()
348 .map(|selection| selection.display_name.clone())
349 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
350
351 let active_model_id = startup_selection
352 .as_ref()
353 .and_then(|selection| selection.selected_model.as_ref())
354 .map(|selected| selected.model_id.clone())
355 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().model_id);
356
357 let active_model_file = startup_selection
358 .as_ref()
359 .and_then(|selection| selection.selected_model.as_ref())
360 .map(|selected| selected.gguf_file.clone())
361 .unwrap_or_else(|| {
362 GgufModelConfig::qwen25_3b()
363 .files
364 .first()
365 .cloned()
366 .unwrap_or_default()
367 });
368
369 let mut model = serde_json::Map::new();
370 model.insert(
371 "display_name".to_string(),
372 serde_json::Value::String(active_model_name),
373 );
374 model.insert(
375 "model_id".to_string(),
376 serde_json::Value::String(active_model_id),
377 );
378 model.insert(
379 "gguf_file".to_string(),
380 serde_json::Value::String(active_model_file),
381 );
382
383 let mut sigit = serde_json::Map::new();
384 sigit.insert("active_model".to_string(), serde_json::Value::Object(model));
385
386 let mut meta = Meta::new();
387 meta.insert("sigit".to_string(), serde_json::Value::Object(sigit));
388 meta
389 }
390
391 struct SiGitAgent {
392 engine: Arc<ChatEngine>,
393 /// The active inference backend. `LocalBackend` by default; swapped to an
394 /// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel.
395 backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>,
396 /// cwd from the editor — tool calls run here, not where the process started
397 session_cwd: std::sync::Mutex<Option<PathBuf>>,
398 current_model: std::sync::Mutex<GgufModelConfig>,
399 /// flipped once the startup model finishes (success or failure)
400 model_ready: Arc<AtomicBool>,
401 /// guards the one-time lazy startup load for ACP mode
402 startup_model_load_started: Arc<AtomicBool>,
403 /// set if the startup load failed
404 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
405 /// true when the startup model isn't cached yet
406 startup_needs_download: bool,
407 /// for progress UI
408 startup_model_name: String,
409 /// for download-progress polling
410 startup_model_id: String,
411 /// Serializes turn-affecting handlers (prompt, session lifecycle, config
412 /// changes). They run in `cx.spawn`ed tasks so the JSON-RPC dispatch loop
413 /// stays free to route client responses (e.g. permission answers) mid-turn;
414 /// this lock reproduces the strict ordering the dispatch loop used to give
415 /// them for free.
416 turn_lock: Arc<tokio::sync::Mutex<()>>,
417 }
418
419 impl SiGitAgent {
420 fn new(
421 engine: Arc<ChatEngine>,
422 initial_model: GgufModelConfig,
423 model_ready: Arc<AtomicBool>,
424 startup_model_load_started: Arc<AtomicBool>,
425 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
426 startup_needs_download: bool,
427 ) -> Self {
428 let startup_model_name = initial_model.display_name.clone();
429 let startup_model_id = initial_model.model_id.clone();
430 let backend: Arc<dyn InferenceBackend> = Arc::new(LocalBackend::new(Arc::clone(&engine)));
431 Self {
432 engine,
433 backend: tokio::sync::Mutex::new(backend),
434 session_cwd: std::sync::Mutex::new(None),
435 current_model: std::sync::Mutex::new(initial_model),
436 model_ready,
437 startup_model_load_started,
438 model_load_error,
439 startup_needs_download,
440 startup_model_name,
441 startup_model_id,
442 turn_lock: Arc::new(tokio::sync::Mutex::new(())),
443 }
444 }
445
446 fn start_startup_model_load_if_needed(&self) {
447 if self
448 .startup_model_load_started
449 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
450 .is_err()
451 {
452 return;
453 }
454
455 self.model_ready.store(false, Ordering::Release);
456 if let Ok(mut guard) = self.model_load_error.lock() {
457 *guard = None;
458 }
459
460 let startup_config = self.current_model.lock().unwrap().clone();
461 let (max_tokens, tool_calling) = models::local_picker_items()
462 .into_iter()
463 .find(|item| {
464 item.config.model_id == startup_config.model_id
465 && item
466 .config
467 .files
468 .first()
469 .zip(startup_config.files.first())
470 .map(|(left, right)| left == right)
471 .unwrap_or(false)
472 })
473 .map(|item| (item.max_tokens, item.tool_calling))
474 .unwrap_or((4096, false));
475
476 let sampling = SamplingConfig {
477 max_tokens: Some(max_tokens),
478 ..SamplingConfig::default()
479 };
480
481 let loader_engine = Arc::clone(&self.engine);
482 let loader_system_prompt = system_prompt_for_model(tool_calling).to_string();
483 let model_ready = Arc::clone(&self.model_ready);
484 let model_load_error = Arc::clone(&self.model_load_error);
485
486 std::thread::spawn(move || {
487 let result = tokio::runtime::Runtime::new()
488 .map_err(|error| error.to_string())
489 .and_then(|rt| {
490 rt.block_on(loader_engine.load_gguf_model(
491 startup_config,
492 Some(loader_system_prompt),
493 Some(sampling),
494 ))
495 .map(|_| ())
496 .map_err(|error| error.to_string())
497 });
498
499 if let Ok(mut guard) = model_load_error.lock() {
500 *guard = result.err();
501 }
502 model_ready.store(true, Ordering::Release);
503 });
504 }
505
506 /// block until the startup model is ready, showing progress in the session.
507 async fn await_model_ready(
508 &self,
509 cx: &ConnectionTo<Client>,
510 session_id: &SessionId,
511 ) -> agent_client_protocol::Result<()> {
512 if self.model_ready.load(Ordering::Acquire) {
513 // already done — might be a stored error from earlier
514 if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
515 return Err(agent_client_protocol::Error::new(
516 -32603,
517 format!("model load failed: {err}"),
518 ));
519 }
520 return Ok(());
521 }
522
523 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
524
525 let tool_call_id = format!("startup-load-{}", uuid::Uuid::new_v4());
526 let title = if self.startup_needs_download {
527 format!("Downloading {}", self.startup_model_name)
528 } else {
529 format!("Loading {}", self.startup_model_name)
530 };
531
532 self.send_tool_call_update(
533 cx,
534 session_id.clone(),
535 SessionUpdate::ToolCall(
536 ToolCall::new(tool_call_id.clone(), &title)
537 .kind(ToolKind::Think)
538 .status(ToolCallStatus::InProgress)
539 .content(vec![format!("{}…", title).into()]),
540 ),
541 )
542 .ok();
543
544 let expected_bytes = if self.startup_needs_download {
545 onde::inference::models::SUPPORTED_MODEL_INFO
546 .iter()
547 .find(|m| m.id == self.startup_model_id)
548 .map(|m| m.expected_size_bytes)
549 .unwrap_or(0)
550 } else {
551 0
552 };
553
554 let load_start = std::time::Instant::now();
555 let mut tick: usize = 0;
556 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
557 interval.tick().await;
558
559 loop {
560 interval.tick().await;
561 tick += 1;
562
563 if self.model_ready.load(Ordering::Acquire) {
564 break;
565 }
566
567 let frame = SPINNER[tick % SPINNER.len()];
568 let elapsed = load_start.elapsed();
569 let elapsed_str = if elapsed.as_secs() >= 60 {
570 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
571 } else {
572 format!("{}s", elapsed.as_secs())
573 };
574
575 let (update_title, update_content) =
576 if self.startup_needs_download && expected_bytes > 0 {
577 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
578 let downloaded = cache_path
579 .as_ref()
580 .filter(|p| p.exists())
581 .map(|p| dir_size_recursive(p))
582 .unwrap_or(0);
583 let pct = ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
584 let bar = progress_bar(pct, 20);
585 let size_hint = format!(" (~{})", format_size_human(expected_bytes));
586 (
587 format!(
588 "{frame} Downloading {}{size_hint} ({pct}%)",
589 self.startup_model_name
590 ),
591 format!(
592 "{} — {bar} {pct}% ({} / {})",
593 self.startup_model_name,
594 format_size_human(downloaded),
595 format_size_human(expected_bytes),
596 ),
597 )
598 } else if self.startup_needs_download {
599 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
600 let downloaded = cache_path
601 .as_ref()
602 .filter(|p| p.exists())
603 .map(|p| dir_size_recursive(p))
604 .unwrap_or(0);
605 (
606 format!("{frame} Downloading {}", self.startup_model_name),
607 format!(
608 "{} — {} downloaded… ({elapsed_str})",
609 self.startup_model_name,
610 format_size_human(downloaded),
611 ),
612 )
613 } else {
614 (
615 format!("{frame} Loading {}", self.startup_model_name),
616 format!(
617 "{frame} Loading {}… ({elapsed_str})",
618 self.startup_model_name
619 ),
620 )
621 };
622
623 self.send_tool_call_update(
624 cx,
625 session_id.clone(),
626 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
627 tool_call_id.clone(),
628 ToolCallUpdateFields::new()
629 .title(update_title)
630 .status(ToolCallStatus::InProgress)
631 .content(vec![update_content.into()]),
632 )),
633 )
634 .ok();
635 }
636
637 // done — check if it blew up
638 let load_error = self.model_load_error.lock().unwrap().clone();
639 if let Some(err) = load_error {
640 self.send_tool_call_update(
641 cx,
642 session_id.clone(),
643 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
644 tool_call_id,
645 ToolCallUpdateFields::new()
646 .title("Model load failed".to_string())
647 .status(ToolCallStatus::Failed)
648 .content(vec![format!("error: {err}").into()]),
649 )),
650 )
651 .ok();
652
653 return Err(agent_client_protocol::Error::new(
654 -32603,
655 format!("model load failed: {err}"),
656 ));
657 }
658
659 let done_title = if self.startup_needs_download {
660 format!("✓ {} downloaded and loaded", self.startup_model_name)
661 } else {
662 format!("✓ {} loaded", self.startup_model_name)
663 };
664
665 self.send_tool_call_update(
666 cx,
667 session_id.clone(),
668 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
669 tool_call_id,
670 ToolCallUpdateFields::new()
671 .title(done_title)
672 .status(ToolCallStatus::Completed),
673 )),
674 )
675 .ok();
676
677 Ok(())
678 }
679
680 fn send_assistant_message(
681 &self,
682 cx: &ConnectionTo<Client>,
683 session_id: SessionId,
684 text: impl Into<String>,
685 ) -> agent_client_protocol::Result<()> {
686 cx.send_notification(SessionNotification::new(
687 session_id,
688 SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))),
689 ))
690 }
691
692 /// Run one inference turn (`fut`) while concurrently forwarding any streamed
693 /// tokens to the editor. The sink receiver is drained as the future runs, so
694 /// chunks reach the client live rather than all at once when it resolves.
695 ///
696 /// `assembled`/`sent`/`streamed_any` persist across the turns of a single
697 /// prompt so reasoning is stripped consistently and we never re-send text.
698 #[allow(clippy::too_many_arguments)]
699 async fn drain_turn<F>(
700 &self,
701 cx: &ConnectionTo<Client>,
702 session_id: &SessionId,
703 fut: F,
704 sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
705 assembled: &mut String,
706 sent: &mut String,
707 streamed_any: &mut bool,
708 ) -> Result<TurnResult, backend::BackendError>
709 where
710 F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
711 {
712 tokio::pin!(fut);
713 let result = loop {
714 tokio::select! {
715 done = &mut fut => break done,
716 Some(piece) = sink_rx.recv() => {
717 self.emit_visible_chunk(cx, session_id, &piece, assembled, sent, streamed_any);
718 }
719 }
720 };
721 // Flush tokens that landed between the last poll and the future resolving.
722 while let Ok(piece) = sink_rx.try_recv() {
723 self.emit_visible_chunk(cx, session_id, &piece, assembled, sent, streamed_any);
724 }
725 result
726 }
727
728 /// Append a streamed fragment, strip `<think>` reasoning from the running
729 /// text, and send only the newly revealed visible suffix as a chunk. Tracking
730 /// the assembled text (not just deltas) keeps think-block stripping correct
731 /// even when a tag spans chunk boundaries.
732 fn emit_visible_chunk(
733 &self,
734 cx: &ConnectionTo<Client>,
735 session_id: &SessionId,
736 piece: &str,
737 assembled: &mut String,
738 sent: &mut String,
739 streamed_any: &mut bool,
740 ) {
741 assembled.push_str(piece);
742 let (_think, visible) = chat::strip_think_blocks(assembled);
743 match visible.strip_prefix(sent.as_str()) {
744 Some(extra) if !extra.is_empty() => {
745 let extra = extra.to_string();
746 *sent = visible;
747 *streamed_any = true;
748 self.send_assistant_message(cx, session_id.clone(), extra)
749 .ok();
750 }
751 // No new visible text, or the visible prefix changed retroactively
752 // (rare, e.g. a late-closing think tag): just resync without
753 // resending what's already on the wire.
754 _ => *sent = visible,
755 }
756 }
757
758 fn send_tool_call_update(
759 &self,
760 cx: &ConnectionTo<Client>,
761 session_id: SessionId,
762 update: SessionUpdate,
763 ) -> agent_client_protocol::Result<()> {
764 cx.send_notification(SessionNotification::new(session_id, update))
765 }
766
767 /// Advertise siGit's slash commands to the client. Editors like Zed parse
768 /// `/`-prefixed input and only forward commands they've been told about, so
769 /// without this `/login`, `/models`, etc. are rejected client-side.
770 fn advertise_commands(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
771 let with_hint = |name: &str, desc: &str, hint: &str| {
772 AvailableCommand::new(name, desc).input(AvailableCommandInput::Unstructured(
773 UnstructuredCommandInput::new(hint),
774 ))
775 };
776 let commands = vec![
777 AvailableCommand::new("help", "Show available commands"),
778 AvailableCommand::new("models", "List available models").input(
779 AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(
780 "model number to switch to (optional)",
781 )),
782 ),
783 with_hint(
784 "local",
785 "Toggle on-device inference mode",
786 "on|off (optional)",
787 ),
788 AvailableCommand::new("skills", "List available Agent Skills"),
789 AvailableCommand::new("mcp", "List MCP servers and their tools"),
790 AvailableCommand::new("load", "Load the selected on-device model"),
791 with_hint("login", "Sign in to siGit Code Cloud", "<email> <password>"),
792 AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
793 AvailableCommand::new("whoami", "Show the signed-in account"),
794 AvailableCommand::new("reload", "Re-sync sign-in and model state"),
795 with_hint(
796 "plan",
797 "Plan mode: research only, no edits or commands",
798 "on|off (optional)",
799 ),
800 AvailableCommand::new("permissions", "Show the tool permission policy"),
801 AvailableCommand::new("compact", "Summarize and shrink the conversation history"),
802 AvailableCommand::new("init", "Generate or improve the repo's AGENTS.md"),
803 AvailableCommand::new("clear", "Wipe the conversation history"),
804 AvailableCommand::new("status", "Show engine status"),
805 ];
806 self.send_tool_call_update(
807 cx,
808 session_id,
809 SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
810 )
811 .ok();
812 }
813
814 async fn switch_model_by_id(
815 &self,
816 model_id: &str,
817 ) -> agent_client_protocol::Result<GgufModelConfig> {
818 let (new_config, max_tokens, new_tool_calling) = resolve_model_config(model_id)
819 .ok_or_else(|| {
820 agent_client_protocol::Error::new(
821 -32602,
822 format!("unknown or unavailable model: {model_id}"),
823 )
824 })?;
825
826 log::info!(
827 "switching model to {} (max_tokens={max_tokens})",
828 new_config.display_name
829 );
830
831 let sampling = SamplingConfig {
832 max_tokens: Some(max_tokens),
833 ..SamplingConfig::default()
834 };
835
836 // block_in_place inside spawn_local panics, so run the load on a
837 // dedicated thread with its own runtime (same trick as startup)
838 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
839 let loader_engine = Arc::clone(&self.engine);
840 let loader_config = new_config.clone();
841 let loader_system_prompt = system_prompt_for_model(new_tool_calling).to_string();
842 let loader_sampling = sampling;
843
844 std::thread::spawn(move || {
845 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
846 let result = rt.block_on(async move {
847 // load_gguf_model already unloads the old model internally;
848 // calling unload first would leave a gap where prompts fail
849 loader_engine
850 .load_gguf_model(
851 loader_config,
852 Some(loader_system_prompt),
853 Some(loader_sampling),
854 )
855 .await
856 });
857 let _ = result_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
858 });
859
860 result_rx
861 .await
862 .map_err(|_| agent_client_protocol::Error::new(-32603, "model loader thread crashed"))?
863 .map_err(|error| {
864 log::error!("model switch failed: {error}");
865 agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}"))
866 })?;
867
868 self.startup_model_load_started
869 .store(true, Ordering::Release);
870 self.model_ready.store(true, Ordering::Release);
871 if let Ok(mut guard) = self.model_load_error.lock() {
872 *guard = None;
873 }
874
875 if let Some(item) = models::local_picker_items()
876 .iter()
877 .find(|item| item.config.model_id == new_config.model_id)
878 && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
879 model_id: item.config.model_id.clone(),
880 gguf_file: item.config.files.first().cloned().unwrap_or_default(),
881 })
882 {
883 log::warn!("failed to persist model selection: {err}");
884 }
885
886 {
887 let mut guard = self.current_model.lock().unwrap();
888 *guard = new_config.clone();
889 }
890
891 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
892 self.engine
893 .push_history(onde::inference::ChatMessage::system(
894 session_context_message(&cwd),
895 ))
896 .await;
897 }
898
899 Ok(new_config)
900 }
901 }
902
903 // ── ACP handler implementations ───────────────────────────────────────────────
904
905 impl SiGitAgent {
906 async fn handle_initialize(
907 &self,
908 _req: InitializeRequest,
909 ) -> agent_client_protocol::Result<InitializeResponse> {
910 log::info!("initialize");
911
912 // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors
913 // like Zed advertise terminal-auth capability but don't actually spawn the
914 // login terminal for *custom* ACP agents, so the button is a silent no-op.
915 // With an Agent method, clicking calls `authenticate`, which returns either
916 // confirmation (already signed in via `/login`) or a message telling the
917 // user to run `/login <email> <password>` — so the button does something.
918 let auth_methods = vec![AuthMethod::Agent(
919 AuthMethodAgent::new("sigit", "Sign in to siGit Code")
920 .description("Sign in with `/login <email> <password>` in the message box."),
921 )];
922
923 Ok(InitializeResponse::new(ProtocolVersion::V1)
924 .agent_info(
925 Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
926 .title("siGit Code - AI Coding Agent"),
927 )
928 .auth_methods(auth_methods)
929 .agent_capabilities(
930 AgentCapabilities::default()
931 .load_session(true)
932 .session_capabilities(
933 SessionCapabilities::new().fork(SessionForkCapabilities::new()),
934 ),
935 )
936 .meta(initialize_meta()))
937 }
938
939 async fn handle_authenticate(
940 &self,
941 req: AuthenticateRequest,
942 ) -> agent_client_protocol::Result<AuthenticateResponse> {
943 log::info!("authenticate: method={}", req.method_id.0);
944
945 // Confirm the stored token works. The button can't collect a password,
946 // so an unsigned-in user is pointed at the `/login` slash command; a user
947 // already signed in via `/login` gets the gate cleared.
948 match account::verify_session().await {
949 Ok(email) => {
950 log::info!("authenticate: verified session for {email}");
951 Ok(AuthenticateResponse::default())
952 }
953 Err(reason) => Err(agent_client_protocol::Error::new(
954 -32000,
955 format!(
956 "Not signed in to siGit Code Cloud ({reason}). \
957 Sign in with `/login <email> <password>` in the message box, \
958 or create an account at https://sigit.si."
959 ),
960 )),
961 }
962 }
963
964 async fn handle_load_session(
965 &self,
966 cx: &ConnectionTo<Client>,
967 args: LoadSessionRequest,
968 ) -> agent_client_protocol::Result<LoadSessionResponse> {
969 log::info!(
970 "load_session: id={}, cwd={}, additional_directories={:?}",
971 args.session_id,
972 args.cwd.display(),
973 args.additional_directories
974 .iter()
975 .map(|p| p.display().to_string())
976 .collect::<Vec<_>>()
977 );
978
979 if let Ok(mut guard) = self.session_cwd.lock() {
980 *guard = Some(args.cwd.clone());
981 }
982
983 // A session boundary: grants and plan mode from the previous life of
984 // this session id must not carry over — and since one shared engine
985 // means one live conversation, state for every other id is dead too.
986 permissions::reset_all();
987
988 // tool calls use relative paths, so we need to match the editor's cwd
989 if args.cwd.is_dir()
990 && let Err(err) = std::env::set_current_dir(&args.cwd)
991 {
992 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
993 }
994
995 // start from a clean slate; a stored session (below) replaces it
996 self.engine.clear_history().await;
997
998 self.engine
999 .push_history(onde::inference::ChatMessage::system(
1000 session_context_message(&args.cwd),
1001 ))
1002 .await;
1003
1004 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1005 self.apply_startup_inference_mode().await;
1006
1007 // Durable sessions: when this session id was saved before, restore its
1008 // history into the active backend. The snapshot includes the system
1009 // messages that were live when it was saved, so restore replaces the
1010 // freshly seeded state wholesale.
1011 if let Some(history) = session_store::load(&args.session_id.to_string()) {
1012 let restored = history.len();
1013 let backend = self.backend.lock().await.clone();
1014 backend.restore_history(history).await;
1015 log::info!(
1016 "load_session: restored {restored} message(s) for {}",
1017 args.session_id
1018 );
1019 }
1020
1021 let config_options = {
1022 let guard = self.current_model.lock().unwrap();
1023 build_model_config_options(&guard)
1024 };
1025
1026 self.advertise_commands(cx, args.session_id.clone());
1027
1028 // Run SessionStart hooks
1029 let hook_settings = settings::load().hooks;
1030 if hook_settings.has_hooks() {
1031 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &args.session_id.to_string());
1032 }
1033
1034 Ok(LoadSessionResponse::new().config_options(config_options))
1035 }
1036
1037 async fn handle_fork_session(
1038 &self,
1039 cx: &ConnectionTo<Client>,
1040 args: ForkSessionRequest,
1041 ) -> agent_client_protocol::Result<ForkSessionResponse> {
1042 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1043 // Session boundary: permission grants and plan mode never cross it
1044 // (see handle_load_session), so a fork starts with a clean slate.
1045 permissions::reset_all();
1046 log::info!(
1047 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
1048 args.session_id,
1049 args.cwd.display(),
1050 args.additional_directories
1051 .iter()
1052 .map(|p| p.display().to_string())
1053 .collect::<Vec<_>>()
1054 );
1055
1056 if let Ok(mut guard) = self.session_cwd.lock() {
1057 *guard = Some(args.cwd.clone());
1058 }
1059 if args.cwd.is_dir()
1060 && let Err(err) = std::env::set_current_dir(&args.cwd)
1061 {
1062 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1063 }
1064
1065 // no persistence, so fork == fresh session
1066 self.engine.clear_history().await;
1067
1068 self.engine
1069 .push_history(onde::inference::ChatMessage::system(
1070 session_context_message(&args.cwd),
1071 ))
1072 .await;
1073
1074 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1075 self.apply_startup_inference_mode().await;
1076
1077 let config_options = {
1078 let guard = self.current_model.lock().unwrap();
1079 build_model_config_options(&guard)
1080 };
1081
1082 self.advertise_commands(cx, new_id.clone());
1083
1084 // Run SessionStart hooks
1085 let hook_settings = settings::load().hooks;
1086 if hook_settings.has_hooks() {
1087 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &new_id.to_string());
1088 }
1089
1090 Ok(ForkSessionResponse::new(new_id).config_options(config_options))
1091 }
1092
1093 async fn handle_new_session(
1094 &self,
1095 cx: &ConnectionTo<Client>,
1096 args: NewSessionRequest,
1097 ) -> agent_client_protocol::Result<NewSessionResponse> {
1098 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1099 // Session boundary: permission grants and plan mode never cross it
1100 // (see handle_load_session), so stale ids stop accumulating state.
1101 permissions::reset_all();
1102 log::info!(
1103 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
1104 args.cwd.display(),
1105 args.additional_directories
1106 .iter()
1107 .map(|p| p.display().to_string())
1108 .collect::<Vec<_>>()
1109 );
1110
1111 if let Ok(mut guard) = self.session_cwd.lock() {
1112 *guard = Some(args.cwd.clone());
1113 }
1114 if args.cwd.is_dir()
1115 && let Err(err) = std::env::set_current_dir(&args.cwd)
1116 {
1117 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
1118 }
1119
1120 self.engine.clear_history().await;
1121
1122 self.engine
1123 .push_history(onde::inference::ChatMessage::system(
1124 session_context_message(&args.cwd),
1125 ))
1126 .await;
1127
1128 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1129 self.apply_startup_inference_mode().await;
1130
1131 let config_options = {
1132 let guard = self.current_model.lock().unwrap();
1133 build_model_config_options(&guard)
1134 };
1135
1136 self.advertise_commands(cx, session_id.clone());
1137
1138 // Run SessionStart hooks
1139 let hook_settings = settings::load().hooks;
1140 if hook_settings.has_hooks() {
1141 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &session_id.to_string());
1142 }
1143
1144 Ok(NewSessionResponse::new(session_id).config_options(config_options))
1145 }
1146
1147 async fn handle_prompt(
1148 &self,
1149 cx: &ConnectionTo<Client>,
1150 args: PromptRequest,
1151 ) -> agent_client_protocol::Result<PromptResponse> {
1152 let session_id = args.session_id.clone();
1153
1154 // log every block so we can debug @ references and file context
1155 for (i, block) in args.prompt.iter().enumerate() {
1156 match block {
1157 ContentBlock::Text(t) => {
1158 log::info!(
1159 "prompt({}) block[{}]: Text({} chars) = \"{}\"",
1160 session_id,
1161 i,
1162 t.text.len(),
1163 t.text.chars().take(200).collect::<String>()
1164 );
1165 }
1166 ContentBlock::Resource(embedded) => {
1167 log::info!(
1168 "prompt({}) block[{}]: EmbeddedResource = {:?}",
1169 session_id,
1170 i,
1171 match &embedded.resource {
1172 EmbeddedResourceResource::TextResourceContents(t) =>
1173 format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
1174 EmbeddedResourceResource::BlobResourceContents(b) =>
1175 format!("BlobResource(uri={})", b.uri),
1176 _ => "Unknown".to_string(),
1177 }
1178 );
1179 }
1180 ContentBlock::ResourceLink(link) => {
1181 log::info!(
1182 "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
1183 session_id,
1184 i,
1185 link.name,
1186 link.uri,
1187 link.title,
1188 link.description
1189 );
1190 }
1191 other => {
1192 log::info!(
1193 "prompt({}) block[{}]: Other({:?})",
1194 session_id,
1195 i,
1196 std::mem::discriminant(other)
1197 );
1198 }
1199 }
1200 }
1201
1202 let mut parts: Vec<String> = Vec::new();
1203
1204 for block in &args.prompt {
1205 match block {
1206 ContentBlock::Text(t) => {
1207 parts.push(t.text.clone());
1208 }
1209 ContentBlock::Resource(embedded) => {
1210 // editor inlined the file content already
1211 match &embedded.resource {
1212 EmbeddedResourceResource::TextResourceContents(text_resource) => {
1213 parts.push(format!(
1214 "\n--- {} ---\n{}\n--- end {} ---",
1215 text_resource.uri, text_resource.text, text_resource.uri
1216 ));
1217 }
1218 EmbeddedResourceResource::BlobResourceContents(blob) => {
1219 parts.push(format!("[binary resource: {}]", blob.uri));
1220 }
1221 _ => {
1222 log::debug!("ignoring unsupported embedded resource variant");
1223 }
1224 }
1225 }
1226 ContentBlock::ResourceLink(link) => {
1227 // reference without content; read the file ourselves
1228 let label = link.name.clone();
1229
1230 if let Some(raw_path) = link.uri.strip_prefix("file://") {
1231 let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') {
1232 let fragment = &raw_path[hash_pos + 1..];
1233 let path = &raw_path[..hash_pos];
1234 // Parse "L207:219" or "L207-219" → (207, 219)
1235 let range = fragment.strip_prefix('L').and_then(|rest| {
1236 let sep = if rest.contains(':') { ':' } else { '-' };
1237 let mut parts = rest.splitn(2, sep);
1238 let start = parts.next()?.parse::<usize>().ok()?;
1239 let end = parts.next()?.parse::<usize>().ok()?;
1240 Some((start, end))
1241 });
1242 (path, range)
1243 } else {
1244 (raw_path, None)
1245 };
1246
1247 match std::fs::read_to_string(file_path) {
1248 Ok(contents) => {
1249 let extracted = if let Some((start, end)) = line_range {
1250 let selected: Vec<&str> = contents
1251 .lines()
1252 .enumerate()
1253 .filter(|(i, _)| {
1254 let line_num = i + 1;
1255 line_num >= start && line_num <= end
1256 })
1257 .map(|(_, line)| line)
1258 .collect();
1259 format!(
1260 "\n--- {label} ({file_path} lines {start}-{end}) ---\n{}\n--- end {label} ---",
1261 selected.join("\n")
1262 )
1263 } else {
1264 format!(
1265 "\n--- {label} ({file_path}) ---\n{contents}\n--- end {label} ---"
1266 )
1267 };
1268 parts.push(extracted);
1269 }
1270 Err(err) => {
1271 log::warn!("could not read ResourceLink {}: {err}", link.uri);
1272 parts.push(format!("[referenced file: {label} ({file_path})]"));
1273 }
1274 }
1275 } else {
1276 parts.push(format!("[resource link: {label} ({})]", link.uri));
1277 }
1278 }
1279 _ => {
1280 log::debug!("ignoring unsupported content block type in prompt");
1281 }
1282 }
1283 }
1284
1285 let user_text = parts.join("\n");
1286
1287 if user_text.trim().is_empty() {
1288 return Ok(PromptResponse::new(StopReason::EndTurn));
1289 }
1290
1291 let user_text = match parse_slash(&user_text) {
1292 // `/init` is not a status command: it substitutes the canned
1293 // AGENTS.md-generation prompt and runs a normal agent turn, so the
1294 // exploration and file write go through the ordinary tools and
1295 // permission checks.
1296 Some(SlashCommand::Init) => instructions::INIT_PROMPT.to_string(),
1297 Some(command) => return exec_slash_acp(self, cx, session_id, command).await,
1298 None => user_text,
1299 };
1300
1301 log::info!(
1302 "prompt({}): \"{}\"",
1303 session_id,
1304 user_text.chars().take(80).collect::<String>()
1305 );
1306
1307 // The active backend drives the turn. Snapshot it once so a mid-turn
1308 // model switch doesn't split the conversation across backends.
1309 let backend = self.backend.lock().await.clone();
1310
1311 // Only on-device inference needs a local model in memory. Cloud tiers run
1312 // over the network, so they never need a local model. We never load the
1313 // on-device model implicitly: the user loads it explicitly with `/load`
1314 // (or by picking one in `/models`). If a prompt arrives before that, guide
1315 // them rather than blocking on a multi-minute download/load.
1316 if !backend.is_remote()
1317 && self.engine.info().await.status == onde::inference::EngineStatus::Unloaded
1318 {
1319 self.send_assistant_message(
1320 cx,
1321 session_id,
1322 "No on-device model is loaded. Run `/load` to load the selected model, \
1323 or `/models` to choose one.",
1324 )
1325 .ok();
1326 return Ok(PromptResponse::new(StopReason::EndTurn));
1327 }
1328
1329 // ── tool-calling loop ────────────────────────────────────────────
1330 // send message → execute any tool calls → feed results back
1331 // repeat up to MAX_TOOL_ROUNDS, then force a text reply
1332
1333 let tools = agent_tools_as_specs();
1334
1335 // Token sink: backends stream assistant text through this while a turn
1336 // runs. We forward the visible portion to the editor as agent-message
1337 // chunks live (see `drain_turn` / `emit_visible_chunk`). The sink stays
1338 // alive for the whole prompt so `recv()` only ends when a turn future
1339 // resolves, never because every sender was dropped.
1340 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
1341 let mut assembled = String::new();
1342 let mut sent = String::new();
1343 let mut streamed_any = false;
1344
1345 let mut result = self
1346 .drain_turn(
1347 cx,
1348 &session_id,
1349 backend.send_message_with_tools(&user_text, &tools, Some(&sink)),
1350 &mut sink_rx,
1351 &mut assembled,
1352 &mut sent,
1353 &mut streamed_any,
1354 )
1355 .await
1356 .map_err(|error| {
1357 log::error!("send_message_with_tools failed: {error}");
1358 agent_client_protocol::Error::new(-32603, format!("inference failed: {error}"))
1359 })?;
1360
1361 let mut round = 0;
1362
1363 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1364 round += 1;
1365 log::info!(
1366 "prompt({}) tool round {} — {} call(s)",
1367 session_id,
1368 round,
1369 result.tool_calls.len()
1370 );
1371
1372 // Auto-compaction: long tool runs grow history fast; fold it into
1373 // a summary before the next round rather than blowing the window.
1374 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
1375 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
1376 log::info!(
1377 "prompt({}) history ≈{} tokens exceeds budget {} — compacting",
1378 session_id,
1379 estimate,
1380 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
1381 );
1382 match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
1383 Ok(()) => {
1384 let after = backend::estimate_tokens(&backend.history_snapshot().await);
1385 log::info!("prompt({}) compacted to ≈{} tokens", session_id, after);
1386 }
1387 Err(error) => log::warn!("prompt({}) compaction failed: {error}", session_id),
1388 }
1389 }
1390
1391 let mut tool_results = Vec::new();
1392
1393 for (call_index, tc) in result.tool_calls.iter().enumerate() {
1394 log::info!(
1395 " → {}({})",
1396 tc.name,
1397 tc.arguments.chars().take(120).collect::<String>()
1398 );
1399
1400 // Permission gate: read-only tools pass straight through; a
1401 // mutating tool consults policy and may ask the client.
1402 let output = match permissions::decision_for(
1403 &session_id.to_string(),
1404 &tc.name,
1405 &tc.arguments,
1406 ) {
1407 permissions::Decision::Allow => {
1408 tools::execute_tool(&tc.name, &tc.arguments).await
1409 }
1410 permissions::Decision::Deny(reason) => {
1411 log::info!(" ✗ {} denied by policy", tc.name);
1412 reason
1413 }
1414 permissions::Decision::Ask => {
1415 match self
1416 .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1417 .await
1418 {
1419 PermissionVerdict::Approved => {
1420 tools::execute_tool(&tc.name, &tc.arguments).await
1421 }
1422 PermissionVerdict::Denied(reason) => {
1423 log::info!(" ✗ {} denied by user", tc.name);
1424 reason
1425 }
1426 PermissionVerdict::TurnCancelled => {
1427 log::info!("prompt({}) cancelled at permission gate", session_id);
1428 // The assistant message carrying these tool
1429 // calls is already in the backend history;
1430 // leaving any of them unanswered makes strict
1431 // OpenAI-compatible endpoints reject every
1432 // later request in the session. Close out this
1433 // call and the ones this round never reached.
1434 for pending in &result.tool_calls[call_index..] {
1435 tool_results.push(BackendToolResult {
1436 tool_call_id: pending.id.clone(),
1437 content: format!(
1438 "`{}` was not executed: the user cancelled the turn \
1439 at the permission prompt.",
1440 pending.name
1441 ),
1442 });
1443 }
1444 backend.record_cancelled_tool_results(tool_results).await;
1445 return Ok(PromptResponse::new(StopReason::Cancelled));
1446 }
1447 }
1448 }
1449 };
1450
1451 log::info!(" ← {} chars", output.len());
1452
1453 tool_results.push(BackendToolResult {
1454 tool_call_id: tc.id.clone(),
1455 content: output,
1456 });
1457 }
1458
1459 let next_tools = if round < MAX_TOOL_ROUNDS {
1460 Some(tools.as_slice())
1461 } else {
1462 None // last round: force text
1463 };
1464
1465 result = self
1466 .drain_turn(
1467 cx,
1468 &session_id,
1469 backend.send_tool_results(tool_results, next_tools, Some(&sink)),
1470 &mut sink_rx,
1471 &mut assembled,
1472 &mut sent,
1473 &mut streamed_any,
1474 )
1475 .await
1476 .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
1477 }
1478
1479 // ── Final text response ───────────────────────────────────────────
1480 // If anything streamed, the visible reply is already on the wire; only
1481 // send a trailing block for the non-streamed path (e.g. on-device direct
1482 // answers, which onde can't stream while tools are on offer).
1483 if !streamed_any {
1484 let reply_text = result.text.trim().to_string();
1485 let final_text = if reply_text.is_empty() {
1486 if round > 0 {
1487 log::warn!(
1488 "prompt({}) — model returned empty reply after {} tool round(s)",
1489 session_id,
1490 round
1491 );
1492 "Something went wrong — the edits didn't go through. Try rephrasing what you need, or point me at the specific lines.".to_string()
1493 } else {
1494 log::warn!(
1495 "prompt({}) — model returned empty reply (no tool rounds)",
1496 session_id
1497 );
1498 String::new()
1499 }
1500 } else {
1501 // strip <think> blocks so reasoning tokens stay hidden
1502 let (_think, visible) = chat::strip_think_blocks(&reply_text);
1503 visible
1504 };
1505
1506 if !final_text.is_empty() {
1507 self.send_assistant_message(cx, session_id.clone(), final_text)
1508 .ok();
1509 }
1510 }
1511
1512 // Persist the completed turn so a restart (or session/load) can pick
1513 // the conversation back up.
1514 let snapshot = backend.history_snapshot().await;
1515 if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) {
1516 log::warn!("prompt({}) session save failed: {error}", session_id);
1517 }
1518
1519 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
1520 Ok(PromptResponse::new(StopReason::EndTurn))
1521 }
1522
1523 /// Ask the ACP client for permission to run one tool call. Presents
1524 /// allow-once / allow-for-session / deny; an "always allow" choice is
1525 /// recorded via [`permissions::grant_for_session`]. Only safe to call from
1526 /// a spawned task (see the handler registration in `run_acp_server`): the
1527 /// dispatch loop must be free to route the client's answer back to us.
1528 async fn request_tool_permission(
1529 &self,
1530 cx: &ConnectionTo<Client>,
1531 session_id: &SessionId,
1532 tool_name: &str,
1533 arguments: &str,
1534 ) -> PermissionVerdict {
1535 // The user decides from this dialog, so show the arguments with any
1536 // truncation flagged (a silently clipped command could hide its tail
1537 // from the person approving it). The full arguments also travel as
1538 // `raw_input` for clients that render it.
1539 let args_preview = permissions::approval_preview(arguments);
1540 let title = if args_preview.is_empty() {
1541 tool_name.to_string()
1542 } else {
1543 format!("{tool_name}({args_preview})")
1544 };
1545 let raw_input: serde_json::Value = serde_json::from_str(arguments)
1546 .unwrap_or_else(|_| serde_json::Value::String(arguments.to_string()));
1547
1548 let request = RequestPermissionRequest::new(
1549 session_id.clone(),
1550 ToolCallUpdate::new(
1551 format!("perm-{}", uuid::Uuid::new_v4()),
1552 ToolCallUpdateFields::new()
1553 .title(title)
1554 .kind(tool_kind_for(tool_name))
1555 .status(ToolCallStatus::Pending)
1556 .raw_input(raw_input),
1557 ),
1558 vec![
1559 PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
1560 PermissionOption::new(
1561 "allow_session",
1562 "Allow for this session",
1563 PermissionOptionKind::AllowAlways,
1564 ),
1565 PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
1566 ],
1567 );
1568
1569 match cx.send_request(request).block_task().await {
1570 Ok(response) => match response.outcome {
1571 RequestPermissionOutcome::Selected(selected) => {
1572 match selected.option_id.0.as_ref() {
1573 "allow_once" => PermissionVerdict::Approved,
1574 "allow_session" => {
1575 permissions::grant_for_session(
1576 &session_id.to_string(),
1577 tool_name,
1578 arguments,
1579 );
1580 PermissionVerdict::Approved
1581 }
1582 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
1583 }
1584 }
1585 RequestPermissionOutcome::Cancelled => PermissionVerdict::TurnCancelled,
1586 // The outcome enum is non_exhaustive; treat anything unknown as
1587 // a denial rather than running a mutating tool unapproved.
1588 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
1589 },
1590 Err(error) => {
1591 log::warn!("permission request for `{tool_name}` failed: {error}");
1592 PermissionVerdict::Denied(format!(
1593 "`{tool_name}` was not executed: this client could not answer the \
1594 permission request ({error}). The user can pre-approve tools in \
1595 settings.toml under [permissions], or set SIGIT_PERMISSIONS=allow \
1596 for clients without permission support."
1597 ))
1598 }
1599 }
1600 }
1601
1602 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
1603 log::info!("cancel requested for session {}", args.session_id);
1604 Ok(())
1605 }
1606
1607 /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
1608 /// current model so the picker shows it selected. Returns the tier's display
1609 /// name on success, or `None` when no account is signed in (caller prompts
1610 /// for login). Shared by the panel picker and the `/models` slash command.
1611 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
1612 let cfg = crate::provider::cloud_tier_provider(tier)?;
1613 let mut system_prompt = system_prompt_for_model(true).to_string();
1614 // Mirror the cwd guidance and project instruction files the local engine
1615 // gets at session load, so the cloud model shares the same project context.
1616 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1617 system_prompt.push_str("\n\n");
1618 system_prompt.push_str(&session_context_message(&cwd));
1619 }
1620 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
1621 cfg.base_url,
1622 cfg.api_key,
1623 cfg.model,
1624 Some(system_prompt),
1625 ));
1626 *self.backend.lock().await = cloud_backend;
1627
1628 let cloud_config = GgufModelConfig {
1629 model_id: format!("sigit-cloud:{tier}"),
1630 files: Vec::new(),
1631 tok_model_id: None,
1632 display_name: cfg.display_name.clone(),
1633 approx_memory: "Cloud".to_string(),
1634 chat_template: None,
1635 };
1636 {
1637 let mut guard = self.current_model.lock().unwrap();
1638 *guard = cloud_config;
1639 }
1640
1641 // Explicitly choosing a cloud tier puts us in cloud mode.
1642 let _ = settings::set_local_inference(false);
1643
1644 log::info!("switched to cloud tier {tier}");
1645 Some(cfg.display_name)
1646 }
1647
1648 /// Apply the persisted Local Inference mode at session start. When local
1649 /// inference is off and an account is signed in, route to a cloud tier so the
1650 /// on-device model is never loaded; otherwise leave the on-device backend in
1651 /// place. Call after the session cwd is set so the cloud system prompt picks
1652 /// it up. Does not flip the stored setting on the not-signed-in fallback.
1653 async fn apply_startup_inference_mode(&self) {
1654 if settings::local_inference_enabled() {
1655 return;
1656 }
1657 if self.switch_to_cloud_tier("balanced").await.is_some() {
1658 log::info!("startup: local inference off; routing inference to siGit Code Cloud");
1659 } else {
1660 log::warn!(
1661 "local inference is off but no account is signed in; staying on-device. \
1662 Run /login or set Local Inference on."
1663 );
1664 }
1665 }
1666
1667 /// Route inference back on-device. Used after leaving a cloud tier for a
1668 /// local model. The `LocalBackend` reads the live `engine`, so this just
1669 /// repoints the active backend.
1670 async fn reset_to_local_backend(&self) {
1671 let local_backend: Arc<dyn InferenceBackend> =
1672 Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
1673 *self.backend.lock().await = local_backend;
1674 }
1675
1676 /// Re-attempt the lazy startup model load if the previous attempt failed.
1677 /// Clears the one-shot guard so the next load runs; a healthy load is left
1678 /// untouched so `/reload` doesn't needlessly reload a working model.
1679 fn retry_startup_model_load_if_failed(&self) {
1680 let had_error = self
1681 .model_load_error
1682 .lock()
1683 .map(|guard| guard.is_some())
1684 .unwrap_or(false);
1685 if had_error {
1686 self.startup_model_load_started
1687 .store(false, Ordering::Release);
1688 self.start_startup_model_load_if_needed();
1689 }
1690 }
1691
1692 /// Re-sync session state in place — no new session needed. Re-applies the
1693 /// active backend from current credentials (so a fresh `/login` token is
1694 /// picked up), retries a failed model load, and pushes refreshed commands +
1695 /// picker so the editor's UI reflects the current state.
1696 async fn handle_reload(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
1697 let signed_in = account::status_line().await;
1698
1699 let on_cloud_tier = {
1700 let guard = self.current_model.lock().unwrap();
1701 guard
1702 .model_id
1703 .strip_prefix("sigit-cloud:")
1704 .map(str::to_string)
1705 };
1706
1707 let backend_note = match on_cloud_tier {
1708 Some(tier) => match self.switch_to_cloud_tier(&tier).await {
1709 Some(name) => format!("Active: {name}."),
1710 None => {
1711 self.reset_to_local_backend().await;
1712 "Signed out — back to on-device. Pick a model with /models.".to_string()
1713 }
1714 },
1715 None => {
1716 self.reset_to_local_backend().await;
1717 self.retry_startup_model_load_if_failed();
1718 let guard = self.current_model.lock().unwrap();
1719 format!("Active: {}.", guard.display_name)
1720 }
1721 };
1722
1723 // Push refreshed picker + commands so the editor reflects current state.
1724 let config_options = {
1725 let guard = self.current_model.lock().unwrap();
1726 build_model_config_options(&guard)
1727 };
1728 self.send_tool_call_update(
1729 cx,
1730 session_id.clone(),
1731 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
1732 )
1733 .ok();
1734 self.advertise_commands(cx, session_id.clone());
1735
1736 self.send_assistant_message(
1737 cx,
1738 session_id,
1739 format!("Reloaded. {signed_in} {backend_note}"),
1740 )
1741 .ok();
1742 }
1743
1744 async fn handle_set_session_config_option(
1745 &self,
1746 cx: &ConnectionTo<Client>,
1747 args: SetSessionConfigOptionRequest,
1748 ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
1749 log::info!(
1750 "set_session_config_option: config_id={}, value={:?}",
1751 args.config_id,
1752 args.value
1753 );
1754
1755 // ── Local Inference toggle ──────────────────────────────────────────
1756 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
1757 let enabled = match args.value.0.as_ref() {
1758 LOCAL_INFERENCE_ON => true,
1759 LOCAL_INFERENCE_OFF => false,
1760 other => {
1761 return Err(agent_client_protocol::Error::new(
1762 -32602,
1763 format!("unknown Local Inference value: {other}"),
1764 ));
1765 }
1766 };
1767 if let Err(error) = settings::set_local_inference(enabled) {
1768 return Err(agent_client_protocol::Error::new(
1769 -32603,
1770 format!("could not save Local Inference setting: {error}"),
1771 ));
1772 }
1773 let message = if enabled {
1774 "Local inference is on. On-device models are highlighted; pick one from Model."
1775 } else {
1776 "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
1777 };
1778 self.send_assistant_message(cx, args.session_id.clone(), format!("\n\n{message}"))
1779 .ok();
1780 // Rebuild so the Model picker reflects the new emphasis/order.
1781 let current = self.current_model.lock().unwrap().clone();
1782 let config_options = build_model_config_options(&current);
1783 return Ok(SetSessionConfigOptionResponse::new(config_options));
1784 }
1785
1786 if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
1787 return Err(agent_client_protocol::Error::new(
1788 -32602,
1789 format!("unknown config option: {}", args.config_id.0),
1790 ));
1791 }
1792
1793 let model_id = args.value.0.as_ref();
1794
1795 // can't switch while the startup model is still loading — the old
1796 // weights are in GPU memory and the new load gets "does not fit"
1797 if self.startup_model_load_started.load(Ordering::Acquire)
1798 && !self.model_ready.load(Ordering::Acquire)
1799 {
1800 log::info!("set_session_config_option: waiting for startup model to finish loading");
1801 while !self.model_ready.load(Ordering::Acquire) {
1802 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1803 }
1804 }
1805
1806 // Zed re-fires the last selection when a thread opens. That re-fire must
1807 // not load anything: on-device models are loaded only on an explicit
1808 // request (`/load`, or actively picking a *different* model below), so a
1809 // re-fire of the already-current selection is a no-op. Otherwise opening a
1810 // new thread would silently load the local model — exactly what we avoid.
1811 {
1812 let current = self.current_model.lock().unwrap();
1813 if current.model_id == model_id {
1814 log::info!(
1815 "set_session_config_option: {} is already the active selection, skipping",
1816 current.display_name
1817 );
1818 let config_options = build_model_config_options(&current);
1819 return Ok(SetSessionConfigOptionResponse::new(config_options));
1820 }
1821 }
1822
1823 // ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
1824 if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
1825 let message = match self.switch_to_cloud_tier(tier).await {
1826 Some(display_name) => format!("Switched to {display_name}."),
1827 None => CLOUD_LOGIN_PROMPT.to_string(),
1828 };
1829 // Start on a fresh line: ACP clients concatenate consecutive
1830 // agent-message chunks into one block, so without this the switch
1831 // confirmation runs onto the end of the previous assistant message.
1832 self.send_assistant_message(cx, args.session_id.clone(), format!("\n\n{message}"))
1833 .ok();
1834
1835 let current = self.current_model.lock().unwrap().clone();
1836 let config_options = build_model_config_options(&current);
1837 return Ok(SetSessionConfigOptionResponse::new(config_options));
1838 }
1839
1840 let needs_download = models::local_picker_items()
1841 .into_iter()
1842 .find(|item| item.config.model_id == model_id)
1843 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
1844 .unwrap_or(false);
1845
1846 // tells the progress poller to stop
1847 let stop_flag = Arc::new(AtomicBool::new(false));
1848
1849 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
1850
1851 if needs_download {
1852 let model_id_owned = model_id.to_string();
1853 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
1854 .iter()
1855 .find(|m| m.id == model_id_owned)
1856 .map(|m| m.expected_size_bytes)
1857 .unwrap_or(0);
1858
1859 let display_name = models::local_picker_items()
1860 .into_iter()
1861 .find(|item| item.config.model_id == model_id_owned)
1862 .map(|item| item.display_name.clone())
1863 .unwrap_or_else(|| model_id_owned.clone());
1864
1865 let size_hint = if expected_bytes > 0 {
1866 format!(" (~{})", format_size_human(expected_bytes))
1867 } else {
1868 String::new()
1869 };
1870
1871 self.send_tool_call_update(
1872 cx,
1873 args.session_id.clone(),
1874 SessionUpdate::ToolCall(
1875 ToolCall::new(
1876 tool_call_id.clone(),
1877 format!("⏬ Downloading {display_name}{size_hint}"),
1878 )
1879 .kind(ToolKind::Think)
1880 .status(ToolCallStatus::InProgress)
1881 .content(vec![
1882 format!(
1883 "Preparing download for {display_name}. This may take a few minutes."
1884 )
1885 .into(),
1886 ]),
1887 ),
1888 )
1889 .ok();
1890
1891 // poll download progress and update the spinner in Zed
1892 let cx_for_poller = cx.clone();
1893 let poller_session = args.session_id.clone();
1894 let poller_model_id = model_id_owned.clone();
1895 let poller_stop = Arc::clone(&stop_flag);
1896 let poller_tool_call_id = tool_call_id.clone();
1897
1898 cx.spawn(async move {
1899 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1900 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
1901 let mut tick: usize = 0;
1902 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
1903 interval.tick().await; // consume the immediate first tick
1904
1905 while !poller_stop.load(Ordering::Relaxed) {
1906 interval.tick().await;
1907
1908 if poller_stop.load(Ordering::Relaxed) {
1909 break;
1910 }
1911
1912 let downloaded = cache_path
1913 .as_ref()
1914 .filter(|p| p.exists())
1915 .map(|p| dir_size_recursive(p))
1916 .unwrap_or(0);
1917
1918 let frame = SPINNER[tick % SPINNER.len()];
1919 tick += 1;
1920
1921 let title = if expected_bytes > 0 {
1922 let pct =
1923 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1924 format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
1925 } else {
1926 format!("{frame} Downloading {display_name}{size_hint}")
1927 };
1928
1929 let msg = if expected_bytes > 0 {
1930 let pct =
1931 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1932 let bar = progress_bar(pct, 20);
1933 format!(
1934 "{display_name} — {bar} {pct}% ({} / {})",
1935 format_size_human(downloaded),
1936 format_size_human(expected_bytes),
1937 )
1938 } else {
1939 format!(
1940 "{display_name} — {} downloaded…",
1941 format_size_human(downloaded)
1942 )
1943 };
1944
1945 let notification = SessionNotification::new(
1946 poller_session.clone(),
1947 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1948 poller_tool_call_id.clone(),
1949 ToolCallUpdateFields::new()
1950 .title(title)
1951 .status(ToolCallStatus::InProgress)
1952 .content(vec![msg.into()]),
1953 )),
1954 );
1955 if cx_for_poller.send_notification(notification).is_err() {
1956 break;
1957 }
1958 }
1959 Ok(())
1960 })
1961 .ok();
1962 }
1963
1964 // cached models still take 10-30s to load weights; show a spinner
1965 if !needs_download {
1966 let cached_display_name = models::local_picker_items()
1967 .into_iter()
1968 .find(|item| item.config.model_id == model_id)
1969 .map(|item| item.display_name.clone())
1970 .unwrap_or_else(|| model_id.to_string());
1971
1972 self.send_tool_call_update(
1973 cx,
1974 args.session_id.clone(),
1975 SessionUpdate::ToolCall(
1976 ToolCall::new(
1977 tool_call_id.clone(),
1978 format!("Loading {cached_display_name}"),
1979 )
1980 .kind(ToolKind::Think)
1981 .status(ToolCallStatus::InProgress)
1982 .content(vec![format!("Loading {cached_display_name}…").into()]),
1983 ),
1984 )
1985 .ok();
1986
1987 // tick every 5s so the user knows we haven't frozen
1988 let cx_for_spinner = cx.clone();
1989 let spinner_session = args.session_id.clone();
1990 let spinner_name = cached_display_name.clone();
1991 let spinner_stop = Arc::clone(&stop_flag);
1992 let spinner_tool_call_id = tool_call_id.clone();
1993 let load_start = std::time::Instant::now();
1994
1995 cx.spawn(async move {
1996 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1997 let mut tick: usize = 0;
1998 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
1999 interval.tick().await; // consume the immediate first tick
2000
2001 while !spinner_stop.load(Ordering::Relaxed) {
2002 interval.tick().await;
2003
2004 if spinner_stop.load(Ordering::Relaxed) {
2005 break;
2006 }
2007
2008 let elapsed = load_start.elapsed();
2009 let elapsed_str = if elapsed.as_secs() >= 60 {
2010 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
2011 } else {
2012 format!("{}s", elapsed.as_secs())
2013 };
2014 let frame = SPINNER[tick % SPINNER.len()];
2015 tick += 1;
2016
2017 let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
2018 let notification = SessionNotification::new(
2019 spinner_session.clone(),
2020 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2021 spinner_tool_call_id.clone(),
2022 ToolCallUpdateFields::new()
2023 .status(ToolCallStatus::InProgress)
2024 .content(vec![msg.into()]),
2025 )),
2026 );
2027 if cx_for_spinner.send_notification(notification).is_err() {
2028 break;
2029 }
2030 }
2031 Ok(())
2032 })
2033 .ok();
2034 }
2035
2036 let switch_result = self.switch_model_by_id(model_id).await;
2037
2038 stop_flag.store(true, Ordering::Relaxed);
2039
2040 match switch_result {
2041 Ok(new_config) => {
2042 // Route inference back on-device (in case we were on a cloud tier).
2043 self.reset_to_local_backend().await;
2044 // Selecting an on-device model puts us in local mode.
2045 let _ = settings::set_local_inference(true);
2046
2047 let completion_title = if needs_download {
2048 format!("✓ {} downloaded and loaded", new_config.display_name)
2049 } else {
2050 format!("✓ Switched to {}", new_config.display_name)
2051 };
2052 let completion_body = if needs_download {
2053 format!("✓ {} downloaded and loaded.", new_config.display_name)
2054 } else {
2055 format!("✓ Switched to {}.", new_config.display_name)
2056 };
2057
2058 self.send_tool_call_update(
2059 cx,
2060 args.session_id.clone(),
2061 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2062 tool_call_id,
2063 ToolCallUpdateFields::new()
2064 .title(completion_title)
2065 .status(ToolCallStatus::Completed)
2066 .content(vec![completion_body.into()]),
2067 )),
2068 )
2069 .ok();
2070
2071 let config_options = {
2072 let guard = self.current_model.lock().unwrap();
2073 build_model_config_options(&guard)
2074 };
2075
2076 log::info!("model switch complete");
2077 Ok(SetSessionConfigOptionResponse::new(config_options))
2078 }
2079 Err(err) => {
2080 self.send_tool_call_update(
2081 cx,
2082 args.session_id.clone(),
2083 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2084 tool_call_id,
2085 ToolCallUpdateFields::new()
2086 .title("Model switch failed".to_string())
2087 .status(ToolCallStatus::Failed)
2088 .content(vec![format!("error loading model: {}", err.message).into()]),
2089 )),
2090 )
2091 .ok();
2092
2093 Err(err)
2094 }
2095 }
2096 }
2097 }
2098
2099 // ── Config option helpers ─────────────────────────────────────────────────────
2100
2101 /// config option ID for the model picker in Zed's agent panel
2102 const MODEL_CONFIG_ID: &str = "sigit-model";
2103
2104 /// config option ID for the Local Inference on/off toggle. Surfaced as a
2105 /// two-option `select` so ACP clients without slash-command support (e.g. Xcode)
2106 /// can still flip the mode from the agent panel.
2107 const LOCAL_INFERENCE_CONFIG_ID: &str = "sigit-local-inference";
2108
2109 /// `select` value ids for the Local Inference toggle.
2110 const LOCAL_INFERENCE_ON: &str = "local-inference-on";
2111 const LOCAL_INFERENCE_OFF: &str = "local-inference-off";
2112
2113 /// Replace non-ASCII chars so a downstream byte-index truncation can't split a
2114 /// multi-byte char. Zed slices the model-picker label at a fixed byte offset
2115 /// (`agent_ui/src/config_options.rs`) and panics — crashing the whole editor —
2116 /// when the cut lands mid-glyph (e.g. inside `☁` or `·`). Mapping to `-` keeps
2117 /// separators readable; ASCII bytes are always char boundaries.
2118 fn ascii_safe(s: &str) -> String {
2119 s.chars()
2120 .map(|c| if c.is_ascii() { c } else { '-' })
2121 .collect()
2122 }
2123
2124 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
2125 // The full list, including the siGit Code Cloud tiers, so the panel picker
2126 // mirrors the TUI `/models`. Cloud entries are sign-in gated at selection.
2127 let items = models::build_model_picker_items();
2128 let active_kind = models::active_inference_kind();
2129
2130 let options: Vec<SessionConfigSelectOption> = items
2131 .iter()
2132 .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
2133 .map(|item| {
2134 let mut desc_parts = Vec::new();
2135 // Mark options in the inactive mode so the active group reads as the
2136 // recommended set (the list is already ordered active-group-first).
2137 if item.source.kind() != active_kind {
2138 desc_parts.push("inactive mode".to_string());
2139 }
2140 if item.tool_calling {
2141 desc_parts.push("tool calling".to_string());
2142 }
2143 desc_parts.push(item.description.clone());
2144 if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
2145 desc_parts.push("download on select".to_string());
2146 }
2147 // ASCII-only for the same reason as the name (see `ascii_safe`).
2148 let description = ascii_safe(&desc_parts.join(" - "));
2149 // Keep badges ASCII: Zed truncates the picker label at a fixed byte
2150 // offset and panics if the cut splits a multi-byte char. See
2151 // `ascii_safe` below.
2152 let source_badge = if item.cloud_tier.is_some() {
2153 " [siGit Code Cloud]"
2154 } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
2155 " [Onde]"
2156 } else {
2157 match item.source_label.as_str() {
2158 "Onde" => " [Onde]",
2159 "HuggingFace" => " [HuggingFace]",
2160 _ => "",
2161 }
2162 };
2163 // For cloud tiers use just the tier title (e.g. "Balanced") so the
2164 // label reads "Balanced [siGit Code Cloud]" instead of repeating the
2165 // brand. The display name can carry non-ASCII (the cloud tier label
2166 // is "siGit Code Cloud · Balanced"), so sanitize the whole label.
2167 let base_name = match &item.cloud_tier {
2168 Some(tier) => crate::provider::tier_title(tier),
2169 None => item.display_name.clone(),
2170 };
2171 let name = ascii_safe(&format!("{base_name}{source_badge}"));
2172 SessionConfigSelectOption::new(
2173 SessionConfigValueId::new(item.config.model_id.as_str()),
2174 name,
2175 )
2176 .description(description)
2177 })
2178 .collect();
2179
2180 // Local Inference on/off toggle, modeled as a two-option select so panel-only
2181 // ACP clients (no slash commands) can flip the mode.
2182 let local_on = settings::local_inference_enabled();
2183 let local_current = SessionConfigValueId::new(if local_on {
2184 LOCAL_INFERENCE_ON
2185 } else {
2186 LOCAL_INFERENCE_OFF
2187 });
2188 let local_options = vec![
2189 SessionConfigSelectOption::new(
2190 SessionConfigValueId::new(LOCAL_INFERENCE_ON),
2191 "On (on-device)".to_string(),
2192 )
2193 .description("Run inference on-device; on-device models are highlighted".to_string()),
2194 SessionConfigSelectOption::new(
2195 SessionConfigValueId::new(LOCAL_INFERENCE_OFF),
2196 "Off (siGit Code Cloud)".to_string(),
2197 )
2198 .description("Use siGit Code Cloud; cloud tiers are highlighted".to_string()),
2199 ];
2200 let local_option = SessionConfigOption::select(
2201 LOCAL_INFERENCE_CONFIG_ID,
2202 "Local Inference",
2203 local_current,
2204 local_options,
2205 )
2206 .description("Toggle on-device inference; changes which models are highlighted");
2207
2208 if options.is_empty() {
2209 return vec![local_option];
2210 }
2211
2212 let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
2213
2214 vec![
2215 SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
2216 .category(SessionConfigOptionCategory::Model)
2217 .description("Select an on-device model or a siGit Code Cloud tier"),
2218 local_option,
2219 ]
2220 }
2221
2222 /// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
2223 fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
2224 let items = models::local_picker_items();
2225 items
2226 .into_iter()
2227 .find(|item| {
2228 item.config.model_id == model_id
2229 && item.cache_health != setup::ModelCacheHealth::Incomplete
2230 })
2231 .map(|item| (item.config, item.max_tokens, item.tool_calling))
2232 }
2233
2234 // ── Slash commands ────────────────────────────────────────────────────────────
2235
2236 #[derive(Debug, Clone)]
2237 enum SlashCommand {
2238 Help,
2239 Clear,
2240 Status,
2241 Models(Option<usize>),
2242 /// toggle on-device inference mode. `Some(true/false)` sets it, `None` flips it.
2243 Local(Option<bool>),
2244 /// List discovered Agent Skills.
2245 Skills,
2246 /// List configured MCP servers and their tools.
2247 Mcp,
2248 /// Explicitly load the selected (or default) on-device model.
2249 Load,
2250 /// `/login <email> <password>` — the raw argument, parsed when executed.
2251 Login(Option<String>),
2252 Logout,
2253 Whoami,
2254 /// Re-sync session state (auth, backend, picker) without a new session.
2255 Reload,
2256 /// Toggle plan mode (read-only research; mutating tools denied with a
2257 /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
2258 Plan(Option<bool>),
2259 /// Show the effective permission policy for this session.
2260 Permissions,
2261 /// Summarize-and-shrink the conversation history on demand.
2262 Compact,
2263 /// Generate (or improve) the repo's AGENTS.md by running an agent turn
2264 /// with [`instructions::INIT_PROMPT`] as the user text.
2265 Init,
2266 Exit,
2267 Unknown(String),
2268 }
2269
2270 fn parse_slash(input: &str) -> Option<SlashCommand> {
2271 let trimmed = input.trim();
2272 if !trimmed.starts_with('/') {
2273 return None;
2274 }
2275 let mut parts = trimmed.splitn(2, char::is_whitespace);
2276 let command = parts.next().unwrap_or("");
2277 let argument = parts.next().map(str::trim);
2278 Some(match command {
2279 "/help" => SlashCommand::Help,
2280 "/clear" => SlashCommand::Clear,
2281 "/status" => SlashCommand::Status,
2282 "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
2283 "/local" => SlashCommand::Local(parse_on_off(argument)),
2284 "/skills" => SlashCommand::Skills,
2285 "/mcp" => SlashCommand::Mcp,
2286 "/load" => SlashCommand::Load,
2287 "/login" => SlashCommand::Login(argument.map(str::to_string)),
2288 "/logout" => SlashCommand::Logout,
2289 "/whoami" => SlashCommand::Whoami,
2290 "/reload" => SlashCommand::Reload,
2291 "/plan" => SlashCommand::Plan(parse_on_off(argument)),
2292 "/permissions" => SlashCommand::Permissions,
2293 "/compact" => SlashCommand::Compact,
2294 "/init" => SlashCommand::Init,
2295 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
2296 other => SlashCommand::Unknown(other.to_string()),
2297 })
2298 }
2299
2300 /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
2301 /// (meaning "toggle the current value").
2302 fn parse_on_off(arg: Option<&str>) -> Option<bool> {
2303 match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() {
2304 "on" | "true" | "1" | "yes" => Some(true),
2305 "off" | "false" | "0" | "no" => Some(false),
2306 _ => None,
2307 }
2308 }
2309
2310 fn format_models_list(current_model: &GgufModelConfig) -> String {
2311 let items = models::build_model_picker_items();
2312 if items.is_empty() {
2313 return "No local models found. siGit will use the platform default model.".to_string();
2314 }
2315
2316 let mut lines = vec!["Available models:".to_string()];
2317 let mut last_source: Option<&str> = None;
2318
2319 for (index, item) in items.iter().enumerate() {
2320 let source_key = match item.source_label.as_str() {
2321 "Onde" => "Onde",
2322 "HuggingFace" => "HuggingFace",
2323 "siGit Code Cloud" => "Cloud",
2324 _ => "Fallback",
2325 };
2326
2327 if last_source != Some(source_key) {
2328 if last_source.is_some() {
2329 lines.push(String::new());
2330 }
2331 let section = match source_key {
2332 "Onde" => "Onde Inference",
2333 "HuggingFace" => "Hugging Face cache",
2334 "Cloud" => "siGit Code Cloud",
2335 _ => "Fallback",
2336 };
2337 lines.push(section.to_string());
2338 // Blank line so the following "N." items render as an ordered list.
2339 // CommonMark only lets an ordered list interrupt a paragraph when it
2340 // starts at 1, so without this the cloud section (items 9+) would be
2341 // absorbed into the header paragraph.
2342 lines.push(String::new());
2343 last_source = Some(source_key);
2344 }
2345
2346 let number = index + 1;
2347 let current_badge = if item.config.model_id == current_model.model_id {
2348 " <- current"
2349 } else {
2350 ""
2351 };
2352 let tool_badge = if item.tool_calling {
2353 " tool calling"
2354 } else {
2355 ""
2356 };
2357 let health_badge = match item.cache_health {
2358 setup::ModelCacheHealth::Complete => "",
2359 setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
2360 setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
2361 };
2362 let source = match source_key {
2363 "Onde" => " [Onde]",
2364 "HuggingFace" => " [HuggingFace]",
2365 "Cloud" => " [☁ Cloud]",
2366 _ => " [default]",
2367 };
2368
2369 lines.push(format!(
2370 "{number}. {} {}{}{}{}{}",
2371 item.display_name, item.description, tool_badge, health_badge, current_badge, source,
2372 ));
2373 }
2374
2375 lines.push(String::new());
2376 lines.push("Use /models N to switch models.".to_string());
2377 lines.join("\n")
2378 }
2379
2380 async fn exec_slash_acp(
2381 agent: &SiGitAgent,
2382 cx: &ConnectionTo<Client>,
2383 session_id: SessionId,
2384 command: SlashCommand,
2385 ) -> agent_client_protocol::Result<PromptResponse> {
2386 match command {
2387 SlashCommand::Help => {
2388 agent
2389 .send_assistant_message(
2390 cx,
2391 session_id,
2392 "/help - show this message\n\
2393 /models - list available models\n\
2394 /models N - switch to model N\n\
2395 /local [on|off]- toggle on-device inference mode\n\
2396 /skills - list available Agent Skills\n\
2397 /mcp - list MCP servers and their tools\n\
2398 /load - load the selected on-device model\n\
2399 /login E P - sign in to siGit Code Cloud\n\
2400 /logout - sign out\n\
2401 /whoami - show the signed-in account\n\
2402 /reload - re-sync sign-in and model state\n\
2403 /plan [on|off] - plan mode: research only, no edits or commands\n\
2404 /permissions - show the tool permission policy\n\
2405 /compact - summarize and shrink conversation history\n\
2406 /init - generate or improve the repo's AGENTS.md\n\
2407 /clear - wipe conversation history\n\
2408 /status - show engine status\n\
2409 /exit - end this turn",
2410 )
2411 .ok();
2412 }
2413 SlashCommand::Clear => {
2414 let cleared = agent.engine.clear_history().await;
2415 permissions::reset_session(&session_id.to_string());
2416 // The saved session must not resurrect what the user just wiped.
2417 session_store::delete(&session_id.to_string());
2418 agent
2419 .send_assistant_message(
2420 cx,
2421 session_id,
2422 format!("Cleared {cleared} turn(s). History is empty."),
2423 )
2424 .ok();
2425 }
2426 SlashCommand::Plan(value) => {
2427 let session_key = session_id.to_string();
2428 let enabled = value.unwrap_or_else(|| !permissions::plan_mode(&session_key));
2429 permissions::set_plan_mode(&session_key, enabled);
2430 let message = if enabled {
2431 "Plan mode ON — the agent researches with read-only tools and presents a \
2432 plan; edits and commands are blocked until /plan off."
2433 } else {
2434 "Plan mode OFF — the agent may execute tools again (subject to the \
2435 permission policy)."
2436 };
2437 agent.send_assistant_message(cx, session_id, message).ok();
2438 }
2439 SlashCommand::Permissions => {
2440 let summary = permissions::describe(&session_id.to_string());
2441 agent.send_assistant_message(cx, session_id, summary).ok();
2442 }
2443 SlashCommand::Compact => {
2444 let backend = agent.backend.lock().await.clone();
2445 let before = backend::estimate_tokens(&backend.history_snapshot().await);
2446 let message = match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
2447 Ok(()) => {
2448 let snapshot = backend.history_snapshot().await;
2449 let after = backend::estimate_tokens(&snapshot);
2450 // Keep the saved session in step with the compacted state.
2451 if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) {
2452 log::warn!("session save after /compact failed: {error}");
2453 }
2454 format!("Compacted history: ~{before} → ~{after} tokens (estimated).")
2455 }
2456 Err(error) => format!("Compaction failed: {error}"),
2457 };
2458 agent.send_assistant_message(cx, session_id, message).ok();
2459 }
2460 SlashCommand::Status => {
2461 let info = agent.engine.info().await;
2462 let model = info.model_name.as_deref().unwrap_or("(none)");
2463 let memory = info.approx_memory.as_deref().unwrap_or("unknown");
2464 agent
2465 .send_assistant_message(
2466 cx,
2467 session_id,
2468 format!(
2469 "status: {:?} model: {} memory: {} history: {} turns",
2470 info.status, model, memory, info.history_length,
2471 ),
2472 )
2473 .ok();
2474 }
2475 SlashCommand::Models(None) => {
2476 let current_model = agent.current_model.lock().unwrap().clone();
2477 agent
2478 .send_assistant_message(cx, session_id, format_models_list(&current_model))
2479 .ok();
2480 }
2481 SlashCommand::Skills => {
2482 agent
2483 .send_assistant_message(cx, session_id, skills::format_skills_list())
2484 .ok();
2485 }
2486 SlashCommand::Mcp => {
2487 agent
2488 .send_assistant_message(cx, session_id, mcp::status_summary())
2489 .ok();
2490 }
2491 SlashCommand::Models(Some(number)) => {
2492 let items = models::build_model_picker_items();
2493 let index = number.saturating_sub(1);
2494 match items.get(index).cloned() {
2495 None => {
2496 agent
2497 .send_assistant_message(
2498 cx,
2499 session_id,
2500 format!("error: no model #{number} - type /models to see the list."),
2501 )
2502 .ok();
2503 }
2504 Some(model) if model.cloud_tier.is_some() => {
2505 // siGit Code Cloud tier: swap backend, sign-in gated.
2506 let tier = model.cloud_tier.clone().unwrap_or_default();
2507 let message = match agent.switch_to_cloud_tier(&tier).await {
2508 Some(display_name) => format!("Switched to {display_name}."),
2509 None => CLOUD_LOGIN_PROMPT.to_string(),
2510 };
2511 agent.send_assistant_message(cx, session_id, message).ok();
2512 }
2513 Some(model) => {
2514 if model.cache_health == setup::ModelCacheHealth::Incomplete {
2515 agent
2516 .send_assistant_message(
2517 cx,
2518 session_id,
2519 format!(
2520 "error: {} has an incomplete local cache and cannot be selected yet.",
2521 model.display_name
2522 ),
2523 )
2524 .ok();
2525 } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
2526 agent
2527 .send_assistant_message(
2528 cx,
2529 session_id.clone(),
2530 format!(
2531 "Downloading and loading {} ({})… this may take a few minutes.",
2532 model.display_name, model.description
2533 ),
2534 )
2535 .ok();
2536
2537 match agent.switch_model_by_id(&model.config.model_id).await {
2538 Ok(new_config) => {
2539 agent.reset_to_local_backend().await;
2540 let _ = settings::set_local_inference(true);
2541 agent.engine.clear_history().await;
2542 agent
2543 .send_assistant_message(
2544 cx,
2545 session_id,
2546 format!(
2547 "✓ Downloaded and switched to {}",
2548 new_config.display_name
2549 ),
2550 )
2551 .ok();
2552 }
2553 Err(err) => {
2554 agent
2555 .send_assistant_message(
2556 cx,
2557 session_id,
2558 format!("error downloading model: {}", err.message),
2559 )
2560 .ok();
2561 }
2562 }
2563 } else {
2564 agent
2565 .send_assistant_message(
2566 cx,
2567 session_id.clone(),
2568 format!("Loading {}...", model.display_name),
2569 )
2570 .ok();
2571
2572 let switched = agent.switch_model_by_id(&model.config.model_id).await?;
2573 agent.reset_to_local_backend().await;
2574 let _ = settings::set_local_inference(true);
2575 agent.engine.clear_history().await;
2576
2577 agent
2578 .send_assistant_message(
2579 cx,
2580 session_id,
2581 format!("Switched to {}.", switched.display_name),
2582 )
2583 .ok();
2584 }
2585 }
2586 }
2587 }
2588 SlashCommand::Local(value) => {
2589 let enabled = value.unwrap_or(!settings::local_inference_enabled());
2590 let message = match settings::set_local_inference(enabled) {
2591 Ok(()) if enabled => "Local inference is on. On-device models are highlighted; \
2592 pick one with /models."
2593 .to_string(),
2594 Ok(()) => "Local inference is off. siGit Code Cloud tiers are highlighted; \
2595 pick one with /models."
2596 .to_string(),
2597 Err(error) => format!("error: could not save local inference setting: {error}"),
2598 };
2599 agent
2600 .send_assistant_message(cx, session_id.clone(), message)
2601 .ok();
2602 // Refresh the panel so the Model picker reflects the new emphasis.
2603 let config_options = {
2604 let current = agent.current_model.lock().unwrap();
2605 build_model_config_options(&current)
2606 };
2607 agent
2608 .send_tool_call_update(
2609 cx,
2610 session_id,
2611 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
2612 )
2613 .ok();
2614 }
2615 SlashCommand::Load => {
2616 // Explicitly load the on-device model. This is the only path that
2617 // brings a local model into memory; prompts never do it implicitly.
2618 // If a cloud tier is active, fall back to a local default so we don't
2619 // try to load the (file-less) cloud config as GGUF.
2620 let on_cloud = {
2621 let guard = agent.current_model.lock().unwrap();
2622 guard.model_id.starts_with("sigit-cloud:")
2623 };
2624 if on_cloud {
2625 let default_config = default_local_model_config();
2626 *agent.current_model.lock().unwrap() = default_config;
2627 agent.reset_to_local_backend().await;
2628 }
2629 // Loading an on-device model puts us in local inference mode.
2630 let _ = settings::set_local_inference(true);
2631 // `await_model_ready` drives the download/load progress UI and reports
2632 // success or failure to the editor.
2633 agent.start_startup_model_load_if_needed();
2634 agent.await_model_ready(cx, &session_id).await?;
2635 }
2636 SlashCommand::Login(argument) => {
2637 let message = match argument.as_deref().and_then(account::parse_login_args) {
2638 Some((email, password)) => match account::authenticate(&email, &password).await {
2639 Ok(email) => format!(
2640 "Signed in as {email}. Pick a siGit Code Cloud tier in /models to use it."
2641 ),
2642 Err(error) => format!("Login failed: {error}"),
2643 },
2644 None => "usage: /login <email> <password>".to_string(),
2645 };
2646 agent.send_assistant_message(cx, session_id, message).ok();
2647 }
2648 SlashCommand::Logout => {
2649 // If we're on a cloud tier, drop back to local — the token is gone.
2650 let on_cloud = {
2651 let guard = agent.current_model.lock().unwrap();
2652 guard.model_id.starts_with("sigit-cloud:")
2653 };
2654 let message = account::end_session().await;
2655 if on_cloud {
2656 agent.reset_to_local_backend().await;
2657 }
2658 agent.send_assistant_message(cx, session_id, message).ok();
2659 }
2660 SlashCommand::Whoami => {
2661 let message = account::status_line().await;
2662 agent.send_assistant_message(cx, session_id, message).ok();
2663 }
2664 SlashCommand::Reload => {
2665 agent.handle_reload(cx, session_id).await;
2666 }
2667 SlashCommand::Exit => {
2668 agent
2669 .send_assistant_message(
2670 cx,
2671 session_id,
2672 "Use the panel controls to close or switch threads.",
2673 )
2674 .ok();
2675 }
2676 SlashCommand::Init => {
2677 // Handled in the prompt path (it runs a real turn); reaching this
2678 // arm means a caller forgot that special case.
2679 agent
2680 .send_assistant_message(cx, session_id, "Send /init as a prompt to run it.")
2681 .ok();
2682 }
2683 SlashCommand::Unknown(command) => {
2684 agent
2685 .send_assistant_message(cx, session_id, format!("unknown command: {command}"))
2686 .ok();
2687 }
2688 }
2689
2690 Ok(PromptResponse::new(StopReason::EndTurn))
2691 }
2692
2693 // ── Request dispatch helper ───────────────────────────────────────────────────
2694
2695 fn handle_response<T: agent_client_protocol::JsonRpcResponse>(
2696 responder: Responder<T>,
2697 result: agent_client_protocol::Result<T>,
2698 ) -> agent_client_protocol::Result<()> {
2699 match result {
2700 Ok(resp) => responder.respond(resp),
2701 Err(err) => responder.respond_with_error(err),
2702 }
2703 }
2704
2705 // ── Download progress helpers ─────────────────────────────────────────────────
2706
2707 /// total bytes on disk under `path`. needed because hf-hub uses staging
2708 /// names during download, so we can't just stat the final blobs.
2709 fn dir_size_recursive(path: &std::path::Path) -> u64 {
2710 let mut total: u64 = 0;
2711 let Ok(entries) = std::fs::read_dir(path) else {
2712 return 0;
2713 };
2714 for entry in entries.flatten() {
2715 let entry_path = entry.path();
2716 if entry_path.is_dir() {
2717 total += dir_size_recursive(&entry_path);
2718 } else if let Ok(meta) = entry_path.metadata() {
2719 total += meta.len();
2720 }
2721 }
2722 total
2723 }
2724
2725 fn format_size_human(bytes: u64) -> String {
2726 const GB: u64 = 1_073_741_824;
2727 const MB: u64 = 1_048_576;
2728 const KB: u64 = 1_024;
2729 if bytes >= GB {
2730 format!("{:.2} GB", bytes as f64 / GB as f64)
2731 } else if bytes >= MB {
2732 format!("{:.1} MB", bytes as f64 / MB as f64)
2733 } else if bytes >= KB {
2734 format!("{:.0} KB", bytes as f64 / KB as f64)
2735 } else {
2736 format!("{bytes} B")
2737 }
2738 }
2739
2740 fn progress_bar(pct: u8, width: usize) -> String {
2741 let filled = ((pct as usize) * width) / 100;
2742 let empty = width.saturating_sub(filled);
2743 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
2744 }
2745
2746 // ── Output capture ────────────────────────────────────────────────────────────
2747
2748 /// redirect stdout+stderr to `$TMPDIR/sigit.log` at the fd level so
2749 /// mistralrs/tracing noise never hits the terminal. returns two dup'd
2750 /// fds to the real tty: one for ratatui, one for cleanup (ratatui 0.29
2751 /// doesn't expose `writer_mut()`).
2752 #[cfg(unix)]
2753 fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
2754 let log_path = std::env::temp_dir().join("sigit.log");
2755 let log_file = std::fs::File::create(&log_path)?;
2756 let log_fd = log_file.as_raw_fd();
2757
2758 // two copies: ratatui needs one, cleanup needs another
2759 let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) };
2760 anyhow::ensure!(
2761 saved_tui >= 0,
2762 "dup(stdout) for tui failed: {}",
2763 std::io::Error::last_os_error()
2764 );
2765 let saved_cleanup = unsafe { libc::dup(libc::STDOUT_FILENO) };
2766 anyhow::ensure!(
2767 saved_cleanup >= 0,
2768 "dup(stdout) for cleanup failed: {}",
2769 std::io::Error::last_os_error()
2770 );
2771
2772 unsafe {
2773 libc::dup2(log_fd, libc::STDOUT_FILENO);
2774 libc::dup2(log_fd, libc::STDERR_FILENO);
2775 }
2776
2777 // safe to drop log_file; dup2 keeps the fd alive via stdout/stderr
2778
2779 Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe {
2780 std::fs::File::from_raw_fd(saved_cleanup)
2781 }))
2782 }
2783
2784 // ── Logging ───────────────────────────────────────────────────────────────────
2785
2786 /// in TUI mode stderr is the log file (redirected earlier);
2787 /// in ACP mode it's real stderr. either way, write there.
2788 fn init_logging(is_tty: bool) {
2789 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
2790 let _ = tracing_fmt::Subscriber::builder()
2791 .with_env_filter(filter)
2792 .with_writer(std::io::stderr)
2793 .with_ansi(!is_tty)
2794 .try_init();
2795 }
2796
2797 // ── Interactive TUI mode ──────────────────────────────────────────────────────
2798
2799 /// boot the TUI and load the model on a background thread.
2800 /// `tty` goes to ratatui; `cleanup_tty` is a separate fd for
2801 /// LeaveAlternateScreen (ratatui 0.29 hides `writer_mut()`).
2802 #[cfg(unix)]
2803 async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
2804 let engine = Arc::new(ChatEngine::new());
2805
2806 let startup_selection = setup::startup_model_selection();
2807 let startup_model_name = startup_selection
2808 .as_ref()
2809 .map(|selection| selection.display_name.clone())
2810 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
2811
2812 // Signals the loading phase to finish. On-device models are no longer loaded
2813 // at startup, so this resolves immediately for both backends; it stays a
2814 // channel so the loading-phase plumbing in `chat::run_with` is unchanged.
2815 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
2816
2817 // Project instruction files (AGENTS.md / CLAUDE.md) for the launch directory,
2818 // injected into the system prompt so the TUI shares the same always-on
2819 // project context the ACP sessions get.
2820 let project_instructions = std::env::current_dir()
2821 .ok()
2822 .and_then(|cwd| instructions::load_project_instructions(&cwd));
2823 let with_instructions = |base: String| match &project_instructions {
2824 Some(extra) => format!("{base}\n\n{extra}"),
2825 None => base,
2826 };
2827
2828 // Pick the inference backend: a configured provider if present, else on-device.
2829 let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) =
2830 match provider::active_provider() {
2831 Some(provider) => {
2832 log::info!(
2833 "inference: using {} (model {}) at {}",
2834 provider.display_name,
2835 provider.model,
2836 provider.base_url
2837 );
2838 // No local model to load; the endpoint is ready immediately.
2839 let _ = load_tx.send(Ok(()));
2840 let label = provider.display_name.clone();
2841 register_subagent_factory_for(&provider);
2842 let backend = Arc::new(OpenAiBackend::new(
2843 provider.base_url,
2844 provider.api_key,
2845 provider.model,
2846 Some(with_instructions(SYSTEM_PROMPT.to_string())),
2847 )) as Arc<dyn InferenceBackend>;
2848 (backend, label)
2849 }
2850 None => {
2851 // Honor the Local Inference toggle: when off and signed in, start
2852 // on a cloud tier. Otherwise bring up on-device WITHOUT loading a
2853 // model — the user loads it explicitly with /load (or /models), so
2854 // the UI comes up immediately. Project instructions are injected at
2855 // load time in `chat.rs`.
2856 let cloud_when_off = if settings::local_inference_enabled() {
2857 None
2858 } else {
2859 provider::cloud_tier_provider("balanced")
2860 };
2861
2862 match cloud_when_off {
2863 Some(provider) => {
2864 log::info!(
2865 "inference: local inference off; using {} (model {})",
2866 provider.display_name,
2867 provider.model
2868 );
2869 let _ = load_tx.send(Ok(()));
2870 let label = provider.display_name.clone();
2871 register_subagent_factory_for(&provider);
2872 let backend = Arc::new(OpenAiBackend::new(
2873 provider.base_url,
2874 provider.api_key,
2875 provider.model,
2876 Some(with_instructions(SYSTEM_PROMPT.to_string())),
2877 )) as Arc<dyn InferenceBackend>;
2878 (backend, label)
2879 }
2880 None => {
2881 if !settings::local_inference_enabled() {
2882 log::warn!(
2883 "local inference is off but no account is signed in; \
2884 bringing up on-device. Run /login or /local on."
2885 );
2886 }
2887 let _ = load_tx.send(Ok(()));
2888 // On-device inference has a single shared history; no
2889 // subagent context is possible yet.
2890 tools::set_subagent_factory(Box::new(|| None));
2891 let backend = Arc::new(LocalBackend::new(Arc::clone(&engine)))
2892 as Arc<dyn InferenceBackend>;
2893 (backend, startup_model_name)
2894 }
2895 }
2896 }
2897 };
2898
2899 crossterm::terminal::enable_raw_mode()?;
2900 let mut tty = BufWriter::new(tty);
2901 crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
2902 let term_backend = ratatui::backend::CrosstermBackend::new(tty);
2903 let mut terminal = ratatui::Terminal::new(term_backend)?;
2904
2905 // polls load_rx with try_recv() each tick, no blocking
2906 let chat_result = chat::run_with(
2907 &mut terminal,
2908 engine,
2909 inference_backend,
2910 load_rx,
2911 startup_model_name,
2912 )
2913 .await;
2914
2915 // cleanup fd because backend's writer is private
2916 crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
2917 cleanup_tty.flush()?;
2918 crossterm::terminal::disable_raw_mode()?;
2919
2920 // restore real stdout/stderr for post-TUI error output
2921 #[cfg(unix)]
2922 {
2923 let cleanup_fd = cleanup_tty.as_raw_fd();
2924 unsafe {
2925 libc::dup2(cleanup_fd, libc::STDOUT_FILENO);
2926 libc::dup2(cleanup_fd, libc::STDERR_FILENO);
2927 }
2928 }
2929
2930 chat_result
2931 }
2932
2933 // ── ACP server mode ───────────────────────────────────────────────────────────
2934
2935 /// The on-device model `/load` should bring up by default: the persisted
2936 /// selection if it still resolves to a known local model, otherwise the built-in
2937 /// default (`qwen25_3b`).
2938 fn default_local_model_config() -> GgufModelConfig {
2939 setup::startup_model_selection()
2940 .as_ref()
2941 .and_then(|selection| {
2942 selection.selected_model.as_ref().and_then(|selected| {
2943 models::local_picker_items()
2944 .into_iter()
2945 .find(|item| {
2946 item.config.model_id == selected.model_id
2947 && item
2948 .config
2949 .files
2950 .iter()
2951 .any(|file| file == &selected.gguf_file)
2952 })
2953 .map(|item| item.config)
2954 })
2955 })
2956 .unwrap_or_else(GgufModelConfig::qwen25_3b)
2957 }
2958
2959 async fn run_acp_server() -> anyhow::Result<()> {
2960 log::info!("ACP mode — starting agent server");
2961
2962 let config = default_local_model_config();
2963
2964 let needs_download = models::local_picker_items()
2965 .iter()
2966 .find(|item| item.config.model_id == config.model_id)
2967 .map(|item| item.cache_health != setup::ModelCacheHealth::Complete)
2968 .unwrap_or(true);
2969
2970 log::info!(
2971 "ACP startup model selected: {} ({})",
2972 config.display_name,
2973 if needs_download {
2974 "needs download"
2975 } else {
2976 "cached"
2977 }
2978 );
2979
2980 let engine = Arc::new(ChatEngine::new());
2981
2982 // The on-device model is never loaded implicitly; the user loads it with
2983 // `/load` (or by picking one in `/models`). So initialize/session/new stay
2984 // lightweight and `model_ready` starts true (nothing is loading).
2985 let model_ready = Arc::new(AtomicBool::new(true));
2986 let startup_model_load_started = Arc::new(AtomicBool::new(false));
2987 let model_load_error: Arc<std::sync::Mutex<Option<String>>> =
2988 Arc::new(std::sync::Mutex::new(None));
2989
2990 let state = Arc::new(SiGitAgent::new(
2991 engine,
2992 config,
2993 model_ready,
2994 startup_model_load_started,
2995 model_load_error,
2996 needs_download,
2997 ));
2998
2999 // Honor the explicit provider override (OPENAI_BASE_URL/OPENAI_API_KEY or
3000 // an active providers.toml profile) in ACP mode too — the interactive
3001 // client already does. Without this the override was silently ignored here
3002 // and prompts insisted on a local model. It is also what lets the ACP
3003 // integration test drive the agent against a scripted endpoint
3004 // (tests/acp_permissions.rs). The model picker still shows the local
3005 // selection; overrides are a power-user escape hatch, not a tier.
3006 if let Some(cfg) = provider::active_provider() {
3007 log::info!(
3008 "inference: using {} (model {}) at {}",
3009 cfg.display_name,
3010 cfg.model,
3011 cfg.base_url
3012 );
3013 register_subagent_factory_for(&cfg);
3014 let override_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
3015 cfg.base_url,
3016 cfg.api_key,
3017 cfg.model,
3018 Some(system_prompt_for_model(true).to_string()),
3019 ));
3020 *state.backend.lock().await = override_backend;
3021 } else {
3022 // On-device inference has a single shared conversation history, so a
3023 // second concurrent subagent context is not possible yet.
3024 tools::set_subagent_factory(Box::new(|| None));
3025 }
3026
3027 let stdin = tokio::io::stdin().compat();
3028 let stdout = tokio::io::stdout().compat_write();
3029 let transport = ByteStreams::new(stdout, stdin);
3030
3031 Agent
3032 .builder()
3033 .on_receive_request(
3034 {
3035 let state = Arc::clone(&state);
3036 async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| {
3037 handle_response(responder, state.handle_initialize(req).await)
3038 }
3039 },
3040 agent_client_protocol::on_receive_request!(),
3041 )
3042 .on_receive_request(
3043 {
3044 let state = Arc::clone(&state);
3045 async move |req: AuthenticateRequest, responder, _cx: ConnectionTo<Client>| {
3046 handle_response(responder, state.handle_authenticate(req).await)
3047 }
3048 },
3049 agent_client_protocol::on_receive_request!(),
3050 )
3051 // Turn-affecting handlers below run in spawned tasks, serialized by
3052 // `turn_lock`, so the dispatch loop stays free to route client
3053 // responses (permission answers) while a turn is in flight. Awaiting a
3054 // client request from *inside* a handler would deadlock: the dispatch
3055 // loop can't read the response while the handler blocks it.
3056 .on_receive_request(
3057 {
3058 let state = Arc::clone(&state);
3059 async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| {
3060 let state = Arc::clone(&state);
3061 let task_cx = cx.clone();
3062 cx.spawn(async move {
3063 let _turn = state.turn_lock.lock().await;
3064 handle_response(responder, state.handle_load_session(&task_cx, req).await)
3065 })
3066 }
3067 },
3068 agent_client_protocol::on_receive_request!(),
3069 )
3070 .on_receive_request(
3071 {
3072 let state = Arc::clone(&state);
3073 async move |req: ForkSessionRequest, responder, cx: ConnectionTo<Client>| {
3074 let state = Arc::clone(&state);
3075 let task_cx = cx.clone();
3076 cx.spawn(async move {
3077 let _turn = state.turn_lock.lock().await;
3078 handle_response(responder, state.handle_fork_session(&task_cx, req).await)
3079 })
3080 }
3081 },
3082 agent_client_protocol::on_receive_request!(),
3083 )
3084 .on_receive_request(
3085 {
3086 let state = Arc::clone(&state);
3087 async move |req: NewSessionRequest, responder, cx: ConnectionTo<Client>| {
3088 let state = Arc::clone(&state);
3089 let task_cx = cx.clone();
3090 cx.spawn(async move {
3091 let _turn = state.turn_lock.lock().await;
3092 handle_response(responder, state.handle_new_session(&task_cx, req).await)
3093 })
3094 }
3095 },
3096 agent_client_protocol::on_receive_request!(),
3097 )
3098 .on_receive_request(
3099 {
3100 let state = Arc::clone(&state);
3101 async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
3102 let state = Arc::clone(&state);
3103 let task_cx = cx.clone();
3104 cx.spawn(async move {
3105 let _turn = state.turn_lock.lock().await;
3106 handle_response(responder, state.handle_prompt(&task_cx, req).await)
3107 })
3108 }
3109 },
3110 agent_client_protocol::on_receive_request!(),
3111 )
3112 .on_receive_request(
3113 {
3114 let state = Arc::clone(&state);
3115 async move |req: SetSessionConfigOptionRequest,
3116 responder,
3117 cx: ConnectionTo<Client>| {
3118 let state = Arc::clone(&state);
3119 let task_cx = cx.clone();
3120 cx.spawn(async move {
3121 let _turn = state.turn_lock.lock().await;
3122 handle_response(
3123 responder,
3124 state.handle_set_session_config_option(&task_cx, req).await,
3125 )
3126 })
3127 }
3128 },
3129 agent_client_protocol::on_receive_request!(),
3130 )
3131 .on_receive_notification(
3132 {
3133 let state = Arc::clone(&state);
3134 async move |notif: CancelNotification, _cx: ConnectionTo<Client>| {
3135 state.handle_cancel(notif).await
3136 }
3137 },
3138 agent_client_protocol::on_receive_notification!(),
3139 )
3140 .connect_to(transport)
3141 .await
3142 .map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?;
3143
3144 log::info!("siGit shutting down");
3145 Ok(())
3146 }
3147
3148 // ── Entry point ──────────────────────────────────────────────────────────────
3149
3150 #[tokio::main]
3151 async fn main() -> anyhow::Result<()> {
3152 // Account subcommands. The editor launches `sigit login` in an embedded
3153 // terminal for ACP terminal-based authentication; the same verbs are handy
3154 // directly from a shell. These must be handled before the TTY/ACP split.
3155 if let Some(verb) = std::env::args().nth(1) {
3156 match verb.as_str() {
3157 "login" => {
3158 init_logging(true);
3159 match account::interactive_login().await {
3160 Ok(email) => {
3161 println!("Signed in to siGit Code Cloud as {email}.");
3162 return Ok(());
3163 }
3164 Err(error) => {
3165 eprintln!("Login failed: {error}");
3166 std::process::exit(1);
3167 }
3168 }
3169 }
3170 "logout" => {
3171 init_logging(true);
3172 println!("{}", account::end_session().await);
3173 return Ok(());
3174 }
3175 "whoami" => {
3176 init_logging(true);
3177 println!("{}", account::status_line().await);
3178 return Ok(());
3179 }
3180 _ => {}
3181 }
3182 }
3183
3184 // Headless programmatic mode: `sigit -p "<prompt>"` runs one prompt and
3185 // exits. Plain stdio and cross-platform (unlike the Unix-only TUI), so it
3186 // is dispatched here, before the TTY/ACP split, like the account
3187 // subcommands above.
3188 let cli_args: Vec<String> = std::env::args().skip(1).collect();
3189 match headless::parse_args(&cli_args) {
3190 Ok(None) => {}
3191 Ok(Some(config)) => {
3192 // Logs to stderr; stdout stays clean for the assistant's answer.
3193 init_logging(false);
3194 // Enter --cwd before anything loads, so instruction files and
3195 // project-local MCP config resolve from it (the headless
3196 // counterpart of the ACP session cwd handling).
3197 if let Some(dir) = &config.cwd
3198 && let Err(error) = std::env::set_current_dir(dir)
3199 {
3200 eprintln!("sigit: cannot use --cwd {}: {error}", dir.display());
3201 std::process::exit(2);
3202 }
3203 setup::setup_shared_model_cache();
3204 // Best-effort MCP discovery (incl. the official server), like the
3205 // other entry points, so MCP tools are offered to the model.
3206 mcp::init().await;
3207 log::info!(
3208 "siGit v{} starting (headless mode)",
3209 env!("CARGO_PKG_VERSION")
3210 );
3211 std::process::exit(headless::run(config).await);
3212 }
3213 Err(error) => {
3214 eprintln!("sigit: {error}\n{}", headless::USAGE);
3215 std::process::exit(2);
3216 }
3217 }
3218
3219 let is_tty = std::io::stdin().is_terminal();
3220
3221 if is_tty {
3222 // must redirect before any library code touches stdout
3223 #[cfg(unix)]
3224 {
3225 let (tty, cleanup_tty) = redirect_output_to_log()?;
3226 init_logging(true);
3227 setup::setup_shared_model_cache();
3228 // Best-effort: discover MCP servers (incl. the official one) before
3229 // the first turn so their tools are offered to the model.
3230 mcp::init().await;
3231 run_interactive(tty, cleanup_tty).await
3232 }
3233 #[cfg(not(unix))]
3234 {
3235 anyhow::bail!("interactive mode requires Unix (macOS / Linux)");
3236 }
3237 } else {
3238 // ACP mode: keep stdout untouched for protocol JSON only.
3239 // Logs already go to stderr via `init_logging(false)`.
3240 init_logging(false);
3241 setup::setup_shared_model_cache();
3242 // Best-effort MCP discovery (incl. the official server) before serving.
3243 mcp::init().await;
3244 log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
3245 run_acp_server().await
3246 }
3247 }
3248
3249 #[cfg(test)]
3250 mod tests {
3251 use super::*;
3252
3253 #[test]
3254 fn system_prompt_advertises_the_commit_co_author_trailer() {
3255 // The prompt instructs the model with the exact trailer that
3256 // `tools::ensure_commit_co_author` enforces; if the two drift apart the
3257 // safety net would re-amend commits the model already attributed.
3258 assert!(
3259 SYSTEM_PROMPT.contains(tools::COMMIT_CO_AUTHOR_TRAILER),
3260 "SYSTEM_PROMPT must quote tools::COMMIT_CO_AUTHOR_TRAILER verbatim"
3261 );
3262 }
3263
3264 #[test]
3265 fn parse_slash_maps_init() {
3266 assert!(matches!(parse_slash("/init"), Some(SlashCommand::Init)));
3267 // Trailing whitespace comes from editors that submit the raw line.
3268 assert!(matches!(parse_slash(" /init "), Some(SlashCommand::Init)));
3269 }
3270
3271 #[test]
3272 fn ascii_safe_replaces_multibyte_chars() {
3273 // The exact label that crashed Zed: the cloud tier name plus the old
3274 // "[☁ siGit Code Cloud]" badge. After sanitizing it must be pure ASCII so
3275 // Zed's fixed byte-offset truncation can never split a glyph.
3276 let crashing = "siGit Code Cloud · Balanced [☁ siGit Code Cloud]";
3277 let safe = ascii_safe(crashing);
3278 assert!(safe.is_ascii(), "sanitized label must be ASCII: {safe:?}");
3279 assert_eq!(safe, "siGit Code Cloud - Balanced [- siGit Code Cloud]");
3280 }
3281
3282 #[test]
3283 fn ascii_safe_leaves_ascii_untouched() {
3284 let plain = "Qwen 2.5 3B [Onde]";
3285 assert_eq!(ascii_safe(plain), plain);
3286 }
3287
3288 #[test]
3289 fn ascii_safe_output_has_only_char_boundaries() {
3290 // Every byte index in an ASCII string is a valid char boundary, so any
3291 // downstream truncation is panic-free regardless of where it cuts.
3292 let safe = ascii_safe("Onde · ◉ ↓ ☁ ○ test");
3293 for i in 0..=safe.len() {
3294 assert!(safe.is_char_boundary(i));
3295 }
3296 }
3297 }