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