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