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