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 browser_auth;
34 mod chat;
35 mod commands;
36 mod credentials;
37 mod frontmatter;
38 mod headless;
39 mod hooks;
40 mod inline_tool_calls;
41 mod instructions;
42 mod mcp;
43 mod models;
44 mod permissions;
45 mod provider;
46 mod session_store;
47 mod settings;
48 mod setup;
49 mod skills;
50 mod subagents;
51 mod tools;
52 mod workspace;
53
54 /// Serializes tests that mutate process-global state: env vars
55 /// (`SIGIT_CONFIG_DIR` etc.) and the current directory. `cargo test` runs
56 /// tests in parallel within a binary, so without this the credentials and
57 /// settings round-trip tests clobber each other's env, and the discovery
58 /// tests (skills/commands/subagents) yank the cwd out from under each other.
59 #[cfg(test)]
60 pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
61
62 use std::io::IsTerminal;
63 #[cfg(unix)]
64 use std::io::{BufWriter, Write};
65 use std::sync::Arc;
66
67 use onde::inference::SamplingConfig;
68
69 // `ProtocolVersion` is a version-agnostic type at the schema root; the rest of the
70 // schema types moved under `schema::v1` in agent-client-protocol 1.0.
71 use agent_client_protocol::schema::ProtocolVersion;
72 use agent_client_protocol::schema::v1::{
73 AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
74 AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
75 ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
76 ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
77 LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
78 PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest,
79 PromptResponse, RequestPermissionOutcome, RequestPermissionRequest,
80 SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionConfigOption,
81 SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
82 SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
83 SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
84 ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields,
85 ToolKind, UnstructuredCommandInput,
86 };
87 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
88 use onde::inference::{ChatEngine, GgufModelConfig};
89
90 use crate::backend::{
91 InferenceBackend, LocalBackend, OpenAiBackend, ToolResult as BackendToolResult, ToolSpec,
92 TurnResult,
93 };
94 use std::path::PathBuf;
95 use std::sync::atomic::{AtomicBool, Ordering};
96 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
97 use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
98
99 #[cfg(unix)]
100 use std::os::unix::io::{AsRawFd, FromRawFd};
101
102 const SYSTEM_PROMPT: &str = "\
103 Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
104 Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are.
105
106 You are a strong general-purpose coding agent. smbCloud is your home turf, \
107 but you should still be useful in any codebase. When the project is clearly \
108 about smbCloud, use that context directly instead of falling back to vague \
109 cloud-platform advice.
110
111 smbCloud context you should know and use when it helps:
112 - smbCloud is a platform for deploying and managing projects
113 - the main CLI is a Rust workspace with focused crates rather than one giant crate
114 - common areas include auth, project management, deploy flows, networking, \
115 shared models, release tooling, and managed services
116 - deploy branches usually follow `release/service-{name}`
117 - Next.js SSR deploys on smbCloud are not the same as generic git-push deploys; \
118 they often use a local build plus rsync/PM2 style flow
119 - auth has a hard boundary between smbCloud platform users and tenant app users; \
120 platform flows use `/v1/users*`, tenant app flows use `/v1/client/*`, and \
121 you should not casually mix `User`, `TenantMembership`, `AuthApp`, and `AuthUser`
122 - smbCloud authorization is layered; do not flatten platform accounts, tenant \
123 memberships, auth-app collaborators, and tenant end users into one model
124 - `Project` is the umbrella workspace, while app-like resources such as \
125 `FrontendApp`, `AuthApp`, and GresIQ are the deployable units with their own \
126 ownership, sharing, and collaboration rules
127 - `FrontendApp` is many-per-project, while `AuthApp` is intentionally one-per-project; \
128 preserve those cardinality rules unless the code clearly changes them
129 - GresIQ is smbCloud's managed PostgreSQL offering; treat it as a platform \
130 service with its own credentials and boundaries, not as a generic local DB helper
131 - when debugging smbCloud Rails APIs, first classify the request: first-party \
132 smbCloud app or tenant app, then check which endpoint family and validator \
133 should be involved before changing code
134 - when working in smbCloud repos, prefer existing workspace patterns, existing \
135 crate boundaries, existing Rails conventions, and existing command flows over \
136 inventing new abstractions
137
138 CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \
139 When the user asks you to clone a repo, run a build, check git status, or do \
140 anything that involves a shell command, you MUST call the run_command tool and \
141 execute it yourself. Do not print shell commands for the user to copy-paste. \
142 Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \
143 If a command fails, try to fix the problem and re-run it. If you cannot fix it \
144 after two attempts, explain what went wrong and what you tried.
145
146 Git operations — always use run_command:
147 - git clone: always pass the full absolute destination path as the last argument \
148 and set cwd to an existing writable parent directory. Example: \
149 run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \
150 \"cwd\": \"/Users/me/Repositories\"})
151 - git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \
152 stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \
153 the repo root
154 - never run git clone without an explicit absolute destination path
155 - if a clone or init fails, check the error, fix the cause (wrong path, missing \
156 directory, permissions), and retry
157 - when you create a commit, always end the commit message with a blank line and \
158 then this trailer on its own line: Co-Authored-By: siGit Code <noreply@sigit.si> \
159 — GitHub reads that exact format and credits siGit as co-author. If a commit \
160 lands without it, siGit Code amends the trailer in automatically and the tool \
161 output says so; do not amend again yourself.
162
163 For repositories hosted on sigit.si, issue and pull request workflows go \
164 through the official MCP tools: mcp__sigit__list_issues, mcp__sigit__get_issue, \
165 mcp__sigit__create_issue, mcp__sigit__list_pull_requests, and \
166 mcp__sigit__get_pull_request. Prefer these tools over shelling out to git (or \
167 fetching web pages) for issue and PR queries on sigit.si repos.
168
169 Never introduce yourself unless asked. Jump straight into the answer. \
170 Keep answers short. Write idiomatic code. \
171 Fix root causes, not symptoms.
172
173 You have access to tools that let you read files, read websites directly from \
174 http and https URLs, create directories, list directories, search code, create \
175 new files, edit existing files, delete files, and run shell commands. You can \
176 also use git directly through shell commands, including `git init` and normal \
177 git workflows. Use them proactively. Read the code or website before answering. \
178 Prefer absolute paths when referring to files and directories, especially in \
179 protocol-facing output and tool arguments. Create directories when needed. Run \
180 builds, tests, and git commands after making changes. Ground your answers in \
181 the actual code or fetched page content, not in guesses.
182
183 CRITICAL — you CAN access websites. You are NOT a typical LLM without internet \
184 access. You have a read_website tool that fetches any http or https URL and \
185 returns the page text. When the user gives you a URL or asks you to read, \
186 summarize, or inspect a web page, you MUST call the read_website tool with that \
187 URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
188 You can. Use the tool.
189
190 CRITICAL — before every edit_file call, you MUST call read_file on the target \
191 file first (or the specific line range if one was given). Never rely on file \
192 content you saw in a previous turn — the user may have reverted, edited, or \
193 changed the file externally since then. Always re-read to get the current state \
194 before constructing old_text. \
195 When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
196 treat it as a fresh task: re-read the file, identify the current content, and \
197 plan the edit from scratch. Do not assume the file still reflects your last edit.
198
199 Tool-use heuristics:
200 - when the user provides a URL or asks about a web page, ALWAYS call \
201 read_website — never refuse or claim you lack internet access
202 - prefer absolute paths over relative paths when you mention, return, or pass \
203 file and directory paths
204 - if a path does not exist yet, create the directory before creating files in it
205 - if the user asks to clone a repo, immediately call run_command with git clone \
206 and an absolute destination path — do not ask where to put it unless the \
207 request is ambiguous; default to the user's home Repositories directory
208 - if the user asks for a new repo, scaffold, or scratch project, create the \
209 directory, create the first files, and run `git init` without waiting unless \
210 the request says otherwise
211 - if the repo looks like smbCloud CLI code, respect workspace crate boundaries, \
212 shared models, and existing command handlers before adding new abstractions
213 - if the repo looks like smbCloud Rails code, check routes, controllers, \
214 validators, and model boundaries before changing business logic
215 - if the task touches smbCloud auth, first decide whether it is a platform-user \
216 flow or a tenant-app flow, then follow the right endpoint family and model layer
217 - if the task touches smbCloud deploy code, check whether it is the generic \
218 deploy path or the Next.js SSR path before proposing changes
219 - after edits, prefer running the smallest useful verification step first, then \
220 widen to broader checks if needed
221 - use git commands naturally for status checks, repo setup, diffs, and normal \
222 developer workflows when they help move the task forward
223 - if a tool call fails, read the error, try to fix it, and retry — do not \
224 fall back to telling the user what to type
225
226 When the repo is not about smbCloud, act like a normal coding agent and do not \
227 force smbCloud-specific advice into the answer. When it is about smbCloud, be \
228 specific and practical.
229
230 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
231 root cause, not the symptom. Correct beats clever.";
232
233 /// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1).
234 /// the full [`SYSTEM_PROMPT`] wastes context and confuses them.
235 const SIMPLE_SYSTEM_PROMPT: &str = "\
236 Your name is siGit — a coding assistant. \
237 You are helpful, concise, and write clean, idiomatic code. \
238 Answer any question the user asks — programming, general knowledge, or casual chat. \
239 When debugging, address the root cause, not the symptom. \
240 Be direct and brief.";
241
242 pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
243 if tool_calling {
244 SYSTEM_PROMPT
245 } else {
246 SIMPLE_SYSTEM_PROMPT
247 }
248 }
249
250 /// cap tool-call loops so a confused model can't spin forever; auto-compaction
251 /// (see [`backend::DEFAULT_CONTEXT_TOKEN_BUDGET`]) keeps long runs inside the
252 /// context window, so the cap can afford to be generous
253 const MAX_TOOL_ROUNDS: usize = 24;
254
255 /// Separator between two stretches of assistant prose that a tool round split
256 /// apart. ACP clients concatenate consecutive agent-message chunks into a
257 /// single block, so a plain newline (or nothing at all) leaves the rounds
258 /// reading as one run-on paragraph.
259 const PARAGRAPH_BREAK: &str = "\n\n";
260
261 /// The assistant reply being streamed over the course of one prompt, tracked
262 /// across every tool round so `<think>` stripping stays correct and no text is
263 /// sent twice.
264 #[derive(Default)]
265 struct StreamedReply {
266 /// Raw text as it arrives, reasoning tags included: think-block stripping
267 /// needs the whole string, since a tag can span chunk boundaries.
268 assembled: String,
269 /// The visible text already on the wire.
270 sent: String,
271 /// Whether any chunk has been sent, i.e. the client already has a reply.
272 streamed_any: bool,
273 /// Whether the next visible text should open a new paragraph.
274 paragraph_pending: bool,
275 }
276
277 impl StreamedReply {
278 /// Record that a tool round has interrupted the prose, so the next chunk
279 /// starts a paragraph. A round that opened with tool calls and no text has
280 /// no sentence to break away from, hence the `streamed_any` guard.
281 fn interrupt(&mut self) {
282 self.paragraph_pending = self.streamed_any;
283 }
284
285 /// Fold one streamed fragment into the reply and return the visible text it
286 /// newly reveals, or `None` when it reveals nothing to show (it landed
287 /// inside a `<think>` block, say).
288 fn push(&mut self, piece: &str) -> Option<String> {
289 if self.paragraph_pending {
290 // A tool round interrupted the prose. Clients concatenate
291 // consecutive agent-message chunks into one block, so the new round
292 // has to open a paragraph or it runs onto the end of the previous
293 // sentence ("…parsed with unwrap_or_else.Let me broaden the
294 // search:"). Whatever whitespace the model puts at the seam gives
295 // way to the break.
296 let piece = piece.trim_start();
297 if piece.is_empty() {
298 return None;
299 }
300 self.assembled.push_str(PARAGRAPH_BREAK);
301 self.assembled.push_str(piece);
302 self.paragraph_pending = false;
303 } else {
304 self.assembled.push_str(piece);
305 }
306
307 let (_think, visible) = chat::strip_think_blocks(&self.assembled);
308 match visible.strip_prefix(self.sent.as_str()) {
309 Some(extra) if !extra.is_empty() => {
310 let extra = extra.to_string();
311 self.sent = visible;
312 self.streamed_any = true;
313 Some(extra)
314 }
315 // No new visible text, or the visible prefix changed retroactively
316 // (rare, e.g. a late-closing think tag): just resync without
317 // resending what's already on the wire.
318 _ => {
319 self.sent = visible;
320 None
321 }
322 }
323 }
324 }
325
326 /// Outcome of asking the client for permission to run one tool call.
327 enum PermissionVerdict {
328 /// Run the tool.
329 Approved,
330 /// Skip the tool; the string becomes its tool result so the model adapts.
331 Denied(String),
332 /// The client cancelled the turn while the request was pending; stop the
333 /// whole prompt with `StopReason::Cancelled` instead of burning rounds.
334 TurnCancelled,
335 }
336
337 /// Display kind for the permission dialog, so editors can show a fitting icon.
338 fn tool_kind_for(tool_name: &str) -> ToolKind {
339 match tool_name {
340 "edit_file" | "multi_edit" | "create_file" | "create_directory" | "remember" => {
341 ToolKind::Edit
342 }
343 "delete_file" => ToolKind::Delete,
344 "run_command" | "kill_command" => ToolKind::Execute,
345 "read_file" | "list_directory" | "command_output" => ToolKind::Read,
346 "search_files" | "glob" | "web_search" => ToolKind::Search,
347 "read_website" => ToolKind::Fetch,
348 "write_todos" => ToolKind::Think,
349 _ => ToolKind::Other,
350 }
351 }
352
353 /// The longest run of consecutive backticks in `text`, so a fence built around
354 /// it can't be broken out of by output that itself contains ```` ``` ````.
355 fn longest_backtick_run(text: &str) -> usize {
356 let mut max = 0;
357 let mut current = 0;
358 for c in text.chars() {
359 if c == '`' {
360 current += 1;
361 max = max.max(current);
362 } else {
363 current = 0;
364 }
365 }
366 max
367 }
368
369 /// Fences `text` as a Markdown code block, sizing the fence from the longest
370 /// backtick run already in `text` so it can't break out of the block.
371 fn fenced_code_block(language: &str, text: &str) -> String {
372 let fence = "`".repeat((longest_backtick_run(text) + 1).max(3));
373 let language = if language
374 .chars()
375 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '+'))
376 {
377 language
378 } else {
379 ""
380 };
381 format!("{fence}{language}\n{text}\n{fence}")
382 }
383
384 /// Displays tool arguments as JSON when they parse as JSON, otherwise as an
385 /// unlabelled code block so malformed arguments do not get misleading syntax
386 /// highlighting.
387 fn tool_arguments_content(arguments_json: &str) -> ToolCallContent {
388 let language = if serde_json::from_str::<serde_json::Value>(arguments_json).is_ok() {
389 "json"
390 } else {
391 ""
392 };
393 fenced_code_block(language, &chat::pretty_tool_arguments(arguments_json)).into()
394 }
395
396 /// Wraps a tool's output as the display content of its ACP tool-call card
397 /// (issue #78). Capped via [`chat::cap_output_preview`] — display only, the
398 /// model still gets the full output — and fenced so plain-text output (or a
399 /// stray tag, per issue #73) renders literally instead of as markdown.
400 fn tool_output_content(output: &str) -> ToolCallContent {
401 if output.is_empty() {
402 return "(no output)".to_string().into();
403 }
404 fenced_code_block("", &chat::cap_output_preview(output)).into()
405 }
406
407 /// A single-file location for the path-bearing tools, so ACP clients can
408 /// follow the agent through a file as it works (issue #78's "follow-along").
409 fn tool_call_locations(name: &str, arguments_json: &str) -> Vec<ToolCallLocation> {
410 if !matches!(
411 name,
412 "read_file" | "create_file" | "edit_file" | "multi_edit" | "delete_file"
413 ) {
414 return Vec::new();
415 }
416 let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments_json) else {
417 return Vec::new();
418 };
419 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
420 return Vec::new();
421 };
422 let path_buf = PathBuf::from(path);
423 let absolute = if path_buf.is_absolute() {
424 path_buf
425 } else {
426 std::env::current_dir()
427 .unwrap_or_else(|_| PathBuf::from("."))
428 .join(path_buf)
429 };
430 vec![ToolCallLocation::new(absolute)]
431 }
432
433 /// The visible text of one saved history message. `content` is normally a
434 /// string, but an OpenAI-compatible endpoint may hand back the block form, and
435 /// tool-call-only assistant turns carry `null`.
436 fn history_message_text(message: &serde_json::Value) -> String {
437 match &message["content"] {
438 serde_json::Value::String(text) => text.clone(),
439 serde_json::Value::Array(blocks) => blocks
440 .iter()
441 .filter_map(|block| block["text"].as_str())
442 .collect::<Vec<_>>()
443 .join(""),
444 _ => String::new(),
445 }
446 }
447
448 /// Rebuild the `session/update` notifications that re-draw a saved conversation
449 /// in the client.
450 ///
451 /// ACP's `session/load` is a replay: the client renders the reopened thread
452 /// purely from the updates the agent streams while the request is in flight.
453 /// Restoring history into the backend is what makes the *model* remember, but
454 /// it puts nothing on screen, so a reopened session came up blank and looked
455 /// like a brand-new one (issue #77).
456 fn history_replay_updates(history: &[serde_json::Value]) -> Vec<SessionUpdate> {
457 // Match results to their calls up front so each call replays as one
458 // finished tool call instead of a call followed by a loose result.
459 let mut outputs: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
460 for message in history {
461 if message["role"] == "tool"
462 && let (Some(id), Some(content)) = (
463 message["tool_call_id"].as_str(),
464 message["content"].as_str(),
465 )
466 {
467 outputs.insert(id, content);
468 }
469 }
470
471 let mut updates = Vec::new();
472 for message in history {
473 match message["role"].as_str().unwrap_or_default() {
474 // Seeded context and project instructions were never on screen.
475 "system" => {}
476 "user" => {
477 let text = history_message_text(message);
478 if !text.trim().is_empty() {
479 updates.push(SessionUpdate::UserMessageChunk(ContentChunk::new(
480 ContentBlock::from(text),
481 )));
482 }
483 }
484 "assistant" => {
485 let (_think, visible) = chat::strip_think_blocks(&history_message_text(message));
486 if !visible.trim().is_empty() {
487 updates.push(SessionUpdate::AgentMessageChunk(ContentChunk::new(
488 ContentBlock::from(visible),
489 )));
490 }
491 for call in message["tool_calls"].as_array().into_iter().flatten() {
492 let name = call["function"]["name"].as_str().unwrap_or("tool");
493 let arguments = call["function"]["arguments"].as_str().unwrap_or("");
494
495 // `write_todos` renders as a plan while it runs; replay it
496 // the same way, and let a later list replace an earlier one
497 // exactly as it did live.
498 if name == "write_todos"
499 && let Some(plan) = todos_arguments_to_plan(arguments)
500 {
501 updates.push(SessionUpdate::Plan(plan));
502 continue;
503 }
504
505 // A saved call is always finished — nothing is still
506 // running when the session file is written.
507 let id = call["id"].as_str().unwrap_or_default();
508 // A call saved without an id would otherwise collapse into
509 // one entry per replay; give each its own.
510 let replay_id = if id.is_empty() {
511 format!("replay-{}", uuid::Uuid::new_v4())
512 } else {
513 id.to_string()
514 };
515 let output = outputs.get(id).copied().unwrap_or_default();
516 let mut tool_call = ToolCall::new(replay_id, chat::tool_title(name, arguments))
517 .kind(tool_kind_for(name))
518 .status(ToolCallStatus::Completed)
519 .content(vec![tool_output_content(output)])
520 .locations(tool_call_locations(name, arguments))
521 .raw_input(serde_json::from_str::<serde_json::Value>(arguments).ok());
522 if let Some(output) = outputs.get(id) {
523 tool_call =
524 tool_call.raw_output(serde_json::Value::String((*output).to_string()));
525 }
526 updates.push(SessionUpdate::ToolCall(tool_call));
527 }
528 }
529 // Folded into the tool call above.
530 "tool" => {}
531 other => log::debug!("session replay: skipping unknown history role '{other}'"),
532 }
533 }
534 updates
535 }
536
537 fn todos_arguments_to_plan(arguments: &str) -> Option<Plan> {
538 let args: serde_json::Value = serde_json::from_str(arguments).ok()?;
539 let todos = args.get("todos")?.as_array()?;
540 if todos.is_empty() {
541 return None;
542 }
543
544 let mut entries = Vec::with_capacity(todos.len());
545 for todo in todos {
546 let content = todo.get("content")?.as_str()?.trim();
547 if content.is_empty() {
548 return None;
549 }
550 // Unknown and absent statuses both fall back to pending, matching how
551 // `exec_write_todos` renders them. The two have to agree: the executor
552 // reports success for an off-enum status, so rejecting the plan here
553 // would leave a stale list on screen while the model believes it
554 // reported progress.
555 let status = match todo.get("status").and_then(serde_json::Value::as_str) {
556 Some("completed") => PlanEntryStatus::Completed,
557 Some("in_progress") => PlanEntryStatus::InProgress,
558 _ => PlanEntryStatus::Pending,
559 };
560 entries.push(PlanEntry::new(content, PlanEntryPriority::Medium, status));
561 }
562
563 Some(Plan::new(entries))
564 }
565
566 /// Shown when a siGit Code Cloud tier is selected without a signed-in account.
567 const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
568 `/login <email> <password>` (or the Authenticate button), then pick the tier again. \
569 Create an account at https://sigit.si.";
570
571 /// The per-session context system message: root-directory guidance plus any
572 /// project instruction files (`AGENTS.md` / `CLAUDE.md`) found for those roots.
573 /// Used by every session entry point so on-device and cloud backends get the
574 /// same always-on project context.
575 ///
576 /// `additional_roots` is empty for an ordinary project and non-empty for a
577 /// multi-root one, where the editor opened several directories at once (see
578 /// `workspace.rs`).
579 fn session_context_message(cwd: &std::path::Path, additional_roots: &[PathBuf]) -> String {
580 let mut message = if additional_roots.is_empty() {
581 format!(
582 "The user's project working directory is {}. \
583 Always use absolute paths under this directory for all file \
584 and directory operations. This is the root of the project \
585 the user has open in their editor.",
586 cwd.display()
587 )
588 } else {
589 let mut roots = format!("- {} (primary)", cwd.display());
590 for root in additional_roots {
591 roots.push_str(&format!("\n- {}", root.display()));
592 }
593 format!(
594 "The user has a multi-root project open in their editor, with these \
595 root directories:\n{roots}\n\
596 All of them are in scope: read, search and edit files in any of them. \
597 Always use absolute paths for file and directory operations, since a \
598 relative path resolves against the primary root only. When the user \
599 names a file without saying which root it is in, look for it in each \
600 root before asking."
601 )
602 };
603 if let Some(project_instructions) =
604 instructions::load_workspace_instructions(cwd, additional_roots)
605 {
606 message.push_str("\n\n");
607 message.push_str(&project_instructions);
608 }
609 message
610 }
611
612 fn agent_tools_as_specs() -> Vec<ToolSpec> {
613 let mut specs: Vec<ToolSpec> = tools::all_tools()
614 .into_iter()
615 .map(|t| ToolSpec {
616 name: t.name.to_string(),
617 description: t.description.to_string(),
618 parameters_schema: t.parameters_schema.to_string(),
619 })
620 .collect();
621
622 // Advertise the `skill` tool only when skills are present, so models without
623 // any skills installed don't see a dangling capability (Agent Skills format,
624 // https://agentskills.io). Discovery metadata lives in the tool description.
625 let discovered = skills::discover_skills();
626 if !discovered.is_empty() {
627 specs.push(ToolSpec {
628 name: skills::SKILL_TOOL_NAME.to_string(),
629 description: skills::skill_tool_description(&discovered),
630 parameters_schema: skills::skill_tool_schema().to_string(),
631 });
632 }
633
634 // Delegated research (`task`) is offered only when a subagent backend can
635 // actually be built — same conditional pattern as the `skill` tool above.
636 if tools::subagent_available() {
637 specs.push(tools::task_tool_spec());
638 }
639
640 // `web_search` is a native wrapper around the official MCP server's
641 // `web_search` tool (see the module doc in tools.rs), offered only when
642 // that MCP tool was actually discovered (i.e. the user is signed in to
643 // siGit Code Cloud).
644 let web_search_offered = tools::web_search_available();
645 if web_search_offered {
646 specs.push(tools::web_search_tool_spec());
647 }
648
649 // Tools discovered from configured MCP servers (incl. the official one).
650 // Exclude the raw web_search delegate when the native wrapper above is
651 // already offered, so the model sees one clean option, not two names for
652 // the same tool.
653 specs.extend(
654 mcp::tool_specs()
655 .into_iter()
656 .filter(|spec| !(web_search_offered && tools::is_web_search_delegate(&spec.name))),
657 );
658
659 specs
660 }
661
662 /// Register the `task` tool's subagent factory for an OpenAI-compatible
663 /// provider: each subagent run gets a FRESH `OpenAiBackend` against the same
664 /// endpoint (its own conversation history), seeded with whichever system
665 /// prompt the caller resolved (the default subagent prompt, or a configured
666 /// subagent type's prompt — see `tools::exec_task_with`). The provider config
667 /// is captured by clone. Called once at startup by whichever surface resolved
668 /// the provider; on-device paths register a factory that returns `None`
669 /// instead (onde has a single shared history, so a second concurrent context
670 /// is not possible yet).
671 fn register_subagent_factory_for(cfg: &provider::ProviderConfig) {
672 let cfg = cfg.clone();
673 tools::set_subagent_factory(Box::new(move |system_prompt: &str| {
674 Some(Arc::new(OpenAiBackend::new(
675 cfg.base_url.clone(),
676 cfg.api_key.clone(),
677 cfg.model.clone(),
678 Some(system_prompt.to_string()),
679 )) as Arc<dyn InferenceBackend>)
680 }));
681 }
682
683 fn initialize_meta() -> Meta {
684 let startup_selection = setup::startup_model_selection();
685
686 let active_model_name = startup_selection
687 .as_ref()
688 .map(|selection| selection.display_name.clone())
689 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
690
691 let active_model_id = startup_selection
692 .as_ref()
693 .and_then(|selection| selection.selected_model.as_ref())
694 .map(|selected| selected.model_id.clone())
695 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().model_id);
696
697 let active_model_file = startup_selection
698 .as_ref()
699 .and_then(|selection| selection.selected_model.as_ref())
700 .map(|selected| selected.gguf_file.clone())
701 .unwrap_or_else(|| {
702 GgufModelConfig::qwen25_3b()
703 .files
704 .first()
705 .cloned()
706 .unwrap_or_default()
707 });
708
709 let mut model = serde_json::Map::new();
710 model.insert(
711 "display_name".to_string(),
712 serde_json::Value::String(active_model_name),
713 );
714 model.insert(
715 "model_id".to_string(),
716 serde_json::Value::String(active_model_id),
717 );
718 model.insert(
719 "gguf_file".to_string(),
720 serde_json::Value::String(active_model_file),
721 );
722
723 let mut sigit = serde_json::Map::new();
724 sigit.insert("active_model".to_string(), serde_json::Value::Object(model));
725
726 let mut meta = Meta::new();
727 meta.insert("sigit".to_string(), serde_json::Value::Object(sigit));
728 meta
729 }
730
731 struct SiGitAgent {
732 engine: Arc<ChatEngine>,
733 /// The active inference backend. `LocalBackend` by default; swapped to an
734 /// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel.
735 backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>,
736 /// cwd from the editor — tool calls run here, not where the process started
737 session_cwd: std::sync::Mutex<Option<PathBuf>>,
738 current_model: std::sync::Mutex<GgufModelConfig>,
739 /// flipped once the startup model finishes (success or failure)
740 model_ready: Arc<AtomicBool>,
741 /// guards the one-time lazy startup load for ACP mode
742 startup_model_load_started: Arc<AtomicBool>,
743 /// set if the startup load failed
744 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
745 /// true when the startup model isn't cached yet
746 startup_needs_download: bool,
747 /// Xcode launches custom agents with `--acp`; that mode should be ready to
748 /// answer the first prompt without requiring an editor slash command.
749 auto_load_local_model: bool,
750 /// for progress UI
751 startup_model_name: String,
752 /// for download-progress polling
753 startup_model_id: String,
754 /// Serializes turn-affecting handlers (prompt, session lifecycle, config
755 /// changes). They run in `cx.spawn`ed tasks so the JSON-RPC dispatch loop
756 /// stays free to route client responses (e.g. permission answers) mid-turn;
757 /// this lock reproduces the strict ordering the dispatch loop used to give
758 /// them for free.
759 turn_lock: Arc<tokio::sync::Mutex<()>>,
760 }
761
762 impl SiGitAgent {
763 fn new(
764 engine: Arc<ChatEngine>,
765 initial_model: GgufModelConfig,
766 model_ready: Arc<AtomicBool>,
767 startup_model_load_started: Arc<AtomicBool>,
768 model_load_error: Arc<std::sync::Mutex<Option<String>>>,
769 startup_needs_download: bool,
770 auto_load_local_model: bool,
771 ) -> Self {
772 let startup_model_name = initial_model.display_name.clone();
773 let startup_model_id = initial_model.model_id.clone();
774 let backend: Arc<dyn InferenceBackend> = Arc::new(LocalBackend::new(Arc::clone(&engine)));
775 Self {
776 engine,
777 backend: tokio::sync::Mutex::new(backend),
778 session_cwd: std::sync::Mutex::new(None),
779 current_model: std::sync::Mutex::new(initial_model),
780 model_ready,
781 startup_model_load_started,
782 model_load_error,
783 startup_needs_download,
784 auto_load_local_model,
785 startup_model_name,
786 startup_model_id,
787 turn_lock: Arc::new(tokio::sync::Mutex::new(())),
788 }
789 }
790
791 fn start_startup_model_load_if_needed(&self) {
792 if self
793 .startup_model_load_started
794 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
795 .is_err()
796 {
797 return;
798 }
799
800 self.model_ready.store(false, Ordering::Release);
801 if let Ok(mut guard) = self.model_load_error.lock() {
802 *guard = None;
803 }
804
805 let startup_config = self.current_model.lock().unwrap().clone();
806 let (max_tokens, tool_calling) = models::local_picker_items()
807 .into_iter()
808 .find(|item| {
809 item.config.model_id == startup_config.model_id
810 && item
811 .config
812 .files
813 .first()
814 .zip(startup_config.files.first())
815 .map(|(left, right)| left == right)
816 .unwrap_or(false)
817 })
818 .map(|item| (item.max_tokens, item.tool_calling))
819 .unwrap_or((4096, false));
820
821 let sampling = SamplingConfig {
822 max_tokens: Some(max_tokens),
823 ..SamplingConfig::default()
824 };
825
826 let loader_engine = Arc::clone(&self.engine);
827 let loader_system_prompt = system_prompt_for_model(tool_calling).to_string();
828 let model_ready = Arc::clone(&self.model_ready);
829 let model_load_error = Arc::clone(&self.model_load_error);
830
831 std::thread::spawn(move || {
832 let result = tokio::runtime::Runtime::new()
833 .map_err(|error| error.to_string())
834 .and_then(|rt| {
835 rt.block_on(loader_engine.load_gguf_model(
836 startup_config,
837 Some(loader_system_prompt),
838 Some(sampling),
839 ))
840 .map(|_| ())
841 .map_err(|error| error.to_string())
842 });
843
844 if let Ok(mut guard) = model_load_error.lock() {
845 *guard = result.err();
846 }
847 model_ready.store(true, Ordering::Release);
848 });
849 }
850
851 /// block until the startup model is ready, showing progress in the session.
852 async fn await_model_ready(
853 &self,
854 cx: &ConnectionTo<Client>,
855 session_id: &SessionId,
856 ) -> agent_client_protocol::Result<()> {
857 if self.model_ready.load(Ordering::Acquire) {
858 // already done — might be a stored error from earlier
859 if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
860 return Err(agent_client_protocol::Error::new(
861 -32603,
862 format!("model load failed: {err}"),
863 ));
864 }
865 return Ok(());
866 }
867
868 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
869
870 let tool_call_id = format!("startup-load-{}", uuid::Uuid::new_v4());
871 let title = if self.startup_needs_download {
872 format!("Downloading {}", self.startup_model_name)
873 } else {
874 format!("Loading {}", self.startup_model_name)
875 };
876
877 self.send_tool_call_update(
878 cx,
879 session_id.clone(),
880 SessionUpdate::ToolCall(
881 ToolCall::new(tool_call_id.clone(), &title)
882 .kind(ToolKind::Think)
883 .status(ToolCallStatus::InProgress)
884 .content(vec![format!("{}…", title).into()]),
885 ),
886 )
887 .ok();
888
889 let expected_bytes = if self.startup_needs_download {
890 onde::inference::models::SUPPORTED_MODEL_INFO
891 .iter()
892 .find(|m| m.id == self.startup_model_id)
893 .map(|m| m.expected_size_bytes)
894 .unwrap_or(0)
895 } else {
896 0
897 };
898
899 let load_start = std::time::Instant::now();
900 let mut tick: usize = 0;
901 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
902 interval.tick().await;
903
904 loop {
905 interval.tick().await;
906 tick += 1;
907
908 if self.model_ready.load(Ordering::Acquire) {
909 break;
910 }
911
912 let frame = SPINNER[tick % SPINNER.len()];
913 let elapsed = load_start.elapsed();
914 let elapsed_str = if elapsed.as_secs() >= 60 {
915 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
916 } else {
917 format!("{}s", elapsed.as_secs())
918 };
919
920 let (update_title, update_content) =
921 if self.startup_needs_download && expected_bytes > 0 {
922 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
923 let downloaded = cache_path
924 .as_ref()
925 .filter(|p| p.exists())
926 .map(|p| dir_size_recursive(p))
927 .unwrap_or(0);
928 let pct = ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
929 let bar = progress_bar(pct, 20);
930 let size_hint = format!(" (~{})", format_size_human(expected_bytes));
931 (
932 format!(
933 "{frame} Downloading {}{size_hint} ({pct}%)",
934 self.startup_model_name
935 ),
936 format!(
937 "{} — {bar} {pct}% ({} / {})",
938 self.startup_model_name,
939 format_size_human(downloaded),
940 format_size_human(expected_bytes),
941 ),
942 )
943 } else if self.startup_needs_download {
944 let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
945 let downloaded = cache_path
946 .as_ref()
947 .filter(|p| p.exists())
948 .map(|p| dir_size_recursive(p))
949 .unwrap_or(0);
950 (
951 format!("{frame} Downloading {}", self.startup_model_name),
952 format!(
953 "{} — {} downloaded… ({elapsed_str})",
954 self.startup_model_name,
955 format_size_human(downloaded),
956 ),
957 )
958 } else {
959 (
960 format!("{frame} Loading {}", self.startup_model_name),
961 format!(
962 "{frame} Loading {}… ({elapsed_str})",
963 self.startup_model_name
964 ),
965 )
966 };
967
968 self.send_tool_call_update(
969 cx,
970 session_id.clone(),
971 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
972 tool_call_id.clone(),
973 ToolCallUpdateFields::new()
974 .title(update_title)
975 .status(ToolCallStatus::InProgress)
976 .content(vec![update_content.into()]),
977 )),
978 )
979 .ok();
980 }
981
982 // done — check if it blew up
983 let load_error = self.model_load_error.lock().unwrap().clone();
984 if let Some(err) = load_error {
985 self.send_tool_call_update(
986 cx,
987 session_id.clone(),
988 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
989 tool_call_id,
990 ToolCallUpdateFields::new()
991 .title("Model load failed".to_string())
992 .status(ToolCallStatus::Failed)
993 .content(vec![format!("error: {err}").into()]),
994 )),
995 )
996 .ok();
997
998 return Err(agent_client_protocol::Error::new(
999 -32603,
1000 format!("model load failed: {err}"),
1001 ));
1002 }
1003
1004 let done_title = if self.startup_needs_download {
1005 format!("✓ {} downloaded and loaded", self.startup_model_name)
1006 } else {
1007 format!("✓ {} loaded", self.startup_model_name)
1008 };
1009
1010 self.send_tool_call_update(
1011 cx,
1012 session_id.clone(),
1013 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1014 tool_call_id,
1015 ToolCallUpdateFields::new()
1016 .title(done_title)
1017 .status(ToolCallStatus::Completed),
1018 )),
1019 )
1020 .ok();
1021
1022 Ok(())
1023 }
1024
1025 fn send_assistant_message(
1026 &self,
1027 cx: &ConnectionTo<Client>,
1028 session_id: SessionId,
1029 text: impl Into<String>,
1030 ) -> agent_client_protocol::Result<()> {
1031 cx.send_notification(SessionNotification::new(
1032 session_id,
1033 SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))),
1034 ))
1035 }
1036
1037 fn send_system_status(
1038 &self,
1039 cx: &ConnectionTo<Client>,
1040 session_id: SessionId,
1041 title: impl Into<String>,
1042 ) -> agent_client_protocol::Result<()> {
1043 cx.send_notification(SessionNotification::new(
1044 session_id,
1045 SessionUpdate::ToolCall(
1046 ToolCall::new(
1047 format!("system-status-{}", uuid::Uuid::new_v4()),
1048 title.into(),
1049 )
1050 .kind(ToolKind::Think)
1051 .status(ToolCallStatus::Completed),
1052 ),
1053 ))
1054 }
1055
1056 /// Run one inference turn (`fut`) while concurrently forwarding any streamed
1057 /// tokens to the editor. The sink receiver is drained as the future runs, so
1058 /// chunks reach the client live rather than all at once when it resolves.
1059 ///
1060 /// `reply` persists across the turns of a single prompt so reasoning is
1061 /// stripped consistently and we never re-send text.
1062 async fn drain_turn<F>(
1063 &self,
1064 cx: &ConnectionTo<Client>,
1065 session_id: &SessionId,
1066 fut: F,
1067 sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1068 reply: &mut StreamedReply,
1069 ) -> Result<TurnResult, backend::BackendError>
1070 where
1071 F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
1072 {
1073 tokio::pin!(fut);
1074 let result = loop {
1075 tokio::select! {
1076 done = &mut fut => break done,
1077 Some(piece) = sink_rx.recv() => {
1078 self.emit_visible_chunk(cx, session_id, &piece, reply);
1079 }
1080 }
1081 };
1082 // Flush tokens that landed between the last poll and the future resolving.
1083 while let Ok(piece) = sink_rx.try_recv() {
1084 self.emit_visible_chunk(cx, session_id, &piece, reply);
1085 }
1086 result
1087 }
1088
1089 /// Fold a streamed fragment into `reply` and send whatever it newly reveals
1090 /// as an agent-message chunk.
1091 fn emit_visible_chunk(
1092 &self,
1093 cx: &ConnectionTo<Client>,
1094 session_id: &SessionId,
1095 piece: &str,
1096 reply: &mut StreamedReply,
1097 ) {
1098 if let Some(extra) = reply.push(piece) {
1099 self.send_assistant_message(cx, session_id.clone(), extra)
1100 .ok();
1101 }
1102 }
1103
1104 fn send_tool_call_update(
1105 &self,
1106 cx: &ConnectionTo<Client>,
1107 session_id: SessionId,
1108 update: SessionUpdate,
1109 ) -> agent_client_protocol::Result<()> {
1110 cx.send_notification(SessionNotification::new(session_id, update))
1111 }
1112
1113 fn send_plan_update(
1114 &self,
1115 cx: &ConnectionTo<Client>,
1116 session_id: SessionId,
1117 plan: Plan,
1118 ) -> agent_client_protocol::Result<()> {
1119 cx.send_notification(SessionNotification::new(
1120 session_id,
1121 SessionUpdate::Plan(plan),
1122 ))
1123 }
1124
1125 /// Advertise siGit's slash commands to the client. Editors like Zed parse
1126 /// `/`-prefixed input and only forward commands they've been told about, so
1127 /// without this `/login`, `/models`, etc. are rejected client-side.
1128 fn advertise_commands(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
1129 let with_hint = |name: &str, desc: &str, hint: &str| {
1130 AvailableCommand::new(name, desc).input(AvailableCommandInput::Unstructured(
1131 UnstructuredCommandInput::new(hint),
1132 ))
1133 };
1134 let mut commands = vec![
1135 AvailableCommand::new("help", "Show available commands"),
1136 AvailableCommand::new("models", "List available models").input(
1137 AvailableCommandInput::Unstructured(UnstructuredCommandInput::new(
1138 "model number to switch to (optional)",
1139 )),
1140 ),
1141 with_hint(
1142 "local",
1143 "Toggle on-device inference mode",
1144 "on|off (optional)",
1145 ),
1146 AvailableCommand::new("skills", "List available Agent Skills"),
1147 AvailableCommand::new(
1148 "agents",
1149 "List configured subagent types (.sigit/agents/*.md) for task",
1150 ),
1151 AvailableCommand::new(
1152 "commands",
1153 "List user-defined commands (.sigit/commands/*.md)",
1154 ),
1155 AvailableCommand::new("mcp", "List MCP servers and their tools"),
1156 AvailableCommand::new("load", "Load the selected on-device model"),
1157 with_hint(
1158 "login",
1159 "Sign in to siGit Code Cloud (opens your browser)",
1160 "[email] [password]",
1161 ),
1162 AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
1163 AvailableCommand::new("whoami", "Show the signed-in account"),
1164 AvailableCommand::new("reload", "Re-sync sign-in and model state"),
1165 with_hint(
1166 "plan",
1167 "Plan mode: research only, no edits or commands",
1168 "on|off (optional)",
1169 ),
1170 AvailableCommand::new("permissions", "Show the tool permission policy"),
1171 AvailableCommand::new("compact", "Summarize and shrink the conversation history"),
1172 AvailableCommand::new("init", "Generate or improve the repo's AGENTS.md"),
1173 AvailableCommand::new("clear", "Wipe the conversation history"),
1174 AvailableCommand::new("status", "Show engine status"),
1175 ];
1176
1177 // User-defined commands (.sigit/commands/*.md etc.) are appended so
1178 // clients that only forward advertised commands (Zed) can actually
1179 // send them. A custom command sharing a name with a built-in is
1180 // unreachable — parse_slash always matches built-ins first — so skip
1181 // advertising the shadowed duplicate rather than confuse the client.
1182 let built_in_names: Vec<String> = commands.iter().map(|c| c.name.to_string()).collect();
1183 for command in commands::discover_commands() {
1184 if built_in_names.contains(&command.name) {
1185 log::warn!(
1186 "custom command \"{}\" at {} is shadowed by a built-in command of the same name",
1187 command.name,
1188 command.path.display()
1189 );
1190 continue;
1191 }
1192 let description = command
1193 .description
1194 .clone()
1195 .unwrap_or_else(|| "User-defined command".to_string());
1196 commands.push(match &command.argument_hint {
1197 Some(hint) => with_hint(&command.name, &description, hint),
1198 None => AvailableCommand::new(&command.name, &description),
1199 });
1200 }
1201
1202 self.send_tool_call_update(
1203 cx,
1204 session_id,
1205 SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)),
1206 )
1207 .ok();
1208 }
1209
1210 async fn switch_model_by_id(
1211 &self,
1212 model_id: &str,
1213 ) -> agent_client_protocol::Result<GgufModelConfig> {
1214 let (new_config, max_tokens, new_tool_calling) = resolve_model_config(model_id)
1215 .ok_or_else(|| {
1216 agent_client_protocol::Error::new(
1217 -32602,
1218 format!("unknown or unavailable model: {model_id}"),
1219 )
1220 })?;
1221
1222 log::info!(
1223 "switching model to {} (max_tokens={max_tokens})",
1224 new_config.display_name
1225 );
1226
1227 // Loading weights resets the engine's history, which would restart the
1228 // conversation under the new model. Keep the turns so far and replay
1229 // them below, once the new prompt and session context are seeded.
1230 let carried = self
1231 .engine
1232 .history()
1233 .await
1234 .into_iter()
1235 .filter(|message| message.role != onde::inference::ChatRole::System)
1236 .collect::<Vec<_>>();
1237
1238 let sampling = SamplingConfig {
1239 max_tokens: Some(max_tokens),
1240 ..SamplingConfig::default()
1241 };
1242
1243 // block_in_place inside spawn_local panics, so run the load on a
1244 // dedicated thread with its own runtime (same trick as startup)
1245 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
1246 let loader_engine = Arc::clone(&self.engine);
1247 let loader_config = new_config.clone();
1248 let loader_system_prompt = system_prompt_for_model(new_tool_calling).to_string();
1249 let loader_sampling = sampling;
1250
1251 std::thread::spawn(move || {
1252 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1253 let result = rt.block_on(async move {
1254 // load_gguf_model already unloads the old model internally;
1255 // calling unload first would leave a gap where prompts fail
1256 loader_engine
1257 .load_gguf_model(
1258 loader_config,
1259 Some(loader_system_prompt),
1260 Some(loader_sampling),
1261 )
1262 .await
1263 });
1264 let _ = result_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
1265 });
1266
1267 result_rx
1268 .await
1269 .map_err(|_| agent_client_protocol::Error::new(-32603, "model loader thread crashed"))?
1270 .map_err(|error| {
1271 log::error!("model switch failed: {error}");
1272 agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}"))
1273 })?;
1274
1275 self.startup_model_load_started
1276 .store(true, Ordering::Release);
1277 self.model_ready.store(true, Ordering::Release);
1278 if let Ok(mut guard) = self.model_load_error.lock() {
1279 *guard = None;
1280 }
1281
1282 if let Some(item) = models::local_picker_items()
1283 .iter()
1284 .find(|item| item.config.model_id == new_config.model_id)
1285 && let Err(err) = setup::save_selected_model(&setup::SelectedModel {
1286 model_id: item.config.model_id.clone(),
1287 gguf_file: item.config.files.first().cloned().unwrap_or_default(),
1288 })
1289 {
1290 log::warn!("failed to persist model selection: {err}");
1291 }
1292
1293 {
1294 let mut guard = self.current_model.lock().unwrap();
1295 *guard = new_config.clone();
1296 }
1297
1298 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1299 self.engine
1300 .push_history(onde::inference::ChatMessage::system(
1301 session_context_message(&cwd, &workspace::additional_roots()),
1302 ))
1303 .await;
1304 }
1305
1306 for message in carried {
1307 self.engine.push_history(message).await;
1308 }
1309
1310 Ok(new_config)
1311 }
1312 }
1313
1314 // ── ACP handler implementations ───────────────────────────────────────────────
1315
1316 impl SiGitAgent {
1317 async fn handle_initialize(
1318 &self,
1319 _req: InitializeRequest,
1320 ) -> agent_client_protocol::Result<InitializeResponse> {
1321 log::info!("initialize");
1322
1323 // Agent-handled auth method. We don't use `AuthMethod::Terminal`: editors
1324 // like Zed advertise terminal-auth capability but don't actually spawn the
1325 // login terminal for *custom* ACP agents, so the button is a silent no-op.
1326 // With an Agent method, clicking calls `authenticate`, which opens the
1327 // browser and finishes the sign-in itself.
1328 let auth_methods = vec![AuthMethod::Agent(
1329 AuthMethodAgent::new("sigit", "Sign in to siGit Code")
1330 .description("Opens sigit.si in your browser to authorize this device."),
1331 )];
1332
1333 Ok(InitializeResponse::new(ProtocolVersion::V1)
1334 .agent_info(
1335 Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
1336 .title("siGit Code - AI Coding Agent"),
1337 )
1338 .auth_methods(auth_methods)
1339 .agent_capabilities(
1340 AgentCapabilities::default()
1341 .load_session(true)
1342 .session_capabilities(
1343 SessionCapabilities::new()
1344 .fork(SessionForkCapabilities::new())
1345 // Without this, a client that has several directories
1346 // open never sends the extra ones: Zed drops every root
1347 // but the first and tells the user the agent has no
1348 // multi-root support. See `workspace.rs`.
1349 .additional_directories(SessionAdditionalDirectoriesCapabilities::new()),
1350 ),
1351 )
1352 .meta(initialize_meta()))
1353 }
1354
1355 async fn handle_authenticate(
1356 &self,
1357 req: AuthenticateRequest,
1358 ) -> agent_client_protocol::Result<AuthenticateResponse> {
1359 log::info!("authenticate: method={}", req.method_id.0);
1360
1361 // An existing session clears the gate without sending anyone to a
1362 // browser they didn't ask for.
1363 if let Ok(email) = account::verify_session().await {
1364 log::info!("authenticate: verified existing session for {email}");
1365 return Ok(AuthenticateResponse::default());
1366 }
1367
1368 // Otherwise run the browser flow. There is no way to collect a password
1369 // here (the method has no UI of its own) and no way to show a code to
1370 // paste, so this is the loopback variant or nothing: the editor gets an
1371 // error naming the terminal command that does work without a listener.
1372 let flow = browser_auth::begin();
1373 if !flow.is_loopback() {
1374 return Err(agent_client_protocol::Error::new(
1375 -32000,
1376 "Could not open a local port to receive the sign-in. \
1377 Run `sigit login` in a terminal instead."
1378 .to_string(),
1379 ));
1380 }
1381
1382 log::info!("authenticate: opening browser at {}", flow.authorize_url());
1383 if !browser_auth::open_browser(flow.authorize_url()) {
1384 log::warn!("authenticate: no browser could be launched");
1385 }
1386
1387 match browser_auth::complete_loopback(flow).await {
1388 Ok(email) => {
1389 log::info!("authenticate: signed in as {email}");
1390 Ok(AuthenticateResponse::default())
1391 }
1392 Err(reason) => Err(agent_client_protocol::Error::new(
1393 -32000,
1394 format!(
1395 "Sign-in did not complete ({reason}). \
1396 Try again, or create an account at https://sigit.si."
1397 ),
1398 )),
1399 }
1400 }
1401
1402 /// Record the session's roots and move the process into the primary one.
1403 ///
1404 /// The editor sends `cwd` plus, for a multi-root project, the other
1405 /// directories it has open. Tool calls may use relative paths, so the
1406 /// process follows `cwd`; the extra roots go to `workspace`, which is where
1407 /// project-local discovery picks them up. Returns the roots that were kept.
1408 fn enter_session_roots(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> Vec<PathBuf> {
1409 if let Ok(mut guard) = self.session_cwd.lock() {
1410 *guard = Some(cwd.to_path_buf());
1411 }
1412 let additional_roots = workspace::set_additional_roots(cwd, additional);
1413 if cwd.is_dir()
1414 && let Err(err) = std::env::set_current_dir(cwd)
1415 {
1416 log::warn!("could not set cwd to {}: {err}", cwd.display());
1417 }
1418 additional_roots
1419 }
1420
1421 async fn handle_load_session(
1422 &self,
1423 cx: &ConnectionTo<Client>,
1424 args: LoadSessionRequest,
1425 ) -> agent_client_protocol::Result<LoadSessionResponse> {
1426 log::info!(
1427 "load_session: id={}, cwd={}, additional_directories={:?}",
1428 args.session_id,
1429 args.cwd.display(),
1430 args.additional_directories
1431 .iter()
1432 .map(|p| p.display().to_string())
1433 .collect::<Vec<_>>()
1434 );
1435
1436 let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1437
1438 // A session boundary: grants and plan mode from the previous life of
1439 // this session id must not carry over — and since one shared engine
1440 // means one live conversation, state for every other id is dead too.
1441 permissions::reset_all();
1442
1443 // start from a clean slate; a stored session (below) replaces it
1444 self.engine.clear_history().await;
1445
1446 self.engine
1447 .push_history(onde::inference::ChatMessage::system(
1448 session_context_message(&args.cwd, &additional_roots),
1449 ))
1450 .await;
1451
1452 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1453 self.apply_startup_inference_mode().await;
1454
1455 // Durable sessions: when this session id was saved before, restore its
1456 // history into the active backend. The snapshot includes the system
1457 // messages that were live when it was saved, so restore replaces the
1458 // freshly seeded state wholesale.
1459 if let Some(history) = session_store::load(&args.session_id.to_string()) {
1460 let restored = history.len();
1461
1462 // Replay before restoring: the client draws the reopened thread
1463 // from these notifications alone, and `restore_history` consumes
1464 // the snapshot.
1465 let updates = history_replay_updates(&history);
1466 let replayed = updates.len();
1467 for update in updates {
1468 cx.send_notification(SessionNotification::new(args.session_id.clone(), update))
1469 .ok();
1470 }
1471
1472 let backend = self.backend.lock().await.clone();
1473 backend.restore_history(history).await;
1474 log::info!(
1475 "load_session: restored {restored} message(s), replayed {replayed} update(s) for {}",
1476 args.session_id
1477 );
1478 }
1479
1480 let config_options = {
1481 let guard = self.current_model.lock().unwrap();
1482 build_config_options(&guard, &args.session_id.to_string())
1483 };
1484
1485 self.advertise_commands(cx, args.session_id.clone());
1486
1487 // Run SessionStart hooks
1488 let hook_settings = settings::load().hooks;
1489 if hook_settings.has_hooks() {
1490 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &args.session_id.to_string());
1491 }
1492
1493 Ok(LoadSessionResponse::new().config_options(config_options))
1494 }
1495
1496 async fn handle_fork_session(
1497 &self,
1498 cx: &ConnectionTo<Client>,
1499 args: ForkSessionRequest,
1500 ) -> agent_client_protocol::Result<ForkSessionResponse> {
1501 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1502 // Session boundary: permission grants and plan mode never cross it
1503 // (see handle_load_session), so a fork starts with a clean slate.
1504 permissions::reset_all();
1505 log::info!(
1506 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
1507 args.session_id,
1508 args.cwd.display(),
1509 args.additional_directories
1510 .iter()
1511 .map(|p| p.display().to_string())
1512 .collect::<Vec<_>>()
1513 );
1514
1515 let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1516
1517 // no persistence, so fork == fresh session
1518 self.engine.clear_history().await;
1519
1520 self.engine
1521 .push_history(onde::inference::ChatMessage::system(
1522 session_context_message(&args.cwd, &additional_roots),
1523 ))
1524 .await;
1525
1526 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1527 self.apply_startup_inference_mode().await;
1528
1529 let config_options = {
1530 let guard = self.current_model.lock().unwrap();
1531 build_config_options(&guard, &new_id.to_string())
1532 };
1533
1534 self.advertise_commands(cx, new_id.clone());
1535
1536 // Run SessionStart hooks
1537 let hook_settings = settings::load().hooks;
1538 if hook_settings.has_hooks() {
1539 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &new_id.to_string());
1540 }
1541
1542 Ok(ForkSessionResponse::new(new_id).config_options(config_options))
1543 }
1544
1545 async fn handle_new_session(
1546 &self,
1547 cx: &ConnectionTo<Client>,
1548 args: NewSessionRequest,
1549 ) -> agent_client_protocol::Result<NewSessionResponse> {
1550 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1551 // Session boundary: permission grants and plan mode never cross it
1552 // (see handle_load_session), so stale ids stop accumulating state.
1553 permissions::reset_all();
1554 log::info!(
1555 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
1556 args.cwd.display(),
1557 args.additional_directories
1558 .iter()
1559 .map(|p| p.display().to_string())
1560 .collect::<Vec<_>>()
1561 );
1562
1563 let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1564
1565 self.engine.clear_history().await;
1566
1567 self.engine
1568 .push_history(onde::inference::ChatMessage::system(
1569 session_context_message(&args.cwd, &additional_roots),
1570 ))
1571 .await;
1572
1573 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1574 self.apply_startup_inference_mode().await;
1575
1576 let config_options = {
1577 let guard = self.current_model.lock().unwrap();
1578 build_config_options(&guard, &session_id.to_string())
1579 };
1580
1581 self.advertise_commands(cx, session_id.clone());
1582
1583 // Run SessionStart hooks
1584 let hook_settings = settings::load().hooks;
1585 if hook_settings.has_hooks() {
1586 hooks::run_session_start_hooks(&hook_settings, &args.cwd, &session_id.to_string());
1587 }
1588
1589 Ok(NewSessionResponse::new(session_id).config_options(config_options))
1590 }
1591
1592 async fn handle_prompt(
1593 &self,
1594 cx: &ConnectionTo<Client>,
1595 args: PromptRequest,
1596 ) -> agent_client_protocol::Result<PromptResponse> {
1597 let session_id = args.session_id.clone();
1598
1599 // log every block so we can debug @ references and file context
1600 for (i, block) in args.prompt.iter().enumerate() {
1601 match block {
1602 ContentBlock::Text(t) => {
1603 log::info!(
1604 "prompt({}) block[{}]: Text({} chars) = \"{}\"",
1605 session_id,
1606 i,
1607 t.text.len(),
1608 t.text.chars().take(200).collect::<String>()
1609 );
1610 }
1611 ContentBlock::Resource(embedded) => {
1612 log::info!(
1613 "prompt({}) block[{}]: EmbeddedResource = {:?}",
1614 session_id,
1615 i,
1616 match &embedded.resource {
1617 EmbeddedResourceResource::TextResourceContents(t) =>
1618 format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()),
1619 EmbeddedResourceResource::BlobResourceContents(b) =>
1620 format!("BlobResource(uri={})", b.uri),
1621 _ => "Unknown".to_string(),
1622 }
1623 );
1624 }
1625 ContentBlock::ResourceLink(link) => {
1626 log::info!(
1627 "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})",
1628 session_id,
1629 i,
1630 link.name,
1631 link.uri,
1632 link.title,
1633 link.description
1634 );
1635 }
1636 other => {
1637 log::info!(
1638 "prompt({}) block[{}]: Other({:?})",
1639 session_id,
1640 i,
1641 std::mem::discriminant(other)
1642 );
1643 }
1644 }
1645 }
1646
1647 let mut parts: Vec<String> = Vec::new();
1648
1649 for block in &args.prompt {
1650 match block {
1651 ContentBlock::Text(t) => {
1652 parts.push(t.text.clone());
1653 }
1654 ContentBlock::Resource(embedded) => {
1655 // editor inlined the file content already
1656 match &embedded.resource {
1657 EmbeddedResourceResource::TextResourceContents(text_resource) => {
1658 parts.push(format!(
1659 "\n--- {} ---\n{}\n--- end {} ---",
1660 text_resource.uri, text_resource.text, text_resource.uri
1661 ));
1662 }
1663 EmbeddedResourceResource::BlobResourceContents(blob) => {
1664 parts.push(format!("[binary resource: {}]", blob.uri));
1665 }
1666 _ => {
1667 log::debug!("ignoring unsupported embedded resource variant");
1668 }
1669 }
1670 }
1671 ContentBlock::ResourceLink(link) => {
1672 // reference without content; read the file ourselves
1673 let label = link.name.clone();
1674
1675 if let Some(raw_path) = link.uri.strip_prefix("file://") {
1676 let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') {
1677 let fragment = &raw_path[hash_pos + 1..];
1678 let path = &raw_path[..hash_pos];
1679 // Parse "L207:219" or "L207-219" → (207, 219)
1680 let range = fragment.strip_prefix('L').and_then(|rest| {
1681 let sep = if rest.contains(':') { ':' } else { '-' };
1682 let mut parts = rest.splitn(2, sep);
1683 let start = parts.next()?.parse::<usize>().ok()?;
1684 let end = parts.next()?.parse::<usize>().ok()?;
1685 Some((start, end))
1686 });
1687 (path, range)
1688 } else {
1689 (raw_path, None)
1690 };
1691
1692 match std::fs::read_to_string(file_path) {
1693 Ok(contents) => {
1694 let extracted = if let Some((start, end)) = line_range {
1695 let selected: Vec<&str> = contents
1696 .lines()
1697 .enumerate()
1698 .filter(|(i, _)| {
1699 let line_num = i + 1;
1700 line_num >= start && line_num <= end
1701 })
1702 .map(|(_, line)| line)
1703 .collect();
1704 format!(
1705 "\n--- {label} ({file_path} lines {start}-{end}) ---\n{}\n--- end {label} ---",
1706 selected.join("\n")
1707 )
1708 } else {
1709 format!(
1710 "\n--- {label} ({file_path}) ---\n{contents}\n--- end {label} ---"
1711 )
1712 };
1713 parts.push(extracted);
1714 }
1715 Err(err) => {
1716 log::warn!("could not read ResourceLink {}: {err}", link.uri);
1717 parts.push(format!("[referenced file: {label} ({file_path})]"));
1718 }
1719 }
1720 } else {
1721 parts.push(format!("[resource link: {label} ({})]", link.uri));
1722 }
1723 }
1724 _ => {
1725 log::debug!("ignoring unsupported content block type in prompt");
1726 }
1727 }
1728 }
1729
1730 let user_text = parts.join("\n");
1731
1732 if user_text.trim().is_empty() {
1733 return Ok(PromptResponse::new(StopReason::EndTurn));
1734 }
1735
1736 let slash_command = parse_slash_from_prompt_texts(args.prompt.iter().filter_map(|block| {
1737 if let ContentBlock::Text(text) = block {
1738 Some(text.text.as_str())
1739 } else {
1740 None
1741 }
1742 }));
1743
1744 let user_text = match slash_command {
1745 // `/init` is not a status command: it substitutes the canned
1746 // AGENTS.md-generation prompt and runs a normal agent turn, so the
1747 // exploration and file write go through the ordinary tools and
1748 // permission checks.
1749 Some(SlashCommand::Init) => instructions::INIT_PROMPT.to_string(),
1750 // A word `parse_slash` didn't recognize might be a user-defined
1751 // command (`.sigit/commands/*.md`) — same substitute-and-run-a-
1752 // real-turn treatment as `/init`. Only fall back to "unknown
1753 // command" once that lookup also misses.
1754 Some(SlashCommand::Unknown(command, argument)) => {
1755 match commands::resolve_command(&command) {
1756 Some(custom) => commands::render(&custom.body, argument.as_deref()),
1757 None => {
1758 return exec_slash_acp(
1759 self,
1760 cx,
1761 session_id,
1762 SlashCommand::Unknown(command, argument),
1763 )
1764 .await;
1765 }
1766 }
1767 }
1768 Some(command) => return exec_slash_acp(self, cx, session_id, command).await,
1769 None => user_text,
1770 };
1771
1772 log::info!(
1773 "prompt({}): \"{}\"",
1774 session_id,
1775 user_text.chars().take(80).collect::<String>()
1776 );
1777
1778 // The active backend drives the turn. Snapshot it once so a mid-turn
1779 // model switch doesn't split the conversation across backends.
1780 let backend = self.backend.lock().await.clone();
1781
1782 // Only on-device inference needs a local model in memory. Cloud tiers run
1783 // over the network, so they never need a local model. Xcode's explicit
1784 // `--acp` mode loads lazily on the first prompt because its custom-agent
1785 // UI has no reliable equivalent of siGit's `/load` command.
1786 if !backend.is_remote()
1787 && self.engine.info().await.status == onde::inference::EngineStatus::Unloaded
1788 {
1789 if self.auto_load_local_model {
1790 self.start_startup_model_load_if_needed();
1791 self.await_model_ready(cx, &session_id).await?;
1792 } else {
1793 self.send_assistant_message(
1794 cx,
1795 session_id,
1796 "No on-device model is loaded. Run `/load` to load the selected model, \
1797 or `/models` to choose one.",
1798 )
1799 .ok();
1800 return Ok(PromptResponse::new(StopReason::EndTurn));
1801 }
1802 }
1803
1804 // ── tool-calling loop ────────────────────────────────────────────
1805 // send message → execute any tool calls → feed results back
1806 // repeat up to MAX_TOOL_ROUNDS, then force a text reply
1807
1808 let tools = agent_tools_as_specs();
1809
1810 // Token sink: backends stream assistant text through this while a turn
1811 // runs. We forward the visible portion to the editor as agent-message
1812 // chunks live (see `drain_turn` / `emit_visible_chunk`). The sink stays
1813 // alive for the whole prompt so `recv()` only ends when a turn future
1814 // resolves, never because every sender was dropped.
1815 let (sink, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
1816 let mut reply = StreamedReply::default();
1817 let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
1818
1819 let mut result = self
1820 .drain_turn(
1821 cx,
1822 &session_id,
1823 backend.send_message_with_tools(&user_text, &tools, Some(&sink)),
1824 &mut sink_rx,
1825 &mut reply,
1826 )
1827 .await
1828 .map_err(|error| {
1829 log::error!("send_message_with_tools failed: {error}");
1830 // Verbatim, no prefix: the backend has already turned this into
1831 // something worth reading (see `describe_api_error`), and it is
1832 // what the editor puts in its error banner. The context a
1833 // prefix would add is in the log line above.
1834 agent_client_protocol::Error::new(-32603, error)
1835 })?;
1836
1837 let mut round = 0;
1838
1839 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
1840 round += 1;
1841 log::info!(
1842 "prompt({}) tool round {} — {} call(s)",
1843 session_id,
1844 round,
1845 result.tool_calls.len()
1846 );
1847
1848 // Auto-compaction: long tool runs grow history fast; fold it into
1849 // a summary before the next round rather than blowing the window.
1850 let estimate = backend::estimate_tokens(&backend.history_snapshot().await);
1851 if estimate > backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
1852 log::info!(
1853 "prompt({}) history ≈{} tokens exceeds budget {} — compacting",
1854 session_id,
1855 estimate,
1856 backend::DEFAULT_CONTEXT_TOKEN_BUDGET
1857 );
1858 match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
1859 Ok(()) => {
1860 let after = backend::estimate_tokens(&backend.history_snapshot().await);
1861 log::info!("prompt({}) compacted to ≈{} tokens", session_id, after);
1862 }
1863 Err(error) => {
1864 log::warn!("prompt({}) compaction failed: {error}", session_id);
1865 self.send_assistant_message(
1866 cx,
1867 session_id,
1868 format!(
1869 "This session is too large, and siGit Code could not compact it: \
1870 {error}. Start a new thread or run `/clear`, then retry."
1871 ),
1872 )
1873 .ok();
1874 return Ok(PromptResponse::new(StopReason::EndTurn));
1875 }
1876 }
1877 }
1878
1879 let mut tool_results = Vec::new();
1880 let mut force_text = false;
1881
1882 for (call_index, tc) in result.tool_calls.iter().enumerate() {
1883 log::info!(
1884 " → {}({})",
1885 tc.name,
1886 tc.arguments.chars().take(120).collect::<String>()
1887 );
1888
1889 // Only treat the call as a plan once its arguments have actually
1890 // produced one. Keying off the tool name alone means a
1891 // `write_todos` the converter rejects sends no plan *and* skips
1892 // the tool-call card below, so the call disappears from the
1893 // client while the model is told the list was updated.
1894 let plan = (tc.name == "write_todos")
1895 .then(|| todos_arguments_to_plan(&tc.arguments))
1896 .flatten();
1897 let render_as_plan = plan.is_some();
1898 if let Some(plan) = plan {
1899 self.send_plan_update(cx, session_id.clone(), plan).ok();
1900 } else {
1901 // Model tool calls are agent actions in ACP too, not merely an
1902 // implementation detail of the completion loop. Announce each
1903 // one before it runs and close it afterwards so clients such as
1904 // Zed can render activity while the next inference round is in
1905 // flight. Previously only model loading emitted ToolCall events,
1906 // leaving an otherwise working tool round visually indistinct
1907 // from a stalled response.
1908 let raw_input: serde_json::Value = serde_json::from_str(&tc.arguments)
1909 .unwrap_or_else(|e| {
1910 log::warn!("malformed JSON in tool arguments for '{}': {e}", tc.name);
1911 serde_json::Value::String(tc.arguments.clone())
1912 });
1913 let invocation_content = tool_arguments_content(&tc.arguments);
1914 self.send_tool_call_update(
1915 cx,
1916 session_id.clone(),
1917 SessionUpdate::ToolCall(
1918 ToolCall::new(tc.id.clone(), chat::tool_title(&tc.name, &tc.arguments))
1919 .kind(tool_kind_for(&tc.name))
1920 .status(ToolCallStatus::InProgress)
1921 .content(vec![invocation_content])
1922 .locations(tool_call_locations(&tc.name, &tc.arguments))
1923 .raw_input(raw_input),
1924 ),
1925 )
1926 .ok();
1927 }
1928
1929 let signature = format!("{}\n{}", tc.name, tc.arguments);
1930 let repeat_count = repeated_tool_calls
1931 .entry(signature)
1932 .and_modify(|count| *count += 1)
1933 .or_insert(1);
1934 let repeated = *repeat_count >= 3;
1935 if repeated {
1936 force_text = true;
1937 log::warn!(
1938 "prompt({}) stopping repeated tool call `{}` after {} attempts",
1939 session_id,
1940 tc.name,
1941 repeat_count
1942 );
1943 }
1944
1945 // Permission gate: read-only tools pass straight through; a
1946 // mutating tool consults policy and may ask the client.
1947 let output = if repeated {
1948 format!(
1949 "The tool `{}` was not executed again because the model repeated \
1950 the same call three times. Continue without this tool.",
1951 tc.name
1952 )
1953 } else {
1954 match permissions::decision_for(
1955 &session_id.to_string(),
1956 &tc.name,
1957 &tc.arguments,
1958 ) {
1959 permissions::Decision::Allow => {
1960 tools::execute_tool(&tc.name, &tc.arguments).await
1961 }
1962 permissions::Decision::Deny(reason) => {
1963 log::info!(" ✗ {} denied by policy", tc.name);
1964 reason
1965 }
1966 permissions::Decision::Ask => {
1967 match self
1968 .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1969 .await
1970 {
1971 PermissionVerdict::Approved => {
1972 tools::execute_tool(&tc.name, &tc.arguments).await
1973 }
1974 PermissionVerdict::Denied(reason) => {
1975 log::info!(" ✗ {} denied by user", tc.name);
1976 reason
1977 }
1978 PermissionVerdict::TurnCancelled => {
1979 log::info!(
1980 "prompt({}) cancelled at permission gate",
1981 session_id
1982 );
1983 // The assistant message carrying these tool
1984 // calls is already in the backend history;
1985 // leaving any of them unanswered makes strict
1986 // OpenAI-compatible endpoints reject every
1987 // later request in the session. Close out this
1988 // call and the ones this round never reached.
1989 for pending in &result.tool_calls[call_index..] {
1990 tool_results.push(BackendToolResult {
1991 tool_call_id: pending.id.clone(),
1992 content: format!(
1993 "`{}` was not executed: the user cancelled the turn \
1994 at the permission prompt.",
1995 pending.name
1996 ),
1997 });
1998 }
1999 let cancelled_reason = "Not executed: the user cancelled the turn at the permission prompt.";
2000 self.send_tool_call_update(
2001 cx,
2002 session_id.clone(),
2003 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2004 tc.id.clone(),
2005 ToolCallUpdateFields::new()
2006 .status(ToolCallStatus::Failed)
2007 .content(vec![tool_output_content(
2008 cancelled_reason,
2009 )])
2010 .raw_output(serde_json::Value::String(
2011 cancelled_reason.to_string(),
2012 )),
2013 )),
2014 )
2015 .ok();
2016 backend.record_cancelled_tool_results(tool_results).await;
2017 return Ok(PromptResponse::new(StopReason::Cancelled));
2018 }
2019 }
2020 }
2021 }
2022 };
2023
2024 log::info!(" ← {} chars", output.len());
2025
2026 if !render_as_plan {
2027 self.send_tool_call_update(
2028 cx,
2029 session_id.clone(),
2030 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2031 tc.id.clone(),
2032 ToolCallUpdateFields::new()
2033 .status(ToolCallStatus::Completed)
2034 .content(vec![tool_output_content(&output)])
2035 .raw_output(serde_json::Value::String(output.clone())),
2036 )),
2037 )
2038 .ok();
2039 }
2040
2041 tool_results.push(BackendToolResult {
2042 tool_call_id: tc.id.clone(),
2043 content: output,
2044 });
2045 }
2046
2047 let allow_tool_calls = round < MAX_TOOL_ROUNDS && !force_text;
2048
2049 // Whatever this round says starts a new paragraph rather than
2050 // continuing the sentence the tool calls interrupted.
2051 reply.interrupt();
2052
2053 result = self
2054 .drain_turn(
2055 cx,
2056 &session_id,
2057 backend.send_tool_results(tool_results, &tools, allow_tool_calls, Some(&sink)),
2058 &mut sink_rx,
2059 &mut reply,
2060 )
2061 .await
2062 .map_err(|error| {
2063 log::error!("send_tool_results failed: {error}");
2064 agent_client_protocol::Error::new(-32603, error)
2065 })?;
2066 }
2067
2068 // ── Final text response ───────────────────────────────────────────
2069 // If anything streamed, the visible reply is already on the wire; only
2070 // send a trailing block for the non-streamed path (e.g. on-device direct
2071 // answers, which onde can't stream while tools are on offer).
2072 if !reply.streamed_any {
2073 let reply_text = result.text.trim().to_string();
2074 let final_text = if reply_text.is_empty() {
2075 if round > 0 {
2076 log::warn!(
2077 "prompt({}) — model returned empty reply after {} tool round(s)",
2078 session_id,
2079 round
2080 );
2081 "Something went wrong — the edits didn't go through. Try rephrasing what you need, or point me at the specific lines.".to_string()
2082 } else {
2083 log::warn!(
2084 "prompt({}) — model returned empty reply (no tool rounds)",
2085 session_id
2086 );
2087 String::new()
2088 }
2089 } else {
2090 // strip <think> blocks so reasoning tokens stay hidden
2091 let (_think, visible) = chat::strip_think_blocks(&reply_text);
2092 visible
2093 };
2094
2095 if !final_text.is_empty() {
2096 self.send_assistant_message(cx, session_id.clone(), final_text)
2097 .ok();
2098 }
2099 }
2100
2101 // Persist the completed turn so a restart (or session/load) can pick
2102 // the conversation back up.
2103 let snapshot = backend.history_snapshot().await;
2104 if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) {
2105 log::warn!("prompt({}) session save failed: {error}", session_id);
2106 }
2107
2108 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
2109 Ok(PromptResponse::new(StopReason::EndTurn))
2110 }
2111
2112 /// Ask the ACP client for permission to run one tool call. Presents
2113 /// allow-once / allow-for-session / deny; an "always allow" choice is
2114 /// recorded via [`permissions::grant_for_session`]. Only safe to call from
2115 /// a spawned task (see the handler registration in `run_acp_server`): the
2116 /// dispatch loop must be free to route the client's answer back to us.
2117 async fn request_tool_permission(
2118 &self,
2119 cx: &ConnectionTo<Client>,
2120 session_id: &SessionId,
2121 tool_name: &str,
2122 arguments: &str,
2123 ) -> PermissionVerdict {
2124 // The user decides from this dialog, so show the arguments with any
2125 // truncation flagged (a silently clipped command could hide its tail
2126 // from the person approving it). The full arguments also travel as
2127 // `raw_input` for clients that render it.
2128 let args_preview = permissions::approval_preview(arguments);
2129 let title = if args_preview.is_empty() {
2130 tool_name.to_string()
2131 } else {
2132 format!("{tool_name}({args_preview})")
2133 };
2134 let raw_input: serde_json::Value = serde_json::from_str(arguments).unwrap_or_else(|e| {
2135 log::warn!("malformed JSON in tool arguments for '{}': {e}", tool_name);
2136 serde_json::Value::String(arguments.to_string())
2137 });
2138
2139 let request = RequestPermissionRequest::new(
2140 session_id.clone(),
2141 ToolCallUpdate::new(
2142 format!("perm-{}", uuid::Uuid::new_v4()),
2143 ToolCallUpdateFields::new()
2144 .title(title)
2145 .kind(tool_kind_for(tool_name))
2146 .status(ToolCallStatus::Pending)
2147 .raw_input(raw_input),
2148 ),
2149 vec![
2150 PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
2151 PermissionOption::new(
2152 "allow_session",
2153 "Allow for this session",
2154 PermissionOptionKind::AllowAlways,
2155 ),
2156 PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
2157 ],
2158 );
2159
2160 match cx.send_request(request).block_task().await {
2161 Ok(response) => match response.outcome {
2162 RequestPermissionOutcome::Selected(selected) => {
2163 match selected.option_id.0.as_ref() {
2164 "allow_once" => PermissionVerdict::Approved,
2165 "allow_session" => {
2166 permissions::grant_for_session(
2167 &session_id.to_string(),
2168 tool_name,
2169 arguments,
2170 );
2171 PermissionVerdict::Approved
2172 }
2173 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
2174 }
2175 }
2176 RequestPermissionOutcome::Cancelled => PermissionVerdict::TurnCancelled,
2177 // The outcome enum is non_exhaustive; treat anything unknown as
2178 // a denial rather than running a mutating tool unapproved.
2179 _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
2180 },
2181 Err(error) => {
2182 log::warn!("permission request for `{tool_name}` failed: {error}");
2183 PermissionVerdict::Denied(format!(
2184 "`{tool_name}` was not executed: this client could not answer the \
2185 permission request ({error}). The user can pre-approve tools in \
2186 settings.toml under [permissions], or set SIGIT_PERMISSIONS=allow \
2187 for clients without permission support."
2188 ))
2189 }
2190 }
2191 }
2192
2193 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
2194 log::info!("cancel requested for session {}", args.session_id);
2195 Ok(())
2196 }
2197
2198 /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
2199 /// current model so the picker shows it selected. Returns the tier's display
2200 /// name on success, or `None` when no account is signed in (caller prompts
2201 /// for login). Shared by the panel picker and the `/models` slash command.
2202 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
2203 let cfg = crate::provider::cloud_tier_provider(tier)?;
2204 let mut system_prompt = system_prompt_for_model(true).to_string();
2205 // Mirror the cwd guidance and project instruction files the local engine
2206 // gets at session load, so the cloud model shares the same project context.
2207 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
2208 system_prompt.push_str("\n\n");
2209 system_prompt.push_str(&session_context_message(
2210 &cwd,
2211 &workspace::additional_roots(),
2212 ));
2213 }
2214 let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
2215 cfg.base_url,
2216 cfg.api_key,
2217 cfg.model,
2218 Some(system_prompt),
2219 ));
2220 // Continue the thread on the new backend instead of starting over.
2221 let carried =
2222 backend::carryover_history(self.backend.lock().await.history_snapshot().await);
2223 backend::adopt_carryover(cloud_backend.as_ref(), carried).await;
2224 *self.backend.lock().await = cloud_backend;
2225
2226 let cloud_config = GgufModelConfig {
2227 model_id: format!("sigit-cloud:{tier}"),
2228 files: Vec::new(),
2229 tok_model_id: None,
2230 display_name: cfg.display_name.clone(),
2231 approx_memory: "Cloud".to_string(),
2232 chat_template: None,
2233 };
2234 {
2235 let mut guard = self.current_model.lock().unwrap();
2236 *guard = cloud_config;
2237 }
2238
2239 // Explicitly choosing a cloud tier puts us in cloud mode.
2240 let _ = settings::set_local_inference(false);
2241
2242 log::info!("switched to cloud tier {tier}");
2243 Some(cfg.display_name)
2244 }
2245
2246 /// Apply the persisted Local Inference mode at session start. When local
2247 /// inference is off and an account is signed in, route to a cloud tier so the
2248 /// on-device model is never loaded; otherwise leave the on-device backend in
2249 /// place. Call after the session cwd is set so the cloud system prompt picks
2250 /// it up. Does not flip the stored setting on the not-signed-in fallback.
2251 async fn apply_startup_inference_mode(&self) {
2252 if settings::local_inference_enabled() {
2253 return;
2254 }
2255 if self
2256 .switch_to_cloud_tier(provider::DEFAULT_CLOUD_TIER)
2257 .await
2258 .is_some()
2259 {
2260 log::info!("startup: local inference off; routing inference to siGit Code Cloud");
2261 } else {
2262 log::warn!(
2263 "local inference is off but no account is signed in; staying on-device. \
2264 Run /login or set Local Inference on."
2265 );
2266 }
2267 }
2268
2269 /// Route inference back on-device. Used after leaving a cloud tier for a
2270 /// local model. The `LocalBackend` reads the live `engine`, so this repoints
2271 /// the active backend and moves the conversation onto the engine with it.
2272 async fn reset_to_local_backend(&self) {
2273 // Coming back from a cloud tier, the turns since the switch live in the
2274 // cloud backend's history, not the engine's — carry them over so the
2275 // on-device model picks the thread up where the cloud one left it. A
2276 // local-to-local switch needs nothing: `switch_model_by_id` has already
2277 // replayed the engine's own history past the reload.
2278 let carried = {
2279 let current = self.backend.lock().await;
2280 if current.is_remote() {
2281 backend::carryover_history(current.history_snapshot().await)
2282 } else {
2283 Vec::new()
2284 }
2285 };
2286
2287 let local_backend: Arc<dyn InferenceBackend> =
2288 Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
2289 backend::adopt_carryover(local_backend.as_ref(), carried).await;
2290 *self.backend.lock().await = local_backend;
2291 }
2292
2293 /// Re-attempt the lazy startup model load if the previous attempt failed.
2294 /// Clears the one-shot guard so the next load runs; a healthy load is left
2295 /// untouched so `/reload` doesn't needlessly reload a working model.
2296 fn retry_startup_model_load_if_failed(&self) {
2297 let had_error = self
2298 .model_load_error
2299 .lock()
2300 .map(|guard| guard.is_some())
2301 .unwrap_or(false);
2302 if had_error {
2303 self.startup_model_load_started
2304 .store(false, Ordering::Release);
2305 self.start_startup_model_load_if_needed();
2306 }
2307 }
2308
2309 /// Re-sync session state in place — no new session needed. Re-applies the
2310 /// active backend from current credentials (so a fresh `/login` token is
2311 /// picked up), retries a failed model load, and pushes refreshed commands +
2312 /// picker so the editor's UI reflects the current state.
2313 async fn handle_reload(&self, cx: &ConnectionTo<Client>, session_id: SessionId) {
2314 let signed_in = account::status_line().await;
2315
2316 let on_cloud_tier = {
2317 let guard = self.current_model.lock().unwrap();
2318 guard
2319 .model_id
2320 .strip_prefix("sigit-cloud:")
2321 .map(str::to_string)
2322 };
2323
2324 let backend_note = match on_cloud_tier {
2325 Some(tier) => match self.switch_to_cloud_tier(&tier).await {
2326 Some(name) => format!("Active: {name}."),
2327 None => {
2328 self.reset_to_local_backend().await;
2329 "Signed out — back to on-device. Pick a model with /models.".to_string()
2330 }
2331 },
2332 None => {
2333 self.reset_to_local_backend().await;
2334 self.retry_startup_model_load_if_failed();
2335 let guard = self.current_model.lock().unwrap();
2336 format!("Active: {}.", guard.display_name)
2337 }
2338 };
2339
2340 // Push refreshed picker + commands so the editor reflects current state.
2341 let config_options = {
2342 let guard = self.current_model.lock().unwrap();
2343 build_config_options(&guard, &session_id.to_string())
2344 };
2345 self.send_tool_call_update(
2346 cx,
2347 session_id.clone(),
2348 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
2349 )
2350 .ok();
2351 self.advertise_commands(cx, session_id.clone());
2352
2353 self.send_assistant_message(
2354 cx,
2355 session_id,
2356 format!("Reloaded. {signed_in} {backend_note}"),
2357 )
2358 .ok();
2359 }
2360
2361 async fn handle_set_session_config_option(
2362 &self,
2363 cx: &ConnectionTo<Client>,
2364 args: SetSessionConfigOptionRequest,
2365 ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> {
2366 log::info!(
2367 "set_session_config_option: config_id={}, value={:?}",
2368 args.config_id,
2369 args.value
2370 );
2371
2372 // ── Local Inference toggle ──────────────────────────────────────────
2373 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
2374 let enabled = match args.value.as_value_id().map(|v| v.0.as_ref()) {
2375 Some(LOCAL_INFERENCE_ON) => true,
2376 Some(LOCAL_INFERENCE_OFF) => false,
2377 other => {
2378 return Err(agent_client_protocol::Error::new(
2379 -32602,
2380 format!("unknown Local Inference value: {other:?}"),
2381 ));
2382 }
2383 };
2384 if let Err(error) = settings::set_local_inference(enabled) {
2385 return Err(agent_client_protocol::Error::new(
2386 -32603,
2387 format!("could not save Local Inference setting: {error}"),
2388 ));
2389 }
2390 let message = if enabled {
2391 "Local inference is on. On-device models are highlighted; pick one from Model."
2392 } else {
2393 "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
2394 };
2395 self.send_system_status(cx, args.session_id.clone(), message)
2396 .ok();
2397 // Rebuild so the Model picker reflects the new emphasis/order.
2398 let current = self.current_model.lock().unwrap().clone();
2399 let config_options = build_config_options(&current, &args.session_id.to_string());
2400 return Ok(SetSessionConfigOptionResponse::new(config_options));
2401 }
2402
2403 // ── Permissions dropdown (issue #76) ────────────────────────────────
2404 if args.config_id.0.as_ref() == PERMISSION_MODE_CONFIG_ID {
2405 let session_key = args.session_id.to_string();
2406 let message = match args.value.as_value_id().map(|v| v.0.as_ref()) {
2407 Some(PERMISSION_MODE_MANUAL) => {
2408 permissions::set_plan_mode(&session_key, false);
2409 permissions::set_session_mode(
2410 &session_key,
2411 Some(settings::PermissionMode::Ask),
2412 );
2413 "Permissions: Manual — the agent asks before running mutating tools."
2414 }
2415 Some(PERMISSION_MODE_AUTO) => {
2416 permissions::set_plan_mode(&session_key, false);
2417 permissions::set_session_mode(
2418 &session_key,
2419 Some(settings::PermissionMode::Allow),
2420 );
2421 "Permissions: Auto — tools run without asking, subject to settings.toml rules."
2422 }
2423 Some(PERMISSION_MODE_PLAN) => {
2424 permissions::set_plan_mode(&session_key, true);
2425 "Permissions: Plan — the agent researches with read-only tools and presents \
2426 a plan; edits and commands are blocked."
2427 }
2428 other => {
2429 return Err(agent_client_protocol::Error::new(
2430 -32602,
2431 format!("unknown Permissions value: {other:?}"),
2432 ));
2433 }
2434 };
2435 self.send_system_status(cx, args.session_id.clone(), message)
2436 .ok();
2437 let current = self.current_model.lock().unwrap().clone();
2438 let config_options = build_config_options(&current, &session_key);
2439 return Ok(SetSessionConfigOptionResponse::new(config_options));
2440 }
2441
2442 if args.config_id.0.as_ref() != MODEL_CONFIG_ID {
2443 return Err(agent_client_protocol::Error::new(
2444 -32602,
2445 format!("unknown config option: {}", args.config_id.0),
2446 ));
2447 }
2448
2449 let Some(model_id) = args.value.as_value_id().map(|v| v.0.as_ref()) else {
2450 return Err(agent_client_protocol::Error::new(
2451 -32602,
2452 format!(
2453 "expected a value-id for {MODEL_CONFIG_ID}, got {:?}",
2454 args.value
2455 ),
2456 ));
2457 };
2458
2459 // can't switch while the startup model is still loading — the old
2460 // weights are in GPU memory and the new load gets "does not fit"
2461 if self.startup_model_load_started.load(Ordering::Acquire)
2462 && !self.model_ready.load(Ordering::Acquire)
2463 {
2464 log::info!("set_session_config_option: waiting for startup model to finish loading");
2465 while !self.model_ready.load(Ordering::Acquire) {
2466 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2467 }
2468 }
2469
2470 // Zed re-fires the last selection when a thread opens. That re-fire must
2471 // not load anything: on-device models are loaded only on an explicit
2472 // request (`/load`, or actively picking a *different* model below), so a
2473 // re-fire of the already-current selection is a no-op. Otherwise opening a
2474 // new thread would silently load the local model — exactly what we avoid.
2475 {
2476 let current = self.current_model.lock().unwrap();
2477 if current.model_id == model_id {
2478 log::info!(
2479 "set_session_config_option: {} is already the active selection, skipping",
2480 current.display_name
2481 );
2482 let config_options = build_config_options(&current, &args.session_id.to_string());
2483 return Ok(SetSessionConfigOptionResponse::new(config_options));
2484 }
2485 }
2486
2487 // ── siGit Code Cloud tier: no local load; sign-in gated ─────────────
2488 if let Some(tier) = model_id.strip_prefix("sigit-cloud:") {
2489 match self.switch_to_cloud_tier(tier).await {
2490 Some(display_name) => {
2491 self.send_system_status(
2492 cx,
2493 args.session_id.clone(),
2494 format!("Switched to {display_name}."),
2495 )
2496 .ok();
2497 }
2498 None => {
2499 // The picker response updates successful selections. When the
2500 // selection cannot apply because auth is missing, surface that
2501 // actionable state in the thread.
2502 self.send_assistant_message(
2503 cx,
2504 args.session_id.clone(),
2505 format!("\n\n{CLOUD_LOGIN_PROMPT}"),
2506 )
2507 .ok();
2508 }
2509 }
2510
2511 let current = self.current_model.lock().unwrap().clone();
2512 let config_options = build_config_options(&current, &args.session_id.to_string());
2513 return Ok(SetSessionConfigOptionResponse::new(config_options));
2514 }
2515
2516 let needs_download = models::local_picker_items()
2517 .into_iter()
2518 .find(|item| item.config.model_id == model_id)
2519 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
2520 .unwrap_or(false);
2521
2522 // tells the progress poller to stop
2523 let stop_flag = Arc::new(AtomicBool::new(false));
2524
2525 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
2526
2527 if needs_download {
2528 let model_id_owned = model_id.to_string();
2529 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
2530 .iter()
2531 .find(|m| m.id == model_id_owned)
2532 .map(|m| m.expected_size_bytes)
2533 .unwrap_or(0);
2534
2535 let display_name = models::local_picker_items()
2536 .into_iter()
2537 .find(|item| item.config.model_id == model_id_owned)
2538 .map(|item| item.display_name.clone())
2539 .unwrap_or_else(|| model_id_owned.clone());
2540
2541 let size_hint = if expected_bytes > 0 {
2542 format!(" (~{})", format_size_human(expected_bytes))
2543 } else {
2544 String::new()
2545 };
2546
2547 self.send_tool_call_update(
2548 cx,
2549 args.session_id.clone(),
2550 SessionUpdate::ToolCall(
2551 ToolCall::new(
2552 tool_call_id.clone(),
2553 format!("⏬ Downloading {display_name}{size_hint}"),
2554 )
2555 .kind(ToolKind::Think)
2556 .status(ToolCallStatus::InProgress)
2557 .content(vec![
2558 format!(
2559 "Preparing download for {display_name}. This may take a few minutes."
2560 )
2561 .into(),
2562 ]),
2563 ),
2564 )
2565 .ok();
2566
2567 // poll download progress and update the spinner in Zed
2568 let cx_for_poller = cx.clone();
2569 let poller_session = args.session_id.clone();
2570 let poller_model_id = model_id_owned.clone();
2571 let poller_stop = Arc::clone(&stop_flag);
2572 let poller_tool_call_id = tool_call_id.clone();
2573
2574 cx.spawn(async move {
2575 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2576 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
2577 let mut tick: usize = 0;
2578 let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
2579 interval.tick().await; // consume the immediate first tick
2580
2581 while !poller_stop.load(Ordering::Relaxed) {
2582 interval.tick().await;
2583
2584 if poller_stop.load(Ordering::Relaxed) {
2585 break;
2586 }
2587
2588 let downloaded = cache_path
2589 .as_ref()
2590 .filter(|p| p.exists())
2591 .map(|p| dir_size_recursive(p))
2592 .unwrap_or(0);
2593
2594 let frame = SPINNER[tick % SPINNER.len()];
2595 tick += 1;
2596
2597 let title = if expected_bytes > 0 {
2598 let pct =
2599 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
2600 format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
2601 } else {
2602 format!("{frame} Downloading {display_name}{size_hint}")
2603 };
2604
2605 let msg = if expected_bytes > 0 {
2606 let pct =
2607 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
2608 let bar = progress_bar(pct, 20);
2609 format!(
2610 "{display_name} — {bar} {pct}% ({} / {})",
2611 format_size_human(downloaded),
2612 format_size_human(expected_bytes),
2613 )
2614 } else {
2615 format!(
2616 "{display_name} — {} downloaded…",
2617 format_size_human(downloaded)
2618 )
2619 };
2620
2621 let notification = SessionNotification::new(
2622 poller_session.clone(),
2623 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2624 poller_tool_call_id.clone(),
2625 ToolCallUpdateFields::new()
2626 .title(title)
2627 .status(ToolCallStatus::InProgress)
2628 .content(vec![msg.into()]),
2629 )),
2630 );
2631 if cx_for_poller.send_notification(notification).is_err() {
2632 break;
2633 }
2634 }
2635 Ok(())
2636 })
2637 .ok();
2638 }
2639
2640 // cached models still take 10-30s to load weights; show a spinner
2641 if !needs_download {
2642 let cached_display_name = models::local_picker_items()
2643 .into_iter()
2644 .find(|item| item.config.model_id == model_id)
2645 .map(|item| item.display_name.clone())
2646 .unwrap_or_else(|| model_id.to_string());
2647
2648 self.send_tool_call_update(
2649 cx,
2650 args.session_id.clone(),
2651 SessionUpdate::ToolCall(
2652 ToolCall::new(
2653 tool_call_id.clone(),
2654 format!("Loading {cached_display_name}"),
2655 )
2656 .kind(ToolKind::Think)
2657 .status(ToolCallStatus::InProgress)
2658 .content(vec![format!("Loading {cached_display_name}…").into()]),
2659 ),
2660 )
2661 .ok();
2662
2663 // tick every 5s so the user knows we haven't frozen
2664 let cx_for_spinner = cx.clone();
2665 let spinner_session = args.session_id.clone();
2666 let spinner_name = cached_display_name.clone();
2667 let spinner_stop = Arc::clone(&stop_flag);
2668 let spinner_tool_call_id = tool_call_id.clone();
2669 let load_start = std::time::Instant::now();
2670
2671 cx.spawn(async move {
2672 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2673 let mut tick: usize = 0;
2674 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
2675 interval.tick().await; // consume the immediate first tick
2676
2677 while !spinner_stop.load(Ordering::Relaxed) {
2678 interval.tick().await;
2679
2680 if spinner_stop.load(Ordering::Relaxed) {
2681 break;
2682 }
2683
2684 let elapsed = load_start.elapsed();
2685 let elapsed_str = if elapsed.as_secs() >= 60 {
2686 format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
2687 } else {
2688 format!("{}s", elapsed.as_secs())
2689 };
2690 let frame = SPINNER[tick % SPINNER.len()];
2691 tick += 1;
2692
2693 let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
2694 let notification = SessionNotification::new(
2695 spinner_session.clone(),
2696 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2697 spinner_tool_call_id.clone(),
2698 ToolCallUpdateFields::new()
2699 .status(ToolCallStatus::InProgress)
2700 .content(vec![msg.into()]),
2701 )),
2702 );
2703 if cx_for_spinner.send_notification(notification).is_err() {
2704 break;
2705 }
2706 }
2707 Ok(())
2708 })
2709 .ok();
2710 }
2711
2712 let switch_result = self.switch_model_by_id(model_id).await;
2713
2714 stop_flag.store(true, Ordering::Relaxed);
2715
2716 match switch_result {
2717 Ok(new_config) => {
2718 // Route inference back on-device (in case we were on a cloud tier).
2719 self.reset_to_local_backend().await;
2720 // Selecting an on-device model puts us in local mode.
2721 let _ = settings::set_local_inference(true);
2722
2723 let completion_title = if needs_download {
2724 format!("✓ {} downloaded and loaded", new_config.display_name)
2725 } else {
2726 format!("✓ Switched to {}", new_config.display_name)
2727 };
2728 let completion_body = if needs_download {
2729 format!("✓ {} downloaded and loaded.", new_config.display_name)
2730 } else {
2731 format!("✓ Switched to {}.", new_config.display_name)
2732 };
2733
2734 self.send_tool_call_update(
2735 cx,
2736 args.session_id.clone(),
2737 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2738 tool_call_id,
2739 ToolCallUpdateFields::new()
2740 .title(completion_title)
2741 .status(ToolCallStatus::Completed)
2742 .content(vec![completion_body.into()]),
2743 )),
2744 )
2745 .ok();
2746
2747 let config_options = {
2748 let guard = self.current_model.lock().unwrap();
2749 build_config_options(&guard, &args.session_id.to_string())
2750 };
2751
2752 log::info!("model switch complete");
2753 Ok(SetSessionConfigOptionResponse::new(config_options))
2754 }
2755 Err(err) => {
2756 self.send_tool_call_update(
2757 cx,
2758 args.session_id.clone(),
2759 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
2760 tool_call_id,
2761 ToolCallUpdateFields::new()
2762 .title("Model switch failed".to_string())
2763 .status(ToolCallStatus::Failed)
2764 .content(vec![format!("error loading model: {}", err.message).into()]),
2765 )),
2766 )
2767 .ok();
2768
2769 Err(err)
2770 }
2771 }
2772 }
2773 }
2774
2775 // ── Config option helpers ─────────────────────────────────────────────────────
2776
2777 /// config option ID for the model picker in Zed's agent panel
2778 const MODEL_CONFIG_ID: &str = "sigit-model";
2779
2780 /// config option ID for the Local Inference on/off toggle. Surfaced as a
2781 /// two-option `select` so ACP clients without slash-command support (e.g. Xcode)
2782 /// can still flip the mode from the agent panel.
2783 const LOCAL_INFERENCE_CONFIG_ID: &str = "sigit-local-inference";
2784
2785 /// `select` value ids for the Local Inference toggle.
2786 const LOCAL_INFERENCE_ON: &str = "local-inference-on";
2787 const LOCAL_INFERENCE_OFF: &str = "local-inference-off";
2788
2789 /// config option ID for the Permissions dropdown (issue #76): flips the
2790 /// session between asking for approval, running tools unattended, and plan
2791 /// mode, without touching `settings.toml`.
2792 const PERMISSION_MODE_CONFIG_ID: &str = "sigit-permission-mode";
2793
2794 /// `select` value ids for the Permissions dropdown.
2795 const PERMISSION_MODE_MANUAL: &str = "permission-mode-manual";
2796 const PERMISSION_MODE_AUTO: &str = "permission-mode-auto";
2797 const PERMISSION_MODE_PLAN: &str = "permission-mode-plan";
2798
2799 /// Replace non-ASCII chars so a downstream byte-index truncation can't split a
2800 /// multi-byte char. Zed slices the model-picker label at a fixed byte offset
2801 /// (`agent_ui/src/config_options.rs`) and panics — crashing the whole editor —
2802 /// when the cut lands mid-glyph (e.g. inside `☁` or `·`). Mapping to `-` keeps
2803 /// separators readable; ASCII bytes are always char boundaries.
2804 fn ascii_safe(s: &str) -> String {
2805 s.chars()
2806 .map(|c| if c.is_ascii() { c } else { '-' })
2807 .collect()
2808 }
2809
2810 fn build_config_options(
2811 current_model: &GgufModelConfig,
2812 session_key: &str,
2813 ) -> Vec<SessionConfigOption> {
2814 // The full list, including the siGit Code Cloud tiers, so the panel picker
2815 // mirrors the TUI `/models`. Cloud entries are sign-in gated at selection.
2816 let items = models::build_model_picker_items();
2817 let active_kind = models::active_inference_kind();
2818
2819 let options: Vec<SessionConfigSelectOption> = items
2820 .iter()
2821 .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
2822 .map(|item| {
2823 let mut desc_parts = Vec::new();
2824 // Mark options in the inactive mode so the active group reads as the
2825 // recommended set (the list is already ordered active-group-first).
2826 if item.source.kind() != active_kind {
2827 desc_parts.push("inactive mode".to_string());
2828 }
2829 if item.tool_calling {
2830 desc_parts.push("tool calling".to_string());
2831 }
2832 desc_parts.push(format!(
2833 "{} context",
2834 models::format_context_window_short(item.context_window_tokens)
2835 ));
2836 desc_parts.push(item.description.clone());
2837 if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
2838 desc_parts.push("download on select".to_string());
2839 }
2840 // ASCII-only for the same reason as the name (see `ascii_safe`).
2841 let description = ascii_safe(&desc_parts.join(" - "));
2842 // Keep labels short for the Zed bottom bar. Source/routing detail
2843 // belongs in descriptions; the selected value is rendered without
2844 // the config-option title, so repeated badges become visual noise.
2845 let source_badge = if item.cloud_tier.is_some() {
2846 ""
2847 } else if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
2848 " [Onde]"
2849 } else {
2850 match item.source_label.as_str() {
2851 "Onde" => " [Onde]",
2852 "HuggingFace" => " [HuggingFace]",
2853 _ => "",
2854 }
2855 };
2856 // For cloud tiers use just the tier title (e.g. "Balanced"). The
2857 // display name can carry non-ASCII (the cloud tier label is
2858 // "siGit Code Cloud · Balanced"), so sanitize the whole label.
2859 let base_name = match &item.cloud_tier {
2860 Some(tier) => crate::provider::tier_title(tier),
2861 None => item.display_name.clone(),
2862 };
2863 let name = ascii_safe(&format!(
2864 "{base_name} - {}{source_badge}",
2865 models::format_context_window(item.context_window_tokens)
2866 ));
2867 SessionConfigSelectOption::new(
2868 SessionConfigValueId::new(item.config.model_id.as_str()),
2869 name,
2870 )
2871 .description(description)
2872 })
2873 .collect();
2874
2875 // Local Inference on/off toggle, modeled as a two-option select so panel-only
2876 // ACP clients (no slash commands) can flip the mode.
2877 let local_on = settings::local_inference_enabled();
2878 let local_current = SessionConfigValueId::new(if local_on {
2879 LOCAL_INFERENCE_ON
2880 } else {
2881 LOCAL_INFERENCE_OFF
2882 });
2883 let local_options = vec![
2884 SessionConfigSelectOption::new(
2885 SessionConfigValueId::new(LOCAL_INFERENCE_ON),
2886 "Local".to_string(),
2887 )
2888 .description("Run inference on-device; on-device models are highlighted".to_string()),
2889 SessionConfigSelectOption::new(
2890 SessionConfigValueId::new(LOCAL_INFERENCE_OFF),
2891 "Cloud".to_string(),
2892 )
2893 .description("Use siGit Code Cloud; cloud tiers are highlighted".to_string()),
2894 ];
2895 let local_option = SessionConfigOption::select(
2896 LOCAL_INFERENCE_CONFIG_ID,
2897 "Inference",
2898 local_current,
2899 local_options,
2900 )
2901 .description("Toggle on-device inference; changes which models are highlighted");
2902
2903 // Permissions dropdown (issue #76): Manual (ask), Auto (run unattended),
2904 // Plan (research only). Derived, never stored — see `permissions.rs`.
2905 let permission_current = SessionConfigValueId::new(if permissions::plan_mode(session_key) {
2906 PERMISSION_MODE_PLAN
2907 } else {
2908 match permissions::effective_mode(session_key) {
2909 settings::PermissionMode::Allow => PERMISSION_MODE_AUTO,
2910 settings::PermissionMode::Ask | settings::PermissionMode::Deny => {
2911 PERMISSION_MODE_MANUAL
2912 }
2913 }
2914 });
2915 let permission_options = vec![
2916 SessionConfigSelectOption::new(
2917 SessionConfigValueId::new(PERMISSION_MODE_MANUAL),
2918 "Manual".to_string(),
2919 )
2920 .description("Ask before running mutating tools".to_string()),
2921 SessionConfigSelectOption::new(
2922 SessionConfigValueId::new(PERMISSION_MODE_AUTO),
2923 "Auto".to_string(),
2924 )
2925 .description("Run tools without asking, subject to settings.toml rules".to_string()),
2926 SessionConfigSelectOption::new(
2927 SessionConfigValueId::new(PERMISSION_MODE_PLAN),
2928 "Plan".to_string(),
2929 )
2930 .description("Research only; no edits or commands".to_string()),
2931 ];
2932 let permission_option = SessionConfigOption::select(
2933 PERMISSION_MODE_CONFIG_ID,
2934 "Permissions",
2935 permission_current,
2936 permission_options,
2937 )
2938 .category(SessionConfigOptionCategory::Mode)
2939 .description("How tool calls are approved for this session");
2940
2941 if options.is_empty() {
2942 return vec![local_option, permission_option];
2943 }
2944
2945 let current_value = SessionConfigValueId::new(current_model.model_id.as_str());
2946
2947 vec![
2948 SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options)
2949 .category(SessionConfigOptionCategory::Model)
2950 .description("Select an on-device model or a siGit Code Cloud tier"),
2951 local_option,
2952 permission_option,
2953 ]
2954 }
2955
2956 /// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
2957 fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
2958 let items = models::local_picker_items();
2959 items
2960 .into_iter()
2961 .find(|item| {
2962 item.config.model_id == model_id
2963 && item.cache_health != setup::ModelCacheHealth::Incomplete
2964 })
2965 .map(|item| (item.config, item.max_tokens, item.tool_calling))
2966 }
2967
2968 // ── Slash commands ────────────────────────────────────────────────────────────
2969
2970 #[derive(Debug, Clone)]
2971 enum SlashCommand {
2972 Help,
2973 Clear,
2974 Status,
2975 Models(Option<usize>),
2976 /// toggle on-device inference mode. `Some(true/false)` sets it, `None` flips it.
2977 Local(Option<bool>),
2978 /// List discovered Agent Skills.
2979 Skills,
2980 /// List configured subagent types (`.sigit/agents/*.md`) for `task`.
2981 Agents,
2982 /// List discovered user-defined commands (`.sigit/commands/*.md`).
2983 Commands,
2984 /// List configured MCP servers and their tools.
2985 Mcp,
2986 /// Explicitly load the selected (or default) on-device model.
2987 Load,
2988 /// `/login <email> <password>` — the raw argument, parsed when executed.
2989 Login(Option<String>),
2990 Logout,
2991 Whoami,
2992 /// Re-sync session state (auth, backend, picker) without a new session.
2993 Reload,
2994 /// Toggle plan mode (read-only research; mutating tools denied with a
2995 /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
2996 Plan(Option<bool>),
2997 /// Show the effective permission policy for this session.
2998 Permissions,
2999 /// Summarize-and-shrink the conversation history on demand.
3000 Compact,
3001 /// Generate (or improve) the repo's AGENTS.md by running an agent turn
3002 /// with [`instructions::INIT_PROMPT`] as the user text.
3003 Init,
3004 Exit,
3005 /// An unrecognized command word, plus whatever followed it on the line
3006 /// (untouched — a caller may still resolve it as a user-defined command
3007 /// before falling back to "unknown command").
3008 Unknown(String, Option<String>),
3009 }
3010
3011 fn parse_slash(input: &str) -> Option<SlashCommand> {
3012 let trimmed = input.trim();
3013 if !trimmed.starts_with('/') {
3014 return None;
3015 }
3016 let mut parts = trimmed.splitn(2, char::is_whitespace);
3017 let command = parts.next().unwrap_or("");
3018 let argument = parts.next().map(str::trim);
3019 Some(match command {
3020 "/help" => SlashCommand::Help,
3021 "/clear" => SlashCommand::Clear,
3022 "/status" => SlashCommand::Status,
3023 "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())),
3024 "/local" => SlashCommand::Local(parse_on_off(argument)),
3025 "/skills" => SlashCommand::Skills,
3026 "/agents" => SlashCommand::Agents,
3027 "/commands" => SlashCommand::Commands,
3028 "/mcp" => SlashCommand::Mcp,
3029 "/load" => SlashCommand::Load,
3030 "/login" => SlashCommand::Login(argument.map(str::to_string)),
3031 "/logout" => SlashCommand::Logout,
3032 "/whoami" => SlashCommand::Whoami,
3033 "/reload" => SlashCommand::Reload,
3034 "/plan" => SlashCommand::Plan(parse_on_off(argument)),
3035 "/permissions" => SlashCommand::Permissions,
3036 "/compact" => SlashCommand::Compact,
3037 "/init" => SlashCommand::Init,
3038 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
3039 other => SlashCommand::Unknown(other.to_string(), argument.map(str::to_string)),
3040 })
3041 }
3042
3043 /// ACP clients may prepend context as separate text blocks before the user's
3044 /// input. Search from the end so a standalone slash command in the final user
3045 /// block is still dispatched locally instead of being buried in joined context.
3046 fn parse_slash_from_prompt_texts<'a>(
3047 texts: impl DoubleEndedIterator<Item = &'a str>,
3048 ) -> Option<SlashCommand> {
3049 texts.rev().find_map(parse_slash)
3050 }
3051
3052 /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
3053 /// (meaning "toggle the current value").
3054 fn parse_on_off(arg: Option<&str>) -> Option<bool> {
3055 match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() {
3056 "on" | "true" | "1" | "yes" => Some(true),
3057 "off" | "false" | "0" | "no" => Some(false),
3058 _ => None,
3059 }
3060 }
3061
3062 fn format_models_list(current_model: &GgufModelConfig) -> String {
3063 let items = models::build_model_picker_items();
3064 if items.is_empty() {
3065 return "No local models found. siGit will use the platform default model.".to_string();
3066 }
3067
3068 let mut lines = vec!["Available models:".to_string()];
3069 let mut last_source: Option<&str> = None;
3070
3071 for (index, item) in items.iter().enumerate() {
3072 let source_key = match item.source_label.as_str() {
3073 "Onde" => "Onde",
3074 "HuggingFace" => "HuggingFace",
3075 "siGit Code Cloud" => "Cloud",
3076 _ => "Fallback",
3077 };
3078
3079 if last_source != Some(source_key) {
3080 if last_source.is_some() {
3081 lines.push(String::new());
3082 }
3083 let section = match source_key {
3084 "Onde" => "Onde Inference",
3085 "HuggingFace" => "Hugging Face cache",
3086 "Cloud" => "siGit Code Cloud",
3087 _ => "Fallback",
3088 };
3089 lines.push(section.to_string());
3090 // Blank line so the following "N." items render as an ordered list.
3091 // CommonMark only lets an ordered list interrupt a paragraph when it
3092 // starts at 1, so without this the cloud section (items 9+) would be
3093 // absorbed into the header paragraph.
3094 lines.push(String::new());
3095 last_source = Some(source_key);
3096 }
3097
3098 let number = index + 1;
3099 let current_badge = if item.config.model_id == current_model.model_id {
3100 " <- current"
3101 } else {
3102 ""
3103 };
3104 let tool_badge = if item.tool_calling {
3105 " tool calling"
3106 } else {
3107 ""
3108 };
3109 let health_badge = match item.cache_health {
3110 setup::ModelCacheHealth::Complete => "",
3111 setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
3112 setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
3113 };
3114 let source = match source_key {
3115 "Onde" => " [Onde]",
3116 "HuggingFace" => " [HuggingFace]",
3117 "Cloud" => " [☁ Cloud]",
3118 _ => " [default]",
3119 };
3120
3121 lines.push(format!(
3122 "{number}. {} {}{}{}{}{}",
3123 item.display_name, item.description, tool_badge, health_badge, current_badge, source,
3124 ));
3125 }
3126
3127 lines.push(String::new());
3128 lines.push("Use /models N to switch models.".to_string());
3129 lines.join("\n")
3130 }
3131
3132 async fn exec_slash_acp(
3133 agent: &SiGitAgent,
3134 cx: &ConnectionTo<Client>,
3135 session_id: SessionId,
3136 command: SlashCommand,
3137 ) -> agent_client_protocol::Result<PromptResponse> {
3138 match command {
3139 SlashCommand::Help => {
3140 agent
3141 .send_assistant_message(
3142 cx,
3143 session_id,
3144 "/help - show this message\n\
3145 /models - list available models\n\
3146 /models N - switch to model N\n\
3147 /local [on|off]- toggle on-device inference mode\n\
3148 /skills - list available Agent Skills\n\
3149 /agents - list configured subagent types (.sigit/agents/*.md)\n\
3150 /commands - list user-defined commands (.sigit/commands/*.md)\n\
3151 /mcp - list MCP servers and their tools\n\
3152 /load - load the selected on-device model\n\
3153 /login E P - sign in to siGit Code Cloud\n\
3154 /logout - sign out\n\
3155 /whoami - show the signed-in account\n\
3156 /reload - re-sync sign-in and model state\n\
3157 /plan [on|off] - plan mode: research only, no edits or commands\n\
3158 /permissions - show the tool permission policy\n\
3159 /compact - summarize and shrink conversation history\n\
3160 /init - generate or improve the repo's AGENTS.md\n\
3161 /clear - wipe conversation history\n\
3162 /status - show engine status\n\
3163 /exit - end this turn",
3164 )
3165 .ok();
3166 }
3167 SlashCommand::Clear => {
3168 let cleared = agent.engine.clear_history().await;
3169 permissions::reset_session(&session_id.to_string());
3170 // The saved session must not resurrect what the user just wiped.
3171 session_store::delete(&session_id.to_string());
3172 // The wipe drops the session's Permissions override too; push the
3173 // refreshed chip so it doesn't keep showing a stale pick.
3174 let config_options = {
3175 let current = agent.current_model.lock().unwrap();
3176 build_config_options(&current, &session_id.to_string())
3177 };
3178 agent
3179 .send_tool_call_update(
3180 cx,
3181 session_id.clone(),
3182 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
3183 )
3184 .ok();
3185 agent
3186 .send_assistant_message(
3187 cx,
3188 session_id,
3189 format!("Cleared {cleared} turn(s). History is empty."),
3190 )
3191 .ok();
3192 }
3193 SlashCommand::Plan(value) => {
3194 let session_key = session_id.to_string();
3195 let enabled = value.unwrap_or_else(|| !permissions::plan_mode(&session_key));
3196 permissions::set_plan_mode(&session_key, enabled);
3197 let message = if enabled {
3198 "Plan mode ON — the agent researches with read-only tools and presents a \
3199 plan; edits and commands are blocked until /plan off."
3200 } else {
3201 "Plan mode OFF — the agent may execute tools again (subject to the \
3202 permission policy)."
3203 };
3204 // Keep the panel Permissions chip in step: /plan on moves it to
3205 // Plan, /plan off drops it back to whatever the session mode is.
3206 let config_options = {
3207 let current = agent.current_model.lock().unwrap();
3208 build_config_options(&current, &session_key)
3209 };
3210 agent
3211 .send_tool_call_update(
3212 cx,
3213 session_id.clone(),
3214 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
3215 )
3216 .ok();
3217 agent.send_assistant_message(cx, session_id, message).ok();
3218 }
3219 SlashCommand::Permissions => {
3220 let summary = permissions::describe(&session_id.to_string());
3221 agent.send_assistant_message(cx, session_id, summary).ok();
3222 }
3223 SlashCommand::Compact => {
3224 let backend = agent.backend.lock().await.clone();
3225 let before = backend::estimate_tokens(&backend.history_snapshot().await);
3226 let message = match backend.compact_history(backend::COMPACT_KEEP_LAST).await {
3227 Ok(()) => {
3228 let snapshot = backend.history_snapshot().await;
3229 let after = backend::estimate_tokens(&snapshot);
3230 // Keep the saved session in step with the compacted state.
3231 if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) {
3232 log::warn!("session save after /compact failed: {error}");
3233 }
3234 format!("Compacted history: ~{before} → ~{after} tokens (estimated).")
3235 }
3236 Err(error) => format!("Compaction failed: {error}"),
3237 };
3238 agent.send_assistant_message(cx, session_id, message).ok();
3239 }
3240 SlashCommand::Status => {
3241 let info = agent.engine.info().await;
3242 let model = info.model_name.as_deref().unwrap_or("(none)");
3243 let memory = info.approx_memory.as_deref().unwrap_or("unknown");
3244 let mut report = format!(
3245 "status: {:?} model: {} memory: {} history: {} turns",
3246 info.status, model, memory, info.history_length,
3247 );
3248 // Only worth a line when the project has more than one root —
3249 // otherwise it just repeats what the editor already shows.
3250 let extra_roots = workspace::additional_roots();
3251 if !extra_roots.is_empty() {
3252 let cwd = agent
3253 .session_cwd
3254 .lock()
3255 .ok()
3256 .and_then(|guard| guard.clone())
3257 .unwrap_or_else(|| PathBuf::from("."));
3258 report.push_str(&format!("\nproject roots: {} (primary)", cwd.display()));
3259 for root in &extra_roots {
3260 report.push_str(&format!(", {}", root.display()));
3261 }
3262 }
3263 agent.send_assistant_message(cx, session_id, report).ok();
3264 }
3265 SlashCommand::Models(None) => {
3266 let current_model = agent.current_model.lock().unwrap().clone();
3267 agent
3268 .send_assistant_message(cx, session_id, format_models_list(&current_model))
3269 .ok();
3270 }
3271 SlashCommand::Skills => {
3272 agent
3273 .send_assistant_message(cx, session_id, skills::format_skills_list())
3274 .ok();
3275 }
3276 SlashCommand::Agents => {
3277 agent
3278 .send_assistant_message(cx, session_id, subagents::format_agent_types_list())
3279 .ok();
3280 }
3281 SlashCommand::Commands => {
3282 agent
3283 .send_assistant_message(cx, session_id, commands::format_commands_list())
3284 .ok();
3285 }
3286 SlashCommand::Mcp => {
3287 agent
3288 .send_assistant_message(cx, session_id, mcp::status_summary())
3289 .ok();
3290 }
3291 SlashCommand::Models(Some(number)) => {
3292 let items = models::build_model_picker_items();
3293 let index = number.saturating_sub(1);
3294 match items.get(index).cloned() {
3295 None => {
3296 agent
3297 .send_assistant_message(
3298 cx,
3299 session_id,
3300 format!("error: no model #{number} - type /models to see the list."),
3301 )
3302 .ok();
3303 }
3304 Some(model) if model.cloud_tier.is_some() => {
3305 // siGit Code Cloud tier: swap backend, sign-in gated.
3306 let tier = model.cloud_tier.clone().unwrap_or_default();
3307 let message = match agent.switch_to_cloud_tier(&tier).await {
3308 Some(display_name) => format!("Switched to {display_name}."),
3309 None => CLOUD_LOGIN_PROMPT.to_string(),
3310 };
3311 agent.send_assistant_message(cx, session_id, message).ok();
3312 }
3313 Some(model) => {
3314 if model.cache_health == setup::ModelCacheHealth::Incomplete {
3315 agent
3316 .send_assistant_message(
3317 cx,
3318 session_id,
3319 format!(
3320 "error: {} has an incomplete local cache and cannot be selected yet.",
3321 model.display_name
3322 ),
3323 )
3324 .ok();
3325 } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
3326 agent
3327 .send_assistant_message(
3328 cx,
3329 session_id.clone(),
3330 format!(
3331 "Downloading and loading {} ({})… this may take a few minutes.",
3332 model.display_name, model.description
3333 ),
3334 )
3335 .ok();
3336
3337 match agent.switch_model_by_id(&model.config.model_id).await {
3338 Ok(new_config) => {
3339 agent.reset_to_local_backend().await;
3340 let _ = settings::set_local_inference(true);
3341 agent
3342 .send_assistant_message(
3343 cx,
3344 session_id,
3345 format!(
3346 "✓ Downloaded and switched to {}",
3347 new_config.display_name
3348 ),
3349 )
3350 .ok();
3351 }
3352 Err(err) => {
3353 agent
3354 .send_assistant_message(
3355 cx,
3356 session_id,
3357 format!("error downloading model: {}", err.message),
3358 )
3359 .ok();
3360 }
3361 }
3362 } else {
3363 agent
3364 .send_assistant_message(
3365 cx,
3366 session_id.clone(),
3367 format!("Loading {}...", model.display_name),
3368 )
3369 .ok();
3370
3371 let switched = agent.switch_model_by_id(&model.config.model_id).await?;
3372 agent.reset_to_local_backend().await;
3373 let _ = settings::set_local_inference(true);
3374
3375 agent
3376 .send_assistant_message(
3377 cx,
3378 session_id,
3379 format!("Switched to {}.", switched.display_name),
3380 )
3381 .ok();
3382 }
3383 }
3384 }
3385 }
3386 SlashCommand::Local(value) => {
3387 let enabled = value.unwrap_or(!settings::local_inference_enabled());
3388 let message = match settings::set_local_inference(enabled) {
3389 Ok(()) if enabled => "Local inference is on. On-device models are highlighted; \
3390 pick one with /models."
3391 .to_string(),
3392 Ok(()) => "Local inference is off. siGit Code Cloud tiers are highlighted; \
3393 pick one with /models."
3394 .to_string(),
3395 Err(error) => format!("error: could not save local inference setting: {error}"),
3396 };
3397 agent
3398 .send_assistant_message(cx, session_id.clone(), message)
3399 .ok();
3400 // Refresh the panel so the Model picker reflects the new emphasis.
3401 let config_options = {
3402 let current = agent.current_model.lock().unwrap();
3403 build_config_options(&current, &session_id.to_string())
3404 };
3405 agent
3406 .send_tool_call_update(
3407 cx,
3408 session_id,
3409 SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
3410 )
3411 .ok();
3412 }
3413 SlashCommand::Load => {
3414 // Explicitly load the on-device model. This is the only path that
3415 // brings a local model into memory; prompts never do it implicitly.
3416 // If a cloud tier is active, fall back to a local default so we don't
3417 // try to load the (file-less) cloud config as GGUF.
3418 let on_cloud = {
3419 let guard = agent.current_model.lock().unwrap();
3420 guard.model_id.starts_with("sigit-cloud:")
3421 };
3422 if on_cloud {
3423 let default_config = default_local_model_config();
3424 *agent.current_model.lock().unwrap() = default_config;
3425 agent.reset_to_local_backend().await;
3426 }
3427 // Loading an on-device model puts us in local inference mode.
3428 let _ = settings::set_local_inference(true);
3429 // `await_model_ready` drives the download/load progress UI and reports
3430 // success or failure to the editor.
3431 agent.start_startup_model_load_if_needed();
3432 agent.await_model_ready(cx, &session_id).await?;
3433 }
3434 SlashCommand::Login(argument) => {
3435 let message = match argument.as_deref().and_then(account::parse_login_args) {
3436 Some((email, password)) => match account::authenticate(&email, &password).await {
3437 Ok(email) => format!(
3438 "Signed in as {email}. Pick a siGit Code Cloud tier in /models to use it."
3439 ),
3440 Err(error) => format!("Login failed: {error}"),
3441 },
3442 // A bare `/login` takes the browser route, same as the editor's
3443 // sign-in button.
3444 None => browser_login_message().await,
3445 };
3446 agent.send_assistant_message(cx, session_id, message).ok();
3447 }
3448 SlashCommand::Logout => {
3449 // If we're on a cloud tier, drop back to local — the token is gone.
3450 let on_cloud = {
3451 let guard = agent.current_model.lock().unwrap();
3452 guard.model_id.starts_with("sigit-cloud:")
3453 };
3454 let message = account::end_session().await;
3455 if on_cloud {
3456 agent.reset_to_local_backend().await;
3457 }
3458 agent.send_assistant_message(cx, session_id, message).ok();
3459 }
3460 SlashCommand::Whoami => {
3461 let message = account::status_line().await;
3462 agent.send_assistant_message(cx, session_id, message).ok();
3463 }
3464 SlashCommand::Reload => {
3465 agent.handle_reload(cx, session_id).await;
3466 }
3467 SlashCommand::Exit => {
3468 agent
3469 .send_assistant_message(
3470 cx,
3471 session_id,
3472 "Use the panel controls to close or switch threads.",
3473 )
3474 .ok();
3475 }
3476 SlashCommand::Init => {
3477 // Handled in the prompt path (it runs a real turn); reaching this
3478 // arm means a caller forgot that special case.
3479 agent
3480 .send_assistant_message(cx, session_id, "Send /init as a prompt to run it.")
3481 .ok();
3482 }
3483 SlashCommand::Unknown(command, _) => {
3484 agent
3485 .send_assistant_message(
3486 cx,
3487 session_id,
3488 format!(
3489 "unknown command: {command}. Type /commands to list user-defined commands."
3490 ),
3491 )
3492 .ok();
3493 }
3494 }
3495
3496 Ok(PromptResponse::new(StopReason::EndTurn))
3497 }
3498
3499 // ── Request dispatch helper ───────────────────────────────────────────────────
3500
3501 fn handle_response<T: agent_client_protocol::JsonRpcResponse>(
3502 responder: Responder<T>,
3503 result: agent_client_protocol::Result<T>,
3504 ) -> agent_client_protocol::Result<()> {
3505 match result {
3506 Ok(resp) => responder.respond(resp),
3507 Err(err) => responder.respond_with_error(err),
3508 }
3509 }
3510
3511 // ── Download progress helpers ─────────────────────────────────────────────────
3512
3513 /// total bytes on disk under `path`. needed because hf-hub uses staging
3514 /// names during download, so we can't just stat the final blobs.
3515 fn dir_size_recursive(path: &std::path::Path) -> u64 {
3516 let mut total: u64 = 0;
3517 let Ok(entries) = std::fs::read_dir(path) else {
3518 return 0;
3519 };
3520 for entry in entries.flatten() {
3521 let entry_path = entry.path();
3522 if entry_path.is_dir() {
3523 total += dir_size_recursive(&entry_path);
3524 } else if let Ok(meta) = entry_path.metadata() {
3525 total += meta.len();
3526 }
3527 }
3528 total
3529 }
3530
3531 fn format_size_human(bytes: u64) -> String {
3532 const GB: u64 = 1_073_741_824;
3533 const MB: u64 = 1_048_576;
3534 const KB: u64 = 1_024;
3535 if bytes >= GB {
3536 format!("{:.2} GB", bytes as f64 / GB as f64)
3537 } else if bytes >= MB {
3538 format!("{:.1} MB", bytes as f64 / MB as f64)
3539 } else if bytes >= KB {
3540 format!("{:.0} KB", bytes as f64 / KB as f64)
3541 } else {
3542 format!("{bytes} B")
3543 }
3544 }
3545
3546 fn progress_bar(pct: u8, width: usize) -> String {
3547 let filled = ((pct as usize) * width) / 100;
3548 let empty = width.saturating_sub(filled);
3549 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
3550 }
3551
3552 // ── Output capture ────────────────────────────────────────────────────────────
3553
3554 /// redirect stdout+stderr to `$TMPDIR/sigit.log` at the fd level so
3555 /// mistralrs/tracing noise never hits the terminal. returns two dup'd
3556 /// fds to the real tty: one for ratatui, one for cleanup (ratatui 0.29
3557 /// doesn't expose `writer_mut()`).
3558 #[cfg(unix)]
3559 fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
3560 let log_path = std::env::temp_dir().join("sigit.log");
3561 let log_file = std::fs::File::create(&log_path)?;
3562 let log_fd = log_file.as_raw_fd();
3563
3564 // two copies: ratatui needs one, cleanup needs another
3565 let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) };
3566 anyhow::ensure!(
3567 saved_tui >= 0,
3568 "dup(stdout) for tui failed: {}",
3569 std::io::Error::last_os_error()
3570 );
3571 let saved_cleanup = unsafe { libc::dup(libc::STDOUT_FILENO) };
3572 anyhow::ensure!(
3573 saved_cleanup >= 0,
3574 "dup(stdout) for cleanup failed: {}",
3575 std::io::Error::last_os_error()
3576 );
3577
3578 unsafe {
3579 libc::dup2(log_fd, libc::STDOUT_FILENO);
3580 libc::dup2(log_fd, libc::STDERR_FILENO);
3581 }
3582
3583 // safe to drop log_file; dup2 keeps the fd alive via stdout/stderr
3584
3585 Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe {
3586 std::fs::File::from_raw_fd(saved_cleanup)
3587 }))
3588 }
3589
3590 // ── Logging ───────────────────────────────────────────────────────────────────
3591
3592 /// in TUI mode stderr is the log file (redirected earlier);
3593 /// in ACP mode it's real stderr. either way, write there.
3594 fn init_logging(is_tty: bool) {
3595 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
3596 let _ = tracing_fmt::Subscriber::builder()
3597 .with_env_filter(filter)
3598 .with_writer(std::io::stderr)
3599 .with_ansi(!is_tty)
3600 .try_init();
3601 }
3602
3603 // ── Interactive TUI mode ──────────────────────────────────────────────────────
3604
3605 /// boot the TUI and load the model on a background thread.
3606 /// `tty` goes to ratatui; `cleanup_tty` is a separate fd for
3607 /// LeaveAlternateScreen (ratatui 0.29 hides `writer_mut()`).
3608 #[cfg(unix)]
3609 async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
3610 let engine = Arc::new(ChatEngine::new());
3611
3612 let startup_selection = setup::startup_model_selection();
3613 let startup_model_name = startup_selection
3614 .as_ref()
3615 .map(|selection| selection.display_name.clone())
3616 .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
3617
3618 // Signals the loading phase to finish. On-device models are no longer loaded
3619 // at startup, so this resolves immediately for both backends; it stays a
3620 // channel so the loading-phase plumbing in `chat::run_with` is unchanged.
3621 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
3622
3623 // Project instruction files (AGENTS.md / CLAUDE.md) for the launch directory,
3624 // injected into the system prompt so the TUI shares the same always-on
3625 // project context the ACP sessions get.
3626 let project_instructions = std::env::current_dir()
3627 .ok()
3628 .and_then(|cwd| instructions::load_project_instructions(&cwd));
3629 let with_instructions = |base: String| match &project_instructions {
3630 Some(extra) => format!("{base}\n\n{extra}"),
3631 None => base,
3632 };
3633
3634 // Pick the inference backend: a configured provider if present, else on-device.
3635 let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) =
3636 match provider::active_provider() {
3637 Some(provider) => {
3638 log::info!(
3639 "inference: using {} (model {}) at {}",
3640 provider.display_name,
3641 provider.model,
3642 provider.base_url
3643 );
3644 // No local model to load; the endpoint is ready immediately.
3645 let _ = load_tx.send(Ok(()));
3646 let label = provider.display_name.clone();
3647 register_subagent_factory_for(&provider);
3648 let backend = Arc::new(OpenAiBackend::new(
3649 provider.base_url,
3650 provider.api_key,
3651 provider.model,
3652 Some(with_instructions(SYSTEM_PROMPT.to_string())),
3653 )) as Arc<dyn InferenceBackend>;
3654 (backend, label)
3655 }
3656 None => {
3657 // Honor the Local Inference toggle: when off and signed in, start
3658 // on a cloud tier. Otherwise bring up on-device WITHOUT loading a
3659 // model — the user loads it explicitly with /load (or /models), so
3660 // the UI comes up immediately. Project instructions are injected at
3661 // load time in `chat.rs`.
3662 let cloud_when_off = if settings::local_inference_enabled() {
3663 None
3664 } else {
3665 provider::cloud_tier_provider(provider::DEFAULT_CLOUD_TIER)
3666 };
3667
3668 match cloud_when_off {
3669 Some(provider) => {
3670 log::info!(
3671 "inference: local inference off; using {} (model {})",
3672 provider.display_name,
3673 provider.model
3674 );
3675 let _ = load_tx.send(Ok(()));
3676 let label = provider.display_name.clone();
3677 register_subagent_factory_for(&provider);
3678 let backend = Arc::new(OpenAiBackend::new(
3679 provider.base_url,
3680 provider.api_key,
3681 provider.model,
3682 Some(with_instructions(SYSTEM_PROMPT.to_string())),
3683 )) as Arc<dyn InferenceBackend>;
3684 (backend, label)
3685 }
3686 None => {
3687 if !settings::local_inference_enabled() {
3688 log::warn!(
3689 "local inference is off but no account is signed in; \
3690 bringing up on-device. Run /login or /local on."
3691 );
3692 }
3693 let _ = load_tx.send(Ok(()));
3694 // On-device inference has a single shared history; no
3695 // subagent context is possible yet.
3696 tools::set_subagent_factory(Box::new(|_system_prompt: &str| None));
3697 let backend = Arc::new(LocalBackend::new(Arc::clone(&engine)))
3698 as Arc<dyn InferenceBackend>;
3699 (backend, startup_model_name)
3700 }
3701 }
3702 }
3703 };
3704
3705 crossterm::terminal::enable_raw_mode()?;
3706 let mut tty = BufWriter::new(tty);
3707 crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
3708 let term_backend = ratatui::backend::CrosstermBackend::new(tty);
3709 let mut terminal = ratatui::Terminal::new(term_backend)?;
3710
3711 // polls load_rx with try_recv() each tick, no blocking
3712 let chat_result = chat::run_with(
3713 &mut terminal,
3714 engine,
3715 inference_backend,
3716 load_rx,
3717 startup_model_name,
3718 )
3719 .await;
3720
3721 // cleanup fd because backend's writer is private
3722 crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
3723 cleanup_tty.flush()?;
3724 crossterm::terminal::disable_raw_mode()?;
3725
3726 // restore real stdout/stderr for post-TUI error output
3727 #[cfg(unix)]
3728 {
3729 let cleanup_fd = cleanup_tty.as_raw_fd();
3730 unsafe {
3731 libc::dup2(cleanup_fd, libc::STDOUT_FILENO);
3732 libc::dup2(cleanup_fd, libc::STDERR_FILENO);
3733 }
3734 }
3735
3736 chat_result
3737 }
3738
3739 // ── ACP server mode ───────────────────────────────────────────────────────────
3740
3741 /// The on-device model `/load` should bring up by default: the persisted
3742 /// selection if it still resolves to a known local model, otherwise the built-in
3743 /// default (`qwen25_3b`).
3744 fn default_local_model_config() -> GgufModelConfig {
3745 setup::startup_model_selection()
3746 .as_ref()
3747 .and_then(|selection| {
3748 selection.selected_model.as_ref().and_then(|selected| {
3749 models::local_picker_items()
3750 .into_iter()
3751 .find(|item| {
3752 item.config.model_id == selected.model_id
3753 && item
3754 .config
3755 .files
3756 .iter()
3757 .any(|file| file == &selected.gguf_file)
3758 })
3759 .map(|item| item.config)
3760 })
3761 })
3762 .unwrap_or_else(GgufModelConfig::qwen25_3b)
3763 }
3764
3765 fn parse_explicit_acp(args: &[String]) -> Result<bool, String> {
3766 if !args.iter().any(|arg| arg == "--acp") {
3767 return Ok(false);
3768 }
3769 if args.iter().any(|arg| arg != "--acp") {
3770 return Err("--acp does not accept additional arguments".to_string());
3771 }
3772 Ok(true)
3773 }
3774
3775 async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
3776 log::info!("ACP mode — starting agent server");
3777
3778 let config = default_local_model_config();
3779
3780 let needs_download = models::local_picker_items()
3781 .iter()
3782 .find(|item| item.config.model_id == config.model_id)
3783 .map(|item| item.cache_health != setup::ModelCacheHealth::Complete)
3784 .unwrap_or(true);
3785
3786 log::info!(
3787 "ACP startup model selected: {} ({})",
3788 config.display_name,
3789 if needs_download {
3790 "needs download"
3791 } else {
3792 "cached"
3793 }
3794 );
3795
3796 let engine = Arc::new(ChatEngine::new());
3797
3798 // The on-device model is never loaded implicitly; the user loads it with
3799 // `/load` (or by picking one in `/models`). So initialize/session/new stay
3800 // lightweight and `model_ready` starts true (nothing is loading).
3801 let model_ready = Arc::new(AtomicBool::new(true));
3802 let startup_model_load_started = Arc::new(AtomicBool::new(false));
3803 let model_load_error: Arc<std::sync::Mutex<Option<String>>> =
3804 Arc::new(std::sync::Mutex::new(None));
3805
3806 let state = Arc::new(SiGitAgent::new(
3807 engine,
3808 config,
3809 model_ready,
3810 startup_model_load_started,
3811 model_load_error,
3812 needs_download,
3813 auto_load_local_model,
3814 ));
3815
3816 // Honor the explicit provider override (OPENAI_BASE_URL/OPENAI_API_KEY or
3817 // an active providers.toml profile) in ACP mode too — the interactive
3818 // client already does. Without this the override was silently ignored here
3819 // and prompts insisted on a local model. It is also what lets the ACP
3820 // integration test drive the agent against a scripted endpoint
3821 // (tests/acp_permissions.rs). The model picker still shows the local
3822 // selection; overrides are a power-user escape hatch, not a tier.
3823 if let Some(cfg) = provider::active_provider() {
3824 log::info!(
3825 "inference: using {} (model {}) at {}",
3826 cfg.display_name,
3827 cfg.model,
3828 cfg.base_url
3829 );
3830 register_subagent_factory_for(&cfg);
3831 let override_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
3832 cfg.base_url,
3833 cfg.api_key,
3834 cfg.model,
3835 Some(system_prompt_for_model(true).to_string()),
3836 ));
3837 *state.backend.lock().await = override_backend;
3838 } else {
3839 // On-device inference has a single shared conversation history, so a
3840 // second concurrent subagent context is not possible yet.
3841 tools::set_subagent_factory(Box::new(|_system_prompt: &str| None));
3842 }
3843
3844 let stdin = tokio::io::stdin().compat();
3845 let stdout = tokio::io::stdout().compat_write();
3846 let transport = ByteStreams::new(stdout, stdin);
3847
3848 Agent
3849 .builder()
3850 .on_receive_request(
3851 {
3852 let state = Arc::clone(&state);
3853 async move |req: InitializeRequest, responder, _cx: ConnectionTo<Client>| {
3854 handle_response(responder, state.handle_initialize(req).await)
3855 }
3856 },
3857 agent_client_protocol::on_receive_request!(),
3858 )
3859 .on_receive_request(
3860 {
3861 let state = Arc::clone(&state);
3862 async move |req: AuthenticateRequest, responder, _cx: ConnectionTo<Client>| {
3863 handle_response(responder, state.handle_authenticate(req).await)
3864 }
3865 },
3866 agent_client_protocol::on_receive_request!(),
3867 )
3868 // Turn-affecting handlers below run in spawned tasks, serialized by
3869 // `turn_lock`, so the dispatch loop stays free to route client
3870 // responses (permission answers) while a turn is in flight. Awaiting a
3871 // client request from *inside* a handler would deadlock: the dispatch
3872 // loop can't read the response while the handler blocks it.
3873 .on_receive_request(
3874 {
3875 let state = Arc::clone(&state);
3876 async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| {
3877 let state = Arc::clone(&state);
3878 let task_cx = cx.clone();
3879 cx.spawn(async move {
3880 let _turn = state.turn_lock.lock().await;
3881 handle_response(responder, state.handle_load_session(&task_cx, req).await)
3882 })
3883 }
3884 },
3885 agent_client_protocol::on_receive_request!(),
3886 )
3887 .on_receive_request(
3888 {
3889 let state = Arc::clone(&state);
3890 async move |req: ForkSessionRequest, responder, cx: ConnectionTo<Client>| {
3891 let state = Arc::clone(&state);
3892 let task_cx = cx.clone();
3893 cx.spawn(async move {
3894 let _turn = state.turn_lock.lock().await;
3895 handle_response(responder, state.handle_fork_session(&task_cx, req).await)
3896 })
3897 }
3898 },
3899 agent_client_protocol::on_receive_request!(),
3900 )
3901 .on_receive_request(
3902 {
3903 let state = Arc::clone(&state);
3904 async move |req: NewSessionRequest, responder, cx: ConnectionTo<Client>| {
3905 let state = Arc::clone(&state);
3906 let task_cx = cx.clone();
3907 cx.spawn(async move {
3908 let _turn = state.turn_lock.lock().await;
3909 handle_response(responder, state.handle_new_session(&task_cx, req).await)
3910 })
3911 }
3912 },
3913 agent_client_protocol::on_receive_request!(),
3914 )
3915 .on_receive_request(
3916 {
3917 let state = Arc::clone(&state);
3918 async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
3919 let state = Arc::clone(&state);
3920 let task_cx = cx.clone();
3921 cx.spawn(async move {
3922 let _turn = state.turn_lock.lock().await;
3923 handle_response(responder, state.handle_prompt(&task_cx, req).await)
3924 })
3925 }
3926 },
3927 agent_client_protocol::on_receive_request!(),
3928 )
3929 .on_receive_request(
3930 {
3931 let state = Arc::clone(&state);
3932 async move |req: SetSessionConfigOptionRequest,
3933 responder,
3934 cx: ConnectionTo<Client>| {
3935 let state = Arc::clone(&state);
3936 let task_cx = cx.clone();
3937 cx.spawn(async move {
3938 let _turn = state.turn_lock.lock().await;
3939 handle_response(
3940 responder,
3941 state.handle_set_session_config_option(&task_cx, req).await,
3942 )
3943 })
3944 }
3945 },
3946 agent_client_protocol::on_receive_request!(),
3947 )
3948 .on_receive_notification(
3949 {
3950 let state = Arc::clone(&state);
3951 async move |notif: CancelNotification, _cx: ConnectionTo<Client>| {
3952 state.handle_cancel(notif).await
3953 }
3954 },
3955 agent_client_protocol::on_receive_notification!(),
3956 )
3957 .connect_to(transport)
3958 .await
3959 .map_err(|e| anyhow::anyhow!("ACP connection error: {e}"))?;
3960
3961 log::info!("siGit shutting down");
3962 Ok(())
3963 }
3964
3965 /// Run the browser sign-in from a chat surface and describe how it went.
3966 ///
3967 /// Both the TUI and an ACP session reach this from a bare `/login`. Neither can
3968 /// prompt for a pasted code mid-command, so a box with no bindable port is told
3969 /// to use `sigit login`, which can.
3970 pub(crate) async fn browser_login_message() -> String {
3971 let flow = browser_auth::begin();
3972 if !flow.is_loopback() {
3973 return "Could not open a local port to receive the sign-in. \
3974 Run `sigit login` in a terminal instead."
3975 .to_string();
3976 }
3977
3978 browser_auth::open_browser(flow.authorize_url());
3979 let url = flow.authorize_url().to_string();
3980
3981 match browser_auth::complete_loopback(flow).await {
3982 Ok(email) => format!("Signed in as {email}. Pick a siGit Code Cloud tier in /models."),
3983 Err(error) => format!("Login failed: {error}\nYou can also open {url} by hand."),
3984 }
3985 }
3986
3987 /// `sigit login` from a shell: open the browser and wait for the code.
3988 ///
3989 /// Unlike the ACP button this has a terminal to fall back on, so when no local
3990 /// port can be bound the flow switches to the paste variant and reads the code
3991 /// from stdin. That is the path an ssh session takes.
3992 async fn terminal_browser_login() -> Result<String, String> {
3993 use std::io::{Write, stdin, stdout};
3994
3995 // `--paste` forces the code-in-hand variant. Binding a port succeeds on a
3996 // remote box, but nothing there can reach it if the browser is running on
3997 // the laptop in front of you, so the choice can't be made by probing.
3998 let paste = std::env::args().any(|arg| arg == "--paste");
3999 let flow = if paste {
4000 browser_auth::begin_paste()
4001 } else {
4002 browser_auth::begin()
4003 };
4004 let opened = browser_auth::open_browser(flow.authorize_url());
4005
4006 if flow.is_loopback() {
4007 if opened {
4008 println!("Opened your browser to finish signing in.");
4009 } else {
4010 println!("Open this URL to finish signing in:");
4011 }
4012 println!(" {}", flow.authorize_url());
4013 println!("Waiting...");
4014 return browser_auth::complete_loopback(flow).await;
4015 }
4016
4017 println!("Open this URL to finish signing in:");
4018 println!(" {}", flow.authorize_url());
4019 print!("Then paste the code shown there: ");
4020 stdout().flush().ok();
4021 let mut code = String::new();
4022 stdin()
4023 .read_line(&mut code)
4024 .map_err(|error| format!("could not read the code: {error}"))?;
4025
4026 browser_auth::complete_paste(&flow, &code).await
4027 }
4028
4029 // ── Entry point ──────────────────────────────────────────────────────────────
4030
4031 #[tokio::main]
4032 async fn main() -> anyhow::Result<()> {
4033 // Account subcommands. The editor launches `sigit login` in an embedded
4034 // terminal for ACP terminal-based authentication; the same verbs are handy
4035 // directly from a shell. These must be handled before the TTY/ACP split.
4036 if let Some(verb) = std::env::args().nth(1) {
4037 match verb.as_str() {
4038 "login" => {
4039 init_logging(true);
4040 // `--password` keeps the old email/password prompt for anyone
4041 // who wants it (scripts, a box with no browser at all).
4042 let by_password = std::env::args().any(|arg| arg == "--password");
4043 let result = if by_password {
4044 account::interactive_login().await
4045 } else {
4046 terminal_browser_login().await
4047 };
4048 match result {
4049 Ok(email) => {
4050 println!("Signed in to siGit Code Cloud as {email}.");
4051 return Ok(());
4052 }
4053 Err(error) => {
4054 eprintln!("Login failed: {error}");
4055 std::process::exit(1);
4056 }
4057 }
4058 }
4059 "logout" => {
4060 init_logging(true);
4061 println!("{}", account::end_session().await);
4062 return Ok(());
4063 }
4064 "whoami" => {
4065 init_logging(true);
4066 println!("{}", account::status_line().await);
4067 return Ok(());
4068 }
4069 _ => {}
4070 }
4071 }
4072
4073 // Headless programmatic mode: `sigit -p "<prompt>"` runs one prompt and
4074 // exits. Plain stdio and cross-platform (unlike the Unix-only TUI), so it
4075 // is dispatched here, before the TTY/ACP split, like the account
4076 // subcommands above.
4077 let cli_args: Vec<String> = std::env::args().skip(1).collect();
4078 // Xcode's custom-agent UI launches ACP agents with an explicit `--acp`
4079 // argument. Honor it even when Xcode gives the child a terminal-like stdin.
4080 match parse_explicit_acp(&cli_args) {
4081 Err(error) => {
4082 eprintln!("sigit: {error}");
4083 std::process::exit(2);
4084 }
4085 Ok(true) => {
4086 init_logging(false);
4087 setup::setup_shared_model_cache();
4088 mcp::init().await;
4089 log::info!(
4090 "siGit v{} starting (explicit ACP mode)",
4091 env!("CARGO_PKG_VERSION")
4092 );
4093 return run_acp_server(true).await;
4094 }
4095 Ok(false) => {}
4096 }
4097 match headless::parse_args(&cli_args) {
4098 Ok(None) => {}
4099 Ok(Some(config)) => {
4100 // Logs to stderr; stdout stays clean for the assistant's answer.
4101 init_logging(false);
4102 // Enter --cwd before anything loads, so instruction files and
4103 // project-local MCP config resolve from it (the headless
4104 // counterpart of the ACP session cwd handling).
4105 if let Some(dir) = &config.cwd
4106 && let Err(error) = std::env::set_current_dir(dir)
4107 {
4108 eprintln!("sigit: cannot use --cwd {}: {error}", dir.display());
4109 std::process::exit(2);
4110 }
4111 // --add-dir is the headless form of a multi-root project: extra
4112 // roots that project-local discovery scans alongside the cwd.
4113 if !config.add_dirs.is_empty() {
4114 for dir in &config.add_dirs {
4115 if !dir.is_dir() {
4116 eprintln!(
4117 "sigit: cannot use --add-dir {}: not a directory",
4118 dir.display()
4119 );
4120 std::process::exit(2);
4121 }
4122 }
4123 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
4124 workspace::set_additional_roots(&cwd, &config.add_dirs);
4125 }
4126 setup::setup_shared_model_cache();
4127 // Best-effort MCP discovery (incl. the official server), like the
4128 // other entry points, so MCP tools are offered to the model.
4129 mcp::init().await;
4130 log::info!(
4131 "siGit v{} starting (headless mode)",
4132 env!("CARGO_PKG_VERSION")
4133 );
4134 std::process::exit(headless::run(config).await);
4135 }
4136 Err(error) => {
4137 eprintln!("sigit: {error}\n{}", headless::USAGE);
4138 std::process::exit(2);
4139 }
4140 }
4141
4142 let is_tty = std::io::stdin().is_terminal();
4143
4144 if is_tty {
4145 // must redirect before any library code touches stdout
4146 #[cfg(unix)]
4147 {
4148 let (tty, cleanup_tty) = redirect_output_to_log()?;
4149 init_logging(true);
4150 setup::setup_shared_model_cache();
4151 // Best-effort: discover MCP servers (incl. the official one) before
4152 // the first turn so their tools are offered to the model.
4153 mcp::init().await;
4154 run_interactive(tty, cleanup_tty).await
4155 }
4156 #[cfg(not(unix))]
4157 {
4158 anyhow::bail!("interactive mode requires Unix (macOS / Linux)");
4159 }
4160 } else {
4161 // ACP mode: keep stdout untouched for protocol JSON only.
4162 // Logs already go to stderr via `init_logging(false)`.
4163 init_logging(false);
4164 setup::setup_shared_model_cache();
4165 // Best-effort MCP discovery (incl. the official server) before serving.
4166 mcp::init().await;
4167 log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION"));
4168 run_acp_server(false).await
4169 }
4170 }
4171
4172 #[cfg(test)]
4173 mod tests {
4174 use super::*;
4175
4176 #[test]
4177 fn session_context_message_lists_every_root_of_a_multi_root_project() {
4178 let primary = PathBuf::from("/tmp/primary");
4179 let secondary = PathBuf::from("/tmp/secondary");
4180
4181 let single = session_context_message(&primary, &[]);
4182 assert!(single.contains("/tmp/primary"));
4183 assert!(!single.contains("multi-root"));
4184
4185 let multi = session_context_message(&primary, &[secondary]);
4186 assert!(multi.contains("multi-root"));
4187 assert!(multi.contains("/tmp/primary (primary)"));
4188 assert!(multi.contains("/tmp/secondary"));
4189 // Relative paths only resolve against the primary root, so the model is
4190 // told to address the others absolutely.
4191 assert!(multi.contains("absolute paths"));
4192 }
4193
4194 #[test]
4195 fn history_replay_redraws_the_conversation_for_the_client() {
4196 // A fixed path, not `std::env::current_dir()`: the cwd is process-global
4197 // and a handful of other tests (skills/commands/subagents discovery,
4198 // the subagent end-to-end test) briefly `set_current_dir` to a temp
4199 // directory behind `ENV_TEST_LOCK`. This test asserts on the exact
4200 // rendered title, so racing one of them mid-run doesn't just read a
4201 // stale value — it can hand back a temp path long enough to trip the
4202 // title's truncation, breaking an assertion that has nothing to do
4203 // with the cwd. A literal path sidesteps the shared state entirely.
4204 let changelog_path = if cfg!(windows) {
4205 PathBuf::from(r"C:\repo\CHANGELOG.md")
4206 } else {
4207 PathBuf::from("/repo/CHANGELOG.md")
4208 };
4209 let changelog_path_text = changelog_path.to_string_lossy().into_owned();
4210 let arguments = serde_json::json!({ "path": changelog_path_text }).to_string();
4211 let history = vec![
4212 serde_json::json!({ "role": "system", "content": "project context" }),
4213 serde_json::json!({ "role": "user", "content": "read the changelog" }),
4214 serde_json::json!({
4215 "role": "assistant",
4216 "content": "<think>which file?</think>Reading it now.",
4217 "tool_calls": [{
4218 "id": "call_1", "type": "function",
4219 "function": { "name": "read_file", "arguments": arguments },
4220 }],
4221 }),
4222 serde_json::json!({ "role": "tool", "tool_call_id": "call_1", "content": "# Changelog" }),
4223 serde_json::json!({ "role": "assistant", "content": "It starts at v1.0." }),
4224 ];
4225
4226 let updates = history_replay_updates(&history);
4227
4228 // The system message seeded the model; it was never on screen.
4229 assert_eq!(updates.len(), 4, "{updates:#?}");
4230
4231 match &updates[0] {
4232 SessionUpdate::UserMessageChunk(chunk) => {
4233 assert!(format!("{:?}", chunk.content).contains("read the changelog"));
4234 }
4235 other => panic!("expected the user message first, got {other:?}"),
4236 }
4237 match &updates[1] {
4238 SessionUpdate::AgentMessageChunk(chunk) => {
4239 let rendered = format!("{:?}", chunk.content);
4240 assert!(rendered.contains("Reading it now."));
4241 // Reasoning stays hidden on replay, exactly as it did live.
4242 assert!(!rendered.contains("which file?"));
4243 }
4244 other => panic!("expected the assistant reply, got {other:?}"),
4245 }
4246 match &updates[2] {
4247 SessionUpdate::ToolCall(call) => {
4248 assert_eq!(call.title, format!("read_file · {changelog_path_text}"));
4249 assert_eq!(call.kind, ToolKind::Read);
4250 // Nothing is still running in a saved session.
4251 assert_eq!(call.status, ToolCallStatus::Completed);
4252 assert_eq!(
4253 call.raw_input,
4254 Some(serde_json::json!({"path": changelog_path_text}))
4255 );
4256 // The result is folded into its call rather than replayed loose.
4257 assert_eq!(
4258 call.raw_output,
4259 Some(serde_json::Value::String("# Changelog".to_string()))
4260 );
4261 // Replay is expandable exactly like a live card: the saved
4262 // output shows up as displayable content, not just raw_output.
4263 assert!(format!("{:?}", call.content).contains("# Changelog"));
4264 assert_eq!(call.locations.len(), 1);
4265 assert_eq!(call.locations[0].path, changelog_path);
4266 }
4267 other => panic!("expected the tool call, got {other:?}"),
4268 }
4269 assert!(matches!(updates[3], SessionUpdate::AgentMessageChunk(_)));
4270 }
4271
4272 #[test]
4273 fn history_replay_renders_write_todos_as_a_plan() {
4274 let history = vec![serde_json::json!({
4275 "role": "assistant",
4276 "content": null,
4277 "tool_calls": [{
4278 "id": "call_1", "type": "function",
4279 "function": {
4280 "name": "write_todos",
4281 "arguments": "{\"todos\":[{\"content\":\"Ship it\",\"status\":\"completed\"}]}",
4282 },
4283 }],
4284 })];
4285
4286 match history_replay_updates(&history).as_slice() {
4287 [SessionUpdate::Plan(plan)] => {
4288 assert_eq!(plan.entries.len(), 1);
4289 assert_eq!(plan.entries[0].content, "Ship it");
4290 assert_eq!(plan.entries[0].status, PlanEntryStatus::Completed);
4291 }
4292 other => panic!("expected one plan update, got {other:?}"),
4293 }
4294 }
4295
4296 #[test]
4297 fn history_replay_shows_a_placeholder_for_a_call_with_no_recorded_output() {
4298 // A session file can carry a tool call whose result was never saved
4299 // (e.g. an older session format). Replay must not show an empty card.
4300 let history = vec![serde_json::json!({
4301 "role": "assistant",
4302 "content": null,
4303 "tool_calls": [{
4304 "id": "call_1", "type": "function",
4305 "function": { "name": "run_command", "arguments": "{\"command\":\"git add -A\"}" },
4306 }],
4307 })];
4308
4309 match history_replay_updates(&history).as_slice() {
4310 [SessionUpdate::ToolCall(call)] => {
4311 assert!(format!("{:?}", call.content).contains("(no output)"));
4312 assert_eq!(call.raw_output, None);
4313 }
4314 other => panic!("expected one tool call, got {other:?}"),
4315 }
4316 }
4317
4318 #[test]
4319 fn history_replay_skips_messages_with_nothing_to_show() {
4320 // A tool-call-only assistant turn (`content: null`), an empty reply and
4321 // a blank user message must not become empty bubbles in the client.
4322 let history = vec![
4323 serde_json::json!({ "role": "user", "content": " " }),
4324 serde_json::json!({ "role": "assistant", "content": null }),
4325 serde_json::json!({ "role": "assistant", "content": "" }),
4326 ];
4327 assert!(history_replay_updates(&history).is_empty());
4328 }
4329
4330 #[test]
4331 fn history_replay_reads_block_form_content() {
4332 // Some OpenAI-compatible endpoints hand back content blocks rather than
4333 // a plain string; both reach the session file.
4334 let history = vec![serde_json::json!({
4335 "role": "user",
4336 "content": [
4337 { "type": "text", "text": "check " },
4338 { "type": "text", "text": "this file" },
4339 ],
4340 })];
4341
4342 match history_replay_updates(&history).as_slice() {
4343 [SessionUpdate::UserMessageChunk(chunk)] => {
4344 assert!(format!("{:?}", chunk.content).contains("check this file"));
4345 }
4346 other => panic!("expected one user message, got {other:?}"),
4347 }
4348 }
4349
4350 #[test]
4351 fn streamed_reply_sends_each_fragment_as_it_is_revealed() {
4352 let mut reply = StreamedReply::default();
4353 assert_eq!(reply.push("Hello"), Some("Hello".to_string()));
4354 assert_eq!(reply.push(", world"), Some(", world".to_string()));
4355 assert!(reply.streamed_any);
4356 }
4357
4358 #[test]
4359 fn streamed_reply_opens_a_paragraph_after_a_tool_round() {
4360 let mut reply = StreamedReply::default();
4361 reply.push("I'll search for this pattern.");
4362
4363 // …tool calls run, then the next round starts talking again.
4364 reply.interrupt();
4365
4366 assert_eq!(
4367 reply.push("Let me broaden the search:"),
4368 Some("\n\nLet me broaden the search:".to_string()),
4369 "the new round must not run onto the end of the last sentence"
4370 );
4371 // Only the seam gets a break; the rest of the round streams as usual.
4372 assert_eq!(reply.push(" Found them."), Some(" Found them.".to_string()));
4373 }
4374
4375 #[test]
4376 fn streamed_reply_drops_the_models_own_whitespace_at_the_seam() {
4377 let mut reply = StreamedReply::default();
4378 reply.push("Checking that.");
4379 reply.interrupt();
4380
4381 // A fragment of pure whitespace reveals nothing and must not consume
4382 // the pending break.
4383 assert_eq!(reply.push("\n"), None);
4384 assert_eq!(
4385 reply.push(" Done."),
4386 Some("\n\nDone.".to_string()),
4387 "the break replaces the model's leading whitespace"
4388 );
4389 }
4390
4391 #[test]
4392 fn streamed_reply_does_not_open_with_a_blank_paragraph() {
4393 // First round was tool calls only, so nothing is on the wire yet and
4394 // there is no sentence to break away from.
4395 let mut reply = StreamedReply::default();
4396 reply.interrupt();
4397
4398 assert_eq!(reply.push("Found them."), Some("Found them.".to_string()));
4399 }
4400
4401 #[test]
4402 fn streamed_reply_hides_reasoning_and_still_breaks_the_paragraph() {
4403 let mut reply = StreamedReply::default();
4404 reply.push("Looking now.");
4405 reply.interrupt();
4406
4407 // A round that opens with reasoning reveals nothing until the visible
4408 // text arrives — and that text still starts the new paragraph.
4409 assert_eq!(reply.push("<think>weigh"), None);
4410 assert_eq!(reply.push(" options</think>"), None);
4411 assert_eq!(
4412 reply.push("Here it is."),
4413 Some("\n\nHere it is.".to_string()),
4414 "the break waits for the reasoning to end and rides out with the text"
4415 );
4416 }
4417
4418 #[test]
4419 fn explicit_acp_flag_is_accepted_without_other_arguments() {
4420 assert_eq!(parse_explicit_acp(&["--acp".to_string()]), Ok(true));
4421 assert_eq!(parse_explicit_acp(&[]), Ok(false));
4422 assert!(parse_explicit_acp(&["--acp".to_string(), "--quiet".to_string()]).is_err());
4423 }
4424
4425 #[test]
4426 fn system_prompt_advertises_the_commit_co_author_trailer() {
4427 // The prompt instructs the model with the exact trailer that
4428 // `tools::ensure_commit_co_author` enforces; if the two drift apart the
4429 // safety net would re-amend commits the model already attributed.
4430 assert!(
4431 SYSTEM_PROMPT.contains(tools::COMMIT_CO_AUTHOR_TRAILER),
4432 "SYSTEM_PROMPT must quote tools::COMMIT_CO_AUTHOR_TRAILER verbatim"
4433 );
4434 }
4435
4436 #[test]
4437 fn parse_slash_maps_init() {
4438 assert!(matches!(parse_slash("/init"), Some(SlashCommand::Init)));
4439 // Trailing whitespace comes from editors that submit the raw line.
4440 assert!(matches!(parse_slash(" /init "), Some(SlashCommand::Init)));
4441 }
4442
4443 #[test]
4444 fn parse_slash_finds_command_after_client_context_blocks() {
4445 let texts = [
4446 "<system-reminder>Xcode context</system-reminder>",
4447 "Project structure: App/App.swift",
4448 "/models",
4449 ];
4450
4451 assert!(matches!(
4452 parse_slash_from_prompt_texts(texts.into_iter()),
4453 Some(SlashCommand::Models(None))
4454 ));
4455 }
4456
4457 #[test]
4458 fn system_prompt_points_issue_and_pr_work_at_the_official_mcp_tools() {
4459 // The guidance must reference the exact namespaced names the official
4460 // server's tools get (see mcp::official_tool_name), or the model will
4461 // call tools that don't exist.
4462 for tool in [
4463 "list_issues",
4464 "get_issue",
4465 "create_issue",
4466 "list_pull_requests",
4467 "get_pull_request",
4468 ] {
4469 assert!(
4470 SYSTEM_PROMPT.contains(&mcp::official_tool_name(tool)),
4471 "SYSTEM_PROMPT must mention mcp__sigit__{tool}"
4472 );
4473 }
4474 }
4475
4476 #[test]
4477 fn ascii_safe_replaces_multibyte_chars() {
4478 // The exact label that crashed Zed: the cloud tier name plus the old
4479 // "[☁ siGit Code Cloud]" badge. After sanitizing it must be pure ASCII so
4480 // Zed's fixed byte-offset truncation can never split a glyph.
4481 let crashing = "siGit Code Cloud · Balanced [☁ siGit Code Cloud]";
4482 let safe = ascii_safe(crashing);
4483 assert!(safe.is_ascii(), "sanitized label must be ASCII: {safe:?}");
4484 assert_eq!(safe, "siGit Code Cloud - Balanced [- siGit Code Cloud]");
4485 }
4486
4487 #[test]
4488 fn ascii_safe_leaves_ascii_untouched() {
4489 let plain = "Qwen 2.5 3B [Onde]";
4490 assert_eq!(ascii_safe(plain), plain);
4491 }
4492
4493 #[test]
4494 fn ascii_safe_output_has_only_char_boundaries() {
4495 // Every byte index in an ASCII string is a valid char boundary, so any
4496 // downstream truncation is panic-free regardless of where it cuts.
4497 let safe = ascii_safe("Onde · ◉ ↓ ☁ ○ test");
4498 for i in 0..=safe.len() {
4499 assert!(safe.is_char_boundary(i));
4500 }
4501 }
4502
4503 #[test]
4504 fn acp_config_option_labels_show_context_window() {
4505 let current = GgufModelConfig {
4506 model_id: "sigit-cloud:oke".to_string(),
4507 files: Vec::new(),
4508 tok_model_id: None,
4509 display_name: provider::cloud_tier_label("oke"),
4510 approx_memory: "Cloud".to_string(),
4511 chat_template: None,
4512 };
4513 let options = serde_json::to_value(build_config_options(&current, "t-config")).unwrap();
4514 let all_options = options.as_array().expect("config options");
4515
4516 let model = all_options
4517 .iter()
4518 .find(|option| option["id"] == MODEL_CONFIG_ID)
4519 .expect("model config option");
4520 let model_names: Vec<&str> = model["options"]
4521 .as_array()
4522 .expect("model select options")
4523 .iter()
4524 .filter_map(|option| option["name"].as_str())
4525 .collect();
4526 assert!(model_names.contains(&"Oke - 200K ctx"));
4527 assert!(
4528 !model_names
4529 .iter()
4530 .any(|name| name.contains("siGit Code Cloud")),
4531 "cloud branding belongs in descriptions, not selected-value labels: {model_names:?}"
4532 );
4533 assert!(
4534 model_names.iter().all(|name| name.contains(" ctx")),
4535 "every model option should expose its context window: {model_names:?}"
4536 );
4537
4538 let inference = all_options
4539 .iter()
4540 .find(|option| option["id"] == LOCAL_INFERENCE_CONFIG_ID)
4541 .expect("inference config option");
4542 let inference_names: Vec<&str> = inference["options"]
4543 .as_array()
4544 .expect("inference select options")
4545 .iter()
4546 .filter_map(|option| option["name"].as_str())
4547 .collect();
4548 assert_eq!(inference_names, ["Local", "Cloud"]);
4549
4550 let permissions = all_options
4551 .iter()
4552 .find(|option| option["id"] == PERMISSION_MODE_CONFIG_ID)
4553 .expect("permissions config option");
4554 assert_eq!(permissions["category"], "mode");
4555 assert_eq!(permissions["currentValue"], PERMISSION_MODE_MANUAL);
4556 let permission_names: Vec<&str> = permissions["options"]
4557 .as_array()
4558 .expect("permissions select options")
4559 .iter()
4560 .filter_map(|option| option["name"].as_str())
4561 .collect();
4562 assert_eq!(permission_names, ["Manual", "Auto", "Plan"]);
4563 }
4564
4565 #[test]
4566 fn tool_output_content_shows_a_placeholder_for_empty_output() {
4567 let content = tool_output_content("");
4568 assert!(format!("{content:?}").contains("(no output)"));
4569 }
4570
4571 #[test]
4572 fn tool_output_content_preserves_whitespace_only_output() {
4573 let content = tool_output_content(" \n\t ");
4574 let rendered = format!("{content:?}");
4575 assert!(!rendered.contains("(no output)"));
4576 assert!(rendered.contains(" \\n\\t "));
4577 }
4578
4579 #[test]
4580 fn tool_arguments_content_only_labels_valid_json() {
4581 let json = format!("{:?}", tool_arguments_content(r#"{"path":"src/main.rs"}"#));
4582 let malformed = format!("{:?}", tool_arguments_content("{not json"));
4583 assert!(json.contains("```json"));
4584 assert!(!malformed.contains("```json"));
4585 assert!(malformed.contains("{not json"));
4586 }
4587
4588 #[test]
4589 fn fenced_code_block_drops_an_unsafe_language() {
4590 let block = fenced_code_block("json\nnot fenced", "{}");
4591 assert_eq!(block, "```\n{}\n```");
4592 }
4593
4594 #[test]
4595 fn tool_output_content_widens_the_fence_around_embedded_backticks() {
4596 let output = "here is a fence:\n```\ncode\n```";
4597 let content = tool_output_content(output);
4598 let rendered = format!("{content:?}");
4599 // A three-backtick fence would terminate early on the embedded ```;
4600 // the wrapping fence must be longer than any run already present.
4601 assert!(rendered.contains("````"));
4602 }
4603
4604 #[test]
4605 fn tool_output_content_caps_long_output_via_cap_output_preview() {
4606 // Comfortably past chat's internal preview cap without depending on
4607 // its exact value, which is private to that module.
4608 let output = "x".repeat(10_000);
4609 let content = tool_output_content(&output);
4610 let rendered = format!("{content:?}");
4611 assert!(rendered.contains("truncated"));
4612 }
4613
4614 #[test]
4615 fn longest_backtick_run_finds_the_widest_run() {
4616 assert_eq!(longest_backtick_run("no backticks here"), 0);
4617 assert_eq!(longest_backtick_run("one ` two `` three ``` four"), 3);
4618 }
4619
4620 #[test]
4621 fn tool_call_locations_require_a_known_tool_and_string_path() {
4622 assert!(tool_call_locations("read_file", "{not json").is_empty());
4623 assert!(tool_call_locations("read_file", r#"{"path":42}"#).is_empty());
4624 assert!(
4625 tool_call_locations("future_multi_file_tool", r#"{"path":"src/main.rs"}"#).is_empty()
4626 );
4627
4628 let locations = tool_call_locations("multi_edit", r#"{"path":"src/main.rs"}"#);
4629 assert_eq!(locations.len(), 1);
4630 assert!(locations[0].path.ends_with("src/main.rs"));
4631 }
4632 }