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() {