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