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