diff --git a/src/integration_spec.rs b/src/integration_spec.rs index 65082738..383f1d8e 100644 --- a/src/integration_spec.rs +++ b/src/integration_spec.rs @@ -1309,7 +1309,8 @@ pub static GROK: IntegrationSpec = IntegrationSpec { adhoc_icon: None, released: true, // Delivery goes through Grok's prompt queue (delivery/grok.rs), never the - // PTY, so there is no composer to parse and no ready footer to wait for. + // PTY, so its gates never read the composer and there is no ready footer to + // wait for. The composer is still parsed for `hcom term --json`. ready_patterns: &[], pty: PtySpec { delivery_start_timeout_secs: 10, diff --git a/src/pty/fixtures/grok_busy_composer.json b/src/pty/fixtures/grok_busy_composer.json new file mode 100644 index 00000000..25d8a86f --- /dev/null +++ b/src/pty/fixtures/grok_busy_composer.json @@ -0,0 +1,33 @@ +{ + "cursor": [ + 15, + 5 + ], + "input_text": null, + "lines": [ + " main ~/src/project 198K / 256K │ [Dashboard]", + "", + " That request is the review. I’ll read it and start an isolated test run; the reply waits on the results if 7:59 PM", + " they aren't in yet.", + "", + " ◈ Ran 2 commands", + " ◆ Subagent running: “Run the new tests” · Waiting for response… (grok-4.6)", + " ◈ Explored lib.rs, 1 search, ran 2 commands", + " █", + "", + " ⠙ Run the new tests … 1m56s 9m27s ⇣198k [stop]", + "", + " ▾ Subagents 1", + " ⸬ Subagent Run the new tests · Waiting for response… grok-4.6 8m30s", + " ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + " │ ❯ │", + " ╰──────────────────────────────────────────────────────────────────────────────────────── Grok 4.6 (high) · always-approve ─╯", + " Ctrl+.:shortcuts" + ], + "prompt_empty": false, + "ready": true, + "size": [ + 18, + 127 + ] +} diff --git a/src/pty/fixtures/grok_idle_composer.json b/src/pty/fixtures/grok_idle_composer.json new file mode 100644 index 00000000..41575f53 --- /dev/null +++ b/src/pty/fixtures/grok_idle_composer.json @@ -0,0 +1,33 @@ +{ + "cursor": [ + 15, + 5 + ], + "input_text": null, + "lines": [ + " main ~/src/project 200K / 256K │ [Dashboard]", + " ◆ Subagent completed in 8m43s: “Run the new tests”", + " ◈ Created notes.md, explored 1 file, 1 search, ran 3 commands +26", + "", + " Done. The change is committed and the targeted tests pass. 8:08 PM", + "", + " The new tests fail without the fix and pass with it. Nothing was installed. Full suite: 2605 pass, 1 ignored;", + " both new tests ok.", + "", + " Next I will look at the follow-up item you mentioned, unless something else comes first. The earlier", + " branch stays untouched.", + "", + " Worked for 10m8s", + " █", + " ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + " │ ❯ │", + " ╰──────────────────────────────────────────────────────────────────────────────────────── Grok 4.6 (high) · always-approve ─╯", + " Ctrl+.:shortcuts" + ], + "prompt_empty": false, + "ready": true, + "size": [ + 18, + 127 + ] +} diff --git a/src/pty/screen.rs b/src/pty/screen.rs index a5ba589d..8fb1e3f8 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -181,6 +181,7 @@ fn debug_prompt_glyph(tool: &str, line: &str) -> Option<&'static str> { Tool::Cursor if line.contains('→') => Some("→"), Tool::Copilot if line.contains('❯') => Some("❯"), Tool::Qoder if line.starts_with("> ") || line == ">" => Some(">"), + Tool::Grok if line.starts_with("│ ❯") => Some("❯"), _ => None, } } @@ -777,9 +778,9 @@ impl ScreenTracker { Ok(Tool::Kimi) => self.get_kimi_input_text(), Ok(Tool::Copilot) => self.get_copilot_input_text(), Ok(Tool::Qoder) => self.get_qoder_input_text(), - // Grok: prompt-empty gate uses generic scrape when available; no - // tool-specific VT100 input parser yet. - Ok(Tool::Grok) => None, + // Delivery goes through Grok's ACP queue and never reads this, but + // `hcom term --json` reports it to scripts deciding when to wake. + Ok(Tool::Grok) => self.get_grok_input_text(), Ok(Tool::Adhoc) => None, Err(_) => None, } @@ -1189,6 +1190,46 @@ impl ScreenTracker { None } + /// Extract Grok Build input text. + /// + /// Grok draws its composer as a rounded box over a one-line footer, with + /// the model and approval mode in the bottom border: + /// + /// ```text + /// ╭──────────────────────────────────────────────────────────╮ + /// │ ❯ │ + /// ╰──────────────────────── Grok 4.6 (high) · always-approve ─╯ + /// Ctrl+.:shortcuts + /// ``` + /// + /// An empty composer is a bare `❯`: no placeholder, idle or mid-turn. + /// Wrapped input adds `│ … │` rows before the bottom border. Anchored to + /// the bottom of the screen so boxes in the transcript are never read as + /// the composer; no box with `❯` on its first row (a menu or approval in + /// its place) is `None`. + fn get_grok_input_text(&self) -> Option { + let lines = self.get_screen_lines(); + let last = lines.iter().rposition(|line| !line.trim().is_empty())?; + let bottom = (last.saturating_sub(3)..=last) + .rev() + .find(|&row| lines[row].trim_start().starts_with('╰'))?; + let top = (bottom.saturating_sub(12)..bottom) + .rev() + .find(|&row| lines[row].trim_start().starts_with('╭'))?; + let inner = |row: usize| { + lines[row] + .trim() + .strip_prefix('│') + .and_then(|rest| rest.strip_suffix('│')) + }; + let first = inner(top + 1)?.trim_start().strip_prefix('❯')?; + let mut text = vec![trim_with_nbsp(first).to_string()]; + for row in top + 2..bottom { + text.push(trim_with_nbsp(inner(row)?).to_string()); + } + Some(text.join("\n").trim().to_string()) + } + /// Extract Qoder CLI input text. /// /// Qoder draws the composer between two `─` rules with a one-line status @@ -2414,6 +2455,115 @@ mod tests { assert!(!t.is_prompt_empty("qoder")); } + // ---- Grok input extraction ---- + + /// Real `hcom term --json` captures (Grok 4.6, 18x127), transcript text + /// anonymized; the composer and footer rows are as Grok drew them. + const GROK_IDLE: &str = include_str!("fixtures/grok_idle_composer.json"); + const GROK_BUSY: &str = include_str!("fixtures/grok_busy_composer.json"); + + fn grok_dump_lines(fixture: &str) -> (u16, u16, Vec) { + let dump: serde_json::Value = serde_json::from_str(fixture).unwrap(); + let size = |i: usize| dump["size"][i].as_u64().unwrap() as u16; + let lines = dump["lines"] + .as_array() + .unwrap() + .iter() + .map(|line| line.as_str().unwrap().to_string()) + .collect(); + (size(0), size(1), lines) + } + + fn grok_screen(rows: u16, cols: u16, lines: &[String]) -> ScreenTracker { + let mut t = make_tool_tracker(rows, cols, crate::tool::Tool::Grok); + let lines: Vec<&str> = lines.iter().map(String::as_str).collect(); + render_rows(&mut t, &lines); + t + } + + /// The idle capture with its composer replaced by `bottom` rows (the + /// transcript scrolls up to make room). + fn grok_idle_with(bottom: &[String]) -> ScreenTracker { + let (rows, cols, mut lines) = grok_dump_lines(GROK_IDLE); + let composer = lines.len() - 4; + lines.truncate(composer); + lines.extend_from_slice(bottom); + let excess = lines.len() - rows as usize; + grok_screen(rows, cols, &lines[excess..]) + } + + /// A composer row as Grok draws it at 127 columns. + fn grok_row(text: &str) -> String { + format!(" │{text:<123}│") + } + + fn grok_box(rows: &[&str]) -> Vec { + let (_, _, lines) = grok_dump_lines(GROK_IDLE); + let n = lines.len(); + let mut out = vec![lines[n - 4].clone()]; + out.extend(rows.iter().map(|row| grok_row(row))); + out.extend_from_slice(&lines[n - 2..]); + out + } + + #[test] + fn grok_idle_empty_composer_is_empty_and_ready() { + // A listening agent with unread mail at a bare `❯`: `hcom term --json` + // said prompt_empty=false here, so wake scripts never typed. + let (rows, cols, lines) = grok_dump_lines(GROK_IDLE); + let t = grok_screen(rows, cols, &lines); + assert_eq!(t.get_grok_input_text().as_deref(), Some("")); + assert!(t.is_prompt_empty("grok")); + assert!(t.is_ready()); + let dump: serde_json::Value = serde_json::from_str(&t.get_screen_dump("grok", 0)).unwrap(); + assert_eq!(dump["ready"], true); + assert_eq!(dump["prompt_empty"], true); + assert_eq!(dump["input_text"], ""); + } + + #[test] + fn grok_composer_stays_empty_mid_turn() { + // Spinner, subagent panel and transcript above; the composer is empty. + let (rows, cols, lines) = grok_dump_lines(GROK_BUSY); + let t = grok_screen(rows, cols, &lines); + assert_eq!(t.get_grok_input_text().as_deref(), Some("")); + assert!(t.is_prompt_empty("grok")); + } + + #[test] + fn grok_extracts_typed_and_wrapped_drafts() { + let t = grok_idle_with(&grok_box(&[" ❯ 3 unread"])); + assert_eq!(t.get_grok_input_text().as_deref(), Some(" 3 unread")); + assert!(!t.is_prompt_empty("grok")); + + let t = grok_idle_with(&grok_box(&[ + " ❯ first line of a long draft", + " second line │ with a bar", + ])); + assert_eq!( + t.get_grok_input_text().as_deref(), + Some("first line of a long draft\nsecond line │ with a bar") + ); + } + + #[test] + fn grok_without_a_composer_is_not_empty() { + // A menu in the composer's place, with an old composer-like box in the + // transcript above it: nothing at the bottom to read, so not safe. + let mut bottom = grok_box(&[" ❯"]); + bottom.extend( + [ + " Allow this command?", + " ❯ 1. Allow once", + " 2. Reject", + ] + .map(String::from), + ); + let t = grok_idle_with(&bottom); + assert_eq!(t.get_grok_input_text(), None); + assert!(!t.is_prompt_empty("grok")); + } + // ---- Kimi input extraction ---- #[test]