@ondeinference / cli / commits / fa13922

Add Downloads screen and local HuggingFace cache scanning

keypair34 committed Apr 22, 2026 at 15:17 UTC fa139224cfcde4479912de5367c2c84c216eb824
4 files changed +737 -8
src/app.rs
+135 -4
@@ -39,6 +39,8 @@ pub enum Screen {
39 Apps,
40 AppDetail,
41 Models,
42 + Downloads,
43 + ModelDetail,
44 }
45
46 #[derive(Debug, Clone, PartialEq)]
@@ -104,13 +106,23 @@ pub enum AuthEvent {
106 AppsLoadFailed(String),
107 AppCreated(OndeApp),
108 AppCreateFailed(String),
107 - AppRenamedOk { app_index: usize, new_name: String },
109 + AppRenamedOk {
110 + app_index: usize,
111 + new_name: String,
112 + },
113 AppRenameFailed(String),
109 - // models
114 + // models (remote catalog for assignment)
115 ModelsLoaded(Vec<OndeModel>),
116 ModelsLoadFailed(String),
112 - ModelAssigned { app_index: usize, model_id: String },
117 + ModelAssigned {
118 + app_index: usize,
119 + model_id: String,
120 + },
121 ModelAssignFailed(String),
122 + // downloads (catalog merged with local HF cache)
123 + DownloadsLoaded(Vec<crate::hf::MergedModel>),
124 + #[allow(dead_code)] // reserved for future explicit error reporting
125 + DownloadsLoadFailed(String),
126 }
127
128 pub struct App {
@@ -130,7 +142,7 @@ pub struct App {
142 pub apps_cursor: usize,
143 pub apps_offset: usize,
144 pub apps_loaded: bool,
133 - // models list
145 + // models list (remote catalog for assignment)
146 pub models: Vec<OndeModel>,
147 pub models_cursor: usize,
148 pub models_offset: usize,
@@ -143,6 +155,11 @@ pub struct App {
155 pub rename_input: String,
156 // which app we're picking a model for
157 pub assigning_for_app_index: Option<usize>,
158 + // local HF cache downloads merged with remote catalog
159 + pub downloads: Vec<crate::hf::MergedModel>,
160 + pub downloads_cursor: usize,
161 + pub downloads_offset: usize,
162 + pub downloads_loaded: bool,
163 }
164
165 impl App {
@@ -170,6 +187,10 @@ impl App {
187 renaming_app: false,
188 rename_input: String::new(),
189 assigning_for_app_index: None,
190 + downloads: Vec::new(),
191 + downloads_cursor: 0,
192 + downloads_offset: 0,
193 + downloads_loaded: false,
194 }
195 }
196
@@ -226,6 +247,10 @@ impl App {
247 self.renaming_app = false;
248 self.rename_input.clear();
249 self.assigning_for_app_index = None;
250 + self.downloads.clear();
251 + self.downloads_loaded = false;
252 + self.downloads_cursor = 0;
253 + self.downloads_offset = 0;
254 self.status = Status::neutral("Signed out.");
255 }
256 AuthEvent::Failed(message) => {
@@ -306,6 +331,26 @@ impl App {
331 self.busy = false;
332 self.status = Status::error(msg);
333 }
334 + // downloads
335 + AuthEvent::DownloadsLoaded(models) => {
336 + self.busy = false;
337 + self.downloads = models;
338 + self.downloads_loaded = true;
339 + if self.downloads.is_empty() {
340 + self.status = Status::neutral("No models downloaded yet.");
341 + } else {
342 + self.status = Status::success(format!(
343 + "{} model{} found.",
344 + self.downloads.len(),
345 + if self.downloads.len() == 1 { "" } else { "s" }
346 + ));
347 + }
348 + }
349 + AuthEvent::DownloadsLoadFailed(msg) => {
350 + self.busy = false;
351 + self.downloads_loaded = true;
352 + self.status = Status::error(msg);
353 + }
354 }
355 }
356 }
@@ -413,6 +458,8 @@ fn handle_key(
458 Screen::Apps => handle_key_apps(app, key, tx),
459 Screen::AppDetail => handle_key_app_detail(app, key, tx),
460 Screen::Models => handle_key_models(app, key, tx),
461 + Screen::Downloads => handle_key_downloads(app, key),
462 + Screen::ModelDetail => handle_key_model_detail(app, key),
463 }
464 }
465
@@ -517,6 +564,14 @@ fn handle_key_apps(
564 app.creating_app = true;
565 app.status = Status::neutral("Type a name and press Enter.");
566 }
567 + (Tab, _) => {
568 + app.screen = Screen::Downloads;
569 + app.downloads_cursor = 0;
570 + app.downloads_offset = 0;
571 + app.downloads_loaded = false;
572 + app.downloads.clear();
573 + trigger_load_downloads(app, tx);
574 + }
575 (Char('s'), KeyModifiers::NONE) | (Char('s'), KeyModifiers::CONTROL) => {
576 sign_out(app, tx);
577 }
@@ -656,6 +711,82 @@ fn clamp_apps_scroll(app: &mut App, max_visible: usize) {
711 }
712 }
713
714 +fn handle_key_downloads(app: &mut App, key: crossterm::event::KeyEvent) {
715 + use KeyCode::*;
716 +
717 + match (key.code, key.modifiers) {
718 + (Up, _) | (Char('k'), KeyModifiers::NONE) => {
719 + app.downloads_cursor = app.downloads_cursor.saturating_sub(1);
720 + clamp_downloads_scroll(app, MAX_VISIBLE);
721 + }
722 + (Down, _) | (Char('j'), KeyModifiers::NONE) => {
723 + if app.downloads_cursor + 1 < app.downloads.len() {
724 + app.downloads_cursor += 1;
725 + }
726 + clamp_downloads_scroll(app, MAX_VISIBLE);
727 + }
728 + (Enter, _) => {
729 + if !app.downloads.is_empty() {
730 + app.screen = Screen::ModelDetail;
731 + app.status = Status::neutral("Model details.");
732 + }
733 + }
734 + (Esc, _) | (Tab, _) => {
735 + app.screen = Screen::Apps;
736 + app.status = app.idle_status();
737 + }
738 + (Char('c'), KeyModifiers::CONTROL) => {
739 + app.should_quit = true;
740 + }
741 + _ => {}
742 + }
743 +}
744 +
745 +fn handle_key_model_detail(app: &mut App, key: crossterm::event::KeyEvent) {
746 + use KeyCode::*;
747 +
748 + match (key.code, key.modifiers) {
749 + (Esc, _) => {
750 + app.screen = Screen::Downloads;
751 + app.status = Status::neutral("Back to models.");
752 + }
753 + (Char('c'), KeyModifiers::CONTROL) => {
754 + app.should_quit = true;
755 + }
756 + _ => {}
757 + }
758 +}
759 +
760 +fn trigger_load_downloads(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
761 + if app.busy {
762 + return;
763 + }
764 + let token = token::load().unwrap_or_default();
765 + app.busy = true;
766 + app.status = Status::neutral("Scanning models…");
767 + tokio::spawn(async move {
768 + // Fetch the remote catalog and scan the local cache concurrently.
769 + let (catalog_result, local_result) = tokio::join!(
770 + crate::gresiq::load_models(&token),
771 + tokio::task::spawn_blocking(crate::hf::list_local_models),
772 + );
773 +
774 + let catalog = catalog_result.unwrap_or_default();
775 + let local = local_result.unwrap_or_default();
776 + let merged = crate::hf::merge_models(&catalog, local);
777 +
778 + let _ = tx.send(AuthEvent::DownloadsLoaded(merged));
779 + });
780 +}
781 +
782 +fn clamp_downloads_scroll(app: &mut App, max_visible: usize) {
783 + if app.downloads_cursor < app.downloads_offset {
784 + app.downloads_offset = app.downloads_cursor;
785 + } else if app.downloads_cursor >= app.downloads_offset + max_visible {
786 + app.downloads_offset = app.downloads_cursor + 1 - max_visible;
787 + }
788 +}
789 +
790 fn clamp_models_scroll(app: &mut App, max_visible: usize) {
791 if app.models_cursor < app.models_offset {
792 app.models_offset = app.models_cursor;
src/hf.rs new
+309
@@ -0,0 +1,309 @@
1 +//! Local HuggingFace model cache scanning and catalog merging.
2 +//!
3 +//! Checks two locations and merges the results:
4 +//! 1. Apple App Group container — `~/Library/Group Containers/
5 +//! group.com.ondeinference.apps/models/hub` (macOS only)
6 +//! 2. Standard HF cache — respects `HF_HUB_CACHE`, `HUGGINGFACE_HUB_CACHE`,
7 +//! `HF_HOME`, then falls back to `~/.cache/huggingface/hub`
8 +//!
9 +//! Models found in both locations are reported once, with `AppGroup` winning.
10 +//! `merge_models` joins the remote Onde Inference catalog with local scan results
11 +//! so the TUI can show all supported models with their download status.
12 +
13 +use smbcloud_gresiq_sdk::OndeModel;
14 +use std::{
15 + collections::{HashMap, HashSet},
16 + path::PathBuf,
17 +};
18 +
19 +// ---------------------------------------------------------------------------
20 +// Public types
21 +// ---------------------------------------------------------------------------
22 +
23 +/// A model directory found in a local HuggingFace cache.
24 +#[derive(Debug, Clone)]
25 +pub struct LocalModel {
26 + /// Full `org/name` identifier, e.g. `bartowski/Qwen2.5-1.5B-Instruct-GGUF`
27 + pub model_id: String,
28 + /// Short name segment shown in the TUI list
29 + pub display_name: String,
30 + /// Human-readable on-disk size (e.g. `941 MB`)
31 + pub size_display: String,
32 + /// Which cache this model came from
33 + pub source: CacheSource,
34 +}
35 +
36 +/// A model from the Onde Inference catalog, enriched with local download status.
37 +#[derive(Debug, Clone)]
38 +pub struct MergedModel {
39 + /// GresIQ internal model ID (used for assignment). `None` for local-only models.
40 + pub catalog_id: Option<String>,
41 + /// HuggingFace repo ID, e.g. `bartowski/Qwen2.5-1.5B-Instruct-GGUF`
42 + pub model_id: String,
43 + /// Human-readable name (from catalog when available, parsed from repo ID otherwise)
44 + pub display_name: String,
45 + /// On-disk size if downloaded, expected size from catalog otherwise
46 + pub size_display: String,
47 + /// Whether the model is present in a local HF cache
48 + pub downloaded: bool,
49 + /// Which cache it was found in (`None` if not downloaded)
50 + pub source: Option<CacheSource>,
51 + /// Full catalog entry — `None` for local-only models not in the catalog
52 + pub catalog_model: Option<OndeModel>,
53 +}
54 +
55 +#[derive(Debug, Clone, PartialEq)]
56 +pub enum CacheSource {
57 + /// Apple App Group — shared across all Onde Inference apps on this Mac
58 + AppGroup,
59 + /// Standard HuggingFace home cache (`~/.cache/huggingface/hub` or `$HF_HOME`)
60 + HfCache,
61 +}
62 +
63 +impl CacheSource {
64 + pub fn label(&self) -> &'static str {
65 + match self {
66 + CacheSource::AppGroup => "App Group",
67 + CacheSource::HfCache => "HF Cache",
68 + }
69 + }
70 +}
71 +
72 +// ---------------------------------------------------------------------------
73 +// Path resolution
74 +// ---------------------------------------------------------------------------
75 +
76 +/// Resolve `~/Library/Group Containers/group.com.ondeinference.apps/models/hub`
77 +/// on macOS. Returns `None` on non-macOS or if the directory does not exist.
78 +#[cfg(target_os = "macos")]
79 +fn app_group_hub() -> Option<PathBuf> {
80 + let p = dirs::home_dir()?
81 + .join("Library")
82 + .join("Group Containers")
83 + .join("group.com.ondeinference.apps")
84 + .join("models")
85 + .join("hub");
86 + p.is_dir().then_some(p)
87 +}
88 +
89 +#[cfg(not(target_os = "macos"))]
90 +fn app_group_hub() -> Option<PathBuf> {
91 + None
92 +}
93 +
94 +/// Resolve the standard HuggingFace hub cache directory.
95 +///
96 +/// Priority order (mirrors `hf-hub` and `onde` crate behaviour):
97 +/// 1. `HF_HUB_CACHE` / `HUGGINGFACE_HUB_CACHE` env vars
98 +/// 2. `$HF_HOME/hub`
99 +/// 3. `~/.cache/huggingface/hub`
100 +fn hf_home_hub() -> Option<PathBuf> {
101 + for var in ["HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"] {
102 + if let Ok(val) = std::env::var(var) {
103 + let p = PathBuf::from(val);
104 + if p.is_dir() {
105 + return Some(p);
106 + }
107 + }
108 + }
109 +
110 + if let Ok(hf_home) = std::env::var("HF_HOME") {
111 + let p = PathBuf::from(hf_home).join("hub");
112 + if p.is_dir() {
113 + return Some(p);
114 + }
115 + }
116 +
117 + dirs::home_dir()
118 + .map(|h| h.join(".cache").join("huggingface").join("hub"))
119 + .filter(|p| p.is_dir())
120 +}
121 +
122 +// ---------------------------------------------------------------------------
123 +// Private helpers
124 +// ---------------------------------------------------------------------------
125 +
126 +fn dir_size(path: &PathBuf) -> u64 {
127 + let mut total = 0u64;
128 + if let Ok(entries) = std::fs::read_dir(path) {
129 + for entry in entries.flatten() {
130 + let p = entry.path();
131 + if p.is_file() {
132 + total += p.metadata().map(|m| m.len()).unwrap_or(0);
133 + } else if p.is_dir() {
134 + total += dir_size(&p);
135 + }
136 + }
137 + }
138 + total
139 +}
140 +
141 +fn format_size(bytes: u64) -> String {
142 + const KB: u64 = 1_024;
143 + const MB: u64 = 1_024 * KB;
144 + const GB: u64 = 1_024 * MB;
145 +
146 + if bytes >= GB {
147 + format!("{:.1} GB", bytes as f64 / GB as f64)
148 + } else if bytes >= MB {
149 + format!("{:.0} MB", bytes as f64 / MB as f64)
150 + } else if bytes >= KB {
151 + format!("{:.0} KB", bytes as f64 / KB as f64)
152 + } else {
153 + format!("{} B", bytes)
154 + }
155 +}
156 +
157 +/// Scan a single hub cache directory and return all `models--*` entries.
158 +fn scan_dir(dir: &PathBuf, source: CacheSource) -> Vec<LocalModel> {
159 + let entries = match std::fs::read_dir(dir) {
160 + Ok(e) => e,
161 + Err(_) => return Vec::new(),
162 + };
163 +
164 + let mut models = Vec::new();
165 +
166 + for entry in entries.flatten() {
167 + let path = entry.path();
168 + if !path.is_dir() {
169 + continue;
170 + }
171 +
172 + let dir_name = match entry.file_name().into_string() {
173 + Ok(n) => n,
174 + Err(_) => continue,
175 + };
176 +
177 + // HF hub stores models as "models--{org}--{name}"
178 + if !dir_name.starts_with("models--") {
179 + continue;
180 + }
181 +
182 + let remainder = &dir_name["models--".len()..];
183 + let model_id = remainder.replace("--", "/");
184 + let display_name = model_id.split('/').last().unwrap_or(&model_id).to_string();
185 +
186 + let size_bytes = dir_size(&path);
187 +
188 + models.push(LocalModel {
189 + model_id,
190 + display_name,
191 + size_display: format_size(size_bytes),
192 + source: source.clone(),
193 + });
194 + }
195 +
196 + models
197 +}
198 +
199 +// ---------------------------------------------------------------------------
200 +// Public API
201 +// ---------------------------------------------------------------------------
202 +
203 +/// Merge the remote Onde Inference model catalog with local HF cache scan results.
204 +///
205 +/// - Every catalog model is included; `downloaded` is set based on the local scan.
206 +/// - Local models not present in the catalog are appended with `catalog_id: None`.
207 +/// - The merge key is `local.model_id == catalog.hf_repo_id`.
208 +/// - Sort order: downloaded first, then alphabetically by display name.
209 +pub fn merge_models(catalog: &[OndeModel], local: Vec<LocalModel>) -> Vec<MergedModel> {
210 + // Index local models by their HF repo ID for O(1) lookup.
211 + let local_by_id: HashMap<&str, &LocalModel> =
212 + local.iter().map(|m| (m.model_id.as_str(), m)).collect();
213 +
214 + let mut merged = Vec::new();
215 + let mut seen: HashSet<String> = HashSet::new();
216 +
217 + // Walk the catalog first so every supported model appears in the list.
218 + for cm in catalog {
219 + let hf_id = cm.hf_repo_id.as_deref().unwrap_or(cm.id.as_str());
220 + seen.insert(hf_id.to_string());
221 +
222 + let local_match = local_by_id.get(hf_id);
223 + let downloaded = local_match.is_some();
224 + let source = local_match.map(|lm| lm.source.clone());
225 +
226 + let size_display = if let Some(lm) = local_match {
227 + lm.size_display.clone()
228 + } else if let Some(bytes) = cm.approx_size_bytes {
229 + format_size(bytes as u64)
230 + } else {
231 + "–".to_string()
232 + };
233 +
234 + let display_name = cm
235 + .name
236 + .as_deref()
237 + .unwrap_or_else(|| hf_id.split('/').last().unwrap_or(hf_id))
238 + .to_string();
239 +
240 + merged.push(MergedModel {
241 + catalog_id: Some(cm.id.clone()),
242 + model_id: hf_id.to_string(),
243 + display_name,
244 + size_display,
245 + downloaded,
246 + source,
247 + catalog_model: Some(cm.clone()),
248 + });
249 + }
250 +
251 + // Append any locally downloaded models that are not in the catalog.
252 + for lm in &local {
253 + if !seen.contains(lm.model_id.as_str()) {
254 + merged.push(MergedModel {
255 + catalog_id: None,
256 + model_id: lm.model_id.clone(),
257 + display_name: lm.display_name.clone(),
258 + size_display: lm.size_display.clone(),
259 + downloaded: true,
260 + source: Some(lm.source.clone()),
261 + catalog_model: None,
262 + });
263 + }
264 + }
265 +
266 + // Downloaded models first, then alphabetically.
267 + merged.sort_by(|a, b| {
268 + b.downloaded.cmp(&a.downloaded).then(
269 + a.display_name
270 + .to_lowercase()
271 + .cmp(&b.display_name.to_lowercase()),
272 + )
273 + });
274 +
275 + merged
276 +}
277 +
278 +/// Scan all local HuggingFace caches and return every downloaded model.
279 +///
280 +/// The App Group container is checked first. If a model appears in both
281 +/// locations it is reported once with [`CacheSource::AppGroup`]. Results are
282 +/// sorted by `model_id` for a stable, predictable order.
283 +///
284 +/// This function is synchronous and does filesystem I/O. Call it from a
285 +/// `tokio::task::spawn_blocking` context to avoid blocking the async runtime.
286 +pub fn list_local_models() -> Vec<LocalModel> {
287 + let mut models = Vec::new();
288 + let mut seen: HashSet<String> = HashSet::new();
289 +
290 + // 1. Apple App Group cache (shared across all Onde Inference apps)
291 + if let Some(path) = app_group_hub() {
292 + for m in scan_dir(&path, CacheSource::AppGroup) {
293 + seen.insert(m.model_id.clone());
294 + models.push(m);
295 + }
296 + }
297 +
298 + // 2. Standard HF cache — skip anything already found in the App Group
299 + if let Some(path) = hf_home_hub() {
300 + for m in scan_dir(&path, CacheSource::HfCache) {
301 + if !seen.contains(&m.model_id) {
302 + models.push(m);
303 + }
304 + }
305 + }
306 +
307 + models.sort_by_key(|m| m.model_id.to_lowercase());
308 + models
309 +}
src/main.rs
+1
@@ -1,5 +1,6 @@
1 mod app;
2 mod gresiq;
3 +mod hf;
4 mod token;
5 mod ui;
6
src/ui.rs
+292 -4
@@ -1,5 +1,6 @@
1 use {
2 crate::app::{App, Focus, Mode, OndeApp, Screen, StatusTone},
3 + crate::hf::CacheSource,
4 ratatui::{
5 Frame,
6 layout::{Alignment, Constraint, Layout, Rect},
@@ -81,6 +82,8 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) {
82 Screen::Apps => render_apps(frame, app, inner),
83 Screen::AppDetail => render_app_detail(frame, app, inner),
84 Screen::Models => render_models(frame, app, inner),
85 + Screen::Downloads => render_downloads(frame, app, inner),
86 + Screen::ModelDetail => render_model_detail(frame, app, inner),
87 }
88 }
89
@@ -281,12 +284,42 @@ fn render_status(frame: &mut Frame, app: &App, area: Rect) {
284
285 // apps screen
286
287 +/// Tab bar shared between the Apps and Downloads screens.
288 +fn render_nav_tabs(frame: &mut Frame, app: &App, area: Rect) {
289 + let cols = Layout::horizontal([
290 + Constraint::Length(9), // [ Apps ]
291 + Constraint::Length(1), // gap
292 + Constraint::Length(11), // [ Models ]
293 + Constraint::Min(0),
294 + ])
295 + .split(area);
296 +
297 + let active = Style::new().fg(C_SURFACE).bg(C_NEON).bold();
298 + let inactive = Style::new().fg(C_MUTED).bg(C_SURFACE_STRONG);
299 +
300 + let apps_style = if app.screen == Screen::Apps {
301 + active
302 + } else {
303 + inactive
304 + };
305 + let models_style = if app.screen == Screen::Downloads {
306 + active
307 + } else {
308 + inactive
309 + };
310 +
311 + frame.render_widget(Paragraph::new(" Apps ").style(apps_style), cols[0]);
312 + frame.render_widget(Paragraph::new(" Models ").style(models_style), cols[2]);
313 +}
314 +
315 fn render_apps(frame: &mut Frame, app: &App, area: Rect) {
316 let email = app.profile.as_ref().map(|p| p.email.as_str()).unwrap_or("");
317
318 let top = Layout::vertical([
319 Constraint::Length(1), // profile badge
320 Constraint::Length(1), // spacer
321 + Constraint::Length(1), // nav tabs
322 + Constraint::Length(1), // spacer
323 Constraint::Length(1), // status
324 Constraint::Length(1), // spacer
325 Constraint::Length(1), // column header
@@ -303,20 +336,21 @@ fn render_apps(frame: &mut Frame, app: &App, area: Rect) {
336 top[0],
337 );
338
306 - render_status(frame, app, top[2]);
339 + render_nav_tabs(frame, app, top[2]);
340 + render_status(frame, app, top[4]);
341
342 frame.render_widget(
343 Paragraph::new(" Name Status Model").style(Style::new().fg(C_MUTED)),
310 - top[4],
344 + top[6],
345 );
346
347 let divider = "─".repeat(area.width as usize);
348 frame.render_widget(
349 Paragraph::new(divider).style(Style::new().fg(C_LINE)),
316 - top[5],
350 + top[7],
351 );
352
319 - let rest = top[6];
353 + let rest = top[8];
354 if app.creating_app {
355 let bottom = Layout::vertical([
356 Constraint::Min(0), // list
@@ -633,6 +667,242 @@ fn render_models(frame: &mut Frame, app: &App, area: Rect) {
667 render_status(frame, app, rows[6]);
668 }
669
670 +// downloads screen
671 +
672 +fn render_downloads(frame: &mut Frame, app: &App, area: Rect) {
673 + let email = app.profile.as_ref().map(|p| p.email.as_str()).unwrap_or("");
674 +
675 + let top = Layout::vertical([
676 + Constraint::Length(1), // profile badge
677 + Constraint::Length(1), // spacer
678 + Constraint::Length(1), // nav tabs
679 + Constraint::Length(1), // spacer
680 + Constraint::Length(1), // status
681 + Constraint::Length(1), // spacer
682 + Constraint::Length(1), // column header
683 + Constraint::Length(1), // divider
684 + Constraint::Min(0), // list + hint
685 + ])
686 + .split(area);
687 +
688 + frame.render_widget(
689 + Paragraph::new(Line::from(vec![
690 + Span::styled("✓ ", Style::new().fg(C_NEON)),
691 + Span::styled(email, Style::new().fg(C_TEXT).bold()),
692 + ])),
693 + top[0],
694 + );
695 +
696 + render_nav_tabs(frame, app, top[2]);
697 + render_status(frame, app, top[4]);
698 +
699 + frame.render_widget(
700 + Paragraph::new(" Model Size Source")
701 + .style(Style::new().fg(C_MUTED)),
702 + top[6],
703 + );
704 +
705 + let divider = "─".repeat(area.width as usize);
706 + frame.render_widget(
707 + Paragraph::new(divider).style(Style::new().fg(C_LINE)),
708 + top[7],
709 + );
710 +
711 + let bottom = Layout::vertical([
712 + Constraint::Min(0), // list
713 + Constraint::Length(1), // hint
714 + ])
715 + .split(top[8]);
716 +
717 + render_downloads_list(frame, app, bottom[0]);
718 +
719 + frame.render_widget(
720 + Paragraph::new("↑↓ · navigate Tab · apps").style(Style::new().fg(C_MUTED)),
721 + bottom[1],
722 + );
723 +}
724 +
725 +fn render_downloads_list(frame: &mut Frame, app: &App, area: Rect) {
726 + if app.downloads.is_empty() {
727 + if app.busy {
728 + frame.render_widget(
729 + Paragraph::new(" Scanning…").style(Style::new().fg(C_MUTED)),
730 + area,
731 + );
732 + } else if app.downloads_loaded {
733 + frame.render_widget(
734 + Paragraph::new(" No models in catalog yet.").style(Style::new().fg(C_MUTED)),
735 + area,
736 + );
737 + }
738 + return;
739 + }
740 +
741 + let max_rows = area.height as usize;
742 + for (list_idx, model) in app
743 + .downloads
744 + .iter()
745 + .enumerate()
746 + .skip(app.downloads_offset)
747 + .take(max_rows)
748 + {
749 + let row_y = area.y + (list_idx - app.downloads_offset) as u16;
750 + let row_area = Rect::new(area.x, row_y, area.width, 1);
751 +
752 + let is_selected = list_idx == app.downloads_cursor;
753 + let prefix = if is_selected { "▶ " } else { " " };
754 +
755 + let (status_label, status_style) = if model.downloaded {
756 + let src = model
757 + .source
758 + .as_ref()
759 + .map(|s| s.label())
760 + .unwrap_or("Downloaded");
761 + let style = match model.source.as_ref() {
762 + Some(CacheSource::AppGroup) => Style::new().fg(C_NEON),
763 + _ => Style::new().fg(C_TEXT),
764 + };
765 + (src, style)
766 + } else {
767 + ("–", Style::new().fg(C_MUTED))
768 + };
769 +
770 + let left = format!(
771 + "{}{:<36} {:<10}",
772 + prefix, model.display_name, model.size_display
773 + );
774 +
775 + let text_style = if is_selected {
776 + Style::new().fg(C_NEON)
777 + } else if model.downloaded {
778 + Style::new().fg(C_TEXT)
779 + } else {
780 + Style::new().fg(C_MUTED)
781 + };
782 +
783 + let line = Line::from(vec![
784 + Span::styled(left, text_style),
785 + Span::styled(status_label, status_style),
786 + ]);
787 +
788 + frame.render_widget(Paragraph::new(line), row_area);
789 + }
790 +}
791 +
792 +// model detail screen
793 +
794 +fn render_model_detail(frame: &mut Frame, app: &App, area: Rect) {
795 + let Some(model) = app.downloads.get(app.downloads_cursor) else {
796 + return;
797 + };
798 +
799 + // Resolve fields from the catalog entry when available.
800 + let hf_repo = model.model_id.as_str();
801 + let format_str = model
802 + .catalog_model
803 + .as_ref()
804 + .and_then(|m| m.format.as_deref())
805 + .unwrap_or("–");
806 + let gguf_file = model
807 + .catalog_model
808 + .as_ref()
809 + .and_then(|m| m.gguf_file.as_deref())
810 + .unwrap_or("–");
811 + let description = model
812 + .catalog_model
813 + .as_ref()
814 + .and_then(|m| m.description.as_deref())
815 + .unwrap_or("–");
816 + let family = model
817 + .catalog_model
818 + .as_ref()
819 + .and_then(|m| m.family.as_deref())
820 + .unwrap_or("–");
821 +
822 + let (dl_label, dl_style) = if model.downloaded {
823 + let src = model.source.as_ref().map(|s| s.label()).unwrap_or("Yes");
824 + (format!("✓ {src}"), Style::new().fg(C_NEON).bold())
825 + } else {
826 + ("– Not downloaded".to_string(), Style::new().fg(C_MUTED))
827 + };
828 +
829 + let rows = Layout::vertical([
830 + Constraint::Length(1), // 0 model name heading
831 + Constraint::Length(1), // 1 spacer
832 + Constraint::Length(1), // 2 status
833 + Constraint::Length(1), // 3 spacer
834 + Constraint::Length(1), // 4 HF Repo label
835 + Constraint::Length(1), // 5 HF Repo value
836 + Constraint::Length(1), // 6 spacer
837 + Constraint::Length(1), // 7 Downloaded label
838 + Constraint::Length(1), // 8 Downloaded value
839 + Constraint::Length(1), // 9 spacer
840 + Constraint::Length(1), // 10 Size label
841 + Constraint::Length(1), // 11 Size value
842 + Constraint::Length(1), // 12 spacer
843 + Constraint::Length(1), // 13 Format label
844 + Constraint::Length(1), // 14 Format value
845 + Constraint::Length(1), // 15 spacer
846 + Constraint::Length(1), // 16 Family label
847 + Constraint::Length(1), // 17 Family value
848 + Constraint::Length(1), // 18 spacer
849 + Constraint::Length(1), // 19 File label
850 + Constraint::Length(1), // 20 File value
851 + Constraint::Length(1), // 21 spacer
852 + Constraint::Length(1), // 22 Catalog ID label
853 + Constraint::Length(1), // 23 Catalog ID value
854 + Constraint::Length(1), // 24 spacer
855 + Constraint::Length(1), // 25 Description label
856 + Constraint::Min(0), // 26 Description value (wraps)
857 + Constraint::Length(1), // 27 hint
858 + ])
859 + .split(area);
860 +
861 + // Heading
862 + frame.render_widget(
863 + Paragraph::new(Line::from(Span::styled(
864 + &model.display_name,
865 + Style::new().fg(C_INK).bold(),
866 + ))),
867 + rows[0],
868 + );
869 +
870 + render_status(frame, app, rows[2]);
871 +
872 + render_detail_field(frame, "HF Repo", hf_repo, rows[4], rows[5]);
873 +
874 + // Downloaded — styled separately
875 + frame.render_widget(
876 + Paragraph::new("Downloaded").style(Style::new().fg(C_MUTED)),
877 + rows[7],
878 + );
879 + frame.render_widget(Paragraph::new(dl_label).style(dl_style), rows[8]);
880 +
881 + render_detail_field(frame, "Size", &model.size_display, rows[10], rows[11]);
882 + render_detail_field(frame, "Format", format_str, rows[13], rows[14]);
883 + render_detail_field(frame, "Family", family, rows[16], rows[17]);
884 + render_detail_field(frame, "File", gguf_file, rows[19], rows[20]);
885 +
886 + let catalog_id_str = model.catalog_id.as_deref().unwrap_or("–");
887 + render_detail_field(frame, "Catalog ID", catalog_id_str, rows[22], rows[23]);
888 +
889 + frame.render_widget(
890 + Paragraph::new("Description").style(Style::new().fg(C_MUTED)),
891 + rows[25],
892 + );
893 + frame.render_widget(
894 + Paragraph::new(description)
895 + .style(Style::new().fg(C_TEXT))
896 + .wrap(Wrap { trim: true }),
897 + rows[26],
898 + );
899 +
900 + frame.render_widget(
901 + Paragraph::new("Esc · back to models").style(Style::new().fg(C_MUTED)),
902 + rows[27],
903 + );
904 +}
905 +
906 fn fmt_bytes(bytes: i64) -> String {
907 if bytes >= 1_000_000_000 {
908 format!("{:.1}GB", bytes as f64 / 1e9)
@@ -672,6 +942,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
942 Span::styled(" · open ", Style::new().fg(C_MUTED)),
943 Span::styled("n", Style::new().fg(C_NEON)),
944 Span::styled(" · new ", Style::new().fg(C_MUTED)),
945 + Span::styled("Tab", Style::new().fg(C_NEON)),
946 + Span::styled(" · models ", Style::new().fg(C_MUTED)),
947 Span::styled("s", Style::new().fg(C_NEON)),
948 Span::styled(" · sign out ", Style::new().fg(C_MUTED)),
949 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
@@ -705,6 +977,22 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
977 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
978 Span::styled(" · quit", Style::new().fg(C_MUTED)),
979 ],
980 + Screen::Downloads => vec![
981 + Span::styled("↑↓", Style::new().fg(C_NEON)),
982 + Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
983 + Span::styled("Enter", Style::new().fg(C_NEON)),
984 + Span::styled(" · detail ", Style::new().fg(C_MUTED)),
985 + Span::styled("Tab", Style::new().fg(C_NEON)),
986 + Span::styled(" · apps ", Style::new().fg(C_MUTED)),
987 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
988 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
989 + ],
990 + Screen::ModelDetail => vec![
991 + Span::styled("Esc", Style::new().fg(C_NEON)),
992 + Span::styled(" · back ", Style::new().fg(C_MUTED)),
993 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
994 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
995 + ],
996 };
997
998 frame.render_widget(