@hej / sigit / commits / 9548958

Switch to local onde dependency and update model handling

- Use local onde crate via path dependency - Update crossterm and related dependencies in Cargo.lock - Switch mistralrs crates to git source - Add model selection and listing to chat TUI - Improve model config and sampling handling for Qwen 3 - Update comments and clarify code in chat and main modules

Seto Elkahfi committed Apr 14, 2026 at 19:20 UTC 9548958673868927782b43c0e7137c4c6b09402c
4 files changed +208 -102
Cargo.lock
+40 -56
@@ -1056,15 +1056,16 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
1056
1057 [[package]]
1058 name = "crossterm"
1059 -version = "0.25.0"
1059 +version = "0.28.1"
1060 source = "registry+https://github.com/rust-lang/crates.io-index"
1061 -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
1061 +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
1062 dependencies = [
1063 - "bitflags 1.3.2",
1063 + "bitflags 2.11.0",
1064 "crossterm_winapi",
1065 - "libc",
1066 - "mio 0.8.11",
1065 + "futures-core",
1066 + "mio",
1067 "parking_lot",
1068 + "rustix 0.38.44",
1069 "signal-hook",
1070 "signal-hook-mio",
1071 "winapi",
@@ -1072,16 +1073,17 @@ dependencies = [
1073
1074 [[package]]
1075 name = "crossterm"
1075 -version = "0.28.1"
1076 +version = "0.29.0"
1077 source = "registry+https://github.com/rust-lang/crates.io-index"
1077 -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
1078 +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
1079 dependencies = [
1080 "bitflags 2.11.0",
1081 "crossterm_winapi",
1081 - "futures-core",
1082 - "mio 1.2.0",
1082 + "derive_more",
1083 + "document-features",
1084 + "mio",
1085 "parking_lot",
1084 - "rustix 0.38.44",
1086 + "rustix 1.1.4",
1087 "signal-hook",
1088 "signal-hook-mio",
1089 "winapi",
@@ -1435,6 +1437,15 @@ version = "1.1.1"
1437 source = "registry+https://github.com/rust-lang/crates.io-index"
1438 checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
1439
1440 +[[package]]
1441 +name = "document-features"
1442 +version = "0.2.12"
1443 +source = "registry+https://github.com/rust-lang/crates.io-index"
1444 +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
1445 +dependencies = [
1446 + "litrs",
1447 +]
1448 +
1449 [[package]]
1450 name = "dtoa"
1451 version = "1.0.11"
@@ -3016,6 +3027,12 @@ version = "0.8.2"
3027 source = "registry+https://github.com/rust-lang/crates.io-index"
3028 checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
3029
3030 +[[package]]
3031 +name = "litrs"
3032 +version = "1.0.0"
3033 +source = "registry+https://github.com/rust-lang/crates.io-index"
3034 +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
3035 +
3036 [[package]]
3037 name = "llguidance"
3038 version = "1.7.2"
@@ -3270,18 +3287,6 @@ dependencies = [
3287 "simd-adler32",
3288 ]
3289
3273 -[[package]]
3274 -name = "mio"
3275 -version = "0.8.11"
3276 -source = "registry+https://github.com/rust-lang/crates.io-index"
3277 -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
3278 -dependencies = [
3279 - "libc",
3280 - "log",
3281 - "wasi",
3282 - "windows-sys 0.48.0",
3283 -]
3284 -
3290 [[package]]
3291 name = "mio"
3292 version = "1.2.0"
@@ -3297,8 +3302,7 @@ dependencies = [
3302 [[package]]
3303 name = "mistralrs"
3304 version = "0.8.1"
3300 -source = "registry+https://github.com/rust-lang/crates.io-index"
3301 -checksum = "9bb0a83340b4492ebba9760ba6845364de369c7e10c1f91af8733d574c7405fa"
3305 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3306 dependencies = [
3307 "anyhow",
3308 "candle-core",
@@ -3325,8 +3329,7 @@ dependencies = [
3329 [[package]]
3330 name = "mistralrs-audio"
3331 version = "0.8.1"
3328 -source = "registry+https://github.com/rust-lang/crates.io-index"
3329 -checksum = "5ac5d36f634c7c20c45845bc995be3b6387ab01048410386a5b180cfeaf89c72"
3332 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3333 dependencies = [
3334 "anyhow",
3335 "apodize",
@@ -3337,8 +3340,7 @@ dependencies = [
3340 [[package]]
3341 name = "mistralrs-core"
3342 version = "0.8.1"
3340 -source = "registry+https://github.com/rust-lang/crates.io-index"
3341 -checksum = "a2b8b9e5c94491d9ceeded3a30291cb6af6ae74810a5776dd55e3bbb8b3429d4"
3343 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3344 dependencies = [
3345 "ahash",
3346 "akin",
@@ -3435,8 +3437,7 @@ dependencies = [
3437 [[package]]
3438 name = "mistralrs-macros"
3439 version = "0.8.1"
3438 -source = "registry+https://github.com/rust-lang/crates.io-index"
3439 -checksum = "5aa9b4794322d3f89fe61d21f33f67be494c16d5bd869604b111218f32544d32"
3440 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3441 dependencies = [
3442 "darling 0.23.0",
3443 "proc-macro2",
@@ -3447,8 +3448,7 @@ dependencies = [
3448 [[package]]
3449 name = "mistralrs-mcp"
3450 version = "0.8.1"
3450 -source = "registry+https://github.com/rust-lang/crates.io-index"
3451 -checksum = "4fa97d4e3189ed80ebbc730b7da5b2356df0ae004ab489066249340c33c089ab"
3451 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3452 dependencies = [
3453 "anyhow",
3454 "async-trait",
@@ -3468,8 +3468,7 @@ dependencies = [
3468 [[package]]
3469 name = "mistralrs-paged-attn"
3470 version = "0.8.1"
3471 -source = "registry+https://github.com/rust-lang/crates.io-index"
3472 -checksum = "6e53ddf1537426997b46abdadbe6ca8a7ce7668004ed2b7cf00115016558bae8"
3471 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3472 dependencies = [
3473 "anyhow",
3474 "candle-core",
@@ -3485,8 +3484,7 @@ dependencies = [
3484 [[package]]
3485 name = "mistralrs-quant"
3486 version = "0.8.1"
3488 -source = "registry+https://github.com/rust-lang/crates.io-index"
3489 -checksum = "980715493d252e9aaf0779c4c101576d67ef4dd9812bfd77a54987f6f17526f4"
3487 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3488 dependencies = [
3489 "byteorder",
3490 "candle-core",
@@ -3515,8 +3513,7 @@ dependencies = [
3513 [[package]]
3514 name = "mistralrs-vision"
3515 version = "0.8.1"
3518 -source = "registry+https://github.com/rust-lang/crates.io-index"
3519 -checksum = "10046a3da2de5b702d3e829b5ddef7aa829ad454819dcc4876dea481a6cb5456"
3516 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3517 dependencies = [
3518 "candle-core",
3519 "image",
@@ -3830,9 +3827,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
3827
3828 [[package]]
3829 name = "onde"
3833 -version = "0.1.1"
3834 -source = "registry+https://github.com/rust-lang/crates.io-index"
3835 -checksum = "5436633863ffc31311f529439353ba06a436dd661d2a5d93c8793c3500123a13"
3830 +version = "0.1.3"
3831 dependencies = [
3832 "anyhow",
3833 "cc",
@@ -5330,8 +5325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5325 checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
5326 dependencies = [
5327 "libc",
5333 - "mio 0.8.11",
5334 - "mio 1.2.0",
5328 + "mio",
5329 "signal-hook",
5330 ]
5331
@@ -6112,7 +6106,7 @@ checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
6106 dependencies = [
6107 "bytes",
6108 "libc",
6115 - "mio 1.2.0",
6109 + "mio",
6110 "parking_lot",
6111 "pin-project-lite",
6112 "signal-hook-registry",
@@ -6292,11 +6286,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
6286 [[package]]
6287 name = "tqdm"
6288 version = "0.8.0"
6295 -source = "registry+https://github.com/rust-lang/crates.io-index"
6296 -checksum = "b316d5c2ac649ca856dacd487d0ebb94f3b746bada51355d93dd2c007ab62a2e"
6289 +source = "git+https://github.com/setoelkahfi/tqdm?branch=deps%2Fbump-crossterm#41e4182829136e6dadbdd1c36af824d19219147a"
6290 dependencies = [
6291 "anyhow",
6299 - "crossterm 0.25.0",
6292 + "crossterm 0.29.0",
6293 "once_cell",
6294 ]
6295
@@ -7273,15 +7266,6 @@ dependencies = [
7266 "windows-targets 0.42.2",
7267 ]
7268
7276 -[[package]]
7277 -name = "windows-sys"
7278 -version = "0.48.0"
7279 -source = "registry+https://github.com/rust-lang/crates.io-index"
7280 -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
7281 -dependencies = [
7282 - "windows-targets 0.48.5",
7283 -]
7284 -
7269 [[package]]
7270 name = "windows-sys"
7271 version = "0.52.0"
Cargo.toml
+1 -1
@@ -15,7 +15,7 @@ path = "src/main.rs"
15 agent-client-protocol = "0.10.4"
16
17 # Onde Inference engine (local LLM)
18 -onde = "0.1.1"
18 +onde = { path = "../onde" }
19
20 # Async runtime
21 async-trait = "0.1"
src/chat.rs
+144 -26
@@ -1,14 +1,13 @@
1 -//! Full-screen terminal chat UI for siGit Code.
2 -//!
3 -//! Takes over the alternate screen and multiplexes terminal events with
4 -//! streaming LLM tokens via `tokio::select!`.
1 +//! Full-screen chat TUI. Runs on the alternate screen so nothing leaks into
2 +//! the main terminal buffer. Streaming tokens and key events share the loop
3 +//! via `tokio::select!`.
4
5 use std::future::pending;
6
7 use anyhow::Result;
8 use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
9 use futures::StreamExt;
11 -use onde::inference::{ChatEngine, StreamChunk};
10 +use onde::inference::{ChatEngine, GgufModelConfig, SamplingConfig, StreamChunk};
11 use ratatui::{
12 Frame,
13 layout::{Constraint, Layout, Position},
@@ -25,7 +24,7 @@ enum Role {
24 User,
25 Assistant,
26 System,
28 - /// Banner art — rendered with per-character digit colorization.
27 + /// Banner art — each character gets its own color.
28 Banner,
29 }
30
@@ -74,7 +73,7 @@ struct App {
73 stream_rx: Option<mpsc::Receiver<StreamChunk>>,
74 stream_buf: String,
75 quit: bool,
77 - /// Toggled every other tick while streaming — drives the blinking cursor.
76 + /// Flips every few ticks while streaming to make the cursor blink.
77 blink_on: bool,
78 blink_counter: u8,
79 }
@@ -143,17 +142,17 @@ impl App {
142 self.blink_on = self.blink_counter % 4 < 2;
143 }
144
146 - /// Total lines the messages area would need (rough estimate for scrolling).
145 + /// Rough line count for the messages area — used to drive auto-scroll.
146 fn total_message_lines(&self, width: u16) -> u16 {
147 if width == 0 {
148 return 0;
149 }
151 - let w = width.saturating_sub(2) as usize; // account for block borders
150 + let w = width.saturating_sub(2) as usize; // subtract border columns
151 let mut lines: u16 = 0;
152 for msg in &self.messages {
153 lines += wrapped_line_count(&msg.text, msg.role, w);
154 }
156 - // streaming buffer
155 + // count any in-progress streaming text too
156 if !self.stream_buf.is_empty() {
157 lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w);
158 }
@@ -174,7 +173,7 @@ fn banner_char_color(_ch: char) -> Color {
173 Color::White
174 }
175
177 -/// Estimate how many terminal rows a message takes once wrapped.
176 +/// How many terminal rows a message takes up after line-wrapping.
177 fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
178 let prefix_len = match role {
179 Role::User => 6, // "you > "
@@ -198,12 +197,52 @@ fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
197 count.max(1)
198 }
199
200 +// ── Model table ──────────────────────────────────────────────────────────────
201 +
202 +struct ModelOption {
203 + /// Name shown in `/models`. Must match `GgufModelConfig::display_name`.
204 + name: &'static str,
205 + /// Short blurb shown next to the name, e.g. "~2.7 GB".
206 + description: &'static str,
207 + /// True if this model actually handles tool calls.
208 + tool_calling: bool,
209 + /// Token budget for generation. Qwen 3 needs 4096+ or it outputs nothing.
210 + max_tokens: u64,
211 + config_fn: fn() -> GgufModelConfig,
212 +}
213 +
214 +const SIGIT_MODELS: &[ModelOption] = &[
215 + ModelOption {
216 + name: "Qwen 3 4B (Q4_K_M)",
217 + description: "~2.7 GB",
218 + tool_calling: true,
219 + max_tokens: 4096,
220 + config_fn: GgufModelConfig::qwen3_4b,
221 + },
222 + ModelOption {
223 + name: "Qwen 2.5 Coder 3B (Q4_K_M)",
224 + description: "~1.93 GB",
225 + tool_calling: false,
226 + max_tokens: 512,
227 + config_fn: GgufModelConfig::qwen25_coder_3b,
228 + },
229 + ModelOption {
230 + name: "Qwen 2.5 Coder 1.5B (Q4_K_M)",
231 + description: "~941 MB",
232 + tool_calling: false,
233 + max_tokens: 512,
234 + config_fn: GgufModelConfig::qwen25_coder_1_5b,
235 + },
236 +];
237 +
238 // ── Slash commands ───────────────────────────────────────────────────────────
239
240 enum SlashCommand {
241 Help,
242 Clear,
243 Status,
244 + /// `/models` lists models. `/models N` switches to model N (1-based).
245 + Models(Option<usize>),
246 Exit,
247 Unknown(String),
248 }
@@ -213,11 +252,14 @@ fn parse_slash(input: &str) -> Option<SlashCommand> {
252 if !trimmed.starts_with('/') {
253 return None;
254 }
216 - let cmd = trimmed.split_whitespace().next().unwrap_or("");
255 + let mut parts = trimmed.splitn(2, char::is_whitespace);
256 + let cmd = parts.next().unwrap_or("");
257 + let arg = parts.next().map(|s| s.trim());
258 Some(match cmd {
259 "/help" => SlashCommand::Help,
260 "/clear" => SlashCommand::Clear,
261 "/status" => SlashCommand::Status,
262 + "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
263 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
264 other => SlashCommand::Unknown(other.to_string()),
265 })
@@ -283,7 +325,7 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
325 let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect();
326 for (i, segment) in buf_lines.iter().enumerate() {
327 if i > 0 {
286 - lines.push(Line::from(spans.drain(..).collect::<Vec<_>>()));
328 + lines.push(Line::from(std::mem::take(&mut spans)));
329 // continuation lines get no prefix
330 }
331 spans.push(Span::raw(segment.to_string()));
@@ -500,14 +542,21 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
542
543 // ── Slash command execution ──────────────────────────────────────────────────
544
503 -async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) {
545 +async fn exec_slash(
546 + app: &mut App,
547 + cmd: SlashCommand,
548 + engine: &ChatEngine,
549 + terminal: &mut ratatui::DefaultTerminal,
550 +) {
551 match cmd {
552 SlashCommand::Help => {
553 app.messages.push(ChatMessage::system(
507 - "/help — show this message\n\
508 - /clear — wipe conversation history\n\
509 - /status — show engine status\n\
510 - /exit — quit chat",
554 + "/help — show this message\n\
555 + /models — list available models\n\
556 + /models N — switch to model N\n\
557 + /clear — wipe conversation history\n\
558 + /status — show engine status\n\
559 + /exit — quit chat",
560 ));
561 }
562 SlashCommand::Clear => {
@@ -527,6 +576,77 @@ async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) {
576 info.status, model, mem, info.history_length,
577 )));
578 }
579 + SlashCommand::Models(selection) => match selection {
580 + None => {
581 + // Show the model list.
582 + let info = engine.info().await;
583 + let current = info.model_name.clone().unwrap_or_default();
584 +
585 + let mut text = String::from("Available models — type /models <n> to switch:\n");
586 + for (i, model) in SIGIT_MODELS.iter().enumerate() {
587 + let current_marker = if current == model.name {
588 + " ← current"
589 + } else {
590 + ""
591 + };
592 + let tool_badge = if model.tool_calling {
593 + " ✓ tool calling"
594 + } else {
595 + ""
596 + };
597 + text.push_str(&format!(
598 + "\n {} {} {}{}{}",
599 + i + 1,
600 + model.name,
601 + model.description,
602 + tool_badge,
603 + current_marker,
604 + ));
605 + }
606 + app.messages.push(ChatMessage::system(text));
607 + }
608 + Some(n) => {
609 + let idx = n.saturating_sub(1);
610 + match SIGIT_MODELS.get(idx) {
611 + None => {
612 + app.messages.push(ChatMessage::system(format!(
613 + "error: no model #{n} — type /models to see the list."
614 + )));
615 + }
616 + Some(model) => {
617 + // Redraw first — "Loading…" has to be on screen before
618 + // we block for however long the load takes.
619 + app.messages
620 + .push(ChatMessage::system(format!("Loading {}…", model.name)));
621 + terminal.draw(|frame| render(frame, app)).ok();
622 +
623 + engine.unload_model().await;
624 +
625 + let config = (model.config_fn)();
626 + let sampling = SamplingConfig {
627 + max_tokens: Some(model.max_tokens),
628 + ..SamplingConfig::default()
629 + };
630 +
631 + match engine.load_gguf_model(config, None, Some(sampling)).await {
632 + Ok(_) => {
633 + engine.clear_history().await;
634 + app.messages.push(ChatMessage::system(format!(
635 + "✓ Switched to {}",
636 + model.name
637 + )));
638 + }
639 + Err(err) => {
640 + app.messages.push(ChatMessage::system(format!(
641 + "error loading {}: {err}",
642 + model.name
643 + )));
644 + }
645 + }
646 + }
647 + }
648 + }
649 + },
650 SlashCommand::Exit => {
651 app.quit = true;
652 }
@@ -539,9 +659,7 @@ async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) {
659
660 // ── Main loop ────────────────────────────────────────────────────────────────
661
542 -/// Run the interactive chat UI. Blocks until the user quits.
543 -///
544 -/// The caller must have already loaded a model into `engine`.
662 +/// Starts the TUI and blocks until the user quits. Model must be loaded first.
663 pub async fn run(engine: &ChatEngine) -> Result<()> {
664 let mut terminal = ratatui::init();
665 let result = event_loop(&mut terminal, engine).await;
@@ -554,7 +672,7 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
672 let mut event_stream = EventStream::new();
673
674 loop {
557 - // draw
675 + // redraw every iteration
676 terminal.draw(|frame| render(frame, &mut app))?;
677
678 if app.quit {
@@ -565,7 +683,7 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
683 tokio::select! {
684 biased;
685
568 - // streaming chunks — only active when we have a receiver
686 + // streaming tokens — only polls when there's an active receiver
687 chunk = async {
688 match app.stream_rx.as_mut() {
689 Some(rx) => rx.recv().await,
@@ -596,12 +714,12 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
714 };
715
716 if let Event::Key(key) = event {
599 - // while streaming, only ctrl+c/d work
717 + // ignore everything except Ctrl+C while a response is coming in
718 if app.is_streaming() {
719 if key.kind == KeyEventKind::Press {
720 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
721 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
604 - // drop the receiver to stop reading
722 + // dropping the receiver makes the stream drain itself
723 app.finalize_stream();
724 app.messages.push(ChatMessage::system("(cancelled)"));
725 }
@@ -612,7 +730,7 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
730 if let Some(text) = handle_key(&mut app, key) {
731 // check for slash command first
732 if let Some(cmd) = parse_slash(&text) {
615 - exec_slash(&mut app, cmd, engine).await;
733 + exec_slash(&mut app, cmd, engine, terminal).await;
734 continue;
735 }
736
src/main.rs
+23 -19
@@ -1,9 +1,9 @@
1 -//! siGit Code — AI coding agent powered by a local LLM via Onde Inference.
1 +//! siGit Code — local LLM coding agent built on Onde.
2 //!
3 -//! Two modes of operation:
3 +//! Two modes:
4 //!
5 -//! - **Interactive** (stdin is a TTY): full-screen chat UI built on ratatui.
6 -//! - **ACP server** (stdin is piped): JSON-RPC over stdio for editors like Zed.
5 +//! - Interactive (stdin is a TTY): full-screen TUI via ratatui.
6 +//! - ACP server (stdin is piped): JSON-RPC over stdio for Zed and friends.
7 //!
8 //! On macOS the model cache is shared with the siGit desktop app through an
9 //! App Group container. See [`setup`].
@@ -58,8 +58,7 @@ root cause, not the symptom. Correct beats clever.";
58
59 // ── Per-session state ────────────────────────────────────────────────────────
60
61 -/// One active session at a time. We store the `SessionId` directly (not as a
62 -/// `String`) so `==` just works.
61 +/// One active session. `SessionId` stored directly (not `String`) so `==` works.
62 struct Session {
63 id: SessionId,
64 }
@@ -70,7 +69,7 @@ struct Session {
69 struct SiGitAgent {
70 engine: Arc<ChatEngine>,
71 active_session: Arc<Mutex<Option<Session>>>,
73 - /// Sends streaming chunks to the forwarder task, which writes them out.
72 + /// Streaming chunks go here; a separate task drains this and writes to the editor.
73 notification_tx: mpsc::Sender<SessionNotification>,
74 }
75
@@ -116,7 +115,7 @@ impl Agent for SiGitAgent {
115 log::info!("new_session: id={session_id}");
116
117 if self.engine.is_loaded().await {
119 - // Model is already warm — just wipe the conversation.
118 + // Already loaded — just clear history for the new session.
119 log::info!("model already loaded — clearing history for new session");
120 self.engine.clear_history().await;
121 } else {
@@ -215,14 +214,20 @@ impl Agent for SiGitAgent {
214
215 // ── Interactive mode ─────────────────────────────────────────────────────────
216
218 -/// Load the model, then hand off to the ratatui chat TUI.
217 +/// Loads the model and starts the TUI.
218 async fn run_interactive() -> anyhow::Result<()> {
219 println!(" Loading siGit...");
220
221 let engine = ChatEngine::new();
223 - let config = GgufModelConfig::platform_default();
222 + let config = GgufModelConfig::qwen3_4b();
223 + // Qwen 3's <think>…</think> block burns 300-400 tokens before the actual reply.
224 + // The default 512 isn't enough — the model outputs nothing. 4096 fixes it.
225 + let sampling = onde::inference::SamplingConfig {
226 + max_tokens: Some(4096),
227 + ..onde::inference::SamplingConfig::default()
228 + };
229 engine
225 - .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
230 + .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), Some(sampling))
231 .await
232 .map_err(|e| anyhow::anyhow!("model load failed: {e}"))?;
233
@@ -238,9 +243,9 @@ async fn run_interactive() -> anyhow::Result<()> {
243
244 // ── ACP server mode ──────────────────────────────────────────────────────────
245
241 -/// The editor spawns us and talks ACP over stdio.
246 +/// The editor spawns us as a subprocess and speaks ACP over stdio.
247 async fn run_acp_server() -> anyhow::Result<()> {
243 - // Agent::prompt sends chunks here; the forwarder task writes them out.
248 + // Chunks from Agent::prompt land here; a forwarder task ships them to the editor.
249 let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
250
251 let agent = SiGitAgent::new(notification_tx);
@@ -254,7 +259,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
259
260 local
261 .run_until(async move {
257 - // Wire up the ACP connection.
262 + // Connect stdin/stdout to the ACP layer.
263 let (conn, io_task) = AgentSideConnection::new(
264 agent,
265 stdout,
@@ -264,7 +269,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
269 },
270 );
271
267 - // Forwarder: drains the mpsc channel and pushes chunks to the client.
272 + // Pull chunks off the channel and push them to the editor as they arrive.
273 tokio::task::spawn_local(async move {
274 while let Some(notification) = notification_rx.recv().await {
275 if let Err(err) = conn.session_notification(notification).await {
@@ -273,7 +278,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
278 }
279 });
280
276 - // Blocks until the editor disconnects.
281 + // Runs until the editor closes the connection.
282 if let Err(err) = io_task.await {
283 log::error!("ACP IO error: {err}");
284 }
@@ -287,13 +292,12 @@ async fn run_acp_server() -> anyhow::Result<()> {
292
293 #[tokio::main]
294 async fn main() -> anyhow::Result<()> {
290 - // Logs always go to stderr (stdout is either the TUI or the ACP wire).
295 + // stderr for logs — stdout belongs to either the TUI or the ACP wire.
296 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
297 .target(env_logger::Target::Stderr)
298 .init();
299
295 - // Shared model cache (macOS App Group) — must run before anything
296 - // touches hf-hub or ChatEngine.
300 + // Set up the shared model cache before anything tries to hit hf-hub.
301 setup::setup_shared_model_cache();
302
303 if std::io::stdin().is_terminal() {