| 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 | } |