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