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