| 1 | //! Golden-buffer contract for the metrics line — the row under the posture |
| 2 | //! bar. |
| 3 | //! |
| 4 | //! Goldens live in `crates/tui/src/tui/goldens/infoline_{screen}_{w}x{h}.txt` |
| 5 | //! for the two screens (startup, work) at the blocker sizes. Re-bless by |
| 6 | //! deleting the golden and running with `CODEWHALE_BLESS_GOLDENS=1`. |
| 7 | |
| 8 | use ratatui::{Terminal, backend::TestBackend, layout::Rect}; |
| 9 | use unicode_width::UnicodeWidthStr; |
| 10 | |
| 11 | use super::{InfoLine, InfoSegment, InfoSegmentId, context_meter_hitbox, infoline_hitboxes}; |
| 12 | use codewhale_palette::{ChromeInk, UI_THEME, UiTheme}; |
| 13 | |
| 14 | /// The hint the live shell advertises, from the one binding module that owns |
| 15 | /// it — a fixture string here would let chrome and routing drift apart. |
| 16 | fn help_hint() -> String { |
| 17 | crate::tui::shell_key_routing::info_help_hint(codewhale_localization::Locale::En) |
| 18 | } |
| 19 | |
| 20 | const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)]; |
| 21 | |
| 22 | fn context(pct: u8) -> InfoSegment { |
| 23 | InfoSegment::new( |
| 24 | InfoSegmentId::Context, |
| 25 | "ctx", |
| 26 | format!("{pct}%"), |
| 27 | if pct >= 80 { |
| 28 | ChromeInk::Failure |
| 29 | } else { |
| 30 | ChromeInk::Info |
| 31 | }, |
| 32 | ) |
| 33 | } |
| 34 | |
| 35 | /// Approved startup screen: no route yet, no metrics yet. |
| 36 | fn startup_segments() -> Vec<InfoSegment> { |
| 37 | vec![ |
| 38 | InfoSegment::new( |
| 39 | InfoSegmentId::Model, |
| 40 | "", |
| 41 | "model not connected", |
| 42 | ChromeInk::Waiting, |
| 43 | ), |
| 44 | context(0), |
| 45 | ] |
| 46 | } |
| 47 | |
| 48 | /// Approved work screen: model, context, cost, then the session metrics. |
| 49 | fn work_segments() -> Vec<InfoSegment> { |
| 50 | vec![ |
| 51 | InfoSegment::new(InfoSegmentId::Model, "", "deepseek-v4", ChromeInk::Identity), |
| 52 | context(61), |
| 53 | InfoSegment::new(InfoSegmentId::Cost, "", "$0.42", ChromeInk::MetadataValue), |
| 54 | InfoSegment::new( |
| 55 | InfoSegmentId::Ttft, |
| 56 | "ttft", |
| 57 | "400ms", |
| 58 | ChromeInk::MetadataValue, |
| 59 | ), |
| 60 | InfoSegment::new( |
| 61 | InfoSegmentId::Rate, |
| 62 | "", |
| 63 | "38 tok/s", |
| 64 | ChromeInk::MetadataValue, |
| 65 | ), |
| 66 | InfoSegment::new( |
| 67 | InfoSegmentId::OutputTokens, |
| 68 | "↓", |
| 69 | "1.2K", |
| 70 | ChromeInk::MetadataValue, |
| 71 | ), |
| 72 | ] |
| 73 | } |
| 74 | |
| 75 | fn fixtures() -> Vec<(&'static str, Vec<InfoSegment>)> { |
| 76 | vec![("startup", startup_segments()), ("work", work_segments())] |
| 77 | } |
| 78 | |
| 79 | fn render_buffer(theme: &UiTheme, width: u16, segments: &[InfoSegment]) -> ratatui::buffer::Buffer { |
| 80 | let backend = TestBackend::new(width, 1); |
| 81 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 82 | let hint = help_hint(); |
| 83 | terminal |
| 84 | .draw(|frame| { |
| 85 | let info = InfoLine::new(theme, &hint, segments); |
| 86 | use ratatui::widgets::Widget; |
| 87 | Widget::render(info, frame.area(), frame.buffer_mut()); |
| 88 | }) |
| 89 | .expect("draw"); |
| 90 | terminal.backend().buffer().clone() |
| 91 | } |
| 92 | |
| 93 | fn render_row(theme: &UiTheme, width: u16, segments: &[InfoSegment]) -> String { |
| 94 | render_cells(theme, width, segments).concat() |
| 95 | } |
| 96 | |
| 97 | /// Per-cell symbols of one rendered row (the golden dump, before joining). |
| 98 | fn render_cells(theme: &UiTheme, width: u16, segments: &[InfoSegment]) -> Vec<String> { |
| 99 | render_buffer(theme, width, segments) |
| 100 | .content() |
| 101 | .iter() |
| 102 | .map(|cell| cell.symbol().to_string()) |
| 103 | .collect() |
| 104 | } |
| 105 | |
| 106 | fn golden_path(name: &str) -> std::path::PathBuf { |
| 107 | std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 108 | .join("src/tui/goldens") |
| 109 | .join(format!("{name}.txt")) |
| 110 | } |
| 111 | |
| 112 | fn bless(name: &str, text: &str) { |
| 113 | let path = golden_path(name); |
| 114 | if let Some(parent) = path.parent() { |
| 115 | std::fs::create_dir_all(parent).expect("create goldens dir"); |
| 116 | } |
| 117 | std::fs::write(path, text).expect("write golden"); |
| 118 | } |
| 119 | |
| 120 | fn golden_text(name: &str) -> Option<String> { |
| 121 | // Normalize to LF; a Windows checkout can hand us CRLF while `render_row` |
| 122 | // always terminates with LF. Cell symbols never contain CR. |
| 123 | std::fs::read_to_string(golden_path(name)) |
| 124 | .ok() |
| 125 | .map(|text| text.replace("\r\n", "\n")) |
| 126 | } |
| 127 | |
| 128 | #[test] |
| 129 | fn infoline_matches_goldens_at_blocker_sizes() { |
| 130 | for (screen, segments) in fixtures() { |
| 131 | for (w, h) in BLOCKER_SIZES { |
| 132 | let name = format!("infoline_{screen}_{w}x{h}"); |
| 133 | let rendered = render_row(&UI_THEME, w, &segments); |
| 134 | let rendered = format!("{rendered}\n"); |
| 135 | match golden_text(&name) { |
| 136 | Some(expected) => { |
| 137 | assert_eq!( |
| 138 | rendered, expected, |
| 139 | "info-line golden drift at {name}; re-bless only with an approved design change" |
| 140 | ); |
| 141 | } |
| 142 | None => { |
| 143 | if std::env::var("CODEWHALE_BLESS_GOLDENS").is_ok() { |
| 144 | bless(&name, &rendered); |
| 145 | } else { |
| 146 | panic!( |
| 147 | "missing golden {name}; run with CODEWHALE_BLESS_GOLDENS=1 to write it" |
| 148 | ); |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// The row states no time of day, carries no wordmark, and no longer names |
| 157 | /// the repository or branch: the launch header and the git bottom view own |
| 158 | /// those (2026-09-02). |
| 159 | #[test] |
| 160 | fn infoline_is_model_context_and_metrics_only() { |
| 161 | for (_, segments) in fixtures() { |
| 162 | for (w, _h) in BLOCKER_SIZES { |
| 163 | let row = render_row(&UI_THEME, w, &segments); |
| 164 | assert!( |
| 165 | !row.contains(':'), |
| 166 | "{w}: the metrics line carries no clock: {row:?}" |
| 167 | ); |
| 168 | assert!( |
| 169 | !row.contains("CODEWHALE") && !row.contains("codewhale"), |
| 170 | "{w}: no wordmark or repository on this row: {row:?}" |
| 171 | ); |
| 172 | assert!(!row.contains('⑂'), "{w}: no branch on this row: {row:?}"); |
| 173 | } |
| 174 | } |
| 175 | let work = render_row(&UI_THEME, 160, &work_segments()); |
| 176 | assert!( |
| 177 | work.starts_with("deepseek-v4 ctx 61% $0.42 ttft 400ms 38 tok/s ↓ 1.2K "), |
| 178 | "{work:?}" |
| 179 | ); |
| 180 | assert!(work.trim_end().ends_with("/help"), "{work:?}"); |
| 181 | } |
| 182 | |
| 183 | /// Secondary counts and help yield before performance readings and cost. The model and `ctx NN%` are the floor at every width. |
| 184 | #[test] |
| 185 | fn infoline_sheds_tokens_then_help_then_rate_then_ttft_then_cost() { |
| 186 | let segments = work_segments(); |
| 187 | // The narrowest row that still shows a thing. A thing that sheds earlier |
| 188 | // needs a wider row to survive, so these strictly decrease down the |
| 189 | // declared order. |
| 190 | let narrowest_showing = |needle: &str| -> u16 { |
| 191 | (24..=180u16) |
| 192 | .filter(|w| render_row(&UI_THEME, *w, &segments).contains(needle)) |
| 193 | .min() |
| 194 | .unwrap_or_else(|| panic!("{needle} never painted at any width")) |
| 195 | }; |
| 196 | let rate = narrowest_showing("tok/s"); |
| 197 | let ttft = narrowest_showing("ttft"); |
| 198 | let tokens = narrowest_showing("↓ 1.2K"); |
| 199 | let help = narrowest_showing("help"); |
| 200 | let cost = narrowest_showing("$0.42"); |
| 201 | assert!( |
| 202 | tokens > help && help > rate && rate > ttft && ttft > cost, |
| 203 | "shed order broke: rate@{rate} ttft@{ttft} tokens@{tokens} help@{help} cost@{cost}" |
| 204 | ); |
| 205 | for w in 24..=180u16 { |
| 206 | let row = render_row(&UI_THEME, w, &segments); |
| 207 | assert!( |
| 208 | row.contains("deepseek-v4") && row.contains("ctx 61%"), |
| 209 | "{w}: the model and the context reading never shed: {row:?}" |
| 210 | ); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | /// `tui.metrics_line = "compact"` (#5950) is the row after its first shed |
| 215 | /// rungs, at any width: output counts and help are gone before width is |
| 216 | /// consulted; selected TTFT/rate survive when they fit. Hitboxes follow the same |
| 217 | /// pass so a click still lands on what painted. |
| 218 | #[test] |
| 219 | fn infoline_compact_keeps_performance_readings_without_extra_rows() { |
| 220 | let segments = work_segments(); |
| 221 | let hint = help_hint(); |
| 222 | let compact_row = |width: u16| -> (String, Vec<InfoSegmentId>) { |
| 223 | let backend = TestBackend::new(width, 1); |
| 224 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 225 | let mut ids = Vec::new(); |
| 226 | terminal |
| 227 | .draw(|frame| { |
| 228 | let info = InfoLine::new(&UI_THEME, &hint, &segments).compact(true); |
| 229 | ids = infoline_hitboxes(&info, frame.area()) |
| 230 | .into_iter() |
| 231 | .map(|hitbox| hitbox.id) |
| 232 | .collect(); |
| 233 | use ratatui::widgets::Widget; |
| 234 | Widget::render(info, frame.area(), frame.buffer_mut()); |
| 235 | }) |
| 236 | .expect("draw"); |
| 237 | let row = terminal |
| 238 | .backend() |
| 239 | .buffer() |
| 240 | .content() |
| 241 | .iter() |
| 242 | .map(|cell| cell.symbol().to_string()) |
| 243 | .collect::<String>(); |
| 244 | (row, ids) |
| 245 | }; |
| 246 | let (wide, ids) = compact_row(160); |
| 247 | assert_eq!( |
| 248 | wide.trim_end(), |
| 249 | "deepseek-v4 ctx 61% $0.42 ttft 400ms 38 tok/s", |
| 250 | "compact keeps performance, route, context and price: {wide:?}" |
| 251 | ); |
| 252 | assert_eq!( |
| 253 | ids, |
| 254 | vec![ |
| 255 | InfoSegmentId::Model, |
| 256 | InfoSegmentId::Context, |
| 257 | InfoSegmentId::Cost, |
| 258 | InfoSegmentId::Ttft, |
| 259 | InfoSegmentId::Rate, |
| 260 | ] |
| 261 | ); |
| 262 | for w in 24..=180u16 { |
| 263 | let (row, _) = compact_row(w); |
| 264 | for gone in ["1.2K", "help"] { |
| 265 | assert!( |
| 266 | !row.contains(gone), |
| 267 | "{w}: compact never paints {gone}: {row:?}" |
| 268 | ); |
| 269 | } |
| 270 | assert!( |
| 271 | row.contains("deepseek-v4") && row.contains("ctx 61%"), |
| 272 | "{w}: the floor still never sheds: {row:?}" |
| 273 | ); |
| 274 | } |
| 275 | // The full row at the same width is the row the user had before. |
| 276 | assert!(render_row(&UI_THEME, 160, &segments).contains("tok/s")); |
| 277 | } |
| 278 | |
| 279 | /// At the 80% cap the context reading takes the error token — the caller |
| 280 | /// picks the ink, and the row paints it on both the label and the value. |
| 281 | #[test] |
| 282 | fn infoline_context_takes_the_error_token_at_eighty() { |
| 283 | let theme = &UI_THEME; |
| 284 | let failure = codewhale_palette::grammar::chrome_style(theme, ChromeInk::Failure) |
| 285 | .fg |
| 286 | .expect("failure ink has a colour"); |
| 287 | for (pct, expect_failure) in [(79u8, false), (80, true), (99, true)] { |
| 288 | let segments = vec![ |
| 289 | InfoSegment::new(InfoSegmentId::Model, "", "deepseek-v4", ChromeInk::Identity), |
| 290 | context(pct), |
| 291 | ]; |
| 292 | let buf = render_buffer(theme, 80, &segments); |
| 293 | let row = render_row(theme, 80, &segments); |
| 294 | let start = row.find("ctx").expect("context reading painted"); |
| 295 | let value_fg = buf[(u16::try_from(start + 4).unwrap(), 0)].fg; |
| 296 | let label_fg = buf[(u16::try_from(start).unwrap(), 0)].fg; |
| 297 | assert_eq!(value_fg == failure, expect_failure, "{pct}%: value ink"); |
| 298 | assert_eq!(label_fg == failure, expect_failure, "{pct}%: label ink"); |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | /// The hint must name a route that actually opens help in this shell. `F1` |
| 303 | /// is eaten by tmux and several emulators, bare `?` is composer text, and how |
| 304 | /// a terminal encodes `Ctrl+/` varies enough that printing it was a promise |
| 305 | /// the product could not keep. `/help` reaches the same view through the |
| 306 | /// composer in every terminal. |
| 307 | #[test] |
| 308 | fn infoline_help_hint_names_a_route_that_opens_help() { |
| 309 | let hint = help_hint(); |
| 310 | assert_eq!(hint, "/help", "a slash command names itself: {hint}"); |
| 311 | assert!(!hint.contains("F1"), "terminals eat F1: {hint}"); |
| 312 | assert!(!hint.starts_with('?'), "bare ? is composer text: {hint}"); |
| 313 | // The chord stays accepted for the terminals that do deliver it; it is |
| 314 | // only no longer what chrome promises. |
| 315 | let key = crossterm::event::KeyEvent::new( |
| 316 | crossterm::event::KeyCode::Char('/'), |
| 317 | crossterm::event::KeyModifiers::CONTROL, |
| 318 | ); |
| 319 | assert!(crate::tui::shell_key_routing::is_help_shortcut(&key)); |
| 320 | let row = render_row(&UI_THEME, 120, &work_segments()); |
| 321 | assert!(row.trim_end().ends_with(&hint), "pinned right: {row:?}"); |
| 322 | } |
| 323 | |
| 324 | /// Every recorded hitbox covers exactly the cells its segment painted, at |
| 325 | /// every width — the hitbox pass and the paint pass share one shed pass. |
| 326 | #[test] |
| 327 | fn infoline_hitboxes_match_painted_cells() { |
| 328 | let segments = work_segments(); |
| 329 | let hint = help_hint(); |
| 330 | for w in 24..=180u16 { |
| 331 | let area = Rect::new(0, 0, w, 1); |
| 332 | let info = InfoLine::new(&UI_THEME, &hint, &segments); |
| 333 | let hitboxes = infoline_hitboxes(&info, area); |
| 334 | let cells = render_cells(&UI_THEME, w, &segments); |
| 335 | for hitbox in &hitboxes { |
| 336 | let segment = segments.iter().find(|s| s.id == hitbox.id).unwrap(); |
| 337 | let painted: String = cells |
| 338 | [usize::from(hitbox.area.x)..usize::from(hitbox.area.x + hitbox.area.width)] |
| 339 | .concat(); |
| 340 | let expected = if segment.label.is_empty() { |
| 341 | segment.value.clone() |
| 342 | } else { |
| 343 | format!("{} {}", segment.label, segment.value) |
| 344 | }; |
| 345 | assert!( |
| 346 | expected.starts_with(painted.trim_end()), |
| 347 | "{w}: {:?} hitbox {:?} covers {painted:?}, expected {expected:?}", |
| 348 | hitbox.id, |
| 349 | hitbox.area |
| 350 | ); |
| 351 | } |
| 352 | // No two hitboxes overlap. |
| 353 | for (i, a) in hitboxes.iter().enumerate() { |
| 354 | for b in &hitboxes[i + 1..] { |
| 355 | assert!( |
| 356 | a.area.right() <= b.area.x || b.area.right() <= a.area.x, |
| 357 | "{w}: hitboxes overlap: {a:?} {b:?}" |
| 358 | ); |
| 359 | } |
| 360 | } |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | /// The context reading's hitbox is exactly the painted `ctx NN%` span. |
| 365 | #[test] |
| 366 | fn context_meter_hitbox_covers_exactly_the_painted_reading() { |
| 367 | let segments = work_segments(); |
| 368 | let hint = help_hint(); |
| 369 | for w in 24..=180u16 { |
| 370 | let area = Rect::new(0, 0, w, 1); |
| 371 | let info = InfoLine::new(&UI_THEME, &hint, &segments); |
| 372 | let hitbox = context_meter_hitbox(&info, area).expect("the reading never sheds"); |
| 373 | let cells = render_cells(&UI_THEME, w, &segments); |
| 374 | let painted: String = |
| 375 | cells[usize::from(hitbox.x)..usize::from(hitbox.x + hitbox.width)].concat(); |
| 376 | assert!( |
| 377 | "ctx 61%".starts_with(painted.trim_end()), |
| 378 | "{w}: context hitbox {hitbox:?} covers {painted:?}" |
| 379 | ); |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | /// ASCII-safe mode projects every glyph to a single-width ASCII cell. |
| 384 | #[test] |
| 385 | fn infoline_ascii_safe_has_no_wide_or_unsupported_glyphs() { |
| 386 | let segments = work_segments(); |
| 387 | let hint = help_hint(); |
| 388 | for (w, _) in BLOCKER_SIZES { |
| 389 | let area = Rect::new(0, 0, w, 1); |
| 390 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 391 | let info = InfoLine::new(&UI_THEME, &hint, &segments).ascii_safe(true); |
| 392 | ratatui::widgets::Widget::render(info, area, &mut buf); |
| 393 | for x in 0..w { |
| 394 | let symbol = buf[(x, 0)].symbol(); |
| 395 | assert!(symbol.is_ascii(), "{w}: cell {x} {symbol:?} is not ASCII"); |
| 396 | assert_eq!( |
| 397 | symbol.width(), |
| 398 | 1, |
| 399 | "{w}: cell {x} {symbol:?} is not one cell" |
| 400 | ); |
| 401 | } |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | /// Hover and degenerate sizes never panic, and hover only brightens the |
| 406 | /// model — the one segment with an action. |
| 407 | #[test] |
| 408 | fn infoline_hover_and_narrow_do_not_panic() { |
| 409 | let segments = work_segments(); |
| 410 | let hint = help_hint(); |
| 411 | for (w, h) in [(0u16, 0u16), (1, 1), (5, 1), (24, 1), (300, 1)] { |
| 412 | let area = Rect::new(0, 0, w, h); |
| 413 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 414 | let info = InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Model)); |
| 415 | ratatui::widgets::Widget::render(info, area, &mut buf); |
| 416 | let info = InfoLine::new(&UI_THEME, &hint, &segments); |
| 417 | let _ = infoline_hitboxes(&info, area); |
| 418 | let _ = context_meter_hitbox(&info, area); |
| 419 | } |
| 420 | let area = Rect::new(0, 0, 120, 1); |
| 421 | let mut plain = ratatui::buffer::Buffer::empty(area); |
| 422 | ratatui::widgets::Widget::render(InfoLine::new(&UI_THEME, &hint, &segments), area, &mut plain); |
| 423 | let mut hovered = ratatui::buffer::Buffer::empty(area); |
| 424 | ratatui::widgets::Widget::render( |
| 425 | InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Model)), |
| 426 | area, |
| 427 | &mut hovered, |
| 428 | ); |
| 429 | assert_ne!(plain[(0, 0)].modifier, hovered[(0, 0)].modifier); |
| 430 | let ctx_x = u16::try_from(render_row(&UI_THEME, 120, &segments).find("ctx").unwrap()).unwrap(); |
| 431 | assert_eq!(plain[(ctx_x, 0)], hovered[(ctx_x, 0)]); |
| 432 | } |
| 433 | |
| 434 | /// Slice G: the context reading owns the inspector click action, so it |
| 435 | /// brightens on hover exactly like the model segment; status-only facts |
| 436 | /// (cost) never do. |
| 437 | #[test] |
| 438 | fn infoline_context_hover_brightens_only_the_context_reading() { |
| 439 | let segments = work_segments(); |
| 440 | let hint = help_hint(); |
| 441 | let area = Rect::new(0, 0, 120, 1); |
| 442 | let mut plain = ratatui::buffer::Buffer::empty(area); |
| 443 | ratatui::widgets::Widget::render(InfoLine::new(&UI_THEME, &hint, &segments), area, &mut plain); |
| 444 | let mut hovered = ratatui::buffer::Buffer::empty(area); |
| 445 | ratatui::widgets::Widget::render( |
| 446 | InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Context)), |
| 447 | area, |
| 448 | &mut hovered, |
| 449 | ); |
| 450 | let row = render_row(&UI_THEME, 120, &segments); |
| 451 | // Hover feedback lands on the value cells (`61%`); the dim label prefix |
| 452 | // (`ctx`) keeps its reading ink, mirroring the model segment's probe. |
| 453 | let ctx_x = u16::try_from(row.find("61%").unwrap()).unwrap(); |
| 454 | assert_ne!( |
| 455 | plain[(ctx_x, 0)].modifier, |
| 456 | hovered[(ctx_x, 0)].modifier, |
| 457 | "hovered context reading must respond visibly" |
| 458 | ); |
| 459 | // Model (actionable but not hovered) and cost (status-only) stay clean. |
| 460 | assert_eq!(plain[(0, 0)], hovered[(0, 0)]); |
| 461 | let cost_x = u16::try_from(row.find("$0.42").unwrap()).unwrap(); |
| 462 | assert_eq!(plain[(cost_x, 0)], hovered[(cost_x, 0)]); |
| 463 | } |
| 464 |