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