| 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 |