From b3dfd01db20ca3570c7f2cfe68c9665825539b19 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 16:23:23 -0700 Subject: [PATCH 1/3] fix: update actions/checkout version to v7 in workflows --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cd47ef..a9a4558 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - run: rustup update stable && rustup default stable - run: cargo test --verbose - run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93fd5d0..3206463 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - run: rustup update stable && rustup default stable - run: cargo clippy --all-targets -- -D warnings - run: cargo test --verbose @@ -28,7 +28,7 @@ jobs: name: fleetcom MSRV (rust-version floor) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Verify the package with its declared minimum Rust version. - run: rustup update 1.88 && rustup default 1.88 - run: cargo check --all-targets From fd2dc76e9dad2d18e19713c1dfbd0a0ab5c44fee Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 16:31:44 -0700 Subject: [PATCH 2/3] fix: match the waiting-row family and exempt blanks from the scan window --- src/harness/summary.rs | 181 ++++++++++++++---- tests/corpus/README.md | 7 +- tests/corpus/preview_claude_workflow_wait.bin | 31 +++ 3 files changed, 175 insertions(+), 44 deletions(-) create mode 100644 tests/corpus/preview_claude_workflow_wait.bin diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 955cf84..886cb6d 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -86,12 +86,17 @@ fn is_rule_row(row: &str) -> bool { /// space and an `…`-terminated status phrase. const CLAUDE_SPINNER: &[char] = &['·', '✢', '✳', '✶', '✻', '✽']; -/// Maximum rows scanned above the input box for a status row. This covers an -/// indented task-list block while keeping the scan pinned to nearby chrome. +/// Maximum content rows scanned above the input box for a status row. The +/// window exists to stop body-wandering, so only rows that could carry text +/// consume it: the indented class and the deciding column-0 row. Blank rows +/// skip free — the workflows view pads with tall blank runs by design, and a +/// blank carries no false-positive risk. This covers an indented task-list +/// block while keeping the scan pinned to nearby chrome. const CLAUDE_STATUS_WINDOW: usize = 16; /// claude (alt screen). Working state: a column-0 spinner row above the -/// input box's top separator, within [`CLAUDE_STATUS_WINDOW`] rows of it. +/// input box's top separator, within [`CLAUDE_STATUS_WINDOW`] content rows +/// of it. /// Approval state: the dialog replaces the input box entirely; the menu /// match fires only when that box is gone. pub struct ClaudeSummary; @@ -137,13 +142,23 @@ fn claude_box_top(rows: &[String]) -> Option { .then_some(top) } -/// Scan above the input box for a spinner or waiting row. Empty and indented -/// hint/task-list rows may separate it from the box. Any other column-0 row, -/// including body prose or a wrapped status tail, invalidates the structure. +/// Scan above the input box for a spinner or waiting row. Blank rows skip +/// without consuming the window; indented hint/task-list rows consume it and +/// skip. Any other column-0 row, including body prose or a wrapped status +/// tail, invalidates the structure — that abort is the only false-positive +/// fence, and the grid above the box bounds the iteration itself. fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> { - for i in (top.saturating_sub(CLAUDE_STATUS_WINDOW)..top).rev() { + let mut content = 0usize; + for i in (0..top).rev() { let row = &rows[i]; - if row.is_empty() || row.starts_with(' ') { + if row.is_empty() { + continue; + } + content += 1; + if content > CLAUDE_STATUS_WINDOW { + return None; + } + if row.starts_with(' ') { continue; } if let Some(verb) = claude_spinner_text(row) { @@ -160,7 +175,7 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati // Waiting rows need no action-row probe: col-0 `⏺` rows above are // body prose, and probing them only widens false positives. if let Some(waiting) = claude_waiting_text(row) { - return Some((waiting, "claude:waiting-agents")); + return Some((waiting, "claude:waiting")); } // Foreign column-0 row: abort (see above). return None; @@ -168,20 +183,28 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati None } -/// Match `Waiting for {n} background agent(s) to finish` after a recognized -/// spinner frame. Keeping this separate from ellipsis-terminated spinner rows -/// prevents `·` body bullets from matching. +/// Match the waiting-row family after a recognized spinner frame: +/// `Waiting for {n} {noun} to finish`, kept verbatim. The specificity lives +/// in the head+digits+tail skeleton, not an exact noun — the harness varies +/// the noun (observed so far: `background agent(s)`, `dynamic workflow(s)`) +/// and enumerating them is a treadmill. The one-to-three-word middle keeps +/// `·`-bullet body sentences from matching. fn claude_waiting_text(row: &str) -> Option { let mut chars = row.chars(); if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' { return None; } let text = chars.as_str(); - let n = text.strip_prefix("Waiting for ")?; - let digits = n.chars().take_while(char::is_ascii_digit).count(); - let tail = &n[digits..]; - (digits >= 1 - && (tail == " background agent to finish" || tail == " background agents to finish")) + let rest = text.strip_prefix("Waiting for ")?; + let digits = rest.chars().take_while(char::is_ascii_digit).count(); + if digits == 0 { + return None; + } + let middle = rest[digits..] + .strip_prefix(' ')? + .strip_suffix(" to finish")?; + (1..=3) + .contains(&middle.split_whitespace().count()) .then(|| text.to_string()) } @@ -787,10 +810,11 @@ mod tests { ); } - /// The ellipsis-less waiting row extracts verbatim for singular and plural - /// counts; near-miss shapes remain foreign and abort. + /// The ellipsis-less waiting family extracts verbatim across its nouns; + /// shapes outside the head+digits+middle+tail skeleton remain foreign + /// and abort. #[test] - fn claude_waiting_row_matches_exactly_and_never_probes() { + fn claude_waiting_family_matches_the_skeleton_and_never_probes() { let sep = "─".repeat(120); let spin = |row: &str| { let rows = [row, &sep, "❯", &sep]; @@ -799,28 +823,28 @@ mod tests { for row in [ "✻ Waiting for 1 background agent to finish", "· Waiting for 1 background agent to finish", + "✻ Waiting for 3 background agents to finish", + "✻ Waiting for 1 dynamic workflow to finish", + "✽ Waiting for 3 dynamic workflows to finish", + "✻ Waiting for 2 tasks to finish", ] { - assert_eq!( - spin(row), - Some(( - "Waiting for 1 background agent to finish".to_string(), - "claude:waiting-agents" - )), - "{row:?}" - ); + let want = row.chars().skip(2).collect::(); + assert_eq!(spin(row), Some((want, "claude:waiting")), "{row:?}"); } - assert_eq!( - spin("✻ Waiting for 3 background agents to finish"), - Some(( - "Waiting for 3 background agents to finish".to_string(), - "claude:waiting-agents" - )) - ); - // Wrong shapes abort to fall-through, glyph or not: the pattern - // carries the specificity, not the frame. - assert_eq!(spin("✻ Waiting patiently"), None); - assert_eq!(spin("· Waiting for review comments to land"), None); + // Outside the skeleton: no digits, a middle wider than three words, + // a foreign tail, or no middle at all. Each aborts to fall-through, + // glyph or not — the pattern carries the specificity, not the frame. + for row in [ + "✻ Waiting patiently", + "· Waiting for review comments to land", + "✻ Waiting for some agents to finish", + "✻ Waiting for 2 very long noun phrases here to finish", + "✻ Waiting for 2 agents to start", + "✻ Waiting for 3 to finish", + ] { + assert_eq!(spin(row), None, "{row:?}"); + } // An action row above the waiting row is body prose in this state // and must not win the head. @@ -836,7 +860,7 @@ mod tests { ClaudeSummary.live_preview(&rs(&rows)), Some(( "Waiting for 1 background agent to finish".to_string(), - "claude:waiting-agents" + "claude:waiting" )) ); } @@ -950,7 +974,7 @@ mod tests { behind_gap("✻ Waiting for 2 background agents to finish", 5), Some(( "Waiting for 2 background agents to finish".to_string(), - "claude:waiting-agents" + "claude:waiting" )) ); @@ -967,6 +991,70 @@ mod tests { assert_eq!(ClaudeSummary.live_preview(&prose), None); } + /// Blank rows do not consume the window: the workflows view pads with + /// tall blank runs, and only content rows carry false-positive risk. + #[test] + fn claude_blank_rows_do_not_consume_the_window() { + let sep = "─".repeat(120); + let resolve = |rows: Vec| { + let refs: Vec<&str> = rows.iter().map(String::as_str).collect(); + ClaudeSummary.live_preview(&rs(&refs)) + }; + let boxed = |sep: &str| [sep.to_string(), "❯ /workflows".to_string(), sep.to_string()]; + + // The sighted layout: the waiting row above a 19-blank run. + let mut rows = vec!["✻ Waiting for 1 dynamic workflow to finish".to_string()]; + rows.extend(std::iter::repeat_n(String::new(), 19)); + rows.extend(boxed(&sep)); + assert_eq!( + resolve(rows), + Some(( + "Waiting for 1 dynamic workflow to finish".to_string(), + "claude:waiting" + )) + ); + + // A mixed gap: blanks interleave the indented rows and only the + // indented rows spend budget, so fifteen still reach the spinner. + let mut rows = vec!["✢ Running phase 1 (dashboard UI)… (4m 20s)".to_string()]; + for i in 0..15 { + rows.push(String::new()); + rows.push(format!(" ◼ Phase {i}: generic step")); + } + rows.extend(boxed(&sep)); + assert_eq!( + resolve(rows), + Some(( + "Running phase 1 (dashboard UI)…".to_string(), + "claude:spinner" + )) + ); + + // Sixteen indented rows exhaust the budget, blanks or not. + let mut rows = vec!["✢ Running phase 1 (dashboard UI)… (4m 20s)".to_string()]; + for i in 0..16 { + rows.push(String::new()); + rows.push(format!(" ◼ Phase {i}: generic step")); + } + rows.extend(boxed(&sep)); + assert_eq!(resolve(rows), None); + + // A column-0 prose row inside a blank run still aborts: blanks + // relax the budget, never the fence. + let prose = rs(&[ + "✻ Hashing… (6s · ↓ 87 tokens)", + "", + "", + "⏺ The workflow report lands below.", + "", + "", + &sep, + "❯ /workflows", + &sep, + ]); + assert_eq!(ClaudeSummary.live_preview(&prose), None); + } + /// The approval menu synthesizes its label only with the input box gone, /// and requires the `2.` sibling below the selector. #[test] @@ -1279,7 +1367,16 @@ mod tests { &ClaudeSummary, // The welcome box is absent, so there is no model prefix. "Waiting for 1 background agent to finish", - "claude:waiting-agents", + "claude:waiting", + ), + Case( + "preview_claude_workflow_wait", + include_bytes!("../../tests/corpus/preview_claude_workflow_wait.bin"), + &ClaudeSummary, + // Exact-text equality also proves the `4/7 agents done` + // roster row below the box never entered the extraction. + "Waiting for 1 dynamic workflow to finish", + "claude:waiting", ), Case( "preview_codex_hint_row", diff --git a/tests/corpus/README.md b/tests/corpus/README.md index ccf769e..9a7a30d 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -41,7 +41,9 @@ The Codex hint-row and approval fixtures use approximate indentation, so their tests match trimmed heads and column-0 structure. The Claude waiting fixture omits the welcome box and includes agent-roster rows below the input box. The Claude task-list fixtures use generic phase names in a task-list layout; both -omit the welcome box. +omit the welcome box. The Claude workflow-wait fixture keeps the sighted +layout (2026-07-20, claude 2.1.215) with its prose paraphrased to generic +wording. | Fixture | Scenario | Coverage | | --- | --- | --- | @@ -62,7 +64,8 @@ omit the welcome box. | `preview_codex_hint_row.bin` | codex working with `tab to queue message` below the composer, no token bar | `codex:working` through the composer pin; no model prefix without the bar | | `preview_codex_approval.bin` | codex approval modal: composer and token bar replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | | `preview_codex_body_menu.bin` | modal-shaped menu quoted in the body, live composer below | negative: the composer's presence suppresses the modal match; floor tier reports | -| `preview_claude_waiting.bin` | claude waiting on a backgrounded subagent, `⏺` prose and agent roster around the box | `claude:waiting-agents` extracts the ellipsis-less row verbatim; no model label mid-session | +| `preview_claude_waiting.bin` | claude waiting on a backgrounded subagent, `⏺` prose and agent roster around the box | `claude:waiting` extracts the ellipsis-less row verbatim; no model label mid-session | +| `preview_claude_workflow_wait.bin` | claude waiting on a dynamic workflow, 19 blank rows above the box, workflow roster below | `claude:waiting` family match across a blank run; the roster row stays out of scan | | `preview_grok_working.bin` | grok braille spinner with elapsed/throughput ticker | `grok:spinner` cut at the label's `…`; border label read | | `preview_grok_worked.bin` | grok `Worked for 8.7s` completion row above the box | `grok:worked` kept verbatim | | `preview_grok_idle.bin` | grok idle session | fall-through to the marker | diff --git a/tests/corpus/preview_claude_workflow_wait.bin b/tests/corpus/preview_claude_workflow_wait.bin new file mode 100644 index 0000000..50ac672 --- /dev/null +++ b/tests/corpus/preview_claude_workflow_wait.bin @@ -0,0 +1,31 @@ +[?1049h⏺ I queued the workflow and will report when it completes. + +⏺ Waiting on the remaining workflow agents now. + +✻ Waiting for 1 dynamic workflow to finish + + + + + + + + + + + + + + + + + + + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ /workflows +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + (statusline scrubbed: user-configured row) + ⏵⏵ auto mode on (shift+tab to cycle) + +◯ some-workflow Generic audit description 4/7 agents done · 5m 27s · ↓ 1.2m tokens \ No newline at end of file From 94b43a4f40221c6db7a47d3dc356b74a7a452380 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Mon, 20 Jul 2026 16:40:23 -0700 Subject: [PATCH 3/3] fix: refine comments and documentation for clarity in summary and README --- src/harness/summary.rs | 61 +++++++++++++++--------------------------- tests/corpus/README.md | 8 +++--- 2 files changed, 26 insertions(+), 43 deletions(-) diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 886cb6d..8222cfe 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -14,7 +14,7 @@ //! //! 1. locates the chrome region structurally (claude's separator-pair input //! box, codex's status bar and composer, grok's bordered input box) and -//! scans only rows pinned to it, never the whole grid; +//! limits status candidates relative to it; //! 2. returns `None` when the expected structure is absent or inconsistent; //! 3. matches row prefixes so status rows truncated with an ellipsis at narrow //! widths remain recognizable. A wrapped row fails the structural check. @@ -86,16 +86,12 @@ fn is_rule_row(row: &str) -> bool { /// space and an `…`-terminated status phrase. const CLAUDE_SPINNER: &[char] = &['·', '✢', '✳', '✶', '✻', '✽']; -/// Maximum content rows scanned above the input box for a status row. The -/// window exists to stop body-wandering, so only rows that could carry text -/// consume it: the indented class and the deciding column-0 row. Blank rows -/// skip free — the workflows view pads with tall blank runs by design, and a -/// blank carries no false-positive risk. This covers an indented task-list -/// block while keeping the scan pinned to nearby chrome. +/// Maximum nonblank rows inspected above the input box. Blank rows do not +/// consume the limit; indented hint and task-list rows do. const CLAUDE_STATUS_WINDOW: usize = 16; /// claude (alt screen). Working state: a column-0 spinner row above the -/// input box's top separator, within [`CLAUDE_STATUS_WINDOW`] content rows +/// input box's top separator, within [`CLAUDE_STATUS_WINDOW`] nonblank rows /// of it. /// Approval state: the dialog replaces the input box entirely; the menu /// match fires only when that box is gone. @@ -142,11 +138,9 @@ fn claude_box_top(rows: &[String]) -> Option { .then_some(top) } -/// Scan above the input box for a spinner or waiting row. Blank rows skip -/// without consuming the window; indented hint/task-list rows consume it and -/// skip. Any other column-0 row, including body prose or a wrapped status -/// tail, invalidates the structure — that abort is the only false-positive -/// fence, and the grid above the box bounds the iteration itself. +/// Scan upward from the input box for a spinner or waiting row. Blank rows do +/// not consume the window; indented rows do. The first other column-0 row, +/// including body prose or a wrapped status tail, invalidates the structure. fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> { let mut content = 0usize; for i in (0..top).rev() { @@ -172,8 +166,7 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati } return Some((format!("{verb}{tail}"), "claude:spinner")); } - // Waiting rows need no action-row probe: col-0 `⏺` rows above are - // body prose, and probing them only widens false positives. + // Action-row lookup applies only to ellipsis-terminated spinner rows. if let Some(waiting) = claude_waiting_text(row) { return Some((waiting, "claude:waiting")); } @@ -183,12 +176,8 @@ fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'stati None } -/// Match the waiting-row family after a recognized spinner frame: -/// `Waiting for {n} {noun} to finish`, kept verbatim. The specificity lives -/// in the head+digits+tail skeleton, not an exact noun — the harness varies -/// the noun (observed so far: `background agent(s)`, `dynamic workflow(s)`) -/// and enumerating them is a treadmill. The one-to-three-word middle keeps -/// `·`-bullet body sentences from matching. +/// Match a spinner-framed `Waiting for {digits} {subject} to finish` row and +/// return its text verbatim. The subject must contain one to three words. fn claude_waiting_text(row: &str) -> Option { let mut chars = row.chars(); if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' { @@ -810,9 +799,8 @@ mod tests { ); } - /// The ellipsis-less waiting family extracts verbatim across its nouns; - /// shapes outside the head+digits+middle+tail skeleton remain foreign - /// and abort. + /// Waiting rows return verbatim; malformed skeletons fail and do not + /// trigger an action-row lookup. #[test] fn claude_waiting_family_matches_the_skeleton_and_never_probes() { let sep = "─".repeat(120); @@ -832,9 +820,8 @@ mod tests { assert_eq!(spin(row), Some((want, "claude:waiting")), "{row:?}"); } - // Outside the skeleton: no digits, a middle wider than three words, - // a foreign tail, or no middle at all. Each aborts to fall-through, - // glyph or not — the pattern carries the specificity, not the frame. + // Reject missing digits, more than three subject words, a foreign + // suffix, or a missing subject. for row in [ "✻ Waiting patiently", "· Waiting for review comments to land", @@ -846,8 +833,7 @@ mod tests { assert_eq!(spin(row), None, "{row:?}"); } - // An action row above the waiting row is body prose in this state - // and must not win the head. + // Waiting rows return without probing the action row above them. let rows = [ "⏺ Running 1 shell command…", "", @@ -991,8 +977,7 @@ mod tests { assert_eq!(ClaudeSummary.live_preview(&prose), None); } - /// Blank rows do not consume the window: the workflows view pads with - /// tall blank runs, and only content rows carry false-positive risk. + /// Blank rows do not consume the nonblank-row window. #[test] fn claude_blank_rows_do_not_consume_the_window() { let sep = "─".repeat(120); @@ -1002,7 +987,7 @@ mod tests { }; let boxed = |sep: &str| [sep.to_string(), "❯ /workflows".to_string(), sep.to_string()]; - // The sighted layout: the waiting row above a 19-blank run. + // Nineteen blank rows separate the waiting row from the input box. let mut rows = vec!["✻ Waiting for 1 dynamic workflow to finish".to_string()]; rows.extend(std::iter::repeat_n(String::new(), 19)); rows.extend(boxed(&sep)); @@ -1014,8 +999,8 @@ mod tests { )) ); - // A mixed gap: blanks interleave the indented rows and only the - // indented rows spend budget, so fifteen still reach the spinner. + // Fifteen indented rows plus the spinner fill the 16-row window; + // interleaved blank rows do not affect the count. let mut rows = vec!["✢ Running phase 1 (dashboard UI)… (4m 20s)".to_string()]; for i in 0..15 { rows.push(String::new()); @@ -1030,7 +1015,7 @@ mod tests { )) ); - // Sixteen indented rows exhaust the budget, blanks or not. + // Sixteen indented rows plus the spinner exceed the window. let mut rows = vec!["✢ Running phase 1 (dashboard UI)… (4m 20s)".to_string()]; for i in 0..16 { rows.push(String::new()); @@ -1039,8 +1024,7 @@ mod tests { rows.extend(boxed(&sep)); assert_eq!(resolve(rows), None); - // A column-0 prose row inside a blank run still aborts: blanks - // relax the budget, never the fence. + // An intervening column-0 prose row still aborts the scan. let prose = rs(&[ "✻ Hashing… (6s · ↓ 87 tokens)", "", @@ -1373,8 +1357,7 @@ mod tests { "preview_claude_workflow_wait", include_bytes!("../../tests/corpus/preview_claude_workflow_wait.bin"), &ClaudeSummary, - // Exact-text equality also proves the `4/7 agents done` - // roster row below the box never entered the extraction. + // The roster below the input box is excluded from the status. "Waiting for 1 dynamic workflow to finish", "claude:waiting", ), diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 9a7a30d..1881932 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -41,9 +41,9 @@ The Codex hint-row and approval fixtures use approximate indentation, so their tests match trimmed heads and column-0 structure. The Claude waiting fixture omits the welcome box and includes agent-roster rows below the input box. The Claude task-list fixtures use generic phase names in a task-list layout; both -omit the welcome box. The Claude workflow-wait fixture keeps the sighted -layout (2026-07-20, claude 2.1.215) with its prose paraphrased to generic -wording. +omit the welcome box. The Claude workflow-wait fixture uses generic wording, +omits the welcome box, and includes a long blank gap above the input box and a +roster below it. | Fixture | Scenario | Coverage | | --- | --- | --- | @@ -65,7 +65,7 @@ wording. | `preview_codex_approval.bin` | codex approval modal: composer and token bar replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | | `preview_codex_body_menu.bin` | modal-shaped menu quoted in the body, live composer below | negative: the composer's presence suppresses the modal match; floor tier reports | | `preview_claude_waiting.bin` | claude waiting on a backgrounded subagent, `⏺` prose and agent roster around the box | `claude:waiting` extracts the ellipsis-less row verbatim; no model label mid-session | -| `preview_claude_workflow_wait.bin` | claude waiting on a dynamic workflow, 19 blank rows above the box, workflow roster below | `claude:waiting` family match across a blank run; the roster row stays out of scan | +| `preview_claude_workflow_wait.bin` | claude waiting on a dynamic workflow, with 19 blank rows before the input box and a workflow roster below it | `claude:waiting` matches across the blank rows; the roster is excluded | | `preview_grok_working.bin` | grok braille spinner with elapsed/throughput ticker | `grok:spinner` cut at the label's `…`; border label read | | `preview_grok_worked.bin` | grok `Worked for 8.7s` completion row above the box | `grok:worked` kept verbatim | | `preview_grok_idle.bin` | grok idle session | fall-through to the marker |