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)),