Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,71 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// CLI `--minimal` / `--fullscreen` must not write `[ui] screen_mode` to
/// config.toml. Mode flags are session-scoped; only a manual config.toml edit
/// should make a mode sticky across plain `grok` launches.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_cli_screen_mode_does_not_persist() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{} no-sticky payload.", turn_sentinel(1)));
// Sessions are keyed by cwd: both runs must share a stable project dir.
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
// First run: explicit `--minimal` — must open minimal but not write config.
let mut first = spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
wait_minimal_ready(&mut first);
// Give any fire-and-forget persist path time to have written if it still
// existed, pumping the PTY so the pager never blocks on a full buffer.
let config_path = content.home().join(".grok").join("config.toml");
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
first.update(Duration::from_millis(100));
}
let body = std::fs::read_to_string(&config_path).unwrap_or_default();
assert!(
!body.contains("screen_mode"),
"--minimal must not persist [ui] screen_mode; config.toml:\n{body}"
);
quit_minimal(&mut first);
// Second run: NO mode flag. Without a manual config preference the plain
// launch must open fullscreen (welcome screen), not minimal.
let binary = pager_binary().expect("resolve pager binary");
let mut second = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--no-leader"],
Some(project.path()),
)
.expect("spawn plain pager");
second.set_respond_to_queries(true);
second
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.unwrap_or_else(|e| {
panic!(
"plain grok should open fullscreen after --minimal (no sticky write): {e}\nscreen:\n{}",
second.screen_contents()
)
});
assert!(
!second.contains_text(MINIMAL_IDLE_SENTINEL),
"plain launch must not be minimal without config screen_mode\nscreen:\n{}",
second.screen_contents()
);
assert!(
!second.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
second.screen_contents()
);
second.quit().expect("clean quit");
}

View file

@ -0,0 +1,56 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal mode's core contract: a finalized assistant block is printed once
/// into the terminal's NATIVE scrollback (via `insert_before`), not redrawn in
/// the pinned live region. We force the commit above the viewport by streaming a
/// response taller than the screen, then assert the head line — which scrolls
/// off the top into history — is readable via the harness scrollback helpers.
/// (Short responses stay on the visible static band above the live region — the
/// content-anchored live region keeps them on screen; only a response genuinely
/// taller than the screen proves content reaches *scrollback* specifically.)
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_commits_response_to_scrollback() {
let content = ContentController::start().await.expect("start content");
// Sentinel on the FIRST rendered row; 80 code-block rows >> the 50-row
// screen, so the head scrolls into native scrollback once the block commits.
// (Prose lines would markdown-reflow into one short paragraph that fits on
// screen — see `tall_response`.)
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// The assistant block is the running turn — it only commits to native
// scrollback once the turn ends. Poll until the head sentinel lands in
// scrollback (above the pinned viewport), which proves it committed rather
// than merely streaming in the live tail.
let deadline = Instant::now() + Duration::from_secs(40);
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
harness.update(Duration::from_millis(100));
}
assert!(
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
"committed assistant block must reach native scrollback\nscrollback:\n{}\nscreen:\n{}",
harness.scrollback_text(),
harness.screen_contents(),
);
assert!(
content.has_chat_completion(),
"mock inference server never received a chat completion\nrequests: {:?}",
content.requests()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,75 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Overlay host: growing the live viewport for an overlay must
/// scroll committed rows up into native scrollback (the `set_viewport_height`
/// grow fix) rather than clobbering them, and shrinking it back when the overlay
/// closes must leave them intact. Commit a tall response into scrollback, open
/// the slash dropdown (grows the viewport over committed rows), close it
/// (shrinks + re-anchors), and assert the committed head survived in scrollback.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_committed_content_survives_overlay_grow() {
let content = ContentController::start().await.expect("start content");
// Sentinel on the first rendered row; 80 code-block rows overflow the screen
// so the head reaches native scrollback (prose would reflow to fit on screen
// and never scroll — see `tall_response`).
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// Precondition: the committed head must be in native scrollback first.
let deadline = Instant::now() + Duration::from_secs(40);
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
harness.update(Duration::from_millis(100));
}
assert!(
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
"precondition: committed block must reach scrollback before the overlay\nscrollback:\n{}",
harness.scrollback_text()
);
// Open the slash dropdown → grows the live viewport over committed rows.
inject_keys_paced(&mut harness, b"/mod");
let dropdown_deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < dropdown_deadline
&& !harness
.screen_contents()
.contains("Switch the active model")
{
harness.update(Duration::from_millis(100));
}
assert!(
harness
.screen_contents()
.contains("Switch the active model"),
"slash dropdown must grow the viewport and render its items even when \
committed content fills the screen\nscreen:\n{}\nscrollback:\n{}",
harness.screen_contents(),
harness.scrollback_text(),
);
// Close it → shrinks + re-anchors to the bottom.
harness.inject_keys(keys::ESC).expect("close dropdown");
harness.update(Duration::from_millis(400));
// The committed head must still be readable in scrollback: the grow/shrink
// cycle must neither clobber nor lose it.
assert!(
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
"committed block must survive the overlay grow/shrink cycle\nscrollback:\n{}",
harness.scrollback_text()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,65 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Resuming a minimal session with `--continue` reprints
/// the prior transcript into native scrollback. Minimal has no separate history
/// pane (the terminal owns history), so a resumed session would otherwise look
/// empty — rather than a compact resume marker, minimal reprints the full
/// transcript instead. This asserts the prior
/// turn's content reappears after resume and a follow-up turn still works.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_continue_reprints_transcript() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{} first session payload.", turn_sentinel(1)));
// Sessions are keyed by cwd: both runs must share a stable project dir.
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
let mut first = spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
wait_minimal_ready(&mut first);
first
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit turn 1");
first
.wait_for_full_text(&turn_sentinel(1), Duration::from_secs(30))
.expect("turn 1 committed to scrollback");
quit_minimal(&mut first);
// Resume the same session. The transcript is reprinted into native
// scrollback (no separate history pane in minimal).
let mut resumed = spawn_minimal_in_dir(
&content,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--continue"],
project.path(),
);
resumed
.wait_for_full_text(&turn_sentinel(1), WELCOME_TIMEOUT)
.unwrap_or_else(|e| {
panic!(
"history must be reprinted after --continue: {e}\nfull:\n{}",
resumed.full_text()
)
});
// A follow-up turn still works in the resumed session.
content.set_response(format!("{} resumed payload.", turn_sentinel(2)));
resumed
.inject_keys(b"again\r")
.expect("submit turn 2 after resume");
resumed
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
.expect("turn 2 rendered in resumed session");
assert!(
!resumed.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
resumed.screen_contents()
);
quit_minimal(&mut resumed);
}

View file

@ -0,0 +1,36 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal mode has no shortcuts bar, so the double-press quit confirmation must
/// surface under the prompt instead: a first Ctrl+C on an empty, idle prompt
/// arms the quit confirmation and shows "press Ctrl+c again to quit", and a
/// second Ctrl+C within the window exits. (Ctrl+Q / Ctrl+D arm the same way; the
/// hint is rendered by `minimal::live::render_exit_hint`.)
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_ctrl_c_arms_and_quits() {
let content = ContentController::start().await.expect("start content");
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
// First Ctrl+C (CSI legacy ETX). Arms quit + shows the hint under the prompt.
harness.inject_keys(b"\x03").expect("inject Ctrl+C");
harness
.wait_for_text("again to quit", Duration::from_secs(5))
.unwrap_or_else(|e| {
panic!(
"quit-confirmation hint expected after first Ctrl+C: {e}\nscreen:\n{}",
harness.screen_contents()
)
});
// Second Ctrl+C within the confirm window exits the process.
harness.inject_keys(b"\x03").expect("inject Ctrl+C again");
let code = harness.wait_exit_code(Duration::from_secs(5));
assert!(
code.is_some(),
"second Ctrl+C should quit minimal\nscreen:\n{}",
harness.screen_contents()
);
}

View file

@ -0,0 +1,98 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal + Apple Terminal: Ctrl+O is the send-now chord. With an empty
/// composer and a mid-turn queued follow-up it must send that row now —
/// cancel-and-send: turn 1 is cancelled silently and the row runs as its own
/// next turn (no interjection preamble) — not open the transcript pager remap.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_ctrl_o_send_now_queued_apple_terminal() {
let content = ContentController::start().await.expect("start content");
content.set_turns([
slow_turn_text("STEPONE"),
"STEPTWO send-now via Ctrl+O acknowledged.".to_owned(),
]);
// Hold turn 1 open deterministically: its content streams, but its
// completion is gated until we release it below. Chunk-delay pacing alone
// left a wall-clock race — under parallel-suite load turn 1 could finish
// before Ctrl+O landed, so the follow-up was promoted FIFO as a plain
// prompt and the send-now chrome never appeared.
content.hold_agent_completions();
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "Apple_Terminal".into()));
// Non-interactive $PAGER so a mistaken transcript open fails fast rather
// than hanging in `less` if the predicate regresses.
env.push(("PAGER".into(), "cat".into()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
.expect("spawn minimal + Apple_Terminal");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("STEPONE", Duration::from_secs(30))
.expect("turn 1 streaming");
harness
.inject_keys(b"minimal send-now payload\r")
.expect("queue follow-up");
harness
.wait_for_text("1 queued", Duration::from_secs(10))
.expect("queue indicator");
// Empty composer + queue: Ctrl+O must yield to send-now, not transcript.
// Cancel-and-send: the shell silently cancels turn 1 (its held completion
// is irrelevant — the abort wins) and the row commits as a standard " "
// prompt block for its own turn. Turn 1 is still gated open here, so the
// queued row cannot have promoted FIFO.
harness.inject_keys(CTRL_O).expect("Ctrl+O send-now");
// Generous deadline: with turn 1 gated open there is no promotion race
// left to mask — this wait is pure render latency, which under heavy
// parallel-suite load can exceed the old 15s budget.
harness
.wait_for_text("\u{276F} minimal send-now payload", Duration::from_secs(60))
.expect("send-now chrome (not a silent transcript open)");
// Let the mock's gate go so the promoted turn streams its reply.
content.release_agent_completions();
harness
.wait_for_text("STEPTWO", Duration::from_secs(40))
.expect("send-now turn reply");
// The send-now cancel of turn 1 is silent (scrollback-aware check:
// minimal commits blocks into native history).
assert!(
!harness.contains_full_text("Turn cancelled by user"),
"send-now cancel must not render a cancelled marker\nfull contents:\n{}",
harness.full_text()
);
let users = all_user_message_blobs(&content);
let sent = users
.iter()
.find(|u| u.contains("minimal send-now payload"))
.unwrap_or_else(|| panic!("queued follow-up never on wire: {users:#?}"));
assert!(
!sent.contains(INTERJECTION_WIRE_PREFIX),
"send-now must not use the interjection preamble: {sent}"
);
assert!(
sent.contains("<user_query>"),
"send-now must arrive as a standard user_query prompt: {sent}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,86 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal mode guards the documented `in_flight_committed` dogfood
/// double-show: a promoted queued prompt's " " block commits (prints) into
/// native scrollback immediately, so cancelling its turn pre-first-token
/// (minimal's cancel gesture is Ctrl+C; Esc is swallowed) must SKIP the
/// composer rewind — a rewind would leave the printed block on screen AND
/// refill the composer, showing the prompt twice. Standard cancel instead:
/// the block renders exactly once and the cancel marker is visible.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_double_esc_committed_queued_prompt_single_render() {
const QUEUED_PROMPT: &str = "bravo promoted block";
let content = ContentController::start().await.expect("start content");
// Gate turn 1's completion so the queue provably lands mid-turn; turn 2
// (the promoted prompt's) streams nothing before the cancel thanks to
// the pacing set just before the release.
content.hold_agent_completions();
content.set_turns([
"STEPONE first reply.".to_owned(),
"STEPTWO never streams before the cancel.".to_owned(),
]);
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("STEPONE", Duration::from_secs(30))
.expect("turn 1 streamed (completion still gated)");
harness
.inject_keys(format!("{QUEUED_PROMPT}\r").as_bytes())
.expect("queue follow-up mid-turn");
harness
.wait_for_text("1 queued", Duration::from_secs(10))
.expect("queue indicator");
// Promote: turn 1 ends, the queued prompt's block commits (prints) and
// its turn starts — but its first token is 30s away, the exact window
// where a naive rewind would double-show the committed block.
content.set_chunk_delay(Some(Duration::from_secs(30)));
content.release_agent_completions();
harness
.wait_for_full_text(
&format!("\u{276F} {QUEUED_PROMPT}"),
Duration::from_secs(30),
)
.expect("promoted prompt block committed");
let committed = harness.full_text().matches(QUEUED_PROMPT).count();
assert_eq!(
committed,
1,
"committed block must print once\nfull contents:\n{}",
harness.full_text()
);
// Cancel the promoted turn pre-first-token. The committed block forces
// the standard cancel path (rewind skipped): marker renders, composer
// stays empty, and the prompt count does NOT grow.
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C cancel");
harness
.wait_for_full_text("Turn cancelled by user", Duration::from_secs(15))
.expect("standard cancel marker (not a silent rewind)");
harness.update(Duration::from_millis(500));
assert_eq!(
harness.full_text().matches(QUEUED_PROMPT).count(),
committed,
"cancel must not re-show the committed prompt (composer refill = double render)\n\
full contents:\n{}",
harness.full_text()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,57 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Mid-turn Esc in minimal mode is a swallowed no-op (the prompt is always
/// focused). Esc must NOT cancel; cancel remains on Ctrl+C.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_esc_mid_turn_is_swallowed() {
let content = ContentController::start().await.expect("start content");
// Paced, long stream so the turn is provably still running when Esc lands.
let long = format!(
"{MOCK_RESPONSE_SENTINEL} {}",
"streaming filler words for the cancellation window. ".repeat(120)
);
content.set_response(long);
content.set_chunk_delay(Some(Duration::from_millis(50)));
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn streaming in the live tail");
harness.inject_keys(keys::ESC).expect("press esc");
harness.update(Duration::from_millis(1000));
// Full-text: minimal commits the cancel marker to native scrollback, so it
// may sit above the pinned viewport — check scrollback + screen.
assert!(
!harness.contains_full_text("Turn cancelled by user"),
"mid-turn Esc must NOT cancel in minimal mode\nfull contents:\n{}",
harness.full_text()
);
// Positive tail: prove the turn was still alive at Esc-time (the negative
// check above would false-pass on an already-finished turn) and that
// Ctrl+C — the replacement cancel gesture — works in minimal mode. The
// prompt is empty and the turn is running, so Ctrl+C cancels (the minimal
// quit arm applies only to an idle empty prompt).
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
harness
.wait_for_full_text("Turn cancelled by user", Duration::from_secs(15))
.expect("Ctrl+C must cancel the still-running turn in minimal mode");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,153 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal mode paints the welcome card edge-to-edge (no outer horizontal pad).
/// The live region's status / prompt / info rows and committed user+agent blocks
/// must share that left edge — previously they sat at `block_pad_left + accent`
/// (= 3 columns of blank gutter), which looked misaligned against the welcome
/// box. Assert every non-blank visible row either is welcome-card chrome
/// (border / logo interior) or starts at column 0.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_flush_left_no_hpad() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} — flush-left alignment check."
));
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
// Idle: status + prompt + info bar must be flush-left (no leading spaces).
assert_flush_left_live_rows(
&harness.screen_contents(),
&["minimal · /help"],
"idle live region",
);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("response renders");
// Let the turn finish and commit settle into scrollback / live region.
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(20))
.expect("return to idle after response");
harness.update(Duration::from_millis(200));
let screen = harness.screen_contents();
assert!(
screen.contains(MOCK_RESPONSE_SENTINEL),
"response must be on screen\nscreen:\n{screen}"
);
// Committed user line (` go`), agent response, and live chrome all
// flush-left with the welcome card.
assert_flush_left_live_rows(
&screen,
&[
&format!(" {PROMPT}"),
MOCK_RESPONSE_SENTINEL,
"minimal · /help",
],
"after response",
);
// Slash menu: typing a command prefix opens the dropdown below the prompt.
// Its rows must be flush-left too — previously the panel sat at the layout
// hpad and the item rows one further column in (` /transcript` at col 3).
harness
.inject_keys(b"/tra")
.expect("type slash command prefix");
harness
.wait_for_text(
"View the full conversation transcript",
Duration::from_secs(10),
)
.expect("slash dropdown opens");
assert_flush_left_live_rows(
&harness.screen_contents(),
&["View the full conversation transcript"],
"slash dropdown",
);
// Close the dropdown / clear the prompt so quit isn't intercepted.
harness.inject_keys(b"\x1b").expect("esc closes dropdown");
harness.update(Duration::from_millis(100));
// Permission modal: a scripted `run_terminal_command` tool call (no --yolo)
// opens the prompt-replacing permission modal. Its rows must be flush-left
// too — previously the whole modal sat at the layout hpad (2 columns in).
// The accent `┃` paints the modal's first column, so a correct row has zero
// leading spaces.
content.set_response("PERMISSION_SETTLED — turn finished after the allow.");
let args = json!({
"command": "touch flush_marker.txt",
"description": "flush-left permission check",
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_flush",
"run_terminal_command",
&args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events(
"run_terminal_command",
&args,
)),
);
harness
.inject_keys(b"run the flush check\r")
.expect("submit tool prompt");
harness
.wait_for_text("No, reject", Duration::from_secs(30))
.expect("permission modal opens");
assert_flush_left_live_rows(
&harness.screen_contents(),
&["No, reject"],
"permission modal",
);
// Allow once (shortcut `1`) so the turn settles, then wait for idle.
harness.inject_keys(b"1").expect("allow once");
harness
.wait_for_text("PERMISSION_SETTLED", Duration::from_secs(30))
.expect("turn settles after allow");
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(20))
.expect("return to idle after permission turn");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}
/// For each `needle`, find the first screen line containing it and assert that
/// line has no leading ASCII spaces (flush-left). Welcome-card rows start with
/// box-drawing chars at col 0 already; this targets the previously-padded
/// live/committed content.
fn assert_flush_left_live_rows(screen: &str, needles: &[&str], phase: &str) {
for needle in needles {
let line = screen
.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| {
panic!("{phase}: no screen line contains {needle:?}\nscreen:\n{screen}")
});
let lead = line.len() - line.trim_start_matches(' ').len();
assert_eq!(
lead, 0,
"{phase}: line containing {needle:?} must be flush-left \
(no leading spaces), got lead={lead}: {line:?}\nscreen:\n{screen}"
);
}
}

View file

@ -0,0 +1,49 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// `/help` opens the command palette inline in minimal mode, hosted in
/// the grown live viewport by the generalized app-modal host (the same host
/// that renders settings) — proving minimal now renders the full `ActiveModal`
/// family, not just settings. Esc dismisses it back to the prompt.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_help_opens_command_palette() {
let content = ContentController::start().await.expect("start content");
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
inject_keys_paced(&mut harness, b"/help");
harness.inject_keys(b"\r").expect("submit /help");
// "New Session" is a stable command-palette entry that renders only inside
// the palette modal (not the status line or the slash dropdown).
harness
.wait_for_text("New Session", Duration::from_secs(10))
.expect("command palette opens inline");
// Esc closes it. The palette opens in input mode, so the first Esc may exit
// input mode and a second closes — press up to twice.
for _ in 0..2 {
harness.inject_keys(keys::ESC).expect("press esc");
harness.update(Duration::from_millis(300));
if !harness.contains_text("New Session") {
break;
}
}
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
.expect("palette closed, back to the prompt");
assert!(
!harness.contains_text("New Session"),
"command palette must be gone after Esc\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,85 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// `/new` in minimal mode starts a fresh session — it commits a
/// second welcome card and resets the committed frontier — while leaving the
/// prior turn's committed lines in the terminal's native scrollback (we cannot,
/// and must not, un-print them). After `/new` a fresh turn streams normally.
///
/// The first turn is genuinely taller than the screen (a fenced code block, not
/// reflowable prose) so the first welcome card and the turn's head scroll into
/// *native scrollback* before `/new`. The robust "new session" signal is then
/// that a *second* welcome card appears (two `Grok Build` banners in
/// scrollback+screen), independent of exactly how content scrolled.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_new_session_keeps_history_and_resets() {
/// Substring printed once per minimal welcome card (see `minimal::welcome`).
const WELCOME_BANNER: &str = "Grok Build";
let content = ContentController::start().await.expect("start content");
// Code-block rows (not prose, which markdown-reflows to fit on screen) so
// turn 1 is genuinely taller than the screen and its head commits into
// native scrollback before `/new` — see `tall_response`.
content.set_response(tall_response(&turn_sentinel(1), 80));
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit turn 1");
// Wait until turn 1's head has committed into *native scrollback* (above the
// viewport). The first welcome card was printed before it, so it is in
// scrollback too by this point.
let deadline = Instant::now() + Duration::from_secs(40);
while Instant::now() < deadline && !harness.scrollback_text().contains(&turn_sentinel(1)) {
harness.update(Duration::from_millis(100));
}
assert!(
harness.scrollback_text().contains(&turn_sentinel(1)),
"turn 1 must reach native scrollback before /new\nscrollback:\n{}",
harness.scrollback_text()
);
// `/new` → fresh session: commits a second welcome card and resets the frontier.
inject_keys_paced(&mut harness, b"/new");
harness.inject_keys(b"\r").expect("submit /new");
// The "new session" signal: a *second* welcome banner now exists across
// scrollback + screen (the first is preserved in native scrollback).
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline && harness.full_text().matches(WELCOME_BANNER).count() < 2 {
harness.update(Duration::from_millis(100));
}
assert!(
harness.full_text().matches(WELCOME_BANNER).count() >= 2,
"/new must commit a second welcome card (first preserved in scrollback)\nfull:\n{}",
harness.full_text()
);
// Prior turn's committed lines remain in native scrollback (not wiped).
assert!(
harness.contains_full_text(&turn_sentinel(1)),
"prior turn must remain in native scrollback after /new\nfull:\n{}",
harness.full_text()
);
// A fresh turn streams in the new session.
content.set_response(format!("{} new session payload.", turn_sentinel(2)));
harness
.inject_keys(b"hi\r")
.expect("submit a turn in the new session");
harness
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
.expect("new-session turn streams");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,65 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal status line: pressing Enter with a draft while a turn is
/// running queues the prompt, and the status line reports the pending count
/// ("N queued") together with the `/queue` inspection hint — minimal has no
/// interactive queue pane, so without the hint the count is a dead end.
/// Running `/queue` mid-turn commits the read-only snapshot listing the queued
/// text. Standalone (no leader), so the queue is the client-local
/// pending-prompts list. The queued prompt then promotes and runs once the
/// first turn finishes.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_queue_indicator_shows_while_running() {
let content = ContentController::start().await.expect("start content");
// Pace turn 1 so it's still streaming when we queue behind it.
content.set_chunk_delay(Some(Duration::from_millis(150)));
content.set_turns([
slow_turn_text("STEPONE"),
"STEPTWO queued prompt handled.".to_owned(),
]);
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("STEPONE", Duration::from_secs(30))
.expect("turn 1 streaming in the live tail");
// Enter with a draft while the turn runs → queue it behind the running turn.
harness
.inject_keys(b"second queued prompt\r")
.expect("queue a prompt mid-turn");
// The count and the inspection hint share the info row: "1 queued · /queue".
harness
.wait_for_text("1 queued \u{b7} /queue", Duration::from_secs(10))
.expect("info row reports the queued count with the /queue hint");
// `/queue` (slash commands run immediately, they don't queue) commits the
// read-only snapshot listing the queued prompt.
inject_keys_paced(&mut harness, b"/queue");
harness.inject_keys(b"\r").expect("run /queue");
harness
.wait_for_full_text("Queued prompt", Duration::from_secs(10))
.expect("/queue commits the queue snapshot block");
harness
.wait_for_full_text("#1 second queued prompt", Duration::from_secs(10))
.expect("the snapshot lists the queued text");
// The queued prompt eventually promotes and runs as turn 2.
harness
.wait_for_full_text("STEPTWO", Duration::from_secs(40))
.expect("queued prompt promoted and ran");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,69 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Resize hazard: minimal mode uses only the built-in viewport
/// machinery and never re-emits committed history (`resize_purge_rerender` /
/// `emit_to_scrollback` are forbidden). The terminal reflows committed
/// scrollback natively on resize. This commits a tall response into native
/// scrollback, resizes the terminal smaller (rows AND cols), and asserts the
/// committed content survives (not wiped, not double-printed), the pager does
/// not panic or exit, and the prompt is still functional afterwards.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_resize_preserves_committed_scrollback() {
let content = ContentController::start().await.expect("start content");
// Sentinel on the first rendered row; 80 code-block rows >> screen, so the
// head scrolls into native scrollback once the block commits. (Prose would
// markdown-reflow into one short on-screen paragraph — see `tall_response`.)
content.set_response(tall_response(MOCK_RESPONSE_SENTINEL, 80));
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// Precondition: the committed head must be in native scrollback first.
let deadline = Instant::now() + Duration::from_secs(40);
while Instant::now() < deadline && !harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL) {
harness.update(Duration::from_millis(100));
}
assert!(
harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
"precondition: committed block must reach scrollback before the resize\nscrollback:\n{}",
harness.scrollback_text()
);
// Resize smaller in both dimensions. The terminal reflows committed history
// natively; minimal must neither reprint (double-print) nor wipe it.
harness.resize(30, 80).expect("resize smaller");
harness.update(Duration::from_millis(800));
assert!(
harness.is_running(),
"pager exited during resize\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked after resize\nscreen:\n{}",
harness.screen_contents()
);
assert!(
harness.contains_full_text(MOCK_RESPONSE_SENTINEL),
"committed content must survive the resize reflow\nfull:\n{}",
harness.full_text()
);
// The prompt is still functional: a second turn streams after the resize.
content.set_response(format!("{} after resize.", turn_sentinel(2)));
harness
.inject_keys(b"again\r")
.expect("submit a second turn after resize");
harness
.wait_for_full_text(&turn_sentinel(2), Duration::from_secs(30))
.expect("second turn streams after the resize");
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,43 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// `/settings` opens the full settings editor inline in minimal mode —
/// hosted in the grown live viewport, reusing the real `render_settings_modal`
/// so behavior matches the full TUI — and Esc closes it back to the prompt.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_settings_modal_opens_and_closes() {
let content = ContentController::start().await.expect("start content");
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
// Open the settings editor (paced so the slash dropdown opens rather than
// the bytes paste-coalescing), then submit.
inject_keys_paced(&mut harness, b"/settings");
harness.inject_keys(b"\r").expect("submit /settings");
// "Appearance" is the first settings category header — it renders only in
// the settings editor, never in the status line or the slash dropdown.
harness
.wait_for_text("Appearance", Duration::from_secs(10))
.expect("settings editor renders inline");
// Esc closes it; the idle prompt status returns and the editor is gone.
harness.inject_keys(keys::ESC).expect("close settings");
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
.expect("settings closed, back to the prompt");
assert!(
!harness.contains_text("Appearance"),
"settings editor must be gone after Esc\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,42 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal mode surfaces the Shift+Tab session mode in the one-line info bar
/// directly under the prompt. The mode cycle itself is shared with the full TUI,
/// but minimal had no persistent indicator, so pressing Shift+Tab "did nothing
/// visible" (dogfood nit). The first press (Normal → Plan, gate-independent)
/// must light a lowercase `plan` flag in the info bar that
/// `crate::minimal::live::render_prompt_info` draws below the prompt.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_shift_tab_shows_mode_in_info_bar() {
let content = ContentController::start().await.expect("start content");
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
// Baseline: nothing on the idle screen says "plan" — the welcome card hint
// is "/help for commands …", the status line "minimal · /help", and the
// empty-prompt placeholder "Build anything". So a lowercase "plan" can only
// come from the mode flag under test. (The shell's transient
// "Switched to mode: Plan" banner uses a capital P, which we don't match.)
assert!(
!harness.contains_text("plan"),
"precondition: idle minimal screen must not already show 'plan'\nscreen:\n{}",
harness.screen_contents()
);
// Shift+Tab → BackTab (CSI Z). First press cycles Normal → Plan.
harness.inject_keys(b"\x1b[Z").expect("inject BackTab");
harness
.wait_for_text("plan", Duration::from_secs(10))
.expect("plan flag in the info bar under the prompt after Shift+Tab");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,96 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Content-anchored live region: a response that FITS on the
/// screen stays on the visible static band with the prompt directly after it. It
/// is NOT force-pushed to the bottom of the screen — the earlier bottom-pin
/// behavior left a large blank gap *above* a short conversation (the reported
/// regression: "you see a big gap … input snapped to the bottom").
///
/// Discriminating signals (all robust to how the emulator pads blank rows):
/// - the response stays on the visible screen, and is NOT pushed into native
/// scrollback (a response that fits never needs to scroll);
/// - the always-focused prompt (the cursor) sits HIGH on the screen, directly
/// after the short conversation, with the rest of the window left blank below
/// it — bottom-pin would instead put the cursor near the last row.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_short_response_stays_on_screen() {
let content = ContentController::start().await.expect("start content");
// A short answer — a couple of rendered rows, far shorter than the 50-row
// screen, so it never needs to scroll into native history.
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} — short answer that fits."
));
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// Wait for the response to render on the visible screen, then let the turn
// finish and the commit settle.
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("short response renders on screen");
harness.update(Duration::from_millis(400));
let rows = DEFAULT_ROWS;
// 1) The short response is on the visible screen …
assert!(
harness.screen_contents().contains(MOCK_RESPONSE_SENTINEL),
"short response must stay on the visible screen\nscreen:\n{}",
harness.screen_contents()
);
// 2) … and was NOT force-pushed into native scrollback. Content-anchored: a
// response that fits stays put; only content taller than the screen scrolls
// (proven separately by `minimal_commits_response_to_scrollback`).
assert!(
!harness.scrollback_text().contains(MOCK_RESPONSE_SENTINEL),
"short response must not be pushed into scrollback\nscrollback:\n{}",
harness.scrollback_text()
);
// 3) The prompt sits directly after the (short) conversation, HIGH on the
// screen, with the rest of the window left blank below it — NOT pinned to
// the bottom with a big gap above (the regression). The cursor is always
// on the focused prompt, so its row is the robust signal: bottom-pin puts
// it near `rows - 1`; content-anchored keeps it in the upper portion.
let (cursor_row, _cursor_col) = harness.cursor_position();
assert!(
cursor_row < rows - 12,
"prompt/cursor should sit high on the screen (content-anchored), not \
pinned near the bottom: cursor_row={cursor_row}, rows={rows}\nscreen:\n{}",
harness.screen_contents()
);
// 4) Nothing is rendered near the bottom of the screen: the last non-blank
// row (the prompt's info bar) is well above the last row. Found explicitly
// (not via trailing padding) so the check is independent of how the
// emulator represents empty rows.
let screen = harness.screen_contents();
let last_non_blank = screen
.lines()
.enumerate()
.filter(|(_, l)| !l.trim().is_empty())
.map(|(i, _)| i)
.last()
.unwrap_or(0);
assert!(
last_non_blank < (rows as usize) - 10,
"content-anchored live region must leave the bottom of the screen blank; \
last non-blank row was {last_non_blank} of {rows}\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,43 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal overlay host: typing `/` opens a prompt-anchored slash
/// dropdown above the prompt (growing the pinned live viewport to make room),
/// and a single Esc dismisses it. The pane-level slash handler must consume the
/// Esc before the idle clear / rewind policy ever runs.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_slash_dropdown_dismisses_with_esc() {
let content = ContentController::start().await.expect("start content");
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
// "/mod" narrows to `/model`, whose description renders only inside the
// dropdown — not in the typed text nor the `minimal · /help` status line —
// so it's an unambiguous "dropdown is open" sentinel.
inject_keys_paced(&mut harness, b"/mod");
harness
.wait_for_text("Switch the active model", Duration::from_secs(10))
.expect("slash dropdown open above the prompt");
harness.inject_keys(keys::ESC).expect("press esc");
harness.update(Duration::from_millis(400));
let screen = harness.screen_contents();
assert!(
!screen.contains("Switch the active model"),
"Esc must dismiss the slash dropdown\nscreen:\n{screen}"
);
// Dismiss only — Esc must not have armed the idle clear or opened rewind.
assert!(
!screen.contains("press again to clear"),
"slash-dropdown Esc must not fall through to the idle clear\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{screen}"
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,102 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// `/minimal` from a fullscreen session re-execs the pager with `--minimal
/// --resume <id>` so the same conversation reopens under scrollback-native
/// rendering. Proves the end-to-end screen-mode switch path (slash command →
/// quit → exec → resume in minimal) that unit tests cannot cover.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_slash_switches_from_fullscreen() {
let content = ContentController::start().await.expect("start content");
let sentinel = turn_sentinel(1);
content.set_response(format!("{sentinel} fullscreen payload."));
// Stable project dir so the resumed session is findable by id after re-exec.
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
let binary = pager_binary().expect("resolve pager binary");
// Start fullscreen (default), standalone. Enable query responses *before*
// the re-exec so the post-switch minimal probe does not silently downgrade
// to full-height inline.
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--no-leader"],
Some(project.path()),
)
.expect("spawn fullscreen pager");
harness.set_respond_to_queries(true);
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Establish a real session with content so `--resume` has history to load.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit turn");
harness
.wait_for_text(&sentinel, Duration::from_secs(30))
.expect("mock response in fullscreen");
// Switch: `/minimal` should re-exec into scrollback-native mode with the
// same session. Pace keystrokes so the slash dropdown opens rather than
// paste-coalescing, then confirm once the description row is visible.
inject_keys_paced(&mut harness, b"/minimal");
harness
.wait_for_text(
"Reopen this session in minimal (scrollback-native) mode",
Duration::from_secs(5),
)
.expect("slash dropdown offers /minimal");
harness.update(Duration::from_millis(150));
harness.inject_keys(b"\r").expect("submit /minimal");
// After the relaunch the PTY stays live (Unix: same process via `exec`;
// Windows: child on the same console with the parent parked in `wait`);
// wait for minimal's idle status. A `/minimal` re-exec shows the
// switch-back form (`… /fullscreen to go back · /help`), not the cold-start
// `minimal · /help` sentinel alone.
harness
.wait_for_text(MINIMAL_SWITCH_BACK_IDLE_SENTINEL, Duration::from_secs(45))
.unwrap_or_else(|e| {
panic!(
"/minimal did not reopen session in minimal mode: {e}\nscreen:\n{}",
harness.screen_contents()
)
});
harness
.wait_for_full_text(&sentinel, Duration::from_secs(30))
.unwrap_or_else(|e| {
panic!(
"prior turn must be present after /minimal resume: {e}\nfull:\n{}",
harness.full_text()
)
});
// Main-screen clear on relaunch: "Reopening session…" was printed just
// before exec and must not remain above the resumed UI (the clear wipes
// residual main-buffer detritus so the welcome card sits at the top).
let screen = harness.screen_contents();
assert!(
!screen.contains("Reopening session"),
"main screen should be cleared on /minimal relaunch; leftover reopen text:\n{screen}"
);
assert!(
screen.contains("Grok Build") || harness.full_text().contains("Grok Build"),
"welcome card should re-anchor at top after /minimal relaunch\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked after /minimal\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,86 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// `/fullscreen` from a minimal session re-execs the pager without `--minimal`
/// and with `--resume <id>`, reopening the same conversation under the
/// fullscreen alt-screen TUI. The reverse of `minimal_slash_switches_from_fullscreen`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_slash_switches_to_fullscreen() {
let content = ContentController::start().await.expect("start content");
let sentinel = turn_sentinel(1);
content.set_response(format!("{sentinel} minimal payload."));
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
let mut harness =
spawn_minimal_in_dir(&content, DEFAULT_ROWS, DEFAULT_COLS, &[], project.path());
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit turn");
harness
.wait_for_full_text(&sentinel, Duration::from_secs(30))
.expect("turn committed in minimal");
// Switch back to fullscreen. Wait for the dropdown row so Enter confirms
// the command (not a bare paste of the text).
inject_keys_paced(&mut harness, b"/fullscreen");
harness
.wait_for_text(
"Reopen this session in fullscreen mode",
Duration::from_secs(5),
)
.expect("slash dropdown offers /fullscreen");
harness.update(Duration::from_millis(150));
harness.inject_keys(b"\r").expect("submit /fullscreen");
// Prior turn content is already on the minimal screen, so we cannot use
// `wait_for_text(sentinel)` as the transition signal — it would return
// immediately. Wait until the minimal idle status line is gone (proves we
// left scrollback-native mode) while the prior turn remains visible.
let deadline = Instant::now() + Duration::from_secs(45);
loop {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
let left_minimal = !screen.contains(MINIMAL_IDLE_SENTINEL)
&& !screen.contains(MINIMAL_SWITCH_BACK_IDLE_SENTINEL)
&& !screen.contains("Reopen this session in fullscreen mode");
let history_present = screen.contains(&sentinel) || harness.full_text().contains(&sentinel);
if left_minimal && history_present {
break;
}
if Instant::now() >= deadline {
panic!(
"/fullscreen did not leave minimal mode with history intact\nscreen:\n{}\nfull:\n{}",
harness.screen_contents(),
harness.full_text()
);
}
}
assert!(
!harness.contains_text("panicked"),
"pager panicked after /fullscreen\nscreen:\n{}",
harness.screen_contents()
);
// Slash-command mode switches are session-scoped: `/fullscreen` relaunch
// must not write `[ui] screen_mode` (manual config only).
let config_path = content.home().join(".grok").join("config.toml");
// Brief settle so a fire-and-forget write would have landed if still present.
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
harness.update(Duration::from_millis(100));
}
let body = std::fs::read_to_string(&config_path).unwrap_or_default();
assert!(
!body.contains("screen_mode"),
"/fullscreen must not persist [ui] screen_mode; config.toml:\n{body}"
);
harness.quit().expect("clean quit");
}

View file

@ -0,0 +1,111 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Reasoning text streamed by the mock. Must never appear in the answer text
/// so screen assertions can tell the two apart.
const REASONING_SENTINEL: &str = "REASONINGSENTINEL";
/// Dogfood bug: "I don't see thoughts in the transcript". With thinking
/// enabled (`[ui] show_thinking_blocks` — the default, set
/// explicitly here so the test doesn't depend on the rollout default),
/// minimal commits reasoning as a **collapsed** `Thought for Xs` header
/// (print-once display policy) — the body is intentionally not in the live
/// scrollback. The advertised full-fidelity `/transcript` view must therefore
/// render the thinking body **expanded**, or the reasoning is unreachable.
///
/// Flow: stream a reasoning+text turn → the answer commits, the reasoning
/// collapses to its header (body nowhere on screen) → `/transcript` with
/// `PAGER=cat` dumps the full view → the reasoning body appears.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_transcript_expands_collapsed_thinking() {
// The model must run on the Responses backend — reasoning summary deltas
// are a Responses-API stream shape (the scripted events below).
let content = ContentController::start_with_models(vec![
MockModel::new("test-model").with_api_backend("responses"),
])
.await
.expect("start content");
// The scripted turn streams reasoning deltas before the visible answer.
// Two copies so an auxiliary request can't starve the prompt turn
// (consumed FIFO; unconsumed scripts are dropped with the server).
let reasoning = format!("{REASONING_SENTINEL} pondering syllables quietly");
let answer = format!("{MOCK_RESPONSE_SENTINEL} the answer body.");
for _ in 0..2 {
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(sse::responses_api_reasoning_and_text_events(
&reasoning,
&answer,
"test-model",
)),
);
}
// Fallback mode for any further auxiliary traffic.
content.set_response(answer.clone());
// Thinking blocks explicitly ON (ingestion is gated on this toggle; the
// sandbox `$HOME` starts with no config at all).
std::fs::create_dir_all(content.home().join(".grok")).expect("mk .grok");
std::fs::write(
content.home().join(".grok/config.toml"),
"[ui]\nshow_thinking_blocks = true\n",
)
.expect("write config");
// Minimal env + PAGER=cat (non-interactive dump, same as
// `minimal_transcript_opens_in_pager`).
let mut env = content.env_for_pager();
env.push(("PAGER".to_string(), "cat".to_string()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn committed");
// The reasoning committed as its collapsed header: the body is NOT in the
// live view (that's the print-once display policy, not a bug)…
harness
.wait_for_full_text("Thought for", Duration::from_secs(10))
.expect("collapsed thinking header committed");
assert!(
!harness.full_text().contains(REASONING_SENTINEL),
"reasoning body must be collapsed in the live view\nfull:\n{}",
harness.full_text()
);
// …so the transcript is the only way to read it. cat dumps the full view.
inject_keys_paced(&mut harness, b"/transcript");
harness.inject_keys(b"\r").expect("submit /transcript");
harness
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(15))
.unwrap_or_else(|e| {
panic!(
"transcript must expand the collapsed thinking body: {e}\nfull:\n{}",
harness.full_text()
)
});
// And the inline TUI survives the suspend/restore round trip.
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
.expect("inline TUI restored after the pager exited");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,71 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Minimal full view: `/transcript` renders the WHOLE conversation
/// fully expanded (reasoning in full, tool output uncapped) as ANSI to a temp
/// file and opens it in `$PAGER`, suspending the inline TUI, then restores. We
/// set `PAGER=cat` so the child dumps the transcript and exits immediately (no
/// interactive `less` to drive). Proof the pager ran on the transcript: the
/// turn's sentinel then appears **twice** — once in the live conversation and
/// once in the dumped transcript — and the inline TUI restores to idle after.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_transcript_opens_in_pager() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} transcript body."));
// Minimal env + PAGER=cat (non-interactive). Response forwarding on so the
// inline-viewport cursor probe completes (see spawn_minimal).
let mut env = content.env_for_pager();
env.push(("PAGER".to_string(), "cat".to_string()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);
// Produce a turn so there is something to transcribe.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn committed");
// Open the transcript in $PAGER (cat dumps it and exits).
inject_keys_paced(&mut harness, b"/transcript");
harness.inject_keys(b"\r").expect("submit /transcript");
// The pager (cat) dumps the full transcript, which re-emits the turn body,
// so the sentinel now appears at least twice across scrollback + screen (the
// live turn plus the dumped transcript). This proves the pager ran on the
// rendered transcript rather than the command being a no-op.
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline
&& harness.full_text().matches(MOCK_RESPONSE_SENTINEL).count() < 2
{
harness.update(Duration::from_millis(100));
}
assert!(
harness.full_text().matches(MOCK_RESPONSE_SENTINEL).count() >= 2,
"transcript must be dumped by $PAGER (sentinel should appear in both the \
live turn and the dumped transcript)\nfull:\n{}",
harness.full_text()
);
// The pager process survives the suspend/restore round-trip and returns to
// the idle prompt.
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
.expect("inline TUI restored after the pager exited");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,137 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Frame-write delay (ms) widening the suspend-vs-queued-frame race window:
/// the writer thread holds every frame this long, so any frame still queued
/// when the suspend runs is guaranteed to land during the `$PAGER` child's
/// tenure (on its alternate screen) unless the pre-suspend drain waits for it.
const FRAME_DELAY_MS: &str = "40";
/// Dogfood bug: after returning from the minimal `/transcript` pager the live
/// region was "off by one" — a stale status row above a freshly drawn prompt,
/// stray `[` escape fragments, the cursor on the info row. Root cause: frames
/// are written to the tty by an async writer thread, and the suspend path
/// never drained it — the frame that armed the suspend (plus any composer
/// clear / viewport scroll frames queued just before) raced `less`, landing on
/// its alternate screen. The main screen never received those writes, so after
/// `rmcup` restored it, the renderer's diff no longer described reality and
/// the stale rows were never repainted.
///
/// The fix drains the writer before the child takes the tty (deterministic
/// coverage: the `WriterSync` unit tests in `render::draw`), then re-anchors
/// and full-repaints after it exits. This test drives the user-visible round
/// trip — burst of composer edits, `/transcript`, a real `less` (alt screen +
/// `rmcup`), `q` — under the frame-write delay, and asserts the restored
/// screen is exactly the idle live region: one status row, one info row, no
/// stale draft/command text, no torn escape fragments.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_transcript_pager_restore_no_artifacts() {
// A real interactive pager is the point (alt screen + rmcup restore).
if std::process::Command::new("less")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_err()
{
eprintln!("skipping: `less` not available on this machine");
return;
}
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} transcript body."));
let mut env = content.env_for_pager();
env.push(("PAGER".to_string(), "less".to_string()));
env.push((
"GROK_TEST_FRAME_WRITE_DELAY_MS".to_string(),
FRAME_DELAY_MS.to_string(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);
// A committed turn so the transcript has content.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn committed");
// Let the (delayed) writer queue fully drain at idle so the burst below is
// the only thing in flight when the suspend arms.
harness.update(Duration::from_secs(3));
// Queue a burst of composer-changing frames right before the suspend:
// type a draft, kill it (Ctrl+U), then type /transcript (slash dropdown
// opens + closes: viewport grow/shrink frames) and submit. With the
// writer delay, the trailing clear/shrink frames are still queued when
// the suspend path runs — the shape of the dogfood report.
inject_keys_paced(&mut harness, b"zetaquxdraft");
harness.update(Duration::from_millis(120));
harness.inject_keys(b"\x15").expect("Ctrl+U kill draft");
inject_keys_paced(&mut harness, b"/transcript");
harness.inject_keys(b"\r").expect("submit /transcript");
// less (alt screen, `+G`) is foreground once its status line shows the
// transcript temp-file path — the conversation sentinel can't be used as
// the signal, it is already visible on the live screen. Then quit it.
harness
.wait_for_text("grok-transcript-", Duration::from_secs(20))
.expect("less shows the transcript");
harness.update(Duration::from_millis(300));
harness.inject_keys(b"q").expect("quit less");
// Restored: the idle live region, with no artifacts. Give the resume
// redraw (and any stragglers) time to settle before asserting.
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
.expect("inline TUI restored after less exited");
harness.update(Duration::from_secs(2));
let screen = harness.screen_contents();
// Exactly one status row and one info row on the visible screen: a lost
// scroll leaves a stale extra copy of one of them (the "off by one" look).
assert_eq!(
screen.matches(MINIMAL_IDLE_SENTINEL).count(),
1,
"exactly one idle status row after the pager round trip\nscreen:\n{screen}"
);
assert_eq!(
screen.matches("ctrl+o transcript").count(),
1,
"exactly one info row after the pager round trip\nscreen:\n{screen}"
);
// The frames queued right before the suspend (composer kill + /transcript
// submit-clear) must have LANDED: without the pre-suspend writer drain
// they die on the pager's alternate screen, and the restored main screen
// still shows the killed draft / the submitted command in the prompt while
// the renderer's diff believes both are gone — exactly the stale-row
// corruption from the dogfood report.
assert!(
!screen.contains(" /transcript"),
"stale submitted command left in the prompt after the round trip\nscreen:\n{screen}"
);
assert!(
!screen.contains("zetaquxdraft"),
"stale killed-draft text left on screen after the round trip\nscreen:\n{screen}"
);
// No torn-escape fragments at the left margin (the dogfood `[Claud…` row):
// no screen row may START with a literal `[`.
assert!(
!screen.lines().any(|l| l.starts_with('[')),
"no torn escape fragments at column 0\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{screen}"
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,30 @@
//! PTY e2e tests for the experimental `--minimal` (scrollback-native) mode.
//!
//! Grouped under one `mod minimal;` so the parent `pty_e2e` module isn't
//! interleaved with a dozen `minimal_*` entries — a full-pager contributor can
//! skip this whole subtree. These exercise the sibling `xai-grok-pager-minimal`
//! crate end-to-end through the built binary (which installs the minimal hook),
//! so they live with the rest of the pty suite rather than in a separate crate.
//! Shared harness helpers are reached via `crate::common` (the family root).
mod minimal_cli_screen_mode_does_not_persist;
mod minimal_commits_response_to_scrollback;
mod minimal_committed_content_survives_overlay_grow;
mod minimal_continue_reprints_transcript;
mod minimal_ctrl_c_arms_and_quits;
mod minimal_double_esc_committed_queued_prompt_single_render;
mod minimal_esc_mid_turn_is_swallowed;
mod minimal_flush_left_no_hpad;
mod minimal_help_opens_command_palette;
mod minimal_new_session_keeps_history_and_resets;
mod minimal_queue_indicator_shows_while_running;
mod minimal_resize_preserves_committed_scrollback;
mod minimal_settings_modal_opens_and_closes;
mod minimal_shift_tab_shows_mode_in_info_bar;
mod minimal_short_response_stays_on_screen;
mod minimal_slash_dropdown_dismisses_with_esc;
mod minimal_slash_switches_from_fullscreen;
mod minimal_slash_switches_to_fullscreen;
mod minimal_transcript_expands_collapsed_thinking;
mod minimal_transcript_opens_in_pager;
mod minimal_transcript_pager_restore_no_artifacts;