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