返回 CodeWhale
tideline_tests.rs
根目录 / crates / tui / src / tui / phase_strip / tideline_tests.rs
1 //! Golden-buffer contract for the posture bar — the first row under the
2 //! composer. Goldens: `footer_{w}x{h}` — the one-row band at the bottom of
3 //! each blocker-size buffer. Re-bless by DELETING the golden and running
4 //! with `CODEWHALE_BLESS_GOLDENS=1`.
5
6 use ratatui::layout::Rect;
7 use unicode_width::UnicodeWidthChar;
8
9 use super::{ChromeInk, TidelineFooter, render_tideline_footer};
10 use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text};
11 use codewhale_palette::UI_THEME;
12
13 struct Fixture {
14 permission: (&'static str, ChromeInk),
15 permission_key: Option<&'static str>,
16 mode: Option<(&'static str, ChromeInk)>,
17 mode_key: Option<&'static str>,
18 turn_clock: Option<(&'static str, ChromeInk)>,
19 counts: Vec<(String, ChromeInk)>,
20 session_clock: Option<(&'static str, ChromeInk)>,
21 hint: Option<(&'static str, ChromeInk)>,
22 context_percent: u8,
23 right: Option<(&'static str, ChromeInk)>,
24 }
25
26 /// A working turn with two sub-agents, ask posture, work mode.
27 fn working() -> Fixture {
28 Fixture {
29 permission: ("ask", ChromeInk::PermissionAsk),
30 permission_key: Some("Shift+Tab to change"),
31 mode: Some(("work", ChromeInk::PolicyAct)),
32 mode_key: Some("Tab"),
33 turn_clock: Some(("working 1m 15s", ChromeInk::Active)),
34 counts: vec![("2 agents".to_string(), ChromeInk::Active)],
35 session_clock: Some(("worked 41m 12s", ChromeInk::Active)),
36 hint: Some(("Esc to interrupt", ChromeInk::MetadataHint)),
37 context_percent: 61,
38 right: None,
39 }
40 }
41
42 impl Fixture {
43 fn widget<'a>(&'a self, theme: &'a codewhale_palette::UiTheme) -> TidelineFooter<'a> {
44 TidelineFooter::new(theme, self.permission)
45 .permission_key(self.permission_key)
46 .mode_chip(self.mode)
47 .mode_key(self.mode_key)
48 .turn_clock(self.turn_clock)
49 .counts(&self.counts)
50 .session_clock(self.session_clock)
51 .hint(self.hint)
52 .context_percent(self.context_percent)
53 .right(self.right)
54 }
55 }
56
57 fn draw(width: u16, height: u16, footer: &TidelineFooter<'_>) -> String {
58 render_golden_text(width, height, |buf| {
59 // The shell reserves exactly one row for the bar.
60 render_tideline_footer(
61 Rect::new(0, height.saturating_sub(1), width, 1),
62 buf,
63 footer,
64 );
65 })
66 }
67
68 #[test]
69 fn footer_matches_goldens_at_blocker_sizes() {
70 let fixture = working();
71 for (w, h) in BLOCKER_SIZES {
72 let footer = fixture.widget(&UI_THEME);
73 assert_matches_golden(&format!("footer_{w}x{h}"), &draw(w, h, &footer));
74 }
75 }
76
77 /// Permission chip with its cycle key, mode
78 /// with its cycle key, the working clock, live counts, then the one hint
79 /// that applies now.
80 #[test]
81 fn posture_bar_reads_permission_mode_clock_counts_hint() {
82 let text = draw(120, 30, &working().widget(&UI_THEME));
83 let band = text.lines().last().unwrap_or_default().trim_end();
84 assert_eq!(
85 band,
86 " ● ask Shift+Tab to change work (Tab) working 1m 15s 2 agents worked 41m 12s Esc to interrupt"
87 );
88 }
89
90 /// Experience mark 8: the current permission reads without color. With every
91 /// ink stripped the row still marks which chip is the permission in force
92 /// (`●`) and says what its key does, instead of printing two look-alike
93 /// `word (key)` chips that read as a menu of options.
94 #[test]
95 fn current_permission_is_identifiable_without_color() {
96 for (w, h) in BLOCKER_SIZES {
97 let text = draw(w, h, &working().widget(&UI_THEME));
98 let band = text.lines().last().unwrap_or_default();
99 assert!(band.starts_with(" ● ask"), "{w}x{h}: {band}");
100 assert_eq!(band.matches('●').count(), 1, "{w}x{h}: {band}");
101 assert!(band.contains("Shift+Tab to change"), "{w}x{h}: {band}");
102 assert!(!band.contains("(Shift+Tab)"), "{w}x{h}: {band}");
103 }
104 }
105
106 /// The bar carries no cost and no context reading: the metrics line owns
107 /// both. The elapsed reading is this bar's again (#5914) — a fixed row is
108 /// the only place a glancing user can find it during a multi-hour session —
109 /// so it is asserted here rather than excluded.
110 #[test]
111 fn posture_bar_states_no_cost_or_context_reading() {
112 for pct in [0u8, 12, 61, 79] {
113 let mut fixture = working();
114 fixture.context_percent = pct;
115 let text = draw(120, 32, &fixture.widget(&UI_THEME));
116 assert!(!text.contains(&format!("{pct}%")), "{pct}: {text}");
117 assert!(!text.contains("<·>"), "{text}");
118 assert!(!text.contains('$'), "{text}");
119 assert!(text.contains("working 1m 15s"), "{text}");
120 assert!(text.contains("worked 41m 12s"), "{text}");
121 assert!(text.contains("2 agents"), "{text}");
122 }
123 }
124
125 /// At the cap the bar's hint slot says what to do, outranking the
126 /// interrupt hint; the reading itself still is not here.
127 #[test]
128 fn posture_bar_warns_at_eighty_percent_cap() {
129 let mut fixture = working();
130 fixture.context_percent = 83;
131 let text = draw(100, 30, &fixture.widget(&UI_THEME));
132 assert!(text.contains("▲ surface soon — /compact"), "{text}");
133 assert!(!text.contains("Esc to interrupt"), "{text}");
134 assert!(!text.contains("83%"), "{text}");
135 }
136
137 /// The cap warning outranks the clock: a working session that cannot start
138 /// its next turn needs the instruction more than it needs the stopwatch.
139 #[test]
140 fn cap_warning_outranks_the_clock_when_only_one_fits() {
141 let mut fixture = working();
142 fixture.context_percent = 83;
143 let mut saw_warning_alone = false;
144 for width in 8..=160u16 {
145 let text = draw(width, 12, &fixture.widget(&UI_THEME));
146 let warning = text.contains("surface soon");
147 let clock = text.contains("worked 41m 12s");
148 assert!(
149 !(clock && !warning),
150 "width {width} kept the clock and shed the cap warning: {text}"
151 );
152 if warning && !clock {
153 saw_warning_alone = true;
154 }
155 }
156 assert!(saw_warning_alone, "no width shed the clock alone");
157 }
158
159 /// The right slot is the notice when one is owed, else the remote-control
160 /// state; it never covers the permission chip.
161 #[test]
162 fn posture_bar_pins_notice_or_remote_control_right() {
163 let mut fixture = working();
164 fixture.right = Some(("/rc connected", ChromeInk::Info));
165 let text = draw(100, 30, &fixture.widget(&UI_THEME));
166 assert!(text.trim_end().ends_with("/rc connected"), "{text}");
167 assert!(text.contains(" ● ask Shift+Tab to change"), "{text}");
168
169 fixture.right = Some(("Auto-denied exec_shell", ChromeInk::Attention));
170 let text = draw(100, 30, &fixture.widget(&UI_THEME));
171 assert!(
172 text.trim_end().ends_with("Auto-denied exec_shell"),
173 "{text}"
174 );
175
176 // Narrow: the notice truncates against the permission floor, never
177 // over it.
178 let narrow = draw(30, 12, &fixture.widget(&UI_THEME));
179 assert!(narrow.contains(" ask"), "{narrow}");
180 }
181
182 /// Shed ladder, most expendable first: the turn clock, the session clock,
183 /// the permission key (a binding reminder, and the widest optional item),
184 /// the hint, the counts, mode key, mode. The permission chip
185 /// never sheds (#5796); the clock is what a glance wants and the hint and
186 /// counts are what a keystroke wants, so on a row too narrow for both the
187 /// clock goes (#5914).
188 #[test]
189 fn posture_bar_sheds_the_clocks_then_the_hint_counts_and_posture_chips() {
190 let fixture = working();
191 let narrowest_showing = |needle: &str| -> u16 {
192 (8..=160u16)
193 .filter(|w| draw(*w, 3, &fixture.widget(&UI_THEME)).contains(needle))
194 .min()
195 .unwrap_or_else(|| panic!("{needle} never painted"))
196 };
197 let turn_clock = narrowest_showing("working 1m 15s");
198 let session_clock = narrowest_showing("worked 41m 12s");
199 let hint = narrowest_showing("Esc to interrupt");
200 let counts = narrowest_showing("2 agents");
201 // `work` alone would also match `working`, so measure the mode chip by
202 // a needle only the mode paints.
203 let mode_key = narrowest_showing("work (Tab)");
204 let mode = narrowest_showing(" work ");
205 let permission_key = narrowest_showing("Shift+Tab to change");
206 assert!(
207 turn_clock > session_clock
208 && session_clock > permission_key
209 && permission_key > hint
210 && hint > counts
211 && counts > mode_key
212 && mode_key > mode,
213 "turn_clock@{turn_clock} session_clock@{session_clock} hint@{hint} counts@{counts} mode_key@{mode_key} mode@{mode} permission_key@{permission_key}"
214 );
215 for w in 8..=160u16 {
216 let text = draw(w, 3, &fixture.widget(&UI_THEME));
217 assert!(
218 text.contains("ask"),
219 "{w}: the permission chip never sheds: {text}"
220 );
221 // Whole chips or none — a clipped posture word is worse than none.
222 assert!(
223 !text.contains("(Ta") || text.contains("(Tab)"),
224 "{w}: {text}"
225 );
226 }
227 }
228
229 /// `tui.posture_bar = "compact"` (#5950) starts the ladder past the clocks,
230 /// the hint and the counts at any width: the row states its posture — the
231 /// permission and mode chips, and the cap warning when it is owed — and
232 /// nothing live. Width still sheds from there, and the right slot is
233 /// untouched.
234 #[test]
235 fn compact_posture_bar_states_posture_and_nothing_live() {
236 let mut fixture = working();
237 fixture.right = Some(("/rc connected", ChromeInk::Info));
238 let wide = draw(160, 3, &fixture.widget(&UI_THEME).compact(true));
239 for kept in [" ● ask", " work (Tab)", "/rc connected"] {
240 assert!(wide.contains(kept), "compact keeps {kept}: {wide}");
241 }
242 for gone in [
243 "working 1m 15s",
244 "worked 41m 12s",
245 "Shift+Tab to change",
246 "2 agents",
247 "Esc to interrupt",
248 ] {
249 assert!(!wide.contains(gone), "compact drops {gone}: {wide}");
250 }
251 // The full row at the same width is the row the user had before.
252 assert!(draw(160, 3, &fixture.widget(&UI_THEME)).contains("working 1m 15s"));
253
254 // The cap warning is not a hint: a compact row still says what to do
255 // about a full context.
256 fixture.context_percent = 85;
257 let capped = draw(160, 3, &fixture.widget(&UI_THEME).compact(true));
258 assert!(capped.contains("surface soon"), "{capped}");
259 assert!(!capped.contains("Esc to interrupt"), "{capped}");
260
261 for w in 8..=160u16 {
262 let text = draw(w, 3, &fixture.widget(&UI_THEME).compact(true));
263 assert!(
264 text.contains("ask"),
265 "{w}: the permission chip never sheds: {text}"
266 );
267 assert!(
268 !text.contains("working") && !text.contains("agents"),
269 "{w}: nothing live in a compact row: {text}"
270 );
271 }
272 }
273
274 /// Permission outranks mode when only one posture chip fits: the longest
275 /// mode word must never displace `full access`.
276 #[test]
277 fn posture_bar_permission_outranks_mode_when_only_one_fits() {
278 let mut fixture = working();
279 fixture.permission = ("full access", ChromeInk::PermissionFullAccess);
280 fixture.mode = Some(("operate", ChromeInk::PolicyOperate));
281 let mut saw_permission_alone = false;
282 for width in 8..=120u16 {
283 let text = draw(width, 12, &fixture.widget(&UI_THEME));
284 let has_mode = text.contains("operate");
285 let has_permission = text.contains("full access");
286 assert!(
287 !(has_mode && !has_permission),
288 "width {width} kept the mode word and shed the permission phrase: {text}"
289 );
290 if has_permission && !has_mode {
291 saw_permission_alone = true;
292 }
293 }
294 assert!(saw_permission_alone, "no width shed the mode word alone");
295 }
296
297 /// The cycle keys print only when the caller says the binding is live —
298 /// the launch stage's Tab moves focus, so the mode chip there has no key.
299 #[test]
300 fn posture_bar_prints_cycle_keys_only_when_live() {
301 let mut fixture = working();
302 fixture.mode_key = None;
303 fixture.permission_key = None;
304 let text = draw(120, 30, &fixture.widget(&UI_THEME));
305 assert!(
306 text.contains(" ● ask work working 1m 15s 2 agents worked 41m 12s"),
307 "{text}"
308 );
309 assert!(!text.contains('('), "{text}");
310 }
311
312 #[test]
313 fn posture_bar_ascii_safe_projects_glyphs() {
314 let mut fixture = working();
315 fixture.context_percent = 90;
316 let text = draw(100, 30, &fixture.widget(&UI_THEME).ascii_safe(true));
317 let band = text.lines().last().unwrap_or_default();
318 assert!(band.starts_with(" . ask"), "inset preserved: {band}");
319 assert!(text.contains("^ surface soon"), "{text}");
320 for ch in text.chars() {
321 if ch != '\n' {
322 assert_eq!(ch.width(), Some(1), "ascii-safe single-width: {ch:?}");
323 }
324 }
325 }
326
327 #[test]
328 fn posture_bar_degenerate_sizes_do_not_panic() {
329 for (w, h) in [(0u16, 0), (2, 1), (8, 1), (300, 2)] {
330 let fixture = working();
331 let _ = draw(w, h, &fixture.widget(&UI_THEME));
332 }
333 }
334
335 // ---------------------------------------------------------------------------
336 // Footer-hint retirement: each hint shows at 0 and 1 uses, and is gone at 2.
337 // These drive `tideline_footer_from_app` with a live session `App`, so the
338 // gating between the use counts and the facts is covered, not just the
339 // predicate in `footer_hints`.
340 // ---------------------------------------------------------------------------
341
342 use super::tideline_footer_from_app;
343 use crate::tui::app::{App, OnboardingState};
344 use crate::tui::footer_hints::{
345 AGENT_ARROWS, ENTER_AGAIN, ESC_INTERRUPT, MODE_CYCLE, PERMISSION_CYCLE,
346 };
347
348 fn session_app() -> App {
349 let mut app = crate::test_support::test_app_with_options(
350 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
351 );
352 app.onboarding = OnboardingState::None;
353 app.launch.visible = false;
354 app
355 }
356
357 fn set_uses(app: &mut App, key: &str, uses: u8) {
358 if uses == 0 {
359 app.footer_hint_uses.remove(key);
360 } else {
361 app.footer_hint_uses.insert(key.to_string(), uses);
362 }
363 }
364
365 /// The cycle chords print while their bindings are fresh, and the chips go
366 /// bare — never away — once each binding has been used twice.
367 #[test]
368 fn cycle_keys_show_at_zero_and_one_use_and_go_bare_at_two() {
369 let mut app = session_app();
370 let facts = tideline_footer_from_app(&mut app, 120);
371 assert_eq!(facts.permission_key.as_deref(), Some("Shift+Tab to change"));
372 assert_eq!(facts.mode_key, Some("Tab"));
373
374 for key in [PERMISSION_CYCLE, MODE_CYCLE] {
375 set_uses(&mut app, key, 1);
376 }
377 let facts = tideline_footer_from_app(&mut app, 120);
378 assert_eq!(facts.permission_key.as_deref(), Some("Shift+Tab to change"));
379 assert_eq!(facts.mode_key, Some("Tab"));
380
381 set_uses(&mut app, PERMISSION_CYCLE, 2);
382 let facts = tideline_footer_from_app(&mut app, 120);
383 assert_eq!(facts.permission_key, None);
384 assert_eq!(
385 facts.mode_key,
386 Some("Tab"),
387 "the mode key retires on its own count"
388 );
389
390 set_uses(&mut app, MODE_CYCLE, 2);
391 let facts = tideline_footer_from_app(&mut app, 120);
392 assert_eq!(facts.permission_key, None);
393 assert_eq!(facts.mode_key, None);
394 assert!(
395 !facts.permission_chip.0.is_empty(),
396 "the permission chip never retires with its key"
397 );
398 assert!(
399 facts.mode_chip.is_some(),
400 "the mode chip never retires with its key"
401 );
402 }
403
404 /// A running turn advertises the interrupt affordance until Esc has been
405 /// used to interrupt twice.
406 #[test]
407 fn interrupt_hint_shows_at_zero_and_one_use_and_clears_at_two() {
408 let mut app = session_app();
409 app.is_loading = true;
410 let facts = tideline_footer_from_app(&mut app, 120);
411 let (text, _) = facts.hint.as_ref().expect("a running turn names Esc");
412 assert!(text.contains("Esc"), "{text}");
413
414 set_uses(&mut app, ESC_INTERRUPT, 1);
415 assert!(tideline_footer_from_app(&mut app, 120).hint.is_some());
416
417 set_uses(&mut app, ESC_INTERRUPT, 2);
418 assert!(tideline_footer_from_app(&mut app, 120).hint.is_none());
419 }
420
421 /// #6502: while the workbar owns focus, Esc closes the workbar instead of
422 /// interrupting, so the posture bar must not promise an interrupt.
423 #[test]
424 fn interrupt_hint_stands_down_while_the_workbar_owns_esc() {
425 let mut app = session_app();
426 app.is_loading = true;
427 app.work_surface.focused = true;
428 let facts = tideline_footer_from_app(&mut app, 120);
429 assert!(
430 facts
431 .hint
432 .as_ref()
433 .is_none_or(|(text, _)| !text.contains("Esc")),
434 "{:?}",
435 facts.hint.as_ref().map(|(text, _)| text)
436 );
437 }
438
439 /// The open double-tap window advertises the second Enter until that steer
440 /// has fired twice.
441 #[test]
442 fn enter_again_hint_shows_at_zero_and_one_use_and_clears_at_two() {
443 let mut app = session_app();
444 app.is_loading = true;
445 app.arm_double_tap_window();
446 let facts = tideline_footer_from_app(&mut app, 120);
447 let (text, _) = facts
448 .hint
449 .as_ref()
450 .expect("an open double-tap window names Enter");
451 assert!(text.contains("Enter"), "{text}");
452
453 set_uses(&mut app, ENTER_AGAIN, 1);
454 assert!(tideline_footer_from_app(&mut app, 120).hint.is_some());
455
456 set_uses(&mut app, ENTER_AGAIN, 2);
457 assert!(tideline_footer_from_app(&mut app, 120).hint.is_none());
458 }
459
460 fn completed_subagent(id: &str) -> crate::tools::subagent::SubAgentResult {
461 crate::tools::subagent::SubAgentResult {
462 usage: None,
463 name: id.to_string(),
464 agent_id: id.to_string(),
465 context_mode: "fresh".to_string(),
466 fork_context: false,
467 workspace: None,
468 git_branch: None,
469 agent_type: crate::tools::subagent::FleetRole::Worker,
470 assignment: crate::tools::subagent::SubAgentAssignment {
471 native_preset: None,
472 objective: format!("objective-{id}"),
473 role: Some("worker".to_string()),
474 },
475 model: String::new(),
476 nickname: None,
477 status: crate::tools::subagent::SubAgentStatus::Completed,
478 worker_status: None,
479 runtime_permissions: None,
480 parent_run_id: None,
481 spawn_depth: 0,
482 child_route: None,
483 result: None,
484 steps_taken: 0,
485 checkpoint: None,
486 needs_input: None,
487 duration_ms: 0,
488 started_at: None,
489 from_prior_session: false,
490 idle_ms: None,
491 heartbeat_timeout_ms: None,
492 }
493 }
494
495 /// The empty composer lends its arrows to the roster until those shortcuts
496 /// have been used twice. The roster entry is a finished agent: live progress
497 /// would put the phase back to Working, where the interrupt hint outranks.
498 #[test]
499 fn agent_arrow_hints_show_at_zero_and_one_use_and_clear_at_two() {
500 let mut app = session_app();
501 app.subagent_cache.push(completed_subagent("agent-a"));
502 assert!(
503 tideline_footer_from_app(&mut app, 120).hint.is_some(),
504 "the empty composer lends its arrows to the roster"
505 );
506
507 set_uses(&mut app, AGENT_ARROWS, 1);
508 assert!(tideline_footer_from_app(&mut app, 120).hint.is_some());
509
510 set_uses(&mut app, AGENT_ARROWS, 2);
511 assert!(tideline_footer_from_app(&mut app, 120).hint.is_none());
512 }
513
514 // ---------------------------------------------------------------------------
515 // The working clock (#5914). The founder watching a multi-hour operate
516 // session could not find how long the thing had been working: the classic
517 // footer's `worked` chip went with the legacy footer path (146ab7f756) and
518 // the phase band's `working_detail` went with the 0.9.12 merged shell
519 // (329960fcbf). These pin what came back — both readings, the state word
520 // that says what the clock is counting, and its place in the shed ladder.
521 // ---------------------------------------------------------------------------
522
523 use std::time::{Duration, Instant};
524
525 use super::{ShellPhase, working_clock};
526
527 /// A live turn states both readings: what the session is doing now and for
528 /// how long, then how long it has worked in total.
529 #[test]
530 fn live_turn_clock_states_the_turn_and_the_session() {
531 let mut app = session_app();
532 app.ui_locale = codewhale_localization::Locale::En;
533 app.is_loading = true;
534 app.turn_started_at = Some(Instant::now() - Duration::from_secs(75));
535 app.cumulative_turn_duration = Duration::from_secs(2_400);
536
537 let facts = tideline_footer_from_app(&mut app, 160);
538 let (turn, turn_ink) = facts.turn_clock.expect("a live turn states its elapsed");
539 assert_eq!(turn, "working 1m 15s");
540 assert_eq!(turn_ink, ChromeInk::Active);
541 let (session, session_ink) = facts
542 .session_clock
543 .expect("a working session states its total");
544 assert_eq!(session, "worked 41m 15s");
545 assert_eq!(session_ink, ChromeInk::Active);
546 }
547
548 /// The session reading is model work, not wall clock: it is the sum of
549 /// finished turns plus the live one, so it never jumps at `TurnComplete`.
550 #[test]
551 fn session_reading_carries_finished_turns_plus_the_live_one() {
552 let mut app = session_app();
553 app.ui_locale = codewhale_localization::Locale::En;
554 app.cumulative_turn_duration = Duration::from_secs(3_600);
555
556 // Turn in flight: the finished total plus this turn.
557 app.is_loading = true;
558 app.turn_started_at = Some(Instant::now() - Duration::from_secs(30));
559 let (_, live) = working_clock(&app, ShellPhase::Working, "working");
560 assert_eq!(live.expect("live session clock").0, "worked 60m 30s");
561
562 // Turn done: the engine folded it into the cumulative total, so the
563 // reading is unchanged and the clock stops.
564 app.is_loading = false;
565 app.turn_started_at = None;
566 app.cumulative_turn_duration = Duration::from_secs(3_630);
567 let facts = tideline_footer_from_app(&mut app, 160);
568 assert!(facts.turn_clock.is_none(), "no turn, no turn clock");
569 let (idle, ink) = facts
570 .session_clock
571 .expect("an idle session still states its total");
572 assert_eq!(idle, "worked 60m 30s");
573 assert_eq!(ink, ChromeInk::MetadataValue, "a stopped clock reads quiet");
574 }
575
576 /// #6041: a session's first long turn must not print one duration twice.
577 /// The turn half names the phase; the session half returns only once a
578 /// finished turn makes the totals different.
579 #[test]
580 fn first_turn_does_not_repeat_the_turn_duration_as_a_session_total() {
581 let mut app = session_app();
582 app.ui_locale = codewhale_localization::Locale::En;
583 app.is_loading = true;
584 app.turn_started_at = Some(Instant::now() - Duration::from_secs(22 * 60 + 39));
585
586 let facts = tideline_footer_from_app(&mut app, 160);
587 assert_eq!(
588 facts.turn_clock.expect("the turn clock states the phase").0,
589 "working 22m 39s"
590 );
591 assert!(
592 facts.session_clock.is_none(),
593 "a session total that repeats the turn reading is furniture (#6041)"
594 );
595
596 // One finished turn later the two readings differ, and both are honest.
597 app.cumulative_turn_duration = Duration::from_secs(14 * 60);
598 let facts = tideline_footer_from_app(&mut app, 160);
599 assert_eq!(
600 facts
601 .session_clock
602 .expect("a finished total earns the chip")
603 .0,
604 "worked 36m 39s"
605 );
606 }
607
608 /// Actively working, waiting on a sub-agent, and waiting on you are three
609 /// different readings — a bare duration cannot tell them apart.
610 #[test]
611 fn clock_distinguishes_working_from_waiting_on_something() {
612 let mut app = session_app();
613 app.ui_locale = codewhale_localization::Locale::En;
614 app.is_loading = true;
615 app.turn_started_at = Some(Instant::now() - Duration::from_secs(75));
616 app.cumulative_turn_duration = Duration::from_secs(2_400);
617
618 let working = tideline_footer_from_app(&mut app, 160)
619 .turn_clock
620 .expect("working clock");
621 assert_eq!(working.0, "working 1m 15s");
622 assert_eq!(working.1, ChromeInk::Active);
623
624 // A live sub-agent: the parent turn's clock keeps running, but the word
625 // says what it is waiting on (#5906 is the same founder session).
626 app.agent_progress
627 .insert("agent-a".to_string(), "reading".to_string());
628 let subagents = tideline_footer_from_app(&mut app, 160)
629 .turn_clock
630 .expect("sub-agent clock");
631 assert_eq!(subagents.0, "agents underway 1m 15s");
632 app.agent_progress.clear();
633
634 // Waiting on the user parks the clock in the waiting ink.
635 app.pending_user_input_prompt = Some((
636 "tool-call-1".to_string(),
637 crate::tools::user_input::UserInputRequest {
638 questions: Vec::new(),
639 },
640 ));
641 let waiting = tideline_footer_from_app(&mut app, 160)
642 .turn_clock
643 .expect("waiting clock");
644 assert_eq!(waiting.0, "needs you 1m 15s");
645 assert_eq!(waiting.1, ChromeInk::Waiting);
646 assert_ne!(waiting.1, working.1, "waiting must not read as working");
647 }
648
649 /// A fresh session says nothing: no turn has run, so there is no clock to
650 /// state, and a sub-minute total stays quiet rather than rendering a noisy
651 /// `worked 4s` that immediately ticks.
652 #[test]
653 fn a_session_that_has_not_worked_states_no_clock() {
654 let mut app = session_app();
655 app.ui_locale = codewhale_localization::Locale::En;
656 let facts = tideline_footer_from_app(&mut app, 160);
657 assert!(facts.turn_clock.is_none());
658 assert!(facts.session_clock.is_none());
659
660 app.cumulative_turn_duration = Duration::from_secs(42);
661 assert!(
662 tideline_footer_from_app(&mut app, 160)
663 .session_clock
664 .is_none(),
665 "under a minute the session total stays quiet"
666 );
667
668 // A live turn states its own elapsed from the first second, though — the
669 // whole point is telling a stuck turn from a working one.
670 app.is_loading = true;
671 app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));
672 let facts = tideline_footer_from_app(&mut app, 160);
673 assert_eq!(
674 facts.turn_clock.expect("a live turn always times itself").0,
675 "working 3s"
676 );
677 assert!(
678 facts.session_clock.is_none(),
679 "the sub-minute total is still quiet"
680 );
681 }
682
683 /// #6084: when the session half will not paint, the shed ladder must not
684 /// drop the turn clock at the turn-only rung — that would leave no clock
685 /// at all. Find a width where both-clocks already sheds the turn half while
686 /// keeping the session half; with only the turn clock, that width must keep
687 /// it (shed at the session-clock rung instead).
688 #[test]
689 fn absent_session_clock_keeps_turn_past_the_turn_only_rung() {
690 let mut first_turn = working();
691 first_turn.session_clock = None;
692 let both = working();
693 let width = (8..=160u16)
694 .find(|&w| {
695 let text = draw(w, 3, &both.widget(&UI_THEME));
696 text.contains("worked 41m 12s") && !text.contains("working 1m 15s")
697 })
698 .expect("both-clocks fixture must shed turn before session at some width");
699 let text = draw(width, 3, &first_turn.widget(&UI_THEME));
700 assert!(
701 text.contains("working 1m 15s"),
702 "{width}: with no session half, the turn clock must survive the turn-only shed rung (#6084): {text}"
703 );
704 }
705
706 /// Narrow terminals shed both clock halves before the hint and the counts,
707 /// the turn half before the session half, and neither reading is ever
708 /// painted half-truncated.
709 #[test]
710 fn narrow_widths_shed_the_clocks_before_the_hint_and_counts() {
711 let fixture = working();
712 for w in 8..=160u16 {
713 let text = draw(w, 3, &fixture.widget(&UI_THEME));
714 let turn = text.contains("working 1m 15s");
715 let session = text.contains("worked 41m 12s");
716 assert!(
717 !turn || session,
718 "{w} kept the turn clock and dropped the session clock: {text}"
719 );
720 assert!(
721 !session || text.contains("Esc to interrupt"),
722 "{w} kept the session clock and dropped the hint: {text}"
723 );
724 assert!(
725 !text.contains("Esc to interrupt") || text.contains("2 agents"),
726 "{w} kept the hint and dropped the counts: {text}"
727 );
728 // Whole readings or none: a clipped duration is a lie.
729 assert!(
730 !text.contains("working 1m") || turn,
731 "{w} painted a half-truncated turn clock: {text}"
732 );
733 assert!(
734 !text.contains("worked 41m") || session,
735 "{w} painted a half-truncated session clock: {text}"
736 );
737 }
738 }
739
740 #[test]
741 fn scheduled_count_opens_automations_directly() {
742 let mut app =
743 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
744 app.automation_panel.active_automations = 2;
745 let facts = super::tideline_footer_from_app(&mut app, 140);
746 assert_eq!(
747 facts.count_actions.last(),
748 Some(&crate::tui::tideline::InteractionAction::OpenAutomations)
749 );
750 }
751
752 #[test]
753 fn posture_shortcuts_recede_without_changing_count_click_targets() {
754 use ratatui::{buffer::Buffer, style::Modifier};
755 let fixture = working();
756 let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
757 let targets = render_tideline_footer(buf.area, &mut buf, &fixture.widget(&UI_THEME));
758 let text: String = buf.content().iter().map(|cell| cell.symbol()).collect();
759 let column = |needle: &str| text[..text.find(needle).unwrap()].chars().count() as u16;
760 let label = column("ask");
761 let key = column("Shift+Tab to change");
762 assert!(buf[(label, 0)].modifier.contains(Modifier::BOLD));
763 assert!(!buf[(key, 0)].modifier.contains(Modifier::BOLD));
764 assert_eq!(
765 buf[(key, 0)].fg,
766 super::tchrome(&UI_THEME, ChromeInk::MetadataHint)
767 .fg
768 .unwrap()
769 );
770 assert_ne!(buf[(label, 0)].fg, buf[(key, 0)].fg);
771 assert_eq!(targets.len(), 1);
772 let (index, target) = targets[0];
773 assert_eq!(index, 0);
774 let painted: String = (target.x..target.right())
775 .map(|x| buf[(x, 0)].symbol())
776 .collect();
777 assert_eq!(painted, "2 agents");
778 }
779
779 lines RUST