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