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