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
index 37108bb..1e92a21 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -20,6 +20,8 @@ use futures::StreamExt;
use onde::inference::{
ChatEngine, GgufModelConfig, SamplingConfig, StreamChunk, ToolDefinition, ToolResult,
};
+
+use crate::setup::DiscoveredModel;
use ratatui::{
Frame,
layout::{Constraint, Layout, Position},
@@ -88,6 +90,11 @@ enum InferenceUpdate {
Error(String),
}
+enum ModelLoadUpdate {
+ Loaded(String),
+ Error(String),
+}
+
// ── App state ─────────────────────────────────────────────────────────────────
struct App {
@@ -99,6 +106,8 @@ struct App {
stream_buf: String,
/// Channel for receiving results from the background inference task.
inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
+ /// Channel for receiving results from a model switch.
+ model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
/// True while waiting for inference to finish.
thinking: bool,
/// Counter driving the thinking spinner animation.
@@ -107,6 +116,8 @@ struct App {
/// Flips every few ticks while streaming to make the cursor blink.
blink_on: bool,
blink_counter: u8,
+ /// True while a model switch is in progress.
+ switching_model: bool,
// ── Loading-phase state ───────────────────────────────────────────────────
/// True while the model is still loading; switches to false on completion.
@@ -120,6 +131,12 @@ struct App {
load_start: Instant,
/// Display name of the model being loaded (shown in the spinner line).
load_model_name: String,
+
+ // ── Model picker state ────────────────────────────────────────────────────
+ show_model_picker: bool,
+ model_picker_index: usize,
+ model_picker_items: Vec<ModelPickerItem>,
+ current_model_name: String,
}
const BANNER_ART: &str = "\
@@ -150,22 +167,29 @@ impl App {
stream_rx: None,
stream_buf: String::new(),
inference_rx: None,
+ model_load_rx: None,
thinking: false,
thinking_tick: 0,
quit: false,
blink_on: true,
blink_counter: 0,
+ switching_model: false,
is_loading: true,
load_tick: 0,
load_error: None,
load_start: Instant::now(),
- load_model_name,
+ load_model_name: load_model_name.clone(),
+ show_model_picker: false,
+ model_picker_index: 0,
+ model_picker_items: build_model_picker_items(),
+ current_model_name: crate::setup::load_selected_model_name()
+ .unwrap_or_else(|| load_model_name.clone()),
}
}
/// True when either streaming tokens or waiting for inference.
fn is_busy(&self) -> bool {
- self.is_streaming() || self.thinking
+ self.is_streaming() || self.thinking || self.switching_model
}
fn is_streaming(&self) -> bool {
@@ -222,6 +246,10 @@ impl App {
self.messages.push(ChatMessage::system(
"In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
));
+ self.messages.push(ChatMessage::system(format!(
+ "Current model: {}",
+ self.current_model_name
+ )));
self.messages
.push(ChatMessage::system("Type /help for commands."));
}
@@ -233,6 +261,44 @@ impl App {
// is_loading stays true so render_loading() keeps rendering.
}
+ fn open_model_picker(&mut self, engine: &ChatEngine) {
+ let current = crate::setup::load_selected_model_name().unwrap_or_else(|| {
+ futures::executor::block_on(engine.info())
+ .model_name
+ .unwrap_or_else(|| self.current_model_name.clone())
+ });
+
+ self.model_picker_items = build_model_picker_items();
+ self.model_picker_index = self
+ .model_picker_items
+ .iter()
+ .position(|item| item.display_name == current)
+ .unwrap_or(0);
+ self.show_model_picker = true;
+ }
+
+ fn close_model_picker(&mut self) {
+ self.show_model_picker = false;
+ }
+
+ fn move_model_picker_up(&mut self) {
+ if self.model_picker_items.is_empty() {
+ return;
+ }
+ if self.model_picker_index == 0 {
+ self.model_picker_index = self.model_picker_items.len().saturating_sub(1);
+ } else {
+ self.model_picker_index -= 1;
+ }
+ }
+
+ fn move_model_picker_down(&mut self) {
+ if self.model_picker_items.is_empty() {
+ return;
+ }
+ self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
+ }
+
/// Total lines the messages area would need (rough estimate for scrolling).
fn total_message_lines(&self, width: u16) -> u16 {
if width == 0 {
@@ -290,41 +356,230 @@ fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
// ── Model table ──────────────────────────────────────────────────────────────
-struct ModelOption {
- /// Name shown in `/models`. Must match `GgufModelConfig::display_name`.
- name: &'static str,
- /// Short blurb shown next to the name, e.g. "~2.7 GB".
- description: &'static str,
- /// True if this model actually handles tool calls.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+enum ModelSource {
+ Onde,
+ HuggingFace,
+ Fallback,
+}
+
+#[derive(Clone)]
+pub(crate) struct ModelPickerItem {
+ pub(crate) display_name: String,
+ description: String,
tool_calling: bool,
- /// Token budget for generation. Qwen 3 needs 4096+ or it outputs nothing.
max_tokens: u64,
- config_fn: fn() -> GgufModelConfig,
+ pub(crate) config: GgufModelConfig,
+ source_label: String,
+ local_path: Option<String>,
+ brand_mark: &'static str,
+ source: ModelSource,
+}
+
+pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
+ let mut items = Vec::new();
+
+ for discovered in crate::setup::discover_local_models() {
+ if let Some(item) = discovered_model_to_picker_item(discovered) {
+ items.push(item);
+ }
+ }
+
+ if items.is_empty() {
+ let config = GgufModelConfig::platform_default();
+ let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)";
+ let max_tokens = if tool_calling { 4096 } else { 512 };
+
+ items.push(ModelPickerItem {
+ display_name: config.display_name.clone(),
+ description: config.approx_memory.clone(),
+ tool_calling,
+ max_tokens,
+ config,
+ source_label: "Platform default".to_string(),
+ local_path: None,
+ brand_mark: "◎",
+ source: ModelSource::Fallback,
+ });
+ }
+
+ items.sort_by(|left, right| {
+ left.source
+ .cmp(&right.source)
+ .then_with(|| left.display_name.cmp(&right.display_name))
+ });
+ items
}
-const SIGIT_MODELS: &[ModelOption] = &[
- ModelOption {
- name: "Qwen 3 4B (Q4_K_M)",
- description: "~2.7 GB",
- tool_calling: true,
- max_tokens: 4096,
- config_fn: GgufModelConfig::qwen3_4b,
- },
- ModelOption {
- name: "Qwen 2.5 Coder 3B (Q4_K_M)",
- description: "~1.93 GB",
- tool_calling: false,
- max_tokens: 512,
- config_fn: GgufModelConfig::qwen25_coder_3b,
- },
- ModelOption {
- name: "Qwen 2.5 Coder 1.5B (Q4_K_M)",
- description: "~941 MB",
- tool_calling: false,
- max_tokens: 512,
- config_fn: GgufModelConfig::qwen25_coder_1_5b,
- },
-];
+fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> {
+ let source_label = if model.from_app_group {
+ "Onde app group".to_string()
+ } else {
+ "Hugging Face cache".to_string()
+ };
+
+ let config = match model.model_id.as_str() {
+ "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
+ "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
+ "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
+ _ => return None,
+ };
+
+ let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF";
+ let max_tokens = if tool_calling { 4096 } else { 512 };
+
+ Some(ModelPickerItem {
+ display_name: config.display_name.clone(),
+ description: config.approx_memory.clone(),
+ tool_calling,
+ max_tokens,
+ config,
+ source_label,
+ local_path: Some(model.gguf_path.display().to_string()),
+ brand_mark: if model.from_app_group { "◉" } else { "○" },
+ source: if model.from_app_group {
+ ModelSource::Onde
+ } else {
+ ModelSource::HuggingFace
+ },
+ })
+}
+
+fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
+ let popup = centered_rect(72, 72, area);
+ let block = Block::default()
+ .title(" Select a model… ")
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(Color::DarkGray));
+
+ let inner = block.inner(popup);
+ frame.render_widget(block, popup);
+
+ let mut lines = Vec::new();
+ let mut last_section: Option<ModelSource> = None;
+
+ for (index, item) in app.model_picker_items.iter().enumerate() {
+ if last_section != Some(item.source) {
+ if last_section.is_some() {
+ lines.push(Line::from(""));
+ }
+
+ let (section_mark, section_name, section_style) = match item.source {
+ ModelSource::Onde => (
+ "◉",
+ "Onde Inference",
+ Style::default()
+ .fg(Color::Green)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ModelSource::HuggingFace => (
+ "○",
+ "Hugging Face cache",
+ Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ModelSource::Fallback => (
+ "◎",
+ "Fallback",
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ ),
+ };
+
+ lines.push(Line::from(vec![
+ Span::styled(format!("{section_mark} "), section_style),
+ Span::styled(section_name, section_style),
+ ]));
+ last_section = Some(item.source);
+ }
+
+ let selected = index == app.model_picker_index;
+ let current = item.display_name == app.current_model_name;
+ let marker = if selected { "› " } else { " " };
+ let tool_badge = if item.tool_calling {
+ " ✓ tool calling"
+ } else {
+ ""
+ };
+ let current_badge = if current { " ← current" } else { "" };
+ let source = format!(" [{} {}]", item.brand_mark, item.source_label);
+
+ let base_style = if selected {
+ Style::default().fg(Color::Black).bg(Color::White)
+ } else {
+ Style::default().fg(Color::White)
+ };
+
+ let source_style = if selected {
+ Style::default().fg(Color::DarkGray).bg(Color::White)
+ } else {
+ match item.source {
+ ModelSource::Onde => Style::default().fg(Color::Green),
+ ModelSource::HuggingFace => Style::default().fg(Color::Cyan),
+ ModelSource::Fallback => Style::default().fg(Color::Yellow),
+ }
+ };
+
+ lines.push(Line::from(vec![
+ Span::styled(
+ format!("{marker}{} {}", item.display_name, item.description),
+ base_style,
+ ),
+ Span::styled(
+ tool_badge.to_string(),
+ if selected {
+ Style::default().fg(Color::Green).bg(Color::White)
+ } else {
+ Style::default().fg(Color::Green)
+ },
+ ),
+ Span::styled(
+ current_badge.to_string(),
+ if selected {
+ Style::default().fg(Color::Blue).bg(Color::White)
+ } else {
+ Style::default().fg(Color::Blue)
+ },
+ ),
+ Span::styled(source, source_style),
+ ]));
+
+ if let Some(path) = &item.local_path {
+ lines.push(Line::from(Span::styled(
+ format!(" {}", path),
+ if selected {
+ Style::default().fg(Color::DarkGray).bg(Color::White)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ },
+ )));
+ }
+ }
+
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
+}
+
+fn centered_rect(
+ percent_x: u16,
+ percent_y: u16,
+ area: ratatui::layout::Rect,
+) -> ratatui::layout::Rect {
+ let vertical = Layout::vertical([
+ Constraint::Percentage((100 - percent_y) / 2),
+ Constraint::Percentage(percent_y),
+ Constraint::Percentage((100 - percent_y) / 2),
+ ])
+ .split(area);
+
+ Layout::horizontal([
+ Constraint::Percentage((100 - percent_x) / 2),
+ Constraint::Percentage(percent_x),
+ Constraint::Percentage((100 - percent_x) / 2),
+ ])
+ .split(vertical[1])[1]
+}
// ── Slash commands ────────────────────────────────────────────────────────────
@@ -332,7 +587,7 @@ enum SlashCommand {
Help,
Clear,
Status,
- /// `/models` lists models. `/models N` switches to model N (1-based).
+ /// `/models` opens the model picker. `/models N` still works as a shortcut.
Models(Option<usize>),
Exit,
Unknown(String),
@@ -387,6 +642,10 @@ fn render(frame: &mut Frame, app: &mut App) {
render_messages(frame, app, zones[1]);
render_input(frame, app, zones[2]);
render_footer(frame, app, zones[3]);
+
+ if app.show_model_picker {
+ render_model_picker(frame, app, area);
+ }
}
}
@@ -655,10 +914,17 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
}
fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
- let hints: &[(&str, &str)] = if app.is_busy() {
+ let hints: &[(&str, &str)] = if app.show_model_picker {
+ &[("↑↓", "select"), ("Enter", "load"), ("Esc", "close")]
+ } else if app.is_busy() {
&[("Ctrl+C", "cancel")]
} else {
- &[("Enter", "send"), ("/help", "commands"), ("Ctrl+C", "quit")]
+ &[
+ ("Enter", "send"),
+ ("/help", "commands"),
+ ("/models", "models"),
+ ("Ctrl+C", "quit"),
+ ]
};
let mut spans: Vec<Span<'_>> = Vec::new();
@@ -692,6 +958,27 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
return None;
}
+ if app.show_model_picker {
+ match key.code {
+ KeyCode::Esc => {
+ app.close_model_picker();
+ return None;
+ }
+ KeyCode::Up => {
+ app.move_model_picker_up();
+ return None;
+ }
+ KeyCode::Down => {
+ app.move_model_picker_down();
+ return None;
+ }
+ KeyCode::Enter => {
+ return Some("/models __pick__".to_string());
+ }
+ _ => return None,
+ }
+ }
+
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.quit = true;
@@ -761,7 +1048,7 @@ async fn exec_slash<B: ratatui::backend::Backend>(
SlashCommand::Help => {
app.messages.push(ChatMessage::system(
"/help — show this message\n\
- /models — list available models\n\
+ /models — open the model picker\n\
/models N — switch to model N\n\
/clear — wipe conversation history\n\
/status — show engine status\n\
@@ -787,71 +1074,47 @@ async fn exec_slash<B: ratatui::backend::Backend>(
}
SlashCommand::Models(selection) => match selection {
None => {
- // Show the model list.
- let info = engine.info().await;
- let current = info.model_name.clone().unwrap_or_default();
-
- let mut text = String::from("Available models — type /models <n> to switch:\n");
- for (i, model) in SIGIT_MODELS.iter().enumerate() {
- let current_marker = if current == model.name {
- " ← current"
- } else {
- ""
- };
- let tool_badge = if model.tool_calling {
- " ✓ tool calling"
- } else {
- ""
- };
- text.push_str(&format!(
- "\n {} {} {}{}{}",
- i + 1,
- model.name,
- model.description,
- tool_badge,
- current_marker,
- ));
- }
- app.messages.push(ChatMessage::system(text));
+ app.open_model_picker(engine);
}
Some(n) => {
let idx = n.saturating_sub(1);
- match SIGIT_MODELS.get(idx) {
+ match app.model_picker_items.get(idx).cloned() {
None => {
app.messages.push(ChatMessage::system(format!(
"error: no model #{n} — type /models to see the list."
)));
}
Some(model) => {
- // Redraw first — "Loading…" has to be on screen before
- // we block for however long the load takes.
- app.messages
- .push(ChatMessage::system(format!("Loading {}…", model.name)));
+ app.close_model_picker();
+ app.messages.push(ChatMessage::system(format!(
+ "Loading {}…",
+ model.display_name
+ )));
terminal.draw(|frame| render(frame, app)).ok();
- engine.unload_model().await;
+ let (tx, rx) = mpsc::channel(1);
+ app.model_load_rx = Some(rx);
+ app.switching_model = true;
- let config = (model.config_fn)();
let sampling = SamplingConfig {
max_tokens: Some(model.max_tokens),
..SamplingConfig::default()
};
- match engine.load_gguf_model(config, None, Some(sampling)).await {
+ engine.unload_model().await;
+
+ let update = match engine
+ .load_gguf_model(model.config.clone(), None, Some(sampling))
+ .await
+ {
Ok(_) => {
engine.clear_history().await;
- app.messages.push(ChatMessage::system(format!(
- "✓ Switched to {}",
- model.name
- )));
+ ModelLoadUpdate::Loaded(model.display_name.clone())
}
- Err(err) => {
- app.messages.push(ChatMessage::system(format!(
- "error loading {}: {err}",
- model.name
- )));
- }
- }
+ Err(err) => ModelLoadUpdate::Error(err.to_string()),
+ };
+
+ let _ = tx.send(update).await;
}
}
}
@@ -984,10 +1247,9 @@ pub async fn run_with<B: ratatui::backend::Backend>(
terminal: &mut ratatui::Terminal<B>,
engine: Arc<ChatEngine>,
load_rx: std_mpsc::Receiver<Result<(), String>>,
+ load_model_name: String,
) -> Result<()> {
- let config = GgufModelConfig::platform_default();
- let model_name = config.display_name.clone();
- event_loop(terminal, engine, load_rx, model_name).await
+ event_loop(terminal, engine, load_rx, load_model_name).await
}
async fn event_loop<B: ratatui::backend::Backend>(
@@ -1018,6 +1280,39 @@ async fn event_loop<B: ratatui::backend::Backend>(
// redraw every iteration
terminal.draw(|frame| render(frame, &mut app))?;
+ if let Some(rx) = app.model_load_rx.as_mut() {
+ match rx.try_recv() {
+ Ok(ModelLoadUpdate::Loaded(model_name)) => {
+ app.switching_model = false;
+ app.model_load_rx = None;
+ app.current_model_name = model_name.clone();
+ if let Err(error) = crate::setup::save_selected_model_name(&model_name) {
+ app.messages.push(ChatMessage::system(format!(
+ "warning: switched to {} but could not save the selection: {}",
+ model_name, error
+ )));
+ } else {
+ app.messages
+ .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
+ }
+ }
+ Ok(ModelLoadUpdate::Error(error)) => {
+ app.switching_model = false;
+ app.model_load_rx = None;
+ app.messages
+ .push(ChatMessage::system(format!("error loading model: {error}")));
+ }
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
+ Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
+ app.switching_model = false;
+ app.model_load_rx = None;
+ app.messages.push(ChatMessage::system(
+ "error loading model: loader task disconnected".to_string(),
+ ));
+ }
+ }
+ }
+
if app.quit {
break;
}
src/main.rs
+38
-4
index a100e09..7af4858 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -446,7 +446,22 @@ fn init_logging(is_tty: bool) {
#[cfg(unix)]
async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
let engine = Arc::new(ChatEngine::new());
- let config = GgufModelConfig::platform_default();
+
+ let startup_selection = setup::startup_model_selection();
+ let startup_model_name = startup_selection
+ .as_ref()
+ .map(|selection| selection.display_name.clone())
+ .unwrap_or_else(|| GgufModelConfig::platform_default().display_name);
+
+ let config = startup_selection
+ .as_ref()
+ .and_then(|selection| {
+ chat::build_model_picker_items()
+ .into_iter()
+ .find(|item| item.display_name == selection.display_name)
+ .map(|item| item.config)
+ })
+ .unwrap_or_else(GgufModelConfig::platform_default);
// std::sync::mpsc — the loader runs on a dedicated OS thread, completely
// 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) ->
// The TUI runs here on the main tokio runtime. It polls load_rx via
// try_recv() on every tick — non-blocking, zero contention.
- let chat_result = chat::run_with(&mut terminal, engine, load_rx).await;
+ let chat_result = chat::run_with(&mut terminal, engine, load_rx, startup_model_name).await;
// Restore the terminal before exiting.
// Use the separate cleanup fd — the backend's writer is private.
@@ -500,12 +515,31 @@ async fn run_acp_server() -> anyhow::Result<()> {
log::info!("loading model (this may take a minute on first run)...");
let engine = Arc::new(ChatEngine::new());
- let config = GgufModelConfig::qwen3_4b();
+
+ let startup_selection = setup::startup_model_selection();
+ let config = startup_selection
+ .as_ref()
+ .and_then(|selection| {
+ chat::build_model_picker_items()
+ .into_iter()
+ .find(|item| item.display_name == selection.display_name)
+ .map(|item| item.config)
+ })
+ .unwrap_or_else(GgufModelConfig::qwen3_4b);
+
+ let max_tokens = if config.display_name == "Qwen 3 4B (Q4_K_M)" {
+ 4096
+ } else {
+ 512
+ };
+
let sampling = SamplingConfig {
- max_tokens: Some(4096),
+ max_tokens: Some(max_tokens),
..SamplingConfig::default()
};
+ log::info!("ACP startup model: {}", config.display_name);
+
engine
.load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), Some(sampling))
.await
src/setup.rs
+270
-2
index 032d11c..da53cdf 100644
--- a/src/setup.rs
+++ b/src/setup.rs
@@ -1,4 +1,5 @@
-//! Shared model cache setup.
+//! Shared model cache setup, local model discovery, and lightweight local
+//! preferences.
//!
//! On macOS, siGit desktop and other Onde apps keep their HuggingFace models
//! in a shared App Group container at:
@@ -9,10 +10,17 @@
//! whatever the desktop app already downloaded (and vice versa). On Linux
//! and Windows the default `~/.cache/huggingface/` path is used.
//!
+//! It also exposes helpers for finding locally available models. Discovery
+//! checks the Onde app group first on macOS, then falls back to the normal
+//! Hugging Face cache layout.
+//!
+//! The selected model name is persisted in a small local preferences file so
+//! the interactive UI can restore the last choice on the next launch.
+//!
//! Call this before anything touches `ChatEngine` or `hf-hub` — they read
//! the env vars once at init and never check again.
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
/// App Group ID shared across all Onde apps (siGit, Rumi, GT8, …).
#[cfg(target_os = "macos")]
@@ -58,6 +66,266 @@ pub fn setup_shared_model_cache() {
}
}
+/// Preference key used to remember the last selected model.
+const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt";
+
+/// Minimal startup model selection info used before the full UI is running.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StartupModelSelection {
+ /// Human-friendly model name shown in the loading UI.
+ pub display_name: String,
+ /// The saved model name if one was found.
+ pub selected_name: Option<String>,
+}
+
+/// A locally discovered GGUF model candidate.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DiscoveredModel {
+ /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`.
+ pub model_id: String,
+ /// GGUF filename inside the snapshot.
+ pub gguf_file: String,
+ /// Human-friendly label shown in model pickers.
+ pub display_name: String,
+ /// Absolute path to the snapshot directory that contains the GGUF file.
+ pub snapshot_path: PathBuf,
+ /// Absolute path to the GGUF file itself.
+ pub gguf_path: PathBuf,
+ /// True when the model came from the Onde app group cache.
+ pub from_app_group: bool,
+}
+
+/// Return all locally discovered GGUF models.
+///
+/// Search order:
+/// 1. Onde app group cache on macOS
+/// 2. Standard Hugging Face cache
+pub fn discover_local_models() -> Vec<DiscoveredModel> {
+ let mut models = Vec::new();
+
+ if let Some(app_group_models) = app_group_models_root() {
+ collect_models_from_cache_root(&app_group_models, true, &mut models);
+ }
+
+ if let Some(hf_cache) = hf_cache_root() {
+ collect_models_from_cache_root(&hf_cache, false, &mut models);
+ }
+
+ models.sort_by(|left, right| {
+ left.display_name
+ .to_lowercase()
+ .cmp(&right.display_name.to_lowercase())
+ .then_with(|| left.model_id.cmp(&right.model_id))
+ .then_with(|| left.gguf_file.cmp(&right.gguf_file))
+ });
+
+ models.dedup_by(|left, right| left.gguf_path == right.gguf_path);
+ models
+}
+
+fn collect_models_from_cache_root(
+ cache_root: &Path,
+ from_app_group: bool,
+ models: &mut Vec<DiscoveredModel>,
+) {
+ let entries = match std::fs::read_dir(cache_root) {
+ Ok(entries) => entries,
+ Err(error) => {
+ log::debug!(
+ "Skipping unreadable model cache root {}: {error}",
+ cache_root.display()
+ );
+ return;
+ }
+ };
+
+ for entry in entries.flatten() {
+ let repo_dir = entry.path();
+ if !repo_dir.is_dir() {
+ continue;
+ }
+
+ let dir_name = match entry.file_name().to_str() {
+ Some(name) => name.to_string(),
+ None => continue,
+ };
+
+ if !dir_name.starts_with("models--") {
+ continue;
+ }
+
+ let model_id = dir_name["models--".len()..].replace("--", "/");
+ let snapshots_dir = repo_dir.join("snapshots");
+ let snapshots = match std::fs::read_dir(&snapshots_dir) {
+ Ok(entries) => entries,
+ Err(_) => continue,
+ };
+
+ for snapshot in snapshots.flatten() {
+ let snapshot_path = snapshot.path();
+ if !snapshot_path.is_dir() {
+ continue;
+ }
+
+ let files = match std::fs::read_dir(&snapshot_path) {
+ Ok(entries) => entries,
+ Err(_) => continue,
+ };
+
+ for file in files.flatten() {
+ let file_path = file.path();
+ if !file_path.is_file() {
+ continue;
+ }
+
+ let extension = file_path
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .unwrap_or_default();
+
+ if !extension.eq_ignore_ascii_case("gguf") {
+ continue;
+ }
+
+ let gguf_file = match file.file_name().to_str() {
+ Some(name) => name.to_string(),
+ None => continue,
+ };
+
+ models.push(DiscoveredModel {
+ display_name: display_name_for_model(&model_id, &gguf_file),
+ model_id: model_id.clone(),
+ gguf_file,
+ snapshot_path: snapshot_path.clone(),
+ gguf_path: file_path,
+ from_app_group,
+ });
+ }
+ }
+ }
+}
+
+fn display_name_for_model(model_id: &str, gguf_file: &str) -> String {
+ let repo_name = model_id
+ .rsplit('/')
+ .next()
+ .unwrap_or(model_id)
+ .replace('_', " ");
+
+ let file_name = gguf_file.strip_suffix(".gguf").unwrap_or(gguf_file);
+
+ if file_name.contains(&repo_name.replace(' ', "_")) || file_name.contains(&repo_name) {
+ repo_name
+ } else {
+ format!("{repo_name} — {file_name}")
+ }
+}
+
+fn app_group_models_root() -> Option<PathBuf> {
+ resolve_shared_container().map(|dir| dir.join("models").join("hub"))
+}
+
+fn hf_cache_root() -> Option<PathBuf> {
+ if let Ok(cache) = std::env::var("HF_HUB_CACHE") {
+ let path = PathBuf::from(cache);
+ if path.is_dir() {
+ return Some(path);
+ }
+ }
+
+ if let Ok(home) = std::env::var("HF_HOME") {
+ let path = PathBuf::from(home).join("hub");
+ if path.is_dir() {
+ return Some(path);
+ }
+ }
+
+ let home = std::env::var("HOME").ok()?;
+ let path = PathBuf::from(home)
+ .join(".cache")
+ .join("huggingface")
+ .join("hub");
+
+ path.is_dir().then_some(path)
+}
+
+pub fn load_selected_model_name() -> Option<String> {
+ let path = selected_model_file_path()?;
+ let contents = std::fs::read_to_string(path).ok()?;
+ let trimmed = contents.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
+}
+
+/// Pick the model name siGit should try to load at startup.
+///
+/// Order:
+/// 1. saved selection, if it still exists locally
+/// 2. first discovered local model (Onde app group first, then HF cache)
+/// 3. no selection
+///
+/// If there is no saved selection but a local model is discovered, persist that
+/// fallback choice so ACP mode and the interactive TUI converge on the same
+/// startup model on the next launch too.
+pub fn startup_model_selection() -> Option<StartupModelSelection> {
+ let discovered = discover_local_models();
+
+ if let Some(saved_name) = load_selected_model_name() {
+ if discovered
+ .iter()
+ .any(|model| model.display_name == saved_name)
+ {
+ return Some(StartupModelSelection {
+ display_name: saved_name.clone(),
+ selected_name: Some(saved_name),
+ });
+ }
+ }
+
+ discovered.into_iter().next().map(|model| {
+ let _ = save_selected_model_name(&model.display_name);
+ StartupModelSelection {
+ display_name: model.display_name.clone(),
+ selected_name: Some(model.display_name),
+ }
+ })
+}
+
+pub fn save_selected_model_name(model_name: &str) -> Result<(), String> {
+ let path = selected_model_file_path()
+ .ok_or_else(|| "Could not determine where to store the selected model.".to_string())?;
+
+ if let Some(parent) = path.parent()
+ && !parent.exists()
+ {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| format!("Could not create preferences directory: {error}"))?;
+ }
+
+ std::fs::write(&path, model_name)
+ .map_err(|error| format!("Could not save selected model: {error}"))
+}
+
+fn selected_model_file_path() -> Option<PathBuf> {
+ if let Some(shared_dir) = resolve_shared_container() {
+ return Some(shared_dir.join(SELECTED_MODEL_FILE_NAME));
+ }
+
+ if let Ok(home) = std::env::var("HF_HOME") {
+ let path = PathBuf::from(home);
+ if path.is_dir() || path.parent().is_some() {
+ return Some(path.join(SELECTED_MODEL_FILE_NAME));
+ }
+ }
+
+ let home = std::env::var("HOME").ok()?;
+ Some(
+ PathBuf::from(home)
+ .join(".cache")
+ .join("sigit")
+ .join(SELECTED_MODEL_FILE_NAME),
+ )
+}
+
/// Look for the App Group container on disk. macOS creates it the first time
/// a signed app in the group accesses it, so it only exists if the user has
/// launched siGit desktop (or another Onde app) at least once. A plain CLI