| 1 | //! Compositions of current Codewhale terminal view families. |
| 2 | //! |
| 3 | //! `InstrumentSurface` and `SessionList` are extracted renderers; the other |
| 4 | //! rooms below are examples composed from their source layout and host facts. |
| 5 | //! Input handling, configuration, catalogs, and session loading belong to the |
| 6 | //! application. Source paths are recorded beside each recipe and in VIEWS.md. |
| 7 | |
| 8 | use ratatui::{ |
| 9 | buffer::Buffer, |
| 10 | layout::{Constraint, Direction, Layout, Rect}, |
| 11 | style::{Modifier, Style}, |
| 12 | symbols::border, |
| 13 | text::{Line, Span}, |
| 14 | widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}, |
| 15 | }; |
| 16 | |
| 17 | use super::Entry; |
| 18 | use crate::{ |
| 19 | InstrumentSurface, KeyHint, List, ListRow, ListRowState, ListState, OceanRamp, Paint, |
| 20 | PickerState, Role, SessionList, SessionRow, Theme, TuiGround, TuiInk, TuiPalette, WorkbarPanel, |
| 21 | centered, text, |
| 22 | }; |
| 23 | |
| 24 | fn hint(key: &str, verb: &str) -> KeyHint { |
| 25 | KeyHint::new(key.to_string(), verb.to_string()) |
| 26 | } |
| 27 | fn move_hint(theme: &Theme) -> KeyHint { |
| 28 | hint(if theme.ascii() { "Up/Dn" } else { "↑/↓" }, "move") |
| 29 | } |
| 30 | fn selected(theme: &Theme) -> Style { |
| 31 | theme |
| 32 | .fg(Role::Foreground) |
| 33 | .patch(theme.bg(Role::Selected)) |
| 34 | .add_modifier(Modifier::BOLD) |
| 35 | } |
| 36 | fn row(area: Rect, y: u16) -> Rect { |
| 37 | Rect::new( |
| 38 | area.x, |
| 39 | area.y.saturating_add(y).min(area.bottom()), |
| 40 | area.width, |
| 41 | u16::from(y < area.height), |
| 42 | ) |
| 43 | } |
| 44 | fn below(area: Rect, rows: u16) -> Rect { |
| 45 | Rect::new( |
| 46 | area.x, |
| 47 | area.y.saturating_add(rows).min(area.bottom()), |
| 48 | area.width, |
| 49 | area.height.saturating_sub(rows), |
| 50 | ) |
| 51 | } |
| 52 | fn safe(value: &str, theme: &Theme) -> String { |
| 53 | let value = text::display_safe(value); |
| 54 | if !theme.ascii() { |
| 55 | return value.into_owned(); |
| 56 | } |
| 57 | value |
| 58 | .chars() |
| 59 | .map(|c| match c { |
| 60 | '—' | '–' => "-".into(), |
| 61 | '·' => ".".into(), |
| 62 | '’' => "'".into(), |
| 63 | '‹' => "<".into(), |
| 64 | '›' => ">".into(), |
| 65 | _ => c.to_string(), |
| 66 | }) |
| 67 | .collect() |
| 68 | } |
| 69 | fn line(value: &str, role: Role, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 70 | let value = safe(value, theme); |
| 71 | let value = text::truncate(&value, usize::from(area.width), theme.ascii()); |
| 72 | if !area.is_empty() { |
| 73 | buf.set_line( |
| 74 | area.x, |
| 75 | area.y, |
| 76 | &Line::styled(value.into_owned(), theme.fg(role)), |
| 77 | area.width, |
| 78 | ); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | fn top_section(title: &str, area: Rect, buf: &mut Buffer, theme: &Theme) -> Rect { |
| 83 | let mut block = Block::default() |
| 84 | .title(Line::styled( |
| 85 | safe(title, theme), |
| 86 | theme.fg(Role::Primary).add_modifier(Modifier::BOLD), |
| 87 | )) |
| 88 | .borders(Borders::TOP) |
| 89 | .border_style(theme.fg(Role::Border)) |
| 90 | .style(theme.tui_ground(TuiGround::Surface)) |
| 91 | .padding(Padding::uniform(1)); |
| 92 | if theme.ascii() { |
| 93 | block = block.border_set(border::Set { |
| 94 | horizontal_top: "-", |
| 95 | ..border::PLAIN |
| 96 | }); |
| 97 | } |
| 98 | let inner = block.inner(area); |
| 99 | if !area.is_empty() { |
| 100 | block.render(area, buf); |
| 101 | } |
| 102 | inner |
| 103 | } |
| 104 | |
| 105 | /// Native centered choice shape from views/{mode_picker,status_picker}.rs |
| 106 | /// and command_palette.rs. The instrument and centered shapes stay distinct. |
| 107 | fn choice_card( |
| 108 | title: &str, |
| 109 | area: Rect, |
| 110 | preferred: (u16, u16), |
| 111 | minimum: (u16, u16), |
| 112 | actions: &[KeyHint], |
| 113 | buf: &mut Buffer, |
| 114 | theme: &Theme, |
| 115 | ) -> Rect { |
| 116 | let area = area.intersection(buf.area); |
| 117 | if area.is_empty() { |
| 118 | return area; |
| 119 | } |
| 120 | let popup = centered(area, preferred.0, preferred.1, minimum.0, minimum.1); |
| 121 | let shadow = Rect::new( |
| 122 | popup.x.saturating_add(1), |
| 123 | popup.y.saturating_add(1), |
| 124 | popup |
| 125 | .width |
| 126 | .min(area.right().saturating_sub(popup.x.saturating_add(1))), |
| 127 | popup |
| 128 | .height |
| 129 | .min(area.bottom().saturating_sub(popup.y.saturating_add(1))), |
| 130 | ); |
| 131 | if !shadow.is_empty() { |
| 132 | Block::default() |
| 133 | .style(theme.tui_ground(TuiGround::Elevated)) |
| 134 | .render(shadow, buf); |
| 135 | } |
| 136 | Clear.render(popup, buf); |
| 137 | Block::default() |
| 138 | .style(theme.tui_ground(TuiGround::Surface)) |
| 139 | .render(popup, buf); |
| 140 | let mut block = Block::default() |
| 141 | .title(Line::styled( |
| 142 | safe(title, theme), |
| 143 | theme.fg(Role::Primary).add_modifier(Modifier::BOLD), |
| 144 | )) |
| 145 | .borders(Borders::ALL) |
| 146 | .border_style(theme.fg(Role::Border)) |
| 147 | .style(theme.tui_ground(TuiGround::Surface)) |
| 148 | .padding(Padding::uniform(1)); |
| 149 | if theme.ascii() { |
| 150 | block = block.border_set(border::Set { |
| 151 | top_left: "+", |
| 152 | top_right: "+", |
| 153 | bottom_left: "+", |
| 154 | bottom_right: "+", |
| 155 | horizontal_top: "-", |
| 156 | horizontal_bottom: "-", |
| 157 | vertical_left: "|", |
| 158 | vertical_right: "|", |
| 159 | }); |
| 160 | } |
| 161 | let inner = block.inner(popup); |
| 162 | block.render(popup, buf); |
| 163 | InstrumentSurface::draw_footer(inner, buf, actions, theme, false) |
| 164 | } |
| 165 | |
| 166 | fn instrument(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 167 | let body = InstrumentSurface::new("context") |
| 168 | .actions(vec![hint("Enter", "details"), hint("Esc", "close")]) |
| 169 | .draw(area, buf, theme); |
| 170 | line( |
| 171 | "48k of 128k · 38% · compacts at 90%", |
| 172 | Role::Foreground, |
| 173 | row(body, 0), |
| 174 | buf, |
| 175 | theme, |
| 176 | ); |
| 177 | line( |
| 178 | "system + tools · 8.4k · 24 tools", |
| 179 | Role::Muted, |
| 180 | row(body, 2), |
| 181 | buf, |
| 182 | theme, |
| 183 | ); |
| 184 | line( |
| 185 | "conversation · 18 messages · 29.2k", |
| 186 | Role::Muted, |
| 187 | row(body, 3), |
| 188 | buf, |
| 189 | theme, |
| 190 | ); |
| 191 | line("tool output · 10.4k", Role::Muted, row(body, 4), buf, theme); |
| 192 | } |
| 193 | |
| 194 | pub(crate) fn sample_sessions() -> SessionList<'static> { |
| 195 | SessionList::new(vec![ |
| 196 | SessionRow::new( |
| 197 | "4a80c752-28b1-4a96-9638-11a6ac1e394d", |
| 198 | "Review authentication", |
| 199 | ) |
| 200 | .messages(18) |
| 201 | .mode("Work") |
| 202 | .updated("2026-10-01 14:12 (2m ago)") |
| 203 | .current(true), |
| 204 | SessionRow::new("b419de17-939c-4d82-af11-698ae29b449e", "Plan the release") |
| 205 | .messages(12) |
| 206 | .mode("Plan") |
| 207 | .updated("2026-10-01 13:40 (34m ago)"), |
| 208 | SessionRow::new( |
| 209 | "936efb90-55a6-4b3d-b223-b8279d319589", |
| 210 | "Review the alternate approach", |
| 211 | ) |
| 212 | .messages(8) |
| 213 | .mode("Work") |
| 214 | .updated("2026-10-01 12:53 (1h ago)") |
| 215 | .fork(true), |
| 216 | SessionRow::new( |
| 217 | "ff82d346-410e-4e69-b63a-cf5208c58180", |
| 218 | "Add keyboard navigation", |
| 219 | ) |
| 220 | .messages(24) |
| 221 | .mode("Operate") |
| 222 | .updated("2026-09-30 17:30 (20h ago)"), |
| 223 | SessionRow::new("3f473bc6-3aee-45a4-9510-6a599fb75452", "Session") |
| 224 | .messages(1) |
| 225 | .mode("Work") |
| 226 | .updated("2026-09-30 11:09 (1d ago)") |
| 227 | .archived(true), |
| 228 | ]) |
| 229 | .state(PickerState::new(0)) |
| 230 | } |
| 231 | |
| 232 | // session_picker.rs::render, section_block, and build_preview_lines: |
| 233 | // history is left 56%, sessions right 44%; narrow rooms stack list above history. |
| 234 | fn sessions(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 235 | let area = area.intersection(buf.area); |
| 236 | let full = vec![ |
| 237 | hint("Enter", "resume"), |
| 238 | hint("/", "search"), |
| 239 | hint("s", "sort"), |
| 240 | hint("r", "rename"), |
| 241 | hint("a", "all workspaces"), |
| 242 | hint("e", "archive"), |
| 243 | hint("x", "archived"), |
| 244 | hint("d", "delete"), |
| 245 | hint("Esc", "close"), |
| 246 | ]; |
| 247 | let shell = InstrumentSurface::new("sessions").actions(full); |
| 248 | let compact = shell.areas(area, theme).body.height < 12; |
| 249 | let body = if compact { |
| 250 | InstrumentSurface::new("sessions") |
| 251 | .actions(vec![ |
| 252 | hint("Enter", "resume"), |
| 253 | hint("/", "search"), |
| 254 | hint("Esc", "close"), |
| 255 | ]) |
| 256 | .draw(area, buf, theme) |
| 257 | } else { |
| 258 | shell.draw(area, buf, theme) |
| 259 | }; |
| 260 | if compact { |
| 261 | sample_sessions().paint(body, buf, theme); |
| 262 | return; |
| 263 | } |
| 264 | let narrow = body.width < 95; |
| 265 | let parts = Layout::default() |
| 266 | .direction(if narrow { |
| 267 | Direction::Vertical |
| 268 | } else { |
| 269 | Direction::Horizontal |
| 270 | }) |
| 271 | .constraints(if narrow { |
| 272 | [Constraint::Percentage(42), Constraint::Percentage(58)] |
| 273 | } else { |
| 274 | [Constraint::Percentage(56), Constraint::Percentage(44)] |
| 275 | }) |
| 276 | .split(body); |
| 277 | let (history, list) = if narrow { |
| 278 | (parts[1], parts[0]) |
| 279 | } else { |
| 280 | (parts[0], parts[1]) |
| 281 | }; |
| 282 | let list = top_section(" sessions (1-9, PgUp/PgDn) ", list, buf, theme); |
| 283 | sample_sessions().paint(list, buf, theme); |
| 284 | let history = top_section(" history (Shift+PgUp/PgDn) ", history, buf, theme); |
| 285 | let lines = [ |
| 286 | "Title: Review authentication", |
| 287 | "ID: 4a80c752-28b1-4a96-9638-11a6ac1e394d", |
| 288 | "Updated: 2026-10-01 14:12", |
| 289 | "Messages: 18 | Model: deepseek-v4", |
| 290 | "Mode: Work", |
| 291 | "", |
| 292 | "USER:", |
| 293 | " Check the sign-in flow and keyboard navigation.", |
| 294 | "", |
| 295 | "ASSISTANT:", |
| 296 | " The sign-in screen now returns focus to the account button.", |
| 297 | " I also updated the tab order through the provider choices.", |
| 298 | ]; |
| 299 | Paragraph::new( |
| 300 | lines |
| 301 | .into_iter() |
| 302 | .map(|v| Line::styled(v, theme.fg(Role::Foreground))) |
| 303 | .collect::<Vec<_>>(), |
| 304 | ) |
| 305 | .wrap(Wrap { trim: false }) |
| 306 | .render(history, buf); |
| 307 | } |
| 308 | |
| 309 | fn session_list(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 310 | sample_sessions().paint(area, buf, theme); |
| 311 | } |
| 312 | fn sessions_empty(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 313 | let body = InstrumentSurface::new("sessions") |
| 314 | .actions(vec![hint("/", "search"), hint("Esc", "close")]) |
| 315 | .draw(area, buf, theme); |
| 316 | SessionList::new(Vec::new()).paint(body, buf, theme); |
| 317 | } |
| 318 | |
| 319 | // views/mode_picker.rs::render: one line per mode, eight-cell name column. |
| 320 | struct ModeRow { |
| 321 | number: char, |
| 322 | name: &'static str, |
| 323 | hint: &'static str, |
| 324 | } |
| 325 | impl ListRow for ModeRow { |
| 326 | fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) { |
| 327 | let prefix = format!( |
| 328 | "{}. {}", |
| 329 | self.number, |
| 330 | text::pad(self.name, 8, theme.ascii()) |
| 331 | ); |
| 332 | let help = safe(self.hint, theme); |
| 333 | let hint = text::truncate_words( |
| 334 | &help, |
| 335 | usize::from(area.width).saturating_sub(text::width(&prefix)), |
| 336 | theme.ascii(), |
| 337 | ); |
| 338 | let hint_style = if state.selected { |
| 339 | theme.fg(Role::Foreground).patch(theme.bg(Role::Selected)) |
| 340 | } else { |
| 341 | theme.fg(Role::Muted) |
| 342 | }; |
| 343 | buf.set_line( |
| 344 | area.x, |
| 345 | area.y, |
| 346 | &Line::from(vec![ |
| 347 | Span::styled(prefix, state.ink(theme)), |
| 348 | Span::styled(hint.into_owned(), hint_style), |
| 349 | ]), |
| 350 | area.width, |
| 351 | ); |
| 352 | } |
| 353 | } |
| 354 | fn modes(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 355 | let body = choice_card( |
| 356 | " Mode ", |
| 357 | area, |
| 358 | (68, 10), |
| 359 | (44, 8), |
| 360 | &[ |
| 361 | move_hint(theme), |
| 362 | hint("Enter", "select"), |
| 363 | hint("Esc", "cancel"), |
| 364 | ], |
| 365 | buf, |
| 366 | theme, |
| 367 | ); |
| 368 | let rows = [ |
| 369 | ModeRow { |
| 370 | number: '1', |
| 371 | name: "Work", |
| 372 | hint: "Direct work in this session — edits and shell ask for approval", |
| 373 | }, |
| 374 | ModeRow { |
| 375 | number: '2', |
| 376 | name: "Plan", |
| 377 | hint: "Read-only research first — present a plan before acting", |
| 378 | }, |
| 379 | ModeRow { |
| 380 | number: '3', |
| 381 | name: "Operate", |
| 382 | hint: "Turns your prompt into a goal: parallel agents, verified work", |
| 383 | }, |
| 384 | ]; |
| 385 | if !body.is_empty() { |
| 386 | List::new(&rows, ListState::new(0)).paint(body, buf, theme); |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | // command_palette.rs::render: query/count/scope header and sectioned entries. |
| 391 | fn commands(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 392 | let body = choice_card( |
| 393 | " Commands — Find and run one action ", |
| 394 | area, |
| 395 | (90, 22), |
| 396 | (44, 8), |
| 397 | &[ |
| 398 | move_hint(theme), |
| 399 | hint("Enter", "select"), |
| 400 | hint("Esc", "cancel"), |
| 401 | ], |
| 402 | buf, |
| 403 | theme, |
| 404 | ); |
| 405 | line("Type to filter", Role::Muted, row(body, 0), buf, theme); |
| 406 | line("6 shown / 6 entries", Role::Dim, row(body, 1), buf, theme); |
| 407 | line( |
| 408 | if theme.ascii() { |
| 409 | "scope: c:cmd . s:skill . t:tool . m:mcp" |
| 410 | } else { |
| 411 | "scope: c:cmd · s:skill · t:tool · m:mcp" |
| 412 | }, |
| 413 | Role::Dim, |
| 414 | row(body, 2), |
| 415 | buf, |
| 416 | theme, |
| 417 | ); |
| 418 | let entries = [ |
| 419 | ("/model", "Choose a model"), |
| 420 | ("/theme", "Preview a theme"), |
| 421 | ("/context", "Inspect the context window"), |
| 422 | ("/sessions", "Browse saved sessions"), |
| 423 | ("/config", "Open settings"), |
| 424 | ("/fleet", "Open the fleet roster"), |
| 425 | ]; |
| 426 | let label_width = 24usize.min(usize::from(body.width).saturating_sub(22)); |
| 427 | let heading = row(body, 4); |
| 428 | if !heading.is_empty() { |
| 429 | buf.set_line( |
| 430 | heading.x, |
| 431 | heading.y, |
| 432 | &Line::styled( |
| 433 | " Commands (6) ", |
| 434 | theme.fg(Role::Primary).add_modifier(Modifier::BOLD), |
| 435 | ), |
| 436 | heading.width, |
| 437 | ); |
| 438 | } |
| 439 | for (index, (label, description)) in entries.into_iter().enumerate() { |
| 440 | let target = row(body, index as u16 + 5); |
| 441 | if target.is_empty() { |
| 442 | break; |
| 443 | } |
| 444 | let chosen = index == 0; |
| 445 | let pointer = if chosen { |
| 446 | if theme.ascii() { ">" } else { "▸" } |
| 447 | } else { |
| 448 | " " |
| 449 | }; |
| 450 | let prefix = format!( |
| 451 | "{pointer} {} ", |
| 452 | text::pad(label, label_width, theme.ascii()) |
| 453 | ); |
| 454 | let desc = text::truncate( |
| 455 | description, |
| 456 | usize::from(target.width).saturating_sub(text::width(&prefix)), |
| 457 | theme.ascii(), |
| 458 | ); |
| 459 | buf.set_line( |
| 460 | target.x, |
| 461 | target.y, |
| 462 | &Line::styled( |
| 463 | format!("{prefix}{desc}"), |
| 464 | if chosen { |
| 465 | selected(theme) |
| 466 | } else { |
| 467 | theme.fg(Role::Foreground) |
| 468 | }, |
| 469 | ), |
| 470 | target.width, |
| 471 | ); |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | // views/status_picker.rs::render; labels/hints from config.rs::StatusItem. |
| 476 | fn status_items(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 477 | let body = choice_card( |
| 478 | " Status line ", |
| 479 | area, |
| 480 | (64, 16), |
| 481 | (40, 8), |
| 482 | &[ |
| 483 | hint("Space", "toggle"), |
| 484 | hint("a", "all"), |
| 485 | hint("n", "none"), |
| 486 | hint("Enter", "save"), |
| 487 | hint("Esc", "cancel"), |
| 488 | ], |
| 489 | buf, |
| 490 | theme, |
| 491 | ); |
| 492 | line( |
| 493 | "Choose what the footer shows:", |
| 494 | Role::Muted, |
| 495 | row(body, 0), |
| 496 | buf, |
| 497 | theme, |
| 498 | ); |
| 499 | let rows = [ |
| 500 | ("Mode", "plan · act · operate", true), |
| 501 | ("Model", "the model id you'll send to", true), |
| 502 | ( |
| 503 | "Context window %", |
| 504 | "tokens used / model context window", |
| 505 | true, |
| 506 | ), |
| 507 | ("Session cost", "running total for this session", true), |
| 508 | ( |
| 509 | "Account balance", |
| 510 | "remaining prepaid credit from the active provider", |
| 511 | false, |
| 512 | ), |
| 513 | ( |
| 514 | "Prompt cache hit rate", |
| 515 | "% of prompt served from cache", |
| 516 | true, |
| 517 | ), |
| 518 | ( |
| 519 | "Output tokens", |
| 520 | "output tokens of the live or last turn", |
| 521 | true, |
| 522 | ), |
| 523 | ( |
| 524 | "Time to first token", |
| 525 | "average wait for the first token", |
| 526 | true, |
| 527 | ), |
| 528 | ( |
| 529 | "Output rate", |
| 530 | "average tok/s, including first-token wait", |
| 531 | true, |
| 532 | ), |
| 533 | ("Workspace", "directory this session writes to", false), |
| 534 | ("Git branch", "branch the next commit lands on", false), |
| 535 | ]; |
| 536 | for (index, (label, help, checked)) in rows.into_iter().enumerate() { |
| 537 | let target = row(body, index as u16 + 2); |
| 538 | if target.is_empty() { |
| 539 | break; |
| 540 | } |
| 541 | let pointer = if index == 0 { |
| 542 | if theme.ascii() { ">" } else { "▸" } |
| 543 | } else { |
| 544 | " " |
| 545 | }; |
| 546 | let mark = if checked { |
| 547 | if theme.ascii() { "[x]" } else { "[✓]" } |
| 548 | } else { |
| 549 | "[ ]" |
| 550 | }; |
| 551 | let prefix = format!(" {pointer} {mark} {label} ("); |
| 552 | let help_text = safe(help, theme); |
| 553 | let help = text::truncate_words( |
| 554 | &help_text, |
| 555 | usize::from(target.width).saturating_sub(text::width(&prefix) + 1), |
| 556 | theme.ascii(), |
| 557 | ); |
| 558 | let display = text::pad( |
| 559 | &format!("{prefix}{help})"), |
| 560 | usize::from(target.width), |
| 561 | theme.ascii(), |
| 562 | ); |
| 563 | buf.set_line( |
| 564 | target.x, |
| 565 | target.y, |
| 566 | &Line::styled( |
| 567 | display, |
| 568 | if index == 0 { |
| 569 | selected(theme) |
| 570 | } else { |
| 571 | theme.fg(if checked { |
| 572 | Role::Foreground |
| 573 | } else { |
| 574 | Role::Muted |
| 575 | }) |
| 576 | }, |
| 577 | ), |
| 578 | target.width, |
| 579 | ); |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | // views/mod.rs::ListDetailLayout::split. Model picker widens the list and |
| 584 | // retains a 30-cell effort pane; provider picker retains the ordinary split. |
| 585 | fn list_detail(area: Rect, min_detail: u16) -> (Rect, Rect, bool) { |
| 586 | if area.is_empty() { |
| 587 | return (area, area, true); |
| 588 | } |
| 589 | let min_list = 30.min(area.width); |
| 590 | if area.width >= 96 && area.width.saturating_sub(1 + min_list) >= min_detail { |
| 591 | let list_width = (area.width.saturating_mul(42) / 100) |
| 592 | .clamp(min_list, area.width.saturating_sub(1 + min_detail).min(52)); |
| 593 | return ( |
| 594 | Rect::new(area.x, area.y, list_width, area.height), |
| 595 | Rect::new( |
| 596 | area.x + list_width + 1, |
| 597 | area.y, |
| 598 | area.width - list_width - 1, |
| 599 | area.height, |
| 600 | ), |
| 601 | false, |
| 602 | ); |
| 603 | } |
| 604 | let gap = u16::from(area.height >= 8); |
| 605 | let max_list = area.height.saturating_sub(gap + 4.min(area.height)); |
| 606 | let list_height = (area.height.saturating_mul(3) / 5).clamp(1, max_list.max(1)); |
| 607 | ( |
| 608 | Rect::new(area.x, area.y, area.width, list_height), |
| 609 | Rect::new( |
| 610 | area.x, |
| 611 | area.y + list_height + gap, |
| 612 | area.width, |
| 613 | area.height.saturating_sub(list_height + gap), |
| 614 | ), |
| 615 | true, |
| 616 | ) |
| 617 | } |
| 618 | |
| 619 | struct RouteRow { |
| 620 | id: &'static str, |
| 621 | route: &'static str, |
| 622 | detail: &'static str, |
| 623 | } |
| 624 | impl ListRow for RouteRow { |
| 625 | fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) { |
| 626 | let id_width = 24.min(usize::from(area.width)); |
| 627 | let route_width = 15.min(usize::from(area.width).saturating_sub(id_width + 2)); |
| 628 | let prefix = format!( |
| 629 | "{} {} ", |
| 630 | text::pad(self.id, id_width, theme.ascii()), |
| 631 | text::pad(self.route, route_width, theme.ascii()) |
| 632 | ); |
| 633 | let detail = text::truncate( |
| 634 | self.detail, |
| 635 | usize::from(area.width).saturating_sub(text::width(&prefix)), |
| 636 | theme.ascii(), |
| 637 | ); |
| 638 | buf.set_line( |
| 639 | area.x, |
| 640 | area.y, |
| 641 | &Line::styled(format!("{prefix}{detail}"), state.ink(theme)), |
| 642 | area.width, |
| 643 | ); |
| 644 | } |
| 645 | } |
| 646 | |
| 647 | // model_picker.rs::render_route / render_pane / widen_model_pane. |
| 648 | fn models(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 649 | let area = area.intersection(buf.area); |
| 650 | let shell = InstrumentSurface::new(if theme.ascii() { |
| 651 | "route . configured" |
| 652 | } else { |
| 653 | "route · configured" |
| 654 | }) |
| 655 | .actions(vec![ |
| 656 | move_hint(theme), |
| 657 | hint("Tab", "switch"), |
| 658 | hint("Type", "search any model"), |
| 659 | hint("Enter", "apply"), |
| 660 | hint( |
| 661 | if theme.ascii() { "Shift+A" } else { "⇧A" }, |
| 662 | "browse catalog", |
| 663 | ), |
| 664 | hint("Esc", "cancel"), |
| 665 | ]); |
| 666 | let regions = shell.areas(area, theme); |
| 667 | let body = shell.draw(area, buf, theme); |
| 668 | title_action( |
| 669 | "browse catalog (3)", |
| 670 | regions.surface, |
| 671 | regions.body.right(), |
| 672 | buf, |
| 673 | theme, |
| 674 | ); |
| 675 | line("Provider: DeepSeek", Role::Muted, row(body, 0), buf, theme); |
| 676 | let (mut list, mut effort, stacked) = list_detail(below(body, 1), 24); |
| 677 | if !stacked { |
| 678 | let width = effort.width.min(30); |
| 679 | list.width = list |
| 680 | .width |
| 681 | .saturating_add(effort.width.saturating_sub(width)); |
| 682 | effort.x = list.right() + 1; |
| 683 | effort.width = width; |
| 684 | } |
| 685 | line( |
| 686 | if theme.ascii() { |
| 687 | " Model . configured" |
| 688 | } else { |
| 689 | " Model · configured" |
| 690 | }, |
| 691 | Role::Foreground, |
| 692 | row(list, 0), |
| 693 | buf, |
| 694 | theme, |
| 695 | ); |
| 696 | line( |
| 697 | " Model Provider Details", |
| 698 | Role::Muted, |
| 699 | row(list, 1), |
| 700 | buf, |
| 701 | theme, |
| 702 | ); |
| 703 | let models = [ |
| 704 | RouteRow { |
| 705 | id: "deepseek-v4", |
| 706 | route: "DeepSeek", |
| 707 | detail: "active", |
| 708 | }, |
| 709 | RouteRow { |
| 710 | id: "deepseek-v4-flash", |
| 711 | route: "DeepSeek", |
| 712 | detail: "saved", |
| 713 | }, |
| 714 | RouteRow { |
| 715 | id: "qwen3.5:35b", |
| 716 | route: "Ollama", |
| 717 | detail: "local", |
| 718 | }, |
| 719 | ]; |
| 720 | if list.height > 2 { |
| 721 | List::new(&models, ListState::new(0)).paint(below(list, 2), buf, theme); |
| 722 | } |
| 723 | line(" Reasoning", Role::Muted, row(effort, 0), buf, theme); |
| 724 | let levels = ["low", "medium", "high"]; |
| 725 | if effort.height > 1 { |
| 726 | List::new(&levels, ListState::new(2)).paint(below(effort, 1), buf, theme); |
| 727 | } |
| 728 | } |
| 729 | |
| 730 | fn title_action(label: &str, surface: Rect, right: u16, buf: &mut Buffer, theme: &Theme) { |
| 731 | if surface.width < 28 || surface.height == 0 { |
| 732 | return; |
| 733 | } |
| 734 | let width = u16::try_from(text::width(label)) |
| 735 | .unwrap_or(u16::MAX) |
| 736 | .min(surface.width.saturating_sub(18)); |
| 737 | let x = right.saturating_sub(width).max(surface.x); |
| 738 | buf.set_stringn( |
| 739 | x, |
| 740 | surface.y, |
| 741 | text::truncate(label, usize::from(width), theme.ascii()), |
| 742 | usize::from(width), |
| 743 | theme.fg(Role::Primary).add_modifier(Modifier::UNDERLINED), |
| 744 | ); |
| 745 | } |
| 746 | |
| 747 | // provider_picker.rs::render_dashboard / render_provider_detail. |
| 748 | fn providers(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 749 | let area = area.intersection(buf.area); |
| 750 | let shell = InstrumentSurface::new("Provider").actions(vec![ |
| 751 | move_hint(theme), |
| 752 | hint("/", "search"), |
| 753 | hint("Enter", "use"), |
| 754 | hint("A", "browse all"), |
| 755 | hint("R", "edit key"), |
| 756 | hint("M", "models"), |
| 757 | hint("C-t", "test connection"), |
| 758 | hint("Esc", "cancel"), |
| 759 | ]); |
| 760 | let regions = shell.areas(area, theme); |
| 761 | let body = shell.draw(area, buf, theme); |
| 762 | title_action( |
| 763 | "browse all", |
| 764 | regions.surface, |
| 765 | regions.body.right(), |
| 766 | buf, |
| 767 | theme, |
| 768 | ); |
| 769 | let (list, detail, _) = list_detail(body, 34); |
| 770 | let rows = [ |
| 771 | ("DeepSeek", " *", "key saved"), |
| 772 | ("Ollama", " ", "local"), |
| 773 | ("OpenAI", " ", "key saved"), |
| 774 | ]; |
| 775 | for (index, (name, active, state)) in rows.into_iter().enumerate() { |
| 776 | let target = row(list, index as u16); |
| 777 | if target.is_empty() { |
| 778 | break; |
| 779 | } |
| 780 | let marker = if index == 0 { |
| 781 | if theme.ascii() { ">" } else { "▸" } |
| 782 | } else { |
| 783 | " " |
| 784 | }; |
| 785 | let prefix = format!(" {marker} {name}{active} "); |
| 786 | let state = text::truncate_words( |
| 787 | state, |
| 788 | usize::from(target.width).saturating_sub(text::width(&prefix)), |
| 789 | theme.ascii(), |
| 790 | ); |
| 791 | buf.set_line( |
| 792 | target.x, |
| 793 | target.y, |
| 794 | &Line::styled( |
| 795 | format!("{prefix}{state}"), |
| 796 | if index == 0 { |
| 797 | selected(theme) |
| 798 | } else { |
| 799 | theme.fg(Role::Foreground) |
| 800 | }, |
| 801 | ), |
| 802 | target.width, |
| 803 | ); |
| 804 | } |
| 805 | line("DeepSeek", Role::Foreground, row(detail, 0), buf, theme); |
| 806 | line("key saved", Role::Muted, row(detail, 2), buf, theme); |
| 807 | line( |
| 808 | "Model: deepseek-v4 · saved", |
| 809 | Role::Foreground, |
| 810 | row(detail, 3), |
| 811 | buf, |
| 812 | theme, |
| 813 | ); |
| 814 | line( |
| 815 | "Endpoint: https://api.deepseek.com", |
| 816 | Role::Muted, |
| 817 | row(detail, 4), |
| 818 | buf, |
| 819 | theme, |
| 820 | ); |
| 821 | line( |
| 822 | "Credential: environment", |
| 823 | Role::Muted, |
| 824 | row(detail, 5), |
| 825 | buf, |
| 826 | theme, |
| 827 | ); |
| 828 | } |
| 829 | |
| 830 | // theme_picker.rs::render; native selectable order and five two-cell swatches. |
| 831 | fn themes(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 832 | let body = InstrumentSurface::new(if theme.ascii() { |
| 833 | "theme . live preview" |
| 834 | } else { |
| 835 | "theme · live preview" |
| 836 | }) |
| 837 | .actions(vec![ |
| 838 | hint(if theme.ascii() { "Up/Dn" } else { "↑/↓" }, "preview"), |
| 839 | hint("Enter", "save"), |
| 840 | hint("Esc", "revert"), |
| 841 | ]) |
| 842 | .draw(area, buf, theme); |
| 843 | let options = [ |
| 844 | ( |
| 845 | "System", |
| 846 | None, |
| 847 | "Follow terminal background (COLORFGBG / macOS appearance)", |
| 848 | ), |
| 849 | ( |
| 850 | "Terminal", |
| 851 | Some(TuiPalette::Terminal), |
| 852 | "Inherit terminal colors fully (transparent surfaces, ANSI accents)", |
| 853 | ), |
| 854 | ( |
| 855 | "Shoreline", |
| 856 | Some(TuiPalette::Shoreline), |
| 857 | "Warm charcoal, one restrained blue — the desktop client's palette", |
| 858 | ), |
| 859 | ( |
| 860 | "Shoreline Light", |
| 861 | Some(TuiPalette::ShorelineLight), |
| 862 | "Shoreline on warm paper — the desktop client's light mode", |
| 863 | ), |
| 864 | ( |
| 865 | "Underwater", |
| 866 | Some(TuiPalette::Underwater), |
| 867 | "The painted ocean field: ombre water, ambient life, the whale", |
| 868 | ), |
| 869 | ( |
| 870 | "Underwater Retro", |
| 871 | Some(TuiPalette::UnderwaterRetro), |
| 872 | "Flat phosphor-teal ocean: the legacy deepsea look, no ombre", |
| 873 | ), |
| 874 | ( |
| 875 | "Blue Stage", |
| 876 | Some(TuiPalette::Whale), |
| 877 | "Stage black, action blue, and one Signal Gold human beacon", |
| 878 | ), |
| 879 | ( |
| 880 | "Blue Stage Light", |
| 881 | Some(TuiPalette::WhaleLight), |
| 882 | "Paper, cobalt action, and one Signal Gold human beacon", |
| 883 | ), |
| 884 | ( |
| 885 | "Grayscale", |
| 886 | Some(TuiPalette::Grayscale), |
| 887 | "Color-minimal high contrast", |
| 888 | ), |
| 889 | ( |
| 890 | "Catppuccin Mocha", |
| 891 | Some(TuiPalette::CatppuccinMocha), |
| 892 | "Soft pastels on warm dark", |
| 893 | ), |
| 894 | ( |
| 895 | "Tokyo Night", |
| 896 | Some(TuiPalette::TokyoNight), |
| 897 | "Deep blue/violet night palette", |
| 898 | ), |
| 899 | ( |
| 900 | "Dracula", |
| 901 | Some(TuiPalette::Dracula), |
| 902 | "Classic high-contrast purple", |
| 903 | ), |
| 904 | ( |
| 905 | "Gruvbox Dark", |
| 906 | Some(TuiPalette::GruvboxDark), |
| 907 | "Vintage warm earth tones", |
| 908 | ), |
| 909 | ("Claude", Some(TuiPalette::Claude), "Warm navy & coral"), |
| 910 | ( |
| 911 | "Matrix", |
| 912 | Some(TuiPalette::Matrix), |
| 913 | "The Matrix films inspired theme", |
| 914 | ), |
| 915 | ( |
| 916 | "Solarized Light", |
| 917 | Some(TuiPalette::SolarizedLight), |
| 918 | "Solarized light — Light, calming palette on warm ivory — easy on the eyes", |
| 919 | ), |
| 920 | ( |
| 921 | "Uwu", |
| 922 | Some(TuiPalette::Uwu), |
| 923 | "Soft kawaii night — sakura, mint, and peach", |
| 924 | ), |
| 925 | ]; |
| 926 | for (index, (name, palette, tagline)) in options.into_iter().enumerate() { |
| 927 | let target = row(body, index as u16 + 1); |
| 928 | if target.is_empty() { |
| 929 | break; |
| 930 | } |
| 931 | let is_selected = index == 4; |
| 932 | let style = if is_selected { |
| 933 | selected(theme) |
| 934 | } else { |
| 935 | theme.fg(Role::Foreground) |
| 936 | }; |
| 937 | let candidate = palette.map_or(*theme, |palette| theme.tui_palette(palette)); |
| 938 | let pointer = if is_selected { |
| 939 | if theme.ascii() { ">" } else { "▸" } |
| 940 | } else { |
| 941 | " " |
| 942 | }; |
| 943 | let mut spans = vec![ |
| 944 | Span::styled(format!(" {pointer} "), style), |
| 945 | Span::styled( |
| 946 | format!("{}. ", index + 1), |
| 947 | if is_selected { |
| 948 | theme |
| 949 | .tui_ink(TuiInk::Working) |
| 950 | .patch(theme.bg(Role::Selected)) |
| 951 | .add_modifier(Modifier::BOLD) |
| 952 | } else { |
| 953 | theme.fg(Role::Hint) |
| 954 | }, |
| 955 | ), |
| 956 | Span::styled(text::pad(name, 22, theme.ascii()), style), |
| 957 | ]; |
| 958 | if palette == Some(TuiPalette::Underwater) && OceanRamp::for_theme(&candidate).is_some() { |
| 959 | for color in [ |
| 960 | OceanRamp::SURFACE, |
| 961 | OceanRamp::MIDDLE, |
| 962 | OceanRamp::DEEP, |
| 963 | OceanRamp::AMBIENT, |
| 964 | ] { |
| 965 | spans.push(Span::styled(" ", Style::default().bg(color))); |
| 966 | } |
| 967 | spans.push(Span::styled( |
| 968 | " ", |
| 969 | Style::default().bg(candidate.tui_ink(TuiInk::Working).fg.unwrap_or_default()), |
| 970 | )); |
| 971 | } else { |
| 972 | spans.extend( |
| 973 | [ |
| 974 | Role::Background, |
| 975 | Role::Surface, |
| 976 | Role::Live, |
| 977 | Role::Attention, |
| 978 | Role::Primary, |
| 979 | ] |
| 980 | .map(|role| Span::styled(" ", candidate.bg(role))), |
| 981 | ); |
| 982 | } |
| 983 | spans.push(Span::raw(" ")); |
| 984 | let prefix = Line::from(spans.clone()).width(); |
| 985 | let tagline_text = safe(tagline, theme); |
| 986 | let tagline = text::truncate_words( |
| 987 | &tagline_text, |
| 988 | usize::from(target.width).saturating_sub(prefix), |
| 989 | theme.ascii(), |
| 990 | ); |
| 991 | spans.push(Span::styled( |
| 992 | tagline.into_owned(), |
| 993 | theme.fg(Role::Dim).patch(if is_selected { |
| 994 | theme.bg(Role::Selected) |
| 995 | } else { |
| 996 | Style::default() |
| 997 | }), |
| 998 | )); |
| 999 | buf.set_line(target.x, target.y, &Line::from(spans), target.width); |
| 1000 | } |
| 1001 | } |
| 1002 | |
| 1003 | // file_picker.rs::render: explicit query, blank row, marker field, paths. |
| 1004 | fn files(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1005 | let body = InstrumentSurface::new("@ attach · 5 matches") |
| 1006 | .actions(vec![ |
| 1007 | move_hint(theme), |
| 1008 | hint("Enter", "insert @path"), |
| 1009 | hint("Esc", "cancel"), |
| 1010 | ]) |
| 1011 | .draw(area, buf, theme); |
| 1012 | let query = row(body, 0); |
| 1013 | if !query.is_empty() { |
| 1014 | buf.set_line( |
| 1015 | query.x, |
| 1016 | query.y, |
| 1017 | &Line::from(vec![ |
| 1018 | Span::styled("> ", theme.fg(Role::Primary).add_modifier(Modifier::BOLD)), |
| 1019 | Span::styled("tui/", theme.fg(Role::Foreground)), |
| 1020 | Span::styled( |
| 1021 | " ", |
| 1022 | theme.bg(Role::Primary).patch(theme.fg(Role::Background)), |
| 1023 | ), |
| 1024 | ]), |
| 1025 | query.width, |
| 1026 | ); |
| 1027 | } |
| 1028 | for (index, path) in [ |
| 1029 | "crates/tui/src/tui/ui/frame.rs", |
| 1030 | "crates/tui/src/tui/widgets/mod.rs", |
| 1031 | "crates/tui/src/tui/work_surface/render/mod.rs", |
| 1032 | "crates/tui/src/tui/session_picker.rs", |
| 1033 | "crates/tui/src/tui/theme_picker.rs", |
| 1034 | ] |
| 1035 | .into_iter() |
| 1036 | .enumerate() |
| 1037 | { |
| 1038 | let target = row(body, index as u16 + 2); |
| 1039 | if target.is_empty() { |
| 1040 | break; |
| 1041 | } |
| 1042 | let pointer = if index == 0 { |
| 1043 | if theme.ascii() { ">" } else { "▸" } |
| 1044 | } else { |
| 1045 | " " |
| 1046 | }; |
| 1047 | let marker = if target.width >= 18 { " " } else { "" }; |
| 1048 | let prefix = format!("{pointer} {marker}"); |
| 1049 | let path = text::truncate( |
| 1050 | path, |
| 1051 | usize::from(target.width).saturating_sub(text::width(&prefix)), |
| 1052 | theme.ascii(), |
| 1053 | ); |
| 1054 | buf.set_line( |
| 1055 | target.x, |
| 1056 | target.y, |
| 1057 | &Line::styled( |
| 1058 | format!("{prefix}{path}"), |
| 1059 | if index == 0 { |
| 1060 | theme.fg(Role::Foreground).patch(theme.bg(Role::Selected)) |
| 1061 | } else { |
| 1062 | theme.fg(Role::Foreground) |
| 1063 | }, |
| 1064 | ), |
| 1065 | target.width, |
| 1066 | ); |
| 1067 | } |
| 1068 | } |
| 1069 | |
| 1070 | // views/mod.rs::ConfigView::render_settings_shell / config_shell_panes. |
| 1071 | fn settings(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1072 | let body = InstrumentSurface::new("Settings") |
| 1073 | .actions(vec![ |
| 1074 | hint("type", "filter"), |
| 1075 | move_hint(theme), |
| 1076 | hint("Enter", "edit"), |
| 1077 | hint("PgUp/PgDn", "scroll"), |
| 1078 | hint("Esc", "close"), |
| 1079 | ]) |
| 1080 | .draw(area, buf, theme); |
| 1081 | let nav = row(body, 0); |
| 1082 | let categories = [ |
| 1083 | "Appearance", |
| 1084 | "Models & providers", |
| 1085 | "Fleet", |
| 1086 | "Work", |
| 1087 | "Tools & MCP", |
| 1088 | "Trust", |
| 1089 | "Motion", |
| 1090 | "Advanced", |
| 1091 | ]; |
| 1092 | let mut x = nav.x; |
| 1093 | for (index, category) in categories |
| 1094 | .into_iter() |
| 1095 | .enumerate() |
| 1096 | .filter(|_| !nav.is_empty()) |
| 1097 | { |
| 1098 | let shown = format!(" {category} "); |
| 1099 | let width = u16::try_from(text::width(&shown)).unwrap_or(u16::MAX); |
| 1100 | if x.saturating_add(width).saturating_add(2) > nav.right() { |
| 1101 | if x < nav.right() { |
| 1102 | buf.set_stringn( |
| 1103 | nav.right().saturating_sub(2), |
| 1104 | nav.y, |
| 1105 | if theme.ascii() { " >" } else { " ›" }, |
| 1106 | 2, |
| 1107 | theme.fg(Role::Hint), |
| 1108 | ); |
| 1109 | } |
| 1110 | break; |
| 1111 | } |
| 1112 | if !nav.is_empty() { |
| 1113 | buf.set_stringn( |
| 1114 | x, |
| 1115 | nav.y, |
| 1116 | &shown, |
| 1117 | usize::from(width), |
| 1118 | if index == 0 { |
| 1119 | selected(theme) |
| 1120 | } else { |
| 1121 | theme.fg(Role::Muted) |
| 1122 | }, |
| 1123 | ); |
| 1124 | } |
| 1125 | x = x.saturating_add(width); |
| 1126 | } |
| 1127 | line( |
| 1128 | " Search: type to filter (5/5)", |
| 1129 | Role::Muted, |
| 1130 | row(body, 1), |
| 1131 | buf, |
| 1132 | theme, |
| 1133 | ); |
| 1134 | let has_detail = body.width >= 100; |
| 1135 | let preview = u16::from(body.height >= 12); |
| 1136 | let band = if body.height >= 11 { |
| 1137 | if has_detail { 1 } else { 3 } |
| 1138 | } else { |
| 1139 | u16::from(body.height >= 5) |
| 1140 | }; |
| 1141 | let list_area = Rect::new( |
| 1142 | body.x, |
| 1143 | body.y.saturating_add(2).min(body.bottom()), |
| 1144 | body.width, |
| 1145 | body.height.saturating_sub(2 + preview + band), |
| 1146 | ); |
| 1147 | let groups_width = if has_detail && list_area.height >= 3 { |
| 1148 | 18 |
| 1149 | } else { |
| 1150 | 0 |
| 1151 | }; |
| 1152 | let detail_width = if has_detail { |
| 1153 | (list_area.width.saturating_mul(34) / 100).clamp(28, 44) |
| 1154 | } else { |
| 1155 | 0 |
| 1156 | }; |
| 1157 | let left_gap = u16::from(groups_width > 0); |
| 1158 | let right_gap = u16::from(detail_width > 0); |
| 1159 | let list = Rect::new( |
| 1160 | list_area.x + groups_width + left_gap, |
| 1161 | list_area.y, |
| 1162 | list_area |
| 1163 | .width |
| 1164 | .saturating_sub(groups_width + left_gap + detail_width + right_gap), |
| 1165 | list_area.height, |
| 1166 | ); |
| 1167 | if groups_width > 0 { |
| 1168 | for (index, group) in ["Display"].into_iter().enumerate() { |
| 1169 | line( |
| 1170 | group, |
| 1171 | if index == 0 { |
| 1172 | Role::Primary |
| 1173 | } else { |
| 1174 | Role::Muted |
| 1175 | }, |
| 1176 | row( |
| 1177 | Rect::new(list_area.x, list_area.y, groups_width, list_area.height), |
| 1178 | index as u16, |
| 1179 | ), |
| 1180 | buf, |
| 1181 | theme, |
| 1182 | ); |
| 1183 | } |
| 1184 | divider( |
| 1185 | Rect::new(list_area.x + groups_width, list_area.y, 1, list_area.height), |
| 1186 | buf, |
| 1187 | theme, |
| 1188 | ); |
| 1189 | } |
| 1190 | let settings = [ |
| 1191 | ("Theme", "underwater", "‹ ›"), |
| 1192 | ("Thinking background highlight", "true", "[x]"), |
| 1193 | ("Contextual tips", "true", "[x]"), |
| 1194 | ("Inline file changes", "full", "‹ ›"), |
| 1195 | ("Transcript spacing", "comfortable", "‹ ›"), |
| 1196 | ]; |
| 1197 | let caption = row(list, 0); |
| 1198 | if !caption.is_empty() { |
| 1199 | buf.set_line( |
| 1200 | caption.x, |
| 1201 | caption.y, |
| 1202 | &Line::styled( |
| 1203 | " Display", |
| 1204 | theme |
| 1205 | .fg(Role::Hint) |
| 1206 | .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), |
| 1207 | ), |
| 1208 | caption.width, |
| 1209 | ); |
| 1210 | } |
| 1211 | let scope_width = if list.width >= 60 { 7 } else { 0 }; |
| 1212 | let available = usize::from(list.width).saturating_sub(11 + scope_width); |
| 1213 | let key_width = 19.min(available.saturating_sub(10)).max(19.min(available)); |
| 1214 | let value_width = available.saturating_sub(key_width).min(44); |
| 1215 | for (index, (key, value, affordance)) in settings.into_iter().enumerate() { |
| 1216 | let target = row(list, index as u16 + 1); |
| 1217 | if target.is_empty() { |
| 1218 | break; |
| 1219 | } |
| 1220 | let chosen = index == 0; |
| 1221 | let pointer = if chosen { |
| 1222 | if theme.ascii() { ">" } else { "❯" } |
| 1223 | } else { |
| 1224 | " " |
| 1225 | }; |
| 1226 | let affordance = if theme.ascii() && affordance == "‹ ›" { |
| 1227 | "< >" |
| 1228 | } else { |
| 1229 | affordance |
| 1230 | }; |
| 1231 | let content = format!( |
| 1232 | "{pointer}{} {} {affordance:<3} {}", |
| 1233 | text::pad(key, key_width, theme.ascii()), |
| 1234 | text::pad(value, value_width, theme.ascii()), |
| 1235 | if scope_width > 0 { "user" } else { "" } |
| 1236 | ); |
| 1237 | buf.set_line( |
| 1238 | target.x, |
| 1239 | target.y, |
| 1240 | &Line::styled( |
| 1241 | content, |
| 1242 | if chosen { |
| 1243 | selected(theme) |
| 1244 | } else { |
| 1245 | theme.fg(Role::Foreground) |
| 1246 | }, |
| 1247 | ), |
| 1248 | target.width, |
| 1249 | ); |
| 1250 | } |
| 1251 | if detail_width > 0 { |
| 1252 | let detail = Rect::new(list.right() + 1, list.y, detail_width, list.height); |
| 1253 | divider(Rect::new(list.right(), list.y, 1, list.height), buf, theme); |
| 1254 | line("Theme", Role::Primary, row(detail, 0), buf, theme); |
| 1255 | line("theme", Role::Dim, row(detail, 1), buf, theme); |
| 1256 | for (index, (name, value)) in [ |
| 1257 | ("current", "underwater"), |
| 1258 | ("saved", "underwater"), |
| 1259 | ("startup", "underwater"), |
| 1260 | ("source", "settings.toml"), |
| 1261 | ("scope", "user"), |
| 1262 | ("apply", "applies on save"), |
| 1263 | ("kind", "choice"), |
| 1264 | ] |
| 1265 | .into_iter() |
| 1266 | .enumerate() |
| 1267 | { |
| 1268 | line( |
| 1269 | &format!("{name:<10}{value}"), |
| 1270 | Role::Foreground, |
| 1271 | row(detail, index as u16 + 3), |
| 1272 | buf, |
| 1273 | theme, |
| 1274 | ); |
| 1275 | } |
| 1276 | } |
| 1277 | let band_area = Rect::new( |
| 1278 | body.x, |
| 1279 | list_area.bottom(), |
| 1280 | body.width, |
| 1281 | band.min(body.bottom().saturating_sub(list_area.bottom())), |
| 1282 | ); |
| 1283 | line( |
| 1284 | "Theme: underwater — Enter to edit", |
| 1285 | Role::Muted, |
| 1286 | row(band_area, 0), |
| 1287 | buf, |
| 1288 | theme, |
| 1289 | ); |
| 1290 | if band > 1 { |
| 1291 | line( |
| 1292 | "current: underwater saved: underwater startup: underwater", |
| 1293 | Role::Muted, |
| 1294 | row(band_area, 1), |
| 1295 | buf, |
| 1296 | theme, |
| 1297 | ); |
| 1298 | } |
| 1299 | if band > 2 { |
| 1300 | line( |
| 1301 | "source: settings.toml scope: user apply: applies on save", |
| 1302 | Role::Muted, |
| 1303 | row(band_area, 2), |
| 1304 | buf, |
| 1305 | theme, |
| 1306 | ); |
| 1307 | } |
| 1308 | if preview > 0 { |
| 1309 | super::posture::working_posture().paint( |
| 1310 | row(body, body.height.saturating_sub(1)), |
| 1311 | buf, |
| 1312 | theme, |
| 1313 | ); |
| 1314 | } |
| 1315 | } |
| 1316 | |
| 1317 | fn divider(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1318 | for y in area.y..area.bottom() { |
| 1319 | if area.width > 0 { |
| 1320 | buf[(area.x, y)] |
| 1321 | .set_symbol(if theme.ascii() { "|" } else { "│" }) |
| 1322 | .set_style(theme.fg(Role::Border)); |
| 1323 | } |
| 1324 | } |
| 1325 | } |
| 1326 | |
| 1327 | // work_surface/render/{mod,rows}.rs: reuse the extracted dock, never turn |
| 1328 | // its panels into unrelated modal cards. Full-size recipes show side placement. |
| 1329 | fn dock(panel: WorkbarPanel, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1330 | let area = area.intersection(buf.area); |
| 1331 | let dock = super::workbar::sample(panel).placement(crate::WorkbarPlacement::Right); |
| 1332 | let regions = dock.regions(area); |
| 1333 | if let Some(dock_area) = regions.dock { |
| 1334 | dock.placement(regions.placement) |
| 1335 | .paint(dock_area, buf, theme); |
| 1336 | } |
| 1337 | if !regions.content.is_empty() { |
| 1338 | let content = regions.content; |
| 1339 | line("USER", Role::Muted, row(content, 1), buf, theme); |
| 1340 | line( |
| 1341 | "Review the changes and continue.", |
| 1342 | Role::Foreground, |
| 1343 | row(content, 2), |
| 1344 | buf, |
| 1345 | theme, |
| 1346 | ); |
| 1347 | line("ASSISTANT", Role::Muted, row(content, 5), buf, theme); |
| 1348 | line( |
| 1349 | "I’m checking the selected files and the running tasks.", |
| 1350 | Role::Foreground, |
| 1351 | row(content, 6), |
| 1352 | buf, |
| 1353 | theme, |
| 1354 | ); |
| 1355 | if content.height > 12 { |
| 1356 | let footer = Rect::new(content.x, content.bottom() - 5, content.width, 5); |
| 1357 | crate::NativeComposer::new("Keep the changes focused.") |
| 1358 | .focused(true) |
| 1359 | .can_submit(true) |
| 1360 | .submit_hint("Enter send") |
| 1361 | .paint(Rect::new(footer.x, footer.y, footer.width, 3), buf, theme); |
| 1362 | super::posture::working_posture().paint(row(footer, 3), buf, theme); |
| 1363 | super::posture::working_metrics().paint(row(footer, 4), buf, theme); |
| 1364 | } |
| 1365 | } |
| 1366 | } |
| 1367 | fn fleet(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1368 | dock(WorkbarPanel::Fleet, area, buf, theme); |
| 1369 | } |
| 1370 | fn jobs(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1371 | dock(WorkbarPanel::Jobs, area, buf, theme); |
| 1372 | } |
| 1373 | fn changed_files(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1374 | dock(WorkbarPanel::Files, area, buf, theme); |
| 1375 | } |
| 1376 | fn context(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1377 | dock(WorkbarPanel::Context, area, buf, theme); |
| 1378 | } |
| 1379 | fn git(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1380 | dock(WorkbarPanel::Git, area, buf, theme); |
| 1381 | } |
| 1382 | fn cost(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1383 | dock(WorkbarPanel::Cost, area, buf, theme); |
| 1384 | } |
| 1385 | |
| 1386 | fn rich_composer_facts(theme: &Theme) -> crate::NativeComposerFrame<'static> { |
| 1387 | use crate::{ |
| 1388 | NativeComposerDensity, NativeComposerFrame, NativeComposerMenu, NativeComposerMenuItem, |
| 1389 | NativeComposerStyles, |
| 1390 | }; |
| 1391 | let background = theme.tui_ground(crate::TuiGround::Composer); |
| 1392 | let plain = theme.fg(crate::Role::Foreground); |
| 1393 | let muted = theme.fg(crate::Role::Muted); |
| 1394 | let primary = theme.fg(crate::Role::Primary); |
| 1395 | let mark = if theme.ascii() { ">" } else { "▸" }; |
| 1396 | NativeComposerFrame { |
| 1397 | text: if theme.ascii() { |
| 1398 | "Review the source and keep the change focused" |
| 1399 | } else { |
| 1400 | "Review 鲸鱼 cafe\u{0301} and keep the change focused" |
| 1401 | } |
| 1402 | .into(), |
| 1403 | cursor: 12, |
| 1404 | selection: Some((7, 12)), |
| 1405 | placeholder: Line::styled("Write a task or use /.", muted), |
| 1406 | enclosed: true, |
| 1407 | density: NativeComposerDensity::Comfortable, |
| 1408 | history_search: false, |
| 1409 | focused: true, |
| 1410 | can_submit: true, |
| 1411 | ascii: theme.ascii(), |
| 1412 | top_title: None, |
| 1413 | top_right: Some(Line::styled( |
| 1414 | " Builder ", |
| 1415 | theme |
| 1416 | .fg(crate::Role::Attention) |
| 1417 | .add_modifier(Modifier::BOLD), |
| 1418 | )), |
| 1419 | hint: Some(Line::styled(" Enter chooses; Esc closes menu ", muted)), |
| 1420 | quiet_hint: None, |
| 1421 | styles: NativeComposerStyles { |
| 1422 | background, |
| 1423 | border: primary, |
| 1424 | quiet_border: theme.fg(crate::Role::Border), |
| 1425 | text: plain, |
| 1426 | selection: plain.patch(theme.bg(crate::Role::Selected)).add_modifier( |
| 1427 | if theme.color(crate::Role::Foreground).is_none() { |
| 1428 | Modifier::REVERSED |
| 1429 | } else { |
| 1430 | Modifier::empty() |
| 1431 | }, |
| 1432 | ), |
| 1433 | prompt: primary, |
| 1434 | submit: theme |
| 1435 | .tui_ink(crate::TuiInk::Info) |
| 1436 | .add_modifier(Modifier::BOLD), |
| 1437 | }, |
| 1438 | menu: NativeComposerMenu { |
| 1439 | selected: 1, |
| 1440 | reserved_rows: 3, |
| 1441 | pointer_rows: true, |
| 1442 | items: vec![ |
| 1443 | NativeComposerMenuItem::Columns { |
| 1444 | name: "/review".into(), |
| 1445 | description: "Review the current changes and show findings".into(), |
| 1446 | prefix: Span::styled(" ", plain), |
| 1447 | marker: Span::styled(" ", plain), |
| 1448 | name_style: plain, |
| 1449 | description_style: muted, |
| 1450 | }, |
| 1451 | NativeComposerMenuItem::Columns { |
| 1452 | name: "/test or /check".into(), |
| 1453 | description: "Run focused checks and preserve their receipt".into(), |
| 1454 | prefix: Span::styled(" ", primary), |
| 1455 | marker: Span::styled(mark, primary), |
| 1456 | name_style: primary, |
| 1457 | description_style: plain, |
| 1458 | }, |
| 1459 | ], |
| 1460 | }, |
| 1461 | } |
| 1462 | } |
| 1463 | fn rich_composer(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1464 | rich_composer_facts(theme).paint(area, buf, theme); |
| 1465 | } |
| 1466 | fn rich_composer_search(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1467 | let mut composer = rich_composer_facts(theme); |
| 1468 | composer.text = "".into(); |
| 1469 | composer.cursor = 0; |
| 1470 | composer.selection = None; |
| 1471 | composer.history_search = true; |
| 1472 | composer.top_title = Some(Line::styled( |
| 1473 | " Search history ", |
| 1474 | theme.fg(crate::Role::Muted), |
| 1475 | )); |
| 1476 | composer.menu.items = vec![crate::NativeComposerMenuItem::Line(Line::styled( |
| 1477 | if theme.ascii() { |
| 1478 | "> Review this project" |
| 1479 | } else { |
| 1480 | "▸ Review this project" |
| 1481 | }, |
| 1482 | theme.fg(crate::Role::Foreground), |
| 1483 | ))]; |
| 1484 | composer.menu.selected = 0; |
| 1485 | composer.menu.reserved_rows = 1; |
| 1486 | composer.menu.pointer_rows = false; |
| 1487 | composer.paint(area, buf, theme); |
| 1488 | } |
| 1489 | |
| 1490 | pub(crate) fn entries() -> Vec<Entry> { |
| 1491 | [ |
| 1492 | ( |
| 1493 | "native-composer-rich-selection", |
| 1494 | 72, |
| 1495 | 9, |
| 1496 | rich_composer as fn(Rect, &mut Buffer, &Theme), |
| 1497 | ), |
| 1498 | ("native-composer-rich-search", 72, 7, rich_composer_search), |
| 1499 | ( |
| 1500 | "instrument-surface", |
| 1501 | 100, |
| 1502 | 24, |
| 1503 | instrument as fn(Rect, &mut Buffer, &Theme), |
| 1504 | ), |
| 1505 | ("session-list", 140, 8, session_list), |
| 1506 | ("view-sessions", 160, 28, sessions), |
| 1507 | ("view-sessions-narrow", 80, 24, sessions), |
| 1508 | ("view-sessions-compact", 44, 12, sessions), |
| 1509 | ("view-sessions-empty", 80, 12, sessions_empty), |
| 1510 | ("view-settings", 132, 28, settings), |
| 1511 | ("view-settings-narrow", 80, 24, settings), |
| 1512 | ("view-commands", 112, 28, commands), |
| 1513 | ("view-models", 132, 28, models), |
| 1514 | ("view-models-narrow", 80, 24, models), |
| 1515 | ("view-providers", 120, 28, providers), |
| 1516 | ("view-theme", 120, 28, themes), |
| 1517 | ("view-mode", 80, 16, modes), |
| 1518 | ("view-status", 90, 24, status_items), |
| 1519 | ("view-file-picker", 100, 24, files), |
| 1520 | ("view-fleet-dock", 120, 24, fleet), |
| 1521 | ("view-jobs-dock", 120, 24, jobs), |
| 1522 | ("view-files-dock", 120, 24, changed_files), |
| 1523 | ("view-context-dock", 120, 24, context), |
| 1524 | ("view-git-dock", 120, 24, git), |
| 1525 | ("view-cost-dock", 120, 24, cost), |
| 1526 | ] |
| 1527 | .into_iter() |
| 1528 | .map(|(name, width, height, draw)| Entry { |
| 1529 | name, |
| 1530 | width, |
| 1531 | height, |
| 1532 | draw, |
| 1533 | }) |
| 1534 | .collect() |
| 1535 | } |
| 1536 |