@setoelkahfi / 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
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