@ondeinference / cli / commits / 2332107

Add Onde inference model picker screen and UI integration

keypair34 committed Apr 26, 2026 at 07:47 UTC 2332107be1ea4fa151a3f1d840e98aa115985266
5 files changed +809 -9
Cargo.lock
+357 -1
@@ -220,6 +220,61 @@ dependencies = [
220 "stable_deref_trait",
221 ]
222
223 +[[package]]
224 +name = "askama"
225 +version = "0.14.0"
226 +source = "registry+https://github.com/rust-lang/crates.io-index"
227 +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
228 +dependencies = [
229 + "askama_derive",
230 + "itoa",
231 + "percent-encoding",
232 + "serde",
233 + "serde_json",
234 +]
235 +
236 +[[package]]
237 +name = "askama_derive"
238 +version = "0.14.0"
239 +source = "registry+https://github.com/rust-lang/crates.io-index"
240 +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
241 +dependencies = [
242 + "askama_parser",
243 + "basic-toml",
244 + "memchr",
245 + "proc-macro2",
246 + "quote",
247 + "rustc-hash 2.1.2",
248 + "serde",
249 + "serde_derive",
250 + "syn 2.0.117",
251 +]
252 +
253 +[[package]]
254 +name = "askama_parser"
255 +version = "0.14.0"
256 +source = "registry+https://github.com/rust-lang/crates.io-index"
257 +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
258 +dependencies = [
259 + "memchr",
260 + "serde",
261 + "serde_derive",
262 + "winnow 0.7.15",
263 +]
264 +
265 +[[package]]
266 +name = "async-compat"
267 +version = "0.2.5"
268 +source = "registry+https://github.com/rust-lang/crates.io-index"
269 +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590"
270 +dependencies = [
271 + "futures-core",
272 + "futures-io",
273 + "once_cell",
274 + "pin-project-lite",
275 + "tokio",
276 +]
277 +
278 [[package]]
279 name = "async-trait"
280 version = "0.1.89"
@@ -331,6 +386,15 @@ version = "0.22.1"
386 source = "registry+https://github.com/rust-lang/crates.io-index"
387 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
388
389 +[[package]]
390 +name = "basic-toml"
391 +version = "0.1.10"
392 +source = "registry+https://github.com/rust-lang/crates.io-index"
393 +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
394 +dependencies = [
395 + "serde",
396 +]
397 +
398 [[package]]
399 name = "bit-set"
400 version = "0.5.3"
@@ -520,6 +584,15 @@ version = "0.1.1"
584 source = "registry+https://github.com/rust-lang/crates.io-index"
585 checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
586
587 +[[package]]
588 +name = "camino"
589 +version = "1.2.2"
590 +source = "registry+https://github.com/rust-lang/crates.io-index"
591 +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
592 +dependencies = [
593 + "serde_core",
594 +]
595 +
596 [[package]]
597 name = "candle-core"
598 version = "0.10.2"
@@ -593,6 +666,29 @@ dependencies = [
666 "ug-metal",
667 ]
668
669 +[[package]]
670 +name = "cargo-platform"
671 +version = "0.1.9"
672 +source = "registry+https://github.com/rust-lang/crates.io-index"
673 +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
674 +dependencies = [
675 + "serde",
676 +]
677 +
678 +[[package]]
679 +name = "cargo_metadata"
680 +version = "0.19.2"
681 +source = "registry+https://github.com/rust-lang/crates.io-index"
682 +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
683 +dependencies = [
684 + "camino",
685 + "cargo-platform",
686 + "semver",
687 + "serde",
688 + "serde_json",
689 + "thiserror 2.0.18",
690 +]
691 +
692 [[package]]
693 name = "cassowary"
694 version = "0.3.0"
@@ -676,7 +772,7 @@ dependencies = [
772 "atty",
773 "bitflags 1.3.2",
774 "strsim 0.8.0",
679 - "textwrap",
775 + "textwrap 0.11.0",
776 "unicode-width 0.1.14",
777 "vec_map",
778 ]
@@ -1625,6 +1721,15 @@ dependencies = [
1721 "percent-encoding",
1722 ]
1723
1724 +[[package]]
1725 +name = "fs-err"
1726 +version = "2.11.0"
1727 +source = "registry+https://github.com/rust-lang/crates.io-index"
1728 +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41"
1729 +dependencies = [
1730 + "autocfg",
1731 +]
1732 +
1733 [[package]]
1734 name = "fs_extra"
1735 version = "1.3.0"
@@ -2068,6 +2173,23 @@ dependencies = [
2173 "weezl",
2174 ]
2175
2176 +[[package]]
2177 +name = "glob"
2178 +version = "0.3.3"
2179 +source = "registry+https://github.com/rust-lang/crates.io-index"
2180 +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
2181 +
2182 +[[package]]
2183 +name = "goblin"
2184 +version = "0.8.2"
2185 +source = "registry+https://github.com/rust-lang/crates.io-index"
2186 +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
2187 +dependencies = [
2188 + "log",
2189 + "plain",
2190 + "scroll",
2191 +]
2192 +
2193 [[package]]
2194 name = "h2"
2195 version = "0.4.13"
@@ -2203,6 +2325,15 @@ dependencies = [
2325 "windows-sys 0.60.2",
2326 ]
2327
2328 +[[package]]
2329 +name = "home"
2330 +version = "0.5.12"
2331 +source = "registry+https://github.com/rust-lang/crates.io-index"
2332 +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
2333 +dependencies = [
2334 + "windows-sys 0.61.2",
2335 +]
2336 +
2337 [[package]]
2338 name = "hound"
2339 version = "3.5.1"
@@ -3638,6 +3769,28 @@ version = "1.70.2"
3769 source = "registry+https://github.com/rust-lang/crates.io-index"
3770 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
3771
3772 +[[package]]
3773 +name = "onde"
3774 +version = "0.1.8"
3775 +dependencies = [
3776 + "anyhow",
3777 + "cc",
3778 + "dotenvy",
3779 + "hf-hub",
3780 + "home",
3781 + "log",
3782 + "mistralrs",
3783 + "mistralrs-core",
3784 + "reqwest 0.12.28",
3785 + "serde",
3786 + "serde_json",
3787 + "smbcloud-gresiq-sdk",
3788 + "thiserror 2.0.18",
3789 + "tokio",
3790 + "tsync",
3791 + "uniffi",
3792 +]
3793 +
3794 [[package]]
3795 name = "onde-cli"
3796 version = "0.2.1"
@@ -3653,6 +3806,7 @@ dependencies = [
3806 "libc",
3807 "log",
3808 "mistralrs",
3809 + "onde",
3810 "ratatui",
3811 "reqwest 0.12.28",
3812 "serde",
@@ -3848,6 +4002,12 @@ version = "0.3.33"
4002 source = "registry+https://github.com/rust-lang/crates.io-index"
4003 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
4004
4005 +[[package]]
4006 +name = "plain"
4007 +version = "0.2.3"
4008 +source = "registry+https://github.com/rust-lang/crates.io-index"
4009 +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
4010 +
4011 [[package]]
4012 name = "png"
4013 version = "0.18.1"
@@ -4846,6 +5006,26 @@ dependencies = [
5006 "tendril 0.4.3",
5007 ]
5008
5009 +[[package]]
5010 +name = "scroll"
5011 +version = "0.12.0"
5012 +source = "registry+https://github.com/rust-lang/crates.io-index"
5013 +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
5014 +dependencies = [
5015 + "scroll_derive",
5016 +]
5017 +
5018 +[[package]]
5019 +name = "scroll_derive"
5020 +version = "0.12.1"
5021 +source = "registry+https://github.com/rust-lang/crates.io-index"
5022 +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
5023 +dependencies = [
5024 + "proc-macro2",
5025 + "quote",
5026 + "syn 2.0.117",
5027 +]
5028 +
5029 [[package]]
5030 name = "security-framework"
5031 version = "3.7.0"
@@ -4893,6 +5073,10 @@ name = "semver"
5073 version = "1.0.28"
5074 source = "registry+https://github.com/rust-lang/crates.io-index"
5075 checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
5076 +dependencies = [
5077 + "serde",
5078 + "serde_core",
5079 +]
5080
5081 [[package]]
5082 name = "seq-macro"
@@ -5185,6 +5369,12 @@ version = "2.0.0-alpha.12"
5369 source = "registry+https://github.com/rust-lang/crates.io-index"
5370 checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857"
5371
5372 +[[package]]
5373 +name = "smawk"
5374 +version = "0.3.2"
5375 +source = "registry+https://github.com/rust-lang/crates.io-index"
5376 +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
5377 +
5378 [[package]]
5379 name = "smbcloud-auth-sdk"
5380 version = "0.3.34"
@@ -5717,6 +5907,19 @@ dependencies = [
5907 "libc",
5908 ]
5909
5910 +[[package]]
5911 +name = "tempfile"
5912 +version = "3.27.0"
5913 +source = "registry+https://github.com/rust-lang/crates.io-index"
5914 +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
5915 +dependencies = [
5916 + "fastrand",
5917 + "getrandom 0.4.2",
5918 + "once_cell",
5919 + "rustix 1.1.4",
5920 + "windows-sys 0.61.2",
5921 +]
5922 +
5923 [[package]]
5924 name = "tendril"
5925 version = "0.4.3"
@@ -5757,6 +5960,15 @@ dependencies = [
5960 "unicode-width 0.1.14",
5961 ]
5962
5963 +[[package]]
5964 +name = "textwrap"
5965 +version = "0.16.2"
5966 +source = "registry+https://github.com/rust-lang/crates.io-index"
5967 +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
5968 +dependencies = [
5969 + "smawk",
5970 +]
5971 +
5972 [[package]]
5973 name = "thiserror"
5974 version = "1.0.69"
@@ -6398,6 +6610,138 @@ version = "0.1.1"
6610 source = "registry+https://github.com/rust-lang/crates.io-index"
6611 checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
6612
6613 +[[package]]
6614 +name = "uniffi"
6615 +version = "0.31.0"
6616 +source = "registry+https://github.com/rust-lang/crates.io-index"
6617 +checksum = "b8c6dec3fc6645f71a16a3fa9ff57991028153bd194ca97f4b55e610c73ce66a"
6618 +dependencies = [
6619 + "anyhow",
6620 + "cargo_metadata",
6621 + "uniffi_bindgen",
6622 + "uniffi_build",
6623 + "uniffi_core",
6624 + "uniffi_macros",
6625 + "uniffi_pipeline",
6626 +]
6627 +
6628 +[[package]]
6629 +name = "uniffi_bindgen"
6630 +version = "0.31.0"
6631 +source = "registry+https://github.com/rust-lang/crates.io-index"
6632 +checksum = "4ed0150801958d4825da56a41c71f000a457ac3a4613fa9647df78ac4b6b6881"
6633 +dependencies = [
6634 + "anyhow",
6635 + "askama",
6636 + "camino",
6637 + "cargo_metadata",
6638 + "fs-err",
6639 + "glob",
6640 + "goblin",
6641 + "heck 0.5.0",
6642 + "indexmap 2.14.0",
6643 + "once_cell",
6644 + "serde",
6645 + "tempfile",
6646 + "textwrap 0.16.2",
6647 + "toml",
6648 + "uniffi_internal_macros",
6649 + "uniffi_meta",
6650 + "uniffi_pipeline",
6651 + "uniffi_udl",
6652 +]
6653 +
6654 +[[package]]
6655 +name = "uniffi_build"
6656 +version = "0.31.0"
6657 +source = "registry+https://github.com/rust-lang/crates.io-index"
6658 +checksum = "b78fd9271a4c2e85bd2c266c5a9ede1fac676eb39fd77f636c27eaf67426fd5f"
6659 +dependencies = [
6660 + "anyhow",
6661 + "camino",
6662 + "uniffi_bindgen",
6663 +]
6664 +
6665 +[[package]]
6666 +name = "uniffi_core"
6667 +version = "0.31.0"
6668 +source = "registry+https://github.com/rust-lang/crates.io-index"
6669 +checksum = "b0ef62e69762fbb9386dcb6c87cd3dd05d525fa8a3a579a290892e60ddbda47e"
6670 +dependencies = [
6671 + "anyhow",
6672 + "async-compat",
6673 + "bytes",
6674 + "once_cell",
6675 + "static_assertions",
6676 +]
6677 +
6678 +[[package]]
6679 +name = "uniffi_internal_macros"
6680 +version = "0.31.0"
6681 +source = "registry+https://github.com/rust-lang/crates.io-index"
6682 +checksum = "98f51ebca0d9a4b2aa6c644d5ede45c56f73906b96403c08a1985e75ccb64a01"
6683 +dependencies = [
6684 + "anyhow",
6685 + "indexmap 2.14.0",
6686 + "proc-macro2",
6687 + "quote",
6688 + "syn 2.0.117",
6689 +]
6690 +
6691 +[[package]]
6692 +name = "uniffi_macros"
6693 +version = "0.31.0"
6694 +source = "registry+https://github.com/rust-lang/crates.io-index"
6695 +checksum = "db9d12529f1223d014fd501e5f29ca0884d15d6ed5ddddd9f506e55350327dc3"
6696 +dependencies = [
6697 + "camino",
6698 + "fs-err",
6699 + "once_cell",
6700 + "proc-macro2",
6701 + "quote",
6702 + "serde",
6703 + "syn 2.0.117",
6704 + "toml",
6705 + "uniffi_meta",
6706 +]
6707 +
6708 +[[package]]
6709 +name = "uniffi_meta"
6710 +version = "0.31.0"
6711 +source = "registry+https://github.com/rust-lang/crates.io-index"
6712 +checksum = "9df6d413db2827c68588f8149d30d49b71d540d46539e435b23a7f7dbd4d4f86"
6713 +dependencies = [
6714 + "anyhow",
6715 + "siphasher",
6716 + "uniffi_internal_macros",
6717 + "uniffi_pipeline",
6718 +]
6719 +
6720 +[[package]]
6721 +name = "uniffi_pipeline"
6722 +version = "0.31.0"
6723 +source = "registry+https://github.com/rust-lang/crates.io-index"
6724 +checksum = "a806dddc8208f22efd7e95a5cdf88ed43d0f3271e8f63b47e757a8bbdb43b63a"
6725 +dependencies = [
6726 + "anyhow",
6727 + "heck 0.5.0",
6728 + "indexmap 2.14.0",
6729 + "tempfile",
6730 + "uniffi_internal_macros",
6731 +]
6732 +
6733 +[[package]]
6734 +name = "uniffi_udl"
6735 +version = "0.31.0"
6736 +source = "registry+https://github.com/rust-lang/crates.io-index"
6737 +checksum = "0d1a7339539bf6f6fa3e9b534dece13f778bda2d54b1a6d4e40b4d6090ac26e7"
6738 +dependencies = [
6739 + "anyhow",
6740 + "textwrap 0.16.2",
6741 + "uniffi_meta",
6742 + "weedle2",
6743 +]
6744 +
6745 [[package]]
6746 name = "unit-prefix"
6747 version = "0.5.2"
@@ -6784,6 +7128,15 @@ dependencies = [
7128 "rustls-pki-types",
7129 ]
7130
7131 +[[package]]
7132 +name = "weedle2"
7133 +version = "5.0.0"
7134 +source = "registry+https://github.com/rust-lang/crates.io-index"
7135 +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
7136 +dependencies = [
7137 + "nom 7.1.3",
7138 +]
7139 +
7140 [[package]]
7141 name = "weezl"
7142 version = "0.1.12"
@@ -7307,6 +7660,9 @@ name = "winnow"
7660 version = "0.7.15"
7661 source = "registry+https://github.com/rust-lang/crates.io-index"
7662 checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
7663 +dependencies = [
7664 + "memchr",
7665 +]
7666
7667 [[package]]
7668 name = "winnow"
Cargo.toml
+1
@@ -41,6 +41,7 @@ serde_json = "1"
41 half = "2"
42 sha2 = "0.10"
43 mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", branch = "fix/all-platform-fixes" }
44 +onde = { path = "../onde" }
45
46 # Metal and Accelerate are macOS-only frameworks; Cargo merges features so on
47 # macOS these are added on top of the base candle-core dep above.
src/app.rs
+309 -3
@@ -38,7 +38,9 @@ pub enum Screen {
38 Auth,
39 Apps,
40 AppDetail,
41 + #[allow(dead_code)]
42 Models,
43 + InferenceModels,
44 Downloads,
45 ModelDetail,
46 FineTune,
@@ -47,6 +49,28 @@ pub enum Screen {
49 Chat,
50 }
51
52 +/// An entry in the Onde inference model picker.
53 +#[derive(Clone)]
54 +pub struct InferenceModelEntry {
55 + /// HuggingFace model ID (e.g. "bartowski/Qwen2.5-3B-Instruct-GGUF")
56 + pub model_id: String,
57 + /// Human-readable name (e.g. "Qwen 2.5 3B (GGUF)")
58 + pub display_name: String,
59 + /// Organisation / publisher
60 + #[allow(dead_code)]
61 + pub org: String,
62 + /// Short description
63 + #[allow(dead_code)]
64 + pub description: String,
65 + /// Expected size in bytes
66 + pub expected_size_bytes: u64,
67 + /// Whether the model is already downloaded locally
68 + pub downloaded: bool,
69 + /// Which cache it lives in (if downloaded)
70 + #[allow(dead_code)]
71 + pub cache_source: Option<crate::hf::CacheSource>,
72 +}
73 +
74 /// What kind of artifact was found on disk.
75 #[derive(Clone, PartialEq)]
76 pub enum ArtifactKind {
@@ -205,7 +229,9 @@ pub enum AuthEvent {
229 },
230 AppRenameFailed(String),
231 // models (remote catalog for assignment)
232 + #[allow(dead_code)]
233 ModelsLoaded(Vec<OndeModel>),
234 + #[allow(dead_code)]
235 ModelsLoadFailed(String),
236 ModelAssigned {
237 app_index: usize,
@@ -223,6 +249,8 @@ pub enum AuthEvent {
249 ModelDownloadProgress(crate::hf_search::DownloadProgress),
250 ModelDownloadComplete(String), // model_id
251 ModelDownloadFailed(String),
252 + // inference model picker
253 + InferenceModelDownloadComplete(String), // model_id
254 // fine-tune
255 FineTuneProgress(crate::finetune::FineTuneProgress),
256 // merge + GGUF export
@@ -319,6 +347,12 @@ pub struct App {
347 pub chat_progress: Option<crate::chat::ChatProgress>,
348 pub chat_command_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::chat::ChatCommand>>,
349 pub chat_scroll_offset: usize,
350 + // inference model picker (Onde local GGUF models)
351 + pub inference_models: Vec<InferenceModelEntry>,
352 + pub inference_cursor: usize,
353 + pub inference_offset: usize,
354 + // deferred navigation: open ModelDetail after downloads finish loading
355 + pub pending_model_detail: bool,
356 }
357
358 impl App {
@@ -390,6 +424,10 @@ impl App {
424 chat_progress: None,
425 chat_command_tx: None,
426 chat_scroll_offset: 0,
427 + inference_models: Vec::new(),
428 + inference_cursor: 0,
429 + inference_offset: 0,
430 + pending_model_detail: false,
431 }
432 }
433
@@ -416,6 +454,7 @@ impl App {
454 AuthEvent::ModelDownloadProgress(_)
455 | AuthEvent::ModelDownloadComplete(_)
456 | AuthEvent::ModelDownloadFailed(_)
457 + | AuthEvent::InferenceModelDownloadComplete(_)
458 | AuthEvent::HfSearchResults(_)
459 | AuthEvent::HfSearchFailed(_)
460 | AuthEvent::MergeProgress(_)
@@ -550,7 +589,22 @@ impl App {
589 self.busy = false;
590 self.downloads = models;
591 self.downloads_loaded = true;
553 - if self.downloads.is_empty() {
592 + if self.pending_model_detail {
593 + self.pending_model_detail = false;
594 + let active_model = self
595 + .apps
596 + .get(self.apps_cursor)
597 + .and_then(|a| a.active_model.clone());
598 + let current_model_id = self
599 + .apps
600 + .get(self.apps_cursor)
601 + .and_then(|a| a.current_model_id.clone());
602 + navigate_to_model_detail(
603 + self,
604 + active_model.as_deref(),
605 + current_model_id.as_deref(),
606 + );
607 + } else if self.downloads.is_empty() {
608 self.status = Status::neutral("No models downloaded yet.");
609 } else {
610 self.status = Status::success(format!(
@@ -607,6 +661,13 @@ impl App {
661 self.download_progress = None;
662 self.status = Status::error(msg);
663 }
664 + AuthEvent::InferenceModelDownloadComplete(model_id) => {
665 + self.downloading = false;
666 + self.download_progress = None;
667 + // Refresh the inference model list so the downloaded badge updates
668 + self.inference_models = build_inference_entries();
669 + self.status = Status::success(format!("{model_id} downloaded and ready."));
670 + }
671 AuthEvent::MergeProgress(progress) => {
672 match &progress {
673 crate::merge::MergeProgress::Done { output_path } => {
@@ -713,7 +774,7 @@ impl App {
774 self.chat_scroll_offset = self.chat_messages.len().saturating_sub(1);
775 }
776 crate::chat::ChatProgress::Reply {
716 - text: _,
777 + _text: _,
778 duration_display,
779 } => {
780 self.chat_thinking = false;
@@ -845,6 +906,7 @@ fn handle_key(
906 Screen::Auth => handle_key_auth(app, key, tx),
907 Screen::Apps => handle_key_apps(app, key, tx),
908 Screen::AppDetail => handle_key_app_detail(app, key, tx),
909 + Screen::InferenceModels => handle_key_inference_models(app, key, tx),
910 Screen::Models => handle_key_models(app, key, tx),
911 Screen::Downloads => handle_key_downloads(app, key, tx),
912 Screen::ModelDetail => handle_key_model_detail(app, key, tx),
@@ -1006,8 +1068,11 @@ fn handle_key_app_detail(
1068 app.screen = Screen::Apps;
1069 app.status = Status::neutral("Back to apps.");
1070 }
1071 + (Enter, _) => {
1072 + open_current_model_detail(app, tx.clone());
1073 + }
1074 (Char('m'), KeyModifiers::NONE) => {
1010 - open_model_picker(app, tx);
1075 + open_inference_picker(app);
1076 }
1077 (Char('r'), KeyModifiers::NONE) => {
1078 let current_name = app
@@ -2345,6 +2410,7 @@ fn clamp_models_scroll(app: &mut App, max_visible: usize) {
2410 }
2411 }
2412
2413 +#[allow(dead_code)]
2414 fn open_model_picker(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2415 if app.apps.is_empty() || app.busy {
2416 return;
@@ -2513,3 +2579,243 @@ fn extract_error(e: &ErrorResponse) -> String {
2579 ErrorResponse::Error { message, .. } => message.clone(),
2580 }
2581 }
2582 +
2583 +// ---------------------------------------------------------------------------
2584 +// Inference model picker
2585 +// ---------------------------------------------------------------------------
2586 +
2587 +fn build_inference_entries() -> Vec<InferenceModelEntry> {
2588 + let local = crate::hf::list_local_models();
2589 +
2590 + onde::inference::models::SUPPORTED_MODEL_INFO
2591 + .iter()
2592 + .map(|info| {
2593 + let local_match = local.iter().find(|m| m.model_id == info.id);
2594 + InferenceModelEntry {
2595 + model_id: info.id.to_string(),
2596 + display_name: info.name.to_string(),
2597 + org: info.org.to_string(),
2598 + description: info.description.to_string(),
2599 + expected_size_bytes: info.expected_size_bytes,
2600 + downloaded: local_match.is_some(),
2601 + cache_source: local_match.map(|m| m.source.clone()),
2602 + }
2603 + })
2604 + .collect()
2605 +}
2606 +
2607 +fn open_inference_picker(app: &mut App) {
2608 + if app.busy {
2609 + return;
2610 + }
2611 + app.assigning_for_app_index = Some(app.apps_cursor);
2612 + app.inference_models = build_inference_entries();
2613 + app.inference_cursor = 0;
2614 + app.inference_offset = 0;
2615 + app.screen = Screen::InferenceModels;
2616 + app.status = Status::neutral("Pick an Onde inference model.");
2617 +}
2618 +
2619 +fn open_current_model_detail(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2620 + let Some(onde_app) = app.apps.get(app.apps_cursor) else {
2621 + return;
2622 + };
2623 +
2624 + let active_model = onde_app.active_model.clone();
2625 + let current_model_id = onde_app.current_model_id.clone();
2626 +
2627 + // If downloads aren't loaded yet, load them first and defer navigation
2628 + if !app.downloads_loaded {
2629 + app.pending_model_detail = true;
2630 + trigger_load_downloads(app, tx);
2631 + return;
2632 + }
2633 +
2634 + navigate_to_model_detail(app, active_model.as_deref(), current_model_id.as_deref());
2635 +}
2636 +
2637 +fn navigate_to_model_detail(
2638 + app: &mut App,
2639 + active_model: Option<&str>,
2640 + current_model_id: Option<&str>,
2641 +) {
2642 + // Try to find the model in downloads
2643 + let found_index = app.downloads.iter().position(|d| {
2644 + // Match by catalog model ID
2645 + if let Some(mid) = current_model_id
2646 + && let Some(ref cm) = d.catalog_model
2647 + && cm.id == mid
2648 + {
2649 + return true;
2650 + }
2651 + // Match by display name
2652 + if let Some(name) = active_model
2653 + && d.display_name == name
2654 + {
2655 + return true;
2656 + }
2657 + false
2658 + });
2659 +
2660 + if let Some(idx) = found_index {
2661 + app.downloads_cursor = idx;
2662 + // Scan adapters
2663 + if let Some(model) = app.downloads.get(idx) {
2664 + let resolved = resolve_hf_cache_path(&model.model_id);
2665 + app.adapter_list = scan_adapters(&resolved);
2666 + app.adapter_cursor = 0;
2667 + }
2668 + app.screen = Screen::ModelDetail;
2669 + if app.adapter_list.is_empty() {
2670 + app.status = Status::neutral("Model details.");
2671 + } else {
2672 + app.status = Status::success(format!(
2673 + "{} adapter{} found.",
2674 + app.adapter_list.len(),
2675 + if app.adapter_list.len() == 1 { "" } else { "s" }
2676 + ));
2677 + }
2678 + } else {
2679 + app.status = Status::error("Model not found locally. Press m to pick a model.");
2680 + }
2681 +}
2682 +
2683 +fn handle_key_inference_models(
2684 + app: &mut App,
2685 + key: crossterm::event::KeyEvent,
2686 + tx: mpsc::UnboundedSender<AuthEvent>,
2687 +) {
2688 + use crossterm::event::KeyCode::*;
2689 + match (key.code, key.modifiers) {
2690 + (Up, _) | (Char('k'), KeyModifiers::NONE) => {
2691 + app.inference_cursor = app.inference_cursor.saturating_sub(1);
2692 + clamp_inference_scroll(app, MAX_VISIBLE);
2693 + }
2694 + (Down, _) | (Char('j'), KeyModifiers::NONE) => {
2695 + if app.inference_cursor + 1 < app.inference_models.len() {
2696 + app.inference_cursor += 1;
2697 + }
2698 + clamp_inference_scroll(app, MAX_VISIBLE);
2699 + }
2700 + (Enter, _) => {
2701 + if let Some(entry) = app.inference_models.get(app.inference_cursor).cloned() {
2702 + if entry.downloaded {
2703 + assign_inference_model(app, &entry, tx.clone());
2704 + } else {
2705 + trigger_inference_download(app, tx);
2706 + }
2707 + }
2708 + }
2709 + (Esc, _) => {
2710 + app.screen = Screen::AppDetail;
2711 + app.assigning_for_app_index = None;
2712 + app.status = Status::neutral("Back to app.");
2713 + }
2714 + (Char('c'), KeyModifiers::CONTROL) => {
2715 + app.should_quit = true;
2716 + }
2717 + _ => {}
2718 + }
2719 +}
2720 +
2721 +fn assign_inference_model(
2722 + app: &mut App,
2723 + entry: &InferenceModelEntry,
2724 + tx: mpsc::UnboundedSender<AuthEvent>,
2725 +) {
2726 + let Some(app_index) = app.assigning_for_app_index else {
2727 + // No app selected — just go back
2728 + app.screen = Screen::AppDetail;
2729 + app.status = Status::success(format!("{} is ready.", entry.display_name));
2730 + return;
2731 + };
2732 + let Some(onde_app) = app.apps.get(app_index) else {
2733 + return;
2734 + };
2735 +
2736 + let token = token::load().unwrap_or_default();
2737 + let onde_app_id = onde_app.id.clone();
2738 + let display_name = entry.display_name.clone();
2739 +
2740 + // Try to find the matching model in the remote catalog to get its API ID
2741 + if let Some(remote_model) = app.models.iter().find(|m| {
2742 + m.hf_repo_id.as_deref() == Some(entry.model_id.as_str())
2743 + || m.name.as_deref() == Some(entry.display_name.as_str())
2744 + }) {
2745 + let api_model_id = remote_model.id.clone();
2746 + app.busy = true;
2747 + app.status = Status::neutral(format!("Assigning {display_name}…"));
2748 + tokio::spawn(async move {
2749 + match crate::gresiq::assign_model(&token, &onde_app_id, &api_model_id).await {
2750 + Ok(()) => {
2751 + let _ = tx.send(AuthEvent::ModelAssigned {
2752 + app_index,
2753 + model_id: api_model_id,
2754 + });
2755 + }
2756 + Err(e) => {
2757 + let _ = tx.send(AuthEvent::ModelAssignFailed(e.to_string()));
2758 + }
2759 + }
2760 + });
2761 + } else {
2762 + // Remote models not loaded yet — just update the local state
2763 + if let Some(onde_app) = app.apps.get_mut(app_index) {
2764 + onde_app.active_model = Some(display_name.clone());
2765 + }
2766 + app.screen = Screen::AppDetail;
2767 + app.assigning_for_app_index = None;
2768 + app.status = Status::success(format!("{display_name} selected."));
2769 + }
2770 +}
2771 +
2772 +fn clamp_inference_scroll(app: &mut App, max_visible: usize) {
2773 + if app.inference_cursor < app.inference_offset {
2774 + app.inference_offset = app.inference_cursor;
2775 + } else if app.inference_cursor >= app.inference_offset + max_visible {
2776 + app.inference_offset = app.inference_cursor + 1 - max_visible;
2777 + }
2778 +}
2779 +
2780 +fn trigger_inference_download(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2781 + let Some(entry) = app.inference_models.get(app.inference_cursor) else {
2782 + return;
2783 + };
2784 + if app.downloading {
2785 + app.status = Status::error("A download is already in progress.");
2786 + return;
2787 + }
2788 + let model_id = entry.model_id.clone();
2789 + let hub = crate::hf::preferred_download_hub();
2790 +
2791 + app.downloading = true;
2792 + app.download_progress = None;
2793 + app.status = Status::neutral(format!("Downloading {}…", entry.display_name));
2794 +
2795 + let (dl_tx, mut dl_rx) = mpsc::unbounded_channel::<crate::hf_search::DownloadEvent>();
2796 +
2797 + let model_id_clone = model_id.clone();
2798 + tokio::spawn(async move {
2799 + crate::hf_search::download_model(model_id_clone, hub, dl_tx).await;
2800 + });
2801 +
2802 + let auth_tx = tx.clone();
2803 + tokio::spawn(async move {
2804 + while let Some(event) = dl_rx.recv().await {
2805 + match event {
2806 + crate::hf_search::DownloadEvent::Progress(p) => {
2807 + let _ = auth_tx.send(AuthEvent::ModelDownloadProgress(p));
2808 + }
2809 + crate::hf_search::DownloadEvent::Complete => {
2810 + let _ =
2811 + auth_tx.send(AuthEvent::InferenceModelDownloadComplete(model_id.clone()));
2812 + break;
2813 + }
2814 + crate::hf_search::DownloadEvent::Failed(e) => {
2815 + let _ = auth_tx.send(AuthEvent::ModelDownloadFailed(e));
2816 + break;
2817 + }
2818 + }
2819 + }
2820 + });
2821 +}
src/chat.rs
+2 -2
@@ -24,7 +24,7 @@ pub enum ChatProgress {
24 Thinking,
25 /// A complete assistant reply has been produced.
26 Reply {
27 - text: String,
27 + _text: String,
28 duration_display: String,
29 },
30 /// A partial token arrived from the streaming response.
@@ -269,7 +269,7 @@ pub async fn start_chat(
269 history.push((TextMessageRole::Assistant, reply_buf.trim().to_string()));
270
271 let _ = progress_tx.send(ChatProgress::Reply {
272 - text: reply_buf.trim().to_string(),
272 + _text: reply_buf.trim().to_string(),
273 duration_display: format_duration(elapsed),
274 });
275 }
src/ui.rs
+140 -3
@@ -81,6 +81,7 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) {
81 Screen::Auth => render_form(frame, app, inner),
82 Screen::Apps => render_apps(frame, app, inner),
83 Screen::AppDetail => render_app_detail(frame, app, inner),
84 + Screen::InferenceModels => render_inference_models(frame, app, inner),
85 Screen::Models => render_models(frame, app, inner),
86 Screen::Downloads => render_downloads(frame, app, inner),
87 Screen::ModelDetail => render_model_detail(frame, app, inner),
@@ -532,10 +533,20 @@ fn render_app_detail(frame: &mut Frame, app: &App, area: Rect) {
533 render_status(frame, app, rows[2]);
534 render_detail_field(frame, "App ID", app_id, rows[4], rows[5]);
535 render_detail_field(frame, "App Secret", app_secret, rows[7], rows[8]);
535 - render_detail_field(frame, "Model", model_name, rows[10], rows[11]);
536 + frame.render_widget(
537 + Paragraph::new("Model").style(Style::new().fg(C_MUTED)),
538 + rows[10],
539 + );
540 + frame.render_widget(
541 + Paragraph::new(Line::from(vec![
542 + Span::styled(model_name, Style::new().fg(C_NEON).bold()),
543 + Span::styled(" ▸", Style::new().fg(C_MUTED)),
544 + ])),
545 + rows[11],
546 + );
547
548 frame.render_widget(
538 - Paragraph::new("m · assign model r · rename s · sign out Esc · back")
549 + Paragraph::new("Enter · detail m · model r · rename Esc · back")
550 .style(Style::new().fg(C_MUTED)),
551 rows[13],
552 );
@@ -584,6 +595,116 @@ fn render_rename_input(frame: &mut Frame, value: &str, area: Rect) {
595 frame.set_cursor_position((cursor_x, inner.y));
596 }
597
598 +// inference models screen
599 +
600 +fn render_inference_models(frame: &mut Frame, app: &App, area: Rect) {
601 + let rows = Layout::vertical([
602 + Constraint::Length(1), // heading
603 + Constraint::Length(1), // spacer
604 + Constraint::Length(1), // column header
605 + Constraint::Length(1), // divider
606 + Constraint::Min(0), // list
607 + Constraint::Length(1), // spacer
608 + Constraint::Length(1), // status / download progress
609 + ])
610 + .split(area);
611 +
612 + let heading: String = app
613 + .assigning_for_app_index
614 + .and_then(|i| app.apps.get(i))
615 + .map(|a| format!("Assign model — {}", a.name))
616 + .unwrap_or_else(|| "Models".to_string());
617 +
618 + frame.render_widget(
619 + Paragraph::new(heading).style(Style::new().fg(C_INK).bold()),
620 + rows[0],
621 + );
622 +
623 + frame.render_widget(
624 + Paragraph::new(" Name Size Status")
625 + .style(Style::new().fg(C_MUTED)),
626 + rows[2],
627 + );
628 +
629 + let divider = "─".repeat(area.width as usize);
630 + frame.render_widget(
631 + Paragraph::new(divider).style(Style::new().fg(C_LINE)),
632 + rows[3],
633 + );
634 +
635 + let list_area = rows[4];
636 + if app.inference_models.is_empty() {
637 + frame.render_widget(
638 + Paragraph::new(" No supported models found.").style(Style::new().fg(C_MUTED)),
639 + list_area,
640 + );
641 + } else {
642 + let max_rows = list_area.height as usize;
643 + for (list_idx, entry) in app
644 + .inference_models
645 + .iter()
646 + .enumerate()
647 + .skip(app.inference_offset)
648 + .take(max_rows)
649 + {
650 + let row_y = list_area.y + (list_idx - app.inference_offset) as u16;
651 + let row_area = Rect::new(list_area.x, row_y, list_area.width, 1);
652 +
653 + let is_selected = list_idx == app.inference_cursor;
654 + let prefix = if is_selected { "▶ " } else { " " };
655 +
656 + let size_str = fmt_bytes(entry.expected_size_bytes as i64);
657 + let status_str = if entry.downloaded {
658 + "✓ ready"
659 + } else {
660 + "↓ download"
661 + };
662 +
663 + let line = format!(
664 + "{}{:<32} {:<12} {}",
665 + prefix, entry.display_name, size_str, status_str
666 + );
667 +
668 + let style = if is_selected {
669 + Style::new().fg(C_NEON)
670 + } else if entry.downloaded {
671 + Style::new().fg(C_TEXT)
672 + } else {
673 + Style::new().fg(C_MUTED)
674 + };
675 +
676 + frame.render_widget(Paragraph::new(line).style(style), row_area);
677 + }
678 + }
679 +
680 + // Show download progress if downloading
681 + if app.downloading {
682 + if let Some(progress) = &app.download_progress {
683 + let pct = if progress.file_bytes_total > 0 {
684 + (progress.file_bytes_done as f64 / progress.file_bytes_total as f64 * 100.0) as u64
685 + } else {
686 + 0
687 + };
688 + let progress_text = format!(
689 + "Downloading {} — {} ({}/{} {}%)",
690 + progress.model_id,
691 + progress.filename,
692 + fmt_bytes(progress.file_bytes_done as i64),
693 + fmt_bytes(progress.file_bytes_total as i64),
694 + pct
695 + );
696 + frame.render_widget(
697 + Paragraph::new(progress_text).style(Style::new().fg(C_NEON)),
698 + rows[6],
699 + );
700 + } else {
701 + render_status(frame, app, rows[6]);
702 + }
703 + } else {
704 + render_status(frame, app, rows[6]);
705 + }
706 +}
707 +
708 // models screen
709
710 fn render_models(frame: &mut Frame, app: &App, area: Rect) {
@@ -2349,8 +2470,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2470 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2471 ],
2472 Screen::AppDetail => vec![
2473 + Span::styled("Enter", Style::new().fg(C_NEON)),
2474 + Span::styled(" · detail ", Style::new().fg(C_MUTED)),
2475 Span::styled("m", Style::new().fg(C_NEON)),
2353 - Span::styled(" · assign model ", Style::new().fg(C_MUTED)),
2476 + Span::styled(" · model ", Style::new().fg(C_MUTED)),
2477 Span::styled("r", Style::new().fg(C_NEON)),
2478 Span::styled(" · rename ", Style::new().fg(C_MUTED)),
2479 Span::styled("Esc", Style::new().fg(C_NEON)),
@@ -2358,6 +2481,20 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2481 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2482 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2483 ],
2484 + Screen::InferenceModels if app.downloading => vec![
2485 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2486 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
2487 + ],
2488 + Screen::InferenceModels => vec![
2489 + Span::styled("↑↓", Style::new().fg(C_NEON)),
2490 + Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2491 + Span::styled("Enter", Style::new().fg(C_NEON)),
2492 + Span::styled(" · select / download ", Style::new().fg(C_MUTED)),
2493 + Span::styled("Esc", Style::new().fg(C_NEON)),
2494 + Span::styled(" · back ", Style::new().fg(C_MUTED)),
2495 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2496 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
2497 + ],
2498 Screen::Models => vec![
2499 Span::styled("↑↓", Style::new().fg(C_NEON)),
2500 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),