development
rs 2,743 lines 96.4 KB
Raw
1 use {
2 crate::app::{App, ArtifactKind, FineTuneFocus, Focus, Mode, OndeApp, Screen, StatusTone},
3 crate::hf::CacheSource,
4 ratatui::{
5 Frame,
6 layout::{Alignment, Constraint, Layout, Rect},
7 style::{Color, Style, Stylize},
8 text::{Line, Span},
9 widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap},
10 },
11 };
12
13 // colours from globals.css
14
15 const C_BG: Color = Color::Rgb(0, 0, 0);
16 const C_SURFACE: Color = Color::Rgb(13, 20, 16);
17 const C_SURFACE_STRONG: Color = Color::Rgb(20, 28, 24);
18 const C_NEON: Color = Color::Rgb(66, 255, 145);
19 const C_TEXT: Color = Color::Rgb(226, 226, 226);
20 const C_MUTED: Color = Color::Rgb(122, 144, 128);
21 const C_INK: Color = Color::Rgb(216, 229, 222);
22 const C_DANGER: Color = Color::Rgb(255, 95, 86);
23 const C_LINE: Color = Color::Rgb(35, 50, 42);
24
25 pub fn render(frame: &mut Frame, app: &App) {
26 let area = frame.area();
27
28 frame.render_widget(Block::new().style(Style::new().bg(C_BG)), area);
29
30 let layout = Layout::vertical([
31 Constraint::Length(3),
32 Constraint::Min(0),
33 Constraint::Length(1),
34 ])
35 .split(area);
36
37 render_header(frame, layout[0]);
38 render_card(frame, app, layout[1]);
39 render_footer(frame, app, layout[2]);
40 }
41
42 fn render_header(frame: &mut Frame, area: Rect) {
43 let text = vec![
44 Line::from(vec![
45 Span::styled("◆ onde", Style::new().fg(C_NEON).bold()),
46 Span::styled(" — ondeinference.com", Style::new().fg(C_MUTED)),
47 ]),
48 Line::from(""),
49 Line::from(Span::styled(
50 "Start building on Apple silicon.",
51 Style::new().fg(C_TEXT),
52 )),
53 ];
54 frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
55 }
56
57 fn render_card(frame: &mut Frame, app: &App, area: Rect) {
58 let card_width = 64_u16.min(area.width.saturating_sub(4));
59 let h_pad = area.width.saturating_sub(card_width) / 2;
60
61 let cols = Layout::horizontal([
62 Constraint::Length(h_pad),
63 Constraint::Length(card_width),
64 Constraint::Min(0),
65 ])
66 .split(area);
67
68 let card_area = cols[1];
69
70 let card = Block::new()
71 .borders(Borders::ALL)
72 .border_type(BorderType::Rounded)
73 .border_style(Style::new().fg(C_LINE))
74 .style(Style::new().bg(C_SURFACE))
75 .padding(Padding::new(2, 2, 1, 1));
76
77 let inner = card.inner(card_area);
78 frame.render_widget(card, card_area);
79
80 match app.screen {
81 Screen::Auth => render_form(frame, app, inner),
82 Screen::Apps => render_apps(frame, app, inner),
83 Screen::AppDetail => render_app_detail(frame, app, inner),
84 Screen::InferenceModels => render_inference_models(frame, app, inner),
85 Screen::Models => render_models(frame, app, inner),
86 Screen::Downloads => render_downloads(frame, app, inner),
87 Screen::ModelDetail => render_model_detail(frame, app, inner),
88 Screen::GgufDetail => render_gguf_detail(frame, app, inner),
89 Screen::FineTune => render_finetune(frame, app, inner),
90 Screen::CloneRepo => render_clone_repo(frame, app, inner),
91 Screen::Chat => render_chat(frame, app, inner),
92 Screen::PublishModel => render_publish_model(frame, app, inner),
93 }
94 }
95
96 // auth form
97
98 fn render_form(frame: &mut Frame, app: &App, area: Rect) {
99 let rows = Layout::vertical([
100 Constraint::Length(1),
101 Constraint::Length(1),
102 Constraint::Length(1),
103 Constraint::Length(2),
104 Constraint::Length(1),
105 Constraint::Length(1),
106 Constraint::Length(1),
107 Constraint::Length(1),
108 Constraint::Length(3),
109 Constraint::Length(1),
110 Constraint::Length(1),
111 Constraint::Length(3),
112 Constraint::Length(1),
113 Constraint::Length(1),
114 Constraint::Length(1),
115 Constraint::Min(0),
116 ])
117 .split(area);
118
119 render_tabs(frame, app, rows[0]);
120
121 let (headline, description) = match app.mode {
122 Mode::Signup => (
123 "Create your account",
124 "We'll send a confirmation email. You'll need to verify before signing in.",
125 ),
126 Mode::Signin => ("Good to have you back", "Sign in to your existing account."),
127 };
128
129 frame.render_widget(
130 Paragraph::new(headline).style(Style::new().fg(C_INK).bold()),
131 rows[2],
132 );
133 frame.render_widget(
134 Paragraph::new(description)
135 .style(Style::new().fg(C_MUTED))
136 .wrap(Wrap { trim: true }),
137 rows[3],
138 );
139
140 render_status(frame, app, rows[5]);
141
142 frame.render_widget(
143 Paragraph::new("Email").style(Style::new().fg(C_MUTED)),
144 rows[7],
145 );
146 render_input(
147 frame,
148 app,
149 &app.email,
150 Focus::Email,
151 "name@company.com",
152 rows[8],
153 );
154
155 frame.render_widget(
156 Paragraph::new("Password").style(Style::new().fg(C_MUTED)),
157 rows[10],
158 );
159 let masked = "•".repeat(app.password.len());
160 render_input(
161 frame,
162 app,
163 &masked,
164 Focus::Password,
165 "Minimum 8 characters",
166 rows[11],
167 );
168
169 let (primary_label, secondary_label) = match app.mode {
170 Mode::Signup => ("[ Create account ]", "I already have an account Ctrl+L"),
171 Mode::Signin => ("[ Sign in ]", "Create a new account Ctrl+N"),
172 };
173
174 let primary_style = if app.busy {
175 Style::new().fg(C_MUTED)
176 } else {
177 Style::new().fg(C_SURFACE).bg(C_NEON).bold()
178 };
179
180 frame.render_widget(Paragraph::new(primary_label).style(primary_style), rows[13]);
181 frame.render_widget(
182 Paragraph::new(secondary_label).style(Style::new().fg(C_MUTED)),
183 rows[14],
184 );
185 }
186
187 fn render_tabs(frame: &mut Frame, app: &App, area: Rect) {
188 let cols = Layout::horizontal([
189 Constraint::Length(19),
190 Constraint::Length(1),
191 Constraint::Length(11),
192 Constraint::Min(0),
193 ])
194 .split(area);
195
196 let active = Style::new().fg(C_SURFACE).bg(C_NEON).bold();
197 let inactive = Style::new().fg(C_MUTED).bg(C_SURFACE_STRONG);
198
199 frame.render_widget(
200 Paragraph::new(" Create account ").style(if app.mode == Mode::Signup {
201 active
202 } else {
203 inactive
204 }),
205 cols[0],
206 );
207 frame.render_widget(
208 Paragraph::new(" Sign in ").style(if app.mode == Mode::Signin {
209 active
210 } else {
211 inactive
212 }),
213 cols[2],
214 );
215 }
216
217 fn render_input(
218 frame: &mut Frame,
219 app: &App,
220 value: &str,
221 field: Focus,
222 placeholder: &str,
223 area: Rect,
224 ) {
225 let is_focused = app.focus == field;
226
227 let border_style = if is_focused {
228 Style::new().fg(C_NEON)
229 } else {
230 Style::new().fg(C_LINE)
231 };
232
233 let block = Block::new()
234 .borders(Borders::ALL)
235 .border_type(BorderType::Rounded)
236 .border_style(border_style)
237 .style(Style::new().bg(C_SURFACE_STRONG));
238
239 let inner = block.inner(area);
240 frame.render_widget(block, area);
241
242 if value.is_empty() && !is_focused {
243 frame.render_widget(
244 Paragraph::new(placeholder).style(Style::new().fg(C_MUTED)),
245 inner,
246 );
247 } else {
248 frame.render_widget(Paragraph::new(value).style(Style::new().fg(C_TEXT)), inner);
249 if is_focused {
250 let cursor_x = (inner.x + value.chars().count() as u16)
251 .min(inner.x + inner.width.saturating_sub(1));
252 frame.set_cursor_position((cursor_x, inner.y));
253 }
254 }
255 }
256
257 // new-app input — always focused, no focus state needed
258 fn render_create_input(frame: &mut Frame, value: &str, area: Rect) {
259 let block = Block::new()
260 .borders(Borders::ALL)
261 .border_type(BorderType::Rounded)
262 .border_style(Style::new().fg(C_NEON))
263 .style(Style::new().bg(C_SURFACE_STRONG));
264
265 let inner = block.inner(area);
266 frame.render_widget(block, area);
267 frame.render_widget(Paragraph::new(value).style(Style::new().fg(C_TEXT)), inner);
268
269 let cursor_x =
270 (inner.x + value.chars().count() as u16).min(inner.x + inner.width.saturating_sub(1));
271 frame.set_cursor_position((cursor_x, inner.y));
272 }
273
274 fn render_status(frame: &mut Frame, app: &App, area: Rect) {
275 let (icon, color) = match app.status.tone {
276 StatusTone::Neutral => ("●", C_MUTED),
277 StatusTone::Success => ("✓", C_NEON),
278 StatusTone::Error => ("✗", C_DANGER),
279 };
280
281 let prefix = if app.busy { "⠿ " } else { "" };
282
283 let line = Line::from(vec![
284 Span::styled(format!("{prefix}{icon} "), Style::new().fg(color)),
285 Span::styled(&app.status.message, Style::new().fg(color)),
286 ]);
287
288 frame.render_widget(Paragraph::new(line), area);
289 }
290
291 // apps screen
292
293 /// Tab bar shared between the Apps and Downloads screens.
294 fn render_nav_tabs(frame: &mut Frame, app: &App, area: Rect) {
295 let cols = Layout::horizontal([
296 Constraint::Length(9), // [ Apps ]
297 Constraint::Length(1), // gap
298 Constraint::Length(11), // [ Models ]
299 Constraint::Min(0),
300 ])
301 .split(area);
302
303 let active = Style::new().fg(C_SURFACE).bg(C_NEON).bold();
304 let inactive = Style::new().fg(C_MUTED).bg(C_SURFACE_STRONG);
305
306 let apps_style = if app.screen == Screen::Apps {
307 active
308 } else {
309 inactive
310 };
311 let models_style = if app.screen == Screen::Downloads {
312 active
313 } else {
314 inactive
315 };
316
317 frame.render_widget(Paragraph::new(" Apps ").style(apps_style), cols[0]);
318 frame.render_widget(Paragraph::new(" Models ").style(models_style), cols[2]);
319 }
320
321 fn render_apps(frame: &mut Frame, app: &App, area: Rect) {
322 let email = app.profile.as_ref().map(|p| p.email.as_str()).unwrap_or("");
323
324 let top = Layout::vertical([
325 Constraint::Length(1), // profile badge
326 Constraint::Length(1), // spacer
327 Constraint::Length(1), // nav tabs
328 Constraint::Length(1), // spacer
329 Constraint::Length(1), // status
330 Constraint::Length(1), // spacer
331 Constraint::Length(1), // column header
332 Constraint::Length(1), // divider
333 Constraint::Min(0), // rest (list + optional form + hint)
334 ])
335 .split(area);
336
337 frame.render_widget(
338 Paragraph::new(Line::from(vec![
339 Span::styled("✓ ", Style::new().fg(C_NEON)),
340 Span::styled(email, Style::new().fg(C_TEXT).bold()),
341 ])),
342 top[0],
343 );
344
345 render_nav_tabs(frame, app, top[2]);
346 render_status(frame, app, top[4]);
347
348 frame.render_widget(
349 Paragraph::new(" Name Status Model").style(Style::new().fg(C_MUTED)),
350 top[6],
351 );
352
353 let divider = "─".repeat(area.width as usize);
354 frame.render_widget(
355 Paragraph::new(divider).style(Style::new().fg(C_LINE)),
356 top[7],
357 );
358
359 let rest = top[8];
360 if app.creating_app {
361 let bottom = Layout::vertical([
362 Constraint::Min(0), // list
363 Constraint::Length(1), // spacer
364 Constraint::Length(1), // "New app name:" label
365 Constraint::Length(3), // input
366 Constraint::Length(1), // hint
367 ])
368 .split(rest);
369
370 render_apps_list(frame, app, bottom[0], "No apps yet. Press n to create one.");
371
372 frame.render_widget(
373 Paragraph::new("New app name:").style(Style::new().fg(C_MUTED)),
374 bottom[2],
375 );
376 render_create_input(frame, &app.new_app_name, bottom[3]);
377 frame.render_widget(
378 Paragraph::new("Enter · create Esc · cancel").style(Style::new().fg(C_MUTED)),
379 bottom[4],
380 );
381 } else {
382 let bottom = Layout::vertical([
383 Constraint::Min(0), // list
384 Constraint::Length(1), // hint
385 ])
386 .split(rest);
387
388 render_apps_list(frame, app, bottom[0], "No apps yet. Press n to create one.");
389 frame.render_widget(
390 Paragraph::new("n · new Enter · open s · sign out").style(Style::new().fg(C_MUTED)),
391 bottom[1],
392 );
393 }
394 }
395
396 fn render_apps_list(frame: &mut Frame, app: &App, area: Rect, empty_hint: &str) {
397 if app.apps.is_empty() {
398 // Keyed on apps_loaded rather than busy: busy can flip back to
399 // false from an unrelated concurrent event (e.g. a model
400 // registration finishing before the apps fetch does) while the
401 // apps list is still genuinely in flight.
402 if !app.apps_loaded {
403 frame.render_widget(
404 Paragraph::new(" Loading…").style(Style::new().fg(C_MUTED)),
405 area,
406 );
407 } else {
408 frame.render_widget(
409 Paragraph::new(format!(" {empty_hint}")).style(Style::new().fg(C_MUTED)),
410 area,
411 );
412 }
413 return;
414 }
415
416 let max_rows = area.height as usize;
417 for (list_idx, onde_app) in app
418 .apps
419 .iter()
420 .enumerate()
421 .skip(app.apps_offset)
422 .take(max_rows)
423 {
424 let row_y = area.y + (list_idx - app.apps_offset) as u16;
425 let row_area = Rect::new(area.x, row_y, area.width, 1);
426
427 let is_selected = list_idx == app.apps_cursor;
428 let prefix = if is_selected { "▶ " } else { " " };
429
430 let model_name = resolve_model_name(app, onde_app);
431 let status_str = onde_app.status.as_deref().unwrap_or("–");
432 let name_str = &onde_app.name;
433
434 let line = format!(
435 "{}{:<22} {:<8} {}",
436 prefix, name_str, status_str, model_name
437 );
438
439 let style = if is_selected {
440 Style::new().fg(C_NEON)
441 } else {
442 Style::new().fg(C_TEXT)
443 };
444
445 frame.render_widget(Paragraph::new(line).style(style), row_area);
446 }
447 }
448
449 // Falls back to "–" if the app has no model assigned or the model isn’t in
450 // the loaded list yet (models load lazily on first visit to the picker).
451 fn resolve_model_name<'a>(app: &'a App, onde_app: &'a OndeApp) -> &'a str {
452 // Prefer the name the API returned directly on the app object.
453 if let Some(name) = onde_app.active_model.as_deref() {
454 return name;
455 }
456 // Fall back to a lookup in the lazily-loaded models list.
457 if let Some(name) = onde_app
458 .current_model_id
459 .as_deref()
460 .and_then(|id| app.models.iter().find(|m| m.id == id))
461 .and_then(|m| m.name.as_deref())
462 {
463 return name;
464 }
465 "No model assigned yet"
466 }
467
468 // publish (assign a just-registered model) screen
469
470 fn render_publish_model(frame: &mut Frame, app: &App, area: Rect) {
471 let model_name = app
472 .publish_model_name
473 .clone()
474 .unwrap_or_else(|| "model".to_string());
475 let heading = format!("Assign {model_name} to app");
476
477 let rows = Layout::vertical([
478 Constraint::Length(1), // heading
479 Constraint::Length(1), // spacer
480 Constraint::Length(1), // column header
481 Constraint::Length(1), // divider
482 Constraint::Min(0), // apps list
483 Constraint::Length(1), // spacer
484 Constraint::Length(1), // status
485 ])
486 .split(area);
487
488 frame.render_widget(
489 Paragraph::new(heading).style(Style::new().fg(C_INK).bold()),
490 rows[0],
491 );
492
493 frame.render_widget(
494 Paragraph::new(" Name Status Model").style(Style::new().fg(C_MUTED)),
495 rows[2],
496 );
497
498 let divider = "─".repeat(area.width as usize);
499 frame.render_widget(
500 Paragraph::new(divider).style(Style::new().fg(C_LINE)),
501 rows[3],
502 );
503
504 render_apps_list(
505 frame,
506 app,
507 rows[4],
508 "No apps yet. Create one from the Apps screen, then come back to assign this model.",
509 );
510 render_status(frame, app, rows[6]);
511 }
512
513 // app detail screen
514
515 fn render_app_detail(frame: &mut Frame, app: &App, area: Rect) {
516 let Some(onde_app) = app.apps.get(app.apps_cursor) else {
517 return;
518 };
519
520 let model_name = resolve_model_name(app, onde_app);
521 let app_id = onde_app.id.as_str();
522 let app_secret = onde_app.app_secret.as_deref().unwrap_or("–");
523 let status_str = onde_app.status.as_deref().unwrap_or("–");
524
525 if app.renaming_app {
526 let rows = Layout::vertical([
527 Constraint::Length(1), // app name heading
528 Constraint::Length(1), // spacer
529 Constraint::Length(1), // status row
530 Constraint::Length(1), // spacer
531 Constraint::Length(1), // App ID label
532 Constraint::Length(1), // App ID value
533 Constraint::Length(1), // spacer
534 Constraint::Length(1), // App Secret label
535 Constraint::Length(1), // App Secret value
536 Constraint::Length(1), // spacer
537 Constraint::Length(1), // Model label
538 Constraint::Length(1), // Model value
539 Constraint::Length(1), // spacer
540 Constraint::Length(1), // rename label
541 Constraint::Length(3), // rename input
542 Constraint::Length(1), // rename hint
543 Constraint::Min(0),
544 ])
545 .split(area);
546
547 render_app_detail_header(frame, onde_app, status_str, rows[0]);
548 render_status(frame, app, rows[2]);
549 render_detail_field(frame, "App ID", app_id, rows[4], rows[5]);
550 render_detail_field(frame, "App Secret", app_secret, rows[7], rows[8]);
551 render_detail_field(frame, "Model", model_name, rows[10], rows[11]);
552
553 frame.render_widget(
554 Paragraph::new("New name:").style(Style::new().fg(C_MUTED)),
555 rows[13],
556 );
557 render_rename_input(frame, &app.rename_input, rows[14]);
558 frame.render_widget(
559 Paragraph::new("Enter · save Esc · cancel").style(Style::new().fg(C_MUTED)),
560 rows[15],
561 );
562 } else {
563 let rows = Layout::vertical([
564 Constraint::Length(1), // app name heading
565 Constraint::Length(1), // spacer
566 Constraint::Length(1), // status row
567 Constraint::Length(1), // spacer
568 Constraint::Length(1), // App ID label
569 Constraint::Length(1), // App ID value
570 Constraint::Length(1), // spacer
571 Constraint::Length(1), // App Secret label
572 Constraint::Length(1), // App Secret value
573 Constraint::Length(1), // spacer
574 Constraint::Length(1), // Model label
575 Constraint::Length(1), // Model value
576 Constraint::Min(0), // spacer
577 Constraint::Length(1), // hint
578 ])
579 .split(area);
580
581 render_app_detail_header(frame, onde_app, status_str, rows[0]);
582 render_status(frame, app, rows[2]);
583 render_detail_field(frame, "App ID", app_id, rows[4], rows[5]);
584 render_detail_field(frame, "App Secret", app_secret, rows[7], rows[8]);
585 frame.render_widget(
586 Paragraph::new("Model").style(Style::new().fg(C_MUTED)),
587 rows[10],
588 );
589 frame.render_widget(
590 Paragraph::new(Line::from(vec![
591 Span::styled(model_name, Style::new().fg(C_NEON).bold()),
592 Span::styled(" ▸", Style::new().fg(C_MUTED)),
593 ])),
594 rows[11],
595 );
596
597 frame.render_widget(
598 Paragraph::new("Enter · detail m · model r · rename Esc · back")
599 .style(Style::new().fg(C_MUTED)),
600 rows[13],
601 );
602 }
603 }
604
605 fn render_app_detail_header(frame: &mut Frame, onde_app: &OndeApp, status_str: &str, area: Rect) {
606 let line = Line::from(vec![
607 Span::styled(&onde_app.name, Style::new().fg(C_INK).bold()),
608 Span::styled(" ", Style::new()),
609 Span::styled(status_str, Style::new().fg(C_MUTED)),
610 ]);
611 frame.render_widget(Paragraph::new(line), area);
612 }
613
614 fn render_detail_field(
615 frame: &mut Frame,
616 label: &str,
617 value: &str,
618 label_area: Rect,
619 value_area: Rect,
620 ) {
621 frame.render_widget(
622 Paragraph::new(label).style(Style::new().fg(C_MUTED)),
623 label_area,
624 );
625 frame.render_widget(
626 Paragraph::new(value).style(Style::new().fg(C_TEXT).bold()),
627 value_area,
628 );
629 }
630
631 fn render_rename_input(frame: &mut Frame, value: &str, area: Rect) {
632 let block = Block::new()
633 .borders(Borders::ALL)
634 .border_type(BorderType::Rounded)
635 .border_style(Style::new().fg(C_NEON))
636 .style(Style::new().bg(C_SURFACE_STRONG));
637
638 let inner = block.inner(area);
639 frame.render_widget(block, area);
640 frame.render_widget(Paragraph::new(value).style(Style::new().fg(C_TEXT)), inner);
641
642 let cursor_x =
643 (inner.x + value.chars().count() as u16).min(inner.x + inner.width.saturating_sub(1));
644 frame.set_cursor_position((cursor_x, inner.y));
645 }
646
647 // inference models screen
648
649 fn render_inference_models(frame: &mut Frame, app: &App, area: Rect) {
650 let rows = Layout::vertical([
651 Constraint::Length(1), // heading
652 Constraint::Length(1), // spacer
653 Constraint::Length(1), // column header
654 Constraint::Length(1), // divider
655 Constraint::Min(0), // list
656 Constraint::Length(1), // spacer
657 Constraint::Length(1), // status / download progress
658 ])
659 .split(area);
660
661 let heading: String = app
662 .assigning_for_app_index
663 .and_then(|i| app.apps.get(i))
664 .map(|a| format!("Assign model — {}", a.name))
665 .unwrap_or_else(|| "Models".to_string());
666
667 frame.render_widget(
668 Paragraph::new(heading).style(Style::new().fg(C_INK).bold()),
669 rows[0],
670 );
671
672 frame.render_widget(
673 Paragraph::new(" Name Size Status")
674 .style(Style::new().fg(C_MUTED)),
675 rows[2],
676 );
677
678 let divider = "─".repeat(area.width as usize);
679 frame.render_widget(
680 Paragraph::new(divider).style(Style::new().fg(C_LINE)),
681 rows[3],
682 );
683
684 let list_area = rows[4];
685 if app.inference_models.is_empty() {
686 frame.render_widget(
687 Paragraph::new(" No supported models found.").style(Style::new().fg(C_MUTED)),
688 list_area,
689 );
690 } else {
691 let max_rows = list_area.height as usize;
692 for (list_idx, entry) in app
693 .inference_models
694 .iter()
695 .enumerate()
696 .skip(app.inference_offset)
697 .take(max_rows)
698 {
699 let row_y = list_area.y + (list_idx - app.inference_offset) as u16;
700 let row_area = Rect::new(list_area.x, row_y, list_area.width, 1);
701
702 let is_selected = list_idx == app.inference_cursor;
703 let prefix = if is_selected { "▶ " } else { " " };
704
705 let size_str = fmt_bytes(entry.expected_size_bytes as i64);
706 let status_str = if entry.downloaded {
707 "✓ ready"
708 } else {
709 "↓ download"
710 };
711
712 let line = format!(
713 "{}{:<32} {:<12} {}",
714 prefix, entry.display_name, size_str, status_str
715 );
716
717 let style = if is_selected {
718 Style::new().fg(C_NEON)
719 } else if entry.downloaded {
720 Style::new().fg(C_TEXT)
721 } else {
722 Style::new().fg(C_MUTED)
723 };
724
725 frame.render_widget(Paragraph::new(line).style(style), row_area);
726 }
727 }
728
729 // Show download progress if downloading
730 if app.downloading {
731 if let Some(progress) = &app.download_progress {
732 let pct = if progress.file_bytes_total > 0 {
733 (progress.file_bytes_done as f64 / progress.file_bytes_total as f64 * 100.0) as u64
734 } else {
735 0
736 };
737 let progress_text = format!(
738 "Downloading {} — {} ({}/{} {}%)",
739 progress.model_id,
740 progress.filename,
741 fmt_bytes(progress.file_bytes_done as i64),
742 fmt_bytes(progress.file_bytes_total as i64),
743 pct
744 );
745 frame.render_widget(
746 Paragraph::new(progress_text).style(Style::new().fg(C_NEON)),
747 rows[6],
748 );
749 } else {
750 render_status(frame, app, rows[6]);
751 }
752 } else {
753 render_status(frame, app, rows[6]);
754 }
755 }
756
757 // models screen
758
759 fn render_models(frame: &mut Frame, app: &App, area: Rect) {
760 let app_name: String = app
761 .assigning_for_app_index
762 .and_then(|i| app.apps.get(i))
763 .map(|a| format!("Assign model — {}", a.name))
764 .unwrap_or_else(|| "Models".to_string());
765
766 let rows = Layout::vertical([
767 Constraint::Length(1), // heading
768 Constraint::Length(1), // spacer
769 Constraint::Length(1), // column header
770 Constraint::Length(1), // divider
771 Constraint::Min(0), // models list
772 Constraint::Length(1), // spacer
773 Constraint::Length(1), // status
774 ])
775 .split(area);
776
777 frame.render_widget(
778 Paragraph::new(app_name.as_str()).style(Style::new().fg(C_INK).bold()),
779 rows[0],
780 );
781
782 frame.render_widget(
783 Paragraph::new(" Name Family Size Format")
784 .style(Style::new().fg(C_MUTED)),
785 rows[2],
786 );
787
788 let divider = "─".repeat(area.width as usize);
789 frame.render_widget(
790 Paragraph::new(divider).style(Style::new().fg(C_LINE)),
791 rows[3],
792 );
793
794 // models list
795 let list_area = rows[4];
796 if app.models.is_empty() {
797 if app.busy {
798 frame.render_widget(
799 Paragraph::new(" Loading…").style(Style::new().fg(C_MUTED)),
800 list_area,
801 );
802 }
803 } else {
804 let max_rows = list_area.height as usize;
805 for (list_idx, model) in app
806 .models
807 .iter()
808 .enumerate()
809 .skip(app.models_offset)
810 .take(max_rows)
811 {
812 let row_y = list_area.y + (list_idx - app.models_offset) as u16;
813 let row_area = Rect::new(list_area.x, row_y, list_area.width, 1);
814
815 let is_selected = list_idx == app.models_cursor;
816 let prefix = if is_selected { "▶ " } else { " " };
817
818 let name = model.name.as_deref().unwrap_or("–");
819 let family = model.family.as_deref().unwrap_or("–");
820 let size = model
821 .approx_size_bytes
822 .map(fmt_bytes)
823 .unwrap_or_else(|| "–".to_string());
824 let format = model.format.as_deref().unwrap_or("–");
825
826 let line = format!(
827 "{}{:<22} {:<8} {:<10} {}",
828 prefix, name, family, size, format
829 );
830
831 let style = if is_selected {
832 Style::new().fg(C_NEON)
833 } else {
834 Style::new().fg(C_TEXT)
835 };
836
837 frame.render_widget(Paragraph::new(line).style(style), row_area);
838 }
839 }
840
841 render_status(frame, app, rows[6]);
842 }
843
844 // downloads screen
845
846 fn render_downloads(frame: &mut Frame, app: &App, area: Rect) {
847 let email = app.profile.as_ref().map(|p| p.email.as_str()).unwrap_or("");
848
849 let top = Layout::vertical([
850 Constraint::Length(1), // profile badge
851 Constraint::Length(1), // spacer
852 Constraint::Length(1), // nav tabs
853 Constraint::Length(1), // spacer
854 Constraint::Length(1), // status
855 Constraint::Length(1), // spacer
856 Constraint::Length(1), // column header
857 Constraint::Length(1), // divider
858 Constraint::Min(0), // body
859 ])
860 .split(area);
861
862 frame.render_widget(
863 Paragraph::new(Line::from(vec![
864 Span::styled("✓ ", Style::new().fg(C_NEON)),
865 Span::styled(email, Style::new().fg(C_TEXT).bold()),
866 ])),
867 top[0],
868 );
869
870 render_nav_tabs(frame, app, top[2]);
871 render_status(frame, app, top[4]);
872
873 // Column header changes based on state.
874 let col_header = if app.hf_search_active {
875 " Model Downloads"
876 } else {
877 " Model Size Source"
878 };
879 frame.render_widget(
880 Paragraph::new(col_header).style(Style::new().fg(C_MUTED)),
881 top[6],
882 );
883
884 let divider = "─".repeat(area.width as usize);
885 frame.render_widget(
886 Paragraph::new(divider).style(Style::new().fg(C_LINE)),
887 top[7],
888 );
889
890 if app.downloading {
891 render_download_progress_panel(frame, app, top[8]);
892 } else if app.hf_search_active {
893 render_hf_search_panel(frame, app, top[8]);
894 } else {
895 let bottom = Layout::vertical([
896 Constraint::Min(0), // list
897 Constraint::Length(1), // hint
898 ])
899 .split(top[8]);
900
901 render_downloads_list(frame, app, bottom[0]);
902
903 frame.render_widget(
904 Paragraph::new("↑↓ · navigate / · search HF Tab · apps")
905 .style(Style::new().fg(C_MUTED)),
906 bottom[1],
907 );
908 }
909 }
910
911 fn render_downloads_list(frame: &mut Frame, app: &App, area: Rect) {
912 if app.downloads.is_empty() {
913 if app.busy {
914 frame.render_widget(
915 Paragraph::new(" Scanning…").style(Style::new().fg(C_MUTED)),
916 area,
917 );
918 } else if app.downloads_loaded {
919 frame.render_widget(
920 Paragraph::new(" No models in catalog yet.").style(Style::new().fg(C_MUTED)),
921 area,
922 );
923 }
924 return;
925 }
926
927 let max_rows = area.height as usize;
928 for (list_idx, model) in app
929 .downloads
930 .iter()
931 .enumerate()
932 .skip(app.downloads_offset)
933 .take(max_rows)
934 {
935 let row_y = area.y + (list_idx - app.downloads_offset) as u16;
936 let row_area = Rect::new(area.x, row_y, area.width, 1);
937
938 let is_selected = list_idx == app.downloads_cursor;
939 let prefix = if is_selected { "▶ " } else { " " };
940
941 let (status_label, status_style) = if model.downloaded {
942 let src = model
943 .source
944 .as_ref()
945 .map(|s| s.label())
946 .unwrap_or("Downloaded");
947 let style = match model.source.as_ref() {
948 Some(CacheSource::AppGroup) => Style::new().fg(C_NEON),
949 _ => Style::new().fg(C_TEXT),
950 };
951 (src, style)
952 } else {
953 ("–", Style::new().fg(C_MUTED))
954 };
955
956 let left = format!(
957 "{}{:<36} {:<10}",
958 prefix, model.display_name, model.size_display
959 );
960
961 let text_style = if is_selected {
962 Style::new().fg(C_NEON)
963 } else if model.downloaded {
964 Style::new().fg(C_TEXT)
965 } else {
966 Style::new().fg(C_MUTED)
967 };
968
969 let line = Line::from(vec![
970 Span::styled(left, text_style),
971 Span::styled(status_label, status_style),
972 ]);
973
974 frame.render_widget(Paragraph::new(line), row_area);
975 }
976 }
977
978 // model detail screen
979
980 fn render_hf_search_panel(frame: &mut Frame, app: &App, area: Rect) {
981 let rows = Layout::vertical([
982 Constraint::Length(1), // "Search HuggingFace Hub" label
983 Constraint::Length(3), // search input box
984 Constraint::Length(1), // spacer
985 Constraint::Min(0), // results list
986 Constraint::Length(1), // hint
987 ])
988 .split(area);
989
990 frame.render_widget(
991 Paragraph::new("Search HuggingFace Hub").style(Style::new().fg(C_MUTED)),
992 rows[0],
993 );
994
995 // Search input — always neon-bordered (it's always focused when active).
996 let block = Block::new()
997 .borders(Borders::ALL)
998 .border_type(BorderType::Rounded)
999 .border_style(Style::new().fg(C_NEON))
1000 .style(Style::new().bg(C_SURFACE_STRONG));
1001 let inner = block.inner(rows[1]);
1002 frame.render_widget(block, rows[1]);
1003
1004 let display_query = if app.hf_search_loading {
1005 format!("{} ⠿", app.hf_search_query)
1006 } else {
1007 app.hf_search_query.clone()
1008 };
1009 frame.render_widget(
1010 Paragraph::new(display_query.as_str()).style(Style::new().fg(C_TEXT)),
1011 inner,
1012 );
1013
1014 // Show cursor only when not loading.
1015 if !app.hf_search_loading {
1016 let cursor_x = (inner.x + app.hf_search_query.chars().count() as u16)
1017 .min(inner.x + inner.width.saturating_sub(1));
1018 frame.set_cursor_position((cursor_x, inner.y));
1019 }
1020
1021 // Results area.
1022 if app.hf_search_results.is_empty() && !app.hf_search_loading {
1023 frame.render_widget(
1024 Paragraph::new(" Type a query and press Enter to search.")
1025 .style(Style::new().fg(C_MUTED)),
1026 rows[3],
1027 );
1028 } else {
1029 render_hf_results_list(frame, app, rows[3]);
1030 }
1031
1032 let hint = if app.hf_search_loading {
1033 "Searching…"
1034 } else if app.hf_search_results.is_empty() {
1035 "Enter · search Esc · cancel"
1036 } else {
1037 "↑↓ · navigate Enter · download Esc · cancel"
1038 };
1039 frame.render_widget(
1040 Paragraph::new(hint).style(Style::new().fg(C_MUTED)),
1041 rows[4],
1042 );
1043 }
1044
1045 fn render_hf_results_list(frame: &mut Frame, app: &App, area: Rect) {
1046 let max_rows = area.height as usize;
1047 for (i, model) in app.hf_search_results.iter().enumerate().take(max_rows) {
1048 let row_y = area.y + i as u16;
1049 let row_area = Rect::new(area.x, row_y, area.width, 1);
1050 let is_selected = i == app.hf_search_cursor;
1051 let prefix = if is_selected { "▶ " } else { " " };
1052
1053 let dl_str = format_downloads(model.downloads);
1054 // Truncate model_id to keep the line from wrapping.
1055 let max_id_len = (area.width as usize).saturating_sub(12);
1056 let model_id_display = if model.model_id.len() > max_id_len {
1057 format!("{}…", &model.model_id[..max_id_len.saturating_sub(1)])
1058 } else {
1059 model.model_id.clone()
1060 };
1061 let left = format!("{}{:<44}", prefix, model_id_display);
1062
1063 let text_style = if is_selected {
1064 Style::new().fg(C_NEON)
1065 } else {
1066 Style::new().fg(C_TEXT)
1067 };
1068
1069 let line = Line::from(vec![
1070 Span::styled(left, text_style),
1071 Span::styled(dl_str, Style::new().fg(C_MUTED)),
1072 ]);
1073 frame.render_widget(Paragraph::new(line), row_area);
1074 }
1075 }
1076
1077 fn format_downloads(n: u64) -> String {
1078 if n >= 1_000_000 {
1079 format!("{:.1}M ↓", n as f64 / 1_000_000.0)
1080 } else if n >= 1_000 {
1081 format!("{:.0}K ↓", n as f64 / 1_000.0)
1082 } else if n > 0 {
1083 format!("{n} ↓")
1084 } else {
1085 "–".to_string()
1086 }
1087 }
1088
1089 fn render_download_progress_panel(frame: &mut Frame, app: &App, area: Rect) {
1090 let rows = Layout::vertical([
1091 Constraint::Length(1), // model ID heading
1092 Constraint::Length(1), // spacer
1093 Constraint::Length(1), // filename
1094 Constraint::Length(1), // progress bar
1095 Constraint::Length(1), // byte count
1096 Constraint::Min(0), // spacer
1097 Constraint::Length(1), // hint
1098 ])
1099 .split(area);
1100
1101 if let Some(dp) = &app.download_progress {
1102 frame.render_widget(
1103 Paragraph::new(Line::from(vec![
1104 Span::styled("⠿ ", Style::new().fg(C_NEON)),
1105 Span::styled(dp.model_id.as_str(), Style::new().fg(C_INK).bold()),
1106 ])),
1107 rows[0],
1108 );
1109
1110 let file_label = format!(
1111 "File {}/{}: {}",
1112 dp.file_index + 1,
1113 dp.total_files,
1114 dp.filename
1115 );
1116 frame.render_widget(
1117 Paragraph::new(file_label).style(Style::new().fg(C_TEXT)),
1118 rows[2],
1119 );
1120
1121 // Progress bar.
1122 let progress = if dp.file_bytes_total > 0 {
1123 dp.file_bytes_done as f64 / dp.file_bytes_total as f64
1124 } else {
1125 0.0
1126 };
1127 let bar_width = area.width.saturating_sub(4) as usize;
1128 let filled = (progress * bar_width as f64) as usize;
1129 let empty = bar_width.saturating_sub(filled);
1130 let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
1131 frame.render_widget(Paragraph::new(bar).style(Style::new().fg(C_NEON)), rows[3]);
1132
1133 let bytes_label = format!(
1134 "{} / {}",
1135 fmt_bytes(dp.file_bytes_done as i64),
1136 fmt_bytes(dp.file_bytes_total as i64),
1137 );
1138 frame.render_widget(
1139 Paragraph::new(bytes_label).style(Style::new().fg(C_MUTED)),
1140 rows[4],
1141 );
1142 } else {
1143 frame.render_widget(
1144 Paragraph::new("⠿ Starting download…").style(Style::new().fg(C_MUTED)),
1145 rows[0],
1146 );
1147 }
1148
1149 frame.render_widget(
1150 Paragraph::new("Ctrl+C · quit").style(Style::new().fg(C_MUTED)),
1151 rows[6],
1152 );
1153 }
1154
1155 fn render_model_detail(frame: &mut Frame, app: &App, area: Rect) {
1156 let Some(model) = app.downloads.get(app.downloads_cursor) else {
1157 return;
1158 };
1159
1160 // Resolve fields from the catalog entry when available.
1161 let hf_repo = model.model_id.as_str();
1162 let format_str = model
1163 .catalog_model
1164 .as_ref()
1165 .and_then(|m| m.format.as_deref())
1166 .unwrap_or("–");
1167 let gguf_file = model
1168 .catalog_model
1169 .as_ref()
1170 .and_then(|m| m.gguf_file.as_deref())
1171 .unwrap_or("–");
1172 let description = model
1173 .catalog_model
1174 .as_ref()
1175 .and_then(|m| m.description.as_deref())
1176 .unwrap_or("–");
1177 let family = model
1178 .catalog_model
1179 .as_ref()
1180 .and_then(|m| m.family.as_deref())
1181 .unwrap_or("–");
1182
1183 let (dl_label, dl_style) = if model.downloaded {
1184 let src = model.source.as_ref().map(|s| s.label()).unwrap_or("Yes");
1185 (format!("✓ {src}"), Style::new().fg(C_NEON).bold())
1186 } else {
1187 ("– Not downloaded".to_string(), Style::new().fg(C_MUTED))
1188 };
1189
1190 let rows = Layout::vertical([
1191 Constraint::Length(1), // 0 model name heading
1192 Constraint::Length(1), // 1 spacer
1193 Constraint::Length(1), // 2 status
1194 Constraint::Length(1), // 3 spacer
1195 Constraint::Length(1), // 4 HF Repo label
1196 Constraint::Length(1), // 5 HF Repo value
1197 Constraint::Length(1), // 6 spacer
1198 Constraint::Length(1), // 7 Downloaded label
1199 Constraint::Length(1), // 8 Downloaded value
1200 Constraint::Length(1), // 9 spacer
1201 Constraint::Length(1), // 10 Size label
1202 Constraint::Length(1), // 11 Size value
1203 Constraint::Length(1), // 12 spacer
1204 Constraint::Length(1), // 13 Format label
1205 Constraint::Length(1), // 14 Format value
1206 Constraint::Length(1), // 15 spacer
1207 Constraint::Length(1), // 16 Family label
1208 Constraint::Length(1), // 17 Family value
1209 Constraint::Length(1), // 18 spacer
1210 Constraint::Length(1), // 19 File label
1211 Constraint::Length(1), // 20 File value
1212 Constraint::Length(1), // 21 spacer
1213 Constraint::Length(1), // 22 Catalog ID label
1214 Constraint::Length(1), // 23 Catalog ID value
1215 Constraint::Length(1), // 24 spacer
1216 Constraint::Length(1), // 25 Description label
1217 Constraint::Length(1), // 26 Description value
1218 Constraint::Length(1), // 27 spacer
1219 Constraint::Length(1), // 28 Adapters heading
1220 Constraint::Min(0), // 29 Adapters list
1221 ])
1222 .split(area);
1223
1224 // Heading
1225 frame.render_widget(
1226 Paragraph::new(Line::from(Span::styled(
1227 &model.display_name,
1228 Style::new().fg(C_INK).bold(),
1229 ))),
1230 rows[0],
1231 );
1232
1233 render_status(frame, app, rows[2]);
1234
1235 render_detail_field(frame, "HF Repo", hf_repo, rows[4], rows[5]);
1236
1237 // Downloaded — styled separately
1238 frame.render_widget(
1239 Paragraph::new("Downloaded").style(Style::new().fg(C_MUTED)),
1240 rows[7],
1241 );
1242 frame.render_widget(Paragraph::new(dl_label).style(dl_style), rows[8]);
1243
1244 render_detail_field(frame, "Size", &model.size_display, rows[10], rows[11]);
1245 render_detail_field(frame, "Format", format_str, rows[13], rows[14]);
1246 render_detail_field(frame, "Family", family, rows[16], rows[17]);
1247 render_detail_field(frame, "File", gguf_file, rows[19], rows[20]);
1248
1249 let catalog_id_str = model.catalog_id.as_deref().unwrap_or("–");
1250 render_detail_field(frame, "Catalog ID", catalog_id_str, rows[22], rows[23]);
1251
1252 frame.render_widget(
1253 Paragraph::new("Description").style(Style::new().fg(C_MUTED)),
1254 rows[25],
1255 );
1256 frame.render_widget(
1257 Paragraph::new(description)
1258 .style(Style::new().fg(C_TEXT))
1259 .wrap(Wrap { trim: true }),
1260 rows[26],
1261 );
1262
1263 // Artifacts section (adapters + exported GGUFs)
1264 let adapter_heading = if app.adapter_list.is_empty() {
1265 "Artifacts"
1266 } else {
1267 "Artifacts (↑↓ select · Enter merge & export)"
1268 };
1269 frame.render_widget(
1270 Paragraph::new(adapter_heading).style(Style::new().fg(C_MUTED)),
1271 rows[28],
1272 );
1273 render_model_detail_adapters(frame, app, rows[29]);
1274 }
1275
1276 /// Render the selectable artifact list inside the Model Detail screen.
1277 fn render_model_detail_adapters(frame: &mut Frame, app: &App, area: Rect) {
1278 if app.adapter_list.is_empty() {
1279 let msg = if app.model_detail_can_finetune {
1280 " No artifacts found. Press f to fine-tune."
1281 } else {
1282 " No artifacts found. (GGUF models cannot be fine-tuned.)"
1283 };
1284 frame.render_widget(Paragraph::new(msg).style(Style::new().fg(C_MUTED)), area);
1285 return;
1286 }
1287
1288 let max_rows = area.height as usize;
1289 for (i, adapter) in app.adapter_list.iter().enumerate().take(max_rows) {
1290 let row_y = area.y + i as u16;
1291 let row_area = Rect::new(area.x, row_y, area.width, 1);
1292
1293 let is_selected = i == app.adapter_cursor;
1294 let marker = if is_selected { "▸ " } else { " " };
1295 let marker_style = if is_selected {
1296 Style::new().fg(C_NEON).bold()
1297 } else {
1298 Style::new().fg(C_NEON)
1299 };
1300 let name_style = if is_selected {
1301 Style::new().fg(C_TEXT).bold()
1302 } else {
1303 Style::new().fg(C_TEXT)
1304 };
1305 let meta_style = Style::new().fg(C_MUTED);
1306
1307 // Kind badge + icon
1308 let (kind_icon, kind_label) = match adapter.kind {
1309 ArtifactKind::LoraAdapter => ("◆ ", "LoRA"),
1310 ArtifactKind::Gguf => ("● ", "GGUF"),
1311 };
1312 let kind_color = match adapter.kind {
1313 ArtifactKind::LoraAdapter => C_NEON,
1314 ArtifactKind::Gguf => Color::Rgb(100, 200, 255),
1315 };
1316
1317 // Show file_name for GGUF, dir_name for LoRA
1318 let display_name = match adapter.kind {
1319 ArtifactKind::Gguf => &adapter.file_name,
1320 ArtifactKind::LoraAdapter => &adapter.dir_name,
1321 };
1322 let name_truncated = if display_name.len() > 28 {
1323 format!("{}…", &display_name[..27])
1324 } else {
1325 display_name.clone()
1326 };
1327
1328 let line = Line::from(vec![
1329 Span::styled(marker, marker_style),
1330 Span::styled(kind_icon, Style::new().fg(kind_color)),
1331 Span::styled(format!("{:<6}", kind_label), Style::new().fg(kind_color)),
1332 Span::styled(format!("{:<30}", name_truncated), name_style),
1333 Span::styled(format!("{:<10}", adapter.size), meta_style),
1334 Span::styled(&adapter.modified, meta_style),
1335 ]);
1336
1337 if is_selected {
1338 frame.render_widget(
1339 Paragraph::new(line).style(Style::new().bg(C_SURFACE_STRONG)),
1340 row_area,
1341 );
1342 } else {
1343 frame.render_widget(Paragraph::new(line), row_area);
1344 }
1345 }
1346 }
1347
1348 fn render_gguf_detail(frame: &mut Frame, app: &App, area: Rect) {
1349 let Some(ref gguf) = app.selected_gguf else {
1350 return;
1351 };
1352
1353 let uploadable = gguf.is_uploadable();
1354
1355 let rows = if uploadable {
1356 Layout::vertical([
1357 Constraint::Length(1), // 0 heading
1358 Constraint::Length(1), // 1 spacer
1359 Constraint::Length(1), // 2 status
1360 Constraint::Length(1), // 3 spacer
1361 Constraint::Length(1), // 4 File label
1362 Constraint::Length(1), // 5 File value
1363 Constraint::Length(1), // 6 spacer
1364 Constraint::Length(1), // 7 Size label
1365 Constraint::Length(1), // 8 Size value
1366 Constraint::Length(1), // 9 spacer
1367 Constraint::Length(1), // 10 Location label
1368 Constraint::Length(1), // 11 Location value
1369 Constraint::Length(1), // 12 spacer
1370 Constraint::Length(1), // 13 Upload heading
1371 Constraint::Length(1), // 14 Repo Name label
1372 Constraint::Length(3), // 15 Repo Name input
1373 Constraint::Length(1), // 16 spacer
1374 Constraint::Length(1), // 17 upload status / progress
1375 Constraint::Length(1), // 18 spacer
1376 Constraint::Min(0), // 19 rest
1377 Constraint::Length(1), // 20 hint
1378 ])
1379 .split(area)
1380 } else {
1381 Layout::vertical([
1382 Constraint::Length(1), // 0 heading
1383 Constraint::Length(1), // 1 spacer
1384 Constraint::Length(1), // 2 status
1385 Constraint::Length(1), // 3 spacer
1386 Constraint::Length(1), // 4 File label
1387 Constraint::Length(1), // 5 File value
1388 Constraint::Length(1), // 6 spacer
1389 Constraint::Length(1), // 7 Size label
1390 Constraint::Length(1), // 8 Size value
1391 Constraint::Length(1), // 9 spacer
1392 Constraint::Length(1), // 10 Location label
1393 Constraint::Length(1), // 11 Location value
1394 Constraint::Length(1), // 12 spacer
1395 Constraint::Length(0), // 13 (hidden)
1396 Constraint::Length(0), // 14 (hidden)
1397 Constraint::Length(0), // 15 (hidden)
1398 Constraint::Length(0), // 16 (hidden)
1399 Constraint::Length(0), // 17 (hidden)
1400 Constraint::Length(0), // 18 (hidden)
1401 Constraint::Min(0), // 19 rest
1402 Constraint::Length(1), // 20 hint
1403 ])
1404 .split(area)
1405 };
1406
1407 // Heading
1408 frame.render_widget(
1409 Paragraph::new(Line::from(Span::styled(
1410 format!("GGUF Model — {}", gguf.file_name),
1411 Style::new().fg(C_INK).bold(),
1412 ))),
1413 rows[0],
1414 );
1415
1416 render_status(frame, app, rows[2]);
1417
1418 // File name
1419 render_detail_field(frame, "File", &gguf.file_name, rows[4], rows[5]);
1420
1421 // Size + modified
1422 let size_modified = format!("{} {}", gguf.size, gguf.modified);
1423 render_detail_field(frame, "Size", &size_modified, rows[7], rows[8]);
1424
1425 // Location — show a friendly label instead of the raw path
1426 let location = gguf.location_label();
1427 render_detail_field(frame, "Location", &location, rows[10], rows[11]);
1428
1429 // Upload section — only for locally fine-tuned models, not HF downloads
1430 if uploadable {
1431 frame.render_widget(
1432 Paragraph::new("Upload to HuggingFace").style(Style::new().fg(C_MUTED)),
1433 rows[13],
1434 );
1435
1436 // Repo name input
1437 frame.render_widget(
1438 Paragraph::new("Repo Name").style(Style::new().fg(C_MUTED)),
1439 rows[14],
1440 );
1441
1442 let input_focused = !app.upload_running
1443 && !matches!(
1444 app.upload_progress,
1445 Some(crate::hf_upload::UploadProgress::Done { .. })
1446 );
1447 let border_style = if input_focused {
1448 Style::new().fg(C_NEON)
1449 } else {
1450 Style::new().fg(C_LINE)
1451 };
1452 let input_block = Block::new()
1453 .borders(Borders::ALL)
1454 .border_type(BorderType::Rounded)
1455 .border_style(border_style)
1456 .style(Style::new().bg(C_SURFACE_STRONG));
1457 let input_inner = input_block.inner(rows[15]);
1458 frame.render_widget(input_block, rows[15]);
1459 frame.render_widget(
1460 Paragraph::new(app.upload_repo_name.as_str()).style(Style::new().fg(C_TEXT)),
1461 input_inner,
1462 );
1463 if input_focused {
1464 let cursor_x = (input_inner.x + app.upload_repo_name.chars().count() as u16)
1465 .min(input_inner.x + input_inner.width.saturating_sub(1));
1466 frame.set_cursor_position((cursor_x, input_inner.y));
1467 }
1468
1469 // Upload progress / status
1470 render_upload_status(frame, app, rows[17]);
1471 }
1472
1473 // Hint
1474 let hint = if app.upload_running {
1475 "Ctrl+C · quit"
1476 } else if matches!(
1477 app.upload_progress,
1478 Some(crate::hf_upload::UploadProgress::Done { .. })
1479 ) {
1480 "a · assign to app Esc · back"
1481 } else if uploadable {
1482 "Enter · upload c · chat Esc · back"
1483 } else {
1484 "c · chat Esc · back"
1485 };
1486 frame.render_widget(
1487 Paragraph::new(hint).style(Style::new().fg(C_MUTED)),
1488 rows[20],
1489 );
1490 }
1491
1492 fn render_upload_status(frame: &mut Frame, app: &App, area: Rect) {
1493 match &app.upload_progress {
1494 Some(crate::hf_upload::UploadProgress::CreatingRepo) => {
1495 frame.render_widget(
1496 Paragraph::new("⠿ Creating repository…").style(Style::new().fg(C_MUTED)),
1497 area,
1498 );
1499 }
1500 Some(crate::hf_upload::UploadProgress::Hashing {
1501 bytes_done,
1502 bytes_total,
1503 }) => {
1504 let pct = if *bytes_total > 0 {
1505 (*bytes_done as f64 / *bytes_total as f64 * 100.0) as u64
1506 } else {
1507 0
1508 };
1509 frame.render_widget(
1510 Paragraph::new(format!("⠿ Hashing… {}%", pct)).style(Style::new().fg(C_MUTED)),
1511 area,
1512 );
1513 }
1514 Some(crate::hf_upload::UploadProgress::Committing) => {
1515 frame.render_widget(
1516 Paragraph::new("⠿ Creating commit…").style(Style::new().fg(C_MUTED)),
1517 area,
1518 );
1519 }
1520 Some(crate::hf_upload::UploadProgress::Uploading {
1521 bytes_sent,
1522 bytes_total,
1523 }) => {
1524 let pct = if *bytes_total > 0 {
1525 (*bytes_sent as f64 / *bytes_total as f64 * 100.0) as u64
1526 } else {
1527 0
1528 };
1529 let sent_str = fmt_bytes(*bytes_sent as i64);
1530 let total_str = fmt_bytes(*bytes_total as i64);
1531 let bar_width = area.width.saturating_sub(30) as usize;
1532 let filled = if *bytes_total > 0 {
1533 (*bytes_sent as f64 / *bytes_total as f64 * bar_width as f64) as usize
1534 } else {
1535 0
1536 };
1537 let empty = bar_width.saturating_sub(filled);
1538 let bar = format!(
1539 "{}{} {}/{} {}%",
1540 "█".repeat(filled),
1541 "░".repeat(empty),
1542 sent_str,
1543 total_str,
1544 pct
1545 );
1546 frame.render_widget(Paragraph::new(bar).style(Style::new().fg(C_NEON)), area);
1547 }
1548 Some(crate::hf_upload::UploadProgress::Done { url }) => {
1549 frame.render_widget(
1550 Paragraph::new(Line::from(vec![
1551 Span::styled("✓ ", Style::new().fg(C_NEON)),
1552 Span::styled("Uploaded → ", Style::new().fg(C_NEON).bold()),
1553 Span::styled(url.as_str(), Style::new().fg(C_TEXT)),
1554 ])),
1555 area,
1556 );
1557 }
1558 Some(crate::hf_upload::UploadProgress::Failed(msg)) => {
1559 frame.render_widget(
1560 Paragraph::new(Line::from(vec![
1561 Span::styled("✗ Upload failed: ", Style::new().fg(C_DANGER).bold()),
1562 Span::styled(msg.as_str(), Style::new().fg(C_DANGER)),
1563 ])),
1564 area,
1565 );
1566 }
1567 None => {
1568 if crate::app::HF_TOKEN.is_empty() {
1569 frame.render_widget(
1570 Paragraph::new("⚠ No HF_TOKEN in .env — upload disabled")
1571 .style(Style::new().fg(C_DANGER)),
1572 area,
1573 );
1574 } else {
1575 frame.render_widget(
1576 Paragraph::new("Ready to upload. Press Enter to start.")
1577 .style(Style::new().fg(C_MUTED)),
1578 area,
1579 );
1580 }
1581 }
1582 }
1583 }
1584
1585 fn fmt_bytes(bytes: i64) -> String {
1586 if bytes >= 1_000_000_000 {
1587 format!("{:.1}GB", bytes as f64 / 1e9)
1588 } else if bytes >= 1_000_000 {
1589 format!("{:.0}MB", bytes as f64 / 1e6)
1590 } else {
1591 format!("{:.0}KB", bytes as f64 / 1e3)
1592 }
1593 }
1594
1595 fn render_finetune(frame: &mut Frame, app: &App, area: Rect) {
1596 if app.finetune_running
1597 || app.merge_running
1598 || app.gguf_running
1599 || app.finetune_progress.is_some()
1600 {
1601 render_finetune_progress(frame, app, area);
1602 } else {
1603 render_finetune_form(frame, app, area);
1604 }
1605 }
1606
1607 fn render_finetune_form(frame: &mut Frame, app: &App, area: Rect) {
1608 let rows = Layout::vertical([
1609 Constraint::Length(1), // 0 heading
1610 Constraint::Length(1), // 1 spacer
1611 Constraint::Length(1), // 2 status
1612 Constraint::Length(1), // 3 spacer
1613 Constraint::Length(1), // 4 Model Dir label
1614 Constraint::Length(3), // 5 Model Dir input
1615 Constraint::Length(1), // 6 spacer
1616 Constraint::Length(1), // 7 Data Path label
1617 Constraint::Length(3), // 8 Data Path input
1618 Constraint::Length(1), // 9 spacer
1619 Constraint::Length(1), // 10 Rank label
1620 Constraint::Length(3), // 11 Rank input
1621 Constraint::Length(1), // 12 spacer
1622 Constraint::Length(1), // 13 Epochs label
1623 Constraint::Length(3), // 14 Epochs input
1624 Constraint::Length(1), // 15 spacer
1625 Constraint::Length(1), // 16 Learning Rate label
1626 Constraint::Length(3), // 17 Learning Rate input
1627 Constraint::Min(0), // 18 spacer
1628 Constraint::Length(1), // 19 hint
1629 ])
1630 .split(area);
1631
1632 // Heading — show the model ID hint
1633 let heading = if app.finetune_model_id.is_empty() {
1634 "Fine-Tune".to_string()
1635 } else {
1636 format!("Fine-Tune — {}", app.finetune_model_id)
1637 };
1638 frame.render_widget(
1639 Paragraph::new(Line::from(Span::styled(
1640 heading,
1641 Style::new().fg(C_INK).bold(),
1642 ))),
1643 rows[0],
1644 );
1645
1646 render_status(frame, app, rows[2]);
1647
1648 // Model Dir
1649 frame.render_widget(
1650 Paragraph::new("Model Directory").style(Style::new().fg(C_MUTED)),
1651 rows[4],
1652 );
1653 render_finetune_input(
1654 frame,
1655 &app.finetune_model_dir,
1656 app.finetune_focus == FineTuneFocus::ModelDir,
1657 rows[5],
1658 );
1659
1660 // Data Path
1661 frame.render_widget(
1662 Paragraph::new("Training Data (JSONL)").style(Style::new().fg(C_MUTED)),
1663 rows[7],
1664 );
1665 render_finetune_input(
1666 frame,
1667 &app.finetune_data_path,
1668 app.finetune_focus == FineTuneFocus::DataPath,
1669 rows[8],
1670 );
1671
1672 // Rank
1673 frame.render_widget(
1674 Paragraph::new("LoRA Rank").style(Style::new().fg(C_MUTED)),
1675 rows[10],
1676 );
1677 render_finetune_input(
1678 frame,
1679 &app.finetune_rank,
1680 app.finetune_focus == FineTuneFocus::Rank,
1681 rows[11],
1682 );
1683
1684 // Epochs
1685 frame.render_widget(
1686 Paragraph::new("Epochs").style(Style::new().fg(C_MUTED)),
1687 rows[13],
1688 );
1689 render_finetune_input(
1690 frame,
1691 &app.finetune_epochs,
1692 app.finetune_focus == FineTuneFocus::Epochs,
1693 rows[14],
1694 );
1695
1696 // Learning Rate
1697 frame.render_widget(
1698 Paragraph::new("Learning Rate").style(Style::new().fg(C_MUTED)),
1699 rows[16],
1700 );
1701 render_finetune_input(
1702 frame,
1703 &app.finetune_lr,
1704 app.finetune_focus == FineTuneFocus::Lr,
1705 rows[17],
1706 );
1707
1708 frame.render_widget(
1709 Paragraph::new("Enter · start Esc · back").style(Style::new().fg(C_MUTED)),
1710 rows[19],
1711 );
1712 }
1713
1714 fn render_finetune_input(frame: &mut Frame, value: &str, is_focused: bool, area: Rect) {
1715 let border_style = if is_focused {
1716 Style::new().fg(C_NEON)
1717 } else {
1718 Style::new().fg(C_LINE)
1719 };
1720
1721 let block = Block::new()
1722 .borders(Borders::ALL)
1723 .border_type(BorderType::Rounded)
1724 .border_style(border_style)
1725 .style(Style::new().bg(C_SURFACE_STRONG));
1726
1727 let inner = block.inner(area);
1728 frame.render_widget(block, area);
1729
1730 frame.render_widget(Paragraph::new(value).style(Style::new().fg(C_TEXT)), inner);
1731
1732 if is_focused {
1733 let cursor_x =
1734 (inner.x + value.chars().count() as u16).min(inner.x + inner.width.saturating_sub(1));
1735 frame.set_cursor_position((cursor_x, inner.y));
1736 }
1737 }
1738
1739 fn render_finetune_progress(frame: &mut Frame, app: &App, area: Rect) {
1740 // Shared top: heading + status. The rest is state-dependent.
1741 let top = Layout::vertical([
1742 Constraint::Length(1), // 0 heading
1743 Constraint::Length(1), // 1 spacer
1744 Constraint::Length(1), // 2 status
1745 Constraint::Length(1), // 3 spacer
1746 Constraint::Min(0), // 4 content (state-dependent)
1747 Constraint::Length(1), // 5 hint
1748 ])
1749 .split(area);
1750
1751 // Heading
1752 let heading = if app.finetune_model_id.is_empty() {
1753 "Fine-Tune".to_string()
1754 } else {
1755 format!("Fine-Tune — {}", app.finetune_model_id)
1756 };
1757 frame.render_widget(
1758 Paragraph::new(Line::from(Span::styled(
1759 heading,
1760 Style::new().fg(C_INK).bold(),
1761 ))),
1762 top[0],
1763 );
1764
1765 render_status(frame, app, top[2]);
1766
1767 let content = top[4];
1768
1769 match &app.finetune_progress {
1770 Some(crate::finetune::FineTuneProgress::Validating) => {
1771 frame.render_widget(
1772 Paragraph::new("⠿ Validating model and data files…")
1773 .style(Style::new().fg(C_MUTED)),
1774 content,
1775 );
1776 }
1777 Some(crate::finetune::FineTuneProgress::LoadingModel) => {
1778 frame.render_widget(
1779 Paragraph::new("⠿ Loading model weights…").style(Style::new().fg(C_MUTED)),
1780 content,
1781 );
1782 }
1783 Some(crate::finetune::FineTuneProgress::Tokenizing { done, total }) => {
1784 frame.render_widget(
1785 Paragraph::new(format!("⠿ Tokenizing… {done}/{total}"))
1786 .style(Style::new().fg(C_MUTED)),
1787 content,
1788 );
1789 }
1790 Some(crate::finetune::FineTuneProgress::Training {
1791 epoch,
1792 total_epochs,
1793 step,
1794 total_steps,
1795 loss,
1796 }) => {
1797 let rows = Layout::vertical([
1798 Constraint::Length(1), // label
1799 Constraint::Length(1), // detail
1800 Constraint::Length(1), // spacer
1801 Constraint::Length(1), // progress bar
1802 Constraint::Min(0), // rest
1803 ])
1804 .split(content);
1805
1806 let detail =
1807 format!("epoch {epoch}/{total_epochs} step {step}/{total_steps} loss {loss:.3}");
1808 frame.render_widget(
1809 Paragraph::new("Training").style(Style::new().fg(C_NEON).bold()),
1810 rows[0],
1811 );
1812 frame.render_widget(
1813 Paragraph::new(detail).style(Style::new().fg(C_TEXT)),
1814 rows[1],
1815 );
1816
1817 let progress = if *total_steps > 0 {
1818 *step as f64 / *total_steps as f64
1819 } else {
1820 0.0
1821 };
1822 let bar_width = content.width.saturating_sub(4) as usize;
1823 let filled = (progress * bar_width as f64) as usize;
1824 let empty = bar_width.saturating_sub(filled);
1825 let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
1826 frame.render_widget(Paragraph::new(bar).style(Style::new().fg(C_NEON)), rows[3]);
1827 }
1828 Some(crate::finetune::FineTuneProgress::Saving) => {
1829 frame.render_widget(
1830 Paragraph::new("⠿ Saving adapter weights…").style(Style::new().fg(C_MUTED)),
1831 content,
1832 );
1833 }
1834 Some(crate::finetune::FineTuneProgress::Done { adapter_path }) => {
1835 render_finetune_done(frame, app, adapter_path, content);
1836 }
1837 Some(crate::finetune::FineTuneProgress::Failed(msg)) => {
1838 let rows = Layout::vertical([
1839 Constraint::Length(1), // label
1840 Constraint::Length(1), // spacer
1841 Constraint::Min(0), // error message (wraps)
1842 ])
1843 .split(content);
1844
1845 frame.render_widget(
1846 Paragraph::new("✗ Failed").style(Style::new().fg(C_DANGER).bold()),
1847 rows[0],
1848 );
1849 frame.render_widget(
1850 Paragraph::new(msg.as_str())
1851 .style(Style::new().fg(C_DANGER))
1852 .wrap(Wrap { trim: true }),
1853 rows[2],
1854 );
1855 }
1856 None => {
1857 frame.render_widget(
1858 Paragraph::new("⠿ Starting…").style(Style::new().fg(C_MUTED)),
1859 content,
1860 );
1861 }
1862 }
1863
1864 // Hint line
1865 let hint = if app.finetune_running || app.merge_running || app.gguf_running {
1866 "Ctrl+C · quit"
1867 } else {
1868 "Esc · back"
1869 };
1870 frame.render_widget(Paragraph::new(hint).style(Style::new().fg(C_MUTED)), top[5]);
1871 }
1872
1873 /// Render the fine-tune "Done" state with full adapter details plus merge/export
1874 /// progress and action hints.
1875 fn render_finetune_done(frame: &mut Frame, app: &App, adapter_path: &std::path::Path, area: Rect) {
1876 let rows = Layout::vertical([
1877 Constraint::Length(1), // 0 ✓ label
1878 Constraint::Length(1), // 1 spacer
1879 Constraint::Length(1), // 2 "Adapter" label
1880 Constraint::Length(3), // 3 path (wrapped in bordered box)
1881 Constraint::Length(1), // 4 spacer
1882 Constraint::Length(1), // 5 size
1883 Constraint::Length(1), // 6 spacer
1884 Constraint::Length(1), // 7 config heading
1885 Constraint::Length(1), // 8 rank + epochs + lr
1886 Constraint::Length(1), // 9 spacer
1887 Constraint::Min(0), // 10 merge/export section or adapters list
1888 ])
1889 .split(area);
1890
1891 // ✓ label
1892 frame.render_widget(
1893 Paragraph::new(Line::from(vec![
1894 Span::styled("✓ ", Style::new().fg(C_NEON)),
1895 Span::styled("Fine-tuning complete", Style::new().fg(C_NEON).bold()),
1896 ])),
1897 rows[0],
1898 );
1899
1900 // Adapter path in a bordered box so it wraps visibly
1901 frame.render_widget(
1902 Paragraph::new("Adapter").style(Style::new().fg(C_MUTED)),
1903 rows[2],
1904 );
1905 let path_block = Block::new()
1906 .borders(Borders::ALL)
1907 .border_type(BorderType::Rounded)
1908 .border_style(Style::new().fg(C_LINE))
1909 .style(Style::new().bg(C_SURFACE_STRONG));
1910 let path_inner = path_block.inner(rows[3]);
1911 frame.render_widget(path_block, rows[3]);
1912 // Show a friendly location badge plus the file name instead of the raw
1913 // absolute path, which truncates to gibberish in this narrow box.
1914 let adapter_file = adapter_path
1915 .file_name()
1916 .map(|n| n.to_string_lossy().to_string())
1917 .unwrap_or_default();
1918 frame.render_widget(
1919 Paragraph::new(Line::from(vec![
1920 Span::styled(
1921 format!("[{}]", crate::app::location_label_for_path(adapter_path)),
1922 Style::new().fg(C_NEON).bold(),
1923 ),
1924 Span::styled(format!(" {adapter_file}"), Style::new().fg(C_TEXT)),
1925 ]))
1926 .wrap(Wrap { trim: true }),
1927 path_inner,
1928 );
1929
1930 // File size
1931 let size_str = std::fs::metadata(adapter_path)
1932 .map(|m| fmt_bytes(m.len() as i64))
1933 .unwrap_or_else(|_| "–".to_string());
1934 frame.render_widget(
1935 Paragraph::new(Line::from(vec![
1936 Span::styled("Size ", Style::new().fg(C_MUTED)),
1937 Span::styled(size_str, Style::new().fg(C_TEXT).bold()),
1938 ])),
1939 rows[5],
1940 );
1941
1942 // Training config
1943 frame.render_widget(
1944 Paragraph::new("Training Config").style(Style::new().fg(C_MUTED)),
1945 rows[7],
1946 );
1947 let config_line = format!(
1948 "rank {} epochs {} lr {}",
1949 app.finetune_rank, app.finetune_epochs, app.finetune_lr
1950 );
1951 frame.render_widget(
1952 Paragraph::new(config_line).style(Style::new().fg(C_TEXT)),
1953 rows[8],
1954 );
1955
1956 // Merge / GGUF export section
1957 render_merge_gguf_section(frame, app, rows[10]);
1958 }
1959
1960 /// Render merge progress, GGUF export progress, and available action hints.
1961 fn render_merge_gguf_section(frame: &mut Frame, app: &App, area: Rect) {
1962 let rows = Layout::vertical([
1963 Constraint::Length(1), // 0 merge status
1964 Constraint::Length(1), // 1 spacer
1965 Constraint::Length(1), // 2 gguf status
1966 Constraint::Length(1), // 3 spacer
1967 Constraint::Length(1), // 4 action hints
1968 Constraint::Length(1), // 5 spacer
1969 Constraint::Length(1), // 6 adapters heading
1970 Constraint::Min(0), // 7 adapters list
1971 ])
1972 .split(area);
1973
1974 // --- Merge status ---
1975 match &app.merge_progress {
1976 Some(crate::merge::MergeProgress::Loading) => {
1977 frame.render_widget(
1978 Paragraph::new("⠿ Loading model for merge…").style(Style::new().fg(C_MUTED)),
1979 rows[0],
1980 );
1981 }
1982 Some(crate::merge::MergeProgress::Merging { layer, total }) => {
1983 frame.render_widget(
1984 Paragraph::new(format!("⠿ Merging layer {layer}/{total}…"))
1985 .style(Style::new().fg(C_MUTED)),
1986 rows[0],
1987 );
1988 }
1989 Some(crate::merge::MergeProgress::Saving) => {
1990 frame.render_widget(
1991 Paragraph::new("⠿ Saving merged model…").style(Style::new().fg(C_MUTED)),
1992 rows[0],
1993 );
1994 }
1995 Some(crate::merge::MergeProgress::Done { output_path }) => {
1996 frame.render_widget(
1997 Paragraph::new(Line::from(vec![
1998 Span::styled("✓ ", Style::new().fg(C_NEON)),
1999 Span::styled("Merged → ", Style::new().fg(C_NEON).bold()),
2000 Span::styled(
2001 format!("[{}]", crate::app::location_label_for_path(output_path)),
2002 Style::new().fg(C_TEXT).bold(),
2003 ),
2004 ])),
2005 rows[0],
2006 );
2007 }
2008 Some(crate::merge::MergeProgress::Failed(msg)) => {
2009 frame.render_widget(
2010 Paragraph::new(Line::from(vec![
2011 Span::styled("✗ Merge failed: ", Style::new().fg(C_DANGER).bold()),
2012 Span::styled(msg.as_str(), Style::new().fg(C_DANGER)),
2013 ])),
2014 rows[0],
2015 );
2016 }
2017 None => {
2018 frame.render_widget(
2019 Paragraph::new(" Merge: not started").style(Style::new().fg(C_MUTED)),
2020 rows[0],
2021 );
2022 }
2023 }
2024
2025 // --- GGUF export status ---
2026 match &app.gguf_progress {
2027 Some(crate::gguf::GgufProgress::ReadingModel) => {
2028 frame.render_widget(
2029 Paragraph::new("⠿ Reading model for GGUF export…").style(Style::new().fg(C_MUTED)),
2030 rows[2],
2031 );
2032 }
2033 Some(crate::gguf::GgufProgress::WritingTensor { index, total, name }) => {
2034 frame.render_widget(
2035 Paragraph::new(format!("⠿ Writing tensor {index}/{total} {name}"))
2036 .style(Style::new().fg(C_MUTED)),
2037 rows[2],
2038 );
2039 }
2040 Some(crate::gguf::GgufProgress::Done {
2041 output_path,
2042 size_bytes,
2043 }) => {
2044 frame.render_widget(
2045 Paragraph::new(Line::from(vec![
2046 Span::styled("✓ ", Style::new().fg(C_NEON)),
2047 Span::styled("GGUF → ", Style::new().fg(C_NEON).bold()),
2048 Span::styled(
2049 output_path.to_string_lossy().to_string(),
2050 Style::new().fg(C_TEXT),
2051 ),
2052 Span::styled(
2053 format!(" ({})", fmt_bytes(*size_bytes as i64)),
2054 Style::new().fg(C_MUTED),
2055 ),
2056 ])),
2057 rows[2],
2058 );
2059 }
2060 Some(crate::gguf::GgufProgress::Failed(msg)) => {
2061 frame.render_widget(
2062 Paragraph::new(Line::from(vec![
2063 Span::styled("✗ GGUF failed: ", Style::new().fg(C_DANGER).bold()),
2064 Span::styled(msg.as_str(), Style::new().fg(C_DANGER)),
2065 ])),
2066 rows[2],
2067 );
2068 }
2069 None => {
2070 if app.merged_model_dir.is_some() {
2071 frame.render_widget(
2072 Paragraph::new(" GGUF: ready to export").style(Style::new().fg(C_MUTED)),
2073 rows[2],
2074 );
2075 } else {
2076 frame.render_widget(
2077 Paragraph::new(" GGUF: merge first").style(Style::new().fg(C_MUTED)),
2078 rows[2],
2079 );
2080 }
2081 }
2082 }
2083
2084 // --- Action hints ---
2085 let mut hints: Vec<Span> = Vec::new();
2086 if !app.merge_running && !app.gguf_running {
2087 hints.push(Span::styled("m", Style::new().fg(C_NEON)));
2088 hints.push(Span::styled(
2089 " · merge adapter ",
2090 Style::new().fg(C_MUTED),
2091 ));
2092 if app.merged_model_dir.is_some() {
2093 hints.push(Span::styled("g", Style::new().fg(C_NEON)));
2094 hints.push(Span::styled(" · export GGUF ", Style::new().fg(C_MUTED)));
2095 }
2096 hints.push(Span::styled("Esc", Style::new().fg(C_NEON)));
2097 hints.push(Span::styled(" · back", Style::new().fg(C_MUTED)));
2098 }
2099 if !hints.is_empty() {
2100 frame.render_widget(Paragraph::new(Line::from(hints)), rows[4]);
2101 }
2102
2103 // --- Adapters list ---
2104 frame.render_widget(
2105 Paragraph::new("All Adapters").style(Style::new().fg(C_MUTED)),
2106 rows[6],
2107 );
2108 render_adapter_list(frame, app, rows[7]);
2109 }
2110
2111 /// Scan the model's cache directory for existing LoRA adapter files and list them.
2112 fn render_adapter_list(frame: &mut Frame, app: &App, area: Rect) {
2113 // Resolve the model's snapshots dir from the model_dir field.
2114 // model_dir points to .../snapshots/{hash}/, so parent is .../snapshots/
2115 let snapshots_dir = std::path::Path::new(&app.finetune_model_dir)
2116 .parent()
2117 .unwrap_or_else(|| std::path::Path::new(""));
2118
2119 let mut adapters: Vec<(String, String, String)> = Vec::new(); // (name, size, date)
2120
2121 // Look for lora_adapter.safetensors in any subdirectory of snapshots
2122 if let Ok(entries) = std::fs::read_dir(snapshots_dir) {
2123 for entry in entries.flatten() {
2124 let dir = entry.path();
2125 if !dir.is_dir() {
2126 continue;
2127 }
2128 let adapter_file = dir.join("lora_adapter.safetensors");
2129 if adapter_file.exists() {
2130 let dir_name = entry.file_name().to_string_lossy().to_string();
2131 let meta = std::fs::metadata(&adapter_file);
2132 let size = meta
2133 .as_ref()
2134 .map(|m| fmt_bytes(m.len() as i64))
2135 .unwrap_or_else(|_| "–".to_string());
2136 let modified = meta
2137 .ok()
2138 .and_then(|m| m.modified().ok())
2139 .map(|t| {
2140 let elapsed = t.elapsed().unwrap_or_default();
2141 let secs = elapsed.as_secs();
2142 if secs < 60 {
2143 "just now".to_string()
2144 } else if secs < 3600 {
2145 format!("{}m ago", secs / 60)
2146 } else if secs < 86400 {
2147 format!("{}h ago", secs / 3600)
2148 } else {
2149 format!("{}d ago", secs / 86400)
2150 }
2151 })
2152 .unwrap_or_else(|| "–".to_string());
2153 adapters.push((dir_name, size, modified));
2154 }
2155 }
2156 }
2157
2158 if adapters.is_empty() {
2159 frame.render_widget(
2160 Paragraph::new(" No adapters found.").style(Style::new().fg(C_MUTED)),
2161 area,
2162 );
2163 return;
2164 }
2165
2166 let max_rows = area.height as usize;
2167 for (i, (name, size, modified)) in adapters.iter().enumerate().take(max_rows) {
2168 let row_y = area.y + i as u16;
2169 let row_area = Rect::new(area.x, row_y, area.width, 1);
2170
2171 let line = Line::from(vec![
2172 Span::styled(" ◆ ", Style::new().fg(C_NEON)),
2173 Span::styled(format!("{:<20}", name), Style::new().fg(C_TEXT)),
2174 Span::styled(format!("{:<10}", size), Style::new().fg(C_MUTED)),
2175 Span::styled(modified.as_str(), Style::new().fg(C_MUTED)),
2176 ]);
2177 frame.render_widget(Paragraph::new(line), row_area);
2178 }
2179 }
2180
2181 fn render_clone_repo(frame: &mut Frame, app: &App, area: Rect) {
2182 let is_picking_base = matches!(
2183 &app.clone_repo_status,
2184 Some(crate::hf_clone::RepoStatus::Empty { .. })
2185 );
2186
2187 let rows = Layout::vertical([
2188 Constraint::Length(1), // 0 heading
2189 Constraint::Length(1), // 1 spacer
2190 Constraint::Length(1), // 2 status
2191 Constraint::Length(1), // 3 spacer
2192 Constraint::Length(1), // 4 Repo ID label
2193 Constraint::Length(3), // 5 Repo ID input
2194 Constraint::Length(1), // 6 spacer
2195 Constraint::Length(1), // 7 result heading
2196 Constraint::Min(0), // 8 result / base model list
2197 ])
2198 .split(area);
2199
2200 // Heading
2201 frame.render_widget(
2202 Paragraph::new(Line::from(Span::styled(
2203 "Clone / Check HuggingFace Repo",
2204 Style::new().fg(C_INK).bold(),
2205 ))),
2206 rows[0],
2207 );
2208
2209 render_status(frame, app, rows[2]);
2210
2211 // Repo ID input
2212 frame.render_widget(
2213 Paragraph::new("Repo ID (e.g. ondeinference/joko)").style(Style::new().fg(C_MUTED)),
2214 rows[4],
2215 );
2216
2217 let input_focused = !app.clone_repo_checking && !is_picking_base;
2218 let border_style = if input_focused {
2219 Style::new().fg(C_NEON)
2220 } else {
2221 Style::new().fg(C_LINE)
2222 };
2223 let input_block = Block::new()
2224 .borders(Borders::ALL)
2225 .border_type(BorderType::Rounded)
2226 .border_style(border_style)
2227 .style(Style::new().bg(C_SURFACE_STRONG));
2228 let input_inner = input_block.inner(rows[5]);
2229 frame.render_widget(input_block, rows[5]);
2230 frame.render_widget(
2231 Paragraph::new(app.clone_repo_id.as_str()).style(Style::new().fg(C_TEXT)),
2232 input_inner,
2233 );
2234 if input_focused {
2235 let cursor_x = (input_inner.x + app.clone_repo_id.chars().count() as u16)
2236 .min(input_inner.x + input_inner.width.saturating_sub(1));
2237 frame.set_cursor_position((cursor_x, input_inner.y));
2238 }
2239
2240 // Result section
2241 match &app.clone_repo_status {
2242 Some(crate::hf_clone::RepoStatus::NotFound) => {
2243 frame.render_widget(
2244 Paragraph::new(Line::from(vec![
2245 Span::styled("✗ ", Style::new().fg(C_DANGER)),
2246 Span::styled(
2247 "Repo not found. Press Enter to create it.",
2248 Style::new().fg(C_MUTED),
2249 ),
2250 ])),
2251 rows[7],
2252 );
2253 }
2254 Some(crate::hf_clone::RepoStatus::HasModel { files, .. }) => {
2255 frame.render_widget(
2256 Paragraph::new(Line::from(vec![
2257 Span::styled("✓ ", Style::new().fg(C_NEON)),
2258 Span::styled(
2259 format!("Repo has {} model file(s). Already set up.", files.len()),
2260 Style::new().fg(C_NEON),
2261 ),
2262 ])),
2263 rows[7],
2264 );
2265 }
2266 Some(crate::hf_clone::RepoStatus::Empty { .. }) => {
2267 frame.render_widget(
2268 Paragraph::new("Select a base model for fine-tuning:")
2269 .style(Style::new().fg(C_MUTED)),
2270 rows[7],
2271 );
2272
2273 // Render base model list
2274 let base_models = crate::hf_clone::BASE_MODELS;
2275 let items: Vec<Line> = base_models
2276 .iter()
2277 .enumerate()
2278 .map(|(i, m)| {
2279 let selected = i == app.clone_base_cursor;
2280 let prefix = if selected { "▸ " } else { " " };
2281 let style = if selected {
2282 Style::new().fg(C_NEON).bold()
2283 } else {
2284 Style::new().fg(C_TEXT)
2285 };
2286 Line::from(vec![
2287 Span::styled(prefix, style),
2288 Span::styled(m.display_name, style),
2289 Span::styled(
2290 format!(" {} {}", m.size_display, m.params),
2291 Style::new().fg(C_MUTED),
2292 ),
2293 ])
2294 })
2295 .collect();
2296
2297 // Add description of selected model below the list
2298 let selected_desc = base_models
2299 .get(app.clone_base_cursor)
2300 .map(|m| m.description)
2301 .unwrap_or("");
2302
2303 let mut all_lines = items;
2304 all_lines.push(Line::from(""));
2305 all_lines.push(Line::from(Span::styled(
2306 selected_desc,
2307 Style::new().fg(C_MUTED).italic(),
2308 )));
2309
2310 frame.render_widget(Paragraph::new(all_lines), rows[8]);
2311 }
2312 None => {
2313 if app.clone_repo_checking {
2314 frame.render_widget(
2315 Paragraph::new("⠿ Checking…").style(Style::new().fg(C_MUTED)),
2316 rows[7],
2317 );
2318 }
2319 }
2320 }
2321 }
2322
2323 fn render_chat(frame: &mut Frame, app: &App, area: Rect) {
2324 use crate::chat::ChatRole;
2325
2326 let rows = Layout::vertical([
2327 Constraint::Length(1), // 0 heading
2328 Constraint::Length(1), // 1 spacer
2329 Constraint::Length(1), // 2 status line
2330 Constraint::Length(1), // 3 spacer
2331 Constraint::Min(0), // 4 message history
2332 Constraint::Length(1), // 5 spacer
2333 Constraint::Length(1), // 6 input label
2334 Constraint::Length(3), // 7 input box
2335 Constraint::Length(1), // 8 hint
2336 ])
2337 .split(area);
2338
2339 // ── Heading ──────────────────────────────────────────────────────────────
2340 let model_name = app
2341 .selected_gguf
2342 .as_ref()
2343 .map(|g| g.file_name.as_str())
2344 .unwrap_or("Local GGUF");
2345 frame.render_widget(
2346 Paragraph::new(Line::from(Span::styled(
2347 format!("Chat — {model_name}"),
2348 Style::new().fg(C_INK).bold(),
2349 ))),
2350 rows[0],
2351 );
2352
2353 render_status(frame, app, rows[2]);
2354
2355 // ── Message history ───────────────────────────────────────────────────────
2356 {
2357 let history_area = rows[4];
2358 let max_visible_lines = history_area.height as usize;
2359
2360 // Build all rendered lines first so we can page from the bottom.
2361 let mut all_lines: Vec<Line> = Vec::new();
2362 for msg in &app.chat_messages {
2363 match msg.role {
2364 ChatRole::User => {
2365 all_lines.push(Line::from(vec![
2366 Span::styled("You ", Style::new().fg(C_NEON).bold()),
2367 Span::styled(msg.content.clone(), Style::new().fg(C_TEXT)),
2368 ]));
2369 }
2370 ChatRole::Assistant => {
2371 // Wrap long assistant replies across multiple display lines.
2372 let label = Span::styled("AI ", Style::new().fg(C_MUTED).bold());
2373 let content_width =
2374 (history_area.width as usize).saturating_sub(5 /* label */);
2375 let words: Vec<&str> = msg.content.split_whitespace().collect();
2376 let mut line_buf = String::new();
2377 let mut first = true;
2378 for word in &words {
2379 if line_buf.len() + word.len() + 1 > content_width && !line_buf.is_empty() {
2380 let prefix = if first {
2381 label.clone()
2382 } else {
2383 Span::styled(" ", Style::new())
2384 };
2385 all_lines.push(Line::from(vec![
2386 prefix,
2387 Span::styled(line_buf.clone(), Style::new().fg(C_INK)),
2388 ]));
2389 line_buf.clear();
2390 first = false;
2391 }
2392 if !line_buf.is_empty() {
2393 line_buf.push(' ');
2394 }
2395 line_buf.push_str(word);
2396 }
2397 if !line_buf.is_empty() {
2398 let prefix = if first {
2399 label.clone()
2400 } else {
2401 Span::styled(" ", Style::new())
2402 };
2403 all_lines.push(Line::from(vec![
2404 prefix,
2405 Span::styled(line_buf, Style::new().fg(C_INK)),
2406 ]));
2407 }
2408 // Blank separator between turns.
2409 all_lines.push(Line::from(""));
2410 }
2411 }
2412 }
2413
2414 if app.chat_thinking {
2415 all_lines.push(Line::from(vec![
2416 Span::styled("AI ", Style::new().fg(C_MUTED).bold()),
2417 Span::styled("▌", Style::new().fg(C_NEON)),
2418 ]));
2419 }
2420
2421 // Scroll: show the last `max_visible_lines` worth of lines.
2422 let start = all_lines.len().saturating_sub(max_visible_lines);
2423 let visible: Vec<Line> = all_lines.into_iter().skip(start).collect();
2424
2425 frame.render_widget(
2426 Paragraph::new(visible).style(Style::new().fg(C_TEXT)),
2427 history_area,
2428 );
2429 }
2430
2431 // ── Input ─────────────────────────────────────────────────────────────────
2432 let input_disabled = app.chat_loading || app.chat_thinking;
2433 frame.render_widget(
2434 Paragraph::new("Message").style(Style::new().fg(C_MUTED)),
2435 rows[6],
2436 );
2437
2438 let border_style = if input_disabled {
2439 Style::new().fg(C_LINE)
2440 } else {
2441 Style::new().fg(C_NEON)
2442 };
2443 let input_block = Block::new()
2444 .borders(Borders::ALL)
2445 .border_type(BorderType::Rounded)
2446 .border_style(border_style)
2447 .style(Style::new().bg(C_SURFACE_STRONG));
2448 let input_inner = input_block.inner(rows[7]);
2449 frame.render_widget(input_block, rows[7]);
2450
2451 let display_text = if input_disabled {
2452 if app.chat_loading {
2453 "Loading model…".to_string()
2454 } else {
2455 "Thinking…".to_string()
2456 }
2457 } else {
2458 app.chat_input.clone()
2459 };
2460
2461 let text_style = if input_disabled {
2462 Style::new().fg(C_MUTED)
2463 } else {
2464 Style::new().fg(C_TEXT)
2465 };
2466 frame.render_widget(
2467 Paragraph::new(display_text.as_str()).style(text_style),
2468 input_inner,
2469 );
2470
2471 if !input_disabled {
2472 let cursor_x = (input_inner.x + app.chat_input.chars().count() as u16)
2473 .min(input_inner.x + input_inner.width.saturating_sub(1));
2474 frame.set_cursor_position((cursor_x, input_inner.y));
2475 }
2476
2477 // ── Hint ──────────────────────────────────────────────────────────────────
2478 let hint = if app.chat_loading || app.chat_thinking {
2479 "Ctrl+C · quit"
2480 } else {
2481 "Enter · send ↑↓ · scroll Esc · back"
2482 };
2483 frame.render_widget(
2484 Paragraph::new(hint).style(Style::new().fg(C_MUTED)),
2485 rows[8],
2486 );
2487 }
2488
2489 fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2490 let keys: Vec<Span> = match app.screen {
2491 Screen::Auth => vec![
2492 Span::styled("Tab", Style::new().fg(C_NEON)),
2493 Span::styled(" · next field ", Style::new().fg(C_MUTED)),
2494 Span::styled("Enter", Style::new().fg(C_NEON)),
2495 Span::styled(" · submit ", Style::new().fg(C_MUTED)),
2496 Span::styled("Ctrl+L", Style::new().fg(C_NEON)),
2497 Span::styled(" · sign in ", Style::new().fg(C_MUTED)),
2498 Span::styled("Ctrl+N", Style::new().fg(C_NEON)),
2499 Span::styled(" · new account ", Style::new().fg(C_MUTED)),
2500 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2501 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2502 ],
2503 Screen::Apps if app.creating_app => vec![
2504 Span::styled("Enter", Style::new().fg(C_NEON)),
2505 Span::styled(" · create ", Style::new().fg(C_MUTED)),
2506 Span::styled("Esc", Style::new().fg(C_NEON)),
2507 Span::styled(" · cancel ", Style::new().fg(C_MUTED)),
2508 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2509 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2510 ],
2511 Screen::Apps => vec![
2512 Span::styled("↑↓", Style::new().fg(C_NEON)),
2513 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2514 Span::styled("Enter", Style::new().fg(C_NEON)),
2515 Span::styled(" · open ", Style::new().fg(C_MUTED)),
2516 Span::styled("n", Style::new().fg(C_NEON)),
2517 Span::styled(" · new ", Style::new().fg(C_MUTED)),
2518 Span::styled("Tab", Style::new().fg(C_NEON)),
2519 Span::styled(" · models ", Style::new().fg(C_MUTED)),
2520 Span::styled("s", Style::new().fg(C_NEON)),
2521 Span::styled(" · sign out ", Style::new().fg(C_MUTED)),
2522 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2523 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2524 ],
2525 Screen::AppDetail if app.renaming_app => vec![
2526 Span::styled("Enter", Style::new().fg(C_NEON)),
2527 Span::styled(" · save ", Style::new().fg(C_MUTED)),
2528 Span::styled("Esc", Style::new().fg(C_NEON)),
2529 Span::styled(" · cancel ", Style::new().fg(C_MUTED)),
2530 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2531 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2532 ],
2533 Screen::AppDetail => vec![
2534 Span::styled("Enter", Style::new().fg(C_NEON)),
2535 Span::styled(" · detail ", Style::new().fg(C_MUTED)),
2536 Span::styled("m", Style::new().fg(C_NEON)),
2537 Span::styled(" · model ", Style::new().fg(C_MUTED)),
2538 Span::styled("r", Style::new().fg(C_NEON)),
2539 Span::styled(" · rename ", Style::new().fg(C_MUTED)),
2540 Span::styled("Esc", Style::new().fg(C_NEON)),
2541 Span::styled(" · back ", Style::new().fg(C_MUTED)),
2542 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2543 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2544 ],
2545 Screen::InferenceModels if app.downloading => vec![
2546 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2547 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2548 ],
2549 Screen::InferenceModels => vec![
2550 Span::styled("↑↓", Style::new().fg(C_NEON)),
2551 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2552 Span::styled("Enter", Style::new().fg(C_NEON)),
2553 Span::styled(" · select / download ", Style::new().fg(C_MUTED)),
2554 Span::styled("Esc", Style::new().fg(C_NEON)),
2555 Span::styled(" · back ", Style::new().fg(C_MUTED)),
2556 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2557 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2558 ],
2559 Screen::Models => vec![
2560 Span::styled("↑↓", Style::new().fg(C_NEON)),
2561 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2562 Span::styled("Enter", Style::new().fg(C_NEON)),
2563 Span::styled(" · assign ", Style::new().fg(C_MUTED)),
2564 Span::styled("Esc", Style::new().fg(C_NEON)),
2565 Span::styled(" · back ", Style::new().fg(C_MUTED)),
2566 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2567 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2568 ],
2569 Screen::Downloads if app.downloading => vec![
2570 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2571 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2572 ],
2573 Screen::Downloads if app.hf_search_active => vec![
2574 Span::styled("Enter", Style::new().fg(C_NEON)),
2575 Span::styled(" · search / download ", Style::new().fg(C_MUTED)),
2576 Span::styled("↑↓", Style::new().fg(C_NEON)),
2577 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2578 Span::styled("Esc", Style::new().fg(C_NEON)),
2579 Span::styled(" · cancel ", Style::new().fg(C_MUTED)),
2580 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2581 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2582 ],
2583 Screen::Downloads => vec![
2584 Span::styled("↑↓", Style::new().fg(C_NEON)),
2585 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2586 Span::styled("/", Style::new().fg(C_NEON)),
2587 Span::styled(" · search HF ", Style::new().fg(C_MUTED)),
2588 Span::styled("c", Style::new().fg(C_NEON)),
2589 Span::styled(" · clone repo ", Style::new().fg(C_MUTED)),
2590 Span::styled("Enter", Style::new().fg(C_NEON)),
2591 Span::styled(" · detail ", Style::new().fg(C_MUTED)),
2592 Span::styled("Tab", Style::new().fg(C_NEON)),
2593 Span::styled(" · apps ", Style::new().fg(C_MUTED)),
2594 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2595 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2596 ],
2597 Screen::FineTune if app.finetune_running || app.merge_running || app.gguf_running => vec![
2598 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2599 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2600 ],
2601 Screen::FineTune
2602 if matches!(
2603 &app.finetune_progress,
2604 Some(crate::finetune::FineTuneProgress::Done { .. })
2605 ) =>
2606 {
2607 let mut keys = vec![
2608 Span::styled("m", Style::new().fg(C_NEON)),
2609 Span::styled(" · merge ", Style::new().fg(C_MUTED)),
2610 ];
2611 if app.merged_model_dir.is_some() {
2612 keys.push(Span::styled("g", Style::new().fg(C_NEON)));
2613 keys.push(Span::styled(" · export GGUF ", Style::new().fg(C_MUTED)));
2614 }
2615 keys.push(Span::styled("Esc", Style::new().fg(C_NEON)));
2616 keys.push(Span::styled(" · back ", Style::new().fg(C_MUTED)));
2617 keys.push(Span::styled("Ctrl+C", Style::new().fg(C_NEON)));
2618 keys.push(Span::styled(" · quit", Style::new().fg(C_MUTED)));
2619 keys
2620 }
2621 Screen::FineTune => vec![
2622 Span::styled("Tab", Style::new().fg(C_NEON)),
2623 Span::styled(" · next field ", Style::new().fg(C_MUTED)),
2624 Span::styled("Enter", Style::new().fg(C_NEON)),
2625 Span::styled(" · start ", Style::new().fg(C_MUTED)),
2626 Span::styled("Esc", Style::new().fg(C_NEON)),
2627 Span::styled(" · back ", Style::new().fg(C_MUTED)),
2628 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2629 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2630 ],
2631 Screen::ModelDetail => {
2632 let mut keys = Vec::new();
2633 if !app.adapter_list.is_empty() {
2634 keys.push(Span::styled("Enter", Style::new().fg(C_NEON)));
2635 keys.push(Span::styled(
2636 " · merge & export ",
2637 Style::new().fg(C_MUTED),
2638 ));
2639 }
2640 if app.model_detail_can_finetune {
2641 keys.push(Span::styled("f", Style::new().fg(C_NEON)));
2642 keys.push(Span::styled(" · fine-tune ", Style::new().fg(C_MUTED)));
2643 }
2644 keys.push(Span::styled("Esc", Style::new().fg(C_NEON)));
2645 keys.push(Span::styled(" · back ", Style::new().fg(C_MUTED)));
2646 keys.push(Span::styled("Ctrl+C", Style::new().fg(C_NEON)));
2647 keys.push(Span::styled(" · quit", Style::new().fg(C_MUTED)));
2648 keys
2649 }
2650 Screen::GgufDetail if app.upload_running => vec![
2651 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2652 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2653 ],
2654 Screen::GgufDetail => {
2655 let uploadable = app
2656 .selected_gguf
2657 .as_ref()
2658 .map(|g| g.is_uploadable())
2659 .unwrap_or(false);
2660 let uploaded = matches!(
2661 app.upload_progress,
2662 Some(crate::hf_upload::UploadProgress::Done { .. })
2663 );
2664 let mut keys = Vec::new();
2665 if uploadable && !uploaded {
2666 keys.push(Span::styled("Enter", Style::new().fg(C_NEON)));
2667 keys.push(Span::styled(" · upload ", Style::new().fg(C_MUTED)));
2668 }
2669 if uploaded {
2670 keys.push(Span::styled("a", Style::new().fg(C_NEON)));
2671 keys.push(Span::styled(
2672 " · assign to app ",
2673 Style::new().fg(C_MUTED),
2674 ));
2675 }
2676 keys.push(Span::styled("c", Style::new().fg(C_NEON)));
2677 keys.push(Span::styled(" · chat ", Style::new().fg(C_MUTED)));
2678 keys.push(Span::styled("Esc", Style::new().fg(C_NEON)));
2679 keys.push(Span::styled(" · back ", Style::new().fg(C_MUTED)));
2680 keys.push(Span::styled("Ctrl+C", Style::new().fg(C_NEON)));
2681 keys.push(Span::styled(" · quit", Style::new().fg(C_MUTED)));
2682 keys
2683 }
2684 Screen::Chat if app.chat_loading || app.chat_thinking => vec![
2685 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2686 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2687 ],
2688 Screen::Chat => vec![
2689 Span::styled("Enter", Style::new().fg(C_NEON)),
2690 Span::styled(" · send ", Style::new().fg(C_MUTED)),
2691 Span::styled("↑↓", Style::new().fg(C_NEON)),
2692 Span::styled(" · scroll ", Style::new().fg(C_MUTED)),
2693 Span::styled("Esc", Style::new().fg(C_NEON)),
2694 Span::styled(" · back ", Style::new().fg(C_MUTED)),
2695 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2696 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2697 ],
2698 Screen::CloneRepo if app.clone_repo_checking => vec![
2699 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2700 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2701 ],
2702 Screen::CloneRepo
2703 if matches!(
2704 &app.clone_repo_status,
2705 Some(crate::hf_clone::RepoStatus::Empty { .. })
2706 ) =>
2707 {
2708 vec![
2709 Span::styled("↑↓", Style::new().fg(C_NEON)),
2710 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2711 Span::styled("Enter", Style::new().fg(C_NEON)),
2712 Span::styled(" · select & download ", Style::new().fg(C_MUTED)),
2713 Span::styled("Esc", Style::new().fg(C_NEON)),
2714 Span::styled(" · back ", Style::new().fg(C_MUTED)),
2715 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2716 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2717 ]
2718 }
2719 Screen::CloneRepo => vec![
2720 Span::styled("Enter", Style::new().fg(C_NEON)),
2721 Span::styled(" · check repo ", Style::new().fg(C_MUTED)),
2722 Span::styled("Esc", Style::new().fg(C_NEON)),
2723 Span::styled(" · back ", Style::new().fg(C_MUTED)),
2724 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2725 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2726 ],
2727 Screen::PublishModel => vec![
2728 Span::styled("↑↓", Style::new().fg(C_NEON)),
2729 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
2730 Span::styled("Enter", Style::new().fg(C_NEON)),
2731 Span::styled(" · assign ", Style::new().fg(C_MUTED)),
2732 Span::styled("Esc", Style::new().fg(C_NEON)),
2733 Span::styled(" · skip ", Style::new().fg(C_MUTED)),
2734 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
2735 Span::styled(" · quit", Style::new().fg(C_MUTED)),
2736 ],
2737 };
2738
2739 frame.render_widget(
2740 Paragraph::new(Line::from(keys)).alignment(Alignment::Center),
2741 area,
2742 );
2743 }