| 1 | //! One owner per fact: the full metrics preset paints each session fact in exactly |
| 2 | //! one chrome row (SHELL-DESIGN-20260901 §2.0 item 3, §2.2, §2.3, §2.3b). |
| 3 | //! |
| 4 | //! Under the composer: row 1 is the posture bar (permission, mode, live |
| 5 | //! counts, the one hint that applies now), row 2 is the metrics line (model, |
| 6 | //! ctx, cost, ttft, tok/s, output tokens); the roster and to-do rows follow |
| 7 | //! only when they have content. Every fact below is asserted to appear in |
| 8 | //! the composed frame exactly once. |
| 9 | |
| 10 | // These tests print the composed frame as failure evidence. They run under |
| 11 | // `cargo test`, never inside the alt-screen, so `tui/mod.rs`'s |
| 12 | // `#![deny(clippy::print_stderr)]` — which exists to stop the scroll demon in |
| 13 | // production paint paths — does not apply here. The exception is test-module |
| 14 | // only; production TUI code still denies unstructured stderr. |
| 15 | #![allow(clippy::print_stderr)] |
| 16 | |
| 17 | use std::path::PathBuf; |
| 18 | use std::time::{Duration, Instant}; |
| 19 | |
| 20 | use ratatui::{Terminal, backend::TestBackend}; |
| 21 | |
| 22 | use crate::config::Config; |
| 23 | use crate::tui::app::App; |
| 24 | use crate::tui::history::HistoryCell; |
| 25 | |
| 26 | fn frame_app() -> App { |
| 27 | let mut app = crate::test_support::test_app_with_options(crate::tui::app::TuiOptions { |
| 28 | model: "deepseek-v4-flash".to_string(), |
| 29 | start_in_agent_mode: true, |
| 30 | max_subagents: 4, |
| 31 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 32 | }); |
| 33 | app.onboarding = crate::tui::app::OnboardingState::None; |
| 34 | app.launch.visible = false; |
| 35 | app.ui_locale = codewhale_localization::Locale::En; |
| 36 | app.metrics_line = crate::config::ChromeRowPreset::Full; |
| 37 | // The posture bar's permission chip carries the filesystem-scope notice |
| 38 | // (`files: workspace (unenforced)`) whenever no sandbox backend can |
| 39 | // actually enforce the policy — true on default Linux and all Windows, |
| 40 | // false on macOS, which resolves seatbelt. That is 30 columns of chip |
| 41 | // that appears or not depending on which machine runs the test, and it |
| 42 | // decides what an 80-column shed ladder can still hold: these tests |
| 43 | // passed locally and failed on both CI legs until it was pinned. Force |
| 44 | // the wider, unenforced reading so every host asserts the same row. |
| 45 | app.sandbox_backend = None; |
| 46 | app |
| 47 | } |
| 48 | |
| 49 | fn subagent( |
| 50 | id: &str, |
| 51 | status: crate::tools::subagent::SubAgentStatus, |
| 52 | ) -> crate::tools::subagent::SubAgentResult { |
| 53 | crate::tools::subagent::SubAgentResult { |
| 54 | usage: None, |
| 55 | name: id.to_string(), |
| 56 | agent_id: id.to_string(), |
| 57 | context_mode: "fresh".to_string(), |
| 58 | fork_context: false, |
| 59 | workspace: None, |
| 60 | git_branch: None, |
| 61 | agent_type: crate::tools::subagent::FleetRole::Worker, |
| 62 | assignment: crate::tools::subagent::SubAgentAssignment { |
| 63 | objective: format!("objective-{id}"), |
| 64 | role: Some("worker".to_string()), |
| 65 | }, |
| 66 | model: "deepseek-v4-flash".to_string(), |
| 67 | nickname: None, |
| 68 | status, |
| 69 | worker_status: None, |
| 70 | runtime_permissions: None, |
| 71 | parent_run_id: None, |
| 72 | spawn_depth: 0, |
| 73 | child_route: None, |
| 74 | result: None, |
| 75 | steps_taken: 0, |
| 76 | checkpoint: None, |
| 77 | needs_input: None, |
| 78 | duration_ms: 0, |
| 79 | started_at: None, |
| 80 | from_prior_session: false, |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | /// A working turn with two running sub-agents and a session that has |
| 85 | /// already reported one turn's metrics. |
| 86 | fn working_app() -> App { |
| 87 | let mut app = frame_app(); |
| 88 | app.history = vec![HistoryCell::User { |
| 89 | content: "audit the shell".to_string(), |
| 90 | }]; |
| 91 | app.resync_history_revisions(); |
| 92 | app.is_loading = true; |
| 93 | app.turn_started_at = Some(Instant::now() - Duration::from_secs(75)); |
| 94 | app.subagent_cache = vec![ |
| 95 | subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running), |
| 96 | subagent("agent_b", crate::tools::subagent::SubAgentStatus::Running), |
| 97 | ]; |
| 98 | app.session_metrics |
| 99 | .record_model_call(1_200, 29_600, Some(400), Some(30_000)); |
| 100 | app.session.last_completion_tokens = Some(1_200); |
| 101 | app |
| 102 | } |
| 103 | |
| 104 | fn draw(app: &mut App, width: u16, height: u16) -> Vec<String> { |
| 105 | let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); |
| 106 | draw_into(app, &mut terminal).0 |
| 107 | } |
| 108 | |
| 109 | fn draw_into( |
| 110 | app: &mut App, |
| 111 | terminal: &mut Terminal<TestBackend>, |
| 112 | ) -> (Vec<String>, Option<(u16, u16)>) { |
| 113 | let config = Config::default(); |
| 114 | let mut cursor = None; |
| 115 | super::prepare_frame_cursor(terminal).unwrap(); |
| 116 | terminal |
| 117 | .draw(|frame| { |
| 118 | cursor = super::render(frame, app, &config); |
| 119 | }) |
| 120 | .unwrap(); |
| 121 | super::finish_frame_cursor(terminal, cursor).unwrap(); |
| 122 | let buf = terminal.backend().buffer(); |
| 123 | let rows = (0..buf.area.height) |
| 124 | .map(|y| { |
| 125 | (0..buf.area.width) |
| 126 | .map(|x| buf[(x, y)].symbol()) |
| 127 | .collect::<String>() |
| 128 | }) |
| 129 | .collect(); |
| 130 | (rows, cursor) |
| 131 | } |
| 132 | |
| 133 | fn count_rows_containing(rows: &[String], needle: &str) -> usize { |
| 134 | rows.iter().filter(|row| row.contains(needle)).count() |
| 135 | } |
| 136 | |
| 137 | /// Every chrome fact paints in exactly one row of the composed default |
| 138 | /// frame: the context reading, the mode and permission chips, the model, |
| 139 | /// the cost, the agent count, and the help hint. |
| 140 | /// |
| 141 | /// 160 columns joins the blocker sizes so both working-clock halves can |
| 142 | /// paint together when the session half is present; at 80 and 120 the |
| 143 | /// clocks shed by design (#5914) against the pinned scope notice. The |
| 144 | /// #6084 shed-order fix (sole turn clock uses the session rung) is pinned |
| 145 | /// in `phase_strip::tideline_tests`, where the narrower permission chip |
| 146 | /// exposes the width band the one-owner fixture's notice collapses. |
| 147 | #[test] |
| 148 | fn composed_frame_paints_each_fact_in_exactly_one_row() { |
| 149 | for (width, height) in [(80u16, 24u16), (120, 32), (160, 40)] { |
| 150 | let mut app = working_app(); |
| 151 | let rows = draw(&mut app, width, height); |
| 152 | let pct = super::info_context_percent(&app); |
| 153 | let (_, model) = app.effective_route_identity_display(); |
| 154 | let (mode, permission) = crate::tui::underwater::posture_chips(&app); |
| 155 | let mode = mode.expect("mode chip").0.into_owned(); |
| 156 | let permission = permission.expect("permission chip").0.into_owned(); |
| 157 | // The context reading paints exactly once, at every fullness |
| 158 | // (#5950 — it used to go silent below 50%). |
| 159 | let mut facts = vec![ |
| 160 | ("mode chip", format!(" {mode} (")), |
| 161 | ("permission chip", format!(" {permission} (")), |
| 162 | ("model", model), |
| 163 | ("cost", super::session_cost_label(&app)), |
| 164 | ("agent count", "2 agents".to_string()), |
| 165 | ("ttft", "ttft 400ms".to_string()), |
| 166 | ]; |
| 167 | facts.push(("context reading", format!("ctx {pct}%"))); |
| 168 | facts.push(("output rate", "40 avg tok/s".to_string())); |
| 169 | if width >= 120 { |
| 170 | facts.push(( |
| 171 | "help hint", |
| 172 | crate::tui::shell_key_routing::info_help_hint(app.ui_locale), |
| 173 | )); |
| 174 | } |
| 175 | for (name, needle) in facts { |
| 176 | if needle.is_empty() { |
| 177 | continue; |
| 178 | } |
| 179 | assert_eq!( |
| 180 | count_rows_containing(&rows, &needle), |
| 181 | 1, |
| 182 | "{width}x{height}: {name} {needle:?} must paint in exactly one row:\n{}", |
| 183 | rows.join("\n") |
| 184 | ); |
| 185 | } |
| 186 | // Rows under the composer: posture bar, then metrics line, then the |
| 187 | // roster — never the other way round. |
| 188 | let posture = rows |
| 189 | .iter() |
| 190 | .position(|row| row.contains("(Shift+Tab)")) |
| 191 | .expect("posture bar"); |
| 192 | let metrics = rows |
| 193 | .iter() |
| 194 | .position(|row| row.contains("ctx ")) |
| 195 | .expect("metrics line"); |
| 196 | let composer = app |
| 197 | .viewport |
| 198 | .last_composer_area |
| 199 | .expect("composer area") |
| 200 | .bottom(); |
| 201 | assert_eq!( |
| 202 | posture, |
| 203 | usize::from(composer), |
| 204 | "posture bar is row 1 under the composer" |
| 205 | ); |
| 206 | assert_eq!(metrics, posture + 1, "metrics line is row 2"); |
| 207 | // The scope notice is pinned on, not inherited from the host: if |
| 208 | // this ever goes quiet the widths below stop meaning what they say. |
| 209 | assert!( |
| 210 | rows[posture].contains("files: workspace (unenforced)"), |
| 211 | "{width}x{height}: the fixture must pin the scope notice: {}", |
| 212 | rows[posture] |
| 213 | ); |
| 214 | // The bar carries the working clock (#5914) — how long the current |
| 215 | // turn has been doing what it is doing, and how long the session has |
| 216 | // worked. Both halves shed before the hint and the counts, so a |
| 217 | // narrow row keeps the affordances and drops the stopwatch. When |
| 218 | // both would paint, the session half needs ~120 columns here and |
| 219 | // the turn half ~160; each paints in exactly one row wherever it |
| 220 | // paints. The metrics line carries no repository, branch or provider. |
| 221 | // First turn: the turn half names the phase and stays; the session |
| 222 | // reading is the identical duration, so it is suppressed rather than |
| 223 | // stated twice (#6041). With no session half to paint, the turn |
| 224 | // clock sheds at the session rung (#6084) rather than first — but |
| 225 | // against this fixture's pinned scope notice, turn+counts+hint is |
| 226 | // still just over a 120-column budget, so the hint wins here and |
| 227 | // the turn half needs ~160. The shed-order contract itself lives |
| 228 | // in tideline_tests. |
| 229 | let turn_needle = "sub-agents underway 1m 15s"; |
| 230 | if width >= 160 { |
| 231 | assert!(rows[posture].contains(turn_needle), "{}", rows[posture]); |
| 232 | assert_eq!( |
| 233 | count_rows_containing(&rows, turn_needle), |
| 234 | 1, |
| 235 | "{width}x{height}: {turn_needle:?} paints in exactly one row:\n{}", |
| 236 | rows.join("\n") |
| 237 | ); |
| 238 | } |
| 239 | assert!( |
| 240 | !rows[posture].contains("worked 1m 15s"), |
| 241 | "{width}x{height}: the duplicate session reading must not be stated: {}", |
| 242 | rows[posture] |
| 243 | ); |
| 244 | // After a finished turn the totals differ and the worked chip |
| 245 | // returns: 1m of finished turns plus the live 1m 15s reads 2m 15s. |
| 246 | let mut worked = working_app(); |
| 247 | worked.cumulative_turn_duration = Duration::from_secs(60); |
| 248 | let rows = draw(&mut worked, width, height); |
| 249 | if width >= 120 { |
| 250 | let worked_needle = "worked 2m 15s"; |
| 251 | assert!(rows[posture].contains(worked_needle), "{}", rows[posture]); |
| 252 | assert_eq!( |
| 253 | count_rows_containing(&rows, worked_needle), |
| 254 | 1, |
| 255 | "{width}x{height}: {worked_needle:?} paints in exactly one row:\n{}", |
| 256 | rows.join("\n") |
| 257 | ); |
| 258 | } |
| 259 | if width >= 160 { |
| 260 | assert!(rows[posture].contains(turn_needle), "{}", rows[posture]); |
| 261 | } |
| 262 | assert!(!rows[metrics].contains('⑂'), "{}", rows[metrics]); |
| 263 | // No dead key hints anywhere in the frame. |
| 264 | for row in &rows { |
| 265 | assert!(!row.contains("F1"), "F1 is not receivable: {row}"); |
| 266 | assert!(!row.contains("? help"), "bare ? is composer text: {row}"); |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | /// Idle: the two rows are there, the roster is not, and the last turn's |
| 272 | /// metrics survive between turns. |
| 273 | #[test] |
| 274 | fn idle_frame_keeps_two_chrome_rows_and_last_turn_metrics() { |
| 275 | let mut app = working_app(); |
| 276 | app.is_loading = false; |
| 277 | app.turn_started_at = None; |
| 278 | app.subagent_cache.clear(); |
| 279 | let rows = draw(&mut app, 100, 32); |
| 280 | let composer = app.viewport.last_composer_area.unwrap().bottom() as usize; |
| 281 | assert!(rows[composer].contains("(Shift+Tab)"), "{}", rows[composer]); |
| 282 | // The idle fixture sits at 0% context and says so: the reading is on |
| 283 | // the row at every fullness (#5950), not only once it is a problem. |
| 284 | assert!( |
| 285 | rows[composer + 1].contains("ctx 0%"), |
| 286 | "{}", |
| 287 | rows[composer + 1] |
| 288 | ); |
| 289 | assert!( |
| 290 | rows[composer + 1].contains("40 avg tok/s"), |
| 291 | "{}", |
| 292 | rows[composer + 1] |
| 293 | ); |
| 294 | assert!( |
| 295 | rows[composer + 1].contains("↓ 1.2K"), |
| 296 | "{}", |
| 297 | rows[composer + 1] |
| 298 | ); |
| 299 | assert_eq!( |
| 300 | composer + 2, |
| 301 | rows.len(), |
| 302 | "nothing under the metrics line when idle" |
| 303 | ); |
| 304 | assert!( |
| 305 | !rows[composer].contains("Esc to interrupt"), |
| 306 | "{}", |
| 307 | rows[composer] |
| 308 | ); |
| 309 | } |
| 310 | |
| 311 | /// At the cap the posture bar's hint says what to do; the reading still |
| 312 | /// paints once, in the metrics line. |
| 313 | #[test] |
| 314 | fn context_cap_warns_once_in_the_posture_bar() { |
| 315 | let mut app = working_app(); |
| 316 | app.active_route_limits = Some(codewhale_config::route::RouteLimits { |
| 317 | context_tokens: Some(60), |
| 318 | ..Default::default() |
| 319 | }); |
| 320 | // 140 columns: the fixture always paints the filesystem scope notice |
| 321 | // (`frame_app` pins `sandbox_backend = None`), so the width has to hold |
| 322 | // the warning with the notice present. The clock halves shed first. |
| 323 | let rows = draw(&mut app, 140, 32); |
| 324 | let pct = super::info_context_percent(&app); |
| 325 | assert!(pct >= 80, "fixture must sit at the cap: {pct}"); |
| 326 | assert_eq!( |
| 327 | count_rows_containing(&rows, "surface soon — /compact"), |
| 328 | 1, |
| 329 | "cap warning rows:\n{}", |
| 330 | rows.join("\n") |
| 331 | ); |
| 332 | assert_eq!( |
| 333 | count_rows_containing(&rows, &format!("{pct}%")), |
| 334 | 1, |
| 335 | "context reading rows:\n{}", |
| 336 | rows.join("\n") |
| 337 | ); |
| 338 | } |
| 339 | |
| 340 | /// The double-tap window advertises itself in the posture bar's hint slot, |
| 341 | /// and keeps it: both halves of the working clock shed before the hint does |
| 342 | /// (#5914), so the steer stays reachable on a 120-column row that cannot |
| 343 | /// also hold the stopwatch. |
| 344 | #[test] |
| 345 | fn double_tap_window_shows_the_send_now_hint() { |
| 346 | let mut app = working_app(); |
| 347 | app.arm_double_tap_window(); |
| 348 | let rows = draw(&mut app, 120, 32); |
| 349 | let composer = app.viewport.last_composer_area.unwrap().bottom() as usize; |
| 350 | assert!( |
| 351 | rows[composer].contains("Enter again to send now · Ctrl+Enter steers"), |
| 352 | "{}", |
| 353 | rows[composer] |
| 354 | ); |
| 355 | assert!(!rows[composer].contains("Esc to interrupt")); |
| 356 | } |
| 357 | |
| 358 | /// `tui.posture_bar` / `tui.metrics_line` (#5950): `hidden` gives a row |
| 359 | /// back to the transcript — one row per hidden preset, two for both — and |
| 360 | /// `compact` keeps the row with its first shed rungs already gone. Every |
| 361 | /// other row of the frame stays where it was, so the composer is never |
| 362 | /// displaced by the choice. |
| 363 | #[test] |
| 364 | fn row_presets_reclaim_rows_and_quiet_them_in_the_composed_frame() { |
| 365 | use crate::config::ChromeRowPreset; |
| 366 | // 160 columns joins the blocker sizes above: the working clock's two |
| 367 | // halves only both fit beside the pinned unenforced-scope permission |
| 368 | // chip from that width up, and this test asserts the full row's clocks. |
| 369 | let (width, height) = (160u16, 32u16); |
| 370 | let posture_row = |rows: &[String]| rows.iter().position(|row| row.contains("(Shift+Tab)")); |
| 371 | let metrics_row = |rows: &[String]| rows.iter().position(|row| row.contains("ctx ")); |
| 372 | |
| 373 | let mut app = working_app(); |
| 374 | let full = draw(&mut app, width, height); |
| 375 | let posture = posture_row(&full).expect("full frame paints the posture bar"); |
| 376 | let metrics = metrics_row(&full).expect("full frame paints the metrics line"); |
| 377 | assert_eq!( |
| 378 | metrics, |
| 379 | posture + 1, |
| 380 | "the metrics line sits under the posture bar" |
| 381 | ); |
| 382 | // The full row's live facts: a turn clock (this fixture waits on |
| 383 | // sub-agents, so #5914 words it `sub-agents underway` rather than |
| 384 | // `working`), the live counts and the hint — the three compact drops. |
| 385 | assert!(full[posture].contains("1m 15s"), "{:?}", full[posture]); |
| 386 | assert!(full[posture].contains("2 agents"), "{:?}", full[posture]); |
| 387 | assert!( |
| 388 | full[posture].contains("Esc to interrupt"), |
| 389 | "{:?}", |
| 390 | full[posture] |
| 391 | ); |
| 392 | assert!(full[metrics].contains("tok/s"), "{:?}", full[metrics]); |
| 393 | |
| 394 | // Hide the posture bar: the metrics line takes its row, and the |
| 395 | // transcript above gains one. |
| 396 | app.posture_bar = ChromeRowPreset::Hidden; |
| 397 | let rows = draw(&mut app, width, height); |
| 398 | assert_eq!(posture_row(&rows), None, "no posture bar: {rows:#?}"); |
| 399 | assert_eq!( |
| 400 | metrics_row(&rows), |
| 401 | Some(metrics), |
| 402 | "the metrics line keeps its row" |
| 403 | ); |
| 404 | assert_eq!( |
| 405 | count_rows_containing(&rows, "ctx "), |
| 406 | 1, |
| 407 | "the context reading is still painted once" |
| 408 | ); |
| 409 | |
| 410 | // Hide both: two rows reclaimed. |
| 411 | app.metrics_line = ChromeRowPreset::Hidden; |
| 412 | let rows = draw(&mut app, width, height); |
| 413 | assert_eq!(posture_row(&rows), None); |
| 414 | assert_eq!(metrics_row(&rows), None); |
| 415 | assert_eq!(count_rows_containing(&rows, "deepseek-v4-pro"), 0); |
| 416 | |
| 417 | // Compact both: the rows are back, quieter — the posture and the |
| 418 | // route/reading/price and measured performance, without secondary counts. |
| 419 | app.posture_bar = ChromeRowPreset::Compact; |
| 420 | app.metrics_line = ChromeRowPreset::Compact; |
| 421 | let rows = draw(&mut app, width, height); |
| 422 | let posture = posture_row(&rows).expect("compact paints the posture bar"); |
| 423 | let metrics = metrics_row(&rows).expect("compact paints the metrics line"); |
| 424 | assert_eq!(metrics, posture + 1); |
| 425 | let (mode, permission) = crate::tui::underwater::posture_chips(&app); |
| 426 | assert!(rows[posture].contains(permission.expect("permission chip").0.as_ref())); |
| 427 | assert!(rows[posture].contains(mode.expect("mode chip").0.as_ref())); |
| 428 | for gone in ["working", "2 agents", "Esc to interrupt"] { |
| 429 | assert!( |
| 430 | !rows[posture].contains(gone), |
| 431 | "{gone} in {:?}", |
| 432 | rows[posture] |
| 433 | ); |
| 434 | } |
| 435 | assert!( |
| 436 | rows[metrics].contains("deepseek-v4-pro"), |
| 437 | "{:?}", |
| 438 | rows[metrics] |
| 439 | ); |
| 440 | let pct = super::info_context_percent(&app); |
| 441 | assert!( |
| 442 | rows[metrics].contains(&format!("ctx {pct}%")), |
| 443 | "{:?}", |
| 444 | rows[metrics] |
| 445 | ); |
| 446 | assert!(rows[metrics].contains("ttft 400ms"), "{}", rows[metrics]); |
| 447 | assert!(rows[metrics].contains("40 avg tok/s"), "{}", rows[metrics]); |
| 448 | for gone in [ |
| 449 | "↓ 1.2K", |
| 450 | crate::tui::shell_key_routing::info_help_hint(app.ui_locale).as_str(), |
| 451 | ] { |
| 452 | assert!( |
| 453 | !rows[metrics].contains(gone), |
| 454 | "{gone} in {:?}", |
| 455 | rows[metrics] |
| 456 | ); |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | /// Exercise live preset transitions on the same terminal and App, including |
| 461 | /// restoration. Fresh buffers alone cannot expose stale chrome or hitboxes. |
| 462 | #[test] |
| 463 | fn statusline_full_frame_presets_preserve_transcript_composer_and_hitboxes() { |
| 464 | use crate::config::{ChromeRowPreset, StatusItem}; |
| 465 | use crate::tui::tideline::{InteractionAction, InteractionTargetId}; |
| 466 | use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; |
| 467 | use ratatui::layout::Position; |
| 468 | |
| 469 | for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32)] { |
| 470 | let mut app = frame_app(); |
| 471 | app.history = vec![HistoryCell::User { |
| 472 | content: (0..60) |
| 473 | .map(|row| format!("transcript-line-{row:02}")) |
| 474 | .collect::<Vec<_>>() |
| 475 | .join("\n"), |
| 476 | }]; |
| 477 | app.resync_history_revisions(); |
| 478 | app.input = "ab中文".to_string(); |
| 479 | app.cursor_position = app.input.chars().count(); |
| 480 | app.composer_border = true; |
| 481 | app.status_items = StatusItem::default_footer(); |
| 482 | app.posture_bar = ChromeRowPreset::Full; |
| 483 | app.metrics_line = ChromeRowPreset::Full; |
| 484 | app.session_metrics |
| 485 | .record_model_call(1_200, 29_600, Some(400), Some(30_000)); |
| 486 | app.session.last_completion_tokens = Some(1_200); |
| 487 | let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); |
| 488 | let (full, _) = draw_into(&mut app, &mut terminal); |
| 489 | let full_buffer = terminal.backend().buffer().clone(); |
| 490 | let full_transcript = app.viewport.last_transcript_area.unwrap(); |
| 491 | let full_composer = app.viewport.last_composer_area.unwrap(); |
| 492 | let full_visible = count_rows_containing(&full, "transcript-line-"); |
| 493 | assert!( |
| 494 | full_visible > 0 && full_visible < 60, |
| 495 | "populated scrollback fixture" |
| 496 | ); |
| 497 | |
| 498 | for (name, posture, metrics, reclaimed) in [ |
| 499 | ("full", ChromeRowPreset::Full, ChromeRowPreset::Full, 0), |
| 500 | ( |
| 501 | "metrics-hidden", |
| 502 | ChromeRowPreset::Full, |
| 503 | ChromeRowPreset::Hidden, |
| 504 | 1, |
| 505 | ), |
| 506 | ( |
| 507 | "posture-hidden", |
| 508 | ChromeRowPreset::Hidden, |
| 509 | ChromeRowPreset::Full, |
| 510 | 1, |
| 511 | ), |
| 512 | ( |
| 513 | "both-hidden", |
| 514 | ChromeRowPreset::Hidden, |
| 515 | ChromeRowPreset::Hidden, |
| 516 | 2, |
| 517 | ), |
| 518 | ( |
| 519 | "both-compact", |
| 520 | ChromeRowPreset::Compact, |
| 521 | ChromeRowPreset::Compact, |
| 522 | 0, |
| 523 | ), |
| 524 | ( |
| 525 | "full-restored", |
| 526 | ChromeRowPreset::Full, |
| 527 | ChromeRowPreset::Full, |
| 528 | 0, |
| 529 | ), |
| 530 | ] { |
| 531 | app.posture_bar = posture; |
| 532 | app.metrics_line = metrics; |
| 533 | let (rows, cursor) = draw_into(&mut app, &mut terminal); |
| 534 | let evidence = format!("{width}x{height} {name}\n{}", rows.join("\n")); |
| 535 | eprintln!("{evidence}"); |
| 536 | let transcript = app.viewport.last_transcript_area.unwrap(); |
| 537 | let composer = app.viewport.last_composer_area.unwrap(); |
| 538 | assert_eq!( |
| 539 | transcript.height, |
| 540 | full_transcript.height + reclaimed, |
| 541 | "{evidence}" |
| 542 | ); |
| 543 | assert_eq!(composer.y, full_composer.y + reclaimed, "{evidence}"); |
| 544 | assert_eq!(composer.height, full_composer.height, "{evidence}"); |
| 545 | assert_eq!(transcript.bottom(), composer.y, "{evidence}"); |
| 546 | assert_eq!( |
| 547 | count_rows_containing(&rows, "transcript-line-"), |
| 548 | full_visible + usize::from(reclaimed), |
| 549 | "{evidence}" |
| 550 | ); |
| 551 | assert!( |
| 552 | rows.iter().any(|row| row.contains("transcript-line-59")), |
| 553 | "latest transcript survives: {evidence}" |
| 554 | ); |
| 555 | assert_eq!(app.input, "ab中文"); |
| 556 | assert!( |
| 557 | rows.iter().all(|row| !row.contains('\u{fffd}')), |
| 558 | "{evidence}" |
| 559 | ); |
| 560 | |
| 561 | let cursor = cursor.expect("active composer exposes its caret"); |
| 562 | let inner = app.viewport.last_composer_content.unwrap(); |
| 563 | let text = crate::tui::widgets::composer_content_geometry(inner, false).text_area; |
| 564 | let submit = crate::tui::widgets::active_composer_submit_rect(&app, composer).unwrap(); |
| 565 | assert!( |
| 566 | text.contains(Position::from(cursor)), |
| 567 | "caret inside text grid: {evidence}" |
| 568 | ); |
| 569 | assert_eq!( |
| 570 | cursor.0, |
| 571 | text.x + 6, |
| 572 | "two ASCII and two wide glyphs: {evidence}" |
| 573 | ); |
| 574 | assert!( |
| 575 | !submit.contains(Position::from(cursor)), |
| 576 | "caret cannot hit Send: {evidence}" |
| 577 | ); |
| 578 | assert!(terminal.backend().cursor_visible()); |
| 579 | terminal |
| 580 | .backend_mut() |
| 581 | .assert_cursor_position(Position::from(cursor)); |
| 582 | // Terminal backends skip continuation cells covered by a wide |
| 583 | // glyph. TestBackend can retain a prior border in those cells; |
| 584 | // it is not visible terminal text after the wide glyph is drawn. |
| 585 | let mut painted_input = String::new(); |
| 586 | let mut x = text.x; |
| 587 | while x < cursor.0 { |
| 588 | let symbol = terminal.backend().buffer()[(x, cursor.1)].symbol(); |
| 589 | painted_input.push_str(symbol); |
| 590 | x += unicode_width::UnicodeWidthStr::width(symbol).max(1) as u16; |
| 591 | } |
| 592 | assert_eq!(painted_input, "ab中文", "{evidence}"); |
| 593 | app.viewport.composer_click_trace = None; |
| 594 | assert!(crate::tui::mouse_ui::handle_composer_mouse( |
| 595 | &mut app, |
| 596 | MouseEvent { |
| 597 | kind: MouseEventKind::Down(MouseButton::Left), |
| 598 | column: cursor.0, |
| 599 | row: cursor.1, |
| 600 | modifiers: KeyModifiers::NONE, |
| 601 | } |
| 602 | )); |
| 603 | assert_eq!( |
| 604 | app.cursor_position, |
| 605 | app.input.chars().count(), |
| 606 | "CJK mouse/caret boundary: {evidence}" |
| 607 | ); |
| 608 | |
| 609 | let context = app |
| 610 | .viewport |
| 611 | .interaction_targets |
| 612 | .iter() |
| 613 | .find(|target| target.id == InteractionTargetId::HEADER_CONTEXT); |
| 614 | let model = app |
| 615 | .viewport |
| 616 | .interaction_targets |
| 617 | .iter() |
| 618 | .find(|target| target.id == InteractionTargetId::HEADER_MODEL); |
| 619 | if metrics == ChromeRowPreset::Hidden { |
| 620 | assert!(app.viewport.last_infoline_hitboxes.is_empty(), "{evidence}"); |
| 621 | assert!( |
| 622 | context.is_none() && model.is_none(), |
| 623 | "hidden chrome has no stale actions: {evidence}" |
| 624 | ); |
| 625 | assert_eq!(count_rows_containing(&rows, "ctx "), 0, "{evidence}"); |
| 626 | } else { |
| 627 | let context = context.expect("visible context has an inspector hitbox"); |
| 628 | let model = model.expect("visible model has a picker hitbox"); |
| 629 | assert_eq!( |
| 630 | context.mouse_action, |
| 631 | Some(InteractionAction::InspectContext) |
| 632 | ); |
| 633 | assert_eq!(model.mouse_action, Some(InteractionAction::OpenModelPicker)); |
| 634 | for target in [context, model] { |
| 635 | assert_eq!(target.keyboard_action, target.mouse_action); |
| 636 | assert_eq!(target.area.y, height - 1, "{evidence}"); |
| 637 | assert_eq!( |
| 638 | app.viewport |
| 639 | .interaction_targets |
| 640 | .target_at(target.area.x, target.area.y), |
| 641 | Some(target) |
| 642 | ); |
| 643 | assert!(!composer.intersects(target.area), "{evidence}"); |
| 644 | } |
| 645 | assert_eq!(count_rows_containing(&rows, "ctx 0%"), 1, "{evidence}"); |
| 646 | } |
| 647 | if metrics == ChromeRowPreset::Compact { |
| 648 | if width >= 60 { |
| 649 | assert!(rows[usize::from(height - 1)].contains("ttft"), "{evidence}"); |
| 650 | assert!( |
| 651 | rows[usize::from(height - 1)].contains("avg tok/s"), |
| 652 | "{evidence}" |
| 653 | ); |
| 654 | } |
| 655 | for shed in ["↓ 1.2K", "Ctrl+/ help"] { |
| 656 | assert!(!rows[usize::from(height - 1)].contains(shed), "{evidence}"); |
| 657 | } |
| 658 | } |
| 659 | if name == "full-restored" { |
| 660 | let restored = terminal.backend().buffer(); |
| 661 | assert_eq!(restored.area, full_buffer.area); |
| 662 | for y in full_buffer.area.y..full_buffer.area.bottom() { |
| 663 | let mut x = full_buffer.area.x; |
| 664 | while x < full_buffer.area.right() { |
| 665 | let expected = &full_buffer[(x, y)]; |
| 666 | assert_eq!( |
| 667 | &restored[(x, y)], |
| 668 | expected, |
| 669 | "restoration leaves no stale visible cell or style at ({x}, {y}): {evidence}" |
| 670 | ); |
| 671 | // Covered continuation cells are not rendered by a |
| 672 | // terminal, so TestBackend's retained contents there |
| 673 | // are not part of visible restoration. |
| 674 | x += unicode_width::UnicodeWidthStr::width(expected.symbol()).max(1) as u16; |
| 675 | } |
| 676 | } |
| 677 | } |
| 678 | } |
| 679 | } |
| 680 | } |
| 681 | |
| 682 | /// #5976 intentionally supersedes #5950's blanket custom-cost omission: |
| 683 | /// missing coverage is evidence, independent of whether today's route is known. |
| 684 | #[test] |
| 685 | fn statusline_full_frame_custom_cost_preserves_evidence_and_width_shedding() { |
| 686 | use crate::config::{ApiProvider, ChromeRowPreset, StatusItem}; |
| 687 | use crate::route_billing::{BillingPresentation, UsageChip}; |
| 688 | |
| 689 | for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32)] { |
| 690 | let mut app = frame_app(); |
| 691 | app.history = vec![HistoryCell::User { |
| 692 | content: "Review saved usage".to_string(), |
| 693 | }]; |
| 694 | app.resync_history_revisions(); |
| 695 | app.set_provider_identity(ApiProvider::Custom, "my-gateway"); |
| 696 | app.active_route_base_url = "https://gateway.example/v1".to_string(); |
| 697 | app.model = "vendor-model-x".to_string(); |
| 698 | app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::High; |
| 699 | app.billing_presentation = BillingPresentation::Unknown; |
| 700 | app.session.cost_coverage_unknown_legacy = true; |
| 701 | app.status_items = vec![StatusItem::ContextPercent, StatusItem::Cost]; |
| 702 | app.posture_bar = ChromeRowPreset::Hidden; |
| 703 | app.metrics_line = ChromeRowPreset::Compact; |
| 704 | let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); |
| 705 | let expected = "cost: unknown (saved coverage unavailable)"; |
| 706 | assert_eq!(app.api_provider, ApiProvider::Custom); |
| 707 | assert!(matches!(app.cumulative_usage_chip(), UsageChip::Unknown(_))); |
| 708 | assert_eq!(super::session_cost_label(&app), expected); |
| 709 | let (rows, _) = draw_into(&mut app, &mut terminal); |
| 710 | eprintln!("{width}x{height} custom-saved-unknown\n{}", rows.join("\n")); |
| 711 | let metrics = rows.last().unwrap(); |
| 712 | assert!(metrics.contains("ctx 0%"), "{metrics}"); |
| 713 | if width >= 60 { |
| 714 | assert!(metrics.contains(expected), "{width}: {metrics}"); |
| 715 | } else { |
| 716 | // The existing whole-segment shed ladder cannot fit the reason |
| 717 | // plus context in 40 columns. It must not invent a zero price. |
| 718 | assert!(!metrics.contains("cost:"), "{metrics}"); |
| 719 | assert!(!metrics.contains('$'), "{metrics}"); |
| 720 | } |
| 721 | assert_eq!( |
| 722 | super::session_cost_label(&app), |
| 723 | expected, |
| 724 | "shedding changes no receipt" |
| 725 | ); |
| 726 | |
| 727 | app.status_items.retain(|item| *item != StatusItem::Cost); |
| 728 | let (hidden, _) = draw_into(&mut app, &mut terminal); |
| 729 | assert!(!hidden.last().unwrap().contains("cost:")); |
| 730 | assert_eq!( |
| 731 | super::session_cost_label(&app), |
| 732 | expected, |
| 733 | "a toggle changes no receipt" |
| 734 | ); |
| 735 | app.status_items.push(StatusItem::Cost); |
| 736 | assert_eq!( |
| 737 | draw_into(&mut app, &mut terminal).0, |
| 738 | rows, |
| 739 | "live cost toggle restores the same frame" |
| 740 | ); |
| 741 | |
| 742 | app.session.cost_coverage_unknown_legacy = false; |
| 743 | app.session.cost_unpriced_turns = 1; |
| 744 | app.session.cost_unpriced_reasons.insert( |
| 745 | crate::pricing::UnpricedReason::NoPricingRow |
| 746 | .label() |
| 747 | .to_string(), |
| 748 | ); |
| 749 | let missing_rate = "cost: unknown (rate unavailable)"; |
| 750 | assert_eq!(super::session_cost_label(&app), missing_rate); |
| 751 | let (unpriced, _) = draw_into(&mut app, &mut terminal); |
| 752 | eprintln!( |
| 753 | "{width}x{height} custom-unpriced-turn\n{}", |
| 754 | unpriced.join("\n") |
| 755 | ); |
| 756 | if width >= 60 { |
| 757 | assert!( |
| 758 | unpriced.last().unwrap().contains(missing_rate), |
| 759 | "{unpriced:?}" |
| 760 | ); |
| 761 | } else { |
| 762 | assert!(!unpriced.last().unwrap().contains("cost:"), "{unpriced:?}"); |
| 763 | } |
| 764 | assert_eq!(app.session.cost_unpriced_turns, 1); |
| 765 | assert!(matches!(app.cumulative_usage_chip(), UsageChip::Unknown(_))); |
| 766 | |
| 767 | // An unavailable effective effort is omitted independently of the |
| 768 | // explicit missing-cost reason. At 100 columns both model and reason fit. |
| 769 | if width == 100 { |
| 770 | app.status_items.insert(0, StatusItem::Model); |
| 771 | let (with_model, _) = draw_into(&mut app, &mut terminal); |
| 772 | eprintln!( |
| 773 | "{width}x{height} custom-model-and-unpriced-turn\n{}", |
| 774 | with_model.join("\n") |
| 775 | ); |
| 776 | let metrics = with_model.last().unwrap(); |
| 777 | assert!(metrics.contains("vendor-model-x"), "{metrics}"); |
| 778 | assert!(metrics.contains(missing_rate), "{metrics}"); |
| 779 | assert_eq!(app.provable_reasoning_effort_label(), None); |
| 780 | assert!(!metrics.contains("high"), "{metrics}"); |
| 781 | assert!(!metrics.contains("effective unavailable"), "{metrics}"); |
| 782 | app.status_items.remove(0); |
| 783 | } |
| 784 | |
| 785 | app.session.cost_unpriced_turns = 0; |
| 786 | app.session.cost_unpriced_reasons.clear(); |
| 787 | app.session.cost_priced_turns = 1; |
| 788 | app.session.session_cost = 0.42; |
| 789 | let (priced, _) = draw_into(&mut app, &mut terminal); |
| 790 | eprintln!( |
| 791 | "{width}x{height} custom-recorded-price\n{}", |
| 792 | priced.join("\n") |
| 793 | ); |
| 794 | assert!(matches!(app.cumulative_usage_chip(), UsageChip::Money(_))); |
| 795 | assert!( |
| 796 | priced.last().unwrap().contains("0.42"), |
| 797 | "real fixture price survives Custom: {priced:?}" |
| 798 | ); |
| 799 | app.set_provider_identity(ApiProvider::Deepseek, "deepseek"); |
| 800 | app.active_route_base_url = "https://api.deepseek.com/v1".to_string(); |
| 801 | app.model = "deepseek-v4-pro".to_string(); |
| 802 | app.billing_presentation = BillingPresentation::Metered; |
| 803 | let (first_party, _) = draw_into(&mut app, &mut terminal); |
| 804 | assert_eq!( |
| 805 | first_party.last(), |
| 806 | priced.last(), |
| 807 | "historical price does not follow today's provider" |
| 808 | ); |
| 809 | app.set_provider_identity(ApiProvider::Custom, "my-gateway"); |
| 810 | app.active_route_base_url = "https://gateway.example/v1".to_string(); |
| 811 | app.model = "vendor-model-x".to_string(); |
| 812 | app.billing_presentation = BillingPresentation::Unknown; |
| 813 | let (restored_custom, _) = draw_into(&mut app, &mut terminal); |
| 814 | assert_eq!(restored_custom.last(), priced.last()); |
| 815 | } |
| 816 | } |
| 817 | |
| 818 | /// Feed real estimated conversation tokens through the composed frame, then |
| 819 | /// change only the route window. Crossing the warning threshold must repaint |
| 820 | /// both text and ink without moving the transcript, composer or inspector. |
| 821 | #[test] |
| 822 | fn statusline_full_frame_context_reading_updates_below_and_at_warning() { |
| 823 | use crate::config::{ChromeRowPreset, StatusItem}; |
| 824 | use crate::tui::tideline::InteractionTargetId; |
| 825 | use codewhale_models::{ContentBlock, Message, Role}; |
| 826 | use codewhale_palette::ChromeInk; |
| 827 | |
| 828 | for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32)] { |
| 829 | let mut app = frame_app(); |
| 830 | app.history = vec![HistoryCell::User { |
| 831 | content: "Keep the context reading visible".to_string(), |
| 832 | }]; |
| 833 | app.resync_history_revisions(); |
| 834 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 835 | role: Role::User, |
| 836 | content: vec![ContentBlock::Text { |
| 837 | text: "context ".repeat(400), |
| 838 | cache_control: None, |
| 839 | }], |
| 840 | }]); |
| 841 | app.input = "next".to_string(); |
| 842 | app.cursor_position = app.input.chars().count(); |
| 843 | app.status_items = StatusItem::default_footer(); |
| 844 | app.posture_bar = ChromeRowPreset::Full; |
| 845 | app.metrics_line = ChromeRowPreset::Full; |
| 846 | let (used, _, _) = super::context_usage_snapshot(&app).unwrap(); |
| 847 | let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); |
| 848 | let mut first_geometry = None; |
| 849 | let mut first_frame = None; |
| 850 | |
| 851 | for pct in [0u8, 10, 79, 80, 10, 0] { |
| 852 | let window = if pct == 0 { |
| 853 | // 0.1% rounds to the displayed 0% even with real context. |
| 854 | used as u64 * 1_000 |
| 855 | } else { |
| 856 | (used as f64 * 100.0 / f64::from(pct)).round() as u64 |
| 857 | }; |
| 858 | app.active_route_limits = Some(codewhale_config::route::RouteLimits { |
| 859 | context_tokens: Some(window), |
| 860 | ..Default::default() |
| 861 | }); |
| 862 | assert_eq!(super::info_context_percent(&app), pct); |
| 863 | let (rows, cursor) = draw_into(&mut app, &mut terminal); |
| 864 | let evidence = format!("{width}x{height} context-{pct}\n{}", rows.join("\n")); |
| 865 | eprintln!("{evidence}"); |
| 866 | let label = format!("ctx {pct}%"); |
| 867 | assert_eq!(count_rows_containing(&rows, &label), 1, "{evidence}"); |
| 868 | assert!( |
| 869 | rows.iter() |
| 870 | .any(|row| row.contains("Keep the context reading visible")), |
| 871 | "{evidence}" |
| 872 | ); |
| 873 | let context = app |
| 874 | .viewport |
| 875 | .interaction_targets |
| 876 | .iter() |
| 877 | .find(|target| target.id == InteractionTargetId::HEADER_CONTEXT) |
| 878 | .expect("the visible reading stays inspectable"); |
| 879 | assert_eq!(context.area.y, height - 1, "{evidence}"); |
| 880 | // Attention, not Failure: the posture bar calls this same >= 80 |
| 881 | // threshold Attention, and the two must not disagree one row apart. |
| 882 | let value_ink = if pct >= 80 { |
| 883 | ChromeInk::Attention |
| 884 | } else { |
| 885 | ChromeInk::Info |
| 886 | }; |
| 887 | let label_ink = if pct >= 80 { |
| 888 | ChromeInk::Attention |
| 889 | } else { |
| 890 | ChromeInk::Metadata |
| 891 | }; |
| 892 | let buffer = terminal.backend().buffer(); |
| 893 | for (x, ink) in [(context.area.x, label_ink), (context.area.x + 4, value_ink)] { |
| 894 | assert_eq!( |
| 895 | buffer[(x, context.area.y)].fg, |
| 896 | codewhale_palette::grammar::chrome_style(&app.ui_theme, ink) |
| 897 | .fg |
| 898 | .unwrap(), |
| 899 | "warning ink must also clear after 80%: {evidence}", |
| 900 | ); |
| 901 | } |
| 902 | let geometry = ( |
| 903 | app.viewport.last_transcript_area, |
| 904 | app.viewport.last_composer_area, |
| 905 | cursor, |
| 906 | ); |
| 907 | if let Some(first) = first_geometry { |
| 908 | assert_eq!(geometry, first, "{evidence}"); |
| 909 | } else { |
| 910 | first_geometry = Some(geometry); |
| 911 | } |
| 912 | if pct == 0 { |
| 913 | if let Some(first) = first_frame.as_ref() { |
| 914 | assert_eq!( |
| 915 | terminal.backend().buffer(), |
| 916 | first, |
| 917 | "returning to 0% clears the warning frame and ink" |
| 918 | ); |
| 919 | } else { |
| 920 | first_frame = Some(terminal.backend().buffer().clone()); |
| 921 | } |
| 922 | } |
| 923 | } |
| 924 | } |
| 925 | } |
| 926 |