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