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