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