| 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 | // Cache survives compact and is the first performance reading to shed |
| 279 | // when the row is narrow (#6565). |
| 280 | let mut with_cache = segments.clone(); |
| 281 | with_cache.insert( |
| 282 | 3, |
| 283 | InfoSegment::new( |
| 284 | InfoSegmentId::Cache, |
| 285 | "cache", |
| 286 | "85%", |
| 287 | ChromeInk::MetadataValue, |
| 288 | ), |
| 289 | ); |
| 290 | let wide_cache = render_row_compact(160, &with_cache); |
| 291 | assert!(wide_cache.contains("cache 85%"), "{wide_cache:?}"); |
| 292 | assert!( |
| 293 | !wide_cache.contains("1.2K") && !wide_cache.contains("help"), |
| 294 | "{wide_cache:?}" |
| 295 | ); |
| 296 | let narrow_cache = render_row_compact(40, &with_cache); |
| 297 | assert!(!narrow_cache.contains("cache"), "{narrow_cache:?}"); |
| 298 | assert!( |
| 299 | narrow_cache.contains("deepseek-v4") && narrow_cache.contains("ctx 61%"), |
| 300 | "{narrow_cache:?}" |
| 301 | ); |
| 302 | } |
| 303 | |
| 304 | fn render_row_compact(width: u16, segments: &[InfoSegment]) -> String { |
| 305 | let backend = TestBackend::new(width, 1); |
| 306 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 307 | terminal |
| 308 | .draw(|frame| { |
| 309 | let hint = help_hint(); |
| 310 | let info = InfoLine::new(&UI_THEME, &hint, segments).compact(true); |
| 311 | use ratatui::widgets::Widget; |
| 312 | Widget::render(info, frame.area(), frame.buffer_mut()); |
| 313 | }) |
| 314 | .expect("draw"); |
| 315 | terminal |
| 316 | .backend() |
| 317 | .buffer() |
| 318 | .content() |
| 319 | .iter() |
| 320 | .map(|cell| cell.symbol().to_string()) |
| 321 | .collect() |
| 322 | } |
| 323 | |
| 324 | /// At the 80% cap the context reading takes the error token — the caller |
| 325 | /// picks the ink, and the row paints it on both the label and the value. |
| 326 | #[test] |
| 327 | fn infoline_context_takes_the_error_token_at_eighty() { |
| 328 | let theme = &UI_THEME; |
| 329 | let failure = codewhale_palette::grammar::chrome_style(theme, ChromeInk::Failure) |
| 330 | .fg |
| 331 | .expect("failure ink has a colour"); |
| 332 | for (pct, expect_failure) in [(79u8, false), (80, true), (99, true)] { |
| 333 | let segments = vec![ |
| 334 | InfoSegment::new(InfoSegmentId::Model, "", "deepseek-v4", ChromeInk::Identity), |
| 335 | context(pct), |
| 336 | ]; |
| 337 | let buf = render_buffer(theme, 80, &segments); |
| 338 | let row = render_row(theme, 80, &segments); |
| 339 | let start = row.find("ctx").expect("context reading painted"); |
| 340 | let value_fg = buf[(u16::try_from(start + 4).unwrap(), 0)].fg; |
| 341 | let label_fg = buf[(u16::try_from(start).unwrap(), 0)].fg; |
| 342 | assert_eq!(value_fg == failure, expect_failure, "{pct}%: value ink"); |
| 343 | assert_eq!(label_fg == failure, expect_failure, "{pct}%: label ink"); |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | /// The hint must name a route that actually opens help in this shell. `F1` |
| 348 | /// is eaten by tmux and several emulators, bare `?` is composer text, and how |
| 349 | /// a terminal encodes `Ctrl+/` varies enough that printing it was a promise |
| 350 | /// the product could not keep. `/help` reaches the same view through the |
| 351 | /// composer in every terminal. |
| 352 | #[test] |
| 353 | fn infoline_help_hint_names_a_route_that_opens_help() { |
| 354 | let hint = help_hint(); |
| 355 | assert_eq!(hint, "/help", "a slash command names itself: {hint}"); |
| 356 | assert!(!hint.contains("F1"), "terminals eat F1: {hint}"); |
| 357 | assert!(!hint.starts_with('?'), "bare ? is composer text: {hint}"); |
| 358 | // The chord stays accepted for the terminals that do deliver it; it is |
| 359 | // only no longer what chrome promises. |
| 360 | let key = crossterm::event::KeyEvent::new( |
| 361 | crossterm::event::KeyCode::Char('/'), |
| 362 | crossterm::event::KeyModifiers::CONTROL, |
| 363 | ); |
| 364 | assert!(crate::tui::shell_key_routing::is_help_shortcut(&key)); |
| 365 | let row = render_row(&UI_THEME, 120, &work_segments()); |
| 366 | assert!(row.trim_end().ends_with(&hint), "pinned right: {row:?}"); |
| 367 | } |
| 368 | |
| 369 | /// Every recorded hitbox covers exactly the cells its segment painted, at |
| 370 | /// every width — the hitbox pass and the paint pass share one shed pass. |
| 371 | #[test] |
| 372 | fn infoline_hitboxes_match_painted_cells() { |
| 373 | let segments = work_segments(); |
| 374 | let hint = help_hint(); |
| 375 | for w in 24..=180u16 { |
| 376 | let area = Rect::new(0, 0, w, 1); |
| 377 | let info = InfoLine::new(&UI_THEME, &hint, &segments); |
| 378 | let hitboxes = infoline_hitboxes(&info, area); |
| 379 | let cells = render_cells(&UI_THEME, w, &segments); |
| 380 | for hitbox in &hitboxes { |
| 381 | let segment = segments.iter().find(|s| s.id == hitbox.id).unwrap(); |
| 382 | let painted: String = cells |
| 383 | [usize::from(hitbox.area.x)..usize::from(hitbox.area.x + hitbox.area.width)] |
| 384 | .concat(); |
| 385 | let expected = if segment.label.is_empty() { |
| 386 | segment.value.clone() |
| 387 | } else { |
| 388 | format!("{} {}", segment.label, segment.value) |
| 389 | }; |
| 390 | assert!( |
| 391 | expected.starts_with(painted.trim_end()), |
| 392 | "{w}: {:?} hitbox {:?} covers {painted:?}, expected {expected:?}", |
| 393 | hitbox.id, |
| 394 | hitbox.area |
| 395 | ); |
| 396 | } |
| 397 | // No two hitboxes overlap. |
| 398 | for (i, a) in hitboxes.iter().enumerate() { |
| 399 | for b in &hitboxes[i + 1..] { |
| 400 | assert!( |
| 401 | a.area.right() <= b.area.x || b.area.right() <= a.area.x, |
| 402 | "{w}: hitboxes overlap: {a:?} {b:?}" |
| 403 | ); |
| 404 | } |
| 405 | } |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | /// The context reading's hitbox is exactly the painted `ctx NN%` span. |
| 410 | #[test] |
| 411 | fn context_meter_hitbox_covers_exactly_the_painted_reading() { |
| 412 | let segments = work_segments(); |
| 413 | let hint = help_hint(); |
| 414 | for w in 24..=180u16 { |
| 415 | let area = Rect::new(0, 0, w, 1); |
| 416 | let info = InfoLine::new(&UI_THEME, &hint, &segments); |
| 417 | let hitbox = context_meter_hitbox(&info, area).expect("the reading never sheds"); |
| 418 | let cells = render_cells(&UI_THEME, w, &segments); |
| 419 | let painted: String = |
| 420 | cells[usize::from(hitbox.x)..usize::from(hitbox.x + hitbox.width)].concat(); |
| 421 | assert!( |
| 422 | "ctx 61%".starts_with(painted.trim_end()), |
| 423 | "{w}: context hitbox {hitbox:?} covers {painted:?}" |
| 424 | ); |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | /// ASCII-safe mode projects every glyph to a single-width ASCII cell. |
| 429 | #[test] |
| 430 | fn infoline_ascii_safe_has_no_wide_or_unsupported_glyphs() { |
| 431 | let segments = work_segments(); |
| 432 | let hint = help_hint(); |
| 433 | for (w, _) in BLOCKER_SIZES { |
| 434 | let area = Rect::new(0, 0, w, 1); |
| 435 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 436 | let info = InfoLine::new(&UI_THEME, &hint, &segments).ascii_safe(true); |
| 437 | ratatui::widgets::Widget::render(info, area, &mut buf); |
| 438 | for x in 0..w { |
| 439 | let symbol = buf[(x, 0)].symbol(); |
| 440 | assert!(symbol.is_ascii(), "{w}: cell {x} {symbol:?} is not ASCII"); |
| 441 | assert_eq!( |
| 442 | symbol.width(), |
| 443 | 1, |
| 444 | "{w}: cell {x} {symbol:?} is not one cell" |
| 445 | ); |
| 446 | } |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | /// Hover and degenerate sizes never panic, and hover only brightens the |
| 451 | /// model — the one segment with an action. |
| 452 | #[test] |
| 453 | fn infoline_hover_and_narrow_do_not_panic() { |
| 454 | let segments = work_segments(); |
| 455 | let hint = help_hint(); |
| 456 | for (w, h) in [(0u16, 0u16), (1, 1), (5, 1), (24, 1), (300, 1)] { |
| 457 | let area = Rect::new(0, 0, w, h); |
| 458 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 459 | let info = InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Model)); |
| 460 | ratatui::widgets::Widget::render(info, area, &mut buf); |
| 461 | let info = InfoLine::new(&UI_THEME, &hint, &segments); |
| 462 | let _ = infoline_hitboxes(&info, area); |
| 463 | let _ = context_meter_hitbox(&info, area); |
| 464 | } |
| 465 | let area = Rect::new(0, 0, 120, 1); |
| 466 | let mut plain = ratatui::buffer::Buffer::empty(area); |
| 467 | ratatui::widgets::Widget::render(InfoLine::new(&UI_THEME, &hint, &segments), area, &mut plain); |
| 468 | let mut hovered = ratatui::buffer::Buffer::empty(area); |
| 469 | ratatui::widgets::Widget::render( |
| 470 | InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Model)), |
| 471 | area, |
| 472 | &mut hovered, |
| 473 | ); |
| 474 | assert_ne!(plain[(0, 0)].modifier, hovered[(0, 0)].modifier); |
| 475 | let ctx_x = u16::try_from(render_row(&UI_THEME, 120, &segments).find("ctx").unwrap()).unwrap(); |
| 476 | assert_eq!(plain[(ctx_x, 0)], hovered[(ctx_x, 0)]); |
| 477 | } |
| 478 | |
| 479 | /// Slice G: the context reading owns the inspector click action, so it |
| 480 | /// brightens on hover exactly like the model segment; status-only facts |
| 481 | /// (cost) never do. |
| 482 | #[test] |
| 483 | fn infoline_context_hover_brightens_only_the_context_reading() { |
| 484 | let segments = work_segments(); |
| 485 | let hint = help_hint(); |
| 486 | let area = Rect::new(0, 0, 120, 1); |
| 487 | let mut plain = ratatui::buffer::Buffer::empty(area); |
| 488 | ratatui::widgets::Widget::render(InfoLine::new(&UI_THEME, &hint, &segments), area, &mut plain); |
| 489 | let mut hovered = ratatui::buffer::Buffer::empty(area); |
| 490 | ratatui::widgets::Widget::render( |
| 491 | InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Context)), |
| 492 | area, |
| 493 | &mut hovered, |
| 494 | ); |
| 495 | let row = render_row(&UI_THEME, 120, &segments); |
| 496 | // Hover feedback lands on the value cells (`61%`); the dim label prefix |
| 497 | // (`ctx`) keeps its reading ink, mirroring the model segment's probe. |
| 498 | let ctx_x = u16::try_from(row.find("61%").unwrap()).unwrap(); |
| 499 | assert_ne!( |
| 500 | plain[(ctx_x, 0)].modifier, |
| 501 | hovered[(ctx_x, 0)].modifier, |
| 502 | "hovered context reading must respond visibly" |
| 503 | ); |
| 504 | // Model (actionable but not hovered) and cost (status-only) stay clean. |
| 505 | assert_eq!(plain[(0, 0)], hovered[(0, 0)]); |
| 506 | let cost_x = u16::try_from(row.find("$0.42").unwrap()).unwrap(); |
| 507 | assert_eq!(plain[(cost_x, 0)], hovered[(cost_x, 0)]); |
| 508 | } |
| 509 | |
| 510 | #[test] |
| 511 | fn infoline_preserves_every_live_chrome_ink_and_untouched_host_style() { |
| 512 | use ratatui::style::{Color, Modifier, Style}; |
| 513 | let theme = UiTheme { |
| 514 | status_working: Color::Rgb(1, 2, 3), |
| 515 | permission_ask: Color::Rgb(2, 3, 4), |
| 516 | permission_auto_review: Color::Rgb(3, 4, 5), |
| 517 | permission_full_access: Color::Rgb(4, 5, 6), |
| 518 | accent_action: Color::Rgb(5, 6, 7), |
| 519 | warning: Color::Rgb(6, 7, 8), |
| 520 | accent_primary: Color::Rgb(7, 8, 9), |
| 521 | text_soft: Color::Rgb(8, 9, 10), |
| 522 | text_muted: Color::Rgb(9, 10, 11), |
| 523 | text_hint: Color::Rgb(10, 11, 12), |
| 524 | text_dim: Color::Rgb(11, 12, 13), |
| 525 | error_fg: Color::Rgb(12, 13, 14), |
| 526 | ..UI_THEME |
| 527 | }; |
| 528 | for ink in [ |
| 529 | ChromeInk::Outcome, |
| 530 | ChromeInk::PermissionAsk, |
| 531 | ChromeInk::PermissionAutoReview, |
| 532 | ChromeInk::PermissionFullAccess, |
| 533 | ChromeInk::Waiting, |
| 534 | ChromeInk::Attention, |
| 535 | ChromeInk::Active, |
| 536 | ChromeInk::PolicyAct, |
| 537 | ChromeInk::PolicyPlan, |
| 538 | ChromeInk::PolicyOperate, |
| 539 | ChromeInk::Identity, |
| 540 | ChromeInk::Info, |
| 541 | ChromeInk::MetadataValue, |
| 542 | ChromeInk::Metadata, |
| 543 | ChromeInk::MetadataHint, |
| 544 | ChromeInk::MetadataDim, |
| 545 | ChromeInk::Failure, |
| 546 | ] { |
| 547 | let segments = [InfoSegment::new( |
| 548 | InfoSegmentId::Model, |
| 549 | "reading", |
| 550 | "value", |
| 551 | ink, |
| 552 | )]; |
| 553 | let area = Rect::new(3, 2, 32, 2); |
| 554 | let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 40, 5)); |
| 555 | let existing = Style::default() |
| 556 | .fg(super::source_theme(false) |
| 557 | .color(codewhale_ratatui::Role::Primary) |
| 558 | .unwrap()) |
| 559 | .bg(Color::Rgb(30, 40, 50)) |
| 560 | .add_modifier(Modifier::ITALIC); |
| 561 | buf.set_style(buf.area, existing); |
| 562 | let before = buf.clone(); |
| 563 | let info = InfoLine::new(&theme, "/help", &segments).hovered(Some(InfoSegmentId::Model)); |
| 564 | ratatui::widgets::Widget::render(info, area, &mut buf); |
| 565 | let label_ink = if matches!(ink, ChromeInk::Failure | ChromeInk::Attention) { |
| 566 | ink |
| 567 | } else { |
| 568 | ChromeInk::Metadata |
| 569 | }; |
| 570 | assert_eq!(buf[(3, 2)].fg, label_ink.color(&theme), "{ink:?}: label"); |
| 571 | for x in 11..16 { |
| 572 | let cell = &buf[(x, 2)]; |
| 573 | assert_eq!(cell.fg, ink.color(&theme), "{ink:?}: value"); |
| 574 | assert_eq!(cell.bg, Color::Rgb(30, 40, 50), "host owns the ground"); |
| 575 | assert!( |
| 576 | cell.modifier |
| 577 | .contains(Modifier::ITALIC | Modifier::BOLD | Modifier::UNDERLINED) |
| 578 | ); |
| 579 | } |
| 580 | assert_eq!( |
| 581 | buf[(30, 2)].fg, |
| 582 | theme.text_hint, |
| 583 | "help uses its own live slot" |
| 584 | ); |
| 585 | for position in [(0, 2), (16, 2), (20, 2), (3, 3), (39, 4)] { |
| 586 | assert_eq!( |
| 587 | buf[position], before[position], |
| 588 | "{ink:?}: untouched {position:?}" |
| 589 | ); |
| 590 | } |
| 591 | } |
| 592 | } |
| 593 | |
| 594 | #[test] |
| 595 | fn infoline_cjk_and_combining_text_keep_projected_pointer_geometry_when_clipped() { |
| 596 | use ratatui::style::{Color, Style}; |
| 597 | let segments = [ |
| 598 | InfoSegment::new( |
| 599 | InfoSegmentId::Model, |
| 600 | "", |
| 601 | "模型-e\u{301}↓", |
| 602 | ChromeInk::Identity, |
| 603 | ), |
| 604 | InfoSegment::new(InfoSegmentId::Context, "上下文", "61%", ChromeInk::Info), |
| 605 | ]; |
| 606 | for ascii in [false, true] { |
| 607 | for width in 0..=40 { |
| 608 | let area = Rect::new(3, 2, width, 1); |
| 609 | let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 46, 4)); |
| 610 | buf.set_style(buf.area, Style::default().bg(Color::Rgb(30, 40, 50))); |
| 611 | let before = buf.clone(); |
| 612 | let info = InfoLine::new(&UI_THEME, "", &segments).ascii_safe(ascii); |
| 613 | let hitboxes = infoline_hitboxes(&info, area); |
| 614 | let context = context_meter_hitbox(&info, area); |
| 615 | ratatui::widgets::Widget::render(info, area, &mut buf); |
| 616 | assert_eq!(buf[(2, 2)], before[(2, 2)], "left clipping"); |
| 617 | assert_eq!( |
| 618 | buf[(area.right(), 2)], |
| 619 | before[(area.right(), 2)], |
| 620 | "right clipping" |
| 621 | ); |
| 622 | assert_eq!(buf[(3, 3)], before[(3, 3)], "the row never wraps"); |
| 623 | for (index, hitbox) in hitboxes.iter().enumerate() { |
| 624 | assert!(hitbox.area.x >= area.x && hitbox.area.right() <= area.right()); |
| 625 | assert_eq!(hitbox.area.y, area.y); |
| 626 | assert_eq!(hitbox.area.height, 1); |
| 627 | if index > 0 { |
| 628 | assert!(hitboxes[index - 1].area.right() <= hitbox.area.x); |
| 629 | } |
| 630 | } |
| 631 | if width >= 7 { |
| 632 | assert_eq!(hitboxes[0].area, Rect::new(3, 2, 7, 1)); |
| 633 | assert_eq!(buf[(3, 2)].symbol(), "模"); |
| 634 | assert_eq!(buf[(5, 2)].symbol(), "型"); |
| 635 | assert_eq!(buf[(8, 2)].symbol(), "e\u{301}"); |
| 636 | assert_eq!(buf[(9, 2)].symbol(), if ascii { "v" } else { "↓" }); |
| 637 | } |
| 638 | if width >= 20 { |
| 639 | assert_eq!(context, Some(Rect::new(13, 2, 10, 1))); |
| 640 | assert_eq!(buf[(13, 2)].symbol(), "上"); |
| 641 | assert_eq!(buf[(15, 2)].symbol(), "下"); |
| 642 | assert_eq!(buf[(17, 2)].symbol(), "文"); |
| 643 | assert_eq!(buf[(20, 2)].symbol(), "6"); |
| 644 | } |
| 645 | } |
| 646 | } |
| 647 | } |
| 648 | |
| 649 | #[test] |
| 650 | fn infoline_sanitizes_hidden_controls_before_painting_and_pointer_measurement() { |
| 651 | let segments = [ |
| 652 | InfoSegment::new( |
| 653 | InfoSegmentId::Model, |
| 654 | "", |
| 655 | "model\u{202e}", |
| 656 | ChromeInk::Identity, |
| 657 | ), |
| 658 | InfoSegment::new( |
| 659 | InfoSegmentId::Context, |
| 660 | "ct\0x", |
| 661 | "6\u{1b}1%", |
| 662 | ChromeInk::Info, |
| 663 | ), |
| 664 | InfoSegment::new( |
| 665 | InfoSegmentId::OutputTokens, |
| 666 | "↓", |
| 667 | "1.2K", |
| 668 | ChromeInk::MetadataValue, |
| 669 | ), |
| 670 | ]; |
| 671 | let area = Rect::new(0, 0, 60, 1); |
| 672 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 673 | let info = InfoLine::new(&UI_THEME, "", &segments).ascii_safe(true); |
| 674 | let hitboxes = infoline_hitboxes(&info, area); |
| 675 | ratatui::widgets::Widget::render(info, area, &mut buf); |
| 676 | let row: String = buf.content().iter().map(|cell| cell.symbol()).collect(); |
| 677 | assert_eq!(row.trim_end(), "model ctx 61% v 1.2K"); |
| 678 | assert_eq!( |
| 679 | hitboxes.iter().map(|hit| hit.area).collect::<Vec<_>>(), |
| 680 | [ |
| 681 | Rect::new(0, 0, 5, 1), |
| 682 | Rect::new(8, 0, 7, 1), |
| 683 | Rect::new(18, 0, 6, 1), |
| 684 | ] |
| 685 | ); |
| 686 | } |
| 687 |