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