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(