Add local model discovery and selection persistence
- Discover GGUF models in the Onde app group and Hugging Face cache - Add model picker UI state and navigation - Persist last selected model for startup restoration - Use discovered/persisted model as default on launch
paydii committed
Apr 24, 2026 at 16:54 UTC
733c70101a65c1cd86815d251bdde921895b581c
3 files changed
+688
-91
src/chat.rs
+380
-85
@@ -20,6 +20,8 @@ use futures::StreamExt;
20
use onde::inference::{
21
ChatEngine, GgufModelConfig, SamplingConfig, StreamChunk, ToolDefinition, ToolResult,
22
};
23
+
24
+use crate::setup::DiscoveredModel;
25
use ratatui::{
26
Frame,
27
layout::{Constraint, Layout, Position},
@@ -88,6 +90,11 @@ enum InferenceUpdate {
90
Error(String),
91
}
92
93
+enum ModelLoadUpdate {
94
+ Loaded(String),
95
+ Error(String),
96
+}
97
+
98
// ── App state ─────────────────────────────────────────────────────────────────
99
100
struct App {
@@ -99,6 +106,8 @@ struct App {
106
stream_buf: String,
107
/// Channel for receiving results from the background inference task.
108
inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
109
+ /// Channel for receiving results from a model switch.
110
+ model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
111
/// True while waiting for inference to finish.
112
thinking: bool,
113
/// Counter driving the thinking spinner animation.
@@ -107,6 +116,8 @@ struct App {
116
/// Flips every few ticks while streaming to make the cursor blink.
117
blink_on: bool,
118
blink_counter: u8,
119
+ /// True while a model switch is in progress.
120
+ switching_model: bool,
121
122
// ── Loading-phase state ───────────────────────────────────────────────────
123
/// True while the model is still loading; switches to false on completion.
@@ -120,6 +131,12 @@ struct App {
131
load_start: Instant,
132
/// Display name of the model being loaded (shown in the spinner line).
133
load_model_name: String,
134
+
135
+ // ── Model picker state ────────────────────────────────────────────────────
136
+ show_model_picker: bool,
137
+ model_picker_index: usize,
138
+ model_picker_items: Vec<ModelPickerItem>,
139
+ current_model_name: String,
140
}
141
142
const BANNER_ART: &str = "\
@@ -150,22 +167,29 @@ impl App {
167
stream_rx: None,
168
stream_buf: String::new(),
169
inference_rx: None,
170
+ model_load_rx: None,
171
thinking: false,
172
thinking_tick: 0,
173
quit: false,
174
blink_on: true,
175
blink_counter: 0,
176
+ switching_model: false,
177
is_loading: true,
178
load_tick: 0,
179
load_error: None,
180
load_start: Instant::now(),
162
- load_model_name,
181
+ load_model_name: load_model_name.clone(),
182
+ show_model_picker: false,
183
+ model_picker_index: 0,
184
+ model_picker_items: build_model_picker_items(),
185
+ current_model_name: crate::setup::load_selected_model_name()
186
+ .unwrap_or_else(|| load_model_name.clone()),
187
}
188
}
189
190
/// True when either streaming tokens or waiting for inference.
191
fn is_busy(&self) -> bool {
168
- self.is_streaming() || self.thinking
192
+ self.is_streaming() || self.thinking || self.switching_model
193
}
194
195
fn is_streaming(&self) -> bool {
@@ -222,6 +246,10 @@ impl App {
246
self.messages.push(ChatMessage::system(
247
"In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
248
));
249
+ self.messages.push(ChatMessage::system(format!(
250
+ "Current model: {}",
251
+ self.current_model_name
252
+ )));
253
self.messages
254
.push(ChatMessage::system("Type /help for commands."));
255
}
@@ -233,6 +261,44 @@ impl App {
261
// is_loading stays true so render_loading() keeps rendering.
262
}
263
264
+ fn open_model_picker(&mut self, engine: &ChatEngine) {
265
+ let current = crate::setup::load_selected_model_name().unwrap_or_else(|| {
266
+ futures::executor::block_on(engine.info())
267
+ .model_name
268
+ .unwrap_or_else(|| self.current_model_name.clone())
269
+ });
270
+
271
+ self.model_picker_items = build_model_picker_items();
272
+ self.model_picker_index = self
273
+ .model_picker_items
274
+ .iter()
275
+ .position(|item| item.display_name == current)
276
+ .unwrap_or(0);
277
+ self.show_model_picker = true;
278
+ }
279
+
280
+ fn close_model_picker(&mut self) {
281
+ self.show_model_picker = false;
282
+ }
283
+
284
+ fn move_model_picker_up(&mut self) {
285
+ if self.model_picker_items.is_empty() {
286
+ return;
287
+ }
288
+ if self.model_picker_index == 0 {
289
+ self.model_picker_index = self.model_picker_items.len().saturating_sub(1);
290
+ } else {
291
+ self.model_picker_index -= 1;
292
+ }
293
+ }
294
+
295
+ fn move_model_picker_down(&mut self) {
296
+ if self.model_picker_items.is_empty() {
297
+ return;
298
+ }
299
+ self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
300
+ }
301
+
302
/// Total lines the messages area would need (rough estimate for scrolling).
303
fn total_message_lines(&self, width: u16) -> u16 {
304
if width == 0 {
@@ -290,41 +356,230 @@ fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
356
357
// ── Model table ──────────────────────────────────────────────────────────────
358
293
-struct ModelOption {
294
- /// Name shown in `/models`. Must match `GgufModelConfig::display_name`.
295
- name: &'static str,
296
- /// Short blurb shown next to the name, e.g. "~2.7 GB".
297
- description: &'static str,
298
- /// True if this model actually handles tool calls.
359
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
360
+enum ModelSource {
361
+ Onde,
362
+ HuggingFace,
363
+ Fallback,
364
+}
365
+
366
+#[derive(Clone)]
367
+pub(crate) struct ModelPickerItem {
368
+ pub(crate) display_name: String,
369
+ description: String,
370
tool_calling: bool,
300
- /// Token budget for generation. Qwen 3 needs 4096+ or it outputs nothing.
371
max_tokens: u64,
302
- config_fn: fn() -> GgufModelConfig,
372
+ pub(crate) config: GgufModelConfig,
373
+ source_label: String,
374
+ local_path: Option<String>,
375
+ brand_mark: &'static str,
376
+ source: ModelSource,
377
+}
378
+
379
+pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
380
+ let mut items = Vec::new();
381
+
382
+ for discovered in crate::setup::discover_local_models() {
383
+ if let Some(item) = discovered_model_to_picker_item(discovered) {
384
+ items.push(item);
385
+ }
386
+ }
387
+
388
+ if items.is_empty() {
389
+ let config = GgufModelConfig::platform_default();
390
+ let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)";
391
+ let max_tokens = if tool_calling { 4096 } else { 512 };
392
+
393
+ items.push(ModelPickerItem {
394
+ display_name: config.display_name.clone(),
395
+ description: config.approx_memory.clone(),
396
+ tool_calling,
397
+ max_tokens,
398
+ config,
399
+ source_label: "Platform default".to_string(),
400
+ local_path: None,
401
+ brand_mark: "◎",
402
+ source: ModelSource::Fallback,
403
+ });
404
+ }
405
+
406
+ items.sort_by(|left, right| {
407
+ left.source
408
+ .cmp(&right.source)
409
+ .then_with(|| left.display_name.cmp(&right.display_name))
410
+ });
411
+ items
412
}
413
305
-const SIGIT_MODELS: &[ModelOption] = &[
306
- ModelOption {
307
- name: "Qwen 3 4B (Q4_K_M)",
308
- description: "~2.7 GB",
309
- tool_calling: true,
310
- max_tokens: 4096,
311
- config_fn: GgufModelConfig::qwen3_4b,
312
- },
313
- ModelOption {
314
- name: "Qwen 2.5 Coder 3B (Q4_K_M)",
315
- description: "~1.93 GB",
316
- tool_calling: false,
317
- max_tokens: 512,
318
- config_fn: GgufModelConfig::qwen25_coder_3b,
319
- },
320
- ModelOption {
321
- name: "Qwen 2.5 Coder 1.5B (Q4_K_M)",
322
- description: "~941 MB",
323
- tool_calling: false,
324
- max_tokens: 512,
325
- config_fn: GgufModelConfig::qwen25_coder_1_5b,
326
- },
327
-];
414
+fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
415
+ let source_label = if model.from_app_group {
416
+ "Onde app group".to_string()
417
+ } else {
418
+ "Hugging Face cache".to_string()
419
+ };
420
+
421
+ let config = match model.model_id.as_str() {
422
+ "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
423
+ "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
424
+ "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
425
+ _ => return None,
426
+ };
427
+
428
+ let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF";
429
+ let max_tokens = if tool_calling { 4096 } else { 512 };
430
+
431
+ Some(ModelPickerItem {
432
+ display_name: config.display_name.clone(),
433
+ description: config.approx_memory.clone(),
434
+ tool_calling,
435
+ max_tokens,
436
+ config,
437
+ source_label,
438
+ local_path: Some(model.gguf_path.display().to_string()),
439
+ brand_mark: if model.from_app_group { "◉" } else { "○" },
440
+ source: if model.from_app_group {
441
+ ModelSource::Onde
442
+ } else {
443
+ ModelSource::HuggingFace
444
+ },
445
+ })
446
+}
447
+
448
+fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
449
+ let popup = centered_rect(72, 72, area);
450
+ let block = Block::default()
451
+ .title(" Select a model… ")
452
+ .borders(Borders::ALL)
453
+ .border_style(Style::default().fg(Color::DarkGray));
454
+
455
+ let inner = block.inner(popup);
456
+ frame.render_widget(block, popup);
457
+
458
+ let mut lines = Vec::new();
459
+ let mut last_section: Option<ModelSource> = None;
460
+
461
+ for (index, item) in app.model_picker_items.iter().enumerate() {
462
+ if last_section != Some(item.source) {
463
+ if last_section.is_some() {
464
+ lines.push(Line::from(""));
465
+ }
466
+
467
+ let (section_mark, section_name, section_style) = match item.source {
468
+ ModelSource::Onde => (
469
+ "◉",
470
+ "Onde Inference",
471
+ Style::default()
472
+ .fg(Color::Green)
473
+ .add_modifier(Modifier::BOLD),
474
+ ),
475
+ ModelSource::HuggingFace => (
476
+ "○",
477
+ "Hugging Face cache",
478
+ Style::default()
479
+ .fg(Color::Cyan)
480
+ .add_modifier(Modifier::BOLD),
481
+ ),
482
+ ModelSource::Fallback => (
483
+ "◎",
484
+ "Fallback",
485
+ Style::default()
486
+ .fg(Color::Yellow)
487
+ .add_modifier(Modifier::BOLD),
488
+ ),
489
+ };
490
+
491
+ lines.push(Line::from(vec![
492
+ Span::styled(format!("{section_mark} "), section_style),
493
+ Span::styled(section_name, section_style),
494
+ ]));
495
+ last_section = Some(item.source);
496
+ }
497
+
498
+ let selected = index == app.model_picker_index;
499
+ let current = item.display_name == app.current_model_name;
500
+ let marker = if selected { "› " } else { " " };
501
+ let tool_badge = if item.tool_calling {
502
+ " ✓ tool calling"
503
+ } else {
504
+ ""
505
+ };
506
+ let current_badge = if current { " ← current" } else { "" };
507
+ let source = format!(" [{} {}]", item.brand_mark, item.source_label);
508
+
509
+ let base_style = if selected {
510
+ Style::default().fg(Color::Black).bg(Color::White)
511
+ } else {
512
+ Style::default().fg(Color::White)
513
+ };
514
+
515
+ let source_style = if selected {
516
+ Style::default().fg(Color::DarkGray).bg(Color::White)
517
+ } else {
518
+ match item.source {
519
+ ModelSource::Onde => Style::default().fg(Color::Green),
520
+ ModelSource::HuggingFace => Style::default().fg(Color::Cyan),
521
+ ModelSource::Fallback => Style::default().fg(Color::Yellow),
522
+ }
523
+ };
524
+
525
+ lines.push(Line::from(vec![
526
+ Span::styled(
527
+ format!("{marker}{} {}", item.display_name, item.description),
528
+ base_style,
529
+ ),
530
+ Span::styled(
531
+ tool_badge.to_string(),
532
+ if selected {
533
+ Style::default().fg(Color::Green).bg(Color::White)
534
+ } else {
535
+ Style::default().fg(Color::Green)
536
+ },
537
+ ),
538
+ Span::styled(
539
+ current_badge.to_string(),
540
+ if selected {
541
+ Style::default().fg(Color::Blue).bg(Color::White)
542
+ } else {
543
+ Style::default().fg(Color::Blue)
544
+ },
545
+ ),
546
+ Span::styled(source, source_style),
547
+ ]));
548
+
549
+ if let Some(path) = &item.local_path {
550
+ lines.push(Line::from(Span::styled(
551
+ format!(" {}", path),
552
+ if selected {
553
+ Style::default().fg(Color::DarkGray).bg(Color::White)
554
+ } else {
555
+ Style::default().fg(Color::DarkGray)
556
+ },
557
+ )));
558
+ }
559
+ }
560
+
561
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
562
+}
563
+
564
+fn centered_rect(
565
+ percent_x: u16,
566
+ percent_y: u16,
567
+ area: ratatui::layout::Rect,
568
+) -> ratatui::layout::Rect {
569
+ let vertical = Layout::vertical([
570
+ Constraint::Percentage((100 - percent_y) / 2),
571
+ Constraint::Percentage(percent_y),
572
+ Constraint::Percentage((100 - percent_y) / 2),
573
+ ])
574
+ .split(area);
575
+
576
+ Layout::horizontal([
577
+ Constraint::Percentage((100 - percent_x) / 2),
578
+ Constraint::Percentage(percent_x),
579
+ Constraint::Percentage((100 - percent_x) / 2),
580
+ ])
581
+ .split(vertical[1])[1]
582
+}
583
584
// ── Slash commands ────────────────────────────────────────────────────────────
585
@@ -332,7 +587,7 @@ enum SlashCommand {
587
Help,
588
Clear,
589
Status,
335
- /// `/models` lists models. `/models N` switches to model N (1-based).
590
+ /// `/models` opens the model picker. `/models N` still works as a shortcut.
591
Models(Option<usize>),
592
Exit,
593
Unknown(String),
@@ -387,6 +642,10 @@ fn render(frame: &mut Frame, app: &mut App) {
642
render_messages(frame, app, zones[1]);
643
render_input(frame, app, zones[2]);
644
render_footer(frame, app, zones[3]);
645
+
646
+ if app.show_model_picker {
647
+ render_model_picker(frame, app, area);
648
+ }
649
}
650
}
651
@@ -655,10 +914,17 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
914
}
915
916
fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
658
- let hints: &[(&str, &str)] = if app.is_busy() {
917
+ let hints: &[(&str, &str)] = if app.show_model_picker {
918
+ &[("↑↓", "select"), ("Enter", "load"), ("Esc", "close")]
919
+ } else if app.is_busy() {
920
&[("Ctrl+C", "cancel")]
921
} else {
661
- &[("Enter", "send"), ("/help", "commands"), ("Ctrl+C", "quit")]
922
+ &[
923
+ ("Enter", "send"),
924
+ ("/help", "commands"),
925
+ ("/models", "models"),
926
+ ("Ctrl+C", "quit"),
927
+ ]
928
};
929
930
let mut spans: Vec<Span<'_>> = Vec::new();
@@ -692,6 +958,27 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
958
return None;
959
}
960
961
+ if app.show_model_picker {
962
+ match key.code {
963
+ KeyCode::Esc => {
964
+ app.close_model_picker();
965
+ return None;
966
+ }
967
+ KeyCode::Up => {
968
+ app.move_model_picker_up();
969
+ return None;
970
+ }
971
+ KeyCode::Down => {
972
+ app.move_model_picker_down();
973
+ return None;
974
+ }
975
+ KeyCode::Enter => {
976
+ return Some("/models __pick__".to_string());
977
+ }
978
+ _ => return None,
979
+ }
980
+ }
981
+
982
match key.code {
983
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
984
app.quit = true;
@@ -761,7 +1048,7 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1048
SlashCommand::Help => {
1049
app.messages.push(ChatMessage::system(
1050
"/help — show this message\n\
764
- /models — list available models\n\
1051
+ /models — open the model picker\n\
1052
/models N — switch to model N\n\
1053
/clear — wipe conversation history\n\
1054
/status — show engine status\n\
@@ -787,71 +1074,47 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1074
}
1075
SlashCommand::Models(selection) => match selection {
1076
None => {
790
- // Show the model list.
791
- let info = engine.info().await;
792
- let current = info.model_name.clone().unwrap_or_default();
793
-
794
- let mut text = String::from("Available models — type /models <n> to switch:\n");
795
- for (i, model) in SIGIT_MODELS.iter().enumerate() {
796
- let current_marker = if current == model.name {
797
- " ← current"
798
- } else {
799
- ""
800
- };
801
- let tool_badge = if model.tool_calling {
802
- " ✓ tool calling"
803
- } else {
804
- ""
805
- };
806
- text.push_str(&format!(
807
- "\n {} {} {}{}{}",
808
- i + 1,
809
- model.name,
810
- model.description,
811
- tool_badge,
812
- current_marker,
813
- ));
814
- }
815
- app.messages.push(ChatMessage::system(text));
1077
+ app.open_model_picker(engine);
1078
}
1079
Some(n) => {
1080
let idx = n.saturating_sub(1);
819
- match SIGIT_MODELS.get(idx) {
1081
+ match app.model_picker_items.get(idx).cloned() {
1082
None => {
1083
app.messages.push(ChatMessage::system(format!(
1084
"error: no model #{n} — type /models to see the list."
1085
)));
1086
}
1087
Some(model) => {
826
- // Redraw first — "Loading…" has to be on screen before
827
- // we block for however long the load takes.
828
- app.messages
829
- .push(ChatMessage::system(format!("Loading {}…", model.name)));
1088
+ app.close_model_picker();
1089
+ app.messages.push(ChatMessage::system(format!(
1090
+ "Loading {}…",
1091
+ model.display_name
1092
+ )));
1093
terminal.draw(|frame| render(frame, app)).ok();
1094
832
- engine.unload_model().await;
1095
+ let (tx, rx) = mpsc::channel(1);
1096
+ app.model_load_rx = Some(rx);
1097
+ app.switching_model = true;
1098
834
- let config = (model.config_fn)();
1099
let sampling = SamplingConfig {
1100
max_tokens: Some(model.max_tokens),
1101
..SamplingConfig::default()
1102
};
1103
840
- match engine.load_gguf_model(config, None, Some(sampling)).await {
1104
+ engine.unload_model().await;
1105
+
1106
+ let update = match engine
1107
+ .load_gguf_model(model.config.clone(), None, Some(sampling))
1108
+ .await
1109
+ {
1110
Ok(_) => {
1111
engine.clear_history().await;
843
- app.messages.push(ChatMessage::system(format!(
844
- "✓ Switched to {}",
845
- model.name
846
- )));
1112
+ ModelLoadUpdate::Loaded(model.display_name.clone())
1113
}
848
- Err(err) => {
849
- app.messages.push(ChatMessage::system(format!(
850
- "error loading {}: {err}",
851
- model.name
852
- )));
853
- }
854
- }
1114
+ Err(err) => ModelLoadUpdate::Error(err.to_string()),
1115
+ };
1116
+
1117
+ let _ = tx.send(update).await;
1118
}
1119
}
1120
}
@@ -984,10 +1247,9 @@ pub async fn run_with<B: ratatui::backend::Backend>(
1247
terminal: &mut ratatui::Terminal<B>,
1248
engine: Arc<ChatEngine>,
1249
load_rx: std_mpsc::Receiver<Result<(), String>>,
1250
+ load_model_name: String,
1251
) -> Result<()> {
988
- let config = GgufModelConfig::platform_default();
989
- let model_name = config.display_name.clone();
990
- event_loop(terminal, engine, load_rx, model_name).await
1252
+ event_loop(terminal, engine, load_rx, load_model_name).await
1253
}
1254
1255
async fn event_loop<B: ratatui::backend::Backend>(
@@ -1018,6 +1280,39 @@ async fn event_loop<B: ratatui::backend::Backend>(
1280
// redraw every iteration
1281
terminal.draw(|frame| render(frame, &mut app))?;
1282
1283
+ if let Some(rx) = app.model_load_rx.as_mut() {
1284
+ match rx.try_recv() {
1285
+ Ok(ModelLoadUpdate::Loaded(model_name)) => {
1286
+ app.switching_model = false;
1287
+ app.model_load_rx = None;
1288
+ app.current_model_name = model_name.clone();
1289
+ if let Err(error) = crate::setup::save_selected_model_name(&model_name) {
1290
+ app.messages.push(ChatMessage::system(format!(
1291
+ "warning: switched to {} but could not save the selection: {}",
1292
+ model_name, error
1293
+ )));
1294
+ } else {
1295
+ app.messages
1296
+ .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
1297
+ }
1298
+ }
1299
+ Ok(ModelLoadUpdate::Error(error)) => {
1300
+ app.switching_model = false;
1301
+ app.model_load_rx = None;
1302
+ app.messages
1303
+ .push(ChatMessage::system(format!("error loading model: {error}")));
1304
+ }
1305
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
1306
+ Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1307
+ app.switching_model = false;
1308
+ app.model_load_rx = None;
1309
+ app.messages.push(ChatMessage::system(
1310
+ "error loading model: loader task disconnected".to_string(),
1311
+ ));
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
if app.quit {
1317
break;
1318
}
src/main.rs
+38
-4
@@ -446,7 +446,22 @@ fn init_logging(is_tty: bool) {
446
#[cfg(unix)]
447
async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
448
let engine = Arc::new(ChatEngine::new());
449
- let config = GgufModelConfig::platform_default();
449
+
450
+ let startup_selection = setup::startup_model_selection();
451
+ let startup_model_name = startup_selection
452
+ .as_ref()
453
+ .map(|selection| selection.display_name.clone())
454
+ .unwrap_or_else(|| GgufModelConfig::platform_default().display_name);
455
+
456
+ let config = startup_selection
457
+ .as_ref()
458
+ .and_then(|selection| {
459
+ chat::build_model_picker_items()
460
+ .into_iter()
461
+ .find(|item| item.display_name == selection.display_name)
462
+ .map(|item| item.config)
463
+ })
464
+ .unwrap_or_else(GgufModelConfig::platform_default);
465
466
// std::sync::mpsc — the loader runs on a dedicated OS thread, completely
467
// decoupled from the tokio runtime so it can't starve the TUI draw loop.
@@ -469,7 +484,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
484
485
// The TUI runs here on the main tokio runtime. It polls load_rx via
486
// try_recv() on every tick — non-blocking, zero contention.
472
- let chat_result = chat::run_with(&mut terminal, engine, load_rx).await;
487
+ let chat_result = chat::run_with(&mut terminal, engine, load_rx, startup_model_name).await;
488
489
// Restore the terminal before exiting.
490
// Use the separate cleanup fd — the backend's writer is private.
@@ -500,12 +515,31 @@ async fn run_acp_server() -> anyhow::Result<()> {
515
log::info!("loading model (this may take a minute on first run)...");
516
517
let engine = Arc::new(ChatEngine::new());
503
- let config = GgufModelConfig::qwen3_4b();
518
+
519
+ let startup_selection = setup::startup_model_selection();
520
+ let config = startup_selection
521
+ .as_ref()
522
+ .and_then(|selection| {
523
+ chat::build_model_picker_items()
524
+ .into_iter()
525
+ .find(|item| item.display_name == selection.display_name)
526
+ .map(|item| item.config)
527
+ })
528
+ .unwrap_or_else(GgufModelConfig::qwen3_4b);
529
+
530
+ let max_tokens = if config.display_name == "Qwen 3 4B (Q4_K_M)" {
531
+ 4096
532
+ } else {
533
+ 512
534
+ };
535
+
536
let sampling = SamplingConfig {
505
- max_tokens: Some(4096),
537
+ max_tokens: Some(max_tokens),
538
..SamplingConfig::default()
539
};
540
541
+ log::info!("ACP startup model: {}", config.display_name);
542
+
543
engine
544
.load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), Some(sampling))
545
.await
src/setup.rs
+270
-2
@@ -1,4 +1,5 @@
1
-//! Shared model cache setup.
1
+//! Shared model cache setup, local model discovery, and lightweight local
2
+//! preferences.
3
//!
4
//! On macOS, siGit desktop and other Onde apps keep their HuggingFace models
5
//! in a shared App Group container at:
@@ -9,10 +10,17 @@
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.
22
15
-use std::path::PathBuf;
23
+use std::path::{Path, PathBuf};
24
25
/// App Group ID shared across all Onde apps (siGit, Rumi, GT8, …).
26
#[cfg(target_os = "macos")]
@@ -58,6 +66,266 @@ pub fn setup_shared_model_cache() {
66
}
67
}
68
69
+/// Preference key used to remember the last selected model.
70
+const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt";
71
+
72
+/// Minimal startup model selection info used before the full UI is running.
73
+#[derive(Debug, Clone, PartialEq, Eq)]
74
+pub struct StartupModelSelection {
75
+ /// Human-friendly model name shown in the loading UI.
76
+ pub display_name: String,
77
+ /// The saved model name if one was found.
78
+ pub selected_name: Option<String>,
79
+}
80
+
81
+/// A locally discovered GGUF model candidate.
82
+#[derive(Debug, Clone, PartialEq, Eq)]
83
+pub struct DiscoveredModel {
84
+ /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`.
85
+ pub model_id: String,
86
+ /// GGUF filename inside the snapshot.
87
+ pub gguf_file: String,
88
+ /// Human-friendly label shown in model pickers.
89
+ pub display_name: String,
90
+ /// Absolute path to the snapshot directory that contains the GGUF file.
91
+ pub snapshot_path: PathBuf,
92
+ /// Absolute path to the GGUF file itself.
93
+ pub gguf_path: PathBuf,
94
+ /// True when the model came from the Onde app group cache.
95
+ pub from_app_group: bool,
96
+}
97
+
98
+/// Return all locally discovered GGUF models.
99
+///
100
+/// Search order:
101
+/// 1. Onde app group cache on macOS
102
+/// 2. Standard Hugging Face cache
103
+pub fn discover_local_models() -> Vec<DiscoveredModel> {
104
+ let mut models = Vec::new();
105
+
106
+ if let Some(app_group_models) = app_group_models_root() {
107
+ collect_models_from_cache_root(&app_group_models, true, &mut models);
108
+ }
109
+
110
+ if let Some(hf_cache) = hf_cache_root() {
111
+ collect_models_from_cache_root(&hf_cache, false, &mut models);
112
+ }
113
+
114
+ models.sort_by(|left, right| {
115
+ left.display_name
116
+ .to_lowercase()
117
+ .cmp(&right.display_name.to_lowercase())
118
+ .then_with(|| left.model_id.cmp(&right.model_id))
119
+ .then_with(|| left.gguf_file.cmp(&right.gguf_file))
120
+ });
121
+
122
+ models.dedup_by(|left, right| left.gguf_path == right.gguf_path);
123
+ models
124
+}
125
+
126
+fn collect_models_from_cache_root(
127
+ cache_root: &Path,
128
+ from_app_group: bool,
129
+ models: &mut Vec<DiscoveredModel>,
130
+) {
131
+ let entries = match std::fs::read_dir(cache_root) {
132
+ Ok(entries) => entries,
133
+ Err(error) => {
134
+ log::debug!(
135
+ "Skipping unreadable model cache root {}: {error}",
136
+ cache_root.display()
137
+ );
138
+ return;
139
+ }
140
+ };
141
+
142
+ for entry in entries.flatten() {
143
+ let repo_dir = entry.path();
144
+ if !repo_dir.is_dir() {
145
+ continue;
146
+ }
147
+
148
+ let dir_name = match entry.file_name().to_str() {
149
+ Some(name) => name.to_string(),
150
+ None => continue,
151
+ };
152
+
153
+ if !dir_name.starts_with("models--") {
154
+ continue;
155
+ }
156
+
157
+ let model_id = dir_name["models--".len()..].replace("--", "/");
158
+ let snapshots_dir = repo_dir.join("snapshots");
159
+ let snapshots = match std::fs::read_dir(&snapshots_dir) {
160
+ Ok(entries) => entries,
161
+ Err(_) => continue,
162
+ };
163
+
164
+ for snapshot in snapshots.flatten() {
165
+ let snapshot_path = snapshot.path();
166
+ if !snapshot_path.is_dir() {
167
+ continue;
168
+ }
169
+
170
+ let files = match std::fs::read_dir(&snapshot_path) {
171
+ Ok(entries) => entries,
172
+ Err(_) => continue,
173
+ };
174
+
175
+ for file in files.flatten() {
176
+ let file_path = file.path();
177
+ if !file_path.is_file() {
178
+ continue;
179
+ }
180
+
181
+ let extension = file_path
182
+ .extension()
183
+ .and_then(|ext| ext.to_str())
184
+ .unwrap_or_default();
185
+
186
+ if !extension.eq_ignore_ascii_case("gguf") {
187
+ continue;
188
+ }
189
+
190
+ let gguf_file = match file.file_name().to_str() {
191
+ Some(name) => name.to_string(),
192
+ None => continue,
193
+ };
194
+
195
+ models.push(DiscoveredModel {
196
+ display_name: display_name_for_model(&model_id, &gguf_file),
197
+ model_id: model_id.clone(),
198
+ gguf_file,
199
+ snapshot_path: snapshot_path.clone(),
200
+ gguf_path: file_path,
201
+ from_app_group,
202
+ });
203
+ }
204
+ }
205
+ }
206
+}
207
+
208
+fn display_name_for_model(model_id: &str, gguf_file: &str) -> String {
209
+ let repo_name = model_id
210
+ .rsplit('/')
211
+ .next()
212
+ .unwrap_or(model_id)
213
+ .replace('_', " ");
214
+
215
+ let file_name = gguf_file.strip_suffix(".gguf").unwrap_or(gguf_file);
216
+
217
+ if file_name.contains(&repo_name.replace(' ', "_")) || file_name.contains(&repo_name) {
218
+ repo_name
219
+ } else {
220
+ format!("{repo_name} — {file_name}")
221
+ }
222
+}
223
+
224
+fn app_group_models_root() -> Option<PathBuf> {
225
+ resolve_shared_container().map(|dir| dir.join("models").join("hub"))
226
+}
227
+
228
+fn hf_cache_root() -> Option<PathBuf> {
229
+ if let Ok(cache) = std::env::var("HF_HUB_CACHE") {
230
+ let path = PathBuf::from(cache);
231
+ if path.is_dir() {
232
+ return Some(path);
233
+ }
234
+ }
235
+
236
+ if let Ok(home) = std::env::var("HF_HOME") {
237
+ let path = PathBuf::from(home).join("hub");
238
+ if path.is_dir() {
239
+ return Some(path);
240
+ }
241
+ }
242
+
243
+ let home = std::env::var("HOME").ok()?;
244
+ let path = PathBuf::from(home)
245
+ .join(".cache")
246
+ .join("huggingface")
247
+ .join("hub");
248
+
249
+ path.is_dir().then_some(path)
250
+}
251
+
252
+pub fn load_selected_model_name() -> Option<String> {
253
+ let path = selected_model_file_path()?;
254
+ let contents = std::fs::read_to_string(path).ok()?;
255
+ let trimmed = contents.trim();
256
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
257
+}
258
+
259
+/// Pick the model name siGit should try to load at startup.
260
+///
261
+/// Order:
262
+/// 1. saved selection, if it still exists locally
263
+/// 2. first discovered local model (Onde app group first, then HF cache)
264
+/// 3. no selection
265
+///
266
+/// If there is no saved selection but a local model is discovered, persist that
267
+/// fallback choice so ACP mode and the interactive TUI converge on the same
268
+/// startup model on the next launch too.
269
+pub fn startup_model_selection() -> Option<StartupModelSelection> {
270
+ let discovered = discover_local_models();
271
+
272
+ if let Some(saved_name) = load_selected_model_name() {
273
+ if discovered
274
+ .iter()
275
+ .any(|model| model.display_name == saved_name)
276
+ {
277
+ return Some(StartupModelSelection {
278
+ display_name: saved_name.clone(),
279
+ selected_name: Some(saved_name),
280
+ });
281
+ }
282
+ }
283
+
284
+ discovered.into_iter().next().map(|model| {
285
+ let _ = save_selected_model_name(&model.display_name);
286
+ StartupModelSelection {
287
+ display_name: model.display_name.clone(),
288
+ selected_name: Some(model.display_name),
289
+ }
290
+ })
291
+}
292
+
293
+pub fn save_selected_model_name(model_name: &str) -> Result<(), String> {
294
+ let path = selected_model_file_path()
295
+ .ok_or_else(|| "Could not determine where to store the selected model.".to_string())?;
296
+
297
+ if let Some(parent) = path.parent()
298
+ && !parent.exists()
299
+ {
300
+ std::fs::create_dir_all(parent)
301
+ .map_err(|error| format!("Could not create preferences directory: {error}"))?;
302
+ }
303
+
304
+ std::fs::write(&path, model_name)
305
+ .map_err(|error| format!("Could not save selected model: {error}"))
306
+}
307
+
308
+fn selected_model_file_path() -> Option<PathBuf> {
309
+ if let Some(shared_dir) = resolve_shared_container() {
310
+ return Some(shared_dir.join(SELECTED_MODEL_FILE_NAME));
311
+ }
312
+
313
+ if let Ok(home) = std::env::var("HF_HOME") {
314
+ let path = PathBuf::from(home);
315
+ if path.is_dir() || path.parent().is_some() {
316
+ return Some(path.join(SELECTED_MODEL_FILE_NAME));
317
+ }
318
+ }
319
+
320
+ let home = std::env::var("HOME").ok()?;
321
+ Some(
322
+ PathBuf::from(home)
323
+ .join(".cache")
324
+ .join("sigit")
325
+ .join(SELECTED_MODEL_FILE_NAME),
326
+ )
327
+}
328
+
329
/// Look for the App Group container on disk. macOS creates it the first time
330
/// a signed app in the group accesses it, so it only exists if the user has
331
/// launched siGit desktop (or another Onde app) at least once. A plain CLI