@hej / sigit / commits / 59e0ce9

Rewrite and clarify README and docs for conciseness

paydii committed Apr 28, 2026 at 07:04 UTC 59e0ce9e5ff2ac803ef5b2c5c8c4ffeb108a1a80
8 files changed +218 -534
README.md
+22 -24
@@ -10,34 +10,32 @@
10
11 siGit is a coding agent that runs on your machine. No API keys. No cloud round-trips.
12
13 -It is meant to be useful anywhere, but it is especially good in smbCloud codebases. It already knows the rough shape of the platform: Rust workspaces with focused crates, Rails services, deploy flows, auth boundaries, and platform-managed services like GresIQ. In smbCloud repos, that usually means less back-and-forth and fewer generic answers.
13 +It works in any codebase, but it's particularly at home in smbCloud repos. It knows the shape of the platform: Rust workspaces, Rails services, deploy flows, auth boundaries, GresIQ. That means less back-and-forth and fewer generic answers when you're working on smbCloud stuff.
14
15 -siGit has two main modes:
15 +Two modes:
16
17 -- ACP mode, where Zed or another ACP-compatible editor starts it over stdio
18 -- an interactive terminal chat when you run `sigit` yourself
17 +- **ACP mode** — Zed or another ACP-compatible editor starts it over stdio
18 +- **Terminal mode** — run `sigit` directly for an interactive chat
19
20 -Current platform support:
20 +| Platform | ACP mode | Terminal mode |
21 +|----------|----------|---------------|
22 +| macOS | ✓ | ✓ |
23 +| Linux | ✓ | ✓ |
24 +| Windows | ✓ | not yet |
25
22 -- macOS: ACP mode and interactive terminal mode
23 -- Linux: ACP mode and interactive terminal mode
24 -- Windows: ACP mode only for now
26 +## smbCloud context
27
26 -## What siGit knows about smbCloud
28 +When siGit is in an smbCloud repo, it uses platform context instead of giving generic cloud-app advice:
29
28 -When siGit is working in an smbCloud repo, it should use platform context instead of treating everything like a generic cloud app. That includes things like:
30 +- platform user flows vs. tenant app auth flows are different things
31 +- `Project` is the umbrella workspace; `FrontendApp`, `AuthApp`, and GresIQ are separate deployable units
32 +- Next.js SSR deploys aren't the same as the git-push path
33 +- existing workspace patterns and crate boundaries over new abstractions
34
30 -- the difference between platform user flows and tenant app auth flows
31 -- the fact that `Project` is the umbrella workspace, while app-like resources such as `FrontendApp`, `AuthApp`, and GresIQ are separate deployable units
32 -- the fact that Next.js SSR deploys are not the same as the generic git-push path
33 -- the fact that smbCloud repos usually prefer existing workspace patterns and crate boundaries over new abstractions
34 -
35 -Outside smbCloud, it should still behave like a normal coding agent and not force platform-specific advice into places where it does not belong.
35 +Outside smbCloud repos it stays general. No platform-specific advice where it doesn't belong.
36
37 ## Install
38
39 -Install siGit Code with Cargo, Homebrew, pip, or npm:
40 -
39 ```sh
40 cargo install sigit
41 ```
@@ -51,9 +49,9 @@ cargo install sigit
49
50 ## First run
51
54 -The first time siGit starts, it downloads a GGUF model, usually around 1 to 2 GB, from Hugging Face. After that, it loads from disk in a few seconds.
52 +On first launch siGit downloads a GGUF model from Hugging Face, usually 1–2 GB. After that it loads from disk in a few seconds.
53
56 -On macOS, siGit shares its model cache with the siGit desktop app through an App Group container. If the desktop app already downloaded the model, the CLI reuses it.
54 +On macOS, the model cache is shared with the siGit desktop app through an App Group container. If the desktop app already has the model, the CLI reuses it.
55
56 ## Zed setup
57
@@ -70,7 +68,7 @@ Add to `~/.config/zed/settings.json`:
68 }
69 ```
70
73 -Use the full absolute path. `~` does not get expanded here.
71 +Use the full absolute path. `~` doesn't expand here.
72
73 ## VSCode via ACP Client extension
74
@@ -90,9 +88,9 @@ Install [ACP client](https://marketplace.visualstudio.com/items?itemName=formula
88
89 ## Terminal mode
90
93 -If you run `sigit` directly in a terminal, it opens an interactive chat UI. It uses the same model and system prompt as the editor integration, so it is handy for quick questions when you do not want to open Zed first.
91 +Run `sigit` in a terminal and you get an interactive chat UI. Same model and system prompt as the editor integration, just without opening Zed.
92
95 -That terminal mode still depends on Unix terminal behavior, so it works on macOS and Linux. On Windows, siGit supports ACP and editor mode only for now.
93 +Terminal mode needs Unix terminal behavior, so macOS and Linux only. Windows gets ACP mode for now.
94
95 ## Platform support
96
@@ -104,7 +102,7 @@ That terminal mode still depends on Unix terminal behavior, so it works on macOS
102
103 ## License
104
107 -Licensed under **Apache 2.0**. See [LICENSE](https://github.com/getsigit/sigit/blob/main/LICENSE).
105 +[Apache 2.0](https://github.com/getsigit/sigit/blob/main/LICENSE)
106
107 ## Copyright
108
npm/README.md.tmpl
+3 -3
@@ -2,7 +2,7 @@
2
3 <p align="center">
4 AI coding agent powered by local LLM via <a href="https://ondeinference.com">Onde Inference</a>.<br>
5 - Runs entirely on your machine — no API keys, no cloud round-trips.
5 + Runs on your machine. No API keys, no cloud.
6 </p>
7
8 <p align="center">
@@ -17,9 +17,9 @@
17
18 ## `${node_pkg}`
19
20 -This is the **npm** distribution of siGit Code — a platform-specific binary package for [`@smbcloud/sigit`](https://www.npmjs.com/package/@smbcloud/sigit). You don't need to install it directly.
20 +This is the npm distribution of siGit Code, a platform-specific binary package for [`@smbcloud/sigit`](https://www.npmjs.com/package/@smbcloud/sigit). You don't need to install it directly.
21
22 -Install the main package and npm pulls in the right binary for your OS and architecture automatically:
22 +Install the main package and npm pulls in the right binary for your OS and architecture:
23
24 ```sh
25 npm install -g @smbcloud/sigit
pypi/README.md
+16 -26
@@ -1,8 +1,8 @@
1 <h1 align="center">siGit Code</h1>
2
3 <p align="center">
4 - <strong>AI coding agent powered by local LLM via <a href="https://ondeinference.com">Onde Inference</a>.</strong><br>
5 - ACP-compatible agent that runs entirely on your machine — no API keys, no cloud.
4 + AI coding agent powered by local LLM via <a href="https://ondeinference.com">Onde Inference</a>.<br>
5 + Runs on your machine. No API keys, no cloud.
6 </p>
7
8 <p align="center">
@@ -19,26 +19,24 @@
19
20 ## Install
21
22 -Use `pip` or `uv`:
23 -
22 ```sh
23 pip install sigit-code
24 uvx --from sigit-code sigit
25 ```
26
29 -Installs the native `sigit` binary for your platform — no compiler, no Node.js, no runtime dependencies.
27 +Installs a native `sigit` binary for your platform. No compiler, no runtime dependencies.
28
29 ## Quick start
30
33 -### Interactive TUI
31 +### Terminal
32
33 ```sh
34 sigit
35 ```
36
39 -A terminal UI opens where you can chat with a local LLM coding agent directly.
37 +Opens a chat UI where you talk to a local LLM coding agent directly.
38
41 -### Zed editor (ACP agent)
39 +### Zed (ACP agent)
40
41 siGit works as an [ACP-compatible](https://github.com/nicobailon/agent-client-protocol) agent in [Zed](https://zed.dev). Add this to your Zed settings:
42
@@ -53,9 +51,9 @@ siGit works as an [ACP-compatible](https://github.com/nicobailon/agent-client-pr
51 }
52 ```
53
56 -Then select **sigit** as your agent profile in the Zed assistant panel.
54 +Then pick **sigit** as your agent in the Zed assistant panel.
55
58 -## Other installation methods
56 +## Other install methods
57
58 | Method | Command |
59 |--------|---------|
@@ -74,27 +72,19 @@ cargo build --release
72
73 ## Platform support
74
77 -Pre-built native binaries ship for every major platform:
78 -
79 -| Platform | Architecture |
80 -|---------------|--------------|
81 -| macOS | arm64, x64 |
82 -| Linux (glibc) | arm64, x64 |
83 -| Windows | arm64, x64 |
75 +| Platform | Architecture |
76 +|----------|--------------|
77 +| macOS | arm64, x64 |
78 +| Linux (glibc) | arm64, x64 |
79 +| Windows | arm64, x64 |
80
85 -## Source & issues
81 +## Source and issues
82
87 -This package ships a pre-built native binary. Source lives at
88 -[github.com/getsigit/sigit](https://github.com/getsigit/sigit) —
89 -file bugs and feature requests there.
83 +This package ships a pre-built binary. Source code is at [github.com/getsigit/sigit](https://github.com/getsigit/sigit). File bugs there.
84
85 ## License
86
93 -Licensed under **Apache 2.0**.
94 -
95 -- [Apache License 2.0](https://github.com/getsigit/sigit/blob/main/LICENSE)
96 -
97 ----
87 +[Apache 2.0](https://github.com/getsigit/sigit/blob/main/LICENSE)
88
89 ## Copyright
90
src/chat.rs
+49 -130
@@ -1,44 +1,33 @@
1 -//! Full-screen terminal chat UI for siGit Code.
1 +//! Full-screen terminal chat UI.
2 //!
3 -//! Takes over the alternate screen and multiplexes terminal events with
4 -//! streaming LLM tokens via `tokio::select!`.
5 -//!
6 -//! The UI has two phases:
7 -//!
8 -//! 1. **Loading phase** — a centered spinner is shown while the model loads
9 -//! in the background. The oneshot channel from the caller signals
10 -//! completion or failure.
11 -//! 2. **Chat phase** — normal interactive chat once `load_rx` resolves.
3 +//! Two phases: a loading spinner while the model initializes, then
4 +//! interactive chat. Uses `tokio::select!` to multiplex terminal events
5 +//! with streaming LLM tokens.
6
7 // ── Think-block stripping ─────────────────────────────────────────────────────
8
15 -/// Strip `<think>…</think>` blocks from a model response.
16 -///
17 -/// Qwen 3 models emit `<think>…</think>` before the real answer. This
18 -/// function separates the thinking content from the visible reply so the
19 -/// UI can render them differently (dimmed / collapsed).
9 +/// Split out `<think>…</think>` blocks from a model response.
10 ///
11 +/// Qwen 3 emits reasoning inside `<think>` tags before the actual answer.
12 /// Returns `(thinking_text, visible_reply)`. Either may be empty.
13 pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
14 let mut thinking = String::new();
15 let mut remainder = raw;
16
17 while let Some(start) = remainder.find("<think>") {
27 - // Text before <think> is visible.
18 let before = &remainder[..start];
19 if let Some(end) = remainder[start..].find("</think>") {
20 let block = &remainder[start + 7..start + end];
21 thinking.push_str(block.trim());
22 remainder = &remainder[start + end + 8..];
33 - // Prepend any text before <think> to the leftover.
23 if !before.trim().is_empty() {
35 - // Unusual — text before <think>. Keep it visible.
24 + // rare: text before <think> — keep it visible
25 let mut combined = before.to_string();
26 combined.push_str(remainder);
27 return (thinking, combined.trim().to_string());
28 }
29 } else {
41 - // Unclosed <think> — treat rest as thinking (model ran out of tokens).
30 + // unclosed tag — model probably ran out of tokens
31 thinking.push_str(remainder[start + 7..].trim());
32 remainder = before;
33 break;
@@ -50,8 +39,7 @@ pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
39
40 // ── Unix-only TUI ─────────────────────────────────────────────────────────────
41 //
53 -// Everything below this point is compiled only on Unix (macOS + Linux).
54 -// Windows supports ACP mode only; the interactive TUI is not available there.
42 +// macOS + Linux only. Windows uses ACP mode instead.
43
44 #[cfg(unix)]
45 mod tui {
@@ -82,14 +70,14 @@ mod tui {
70 User,
71 Assistant,
72 System,
85 - /// Banner art — each character gets its own color.
73 + /// rainbow-colored banner art
74 Banner,
75 }
76
77 struct ChatMessage {
78 role: Role,
79 text: String,
92 - /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning).
80 + /// Qwen 3 reasoning extracted from `<think>` tags, if any.
81 think_block: Option<String>,
82 }
83
@@ -131,13 +119,10 @@ mod tui {
119
120 // ── Inference updates from background task ────────────────────────────────
121
134 - /// Messages sent from the spawned inference task back to the event loop.
122 enum InferenceUpdate {
136 - /// The model is calling a tool — show its name in the chat.
123 + /// show tool name in chat while it runs
124 ToolUse(String),
138 - /// The model produced a final text response.
125 Response(String),
140 - /// Something went wrong during inference.
126 Error(String),
127 }
128
@@ -155,39 +140,26 @@ mod tui {
140 scroll_offset: u16,
141 stream_rx: Option<mpsc::Receiver<StreamChunk>>,
142 stream_buf: String,
158 - /// Channel for receiving results from the background inference task.
143 inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
160 - /// Channel for receiving results from a model switch.
144 model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
162 - /// True while waiting for inference to finish.
145 thinking: bool,
164 - /// Counter driving the thinking spinner animation.
146 thinking_tick: u8,
147 quit: bool,
167 - /// Flips every few ticks while streaming to make the cursor blink.
148 + /// toggled periodically so the streaming cursor blinks
149 blink_on: bool,
150 blink_counter: u8,
170 - /// True while a model switch is in progress.
151 switching_model: bool,
172 - /// Tool-calling flag for the model currently being loaded in the background.
173 - /// Applied to `app.tool_calling` when `ModelLoadUpdate::Loaded` arrives.
152 + /// stashed until ModelLoadUpdate::Loaded applies it to `app.tool_calling`
153 pending_tool_calling: Option<bool>,
175 - /// Set to true when the user cancels a model switch with Ctrl+C.
176 - /// Suppresses the "loader task disconnected" error message that would
177 - /// otherwise appear when we drop model_load_rx to abort the switch.
154 + /// suppresses the spurious "disconnected" error when we drop model_load_rx on cancel
155 model_load_cancelled: bool,
156
157 // ── Loading-phase state ───────────────────────────────────────────────
181 - /// True while the model is still loading; switches to false on completion.
158 is_loading: bool,
183 - /// Monotonic counter incremented on every animation tick. Drives the
184 - /// braille spinner shown during loading.
159 load_tick: u32,
186 - /// Set when model loading fails; keeps the loading view up with the error.
160 + /// keeps the loading view visible so the user can read the error
161 load_error: Option<String>,
188 - /// When loading started — drives the elapsed-time counter.
162 load_start: Instant,
190 - /// Display name of the model being loaded (shown in the spinner line).
163 load_model_name: String,
164
165 // ── Model picker state ────────────────────────────────────────────────
@@ -195,16 +167,11 @@ mod tui {
167 model_picker_index: usize,
168 model_picker_items: Vec<ModelPickerItem>,
169 current_model_name: String,
198 - /// Whether the currently loaded model supports tool calling.
170 tool_calling: bool,
171
172 // ── Model-switch download progress ────────────────────────────────────
202 - /// The model_id of the model currently being downloaded/switched to.
203 - /// `None` when no switch is in progress.
173 switching_model_id: Option<String>,
205 - /// Bytes on disk / expected bytes for the in-progress download.
206 - /// Updated every 100 ms tick while `switching_model` is true and the
207 - /// selected model was not yet cached.
174 + /// (downloaded, expected) bytes — polled every tick during a model switch
175 download_progress: Option<(u64, u64)>,
176 }
177
@@ -223,7 +190,6 @@ mod tui {
190 55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
191 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
192
226 - /// Spinner frames for the "thinking" animation.
193 const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
194
195 impl App {
@@ -267,7 +233,6 @@ mod tui {
233 }
234 }
235
270 - /// True when either streaming tokens or waiting for inference.
236 fn is_busy(&self) -> bool {
237 self.is_streaming() || self.thinking || self.switching_model
238 }
@@ -315,13 +280,11 @@ mod tui {
280 THINKING_FRAMES[idx]
281 }
282
318 - /// Advance the spinner tick counter.
283 fn tick(&mut self) {
284 self.load_tick = self.load_tick.wrapping_add(1);
285 }
286
323 - /// Poll the HF cache directory for the model being switched to and update
324 - /// `download_progress`. Called on every 100 ms tick while switching.
287 + /// check how much of the model has landed on disk so far
288 fn poll_download_progress(&mut self) {
289 let Some(ref model_id) = self.switching_model_id else {
290 return;
@@ -340,8 +303,7 @@ mod tui {
303 self.download_progress = Some((downloaded, expected));
304 }
305
343 - /// Transition from loading phase to normal chat.
344 - /// Adds the banner art and welcome messages to the message log.
306 + /// switch to chat phase and show the welcome banner
307 fn finish_loading(&mut self) {
308 self.is_loading = false;
309 for line in BANNER_ART.lines() {
@@ -359,8 +321,7 @@ mod tui {
321 .push(ChatMessage::system("Type /help for commands."));
322 }
323
362 - /// Record a loading error. The loading view stays visible so the user can
363 - /// read the message before pressing Ctrl+C.
324 + /// store the error but stay in loading view so the user can read it
325 fn set_load_error(&mut self, error: String) {
326 self.load_error = Some(error);
327 // is_loading stays true so render_loading() keeps rendering.
@@ -418,21 +379,19 @@ mod tui {
379 self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
380 }
381
421 - /// Total lines the messages area would need (rough estimate for scrolling).
382 + /// rough line count for scroll math
383 fn total_message_lines(&self, width: u16) -> u16 {
384 if width == 0 {
385 return 0;
386 }
426 - let w = width.saturating_sub(2) as usize; // subtract border columns
387 + let w = width.saturating_sub(2) as usize;
388 let mut lines: u16 = 0;
389 for msg in &self.messages {
390 lines += wrapped_line_count(&msg.text, msg.role, w);
391 }
431 - // count any in-progress streaming text too
392 if !self.stream_buf.is_empty() {
393 lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w);
394 }
435 - // thinking / switching indicator
395 if self.thinking || self.switching_model {
396 lines += 1;
397 }
@@ -449,7 +408,6 @@ mod tui {
408 }
409 }
410
452 - /// How many terminal rows a message takes up after line-wrapping.
411 fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
412 let prefix_len = match role {
413 Role::User => 6, // "you > "
@@ -473,16 +431,14 @@ mod tui {
431 count.max(1)
432 }
433
476 - // ── Model table ──────────────────────────────────────────────────────────
434 + // ── Model picker ─────────────────────────────────────────────────────────
435 //
478 - // ModelSource, ModelPickerItem, and build_model_picker_items live in
479 - // crate::models so they are available on all platforms (including Windows),
480 - // not just unix where this chat module is compiled.
436 + // picker data types live in crate::models so Windows (ACP-only) can use them too
437
438 fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
439 let popup = centered_rect(82, 72, area);
440
485 - // Erase whatever is behind the popup so the panel is fully readable.
441 + // clear the background so text doesn't bleed through
442 frame.render_widget(Clear, popup);
443
444 let block = Block::default()
@@ -665,7 +621,7 @@ mod tui {
621 Help,
622 Clear,
623 Status,
668 - /// `/models` opens the model picker. `/models N` still works as a shortcut.
624 + /// picker UI, or jump straight to model N
625 Models(Option<usize>),
626 Exit,
627 Unknown(String),
@@ -695,7 +651,6 @@ mod tui {
651 let area = frame.area();
652
653 if app.is_loading {
698 - // Loading phase: title bar with spinner | loading info | footer hint.
654 let zones = Layout::vertical([
655 Constraint::Length(1),
656 Constraint::Min(1),
@@ -708,7 +663,6 @@ mod tui {
663 return;
664 }
665
711 - // Normal chat phase: title | messages | input | footer.
666 let zones = Layout::vertical([
667 Constraint::Length(1),
668 Constraint::Min(1),
@@ -825,7 +779,6 @@ mod tui {
779 render_chat_message(&mut lines, msg, inner_width as usize);
780 }
781
828 - // In-progress streaming token buffer.
782 if !app.stream_buf.is_empty() {
783 let fake = ChatMessage {
784 role: Role::Assistant,
@@ -833,7 +786,6 @@ mod tui {
786 think_block: None,
787 };
788 render_chat_message(&mut lines, &fake, inner_width as usize);
836 - // blinking cursor at end
789 if app.blink_on
790 && let Some(last) = lines.last_mut()
791 {
@@ -842,7 +794,6 @@ mod tui {
794 }
795 }
796
845 - // Thinking / switching spinner.
797 if app.thinking {
798 lines.push(Line::from(Span::styled(
799 format!(" {} thinking…", app.thinking_frame()),
@@ -888,7 +839,6 @@ mod tui {
839 fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) {
840 match msg.role {
841 Role::Banner => {
891 - // Each character in banner art gets its own rainbow colour.
842 let palette = [
843 Color::Red,
844 Color::Yellow,
@@ -950,7 +900,6 @@ mod tui {
900 }
901 }
902 Role::Assistant => {
953 - // If there is a think block, render it first, dimmed.
903 if let Some(ref think) = msg.think_block {
904 lines.push(Line::from(Span::styled(
905 " ┌ thinking ".to_string(),
@@ -1005,7 +954,6 @@ mod tui {
954 inner,
955 );
956
1008 - // Position the real terminal cursor inside the input box.
957 let col = (app.cursor as u16) % inner.width;
958 let row = (app.cursor as u16) / inner.width;
959 frame.set_cursor_position(Position {
@@ -1213,11 +1161,7 @@ mod tui {
1161 ..SamplingConfig::default()
1162 };
1163
1216 - // Use a dedicated OS thread with its own tokio Runtime
1217 - // so that load_gguf_model's internal block_in_place
1218 - // cannot steal the main runtime's worker threads and
1219 - // freeze the TUI draw loop. This mirrors the pattern
1220 - // used at startup in run_interactive / run_acp_server.
1164 + // own thread + runtime so block_in_place doesn't starve the TUI loop
1165 let system_prompt = crate::system_prompt_for_model(model.tool_calling);
1166 let engine_handle = Arc::clone(&engine);
1167 let tool_calling = model.tool_calling;
@@ -1239,13 +1183,10 @@ mod tui {
1183 Err(err) => ModelLoadUpdate::Error(err.to_string()),
1184 }
1185 });
1242 - // blocking_send is fine here — the channel has
1243 - // capacity 1 and the receiver is always alive while
1244 - // switching_model is true.
1186 + // capacity-1 channel, receiver alive while switching
1187 let _ = tx.blocking_send(update);
1188 });
1247 - // tool_calling is applied when ModelLoadUpdate::Loaded
1248 - // arrives in the event loop (see model_load_rx handler).
1189 + // applied on ModelLoadUpdate::Loaded
1190 app.pending_tool_calling = Some(tool_calling);
1191 }
1192 }
@@ -1263,10 +1204,9 @@ mod tui {
1204
1205 // ── Background inference task ─────────────────────────────────────────────
1206
1266 - /// Maximum number of tool-calling rounds before forcing a text response.
1207 + /// cap tool rounds so a confused model can't loop forever
1208 const MAX_TOOL_ROUNDS: usize = 10;
1209
1269 - /// Build onde `ToolDefinition`s from our agent tools.
1210 fn build_onde_tools() -> Vec<ToolDefinition> {
1211 crate::tools::all_tools()
1212 .into_iter()
@@ -1278,11 +1218,8 @@ mod tui {
1218 .collect()
1219 }
1220
1281 - /// Runs the agentic tool-calling loop on a background task and sends
1282 - /// progress updates back through `tx`.
1283 - ///
1284 - /// The sender is dropped when the task finishes, which the event loop
1285 - /// detects as `None` from `rx.recv()`.
1221 + /// run the tool-calling loop off the main thread, posting updates via `tx`.
1222 + /// dropping `tx` signals completion to the event loop.
1223 async fn run_inference_task(
1224 engine: Arc<ChatEngine>,
1225 text: String,
@@ -1318,12 +1255,10 @@ mod tui {
1255 tc.arguments.chars().take(120).collect::<String>()
1256 );
1257
1321 - // Notify the UI about the tool call.
1258 let _ = tx
1259 .send(InferenceUpdate::ToolUse(tc.function_name.clone()))
1260 .await;
1261
1326 - // Execute the tool.
1262 let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await;
1263 log::info!(" ← {} chars", output.len());
1264
@@ -1333,11 +1268,11 @@ mod tui {
1268 });
1269 }
1270
1336 - // Allow further tool calls unless we've hit the limit.
1271 + // on the last round, pass no tools so the model must produce text
1272 let next_tools = if round < MAX_TOOL_ROUNDS {
1273 Some(onde_tools.as_slice())
1274 } else {
1340 - None // force a text response on the last round
1275 + None
1276 };
1277
1278 match engine.send_tool_results(tool_results, next_tools).await {
@@ -1349,7 +1284,6 @@ mod tui {
1284 }
1285 }
1286
1352 - // Send the final text response, or a fallback if the model returned nothing.
1287 if result.tool_calls.is_empty() {
1288 if result.text.is_empty() {
1289 log::warn!(
@@ -1368,20 +1302,14 @@ mod tui {
1302 }
1303
1304 log::info!("inference complete — {} tool round(s)", round);
1371 - // Sender drops here → event loop sees `None`.
1305 + // tx drops here — event loop gets None from rx.recv()
1306 }
1307
1308 // ── Main loop ─────────────────────────────────────────────────────────────
1309
1376 - /// Run the interactive chat UI. Blocks until the user quits.
1377 - ///
1378 - /// Accepts a terminal that has already been initialised by the caller —
1379 - /// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility.
1380 - ///
1381 - /// `load_rx` is the receiving end of a [`std::sync::mpsc`] channel. A
1382 - /// dedicated OS thread loads the model and sends `Ok(())` or `Err(msg)` when
1383 - /// done. The event loop polls `try_recv()` on every tick — non-blocking,
1384 - /// zero contention with the tokio runtime.
1310 + /// entry point — blocks until the user quits.
1311 + /// caller owns terminal init/restore. `load_rx` delivers the model-load result
1312 + /// from a dedicated OS thread; we poll it non-blocking each tick.
1313 pub async fn run_with<B: ratatui::backend::Backend>(
1314 terminal: &mut ratatui::Terminal<B>,
1315 engine: Arc<ChatEngine>,
@@ -1400,7 +1328,7 @@ mod tui {
1328 let mut app = App::new(load_model_name);
1329 let mut event_stream = EventStream::new();
1330
1403 - // 100 ms per tick ≈ 10 fps — enough for a smooth spinner.
1331 + // 10 fps is plenty for spinners
1332 let mut ticker = interval(Duration::from_millis(100));
1333
1334 loop {
@@ -1521,7 +1449,7 @@ mod tui {
1449 app.finalize_stream();
1450 }
1451 }
1524 - // Sender dropped without sending done=true.
1452 + // sender dropped without done=true
1453 None => {
1454 app.finalize_stream();
1455 }
@@ -1548,8 +1476,7 @@ mod tui {
1476 app.messages.push(ChatMessage::system(format!("error: {msg}")));
1477 }
1478 None => {
1551 - // Sender dropped — task finished (possibly with no
1552 - // text response, e.g. all tool calls with empty final).
1479 + // task finished, possibly with no text to show
1480 app.stop_thinking();
1481 }
1482 }
@@ -1564,8 +1491,7 @@ mod tui {
1491 }
1492 } => {
1493 app.tick_thinking();
1567 - // Refresh download-progress bytes from the HF cache dir so
1568 - // the progress bar in render_messages stays current.
1494 + // keep the progress display fresh
1495 if app.switching_model {
1496 app.poll_download_progress();
1497 }
@@ -1578,7 +1504,7 @@ mod tui {
1504 };
1505
1506 if let Event::Key(key) = event {
1581 - // During loading, only Ctrl+C / Ctrl+D are accepted.
1507 + // loading phase — only quit keys work
1508 if app.is_loading {
1509 if key.kind == KeyEventKind::Press {
1510 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -1592,7 +1518,7 @@ mod tui {
1518 continue;
1519 }
1520
1595 - // While busy (streaming or thinking), only Ctrl+C/D work.
1521 + // busy — only cancel keys work
1522 if app.is_busy() {
1523 if key.kind == KeyEventKind::Press {
1524 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -1602,15 +1528,12 @@ mod tui {
1528 app.messages.push(ChatMessage::system("(cancelled)"));
1529 }
1530 if app.thinking {
1605 - // Drop the receiver — the background task
1606 - // will see a closed channel and stop.
1531 + // dropping rx kills the background task
1532 app.stop_thinking();
1533 app.messages.push(ChatMessage::system("(cancelled)"));
1534 }
1535 if app.switching_model {
1611 - // Mark as cancelled before dropping the
1612 - // receiver so the Disconnected arm in the
1613 - // model_load_rx handler stays silent.
1536 + // flag before drop so Disconnected handler stays quiet
1537 app.model_load_cancelled = true;
1538 app.switching_model = false;
1539 app.switching_model_id = None;
@@ -1630,7 +1553,7 @@ mod tui {
1553 continue;
1554 }
1555
1633 - // ── Spawn inference on a background task ─────────
1556 + // ── spawn inference ──────────────────────────────
1557 app.messages.push(ChatMessage::user(&text));
1558 app.start_thinking();
1559
@@ -1654,8 +1577,7 @@ mod tui {
1577
1578 // ── Download progress helpers (TUI) ──────────────────────────────────────
1579
1657 - /// Recursively sum the on-disk size of all files under `path`, following
1658 - /// symlinks so hf-hub's blob layout is counted correctly.
1580 + /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
1581 fn dir_size_recursive(path: &std::path::Path) -> u64 {
1582 let mut total: u64 = 0;
1583 let Ok(entries) = std::fs::read_dir(path) else {
@@ -1672,7 +1594,6 @@ mod tui {
1594 total
1595 }
1596
1675 - /// Format a byte count as a terse human-readable string.
1597 fn format_size_human(bytes: u64) -> String {
1598 const GB: u64 = 1_073_741_824;
1599 const MB: u64 = 1_048_576;
@@ -1689,9 +1610,7 @@ mod tui {
1610 }
1611 } // end #[cfg(unix)] mod tui
1612
1692 -// Re-export the Unix-only public entry point so callers can write
1693 -// `chat::run_with(...)` on all platforms and get a clean "not available"
1694 -// compile error on Windows rather than a missing-item error.
1613 +// re-export so callers write `chat::run_with(...)` on all platforms
1614 #[cfg(unix)]
1615 pub use tui::run_with;
1616
src/main.rs
+81 -161
@@ -1,29 +1,21 @@
1 -//! siGit Code is a local coding agent built on Onde Inference.
1 +//! siGit Code — local coding agent on Onde Inference.
2 //!
3 -//! When you run it in an interactive terminal, all process output goes to
4 -//! `$TMPDIR/sigit.log` first. That includes `log::` events, `tracing` output
5 -//! from mistralrs_core, and even stray `println!` calls from dependencies.
6 -//! Ratatui gets its own copy of the real terminal handle, so the UI can keep
7 -//! drawing normally while the noisy stuff goes to the log file.
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 //!
9 -//! siGit has two modes:
10 -//! - ACP mode over stdio for editors like Zed
11 -//! - interactive terminal mode when you run it directly in a TTY
7 +//! Two modes:
8 +//! - ACP over stdio (editor integration, e.g. Zed)
9 +//! - interactive terminal (direct TTY)
10 //!
13 -//! Current platform support:
14 -//! - macOS: ACP mode and interactive terminal mode
15 -//! - Linux: ACP mode and interactive terminal mode
16 -//! - Windows: ACP mode only for now
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 //!
18 -//! The interactive terminal path is still Unix-only because it relies on
19 -//! Unix file-descriptor redirection to keep logs away from the TUI.
14 +//! The model loads before the ACP `LocalSet` starts because `mistralrs` calls
15 +//! `block_in_place`, which panics inside `spawn_local`. Loading on a regular
16 +//! multi-thread worker sidesteps that.
17 //!
21 -//! The model loads before the ACP `LocalSet` starts. That is important because
22 -//! `mistralrs` calls `block_in_place` internally, and that blows up inside
23 -//! `spawn_local` tasks. Loading it on a normal multi-thread worker avoids the
24 -//! problem.
25 -//!
26 -//! On macOS, the HF cache points at the App Group container shared with the
18 +//! On macOS the HF cache lives in the App Group container shared with the
19 //! siGit desktop app. See [`setup`].
20 //!
21 //! # Zed setup
@@ -194,11 +186,8 @@ specific and practical.
186 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
187 root cause, not the symptom. Correct beats clever.";
188
197 -/// Slim system prompt for models that do not support tool calling.
198 -///
199 -/// These models (e.g. DeepSeek Coder v1) cannot use the agent tools, so
200 -/// the long tool-oriented instructions in [`SYSTEM_PROMPT`] would waste
201 -/// context and confuse the model. Keep this short and code-focused.
189 +/// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1).
190 +/// the full [`SYSTEM_PROMPT`] wastes context and confuses them.
191 const SIMPLE_SYSTEM_PROMPT: &str = "\
192 Your name is siGit — a coding assistant. \
193 You are helpful, concise, and write clean, idiomatic code. \
@@ -206,7 +195,6 @@ Answer any question the user asks — programming, general knowledge, or casual
195 When debugging, address the root cause, not the symptom. \
196 Be direct and brief.";
197
209 -/// Pick the right system prompt based on whether the model supports tool calling.
198 pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
199 if tool_calling {
200 SYSTEM_PROMPT
@@ -215,10 +203,9 @@ pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
203 }
204 }
205
218 -/// Maximum number of tool-calling rounds before forcing a text response.
206 +/// cap tool-call loops so a confused model can't spin forever
207 const MAX_TOOL_ROUNDS: usize = 10;
208
221 -/// Convert the agent tool definitions into onde's `ToolDefinition` type.
209 fn agent_tools_as_onde() -> Vec<ToolDefinition> {
210 tools::all_tools()
211 .into_iter()
@@ -278,16 +265,11 @@ fn initialize_meta() -> Meta {
265 meta
266 }
267
281 -// Agent
282 -
268 struct SiGitAgent {
269 engine: Arc<ChatEngine>,
270 notification_tx: mpsc::Sender<SessionNotification>,
286 - /// The project working directory provided by the editor via ACP session
287 - /// creation. Tool calls use this as `cwd` so file operations target the
288 - /// correct project, not wherever the agent process was spawned.
271 + /// cwd from the editor — tool calls run here, not where the process started
272 session_cwd: std::sync::Mutex<Option<PathBuf>>,
290 - /// The currently loaded model config, used for config_options reporting.
273 current_model: std::sync::Mutex<GgufModelConfig>,
274 }
275
@@ -344,12 +326,8 @@ impl SiGitAgent {
326 ..SamplingConfig::default()
327 };
328
347 - // load_gguf_model calls block_in_place internally. Calling it from
348 - // inside the ACP LocalSet (spawn_local) panics with "can call blocking
349 - // only when running on the multi-threaded runtime". Fix: run the
350 - // unload + load on a dedicated OS thread with its own runtime, then
351 - // await the result over a oneshot channel — same pattern used at
352 - // startup in run_acp_server.
329 + // block_in_place inside spawn_local panics, so run the load on a
330 + // dedicated thread with its own runtime (same trick as startup)
331 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
332 let loader_engine = Arc::clone(&self.engine);
333 let loader_config = new_config.clone();
@@ -359,10 +337,8 @@ impl SiGitAgent {
337 std::thread::spawn(move || {
338 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
339 let result = rt.block_on(async move {
362 - // load_gguf_model unloads any existing model internally before
363 - // loading the new one. Calling unload_model() explicitly first
364 - // would create a window where no model is loaded — if a prompt
365 - // arrived in that gap it would fail with NoModelLoaded.
340 + // load_gguf_model already unloads the old model internally;
341 + // calling unload first would leave a gap where prompts fail
342 loader_engine
343 .load_gguf_model(
344 loader_config,
@@ -414,10 +390,9 @@ impl SiGitAgent {
390 }
391 }
392
417 -/// The config option ID used for the model selector in the Zed agent panel.
393 +/// config option ID for the model picker in Zed's agent panel
394 const MODEL_CONFIG_ID: &str = "sigit-model";
395
420 -/// Build the `SessionConfigOption` list for model selection.
396 fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> {
397 let items = models::build_model_picker_items();
398
@@ -465,9 +440,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
440 ]
441 }
442
468 -/// Look up the GgufModelConfig for a given model_id value from the picker items.
469 -///
470 -/// Returns `(config, max_tokens, tool_calling)`.
443 +/// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None
444 fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
445 let items = models::build_model_picker_items();
446 items
@@ -760,24 +733,20 @@ impl Agent for SiGitAgent {
733 .collect::<Vec<_>>()
734 );
735
763 - // Capture the project working directory from the editor.
736 if let Ok(mut guard) = self.session_cwd.lock() {
737 *guard = Some(args.cwd.clone());
738 }
739
768 - // Set the process cwd so tool calls using relative paths land in the
769 - // correct project directory.
740 + // tool calls use relative paths, so we need to match the editor's cwd
741 if args.cwd.is_dir()
742 && let Err(err) = std::env::set_current_dir(&args.cwd)
743 {
744 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
745 }
746
776 - // Clear conversation history — siGit doesn't persist sessions, so a
777 - // "load" is effectively a fresh start with the same session ID.
747 + // no session persistence, so "load" just resets
748 self.engine.clear_history().await;
749
780 - // Tell the model which project directory it's working in.
750 self.engine
751 .push_history(onde::inference::ChatMessage::system(format!(
752 "The user's project working directory is {}. \
@@ -811,7 +780,6 @@ impl Agent for SiGitAgent {
780 .collect::<Vec<_>>()
781 );
782
814 - // Update cwd if the fork provides a different one.
783 if let Ok(mut guard) = self.session_cwd.lock() {
784 *guard = Some(args.cwd.clone());
785 }
@@ -821,9 +789,7 @@ impl Agent for SiGitAgent {
789 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
790 }
791
824 - // siGit doesn't persist history, so a fork is effectively a fresh
825 - // session — clear the conversation and let the user start over from
826 - // their edited message.
792 + // no persistence, so fork == fresh session
793 self.engine.clear_history().await;
794
795 self.engine
@@ -858,7 +824,6 @@ impl Agent for SiGitAgent {
824 .collect::<Vec<_>>()
825 );
826
861 - // Capture the project working directory from the editor.
827 if let Ok(mut guard) = self.session_cwd.lock() {
828 *guard = Some(args.cwd.clone());
829 }
@@ -868,7 +833,6 @@ impl Agent for SiGitAgent {
833 log::warn!("could not set cwd to {}: {err}", args.cwd.display());
834 }
835
871 - // Clear history — the model is already loaded.
836 self.engine.clear_history().await;
837
838 self.engine
@@ -892,8 +856,7 @@ impl Agent for SiGitAgent {
856 async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> {
857 let session_id = args.session_id.clone();
858
895 - // Debug: log every content block the editor sends so we can see
896 - // exactly what arrives for @ references, file context, etc.
859 + // log every block so we can debug @ references and file context
860 for (i, block) in args.prompt.iter().enumerate() {
861 match block {
862 ContentBlock::Text(t) => {
@@ -949,7 +912,7 @@ impl Agent for SiGitAgent {
912 parts.push(t.text.clone());
913 }
914 ContentBlock::Resource(embedded) => {
952 - // Embedded file content sent by the editor (preferred over ResourceLink).
915 + // editor inlined the file content already
916 match &embedded.resource {
917 agent_client_protocol::EmbeddedResourceResource::TextResourceContents(
918 text_resource,
@@ -970,11 +933,10 @@ impl Agent for SiGitAgent {
933 }
934 }
935 ContentBlock::ResourceLink(link) => {
973 - // The editor sent a reference but not the content — read it if it's a file.
936 + // reference without content; read the file ourselves
937 let label = link.name.clone();
938
939 if let Some(raw_path) = link.uri.strip_prefix("file://") {
977 - // Split off the #L<start>:<end> fragment if present.
940 let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') {
941 let fragment = &raw_path[hash_pos + 1..];
942 let path = &raw_path[..hash_pos];
@@ -994,7 +956,6 @@ impl Agent for SiGitAgent {
956 match std::fs::read_to_string(file_path) {
957 Ok(contents) => {
958 let extracted = if let Some((start, end)) = line_range {
997 - // Extract only the requested line range (1-based, inclusive).
959 let selected: Vec<&str> = contents
960 .lines()
961 .enumerate()
@@ -1046,13 +1007,9 @@ impl Agent for SiGitAgent {
1007 user_text.chars().take(80).collect::<String>()
1008 );
1009
1049 - // ── Agentic tool-calling loop ────────────────────────────────────
1050 - //
1051 - // 1. Send the user message with tool definitions (non-streaming).
1052 - // 2. If the model responds with tool calls, execute them, feed
1053 - // results back, and repeat (up to MAX_TOOL_ROUNDS).
1054 - // 3. Once the model produces a text response (no tool calls),
1055 - // stream it to the editor.
1010 + // ── tool-calling loop ────────────────────────────────────────────
1011 + // send message → execute any tool calls → feed results back
1012 + // repeat up to MAX_TOOL_ROUNDS, then force a text reply
1013
1014 let onde_tools = agent_tools_as_onde();
1015
@@ -1085,7 +1042,6 @@ impl Agent for SiGitAgent {
1042 tc.arguments.chars().take(120).collect::<String>()
1043 );
1044
1088 - // Execute the tool (async — read_website uses spawn_blocking internally).
1045 let output = tools::execute_tool(&tc.function_name, &tc.arguments).await;
1046
1047 log::info!(" ← {} chars", output.len());
@@ -1096,11 +1052,10 @@ impl Agent for SiGitAgent {
1052 });
1053 }
1054
1099 - // Decide whether to allow further tool calls.
1055 let next_tools = if round < MAX_TOOL_ROUNDS {
1056 Some(onde_tools.as_slice())
1057 } else {
1103 - None // force a text response on the last round
1058 + None // last round: force text
1059 };
1060
1061 result = self
@@ -1129,8 +1084,7 @@ impl Agent for SiGitAgent {
1084 String::new()
1085 }
1086 } else {
1132 - // Strip Qwen 3 `<think>…</think>` blocks — the editor doesn't
1133 - // need to see internal reasoning tokens.
1087 + // strip <think> blocks so reasoning tokens stay hidden
1088 let (_think, visible) = chat::strip_think_blocks(&reply_text);
1089 visible
1090 };
@@ -1173,21 +1127,18 @@ impl Agent for SiGitAgent {
1127
1128 let model_id = args.value.0.as_ref();
1129
1176 - // Check if this model needs to be downloaded first so we can show
1177 - // a progress indicator in Zed while the download + load is happening.
1130 let needs_download = models::build_model_picker_items()
1131 .into_iter()
1132 .find(|item| item.config.model_id == model_id)
1133 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
1134 .unwrap_or(false);
1135
1184 - // Spawn a progress-poller task that sends periodic download status
1185 - // messages to Zed via the notification channel. A shared flag lets
1186 - // us stop the poller once the load finishes.
1136 + // shared flag to kill the progress poller when the load finishes
1137 let stop_flag = Arc::new(AtomicBool::new(false));
1138
1139 + let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
1140 +
1141 if needs_download {
1190 - // Send the initial "downloading" banner immediately.
1142 let model_id_owned = model_id.to_string();
1143 let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
1144 .iter()
@@ -1207,14 +1158,12 @@ impl Agent for SiGitAgent {
1158 String::new()
1159 };
1160
1210 - let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
1211 -
1161 self.send_tool_call_update(
1162 args.session_id.clone(),
1163 SessionUpdate::ToolCall(
1164 ToolCall::new(
1165 tool_call_id.clone(),
1217 - format!("Downloading {display_name}{size_hint}"),
1166 + format!("⏬ Downloading {display_name}{size_hint}"),
1167 )
1168 .kind(ToolKind::Execute)
1169 .status(ToolCallStatus::InProgress)
@@ -1228,7 +1177,7 @@ impl Agent for SiGitAgent {
1177 )
1178 .await;
1179
1231 - // Poller: every 4 seconds report bytes-on-disk / expected.
1180 + // poll download progress and update the spinner in Zed
1181 let poller_tx = self.notification_tx.clone();
1182 let poller_session = args.session_id.clone();
1183 let poller_model_id = model_id_owned.clone();
@@ -1236,8 +1185,10 @@ impl Agent for SiGitAgent {
1185 let poller_tool_call_id = tool_call_id.clone();
1186
1187 tokio::task::spawn_local(async move {
1188 + const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1189 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
1240 - let mut interval = tokio::time::interval(std::time::Duration::from_secs(4));
1190 + let mut tick: usize = 0;
1191 + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
1192 interval.tick().await; // consume the immediate first tick
1193
1194 while !poller_stop.load(Ordering::Relaxed) {
@@ -1253,6 +1204,17 @@ impl Agent for SiGitAgent {
1204 .map(|p| dir_size_recursive(p))
1205 .unwrap_or(0);
1206
1207 + let frame = SPINNER[tick % SPINNER.len()];
1208 + tick += 1;
1209 +
1210 + let title = if expected_bytes > 0 {
1211 + let pct =
1212 + ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1213 + format!("{frame} Downloading {display_name}{size_hint} ({pct}%)")
1214 + } else {
1215 + format!("{frame} Downloading {display_name}{size_hint}")
1216 + };
1217 +
1218 let msg = if expected_bytes > 0 {
1219 let pct =
1220 ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
@@ -1274,6 +1236,7 @@ impl Agent for SiGitAgent {
1236 SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1237 poller_tool_call_id.clone(),
1238 ToolCallUpdateFields::new()
1239 + .title(title)
1240 .status(ToolCallStatus::InProgress)
1241 .content(vec![msg.into()]),
1242 )),
@@ -1285,10 +1248,7 @@ impl Agent for SiGitAgent {
1248 });
1249 }
1250
1288 - // For already-cached models, send a "loading" message and a spinner
1289 - // so the user sees activity while mistralrs loads the weights (~10-30 s).
1290 - let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
1291 -
1251 + // cached models still take 10-30s to load weights; show a spinner
1252 if !needs_download {
1253 let cached_display_name = models::build_model_picker_items()
1254 .into_iter()
@@ -1310,8 +1270,7 @@ impl Agent for SiGitAgent {
1270 )
1271 .await;
1272
1313 - // Spinner poller: send an elapsed-time update every 5 seconds so
1314 - // the user can tell siGit is still working.
1273 + // tick every 5s so the user knows we haven't frozen
1274 let spinner_tx = self.notification_tx.clone();
1275 let spinner_session = args.session_id.clone();
1276 let spinner_name = cached_display_name.clone();
@@ -1360,15 +1319,14 @@ impl Agent for SiGitAgent {
1319
1320 let switch_result = self.switch_model_by_id(model_id).await;
1321
1363 - // Stop the progress / spinner poller regardless of success/failure.
1322 stop_flag.store(true, Ordering::Relaxed);
1323
1324 match switch_result {
1325 Ok(new_config) => {
1326 let completion_title = if needs_download {
1369 - format!("{} downloaded and loaded", new_config.display_name)
1327 + format!("✓ {} downloaded and loaded", new_config.display_name)
1328 } else {
1371 - format!("Switched to {}", new_config.display_name)
1329 + format!("✓ Switched to {}", new_config.display_name)
1330 };
1331 let completion_body = if needs_download {
1332 format!("✓ {} downloaded and loaded.", new_config.display_name)
@@ -1417,9 +1375,8 @@ impl Agent for SiGitAgent {
1375
1376 // ── Download progress helpers ─────────────────────────────────────────────────
1377
1420 -/// Recursively sum the sizes of all files under `path`, following symlinks.
1421 -/// Used by the ACP download-progress poller to report bytes-on-disk before
1422 -/// hf-hub renames the staging files to their final blob names.
1378 +/// total bytes on disk under `path`. needed because hf-hub uses staging
1379 +/// names during download, so we can't just stat the final blobs.
1380 fn dir_size_recursive(path: &std::path::Path) -> u64 {
1381 let mut total: u64 = 0;
1382 let Ok(entries) = std::fs::read_dir(path) else {
@@ -1436,7 +1393,6 @@ fn dir_size_recursive(path: &std::path::Path) -> u64 {
1393 total
1394 }
1395
1439 -/// Format a byte count as a human-readable string (B / KB / MB / GB).
1396 fn format_size_human(bytes: u64) -> String {
1397 const GB: u64 = 1_073_741_824;
1398 const MB: u64 = 1_048_576;
@@ -1452,8 +1408,6 @@ fn format_size_human(bytes: u64) -> String {
1408 }
1409 }
1410
1455 -/// Build a simple ASCII progress bar string of the given width.
1456 -/// e.g. `[████████░░░░░░░░░░░░]` at 40 %
1411 fn progress_bar(pct: u8, width: usize) -> String {
1412 let filled = ((pct as usize) * width) / 100;
1413 let empty = width.saturating_sub(filled);
@@ -1462,28 +1416,17 @@ fn progress_bar(pct: u8, width: usize) -> String {
1416
1417 // ── Output capture ────────────────────────────────────────────────────────────
1418
1465 -/// Redirect **both** stdout and stderr to `$TMPDIR/sigit.log` at the
1466 -/// file-descriptor level and return a [`std::fs::File`] handle to the *real*
1467 -/// terminal (the original stdout) so ratatui can still render to it.
1468 -///
1469 -/// This is the nuclear option — it catches absolutely everything that any
1470 -/// library writes to stdout (`println!` in mistralrs `print_metadata`) or
1471 -/// stderr (`tracing::info!`, `log::info!`, raw `eprintln!`).
1472 -///
1473 -/// Returns **two** `File` handles to the real terminal (both created via
1474 -/// `dup(STDOUT)` *before* the redirect):
1475 -///
1476 -/// 1. **`tui`** — given to ratatui's `CrosstermBackend` for rendering.
1477 -/// 2. **`cleanup`** — kept by the caller for writing `LeaveAlternateScreen`
1478 -/// and restoring stdout/stderr after the TUI exits (since ratatui 0.29
1479 -/// does not expose `writer_mut()` on the backend).
1419 +/// redirect stdout+stderr to `$TMPDIR/sigit.log` at the fd level so
1420 +/// mistralrs/tracing noise never hits the terminal. returns two dup'd
1421 +/// fds to the real tty: one for ratatui, one for cleanup (ratatui 0.29
1422 +/// doesn't expose `writer_mut()`).
1423 #[cfg(unix)]
1424 fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
1425 let log_path = std::env::temp_dir().join("sigit.log");
1426 let log_file = std::fs::File::create(&log_path)?;
1427 let log_fd = log_file.as_raw_fd();
1428
1486 - // Save TWO copies of the real terminal fd before we clobber stdout.
1429 + // two copies: ratatui needs one, cleanup needs another
1430 let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) };
1431 anyhow::ensure!(
1432 saved_tui >= 0,
@@ -1497,14 +1440,12 @@ fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
1440 std::io::Error::last_os_error()
1441 );
1442
1500 - // Point stdout and stderr at the log file.
1443 unsafe {
1444 libc::dup2(log_fd, libc::STDOUT_FILENO);
1445 libc::dup2(log_fd, libc::STDERR_FILENO);
1446 }
1447
1506 - // `log_file` can drop — dup2 created independent references to the
1507 - // underlying file description, so stdout/stderr keep it alive.
1448 + // safe to drop log_file; dup2 keeps the fd alive via stdout/stderr
1449
1450 Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe {
1451 std::fs::File::from_raw_fd(saved_cleanup)
@@ -1513,11 +1454,8 @@ fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> {
1454
1455 // ── Logging ───────────────────────────────────────────────────────────────────
1456
1516 -/// Initialise `tracing-subscriber` as the single logging backend.
1517 -///
1518 -/// In TUI mode stdout/stderr have already been redirected to the log file by
1519 -/// [`redirect_output_to_log`], so the subscriber simply writes to stderr
1520 -/// (which *is* the log file). In ACP mode stderr is the real stderr.
1457 +/// in TUI mode stderr is the log file (redirected earlier);
1458 +/// in ACP mode it's real stderr. either way, write there.
1459 fn init_logging(is_tty: bool) {
1460 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
1461 let _ = tracing_fmt::Subscriber::builder()
@@ -1529,19 +1467,9 @@ fn init_logging(is_tty: bool) {
1467
1468 // ── Interactive TUI mode ──────────────────────────────────────────────────────
1469
1532 -/// Start the TUI immediately, load the model concurrently, signal completion
1533 -/// via a oneshot channel so the TUI can animate the banner while waiting.
1534 -///
1535 -/// The terminal is set up *manually* against the saved real-terminal `File`
1536 -/// returned by [`redirect_output_to_log`]. Because stdout/stderr have
1537 -/// already been redirected to the log file at that point, any `println!`,
1538 -/// `eprintln!`, `log::info!`, or `tracing::info!` emitted by mistralrs or
1539 -/// onde goes straight to `$TMPDIR/sigit.log` and never touches the screen.
1540 -///
1541 -/// `tty` is given to ratatui; `cleanup_tty` is a second fd to the same
1542 -/// terminal, used for `LeaveAlternateScreen` and restoring stdout/stderr
1543 -/// (we cannot access the backend's writer because `writer_mut()` is private
1544 -/// in ratatui 0.29).
1470 +/// boot the TUI and load the model on a background thread.
1471 +/// `tty` goes to ratatui; `cleanup_tty` is a separate fd for
1472 +/// LeaveAlternateScreen (ratatui 0.29 hides `writer_mut()`).
1473 #[cfg(unix)]
1474 async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
1475 let engine = Arc::new(ChatEngine::new());
@@ -1550,7 +1478,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1478 let startup_model_name = startup_selection
1479 .as_ref()
1480 .map(|selection| selection.display_name.clone())
1553 - .unwrap_or_else(|| GgufModelConfig::platform_default().display_name);
1481 + .unwrap_or_else(|| GgufModelConfig::qwen3_4b().display_name);
1482
1483 let config = startup_selection
1484 .as_ref()
@@ -1573,14 +1501,13 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1501 })
1502 .map(|item| item.config)
1503 })
1576 - .unwrap_or_else(GgufModelConfig::platform_default);
1504 + .unwrap_or_else(GgufModelConfig::qwen3_4b);
1505 let sampling = SamplingConfig {
1506 max_tokens: Some(8192),
1507 ..SamplingConfig::default()
1508 };
1509
1582 - // std::sync::mpsc — the loader runs on a dedicated OS thread, completely
1583 - // decoupled from the tokio runtime so it can't starve the TUI draw loop.
1510 + // std::sync::mpsc on a real thread so model loading can't starve the TUI
1511 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
1512
1513 let loader_engine = Arc::clone(&engine);
@@ -1597,24 +1524,21 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1524 let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
1525 });
1526
1600 - // Set up the terminal manually on the real tty fd.
1527 crossterm::terminal::enable_raw_mode()?;
1528 let mut tty = BufWriter::new(tty);
1529 crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
1530 let backend = ratatui::backend::CrosstermBackend::new(tty);
1531 let mut terminal = ratatui::Terminal::new(backend)?;
1532
1607 - // The TUI runs here on the main tokio runtime. It polls load_rx via
1608 - // try_recv() on every tick — non-blocking, zero contention.
1533 + // polls load_rx with try_recv() each tick, no blocking
1534 let chat_result = chat::run_with(&mut terminal, engine, load_rx, startup_model_name).await;
1535
1611 - // Restore the terminal before exiting.
1612 - // Use the separate cleanup fd — the backend's writer is private.
1536 + // cleanup fd because backend's writer is private
1537 crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
1538 cleanup_tty.flush()?;
1539 crossterm::terminal::disable_raw_mode()?;
1540
1617 - // Restore stdout/stderr so any post-TUI error messages are visible.
1541 + // restore real stdout/stderr for post-TUI error output
1542 #[cfg(unix)]
1543 {
1544 let cleanup_fd = cleanup_tty.as_raw_fd();
@@ -1632,8 +1556,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1556 async fn run_acp_server() -> anyhow::Result<()> {
1557 log::info!("ACP mode — starting agent server");
1558
1635 - // Load before the LocalSet. block_in_place panics inside spawn_local,
1636 - // so the model must load on a regular worker thread.
1559 + // must load before LocalSet: block_in_place panics inside spawn_local
1560 log::info!("loading model (this may take a minute on first run)...");
1561
1562 let engine = Arc::new(ChatEngine::new());
@@ -1690,11 +1613,11 @@ async fn run_acp_server() -> anyhow::Result<()> {
1613 let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
1614 let agent = SiGitAgent::new(engine, notification_tx, startup_config);
1615
1693 - // AgentSideConnection wants futures-io, not tokio-io.
1616 + // AgentSideConnection needs futures-io
1617 let stdin = tokio::io::stdin().compat();
1618 let stdout = tokio::io::stdout().compat_write();
1619
1697 - // ACP futures are !Send — needs a LocalSet.
1620 + // ACP futures are !Send
1621 let local = tokio::task::LocalSet::new();
1622
1623 local
@@ -1708,7 +1631,6 @@ async fn run_acp_server() -> anyhow::Result<()> {
1631 },
1632 );
1633
1711 - // Forward streamed chunks to the editor.
1634 tokio::task::spawn_local(async move {
1635 while let Some(notification) = notification_rx.recv().await {
1636 if let Err(err) = conn.session_notification(notification).await {
@@ -1717,7 +1639,6 @@ async fn run_acp_server() -> anyhow::Result<()> {
1639 }
1640 });
1641
1720 - // Runs until the editor disconnects.
1642 if let Err(err) = io_task.await {
1643 log::error!("ACP IO error: {err}");
1644 }
@@ -1735,8 +1656,7 @@ async fn main() -> anyhow::Result<()> {
1656 let is_tty = std::io::stdin().is_terminal();
1657
1658 if is_tty {
1738 - // Redirect stdout/stderr to $TMPDIR/sigit.log *first* — before any
1739 - // library code can println!/eprintln!/log to the real terminal.
1659 + // must redirect before any library code touches stdout
1660 #[cfg(unix)]
1661 {
1662 let (tty, cleanup_tty) = redirect_output_to_log()?;
src/models.rs
+7 -35
@@ -1,8 +1,5 @@
1 -//! Platform-independent model picker types and item construction.
2 -//!
3 -//! This module is available on all target platforms (Windows, macOS, Linux).
4 -//! The TUI rendering code in `chat.rs` (unix-only) re-uses these types
5 -//! rather than defining them inline.
1 +//! Model picker types and item construction, shared across platforms.
2 +//! The unix-only TUI in `chat.rs` pulls from here.
3
4 use onde::inference::GgufModelConfig;
5
@@ -16,8 +13,7 @@ pub(crate) use crate::setup::ModelCacheHealth;
13 pub(crate) enum ModelSource {
14 Onde,
15 HuggingFace,
19 - /// Supported model that is not yet downloaded locally. When selected it
20 - /// will be downloaded into the Onde app-group cache automatically.
16 + /// not downloaded yet — selecting it triggers a download into the app-group cache.
17 Available,
18 Fallback,
19 }
@@ -37,9 +33,7 @@ pub(crate) struct ModelPickerItem {
33
34 // ── Model ID → GgufModelConfig mapping ────────────────────────────────────────
35
40 -/// Map a HuggingFace model ID to the corresponding [`GgufModelConfig`]
41 -/// constructor. Returns `None` for model IDs that siGit does not know how
42 -/// to load.
36 +/// map a HF model ID to its config constructor, or `None` if we don't support it.
37 pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
38 Some(match model_id {
39 "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
@@ -56,7 +50,6 @@ pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
50 })
51 }
52
59 -/// Whether a model ID supports tool calling (Qwen 3 family).
53 fn is_tool_calling(model_id: &str) -> bool {
54 matches!(
55 model_id,
@@ -68,29 +61,15 @@ fn is_tool_calling(model_id: &str) -> bool {
61 )
62 }
63
71 -/// Max tokens for a given model (tool-calling models need higher budgets
72 -/// because the `<think>…</think>` block consumes tokens before the real
73 -/// response).
64 +/// tool-calling models get more tokens because `<think>` blocks eat into the budget.
65 fn max_tokens_for(model_id: &str) -> u64 {
66 if is_tool_calling(model_id) { 4096 } else { 512 }
67 }
68
69 // ── Builder ───────────────────────────────────────────────────────────────────
70
80 -/// Build the full list of model picker items.
81 -///
82 -/// Items are sourced from:
83 -/// 1. **Locally cached** models in the Onde app-group and HuggingFace caches.
84 -/// 2. **All supported models** from [`onde::inference::models::SUPPORTED_MODEL_INFO`]
85 -/// that are not yet downloaded locally — shown as `Available` so the user
86 -/// can select them to trigger a download into the app-group cache.
87 -///
88 -/// If no models are discovered *and* no supported models are known, a single
89 -/// fallback entry for the platform-default model is returned so the picker
90 -/// is never empty.
91 -///
92 -/// Items are sorted: Onde first, then HuggingFace, then Available (not
93 -/// downloaded), then Fallback, and alphabetically within each group.
71 +/// collect every model the picker should show: local cache, remote available, fallback.
72 +/// sorted by source (Onde > HF > Available > Fallback), then alphabetically.
73 pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
74 let mut items = Vec::new();
75
@@ -102,13 +81,6 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
81 }
82
83 // ── 2. Supported models not yet downloaded ───────────────────────────
105 - //
106 - // Walk SUPPORTED_MODEL_INFO and add an entry for every model ID that
107 - // does not already appear in the local items list (by model_id).
108 - // These entries have `cache_health: NotDownloaded` and
109 - // `source: Available`. When the user selects one, `load_gguf_model`
110 - // will download the GGUF file from HuggingFace into the app-group
111 - // cache automatically.
84 for info in onde::inference::models::SUPPORTED_MODEL_INFO {
85 let already_present = items.iter().any(|item| item.config.model_id == info.id);
86 if already_present {
src/setup.rs
+32 -67
@@ -1,39 +1,25 @@
1 -//! Shared model cache setup, local model discovery, and lightweight local
2 -//! preferences.
1 +//! Model cache setup, local model discovery, and selected-model persistence.
2 //!
4 -//! On macOS, siGit desktop and other Onde apps keep their HuggingFace models
5 -//! in a shared App Group container at:
3 +//! On macOS the CLI shares a HuggingFace cache with Onde desktop apps via an
4 +//! App Group container (`~/Library/Group Containers/group.com.ondeinference.apps/models/`).
5 +//! On other platforms it falls back to `~/.cache/huggingface/`.
6 //!
7 -//! `~/Library/Group Containers/group.com.ondeinference.apps/models/`
8 -//!
9 -//! This module points `HF_HOME` / `HF_HUB_CACHE` there so the CLI reuses
10 -//! whatever the desktop app already downloaded (and vice versa). On Linux
11 -//! and Windows the default `~/.cache/huggingface/` path is used.
12 -//!
13 -//! It also exposes helpers for finding locally available models. Discovery
14 -//! checks the Onde app group first on macOS, then falls back to the normal
15 -//! Hugging Face cache layout.
16 -//!
17 -//! The selected model name is persisted in a small local preferences file so
18 -//! the interactive UI can restore the last choice on the next launch.
19 -//!
20 -//! Call this before anything touches `ChatEngine` or `hf-hub` — they read
21 -//! the env vars once at init and never check again.
7 +//! Must run before anything touches `ChatEngine` or `hf-hub` because they
8 +//! read the env vars once at init.
9
10 use std::path::{Path, PathBuf};
11
25 -/// App Group ID shared across all Onde apps (siGit, Rumi, GT8, …).
12 +/// shared across siGit, Rumi, GT8, etc.
13 #[cfg(target_os = "macos")]
14 const APP_GROUP_IDENTIFIER: &str = "group.com.ondeinference.apps";
15
29 -/// Find the shared container and set `HF_HOME` / `HF_HUB_CACHE` to point
30 -/// there. Skips any var the user already set.
16 +/// point `HF_HOME` / `HF_HUB_CACHE` at the shared container. no-ops if
17 +/// the user already set them.
18 pub fn setup_shared_model_cache() {
19 if let Some(shared_dir) = resolve_shared_container() {
20 let models_home = shared_dir.join("models");
21 let model_hub = models_home.join("hub");
22
36 - // Make sure the dirs exist.
23 if let Err(error) = std::fs::create_dir_all(&model_hub) {
24 log::warn!(
25 "Failed to create shared model cache at {}: {error} — falling back to default",
@@ -42,7 +28,6 @@ pub fn setup_shared_model_cache() {
28 return;
29 }
30
45 - // hf-hub derives all its paths from HF_HOME.
31 if std::env::var("HF_HOME").is_err() {
32 // SAFETY: called once at startup before any threads are spawned.
33 unsafe { std::env::set_var("HF_HOME", &models_home) };
@@ -54,8 +39,7 @@ pub fn setup_shared_model_cache() {
39 );
40 }
41
57 - // Some mistral.rs code paths read HF_HUB_CACHE directly instead
58 - // of deriving it from HF_HOME, so we set both.
42 + // mistral.rs reads HF_HUB_CACHE directly instead of deriving from HF_HOME
43 if std::env::var("HF_HUB_CACHE").is_err() {
44 // SAFETY: called once at startup before any threads are spawned.
45 unsafe { std::env::set_var("HF_HUB_CACHE", &model_hub) };
@@ -66,15 +50,14 @@ pub fn setup_shared_model_cache() {
50 }
51 }
52
69 -/// Preference key used to remember the last selected model.
53 const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt";
54
72 -/// Stable persisted identifier for a selected local model.
55 +/// persisted identifier for a selected model (model_id + gguf filename).
56 #[derive(Debug, Clone, PartialEq, Eq)]
57 pub struct SelectedModel {
75 - /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`.
58 + /// e.g. `bartowski/Qwen_Qwen3-4B-GGUF`
59 pub model_id: String,
77 - /// GGUF filename inside the snapshot.
60 +
61 pub gguf_file: String,
62 }
63
@@ -91,31 +74,31 @@ impl SelectedModel {
74 }
75 }
76
94 -/// Minimal startup model selection info used before the full UI is running.
77 +/// what we know about the model before the full UI is up.
78 #[derive(Debug, Clone, PartialEq, Eq)]
79 pub struct StartupModelSelection {
97 - /// Human-friendly model name shown in the loading UI.
80 + /// shown in the loading screen
81 pub display_name: String,
99 - /// The saved model identifier if one was found.
82 +
83 pub selected_model: Option<SelectedModel>,
84 }
85
103 -/// A locally discovered GGUF model candidate.
86 +/// a GGUF model found on disk.
87 #[derive(Debug, Clone, PartialEq, Eq)]
88 pub struct DiscoveredModel {
106 - /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`.
89 + /// e.g. `bartowski/Qwen_Qwen3-4B-GGUF`
90 pub model_id: String,
108 - /// GGUF filename inside the snapshot.
91 + /// filename inside the snapshot dir
92 pub gguf_file: String,
110 - /// Human-friendly label shown in model pickers.
93 +
94 pub display_name: String,
112 - /// Absolute path to the snapshot directory that contains the GGUF file.
95 +
96 pub snapshot_path: PathBuf,
114 - /// Absolute path to the GGUF file itself.
97 +
98 pub gguf_path: PathBuf,
116 - /// True when the model came from the Onde app group cache.
99 +
100 pub from_app_group: bool,
118 - /// Whether the snapshot looks complete enough to load.
101 +
102 pub cache_health: ModelCacheHealth,
103 }
104
@@ -126,11 +109,7 @@ pub enum ModelCacheHealth {
109 NotDownloaded,
110 }
111
129 -/// Return all locally discovered GGUF models.
130 -///
131 -/// Search order:
132 -/// 1. Onde app group cache on macOS
133 -/// 2. Standard Hugging Face cache
112 +/// find all GGUF models on disk. checks Onde app group first, then HF cache.
113 pub fn discover_local_models() -> Vec<DiscoveredModel> {
114 let mut models = Vec::new();
115 let mut seen_roots = Vec::new();
@@ -243,18 +222,14 @@ fn collect_models_from_cache_root(
222 }
223
224 if gguf_files.is_empty() {
246 - // No GGUF file found — the snapshot exists on disk (e.g. only
247 - // metadata arrived, or the download is still in progress).
248 - // Push a sentinel entry with Incomplete health so the model
249 - // picker can show it as disabled rather than hiding it entirely.
225 + // snapshot dir exists but no .gguf yet (download in progress or
226 + // only metadata). mark incomplete so the picker can show it disabled.
227 models.push(DiscoveredModel {
228 display_name: display_name_for_model(&model_id, ""),
229 model_id: model_id.clone(),
230 gguf_file: String::new(),
231 snapshot_path: snapshot_path.clone(),
255 - // Point at the snapshot directory itself; this path is
256 - // never used for loading because Incomplete models are
257 - // filtered out before any GgufModelConfig is built.
232 + // unused for loading; incomplete models are filtered out before config
233 gguf_path: snapshot_path.clone(),
234 from_app_group,
235 cache_health: ModelCacheHealth::Incomplete,
@@ -355,16 +330,8 @@ pub fn load_selected_model_name() -> Option<String> {
330 .map(|model| model.display_name)
331 }
332
358 -/// Pick the model name siGit should try to load at startup.
359 -///
360 -/// Order:
361 -/// 1. saved selection, if it still exists locally
362 -/// 2. first discovered local model (Onde app group first, then HF cache)
363 -/// 3. no selection
364 -///
365 -/// If there is no saved selection but a local model is discovered, persist that
366 -/// fallback choice so ACP mode and the interactive TUI converge on the same
367 -/// startup model on the next launch too.
333 +/// pick a model for startup: saved selection > first local model > none.
334 +/// if we fall back to a local model, persist it so ACP and TUI agree next time.
335 pub fn startup_model_selection() -> Option<StartupModelSelection> {
336 let discovered = discover_local_models();
337
@@ -433,10 +400,8 @@ fn selected_model_file_path() -> Option<PathBuf> {
400 )
401 }
402
436 -/// Look for the App Group container on disk. macOS creates it the first time
437 -/// a signed app in the group accesses it, so it only exists if the user has
438 -/// launched siGit desktop (or another Onde app) at least once. A plain CLI
439 -/// binary can read/write there without extra entitlements.
403 +/// macOS only creates this dir when a signed app in the group first runs,
404 +/// so it won't exist until the user has launched siGit desktop or another Onde app.
405 #[cfg(target_os = "macos")]
406 fn resolve_shared_container() -> Option<PathBuf> {
407 let home = std::env::var("HOME").ok()?;
src/tools.rs
+8 -88
@@ -1,31 +1,4 @@
1 -//! Tool definitions and execution for the siGit Code.
2 -//!
3 -//! Each tool has:
4 -//! - A schema (JSON Schema) that describes its parameters for the LLM
5 -//! - An execution function that runs the tool and returns a string result
6 -//!
7 -//! # Dependencies
8 -//!
9 -//! This module requires `serde_json` and `regex` crates in `Cargo.toml`:
10 -//! ```toml
11 -//! serde_json = "1"
12 -//! regex = "1"
13 -//! ```
14 -//!
15 -//! # Write Tools
16 -//!
17 -//! - `create_directory` — create a directory and any missing parent directories
18 -//! - `create_file` — create a new file (fails if it already exists)
19 -//! - `edit_file` — replace an exact old-text span with new text in an existing file
20 -//! - `delete_file` — delete a file or empty directory at the given path
21 -//!
22 -//! # Web Tools
23 -//!
24 -//! - `read_website` — fetch a web page and return readable text content
25 -//!
26 -//! # Shell Tools
27 -//!
28 -//! - `run_command` — run shell commands, including git porcelain and plumbing commands
1 +//! Agent tools: schema definitions + execution for siGit Code.
2
3 use regex::Regex;
4 use serde_json::{Value, json};
@@ -38,25 +11,17 @@ const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
11 const WEBSITE_USER_AGENT: &str =
12 "siGit/0.1 (+https://github.com/getsigit/sigit; website-reading tool)";
13
41 -/// Maximum characters returned from `read_file` before truncation.
14 const READ_FILE_CHAR_LIMIT: usize = 10_000;
43 -
44 -/// Maximum number of matching lines returned from `search_files`.
15 const SEARCH_FILES_MATCH_LIMIT: usize = 50;
16
17 // ── Tool schemas ─────────────────────────────────────────────────────────────
18
49 -/// A tool definition with its JSON Schema and metadata for the LLM.
19 pub struct AgentTool {
51 - /// Machine-readable tool name (e.g. `"read_file"`).
20 pub name: &'static str,
53 - /// Human-readable description shown to the LLM.
21 pub description: &'static str,
55 - /// JSON Schema describing the tool's parameters.
22 pub parameters_schema: Value,
23 }
24
59 -/// Return all available agent tools.
25 pub fn all_tools() -> Vec<AgentTool> {
26 vec![
27 AgentTool {
@@ -271,19 +236,13 @@ pub fn all_tools() -> Vec<AgentTool> {
236
237 // ── Tool execution ───────────────────────────────────────────────────────────
238
274 -/// Execute a tool by name with the given JSON arguments string.
275 -///
276 -/// Returns the tool output as a human-readable string. Errors are returned as
277 -/// descriptive strings rather than panicking.
239 pub async fn execute_tool(name: &str, arguments: &str) -> String {
240 match name {
241 "read_file" => exec_read_file(arguments),
242 "list_directory" => exec_list_directory(arguments),
243 "search_files" => exec_search_files(arguments),
244 "read_website" => {
284 - // reqwest::blocking panics if called inside a tokio runtime
285 - // ("Cannot start a runtime from within a runtime"), so we
286 - // off-load it to the blocking thread pool.
245 + // reqwest::blocking panics inside a tokio runtime, so run on the blocking pool.
246 let args = arguments.to_owned();
247 tokio::task::spawn_blocking(move || exec_read_website(&args))
248 .await
@@ -314,7 +273,6 @@ fn absolute_path_string(path: &Path) -> String {
273
274 // ── read_file ────────────────────────────────────────────────────────────────
275
317 -/// Read the contents of a single file, truncating at [`READ_FILE_CHAR_LIMIT`].
276 fn exec_read_file(arguments: &str) -> String {
277 let args: Value = match serde_json::from_str(arguments) {
278 Ok(v) => v,
@@ -380,7 +338,6 @@ fn exec_read_file(arguments: &str) -> String {
338
339 // ── list_directory ───────────────────────────────────────────────────────────
340
383 -/// List directory entries, directories first, sorted alphabetically.
341 fn exec_list_directory(arguments: &str) -> String {
342 let args: Value = match serde_json::from_str(arguments) {
343 Ok(v) => v,
@@ -438,7 +395,6 @@ fn exec_list_directory(arguments: &str) -> String {
395 dirs.sort();
396 files.sort();
397
441 - // Directories first, then files.
398 dirs.extend(files);
399
400 if dirs.is_empty() {
@@ -450,7 +406,6 @@ fn exec_list_directory(arguments: &str) -> String {
406
407 // ── search_files ─────────────────────────────────────────────────────────────
408
453 -/// Recursively search files for a regex pattern, returning matching lines.
409 fn exec_search_files(arguments: &str) -> String {
410 let args: Value = match serde_json::from_str(arguments) {
411 Ok(v) => v,
@@ -499,13 +454,8 @@ fn exec_search_files(arguments: &str) -> String {
454 matches.join("\n")
455 }
456
502 -/// Recursively walk a directory and collect regex matches.
503 -///
504 -/// Skips hidden directories (names starting with `.`) and binary files.
505 -/// Stops collecting once the match list reaches a generous internal cap (2×
506 -/// the public limit) to avoid unbounded work.
457 +/// caps collected matches at 2x the public limit to bound work on large trees.
458 fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
508 - // Internal cap to avoid scanning the entire filesystem.
459 const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2;
460
461 let entries = match fs::read_dir(dir) {
@@ -513,7 +463,6 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
463 Err(_) => return,
464 };
465
516 - // Collect and sort for deterministic output.
466 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
467 sorted.sort_by_key(|e| e.file_name());
468
@@ -526,7 +475,6 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
475 let name = entry.file_name();
476 let name_str = name.to_string_lossy();
477
529 - // Skip hidden entries.
478 if name_str.starts_with('.') {
479 continue;
480 }
@@ -539,13 +487,10 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
487 }
488 }
489
542 -/// Search a single file line-by-line for the regex pattern.
543 -///
544 -/// Skips files that cannot be read as UTF-8 (assumed binary).
490 +/// skips non-UTF-8 files (probably binary).
491 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
492 let contents = match fs::read_to_string(path) {
493 Ok(c) => c,
548 - // Skip binary / unreadable files silently.
494 Err(_) => return,
495 };
496
@@ -677,7 +622,6 @@ fn exec_read_website(arguments: &str) -> String {
622
623 // ── create_directory ─────────────────────────────────────────────────────────
624
680 -/// Create a directory and any missing parent directories.
625 fn exec_create_directory(arguments: &str) -> String {
626 let args: Value = match serde_json::from_str(arguments) {
627 Ok(v) => v,
@@ -706,11 +650,7 @@ fn exec_create_directory(arguments: &str) -> String {
650 }
651 }
652
709 -/// Create a new file with the provided content.
710 -///
711 -/// Parent directories are created automatically. Fails if the file already
712 -/// exists to prevent accidental overwrites — the LLM should use `edit_file`
713 -/// for existing files.
653 +/// fails if file exists so the LLM is forced to use `edit_file` for modifications.
654 fn exec_create_file(arguments: &str) -> String {
655 let args: Value = match serde_json::from_str(arguments) {
656 Ok(v) => v,
@@ -737,7 +677,6 @@ fn exec_create_file(arguments: &str) -> String {
677 );
678 }
679
740 - // Create parent directories if needed.
680 if let Some(parent) = absolute_path.parent()
681 && !parent.as_os_str().is_empty()
682 && !parent.exists()
@@ -757,12 +696,7 @@ fn exec_create_file(arguments: &str) -> String {
696
697 // ── edit_file ────────────────────────────────────────────────────────────────
698
760 -/// Edit an existing file by replacing an exact occurrence of `old_text` with
761 -/// `new_text`.
762 -///
763 -/// The `old_text` must appear **exactly once** in the file. This prevents
764 -/// ambiguous edits and forces the LLM to read the file first to get the exact
765 -/// text span.
699 +/// `old_text` must match exactly once — ambiguity means the LLM didn't read the file first.
700 fn exec_edit_file(arguments: &str) -> String {
701 let args: Value = match serde_json::from_str(arguments) {
702 Ok(v) => v,
@@ -803,7 +737,6 @@ fn exec_edit_file(arguments: &str) -> String {
737 Err(err) => return format!("Error: could not read file: {err}"),
738 };
739
806 - // Count occurrences to give a clear error message.
740 let occurrences = contents.matches(old_text).count();
741
742 if occurrences == 0 {
@@ -833,10 +766,7 @@ fn exec_edit_file(arguments: &str) -> String {
766
767 // ── delete_file ──────────────────────────────────────────────────────────────
768
836 -/// Delete a file or empty directory at the given path.
837 -///
838 -/// Refuses to remove non-empty directories to guard against accidental
839 -/// recursive deletes.
769 +/// only removes files or *empty* directories — no recursive deletes.
770 fn exec_delete_file(arguments: &str) -> String {
771 let args: Value = match serde_json::from_str(arguments) {
772 Ok(v) => v,
@@ -874,18 +804,10 @@ fn exec_delete_file(arguments: &str) -> String {
804
805 // ── run_command ──────────────────────────────────────────────────────────────
806
877 -/// Maximum time a command is allowed to run before being killed.
807 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
879 -
880 -/// Maximum bytes of combined output returned from a command.
808 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
809
883 -/// Run a shell command and return its combined stdout + stderr output.
884 -///
885 -/// The command is executed via `sh -c` (Unix) or `cmd /C` (Windows) so shell
886 -/// features like pipes, redirects, and chaining work out of the box.
887 -///
888 -/// Long-running commands are killed after [`COMMAND_TIMEOUT`] seconds.
810 +/// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT.
811 fn exec_run_command(arguments: &str) -> String {
812 let args: Value = match serde_json::from_str(arguments) {
813 Ok(v) => v,
@@ -937,7 +859,6 @@ fn exec_run_command(arguments: &str) -> String {
859 Err(err) => return format!("Error: failed to spawn command: {err}"),
860 };
861
940 - // Wait with a timeout.
862 let start = std::time::Instant::now();
863 loop {
864 match child.try_wait() {
@@ -966,7 +887,6 @@ fn exec_run_command(arguments: &str) -> String {
887 combined.push_str(&String::from_utf8_lossy(&output.stdout));
888 combined.push_str(&String::from_utf8_lossy(&output.stderr));
889
969 - // Truncate if output is huge.
890 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
891 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
892 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")