diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 2687a2c..2355e02 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -25,11 +25,13 @@ //! 2. Return `None` when the expected structure is absent or inconsistent. //! 3. Preserve CLI-generated ellipsis truncation. //! -//! Remove spinner glyphs, elapsed counters, throughput data, and key hints during -//! normalization; preserve the CLI's status text. The only synthesized status is -//! `awaiting approval`, for approval menus (claude's dialog and codex's modal) and -//! for omp's `!` title, which is that harness's only blocked signal. Supported -//! screen structures are recorded in the corpus fixtures in `tests/corpus`. +//! During normalization, omit spinner glyphs, elapsed counters, throughput data, +//! and key hints; preserve the CLI's status text. Synthesize `awaiting approval` +//! only for approval menus (Claude's dialog and Codex's modal), omp's `!` title +//! (its only blocked signal), and grok's blocked titles (`⚠ Action Required`, +//! `Running: Plan: Exit`, `Running: Ask: …`). For grok's blocked states, normalize +//! the announced title; no structural match exists for them. See `tests/corpus` +//! for supported screen structures. use crate::preview::SummaryAdapter; @@ -656,10 +658,14 @@ fn codex_working(header: &str, after_paren: &str) -> String { // ------------------------------------------------------------------ grok -- -/// grok (alt screen). The pin is its bordered input box; the status row -/// (braille spinner while working, `Worked for {n}s` after a turn, or -/// `◎ … still running` / `◎ waiting` while background work is live) is -/// the first painted row above the box's top border. +/// grok (alt screen). Use the bordered input box as the structural anchor. Read +/// the status from the first painted row above the box's top border: a braille +/// spinner while working, `Worked for {n}s` after a turn, or `◎ … still running` +/// / `◎ waiting` while background work is live. Limit this tier to working and +/// completion rows. Handle the three blocked states (permission prompt, plan +/// approval, question) through title normalization: expect a `┃`-fenced dialog +/// in place of the box for permission prompts and questions, and `◆` in the +/// probe position for plan approval. See [`SummaryAdapter::normalize_title`]. pub struct GrokSummary; impl SummaryAdapter for GrokSummary { @@ -686,6 +692,45 @@ impl SummaryAdapter for GrokSummary { let (_, bottom) = grok_input_box(rows)?; grok_border_label(&rows[bottom]) } + + /// Parse grok 1.0.50's raw `SetTitle` announcements, unchanged by theme + /// processing. Observed forms: + /// - Splash: `grok`. + /// - Idle: `{summary} - grok`. + /// - Working before the session is named: `{frame} - {status} - grok`. + /// - Working after the session is named: `{frame} - {status} - {summary} - grok`. + /// - Permission prompt: `⚠ Action Required - {frame} - {status}… - grok`. + /// + /// Use the single braille character as the frame. Treat the status as grok's + /// live phrase, truncated with its own `…` at roughly 40 columns, and the + /// summary as the session name. + /// + /// Map the three blocked states to [`AWAITING_APPROVAL`]: match + /// `⚠ Action Required` for permission prompts, `Running: Plan: Exit` for plan + /// approval, and the `Running: Ask: ` prefix for questions. For other working + /// titles, replace the frame with `⠋` and keep the first status segment. For + /// idle titles, return the summary. Treat ` - ` as the status/session + /// delimiter. Keep only the first segment after the frame as the status and + /// discard the remainder, even if the status contains that delimiter. + /// + /// Return `None` for a title without the ` - grok` suffix, bare `grok`, or an + /// empty status or summary. On grok's alternate screen, render the captured + /// title verbatim when normalization returns `None`. + fn normalize_title(&self, title: &str) -> Option { + let rest = title.strip_suffix(" - grok")?; + if rest.starts_with("⚠ Action Required") { + return Some(AWAITING_APPROVAL.to_string()); + } + let Some(working) = after_frame(rest, braille_frame).and_then(|r| r.strip_prefix("- ")) + else { + return (!rest.is_empty()).then(|| rest.to_string()); + }; + let status = working.find(" - ").map_or(working, |i| &working[..i]); + if status == "Running: Plan: Exit" || status.starts_with("Running: Ask: ") { + return Some(AWAITING_APPROVAL.to_string()); + } + (!status.is_empty()).then(|| format!("⠋ {status}")) + } } /// grok's input box: the bottom-most `╰…╯` border (the splash panel's box diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index e1ca8b8..feb9da3 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -1206,6 +1206,75 @@ fn grok_still_running_shapes() { ); } +/// Test each observed ` - `-joined title form from grok 1.0.50. Expect the +/// session name for idle titles, a normalized frame and first status segment for +/// working titles, and the blocked status for the three blocked forms. Return +/// `None` for bare `grok` and titles without the suffix. +#[test] +fn grok_title_segments_decode_state_and_status() { + let t = |title: &str| GrokSummary.normalize_title(title); + + assert_eq!( + t("Run ls -la list directory file names - grok"), + Some("Run ls -la list directory file names".to_string()) + ); + + // Exercise every working frame with and without a session name; expect the + // same normalized title for both forms. + for frame in BRAILLE_FRAMES { + assert_eq!( + t(&format!("{frame} - Waiting for response… - grok")), + Some("⠋ Waiting for response…".to_string()), + "{frame:?}" + ); + assert_eq!( + t(&format!( + "{frame} - Thinking - Run ls -la list directory file names - grok" + )), + Some("⠋ Thinking".to_string()), + "{frame:?}" + ); + } + // Treat statuses without grok's trailing `…`, including `Not responding`, + // as ordinary working states. + assert_eq!( + t("⠼ - Running: verbose|clap|arg\\( - Add verbose flag to main.rs - grok"), + Some("⠋ Running: verbose|clap|arg\\(".to_string()) + ); + assert_eq!( + t("⠋ - Not responding - grok"), + Some("⠋ Not responding".to_string()) + ); + // For statuses containing ` - `, keep only the first segment. + assert_eq!( + t("⠙ - Running: Edit a - b.rs - Add verbose flag to main.rs - grok"), + Some("⠋ Running: Edit a".to_string()) + ); + + // Identify permission prompts by the prefix; identify plan approval and + // questions from their status strings. + for title in [ + "⚠ Action Required - ⠙ - Create junk/a.txt then remove the direct… - grok", + "⚠ Action Required - grok", + "⠹ - Running: Plan: Exit - Add verbose flag to main.rs - grok", + "⠼ - Running: Ask: Which greeting do you pre… - Ask preferred greeting hello versus hi - grok", + ] { + assert_eq!(t(title), Some(AWAITING_APPROVAL.to_string()), "{title:?}"); + } + + // Return `None` for bare, foreign, suffix-less, and empty-status titles. + // On the alternate screen, use that result to display the captured title. + for title in [ + "grok", + "⠋ - Thinking", + "⠋ - - grok", + "π > fix the parser", + "", + ] { + assert_eq!(t(title), None, "{title:?}"); + } +} + /// omp title normalization strips idle and disabled-state prefixes, folds /// working frames to `⠋`, maps `!` to the blocked status, and rejects /// unsupported shapes. @@ -1545,9 +1614,10 @@ fn corpus_positive_states_resolve_exactly() { "grok:still-running", ), ]; - // Title-tier positives: a retained primary title the adapter recognizes, - // on a screen that anchors nothing. - let titles: [(&str, &[u8], &dyn SummaryAdapter, &str); 2] = [ + // Exercise both title paths against screens without structural anchors: + // retained OSC titles on the primary screen and live announces on the + // alternate screen. + let titles: [(&str, &[u8], &dyn SummaryAdapter, &str); 6] = [ ( "preview_omp_idle_titled", include_bytes!("../../tests/corpus/preview_omp_idle_titled.bin"), @@ -1560,6 +1630,33 @@ fn corpus_positive_states_resolve_exactly() { &OmpSummary, "⠋ List files in current directory", ), + ( + "preview_grok_idle_titled", + include_bytes!("../../tests/corpus/preview_grok_idle_titled.bin"), + &GrokSummary, + "Run ls -la list directory file names", + ), + // Expect a `┃`-fenced option list instead of the input box. + ( + "preview_grok_permission", + include_bytes!("../../tests/corpus/preview_grok_permission.bin"), + &GrokSummary, + AWAITING_APPROVAL, + ), + // Exercise the title tier with the input box present and + // `◆ Waiting on plan approval` in the probe row. + ( + "preview_grok_plan_approval", + include_bytes!("../../tests/corpus/preview_grok_plan_approval.bin"), + &GrokSummary, + AWAITING_APPROVAL, + ), + ( + "preview_grok_question", + include_bytes!("../../tests/corpus/preview_grok_question.bin"), + &GrokSummary, + AWAITING_APPROVAL, + ), ]; // Fixture names start with the program word: require a positive case for // every registered harness, in whichever tier carries its state. diff --git a/tests/corpus/README.md b/tests/corpus/README.md index a45aadc..bd2778d 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -59,7 +59,10 @@ structural tier, since its chrome is user-themeable, so these fixtures pin that nothing anchors on them and that the title tier carries omp's state. The titled fixtures reuse their untitled rows verbatim behind a title announce: `ESC]0;π > fix the parser BEL` for idle and `ESC]0;π ⠇ List files in current directory -BEL` for working. +BEL` for working. Capture grok 1.0.50's title announce immediately after entry +into the alternate screen. For these blocked states, title normalization is the +only matching path. Use a same-width synthetic value for the header path to +prevent a byte-for-byte match with the captured header. | Fixture | Scenario | Coverage | | --- | --- | --- | @@ -90,6 +93,10 @@ BEL` for working. | `preview_grok_subagent_scrollback.bin` | grok idle with `Subagent running:` in the body, no `◎` row | fall-through; body-shaped text is not status | | `preview_grok_idle.bin` | grok idle session | fall-through to the marker | | `preview_grok_splash.bin` | grok launch splash with resume hint above the box | fall-through; distinct views excluded from anchors | +| `preview_grok_idle_titled.bin` | `preview_grok_idle.bin` plus a `Run ls -la list directory file names - grok` title announce | Strip `- grok` and render the session name through the title tier | +| `preview_grok_permission.bin` | grok 1.0.50 permission prompt: `┃`-fenced option list in place of the input box; `⚠ Action Required - ⠸ - … - grok` title announce | Match the `⚠ Action Required` prefix and return `awaiting approval`; this fixture has no input-box anchor | +| `preview_grok_plan_approval.bin` | grok 1.0.50 plan approval: `plan.md` panel, `◆ Waiting on plan approval` above the input box, `plan approval` border label, `⠦ - Running: Plan: Exit - … - grok` title announce | The `◆` probe row has no structural match; return `awaiting approval` from the title tier | +| `preview_grok_question.bin` | grok 1.0.50 Ask tool: `┃`-fenced answer list in place of the input box; `⠼ - Running: Ask: … - grok` title announce | Match the `Running: Ask:` prefix and return `awaiting approval`; this fixture has no input-box anchor | | `preview_omp_idle.bin` | omp 18.8.7 idle: logo, tip, `π >` status band, `╰─` composer gutter | negative: no anchor on the band layout; fall-through to the floor tier, no marker for an inline UI | | `preview_omp_idle_titled.bin` | `preview_omp_idle.bin` after a `π > fix the parser` title announce | idle prefix stripped and `fix the parser` rendered in the primary-screen title tier | | `preview_omp_working.bin` | omp 18.8.7 mid tool call: `⎋ List files in current directory` working row, `⠇ 4s >` status band, composer gutter | negative: no anchor on the working row or band; fall-through to the floor tier | diff --git a/tests/corpus/preview_grok_idle_titled.bin b/tests/corpus/preview_grok_idle_titled.bin new file mode 100644 index 0000000..428808e --- /dev/null +++ b/tests/corpus/preview_grok_idle_titled.bin @@ -0,0 +1,39 @@ +[?1049h]0;Run ls -la list directory file names - grok + ~/Projects/fixture-sources 3.0K / 500K + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ❯ │ + ╰────────────────────────────────────────────────────────────────────────────── Grok 4.5 (xhigh) · always-approve ─╯ + + Shift+Tab:mode │ Ctrl+x:shortcuts diff --git a/tests/corpus/preview_grok_permission.bin b/tests/corpus/preview_grok_permission.bin new file mode 100644 index 0000000..9015c0a --- /dev/null +++ b/tests/corpus/preview_grok_permission.bin @@ -0,0 +1,39 @@ +[?1049h]0;⚠ Action Required - ⠸ - Create junk/a.txt then remove the direct… - Create junk dir with a.txt then rm -rf - grok + ~/Projects/fixture-sources/grok 19K / 256K │ [Dashboard] + + + ❯ Create a directory named junk containing a file named a.txt, then remove the whole directory with 1:16 PM + rm -rf junk. + + + ◆ Thought for 2.3s + + I'll create junk/a.txt, then remove the directory with rm -rf junk. 1:16 PM + + ◆ Running create junk/a.txt then remove the directory + + + + + + + + + + + + + + ◆ Create junk/a.txt then remove the direct… 10.0s 14s ⇣19.3k [↓][stop] + + ┃ + ┃ Create junk/a.txt then remove the directory + ┃ mkdir -p junk && printf '' > junk/a.txt && ls -la junk && rm -rf junk && if [ -e junk ]; + ┃ then echo "STILL_EXISTS"; else echo "REMOVED"; fi + ┃ + ┃ 1 (●) Yes, and don't ask again for anything (always-approve mode) + ┃ 2 (○) Yes, proceed + ┃ 3 (○) No, reject (type to add feedback) + ┃ + + 1/3:select │ Tab:next option │ Ctrl+o:always-approve │ Ctrl+c:cancel │ Esc:scrollback diff --git a/tests/corpus/preview_grok_plan_approval.bin b/tests/corpus/preview_grok_plan_approval.bin new file mode 100644 index 0000000..5d7c53c --- /dev/null +++ b/tests/corpus/preview_grok_plan_approval.bin @@ -0,0 +1,39 @@ +[?1049h]0;⠦ - Running: Plan: Exit - Add verbose flag to main.rs - grok + ╭─ plan.md ─────────────────────────────────────────────────────────────────────────────────────────────────── [↗] ╮ + │1 Add --verbose to main.rs │ + │2 │ + │3 main.rs is fn main() { println!("hi"); } with no parser and no Cargo.toml. Use std::env::args; a crate │ + │ dependency is unjustified for one flag. │ █ + │4 │ █ + │5 1. Scan std::env::args().skip(1) for an exact --verbose match and store it as a bool. Ignore other arguments. │ █ + │6 2. When the flag is set, print one extra line (verbose) before the existing hi. Leave the no-flag path printing │ █ + │ only hi. │ █ + │7 3. Run rustc main.rs -o /tmp/verbose-demo && /tmp/verbose-demo && /tmp/verbose-demo --verbose and confirm the │ █ + │ first run prints hi and the second prints verbose then hi. │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │ │ █ + │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ █ + │ a approve | s request changes | c comment | y copy plan | q quit plan │ █ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ◆ Waiting on plan approval + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ❯ Build anything │ + ╰──────────────────────────────────────────────────────────────────────────────── Grok 4.7 (high) · plan approval ─╯ + + c:comment │ y:copy plan │ a:approve │ q:quit plan │ v:select │ Tab:prompt diff --git a/tests/corpus/preview_grok_question.bin b/tests/corpus/preview_grok_question.bin new file mode 100644 index 0000000..b9c1a40 --- /dev/null +++ b/tests/corpus/preview_grok_question.bin @@ -0,0 +1,39 @@ +[?1049h]0;⠼ - Running: Ask: Which greeting do you pre… - Ask preferred greeting hello versus hi - grok + ~/Projects/fixture-sources/grok 19K / 256K │ [Dashboard] + + + ❯ Before doing anything else, use your ask-user-question tool to ask me which greeting I prefer, 1:15 PM + hello or hi. Wait for my answer. + + + ◆ Ask Which greeting do you prefer? + + + + + + + + + + + + + + + + + ◆ Waiting on answers for Which greeting do you prefer? 2.4s ⇣19.0k [stop] + + ┃ + ┃ Which greeting do you prefer? + ┃ + ┃ + ┃ 1 (○) Hello Use hello as the greeting. █ + ┃ 2 (○) Hi Use hi as the greeting. █ + ┃ z (○) Type your answer here + ┃ + ┃ ↑/↓ navigate · y copy Enter:submit + ┃ + + Tab:next answer │ Esc:scrollback │ Shift+x:dismiss