@ondeinference / cli / commits / 78665b6

Add HuggingFace repo cloning and project workspace support

- Add hf_clone.rs for HuggingFace repo inspection and cloning - Add project.rs for project workspace management and artifact tracking - Update main.rs and ui.rs to integrate new modules and screens - Minor fix in finetune.rs for optimizer variable cloning

keypair34 committed Apr 23, 2026 at 05:35 UTC 78665b668dd94dc7efde57a46b36e151fb52b390
6 files changed +1265 -27
src/app.rs
+390 -24
@@ -44,6 +44,7 @@ pub enum Screen {
44 ModelDetail,
45 FineTune,
46 GgufDetail,
47 + CloneRepo,
48 }
49
50 /// What kind of artifact was found on disk.
@@ -175,6 +176,7 @@ pub enum AuthEvent {
176 GgufProgress(crate::gguf::GgufProgress),
177 // HF upload
178 UploadProgress(crate::hf_upload::UploadProgress),
179 + CloneProgress(crate::hf_clone::CloneProgress),
180 }
181
182 pub struct App {
@@ -245,6 +247,14 @@ pub struct App {
247 pub upload_running: bool,
248 pub upload_progress: Option<crate::hf_upload::UploadProgress>,
249 pub upload_repo_name: String,
250 + // active project (set when entering fine-tune from CloneRepo)
251 + pub current_project: Option<crate::project::OndeProject>,
252 + // clone repo (check + base model selection)
253 + pub clone_repo_id: String,
254 + pub clone_repo_checking: bool,
255 + pub clone_repo_status: Option<crate::hf_clone::RepoStatus>,
256 + pub clone_base_cursor: usize,
257 + pub clone_progress: Option<crate::hf_clone::CloneProgress>,
258 }
259
260 impl App {
@@ -285,10 +295,10 @@ impl App {
295 download_progress: None,
296 finetune_model_id: String::new(),
297 finetune_model_dir: String::new(),
288 - finetune_data_path: "~/.onde/finetune/train.jsonl".to_string(),
298 + finetune_data_path: String::new(),
299 finetune_rank: "8".to_string(),
300 finetune_epochs: "3".to_string(),
291 - finetune_lr: "0.0001".to_string(),
301 + finetune_lr: "0.00001".to_string(),
302 finetune_focus: FineTuneFocus::ModelDir,
303 finetune_running: false,
304 finetune_progress: None,
@@ -303,6 +313,12 @@ impl App {
313 upload_running: false,
314 upload_progress: None,
315 upload_repo_name: String::new(),
316 + current_project: None,
317 + clone_repo_id: String::new(),
318 + clone_repo_checking: false,
319 + clone_repo_status: None,
320 + clone_base_cursor: 0,
321 + clone_progress: None,
322 }
323 }
324
@@ -333,7 +349,8 @@ impl App {
349 | AuthEvent::HfSearchFailed(_)
350 | AuthEvent::MergeProgress(_)
351 | AuthEvent::GgufProgress(_)
336 - | AuthEvent::UploadProgress(_) => {}
352 + | AuthEvent::UploadProgress(_)
353 + | AuthEvent::CloneProgress(_) => {}
354 _ => {
355 self.busy = false;
356 }
@@ -554,6 +571,44 @@ impl App {
571 }
572 self.upload_progress = Some(progress);
573 }
574 + AuthEvent::CloneProgress(progress) => {
575 + match &progress {
576 + crate::hf_clone::CloneProgress::RepoChecked(status) => {
577 + self.clone_repo_checking = false;
578 + self.clone_repo_status = Some(status.clone());
579 + match status {
580 + crate::hf_clone::RepoStatus::NotFound => {
581 + self.status =
582 + Status::neutral("Repo not found. Press Enter to create it.");
583 + }
584 + crate::hf_clone::RepoStatus::Empty { .. } => {
585 + self.status = Status::neutral(
586 + "Repo is empty — pick a base model to fine-tune.",
587 + );
588 + }
589 + crate::hf_clone::RepoStatus::HasModel { .. } => {
590 + self.status = Status::success("Repo already has model files.");
591 + }
592 + }
593 + }
594 + crate::hf_clone::CloneProgress::RepoReady => {
595 + self.clone_repo_checking = false;
596 + self.status =
597 + Status::success("Repo created. Pick a base model to fine-tune.");
598 + // After creating, set status to Empty so the UI shows base model picker
599 + self.clone_repo_status = Some(crate::hf_clone::RepoStatus::Empty {
600 + repo_id: self.clone_repo_id.clone(),
601 + files: vec![],
602 + });
603 + }
604 + crate::hf_clone::CloneProgress::Failed(msg) => {
605 + self.clone_repo_checking = false;
606 + self.status = Status::error(msg.clone());
607 + }
608 + _ => {}
609 + }
610 + self.clone_progress = Some(progress);
611 + }
612 }
613 }
614 }
@@ -674,6 +729,7 @@ fn handle_key(
729 Screen::ModelDetail => handle_key_model_detail(app, key, tx),
730 Screen::GgufDetail => handle_key_gguf_detail(app, key, tx),
731 Screen::FineTune => handle_key_finetune(app, key, tx),
732 + Screen::CloneRepo => handle_key_clone_repo(app, key, tx),
733 }
734 }
735
@@ -1021,6 +1077,15 @@ fn handle_key_downloads(
1077 ));
1078 }
1079 }
1080 + (Char('c'), KeyModifiers::NONE) => {
1081 + app.clone_repo_id.clear();
1082 + app.clone_repo_status = None;
1083 + app.clone_base_cursor = 0;
1084 + app.clone_repo_checking = false;
1085 + app.clone_progress = None;
1086 + app.screen = Screen::CloneRepo;
1087 + app.status = Status::neutral("Enter a HuggingFace repo ID to check.");
1088 + }
1089 (Char('/'), KeyModifiers::NONE) => {
1090 app.hf_search_active = true;
1091 app.hf_search_query.clear();
@@ -1074,17 +1139,22 @@ fn handle_key_model_detail(
1139 .is_some_and(|a| a.kind == ArtifactKind::Gguf) =>
1140 {
1141 if let Some(entry) = app.adapter_list.get(app.adapter_cursor) {
1077 - // Derive a default repo name from model + file
1078 - let base_name = app
1079 - .downloads
1080 - .get(app.downloads_cursor)
1081 - .map(|m| m.model_id.replace('/', "-"))
1082 - .unwrap_or_else(|| "finetuned-model".to_string());
1083 - let stem = entry
1084 - .file_name
1085 - .strip_suffix(".gguf")
1086 - .unwrap_or(&entry.file_name);
1087 - app.upload_repo_name = format!("ondeinference/{base_name}-{stem}");
1142 + // Use the project repo ID when available (e.g. "ondeinference/rumi"),
1143 + // otherwise derive from the base model name.
1144 + app.upload_repo_name = if let Some(ref project) = app.current_project {
1145 + project.repo_id.clone()
1146 + } else {
1147 + let base_name = app
1148 + .downloads
1149 + .get(app.downloads_cursor)
1150 + .map(|m| m.model_id.replace('/', "-"))
1151 + .unwrap_or_else(|| "finetuned-model".to_string());
1152 + let stem = entry
1153 + .file_name
1154 + .strip_suffix(".gguf")
1155 + .unwrap_or(&entry.file_name);
1156 + format!("ondeinference/{base_name}-{stem}")
1157 + };
1158 app.selected_gguf = Some(entry.clone());
1159 app.upload_progress = None;
1160 app.upload_running = false;
@@ -1140,6 +1210,8 @@ fn handle_key_model_detail(
1210 } else {
1211 app.finetune_model_id = model.model_id.clone();
1212 app.finetune_model_dir = resolved;
1213 + app.finetune_data_path = finetune_data_path_for_model(&model.model_id);
1214 + app.current_project = None; // no project context from model detail
1215 app.finetune_focus = FineTuneFocus::DataPath;
1216 app.finetune_running = false;
1217 app.finetune_progress = None;
@@ -1461,7 +1533,12 @@ fn handle_key_finetune(
1533 })
1534 .unwrap_or_else(|| raw.to_string())
1535 });
1464 - let output_dir = base_dir.parent().unwrap_or(&base_dir).join("merged-model");
1536 + // Derive a timestamped merge dir from the adapter dir name.
1537 + // Put the merged model in a `merged/` subdir next to the adapter
1538 + let output_dir = adapter_path
1539 + .parent()
1540 + .unwrap_or_else(|| std::path::Path::new("."))
1541 + .join("merged");
1542
1543 let merge_config = crate::merge::MergeConfig {
1544 base_dir,
@@ -1487,10 +1564,28 @@ fn handle_key_finetune(
1564 (Char('g'), KeyModifiers::NONE) if app.merged_model_dir.is_some() => {
1565 // Start GGUF export from merged model
1566 let model_dir = app.merged_model_dir.clone().unwrap_or_default();
1490 - let output_path = model_dir
1491 - .parent()
1492 - .unwrap_or(&model_dir)
1493 - .join("model-finetuned-q8_0.gguf");
1567 + // Derive GGUF filename from the merged dir name for traceability.
1568 + // Place the GGUF in the run dir (parent of merged/).
1569 + let run_dir = model_dir.parent().unwrap_or(&model_dir);
1570 + let gguf_name = app
1571 + .current_project
1572 + .as_ref()
1573 + .map(|p| {
1574 + p.repo_id
1575 + .split('/')
1576 + .next_back()
1577 + .unwrap_or("model")
1578 + .to_string()
1579 + })
1580 + .unwrap_or_else(|| {
1581 + // Use the base model short name for traceability
1582 + app.finetune_model_id
1583 + .split('/')
1584 + .next_back()
1585 + .unwrap_or("model-finetuned")
1586 + .to_string()
1587 + });
1588 + let output_path = run_dir.join(format!("{gguf_name}-q8_0.gguf"));
1589
1590 let gguf_config = crate::gguf::GgufConfig {
1591 model_dir,
@@ -1560,7 +1655,7 @@ fn handle_key_finetune(
1655 (Enter, _) => {
1656 let rank: usize = app.finetune_rank.parse().unwrap_or(8);
1657 let epochs: usize = app.finetune_epochs.parse().unwrap_or(3);
1563 - let lr: f64 = app.finetune_lr.parse().unwrap_or(0.0001);
1658 + let lr: f64 = app.finetune_lr.parse().unwrap_or(0.00001);
1659
1660 // Expand ~ in paths
1661 let expand = |s: &str| -> String {
@@ -1574,10 +1669,23 @@ fn handle_key_finetune(
1669
1670 let model_dir = std::path::PathBuf::from(expand(&app.finetune_model_dir));
1671 let data_path = std::path::PathBuf::from(expand(&app.finetune_data_path));
1577 - let output_dir = model_dir
1578 - .parent()
1579 - .unwrap_or(&model_dir)
1580 - .join("lora-adapter");
1672 + // Use the project's run directory if available, otherwise fall back
1673 + // to a timestamped dir next to the model.
1674 + let output_dir = if let Some(ref project) = app.current_project {
1675 + match crate::project::new_run_dir(project) {
1676 + Ok(run_dir) => run_dir,
1677 + Err(e) => {
1678 + app.status = Status::error(format!("Failed to create run dir: {e}"));
1679 + return;
1680 + }
1681 + }
1682 + } else {
1683 + let ts = run_timestamp();
1684 + model_dir
1685 + .parent()
1686 + .unwrap_or(&model_dir)
1687 + .join(format!("lora-{ts}"))
1688 + };
1689
1690 let config = crate::finetune::FineTuneConfig {
1691 model_dir,
@@ -1632,6 +1740,264 @@ fn handle_key_finetune(
1740 }
1741 }
1742
1743 +fn handle_key_clone_repo(
1744 + app: &mut App,
1745 + key: crossterm::event::KeyEvent,
1746 + tx: mpsc::UnboundedSender<AuthEvent>,
1747 +) {
1748 + use KeyCode::*;
1749 +
1750 + // While checking, only allow quit.
1751 + if app.clone_repo_checking {
1752 + if matches!(
1753 + (key.code, key.modifiers),
1754 + (Char('c'), KeyModifiers::CONTROL)
1755 + ) {
1756 + app.should_quit = true;
1757 + }
1758 + return;
1759 + }
1760 +
1761 + // If repo is empty or not found + created → show base model picker
1762 + let is_picking_base = matches!(
1763 + &app.clone_repo_status,
1764 + Some(crate::hf_clone::RepoStatus::Empty { .. })
1765 + );
1766 +
1767 + if is_picking_base {
1768 + match (key.code, key.modifiers) {
1769 + (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1770 + app.clone_base_cursor = app.clone_base_cursor.saturating_sub(1);
1771 + }
1772 + (Down, _) | (Char('j'), KeyModifiers::NONE) => {
1773 + if app.clone_base_cursor + 1 < crate::hf_clone::BASE_MODELS.len() {
1774 + app.clone_base_cursor += 1;
1775 + }
1776 + }
1777 + (Enter, _) => {
1778 + // User selected a base model — download it then go to fine-tune
1779 + let base = &crate::hf_clone::BASE_MODELS[app.clone_base_cursor];
1780 + let base_repo_id = base.repo_id.to_string();
1781 +
1782 + // Check if the base model is already downloaded locally
1783 + let resolved = resolve_hf_cache_path(&base_repo_id);
1784 + if !resolved.is_empty()
1785 + && std::path::Path::new(&resolved).join("config.json").exists()
1786 + {
1787 + // Create (or load) the project workspace for this repo
1788 + let target_repo = app.clone_repo_id.clone();
1789 + match crate::project::create_project(&target_repo, &base_repo_id) {
1790 + Ok(mut project) => {
1791 + project.base_model_dir = resolved.clone();
1792 + let dataset_display =
1793 + project.dataset_path.to_string_lossy().to_string();
1794 + // Try to shorten the display path with ~/
1795 + let dataset_display = if let Some(home) = dirs::home_dir() {
1796 + let home_str = home.to_string_lossy();
1797 + if dataset_display.starts_with(home_str.as_ref()) {
1798 + format!("~{}", &dataset_display[home_str.len()..])
1799 + } else {
1800 + dataset_display
1801 + }
1802 + } else {
1803 + dataset_display
1804 + };
1805 + app.finetune_data_path = dataset_display;
1806 + app.finetune_model_id = base_repo_id;
1807 + app.finetune_model_dir = resolved;
1808 + app.current_project = Some(project);
1809 + app.finetune_focus = FineTuneFocus::DataPath;
1810 + app.finetune_running = false;
1811 + app.finetune_progress = None;
1812 + app.screen = Screen::FineTune;
1813 + app.status = Status::neutral("Project ready. Configure fine-tuning.");
1814 + }
1815 + Err(e) => {
1816 + app.status = Status::error(format!("Failed to create project: {e}"));
1817 + }
1818 + }
1819 + return;
1820 + }
1821 +
1822 + // Not downloaded — trigger download via the existing HF search download flow
1823 + let hub = crate::hf::preferred_download_hub();
1824 + app.downloading = true;
1825 + app.download_progress = None;
1826 + app.status =
1827 + Status::neutral(format!("Downloading base model {}…", base.display_name));
1828 +
1829 + // Switch to Downloads screen to show progress
1830 + app.screen = Screen::Downloads;
1831 +
1832 + let (dl_tx, mut dl_rx) =
1833 + mpsc::unbounded_channel::<crate::hf_search::DownloadEvent>();
1834 +
1835 + let model_id_for_download = base_repo_id.clone();
1836 + tokio::spawn(async move {
1837 + crate::hf_search::download_model(model_id_for_download, hub, dl_tx).await;
1838 + });
1839 +
1840 + let auth_tx = tx.clone();
1841 + let model_id_for_event = base_repo_id;
1842 + tokio::spawn(async move {
1843 + while let Some(event) = dl_rx.recv().await {
1844 + match event {
1845 + crate::hf_search::DownloadEvent::Progress(p) => {
1846 + let _ = auth_tx.send(AuthEvent::ModelDownloadProgress(p));
1847 + }
1848 + crate::hf_search::DownloadEvent::Complete => {
1849 + let _ = auth_tx.send(AuthEvent::ModelDownloadComplete(
1850 + model_id_for_event.clone(),
1851 + ));
1852 + break;
1853 + }
1854 + crate::hf_search::DownloadEvent::Failed(e) => {
1855 + let _ = auth_tx.send(AuthEvent::ModelDownloadFailed(e));
1856 + break;
1857 + }
1858 + }
1859 + }
1860 + });
1861 + }
1862 + (Esc, _) => {
1863 + // Go back to repo name input
1864 + app.clone_repo_status = None;
1865 + app.clone_base_cursor = 0;
1866 + app.status = Status::neutral("Enter a HuggingFace repo ID.");
1867 + }
1868 + (Char('c'), KeyModifiers::CONTROL) => {
1869 + app.should_quit = true;
1870 + }
1871 + _ => {}
1872 + }
1873 + return;
1874 + }
1875 +
1876 + // If repo has model files, show a note and allow going back
1877 + if matches!(
1878 + &app.clone_repo_status,
1879 + Some(crate::hf_clone::RepoStatus::HasModel { .. })
1880 + ) {
1881 + match (key.code, key.modifiers) {
1882 + (Esc, _) => {
1883 + app.clone_repo_status = None;
1884 + app.status = Status::neutral("Enter a HuggingFace repo ID.");
1885 + }
1886 + (Char('c'), KeyModifiers::CONTROL) => {
1887 + app.should_quit = true;
1888 + }
1889 + _ => {}
1890 + }
1891 + return;
1892 + }
1893 +
1894 + // Repo status is NotFound — allow creating it with Enter
1895 + if matches!(
1896 + &app.clone_repo_status,
1897 + Some(crate::hf_clone::RepoStatus::NotFound)
1898 + ) {
1899 + match (key.code, key.modifiers) {
1900 + (Enter, _) => {
1901 + let token = HF_TOKEN.to_string();
1902 + if token.is_empty() {
1903 + app.status = Status::error("No HF_TOKEN set. Add it to .env and rebuild.");
1904 + return;
1905 + }
1906 + let repo_id = app.clone_repo_id.clone();
1907 + app.clone_repo_checking = true;
1908 + app.status = Status::neutral("Creating repo…");
1909 +
1910 + let (clone_tx, mut clone_rx) = mpsc::unbounded_channel();
1911 + let auth_tx = tx.clone();
1912 + tokio::spawn(async move {
1913 + crate::hf_clone::start_create_repo(repo_id, token, clone_tx).await;
1914 + });
1915 + tokio::spawn(async move {
1916 + while let Some(progress) = clone_rx.recv().await {
1917 + let _ = auth_tx.send(AuthEvent::CloneProgress(progress));
1918 + }
1919 + });
1920 + }
1921 + (Esc, _) => {
1922 + app.clone_repo_status = None;
1923 + app.status = Status::neutral("Enter a HuggingFace repo ID.");
1924 + }
1925 + (Char('c'), KeyModifiers::CONTROL) => {
1926 + app.should_quit = true;
1927 + }
1928 + _ => {}
1929 + }
1930 + return;
1931 + }
1932 +
1933 + // Default: repo name input mode
1934 + match (key.code, key.modifiers) {
1935 + (Enter, _) => {
1936 + if app.clone_repo_id.trim().is_empty() {
1937 + app.status = Status::error("Repo ID cannot be empty.");
1938 + return;
1939 + }
1940 + let token = HF_TOKEN.to_string();
1941 + let repo_id = app.clone_repo_id.clone();
1942 + app.clone_repo_checking = true;
1943 + app.clone_repo_status = None;
1944 + app.status = Status::neutral(format!("Checking {}…", repo_id));
1945 +
1946 + let (clone_tx, mut clone_rx) = mpsc::unbounded_channel();
1947 + let auth_tx = tx.clone();
1948 + tokio::spawn(async move {
1949 + crate::hf_clone::start_check_repo(repo_id, token, clone_tx).await;
1950 + });
1951 + tokio::spawn(async move {
1952 + while let Some(progress) = clone_rx.recv().await {
1953 + let _ = auth_tx.send(AuthEvent::CloneProgress(progress));
1954 + }
1955 + });
1956 + }
1957 + (Backspace, _) => {
1958 + app.clone_repo_id.pop();
1959 + }
1960 + (Esc, _) => {
1961 + app.screen = Screen::Downloads;
1962 + app.clone_repo_id.clear();
1963 + app.clone_repo_status = None;
1964 + app.clone_base_cursor = 0;
1965 + app.status = Status::neutral("Back to models.");
1966 + }
1967 + (Char('c'), KeyModifiers::CONTROL) => {
1968 + app.should_quit = true;
1969 + }
1970 + (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
1971 + // Typing clears any previous check result
1972 + if app.clone_repo_status.is_some() {
1973 + app.clone_repo_status = None;
1974 + }
1975 + app.clone_repo_id.push(c);
1976 + }
1977 + _ => {}
1978 + }
1979 +}
1980 +
1981 +/// Generate a compact timestamp for unique artifact naming.
1982 +///
1983 +/// Uses Unix epoch seconds for simplicity — produces values like `1750012345`
1984 +/// which are unique, monotonic, and filesystem-safe.
1985 +fn run_timestamp() -> String {
1986 + let secs = std::time::SystemTime::now()
1987 + .duration_since(std::time::UNIX_EPOCH)
1988 + .unwrap_or_default()
1989 + .as_secs();
1990 + format!("{secs}")
1991 +}
1992 +
1993 +/// Derive a per-project dataset path from a model ID.
1994 +///
1995 +/// Converts `org/model-name` → `~/.onde/datasets/org/model-name/train.jsonl`
1996 +/// so each fine-tuning project has its own dataset folder.
1997 +fn finetune_data_path_for_model(model_id: &str) -> String {
1998 + format!("~/.onde/datasets/{}/train.jsonl", model_id)
1999 +}
2000 +
2001 fn trigger_hf_search(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2002 let query = app.hf_search_query.trim().to_string();
2003 if query.is_empty() {
src/finetune.rs
+63 -3
@@ -233,10 +233,12 @@ fn run_finetune(
233 lr: config.learning_rate,
234 ..ParamsAdamW::default()
235 };
236 - let mut optimizer = AdamW::new(lora_vars, adamw_params).context("creating AdamW optimizer")?;
236 + let mut optimizer =
237 + AdamW::new(lora_vars.clone(), adamw_params).context("creating AdamW optimizer")?;
238
239 let total_steps = token_batches.len();
240 let vocab_size = model_cfg.vocab_size;
241 + let max_grad_norm: f64 = 1.0;
242
243 for epoch in 0..config.epochs {
244 for (step, batch_tokens) in token_batches.iter().enumerate() {
@@ -259,10 +261,68 @@ fn run_finetune(
261 let loss = candle_nn::loss::cross_entropy(&logits_shifted, &targets)
262 .context("computing cross-entropy loss")?;
263
262 - optimizer.backward_step(&loss).context("optimizer step")?;
263 -
264 let loss_val = loss.to_scalar::<f32>().context("reading loss scalar")?;
265
266 + // Guard against NaN/Inf loss — skip this step to avoid poisoning
267 + // the optimizer state.
268 + if loss_val.is_nan() || loss_val.is_infinite() {
269 + eprintln!(
270 + "[finetune] WARNING: loss is {} at epoch {} step {}, skipping",
271 + loss_val,
272 + epoch + 1,
273 + step + 1
274 + );
275 + let _ = tx.send(FineTuneProgress::Training {
276 + epoch: epoch + 1,
277 + total_epochs: config.epochs,
278 + step: step + 1,
279 + total_steps,
280 + loss: loss_val,
281 + });
282 + continue;
283 + }
284 +
285 + // Manual backward + gradient clipping + optimizer step.
286 + //
287 + // We avoid `backward_step` so we can clip gradients between the
288 + // backward pass and the AdamW update. This preserves AdamW's
289 + // momentum / velocity state (unlike manually applying lr * grad).
290 + let grads = loss.backward().context("backward pass")?;
291 +
292 + // Compute global gradient L2 norm across all LoRA variables.
293 + let mut total_norm_sq: f64 = 0.0;
294 + for var in &lora_vars {
295 + if let Some(grad) = grads.get(var.as_tensor()) {
296 + let norm_sq = grad
297 + .sqr()
298 + .and_then(|t| t.sum_all())
299 + .and_then(|t| t.to_scalar::<f32>())
300 + .unwrap_or(0.0) as f64;
301 + total_norm_sq += norm_sq;
302 + }
303 + }
304 + let total_norm = total_norm_sq.sqrt();
305 +
306 + // If the global norm exceeds the threshold, scale every gradient
307 + // down and insert the clipped version back into the GradStore.
308 + let grads = if total_norm > max_grad_norm {
309 + let clip_coef = max_grad_norm / (total_norm + 1e-6);
310 + let mut clipped_grads = grads;
311 + for var in &lora_vars {
312 + if let Some(grad) = clipped_grads.get(var.as_tensor()) {
313 + let clipped = (grad * clip_coef).context("clipping gradient")?;
314 + clipped_grads.insert(var.as_tensor(), clipped);
315 + }
316 + }
317 + clipped_grads
318 + } else {
319 + grads
320 + };
321 +
322 + // AdamW step with (possibly clipped) gradients — preserves
323 + // first-moment and second-moment estimates.
324 + optimizer.step(&grads).context("optimizer step")?;
325 +
326 let _ = tx.send(FineTuneProgress::Training {
327 epoch: epoch + 1,
328 total_epochs: config.epochs,
src/hf_clone.rs new
+275
@@ -0,0 +1,275 @@
1 +//! HuggingFace repo inspection and base-model cloning for onde-cli.
2 +//!
3 +//! Provides helpers to:
4 +//! - Check whether a HuggingFace repo exists and if it contains model files
5 +//! - Fetch the list of recommended base models for fine-tuning
6 +//! - Clone (download) a base model's safetensors files into the local HF cache
7 +
8 +use anyhow::{Context, Result};
9 +use serde::Deserialize;
10 +
11 +// ---------------------------------------------------------------------------
12 +// Public types
13 +// ---------------------------------------------------------------------------
14 +
15 +/// Result of inspecting a HuggingFace repository.
16 +#[derive(Debug, Clone)]
17 +#[allow(dead_code)]
18 +pub enum RepoStatus {
19 + /// Repo does not exist on HuggingFace (404).
20 + NotFound,
21 + /// Repo exists but contains no model files (safetensors/GGUF).
22 + Empty { repo_id: String, files: Vec<String> },
23 + /// Repo exists and has model files ready for use.
24 + HasModel {
25 + repo_id: String,
26 + files: Vec<String>,
27 + /// Approximate total size in bytes of all model files.
28 + model_size_bytes: u64,
29 + },
30 +}
31 +
32 +/// A base model that can be used as a starting point for fine-tuning.
33 +#[derive(Debug, Clone)]
34 +pub struct BaseModel {
35 + /// HuggingFace repo ID, e.g. `Qwen/Qwen3-0.6B`
36 + pub repo_id: &'static str,
37 + /// Human-readable display name
38 + pub display_name: &'static str,
39 + /// Approximate size of the safetensors model files
40 + pub size_display: &'static str,
41 + /// Parameter count description
42 + pub params: &'static str,
43 + /// Short description of recommended use case
44 + pub description: &'static str,
45 +}
46 +
47 +/// Progress updates emitted while checking or cloning a repo.
48 +#[derive(Debug, Clone)]
49 +pub enum CloneProgress {
50 + /// Checking if the target repo exists on HuggingFace.
51 + CheckingRepo,
52 + /// Repo status has been determined.
53 + RepoChecked(RepoStatus),
54 + /// Creating the target repo on HuggingFace.
55 + CreatingRepo,
56 + /// Repo was created (or already existed).
57 + RepoReady,
58 + /// Failed with an error message.
59 + Failed(String),
60 +}
61 +
62 +// ---------------------------------------------------------------------------
63 +// Base model catalog
64 +// ---------------------------------------------------------------------------
65 +
66 +/// Recommended base models for LoRA fine-tuning.
67 +///
68 +/// These are full-precision safetensors models (not GGUF) since fine-tuning
69 +/// requires autograd-capable weights. Ordered by size (smallest first).
70 +pub const BASE_MODELS: &[BaseModel] = &[
71 + BaseModel {
72 + repo_id: "Qwen/Qwen3-0.6B",
73 + display_name: "Qwen3 0.6B",
74 + size_display: "~1.2 GB",
75 + params: "751M",
76 + description: "Smallest Qwen3 — ideal for mobile & quick experiments",
77 + },
78 + BaseModel {
79 + repo_id: "Qwen/Qwen2.5-1.5B-Instruct",
80 + display_name: "Qwen2.5 1.5B Instruct",
81 + size_display: "~3.0 GB",
82 + params: "1.5B",
83 + description: "Balanced size — good for instruction-following tasks",
84 + },
85 + BaseModel {
86 + repo_id: "Qwen/Qwen3-1.7B",
87 + display_name: "Qwen3 1.7B",
88 + size_display: "~3.4 GB",
89 + params: "1.7B",
90 + description: "Latest Qwen3 small model — strong reasoning",
91 + },
92 + BaseModel {
93 + repo_id: "Qwen/Qwen3-4B",
94 + display_name: "Qwen3 4B",
95 + size_display: "~8.0 GB",
96 + params: "4B",
97 + description: "Larger Qwen3 — best quality, macOS recommended",
98 + },
99 +];
100 +
101 +// ---------------------------------------------------------------------------
102 +// HF API response types
103 +// ---------------------------------------------------------------------------
104 +
105 +#[derive(Debug, Deserialize)]
106 +struct HfRepoInfo {
107 + #[serde(default)]
108 + siblings: Vec<HfFileSibling>,
109 +}
110 +
111 +#[derive(Debug, Deserialize)]
112 +struct HfFileSibling {
113 + rfilename: String,
114 + #[serde(default)]
115 + size: Option<u64>,
116 +}
117 +
118 +// ---------------------------------------------------------------------------
119 +// Public API
120 +// ---------------------------------------------------------------------------
121 +
122 +/// Check whether a HuggingFace repo exists and inspect its contents.
123 +///
124 +/// Returns [`RepoStatus::NotFound`] if the repo doesn't exist (HTTP 404),
125 +/// [`RepoStatus::Empty`] if it exists but has no model files, or
126 +/// [`RepoStatus::HasModel`] if it contains safetensors or GGUF files.
127 +pub async fn check_repo(repo_id: &str, hf_token: &str) -> Result<RepoStatus> {
128 + let client = build_client()?;
129 +
130 + let mut request = client.get(format!("https://huggingface.co/api/models/{repo_id}"));
131 + if !hf_token.is_empty() {
132 + request = request.bearer_auth(hf_token);
133 + }
134 +
135 + let response = request
136 + .send()
137 + .await
138 + .context("Failed to reach HuggingFace API")?;
139 +
140 + if response.status() == reqwest::StatusCode::NOT_FOUND {
141 + return Ok(RepoStatus::NotFound);
142 + }
143 +
144 + let response = response
145 + .error_for_status()
146 + .context("HuggingFace API error")?;
147 +
148 + let info: HfRepoInfo = response
149 + .json()
150 + .await
151 + .context("Failed to parse repo metadata")?;
152 +
153 + let all_files: Vec<String> = info.siblings.iter().map(|s| s.rfilename.clone()).collect();
154 +
155 + // Check for model files: safetensors or GGUF
156 + let model_files: Vec<&HfFileSibling> = info
157 + .siblings
158 + .iter()
159 + .filter(|s| is_model_file(&s.rfilename))
160 + .collect();
161 +
162 + if model_files.is_empty() {
163 + Ok(RepoStatus::Empty {
164 + repo_id: repo_id.to_string(),
165 + files: all_files,
166 + })
167 + } else {
168 + let total_size: u64 = model_files.iter().filter_map(|s| s.size).sum();
169 + Ok(RepoStatus::HasModel {
170 + repo_id: repo_id.to_string(),
171 + files: all_files,
172 + model_size_bytes: total_size,
173 + })
174 + }
175 +}
176 +
177 +/// Create a HuggingFace repo if it doesn't already exist.
178 +///
179 +/// Returns `Ok(())` on success or if the repo already exists (HTTP 409).
180 +pub async fn create_repo(repo_id: &str, hf_token: &str) -> Result<()> {
181 + let client = build_client()?;
182 +
183 + let body = if let Some((org, name)) = repo_id.split_once('/') {
184 + serde_json::json!({
185 + "type": "model",
186 + "name": name,
187 + "organization": org,
188 + "private": false,
189 + "sdk": "onde-cli"
190 + })
191 + } else {
192 + serde_json::json!({
193 + "type": "model",
194 + "name": repo_id,
195 + "private": false,
196 + "sdk": "onde-cli"
197 + })
198 + };
199 +
200 + let response = client
201 + .post("https://huggingface.co/api/repos/create")
202 + .bearer_auth(hf_token)
203 + .json(&body)
204 + .send()
205 + .await
206 + .context("Failed to reach HuggingFace API")?;
207 +
208 + let status = response.status();
209 +
210 + // 200 = created, 409 = already exists — both are fine.
211 + if !status.is_success() && status.as_u16() != 409 {
212 + let body_text = response.text().await.unwrap_or_default();
213 + anyhow::bail!("Failed to create repo {repo_id}: {status} {body_text}");
214 + }
215 +
216 + Ok(())
217 +}
218 +
219 +/// Spawn-friendly entry point that checks a repo and sends progress via channel.
220 +pub async fn start_check_repo(
221 + repo_id: String,
222 + hf_token: String,
223 + tx: tokio::sync::mpsc::UnboundedSender<CloneProgress>,
224 +) {
225 + let _ = tx.send(CloneProgress::CheckingRepo);
226 +
227 + match check_repo(&repo_id, &hf_token).await {
228 + Ok(status) => {
229 + let _ = tx.send(CloneProgress::RepoChecked(status));
230 + }
231 + Err(e) => {
232 + let _ = tx.send(CloneProgress::Failed(format!("{e:#}")));
233 + }
234 + }
235 +}
236 +
237 +/// Create the repo (if needed) and report progress.
238 +pub async fn start_create_repo(
239 + repo_id: String,
240 + hf_token: String,
241 + tx: tokio::sync::mpsc::UnboundedSender<CloneProgress>,
242 +) {
243 + let _ = tx.send(CloneProgress::CreatingRepo);
244 +
245 + match create_repo(&repo_id, &hf_token).await {
246 + Ok(()) => {
247 + let _ = tx.send(CloneProgress::RepoReady);
248 + }
249 + Err(e) => {
250 + let _ = tx.send(CloneProgress::Failed(format!("{e:#}")));
251 + }
252 + }
253 +}
254 +
255 +// ---------------------------------------------------------------------------
256 +// Private helpers
257 +// ---------------------------------------------------------------------------
258 +
259 +fn build_client() -> Result<reqwest::Client> {
260 + reqwest::Client::builder()
261 + .user_agent("onde-cli/0.2")
262 + .redirect(reqwest::redirect::Policy::limited(10))
263 + .build()
264 + .context("Failed to build HTTP client")
265 +}
266 +
267 +/// Check if a filename looks like a model weight file.
268 +fn is_model_file(filename: &str) -> bool {
269 + let lower = filename.to_lowercase();
270 + lower.ends_with(".safetensors")
271 + || lower.ends_with(".gguf")
272 + || lower.ends_with(".bin")
273 + || lower.ends_with(".pt")
274 + || lower.ends_with(".pth")
275 +}
src/main.rs
+2
@@ -3,9 +3,11 @@ mod finetune;
3 mod gguf;
4 mod gresiq;
5 mod hf;
6 +mod hf_clone;
7 mod hf_search;
8 mod hf_upload;
9 mod merge;
10 +mod project;
11 mod token;
12 mod ui;
13
src/project.rs new
+361
@@ -0,0 +1,361 @@
1 +#![allow(dead_code)]
2 +//! Project workspace management for onde-cli fine-tuning.
3 +//!
4 +//! Each target HuggingFace repo (e.g. `ondeinference/joko`) gets its own
5 +//! workspace under `~/.onde/projects/{org}/{name}/`. The base model lives
6 +//! in the shared HuggingFace cache (read-only); all generated artifacts
7 +//! (LoRA adapters, merged models, GGUF exports) go into the project dir.
8 +
9 +use std::path::{Path, PathBuf};
10 +
11 +use anyhow::{Context, Result};
12 +use serde::{Deserialize, Serialize};
13 +
14 +// ---------------------------------------------------------------------------
15 +// Public types
16 +// ---------------------------------------------------------------------------
17 +
18 +/// Metadata stored in `project.json` at the project root.
19 +#[derive(Debug, Clone, Serialize, Deserialize)]
20 +pub struct ProjectMeta {
21 + /// Target HuggingFace repo ID, e.g. `ondeinference/joko`.
22 + pub repo_id: String,
23 + /// Base model HF repo used for fine-tuning, e.g. `Qwen/Qwen3-0.6B`.
24 + pub base_model_id: String,
25 + /// Unix timestamp when the project was created.
26 + pub created_at: u64,
27 +}
28 +
29 +/// A resolved project workspace with all paths computed.
30 +#[derive(Debug, Clone)]
31 +pub struct OndeProject {
32 + /// Target HF repo, e.g. `ondeinference/joko`.
33 + pub repo_id: String,
34 + /// Base model HF repo, e.g. `Qwen/Qwen3-0.6B`.
35 + pub base_model_id: String,
36 + /// Resolved path to the base model in the local HF cache.
37 + pub base_model_dir: String,
38 + /// Root of this project workspace, e.g. `~/.onde/projects/ondeinference/joko`.
39 + pub project_dir: PathBuf,
40 + /// Path to the dataset file: `{project_dir}/dataset/train.jsonl`.
41 + pub dataset_path: PathBuf,
42 + /// Path to the runs directory: `{project_dir}/runs/`.
43 + pub runs_dir: PathBuf,
44 +}
45 +
46 +// ---------------------------------------------------------------------------
47 +// Public API
48 +// ---------------------------------------------------------------------------
49 +
50 +/// Root directory for all onde projects.
51 +pub fn projects_root() -> PathBuf {
52 + dirs::home_dir()
53 + .unwrap_or_else(|| PathBuf::from("."))
54 + .join(".onde")
55 + .join("projects")
56 +}
57 +
58 +/// Create a new project workspace on disk and return its metadata.
59 +///
60 +/// - Creates `~/.onde/projects/{repo_id}/` with `dataset/` and `runs/` subdirs.
61 +/// - Writes `project.json` with the repo ID and base model.
62 +/// - Does NOT overwrite an existing `project.json`.
63 +pub fn create_project(repo_id: &str, base_model_id: &str) -> Result<OndeProject> {
64 + let project_dir = projects_root().join(repo_id);
65 + let dataset_dir = project_dir.join("dataset");
66 + let runs_dir = project_dir.join("runs");
67 +
68 + std::fs::create_dir_all(&dataset_dir)
69 + .with_context(|| format!("creating {}", dataset_dir.display()))?;
70 + std::fs::create_dir_all(&runs_dir)
71 + .with_context(|| format!("creating {}", runs_dir.display()))?;
72 +
73 + let meta_path = project_dir.join("project.json");
74 + let meta = if meta_path.exists() {
75 + // Load existing — don't overwrite (user may have changed base_model_id)
76 + let raw = std::fs::read_to_string(&meta_path)
77 + .with_context(|| format!("reading {}", meta_path.display()))?;
78 + serde_json::from_str::<ProjectMeta>(&raw)
79 + .with_context(|| format!("parsing {}", meta_path.display()))?
80 + } else {
81 + let now = std::time::SystemTime::now()
82 + .duration_since(std::time::UNIX_EPOCH)
83 + .unwrap_or_default()
84 + .as_secs();
85 + let meta = ProjectMeta {
86 + repo_id: repo_id.to_string(),
87 + base_model_id: base_model_id.to_string(),
88 + created_at: now,
89 + };
90 + let json = serde_json::to_string_pretty(&meta).context("serializing project.json")?;
91 + std::fs::write(&meta_path, json)
92 + .with_context(|| format!("writing {}", meta_path.display()))?;
93 + meta
94 + };
95 +
96 + Ok(OndeProject {
97 + repo_id: meta.repo_id,
98 + base_model_id: meta.base_model_id.clone(),
99 + base_model_dir: String::new(), // resolved later by the caller
100 + project_dir,
101 + dataset_path: dataset_dir.join("train.jsonl"),
102 + runs_dir,
103 + })
104 +}
105 +
106 +/// Load an existing project from disk.
107 +pub fn load_project(repo_id: &str) -> Result<OndeProject> {
108 + let project_dir = projects_root().join(repo_id);
109 + let meta_path = project_dir.join("project.json");
110 +
111 + if !meta_path.exists() {
112 + anyhow::bail!("no project found at {}", project_dir.display());
113 + }
114 +
115 + let raw = std::fs::read_to_string(&meta_path)
116 + .with_context(|| format!("reading {}", meta_path.display()))?;
117 + let meta: ProjectMeta =
118 + serde_json::from_str(&raw).with_context(|| format!("parsing {}", meta_path.display()))?;
119 +
120 + let dataset_path = project_dir.join("dataset").join("train.jsonl");
121 + let runs_dir = project_dir.join("runs");
122 +
123 + Ok(OndeProject {
124 + repo_id: meta.repo_id,
125 + base_model_id: meta.base_model_id,
126 + base_model_dir: String::new(),
127 + project_dir,
128 + dataset_path,
129 + runs_dir,
130 + })
131 +}
132 +
133 +/// List all existing projects under `~/.onde/projects/`.
134 +///
135 +/// Returns `(repo_id, ProjectMeta)` pairs sorted alphabetically.
136 +pub fn list_projects() -> Vec<(String, ProjectMeta)> {
137 + let root = projects_root();
138 + let mut projects = Vec::new();
139 +
140 + let Ok(orgs) = std::fs::read_dir(&root) else {
141 + return projects;
142 + };
143 +
144 + for org_entry in orgs.flatten() {
145 + let org_path = org_entry.path();
146 + if !org_path.is_dir() {
147 + continue;
148 + }
149 + let org_name = org_entry.file_name().to_string_lossy().to_string();
150 +
151 + let Ok(repos) = std::fs::read_dir(&org_path) else {
152 + continue;
153 + };
154 +
155 + for repo_entry in repos.flatten() {
156 + let repo_path = repo_entry.path();
157 + let meta_path = repo_path.join("project.json");
158 + if !meta_path.exists() {
159 + continue;
160 + }
161 +
162 + let repo_name = repo_entry.file_name().to_string_lossy().to_string();
163 + let repo_id = format!("{org_name}/{repo_name}");
164 +
165 + if let Ok(raw) = std::fs::read_to_string(&meta_path) {
166 + if let Ok(meta) = serde_json::from_str::<ProjectMeta>(&raw) {
167 + projects.push((repo_id, meta));
168 + }
169 + }
170 + }
171 + }
172 +
173 + projects.sort_by(|a, b| a.0.cmp(&b.0));
174 + projects
175 +}
176 +
177 +/// Generate a new timestamped run directory under the project.
178 +///
179 +/// Returns the path to the run dir (e.g. `{project}/runs/1750012345/`).
180 +/// The directory is created on disk.
181 +pub fn new_run_dir(project: &OndeProject) -> Result<PathBuf> {
182 + let ts = std::time::SystemTime::now()
183 + .duration_since(std::time::UNIX_EPOCH)
184 + .unwrap_or_default()
185 + .as_secs();
186 + let run_dir = project.runs_dir.join(format!("{ts}"));
187 + std::fs::create_dir_all(&run_dir)
188 + .with_context(|| format!("creating run dir {}", run_dir.display()))?;
189 + Ok(run_dir)
190 +}
191 +
192 +/// Scan a project's runs directory for all artifacts (LoRA adapters and GGUFs).
193 +///
194 +/// Each run is a timestamped subdirectory under `{project}/runs/`.
195 +/// Returns entries sorted by modification time (newest first).
196 +pub fn scan_project_artifacts(project: &OndeProject) -> Vec<ProjectArtifact> {
197 + let mut artifacts = Vec::new();
198 +
199 + let Ok(runs) = std::fs::read_dir(&project.runs_dir) else {
200 + return artifacts;
201 + };
202 +
203 + for run_entry in runs.flatten() {
204 + let run_dir = run_entry.path();
205 + if !run_dir.is_dir() {
206 + continue;
207 + }
208 + let run_name = run_entry.file_name().to_string_lossy().to_string();
209 +
210 + // Check for LoRA adapter
211 + let lora_file = run_dir.join("lora_adapter.safetensors");
212 + if lora_file.is_file() {
213 + if let Some(a) = artifact_from_path(&lora_file, &run_name, ArtifactType::LoraAdapter) {
214 + artifacts.push(a);
215 + }
216 + }
217 +
218 + // Check for merged model
219 + let merged_dir = run_dir.join("merged");
220 + if merged_dir.is_dir() {
221 + let merged_safetensors = merged_dir.join("model.safetensors");
222 + if merged_safetensors.is_file() {
223 + if let Some(a) =
224 + artifact_from_path(&merged_safetensors, &run_name, ArtifactType::MergedModel)
225 + {
226 + artifacts.push(a);
227 + }
228 + }
229 + }
230 +
231 + // Check for GGUF files
232 + if let Ok(files) = std::fs::read_dir(&run_dir) {
233 + for file in files.flatten() {
234 + let fname = file.file_name().to_string_lossy().to_string();
235 + if fname.ends_with(".gguf") {
236 + if let Some(a) = artifact_from_path(&file.path(), &run_name, ArtifactType::Gguf)
237 + {
238 + artifacts.push(a);
239 + }
240 + }
241 + }
242 + }
243 + }
244 +
245 + // Newest first
246 + artifacts.sort_by(|a, b| b.modified_ts.cmp(&a.modified_ts));
247 + artifacts
248 +}
249 +
250 +// ---------------------------------------------------------------------------
251 +// Artifact types
252 +// ---------------------------------------------------------------------------
253 +
254 +#[derive(Debug, Clone, PartialEq)]
255 +pub enum ArtifactType {
256 + LoraAdapter,
257 + MergedModel,
258 + Gguf,
259 +}
260 +
261 +/// An artifact found in a project's runs directory.
262 +#[derive(Debug, Clone)]
263 +pub struct ProjectArtifact {
264 + /// Full path to the artifact file.
265 + pub path: PathBuf,
266 + /// Name of the run directory (timestamp).
267 + pub run_name: String,
268 + /// Filename.
269 + pub file_name: String,
270 + /// Human-readable file size.
271 + pub size_display: String,
272 + /// Relative time display ("just now", "3h ago").
273 + pub modified_display: String,
274 + /// Raw modification timestamp for sorting.
275 + pub modified_ts: u64,
276 + /// What kind of artifact this is.
277 + pub kind: ArtifactType,
278 +}
279 +
280 +// ---------------------------------------------------------------------------
281 +// Private helpers
282 +// ---------------------------------------------------------------------------
283 +
284 +fn artifact_from_path(path: &Path, run_name: &str, kind: ArtifactType) -> Option<ProjectArtifact> {
285 + let meta = std::fs::metadata(path).ok()?;
286 + let size = if meta.is_dir() {
287 + dir_size(path)
288 + } else {
289 + meta.len()
290 + };
291 + let modified_ts = meta
292 + .modified()
293 + .ok()
294 + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
295 + .map(|d| d.as_secs())
296 + .unwrap_or(0);
297 +
298 + let now = std::time::SystemTime::now()
299 + .duration_since(std::time::UNIX_EPOCH)
300 + .unwrap_or_default()
301 + .as_secs();
302 + let age_secs = now.saturating_sub(modified_ts);
303 + let modified_display = format_age(age_secs);
304 +
305 + Some(ProjectArtifact {
306 + path: path.to_path_buf(),
307 + run_name: run_name.to_string(),
308 + file_name: path
309 + .file_name()
310 + .unwrap_or_default()
311 + .to_string_lossy()
312 + .to_string(),
313 + size_display: format_size(size),
314 + modified_display,
315 + modified_ts,
316 + kind,
317 + })
318 +}
319 +
320 +fn dir_size(path: &Path) -> u64 {
321 + let mut total = 0u64;
322 + if let Ok(entries) = std::fs::read_dir(path) {
323 + for entry in entries.flatten() {
324 + let p = entry.path();
325 + if p.is_file() {
326 + total += p.metadata().map(|m| m.len()).unwrap_or(0);
327 + } else if p.is_dir() {
328 + total += dir_size(&p);
329 + }
330 + }
331 + }
332 + total
333 +}
334 +
335 +fn format_size(bytes: u64) -> String {
336 + const KB: u64 = 1_024;
337 + const MB: u64 = 1_024 * KB;
338 + const GB: u64 = 1_024 * MB;
339 +
340 + if bytes >= GB {
341 + format!("{:.1}GB", bytes as f64 / GB as f64)
342 + } else if bytes >= MB {
343 + format!("{}MB", bytes / MB)
344 + } else if bytes >= KB {
345 + format!("{}KB", bytes / KB)
346 + } else {
347 + format!("{}B", bytes)
348 + }
349 +}
350 +
351 +fn format_age(secs: u64) -> String {
352 + if secs < 60 {
353 + "just now".to_string()
354 + } else if secs < 3600 {
355 + format!("{}m ago", secs / 60)
356 + } else if secs < 86400 {
357 + format!("{}h ago", secs / 3600)
358 + } else {
359 + format!("{}d ago", secs / 86400)
360 + }
361 +}
src/ui.rs
+174
@@ -86,6 +86,7 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) {
86 Screen::ModelDetail => render_model_detail(frame, app, inner),
87 Screen::GgufDetail => render_gguf_detail(frame, app, inner),
88 Screen::FineTune => render_finetune(frame, app, inner),
89 + Screen::CloneRepo => render_clone_repo(frame, app, inner),
90 }
91 }
92
@@ -1976,6 +1977,148 @@ fn render_adapter_list(frame: &mut Frame, app: &App, area: Rect) {
1977 }
1978 }
1979
1980 +fn render_clone_repo(frame: &mut Frame, app: &App, area: Rect) {
1981 + let is_picking_base = matches!(
1982 + &app.clone_repo_status,
1983 + Some(crate::hf_clone::RepoStatus::Empty { .. })
1984 + );
1985 +
1986 + let rows = Layout::vertical([
1987 + Constraint::Length(1), // 0 heading
1988 + Constraint::Length(1), // 1 spacer
1989 + Constraint::Length(1), // 2 status
1990 + Constraint::Length(1), // 3 spacer
1991 + Constraint::Length(1), // 4 Repo ID label
1992 + Constraint::Length(3), // 5 Repo ID input
1993 + Constraint::Length(1), // 6 spacer
1994 + Constraint::Length(1), // 7 result heading
1995 + Constraint::Min(0), // 8 result / base model list
1996 + ])
1997 + .split(area);
1998 +
1999 + // Heading
2000 + frame.render_widget(
2001 + Paragraph::new(Line::from(Span::styled(
2002 + "Clone / Check HuggingFace Repo",
2003 + Style::new().fg(C_INK).bold(),
2004 + ))),
2005 + rows[0],
2006 + );
2007 +
2008 + render_status(frame, app, rows[2]);
2009 +
2010 + // Repo ID input
2011 + frame.render_widget(
2012 + Paragraph::new("Repo ID (e.g. ondeinference/joko)").style(Style::new().fg(C_MUTED)),
2013 + rows[4],
2014 + );
2015 +
2016 + let input_focused = !app.clone_repo_checking && !is_picking_base;
2017 + let border_style = if input_focused {
2018 + Style::new().fg(C_NEON)
2019 + } else {
2020 + Style::new().fg(C_LINE)
2021 + };
2022 + let input_block = Block::new()
2023 + .borders(Borders::ALL)
2024 + .border_type(BorderType::Rounded)
2025 + .border_style(border_style)
2026 + .style(Style::new().bg(C_SURFACE_STRONG));
2027 + let input_inner = input_block.inner(rows[5]);
2028 + frame.render_widget(input_block, rows[5]);
2029 + frame.render_widget(
2030 + Paragraph::new(app.clone_repo_id.as_str()).style(Style::new().fg(C_TEXT)),
2031 + input_inner,
2032 + );
2033 + if input_focused {
2034 + let cursor_x = (input_inner.x + app.clone_repo_id.chars().count() as u16)
2035 + .min(input_inner.x + input_inner.width.saturating_sub(1));
2036 + frame.set_cursor_position((cursor_x, input_inner.y));
2037 + }
2038 +
2039 + // Result section
2040 + match &app.clone_repo_status {
2041 + Some(crate::hf_clone::RepoStatus::NotFound) => {
2042 + frame.render_widget(
2043 + Paragraph::new(Line::from(vec![
2044 + Span::styled("✗ ", Style::new().fg(C_DANGER)),
2045 + Span::styled(
2046 + "Repo not found. Press Enter to create it.",
2047 + Style::new().fg(C_MUTED),
2048 + ),
2049 + ])),
2050 + rows[7],
2051 + );
2052 + }
2053 + Some(crate::hf_clone::RepoStatus::HasModel { files, .. }) => {
2054 + frame.render_widget(
2055 + Paragraph::new(Line::from(vec![
2056 + Span::styled("✓ ", Style::new().fg(C_NEON)),
2057 + Span::styled(
2058 + format!("Repo has {} model file(s). Already set up.", files.len()),
2059 + Style::new().fg(C_NEON),
2060 + ),
2061 + ])),
2062 + rows[7],
2063 + );
2064 + }
2065 + Some(crate::hf_clone::RepoStatus::Empty { .. }) => {
2066 + frame.render_widget(
2067 + Paragraph::new("Select a base model for fine-tuning:")
2068 + .style(Style::new().fg(C_MUTED)),
2069 + rows[7],
2070 + );
2071 +
2072 + // Render base model list
2073 + let base_models = crate::hf_clone::BASE_MODELS;
2074 + let items: Vec<Line> = base_models
2075 + .iter()
2076 + .enumerate()
2077 + .map(|(i, m)| {
2078 + let selected = i == app.clone_base_cursor;
2079 + let prefix = if selected { "▸ " } else { " " };
2080 + let style = if selected {
2081 + Style::new().fg(C_NEON).bold()
2082 + } else {
2083 + Style::new().fg(C_TEXT)
2084 + };
2085 + Line::from(vec![
2086 + Span::styled(prefix, style),
2087 + Span::styled(m.display_name, style),
2088 + Span::styled(
2089 + format!(" {} {}", m.size_display, m.params),
2090 + Style::new().fg(C_MUTED),
2091 + ),
2092 + ])
2093 + })
2094 + .collect();
2095 +
2096 + // Add description of selected model below the list
2097 + let selected_desc = base_models
2098 + .get(app.clone_base_cursor)
2099 + .map(|m| m.description)
2100 + .unwrap_or("");
2101 +
2102 + let mut all_lines = items;
2103 + all_lines.push(Line::from(""));
2104 + all_lines.push(Line::from(Span::styled(
2105 + selected_desc,
2106 + Style::new().fg(C_MUTED).italic(),
2107 + )));
2108 +
2109 + frame.render_widget(Paragraph::new(all_lines), rows[8]);
2110 + }
2111 + None => {
2112 + if app.clone_repo_checking {
2113 + frame.render_widget(
2114 + Paragraph::new("⠿ Checking…").style(Style::new().fg(C_MUTED)),
2115 + rows[7],
2116 + );
2117 + }
2118 + }
2119 + }
2120 +}
2121 +
2122 fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2123 let keys: Vec<Span> = match app.screen {
2124 Screen::Auth => vec![
@@ -2059,6 +2202,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2202 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2203 Span::styled("/", Style::new().fg(C_NEON)),
2204 Span::styled(" · search HF ", Style::new().fg(C_MUTED)),
2205 + Span::styled("c", Style::new().fg(C_NEON)),
2206 + Span::styled(" · clone repo ", Style::new().fg(C_MUTED)),
2207 Span::styled("Enter", Style::new().fg(C_NEON)),
2208 Span::styled(" · detail ", Style::new().fg(C_MUTED)),
2209 Span::styled("Tab", Style::new().fg(C_NEON)),
@@ -2129,6 +2274,35 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2274 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2275 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2276 ],
2277 + Screen::CloneRepo if app.clone_repo_checking => vec![
2278 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2279 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
2280 + ],
2281 + Screen::CloneRepo
2282 + if matches!(
2283 + &app.clone_repo_status,
2284 + Some(crate::hf_clone::RepoStatus::Empty { .. })
2285 + ) =>
2286 + {
2287 + vec![
2288 + Span::styled("↑↓", Style::new().fg(C_NEON)),
2289 + Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2290 + Span::styled("Enter", Style::new().fg(C_NEON)),
2291 + Span::styled(" · select & download ", Style::new().fg(C_MUTED)),
2292 + Span::styled("Esc", Style::new().fg(C_NEON)),
2293 + Span::styled(" · back ", Style::new().fg(C_MUTED)),
2294 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2295 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
2296 + ]
2297 + }
2298 + Screen::CloneRepo => vec![
2299 + Span::styled("Enter", Style::new().fg(C_NEON)),
2300 + Span::styled(" · check repo ", Style::new().fg(C_MUTED)),
2301 + Span::styled("Esc", Style::new().fg(C_NEON)),
2302 + Span::styled(" · back ", Style::new().fg(C_MUTED)),
2303 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2304 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
2305 + ],
2306 };
2307
2308 frame.render_widget(