@hej / sigit / commits / 733c701

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