@ondeinference / cli / commits / 164e83f

Add HuggingFace Hub search and model download functionality

keypair34 committed Apr 22, 2026 at 17:08 UTC 164e83f55627cd8f1b53ad593122fd9d89316d84
7 files changed +707 -16
Cargo.lock
+30
@@ -2072,6 +2072,7 @@ dependencies = [
2072 "libc",
2073 "log",
2074 "ratatui",
2075 + "reqwest",
2076 "serde",
2077 "serde_json",
2078 "smbcloud-auth-sdk",
@@ -2542,6 +2543,7 @@ dependencies = [
2543 "base64 0.22.1",
2544 "bytes",
2545 "futures-core",
2546 + "futures-util",
2547 "http",
2548 "http-body",
2549 "http-body-util",
@@ -2562,12 +2564,14 @@ dependencies = [
2564 "sync_wrapper",
2565 "tokio",
2566 "tokio-rustls",
2567 + "tokio-util",
2568 "tower",
2569 "tower-http",
2570 "tower-service",
2571 "url",
2572 "wasm-bindgen",
2573 "wasm-bindgen-futures",
2574 + "wasm-streams",
2575 "web-sys",
2576 "webpki-roots",
2577 ]
@@ -3340,6 +3344,19 @@ dependencies = [
3344 "tokio",
3345 ]
3346
3347 +[[package]]
3348 +name = "tokio-util"
3349 +version = "0.7.18"
3350 +source = "registry+https://github.com/rust-lang/crates.io-index"
3351 +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
3352 +dependencies = [
3353 + "bytes",
3354 + "futures-core",
3355 + "futures-sink",
3356 + "pin-project-lite",
3357 + "tokio",
3358 +]
3359 +
3360 [[package]]
3361 name = "tower"
3362 version = "0.5.3"
@@ -3762,6 +3779,19 @@ dependencies = [
3779 "wasmparser",
3780 ]
3781
3782 +[[package]]
3783 +name = "wasm-streams"
3784 +version = "0.4.2"
3785 +source = "registry+https://github.com/rust-lang/crates.io-index"
3786 +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
3787 +dependencies = [
3788 + "futures-util",
3789 + "js-sys",
3790 + "wasm-bindgen",
3791 + "wasm-bindgen-futures",
3792 + "web-sys",
3793 +]
3794 +
3795 [[package]]
3796 name = "wasmparser"
3797 version = "0.244.0"
Cargo.toml
+1
@@ -27,6 +27,7 @@ futures = "0.3"
27 log = "0.4"
28 ratatui = "0.29"
29 libc = "0.2"
30 +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
31 tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
32 smbcloud-auth-sdk = { path = "../smbcloud-cli/crates/smbcloud-auth-sdk" }
33 smbcloud-model = { path = "../smbcloud-cli/crates/smbcloud-model" }
src/app.rs
+213 -3
@@ -133,6 +133,13 @@ pub enum AuthEvent {
133 DownloadsLoaded(Vec<crate::hf::MergedModel>),
134 #[allow(dead_code)] // reserved for future explicit error reporting
135 DownloadsLoadFailed(String),
136 + // HF Hub search
137 + HfSearchResults(Vec<crate::hf_search::HfModelInfo>),
138 + HfSearchFailed(String),
139 + // Model download
140 + ModelDownloadProgress(crate::hf_search::DownloadProgress),
141 + ModelDownloadComplete(String), // model_id
142 + ModelDownloadFailed(String),
143 // fine-tune
144 FineTuneProgress(crate::finetune::FineTuneProgress),
145 }
@@ -172,6 +179,15 @@ pub struct App {
179 pub downloads_cursor: usize,
180 pub downloads_offset: usize,
181 pub downloads_loaded: bool,
182 + // HF Hub search
183 + pub hf_search_active: bool,
184 + pub hf_search_query: String,
185 + pub hf_search_results: Vec<crate::hf_search::HfModelInfo>,
186 + pub hf_search_cursor: usize,
187 + pub hf_search_loading: bool,
188 + // Model download (runs in background, does not set app.busy)
189 + pub downloading: bool,
190 + pub download_progress: Option<crate::hf_search::DownloadProgress>,
191 // fine-tune
192 pub finetune_model_id: String,
193 pub finetune_model_dir: String,
@@ -213,6 +229,13 @@ impl App {
229 downloads_cursor: 0,
230 downloads_offset: 0,
231 downloads_loaded: false,
232 + hf_search_active: false,
233 + hf_search_query: String::new(),
234 + hf_search_results: Vec::new(),
235 + hf_search_cursor: 0,
236 + hf_search_loading: false,
237 + downloading: false,
238 + download_progress: None,
239 finetune_model_id: String::new(),
240 finetune_model_dir: String::new(),
241 finetune_data_path: "~/.onde/finetune/train.jsonl".to_string(),
@@ -243,7 +266,17 @@ impl App {
266 }
267
268 pub fn apply(&mut self, event: AuthEvent) {
246 - self.busy = false;
269 + // Download and search events run in the background without the busy flag.
270 + match &event {
271 + AuthEvent::ModelDownloadProgress(_)
272 + | AuthEvent::ModelDownloadComplete(_)
273 + | AuthEvent::ModelDownloadFailed(_)
274 + | AuthEvent::HfSearchResults(_)
275 + | AuthEvent::HfSearchFailed(_) => {}
276 + _ => {
277 + self.busy = false;
278 + }
279 + }
280 match event {
281 // auth
282 AuthEvent::SignupOk(message) => {
@@ -392,6 +425,38 @@ impl App {
425 }
426 self.finetune_progress = Some(progress);
427 }
428 + AuthEvent::HfSearchResults(results) => {
429 + self.hf_search_loading = false;
430 + self.hf_search_cursor = 0;
431 + self.hf_search_results = results;
432 + self.status = if self.hf_search_results.is_empty() {
433 + Status::neutral("No models found.")
434 + } else {
435 + Status::neutral(format!("{} models found.", self.hf_search_results.len()))
436 + };
437 + }
438 + AuthEvent::HfSearchFailed(msg) => {
439 + self.hf_search_loading = false;
440 + self.status = Status::error(msg);
441 + }
442 + AuthEvent::ModelDownloadProgress(progress) => {
443 + self.download_progress = Some(progress);
444 + }
445 + AuthEvent::ModelDownloadComplete(model_id) => {
446 + self.downloading = false;
447 + self.download_progress = None;
448 + self.hf_search_active = false;
449 + self.hf_search_query.clear();
450 + self.hf_search_results.clear();
451 + self.hf_search_cursor = 0;
452 + self.downloads_loaded = false; // triggers list reload
453 + self.status = Status::success(format!("{model_id} downloaded."));
454 + }
455 + AuthEvent::ModelDownloadFailed(msg) => {
456 + self.downloading = false;
457 + self.download_progress = None;
458 + self.status = Status::error(msg);
459 + }
460 }
461 }
462 }
@@ -443,6 +508,15 @@ pub async fn run(terminal: &mut DefaultTerminal) -> Result<()> {
508 {
509 trigger_load_apps(&mut app, tx.clone());
510 }
511 + // Reload downloads list after a successful download.
512 + if app.profile.is_some()
513 + && app.screen == Screen::Downloads
514 + && !app.downloads_loaded
515 + && !app.busy
516 + && !app.downloading
517 + {
518 + trigger_load_downloads(&mut app, tx.clone());
519 + }
520 }
521 }
522 }
@@ -499,7 +573,7 @@ fn handle_key(
573 Screen::Apps => handle_key_apps(app, key, tx),
574 Screen::AppDetail => handle_key_app_detail(app, key, tx),
575 Screen::Models => handle_key_models(app, key, tx),
502 - Screen::Downloads => handle_key_downloads(app, key),
576 + Screen::Downloads => handle_key_downloads(app, key, tx),
577 Screen::ModelDetail => handle_key_model_detail(app, key),
578 Screen::FineTune => handle_key_finetune(app, key, tx),
579 }
@@ -753,9 +827,76 @@ fn clamp_apps_scroll(app: &mut App, max_visible: usize) {
827 }
828 }
829
756 -fn handle_key_downloads(app: &mut App, key: crossterm::event::KeyEvent) {
830 +fn handle_key_downloads(
831 + app: &mut App,
832 + key: crossterm::event::KeyEvent,
833 + tx: mpsc::UnboundedSender<AuthEvent>,
834 +) {
835 use KeyCode::*;
836
837 + // While a download is running, only allow quit.
838 + if app.downloading {
839 + if matches!(
840 + (key.code, key.modifiers),
841 + (Char('c'), KeyModifiers::CONTROL)
842 + ) {
843 + app.should_quit = true;
844 + }
845 + return;
846 + }
847 +
848 + if app.hf_search_active {
849 + match (key.code, key.modifiers) {
850 + (Esc, _) => {
851 + app.hf_search_active = false;
852 + app.hf_search_query.clear();
853 + app.hf_search_results.clear();
854 + app.hf_search_cursor = 0;
855 + app.hf_search_loading = false;
856 + app.status = Status::neutral("Search cancelled.");
857 + }
858 + (Enter, _) => {
859 + if !app.hf_search_results.is_empty() {
860 + trigger_download(app, tx);
861 + } else if !app.hf_search_query.trim().is_empty() && !app.hf_search_loading {
862 + trigger_hf_search(app, tx);
863 + }
864 + }
865 + (Up, _) | (Char('k'), KeyModifiers::NONE) => {
866 + app.hf_search_cursor = app.hf_search_cursor.saturating_sub(1);
867 + }
868 + (Down, _) | (Char('j'), KeyModifiers::NONE) => {
869 + if app.hf_search_cursor + 1 < app.hf_search_results.len() {
870 + app.hf_search_cursor += 1;
871 + }
872 + }
873 + (Backspace, _) => {
874 + if !app.hf_search_results.is_empty() {
875 + // Editing query clears old results.
876 + app.hf_search_results.clear();
877 + app.hf_search_cursor = 0;
878 + }
879 + if app.hf_search_query.pop().is_none() {
880 + app.hf_search_active = false;
881 + }
882 + }
883 + (Char('c'), KeyModifiers::CONTROL) => {
884 + app.should_quit = true;
885 + }
886 + (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
887 + // Editing query clears old results.
888 + if !app.hf_search_results.is_empty() {
889 + app.hf_search_results.clear();
890 + app.hf_search_cursor = 0;
891 + }
892 + app.hf_search_query.push(c);
893 + }
894 + _ => {}
895 + }
896 + return;
897 + }
898 +
899 + // Normal Downloads screen navigation.
900 match (key.code, key.modifiers) {
901 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
902 app.downloads_cursor = app.downloads_cursor.saturating_sub(1);
@@ -773,6 +914,14 @@ fn handle_key_downloads(app: &mut App, key: crossterm::event::KeyEvent) {
914 app.status = Status::neutral("Model details.");
915 }
916 }
917 + (Char('/'), KeyModifiers::NONE) => {
918 + app.hf_search_active = true;
919 + app.hf_search_query.clear();
920 + app.hf_search_results.clear();
921 + app.hf_search_cursor = 0;
922 + app.hf_search_loading = false;
923 + app.status = Status::neutral("Search HuggingFace Hub.");
924 + }
925 (Esc, _) | (Tab, _) => {
926 app.screen = Screen::Apps;
927 app.status = app.idle_status();
@@ -977,6 +1126,67 @@ fn handle_key_finetune(
1126 }
1127 }
1128
1129 +fn trigger_hf_search(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1130 + let query = app.hf_search_query.trim().to_string();
1131 + if query.is_empty() {
1132 + return;
1133 + }
1134 + app.hf_search_loading = true;
1135 + app.hf_search_results.clear();
1136 + app.hf_search_cursor = 0;
1137 + app.status = Status::neutral(format!("Searching for \"{query}\"…"));
1138 + tokio::spawn(async move {
1139 + match crate::hf_search::search_hf(&query).await {
1140 + Ok(results) => {
1141 + let _ = tx.send(AuthEvent::HfSearchResults(results));
1142 + }
1143 + Err(e) => {
1144 + let _ = tx.send(AuthEvent::HfSearchFailed(e.to_string()));
1145 + }
1146 + }
1147 + });
1148 +}
1149 +
1150 +fn trigger_download(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1151 + let Some(model) = app.hf_search_results.get(app.hf_search_cursor) else {
1152 + return;
1153 + };
1154 + let model_id = model.model_id.clone();
1155 + let hub = crate::hf::preferred_download_hub();
1156 +
1157 + app.downloading = true;
1158 + app.download_progress = None;
1159 + app.status = Status::neutral(format!("Downloading {model_id}…"));
1160 +
1161 + let (dl_tx, mut dl_rx) = mpsc::unbounded_channel::<crate::hf_search::DownloadEvent>();
1162 +
1163 + // Task 1: run the download and send DownloadEvents.
1164 + let model_id_clone = model_id.clone();
1165 + tokio::spawn(async move {
1166 + crate::hf_search::download_model(model_id_clone, hub, dl_tx).await;
1167 + });
1168 +
1169 + // Task 2: forward DownloadEvents → AuthEvents.
1170 + let auth_tx = tx.clone();
1171 + tokio::spawn(async move {
1172 + while let Some(event) = dl_rx.recv().await {
1173 + match event {
1174 + crate::hf_search::DownloadEvent::Progress(p) => {
1175 + let _ = auth_tx.send(AuthEvent::ModelDownloadProgress(p));
1176 + }
1177 + crate::hf_search::DownloadEvent::Complete => {
1178 + let _ = auth_tx.send(AuthEvent::ModelDownloadComplete(model_id.clone()));
1179 + break;
1180 + }
1181 + crate::hf_search::DownloadEvent::Failed(e) => {
1182 + let _ = auth_tx.send(AuthEvent::ModelDownloadFailed(e));
1183 + break;
1184 + }
1185 + }
1186 + }
1187 + });
1188 +}
1189 +
1190 fn trigger_load_downloads(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1191 if app.busy {
1192 return;
src/hf.rs
+20
@@ -307,3 +307,23 @@ pub fn list_local_models() -> Vec<LocalModel> {
307 models.sort_by_key(|m| m.model_id.to_lowercase());
308 models
309 }
310 +
311 +/// Returns the preferred hub directory for downloading new models.
312 +///
313 +/// On macOS, prefers the App Group container so downloads are shared with
314 +/// the Onde Inference apps. Falls back to the standard HF home cache, then
315 +/// `~/.cache/huggingface/hub` as a last resort.
316 +pub fn preferred_download_hub() -> std::path::PathBuf {
317 + #[cfg(target_os = "macos")]
318 + if let Some(p) = app_group_hub() {
319 + return p;
320 + }
321 +
322 + if let Some(p) = hf_home_hub() {
323 + return p;
324 + }
325 +
326 + dirs::home_dir()
327 + .map(|h| h.join(".cache").join("huggingface").join("hub"))
328 + .unwrap_or_else(|| std::path::PathBuf::from(".cache/huggingface/hub"))
329 +}
src/hf_search.rs new
+226
@@ -0,0 +1,226 @@
1 +//! HuggingFace Hub model search and download for onde-cli.
2 +//!
3 +//! `search_hf` queries the public HF API (no auth required for public models).
4 +//! `download_model` streams all files into the standard HF hub cache layout:
5 +//! `{hub}/models--{org}--{name}/snapshots/{sha}/{filename}`
6 +
7 +use anyhow::{Context, Result};
8 +use futures::StreamExt;
9 +use serde::Deserialize;
10 +use std::path::PathBuf;
11 +use tokio::io::AsyncWriteExt;
12 +
13 +// ---------------------------------------------------------------------------
14 +// Public types
15 +// ---------------------------------------------------------------------------
16 +
17 +/// Compact model info returned by the HF search API.
18 +#[derive(Debug, Clone, Deserialize)]
19 +pub struct HfModelInfo {
20 + #[serde(rename = "id")]
21 + pub model_id: String,
22 + #[serde(default)]
23 + pub downloads: u64,
24 + #[serde(default)]
25 + pub tags: Vec<String>,
26 + /// Commit SHA of the latest revision.
27 + pub sha: Option<String>,
28 + /// Files present in the repository.
29 + #[serde(default)]
30 + pub siblings: Vec<HfSibling>,
31 +}
32 +
33 +/// A file entry in a HuggingFace model repository.
34 +#[derive(Debug, Clone, Deserialize)]
35 +pub struct HfSibling {
36 + pub rfilename: String,
37 +}
38 +
39 +/// Progress update for an in-progress model download.
40 +#[derive(Debug, Clone)]
41 +pub struct DownloadProgress {
42 + pub model_id: String,
43 + pub filename: String,
44 + pub file_index: usize,
45 + pub total_files: usize,
46 + pub file_bytes_done: u64,
47 + pub file_bytes_total: u64,
48 +}
49 +
50 +/// Events emitted by the download background task.
51 +pub enum DownloadEvent {
52 + Progress(DownloadProgress),
53 + Complete,
54 + Failed(String),
55 +}
56 +
57 +// ---------------------------------------------------------------------------
58 +// Public API
59 +// ---------------------------------------------------------------------------
60 +
61 +/// Search the HuggingFace Hub for models matching `query` (up to 20 results).
62 +pub async fn search_hf(query: &str) -> Result<Vec<HfModelInfo>> {
63 + let client = build_client()?;
64 + let results: Vec<HfModelInfo> = client
65 + .get("https://huggingface.co/api/models")
66 + .query(&[("search", query), ("limit", "20"), ("full", "false")])
67 + .send()
68 + .await
69 + .context("HuggingFace API unreachable")?
70 + .error_for_status()
71 + .context("HuggingFace API returned an error")?
72 + .json()
73 + .await
74 + .context("Failed to parse HuggingFace API response")?;
75 + Ok(results)
76 +}
77 +
78 +/// Download all files of a HuggingFace model into `hub_dir` using the standard
79 +/// HF hub cache layout. Progress and completion are reported via `tx`.
80 +///
81 +/// This function is infallible — all errors are sent as `DownloadEvent::Failed`.
82 +pub async fn download_model(
83 + model_id: String,
84 + hub_dir: PathBuf,
85 + tx: tokio::sync::mpsc::UnboundedSender<DownloadEvent>,
86 +) {
87 + match run_download(model_id, hub_dir, &tx).await {
88 + Ok(()) => {
89 + let _ = tx.send(DownloadEvent::Complete);
90 + }
91 + Err(e) => {
92 + let _ = tx.send(DownloadEvent::Failed(format!("{e:#}")));
93 + }
94 + }
95 +}
96 +
97 +// ---------------------------------------------------------------------------
98 +// Private helpers
99 +// ---------------------------------------------------------------------------
100 +
101 +fn build_client() -> Result<reqwest::Client> {
102 + reqwest::Client::builder()
103 + .user_agent("onde-cli/0.1")
104 + .redirect(reqwest::redirect::Policy::limited(10))
105 + .build()
106 + .context("Failed to build HTTP client")
107 +}
108 +
109 +/// Fetch full model metadata (commit SHA + file list).
110 +async fn fetch_model_info(client: &reqwest::Client, model_id: &str) -> Result<HfModelInfo> {
111 + let url = format!("https://huggingface.co/api/models/{model_id}");
112 + let info: HfModelInfo = client
113 + .get(&url)
114 + .send()
115 + .await
116 + .context("Failed to fetch model metadata")?
117 + .error_for_status()
118 + .context("Model not found or inaccessible")?
119 + .json()
120 + .await
121 + .context("Failed to parse model metadata")?;
122 + Ok(info)
123 +}
124 +
125 +async fn run_download(
126 + model_id: String,
127 + hub_dir: PathBuf,
128 + tx: &tokio::sync::mpsc::UnboundedSender<DownloadEvent>,
129 +) -> Result<()> {
130 + let client = build_client()?;
131 +
132 + // Fetch metadata to get the commit SHA and file list.
133 + let info = fetch_model_info(&client, &model_id).await?;
134 + let sha = info.sha.as_deref().unwrap_or("main");
135 +
136 + // Build snapshot directory: {hub}/models--{org}--{name}/snapshots/{sha}/
137 + let dir_name = format!("models--{}", model_id.replace('/', "--"));
138 + let snapshot_dir = hub_dir.join(&dir_name).join("snapshots").join(sha);
139 + tokio::fs::create_dir_all(&snapshot_dir)
140 + .await
141 + .context("Failed to create snapshot directory")?;
142 +
143 + // Write refs/main so the standard HF cache tools can find the revision.
144 + let refs_dir = hub_dir.join(&dir_name).join("refs");
145 + tokio::fs::create_dir_all(&refs_dir).await?;
146 + tokio::fs::write(refs_dir.join("main"), sha).await?;
147 +
148 + // Download each file.
149 + let files: Vec<String> = info.siblings.iter().map(|s| s.rfilename.clone()).collect();
150 + let total_files = files.len();
151 +
152 + for (file_index, filename) in files.iter().enumerate() {
153 + let dest = snapshot_dir.join(filename);
154 +
155 + // Create parent dirs for nested paths (e.g. "subfolder/file.json").
156 + if let Some(parent) = dest.parent() {
157 + tokio::fs::create_dir_all(parent).await?;
158 + }
159 +
160 + // Skip files that are already fully present.
161 + if dest.exists() {
162 + // Still send a progress event so the UI reflects the file count.
163 + let _ = tx.send(DownloadEvent::Progress(DownloadProgress {
164 + model_id: model_id.clone(),
165 + filename: filename.clone(),
166 + file_index,
167 + total_files,
168 + file_bytes_done: 0,
169 + file_bytes_total: 0,
170 + }));
171 + continue;
172 + }
173 +
174 + let url = format!("https://huggingface.co/{model_id}/resolve/{sha}/{filename}");
175 + let response = client
176 + .get(&url)
177 + .send()
178 + .await
179 + .with_context(|| format!("Failed to download {filename}"))?
180 + .error_for_status()
181 + .with_context(|| format!("Server error downloading {filename}"))?;
182 +
183 + let total_bytes = response.content_length().unwrap_or(0);
184 + let mut downloaded: u64 = 0;
185 + let mut last_reported: u64 = 0;
186 +
187 + // Write to a temp file first, then rename on success.
188 + let tmp_dest = snapshot_dir.join(format!("{filename}.tmp"));
189 + let mut file = tokio::fs::File::create(&tmp_dest)
190 + .await
191 + .with_context(|| format!("Failed to create {}", tmp_dest.display()))?;
192 +
193 + let mut stream = response.bytes_stream();
194 +
195 + while let Some(chunk) = stream.next().await {
196 + let chunk = chunk.with_context(|| format!("Download error for {filename}"))?;
197 + file.write_all(&chunk)
198 + .await
199 + .with_context(|| format!("Write error for {filename}"))?;
200 + downloaded += chunk.len() as u64;
201 +
202 + // Report progress roughly every 512 KB.
203 + if downloaded.saturating_sub(last_reported) >= 512 * 1024
204 + || (total_bytes > 0 && downloaded >= total_bytes)
205 + {
206 + last_reported = downloaded;
207 + let _ = tx.send(DownloadEvent::Progress(DownloadProgress {
208 + model_id: model_id.clone(),
209 + filename: filename.clone(),
210 + file_index,
211 + total_files,
212 + file_bytes_done: downloaded,
213 + file_bytes_total: total_bytes,
214 + }));
215 + }
216 + }
217 +
218 + file.flush().await?;
219 + drop(file);
220 + tokio::fs::rename(&tmp_dest, &dest)
221 + .await
222 + .with_context(|| format!("Failed to finalize {filename}"))?;
223 + }
224 +
225 + Ok(())
226 +}
src/main.rs
+1
@@ -2,6 +2,7 @@ mod app;
2 mod finetune;
3 mod gresiq;
4 mod hf;
5 +mod hf_search;
6 mod token;
7 mod ui;
8
src/ui.rs
+216 -13
@@ -682,7 +682,7 @@ fn render_downloads(frame: &mut Frame, app: &App, area: Rect) {
682 Constraint::Length(1), // spacer
683 Constraint::Length(1), // column header
684 Constraint::Length(1), // divider
685 - Constraint::Min(0), // list + hint
685 + Constraint::Min(0), // body
686 ])
687 .split(area);
688
@@ -697,9 +697,14 @@ fn render_downloads(frame: &mut Frame, app: &App, area: Rect) {
697 render_nav_tabs(frame, app, top[2]);
698 render_status(frame, app, top[4]);
699
700 + // Column header changes based on state.
701 + let col_header = if app.hf_search_active {
702 + " Model Downloads"
703 + } else {
704 + " Model Size Source"
705 + };
706 frame.render_widget(
701 - Paragraph::new(" Model Size Source")
702 - .style(Style::new().fg(C_MUTED)),
707 + Paragraph::new(col_header).style(Style::new().fg(C_MUTED)),
708 top[6],
709 );
710
@@ -709,18 +714,25 @@ fn render_downloads(frame: &mut Frame, app: &App, area: Rect) {
714 top[7],
715 );
716
712 - let bottom = Layout::vertical([
713 - Constraint::Min(0), // list
714 - Constraint::Length(1), // hint
715 - ])
716 - .split(top[8]);
717 + if app.downloading {
718 + render_download_progress_panel(frame, app, top[8]);
719 + } else if app.hf_search_active {
720 + render_hf_search_panel(frame, app, top[8]);
721 + } else {
722 + let bottom = Layout::vertical([
723 + Constraint::Min(0), // list
724 + Constraint::Length(1), // hint
725 + ])
726 + .split(top[8]);
727
718 - render_downloads_list(frame, app, bottom[0]);
728 + render_downloads_list(frame, app, bottom[0]);
729
720 - frame.render_widget(
721 - Paragraph::new("↑↓ · navigate Tab · apps").style(Style::new().fg(C_MUTED)),
722 - bottom[1],
723 - );
730 + frame.render_widget(
731 + Paragraph::new("↑↓ · navigate / · search HF Tab · apps")
732 + .style(Style::new().fg(C_MUTED)),
733 + bottom[1],
734 + );
735 + }
736 }
737
738 fn render_downloads_list(frame: &mut Frame, app: &App, area: Rect) {
@@ -792,6 +804,181 @@ fn render_downloads_list(frame: &mut Frame, app: &App, area: Rect) {
804
805 // model detail screen
806
807 +fn render_hf_search_panel(frame: &mut Frame, app: &App, area: Rect) {
808 + let rows = Layout::vertical([
809 + Constraint::Length(1), // "Search HuggingFace Hub" label
810 + Constraint::Length(3), // search input box
811 + Constraint::Length(1), // spacer
812 + Constraint::Min(0), // results list
813 + Constraint::Length(1), // hint
814 + ])
815 + .split(area);
816 +
817 + frame.render_widget(
818 + Paragraph::new("Search HuggingFace Hub").style(Style::new().fg(C_MUTED)),
819 + rows[0],
820 + );
821 +
822 + // Search input — always neon-bordered (it's always focused when active).
823 + let block = Block::new()
824 + .borders(Borders::ALL)
825 + .border_type(BorderType::Rounded)
826 + .border_style(Style::new().fg(C_NEON))
827 + .style(Style::new().bg(C_SURFACE_STRONG));
828 + let inner = block.inner(rows[1]);
829 + frame.render_widget(block, rows[1]);
830 +
831 + let display_query = if app.hf_search_loading {
832 + format!("{} ⠿", app.hf_search_query)
833 + } else {
834 + app.hf_search_query.clone()
835 + };
836 + frame.render_widget(
837 + Paragraph::new(display_query.as_str()).style(Style::new().fg(C_TEXT)),
838 + inner,
839 + );
840 +
841 + // Show cursor only when not loading.
842 + if !app.hf_search_loading {
843 + let cursor_x = (inner.x + app.hf_search_query.chars().count() as u16)
844 + .min(inner.x + inner.width.saturating_sub(1));
845 + frame.set_cursor_position((cursor_x, inner.y));
846 + }
847 +
848 + // Results area.
849 + if app.hf_search_results.is_empty() && !app.hf_search_loading {
850 + frame.render_widget(
851 + Paragraph::new(" Type a query and press Enter to search.")
852 + .style(Style::new().fg(C_MUTED)),
853 + rows[3],
854 + );
855 + } else {
856 + render_hf_results_list(frame, app, rows[3]);
857 + }
858 +
859 + let hint = if app.hf_search_loading {
860 + "Searching…"
861 + } else if app.hf_search_results.is_empty() {
862 + "Enter · search Esc · cancel"
863 + } else {
864 + "↑↓ · navigate Enter · download Esc · cancel"
865 + };
866 + frame.render_widget(
867 + Paragraph::new(hint).style(Style::new().fg(C_MUTED)),
868 + rows[4],
869 + );
870 +}
871 +
872 +fn render_hf_results_list(frame: &mut Frame, app: &App, area: Rect) {
873 + let max_rows = area.height as usize;
874 + for (i, model) in app.hf_search_results.iter().enumerate().take(max_rows) {
875 + let row_y = area.y + i as u16;
876 + let row_area = Rect::new(area.x, row_y, area.width, 1);
877 + let is_selected = i == app.hf_search_cursor;
878 + let prefix = if is_selected { "▶ " } else { " " };
879 +
880 + let dl_str = format_downloads(model.downloads);
881 + // Truncate model_id to keep the line from wrapping.
882 + let max_id_len = (area.width as usize).saturating_sub(12);
883 + let model_id_display = if model.model_id.len() > max_id_len {
884 + format!("{}…", &model.model_id[..max_id_len.saturating_sub(1)])
885 + } else {
886 + model.model_id.clone()
887 + };
888 + let left = format!("{}{:<44}", prefix, model_id_display);
889 +
890 + let text_style = if is_selected {
891 + Style::new().fg(C_NEON)
892 + } else {
893 + Style::new().fg(C_TEXT)
894 + };
895 +
896 + let line = Line::from(vec![
897 + Span::styled(left, text_style),
898 + Span::styled(dl_str, Style::new().fg(C_MUTED)),
899 + ]);
900 + frame.render_widget(Paragraph::new(line), row_area);
901 + }
902 +}
903 +
904 +fn format_downloads(n: u64) -> String {
905 + if n >= 1_000_000 {
906 + format!("{:.1}M ↓", n as f64 / 1_000_000.0)
907 + } else if n >= 1_000 {
908 + format!("{:.0}K ↓", n as f64 / 1_000.0)
909 + } else if n > 0 {
910 + format!("{n} ↓")
911 + } else {
912 + "–".to_string()
913 + }
914 +}
915 +
916 +fn render_download_progress_panel(frame: &mut Frame, app: &App, area: Rect) {
917 + let rows = Layout::vertical([
918 + Constraint::Length(1), // model ID heading
919 + Constraint::Length(1), // spacer
920 + Constraint::Length(1), // filename
921 + Constraint::Length(1), // progress bar
922 + Constraint::Length(1), // byte count
923 + Constraint::Min(0), // spacer
924 + Constraint::Length(1), // hint
925 + ])
926 + .split(area);
927 +
928 + if let Some(dp) = &app.download_progress {
929 + frame.render_widget(
930 + Paragraph::new(Line::from(vec![
931 + Span::styled("⠿ ", Style::new().fg(C_NEON)),
932 + Span::styled(dp.model_id.as_str(), Style::new().fg(C_INK).bold()),
933 + ])),
934 + rows[0],
935 + );
936 +
937 + let file_label = format!(
938 + "File {}/{}: {}",
939 + dp.file_index + 1,
940 + dp.total_files,
941 + dp.filename
942 + );
943 + frame.render_widget(
944 + Paragraph::new(file_label).style(Style::new().fg(C_TEXT)),
945 + rows[2],
946 + );
947 +
948 + // Progress bar.
949 + let progress = if dp.file_bytes_total > 0 {
950 + dp.file_bytes_done as f64 / dp.file_bytes_total as f64
951 + } else {
952 + 0.0
953 + };
954 + let bar_width = area.width.saturating_sub(4) as usize;
955 + let filled = (progress * bar_width as f64) as usize;
956 + let empty = bar_width.saturating_sub(filled);
957 + let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
958 + frame.render_widget(Paragraph::new(bar).style(Style::new().fg(C_NEON)), rows[3]);
959 +
960 + let bytes_label = format!(
961 + "{} / {}",
962 + fmt_bytes(dp.file_bytes_done as i64),
963 + fmt_bytes(dp.file_bytes_total as i64),
964 + );
965 + frame.render_widget(
966 + Paragraph::new(bytes_label).style(Style::new().fg(C_MUTED)),
967 + rows[4],
968 + );
969 + } else {
970 + frame.render_widget(
971 + Paragraph::new("⠿ Starting download…").style(Style::new().fg(C_MUTED)),
972 + rows[0],
973 + );
974 + }
975 +
976 + frame.render_widget(
977 + Paragraph::new("Ctrl+C · quit").style(Style::new().fg(C_MUTED)),
978 + rows[6],
979 + );
980 +}
981 +
982 fn render_model_detail(frame: &mut Frame, app: &App, area: Rect) {
983 let Some(model) = app.downloads.get(app.downloads_cursor) else {
984 return;
@@ -1249,9 +1436,25 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
1436 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
1437 Span::styled(" · quit", Style::new().fg(C_MUTED)),
1438 ],
1439 + Screen::Downloads if app.downloading => vec![
1440 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
1441 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
1442 + ],
1443 + Screen::Downloads if app.hf_search_active => vec![
1444 + Span::styled("Enter", Style::new().fg(C_NEON)),
1445 + Span::styled(" · search / download ", Style::new().fg(C_MUTED)),
1446 + Span::styled("↑↓", Style::new().fg(C_NEON)),
1447 + Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
1448 + Span::styled("Esc", Style::new().fg(C_NEON)),
1449 + Span::styled(" · cancel ", Style::new().fg(C_MUTED)),
1450 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
1451 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
1452 + ],
1453 Screen::Downloads => vec![
1454 Span::styled("↑↓", Style::new().fg(C_NEON)),
1455 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
1456 + Span::styled("/", Style::new().fg(C_NEON)),
1457 + Span::styled(" · search HF ", Style::new().fg(C_MUTED)),
1458 Span::styled("Enter", Style::new().fg(C_NEON)),
1459 Span::styled(" · detail ", Style::new().fg(C_MUTED)),
1460 Span::styled("Tab", Style::new().fg(C_NEON)),