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)")