//! Unified PTY harness for kigi-tui. //! //! The same layered API serves three consumers: //! //! 1. **Regression scenarios** (e.g. `scenarios::plan_approval_resume`, //! exercised via `tests/` in this crate and `pty-scenario` YAML under //! `kigi-tui/tests/scenarios/`) — assert screen contents and //! multi-process resume behavior. //! 2. **Benchmarks** (`benches/pty_bench.rs`) — run timing scenarios, collect //! per-frame timings, emit JSON / compare against baselines. //! 3. **Ad-hoc scenario runs** — spin up the harness to reproduce issues locally. //! //! ## Layers //! //! - **`pty`** (L1) — PTY management (spawn, inject keys, resize, drain). //! - **`screen`** (L2a) — Virtual terminal state via `alacritty_terminal` ("what the user sees"). //! - **`timing`** (L2b) — Per-frame durations via `?2026 h/l` markers. //! - **`content`** (L3) — Mock inference server driving real content into the pager. //! - **`scenarios`** — Named, parameterised workloads returning `BenchResults`. //! - **`results`** — Aggregated statistics, baseline compare. //! - **`scroll_matrix`** — `KIGI_SCROLL_LOG` JSONL ingestion for the scroll validation matrix. //! - **`env`** — Binary resolution and workspace path helpers. //! - **`flows`** — Cross-suite drive/seed helpers shared by the pager's e2e targets. pub mod content; pub mod env; pub mod flows; pub mod host_clipboard; pub mod leader; pub mod pty; pub mod results; pub mod scenarios; pub mod screen; pub mod scripted; pub mod scroll_matrix; pub mod timing; pub use content::{ContentController, MockModel, ScriptedResponse, SseEvent, sse}; pub use env::pager_binary; pub use flows::{ inference_request_count, oauth_env_for_pager, seed_fake_oauth, submit_turn, wait_for_labels_absent, wait_for_model_via_new_sessions, }; pub use host_clipboard::HostClipboardTextGuard; pub use leader::LeaderCluster; use pty::PtyRead; pub use pty::{PtyController, keys}; pub use results::{BenchResults, compare_baseline}; pub use scenarios::Scenario; pub use screen::ScreenTracker; pub use scripted::{ BugFinding, BugSeverity, DimensionAssertion, EnvVar, EnvironmentConfig, ImageFixture, ImageFixtureKind, MockConfig, MouseButton, MousePoint, SGR_SCROLL_DOWN, SGR_SCROLL_UP, ScenarioStep, ScriptedRunConfig, ScriptedRunReport, ScriptedRunStatus, ScriptedScenario, ScriptedScenarioRunner, ScrollDirection, StepOutcome, StepStatus, TerminalConfig, VisualArtifact, }; pub use timing::{FrameTiming, FrameTimingParser}; // Re-export ptyctl types for richer terminal emulation, vim key notation, // and styled output support. pub use ptyctl::keys::parse_keys; pub use ptyctl::styled::{StyledLine, StyledRun}; pub use ptyctl::term::{ScreenOutput, Terminal as AlacrittyTerminal}; use std::path::Path; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use portable_pty::PtySize; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PtyPump { Chunk, Timeout, Closed, } /// High-level harness that composes PTY control, screen state, and frame timing. /// /// The key method is [`update`](PtyHarness::update), which receives PTY output /// chunks inline and feeds each to **both** the [`ScreenTracker`] and /// [`FrameTimingParser`] as it arrives. This preserves inter-chunk timing for /// accurate frame measurement. pub struct PtyHarness { pty: PtyController, screen: ScreenTracker, timing: FrameTimingParser, raw_output: Vec, /// Spawn instant — the time origin for asciinema cast event timestamps. spawned_at: Instant, /// Per-chunk cast events as `(elapsed_secs, end_offset_into_raw_output)`. /// Each event's bytes are `raw_output[prev_end..end]`, so the chunks are /// not duplicated in memory. cast_events: Vec<(f64, usize)>, /// Terminal size at spawn as `(cols, rows)` for the cast header. cast_size: (u16, u16), /// When true, [`update`](Self::update) forwards terminal-generated replies /// (cursor-position reports, device attributes, …) back to the child. /// Off by default so tests that script their own probe replies (e.g. /// `pty_xtversion`) keep full control; minimal-mode tests turn it on so the /// inline viewport's startup cursor query completes instead of timing out. respond_to_queries: bool, } impl PtyHarness { /// Spawn the pager in a PTY and create a new harness. /// /// Both `rows` and `cols` follow terminal convention: `(rows, cols)`. pub fn new( binary: &Path, rows: u16, cols: u16, args: &[&str], env: &[(&str, &str)], ) -> Result { Self::new_in_dir(binary, rows, cols, args, env, None) } /// Like [`new`](Self::new), with an explicit working directory (`None` inherits). pub fn new_in_dir( binary: &Path, rows: u16, cols: u16, args: &[&str], env: &[(&str, &str)], cwd: Option<&Path>, ) -> Result { let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }; let pty = PtyController::spawn_in_dir(binary, size, args, env, cwd) .context("failed to spawn pager in PTY")?; Ok(Self { pty, screen: ScreenTracker::new(rows, cols), timing: FrameTimingParser::new(), raw_output: Vec::new(), spawned_at: Instant::now(), cast_events: Vec::new(), cast_size: (cols, rows), respond_to_queries: false, }) } /// Enable (or disable) forwarding terminal-generated replies back to the /// child during [`update`](Self::update). Real terminals answer device /// queries automatically; the harness leaves this off by default so probe /// tests can script their own replies. Minimal-mode tests enable it so the /// inline viewport's startup cursor-position query (`ESC[6n`) is answered /// and `--minimal` is not silently downgraded to full-screen inline. pub fn set_respond_to_queries(&mut self, enabled: bool) { self.respond_to_queries = enabled; } /// Spawn the pager with env vars from a [`ContentController`] attached. /// /// This is the common pattern for both e2e tests and benchmarks: /// /// ```no_run /// # use std::time::Duration; /// # use kigi_pager_pty_harness::{PtyHarness, ContentController, pager_binary}; /// # async fn example() -> anyhow::Result<()> { /// let content = ContentController::start().await?; /// content.set_response("# Hello\n\nAgent said hi."); /// /// let mut harness = PtyHarness::spawn_with_content( /// &pager_binary()?, 50, 120, &content, &[], /// )?; /// harness.wait_for_text("Hello", Duration::from_secs(10))?; /// harness.quit()?; /// # Ok(()) } /// ``` pub fn spawn_with_content( binary: &Path, rows: u16, cols: u16, content: &ContentController, extra_args: &[&str], ) -> Result { Self::spawn_with_content_in_dir(binary, rows, cols, content, extra_args, None) } /// Like [`spawn_with_content`](Self::spawn_with_content), with an explicit working directory. pub fn spawn_with_content_in_dir( binary: &Path, rows: u16, cols: u16, content: &ContentController, extra_args: &[&str], cwd: Option<&Path>, ) -> Result { let env = content.env_for_pager(); let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); Self::new_in_dir(binary, rows, cols, extra_args, &env_refs, cwd) } // ── PTY control ────────────────────────────────────────────────── /// Inject raw key bytes into the PTY. pub fn inject_keys(&mut self, keys: &[u8]) -> Result<()> { self.pty.inject_keys(keys) } /// Resize the PTY and virtual screen. Arguments are `(rows, cols)`. pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> { self.pty.resize(rows, cols)?; self.screen.resize(rows, cols); Ok(()) } // ── Update: receive PTY output inline → feed both parsers ──────── /// Receive PTY output for up to `timeout`, feeding each chunk to both /// the screen state tracker and the frame timing parser as it arrives. /// /// Processing inline (rather than buffering all chunks first) preserves /// inter-chunk timing so that `FrameTimingParser` records accurate /// wall-clock frame durations. pub fn update(&mut self, timeout: Duration) { let deadline = Instant::now() + timeout; loop { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() || !matches!(self.pump_one(remaining), PtyPump::Chunk) { break; } } } fn pump_one(&mut self, timeout: Duration) -> PtyPump { match self.pty.recv_chunk(timeout) { PtyRead::Chunk(chunk) => { self.raw_output.extend_from_slice(&chunk); self.cast_events.push(( self.spawned_at.elapsed().as_secs_f64(), self.raw_output.len(), )); self.screen.feed(&chunk); self.timing.feed(&chunk); if self.respond_to_queries { let responses = self.screen.drain_responses(); if !responses.is_empty() { let _ = self.pty.inject_keys(&responses); } } PtyPump::Chunk } PtyRead::Timeout => PtyPump::Timeout, PtyRead::Closed => PtyPump::Closed, } } /// Feed bytes **directly into the virtual screen only**, bypassing the /// child (grok). /// /// Simulates an out-of-band repaint/reflow by an outer layer (tmux, or an /// nvim/vim `:terminal`) that changes what's on screen without going /// through grok's stdout. Used to reproduce the doubled-line class of bugs /// where grok's diff renderer never re-asserts a region it didn't write /// itself (since the harness is a single faithful emulator and cannot nest /// a real tmux/nvim). pub fn feed_screen(&mut self, bytes: &[u8]) { self.screen.feed(bytes); } /// Check whether the child process is still running. pub fn is_running(&mut self) -> bool { self.pty.is_running() } // ── Screen state queries ───────────────────────────────────────── /// Return structured plain-text screen contents. pub fn screen_output(&self) -> ScreenOutput { self.screen.output() } /// Return the full text contents of the virtual screen. pub fn screen_contents(&self) -> String { self.screen.contents() } /// Return the full screen with style information. pub fn screen_styled(&self) -> Vec { self.screen.styled() } /// Render the current screen as HTML. pub fn screen_html(&self) -> String { self.screen.html() } /// Check whether the screen contains the given text. pub fn contains_text(&self, text: &str) -> bool { self.screen.contains(text) } /// Pump PTY output until `condition` becomes true or `timeout` expires. /// /// The condition is checked before the first pump and after each output /// slice. `description` names the semantic state in timeout diagnostics. pub fn wait_until( &mut self, description: &str, timeout: Duration, mut condition: impl FnMut(&Self) -> bool, ) -> Result<()> { let deadline = Instant::now() + timeout; loop { if condition(self) { return Ok(()); } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { anyhow::bail!( "timed out after {timeout:?} waiting for {description}\n\ process running: {}\nscreen contents:\n{}", self.pty.is_running(), self.screen.contents() ); } match self.pump_one(Duration::from_millis(50).min(remaining)) { PtyPump::Chunk | PtyPump::Timeout => {} PtyPump::Closed => { anyhow::bail!( "PTY closed while waiting for {description}\n\ process running: false\nscreen contents:\n{}\nraw output:\n{}", self.screen.contents(), String::from_utf8_lossy(&self.raw_output) ); } } } } /// Like [`Self::wait_until`], but the condition must remain true for `hold`. /// /// The single `timeout` covers both reaching the condition and holding it; /// PTY output continues to be pumped throughout the stability window. pub fn wait_until_stable( &mut self, description: &str, timeout: Duration, hold: Duration, mut condition: impl FnMut(&Self) -> bool, ) -> Result<()> { let deadline = Instant::now() + timeout; let mut true_since = None; loop { if condition(self) { let since = true_since.get_or_insert_with(Instant::now); if since.elapsed() >= hold { return Ok(()); } } else { true_since = None; } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { anyhow::bail!( "timed out after {timeout:?} waiting for {description} to remain true for \ {hold:?}\nprocess running: {}\nscreen contents:\n{}", self.pty.is_running(), self.screen.contents() ); } match self.pump_one(Duration::from_millis(50).min(remaining)) { PtyPump::Chunk | PtyPump::Timeout => {} PtyPump::Closed => { anyhow::bail!( "PTY closed while waiting for {description} to remain true for {hold:?}\n\ process running: false\nscreen contents:\n{}\nraw output:\n{}", self.screen.contents(), String::from_utf8_lossy(&self.raw_output) ); } } } } /// Block until the screen contains `text` or `timeout` expires. pub fn wait_for_text(&mut self, text: &str, timeout: Duration) -> Result<()> { self.wait_until(&format!("screen text {text:?}"), timeout, |h| { h.contains_text(text) }) } /// Block until the visible screen no longer contains `text`. pub fn wait_for_text_absent(&mut self, text: &str, timeout: Duration) -> Result<()> { self.wait_until( &format!("screen text {text:?} to disappear"), timeout, |h| !h.contains_text(text), ) } /// Wait for a rendered response to reach the idle prompt state. /// /// Call this after observing turn output: the running status and cancel /// keybar disappear only after the pager finalizes the turn. pub fn wait_for_turn_idle(&mut self, timeout: Duration) -> Result<()> { self.wait_until_stable( "turn to become idle", timeout, Duration::from_millis(250), |h| { !h.contains_text("Ctrl+c:cancel") && !h.contains_text("Waiting for response") && !h.contains_text("Responding") }, ) } /// Return all raw bytes emitted by the child PTY so far. pub fn raw_output(&self) -> &[u8] { &self.raw_output } /// Write everything the child PTY emitted so far as an asciinema v2 cast /// (`.cast`), one output event per received chunk with its original /// arrival timestamp. Replayable locally with `asciinema play`. Bytes are /// decoded lossily so binary escapes cannot poison the JSON encoding. /// /// Limitation: the header is pinned to the spawn-time size and no `"r"` /// resize events are emitted, so a cast from a test that calls /// [`resize`](Self::resize) plays back at the original geometry. pub fn write_cast(&self, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create cast dir {}", parent.display()))?; } let (cols, rows) = self.cast_size; let mut out = String::new(); out.push_str(&serde_json::json!({"version": 2, "width": cols, "height": rows}).to_string()); out.push('\n'); let mut start = 0usize; for (elapsed, end) in &self.cast_events { let mut end = *end; // A multi-byte codepoint split across two PTY reads must not be // lossy-decoded in halves: back off to the char boundary and let // the partial bytes ride in the next event (a dangling tail at // end-of-capture still decodes lossily — nothing to carry into). while end > start && end < self.raw_output.len() && (self.raw_output[end] & 0xC0) == 0x80 { end -= 1; } if end == start { continue; } let data = String::from_utf8_lossy(&self.raw_output[start..end]); out.push_str(&serde_json::json!([elapsed, "o", data]).to_string()); out.push('\n'); start = end; } std::fs::write(path, out).with_context(|| format!("write cast {}", path.display())) } // ── Scrollback queries (minimal mode commits blocks into native history) ── /// The terminal's scrollback history as text (oldest line first). pub fn scrollback_text(&self) -> String { self.screen.scrollback_text() } /// Scrollback history + the visible screen, joined oldest→newest. Use for /// minimal-mode assertions: a committed block may be on-screen or scrolled /// above the pinned viewport depending on how much has accumulated. pub fn full_text(&self) -> String { self.screen.full_text() } /// Whether scrollback + visible screen contains `text`. pub fn contains_full_text(&self, text: &str) -> bool { self.screen.full_contains(text) } /// Block until scrollback + visible screen contains `text`, or `timeout` /// expires. The scrollback-aware companion to [`Self::wait_for_text`] for /// content that may have scrolled above the viewport (minimal mode). pub fn wait_for_full_text(&mut self, text: &str, timeout: Duration) -> Result<()> { let result = self.wait_until(&format!("full text {text:?}"), timeout, |h| { h.contains_full_text(text) }); result.map_err(|error| { anyhow::anyhow!("{error}\nfull contents:\n{}", self.screen.full_text()) }) } /// Block until scrollback + visible screen no longer contains `text`. pub fn wait_for_full_text_absent(&mut self, text: &str, timeout: Duration) -> Result<()> { let result = self.wait_until(&format!("full text {text:?} to disappear"), timeout, |h| { !h.contains_full_text(text) }); result.map_err(|error| { anyhow::anyhow!("{error}\nfull contents:\n{}", self.screen.full_text()) }) } /// Count Kitty graphics APC sequences that carry image data or placement in /// the raw PTY output so far (delete / capability-query escapes excluded). /// /// These escapes are written into the synchronized-update frame buffer /// (outside the vt100 cell grid), so they aren't visible to `wait_for_text`; /// scanning the raw bytes is the only way to observe them. pub fn count_kitty_graphics(&self) -> usize { scripted::count_kitty_graphics(&self.raw_output) } /// Block until at least `min` Kitty graphics APC sequences (`ESC _ G`) have /// appeared in the raw PTY output, or `timeout` expires. /// /// Polling the raw bytes avoids a fixed sleep (flake under load) and returns /// as soon as the image is transmitted/placed. pub fn wait_for_kitty_graphics(&mut self, min: usize, timeout: Duration) -> Result<()> { let deadline = Instant::now() + timeout; loop { if self.count_kitty_graphics() >= min { return Ok(()); } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { anyhow::bail!( "timed out after {timeout:?} waiting for {min} Kitty graphics escape(s); \ found {}", self.count_kitty_graphics() ); } self.update(Duration::from_millis(50).min(remaining)); } } /// Return the current cursor position as `(row, col)`. pub fn cursor_position(&self) -> (u16, u16) { self.screen.cursor_position() } // ── Frame timing queries ───────────────────────────────────────── /// Return all recorded frame timings. pub fn frame_timings(&self) -> &[FrameTiming] { self.timing.timings() } /// Compute aggregated benchmark results from collected frame timings. pub fn bench_results(&self, scenario: &str, wall_time: Duration) -> BenchResults { BenchResults::from_timings(scenario, self.timing.timings(), wall_time) } /// Return the total number of completed frames. pub fn frame_count(&self) -> u64 { self.timing.frame_count() } /// Reset all frame timing data. pub fn reset_timing(&mut self) { self.timing.reset(); } // ── Lifecycle ──────────────────────────────────────────────────── /// Send 'q' and wait for the child process to exit (5s timeout, then kill). pub fn quit(&mut self) -> Result<()> { self.pty.quit() } /// Wait up to `timeout` for the child to exit, returning its exit code /// (`None` if it's still running at the deadline). Call once and cache the /// result — the underlying `try_wait` reaps the child. pub fn wait_exit_code(&mut self, timeout: Duration) -> Option { self.pty.wait_exit_code(timeout) } /// Wait for child exit, then drain final PTY output through EOF or quiet. /// /// `exit_timeout` applies only until exit. Once exit is observed, the known /// status is preserved while a separate bounded drain phase runs. pub fn wait_for_exit_and_drain( &mut self, exit_timeout: Duration, drain_timeout: Duration, ) -> Result { let exit_deadline = Instant::now() + exit_timeout; let exit_code = loop { if let Some(code) = self.pty.try_exit_code()? { break code; } let remaining = exit_deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { anyhow::bail!( "timed out after {exit_timeout:?} waiting for child exit\n\ process running: true\nscreen contents:\n{}\nraw output:\n{}", self.screen.contents(), String::from_utf8_lossy(&self.raw_output) ); } self.update(Duration::from_millis(50).min(remaining)); }; let drain_deadline = Instant::now() + drain_timeout; let mut last_output_at = Instant::now(); loop { let remaining = drain_deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return Ok(exit_code); } match self.pump_one(Duration::from_millis(50).min(remaining)) { PtyPump::Chunk => last_output_at = Instant::now(), PtyPump::Closed => return Ok(exit_code), PtyPump::Timeout if last_output_at.elapsed() >= Duration::from_millis(200) => { return Ok(exit_code); } PtyPump::Timeout => {} } } } /// Child PID (see [`PtyController::child_pid`]). pub fn child_pid(&self) -> Option { self.pty.child_pid() } /// Deliver a signal to the child (unix). See [`PtyController::send_signal`]. #[cfg(unix)] pub fn send_signal(&self, signal: i32) -> Result<()> { self.pty.send_signal(signal) } }