返回 CodeWhale
one_owner_tests.rs
根目录 / crates / tui / src / tui / ui / frame / one_owner_tests.rs
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
926 lines RUST