main
rs 3,172 lines 114 KB
Raw
1 use {
2 crate::token,
3 anyhow::Result,
4 crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers},
5 futures::StreamExt,
6 smbcloud_auth_sdk::{
7 client_credentials::ClientCredentials, login::login_with_client,
8 logout::logout_with_client, me::me_with_client, signup::signup_with_client,
9 },
10 smbcloud_model::{error_codes::ErrorResponse, login::AccountStatus},
11 smbcloud_network::environment::Environment,
12 tokio::sync::mpsc,
13 };
14
15 // ui.rs pulls OndeApp and OndeModel from here so it does not have to depend on the SDK directly.
16 pub use smbcloud_gresiq_sdk::{OndeApp, OndeModel};
17
18 // Onde is a tenant in smbCloud Auth. These values identify the CLI to the backend.
19 // They are baked in at compile time from the environment.
20 // Put them in .env for local builds, or inject them as CI secrets.
21 pub(crate) const ONDE_APP_ID: &str = env!("ONDE_APP_ID");
22 pub(crate) const ONDE_APP_SECRET: &str = env!("ONDE_APP_SECRET");
23 pub(crate) const HF_TOKEN: &str = env!("HF_TOKEN");
24
25 // GresIQ credentials for the same tenant. These are separate from Auth.
26 pub(crate) const GRESIQ_API_KEY: &str = env!("GRESIQ_API_KEY");
27 pub(crate) const GRESIQ_API_SECRET: &str = env!("GRESIQ_API_SECRET");
28
29 fn credentials() -> ClientCredentials<'static> {
30 ClientCredentials {
31 app_id: ONDE_APP_ID,
32 app_secret: ONDE_APP_SECRET,
33 }
34 }
35
36 #[derive(Debug, Clone, PartialEq)]
37 pub enum Screen {
38 Auth,
39 Apps,
40 AppDetail,
41 #[allow(dead_code)]
42 Models,
43 InferenceModels,
44 Downloads,
45 ModelDetail,
46 FineTune,
47 GgufDetail,
48 CloneRepo,
49 Chat,
50 /// Pick which app to assign a just-registered fine-tuned model to.
51 PublishModel,
52 }
53
54 /// An entry in the Onde inference model picker.
55 #[derive(Clone)]
56 pub struct InferenceModelEntry {
57 /// HuggingFace model ID (e.g. "bartowski/Qwen2.5-3B-Instruct-GGUF")
58 pub model_id: String,
59 /// Human-readable name (e.g. "Qwen 2.5 3B (GGUF)")
60 pub display_name: String,
61 /// Organisation / publisher
62 #[allow(dead_code)]
63 pub org: String,
64 /// Short description
65 #[allow(dead_code)]
66 pub description: String,
67 /// Expected size in bytes
68 pub expected_size_bytes: u64,
69 /// Whether the model is already downloaded locally
70 pub downloaded: bool,
71 /// Which cache it lives in (if downloaded)
72 #[allow(dead_code)]
73 pub cache_source: Option<crate::hf::CacheSource>,
74 }
75
76 /// What kind of artifact was found on disk.
77 #[derive(Clone, PartialEq)]
78 pub enum ArtifactKind {
79 /// A LoRA adapter (lora_adapter.safetensors).
80 LoraAdapter,
81 /// An exported GGUF model (.gguf file).
82 Gguf,
83 }
84
85 /// A discovered artifact (adapter or exported model) on disk.
86 #[derive(Clone)]
87 pub struct AdapterEntry {
88 /// Full path to the file.
89 pub path: std::path::PathBuf,
90 /// Directory name (snapshot hash or "lora-adapter").
91 pub dir_name: String,
92 /// File name for GGUF files, or "lora_adapter.safetensors" for adapters.
93 pub file_name: String,
94 /// Human-readable file size.
95 pub size: String,
96 /// Relative timestamp ("just now", "3h ago", etc.).
97 pub modified: String,
98 /// Whether this is a LoRA adapter or a GGUF export.
99 pub kind: ArtifactKind,
100 }
101
102 /// Classify a filesystem path into a short, human-friendly location label.
103 ///
104 /// Long absolute paths (e.g. the App Group container) get truncated to
105 /// gibberish in narrow TUI fields, so anywhere we'd otherwise print a raw
106 /// path we show this label instead.
107 ///
108 /// - `"Onde Inference"`: inside the shared App Group container
109 /// - `"HF Cache"`: inside `~/.cache/huggingface/hub` or `$HF_HOME`
110 /// - the parent directory string: anything else, usually a custom or local export
111 pub fn location_label_for_path(path: &std::path::Path) -> String {
112 let path_str = path.to_string_lossy();
113
114 // App Group container (macOS)
115 if path_str.contains("group.com.ondeinference.apps") {
116 return "Onde Inference".to_string();
117 }
118
119 // Standard HF cache locations
120 if path_str.contains(".cache/huggingface/hub") {
121 return "HF Cache".to_string();
122 }
123 if let Ok(hf_home) = std::env::var("HF_HOME")
124 && path_str.starts_with(&hf_home)
125 {
126 return "HF Cache".to_string();
127 }
128
129 // For custom or local paths, just show the directory.
130 path.parent()
131 .map(|p| p.to_string_lossy().to_string())
132 .unwrap_or_else(|| path_str.to_string())
133 }
134
135 impl AdapterEntry {
136 /// Classify the location of this artifact for display purposes.
137 /// See [`location_label_for_path`].
138 pub fn location_label(&self) -> String {
139 location_label_for_path(&self.path)
140 }
141
142 /// Whether this GGUF should show the upload-to-HuggingFace UI.
143 ///
144 /// Only local fine-tune outputs should get the upload UI.
145 /// If the model already came from a known HF cache, skip it.
146 pub fn is_uploadable(&self) -> bool {
147 let path_str = self.path.to_string_lossy();
148 // App Group files were downloaded from HF, so skip them.
149 if path_str.contains("group.com.ondeinference.apps") {
150 return false;
151 }
152 // Not in the standard HF cache
153 if path_str.contains(".cache/huggingface/hub") {
154 return false;
155 }
156 if let Ok(hf_home) = std::env::var("HF_HOME")
157 && path_str.starts_with(&hf_home)
158 {
159 return false;
160 }
161 true
162 }
163 }
164
165 #[derive(Debug, Clone, PartialEq)]
166 pub enum Mode {
167 Signup,
168 Signin,
169 }
170
171 #[derive(Debug, Clone, PartialEq)]
172 pub enum Focus {
173 Email,
174 Password,
175 }
176
177 #[derive(Debug, Clone, PartialEq)]
178 pub enum FineTuneFocus {
179 ModelDir,
180 DataPath,
181 Rank,
182 Epochs,
183 Lr,
184 }
185
186 #[derive(Debug, Clone, PartialEq)]
187 pub enum StatusTone {
188 Neutral,
189 Success,
190 Error,
191 }
192
193 #[derive(Debug, Clone)]
194 pub struct Status {
195 pub tone: StatusTone,
196 pub message: String,
197 }
198
199 impl Status {
200 pub fn neutral(msg: impl Into<String>) -> Self {
201 Self {
202 tone: StatusTone::Neutral,
203 message: msg.into(),
204 }
205 }
206 pub fn success(msg: impl Into<String>) -> Self {
207 Self {
208 tone: StatusTone::Success,
209 message: msg.into(),
210 }
211 }
212 pub fn error(msg: impl Into<String>) -> Self {
213 Self {
214 tone: StatusTone::Error,
215 message: msg.into(),
216 }
217 }
218 }
219
220 #[derive(Debug, Clone)]
221 pub struct Profile {
222 pub email: String,
223 }
224
225 pub enum AuthEvent {
226 // auth
227 SignupOk(String),
228 SigninOk(Profile),
229 ProfileOk(Profile),
230 SignedOut,
231 Failed(String),
232 // apps
233 AppsLoaded(Vec<OndeApp>),
234 AppsLoadFailed(String),
235 AppCreated(OndeApp),
236 AppCreateFailed(String),
237 AppRenamedOk {
238 app_index: usize,
239 new_name: String,
240 },
241 AppRenameFailed(String),
242 // models (remote catalog for assignment)
243 #[allow(dead_code)]
244 ModelsLoaded(Vec<OndeModel>),
245 #[allow(dead_code)]
246 ModelsLoadFailed(String),
247 ModelAssigned {
248 app_index: usize,
249 model_id: String,
250 },
251 ModelAssignFailed(String),
252 // publish: register a freshly HF-uploaded fine-tune into the catalog
253 ModelRegistered(OndeModel),
254 ModelRegisterFailed(String),
255 // downloads (catalog merged with local HF cache)
256 DownloadsLoaded(Vec<crate::hf::MergedModel>),
257 #[allow(dead_code)] // reserved for future explicit error reporting
258 DownloadsLoadFailed(String),
259 // HF Hub search
260 HfSearchResults(Vec<crate::hf_search::HfModelInfo>),
261 HfSearchFailed(String),
262 // Model download
263 ModelDownloadProgress(crate::hf_search::DownloadProgress),
264 ModelDownloadComplete(String), // model_id
265 ModelDownloadFailed(String),
266 // inference model picker
267 InferenceModelDownloadComplete(String), // model_id
268 // fine-tune
269 FineTuneProgress(crate::finetune::FineTuneProgress),
270 // merge + GGUF export
271 MergeProgress(crate::merge::MergeProgress),
272 GgufProgress(crate::gguf::GgufProgress),
273 // HF upload
274 UploadProgress(crate::hf_upload::UploadProgress),
275 CloneProgress(crate::hf_clone::CloneProgress),
276 // chat
277 ChatProgress(crate::chat::ChatProgress),
278 }
279
280 pub struct App {
281 // auth
282 pub mode: Mode,
283 pub email: String,
284 pub password: String,
285 pub focus: Focus,
286 pub status: Status,
287 pub busy: bool,
288 pub profile: Option<Profile>,
289 pub should_quit: bool,
290 // navigation
291 pub screen: Screen,
292 // apps list
293 pub apps: Vec<OndeApp>,
294 pub apps_cursor: usize,
295 pub apps_offset: usize,
296 pub apps_loaded: bool,
297 // models list (remote catalog for assignment)
298 pub models: Vec<OndeModel>,
299 pub models_cursor: usize,
300 pub models_offset: usize,
301 pub models_loaded: bool,
302 // inline create form
303 pub creating_app: bool,
304 pub new_app_name: String,
305 // rename form (used on AppDetail screen)
306 pub renaming_app: bool,
307 pub rename_input: String,
308 // which app we're picking a model for
309 pub assigning_for_app_index: Option<usize>,
310 // the model just registered from GgufDetail, pending assignment on the
311 // PublishModel screen. Held by id/name directly (not a models_cursor
312 // index) so a later models reload can never shift it onto a different row.
313 pub publish_model_id: Option<String>,
314 pub publish_model_name: Option<String>,
315 // local HF cache downloads merged with remote catalog
316 pub downloads: Vec<crate::hf::MergedModel>,
317 pub downloads_cursor: usize,
318 pub downloads_offset: usize,
319 pub downloads_loaded: bool,
320 // HF Hub search
321 pub hf_search_active: bool,
322 pub hf_search_query: String,
323 pub hf_search_results: Vec<crate::hf_search::HfModelInfo>,
324 pub hf_search_cursor: usize,
325 pub hf_search_loading: bool,
326 // Model download (runs in background, does not set app.busy)
327 pub downloading: bool,
328 pub download_progress: Option<crate::hf_search::DownloadProgress>,
329 // fine-tune
330 pub finetune_model_id: String,
331 pub finetune_model_dir: String,
332 pub finetune_data_path: String,
333 pub finetune_rank: String,
334 pub finetune_epochs: String,
335 pub finetune_lr: String,
336 pub finetune_focus: FineTuneFocus,
337 pub finetune_running: bool,
338 pub finetune_progress: Option<crate::finetune::FineTuneProgress>,
339 // merge + GGUF export
340 pub merge_running: bool,
341 pub merge_progress: Option<crate::merge::MergeProgress>,
342 pub gguf_running: bool,
343 pub gguf_progress: Option<crate::gguf::GgufProgress>,
344 pub merged_model_dir: Option<std::path::PathBuf>,
345 // adapters discovered on the model detail screen
346 pub adapter_list: Vec<AdapterEntry>,
347 pub adapter_cursor: usize,
348 /// `true` when the model on the detail screen has `config.json` (safetensors)
349 /// and can therefore be fine-tuned. GGUF-only models set this to `false`.
350 pub model_detail_can_finetune: bool,
351 // GGUF detail + upload
352 pub selected_gguf: Option<AdapterEntry>,
353 pub upload_running: bool,
354 pub upload_progress: Option<crate::hf_upload::UploadProgress>,
355 pub upload_repo_name: String,
356 // active project (set when entering fine-tune from CloneRepo)
357 pub current_project: Option<crate::project::OndeProject>,
358 // clone repo (check + base model selection)
359 pub clone_repo_id: String,
360 pub clone_repo_checking: bool,
361 pub clone_repo_status: Option<crate::hf_clone::RepoStatus>,
362 pub clone_base_cursor: usize,
363 pub clone_progress: Option<crate::hf_clone::CloneProgress>,
364 // chat (test a local GGUF before publishing)
365 pub chat_messages: Vec<crate::chat::ChatMessage>,
366 pub chat_input: String,
367 pub chat_loading: bool,
368 pub chat_thinking: bool,
369 pub chat_progress: Option<crate::chat::ChatProgress>,
370 pub chat_command_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::chat::ChatCommand>>,
371 pub chat_scroll_offset: usize,
372 // inference model picker (Onde local GGUF models)
373 pub inference_models: Vec<InferenceModelEntry>,
374 pub inference_cursor: usize,
375 pub inference_offset: usize,
376 // deferred navigation: open ModelDetail after downloads finish loading
377 pub pending_model_detail: bool,
378 }
379
380 impl App {
381 pub fn new() -> Self {
382 Self {
383 mode: Mode::Signup,
384 email: String::new(),
385 password: String::new(),
386 focus: Focus::Email,
387 status: Status::neutral("Type your email and password."),
388 busy: false,
389 profile: None,
390 should_quit: false,
391 screen: Screen::Auth,
392 apps: Vec::new(),
393 apps_cursor: 0,
394 apps_offset: 0,
395 apps_loaded: false,
396 models: Vec::new(),
397 models_cursor: 0,
398 models_offset: 0,
399 models_loaded: false,
400 creating_app: false,
401 new_app_name: String::new(),
402 renaming_app: false,
403 rename_input: String::new(),
404 assigning_for_app_index: None,
405 publish_model_id: None,
406 publish_model_name: None,
407 downloads: Vec::new(),
408 downloads_cursor: 0,
409 downloads_offset: 0,
410 downloads_loaded: false,
411 hf_search_active: false,
412 hf_search_query: String::new(),
413 hf_search_results: Vec::new(),
414 hf_search_cursor: 0,
415 hf_search_loading: false,
416 downloading: false,
417 download_progress: None,
418 finetune_model_id: String::new(),
419 finetune_model_dir: String::new(),
420 finetune_data_path: String::new(),
421 finetune_rank: "8".to_string(),
422 finetune_epochs: "3".to_string(),
423 finetune_lr: "0.00001".to_string(),
424 finetune_focus: FineTuneFocus::ModelDir,
425 finetune_running: false,
426 finetune_progress: None,
427 merge_running: false,
428 merge_progress: None,
429 gguf_running: false,
430 gguf_progress: None,
431 merged_model_dir: None,
432 adapter_list: Vec::new(),
433 adapter_cursor: 0,
434 model_detail_can_finetune: false,
435 selected_gguf: None,
436 upload_running: false,
437 upload_progress: None,
438 upload_repo_name: String::new(),
439 current_project: None,
440 clone_repo_id: String::new(),
441 clone_repo_checking: false,
442 clone_repo_status: None,
443 clone_base_cursor: 0,
444 clone_progress: None,
445 chat_messages: Vec::new(),
446 chat_input: String::new(),
447 chat_loading: false,
448 chat_thinking: false,
449 chat_progress: None,
450 chat_command_tx: None,
451 chat_scroll_offset: 0,
452 inference_models: Vec::new(),
453 inference_cursor: 0,
454 inference_offset: 0,
455 pending_model_detail: false,
456 }
457 }
458
459 fn idle_status(&self) -> Status {
460 match self.mode {
461 Mode::Signup => Status::neutral("Type your email and password."),
462 Mode::Signin => Status::neutral("Sign in to your existing account."),
463 }
464 }
465
466 pub fn switch_mode(&mut self, mode: Mode) {
467 if self.busy {
468 return;
469 }
470 self.mode = mode;
471 if self.profile.is_none() {
472 self.status = self.idle_status();
473 }
474 }
475
476 pub fn apply(&mut self, event: AuthEvent) {
477 // Download, search, merge, and gguf events run in the background without the busy flag.
478 match &event {
479 AuthEvent::ModelDownloadProgress(_)
480 | AuthEvent::ModelDownloadComplete(_)
481 | AuthEvent::ModelDownloadFailed(_)
482 | AuthEvent::InferenceModelDownloadComplete(_)
483 | AuthEvent::HfSearchResults(_)
484 | AuthEvent::HfSearchFailed(_)
485 | AuthEvent::MergeProgress(_)
486 | AuthEvent::GgufProgress(_)
487 | AuthEvent::UploadProgress(_)
488 | AuthEvent::CloneProgress(_)
489 | AuthEvent::ChatProgress(_) => {}
490 _ => {
491 self.busy = false;
492 }
493 }
494 match event {
495 // auth
496 AuthEvent::SignupOk(message) => {
497 self.password.clear();
498 self.status = Status::success(message);
499 }
500 AuthEvent::SigninOk(profile) => {
501 self.password.clear();
502 self.profile = Some(profile);
503 self.screen = Screen::Apps;
504 self.status = Status::success("You're in.");
505 }
506 AuthEvent::ProfileOk(profile) => {
507 self.profile = Some(profile);
508 self.screen = Screen::Apps;
509 self.status = Status::success("Still signed in.");
510 }
511 AuthEvent::SignedOut => {
512 token::clear();
513 self.profile = None;
514 self.screen = Screen::Auth;
515 self.apps.clear();
516 self.apps_loaded = false;
517 self.apps_cursor = 0;
518 self.apps_offset = 0;
519 self.models.clear();
520 self.models_loaded = false;
521 self.models_cursor = 0;
522 self.models_offset = 0;
523 self.creating_app = false;
524 self.new_app_name.clear();
525 self.renaming_app = false;
526 self.rename_input.clear();
527 self.assigning_for_app_index = None;
528 self.downloads.clear();
529 self.downloads_loaded = false;
530 self.downloads_cursor = 0;
531 self.downloads_offset = 0;
532 self.status = Status::neutral("Signed out.");
533 }
534 AuthEvent::Failed(message) => {
535 self.status = Status::error(message);
536 }
537 // apps
538 AuthEvent::AppsLoaded(apps) => {
539 self.busy = false;
540 self.apps = apps;
541 self.apps_loaded = true;
542 self.status = Status::success("Apps loaded.");
543 }
544 AuthEvent::AppsLoadFailed(msg) => {
545 self.busy = false;
546 self.status = Status::error(msg);
547 }
548 AuthEvent::AppCreated(app) => {
549 self.busy = false;
550 self.apps.insert(0, app);
551 self.creating_app = false;
552 self.new_app_name.clear();
553 self.status = Status::success("App created.");
554 }
555 AuthEvent::AppCreateFailed(msg) => {
556 self.busy = false;
557 self.status = Status::error(msg);
558 }
559 AuthEvent::AppRenamedOk {
560 app_index,
561 new_name,
562 } => {
563 self.busy = false;
564 self.renaming_app = false;
565 self.rename_input.clear();
566 if let Some(onde_app) = self.apps.get_mut(app_index) {
567 onde_app.name = new_name;
568 }
569 self.status = Status::success("App renamed.");
570 }
571 AuthEvent::AppRenameFailed(msg) => {
572 self.busy = false;
573 self.status = Status::error(msg);
574 }
575 // models
576 AuthEvent::ModelsLoaded(models) => {
577 self.busy = false;
578 self.models = models;
579 self.models_loaded = true;
580 self.status = Status::neutral("Choose a model.");
581 }
582 AuthEvent::ModelsLoadFailed(msg) => {
583 self.busy = false;
584 self.status = Status::error(msg);
585 }
586 AuthEvent::ModelAssigned {
587 app_index,
588 model_id,
589 } => {
590 self.busy = false;
591 self.screen = Screen::AppDetail;
592 self.assigning_for_app_index = None;
593 if let Some(onde_app) = self.apps.get_mut(app_index) {
594 // Resolve the display name from the loaded models list so
595 // the apps list and detail view update immediately without
596 // a round-trip. Falls back to the raw ID if not found.
597 let resolved_name = self
598 .models
599 .iter()
600 .find(|m| m.id == model_id)
601 .and_then(|m| m.name.clone())
602 .unwrap_or_else(|| model_id.clone());
603 onde_app.current_model_id = Some(model_id);
604 onde_app.active_model = Some(resolved_name);
605 }
606 self.status = Status::success("Model assigned.");
607 }
608 AuthEvent::ModelAssignFailed(msg) => {
609 self.busy = false;
610 self.status = Status::error(msg);
611 }
612 // publish
613 AuthEvent::ModelRegistered(model) => {
614 self.busy = false;
615 let model_name = model
616 .name
617 .clone()
618 .or_else(|| model.hf_repo_id.clone())
619 .unwrap_or_else(|| "Model".to_string());
620 // Held by id/name directly rather than a models_cursor index,
621 // so this can't drift onto a different row if app.models is
622 // ever reloaded/reordered while this screen is open.
623 self.publish_model_id = Some(model.id.clone());
624 self.publish_model_name = Some(model_name.clone());
625 self.models.push(model);
626 self.screen = Screen::PublishModel;
627 self.apps_cursor = 0;
628 self.apps_offset = 0;
629 self.status = Status::success(format!(
630 "{model_name} registered. Pick an app to assign it to."
631 ));
632 }
633 AuthEvent::ModelRegisterFailed(msg) => {
634 self.busy = false;
635 self.status = Status::error(format!("Registration failed: {msg}"));
636 }
637 // downloads
638 AuthEvent::DownloadsLoaded(models) => {
639 self.busy = false;
640 self.downloads = models;
641 self.downloads_loaded = true;
642 if self.pending_model_detail {
643 self.pending_model_detail = false;
644 let active_model = self
645 .apps
646 .get(self.apps_cursor)
647 .and_then(|a| a.active_model.clone());
648 let current_model_id = self
649 .apps
650 .get(self.apps_cursor)
651 .and_then(|a| a.current_model_id.clone());
652 navigate_to_model_detail(
653 self,
654 active_model.as_deref(),
655 current_model_id.as_deref(),
656 );
657 } else if self.downloads.is_empty() {
658 self.status = Status::neutral("No models downloaded yet.");
659 } else {
660 self.status = Status::success(format!(
661 "{} model{} found.",
662 self.downloads.len(),
663 if self.downloads.len() == 1 { "" } else { "s" }
664 ));
665 }
666 }
667 AuthEvent::DownloadsLoadFailed(msg) => {
668 self.busy = false;
669 self.downloads_loaded = true;
670 self.status = Status::error(msg);
671 }
672 AuthEvent::FineTuneProgress(progress) => {
673 match &progress {
674 crate::finetune::FineTuneProgress::Done { .. }
675 | crate::finetune::FineTuneProgress::Failed(_) => {
676 self.finetune_running = false;
677 }
678 _ => {}
679 }
680 self.finetune_progress = Some(progress);
681 }
682 AuthEvent::HfSearchResults(results) => {
683 self.hf_search_loading = false;
684 self.hf_search_cursor = 0;
685 self.hf_search_results = results;
686 self.status = if self.hf_search_results.is_empty() {
687 Status::neutral("No models found.")
688 } else {
689 Status::neutral(format!("{} models found.", self.hf_search_results.len()))
690 };
691 }
692 AuthEvent::HfSearchFailed(msg) => {
693 self.hf_search_loading = false;
694 self.status = Status::error(msg);
695 }
696 AuthEvent::ModelDownloadProgress(progress) => {
697 self.download_progress = Some(progress);
698 }
699 AuthEvent::ModelDownloadComplete(model_id) => {
700 self.downloading = false;
701 self.download_progress = None;
702 self.hf_search_active = false;
703 self.hf_search_query.clear();
704 self.hf_search_results.clear();
705 self.hf_search_cursor = 0;
706 self.downloads_loaded = false; // triggers list reload
707 self.status = Status::success(format!("{model_id} downloaded."));
708 }
709 AuthEvent::ModelDownloadFailed(msg) => {
710 self.downloading = false;
711 self.download_progress = None;
712 self.status = Status::error(msg);
713 }
714 AuthEvent::InferenceModelDownloadComplete(model_id) => {
715 self.downloading = false;
716 self.download_progress = None;
717 // Refresh the inference model list so the downloaded badge updates
718 self.inference_models = build_inference_entries();
719 self.status = Status::success(format!("{model_id} downloaded and ready."));
720 }
721 AuthEvent::MergeProgress(progress) => {
722 match &progress {
723 crate::merge::MergeProgress::Done { output_path } => {
724 self.merge_running = false;
725 // Store the merged model directory for GGUF export
726 if let Some(parent) = output_path.parent() {
727 self.merged_model_dir = Some(parent.to_path_buf());
728 }
729 }
730 crate::merge::MergeProgress::Failed(_) => {
731 self.merge_running = false;
732 }
733 _ => {}
734 }
735 self.merge_progress = Some(progress);
736 }
737 AuthEvent::GgufProgress(progress) => {
738 match &progress {
739 crate::gguf::GgufProgress::Done { .. }
740 | crate::gguf::GgufProgress::Failed(_) => {
741 self.gguf_running = false;
742 }
743 _ => {}
744 }
745 self.gguf_progress = Some(progress);
746 }
747 AuthEvent::UploadProgress(progress) => {
748 match &progress {
749 crate::hf_upload::UploadProgress::Done { .. }
750 | crate::hf_upload::UploadProgress::Failed(_) => {
751 self.upload_running = false;
752 }
753 _ => {}
754 }
755 self.upload_progress = Some(progress);
756 }
757 AuthEvent::CloneProgress(progress) => {
758 match &progress {
759 crate::hf_clone::CloneProgress::RepoChecked(status) => {
760 self.clone_repo_checking = false;
761 self.clone_repo_status = Some(status.clone());
762 match status {
763 crate::hf_clone::RepoStatus::NotFound => {
764 self.status =
765 Status::neutral("Repo not found. Press Enter to create it.");
766 }
767 crate::hf_clone::RepoStatus::Empty { .. } => {
768 self.status = Status::neutral(
769 "Repo is empty — pick a base model to fine-tune.",
770 );
771 }
772 crate::hf_clone::RepoStatus::HasModel { .. } => {
773 self.status = Status::success("Repo already has model files.");
774 }
775 }
776 }
777 crate::hf_clone::CloneProgress::RepoReady => {
778 self.clone_repo_checking = false;
779 self.status =
780 Status::success("Repo created. Pick a base model to fine-tune.");
781 // After creating, set status to Empty so the UI shows base model picker
782 self.clone_repo_status = Some(crate::hf_clone::RepoStatus::Empty {
783 repo_id: self.clone_repo_id.clone(),
784 files: vec![],
785 });
786 }
787 crate::hf_clone::CloneProgress::Failed(msg) => {
788 self.clone_repo_checking = false;
789 self.status = Status::error(msg.clone());
790 }
791 _ => {}
792 }
793 self.clone_progress = Some(progress);
794 }
795 AuthEvent::ChatProgress(progress) => {
796 match &progress {
797 crate::chat::ChatProgress::LoadingModel => {
798 self.chat_loading = true;
799 self.chat_thinking = false;
800 self.status = Status::neutral("Loading model…");
801 }
802 crate::chat::ChatProgress::Ready { model_name } => {
803 self.chat_loading = false;
804 self.status =
805 Status::success(format!("{model_name} ready. Start chatting!"));
806 }
807 crate::chat::ChatProgress::Thinking => {
808 self.chat_thinking = true;
809 self.status = Status::neutral("Thinking…");
810 }
811 crate::chat::ChatProgress::StreamDelta(delta) => {
812 // Append the token to the current streaming assistant message.
813 if let Some(last) = self.chat_messages.last_mut() {
814 if last.role == crate::chat::ChatRole::Assistant {
815 last.content.push_str(delta);
816 } else {
817 self.chat_messages
818 .push(crate::chat::ChatMessage::assistant(delta.clone()));
819 }
820 } else {
821 self.chat_messages
822 .push(crate::chat::ChatMessage::assistant(delta.clone()));
823 }
824 self.chat_scroll_offset = self.chat_messages.len().saturating_sub(1);
825 }
826 crate::chat::ChatProgress::Reply {
827 _text: _,
828 duration_display,
829 } => {
830 self.chat_thinking = false;
831 self.chat_scroll_offset = self.chat_messages.len().saturating_sub(1);
832 self.status = Status::success(format!(
833 "Reply in {duration_display}. Enter · send Esc · back"
834 ));
835 }
836 crate::chat::ChatProgress::Error(msg) => {
837 self.chat_loading = false;
838 self.chat_thinking = false;
839 self.status = Status::error(msg.clone());
840 }
841 }
842 self.chat_progress = Some(progress);
843 }
844 }
845 }
846 }
847
848 pub async fn run<B: ratatui::backend::Backend>(terminal: &mut ratatui::Terminal<B>) -> Result<()> {
849 let mut app = App::new();
850 let (tx, mut rx) = mpsc::unbounded_channel::<AuthEvent>();
851 let mut events = EventStream::new();
852
853 // pick up where we left off if there is a saved token
854 if let Some(saved_token) = token::load() {
855 app.busy = true;
856 app.status = Status::neutral("Restoring session…");
857 let tx2 = tx.clone();
858 tokio::spawn(async move {
859 match me_with_client(Environment::Production, credentials(), &saved_token).await {
860 Ok(user) => {
861 let _ = tx2.send(AuthEvent::ProfileOk(Profile { email: user.email }));
862 }
863 Err(_) => {
864 token::clear();
865 let _ = tx2.send(AuthEvent::Failed("Session expired. Sign in again.".into()));
866 }
867 }
868 });
869 }
870
871 loop {
872 terminal.draw(|frame| crate::ui::render(frame, &app))?;
873
874 tokio::select! {
875 maybe = events.next() => {
876 match maybe {
877 Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => {
878 handle_key(&mut app, key, tx.clone());
879 }
880 None => break,
881 _ => {}
882 }
883 }
884 maybe = rx.recv() => {
885 if let Some(event) = maybe {
886 app.apply(event);
887 // kick off apps loading whenever we land on the Apps screen fresh
888 if app.profile.is_some()
889 && app.screen == Screen::Apps
890 && !app.apps_loaded
891 && !app.busy
892 {
893 trigger_load_apps(&mut app, tx.clone());
894 }
895 // Reload downloads list after a successful download.
896 if app.profile.is_some()
897 && app.screen == Screen::Downloads
898 && !app.downloads_loaded
899 && !app.busy
900 && !app.downloading
901 {
902 trigger_load_downloads(&mut app, tx.clone());
903 }
904 }
905 }
906 }
907
908 if app.should_quit {
909 break;
910 }
911 }
912
913 Ok(())
914 }
915
916 fn trigger_load_apps(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
917 if app.busy {
918 return;
919 }
920 let token = token::load().unwrap_or_default();
921 app.busy = true;
922 app.status = Status::neutral("Loading apps…");
923 tokio::spawn(async move {
924 match crate::gresiq::load_apps(&token).await {
925 Ok(apps) => {
926 let _ = tx.send(AuthEvent::AppsLoaded(apps));
927 }
928 Err(e) => {
929 let _ = tx.send(AuthEvent::AppsLoadFailed(e.to_string()));
930 }
931 }
932 });
933 }
934
935 const MAX_VISIBLE: usize = 8;
936
937 fn handle_key(
938 app: &mut App,
939 key: crossterm::event::KeyEvent,
940 tx: mpsc::UnboundedSender<AuthEvent>,
941 ) {
942 use KeyCode::*;
943
944 // while a request is in flight only Ctrl+C is allowed
945 if app.busy {
946 if matches!(
947 (key.code, key.modifiers),
948 (Char('c'), KeyModifiers::CONTROL)
949 ) {
950 app.should_quit = true;
951 }
952 return;
953 }
954
955 match app.screen {
956 Screen::Auth => handle_key_auth(app, key, tx),
957 Screen::Apps => handle_key_apps(app, key, tx),
958 Screen::AppDetail => handle_key_app_detail(app, key, tx),
959 Screen::InferenceModels => handle_key_inference_models(app, key, tx),
960 Screen::Models => handle_key_models(app, key, tx),
961 Screen::Downloads => handle_key_downloads(app, key, tx),
962 Screen::ModelDetail => handle_key_model_detail(app, key, tx),
963 Screen::GgufDetail => handle_key_gguf_detail(app, key, tx),
964 Screen::FineTune => handle_key_finetune(app, key, tx),
965 Screen::CloneRepo => handle_key_clone_repo(app, key, tx),
966 Screen::Chat => handle_key_chat(app, key, tx),
967 Screen::PublishModel => handle_key_publish_model(app, key, tx),
968 }
969 }
970
971 fn handle_key_auth(
972 app: &mut App,
973 key: crossterm::event::KeyEvent,
974 tx: mpsc::UnboundedSender<AuthEvent>,
975 ) {
976 use KeyCode::*;
977
978 match (key.code, key.modifiers) {
979 (Char('c'), KeyModifiers::CONTROL) | (Esc, _) => {
980 app.should_quit = true;
981 }
982 (Tab, _) => {
983 app.focus = match app.focus {
984 Focus::Email => Focus::Password,
985 Focus::Password => Focus::Email,
986 };
987 }
988 (Char('l'), KeyModifiers::CONTROL) => {
989 app.switch_mode(Mode::Signin);
990 }
991 (Char('n'), KeyModifiers::CONTROL) => {
992 app.switch_mode(Mode::Signup);
993 }
994 (Enter, _) => {
995 submit(app, tx);
996 }
997 (Backspace, _) => match app.focus {
998 Focus::Email => {
999 app.email.pop();
1000 }
1001 Focus::Password => {
1002 app.password.pop();
1003 }
1004 },
1005 (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => match app.focus {
1006 Focus::Email => app.email.push(c),
1007 Focus::Password => app.password.push(c),
1008 },
1009 _ => {}
1010 }
1011 }
1012
1013 fn handle_key_apps(
1014 app: &mut App,
1015 key: crossterm::event::KeyEvent,
1016 tx: mpsc::UnboundedSender<AuthEvent>,
1017 ) {
1018 use KeyCode::*;
1019
1020 if app.creating_app {
1021 match (key.code, key.modifiers) {
1022 (Esc, _) => {
1023 app.creating_app = false;
1024 app.new_app_name.clear();
1025 app.status = app.idle_status();
1026 }
1027 (Enter, _) => {
1028 submit_create_app(app, tx);
1029 }
1030 (Backspace, _) => {
1031 app.new_app_name.pop();
1032 }
1033 (Char('c'), KeyModifiers::CONTROL) => {
1034 app.should_quit = true;
1035 }
1036 (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
1037 app.new_app_name.push(c);
1038 }
1039 _ => {}
1040 }
1041 } else {
1042 match (key.code, key.modifiers) {
1043 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1044 app.apps_cursor = app.apps_cursor.saturating_sub(1);
1045 clamp_apps_scroll(app, MAX_VISIBLE);
1046 }
1047 (Down, _) | (Char('j'), KeyModifiers::NONE) => {
1048 if app.apps_cursor + 1 < app.apps.len() {
1049 app.apps_cursor += 1;
1050 }
1051 clamp_apps_scroll(app, MAX_VISIBLE);
1052 }
1053 (Enter, _) if !app.apps.is_empty() => {
1054 app.screen = Screen::AppDetail;
1055 app.renaming_app = false;
1056 app.rename_input.clear();
1057 let app_name = app
1058 .apps
1059 .get(app.apps_cursor)
1060 .map(|a| a.name.as_str())
1061 .unwrap_or("app");
1062 app.status =
1063 Status::neutral(format!("{app_name} — m · model r · rename Esc · back"));
1064 }
1065 (Char('n'), KeyModifiers::NONE) => {
1066 app.creating_app = true;
1067 app.status = Status::neutral("Type a name and press Enter.");
1068 }
1069 (Tab, _) => {
1070 app.screen = Screen::Downloads;
1071 app.downloads_cursor = 0;
1072 app.downloads_offset = 0;
1073 app.downloads_loaded = false;
1074 app.downloads.clear();
1075 trigger_load_downloads(app, tx);
1076 }
1077 (Char('s'), KeyModifiers::NONE) | (Char('s'), KeyModifiers::CONTROL) => {
1078 sign_out(app, tx);
1079 }
1080 (Esc, _) | (Char('c'), KeyModifiers::CONTROL) => {
1081 app.should_quit = true;
1082 }
1083 _ => {}
1084 }
1085 }
1086 }
1087
1088 fn handle_key_app_detail(
1089 app: &mut App,
1090 key: crossterm::event::KeyEvent,
1091 tx: mpsc::UnboundedSender<AuthEvent>,
1092 ) {
1093 use KeyCode::*;
1094
1095 if app.renaming_app {
1096 match (key.code, key.modifiers) {
1097 (Esc, _) => {
1098 app.renaming_app = false;
1099 app.rename_input.clear();
1100 app.status = Status::neutral("Rename cancelled.");
1101 }
1102 (Enter, _) => {
1103 submit_rename_app(app, tx);
1104 }
1105 (Backspace, _) => {
1106 app.rename_input.pop();
1107 }
1108 (Char('c'), KeyModifiers::CONTROL) => {
1109 app.should_quit = true;
1110 }
1111 (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
1112 app.rename_input.push(c);
1113 }
1114 _ => {}
1115 }
1116 } else {
1117 match (key.code, key.modifiers) {
1118 (Esc, _) => {
1119 app.screen = Screen::Apps;
1120 app.status = Status::neutral("Back to apps.");
1121 }
1122 (Enter, _) => {
1123 open_current_model_detail(app, tx.clone());
1124 }
1125 (Char('m'), KeyModifiers::NONE) => {
1126 open_inference_picker(app);
1127 }
1128 (Char('r'), KeyModifiers::NONE) => {
1129 let current_name = app
1130 .apps
1131 .get(app.apps_cursor)
1132 .map(|a| a.name.clone())
1133 .unwrap_or_default();
1134 app.rename_input = current_name;
1135 app.renaming_app = true;
1136 app.status = Status::neutral("Edit the name and press Enter.");
1137 }
1138 (Char('s'), KeyModifiers::NONE) => {
1139 sign_out(app, tx);
1140 }
1141 (Char('c'), KeyModifiers::CONTROL) => {
1142 app.should_quit = true;
1143 }
1144 _ => {}
1145 }
1146 }
1147 }
1148
1149 fn submit_rename_app(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1150 let name = app.rename_input.trim().to_string();
1151 if name.is_empty() {
1152 app.status = Status::error("Name cannot be empty.");
1153 return;
1154 }
1155 let Some(onde_app) = app.apps.get(app.apps_cursor) else {
1156 return;
1157 };
1158 let token = token::load().unwrap_or_default();
1159 let onde_app_id = onde_app.id.clone();
1160 let app_index = app.apps_cursor;
1161 app.busy = true;
1162 app.status = Status::neutral(format!("Renaming to \"{name}\"\u{2026}"));
1163 tokio::spawn(async move {
1164 match crate::gresiq::rename_app(&token, &onde_app_id, &name).await {
1165 Ok(_) => {
1166 let _ = tx.send(AuthEvent::AppRenamedOk {
1167 app_index,
1168 new_name: name,
1169 });
1170 }
1171 Err(e) => {
1172 let _ = tx.send(AuthEvent::AppRenameFailed(e.to_string()));
1173 }
1174 }
1175 });
1176 }
1177
1178 fn handle_key_models(
1179 app: &mut App,
1180 key: crossterm::event::KeyEvent,
1181 tx: mpsc::UnboundedSender<AuthEvent>,
1182 ) {
1183 use KeyCode::*;
1184
1185 match (key.code, key.modifiers) {
1186 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1187 app.models_cursor = app.models_cursor.saturating_sub(1);
1188 clamp_models_scroll(app, MAX_VISIBLE);
1189 }
1190 (Down, _) | (Char('j'), KeyModifiers::NONE) => {
1191 if app.models_cursor + 1 < app.models.len() {
1192 app.models_cursor += 1;
1193 }
1194 clamp_models_scroll(app, MAX_VISIBLE);
1195 }
1196 (Enter, _) => {
1197 submit_assign_model(app, tx);
1198 }
1199 (Esc, _) => {
1200 app.screen = Screen::AppDetail;
1201 app.assigning_for_app_index = None;
1202 app.status = Status::neutral("Back to app.");
1203 }
1204 (Char('c'), KeyModifiers::CONTROL) => {
1205 app.should_quit = true;
1206 }
1207 _ => {}
1208 }
1209 }
1210
1211 fn clamp_apps_scroll(app: &mut App, max_visible: usize) {
1212 if app.apps_cursor < app.apps_offset {
1213 app.apps_offset = app.apps_cursor;
1214 } else if app.apps_cursor >= app.apps_offset + max_visible {
1215 app.apps_offset = app.apps_cursor + 1 - max_visible;
1216 }
1217 }
1218
1219 fn handle_key_downloads(
1220 app: &mut App,
1221 key: crossterm::event::KeyEvent,
1222 tx: mpsc::UnboundedSender<AuthEvent>,
1223 ) {
1224 use KeyCode::*;
1225
1226 // While a download is running, only allow quit.
1227 if app.downloading {
1228 if matches!(
1229 (key.code, key.modifiers),
1230 (Char('c'), KeyModifiers::CONTROL)
1231 ) {
1232 app.should_quit = true;
1233 }
1234 return;
1235 }
1236
1237 if app.hf_search_active {
1238 match (key.code, key.modifiers) {
1239 (Esc, _) => {
1240 app.hf_search_active = false;
1241 app.hf_search_query.clear();
1242 app.hf_search_results.clear();
1243 app.hf_search_cursor = 0;
1244 app.hf_search_loading = false;
1245 app.status = Status::neutral("Search cancelled.");
1246 }
1247 (Enter, _) => {
1248 if !app.hf_search_results.is_empty() {
1249 trigger_download(app, tx);
1250 } else if !app.hf_search_query.trim().is_empty() && !app.hf_search_loading {
1251 trigger_hf_search(app, tx);
1252 }
1253 }
1254 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1255 app.hf_search_cursor = app.hf_search_cursor.saturating_sub(1);
1256 }
1257 (Down, _) | (Char('j'), KeyModifiers::NONE)
1258 if app.hf_search_cursor + 1 < app.hf_search_results.len() =>
1259 {
1260 app.hf_search_cursor += 1;
1261 }
1262 (Backspace, _) => {
1263 if !app.hf_search_results.is_empty() {
1264 // Editing query clears old results.
1265 app.hf_search_results.clear();
1266 app.hf_search_cursor = 0;
1267 }
1268 if app.hf_search_query.pop().is_none() {
1269 app.hf_search_active = false;
1270 }
1271 }
1272 (Char('c'), KeyModifiers::CONTROL) => {
1273 app.should_quit = true;
1274 }
1275 (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
1276 // Editing query clears old results.
1277 if !app.hf_search_results.is_empty() {
1278 app.hf_search_results.clear();
1279 app.hf_search_cursor = 0;
1280 }
1281 app.hf_search_query.push(c);
1282 }
1283 _ => {}
1284 }
1285 return;
1286 }
1287
1288 // Normal Downloads screen navigation.
1289 match (key.code, key.modifiers) {
1290 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1291 app.downloads_cursor = app.downloads_cursor.saturating_sub(1);
1292 clamp_downloads_scroll(app, MAX_VISIBLE);
1293 }
1294 (Down, _) | (Char('j'), KeyModifiers::NONE) => {
1295 if app.downloads_cursor + 1 < app.downloads.len() {
1296 app.downloads_cursor += 1;
1297 }
1298 clamp_downloads_scroll(app, MAX_VISIBLE);
1299 }
1300 (Enter, _) if !app.downloads.is_empty() => {
1301 // Scan for existing adapters before entering the detail screen
1302 let can_finetune;
1303 if let Some(model) = app.downloads.get(app.downloads_cursor) {
1304 let resolved = resolve_hf_cache_path(&model.model_id);
1305 can_finetune = !resolved.is_empty()
1306 && std::path::Path::new(&resolved).join("config.json").exists();
1307 app.adapter_list = scan_adapters(&resolved);
1308 app.adapter_cursor = 0;
1309 } else {
1310 can_finetune = false;
1311 }
1312 app.model_detail_can_finetune = can_finetune;
1313 app.screen = Screen::ModelDetail;
1314 if app.adapter_list.is_empty() {
1315 app.status = Status::neutral("Model details.");
1316 } else {
1317 let ft_hint = if can_finetune {
1318 " f · fine-tune new"
1319 } else {
1320 ""
1321 };
1322 app.status = Status::success(format!(
1323 "{} adapter{} found. Enter · merge & export{ft_hint}",
1324 app.adapter_list.len(),
1325 if app.adapter_list.len() == 1 { "" } else { "s" }
1326 ));
1327 }
1328 }
1329 (Char('c'), KeyModifiers::NONE) => {
1330 app.clone_repo_id.clear();
1331 app.clone_repo_status = None;
1332 app.clone_base_cursor = 0;
1333 app.clone_repo_checking = false;
1334 app.clone_progress = None;
1335 app.screen = Screen::CloneRepo;
1336 app.status = Status::neutral("Enter a HuggingFace repo ID to check.");
1337 }
1338 (Char('/'), KeyModifiers::NONE) => {
1339 app.hf_search_active = true;
1340 app.hf_search_query.clear();
1341 app.hf_search_results.clear();
1342 app.hf_search_cursor = 0;
1343 app.hf_search_loading = false;
1344 app.status = Status::neutral("Search HuggingFace Hub.");
1345 }
1346 (Esc, _) | (Tab, _) => {
1347 app.screen = Screen::Apps;
1348 app.status = app.idle_status();
1349 }
1350 (Char('c'), KeyModifiers::CONTROL) => {
1351 app.should_quit = true;
1352 }
1353 _ => {}
1354 }
1355 }
1356
1357 fn handle_key_model_detail(
1358 app: &mut App,
1359 key: crossterm::event::KeyEvent,
1360 _tx: mpsc::UnboundedSender<AuthEvent>,
1361 ) {
1362 use KeyCode::*;
1363
1364 match (key.code, key.modifiers) {
1365 (Esc, _) => {
1366 app.screen = Screen::Downloads;
1367 app.adapter_list.clear();
1368 app.adapter_cursor = 0;
1369 app.status = Status::neutral("Back to models.");
1370 }
1371 (Char('c'), KeyModifiers::CONTROL) => {
1372 app.should_quit = true;
1373 }
1374 // Navigate adapter list
1375 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1376 app.adapter_cursor = app.adapter_cursor.saturating_sub(1);
1377 }
1378 (Down, _) | (Char('j'), KeyModifiers::NONE)
1379 if !app.adapter_list.is_empty() && app.adapter_cursor + 1 < app.adapter_list.len() =>
1380 {
1381 app.adapter_cursor += 1;
1382 }
1383 // Enter on a GGUF artifact — open GGUF detail screen
1384 (Enter, _)
1385 if app
1386 .adapter_list
1387 .get(app.adapter_cursor)
1388 .is_some_and(|a| a.kind == ArtifactKind::Gguf) =>
1389 {
1390 if let Some(entry) = app.adapter_list.get(app.adapter_cursor) {
1391 // Use the project repo ID when available (e.g. "ondeinference/rumi"),
1392 // otherwise derive from the base model name.
1393 app.upload_repo_name = if let Some(ref project) = app.current_project {
1394 project.repo_id.clone()
1395 } else {
1396 let base_name = app
1397 .downloads
1398 .get(app.downloads_cursor)
1399 .map(|m| m.model_id.replace('/', "-"))
1400 .unwrap_or_else(|| "finetuned-model".to_string());
1401 let stem = entry
1402 .file_name
1403 .strip_suffix(".gguf")
1404 .unwrap_or(&entry.file_name);
1405 format!("ondeinference/{base_name}-{stem}")
1406 };
1407 app.selected_gguf = Some(entry.clone());
1408 app.upload_progress = None;
1409 app.upload_running = false;
1410 app.screen = Screen::GgufDetail;
1411 // Upload only applies to local fine-tune outputs; GGUFs that
1412 // came from HF (App Group / HF cache) have nowhere to go.
1413 app.status = if entry.is_uploadable() {
1414 Status::neutral("GGUF model details. Enter · upload to HuggingFace")
1415 } else {
1416 Status::neutral("GGUF model details. c · test in chat")
1417 };
1418 }
1419 }
1420 // Enter or 'm' — merge the selected LoRA adapter (skip fine-tuning)
1421 (Enter, _) | (Char('m'), KeyModifiers::NONE)
1422 if app
1423 .adapter_list
1424 .get(app.adapter_cursor)
1425 .is_some_and(|a| a.kind == ArtifactKind::LoraAdapter) =>
1426 {
1427 if let Some(adapter) = app.adapter_list.get(app.adapter_cursor) {
1428 if let Some(model) = app.downloads.get(app.downloads_cursor) {
1429 let resolved = resolve_hf_cache_path(&model.model_id);
1430 if resolved.is_empty() {
1431 app.status = Status::error("Model not downloaded locally.");
1432 return;
1433 }
1434
1435 let adapter_path = adapter.path.clone();
1436 app.finetune_model_id = model.model_id.clone();
1437 app.finetune_model_dir = resolved.clone();
1438
1439 // Set finetune_progress to Done so the FineTune screen
1440 // shows the post-training view with merge/export actions.
1441 app.finetune_progress = Some(crate::finetune::FineTuneProgress::Done {
1442 adapter_path: adapter_path.clone(),
1443 });
1444 app.finetune_running = false;
1445 app.merge_progress = None;
1446 app.gguf_progress = None;
1447 app.merged_model_dir = None;
1448 app.screen = Screen::FineTune;
1449 app.status =
1450 Status::neutral("Adapter selected. Press m to merge, Esc to go back.");
1451 }
1452 } else {
1453 app.status = Status::error("No adapter selected.");
1454 }
1455 }
1456 (Char('f'), KeyModifiers::NONE) if app.model_detail_can_finetune => {
1457 if let Some(model) = app.downloads.get(app.downloads_cursor) {
1458 let resolved = resolve_hf_cache_path(&model.model_id);
1459 if resolved.is_empty() {
1460 app.status = Status::error("Model not downloaded locally.");
1461 } else {
1462 app.finetune_model_id = model.model_id.clone();
1463 app.finetune_model_dir = resolved;
1464 app.finetune_data_path = finetune_data_path_for_model(&model.model_id);
1465 app.current_project = None; // no project context from model detail
1466 app.finetune_focus = FineTuneFocus::DataPath;
1467 app.finetune_running = false;
1468 app.finetune_progress = None;
1469 app.screen = Screen::FineTune;
1470 app.status = Status::neutral("Configure fine-tuning.");
1471 }
1472 }
1473 }
1474 _ => {}
1475 }
1476 }
1477
1478 /// Resolve a HuggingFace model ID to its local cache snapshot directory.
1479 ///
1480 /// Priority: App Group (onde-cli download cache) → `$HF_HOME` → `~/.cache/huggingface/hub`.
1481 /// Looks for `{hub}/models--{org}--{name}/snapshots/{hash}/` and returns the first match,
1482 /// or an empty string if not found.
1483 fn resolve_hf_cache_path(model_id: &str) -> String {
1484 let dir_name = format!("models--{}", model_id.replace('/', "--"));
1485
1486 // App Group first (that's where onde-cli downloads), then standard HF cache
1487 let candidates: Vec<std::path::PathBuf> = {
1488 let mut c = Vec::new();
1489 #[cfg(target_os = "macos")]
1490 if let Some(home) = dirs::home_dir() {
1491 c.push(
1492 home.join("Library")
1493 .join("Group Containers")
1494 .join("group.com.ondeinference.apps")
1495 .join("models")
1496 .join("hub"),
1497 );
1498 }
1499 // Check HF_HOME env
1500 if let Ok(hf_home) = std::env::var("HF_HOME") {
1501 c.push(std::path::PathBuf::from(hf_home).join("hub"));
1502 }
1503 // ~/.cache/huggingface/hub
1504 if let Some(home) = dirs::home_dir() {
1505 c.push(home.join(".cache").join("huggingface").join("hub"));
1506 }
1507 c
1508 };
1509
1510 for hub in candidates {
1511 let snapshots_dir = hub.join(&dir_name).join("snapshots");
1512 if let Ok(entries) = std::fs::read_dir(&snapshots_dir) {
1513 // Pick the first directory that looks like a commit hash (hex string).
1514 // This skips non-hash dirs like "lora-adapter" that fine-tuning creates.
1515 for entry in entries.flatten() {
1516 let p = entry.path();
1517 if p.is_dir() {
1518 let name = entry.file_name();
1519 let name = name.to_string_lossy();
1520 if name.len() >= 7 && name.chars().all(|c| c.is_ascii_hexdigit()) {
1521 return p.to_string_lossy().to_string();
1522 }
1523 }
1524 }
1525 }
1526 }
1527
1528 String::new()
1529 }
1530
1531 /// Scan for existing `lora_adapter.safetensors` files and `.gguf` exports under
1532 /// the model's snapshots directory. Returns entries sorted by modification time
1533 /// (newest first).
1534 fn scan_adapters(model_dir: &str) -> Vec<AdapterEntry> {
1535 if model_dir.is_empty() {
1536 return Vec::new();
1537 }
1538 let snapshots_dir = std::path::Path::new(model_dir)
1539 .parent()
1540 .unwrap_or_else(|| std::path::Path::new(""));
1541
1542 let mut artifacts: Vec<AdapterEntry> = Vec::new();
1543
1544 let Ok(entries) = std::fs::read_dir(snapshots_dir) else {
1545 return artifacts;
1546 };
1547
1548 for entry in entries.flatten() {
1549 let dir = entry.path();
1550 if !dir.is_dir() {
1551 continue;
1552 }
1553 let dir_name = entry.file_name().to_string_lossy().to_string();
1554
1555 // Check for LoRA adapter
1556 let adapter_file = dir.join("lora_adapter.safetensors");
1557 if adapter_file.exists()
1558 && let Some(ae) = make_artifact_entry(
1559 &adapter_file,
1560 &dir_name,
1561 "lora_adapter.safetensors",
1562 ArtifactKind::LoraAdapter,
1563 )
1564 {
1565 artifacts.push(ae);
1566 }
1567
1568 // Check for any .gguf files in this subdirectory
1569 if let Ok(sub_entries) = std::fs::read_dir(&dir) {
1570 for sub in sub_entries.flatten() {
1571 let fname = sub.file_name().to_string_lossy().to_string();
1572 if fname.ends_with(".gguf")
1573 && let Some(ae) =
1574 make_artifact_entry(&sub.path(), &dir_name, &fname, ArtifactKind::Gguf)
1575 {
1576 artifacts.push(ae);
1577 }
1578 }
1579 }
1580 }
1581
1582 // Also check the snapshots parent directory itself for .gguf files
1583 // (GGUF export writes to e.g. .../snapshots/model-finetuned-q8_0.gguf)
1584 if let Ok(parent_entries) = std::fs::read_dir(snapshots_dir) {
1585 for entry in parent_entries.flatten() {
1586 let path = entry.path();
1587 if path.is_file() {
1588 let fname = entry.file_name().to_string_lossy().to_string();
1589 if fname.ends_with(".gguf")
1590 && let Some(ae) =
1591 make_artifact_entry(&path, "snapshots", &fname, ArtifactKind::Gguf)
1592 {
1593 artifacts.push(ae);
1594 }
1595 }
1596 }
1597 }
1598
1599 // Sort newest first
1600 artifacts.sort_by(|a, b| {
1601 let ma = std::fs::metadata(&a.path)
1602 .ok()
1603 .and_then(|m| m.modified().ok());
1604 let mb = std::fs::metadata(&b.path)
1605 .ok()
1606 .and_then(|m| m.modified().ok());
1607 mb.cmp(&ma)
1608 });
1609
1610 artifacts
1611 }
1612
1613 fn make_artifact_entry(
1614 path: &std::path::Path,
1615 dir_name: &str,
1616 file_name: &str,
1617 kind: ArtifactKind,
1618 ) -> Option<AdapterEntry> {
1619 let meta = std::fs::metadata(path).ok()?;
1620 let size = format_adapter_size(meta.len());
1621 let modified = meta
1622 .modified()
1623 .ok()
1624 .map(|t| {
1625 let elapsed = t.elapsed().unwrap_or_default();
1626 let secs = elapsed.as_secs();
1627 if secs < 60 {
1628 "just now".to_string()
1629 } else if secs < 3600 {
1630 format!("{}m ago", secs / 60)
1631 } else if secs < 86400 {
1632 format!("{}h ago", secs / 3600)
1633 } else {
1634 format!("{}d ago", secs / 86400)
1635 }
1636 })
1637 .unwrap_or_else(|| "–".to_string());
1638 Some(AdapterEntry {
1639 path: path.to_path_buf(),
1640 dir_name: dir_name.to_string(),
1641 file_name: file_name.to_string(),
1642 size,
1643 modified,
1644 kind,
1645 })
1646 }
1647
1648 fn handle_key_gguf_detail(
1649 app: &mut App,
1650 key: crossterm::event::KeyEvent,
1651 tx: mpsc::UnboundedSender<AuthEvent>,
1652 ) {
1653 use KeyCode::*;
1654
1655 // While uploading, only allow Ctrl+C
1656 if app.upload_running {
1657 if matches!(
1658 (key.code, key.modifiers),
1659 (Char('c'), KeyModifiers::CONTROL)
1660 ) {
1661 app.should_quit = true;
1662 }
1663 return;
1664 }
1665
1666 // If upload is done or failed, Esc goes back; otherwise show result
1667 if let Some(crate::hf_upload::UploadProgress::Done { ref url }) = app.upload_progress {
1668 let url_owned = url.clone();
1669 match (key.code, key.modifiers) {
1670 (Esc, _) => {
1671 app.screen = Screen::ModelDetail;
1672 app.selected_gguf = None;
1673 app.upload_progress = None;
1674 app.status = Status::success(format!("Uploaded: {url_owned}"));
1675 }
1676 (Char('a'), KeyModifiers::NONE) => {
1677 trigger_publish_model(app, tx);
1678 }
1679 (Char('c'), KeyModifiers::CONTROL) => {
1680 app.should_quit = true;
1681 }
1682 _ => {}
1683 }
1684 return;
1685 }
1686
1687 match (key.code, key.modifiers) {
1688 (Esc, _) => {
1689 app.screen = Screen::ModelDetail;
1690 app.selected_gguf = None;
1691 app.upload_progress = None;
1692 app.status = Status::neutral("Back to model.");
1693 }
1694 (Char('c'), KeyModifiers::CONTROL) => {
1695 app.should_quit = true;
1696 }
1697 // 'c' — open the chat screen to test this GGUF
1698 (Char('c'), KeyModifiers::NONE) => {
1699 if let Some(ref gguf) = app.selected_gguf {
1700 trigger_chat(app, gguf.path.clone(), tx);
1701 }
1702 }
1703 // Upload-only keys — guarded behind is_uploadable()
1704 (Backspace, _)
1705 if app
1706 .selected_gguf
1707 .as_ref()
1708 .is_some_and(|g| g.is_uploadable()) =>
1709 {
1710 app.upload_repo_name.pop();
1711 }
1712 (Enter, _)
1713 if app
1714 .selected_gguf
1715 .as_ref()
1716 .is_some_and(|g| g.is_uploadable()) =>
1717 {
1718 if app.upload_repo_name.is_empty() {
1719 app.status = Status::error("Repo name cannot be empty.");
1720 return;
1721 }
1722 let token = HF_TOKEN.to_string();
1723 if token.is_empty() {
1724 app.status = Status::error("No HF_TOKEN set. Add it to .env and rebuild.");
1725 return;
1726 }
1727 let Some(ref gguf) = app.selected_gguf else {
1728 app.status = Status::error("No GGUF file selected.");
1729 return;
1730 };
1731
1732 let config = crate::hf_upload::UploadConfig {
1733 file_path: gguf.path.clone(),
1734 repo_id: app.upload_repo_name.clone(),
1735 path_in_repo: gguf.file_name.clone(),
1736 hf_token: token,
1737 commit_message: format!("Upload fine-tuned GGUF: {}", gguf.file_name),
1738 };
1739
1740 app.upload_running = true;
1741 app.upload_progress = None;
1742 app.status = Status::neutral("Uploading to HuggingFace…");
1743
1744 let (upload_tx, mut upload_rx) = mpsc::unbounded_channel();
1745 let auth_tx = tx.clone();
1746 tokio::spawn(async move {
1747 crate::hf_upload::start_upload(config, upload_tx).await;
1748 });
1749 tokio::spawn(async move {
1750 while let Some(progress) = upload_rx.recv().await {
1751 let _ = auth_tx.send(AuthEvent::UploadProgress(progress));
1752 }
1753 });
1754 }
1755 // Printable chars edit the repo name — only when uploadable
1756 (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT)
1757 if app
1758 .selected_gguf
1759 .as_ref()
1760 .is_some_and(|g| g.is_uploadable()) =>
1761 {
1762 app.upload_repo_name.push(c);
1763 }
1764 _ => {}
1765 }
1766 }
1767
1768 /// Registers the just-uploaded GGUF into the GresIQ catalog, private to this
1769 /// account, then moves to the app picker so it can be assigned right away.
1770 fn trigger_publish_model(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1771 if app.busy {
1772 return;
1773 }
1774 let Some(ref gguf) = app.selected_gguf else {
1775 return;
1776 };
1777 let hf_repo_id = app.upload_repo_name.clone();
1778 if hf_repo_id.is_empty() {
1779 app.status = Status::error("No uploaded repo to register.");
1780 return;
1781 }
1782 let gguf_file = gguf.file_name.clone();
1783 let approx_size_bytes = std::fs::metadata(&gguf.path).ok().map(|m| m.len() as i64);
1784 let base_model_id = app
1785 .current_project
1786 .as_ref()
1787 .map(|p| p.base_model_id.clone());
1788 let (base_name, family, parameter_class) = derive_base_model_meta(base_model_id.as_deref());
1789 let display_name = format!("{base_name} (Fine-tuned)");
1790 let token = token::load().unwrap_or_default();
1791
1792 // Make sure the apps list is ready by the time the picker shows up. This
1793 // must run before `app.busy` is set below -- trigger_load_apps early-
1794 // returns if busy is already true, so reordering these two would make
1795 // the apps fetch silently no-op.
1796 if !app.apps_loaded {
1797 trigger_load_apps(app, tx.clone());
1798 }
1799
1800 app.busy = true;
1801 app.status = Status::neutral("Registering model…");
1802
1803 tokio::spawn(async move {
1804 match crate::gresiq::create_model(
1805 &token,
1806 &hf_repo_id,
1807 &display_name,
1808 &family,
1809 &parameter_class,
1810 Some(&gguf_file),
1811 approx_size_bytes,
1812 )
1813 .await
1814 {
1815 Ok(model) => {
1816 let _ = tx.send(AuthEvent::ModelRegistered(model));
1817 }
1818 Err(e) => {
1819 let _ = tx.send(AuthEvent::ModelRegisterFailed(e.to_string()));
1820 }
1821 }
1822 });
1823 }
1824
1825 /// True if `size` (e.g. "3B") appears in `haystack` as its own token, bounded
1826 /// on both sides by a non-alphanumeric character (or the string edge). "3B"
1827 /// is a plain substring of "13B"/"43B" (digit before) and of "3BETA"/"3B2"
1828 /// (alphanumeric after), so a naive `.contains()` would misdetect those.
1829 /// A preceding "." also disqualifies a match: "7B" inside "1.7B" is the tail
1830 /// of a decimal size, not a 7B model.
1831 fn contains_size_token(haystack: &str, size: &str) -> bool {
1832 let mut start = 0;
1833 while let Some(idx) = haystack[start..].find(size) {
1834 let abs_idx = start + idx;
1835 let end_idx = abs_idx + size.len();
1836 let preceded_by_digit_or_dot = haystack.as_bytes()[..abs_idx]
1837 .last()
1838 .is_some_and(|b| b.is_ascii_digit() || *b == b'.');
1839 let followed_by_alnum = haystack.as_bytes()[end_idx..]
1840 .first()
1841 .is_some_and(u8::is_ascii_alphanumeric);
1842 if !preceded_by_digit_or_dot && !followed_by_alnum {
1843 return true;
1844 }
1845 start = abs_idx + 1;
1846 }
1847 false
1848 }
1849
1850 /// Best-effort display name / family / parameter class for a fine-tune,
1851 /// derived from the base model it was trained from. Falls back to generic
1852 /// values when the base model isn't one of the officially supported ones.
1853 fn derive_base_model_meta(base_model_id: Option<&str>) -> (String, String, String) {
1854 let base_model_id = base_model_id.unwrap_or("");
1855 let info = onde::inference::models::SUPPORTED_MODEL_INFO
1856 .iter()
1857 .find(|i| i.id == base_model_id);
1858
1859 let base_name = info.map(|i| i.name.to_string()).unwrap_or_else(|| {
1860 base_model_id
1861 .rsplit('/')
1862 .next()
1863 .filter(|s| !s.is_empty())
1864 .unwrap_or("Custom model")
1865 .to_string()
1866 });
1867
1868 let haystack = format!("{base_model_id} {base_name}").to_lowercase();
1869 // Checked in this order (most-specific first) because "qwen2.5-coder"
1870 // also contains the substring "qwen2.5" — reordering these would make
1871 // coder models misclassified as plain qwen2.5.
1872 let family = if haystack.contains("qwen3") {
1873 "qwen3"
1874 } else if haystack.contains("qwen2.5-coder") || haystack.contains("qwen2_5-coder") {
1875 "qwen2.5-coder"
1876 } else if haystack.contains("qwen2.5") || haystack.contains("qwen2_5") {
1877 "qwen2.5"
1878 } else {
1879 "custom"
1880 }
1881 .to_string();
1882
1883 // Covers the Qwen2.5 (0.5B–72B) and Qwen3 (0.6B–235B) lineups plus other
1884 // common parameter counts. `contains_size_token` enforces token boundaries
1885 // (no digit or "." before, no alphanumeric after), so no entry here can
1886 // match inside another — ordering is not load-bearing.
1887 const SIZES: &[&str] = &[
1888 "0.5B", "0.6B", "1.5B", "1.7B", "1.8B", "2B", "3B", "4B", "7B", "8B", "9B", "13B", "14B",
1889 "30B", "32B", "70B", "72B", "235B",
1890 ];
1891 let haystack_upper = haystack.to_uppercase();
1892 let parameter_class = SIZES
1893 .iter()
1894 .find(|s| contains_size_token(&haystack_upper, s))
1895 .map(|s| s.to_string())
1896 .unwrap_or_else(|| "unknown".to_string());
1897
1898 (base_name, family, parameter_class)
1899 }
1900
1901 fn handle_key_publish_model(
1902 app: &mut App,
1903 key: crossterm::event::KeyEvent,
1904 tx: mpsc::UnboundedSender<AuthEvent>,
1905 ) {
1906 use KeyCode::*;
1907
1908 match (key.code, key.modifiers) {
1909 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
1910 app.apps_cursor = app.apps_cursor.saturating_sub(1);
1911 clamp_apps_scroll(app, MAX_VISIBLE);
1912 }
1913 (Down, _) | (Char('j'), KeyModifiers::NONE) => {
1914 if app.apps_cursor + 1 < app.apps.len() {
1915 app.apps_cursor += 1;
1916 }
1917 clamp_apps_scroll(app, MAX_VISIBLE);
1918 }
1919 (Enter, _) if !app.apps.is_empty() => {
1920 submit_assign_published_model(app, tx);
1921 }
1922 (Esc, _) => {
1923 app.publish_model_id = None;
1924 app.publish_model_name = None;
1925 app.screen = Screen::GgufDetail;
1926 app.status = Status::neutral("Model registered — assign it later from Apps.");
1927 }
1928 (Char('c'), KeyModifiers::CONTROL) => {
1929 app.should_quit = true;
1930 }
1931 _ => {}
1932 }
1933 }
1934
1935 /// Assigns the model held in `app.publish_model_id` to the app currently
1936 /// selected in the picker. Reads the target model by id, not by indexing
1937 /// into `app.models` with a cursor, so it can't silently assign the wrong
1938 /// row if the models list is ever reloaded or reordered while this screen
1939 /// is open.
1940 fn submit_assign_published_model(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1941 if app.busy {
1942 return;
1943 }
1944 let Some(model_id) = app.publish_model_id.clone() else {
1945 return;
1946 };
1947 let Some(onde_app) = app.apps.get(app.apps_cursor) else {
1948 return;
1949 };
1950 let token = token::load().unwrap_or_default();
1951 let onde_app_id = onde_app.id.clone();
1952 let app_index = app.apps_cursor;
1953 let model_name = app
1954 .publish_model_name
1955 .clone()
1956 .unwrap_or_else(|| model_id.clone());
1957 app.busy = true;
1958 app.publish_model_id = None;
1959 app.publish_model_name = None;
1960 app.status = Status::neutral(format!("Assigning {model_name}…"));
1961 tokio::spawn(async move {
1962 match crate::gresiq::assign_model(&token, &onde_app_id, &model_id).await {
1963 Ok(()) => {
1964 let _ = tx.send(AuthEvent::ModelAssigned {
1965 app_index,
1966 model_id,
1967 });
1968 }
1969 Err(e) => {
1970 let _ = tx.send(AuthEvent::ModelAssignFailed(e.to_string()));
1971 }
1972 }
1973 });
1974 }
1975
1976 fn format_adapter_size(bytes: u64) -> String {
1977 if bytes >= 1_000_000_000 {
1978 format!("{:.1}GB", bytes as f64 / 1e9)
1979 } else if bytes >= 1_000_000 {
1980 format!("{:.0}MB", bytes as f64 / 1e6)
1981 } else {
1982 format!("{:.0}KB", bytes as f64 / 1e3)
1983 }
1984 }
1985
1986 fn trigger_chat(
1987 app: &mut App,
1988 gguf_path: std::path::PathBuf,
1989 tx: mpsc::UnboundedSender<AuthEvent>,
1990 ) {
1991 // Reset chat state for a fresh session.
1992 app.chat_messages.clear();
1993 app.chat_input.clear();
1994 app.chat_loading = false;
1995 app.chat_thinking = false;
1996 app.chat_progress = None;
1997 app.chat_scroll_offset = 0;
1998 app.chat_command_tx = None;
1999
2000 let (progress_tx, mut progress_rx) = mpsc::unbounded_channel::<crate::chat::ChatProgress>();
2001 let (command_tx, command_rx) = mpsc::unbounded_channel::<crate::chat::ChatCommand>();
2002
2003 app.chat_command_tx = Some(command_tx);
2004 app.screen = Screen::Chat;
2005 app.status = Status::neutral("Loading model from disk…");
2006
2007 // Spawn the background chat worker.
2008 tokio::spawn(async move {
2009 crate::chat::start_chat(gguf_path, progress_tx, command_rx).await;
2010 });
2011
2012 // Forward progress events to the main event loop.
2013 tokio::spawn(async move {
2014 while let Some(progress) = progress_rx.recv().await {
2015 let _ = tx.send(AuthEvent::ChatProgress(progress));
2016 }
2017 });
2018 }
2019
2020 fn handle_key_chat(
2021 app: &mut App,
2022 key: crossterm::event::KeyEvent,
2023 _tx: mpsc::UnboundedSender<AuthEvent>,
2024 ) {
2025 use KeyCode::*;
2026
2027 // While model is loading or inference is running, only allow Ctrl+C.
2028 if app.chat_loading || app.chat_thinking {
2029 if matches!(
2030 (key.code, key.modifiers),
2031 (Char('c'), KeyModifiers::CONTROL)
2032 ) {
2033 app.should_quit = true;
2034 }
2035 return;
2036 }
2037
2038 match (key.code, key.modifiers) {
2039 (Char('c'), KeyModifiers::CONTROL) => {
2040 app.should_quit = true;
2041 }
2042 (Esc, _) => {
2043 // Send Quit to the background worker so it can clean up.
2044 if let Some(ref tx) = app.chat_command_tx {
2045 let _ = tx.send(crate::chat::ChatCommand::Quit);
2046 }
2047 app.chat_command_tx = None;
2048 app.screen = Screen::GgufDetail;
2049 app.status = Status::neutral("Back to GGUF detail.");
2050 }
2051 // Scroll up through message history.
2052 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
2053 app.chat_scroll_offset = app.chat_scroll_offset.saturating_sub(1);
2054 }
2055 // Scroll down through message history.
2056 (Down, _) | (Char('j'), KeyModifiers::NONE) => {
2057 let max = app.chat_messages.len().saturating_sub(1);
2058 if app.chat_scroll_offset < max {
2059 app.chat_scroll_offset += 1;
2060 }
2061 }
2062 // Delete last character from input.
2063 (Backspace, _) => {
2064 app.chat_input.pop();
2065 }
2066 // Send the message.
2067 (Enter, _) => {
2068 let text = app.chat_input.trim().to_string();
2069 if text.is_empty() {
2070 return;
2071 }
2072 if let Some(ref tx) = app.chat_command_tx {
2073 app.chat_messages
2074 .push(crate::chat::ChatMessage::user(text.clone()));
2075 app.chat_scroll_offset = app.chat_messages.len().saturating_sub(1);
2076 app.chat_input.clear();
2077 app.chat_thinking = true;
2078 app.status = Status::neutral("Thinking…");
2079 let _ = tx.send(crate::chat::ChatCommand::SendMessage(text));
2080 } else {
2081 app.status = Status::error("Chat engine not ready.");
2082 }
2083 }
2084 // Append printable characters to the input buffer.
2085 (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
2086 app.chat_input.push(c);
2087 }
2088 _ => {}
2089 }
2090 }
2091
2092 fn handle_key_finetune(
2093 app: &mut App,
2094 key: crossterm::event::KeyEvent,
2095 tx: mpsc::UnboundedSender<AuthEvent>,
2096 ) {
2097 use KeyCode::*;
2098
2099 // While any background task is running, only allow Ctrl+C
2100 if app.finetune_running || app.merge_running || app.gguf_running {
2101 if matches!(
2102 (key.code, key.modifiers),
2103 (Char('c'), KeyModifiers::CONTROL)
2104 ) {
2105 app.should_quit = true;
2106 }
2107 return;
2108 }
2109
2110 // After fine-tune is done: handle merge (m) and GGUF export (g)
2111 if let Some(crate::finetune::FineTuneProgress::Done { adapter_path }) = &app.finetune_progress {
2112 match (key.code, key.modifiers) {
2113 (Char('m'), KeyModifiers::NONE) => {
2114 // Start merge: adapter into base model
2115 let base_dir = std::path::PathBuf::from({
2116 let raw = &app.finetune_model_dir;
2117 dirs::home_dir()
2118 .and_then(|home| {
2119 raw.strip_prefix("~/")
2120 .map(|rest| home.join(rest).to_string_lossy().to_string())
2121 })
2122 .unwrap_or_else(|| raw.to_string())
2123 });
2124 // Derive a timestamped merge dir from the adapter dir name.
2125 // Put the merged model in a `merged/` subdir next to the adapter
2126 let output_dir = adapter_path
2127 .parent()
2128 .unwrap_or_else(|| std::path::Path::new("."))
2129 .join("merged");
2130
2131 let merge_config = crate::merge::MergeConfig {
2132 base_dir,
2133 adapter_path: adapter_path.clone(),
2134 output_dir,
2135 };
2136
2137 app.merge_running = true;
2138 app.merge_progress = None;
2139 app.status = Status::neutral("Merging adapter…");
2140
2141 let (merge_tx, mut merge_rx) = mpsc::unbounded_channel();
2142 crate::merge::start_merge(merge_config, merge_tx);
2143
2144 let auth_tx = tx.clone();
2145 tokio::spawn(async move {
2146 while let Some(progress) = merge_rx.recv().await {
2147 let _ = auth_tx.send(AuthEvent::MergeProgress(progress));
2148 }
2149 });
2150 return;
2151 }
2152 (Char('g'), KeyModifiers::NONE) if app.merged_model_dir.is_some() => {
2153 // Start GGUF export from merged model
2154 let model_dir = app.merged_model_dir.clone().unwrap_or_default();
2155 // Derive GGUF filename from the merged dir name for traceability.
2156 // Place the GGUF in the run dir (parent of merged/).
2157 let run_dir = model_dir.parent().unwrap_or(&model_dir);
2158 let gguf_name = app
2159 .current_project
2160 .as_ref()
2161 .map(|p| {
2162 p.repo_id
2163 .split('/')
2164 .next_back()
2165 .unwrap_or("model")
2166 .to_string()
2167 })
2168 .unwrap_or_else(|| {
2169 // Use the base model short name for traceability
2170 app.finetune_model_id
2171 .split('/')
2172 .next_back()
2173 .unwrap_or("model-finetuned")
2174 .to_string()
2175 });
2176 let output_path = run_dir.join(format!("{gguf_name}-q8_0.gguf"));
2177
2178 let gguf_config = crate::gguf::GgufConfig {
2179 model_dir,
2180 output_path,
2181 dtype: crate::gguf::GgufDtype::Q8_0,
2182 };
2183
2184 app.gguf_running = true;
2185 app.gguf_progress = None;
2186 app.status = Status::neutral("Exporting GGUF…");
2187
2188 let (gguf_tx, mut gguf_rx) = mpsc::unbounded_channel();
2189 crate::gguf::start_gguf_export(gguf_config, gguf_tx);
2190
2191 let auth_tx = tx.clone();
2192 tokio::spawn(async move {
2193 while let Some(progress) = gguf_rx.recv().await {
2194 let _ = auth_tx.send(AuthEvent::GgufProgress(progress));
2195 }
2196 });
2197 return;
2198 }
2199 (Esc, _) => {
2200 app.screen = Screen::ModelDetail;
2201 app.finetune_progress = None;
2202 app.merge_progress = None;
2203 app.gguf_progress = None;
2204 app.merged_model_dir = None;
2205 app.status = Status::neutral("Back to model.");
2206 return;
2207 }
2208 (Char('c'), KeyModifiers::CONTROL) => {
2209 app.should_quit = true;
2210 return;
2211 }
2212 _ => return,
2213 }
2214 }
2215
2216 match (key.code, key.modifiers) {
2217 (Tab, _) => {
2218 app.finetune_focus = match app.finetune_focus {
2219 FineTuneFocus::ModelDir => FineTuneFocus::DataPath,
2220 FineTuneFocus::DataPath => FineTuneFocus::Rank,
2221 FineTuneFocus::Rank => FineTuneFocus::Epochs,
2222 FineTuneFocus::Epochs => FineTuneFocus::Lr,
2223 FineTuneFocus::Lr => FineTuneFocus::ModelDir,
2224 };
2225 }
2226 (Backspace, _) => match app.finetune_focus {
2227 FineTuneFocus::ModelDir => {
2228 app.finetune_model_dir.pop();
2229 }
2230 FineTuneFocus::DataPath => {
2231 app.finetune_data_path.pop();
2232 }
2233 FineTuneFocus::Rank => {
2234 app.finetune_rank.pop();
2235 }
2236 FineTuneFocus::Epochs => {
2237 app.finetune_epochs.pop();
2238 }
2239 FineTuneFocus::Lr => {
2240 app.finetune_lr.pop();
2241 }
2242 },
2243 (Enter, _) => {
2244 let rank: usize = app.finetune_rank.parse().unwrap_or(8);
2245 let epochs: usize = app.finetune_epochs.parse().unwrap_or(3);
2246 let lr: f64 = app.finetune_lr.parse().unwrap_or(0.00001);
2247
2248 // Expand ~ in paths
2249 let expand = |s: &str| -> String {
2250 dirs::home_dir()
2251 .and_then(|home| {
2252 s.strip_prefix("~/")
2253 .map(|rest| home.join(rest).to_string_lossy().to_string())
2254 })
2255 .unwrap_or_else(|| s.to_string())
2256 };
2257
2258 let model_dir = std::path::PathBuf::from(expand(&app.finetune_model_dir));
2259 let data_path = std::path::PathBuf::from(expand(&app.finetune_data_path));
2260 // Use the project's run directory if available, otherwise fall back
2261 // to a timestamped dir next to the model.
2262 let output_dir = if let Some(ref project) = app.current_project {
2263 match crate::project::new_run_dir(project) {
2264 Ok(run_dir) => run_dir,
2265 Err(e) => {
2266 app.status = Status::error(format!("Failed to create run dir: {e}"));
2267 return;
2268 }
2269 }
2270 } else {
2271 let ts = run_timestamp();
2272 model_dir
2273 .parent()
2274 .unwrap_or(&model_dir)
2275 .join(format!("lora-{ts}"))
2276 };
2277
2278 let config = crate::finetune::FineTuneConfig {
2279 model_dir,
2280 data_path,
2281 output_dir,
2282 lora_rank: rank,
2283 lora_alpha: (rank as f32) * 2.0,
2284 learning_rate: lr,
2285 epochs,
2286 max_seq_len: 512,
2287 };
2288
2289 app.finetune_running = true;
2290 app.finetune_progress = None;
2291 app.status = Status::neutral("Fine-tuning started…");
2292
2293 let (ft_tx, mut ft_rx) = mpsc::unbounded_channel();
2294 crate::finetune::start_finetune(config, ft_tx);
2295
2296 let auth_tx = tx.clone();
2297 tokio::spawn(async move {
2298 while let Some(progress) = ft_rx.recv().await {
2299 let _ = auth_tx.send(AuthEvent::FineTuneProgress(progress));
2300 }
2301 });
2302 }
2303 (Esc, _) => {
2304 app.screen = Screen::ModelDetail;
2305 app.status = Status::neutral("Back to model.");
2306 }
2307 (Char('c'), KeyModifiers::CONTROL) => {
2308 app.should_quit = true;
2309 }
2310 (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => match app.finetune_focus {
2311 FineTuneFocus::ModelDir => {
2312 app.finetune_model_dir.push(c);
2313 }
2314 FineTuneFocus::DataPath => {
2315 app.finetune_data_path.push(c);
2316 }
2317 FineTuneFocus::Rank => {
2318 app.finetune_rank.push(c);
2319 }
2320 FineTuneFocus::Epochs => {
2321 app.finetune_epochs.push(c);
2322 }
2323 FineTuneFocus::Lr => {
2324 app.finetune_lr.push(c);
2325 }
2326 },
2327 _ => {}
2328 }
2329 }
2330
2331 fn handle_key_clone_repo(
2332 app: &mut App,
2333 key: crossterm::event::KeyEvent,
2334 tx: mpsc::UnboundedSender<AuthEvent>,
2335 ) {
2336 use KeyCode::*;
2337
2338 // While checking, only allow quit.
2339 if app.clone_repo_checking {
2340 if matches!(
2341 (key.code, key.modifiers),
2342 (Char('c'), KeyModifiers::CONTROL)
2343 ) {
2344 app.should_quit = true;
2345 }
2346 return;
2347 }
2348
2349 // If repo is empty or not found + created → show base model picker
2350 let is_picking_base = matches!(
2351 &app.clone_repo_status,
2352 Some(crate::hf_clone::RepoStatus::Empty { .. })
2353 );
2354
2355 if is_picking_base {
2356 match (key.code, key.modifiers) {
2357 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
2358 app.clone_base_cursor = app.clone_base_cursor.saturating_sub(1);
2359 }
2360 (Down, _) | (Char('j'), KeyModifiers::NONE)
2361 if app.clone_base_cursor + 1 < crate::hf_clone::BASE_MODELS.len() =>
2362 {
2363 app.clone_base_cursor += 1;
2364 }
2365 (Enter, _) => {
2366 // User selected a base model — download it then go to fine-tune
2367 let base = &crate::hf_clone::BASE_MODELS[app.clone_base_cursor];
2368 let base_repo_id = base.repo_id.to_string();
2369
2370 // Check if the base model is already downloaded locally
2371 let resolved = resolve_hf_cache_path(&base_repo_id);
2372 if !resolved.is_empty()
2373 && std::path::Path::new(&resolved).join("config.json").exists()
2374 {
2375 // Create (or load) the project workspace for this repo
2376 let target_repo = app.clone_repo_id.clone();
2377 match crate::project::create_project(&target_repo, &base_repo_id) {
2378 Ok(mut project) => {
2379 project.base_model_dir = resolved.clone();
2380 let dataset_display =
2381 project.dataset_path.to_string_lossy().to_string();
2382 // Try to shorten the display path with ~/
2383 let dataset_display = if let Some(home) = dirs::home_dir() {
2384 let home_str = home.to_string_lossy();
2385 if dataset_display.starts_with(home_str.as_ref()) {
2386 format!("~{}", &dataset_display[home_str.len()..])
2387 } else {
2388 dataset_display
2389 }
2390 } else {
2391 dataset_display
2392 };
2393 app.finetune_data_path = dataset_display;
2394 app.finetune_model_id = base_repo_id;
2395 app.finetune_model_dir = resolved;
2396 app.current_project = Some(project);
2397 app.finetune_focus = FineTuneFocus::DataPath;
2398 app.finetune_running = false;
2399 app.finetune_progress = None;
2400 app.screen = Screen::FineTune;
2401 app.status = Status::neutral("Project ready. Configure fine-tuning.");
2402 }
2403 Err(e) => {
2404 app.status = Status::error(format!("Failed to create project: {e}"));
2405 }
2406 }
2407 return;
2408 }
2409
2410 // Not downloaded — trigger download via the existing HF search download flow
2411 let hub = crate::hf::preferred_download_hub();
2412 app.downloading = true;
2413 app.download_progress = None;
2414 app.status =
2415 Status::neutral(format!("Downloading base model {}…", base.display_name));
2416
2417 // Switch to Downloads screen to show progress
2418 app.screen = Screen::Downloads;
2419
2420 let (dl_tx, mut dl_rx) =
2421 mpsc::unbounded_channel::<crate::hf_search::DownloadEvent>();
2422
2423 let model_id_for_download = base_repo_id.clone();
2424 tokio::spawn(async move {
2425 crate::hf_search::download_model(model_id_for_download, hub, dl_tx).await;
2426 });
2427
2428 let auth_tx = tx.clone();
2429 let model_id_for_event = base_repo_id;
2430 tokio::spawn(async move {
2431 while let Some(event) = dl_rx.recv().await {
2432 match event {
2433 crate::hf_search::DownloadEvent::Progress(p) => {
2434 let _ = auth_tx.send(AuthEvent::ModelDownloadProgress(p));
2435 }
2436 crate::hf_search::DownloadEvent::Complete => {
2437 let _ = auth_tx.send(AuthEvent::ModelDownloadComplete(
2438 model_id_for_event.clone(),
2439 ));
2440 break;
2441 }
2442 crate::hf_search::DownloadEvent::Failed(e) => {
2443 let _ = auth_tx.send(AuthEvent::ModelDownloadFailed(e));
2444 break;
2445 }
2446 }
2447 }
2448 });
2449 }
2450 (Esc, _) => {
2451 // Go back to repo name input
2452 app.clone_repo_status = None;
2453 app.clone_base_cursor = 0;
2454 app.status = Status::neutral("Enter a HuggingFace repo ID.");
2455 }
2456 (Char('c'), KeyModifiers::CONTROL) => {
2457 app.should_quit = true;
2458 }
2459 _ => {}
2460 }
2461 return;
2462 }
2463
2464 // If repo has model files, show a note and allow going back
2465 if matches!(
2466 &app.clone_repo_status,
2467 Some(crate::hf_clone::RepoStatus::HasModel { .. })
2468 ) {
2469 match (key.code, key.modifiers) {
2470 (Esc, _) => {
2471 app.clone_repo_status = None;
2472 app.status = Status::neutral("Enter a HuggingFace repo ID.");
2473 }
2474 (Char('c'), KeyModifiers::CONTROL) => {
2475 app.should_quit = true;
2476 }
2477 _ => {}
2478 }
2479 return;
2480 }
2481
2482 // Repo status is NotFound — allow creating it with Enter
2483 if matches!(
2484 &app.clone_repo_status,
2485 Some(crate::hf_clone::RepoStatus::NotFound)
2486 ) {
2487 match (key.code, key.modifiers) {
2488 (Enter, _) => {
2489 let token = HF_TOKEN.to_string();
2490 if token.is_empty() {
2491 app.status = Status::error("No HF_TOKEN set. Add it to .env and rebuild.");
2492 return;
2493 }
2494 let repo_id = app.clone_repo_id.clone();
2495 app.clone_repo_checking = true;
2496 app.status = Status::neutral("Creating repo…");
2497
2498 let (clone_tx, mut clone_rx) = mpsc::unbounded_channel();
2499 let auth_tx = tx.clone();
2500 tokio::spawn(async move {
2501 crate::hf_clone::start_create_repo(repo_id, token, clone_tx).await;
2502 });
2503 tokio::spawn(async move {
2504 while let Some(progress) = clone_rx.recv().await {
2505 let _ = auth_tx.send(AuthEvent::CloneProgress(progress));
2506 }
2507 });
2508 }
2509 (Esc, _) => {
2510 app.clone_repo_status = None;
2511 app.status = Status::neutral("Enter a HuggingFace repo ID.");
2512 }
2513 (Char('c'), KeyModifiers::CONTROL) => {
2514 app.should_quit = true;
2515 }
2516 _ => {}
2517 }
2518 return;
2519 }
2520
2521 // Default: repo name input mode
2522 match (key.code, key.modifiers) {
2523 (Enter, _) => {
2524 if app.clone_repo_id.trim().is_empty() {
2525 app.status = Status::error("Repo ID cannot be empty.");
2526 return;
2527 }
2528 let token = HF_TOKEN.to_string();
2529 let repo_id = app.clone_repo_id.clone();
2530 app.clone_repo_checking = true;
2531 app.clone_repo_status = None;
2532 app.status = Status::neutral(format!("Checking {}…", repo_id));
2533
2534 let (clone_tx, mut clone_rx) = mpsc::unbounded_channel();
2535 let auth_tx = tx.clone();
2536 tokio::spawn(async move {
2537 crate::hf_clone::start_check_repo(repo_id, token, clone_tx).await;
2538 });
2539 tokio::spawn(async move {
2540 while let Some(progress) = clone_rx.recv().await {
2541 let _ = auth_tx.send(AuthEvent::CloneProgress(progress));
2542 }
2543 });
2544 }
2545 (Backspace, _) => {
2546 app.clone_repo_id.pop();
2547 }
2548 (Esc, _) => {
2549 app.screen = Screen::Downloads;
2550 app.clone_repo_id.clear();
2551 app.clone_repo_status = None;
2552 app.clone_base_cursor = 0;
2553 app.status = Status::neutral("Back to models.");
2554 }
2555 (Char('c'), KeyModifiers::CONTROL) => {
2556 app.should_quit = true;
2557 }
2558 (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
2559 // Typing clears any previous check result
2560 if app.clone_repo_status.is_some() {
2561 app.clone_repo_status = None;
2562 }
2563 app.clone_repo_id.push(c);
2564 }
2565 _ => {}
2566 }
2567 }
2568
2569 /// Generate a compact timestamp for unique artifact naming.
2570 ///
2571 /// Uses Unix epoch seconds for simplicity — produces values like `1750012345`
2572 /// which are unique, monotonic, and filesystem-safe.
2573 fn run_timestamp() -> String {
2574 let secs = std::time::SystemTime::now()
2575 .duration_since(std::time::UNIX_EPOCH)
2576 .unwrap_or_default()
2577 .as_secs();
2578 format!("{secs}")
2579 }
2580
2581 /// Derive a per-project dataset path from a model ID.
2582 ///
2583 /// Converts `org/model-name` → `~/.onde/datasets/org/model-name/train.jsonl`
2584 /// so each fine-tuning project has its own dataset folder.
2585 fn finetune_data_path_for_model(model_id: &str) -> String {
2586 format!("~/.onde/datasets/{}/train.jsonl", model_id)
2587 }
2588
2589 fn trigger_hf_search(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2590 let query = app.hf_search_query.trim().to_string();
2591 if query.is_empty() {
2592 return;
2593 }
2594 app.hf_search_loading = true;
2595 app.hf_search_results.clear();
2596 app.hf_search_cursor = 0;
2597 app.status = Status::neutral(format!("Searching for \"{query}\"…"));
2598 tokio::spawn(async move {
2599 match crate::hf_search::search_hf(&query).await {
2600 Ok(results) => {
2601 let _ = tx.send(AuthEvent::HfSearchResults(results));
2602 }
2603 Err(e) => {
2604 let _ = tx.send(AuthEvent::HfSearchFailed(e.to_string()));
2605 }
2606 }
2607 });
2608 }
2609
2610 fn trigger_download(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2611 let Some(model) = app.hf_search_results.get(app.hf_search_cursor) else {
2612 return;
2613 };
2614 let model_id = model.model_id.clone();
2615 let hub = crate::hf::preferred_download_hub();
2616
2617 app.downloading = true;
2618 app.download_progress = None;
2619 app.status = Status::neutral(format!("Downloading {model_id}…"));
2620
2621 let (dl_tx, mut dl_rx) = mpsc::unbounded_channel::<crate::hf_search::DownloadEvent>();
2622
2623 // Task 1: run the download and send DownloadEvents.
2624 let model_id_clone = model_id.clone();
2625 tokio::spawn(async move {
2626 crate::hf_search::download_model(model_id_clone, hub, dl_tx).await;
2627 });
2628
2629 // Task 2: forward DownloadEvents → AuthEvents.
2630 let auth_tx = tx.clone();
2631 tokio::spawn(async move {
2632 while let Some(event) = dl_rx.recv().await {
2633 match event {
2634 crate::hf_search::DownloadEvent::Progress(p) => {
2635 let _ = auth_tx.send(AuthEvent::ModelDownloadProgress(p));
2636 }
2637 crate::hf_search::DownloadEvent::Complete => {
2638 let _ = auth_tx.send(AuthEvent::ModelDownloadComplete(model_id.clone()));
2639 break;
2640 }
2641 crate::hf_search::DownloadEvent::Failed(e) => {
2642 let _ = auth_tx.send(AuthEvent::ModelDownloadFailed(e));
2643 break;
2644 }
2645 }
2646 }
2647 });
2648 }
2649
2650 fn trigger_load_downloads(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2651 if app.busy {
2652 return;
2653 }
2654 let token = token::load().unwrap_or_default();
2655 app.busy = true;
2656 app.status = Status::neutral("Scanning models…");
2657 tokio::spawn(async move {
2658 // Fetch the remote catalog and scan the local cache concurrently.
2659 let (catalog_result, local_result) = tokio::join!(
2660 crate::gresiq::load_models(&token),
2661 tokio::task::spawn_blocking(crate::hf::list_local_models),
2662 );
2663
2664 let catalog = catalog_result.unwrap_or_default();
2665 let local = local_result.unwrap_or_default();
2666 let merged = crate::hf::merge_models(&catalog, local);
2667
2668 let _ = tx.send(AuthEvent::DownloadsLoaded(merged));
2669 });
2670 }
2671
2672 fn clamp_downloads_scroll(app: &mut App, max_visible: usize) {
2673 if app.downloads_cursor < app.downloads_offset {
2674 app.downloads_offset = app.downloads_cursor;
2675 } else if app.downloads_cursor >= app.downloads_offset + max_visible {
2676 app.downloads_offset = app.downloads_cursor + 1 - max_visible;
2677 }
2678 }
2679
2680 fn clamp_models_scroll(app: &mut App, max_visible: usize) {
2681 if app.models_cursor < app.models_offset {
2682 app.models_offset = app.models_cursor;
2683 } else if app.models_cursor >= app.models_offset + max_visible {
2684 app.models_offset = app.models_cursor + 1 - max_visible;
2685 }
2686 }
2687
2688 #[allow(dead_code)]
2689 fn open_model_picker(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2690 if app.apps.is_empty() || app.busy {
2691 return;
2692 }
2693 app.assigning_for_app_index = Some(app.apps_cursor);
2694 app.screen = Screen::Models;
2695 app.models_cursor = 0;
2696 app.models_offset = 0;
2697 if !app.models_loaded {
2698 let token = token::load().unwrap_or_default();
2699 app.busy = true;
2700 app.status = Status::neutral("Loading models…");
2701 tokio::spawn(async move {
2702 match crate::gresiq::load_models(&token).await {
2703 Ok(models) => {
2704 let _ = tx.send(AuthEvent::ModelsLoaded(models));
2705 }
2706 Err(e) => {
2707 let _ = tx.send(AuthEvent::ModelsLoadFailed(e.to_string()));
2708 }
2709 }
2710 });
2711 } else {
2712 app.status = Status::neutral("Choose a model.");
2713 }
2714 }
2715
2716 fn submit_create_app(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2717 let name = app.new_app_name.trim().to_string();
2718 if name.is_empty() {
2719 return;
2720 }
2721 let token = token::load().unwrap_or_default();
2722 app.busy = true;
2723 app.status = Status::neutral(format!("Creating “{name}”…"));
2724 tokio::spawn(async move {
2725 match crate::gresiq::create_app(&token, &name).await {
2726 Ok(created) => {
2727 let _ = tx.send(AuthEvent::AppCreated(created));
2728 }
2729 Err(e) => {
2730 let _ = tx.send(AuthEvent::AppCreateFailed(e.to_string()));
2731 }
2732 }
2733 });
2734 }
2735
2736 fn submit_assign_model(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2737 if app.busy {
2738 return;
2739 }
2740 let Some(app_index) = app.assigning_for_app_index else {
2741 return;
2742 };
2743 let Some(onde_app) = app.apps.get(app_index) else {
2744 return;
2745 };
2746 let Some(model) = app.models.get(app.models_cursor) else {
2747 return;
2748 };
2749 let token = token::load().unwrap_or_default();
2750 let onde_app_id = onde_app.id.clone();
2751 let model_id = model.id.clone();
2752 let model_name = model.name.clone().unwrap_or_else(|| model_id.clone());
2753 app.busy = true;
2754 app.status = Status::neutral(format!("Assigning {model_name}…"));
2755 tokio::spawn(async move {
2756 match crate::gresiq::assign_model(&token, &onde_app_id, &model_id).await {
2757 Ok(()) => {
2758 let _ = tx.send(AuthEvent::ModelAssigned {
2759 app_index,
2760 model_id,
2761 });
2762 }
2763 Err(e) => {
2764 let _ = tx.send(AuthEvent::ModelAssignFailed(e.to_string()));
2765 }
2766 }
2767 });
2768 }
2769
2770 fn submit(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2771 let email = app.email.trim().to_string();
2772 let password = app.password.clone();
2773
2774 if email.is_empty() || password.is_empty() {
2775 app.status = Status::error("Email and password are required.");
2776 return;
2777 }
2778
2779 app.busy = true;
2780 app.status = match app.mode {
2781 Mode::Signup => Status::neutral("Creating account…"),
2782 Mode::Signin => Status::neutral("Signing in…"),
2783 };
2784
2785 let mode = app.mode.clone();
2786
2787 tokio::spawn(async move {
2788 let event = match mode {
2789 Mode::Signup => {
2790 match signup_with_client(Environment::Production, credentials(), email, password)
2791 .await
2792 {
2793 Ok(result) => AuthEvent::SignupOk(result.message),
2794 Err(e) => AuthEvent::Failed(extract_error(&e)),
2795 }
2796 }
2797 Mode::Signin => {
2798 match login_with_client(
2799 Environment::Production,
2800 credentials(),
2801 email.clone(),
2802 password,
2803 )
2804 .await
2805 {
2806 Ok(AccountStatus::Ready { access_token }) => {
2807 if let Err(e) = token::save(&access_token) {
2808 log::warn!("Could not persist token: {e}");
2809 }
2810 let profile_email = match me_with_client(
2811 Environment::Production,
2812 credentials(),
2813 &access_token,
2814 )
2815 .await
2816 {
2817 Ok(user) => user.email,
2818 Err(_) => email,
2819 };
2820 AuthEvent::SigninOk(Profile {
2821 email: profile_email,
2822 })
2823 }
2824 Ok(AccountStatus::Incomplete { .. }) => AuthEvent::Failed(
2825 "Check your email first — we sent a confirmation link when you signed up."
2826 .into(),
2827 ),
2828 Ok(AccountStatus::NotFound) => {
2829 AuthEvent::Failed("That email isn’t in our system.".into())
2830 }
2831 Err(e) => AuthEvent::Failed(extract_error(&e)),
2832 }
2833 }
2834 };
2835 let _ = tx.send(event);
2836 });
2837 }
2838
2839 fn sign_out(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2840 let saved_token = token::load();
2841 app.busy = true;
2842 app.status = Status::neutral("Signing out…");
2843
2844 tokio::spawn(async move {
2845 if let Some(t) = saved_token {
2846 let _ = logout_with_client(Environment::Production, credentials(), t).await;
2847 }
2848 let _ = tx.send(AuthEvent::SignedOut);
2849 });
2850 }
2851
2852 fn extract_error(e: &ErrorResponse) -> String {
2853 match e {
2854 ErrorResponse::Error { message, .. } => message.clone(),
2855 }
2856 }
2857
2858 // ---------------------------------------------------------------------------
2859 // Inference model picker
2860 // ---------------------------------------------------------------------------
2861
2862 fn build_inference_entries() -> Vec<InferenceModelEntry> {
2863 let local = crate::hf::list_local_models();
2864
2865 onde::inference::models::SUPPORTED_MODEL_INFO
2866 .iter()
2867 .map(|info| {
2868 let local_match = local.iter().find(|m| m.model_id == info.id);
2869 InferenceModelEntry {
2870 model_id: info.id.to_string(),
2871 display_name: info.name.to_string(),
2872 org: info.org.to_string(),
2873 description: info.description.to_string(),
2874 expected_size_bytes: info.expected_size_bytes,
2875 downloaded: local_match.is_some(),
2876 cache_source: local_match.map(|m| m.source.clone()),
2877 }
2878 })
2879 .collect()
2880 }
2881
2882 fn open_inference_picker(app: &mut App) {
2883 if app.busy {
2884 return;
2885 }
2886 app.assigning_for_app_index = Some(app.apps_cursor);
2887 app.inference_models = build_inference_entries();
2888 app.inference_cursor = 0;
2889 app.inference_offset = 0;
2890 app.screen = Screen::InferenceModels;
2891 app.status = Status::neutral("Pick an Onde inference model.");
2892 }
2893
2894 fn open_current_model_detail(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
2895 let Some(onde_app) = app.apps.get(app.apps_cursor) else {
2896 return;
2897 };
2898
2899 let active_model = onde_app.active_model.clone();
2900 let current_model_id = onde_app.current_model_id.clone();
2901
2902 // If downloads aren't loaded yet, load them first and defer navigation
2903 if !app.downloads_loaded {
2904 app.pending_model_detail = true;
2905 trigger_load_downloads(app, tx);
2906 return;
2907 }
2908
2909 navigate_to_model_detail(app, active_model.as_deref(), current_model_id.as_deref());
2910 }
2911
2912 fn navigate_to_model_detail(
2913 app: &mut App,
2914 active_model: Option<&str>,
2915 current_model_id: Option<&str>,
2916 ) {
2917 // Try to find the model in downloads
2918 let found_index = app.downloads.iter().position(|d| {
2919 // Match by catalog model ID
2920 if let Some(mid) = current_model_id
2921 && let Some(ref cm) = d.catalog_model
2922 && cm.id == mid
2923 {
2924 return true;
2925 }
2926 // Match by display name
2927 if let Some(name) = active_model
2928 && d.display_name == name
2929 {
2930 return true;
2931 }
2932 false
2933 });
2934
2935 if let Some(idx) = found_index {
2936 app.downloads_cursor = idx;
2937 // Scan adapters
2938 if let Some(model) = app.downloads.get(idx) {
2939 let resolved = resolve_hf_cache_path(&model.model_id);
2940 app.adapter_list = scan_adapters(&resolved);
2941 app.adapter_cursor = 0;
2942 }
2943 app.screen = Screen::ModelDetail;
2944 if app.adapter_list.is_empty() {
2945 app.status = Status::neutral("Model details.");
2946 } else {
2947 app.status = Status::success(format!(
2948 "{} adapter{} found.",
2949 app.adapter_list.len(),
2950 if app.adapter_list.len() == 1 { "" } else { "s" }
2951 ));
2952 }
2953 } else {
2954 app.status = Status::error("Model not found locally. Press m to pick a model.");
2955 }
2956 }
2957
2958 fn handle_key_inference_models(
2959 app: &mut App,
2960 key: crossterm::event::KeyEvent,
2961 tx: mpsc::UnboundedSender<AuthEvent>,
2962 ) {
2963 use crossterm::event::KeyCode::*;
2964 match (key.code, key.modifiers) {
2965 (Up, _) | (Char('k'), KeyModifiers::NONE) => {
2966 app.inference_cursor = app.inference_cursor.saturating_sub(1);
2967 clamp_inference_scroll(app, MAX_VISIBLE);
2968 }
2969 (Down, _) | (Char('j'), KeyModifiers::NONE) => {
2970 if app.inference_cursor + 1 < app.inference_models.len() {
2971 app.inference_cursor += 1;
2972 }
2973 clamp_inference_scroll(app, MAX_VISIBLE);
2974 }
2975 (Enter, _) => {
2976 if let Some(entry) = app.inference_models.get(app.inference_cursor).cloned() {
2977 if entry.downloaded {
2978 assign_inference_model(app, &entry, tx.clone());
2979 } else {
2980 trigger_inference_download(app, tx);
2981 }
2982 }
2983 }
2984 (Esc, _) => {
2985 app.screen = Screen::AppDetail;
2986 app.assigning_for_app_index = None;
2987 app.status = Status::neutral("Back to app.");
2988 }
2989 (Char('c'), KeyModifiers::CONTROL) => {
2990 app.should_quit = true;
2991 }
2992 _ => {}
2993 }
2994 }
2995
2996 fn assign_inference_model(
2997 app: &mut App,
2998 entry: &InferenceModelEntry,
2999 tx: mpsc::UnboundedSender<AuthEvent>,
3000 ) {
3001 let Some(app_index) = app.assigning_for_app_index else {
3002 // No app selected — just go back
3003 app.screen = Screen::AppDetail;
3004 app.status = Status::success(format!("{} is ready.", entry.display_name));
3005 return;
3006 };
3007 let Some(onde_app) = app.apps.get(app_index) else {
3008 return;
3009 };
3010
3011 let token = token::load().unwrap_or_default();
3012 let onde_app_id = onde_app.id.clone();
3013 let display_name = entry.display_name.clone();
3014
3015 // Try to find the matching model in the remote catalog to get its API ID
3016 if let Some(remote_model) = app.models.iter().find(|m| {
3017 m.hf_repo_id.as_deref() == Some(entry.model_id.as_str())
3018 || m.name.as_deref() == Some(entry.display_name.as_str())
3019 }) {
3020 let api_model_id = remote_model.id.clone();
3021 app.busy = true;
3022 app.status = Status::neutral(format!("Assigning {display_name}…"));
3023 tokio::spawn(async move {
3024 match crate::gresiq::assign_model(&token, &onde_app_id, &api_model_id).await {
3025 Ok(()) => {
3026 let _ = tx.send(AuthEvent::ModelAssigned {
3027 app_index,
3028 model_id: api_model_id,
3029 });
3030 }
3031 Err(e) => {
3032 let _ = tx.send(AuthEvent::ModelAssignFailed(e.to_string()));
3033 }
3034 }
3035 });
3036 } else {
3037 // Remote models not loaded yet — just update the local state
3038 if let Some(onde_app) = app.apps.get_mut(app_index) {
3039 onde_app.active_model = Some(display_name.clone());
3040 }
3041 app.screen = Screen::AppDetail;
3042 app.assigning_for_app_index = None;
3043 app.status = Status::success(format!("{display_name} selected."));
3044 }
3045 }
3046
3047 fn clamp_inference_scroll(app: &mut App, max_visible: usize) {
3048 if app.inference_cursor < app.inference_offset {
3049 app.inference_offset = app.inference_cursor;
3050 } else if app.inference_cursor >= app.inference_offset + max_visible {
3051 app.inference_offset = app.inference_cursor + 1 - max_visible;
3052 }
3053 }
3054
3055 fn trigger_inference_download(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
3056 let Some(entry) = app.inference_models.get(app.inference_cursor) else {
3057 return;
3058 };
3059 if app.downloading {
3060 app.status = Status::error("A download is already in progress.");
3061 return;
3062 }
3063 let model_id = entry.model_id.clone();
3064 let hub = crate::hf::preferred_download_hub();
3065
3066 app.downloading = true;
3067 app.download_progress = None;
3068 app.status = Status::neutral(format!("Downloading {}…", entry.display_name));
3069
3070 let (dl_tx, mut dl_rx) = mpsc::unbounded_channel::<crate::hf_search::DownloadEvent>();
3071
3072 let model_id_clone = model_id.clone();
3073 tokio::spawn(async move {
3074 crate::hf_search::download_model(model_id_clone, hub, dl_tx).await;
3075 });
3076
3077 let auth_tx = tx.clone();
3078 tokio::spawn(async move {
3079 while let Some(event) = dl_rx.recv().await {
3080 match event {
3081 crate::hf_search::DownloadEvent::Progress(p) => {
3082 let _ = auth_tx.send(AuthEvent::ModelDownloadProgress(p));
3083 }
3084 crate::hf_search::DownloadEvent::Complete => {
3085 let _ =
3086 auth_tx.send(AuthEvent::InferenceModelDownloadComplete(model_id.clone()));
3087 break;
3088 }
3089 crate::hf_search::DownloadEvent::Failed(e) => {
3090 let _ = auth_tx.send(AuthEvent::ModelDownloadFailed(e));
3091 break;
3092 }
3093 }
3094 }
3095 });
3096 }
3097
3098 #[cfg(test)]
3099 mod tests {
3100 use super::*;
3101
3102 #[test]
3103 fn test_derive_base_model_meta_known_qwen3() {
3104 let (name, family, parameter_class) =
3105 derive_base_model_meta(Some("bartowski/Qwen_Qwen3-4B-GGUF"));
3106 assert_eq!(family, "qwen3");
3107 assert_eq!(parameter_class, "4B");
3108 assert!(!name.is_empty());
3109 }
3110
3111 #[test]
3112 fn test_derive_base_model_meta_known_qwen25_coder() {
3113 let (_, family, parameter_class) =
3114 derive_base_model_meta(Some("bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF"));
3115 assert_eq!(family, "qwen2.5-coder");
3116 assert_eq!(parameter_class, "1.5B");
3117 }
3118
3119 #[test]
3120 fn test_derive_base_model_meta_unknown_falls_back() {
3121 let (name, family, parameter_class) = derive_base_model_meta(Some("someone/mystery-model"));
3122 assert_eq!(family, "custom");
3123 assert_eq!(parameter_class, "unknown");
3124 assert_eq!(name, "mystery-model");
3125 }
3126
3127 #[test]
3128 fn test_derive_base_model_meta_none() {
3129 let (name, family, parameter_class) = derive_base_model_meta(None);
3130 assert_eq!(family, "custom");
3131 assert_eq!(parameter_class, "unknown");
3132 assert_eq!(name, "Custom model");
3133 }
3134
3135 #[test]
3136 fn test_derive_base_model_meta_larger_size_not_shadowed_by_shorter_substring() {
3137 // "13B" contains the substring "3B" -- this must resolve to "13B",
3138 // not "3B".
3139 let (_, _, parameter_class) = derive_base_model_meta(Some("someone/Mystery-13B-GGUF"));
3140 assert_eq!(parameter_class, "13B");
3141 }
3142
3143 #[test]
3144 fn test_derive_base_model_meta_qwen3_small_sizes() {
3145 let (_, family, parameter_class) = derive_base_model_meta(Some("Qwen/Qwen3-0.6B"));
3146 assert_eq!(family, "qwen3");
3147 assert_eq!(parameter_class, "0.6B");
3148 let (_, _, parameter_class) = derive_base_model_meta(Some("Qwen/Qwen3-8B"));
3149 assert_eq!(parameter_class, "8B");
3150 }
3151
3152 #[test]
3153 fn test_derive_base_model_meta_decimal_size_not_matched_as_integer_tail() {
3154 // "1.7B" contains the substring "7B" -- this must resolve to "1.7B",
3155 // not "7B".
3156 let (_, _, parameter_class) = derive_base_model_meta(Some("Qwen/Qwen3-1.7B"));
3157 assert_eq!(parameter_class, "1.7B");
3158 // "2.7B" isn't a known size; its "7B" tail must not match either.
3159 let (_, _, parameter_class) = derive_base_model_meta(Some("someone/Mystery-2.7B-GGUF"));
3160 assert_eq!(parameter_class, "unknown");
3161 }
3162
3163 #[test]
3164 fn test_derive_base_model_meta_size_not_matched_mid_word() {
3165 // "3B" is a substring of both "3BETA" (trailing letter) and "3B2"
3166 // (trailing digit) -- neither should resolve to "3B".
3167 let (_, _, beta) = derive_base_model_meta(Some("someone/Mystery-3BETA-GGUF"));
3168 assert_eq!(beta, "unknown");
3169 let (_, _, versioned) = derive_base_model_meta(Some("someone/Mystery-3B2-GGUF"));
3170 assert_eq!(versioned, "unknown");
3171 }
3172 }