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