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