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