M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,102 @@
# Synthetic bash-history corpus for permission-view / fuzzy-match tests.
# All hostnames, paths, user names, branch names, and flags are fictionalized
# or generic. No real production session content. (OSS fixture hygiene.)
#
### CMD 01
gh api user --jq '.login' && echo "---" && gh search prs --author=@me --sort=updated --limit=15 --json number,title,url,state,updatedAt,repository,isDraft --jq '.[] | "\(.state)\t#\(.number)\t\(.updatedAt)\t\(.repository.nameWithOwner)\t\(.title)\t\(.url)"'
### END
### CMD 02
cargo test \
--all \
-- --nocapture
### END
### CMD 03
docker run \
-v /tmp:/tmp \
-e FOO=bar \
alpine:latest echo hi
### END
### CMD 04
cat <<'EOF' && echo after
line with && not an operator
line with | pipe text
EOF
### END
### CMD 05
echo "a && b" && echo real && echo 'x | y'
### END
### CMD 06
git status --short --branch && cargo test --workspace --all-features
### END
### CMD 07
ps aux | grep -v grep | grep cargo | head -n 20
### END
### CMD 08
true&&false||true; echo done
### END
### CMD 09
cd /tmp && sleep 1 && timeout 30 cargo check 2>&1 | tail -50
### END
### CMD 10
python3 -c 'print("hello && world | not pipe")' && echo ok
### END
### CMD 11
cd /tmp/compaction-repro && KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -p 'reply with exactly: ok' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless2.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless2.log
### END
### CMD 12
cd /tmp/compaction-repro && KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -p 'reply with exactly: ok' --always-approve --no-leader > /tmp/compaction-repro/headless1.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless1.log
### END
### CMD 13
cd /Users/alice/Documents/code/project && DYLD_INSERT_LIBRARIES=/opt/homebrew/opt/libfaketime/lib/faketime/libfaketime.1.dylib FAKETIME="@2026-06-11 23:58:30" FAKETIME_DONT_FAKE_MONOTONIC=1 /tmp/pager-under-test-dr --no-leader --always-approve 2>/tmp/ftroll.err
### END
### CMD 14
! kubectl oidc-login get-token --oidc-issuer-url https://login.example.com --oidc-client-id example-client-id --oidc-extra-scope "openid profile email groups offline_access" >/dev/null && echo OIDC_OK
### END
### CMD 15
ssh -t -L 8000:localhost:8000 user@build-host.example.internal 'cd /home/user/project && export PATH=$HOME/.cargo/bin:$PATH && ./scripts/login.sh'
### END
### CMD 16
cd /Users/alice/Documents/code/project && DYLD_INSERT_LIBRARIES=/opt/homebrew/opt/libfaketime/lib/faketime/libfaketime.1.dylib FAKETIME_TIMESTAMP_FILE=/tmp/ftfile FAKETIME_NO_CACHE=1 FAKETIME_DONT_FAKE_MONOTONIC=1 /tmp/pager-under-test-dr --no-leader --always-approve 2>/tmp/ftdbg.err
### END
### CMD 17
! kubectl --context kind-local -n example-system get secret example-db-password -o jsonpath='{.data.EXAMPLE_DB_PASSWORD}' | base64 -d
### END
### CMD 18
curl -fsSL x.ai/cli/install.sh | grep jq
### END
### CMD 19
curl -LsSf "http://file-server.example.internal/cicd/example/install.sh?nocache" | bash
### END
### CMD 20
! sudo du -shx /.Spotlight-V100 /.DocumentRevisions-V100 /private/var/vm /private/var/folders /cores /Library 2>/dev/null | sort -rh
### END
### CMD 21
cat ~/.grok/config.toml | grep composer
### END
### CMD 22
KIGI_DEBUG_CONTEXT_WINDOW=100000 KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=5 /tmp/pager-under-test -r 019e0000-0000-7000-8000-000000000001 -p 'reply with exactly: again' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless5.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless5.log
### END
### CMD 23
KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -r 019e0000-0000-7000-8000-000000000001 -p 'now reply with exactly: done' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless3.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless3.log
### END
### CMD 24
KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -r 019e0000-0000-7000-8000-000000000001 -p 'reply with exactly: final' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless4.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless4.log
### END
### CMD 25
KIGI_AUTO_COMPACT_THRESHOLD_PERCENT=1 /tmp/pager-under-test -p 'reply with exactly: ok' --model example-model --always-approve --no-leader > /tmp/compaction-repro/headless1.log 2>&1; echo EXIT=$? >> /tmp/compaction-repro/headless1.log
### END
### CMD 26
cd /Users/alice/Documents/code/project-wt/fix-prompt-wheel-history && \\
### END
### CMD 27
strings target/debug/kigi-tui | grep -c slash-mru-writer # must print >=1\
### END
### CMD 28
SSH_CONNECTION='10.0.0.1 51234 10.0.0.2 22' \\
### END
### CMD 29
DYLD_FORCE_FLAT_NAMESPACE=1 DYLD_INSERT_LIBRARIES=/opt/homebrew/lib/faketime/libfaketime.1.dylib FAKETIME_TIMESTAMP_FILE=/tmp/ft.rc FAKETIME_NO_CACHE=1 FAKETIME_DONT_FAKE_MONOTONIC=1 /tmp/pager-date-test --model grok-build --always-approve --no-leader
### END
### CMD 30
DYLD_FORCE_FLAT_NAMESPACE=1 DYLD_INSERT_LIBRARIES=/opt/homebrew/lib/faketime/libfaketime.1.dylib FAKETIME_TIMESTAMP_FILE=/tmp/ft.rc FAKETIME_NO_CACHE=1 FAKETIME_DONT_FAKE_MONOTONIC=1 /tmp/pager-date-test --always-approve --no-leader
### END
@@ -0,0 +1,37 @@
//! `KIGI_SHARE_DIR` override tests in an isolated binary so `kigi_home()`'s
//! process-wide `OnceLock` initializes from the overridden env var.
use std::path::PathBuf;
#[test]
fn kigi_home_override_path_helpers() {
let tmp = tempfile::tempdir().expect("tempdir");
let kigi_home = tmp.path().to_path_buf();
unsafe {
std::env::set_var("KIGI_SHARE_DIR", &kigi_home);
}
assert_eq!(
kigi_tui::util::pager_toml_path(),
kigi_home.join("pager.toml")
);
assert_eq!(
kigi_tui::util::display_kigi_home_prefix(),
"$KIGI_SHARE_DIR"
);
assert_eq!(
kigi_tui::util::display_user_grok_path("config.toml"),
"$KIGI_SHARE_DIR/config.toml"
);
let memory_path = kigi_home.join("memory/MEMORY.md");
assert_eq!(
kigi_tui::util::abbreviate_path(&memory_path.display().to_string()),
"$KIGI_SHARE_DIR/memory/MEMORY.md"
);
assert!(kigi_tui::util::is_under_user_kigi_home(&memory_path));
assert!(!kigi_tui::util::is_under_user_kigi_home(
PathBuf::from("/tmp/other").as_path()
));
}
@@ -0,0 +1,140 @@
// Per-test-case module for the `leader_pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// **Leader mode: a `/model` pick in the TUI dismisses a remote campaign.**
///
/// The dismiss chokepoint (`persist_user_choice`) runs in the **TUI process**,
/// but in leader mode no in-process agent ever seeds the TUI's remote campaign
/// cache — only `app::run`'s own seed makes a remote campaign visible to
/// `resolve_dismissable_campaigns`. Without that seed this test times out in
/// the dismiss phase: the pick persists but no dismissal is recorded, and the
/// leader re-nudges every new session over the user's explicit choice.
///
/// The TUI's settings prefetch is deliberately 2s-capped, so on a loaded
/// runner a spawn can miss the fetch (unseeded cache — the documented
/// transient leader-mode divergence). The test retries with fresh TUI spawns
/// (same leader) until a pick lands the dismissal, then proves it sticks.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
async fn campaign_leader_mode_remote_dismiss_on_model_pick() {
const CONFIG_MODEL: &str = "config-model";
const CAMPAIGN_MODEL: &str = "campaign-model";
const CAMPAIGN_ID: &str = "e2e-leader-remote-nudge";
let content = ContentController::start_with_models(vec![
MockModel::new(CONFIG_MODEL),
MockModel::new(CAMPAIGN_MODEL),
])
.await
.expect("start content with two models");
// Serve the campaign from the settings endpoint (restating `allow_access`,
// which the preset otherwise provides).
content.server().set_settings(json!({
"allow_access": true,
"campaigns": [
{ "id": CAMPAIGN_ID, "models": { "default": CAMPAIGN_MODEL } }
]
}));
// Seed config.toml with the user's own default model; a fixed leader
// socket under the shared KIGI_SHARE_DIR so every spawn elects/attaches to the
// same leader (mirrors `LeaderCluster`).
let kigi_home = content.home().join(".kigi");
std::fs::create_dir_all(&kigi_home).expect("create KIGI_SHARE_DIR");
std::fs::write(
kigi_home.join("config.toml"),
format!("[models]\ndefault = \"{CONFIG_MODEL}\"\n"),
)
.expect("write config.toml");
let socket = kigi_home.join("leader-e2e.sock");
let socket = socket.to_str().expect("socket path is utf-8").to_owned();
// Session (OAuth) auth, not the harness's default XAI_API_KEY: the
// settings fetch requires `auth_manager.auth()` — in ApiKey/BYOK mode the
// pager never requests `/v1/settings`, so a remote campaign would be
// structurally unreachable (see `spawn_polling_session`'s doc).
seed_fake_oauth(&content, "pty-campaign-leader");
let binary = pager_binary().expect("resolve pager binary");
let env = oauth_env_for_pager(&content);
let spawn = || -> PtyHarness {
let env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--leader", "--leader-socket", &socket],
&env_refs,
)
.expect("spawn leader-mode pager")
};
let state_path = kigi_home.join("campaigns_state.json");
let dismissed = |state_path: &std::path::Path| {
std::fs::read_to_string(state_path)
.map(|s| s.contains(CAMPAIGN_ID))
.unwrap_or(false)
};
// ── Phase 1+2: nudge on a new session; a pick records the dismissal in
// the TUI process. Retries fresh TUI spawns (same leader) so a missed
// 2s prefetch window on a loaded runner can't wedge the test.
let mut recorded = false;
'attempts: for attempt in 0..3 {
let mut h = spawn();
h.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
.unwrap_or_else(|_| {
panic!(
"leader-mode welcome never rendered (attempt {attempt})\nscreen:\n{}",
h.screen_contents()
)
});
if !wait_for_model_via_new_sessions(&mut h, CAMPAIGN_MODEL, Duration::from_secs(60)) {
// Campaign never applied on this spawn; try a fresh TUI.
h.quit().expect("clean quit");
continue;
}
h.inject_keys(format!("/model {CONFIG_MODEL}\r").as_bytes())
.expect("pick model");
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
h.update(Duration::from_millis(200));
if dismissed(&state_path) {
recorded = true;
h.quit().expect("clean quit");
break 'attempts;
}
}
// The regression under test: pick persisted but dismissal missing.
// With the app::run seed present this only happens when the prefetch
// missed on this spawn; retry once more before declaring failure.
h.quit().expect("clean quit");
}
assert!(
recorded,
"leader-mode TUI must record the remote campaign dismissal in {state_path:?}"
);
// ── Phase 3: the dismissal is durable and the pick is persisted. The
// user's choice must be in config.toml (campaign value never laundered
// in), and the dismissed id on disk is what every future resolution —
// leader or not — filters on (`dismissed_id_is_dropped_from_override`
// pins the filter; the sibling remote-settings e2e pins the full
// no-re-nudge reboot in-process). A fresh same-leader-socket client is
// deliberately not asserted on-screen here: reattach paint timing is the
// one flaky piece and adds no coverage over the disk + sibling asserts.
let config = std::fs::read_to_string(kigi_home.join("config.toml")).expect("read config.toml");
assert!(
config.contains(&format!("default = \"{CONFIG_MODEL}\"")),
"the user's pick must be persisted to config.toml:\n{config}"
);
assert!(
!config.contains(CAMPAIGN_MODEL),
"the campaign value must never be written to config.toml:\n{config}"
);
assert!(
dismissed(&state_path),
"the dismissal must survive on disk after the client exits"
);
}
@@ -0,0 +1,44 @@
//! Shared constants for the leader-mode PTY e2e tests.
//!
//! Drive/seed helpers live in `kigi_pager_pty_harness::flows` (one
//! canonical copy shared with `pty_e2e`); only suite-local constants stay
//! here.
pub(crate) use kigi_pager_pty_harness::{
ContentController, LeaderCluster, MockModel, PtyHarness, inference_request_count, keys,
oauth_env_for_pager, pager_binary, seed_fake_oauth, submit_turn, wait_for_labels_absent,
wait_for_model_via_new_sessions,
};
pub(crate) use serde_json::json;
pub(crate) use std::time::{Duration, Instant};
/// Default PTY size used by every e2e test (same as `pty_e2e`).
pub(crate) const DEFAULT_ROWS: u16 = 50;
pub(crate) const DEFAULT_COLS: u16 = 120;
/// Substring we wait for on the welcome screen (matches the menu label).
pub(crate) const WELCOME_SCREEN_SENTINEL: &str = "Quit";
/// Prompt sent to the agent in content-driven tests.
pub(crate) const PROMPT: &str = "go";
/// Response sentinel the mock server streams back.
pub(crate) const MOCK_RESPONSE_SENTINEL: &str = "MOCKRESPONSE";
/// Cold leader-client bring-up budget. History: 60s → 120s → 240s while these
/// cases ran interleaved with the full `pty_e2e` suite (each leader case
/// spawns multiple full pager processes; suite-wide contention pushed cold
/// bring-up past two minutes). Now that the leader cases run in their own
/// serialized target the budget can come back down after a green soak week —
/// tracked in the leader test-infra plan.
pub(crate) const LEADER_TIMEOUT: Duration = Duration::from_secs(240);
/// Streamed-turn deadline in leader mode (same contention rationale).
pub(crate) const STREAM_TIMEOUT: Duration = Duration::from_secs(120);
/// Sentinel for leader-test turn `n`, short enough to never wrap at 120 cols
/// (wrapping would break the exactly-once occurrence counts).
pub(crate) fn turn_sentinel(n: u8) -> String {
format!("{MOCK_RESPONSE_SENTINEL}_T{n}")
}
@@ -0,0 +1,109 @@
// Per-test-case module for the `leader_pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 23. **Leader mode — N clients share one session.**
/// Scales the multi-client fan-out to N = 1 driver + `VIEWERS` viewers (bump
/// `VIEWERS` to widen it). The driver spawns the leader and runs a turn; every
/// viewer attaches through the SAME leader and must replay that turn exactly
/// once (duplicated replay history and an empty/stuck pane both fail); a later
/// turn driven from the driver must stream live into ALL viewers exactly once;
/// and the leader + viewers must survive the spawning client's exit.
/// Deliberately NOT a superset of `leader_two_clients_shared_session`: the
/// driver/viewer role-flip (a turn driven from a viewer back to the driver)
/// and the multi-turn scrollback exactly-once integrity stay unique to that
/// test, so it must not be deleted as redundant.
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
async fn leader_n_clients_shared_session() {
// N = 1 driver + VIEWERS viewers; bump to scale the live fan-out. Keep N
// small: worker_threads above and the per-viewer survival pump below are
// sized for it — raise them together if you scale VIEWERS up.
const VIEWERS: usize = 2;
let cluster = LeaderCluster::start(DEFAULT_ROWS, DEFAULT_COLS)
.await
.expect("start cluster");
cluster
.content()
.set_response(format!("{} first turn payload.", turn_sentinel(1)));
// The driver spawns the leader and runs turn 1.
let mut a = cluster.spawn_leader(&[]).expect("spawn driver");
a.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
.expect("driver welcome");
a.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("driver submit turn 1");
a.wait_for_text(&turn_sentinel(1), STREAM_TIMEOUT)
.expect("driver turn 1");
// Every viewer attaches through the shared leader and must replay the
// driver's transcript exactly once (duplicated replay or an empty pane
// both fail).
let mut viewers: Vec<PtyHarness> = Vec::new();
for i in 0..VIEWERS {
let mut v = cluster
.attach(&[])
.unwrap_or_else(|e| panic!("spawn viewer {i}: {e}"));
v.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
.unwrap_or_else(|e| panic!("viewer {i} replayed driver's transcript: {e}"));
// Settle the PTY before counting: wait_for_text returns on first match,
// so a duplicate replay arriving in a later batch would slip past an
// immediate count. A pump can only reveal a duplicate, never hide one.
v.update(Duration::from_millis(500));
let screen = v.screen_contents();
assert_eq!(
screen.matches(&turn_sentinel(1)).count(),
1,
"turn 1 must appear in viewer {i} exactly once (duplicated replay?)\nscreen:\n{screen}"
);
viewers.push(v);
}
// Turn 2 driven from the driver streams live into EVERY viewer (fan-out).
cluster
.content()
.set_response(format!("{} second turn payload.", turn_sentinel(2)));
a.inject_keys(b"again\r").expect("driver submit turn 2");
a.wait_for_text(&turn_sentinel(2), STREAM_TIMEOUT)
.expect("driver turn 2");
for (i, v) in viewers.iter_mut().enumerate() {
v.wait_for_text(&turn_sentinel(2), STREAM_TIMEOUT)
.unwrap_or_else(|e| panic!("viewer {i} received driver's live turn: {e}"));
// Same settle-then-count guard as the replay check, now for the LIVE
// stream: a duplicated fan-out frame must fail too.
v.update(Duration::from_millis(500));
let screen = v.screen_contents();
assert_eq!(
screen.matches(&turn_sentinel(2)).count(),
1,
"turn 2 must appear in viewer {i} exactly once (duplicated live stream?)\nscreen:\n{screen}"
);
}
// The spawning client's exit must not take the leader (or the viewers)
// down: each viewer keeps its transcript and stays attached.
drop(a);
for (i, v) in viewers.iter_mut().enumerate() {
v.update(Duration::from_secs(3));
assert!(
v.is_running(),
"viewer {i} exited after the driver quit\nscreen:\n{}",
v.screen_contents()
);
assert!(
!v.contains_text("panicked"),
"viewer {i} rendered a panic\nscreen:\n{}",
v.screen_contents()
);
assert!(
v.contains_text(&turn_sentinel(2)),
"viewer {i} lost its transcript after the driver quit\nscreen:\n{}",
v.screen_contents()
);
}
for mut v in viewers {
v.quit().expect("quit viewer");
}
}
@@ -0,0 +1,128 @@
// Per-test-case module for the `leader_pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 25. **Leader reattach — cancellation round-trips through the durable log.**
/// A turn driven on leader client A is Ctrl+C-cancelled mid-stream; the leader
/// must persist a `turn_completed` terminal with `stop_reason == cancelled`
/// (the producer fail-before). A FRESH client must replay the cancelled
/// transcript through the same leader and land clean — running, no panic, and
/// not stranded on the "Waiting"/"Cancelling" spinners — and must still hold
/// that transcript after A (the original driver) exits. A keep-alive viewer
/// holds the leader up across A's exit (the leader stops with its last client).
///
/// C attaches *before* A is dropped: `PtyHarness` Drop SIGKILLs the child, and
/// under full-suite contention a cold `--resume` handshake racing that teardown
/// flakes with an empty screen for the whole `LEADER_TIMEOUT` (the observed
/// "C replayed the cancelled transcript" timeout). Replaying while A is still
/// up, then proving C survives A's exit, covers the durable-log + multi-client
/// survival invariants without that race.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
async fn leader_reattach_cancellation_roundtrips_durable_log() {
let cluster = LeaderCluster::start(DEFAULT_ROWS, DEFAULT_COLS)
.await
.expect("start leader cluster");
// Paced enough to cancel mid-stream, short enough that the heavy
// multi-client cancel drain does not dominate suite-wide contention.
let long_response = format!(
"{} {}",
turn_sentinel(1),
"more streamed filler to hold the turn open. ".repeat(40)
);
cluster.content().set_response(long_response);
cluster
.content()
.set_chunk_delay(Some(Duration::from_millis(40)));
let mut a = cluster.spawn_leader(&[]).expect("spawn leader client A");
a.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
.expect("A welcome");
a.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("A submit turn");
// Wait until the turn is clearly streaming (sentinel visible); this also
// closes the leader's rewind window so cancel is not confused with rewind.
a.wait_for_text(&turn_sentinel(1), STREAM_TIMEOUT)
.expect("A turn streaming");
// Ctrl+C on an empty prompt cancels while streaming (Esc no longer cancels).
a.inject_keys(keys::CTRL_C).expect("A press ctrl+c");
a.update(Duration::from_millis(200));
// Generous budget: the heavy multi-client leader cluster drains the paced
// cancel slower than the single-client path, so match the test's other
// waits (LEADER/STREAM_TIMEOUT) rather than the single-client 15s.
a.wait_for_text("Turn cancelled by user", STREAM_TIMEOUT)
.expect("A turn cancelled marker");
// Producer fail-before: the cancel must have persisted a durable terminal
// carrying the cancelled stop reason.
let rec = cluster
.wait_for_turn_completed(STREAM_TIMEOUT)
.expect("turn_completed persisted to updates.jsonl");
assert_eq!(
rec["stop_reason"], "cancelled",
"cancelled turn must record stop_reason=cancelled, got {rec}"
);
// Keep-alive viewer attaches AFTER the cancel so the leader survives A's
// exit; waiting for it to replay proves it is attached to the same session.
let mut keep = cluster.attach(&[]).expect("spawn keep-alive viewer");
keep.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
.expect("keep-alive replayed the cancelled transcript");
// Reattach must replay from the durable log, not re-drive a turn: the mock
// must see no new inference request while C catches up.
let inference_before_reattach = inference_request_count(cluster.content());
// Fresh reattach while A is still up (see module comment), then prove the
// original driver's exit does not take C (or the transcript) down.
let mut c = cluster.attach(&[]).expect("spawn fresh reattach client C");
c.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
.expect("C replayed the cancelled transcript");
drop(a);
// Leader processes A's disconnect; brief settle then re-check C still has
// the durable replay (not a fixed long sleep that would mask a hang).
c.update(Duration::from_millis(500));
assert!(
c.is_running(),
"C exited after A quit\nscreen:\n{}",
c.screen_contents()
);
assert!(
c.contains_text(&turn_sentinel(1)),
"C lost the cancelled transcript after A quit\nscreen:\n{}",
c.screen_contents()
);
// Same fidelity caveat as the completion case: a fresh reattach lands Idle
// either way, so absent spinners are regression guards, not fail-befores.
// Bare substrings (not the full `…`-suffixed labels) are simple stable
// matches for the spinner labels. Bound the replay wait rather than a fixed
// settle.
wait_for_labels_absent(&mut c, &["Waiting", "Cancelling"], Duration::from_secs(5));
assert!(
!c.contains_text("panicked"),
"C rendered a panic\nscreen:\n{}",
c.screen_contents()
);
assert!(
!c.contains_text("Waiting"),
"C is stranded on the active-turn spinner\nscreen:\n{}",
c.screen_contents()
);
assert!(
!c.contains_text("Cancelling"),
"C is stranded on the cancelling spinner\nscreen:\n{}",
c.screen_contents()
);
assert_eq!(
inference_request_count(cluster.content()),
inference_before_reattach,
"reattach must replay from the durable log, not re-drive a turn (no new inference request)"
);
c.quit().expect("quit C");
keep.quit().expect("quit keep-alive viewer");
}
@@ -0,0 +1,103 @@
// Per-test-case module for the `leader_pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 24. **Leader reattach — completion round-trips through the durable log.**
/// A turn driven on the leader-electing client A completes; the leader must
/// persist a replayable `turn_completed` terminal — the producer fail-before:
/// without the producer the record simply would not exist. A FRESH client that
/// re-attaches after A exits must then replay the completed transcript exactly
/// once through the same leader and land clean: running, no panic, and not
/// stranded on the active-turn "Waiting" spinner. A keep-alive viewer holds
/// the leader up across A's exit (the leader stops with its last client).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
async fn leader_reattach_completion_roundtrips_durable_log() {
let cluster = LeaderCluster::start(DEFAULT_ROWS, DEFAULT_COLS)
.await
.expect("start leader cluster");
cluster
.content()
.set_response(format!("{} completed turn payload.", turn_sentinel(1)));
// A elects the leader and drives one turn to completion.
let mut a = cluster.spawn_leader(&[]).expect("spawn leader client A");
a.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
.expect("A welcome");
a.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("A submit turn");
a.wait_for_text(&turn_sentinel(1), STREAM_TIMEOUT)
.expect("A turn rendered");
// Attach the keep-alive viewer AFTER the turn so the leader survives A's
// exit; waiting for it to replay the transcript proves it is attached.
let mut keep = cluster.attach(&[]).expect("spawn keep-alive viewer");
keep.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
.expect("keep-alive replayed A's transcript");
// Producer fail-before: the completed turn must have persisted a durable,
// replayable terminal carrying the real stop reason.
let rec = cluster
.wait_for_turn_completed(STREAM_TIMEOUT)
.expect("turn_completed persisted to updates.jsonl");
assert_eq!(
rec["stop_reason"], "end_turn",
"completed turn must record stop_reason=end_turn, got {rec}"
);
assert!(
rec["prompt_id"].as_str().is_some_and(|s| !s.is_empty()),
"turn_completed must carry a non-empty prompt_id, got {rec}"
);
// Reattach must replay from the durable log, not re-drive a turn: the mock
// must see no new inference request while C catches up.
let inference_before_reattach = inference_request_count(cluster.content());
// Fresh reattach AFTER A exits: it replays the completed transcript via the
// durable rail through the surviving leader.
drop(a);
let mut c = cluster.attach(&[]).expect("spawn fresh reattach client C");
c.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
.expect("C replayed the completed transcript");
// A finished turn clears the leader's prompt slot, so a fresh reattach lands
// Idle regardless of the consumer guard; absent "Waiting" is thus a
// regression guard, not a fail-before. The bare substring (not the full
// `…`-suffixed label) is a simple stable match for the spinner label, and
// nothing else renders "Waiting" in a settled reattached session. Bound the
// replay wait rather than a fixed settle that could flake under load.
wait_for_labels_absent(&mut c, &["Waiting"], Duration::from_secs(5));
assert!(
c.is_running(),
"C exited unexpectedly\nscreen:\n{}",
c.screen_contents()
);
assert!(
!c.contains_text("panicked"),
"C rendered a panic\nscreen:\n{}",
c.screen_contents()
);
assert!(
!c.contains_text("Waiting"),
"C is stranded on the active-turn spinner\nscreen:\n{}",
c.screen_contents()
);
assert_eq!(
inference_request_count(cluster.content()),
inference_before_reattach,
"reattach must replay from the durable log, not re-drive a turn (no new inference request)"
);
// Pump once more so a late or duplicate replay batch would be painted before
// the exactly-once count below — this test's headline guard.
c.update(Duration::from_millis(500));
let screen = c.screen_contents();
assert_eq!(
screen.matches(&turn_sentinel(1)).count(),
1,
"C must replay the turn exactly once (duplicated replay?)\nscreen:\n{screen}"
);
c.quit().expect("quit C");
keep.quit().expect("quit keep-alive viewer");
}
@@ -0,0 +1,146 @@
// Per-test-case module for the `leader_pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 12. **Leader mode — two clients share one session.**
/// The two-terminal flow: pager A starts with `--leader` on an isolated
/// socket (spawning the leader), runs a turn; pager B starts with the same
/// socket + `--resume` and attaches to A's session through the SAME leader.
/// Discriminating for the multi-client replay surface: B must render A's
/// transcript exactly once (duplicated replay history and an empty/stuck
/// pane both fail), later turns must stream live into BOTH panes regardless
/// of which client drives, and the leader + viewer must survive the
/// spawning client's exit.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1"]
async fn leader_two_clients_shared_session() {
// One shared leader (shared HOME/KIGI_SHARE_DIR hold the sessions AND the
// explicit leader socket), so B attaches to the leader A spawned instead
// of the machine's default one.
let cluster = LeaderCluster::start(DEFAULT_ROWS, DEFAULT_COLS)
.await
.expect("start cluster");
cluster
.content()
.set_response(format!("{} first turn payload.", turn_sentinel(1)));
let mut a = cluster.spawn_leader(&[]).expect("spawn pager A");
a.wait_for_text(WELCOME_SCREEN_SENTINEL, LEADER_TIMEOUT)
.expect("A welcome");
a.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("A submit turn 1");
a.wait_for_text(&turn_sentinel(1), STREAM_TIMEOUT)
.expect("A turn 1");
// B attaches to A's session (most recent in the shared cwd) via the
// shared leader and must replay A's transcript.
let mut b = cluster.attach(&[]).expect("spawn pager B");
b.wait_for_text(&turn_sentinel(1), LEADER_TIMEOUT)
.expect("B replayed A's transcript");
let b_screen = b.screen_contents();
assert_eq!(
b_screen.matches(&turn_sentinel(1)).count(),
1,
"A's turn must appear in B exactly once (duplicated replay?)\nB screen:\n{b_screen}"
);
// Turn 2 driven from A streams live into the attached viewer B.
cluster
.content()
.set_response(format!("{} second turn payload.", turn_sentinel(2)));
submit_turn(&mut a, "again", &turn_sentinel(2), STREAM_TIMEOUT);
b.wait_for_text(&turn_sentinel(2), STREAM_TIMEOUT)
.expect("B received A's live turn");
// Turn 3 driven from B reaches A (driver/viewer flip).
cluster
.content()
.set_response(format!("{} third turn payload.", turn_sentinel(3)));
submit_turn(&mut b, "more", &turn_sentinel(3), STREAM_TIMEOUT);
a.wait_for_text(&turn_sentinel(3), STREAM_TIMEOUT)
.expect("A received B's live turn");
// Each submitted prompt top-anchors its turn, scrolling earlier turns
// above the viewport. Grow the viewport and wheel-scroll to the top
// (keyboard scroll keys are captured by the focused input box) so the
// whole 3-turn transcript is on screen, then count every sentinel
// exactly once — a duplicated replay or a dropped turn both fail here,
// on either pane.
//
// Non-dev builds no longer metronome on tracing_rx; scroll finalize runs
// via has_active_stream ticks and cadence-suppressed wheels must still
// schedule ticks (handle_input returns Changed). Bazel/linux amd64 is
// slower than macOS local: keep retrying wheel bursts until every turn
// sentinel is visible (or time out), not a single fixed burst budget.
fn wheel_scroll_to_top(h: &mut PtyHarness) {
for burst in 0..4 {
for _ in 0..50 {
let _ = h.inject_keys(b"\x1b[<64;40;10M");
}
h.update(Duration::from_millis(350 + burst * 120));
}
h.update(Duration::from_millis(500));
}
fn all_turns_once(screen: &str) -> bool {
(1..=3).all(|turn| screen.matches(&turn_sentinel(turn)).count() == 1)
}
for (name, h) in [("A", &mut a), ("B", &mut b)] {
h.resize(200, DEFAULT_COLS).expect("grow viewport");
h.update(Duration::from_millis(600));
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let mut screen = h.screen_contents();
while !all_turns_once(&screen) && std::time::Instant::now() < deadline {
wheel_scroll_to_top(h);
// Home/goto-top via Esc then wheel again if turn 1 still missing.
if screen.matches(&turn_sentinel(1)).count() == 0 {
let _ = h.inject_keys(keys::ESC);
h.update(Duration::from_millis(200));
let _ = h.inject_keys(keys::ESC);
h.update(Duration::from_millis(200));
wheel_scroll_to_top(h);
}
screen = h.screen_contents();
}
assert!(
h.is_running(),
"pager {name} exited\nscreen:\n{}",
h.screen_contents()
);
assert!(
!h.contains_text("panicked"),
"pager {name} rendered a panic\nscreen:\n{}",
h.screen_contents()
);
let screen = h.screen_contents();
for turn in 1..=3 {
assert_eq!(
screen.matches(&turn_sentinel(turn)).count(),
1,
"turn {turn} must appear in {name} exactly once\nscreen:\n{screen}"
);
}
}
// The spawning client's exit must not take the leader (or B) down: B
// keeps its transcript and stays attached.
drop(a);
b.update(Duration::from_secs(3));
assert!(
b.is_running(),
"B exited after A quit\nscreen:\n{}",
b.screen_contents()
);
assert!(
b.contains_text(&turn_sentinel(3)),
"B lost its transcript after A quit\nscreen:\n{}",
b.screen_contents()
);
b.quit().expect("quit B");
}
@@ -0,0 +1,24 @@
//! Leader-mode PTY e2e tests, split out of the shared `pty_e2e` target.
//!
//! These cases spawn multi-process leader clusters (each test boots 2-3 full
//! pager processes plus a leader subprocess), so running them interleaved
//! with the ~45-test `pty_e2e` suite was the root of the 60→240s
//! `LEADER_TIMEOUT` flake history. As their own `[[test]]` target they get
//! their own Bazel test action (serialized from the main PTY pool) and can be
//! invoked in isolation:
//!
//! ```bash
//! cargo test -p kigi-tui --test leader_pty_e2e -- --ignored --test-threads=1 --nocapture
//! ```
//!
//! Binary resolution and harness plumbing are identical to `pty_e2e` (see
//! that target's `mod.rs`); the shared helpers these tests need live in this
//! directory's `common.rs`.
mod common;
mod campaign_leader_mode_remote_dismiss_on_model_pick;
mod leader_n_clients_shared_session;
mod leader_reattach_cancellation_roundtrips_durable_log;
mod leader_reattach_completion_roundtrips_durable_log;
mod leader_two_clients_shared_session;
@@ -0,0 +1,153 @@
//! End-to-end coverage of the out-of-process Mermaid render path.
//!
//! Spawns the **real** built pager binary as the hidden `__mermaid-render`
//! child via [`render_via_subprocess`] — the exact function the render worker
//! uses in production — and asserts:
//! * a valid diagram (the cyclic login-flow) renders to a decodable PNG;
//! * an oversized / invalid diagram is *contained*: the child exits non-zero,
//! the parent returns `Err`, and no PNG is written;
//! * a tight timeout makes the parent kill the child and return `Err` quickly
//! (a real, process-killable timeout — the crash/timeout containment gate).
//!
//! These exercise the cross-platform `Command` + `current_exe()`/`Child::kill`
//! machinery against the actual binary, which the in-process worker unit tests
//! (under `cargo test`, where the harness binary is not the pager) cannot.
//!
//! Every test is `#[ignore]` (like `pty_e2e`): it needs the built binary, so
//! `cargo test` skips it by default and CI opts in via `-- --ignored`. The
//! binary path is resolved at runtime by [`pager_binary`], so the file still
//! compiles where `CARGO_BIN_EXE_*` is unset (e.g. Bazel), where it is skipped.
use std::time::{Duration, Instant};
use kigi_pager_pty_harness::pager_binary;
use kigi_tui::app::mermaid_worker::render_via_subprocess;
use kigi_tui::scrollback::blocks::mermaid_content::MermaidRenderQuality;
/// A cyclic login-flow whose back-edge (`Attempts -->|No| Enter`) routes back
/// into the cycle — the tricky case for flowchart edge routing.
const LOGIN_FLOW: &str = "flowchart TD\n\
Start([User visits login page]) --> Enter[Enter username & password]\n\
Enter --> Submit[Submit credentials]\n\
Submit --> Validate{Credentials valid?}\n\
Validate -->|No| Fail[Show error message]\n\
Fail --> Attempts{Too many failed attempts?}\n\
Attempts -->|Yes| Lock[Lock account]\n\
Attempts -->|No| Enter\n\
Validate -->|Yes| Session[Create session]";
#[test]
#[ignore = "spawns the built pager binary; run with cargo test -- --ignored"]
fn child_renders_login_flow_to_png() {
let bin = pager_binary().expect("resolve pager binary");
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("login.png");
let result = render_via_subprocess(
&bin,
LOGIN_FLOW,
false,
1024,
MermaidRenderQuality::Open,
&out,
Duration::from_secs(30),
);
assert!(
result.is_ok(),
"the login-flow must render via the __mermaid-render child: {result:?}"
);
assert!(out.exists(), "the child wrote the PNG to the out-path");
let bytes = std::fs::read(&out).expect("read PNG");
let img = image::load_from_memory(&bytes).expect("output is a decodable PNG");
assert!(img.width() > 0 && img.height() > 0);
}
#[test]
#[ignore = "spawns the built pager binary; run with cargo test -- --ignored"]
fn oversized_source_is_contained() {
// Source over the 64 KiB cap: the child rejects it and exits non-zero, so
// the parent returns Err and no PNG is produced (degrades to the fallback).
let bin = pager_binary().expect("resolve pager binary");
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("huge.png");
let huge = format!("flowchart TD\n{}", "A-->B\n".repeat(100_000));
let result = render_via_subprocess(
&bin,
&huge,
false,
1024,
MermaidRenderQuality::Terminal,
&out,
Duration::from_secs(30),
);
assert!(
result.is_err(),
"oversized source must be contained: {result:?}"
);
assert!(!out.exists(), "a contained (failed) child writes no PNG");
}
#[test]
#[ignore = "spawns the built pager binary; run with cargo test -- --ignored"]
fn invalid_diagram_is_contained() {
// An unrenderable diagram: the child's render errors and it exits non-zero;
// the parent returns Err, no PNG. This is the same containment path a child
// panic (abort -> non-success exit) would take.
let bin = pager_binary().expect("resolve pager binary");
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("bad.png");
let result = render_via_subprocess(
&bin,
"this is not a mermaid diagram at all",
false,
1024,
MermaidRenderQuality::Terminal,
&out,
Duration::from_secs(30),
);
assert!(
result.is_err(),
"an invalid diagram must be contained: {result:?}"
);
assert!(!out.exists(), "no PNG for an unrenderable diagram");
}
#[test]
#[ignore = "spawns the built pager binary; run with cargo test -- --ignored"]
fn tight_timeout_kills_child_and_returns_err() {
// A 1 ms budget cannot cover spawning + rendering, so the parent must kill
// and reap the child and return Err, then return promptly (not block on the
// child finishing). That the kill actually terminates the child's process
// group is asserted directly (and without the heavy binary) by the
// `kigi_mermaid::subprocess` `reap_terminates_the_process` unit test;
// here the loose ceiling just guards against the parent blocking on a child
// that outlived its budget, while tolerating slow-CI spawn of the real
// binary.
let bin = pager_binary().expect("resolve pager binary");
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("slow.png");
let started = Instant::now();
let result = render_via_subprocess(
&bin,
LOGIN_FLOW,
false,
1024,
MermaidRenderQuality::Open,
&out,
Duration::from_millis(1),
);
let elapsed = started.elapsed();
assert!(result.is_err(), "a 1ms budget must time out: {result:?}");
assert!(
elapsed < Duration::from_secs(10),
"the parent must return at the deadline (real kill), took {elapsed:?}",
);
assert!(!out.exists(), "a killed child leaves no PNG");
}
@@ -0,0 +1,282 @@
//! PTY e2e: permission Auto mode is distinct on the real pager screen.
//!
//! Uses `kigi-pager-pty-harness` (`PtyHarness`) + Shift+Tab (CSI Z,
//! compatible with `ptyctl` key injection) to cycle Normal → Plan → Auto
//! and assert the mode banner / status shows Auto without conflating
//! Always-Approve.
//!
//! Auth: seeds `HOME/.kigi/auth.json` from `KIGI_AUTH_JSON` (path) or the
//! developer's `~/.kigi/auth.json` so the pager skips device-login when
//! credentials exist. Without auth the test records an environmental
//! failure (login screen) and still asserts the harness API surface.
//!
//! Run with:
//! `cargo test -p kigi-tui --test pty_auto_mode -- --ignored --nocapture`
use std::path::{Path, PathBuf};
use std::time::Duration;
use kigi_pager_pty_harness::{PtyHarness, pager_binary};
const ROWS: u16 = 40;
const COLS: u16 = 120;
const WELCOME_TIMEOUT: Duration = Duration::from_secs(25);
const WELCOME_SCREEN_SENTINEL: &str = "Quit";
/// Back-tab / Shift+Tab (CSI Z) — pager binds this to CycleMode.
const SHIFT_TAB: &[u8] = b"\x1b[Z";
/// Prefer explicit path, else the user's real `~/.kigi/auth.json`.
fn auth_json_source() -> Option<PathBuf> {
if let Ok(p) = std::env::var("KIGI_AUTH_JSON") {
let pb = PathBuf::from(p);
if pb.is_file() {
return Some(pb);
}
}
dirs_next_home()
.map(|h| h.join(".kigi/auth.json"))
.filter(|p| p.is_file())
}
fn dirs_next_home() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
/// Sandbox HOME + optional auth.json seed (no secrets logged), with the
/// auto-permission-mode feature gate pinned explicitly via `gate_on` so each
/// test is self-contained and deterministic regardless of the runner's shell.
fn prepare_sandbox(home: &Path, gate_on: bool) -> Vec<(String, String)> {
let grok = home.join(".kigi");
let _ = std::fs::create_dir_all(&grok);
if let Some(src) = auth_json_source() {
let dest = grok.join("auth.json");
if let Err(e) = std::fs::copy(&src, &dest) {
eprintln!("pty_auto_mode: could not copy auth.json ({e}); login may block mode cycle");
} else {
eprintln!(
"pty_auto_mode: seeded auth from {} ({} bytes)",
src.display(),
std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0)
);
}
} else {
eprintln!("pty_auto_mode: no ~/.kigi/auth.json — may hit device login");
}
let home_s = home.display().to_string();
let mut env = vec![
("HOME".into(), home_s.clone()),
("KIGI_SHARE_DIR".into(), grok.display().to_string()),
("XDG_CONFIG_HOME".into(), format!("{home_s}/.config")),
("XDG_DATA_HOME".into(), format!("{home_s}/.local/share")),
("XDG_CACHE_HOME".into(), format!("{home_s}/.cache")),
("TERM".into(), "xterm-256color".into()),
("COLORTERM".into(), "truecolor".into()),
("NO_COLOR".into(), "0".into()),
("TERM_PROGRAM".into(), "".into()),
("TMUX".into(), "".into()),
// Do not set XAI_API_KEY — prefer OIDC entry in auth.json (pty_e2e pattern).
];
// Pin the feature gate explicitly so the cycle is deterministic regardless
// of the developer's shell. `KIGI_AUTO_PERMISSION_MODE` is the highest gate
// layer below requirements; "1"/"0" parse to on/off (kigi_config::
// env_bool), and portable-pty merges this over the inherited environment —
// so an exported value can't flip the result (Auto is present in the ring
// with the gate on, skipped with it off).
env.push((
"KIGI_AUTO_PERMISSION_MODE".into(),
if gate_on { "1" } else { "0" }.into(),
));
env
}
fn is_login_screen(screen: &str) -> bool {
screen.contains("Waiting for approval")
|| screen.contains("Approve in your browser")
|| screen.contains("finish signing in")
}
/// Whether the caller expects seeded auth (CI / a deliberate e2e run). When set,
/// hitting the login screen is a real failure (broken auth seeding), not an
/// environmental skip — so the test hard-fails instead of passing vacuously.
fn require_auth() -> bool {
std::env::var("KIGI_PTY_REQUIRE_AUTH").is_ok_and(|v| v == "1" || v == "true")
}
/// Cycle into Auto on the welcome / pre-session path and assert screen text
/// shows Auto (mode banner) while not stuck on Always-Approve alone.
#[test]
#[ignore = "spawns real pager PTY; run with cargo test -- --ignored"]
fn pty_shift_tab_cycles_to_auto_mode_banner() {
let binary = match pager_binary() {
Ok(b) => b,
Err(e) => panic!("resolve pager binary via harness env: {e:#}"),
};
let tmp = tempfile::tempdir().expect("temp HOME");
let env_owned = prepare_sandbox(tmp.path(), true);
let env_refs: Vec<(&str, &str)> = env_owned
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut harness = PtyHarness::new(&binary, ROWS, COLS, &[], &env_refs)
.expect("spawn pager in PTY (kigi-pager-pty-harness)");
// Drain startup; welcome or agent chrome.
let _ = harness.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT);
let early = harness.screen_contents();
if is_login_screen(&early) {
assert!(
!require_auth(),
"KIGI_PTY_REQUIRE_AUTH set but pager hit the login screen — auth seeding broke"
);
// Auth still blocking (expired token / no network). Honest env failure:
// the UI-ring guarantee is covered by the dispatch-level unit tests; save
// the screen for debugging.
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
let _ = std::fs::write(&dump, &early);
}
eprintln!(
"pty_auto_mode: login/device-auth screen blocked Shift+Tab cycle \
(seeded auth may be expired). Screen saved; treating as env limit — \
see the permission_auto_mode SessionActor wire tests for coverage."
);
// Still prove we exercised PtyHarness spawn (not a no-op).
assert!(
harness.is_running() || !early.is_empty(),
"pager must have produced output even on login screen"
);
let _ = harness.inject_keys(b"\x11"); // ctrl+q if bound
return;
}
// Normal → Plan
harness
.inject_keys(SHIFT_TAB)
.expect("inject Shift+Tab (Plan)");
let _ = harness.wait_for_text("Plan", Duration::from_secs(8));
// Plan → Auto
harness
.inject_keys(SHIFT_TAB)
.expect("inject Shift+Tab (Auto)");
let saw_auto = harness
.wait_for_text("Auto", Duration::from_secs(12))
.is_ok();
let screen = harness.screen_contents();
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
let _ = std::fs::write(&dump, &screen);
}
if is_login_screen(&screen) {
eprintln!("pty_auto_mode: landed on login after key inject; env auth limit");
return;
}
assert!(
saw_auto || screen.contains("Auto") || screen.contains("Switched to mode: Auto"),
"after Plan → Auto cycle, screen must show Auto (distinct mode). screen=\n{screen}"
);
if screen.contains("Always-Approve") && !screen.contains("Auto") {
panic!("landed on Always-Approve without Auto — cycle skipped Auto mode");
}
let _ = harness.inject_keys(b"q");
}
/// Gate OFF (the shipped default): the Shift+Tab ring must SKIP Auto entirely
/// (Normal → Plan → Always-Approve → Normal), so the feature is inert and the
/// classifier never launches. Negative companion to the gate-ON cycle test;
/// proves the gate governs the UI ring, not just the engine.
#[test]
#[ignore = "spawns real pager PTY; run with cargo test -- --ignored"]
fn pty_shift_tab_skips_auto_when_gate_off() {
let binary = match pager_binary() {
Ok(b) => b,
Err(e) => panic!("resolve pager binary via harness env: {e:#}"),
};
let tmp = tempfile::tempdir().expect("temp HOME");
let env_owned = prepare_sandbox(tmp.path(), false);
let env_refs: Vec<(&str, &str)> = env_owned
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut harness = PtyHarness::new(&binary, ROWS, COLS, &[], &env_refs)
.expect("spawn pager in PTY (kigi-pager-pty-harness)");
let _ = harness.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT);
let early = harness.screen_contents();
if is_login_screen(&early) {
assert!(
!require_auth(),
"KIGI_PTY_REQUIRE_AUTH set but pager hit the login screen — auth seeding broke"
);
eprintln!(
"pty_auto_mode(gate off): login/device-auth screen blocked cycle; env auth limit"
);
assert!(
harness.is_running() || !early.is_empty(),
"pager must have produced output even on login screen"
);
let _ = harness.inject_keys(b"\x11");
return;
}
// Cycle the full ring (4 presses returns to Normal). With the gate off the
// ring is Normal → Plan → Always-Approve → Normal: Auto must never appear,
// while Plan and Always-Approve still must (the ring otherwise works).
let mut saw_plan = false;
let mut saw_always = false;
let mut saw_auto = false;
for _ in 0..4 {
harness.inject_keys(SHIFT_TAB).expect("inject Shift+Tab");
// Drain output so the NEW mode banner renders before we read. A
// `wait_for_text("Switched to mode:")` would hit its fast-path and
// return instantly on the prior press's banner still on screen, so we
// explicitly pump for a fixed window instead (the screen tracker keeps
// only the latest frame, so each read reflects the current mode).
harness.update(Duration::from_secs(2));
let s = harness.screen_contents();
if is_login_screen(&s) {
eprintln!("pty_auto_mode(gate off): landed on login after key inject; env auth limit");
return;
}
if s.contains("Switched to mode: Plan") {
saw_plan = true;
}
if s.contains("Switched to mode: Always-Approve") {
saw_always = true;
}
if s.contains("Switched to mode: Auto") {
saw_auto = true;
}
}
if let Ok(dump) = std::env::var("PTY_AUTO_MODE_SCREEN_DUMP") {
let _ = std::fs::write(&dump, harness.screen_contents());
}
assert!(
!saw_auto,
"gate OFF: Shift+Tab ring must skip Auto, but the Auto banner appeared"
);
assert!(
saw_plan && saw_always,
"gate OFF: ring should still cycle Plan and Always-Approve \
(saw_plan={saw_plan}, saw_always={saw_always})"
);
let _ = harness.inject_keys(b"q");
}
/// Structural: harness crate exports used by this e2e (fails compile if removed).
#[test]
fn pty_harness_api_surface_for_auto_mode_e2e() {
let _ = std::any::type_name::<PtyHarness>();
assert_eq!(SHIFT_TAB, b"\x1b[Z");
}
@@ -0,0 +1,41 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 2. **Agent response.**
/// Submitting a prompt produces the mock server's response text on screen.
/// This is the full loop: pager → shell agent → mock inference → streamed
/// chunks → pager render.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn agent_response() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} hello from the mock inference server."
));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Type the prompt and submit with Enter.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("mock response on screen");
assert!(
content.has_chat_completion(),
"mock inference server never received a chat completion request\nrequests: {:?}",
content.requests()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,49 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 5. **Agent type mismatch — modal appears on `/model` switch.**
/// After sending a prompt (turn_count > 0), switching to a model with a
/// different agent type shows the question modal instead of a raw error.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn agent_type_mismatch_modal_on_model_switch() {
let content = start_dual_agent_type_content().await;
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} hello from the default harness."
));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Send a prompt to establish turn_count > 0.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("response rendered");
harness
.inject_keys(b"/model cursor-model\r")
.expect("type model switch");
// The question modal should appear.
harness
.wait_for_text("requires starting a new session", Duration::from_secs(15))
.expect("agent type mismatch modal should appear");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,67 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 7. **Agent type mismatch — "No" returns to current session.**
/// Selecting "No" dismisses the modal and keeps the current session
/// with its original model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn agent_type_mismatch_no_keeps_current_session() {
let content = start_dual_agent_type_content().await;
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} hello from the default harness."
));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Send a prompt to establish turn_count > 0.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("response rendered");
harness
.inject_keys(b"/model cursor-model\r")
.expect("type model switch");
// Wait for the modal.
harness
.wait_for_text("requires starting a new session", Duration::from_secs(15))
.expect("modal appeared");
// Select "No" (second option — press j then Enter).
harness.inject_keys(keys::J).expect("move to No");
harness.inject_keys(keys::ENTER).expect("select no");
// Modal should dismiss. Wait a moment for UI to settle.
harness.update(Duration::from_millis(500));
// The original response should still be visible (same session).
assert!(
harness.contains_text(MOCK_RESPONSE_SENTINEL),
"original response should still be visible after cancelling\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("requires starting a new session"),
"modal should be dismissed\nscreen:\n{}",
harness.screen_contents()
);
assert!(
harness.is_running(),
"pager exited\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,63 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 6. **Agent type mismatch — "Yes" starts a new session.**
/// Selecting "Yes" on the modal creates a new session with the target
/// model active.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn agent_type_mismatch_yes_starts_new_session() {
let content = start_dual_agent_type_content().await;
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} hello from the default harness."
));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Send a prompt to establish turn_count > 0.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("response rendered");
harness
.inject_keys(b"/model cursor-model\r")
.expect("type model switch");
// Wait for the modal.
harness
.wait_for_text("requires starting a new session", Duration::from_secs(15))
.expect("modal appeared");
// Select "Yes" (first option, already focused by default).
harness.inject_keys(keys::ENTER).expect("select yes");
// A new session should be created — look for the session tip or
// the fresh prompt (no scrollback from the previous session).
harness
.wait_for_text("Session", Duration::from_secs(15))
.expect("new session created");
assert!(
harness.is_running(),
"pager exited after starting new session\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,120 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// **ANSI scrollback content integrity (minimal mode).**
///
/// Regression guard for the `kigi-ratatui-inline` rewrite (termwiz →
/// anstyle-parse). Minimal mode commits finalized assistant blocks into the
/// terminal's NATIVE scrollback through `kigi_ratatui_inline::Terminal::
/// insert_before` — the production seam of the rewritten crate — so a parsing
/// or offset regression there shows up as truncated, duplicated, or corrupted
/// committed content.
///
/// The response stresses the rendered-ANSI surface: a syntax-highlighted code
/// block (SGR color sequences interleave the text), CJK + emoji wide
/// characters (multi-byte UTF-8, width-2 cells), and enough unique code-block
/// rows (which never markdown-reflow) to overflow the 50-row screen and force
/// the head of the block into scrollback.
///
/// Assertions read scrollback + screen after the commit: head/tail sentinels
/// and every payload row present exactly once (dropped or doubled emissions
/// change the count), wide-char markers intact.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn ansi_scrollback_content_integrity() {
const HEAD: &str = "HEADSENTINEL7431";
const TAIL: &str = "TAILSENTINEL7431";
const ROWS: usize = 80;
// Code-block rows render verbatim (no markdown reflow) and syntect
// highlights the python fence, so committed rows carry SGR sequences.
let mut response = String::new();
response.push_str("```python\n");
response.push_str(&format!("# {HEAD} wide: 你好 WIDEMARK 世界 🚀 EMOJIMARK\n"));
for i in 0..ROWS {
response.push_str(&format!(
"payload_row_{i:02} = \"qzjvxk\" # comment {i:02}\n"
));
}
response.push_str(&format!("# {TAIL}\n"));
response.push_str("```\n");
let content = ContentController::start().await.expect("start content");
content.set_response(response);
let mut harness = spawn_minimal(&content);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// The block commits to native scrollback when the turn finalizes; poll
// until the head sentinel (which scrolled far above the live region)
// lands in scrollback.
let deadline = Instant::now() + Duration::from_secs(40);
while Instant::now() < deadline && !harness.scrollback_text().contains(HEAD) {
harness.update(Duration::from_millis(100));
}
// Precondition: the block head actually committed to NATIVE scrollback.
// Without this, a timeout above would fall through to `full_text()`
// (scrollback + screen) assertions, which content that never left the
// live region could partially satisfy — and fail confusingly.
assert!(
harness.scrollback_text().contains(HEAD),
"committed block head must reach native scrollback\nscrollback:\n{}\nscreen:\n{}",
harness.scrollback_text(),
harness.screen_contents(),
);
let full = harness.full_text();
// Head and tail sentinels present exactly once and in order: a duplicated
// segment emission repeats them, a dropped one loses them.
for sentinel in [HEAD, TAIL] {
assert_eq!(
full.matches(sentinel).count(),
1,
"sentinel {sentinel} should appear exactly once\nfull text:\n{full}"
);
}
assert!(
full.find(HEAD).unwrap() < full.find(TAIL).unwrap(),
"sentinels out of order\nfull text:\n{full}"
);
// Every committed row survives exactly once, in full — an offset bug at a
// segment boundary truncates or doubles rows.
for i in 0..ROWS {
let row = format!("payload_row_{i:02} = \"qzjvxk\" # comment {i:02}");
assert_eq!(
full.matches(&row).count(),
1,
"payload row {i:02} should appear exactly once and unmangled\nfull text:\n{full}"
);
}
// Wide chars survive the multi-byte wrap-point handling. Checked as
// individual chars: the harness's text extraction renders each width-2
// char followed by its spacer cell ("你 好", not "你好").
for marker in ["", "", "", "", "🚀", "WIDEMARK", "EMOJIMARK"] {
assert!(
full.contains(marker),
"wide-char marker {marker:?} missing\nfull text:\n{full}"
);
}
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);
}
@@ -0,0 +1,152 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
// ── Auto-compact: the top padding row disappears on tiny terminals ─────────
//
// The default layout reserves a blank top padding row (`outer_vpad`) above the
// status bar. When the terminal is `AUTO_COMPACT_MAX_ROWS` (20) or shorter,
// the render-value compact flag is derived ON (auto-compact) and the padding
// goes away, so the status bar moves to screen row 0. Growing the terminal
// back restores the padding — the user's persisted compact setting never
// changes.
//
// A YAML scenario cannot assert on-screen positions, so this test reads the
// first non-blank screen row directly: row 0 blank = padding present, row 0
// populated (by the status bar) = auto-compact engaged.
/// Short height that engages auto-compact. Above `SHORT_TERMINAL_ROWS` (16)
/// but at most `AUTO_COMPACT_MAX_ROWS` (20), so it pins the auto-compact
/// derivation specifically — not the older short-terminal layout trims.
const SHORT_ROWS: u16 = 18;
/// Index of the first screen row with any non-whitespace content, panicking
/// with the screen when the whole screen is blank.
fn first_content_row(harness: &PtyHarness, when: &str) -> u16 {
let screen = harness.screen_contents();
screen
.lines()
.position(|line| !line.trim().is_empty())
.unwrap_or_else(|| panic!("{when}: screen is entirely blank\nscreen:\n{screen}")) as u16
}
/// **Auto-compact drops the top padding row on tiny terminals.**
/// Tall: row 0 is the blank top padding row (first content below it).
/// At `SHORT_ROWS`: auto-compact removes the padding, status bar lands on
/// row 0. Back tall: padding (and the blank row 0) restored.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn auto_compact_top_row() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} auto-compact probe."));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Enter the agent view (the layout under test) and let the turn end.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("mock response rendered");
harness.update(Duration::from_millis(500));
let tall_row = first_content_row(&harness, "tall spawn");
assert!(
tall_row > 0,
"tall terminal must keep the blank top padding row (content starts below \
row 0), got first content on row {tall_row}\nscreen:\n{}",
harness.screen_contents()
);
// Shrink to an auto-compact height: the padding row must vanish.
harness
.resize(SHORT_ROWS, DEFAULT_COLS)
.expect("resize short");
harness.update(Duration::from_millis(900));
assert!(
harness.is_running(),
"pager exited during resize\nscreen:\n{}",
harness.screen_contents()
);
let short_row = first_content_row(&harness, "after shrink");
assert_eq!(
short_row,
0,
"auto-compact must drop the top padding row at {SHORT_ROWS} rows \
(status bar on row 0)\nscreen:\n{}",
harness.screen_contents()
);
// Grow back: the derived value reverts to the user's setting.
harness
.resize(DEFAULT_ROWS, DEFAULT_COLS)
.expect("resize tall");
harness.update(Duration::from_millis(900));
let restored_row = first_content_row(&harness, "after grow");
assert!(
restored_row > 0,
"growing back must restore the blank top padding row, got first content \
on row {restored_row}\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.screen_contents().contains("panicked"),
"pager rendered 'panicked'\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
/// **Auto-compact engages from the startup seed alone.**
/// Spawning already-tiny (no resize event ever fires) must still land the
/// status bar on row 0: the startup seed (`crossterm::terminal::size()` into
/// the initial appearance) is the only path that can have derived it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn auto_compact_at_startup() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} startup probe."));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, SHORT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager with content");
// The welcome menu may be trimmed on a short spawn; the prompt marker
// always paints. The first char promotes to the agent view under test.
harness
.wait_for_text("\u{276f}", WELCOME_TIMEOUT)
.expect("prompt marker");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("mock response rendered");
harness.update(Duration::from_millis(500));
let row = first_content_row(&harness, "tiny spawn");
assert_eq!(
row,
0,
"a {SHORT_ROWS}-row spawn must start auto-compacted (status bar on \
row 0, no top padding row) without any resize\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.screen_contents().contains("panicked"),
"pager rendered 'panicked'\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,262 @@
//! PTY: a user prompt queued behind a running auto-wake turn must survive a
//! Ctrl+C cancel — it runs next and is durable across `--continue`.
//!
//! The failure chain this guards: a background task completes while the agent
//! is idle, so the shell injects a synthetic `task-completed-<id>` prompt
//! (auto-wake) whose reminder tells the model to poll the task-output tool.
//! That tool result triggers the consumed-completion sweep of
//! `pending_inputs`, which must NOT delete the running auto-wake turn's own
//! front slot. If it does, a user prompt queued behind it (the pager doesn't
//! adopt synthetic turns, so a typed message dispatches immediately) shifts to
//! the front, and the next Ctrl+C resolves THE USER'S prompt as Cancelled: it
//! never reaches the model, and — since user messages are only persisted when
//! their turn starts — it is silently gone after a `--continue` resume.
//!
//! Set `KIGI_PTY_CAST_DIR` to also dump asciinema casts of both pager runs
//! (written before the final asserts so a failing run still produces them).
#[allow(unused_imports)]
use super::common::*;
/// Marker for the user's mid-auto-wake message. Unique enough to grep for in
/// request bodies and replayed history without false positives.
#[cfg(unix)]
const CLARIFY_MARKER: &str = "CLARIFY_MARKER_XYZ";
/// Background sleep that triggers the auto-wake on completion. Long enough
/// that turn 1 settles and the auto-wake scripts are enqueued before it fires,
/// even on a loaded CI host.
#[cfg(unix)]
const BG_SLEEP_SECS: &str = "6";
/// Foreground sleep that holds the auto-wake turn deterministically running
/// while the user message and Ctrl+C are injected. Never runs to completion —
/// the cancel kills it on both the broken and fixed paths — so a generous
/// bound costs nothing and removes the settle-before-cancel race.
#[cfg(unix)]
const HOLD_SLEEP_SECS: &str = "15";
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn auto_wake_cancel_preserves_queued_user_prompt() {
let content = ContentController::start().await.expect("start content");
// Turn 1: the model backgrounds a sleep via run_terminal_command, then the
// follow-up turn settles to plain text so the agent goes idle while the
// background task runs (the precondition for an auto-wake).
let bg_args = json!({
"command": format!("/bin/sleep {BG_SLEEP_SECS}"),
"description": "auto-wake trigger",
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_bg_wake",
"run_terminal_command",
&bg_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_bg_wake",
"run_terminal_command",
&bg_args,
)),
);
content.set_response("TURN1_SETTLED");
let binary = pager_binary().expect("resolve pager binary");
// --yolo skips the bash permission prompt; --trust skips the folder-trust gate.
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("TURN1_SETTLED", Duration::from_secs(45))
.unwrap_or_else(|_| {
panic!(
"background tool call never settled; screen:\n{}",
harness.screen_contents()
)
});
// The runtime task id (a UUID minted by the terminal actor, NOT the
// scripted tool_call_id) rides in the tool result of turn 1's follow-up
// request, inside a <task-id>…</task-id> envelope.
let task_id = poll_for(Duration::from_secs(10), || {
content
.request_bodies()
.iter()
.find_map(|b| extract_task_id(&b.to_string()))
})
.unwrap_or_else(|| {
panic!(
"no <task-id> in any request body\n--- non-system messages ---\n{}",
dump_non_system_messages(&content.request_bodies())
)
});
// Enqueue the auto-wake turn's scripts BEFORE the background sleep
// completes: first a task-output poll (whose completed result triggers the
// consumed-completion sweep), then a foreground sleep that pins the turn
// running while the user message and Ctrl+C land.
let poll_args = json!({ "task_ids": [task_id.clone()] }).to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_wake_poll",
"get_command_or_subagent_output",
&poll_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_wake_poll",
"get_command_or_subagent_output",
&poll_args,
)),
);
let hold_args = json!({
"command": format!("/bin/sleep {HOLD_SLEEP_SECS}"),
"description": "hold turn"
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_wake_hold",
"run_terminal_command",
&hold_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_wake_hold",
"run_terminal_command",
&hold_args,
)),
);
// Fallback for every unscripted request after the queues drain (and the
// response the surviving user prompt streams on the fixed path).
content.set_response("AUTO_WAKE_SETTLED");
// Auto-wake mid-flight gate: the request AFTER the task-output tool call
// executed carries its result ("=== Task <id> ==="). At that point the
// sweep has run and the foreground hold is about to start.
let wake_polled = poll_for(Duration::from_secs(30), || {
let marker = format!("=== Task {task_id} ===");
content
.request_bodies()
.iter()
.any(|b| b.to_string().contains(&marker))
.then_some(())
})
.is_some();
assert!(
wake_polled,
"auto-wake turn never polled the task output\n--- non-system messages ---\n{}\n--- screen ---\n{}",
dump_non_system_messages(&content.request_bodies()),
harness.screen_contents()
);
harness.update(Duration::from_millis(500));
// The pager does not adopt synthetic turns, so it believes it is idle and
// dispatches the typed message immediately — it queues server-side behind
// the running auto-wake turn. Text and Enter go separately so a bulk
// inject can't be paste-coalesced past the submit.
harness
.inject_keys(format!("{CLARIFY_MARKER} please stop").as_bytes())
.expect("type clarifying message");
harness.update(Duration::from_millis(500));
harness
.inject_keys(b"\r")
.expect("submit clarifying message");
harness.update(Duration::from_millis(500));
// One Ctrl+C: must cancel the auto-wake turn (killing the held sleep),
// not the queued user prompt.
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
harness.update(Duration::from_secs(2));
// The surviving prompt is promoted after the cancel and reaches the model.
let marker_on_wire = poll_for(Duration::from_secs(20), || {
content
.request_bodies()
.iter()
.any(|b| b.to_string().contains(CLARIFY_MARKER))
.then_some(())
})
.is_some();
if marker_on_wire {
// Let the promoted turn finish streaming so the resumed replay below
// is deterministic on the fixed path.
let _ = harness.wait_for_full_text("AUTO_WAKE_SETTLED", Duration::from_secs(15));
}
write_cast_if_requested(&harness, "auto_wake_repro_main.cast");
// Graceful quit (Ctrl+Q double-press: focus is in the prompt, 'q' would type).
harness.update(Duration::from_millis(500));
harness.inject_keys(b"\x11").expect("ctrl-q once");
harness.update(Duration::from_millis(200));
harness.inject_keys(b"\x11").expect("ctrl-q confirm");
harness.quit().expect("reap pager");
// Resume the same session: the user's message must have been persisted
// (user messages are only written once their turn starts) and replay.
let mut resumed = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--continue", "--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn resumed pager");
// The replay follows the transcript tail, so on the fixed path turn 1 may
// have scrolled above the viewport — the marker near the tail is an
// equally valid replay-finished signal.
let replay_ok = resumed
.wait_for_full_text("TURN1_SETTLED", Duration::from_secs(30))
.is_ok();
resumed.update(Duration::from_secs(1));
let resumed_full_text = resumed.full_text();
let marker_in_replay = resumed.contains_full_text(CLARIFY_MARKER);
write_cast_if_requested(&resumed, "auto_wake_repro_continue.cast");
resumed.quit().expect("quit resumed pager");
assert!(
marker_on_wire,
"Ctrl+C during the auto-wake turn destroyed the queued user prompt: \
{CLARIFY_MARKER} never reached the model\nrequests: {}\n--- non-system messages ---\n{}",
content.request_count(),
dump_non_system_messages(&content.request_bodies())
);
assert!(
replay_ok || marker_in_replay,
"--continue never replayed the session history\nfull contents:\n{resumed_full_text}"
);
assert!(
marker_in_replay,
"queued user prompt missing from --continue replay (lost from history)\n\
full contents:\n{resumed_full_text}"
);
}
@@ -0,0 +1,171 @@
//! PTY: a background shell command spawned by a tool call must be reaped when
//! the pager exits, so it can't outlive the TUI. This is the `/loop`
//! orphaned-watcher quota-drain bug: background `run_terminal_command` / `monitor`
//! commands are `setsid`-detached, so they escape the terminal's process group
//! and survive a quit/kill unless the exit path reaps them.
//!
//! Drives the real pager against the mock (spawn args are only `--yolo --trust`;
//! the prompt is typed in via `inject_keys` after the welcome screen), scripts a
//! background command that records its PID then sleeps, quits via a real SIGINT,
//! and asserts the PID is gone. Without the fix the detached process reparents to
//! init and keeps running, so the final poll times out.
#[allow(unused_imports)]
use super::common::*;
/// 10 min: comfortably outlasts the test's worst-case runtime yet self-exits, so
/// a reap regression can't leak a ~68-year `/bin/sleep` on CI (tracked by PID
/// file, so the exact bound only needs to outlast the test).
#[cfg(unix)]
const SLEEP_SECS: &str = "600";
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn background_task_reaped_on_quit() {
let content = ContentController::start().await.expect("start content");
let pidfile = content.home().join("orphan_bg.pid");
let donefile = content.home().join("orphan_bg.done");
let errfile = content.home().join("orphan_bg.err");
// Turn 1: the model runs a background command via run_terminal_command. It writes
// its PID, then sleeps; the done/err files capture an unexpected early exit
// for diagnostics. Absolute /bin/sleep avoids PATH surprises in the spawn env.
let command = format!(
"echo $$ > {pid}; /bin/sleep {SLEEP_SECS} 2> {err}; echo rc=$? > {done}",
pid = pidfile.display(),
err = errfile.display(),
done = donefile.display(),
);
let args = json!({
"command": command,
"description": "background orphan reap test",
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_bg",
"run_terminal_command",
&args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events(
"run_terminal_command",
&args,
)),
);
// Follow-up turns settle to plain text so the session goes idle.
content.set_response("BG_TASK_STARTED");
let binary = pager_binary().expect("resolve pager binary");
// --yolo skips the bash permission prompt; --trust skips the folder-trust gate.
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// The settle text confirms the tool call ran and the follow-up turn finished.
harness
.wait_for_text("BG_TASK_STARTED", Duration::from_secs(45))
.unwrap_or_else(|_| {
panic!(
"background tool call never settled; screen:\n{}",
harness.screen_contents()
)
});
// The background command writes its PID once it runs (proof the tool executed).
if !wait_until(Duration::from_secs(10), || pidfile.exists()) {
panic!(
"background command never ran (no pidfile)\n--- non-system messages ---\n{}\n--- files under home ---\n{}\n--- screen ---\n{}",
dump_non_system_messages(&content.request_bodies()),
dump_files(content.home()),
harness.screen_contents()
);
}
let pid = read_pid(&pidfile);
assert!(pid > 1, "pidfile did not contain a valid pid: {pid}");
// Sanity: the detached sleep is alive before we quit.
if !wait_until(Duration::from_secs(5), || pid_alive(pid)) {
panic!(
"background sleep (pid {pid}) exited immediately; nothing to test\n\
done={:?} err={:?}\n--- files under home ---\n{}\n--- screen ---\n{}",
std::fs::read_to_string(&donefile).ok(),
std::fs::read_to_string(&errfile).ok(),
dump_files(content.home()),
harness.screen_contents()
);
}
// A real SIGINT (not an injected Ctrl+C key byte) drives the OS-signal exit.
// Both the graceful-quit teardown and the hard-exit tail reap spawned
// children via the process-global ProcessScope, so the orphan dies either way.
harness.send_signal(libc::SIGINT).expect("send SIGINT");
let code = harness.wait_exit_code(Duration::from_secs(15));
assert!(code.is_some(), "pager did not exit after SIGINT");
// The fix: no orphaned background process survives the quit. Without it the
// setsid-detached sleep reparents to init and keeps running -> this times out.
assert!(
wait_until(Duration::from_secs(15), || !pid_alive(pid)),
"background sleep (pid {pid}) survived pager exit (orphaned)"
);
}
/// Read a PID written by `echo $$`.
#[cfg(unix)]
fn read_pid(p: &Path) -> i32 {
std::fs::read_to_string(p)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(-1)
}
/// Whether `pid` exists (running or not-yet-reaped zombie) via `kill(pid, 0)`.
#[cfg(unix)]
fn pid_alive(pid: i32) -> bool {
if pid <= 1 {
return false;
}
// SAFETY: kill with signal 0 performs only an existence/permission check.
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
/// Recursive listing of files under `home` (with sizes), to find where tool
/// output / our marker files landed.
#[cfg(unix)]
fn dump_files(home: &Path) -> String {
let mut out = String::new();
let mut stack = vec![home.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else {
let len = e.metadata().map(|m| m.len()).unwrap_or(0);
out.push_str(&format!("{} ({len}B)\n", p.display()));
}
}
}
out
}
@@ -0,0 +1,354 @@
// Demo artifact generator (and smoke assert) for basename-only collapsed
// Read headers. Writes asciicast + HTML under /tmp/basename_path_video for
// agg → gif/mp4. Not meant as a permanent product regression pin.
#[allow(unused_imports)]
use super::common::*;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Instant;
const DONE_SENTINEL: &str = "BASENAME_PATH_DEMO_DONE";
const ARTIFACT_DIR: &str = "/tmp/basename_path_video";
/// Unique basename so on-screen match is unambiguous.
const FILE_NAME: &str = "basename_demo_target.rs";
/// Nested dirs so full abs path is long and would dominate a collapsed header.
const NEST: &str = "very/deep/nested/project/src/module";
fn screen_shows_full_path(screen: &str, full_path: &str) -> bool {
if screen.contains(full_path) {
return true;
}
// Modal wraps long paths mid-segment-list (e.g. `…/src/` then `module/file`).
// Require nest markers that never appear in HOME chrome alone.
let joined = screen.replace('\n', "");
if joined.contains(NEST) && joined.contains(FILE_NAME) {
return true;
}
screen.contains("very/deep") && screen.contains("nested/project") && screen.contains(FILE_NAME)
}
fn write_asciicast(path: &Path, cols: u16, rows: u16, events: &[(f64, String)]) {
let mut f = fs::File::create(path).expect("create cast");
let header = serde_json::json!({
"version": 2,
"width": cols,
"height": rows,
"timestamp": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
"env": {"TERM": "xterm-256color", "SHELL": "/bin/zsh"},
});
writeln!(f, "{header}").expect("header");
for (t, out) in events {
let line = serde_json::json!([t, "o", out]);
writeln!(f, "{line}").expect("event");
}
}
fn enqueue_tool_turn(content: &ContentController, call_id: &str, name: &str, args: String) {
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(call_id, name, &args)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events(name, &args)),
);
}
/// PTY demo: long abs path → collapsed header shows basename only; open block
/// viewer and show full path in modal preamble. Dumps asciicast for video.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "demo video generator; run with cargo test -p kigi-tui --test pty_e2e_smoke basename_path_demo_pty -- --ignored --nocapture"]
async fn basename_path_demo_pty() {
fs::create_dir_all(ARTIFACT_DIR).expect("artifact dir");
let content = ContentController::start().await.expect("start content");
let nest_dir = content.home().join(NEST);
fs::create_dir_all(&nest_dir).expect("nest dirs");
let target = nest_dir.join(FILE_NAME);
fs::write(&target, "// basename path demo fixture\npub fn demo() {}\n").expect("write fixture");
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
let full_path = abs.to_string_lossy().into_owned();
enqueue_tool_turn(
&content,
"call_basename_read",
"read_file",
json!({ "target_file": full_path }).to_string(),
);
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");
let rows = DEFAULT_ROWS;
let cols = DEFAULT_COLS;
// The demo shows the RAW basename-only `Read {path}` header; with
// verb-group folding on (default), a lone read folds into "Read 1 file"
// and the basename row never renders.
seed_ui_config(&content, "group_tool_verbs = false");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
rows,
cols,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
let t0 = Instant::now();
let mut events: Vec<(f64, String)> = Vec::new();
let mut raw_cursor = 0usize;
let mut sample = |harness: &mut PtyHarness| {
harness.update(Duration::from_millis(50));
let raw = harness.raw_output();
if raw.len() > raw_cursor {
let chunk = &raw[raw_cursor..];
raw_cursor = raw.len();
let s = String::from_utf8_lossy(chunk).into_owned();
if !s.is_empty() {
events.push((t0.elapsed().as_secs_f64(), s));
}
}
};
let welcome_deadline = Instant::now() + WELCOME_TIMEOUT;
loop {
sample(&mut harness);
if harness.contains_text(WELCOME_SCREEN_SENTINEL) {
break;
}
assert!(
Instant::now() < welcome_deadline,
"welcome timeout; screen:\n{}",
harness.screen_contents()
);
}
// Hold welcome for the video.
for _ in 0..15 {
sample(&mut harness);
}
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// Wait for collapsed Read header with basename.
let read_deadline = Instant::now() + Duration::from_secs(60);
loop {
sample(&mut harness);
let screen = harness.screen_contents();
if screen.contains(FILE_NAME) && screen.contains("Read ") {
break;
}
if Instant::now() > read_deadline {
panic!("timeout waiting for Read {FILE_NAME}; screen:\n{screen}");
}
}
// Settle turn.
let settle_deadline = Instant::now() + Duration::from_secs(45);
loop {
sample(&mut harness);
if harness.contains_text(DONE_SENTINEL) {
break;
}
if Instant::now() > settle_deadline {
panic!(
"timeout waiting for {DONE_SENTINEL}; screen:\n{}",
harness.screen_contents()
);
}
}
// Hold collapsed view so the video can show basename-only header.
for _ in 0..40 {
sample(&mut harness);
}
let collapsed = harness.screen_contents();
fs::write(
PathBuf::from(ARTIFACT_DIR).join("collapsed.txt"),
&collapsed,
)
.ok();
fs::write(
PathBuf::from(ARTIFACT_DIR).join("collapsed.html"),
harness.screen_html(),
)
.ok();
// Collapsed header must show basename, not the nested parent segments.
assert!(
collapsed.contains(FILE_NAME),
"collapsed screen must show basename; screen:\n{collapsed}"
);
assert!(
collapsed.contains("Read "),
"collapsed screen must show Read label; screen:\n{collapsed}"
);
// Parent nest segment should not appear next to the tool label on the
// collapsed one-liner (full path may still appear elsewhere rarely; the
// header itself is basename-only — check the Read line specifically).
let mut saw_basename_header = false;
for line in collapsed.lines() {
if line.contains("Read ") && line.contains(FILE_NAME) {
saw_basename_header = true;
assert!(
!line.contains("very/deep")
&& !line.contains(&*full_path)
&& !line.contains("/module/"),
"collapsed Read header must not show full/nested path; line={line:?} full={full_path}"
);
}
}
assert!(
saw_basename_header,
"expected a Read {FILE_NAME} header line; screen:\n{collapsed}"
);
// Open block viewer: Tab → select Read line (click) → Ctrl+f (OpenBlockViewer
// alt binding; Enter alone may toggle a verb-group header instead).
harness.inject_keys(b"\t").expect("focus scrollback");
for _ in 0..10 {
sample(&mut harness);
}
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
for _ in 0..10 {
sample(&mut harness);
}
let screen_for_click = harness.screen_contents();
if let Some((row, col)) = locate_screen_text(&screen_for_click, FILE_NAME) {
let click = format!(
"{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
);
harness
.inject_keys(click.as_bytes())
.expect("click Read header");
for _ in 0..8 {
sample(&mut harness);
}
}
// Ctrl+f = OpenBlockViewer (alt key).
harness.inject_keys(b"\x06").expect("Ctrl+f open viewer");
let modal_deadline = Instant::now() + Duration::from_secs(15);
let mut expanded_screen = loop {
sample(&mut harness);
let screen = harness.screen_contents();
if screen_shows_full_path(&screen, &full_path) || Instant::now() > modal_deadline {
break screen;
}
};
// If viewer didn't open, fold-expand the Read block (Truncated header uses
// full path via width: None) — still demos the dual display.
if !screen_shows_full_path(&expanded_screen, &full_path) {
// Esc out of any partial overlay, then click + Enter (toggle fold / group).
harness.inject_keys(b"\x1b").expect("esc");
for _ in 0..6 {
sample(&mut harness);
}
if let Some((row, col)) = locate_screen_text(&harness.screen_contents(), FILE_NAME) {
let click = format!(
"{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
);
harness
.inject_keys(click.as_bytes())
.expect("re-click Read");
for _ in 0..6 {
sample(&mut harness);
}
}
harness.inject_keys(b"\r").expect("Enter expand/view");
let expand_deadline = Instant::now() + Duration::from_secs(10);
loop {
sample(&mut harness);
expanded_screen = harness.screen_contents();
if screen_shows_full_path(&expanded_screen, &full_path) {
break;
}
if Instant::now() > expand_deadline {
break;
}
}
}
// Hold expanded/modal view for the video.
for _ in 0..50 {
sample(&mut harness);
}
expanded_screen = harness.screen_contents();
fs::write(
PathBuf::from(ARTIFACT_DIR).join("expanded.txt"),
&expanded_screen,
)
.ok();
fs::write(
PathBuf::from(ARTIFACT_DIR).join("expanded.html"),
harness.screen_html(),
)
.ok();
let expanded_has_full = screen_shows_full_path(&expanded_screen, &full_path);
fs::write(
PathBuf::from(ARTIFACT_DIR).join("assertions.txt"),
format!(
"full_path={full_path}\n\
basename={FILE_NAME}\n\
collapsed_has_basename={}\n\
collapsed_has_full_on_header={}\n\
expanded_has_full_path={expanded_has_full}\n",
collapsed.contains(FILE_NAME),
collapsed
.lines()
.any(|l| l.contains("Read ") && l.contains(&*full_path)),
),
)
.ok();
assert!(
expanded_has_full,
"expanded / modal view should show full path ({full_path}); screen:\n{expanded_screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
let cast_path = PathBuf::from(ARTIFACT_DIR).join("basename-path-demo.cast");
write_asciicast(&cast_path, cols, rows, &events);
fs::write(
PathBuf::from(ARTIFACT_DIR).join("raw.ansi"),
harness.raw_output(),
)
.ok();
fs::write(
PathBuf::from(ARTIFACT_DIR).join("final.txt"),
harness.screen_contents(),
)
.ok();
fs::write(
PathBuf::from(ARTIFACT_DIR).join("final.html"),
harness.screen_html(),
)
.ok();
eprintln!(
"basename path demo artifacts → {ARTIFACT_DIR} (cast events={}, duration≈{:.1}s, full_path={full_path})",
events.len(),
t0.elapsed().as_secs_f64()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,127 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// PTY, against the built binary with real SGR clicks: a finished `!`
/// command shows its full output (success and failure), double-click folds
/// the block, and a second double-click restores the full output — never
/// the first/last preview.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
#[cfg(unix)]
async fn bash_full_output_double_click_fold_pty() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} session ready."));
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
// Establish a session so `!` runs as an execute tool with Run chrome.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("start session");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("session ready");
// 1. Success: 12 lines exceed the streaming window; all visible on finish.
harness
.inject_keys(b"! printf 'L%02d\\n' $(seq 1 12)\r")
.expect("submit bash-mode command");
harness
.wait_for_text("L12", Duration::from_secs(30))
.expect("bash output tail");
harness
.wait_for_text("L06", Duration::from_secs(10))
.unwrap_or_else(|_| {
panic!(
"finished ! command must show its full output (middle lines); got:\n{}",
harness.screen_contents()
)
});
for line in ["L01", "L03", "L09"] {
assert!(
harness.contains_text(line),
"finished ! command must not truncate output ({line} missing)\nscreen:\n{}",
harness.screen_contents()
);
}
// 2. Double-click folds; a second double-click restores the full output.
harness.inject_keys(b"\t").expect("focus scrollback");
harness
.wait_for_text("Ctrl+e:", Duration::from_secs(10))
.expect("scrollback owns keys");
let screen = harness.screen_contents();
let (row, col) = locate_screen_text(&screen, "Run (user)")
.unwrap_or_else(|| panic!("locate ! block header; screen:\n{screen}"));
let dbl = format!(
"{}{}{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
);
harness
.inject_keys(dbl.as_bytes())
.expect("double-click to fold");
let gone = std::time::Instant::now() + Duration::from_secs(5);
while harness.contains_text("L06") && std::time::Instant::now() < gone {
harness.update(Duration::from_millis(100));
}
assert!(
!harness.contains_text("L06"),
"double-click must collapse the ! block; got:\n{}",
harness.screen_contents()
);
harness.update(Duration::from_millis(500)); // let the multi-click window lapse
harness
.inject_keys(dbl.as_bytes())
.expect("double-click to expand");
harness
.wait_for_text("L06", Duration::from_secs(10))
.unwrap_or_else(|_| {
panic!(
"double-click must restore the FULL output (middle lines); got:\n{}",
harness.screen_contents()
)
});
// 3. A failing command finishes fully expanded too.
harness.inject_keys(b"\t").expect("refocus prompt");
harness
.wait_for_text("Shift+Tab:mode", Duration::from_secs(10))
.expect("prompt owns keys");
harness
.inject_keys(b"! printf 'E%02d\\n' $(seq 1 12); false\r")
.expect("submit failing bash-mode command");
harness
.wait_for_text("E12", Duration::from_secs(30))
.expect("failed bash output tail");
harness
.wait_for_text("E06", Duration::from_secs(10))
.unwrap_or_else(|_| {
panic!(
"FAILED ! command must show its full output; got:\n{}",
harness.screen_contents()
)
});
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,171 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Content of the nested file — `cat`ing it through the completed, quoted
/// command must print this into the scrollback (proves the inserted quoting
/// parses as a valid command in the real shell).
const INNER_SENTINEL: &str = "INNER-NOTE-SENTINEL-4173";
/// Env: NO suggestion flag — Tab completion in bash mode is always on, and
/// this test is the acceptance proof. `KIGI_SUGGESTIONS=0` pins the
/// as-you-type pipeline OFF hermetically (the PTY child inherits the parent
/// env, so a dev shell exporting the flag must not turn it on here); the
/// history tier is pinned to a nonexistent file so file completions are the
/// ONLY dropdown source.
fn suggestions_env(content: &ContentController) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.push(("SHELL".into(), "/bin/bash".into()));
env.push(("KIGI_SUGGESTIONS".into(), "0".into()));
env.push((
"HISTFILE".into(),
content
.home()
.join(".no_such_history")
.to_string_lossy()
.into_owned(),
));
env
}
/// Seed the session cwd the file provider lists:
/// - `alpha_one.txt` + `alpha_two.txt` — the common-prefix-fill pair;
/// - `notes.md` + `Notes Archive/inner_note.txt` — exact vs case-insensitive
/// candidates, a spaced directory to drill into, and the file to run;
/// - `script.sh` — unrelated noise that must never match either prefix.
fn seed_cwd(cwd: &Path) {
std::fs::create_dir_all(cwd.join(".git")).expect("create .git");
std::fs::write(cwd.join("alpha_one.txt"), "").expect("seed alpha_one");
std::fs::write(cwd.join("alpha_two.txt"), "").expect("seed alpha_two");
std::fs::write(cwd.join("notes.md"), "").expect("seed notes.md");
std::fs::write(cwd.join("script.sh"), "").expect("seed script.sh");
let dir = cwd.join("Notes Archive");
std::fs::create_dir_all(&dir).expect("seed spaced dir");
std::fs::write(dir.join("inner_note.txt"), format!("{INNER_SENTINEL}\n"))
.expect("seed inner note");
}
/// The Tab-armed fetch and the post-accept/fill refreshes are async; give a
/// landed response comfortable slack before the next Tab consumes it.
async fn settle() {
tokio::time::sleep(Duration::from_millis(1500)).await;
}
/// **Bash-mode file completion behaves like a real shell — with NO env
/// flag.** In a seeded sandbox cwd:
/// - `!cat al` + Tab fills the shared prefix `alpha_` in place (no dropdown
/// flash); the next Tab opens the dropdown listing both candidates;
/// - `cat "no` + Tab opens the dropdown (exact `notes.md` above the
/// case-insensitive `Notes Archive/`); Down+Tab accepts the directory,
/// preserving the open quote and keeping it open for drill-down;
/// - the next Tab finds exactly one candidate inside the directory and
/// accepts it immediately, closing the quote;
/// - Enter runs the completed command through the real shell — the file's
/// sentinel content reaching the scrollback proves the quoting produced a
/// valid command.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
#[cfg(unix)]
async fn bash_mode_file_completion_shell_like() {
let project = tempfile::tempdir().expect("create project dir");
seed_cwd(project.path());
let cwd = dunce::canonicalize(project.path()).expect("canonicalize project");
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} session up."));
let env = suggestions_env(&content);
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_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&[],
&env_refs,
Some(&cwd),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Establish a session: bash mode lives on the agent-view prompt (the
// welcome prompt never completes).
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("start session");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("session ready");
// ── Leg 1: Tab fills the common prefix, second Tab opens the list ───
harness
.inject_keys(b"!cat al")
.expect("type bash prefix with shared-prefix candidates");
harness.inject_keys(b"\t").expect("first Tab");
harness
.wait_for_text("cat alpha_", Duration::from_secs(10))
.expect("first Tab filled the common prefix in place");
let screen = harness.screen_contents();
assert!(
!screen.contains("alpha_one.txt"),
"prefix fill must not flash the dropdown:\n{screen}"
);
settle().await;
harness.inject_keys(b"\t").expect("second Tab");
harness
.wait_for_text("alpha_one.txt", Duration::from_secs(10))
.expect("second Tab opened the dropdown");
harness
.wait_for_text("alpha_two.txt", Duration::from_secs(5))
.expect("both candidates listed");
// Close the dropdown and clear the draft (non-empty: bash mode sticks).
harness.inject_keys(b"\x1b").expect("Esc closes dropdown");
harness.inject_keys(b"\x15").expect("Ctrl+U clears draft");
// ── Leg 2: quoted dropdown → dir accept → drill-down insta-accept ───
harness
.inject_keys(b"cat \"no")
.expect("type quoted prefix");
harness.inject_keys(b"\t").expect("Tab opens dropdown");
// Case-differing candidates share no common prefix: no fill, plain open.
harness
.wait_for_text("Notes Archive/", Duration::from_secs(10))
.expect("dropdown lists the case-insensitive directory match");
harness
.inject_keys(b"\x1b[B")
.expect("Down to the directory");
harness
.inject_keys(b"\t")
.expect("Tab accepts the directory");
harness
.wait_for_text("cat \"Notes Archive/", Duration::from_secs(10))
.expect("directory accepted with the quote preserved and open");
// The accept re-fetched inside the directory; its single candidate
// insta-accepts and closes the quote.
settle().await;
harness.inject_keys(b"\t").expect("Tab drills down");
harness
.wait_for_text("Notes Archive/inner_note.txt\"", Duration::from_secs(10))
.expect("single inner candidate accepted immediately, quote closed");
// ── The completed command actually RUNS through the real shell ──────
harness.inject_keys(b"\r").expect("Enter runs the command");
harness
.wait_for_text(INNER_SENTINEL, Duration::from_secs(30))
.expect("cat printed the nested file through the completed quoting");
let screen = harness.screen_contents();
assert!(
!screen.contains("panicked"),
"pager panicked\nscreen:\n{screen}"
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,92 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 23. **Bash-mode strips redundant `cd $SESSION_CWD &&` from execute chrome.**
/// User `!` command with a leading cd into the session cwd should show the
/// short command in the Run header, not the long cd prefix. Start a real session
/// first so session_cwd is set and bash-mode runs as an execute tool (Run chrome),
/// not only as a welcome-history line (`#1 ! …` keeps the typed command).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
#[cfg(unix)]
async fn bash_mode_strips_redundant_session_cd_from_chrome() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} session ready for bash strip."
));
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
let cwd = dunce::canonicalize(project.path()).expect("canonicalize project");
let cwd_str = cwd.to_string_lossy();
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
Some(cwd.as_path()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Establish session so tracker has session_cwd and `!` goes through execute chrome.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("start session");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("session ready");
let cmd = format!("! cd {cwd_str} && printf 'STRIP_CD_OK\\n'\r");
harness
.inject_keys(cmd.as_bytes())
.expect("submit bash-mode");
harness
.wait_for_text("STRIP_CD_OK", Duration::from_secs(30))
.expect("command output");
// Completed bash-mode shows "Run (user)" in execute chrome (not history `#N ! …`).
harness
.wait_for_text("Run (user)", Duration::from_secs(15))
.expect("Run (user) chrome on screen");
let screen = harness.screen_contents();
assert!(
!screen.contains("panicked"),
"pager panicked\nscreen:\n{screen}"
);
assert!(
screen.contains("STRIP_CD_OK"),
"expected command output on screen:\n{screen}"
);
// Line-oriented asserts avoid byte-slicing box-drawing UI (char boundary panics).
// History `#N ! cd …` keeps the typed command; only Run (user) chrome is peeled.
let run_lines: Vec<&str> = screen
.lines()
.filter(|line| line.contains("Run (user)"))
.collect();
assert!(
!run_lines.is_empty(),
"Run (user) chrome missing on screen:\n{screen}"
);
assert!(
run_lines.iter().any(|line| line.contains("printf")),
"expected short command (printf) on Run (user) line(s) {run_lines:?}\nfull:\n{screen}"
);
let noisy_prefix = format!("cd {cwd_str} &&");
assert!(
run_lines
.iter()
.all(|line| !line.contains(noisy_prefix.as_str())),
"Run chrome still shows redundant cd prefix in {run_lines:?}\nfull:\n{screen}"
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,136 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Two seeded files sharing the typed prefix (equal length, so ranking ties
/// break on name and `AAA` is deterministically the top row) — both reach
/// the screen only through the Tab-opened dropdown.
const FILE_AAA: &str = "SUGGESTAAA.txt";
const FILE_BBB: &str = "SUGGESTBBB.txt";
/// A seeded shell-history line sharing the same typed prefix. Tab fetches
/// are token-only: this row must NEVER surface on a Tab (it would make the
/// set mixed and kill terminal-Tab semantics for the file rows).
const HISTORY_LINE: &str = "cat SUGGESTHISTROW-42";
const HISTORY_SENTINEL: &str = "SUGGESTHISTROW";
/// What the mocked user types: `!` flips into bash mode (consumed by the
/// prompt), then the shared prefix of both files — exactly their LCP, so
/// the first Tab opens the dropdown instead of prefix-filling.
const TYPED_PREFIX: &str = "!cat SUGGEST";
/// Env: NO suggestion flag — Tab completion in bash mode is always on, and
/// this test is the acceptance proof. `KIGI_SUGGESTIONS=0` pins the
/// as-you-type pipeline OFF hermetically (the PTY child inherits the parent
/// env, so a dev shell exporting the flag must not turn it on here); the
/// shell-history tier is pinned to the seeded file.
fn suggestions_env(content: &ContentController, histfile: &Path) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.push(("SHELL".into(), "/bin/bash".into()));
env.push(("KIGI_SUGGESTIONS".into(), "0".into()));
env.push(("HISTFILE".into(), histfile.to_string_lossy().into_owned()));
env
}
fn seed_history(content: &ContentController) -> std::path::PathBuf {
let histfile = content.home().join(".bash_history");
std::fs::write(&histfile, format!("{HISTORY_LINE}\n")).expect("seed shell history");
histfile
}
/// **Bash-mode completion acceptance with NO env flag: Tab fetches
/// token-only candidates and opens the dropdown, Tab accepts the selected
/// item in place.** In a real session with a seeded cwd and history, typing
/// `!cat SUGGEST`:
/// - nothing completes before Tab (no as-you-type pipeline without the
/// env flag);
/// - Tab fires the deterministic fetch; when its candidates land, the
/// dropdown opens with BOTH file rows (their LCP equals the typed token,
/// so no fill) — and the prefix-matching history line stays out (Tab
/// fetches run only the token providers);
/// - Down + Tab accepts the second item into the prompt without clobbering
/// it: a trailing typed char composes with the spliced token, and the
/// other candidate vanishes with the closed dropdown.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
#[cfg(unix)]
async fn bash_mode_tab_accepts_dropdown_item_in_place() {
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
std::fs::write(project.path().join(FILE_AAA), "").expect("seed AAA file");
std::fs::write(project.path().join(FILE_BBB), "").expect("seed BBB file");
let cwd = dunce::canonicalize(project.path()).expect("canonicalize project");
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} session up."));
let histfile = seed_history(&content);
let env = suggestions_env(&content, &histfile);
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_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&[],
&env_refs,
Some(&cwd),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Establish a session: bash mode lives on the agent-view prompt (the
// welcome prompt never completes).
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("start session");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("session ready");
harness
.inject_keys(TYPED_PREFIX.as_bytes())
.expect("type bash prefix");
// No as-you-type pipeline: neither candidate may render before Tab.
let screen = harness.screen_contents();
assert!(
!screen.contains(FILE_AAA) && !screen.contains(FILE_BBB),
"nothing may complete before Tab without the env flag:\n{screen}"
);
harness.inject_keys(b"\t").expect("press Tab");
// The Tab-armed fetch lands and opens the dropdown with both files.
harness
.wait_for_text(FILE_AAA, Duration::from_secs(15))
.expect("dropdown lists the top file candidate");
harness
.wait_for_text(FILE_BBB, Duration::from_secs(5))
.expect("dropdown lists the second file candidate");
let screen = harness.screen_contents();
assert!(
!screen.contains(HISTORY_SENTINEL),
"token-only Tab fetch must not surface history rows:\n{screen}"
);
harness.inject_keys(b"\x1b[B").expect("press Down");
harness.inject_keys(b"\t").expect("press Tab to accept");
// A trailing char proves the accepted token is real prompt text (the
// dropdown never contained this composition).
harness.inject_keys(b"Y").expect("type trailing char");
harness
.wait_for_text(&format!("cat {FILE_BBB}Y"), Duration::from_secs(10))
.expect("selected item accepted into the prompt in place");
let screen = harness.screen_contents();
assert!(
!screen.contains("panicked"),
"pager panicked\nscreen:\n{screen}"
);
assert!(
!screen.contains(FILE_AAA),
"the other candidate must vanish with the closed dropdown:\n{screen}"
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,94 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// A `!` row queued mid-turn stays real bash under empty-Enter send-now:
/// the row is promoted to run NOW as its own bash turn (silent cancel of the
/// running turn — no "Turn cancelled by user" marker), never leaking to the
/// model as prompt/interjection text and never rendering a " !…" block.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
#[cfg(unix)]
async fn bash_queued_mid_turn_drains_as_bash() {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(Duration::from_millis(150)));
let step_one = {
let mut s = String::from("STEPONE");
for i in 0..150 {
s.push_str(&format!(" streaming{i}"));
}
s
};
content.set_turns([step_one]);
let project = tempfile::tempdir().expect("create project dir");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
let cwd = dunce::canonicalize(project.path()).expect("canonicalize project");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
Some(cwd.as_path()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("STEPONE", Duration::from_secs(30))
.expect("turn 1 streaming");
// `QBASH_%s_OK` keeps the output sentinel out of the queue-row text.
harness
.inject_keys(b"!printf 'QBASH_%s_OK\\n' MIDTURN\r")
.expect("submit bash-mode command mid-turn");
harness
.wait_for_text("QBASH_%s_OK", Duration::from_secs(10))
.expect("bash command visible as a queued row");
// Empty Enter is send-now (cancel-and-send): the shell silently cancels
// turn 1 and runs the bash row as its own next turn immediately.
harness.inject_keys(b"\r").expect("empty Enter send-now");
harness
.wait_for_text("QBASH_MIDTURN_OK", Duration::from_secs(30))
.expect("queued bash command executed now (send-now)");
harness
.wait_for_text("Run (user)", Duration::from_secs(15))
.expect("Run (user) chrome for the promoted bash turn");
// Bash rows never render a user-prompt block (the execute block IS the
// visual entry) — a " !printf…" block would mean the row went to the
// model as text instead of executing.
assert!(
!harness.contains_text("\u{276F} !printf"),
"bash row must not render a user-prompt block\nscreen:\n{}",
harness.screen_contents()
);
// The send-now cancel of turn 1 is silent.
assert!(
!harness.contains_text("Turn cancelled by user"),
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
harness.screen_contents()
);
let users = all_user_message_blobs(&content);
assert!(
!users.iter().any(|u| u.contains("QBASH")),
"bash command leaked to the model as a prompt/interjection: {users:#?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,142 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Regression, Linux-hermetic: under Otty (`TERM_PROGRAM=otty` — the
/// only terminal known to deliver macOS IME commits as bracketed paste),
/// bracketed text that did not come from the system clipboard must not attach
/// the unrelated clipboard image. Any other terminal keeps the historical
/// probe behavior, asserted here with a second, TERM_PROGRAM-less spawn. A
/// fake `wl-paste`/`wl-copy` pair on `PATH` plays the clipboard; the sibling
/// `bracketed_ime_paste_skips_clipboard_image_macos` covers the reported
/// agent-prompt surface on a real pasteboard.
#[cfg(target_os = "linux")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn bracketed_ime_paste_skips_clipboard_image_linux() {
use std::os::unix::fs::PermissionsExt as _;
const IME_PAYLOAD: &str = "中文";
const CAPTION: &str = "CLIPCAPTION42";
let tmp = tempfile::tempdir().expect("tempdir for fake clipboard tools");
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).expect("mkdir fake bin");
let text_file = tmp.path().join("clipboard_text");
let png_file = kigi_pager_pty_harness::host_clipboard::write_fixture_png(tmp.path())
.expect("write clipboard png fixture");
std::fs::write(&text_file, b"").expect("empty clipboard text");
let wl_paste = format!(
"#!/bin/sh\ncase \"$*\" in\n *image/png*) cat '{png}' ;;\n *text*) cat '{txt}' ;;\n *) exit 0 ;;\nesac\n",
png = png_file.display(),
txt = text_file.display(),
);
let wl_copy = "#!/bin/sh\nexit 0\n";
for (name, body) in [("wl-paste", wl_paste.as_str()), ("wl-copy", wl_copy)] {
let path = bin_dir.join(name);
std::fs::write(&path, body).expect("write fake tool");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("chmod fake tool");
}
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} ime paste turn."));
let path_env = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let base_env: Vec<(String, String)> = {
let mut env = content.env_for_pager();
env.push(("PATH".into(), path_env));
env.push(("WAYLAND_DISPLAY".into(), "wayland-fake".into()));
env.push(("DISPLAY".into(), String::new()));
env
};
/// Spawn the pager with `extra_env` and drive it to the dashboard, where
/// bracketed paste routes to the dispatch input.
fn spawn_on_dashboard(base_env: &[(String, String)], extra_env: &[(&str, &str)]) -> PtyHarness {
let mut env_refs: Vec<(&str, &str)> = base_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
env_refs.extend_from_slice(extra_env);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn rendered (idle session)");
harness
.inject_keys(b"\x1b[92;5u")
.expect("ctrl+\\ open dashboard");
harness
.wait_for_text("+ New Agent", Duration::from_secs(10))
.expect("dashboard opens");
harness
}
// ── Otty: IME-style bracketed paste, image-only clipboard → no image ──
let mut harness = spawn_on_dashboard(&base_env, &[("TERM_PROGRAM", "otty")]);
harness
.inject_keys(format!("\x1b[200~{IME_PAYLOAD}\x1b[201~").as_bytes())
.expect("bracketed IME payload");
harness
.wait_for_text(IME_PAYLOAD, Duration::from_secs(10))
.expect("IME text reaches the dispatch input");
harness.update(Duration::from_millis(500));
assert!(
!harness.contains_text("[Image #"),
"under Otty, IME-committed text must NOT attach the clipboard image\nscreen:\n{}",
harness.screen_contents()
);
// ── Otty (positive control): payload == clipboard caption → image ──
std::fs::write(&text_file, CAPTION.as_bytes()).expect("genuine-paste clipboard text");
harness
.inject_keys(format!("\x1b[200~{CAPTION}\x1b[201~").as_bytes())
.expect("bracketed genuine clipboard payload");
harness
.wait_for_text("[Image #1", Duration::from_secs(10))
.expect(
"a genuine clipboard paste (payload matches clipboard text) must still \
attach the clipboard image — the probe path must stay live",
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
// ── No TERM_PROGRAM (any other terminal): historical behavior intact —
// the same mismatched bracketed payload still attaches the image ──
std::fs::write(&text_file, b"").expect("reset clipboard text");
let mut harness = spawn_on_dashboard(&base_env, &[]);
harness
.inject_keys(format!("\x1b[200~{IME_PAYLOAD}\x1b[201~").as_bytes())
.expect("bracketed payload without otty");
harness
.wait_for_text("[Image #1", Duration::from_secs(10))
.expect(
"outside Otty the payload-origin gate must not run — the historical \
bracketed-paste image probe attaches the clipboard image unchanged",
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,78 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Regression, macOS-only, REAL host pasteboard — the exact reported
/// surface: under Otty (`TERM_PROGRAM=otty`, the only terminal known to
/// deliver macOS IME commits as bracketed paste), with an image on the
/// clipboard, an IME commit must not attach that image to the agent prompt.
///
/// Skips (loudly) when the session has no usable clipboard — a CI runner
/// without a pasteboard shouldn't fail on environment.
///
/// WARNING: this test OVERWRITES the machine-global clipboard with an image.
/// A prior TEXT clipboard is restored on exit; a prior IMAGE cannot be.
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
#[serial_test::serial(host_clipboard)]
async fn bracketed_ime_paste_skips_clipboard_image_macos() {
use kigi_pager_pty_harness::host_clipboard::{
HostClipboardTextGuard, clipboard_roundtrip_works, set_clipboard_png, write_fixture_png,
};
const IME_PAYLOAD: &str = "中文";
// Guard FIRST: the roundtrip check overwrites the clipboard, and a guard
// taken after it would restore the nonce instead of the user's clipboard.
let _restore = HostClipboardTextGuard::save();
if !clipboard_roundtrip_works() {
eprintln!(
"SKIP bracketed_ime_paste_skips_clipboard_image_macos: host clipboard \
roundtrip failed (no usable clipboard in this session)"
);
return;
}
let tmp = tempfile::tempdir().expect("tempdir for the clipboard PNG");
let png = write_fixture_png(tmp.path()).expect("write clipboard png fixture");
set_clipboard_png(&png).expect("put the fixture PNG on the host pasteboard");
let content = ContentController::start().await.expect("start content");
let binary = pager_binary().expect("resolve pager binary");
// The payload-origin gate only runs under Otty (TERM_PROGRAM=otty).
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "otty".into()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness =
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("\x1b[200~{IME_PAYLOAD}\x1b[201~").as_bytes())
.expect("bracketed IME payload");
harness
.wait_for_text(IME_PAYLOAD, Duration::from_secs(10))
.expect("IME text echoes in the agent prompt");
// Settle long enough for the deferred clipboard probe to resolve.
harness.update(Duration::from_secs(3));
assert!(
!harness.contains_text("[Image #"),
"IME-committed text must NOT attach the clipboard image\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,122 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// **Campaign soft default-model nudge — overrides the config default for new
/// sessions, and an explicit `/model` pick dismisses it (the user wins).**
///
/// - boot with a `[models].default` in config.toml plus a campaign (via the
/// override env, the remote-equivalent injection) nudging a *different* model
/// → the welcome screen shows the **campaign** model;
/// - start a session and pick the config model via `/model` → the campaign id is
/// recorded dismissed in `campaigns_state.json`;
/// - reboot with the *same* campaign env → the welcome shows the **config**
/// model, proving the dismissal persisted (an explicit pick beats the nudge).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn campaign_nudges_default_until_dismissed_by_model_pick() {
const CONFIG_MODEL: &str = "config-model";
const CAMPAIGN_MODEL: &str = "campaign-model";
const CAMPAIGN_ID: &str = "e2e-nudge";
let content = ContentController::start_with_models(vec![
MockModel::new(CONFIG_MODEL),
MockModel::new(CAMPAIGN_MODEL),
])
.await
.expect("start content with two models");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} ok."));
// Seed config.toml with the user's own default model.
let kigi_home = content.home().join(".kigi");
std::fs::create_dir_all(&kigi_home).expect("create KIGI_SHARE_DIR");
std::fs::write(
kigi_home.join("config.toml"),
format!("[models]\ndefault = \"{CONFIG_MODEL}\"\n"),
)
.expect("write config.toml");
// The campaign (remote-equivalent) nudges new sessions to CAMPAIGN_MODEL.
let campaign_env = (
"KIGI_CAMPAIGNS_OVERRIDE".to_string(),
format!(r#"[{{"id":"{CAMPAIGN_ID}","models":{{"default":"{CAMPAIGN_MODEL}"}}}}]"#),
);
let binary = pager_binary().expect("resolve pager binary");
let spawn = |extra: &(String, String)| -> PtyHarness {
let mut env = content.env_for_pager();
env.push(extra.clone());
let env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager")
};
// ── Phase 1: a fresh boot shows the campaign model, not the config one. ──
{
let mut h = spawn(&campaign_env);
h.wait_for_text(CAMPAIGN_MODEL, WELCOME_TIMEOUT)
.unwrap_or_else(|_| {
panic!(
"welcome should show the campaign-nudged model\nscreen:\n{}",
h.screen_contents()
)
});
assert!(
!h.contains_text("panicked"),
"pager panicked\n{}",
h.screen_contents()
);
h.quit().expect("clean quit");
}
// ── Phase 2: a session + explicit `/model` pick dismisses the campaign. ──
{
let mut h = spawn(&campaign_env);
h.wait_for_text(CAMPAIGN_MODEL, WELCOME_TIMEOUT)
.expect("welcome shows campaign model");
h.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
h.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("response rendered");
// Explicit pick of the config model -> persists default + dismisses campaign.
h.inject_keys(format!("/model {CONFIG_MODEL}\r").as_bytes())
.expect("pick model");
// Deterministically wait for the dismiss to land on disk.
let state_path = kigi_home.join("campaigns_state.json");
let deadline = Instant::now() + Duration::from_secs(15);
loop {
h.update(Duration::from_millis(200));
let dismissed = std::fs::read_to_string(&state_path)
.map(|s| s.contains(CAMPAIGN_ID))
.unwrap_or(false);
if dismissed {
break;
}
assert!(
Instant::now() < deadline,
"campaign id should be recorded dismissed in {state_path:?}\nscreen:\n{}",
h.screen_contents()
);
}
h.quit().expect("clean quit");
}
// ── Phase 3: reboot with the SAME campaign env -> the config model wins. ──
{
let mut h = spawn(&campaign_env);
h.wait_for_text(CONFIG_MODEL, WELCOME_TIMEOUT)
.unwrap_or_else(|_| {
panic!(
"after dismissal the welcome must show the config model\nscreen:\n{}",
h.screen_contents()
)
});
assert!(
!h.contains_text(CAMPAIGN_MODEL),
"a dismissed campaign must not re-nudge the model\nscreen:\n{}",
h.screen_contents()
);
h.quit().expect("clean quit");
}
}
@@ -0,0 +1,129 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// **Campaign nudge via the real remote path** — the production source
/// (`GET /v1/settings` → `RemoteSettings.campaigns` → process cache seed →
/// apply/dismiss), unlike the sibling test which injects the campaign through
/// `KIGI_CAMPAIGNS_OVERRIDE`.
///
/// - boot with a `[models].default` in config.toml plus a **server-served**
/// campaign nudging a *different* model → a (possibly not first — see
/// [`wait_for_model_via_new_sessions`]) new session opens on the
/// **campaign** model;
/// - pick the config model via `/model` → the remote campaign id is recorded
/// dismissed in `campaigns_state.json`;
/// - reboot against the *same* server settings → the **config** model wins
/// and stays winning across `/new`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn campaign_remote_settings_nudge_and_dismiss() {
const CONFIG_MODEL: &str = "config-model";
const CAMPAIGN_MODEL: &str = "campaign-model";
const CAMPAIGN_ID: &str = "e2e-remote-nudge";
let content = ContentController::start_with_models(vec![
MockModel::new(CONFIG_MODEL),
MockModel::new(CAMPAIGN_MODEL),
])
.await
.expect("start content with two models");
// Serve the campaign from the settings endpoint (replaces the preset, so
// `allow_access` must be restated or the pager parks on the upsell screen).
content.server().set_settings(json!({
"allow_access": true,
"campaigns": [
{ "id": CAMPAIGN_ID, "models": { "default": CAMPAIGN_MODEL } }
]
}));
// Seed config.toml with the user's own default model.
let kigi_home = content.home().join(".kigi");
std::fs::create_dir_all(&kigi_home).expect("create KIGI_SHARE_DIR");
std::fs::write(
kigi_home.join("config.toml"),
format!("[models]\ndefault = \"{CONFIG_MODEL}\"\n"),
)
.expect("write config.toml");
// Session (OAuth) auth, not the harness's default XAI_API_KEY: the
// settings fetch requires `auth_manager.auth()` — in ApiKey/BYOK mode the
// pager never requests `/v1/settings`, so a remote campaign would be
// structurally unreachable (see `spawn_polling_session`'s doc).
seed_fake_oauth(&content, "pty-campaign-remote");
let binary = pager_binary().expect("resolve pager binary");
let env = oauth_env_for_pager(&content);
let spawn = || -> PtyHarness {
let env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager")
};
// ── Phase 1+2: the campaign applies to a new session; a pick dismisses. ──
{
let mut h = spawn();
h.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome renders");
assert!(
wait_for_model_via_new_sessions(&mut h, CAMPAIGN_MODEL, Duration::from_secs(60)),
"a new session should open on the remote campaign model\nscreen:\n{}",
h.screen_contents()
);
assert!(
!h.contains_text("panicked"),
"pager panicked\n{}",
h.screen_contents()
);
// Explicit pick of the config model → persists default + dismisses.
h.inject_keys(format!("/model {CONFIG_MODEL}\r").as_bytes())
.expect("pick model");
// Deterministically wait for the dismiss to land on disk.
let state_path = kigi_home.join("campaigns_state.json");
let deadline = Instant::now() + Duration::from_secs(20);
loop {
h.update(Duration::from_millis(200));
let dismissed = std::fs::read_to_string(&state_path)
.map(|s| s.contains(CAMPAIGN_ID))
.unwrap_or(false);
if dismissed {
break;
}
assert!(
Instant::now() < deadline,
"remote campaign id should be recorded dismissed in {state_path:?}\nscreen:\n{}",
h.screen_contents()
);
}
h.quit().expect("clean quit");
}
// ── Phase 3: reboot against the SAME settings → the config model wins. ──
{
let mut h = spawn();
h.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome renders after reboot");
h.wait_for_text(CONFIG_MODEL, Duration::from_secs(20))
.unwrap_or_else(|_| {
panic!(
"after dismissal the config model must show\nscreen:\n{}",
h.screen_contents()
)
});
// Give the settings fetch time to land, then prove a fresh session
// still resolves to the user's model (dismissed campaigns never
// re-apply, even once the remote campaign is in the cache).
let _ = h.inject_keys(b"/new\r");
h.update(Duration::from_millis(4000));
h.wait_for_text(CONFIG_MODEL, Duration::from_secs(10))
.expect("config model after post-fetch /new");
assert!(
!h.contains_text(CAMPAIGN_MODEL),
"a dismissed remote campaign must not re-nudge the model\nscreen:\n{}",
h.screen_contents()
);
h.quit().expect("clean quit");
}
}
@@ -0,0 +1,98 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 22. **Send-now delivery vs. explicit cancel.** (Historical name: the chord
/// used to buffer a same-turn interjection that a cancel could discard.)
/// The chord is now cancel-and-send: text + Ctrl+Enter mid-stream silently
/// cancels the running turn and delivers the text as its OWN next turn (no
/// interjection preamble on the wire). A later explicit Ctrl+C still renders
/// its "Turn cancelled by user" marker — the consumed send-now expectation
/// must never suppress a real user cancel.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn cancel_discards_buffered_interjection() {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(Duration::from_millis(150)));
content.set_turns([
slow_turn_text("CANCELTURN"),
slow_turn_text("STEERTURN"),
"FRESHTURN after cancel.".to_owned(),
]);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("CANCELTURN", Duration::from_secs(30))
.expect("turn streaming");
harness
.inject_keys(b"deliver this steer now")
.expect("type steering");
harness.inject_keys(CTRL_ENTER).expect("send-now chord");
// Cancel-and-send: the steer commits as a standard " " prompt block via
// the turn-start adoption and runs as its own turn.
harness
.wait_for_text("\u{276F} deliver this steer now", Duration::from_secs(15))
.expect("send-now prompt block");
harness
.wait_for_text("STEERTURN", Duration::from_secs(30))
.expect("steer runs as its own turn");
// The send-now half cancelled turn 1 silently.
assert!(
!harness.contains_text("Turn cancelled by user"),
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
harness.screen_contents()
);
// Explicit Ctrl+C on the steer turn (still streaming): a REAL cancel,
// whose marker must render — the earlier send-now expectation was
// consumed and must not silence it.
harness.inject_keys(keys::CTRL_C).expect("cancel turn");
harness
.wait_for_text("Turn cancelled by user", Duration::from_secs(10))
.expect("explicit cancel marker");
harness
.inject_keys(b"fresh prompt\r")
.expect("submit fresh prompt");
harness
.wait_for_text("FRESHTURN", Duration::from_secs(30))
.expect("fresh turn response");
let users = all_user_message_blobs(&content);
let steers: Vec<_> = users
.iter()
.filter(|u| u.contains("deliver this steer now"))
.collect();
assert!(
!steers.is_empty(),
"send-now steer never reached the wire: {users:#?}"
);
for s in &steers {
assert!(
!s.contains(INTERJECTION_WIRE_PREFIX),
"send-now must not use the interjection preamble: {s}"
);
assert!(
s.contains("<user_query>"),
"send-now must arrive as a standard user_query prompt: {s}"
);
}
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,107 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// The `cancel_then_send` confounder: submit, Ctrl+C (pristine rewind), then
/// Enter to resend the restored text. The prompt must appear EXACTLY ONCE in
/// scrollback and in each wire request — the rewound turn's copy must not
/// survive in session history and pair with the resend as 2x.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn cancel_then_resend_prompt_appears_once() {
const RESEND_PROMPT: &str = "resend me exactly once";
let content = ContentController::start().await.expect("start content");
// Turn 1 is rewound pre-first-token (the 30s pacing guarantees the
// pristine window); turn 2 is the resend's reply, streamed after the
// pacing is dropped below.
content.set_turns([
"GONE never streams.".to_owned(),
"RESENT_REPLY to the restored prompt.".to_owned(),
]);
content.set_chunk_delay(Some(Duration::from_secs(30)));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{RESEND_PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_until(
"prompt block committed and composer cleared",
Duration::from_secs(30),
|h| block_lines_containing(h, RESEND_PROMPT) == 1 && !composer_holds(h, RESEND_PROMPT),
)
.expect("prompt block committed");
harness
.wait_for_text("Waiting for response", Duration::from_secs(25))
.expect("turn running pre-first-token");
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C rewind");
// Require the rewound state to *settle*, not just flash true: the pager
// clears the scrollback block optimistically, but the shell's trim of the
// rewound copy from session history is a separate round-trip. On a slow,
// contended runner, resending during that gap pairs the stale copy with
// the resend in one wire request (the 2x this test guards). Holding the
// rewound state continuously lets the trim land first.
harness
.wait_until_stable(
"rewound prompt restored after session history trim",
Duration::from_secs(30),
Duration::from_millis(1500),
|h| composer_holds(h, RESEND_PROMPT) && block_lines_containing(h, RESEND_PROMPT) == 0,
)
.expect("rewound prompt restored after history trim");
// Resend the restored text as a fresh turn.
content.set_chunk_delay(None);
harness.inject_keys(b"\r").expect("Enter resends");
harness
.wait_for_text("RESENT_REPLY", Duration::from_secs(90))
.expect("resent turn reply");
// Exactly once in scrollback (block back, composer empty again).
harness
.wait_until(
"resent prompt rendered exactly once",
Duration::from_secs(30),
|h| block_lines_containing(h, RESEND_PROMPT) == 1 && !composer_holds(h, RESEND_PROMPT),
)
.expect("resent prompt rendered exactly once");
// Exactly once per wire request: the rewound copy must have been cut from
// session history, so no request pairs a stale copy with the resend.
for body in content.request_bodies() {
// Chat Completions carries `messages`; the Responses shape `input`.
let items = body["messages"]
.as_array()
.or_else(|| body["input"].as_array());
let users: Vec<&serde_json::Value> = items
.into_iter()
.flatten()
.filter(|m| {
m["role"] == "user"
&& m["content"]
.as_str()
.is_some_and(|c| c.contains(RESEND_PROMPT))
})
.collect();
assert!(
users.len() <= 1,
"prompt duplicated in one request (stale rewound copy + resend): {body}"
);
}
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 16. **`--continue` resumes the latest session.**
/// History must render exactly once (duplicate replay and empty pane both
/// fail) and the resumed session must accept a follow-up turn.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn continue_resumes_session_with_history() {
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 binary = pager_binary().expect("resolve pager binary");
let mut first = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&[],
Some(project.path()),
)
.expect("spawn first pager");
first
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
first
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit turn 1");
first
.wait_for_text(&turn_sentinel(1), Duration::from_secs(30))
.expect("turn 1 rendered");
// Quit via Ctrl+Q double-press: focus is in the prompt, so 'q' would just type.
first.update(Duration::from_millis(500));
first.inject_keys(b"\x11").expect("ctrl-q once");
first.update(Duration::from_millis(200));
first.inject_keys(b"\x11").expect("ctrl-q confirm");
first.quit().expect("reap first pager");
let mut resumed = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--continue"],
Some(project.path()),
)
.expect("spawn resumed pager");
resumed
.wait_for_text(&turn_sentinel(1), WELCOME_TIMEOUT)
.expect("history replayed after --continue");
let screen = resumed.screen_contents();
assert_eq!(
screen.matches(&turn_sentinel(1)).count(),
1,
"turn 1 must appear exactly once after resume\nscreen:\n{screen}"
);
content.set_response(format!("{} resumed session payload.", turn_sentinel(2)));
resumed
.inject_keys(b"again\r")
.expect("submit turn 2 after resume");
resumed
.wait_for_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()
);
resumed.quit().expect("quit resumed pager");
}
@@ -0,0 +1,102 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 17. **Ctrl+C-cancel mid-stream recovers cleanly.**
///
/// Cancelling a streaming turn must (a) surface the "Turn cancelled" marker
/// exactly once — the turn end now arrives via BOTH the PromptResponse RPC
/// and the `prompt_complete` broadcast (which arms the lost-response
/// reconcile), and a double-finish would render two markers — and (b) leave
/// the pane usable: no `TurnCancelling` latch, the next typed prompt runs.
/// Cancel is via Ctrl+C (Esc no longer cancels mid-turn).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn ctrl_c_cancel_during_stream_recovers_cleanly() {
let content = ContentController::start().await.expect("start content");
// A long, paced response keeps the turn visibly streaming while Ctrl+C is
// pressed (the mock otherwise dumps all SSE events instantly and the
// turn completes before the cancel lands).
let long_response = format!(
"{MOCK_RESPONSE_SENTINEL} {}",
"streaming filler words for the cancellation window. ".repeat(120)
);
content.set_response(long_response);
content.set_chunk_delay(Some(Duration::from_millis(50)));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("stream started");
// Ctrl+C on an empty prompt cancels while streaming.
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
harness.update(Duration::from_millis(200));
harness
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
.expect("turn cancelled marker");
// Settle, then assert the marker rendered exactly once — a double marker
// means the broadcast reconcile double-finished the turn alongside the
// PromptResponse path.
harness.update(Duration::from_millis(1000));
let screen = harness.screen_contents();
assert_eq!(
screen.matches("Turn cancelled by user").count(),
1,
"'Turn cancelled' must appear exactly once\nscreen:\n{screen}"
);
// Recovery: the pane must accept and run a new prompt (no
// TurnCancelling latch, which previously required a restart here).
content.set_chunk_delay(None);
content.set_response("RECOVERYSENTINEL post-cancel turn ran.");
harness
.inject_keys(b"run again\r")
.expect("submit post-cancel prompt");
harness
.wait_for_text("RECOVERYSENTINEL", Duration::from_secs(30))
.expect("post-cancel turn streamed");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
// The new cancel-path observability must have recorded the cancel
// end-to-end in the (isolated) unified log: the agent received the
// `session/cancel` notification and processed it against the running
// prompt. Absence of these markers was exactly what made the original
// incident undiagnosable.
let unified_log = content
.home()
.join(".kigi")
.join("logs")
.join("unified.jsonl");
let unified = std::fs::read_to_string(&unified_log).unwrap_or_default();
assert!(
unified.contains("\"msg\":\"shell.cancel.received\""),
"unified log must record the agent receiving the cancel\nlog path: {}",
unified_log.display()
);
assert!(
unified.contains("\"msg\":\"shell.cancel.processing\""),
"unified log must record the cancel being processed\nlog path: {}",
unified_log.display()
);
}
@@ -0,0 +1,59 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// The no-rewind boundary: once the server has streamed ANY activity, Ctrl+C
/// is a standard cancel — the prompt stays a committed " " block (exactly
/// once, NOT restored to the composer) and the "Turn cancelled by user"
/// marker renders.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn ctrlc_after_activity_no_rewind_prompt_once() {
const CANCEL_PROMPT: &str = "cancel after activity";
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(Duration::from_millis(150)));
content.set_turns([slow_turn_text("CANCELME")]);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{CANCEL_PROMPT}\r").as_bytes())
.expect("submit prompt");
// Server activity on screen — the rewind window is provably closed.
harness
.wait_for_text("CANCELME", Duration::from_secs(30))
.expect("turn streaming");
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C cancel");
harness
.wait_for_text("Turn cancelled by user", Duration::from_secs(10))
.expect("standard cancel marker");
// No rewind: the composer stays empty and the committed block stays put,
// exactly once.
assert!(
!composer_holds(&harness, CANCEL_PROMPT),
"post-activity cancel must not restore the prompt to the composer\nscreen:\n{}",
harness.screen_contents()
);
assert_eq!(
block_lines_containing(&harness, CANCEL_PROMPT),
1,
"prompt must stay committed exactly once\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,97 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Rewind is SKIPPED when a queued prompt exists: Ctrl+C on the running turn
/// is a standard cancel (visible marker, A never mixed into the composer),
/// the queued B promotes as the next turn, and each of A/B renders exactly
/// once.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn ctrlc_with_queued_prompt_no_dup() {
const PROMPT_A: &str = "alpha primary task";
const PROMPT_B: &str = "bravo queued follow";
let content = ContentController::start().await.expect("start content");
// Gate turn A's terminal event so the queue + Ctrl+C provably land
// mid-turn (the cancel abort beats the held completion).
content.hold_agent_completions();
content.set_turns([
slow_turn_text("ALPHARESP"),
"BRAVORESP promoted after cancel.".to_owned(),
]);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT_A}\r").as_bytes())
.expect("submit A");
harness
.wait_for_text("ALPHARESP", Duration::from_secs(45))
.expect("A streaming");
harness
.inject_keys(format!("{PROMPT_B}\r").as_bytes())
.expect("queue B mid-turn");
harness
.wait_for_text(PROMPT_B, Duration::from_secs(20))
.expect("B visible as a queued row");
harness.inject_keys(keys::CTRL_C).expect("Ctrl+C cancel A");
content.release_agent_completions();
// Standard cancel (queued prompts skip the rewind): A is cancelled and B
// promotes as the next turn. The "Turn cancelled by user" marker and the
// " B" promotion scroll above the viewport once B's start-adoption pins
// its block to the head, so gate on B's reply (stable at the head) and
// prove correctness via the composer state + the recorded wire below.
harness
.wait_for_text("BRAVORESP", Duration::from_secs(90))
.expect("B promoted and replied");
// A stayed a committed block — never mixed into the composer.
assert!(
!composer_holds(&harness, PROMPT_A),
"cancel with a queued prompt must not restore A to the composer\nscreen:\n{}",
harness.screen_contents()
);
// No duplication: A (cancelled) and B (promoted) each reach the wire
// exactly once in the final request — A is not interrupt-dup'd and B is not
// double-sent. A visible-line count is unreliable here (A scrolls above the
// viewport on B's adoption), so assert against the wire.
let bodies = content.request_bodies();
let last = bodies.last().expect("final request recorded");
let user_queries: Vec<String> = last["messages"]
.as_array()
.into_iter()
.flatten()
.filter(|m| m["role"] == "user")
.filter_map(|m| m["content"].as_str())
.filter(|c| c.contains("<user_query>"))
.map(str::to_owned)
.collect();
let wire_count = |needle: &str| user_queries.iter().filter(|c| c.contains(needle)).count();
assert_eq!(
wire_count(PROMPT_A),
1,
"A must reach the wire exactly once (no interrupt dup): {user_queries:#?}"
);
assert_eq!(
wire_count(PROMPT_B),
1,
"B must reach the wire exactly once: {user_queries:#?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,135 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Ctrl+\ (OpenDashboard). crossterm maps the raw 0x1c byte to Ctrl+4, so the
/// universal dashboard chord must be sent as the kitty CSI-u form: code 92
/// (`\`), modifier 5 (Ctrl). Mirrors `CTRL_ENTER` / `CTRL_SEMICOLON` in common.
const CTRL_BACKSLASH: &[u8] = b"\x1b[92;5u";
/// Attach the (only) agent row as a session overlay from the dashboard list.
/// Down clamps at the last focusable, so three Downs land on the row regardless
/// of the current cursor (fresh open = New Agent button; after a back-out =
/// previously-selected row); Enter then attaches the peeked row. Waits until the
/// overlay is up: the dashboard list ("+ New Agent") is gone and the agent's
/// transcript (MOCKRESPONSE) is shown.
fn attach_overlay(h: &mut PtyHarness) {
for _ in 0..3 {
h.inject_keys(keys::DOWN).expect("down to row");
h.update(Duration::from_millis(200));
}
h.inject_keys(keys::ENTER).expect("attach row");
wait_for_labels_absent(h, &["+ New Agent"], Duration::from_secs(10));
h.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(10))
.expect("attached the original agent as an overlay");
assert!(
!h.contains_text("+ New Agent"),
"attach must leave the dashboard list for the agent overlay\nscreen:\n{}",
h.screen_contents()
);
}
/// Dashboard-overlay back-out. Attaching a
/// session lands on the default Prompt focus, so every keyboard back-out path
/// must work and the user must never be trapped:
/// - **Ctrl+\** opens the dashboard from a session (and from inside the overlay);
/// - **empty-prompt Esc** backs out;
/// - **Left on an empty prompt** backs out;
/// - **a drafted-prompt Esc** does NOT back out — it arms "press again to clear";
/// - **Tab then a neutral scrollback Esc** backs out.
/// Each back-out is re-verified against a freshly re-attached overlay.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn dashboard_overlay_tab_esc_backout_and_ctrl_backslash() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} dashboard overlay turn."));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn rendered (idle session)");
// Ctrl+\ opens the dashboard from inside a session (universal back-out).
harness
.inject_keys(CTRL_BACKSLASH)
.expect("ctrl+\\ open dashboard");
harness
.wait_for_text("+ New Agent", Duration::from_secs(10))
.expect("Ctrl+\\ opens the dashboard");
// ── (c) Drafted-prompt Esc must NOT back out (arms "press again to clear")
// and (d) empty-prompt Esc backs out. Overlay lands on Prompt.
attach_overlay(&mut harness);
let draft = "OVLDRAFT";
harness.inject_keys(draft.as_bytes()).expect("type draft");
harness
.wait_for_text(draft, Duration::from_secs(10))
.expect("draft renders in the overlay prompt");
harness.inject_keys(keys::ESC).expect("esc with draft");
harness.update(Duration::from_millis(300));
assert!(
harness.contains_text("press again to clear"),
"a drafted overlay prompt Esc must arm clear, not back out\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("+ New Agent"),
"a drafted overlay prompt Esc must NOT return to the dashboard\nscreen:\n{}",
harness.screen_contents()
);
// Wipe the draft (Ctrl+U clears the armed pending then kills the line).
harness.inject_keys(b"\x15").expect("ctrl+u clear draft");
wait_for_labels_absent(&mut harness, &[draft], Duration::from_secs(5));
// (d) Empty-prompt Esc now backs out to the dashboard.
harness.inject_keys(keys::ESC).expect("empty-prompt esc");
harness
.wait_for_text("+ New Agent", Duration::from_secs(10))
.expect("empty-prompt overlay Esc must back out to the dashboard");
// ── (a) Left on an empty prompt backs out. (Left arrow = CSI D.)
attach_overlay(&mut harness);
harness
.inject_keys(b"\x1b[D")
.expect("left on empty prompt");
harness
.wait_for_text("+ New Agent", Duration::from_secs(10))
.expect("Left on an empty overlay prompt must back out to the dashboard");
// ── (b) Ctrl+\ from inside the overlay backs out.
attach_overlay(&mut harness);
harness
.inject_keys(CTRL_BACKSLASH)
.expect("ctrl+\\ from overlay");
harness
.wait_for_text("+ New Agent", Duration::from_secs(10))
.expect("Ctrl+\\ from inside the overlay must back out to the dashboard");
// ── Tab then a neutral scrollback Esc backs out (bare-scrollback path).
attach_overlay(&mut harness);
harness.inject_keys(b"\t").expect("tab to scrollback");
harness.update(Duration::from_millis(400));
harness
.inject_keys(keys::ESC)
.expect("neutral esc back-out");
harness
.wait_for_text("+ New Agent", Duration::from_secs(10))
.expect("Tab then neutral Esc returns to the dashboard list");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,83 @@
//! Doubled lines — regression guard via a simulated
//! out-of-band screen reflow.
//!
//! The stack is `tmux -> nvim :terminal -> grok`: nvim/tmux repaint grok's
//! pane out-of-band (no grok PTY resize), and grok's diff renderer only
//! re-clears on a real size change — so the rows it doesn't own survive and
//! you get doubled lines at the top/bottom until restart.
//!
//! The harness is a single faithful emulator and can't nest a real tmux/nvim,
//! so we SIMULATE the out-of-band reflow with `feed_screen` (writes straight
//! into the virtual terminal, bypassing grok) and then check whether grok
//! heals it. `NVIM` is set in grok's env so it sees the embedded-editor
//! context the fix keys off (mirroring a real nvim `:terminal`).
//!
//! The fix: grok forces a full clear+repaint on `FocusGained` in
//! editor/multiplexer contexts, so the injected row is gone after refocus.
//! The final assertion guards that heal.
use super::common::*;
/// Unique sentinel that grok would never render on its own.
const STALE_MARKER: &str = "STALE_OUT_OF_BAND_ROW_ZZZ";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore] // opt-in PTY e2e (run with `-- --ignored`)
async fn out_of_band_stale_row_heals_on_focus_gained() {
let content = ContentController::start()
.await
.expect("start mock content");
// Mock-auth env + pretend we're inside a neovim `:terminal` (sets the
// embedded-editor context the doubled-line fix gates on).
let mut env = content.env_for_pager();
env.push(("NVIM".into(), "/tmp/grok-pty-harness-fake-nvim.sock".into()));
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 h =
PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs).expect("spawn pager");
h.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome screen");
// Let the initial draws land before injecting. The Welcome logo shimmers, so
// the screen never goes fully frame-idle, but the col-1 marker below is safe.
h.update(Duration::from_millis(800));
assert!(
!h.contains_text(STALE_MARKER),
"marker must be absent before injection"
);
// Simulate the out-of-band reflow: write a stale row straight into the
// virtual screen at col 1 — the static left margin grok's diff renderer
// doesn't repaint during the logo shimmer. The heal is a full clear, so
// removal is reliable. `\x1b[<row>;<col>H` is 1-based cursor positioning.
h.feed_screen(format!("\x1b[6;1H{STALE_MARKER}").as_bytes());
assert!(
h.contains_text(STALE_MARKER),
"marker should be on the virtual screen right after injection"
);
// grok must not self-heal out-of-band content via ordinary diff redraws:
// it only rewrites cells whose own model changed, so this row is stranded.
h.update(Duration::from_millis(300));
assert!(
h.contains_text(STALE_MARKER),
"stale row should survive a normal redraw (grok's diff renderer doesn't own it)\nscreen:\n{}",
h.screen_contents()
);
// A FocusGained (CSI I) forces a full clear+repaint that re-asserts grok's
// whole screen and removes the out-of-band row.
h.inject_keys(b"\x1b[I").expect("inject FocusGained");
// Poll for the heal instead of a fixed settle so host load can't flake it.
wait_for_labels_absent(&mut h, &[STALE_MARKER], Duration::from_secs(5));
assert!(
!h.contains_text(STALE_MARKER),
"FocusGained should force a full repaint and clear the out-of-band row \
(regression guard for the doubled-line bug)\nscreen:\n{}",
h.screen_contents()
);
h.quit().expect("clean quit");
}
@@ -0,0 +1,97 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
#[allow(unused_imports)]
use super::scroll::*;
/// PTY: drag-autoscroll must not bounce. Over tall content scrolled off the
/// bottom, a drag held past the pane's bottom edge autoscrolls down; the
/// topmost visible marker, sampled every ~100ms, must be monotonically
/// non-decreasing (never a regression to an earlier marker = no direction
/// flip, no offset jitter) and must settle back at the bottom clamp.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn drag_autoscroll_no_bounce_pty() {
let (mut harness, _content, baseline_topmost) = spawn_bottom_pinned_marker_scrollback(60).await;
// Wheel up (back-to-back reports classify as wheel) so the autoscroll
// has room to move back down to the clamp.
let mut wheel = String::new();
for _ in 0..12 {
wheel.push_str(&sgr_mouse(SGR_SCROLL_UP, WHEEL_ROW, WHEEL_COL, 'M'));
}
harness.inject_keys(wheel.as_bytes()).expect("wheel up");
harness.update(Duration::from_millis(600));
let scrolled_topmost = topmost_visible_marker(&harness).expect("markers visible");
assert!(
scrolled_topmost + 3 <= baseline_topmost,
"setup: wheel-up must scroll up (topmost {scrolled_topmost} vs baseline {baseline_topmost})\nscreen:\n{}",
harness.screen_contents()
);
// Press on a visible marker line (text anchor), then drag to the strip
// row above the prompt box — past the pane's bottom edge — and HOLD.
let screen = harness.screen_contents();
let marker_text = format!("MARKER-{:04}", scrolled_topmost + 3);
let (press_row, press_col) = locate_screen_text(&screen, &marker_text)
.unwrap_or_else(|| panic!("could not locate {marker_text:?}; screen:\n{screen}"));
let (placeholder_row, _) = locate_screen_text(&screen, "Build anything")
.unwrap_or_else(|| panic!("could not locate the prompt placeholder; screen:\n{screen}"));
let hold_row = placeholder_row - 2;
assert!(hold_row > press_row, "setup: hold point below the press");
// Two motion samples, as any real drag emits: the first promotes the
// pending drag, the second (at the held position) arms the autoscroll —
// promotion itself deliberately arms nothing.
let mut drag = String::new();
drag.push_str(&sgr_mouse(0, press_row, press_col, 'M'));
drag.push_str(&sgr_mouse(32, press_row + 1, press_col, 'M'));
drag.push_str(&sgr_mouse(32, hold_row, press_col, 'M'));
harness
.inject_keys(drag.as_bytes())
.expect("press a marker, drag past the bottom edge");
// Sample the viewport every ~100ms with the pointer held: the topmost
// marker must never regress (bounce) and must settle at the clamp.
let mut samples = Vec::new();
for _ in 0..25 {
harness.update(Duration::from_millis(100));
samples.push(topmost_visible_marker(&harness).unwrap_or_else(|| {
panic!(
"markers must stay visible mid-autoscroll\nscreen:\n{}",
harness.screen_contents()
)
}));
}
for pair in samples.windows(2) {
assert!(
pair[1] >= pair[0],
"viewport bounced: topmost regressed {} -> {}; samples={samples:?}",
pair[0],
pair[1]
);
}
let last = *samples.last().unwrap();
assert!(
last > samples[0] || samples[0] == baseline_topmost,
"autoscroll made no progress; samples={samples:?}"
);
assert_eq!(
last, baseline_topmost,
"must settle back at the bottom clamp; samples={samples:?}"
);
assert!(
samples[samples.len() - 5..].iter().all(|&m| m == last),
"must hold flat once clamped (no jitter); samples={samples:?}"
);
harness
.inject_keys(sgr_mouse(0, hold_row, press_col, 'm').as_bytes())
.expect("release");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,112 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Single-line message; the drag enters it at the tail word.
const GAPDEEP_LINE: &str = "GAPDEEP alpha beta gamma delta epsilon";
const ENTRY_WORD: &str = "epsilon";
/// PTY: a mouse-down on the blank gap below the conversation (between the
/// turn marker and the prompt box) arms an anchor-less drag — dead space
/// is a valid drag start — and the anchor materializes at the first drag
/// position that lands on selectable text: here a word inside the last
/// message. The payload is the entry-to-release slice of that single line
/// — not a snap to the press-nearest text, not a block copy.
///
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn drag_enters_content_from_gap_pty() {
let content = ContentController::start().await.expect("start content");
content.set_response(GAPDEEP_LINE.to_string());
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&[],
&env_refs,
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(ENTRY_WORD, Duration::from_secs(45))
.expect("message rendered");
harness
.wait_for_text("Worked for", Duration::from_secs(20))
.expect("turn marker rendered");
harness.inject_keys(b"\t").expect("focus scrollback");
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
harness.update(Duration::from_millis(1500));
let screen = harness.screen_contents();
let (msg_row, _) = locate_screen_text(&screen, "GAPDEEP")
.unwrap_or_else(|| panic!("could not locate GAPDEEP; screen:\n{screen}"));
let (entry_row, entry_col) = locate_screen_text(&screen, ENTRY_WORD)
.unwrap_or_else(|| panic!("could not locate {ENTRY_WORD:?}; screen:\n{screen}"));
assert_eq!(entry_row, msg_row, "setup: single unwrapped message line");
let (marker_row, _) = locate_screen_text(&screen, "Worked for")
.unwrap_or_else(|| panic!("could not locate the turn marker; screen:\n{screen}"));
assert!(marker_row > msg_row, "setup: marker below the message");
// The gap row below the marker must be fully blank (above the prompt box).
let gap_row = marker_row + 1;
let gap_line = screen.lines().nth(gap_row as usize).unwrap_or("");
assert!(
gap_line.trim().is_empty(),
"setup: the press row must be a blank gap; line: {gap_line:?}"
);
// PRESS in the gap, then drag up into the message. The motion samples
// jump the marker row deliberately (terminals coalesce motion): the
// same-row column clamp makes the marker's line hittable at any column
// of its row, so a sample there would anchor the drag on the marker —
// the correct first-text-entered answer for that path, but not this
// test's subject. First sample on the message = anchor at the word's
// first column; then extend to its last column and release.
let head_col = entry_col + ENTRY_WORD.len() as u16 - 1;
let mut drag = String::new();
drag.push_str(&sgr_mouse(0, gap_row, entry_col, 'M'));
drag.push_str(&sgr_mouse(32, entry_row, entry_col, 'M'));
drag.push_str(&sgr_mouse(32, entry_row, head_col, 'M'));
drag.push_str(&sgr_mouse(0, entry_row, head_col, 'm'));
harness
.inject_keys(drag.as_bytes())
.expect("press the gap, drag up into the message");
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
assert!(
!payloads.is_empty(),
"expected an OSC 52 clipboard write after release; screen:\n{}",
harness.screen_contents()
);
let joined = payloads.join("\n");
assert_eq!(
joined, ENTRY_WORD,
"payload must be the entry-to-release slice of the entered line \
(anchor at text entry, not at the press or a snap); payloads={payloads:?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,119 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Single-line message; the drag enters it at the tail word.
const STRIPDEEP_LINE: &str = "STRIPDEEP alpha beta gamma delta epsilon";
const ENTRY_WORD: &str = "epsilon";
/// PTY: a mouse-down on the strip directly above the prompt box — OUTSIDE
/// the scrollback pane — arms the anchor-less latch, and dragging up into
/// the last message anchors where the pointer enters text: the payload is
/// the entry-to-release slice. The prompt-gap row is the deterministic
/// representative of the band (turn status and banner need live turn or
/// watcher state the harness can't stage while idle; all band rows share
/// the same arming path).
///
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn drag_from_above_prompt_strip_pty() {
let content = ContentController::start().await.expect("start content");
content.set_response(STRIPDEEP_LINE.to_string());
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&[],
&env_refs,
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(ENTRY_WORD, Duration::from_secs(45))
.expect("message rendered");
harness
.wait_for_text("Worked for", Duration::from_secs(20))
.expect("turn marker rendered");
harness.inject_keys(b"\t").expect("focus scrollback");
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
harness.update(Duration::from_millis(1500));
let screen = harness.screen_contents();
let (entry_row, entry_col) = locate_screen_text(&screen, ENTRY_WORD)
.unwrap_or_else(|| panic!("could not locate {ENTRY_WORD:?}; screen:\n{screen}"));
// The strip row sits two rows above the prompt placeholder: placeholder,
// then the box's top border, then the gap row between the scrollback
// pane and the prompt box.
let (placeholder_row, _) = locate_screen_text(&screen, "Build anything")
.unwrap_or_else(|| panic!("could not locate the prompt placeholder; screen:\n{screen}"));
let border_row = placeholder_row - 1;
let border_line = screen.lines().nth(border_row as usize).unwrap_or("");
assert!(
border_line.contains('\u{256d}'),
"setup: prompt top border above the placeholder; line: {border_line:?}"
);
let strip_row = border_row - 1;
let strip_line = screen.lines().nth(strip_row as usize).unwrap_or("");
assert!(
strip_line.trim().is_empty(),
"setup: the press row must be the blank above-prompt strip; line: {strip_line:?}"
);
assert!(
strip_row > entry_row,
"setup: strip below the message\nscreen:\n{screen}"
);
// PRESS on the strip, then drag up into the message. The single motion
// sample jumps the turn-marker row deliberately (terminals coalesce
// motion): the same-row column clamp makes the marker's line hittable
// at any column of its row, and this test pins the strip-to-message
// path, not marker anchoring. First sample on the message = anchor at
// the word's first column; then extend to its last column and release.
let head_col = entry_col + ENTRY_WORD.len() as u16 - 1;
let mut drag = String::new();
drag.push_str(&sgr_mouse(0, strip_row, entry_col, 'M'));
drag.push_str(&sgr_mouse(32, entry_row, entry_col, 'M'));
drag.push_str(&sgr_mouse(32, entry_row, head_col, 'M'));
drag.push_str(&sgr_mouse(0, entry_row, head_col, 'm'));
harness
.inject_keys(drag.as_bytes())
.expect("press the above-prompt strip, drag up into the message");
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
assert!(
!payloads.is_empty(),
"expected an OSC 52 clipboard write after release; screen:\n{}",
harness.screen_contents()
);
let joined = payloads.join("\n");
assert_eq!(
joined, ENTRY_WORD,
"payload must be the entry-to-release slice of the entered line; \
payloads={payloads:?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,112 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Paragraph tokens above and below the blank row inside one message.
const CHROMEHOLD_TOP: &str = "CHROMEHOLD_TOP";
const CHROMEHOLD_BOTTOM: &str = "CHROMEHOLD_BOTTOM";
/// PTY: a mouse-down on a blank row INSIDE a block's area whose drag never
/// touches selectable text stays a whole-block drag: release copies the
/// whole message (both paragraphs), exactly as before the deferred-anchor
/// latch existed. Covers the never-enter branch the anchor-on-entry
/// conversion must not disturb.
///
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn drag_from_chrome_stays_block_pty() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!(
"{CHROMEHOLD_TOP} first paragraph line\n\n{CHROMEHOLD_BOTTOM} second paragraph line"
));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&[],
&env_refs,
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(CHROMEHOLD_BOTTOM, Duration::from_secs(45))
.expect("both paragraphs rendered");
harness.inject_keys(b"\t").expect("focus scrollback");
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
harness.update(Duration::from_millis(1500));
let screen = harness.screen_contents();
let (row_top, col_top) = locate_screen_text(&screen, CHROMEHOLD_TOP)
.unwrap_or_else(|| panic!("could not locate {CHROMEHOLD_TOP:?}; screen:\n{screen}"));
let (row_bottom, _) = locate_screen_text(&screen, CHROMEHOLD_BOTTOM)
.unwrap_or_else(|| panic!("could not locate {CHROMEHOLD_BOTTOM:?}; screen:\n{screen}"));
assert_eq!(
row_bottom,
row_top + 2,
"setup: exactly one blank row between the paragraphs\nscreen:\n{screen}"
);
let chrome_row = row_top + 1;
let chrome_line = screen.lines().nth(chrome_row as usize).unwrap_or("");
assert!(
chrome_line
.chars()
.skip(col_top as usize)
.take(16)
.all(char::is_whitespace),
"setup: the in-block row must be blank at the drag columns; line: {chrome_line:?}"
);
// PRESS on the blank in-block row and drag sideways along it — the
// pointer never enters selectable text, so the gesture stays a block
// drag — then release.
let mut drag = String::new();
drag.push_str(&sgr_mouse(0, chrome_row, col_top, 'M'));
drag.push_str(&sgr_mouse(32, chrome_row, col_top + 3, 'M'));
drag.push_str(&sgr_mouse(0, chrome_row, col_top + 3, 'm'));
harness
.inject_keys(drag.as_bytes())
.expect("press the in-block blank row, drag along it");
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
assert!(
!payloads.is_empty(),
"expected an OSC 52 clipboard write after release; screen:\n{}",
harness.screen_contents()
);
let joined = payloads.join("\n");
assert!(
joined.starts_with(&format!("{CHROMEHOLD_TOP} first paragraph line")),
"whole-block copy starts at the block's first line; payloads={payloads:?}"
);
assert!(
joined
.trim_end()
.ends_with(&format!("{CHROMEHOLD_BOTTOM} second paragraph line")),
"whole-block copy includes the full second paragraph; payloads={payloads:?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,112 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Paragraph tokens above and below the blank row inside one message.
const GAP_TOP: &str = "GAPROW_ALPHA";
const GAP_BOTTOM: &str = "GAPROW_OMEGA";
/// PTY: a drag whose LAST motion lands on the dead blank row between two
/// paragraphs of one message must not freeze the head — the copy spans the
/// gap into the second paragraph.
///
/// Both paragraphs share the message body's selection range; the blank row
/// between them has no selectable columns, so before dead-zone snapping the
/// head collapsed to the anchor there (a one-character copy).
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn drag_over_gap_rows_does_not_freeze_head_pty() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!(
"{GAP_TOP} first paragraph line\n\n{GAP_BOTTOM} second paragraph line"
));
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&[],
&env_refs,
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(GAP_BOTTOM, Duration::from_secs(45))
.expect("both paragraphs rendered");
harness.inject_keys(b"\t").expect("focus scrollback");
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
// Settle: the turn-end relayout can shift rows; locate afterwards.
harness.update(Duration::from_millis(1500));
let screen = harness.screen_contents();
let (row_top, col_top) = locate_screen_text(&screen, GAP_TOP)
.unwrap_or_else(|| panic!("could not locate {GAP_TOP:?}; screen:\n{screen}"));
let (row_bottom, _) = locate_screen_text(&screen, GAP_BOTTOM)
.unwrap_or_else(|| panic!("could not locate {GAP_BOTTOM:?}; screen:\n{screen}"));
assert_eq!(
row_bottom,
row_top + 2,
"setup: exactly one blank row between the paragraphs\nscreen:\n{screen}"
);
let gap_row = row_top + 1;
let drag_col = col_top + 12;
let gap_line = screen.lines().nth(gap_row as usize).unwrap_or("");
assert!(
gap_line
.chars()
.skip(col_top as usize)
.take(13)
.all(char::is_whitespace),
"setup: the gap row must be blank at the drag columns; line: {gap_line:?}"
);
// Press on the first paragraph, drag ONCE onto the dead row, release there.
let mut drag = String::new();
drag.push_str(&sgr_mouse(0, row_top, col_top, 'M'));
drag.push_str(&sgr_mouse(32, gap_row, drag_col, 'M'));
drag.push_str(&sgr_mouse(0, gap_row, drag_col, 'm'));
harness
.inject_keys(drag.as_bytes())
.expect("drag onto the gap row and release");
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
assert!(
!payloads.is_empty(),
"expected an OSC 52 clipboard write after release; screen:\n{}",
harness.screen_contents()
);
let joined = payloads.join("\n");
assert!(
joined.contains(GAP_TOP),
"clipboard must contain the anchor paragraph; payloads={payloads:?}"
);
assert!(
joined.contains(GAP_BOTTOM),
"clipboard span must cross the dead row into the second paragraph; \
payloads={payloads:?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,169 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
#[allow(unused_imports)]
use super::scroll::*;
/// First and last line tokens of the anchor block (turn 1's message).
const ANCHOR_FIRST: &str = "SCROLLOUT_ALPHA";
const ANCHOR_LAST: &str = "SCROLLOUT_OMEGA";
/// Turn-2 filler rows: comfortably taller than the 50-row PTY so drag
/// autoscroll can push the anchor block fully out of the viewport.
const FILLER_ROWS: usize = 120;
/// PTY: drag-hold at the bottom edge autoscrolls until the anchor block has
/// scrolled FULLY out of the viewport, then release — the copy must still
/// contain the anchor line and the block's late lines.
///
/// Exercises the drag-start `content_width` snapshot (mouse-up-time
/// `visible_blocks` no longer has the anchor block) plus the per-frame head
/// reclamp keeping the head on the range while it scrolls out.
/// `SSH_CONNECTION` forces the OSC 52 clipboard route for readback.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn drag_select_autoscroll_full_scrollout_copy_pty() {
let content = ContentController::start().await.expect("start content");
// Turn 1: a three-row anchor message (markdown hard breaks keep one row
// per source line). Turn 2: filler tall enough to scroll it fully out.
content.set_turns([
format!("{ANCHOR_FIRST} anchor first line \nmiddle filler line \n{ANCHOR_LAST} anchor last line"),
marker_response(MOCK_RESPONSE_SENTINEL, FILLER_ROWS),
]);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push((
"SSH_CONNECTION".into(),
"scripted-test 1 127.0.0.1 2".into(),
));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut harness = PtyHarness::new_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&[],
&env_refs,
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit turn 1");
harness
.wait_for_text(ANCHOR_LAST, Duration::from_secs(45))
.expect("anchor message rendered");
harness.update(Duration::from_millis(500));
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit turn 2");
harness
.wait_for_text(&marker_line(FILLER_ROWS - 1), Duration::from_secs(60))
.expect("filler streamed");
harness.update(Duration::from_millis(500));
harness.inject_keys(b"\t").expect("focus scrollback");
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(10));
harness.update(Duration::from_millis(500));
// Wheel up until the anchor block's first line is back on screen, then a
// little further so the press row sits clear of the top autoscroll zone.
let scroll_deadline = Instant::now() + Duration::from_secs(30);
while !harness.contains_text(ANCHOR_FIRST) {
assert!(
Instant::now() < scroll_deadline,
"never scrolled the anchor line back into view\nscreen:\n{}",
harness.screen_contents()
);
send_wheel_burst(
&mut harness,
SGR_SCROLL_UP,
10,
WHEEL_ROW,
WHEEL_COL,
Duration::ZERO,
);
harness.update(Duration::from_millis(200));
}
send_wheel_burst(
&mut harness,
SGR_SCROLL_UP,
3,
WHEEL_ROW,
WHEEL_COL,
Duration::ZERO,
);
harness.update(Duration::from_millis(400));
let screen = harness.screen_contents();
let (row, col) = locate_screen_text(&screen, ANCHOR_FIRST)
.unwrap_or_else(|| panic!("could not locate {ANCHOR_FIRST:?}; screen:\n{screen}"));
// Press on the anchor line, drag to the bottom edge, and HOLD: the edge
// position arms autoscroll and the ticks do the rest.
let bottom_row = DEFAULT_ROWS - 3;
let mut drag = String::new();
drag.push_str(&sgr_mouse(0, row, col, 'M'));
drag.push_str(&sgr_mouse(32, row + 1, col + 8, 'M'));
drag.push_str(&sgr_mouse(32, bottom_row, col + 20, 'M'));
harness
.inject_keys(drag.as_bytes())
.expect("press and drag to bottom edge");
// Hold until the anchor block is PROVABLY out of `visible_blocks`: the
// numbered filler sits below the anchor block, its padding, and the
// second prompt, so once the topmost visible filler marker is >= 4 every
// row above it — the whole anchor block included — is beyond the
// viewport top and the mouse-up-time width lookup cannot rescue the copy.
let out_deadline = Instant::now() + Duration::from_secs(30);
while topmost_visible_marker(&harness).is_none_or(|m| m < 4) {
assert!(
Instant::now() < out_deadline,
"autoscroll never pushed the anchor block off-screen\nscreen:\n{}",
harness.screen_contents()
);
harness.update(Duration::from_millis(200));
}
assert!(
!harness.contains_text(ANCHOR_FIRST) && !harness.contains_text(ANCHOR_LAST),
"anchor lines still on screen past marker 4\nscreen:\n{}",
harness.screen_contents()
);
harness
.inject_keys(sgr_mouse(0, bottom_row, col + 20, 'm').as_bytes())
.expect("release");
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
assert!(
!payloads.is_empty(),
"expected an OSC 52 clipboard write after release; screen:\n{}",
harness.screen_contents()
);
let joined = payloads.join("\n");
assert!(
joined.contains(ANCHOR_FIRST),
"clipboard must contain the (scrolled-out) anchor line; payloads={payloads:?}"
);
assert!(
joined.contains(ANCHOR_LAST),
"clipboard must contain the anchor block's late lines; payloads={payloads:?}"
);
// The selection stays pinned to the anchor block's range: no filler lines.
assert!(
!joined.contains("MARKER-"),
"clipboard must not contain lines from the filler block; payloads={payloads:?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,101 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
#[allow(unused_imports)]
use super::scroll::*;
/// Tall enough that a mid-drag wheel burst has ~150 rows of headroom above
/// the bottom-pinned viewport.
const MARKER_COUNT: usize = 240;
/// Wheel-up reports sent mid-drag in one write (batched with the follow-up
/// one-cell motion so no redraw separates them).
const WHEEL_NOTCHES: usize = 16;
/// The extension witness: a marker this many lines above the pre-wheel head.
/// 16 notches scroll ≥16 rows under any stream classification, so the
/// post-wheel head is at least this far up; the pre-wheel span was 2 rows.
const MIN_EXTEND_LINES: usize = 12;
/// PTY: wheel-scrolling mid-drag extends the selection — the next draw
/// rebuilds the model and re-snaps the head to the held pointer, so lines
/// revealed by the wheel land in the copy.
///
/// The wheel reports and the one-cell motion ride ONE write so the motion
/// resolves against the pre-wheel model; only the post-render reclamp can
/// extend the head to the revealed rows. `SSH_CONNECTION` forces the OSC 52
/// clipboard route for readback.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn drag_select_wheel_scroll_extends_pty() {
let (mut harness, _content, top_before) = spawn_bottom_pinned_marker_scrollback_with_env(
MARKER_COUNT,
&[("SSH_CONNECTION", "scripted-test 1 127.0.0.1 2")],
)
.await;
// Anchor ~25 rows below the topmost visible marker: mid-screen, clear of
// both autoscroll edge zones.
let anchor_idx = top_before + 25;
let anchor_marker = marker_line(anchor_idx);
let screen = harness.screen_contents();
let (row_a, col_a) = locate_screen_text(&screen, &anchor_marker)
.unwrap_or_else(|| panic!("could not locate {anchor_marker:?}; screen:\n{screen}"));
// Press at the END of the anchor marker (the anchor line is the span's
// bottom endpoint; its slice runs 0..=press col, keeping the token whole),
// then drag two rows UP ending at the marker's start column.
let press_col = col_a + anchor_marker.len() as u16 - 1;
let mut drag = String::new();
drag.push_str(&sgr_mouse(0, row_a, press_col, 'M'));
drag.push_str(&sgr_mouse(32, row_a - 1, col_a + 5, 'M'));
drag.push_str(&sgr_mouse(32, row_a - 2, col_a, 'M'));
harness
.inject_keys(drag.as_bytes())
.expect("press and drag up");
harness.update(Duration::from_millis(400));
// Wheel up mid-drag, then move one cell left — batched into one write.
let mut wheel = String::new();
for _ in 0..WHEEL_NOTCHES {
wheel.push_str(&sgr_mouse(SGR_SCROLL_UP, row_a - 2, col_a, 'M'));
}
wheel.push_str(&sgr_mouse(32, row_a - 2, col_a.saturating_sub(1), 'M'));
harness
.inject_keys(wheel.as_bytes())
.expect("wheel burst mid-drag");
harness.update(Duration::from_millis(800));
harness
.inject_keys(sgr_mouse(0, row_a - 2, col_a.saturating_sub(1), 'm').as_bytes())
.expect("release");
let payloads = wait_for_osc52_payloads(&mut harness, Duration::from_secs(10));
assert!(
!payloads.is_empty(),
"expected an OSC 52 clipboard write after release; screen:\n{}",
harness.screen_contents()
);
let joined = payloads.join("\n");
assert!(
joined.contains(&anchor_marker),
"clipboard must contain the anchor line; payloads={payloads:?}"
);
let revealed_marker = marker_line(anchor_idx - MIN_EXTEND_LINES);
assert!(
joined.contains(&revealed_marker),
"clipboard must span rows revealed by the mid-drag wheel scroll \
(expected {revealed_marker}); payloads={payloads:?}"
);
assert!(
!joined.contains(&marker_line(anchor_idx + 1)),
"an upward drag must not copy below the anchor line; payloads={payloads:?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,125 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
const DONE_SENTINEL: &str = "EDIT_COLLAPSED_DONE";
/// Comment planted in the replacement text: visible only when the diff body
/// renders, so it separates the collapsed one-liner from the expanded view.
const BODY_MARKER: &str = "EXPANDED_BODY_MARKER";
/// PTY: with the `collapsed_edit_blocks` flag enabled (config tier here;
/// remote settings/managed in production), an Edit tool call lands as a collapsed
/// one-liner — header with the colored `+N/-M` diffstat, no diff body — and a
/// double-click on the header expands it to the full diff. (The keyboard fold
/// keys operate on the scrollback selection, which this test does not
/// exercise.) The flag-off/legacy default is covered end-to-end by
/// `edit_hl_inplace_refresh_pty`, which relies on expanded-on-arrival diffs.
/// Doubles as the demo-cast generator via `KIGI_PTY_CAST_DIR`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn edit_collapsed_oneliner_pty() {
let content = ContentController::start().await.expect("start content");
seed_ui_config(&content, "collapsed_edit_blocks = true");
// Fixture under the isolated HOME so the scripted search_replace succeeds.
let target = content.home().join("greet_fix.py");
std::fs::write(
&target,
"# collapsed one-liner demo fixture\ndef greet():\n return \"hi\"\n",
)
.expect("write fixture");
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
// One deleted line, two inserted lines => a `+2/-1` diffstat.
enqueue_tool_turn(
&content,
"call_collapsed",
"search_replace",
json!({
"file_path": abs.to_string_lossy(),
"old_string": " return \"hi\"",
"new_string": format!(" name = \"grok\" # {BODY_MARKER}\n return f\"hi {{name}}\""),
})
.to_string(),
);
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// The follow-up completion means the tool turn settled.
harness
.wait_for_text(DONE_SENTINEL, Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"expected settled transcript; got:\n{}",
harness.screen_contents()
)
});
// Collapsed one-liner: basename + colored diffstat right after it.
harness
.wait_for_text("Edit greet_fix.py +2/-1", Duration::from_secs(20))
.unwrap_or_else(|_| {
panic!(
"expected the collapsed `Edit <basename> +N/-M` one-liner; got:\n{}",
harness.screen_contents()
)
});
assert!(
!harness.contains_text(BODY_MARKER),
"diff body must stay hidden while collapsed\nscreen:\n{}",
harness.screen_contents()
);
// Double-click the header to expand the block in place.
let screen = harness.screen_contents();
let (row, col) = locate_screen_text(&screen, "Edit greet_fix.py").unwrap_or_else(|| {
panic!("could not locate the Edit header; screen:\n{screen}");
});
let dbl = format!(
"{}{}{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
);
harness
.inject_keys(dbl.as_bytes())
.expect("double-click header");
harness
.wait_for_text(BODY_MARKER, Duration::from_secs(10))
.unwrap_or_else(|_| {
panic!(
"double-click must expand the Edit block to the diff body; got:\n{}",
harness.screen_contents()
)
});
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
#[cfg(unix)]
write_cast_if_requested(&harness, "edit_collapsed_oneliner.cast");
harness.quit().expect("clean quit");
}
@@ -0,0 +1,284 @@
// Per-test-case module for the `pty_e2e` integration test crate.
//
// Regression pin for the in-place edit-HL upgrade: the target line's styling
// must CHANGE on screen after the first (hunk-only) paint, proving the
// file-scoped repaint landed. Doubles as the demo-artifact generator — the
// asciicast/HTML dumps under /tmp/edit_hl_video are kept for demo videos.
#[allow(unused_imports)]
use super::common::*;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::time::Instant;
use kigi_pager_pty_harness::StyledLine;
const DONE_SENTINEL: &str = "EDIT_HL_DONE";
const ARTIFACT_DIR: &str = "/tmp/edit_hl_video";
/// Marker unique to the edited (Insert) line; its styling flips on upgrade.
const TARGET_MARKER: &str = "min_length=2";
/// Tail of the same line; requiring both ends rejects partially-painted rows.
const TARGET_TAIL: &str = "upgrade target";
/// Style-run snapshot of every fully-painted screen row containing the target
/// line, row-position independent (scrolling must not read as a styling
/// change). `None` until the whole line is on screen.
fn target_line_style_snapshot(rows: &[StyledLine]) -> Option<String> {
let mut snaps = Vec::new();
for row in rows {
let text: String = row.runs.iter().map(|r| r.text.as_str()).collect();
if text.contains(TARGET_MARKER) && text.contains(TARGET_TAIL) {
snaps.push(serde_json::to_string(&row.runs).unwrap_or_default());
}
}
if snaps.is_empty() {
None
} else {
Some(snaps.join("\n"))
}
}
/// Python body: mid-file closing `"""` then fields.
/// Prefix padding slows full-file HL so the in-place upgrade is visible.
fn fixture_python(pad_lines: usize) -> String {
let mut s = String::with_capacity(pad_lines * 48 + 512);
s.push_str("# queue_item.py — edit HL demo fixture (cold-start mismatch shape)\n");
for i in 0..pad_lines {
s.push_str(&format!(
"# pad line {i:04} keep full-file HL non-instant\n"
));
}
s.push_str(
r#"
class ProcessQueueItem(BaseModel):
"""Request body for processing a single queue item.
The item id is in the path; keep notes in the body.
"""
notes: str = Field(..., min_length=1)
category_id: CategoryId = Field(default=DEFAULT_CATEGORY_ID)
"#,
);
s
}
fn write_asciicast(path: &Path, cols: u16, rows: u16, events: &[(f64, String)]) {
let mut f = fs::File::create(path).expect("create cast");
let header = serde_json::json!({
"version": 2,
"width": cols,
"height": rows,
"timestamp": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
"env": {"TERM": "xterm-256color", "SHELL": "/bin/zsh"},
});
writeln!(f, "{header}").expect("header");
for (t, out) in events {
// asciicast v2: [time, "o", data]
let line = serde_json::json!([t, "o", out]);
writeln!(f, "{line}").expect("event");
}
}
/// PTY: search_replace on a mid-file `"""` fixture; assert the Edit block's
/// target line restyles in place (hunk-only → file-scoped upgrade) and dump
/// asciicast + HTML demo artifacts.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn edit_hl_inplace_refresh_pty() {
fs::create_dir_all(ARTIFACT_DIR).expect("artifact dir");
let content = ContentController::start().await.expect("start content");
// No config seeds: the collapsed_edit_blocks flag ships OFF, so Edit
// diffs arrive expanded — this test asserts the on-screen restyle of the
// diff BODY and doubles as the flag-off/legacy-default e2e.
// ~2.5k pad lines: full-file HL takes hundreds of ms (visible upgrade).
let pad = 2500usize;
let body = fixture_python(pad);
let target = content.home().join("queue_item.py");
fs::write(&target, &body).expect("write fixture");
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
// Small unique edit on the field line after the closing """ (the spill zone).
let old = " notes: str = Field(..., min_length=1)";
let new = " notes: str = Field(..., min_length=2) # HL upgrade target";
enqueue_tool_turn(
&content,
"call_edit_hl",
"search_replace",
json!({
"file_path": abs.to_string_lossy(),
"old_string": old,
"new_string": new,
})
.to_string(),
);
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");
let rows = DEFAULT_ROWS;
let cols = DEFAULT_COLS;
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
rows,
cols,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
// Timed raw-output samples for asciicast.
let t0 = Instant::now();
let mut events: Vec<(f64, String)> = Vec::new();
let mut raw_cursor = 0usize;
let mut sample = |harness: &mut PtyHarness| {
harness.update(Duration::from_millis(50));
let raw = harness.raw_output();
if raw.len() > raw_cursor {
let chunk = &raw[raw_cursor..];
raw_cursor = raw.len();
if let Ok(s) = std::str::from_utf8(chunk) {
if !s.is_empty() {
events.push((t0.elapsed().as_secs_f64(), s.to_owned()));
}
} else {
// Lossy for binary OSC/etc. still useful for video.
let s = String::from_utf8_lossy(chunk).into_owned();
if !s.is_empty() {
events.push((t0.elapsed().as_secs_f64(), s));
}
}
}
};
// Welcome
let welcome_deadline = Instant::now() + WELCOME_TIMEOUT;
loop {
sample(&mut harness);
if harness.contains_text(WELCOME_SCREEN_SENTINEL) {
break;
}
assert!(
Instant::now() < welcome_deadline,
"welcome timeout; screen:\n{}",
harness.screen_contents()
);
}
// Hold welcome briefly for the video.
for _ in 0..20 {
sample(&mut harness);
}
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// Phase 1: hunk-only first paint — capture the target line's styling at
// first sighting. The 2.5k pad keeps the full-file HL slow enough that
// this frame reliably precedes the upgrade.
// Phase 2: in-place upgrade — poll until the SAME line's styling changes.
let edit_deadline = Instant::now() + Duration::from_secs(90);
let mut saw_edit = false;
let mut first_styles: Option<String> = None;
let mut upgraded_styles: Option<String> = None;
let mut post_upgrade_html: Option<String> = None;
loop {
sample(&mut harness);
let screen = harness.screen_contents();
if !saw_edit && (screen.contains("Edit ") || screen.contains("queue_item.py")) {
saw_edit = true;
}
if let Some(snap) = target_line_style_snapshot(&harness.screen_styled()) {
match &first_styles {
None => {
first_styles = Some(snap);
let _ = fs::write(
PathBuf::from(ARTIFACT_DIR).join("frame_edit_first.html"),
harness.screen_html(),
);
let _ = fs::write(
PathBuf::from(ARTIFACT_DIR).join("frame_edit_first.txt"),
&screen,
);
}
Some(first) if upgraded_styles.is_none() && *first != snap => {
upgraded_styles = Some(snap);
let html = harness.screen_html();
post_upgrade_html = Some(html.clone());
let _ = fs::write(
PathBuf::from(ARTIFACT_DIR).join("frame_edit_upgraded.html"),
&html,
);
let _ = fs::write(
PathBuf::from(ARTIFACT_DIR).join("frame_edit_upgraded.txt"),
&screen,
);
}
_ => {}
}
}
if saw_edit && upgraded_styles.is_some() && screen.contains(DONE_SENTINEL) {
break;
}
if Instant::now() > edit_deadline {
panic!(
"timeout waiting for edit HL flow (saw_edit={saw_edit} \
first_styles={} upgraded={}); screen:\n{screen}",
first_styles.is_some(),
upgraded_styles.is_some(),
);
}
}
// Hold the upgraded view for the video (~3s).
for _ in 0..60 {
sample(&mut harness);
}
// The in-place upgrade proof: the marker line's style runs changed after
// the hunk-only first paint while its text stayed put.
let first = first_styles.expect("target line styled snapshot at first paint");
let upgraded = upgraded_styles.expect("target line styling must change (file-scoped upgrade)");
assert_ne!(
first, upgraded,
"upgrade must restyle the target line in place"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
// Final full dumps.
let final_screen = harness.screen_contents();
let final_html = harness.screen_html();
fs::write(PathBuf::from(ARTIFACT_DIR).join("final.txt"), &final_screen).ok();
fs::write(PathBuf::from(ARTIFACT_DIR).join("final.html"), &final_html).ok();
fs::write(
PathBuf::from(ARTIFACT_DIR).join("raw.ansi"),
harness.raw_output(),
)
.ok();
let cast_path = PathBuf::from(ARTIFACT_DIR).join("edit-hl-demo.cast");
write_asciicast(&cast_path, cols, rows, &events);
eprintln!(
"edit HL PTY artifacts → {ARTIFACT_DIR} (cast events={}, duration≈{:.1}s)",
events.len(),
events.last().map(|(t, _)| *t).unwrap_or(0.0)
);
// Soft color proof: HTML after upgrade should include span styling (not plain text only).
let html = post_upgrade_html.unwrap_or(final_html);
assert!(
html.contains("style=") || html.contains("<span"),
"expected styled HTML for syntax colors; html len={}",
html.len()
);
}
@@ -0,0 +1,229 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// **Edit-interject of the lone queued row must not brick the TUI.**
///
/// Regression e2e for the orphaned invisible `EditConfirm`: interjecting
/// (Ctrl+Enter) a DIRTY edit of a LOCAL queue row that is also the LAST
/// visible row empties the queue mid-flow — the pane auto-hide used to run
/// its pane switch while still in `EditingQueued`, arming a confirm modal
/// that never renders but consumes every subsequent key. The interjection
/// itself still went out (toast + drain both looked healthy), so only an
/// after-the-fact liveness probe catches the brick: on a broken binary the
/// probe text never echoes and step 7 times out.
///
/// The queued message carries a pasted image (as in the original report) —
/// that is also what forces it onto the LOCAL queue: mid-turn plain text
/// takes the server-authoritative immediate-send path instead
/// (`immediate_server_send_eligible`), which never hits the fixed code.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn edit_interject_lone_queued_row_keeps_tui_alive() {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(Duration::from_millis(150)));
// Turn 1 must stay open long enough for the ENTIRE mid-turn setup — type
// the queued text, paste the image path, queue it, focus the queue pane,
// enter edit, dirty it, Ctrl+Enter — to land WHILE it is still streaming.
// Only then does the edit-interject drain into turn 1 (→ STEPTWO). Under
// the ~60-way-parallel suite a short stream can collapse before the
// interject lands (same flake class the lifecycle test documents), so
// stream ~150 tokens (~22s) while STEPTWO still drains well inside its
// 40s wait below.
let step_one = {
let mut s = String::from("STEPONE");
for i in 0..150 {
s.push_str(&format!(" streaming{i}"));
}
s
};
content.set_turns([
step_one,
"STEPTWO interjection acknowledged.".to_owned(),
"STEPTHREE liveness prompt handled.".to_owned(),
]);
// Image fixture under the isolated HOME; the pasted absolute path becomes
// an `[Image #1]` composer chip (path-paste detection reads + decodes it).
let png_path = content.home().join("queue-edit-fixture.png");
std::fs::write(&png_path, PNG_32X32_GRAY).expect("write png fixture");
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("STEPONE", Duration::from_secs(30))
.expect("step 1: turn streaming");
// Queue ONE image-bearing message → the lone LOCAL queue row. The prose
// and the path must reach the pager as SEPARATE events: back-to-back
// injects can land in one EventStream batch and get paste-coalesced,
// merging the prose into the paste payload — and the paste pipeline is
// whole-paste-or-nothing (`try_read_dropped_paths`), so a mixed
// prose+path payload falls back to plain text instead of a chip. Gate
// on the typed prose rendering before pasting the bare path alone.
harness
.inject_keys(b"brick repro payload ")
.expect("type queued text");
harness
.wait_for_text("brick repro payload", Duration::from_secs(10))
.expect("step 2a: typed prose echoed before the paste");
harness
.inject_keys(format!("\x1b[200~{}\x1b[201~", png_path.display()).as_bytes())
.expect("paste png path");
harness.update(Duration::from_millis(500));
harness
.wait_for_text("[Image #", Duration::from_secs(10))
.expect("step 2b: image chip attached");
harness.inject_keys(b"\r").expect("queue the message");
// The `#1 ` row prefix is queue-pane-only chrome — the composer echo
// (on screen since step 2a) can never match it, so this proves the
// Enter actually queued the row.
harness
.wait_for_text("#1 brick repro payload", Duration::from_secs(10))
.expect("step 3: message queued as row #1 in the queue pane");
// Edit the row (prompt info row flips to "editing queued #1"), dirty it,
// then force-interject the edit.
harness
.inject_keys(CTRL_SEMICOLON)
.expect("focus queue pane");
harness.update(Duration::from_millis(300));
harness.inject_keys(b"e").expect("edit queued row");
harness
.wait_for_text("editing queued #1", Duration::from_secs(10))
.expect("step 4: edit mode entered");
harness.inject_keys(b"EDITED ").expect("dirty the edit");
harness.inject_keys(CTRL_ENTER).expect("interject the edit");
harness
.wait_for_text("Interjection sent", Duration::from_secs(10))
.expect("step 5: interjection toast");
harness
.wait_for_text("STEPTWO", Duration::from_secs(40))
.expect("step 6: interjection drained into turn 1");
// THE regression assertion — liveness probe. Both prior steps pass on a
// broken binary too (the interjection dispatch is not gated by the agent
// modal); what breaks is everything AFTER: the orphaned EditConfirm eats
// all input. Space first: the queue-pane auto-hide left focus on the
// scrollback, where Space focuses the prompt (on a broken binary the
// modal eats the Space as well, so the probe below never echoes).
harness
.inject_keys(b" ")
.expect("focus prompt from scrollback");
harness.update(Duration::from_millis(200));
harness
.inject_keys(b"liveness-probe-xyz")
.expect("type liveness probe");
harness
.wait_for_text("liveness-probe-xyz", Duration::from_secs(10))
.expect("step 7: typed input echoes — an orphaned EditConfirm would eat it");
harness.inject_keys(b"\r").expect("submit liveness probe");
harness
.wait_for_text("STEPTHREE", Duration::from_secs(40))
.expect("step 8: liveness prompt round-trips input, dispatch, and wire");
// Wire checks: final request's user_query sequence is exactly
// [prompt, edited interjection (with wire prefix), liveness probe].
let bodies = content.request_bodies();
let last = bodies.last().expect("final request recorded");
// User-role context preambles (user_info, skill reminders) don't carry
// <user_query>; real prompts and interjections do. Content is a plain
// string OR a parts array (the image-bearing interjection), so extract
// text parts-aware instead of `as_str`.
let finals: Vec<String> = last["messages"]
.as_array()
.expect("messages array")
.iter()
.filter(|m| m["role"] == "user")
.map(message_text)
.filter(|c| c.contains("<user_query>"))
.collect();
assert_eq!(3, finals.len(), "expected 3 user messages: {finals:#?}");
assert!(finals[0].contains(PROMPT), "first: {finals:#?}");
assert!(
finals[1].contains("EDITED")
&& finals[1].contains("brick repro payload")
&& finals[1].contains(INTERJECTION_WIRE_PREFIX),
"second must be the EDITED interjection: {finals:#?}"
);
assert!(
finals[2].contains("liveness-probe-xyz"),
"third: {finals:#?}"
);
// The queued row's stored image must ride the interjection to the wire.
let interjection_bodies: Vec<&serde_json::Value> = bodies
.iter()
.filter(|b| b.to_string().contains("EDITED"))
.collect();
assert!(
!interjection_bodies.is_empty(),
"no request carried the edited interjection"
);
assert!(
interjection_bodies.iter().any(|b| contains_image_part(b)),
"queued row's image never reached the wire: {interjection_bodies:#?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
/// A user message's text: plain-string content verbatim, parts-array content
/// (image-bearing messages) as the joined `text` parts.
fn message_text(m: &serde_json::Value) -> String {
match &m["content"] {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(parts) => parts
.iter()
.filter_map(|p| p["text"].as_str())
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
/// Whether any nested object is an image content part — same shape the
/// harness's scenario assertions accept: a `type` containing "image"
/// (`image_url`, `input_image`, …) or inline `mime_type` + `data`.
fn contains_image_part(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Object(map) => {
let is_image = map
.get("type")
.and_then(serde_json::Value::as_str)
.is_some_and(|ty| ty.contains("image"))
|| (map.contains_key("mime_type") && map.contains_key("data"));
is_image || map.values().any(contains_image_part)
}
serde_json::Value::Array(values) => values.iter().any(contains_image_part),
_ => false,
}
}
/// Valid 32×32 8-bit grayscale PNG (signature + IHDR + IDAT + IEND, CRCs
/// correct, IDAT zlib round-trips). Hardcoded rather than encoded via the
/// `image` dep so the fixture is byte-stable and encoder-independent.
/// 32×32 = 1024 total pixels clears the API/client 512-total-pixel floor
/// (`MIN_VISION_TOTAL_PX`) so the image rides the interjection to the wire
/// instead of being replaced by an `image_dropped_notice`.
const PNG_32X32_GRAY: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x08, 0x00, 0x00, 0x00, 0x00, 0x56, 0x11, 0x25,
0x28, 0x00, 0x00, 0x00, 0x16, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0x68, 0x20, 0x00, 0x18,
0x46, 0x15, 0x8c, 0x2a, 0x18, 0x55, 0x30, 0x52, 0x15, 0x00, 0x00, 0x42, 0x00, 0x00, 0x1f, 0x37,
0x97, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
@@ -0,0 +1,116 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
const DONE_SENTINEL: &str = "EDIT_MERGE_PAR_DONE";
const FIXTURE: &str = "parallel_fix.py";
/// PTY: with `collapsed_edit_blocks` enabled, TWO search_replace calls to the
/// same file issued in ONE model turn (parallel tool calls) coalesce into a
/// single Edit row with the summed diffstat, regardless of the order their
/// completions land in.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test pty_e2e -- --ignored"]
async fn edit_merge_parallel_pty() {
let content = ContentController::start().await.expect("start content");
seed_ui_config(&content, "collapsed_edit_blocks = true");
let target = content.home().join(FIXTURE);
std::fs::write(
&target,
"# parallel merge fixture\n\
first = \"one\"\n\
second = \"two\"\n\
third = \"three\"\n\
fourth = \"four\"\n\
fifth = \"five\"\n\
sixth = \"six\"\n\
seventh = \"seven\"\n\
eighth = \"eight\"\n\
ninth = \"nine\"\n\
tenth = \"ten\"\n",
)
.expect("write fixture");
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
// Non-overlapping 1:1 replacements so both calls succeed against the
// same starting file whatever order the shell runs them in.
let args_a = json!({
"file_path": abs.to_string_lossy(),
"old_string": "second = \"two\"",
"new_string": "second = \"two\" # PAR_EDIT_A",
})
.to_string();
let args_b = json!({
"file_path": abs.to_string_lossy(),
"old_string": "ninth = \"nine\"",
"new_string": "ninth = \"nine\" # PAR_EDIT_B",
})
.to_string();
enqueue_parallel_tool_turn(
&content,
&[
("call_par_1", "search_replace", args_a),
("call_par_2", "search_replace", args_b),
],
);
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(DONE_SENTINEL, Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"expected settled transcript; got:\n{}",
harness.screen_contents()
)
});
// One merged row summing both calls' diffstats.
harness
.wait_for_text(&format!("Edit {FIXTURE} +2/-2"), Duration::from_secs(20))
.unwrap_or_else(|_| {
panic!(
"expected one merged `Edit {FIXTURE} +2/-2` row; got:\n{}",
harness.screen_contents()
)
});
let screen = harness.screen_contents();
let edit_rows = screen
.lines()
.filter(|l| l.contains(&format!("Edit {FIXTURE}")))
.count();
assert_eq!(
edit_rows, 1,
"parallel same-file edits must render as ONE Edit row\nscreen:\n{screen}"
);
assert!(
!screen.contains("+1/-1"),
"per-call diffstats must not survive the merge\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,273 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
#[allow(unused_imports)]
use super::scroll::*;
const DONE_SENTINEL: &str = "EDIT_MERGE_SEQ_DONE";
/// Agent text between the merged run and the break-case edit.
const BREAK_TEXT_SENTINEL: &str = "MERGE_BREAK_TEXT";
/// Markers planted by the scripted edits: visible only in the diff body.
const EDIT_ONE_MARK: &str = "EDIT_ONE_MARK";
const EDIT_THREE_MARK: &str = "EDIT_THREE_MARK";
const FIXTURE: &str = "merge_fix.py";
/// One line of the fixture per word, so each edit is a 1:1 line replacement
/// (`+1/-1`) and line numbers stay stable across the whole scripted turn.
fn fixture_text() -> String {
let words = [
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
"juliett", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo",
"sierra", "tango", "uniform", "victor", "whiskey", "xray", "yankee", "zulu", "omega",
"finale",
];
let mut text = String::from("# merge fixture\n");
for (i, word) in words.iter().enumerate() {
text.push_str(&format!("v{:02} = \"{word}\"\n", i + 1));
}
text
}
fn edit_args(abs: &Path, var: &str, word: &str, mark: &str) -> String {
json!({
"file_path": abs.to_string_lossy(),
"old_string": format!("{var} = \"{word}\""),
"new_string": format!("{var} = \"{word}\" # {mark}"),
})
.to_string()
}
/// Count screen rows carrying an Edit header for the fixture.
fn edit_header_rows(screen: &str) -> usize {
screen
.lines()
.filter(|l| l.contains(&format!("Edit {FIXTURE}")))
.count()
}
/// PTY: with `collapsed_edit_blocks` enabled, three sequential same-file
/// edits coalesce into ONE Edit row whose header sums the diffstat (`+3/-3`);
/// expanding it shows every hunk with `… N unchanged lines` gap markers
/// between them. A fourth edit arriving after intervening agent text stays a
/// separate second Edit row — counted after wheeling back above the second
/// submit's page-flip, which pins the new prompt to the pane top and scrolls
/// turn 1 out of view by design. (Flag off, coalescing is disabled entirely —
/// pinned by the tracker unit test.)
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run with cargo test -p kigi-tui --test pty_e2e -- --ignored"]
async fn edit_merge_sequential_pty() {
let content = ContentController::start().await.expect("start content");
seed_ui_config(&content, "collapsed_edit_blocks = true");
let target = content.home().join(FIXTURE);
std::fs::write(&target, fixture_text()).expect("write fixture");
let abs = dunce::canonicalize(&target).unwrap_or(target.clone());
// Three 1:1 replacements at widely separated, increasing lines so every
// merged-hunk gap is computable (edits sit ~11 lines apart, context ±3).
enqueue_tool_turn(
&content,
"call_sr_1",
"search_replace",
edit_args(&abs, "v03", "charlie", EDIT_ONE_MARK),
);
enqueue_tool_turn(
&content,
"call_sr_2",
"search_replace",
edit_args(&abs, "v14", "november", "EDIT_TWO_MARK"),
);
enqueue_tool_turn(
&content,
"call_sr_3",
"search_replace",
edit_args(&abs, "v25", "yankee", EDIT_THREE_MARK),
);
// The first prompt's turn ends on agent text — the break for the run.
let break_text = format!("{BREAK_TEXT_SENTINEL} first batch settled.");
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_message_events(&break_text)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_message_events(&break_text)),
);
content.set_response(DONE_SENTINEL);
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(BREAK_TEXT_SENTINEL, Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"expected settled first batch; got:\n{}",
harness.screen_contents()
)
});
// One merged row with the summed diffstat; the per-call counts are gone.
harness
.wait_for_text(&format!("Edit {FIXTURE} +3/-3"), Duration::from_secs(20))
.unwrap_or_else(|_| {
panic!(
"expected one merged `Edit {FIXTURE} +3/-3` row; got:\n{}",
harness.screen_contents()
)
});
let screen = harness.screen_contents();
assert_eq!(
edit_header_rows(&screen),
1,
"three adjacent edits must render as ONE Edit row\nscreen:\n{screen}"
);
assert!(
!screen.contains("+1/-1") && !screen.contains("+2/-2"),
"per-call diffstats must not survive the merge\nscreen:\n{screen}"
);
// Expand the merged block: all hunks with gap markers between them.
let (row, col) = locate_screen_text(&screen, &format!("Edit {FIXTURE}")).unwrap_or_else(|| {
panic!("could not locate the merged Edit header; screen:\n{screen}");
});
let dbl = format!(
"{}{}{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
);
harness
.inject_keys(dbl.as_bytes())
.expect("double-click header");
harness
.wait_for_text(" unchanged lines", Duration::from_secs(10))
.unwrap_or_else(|_| {
panic!(
"expanded merged block must show gap markers; got:\n{}",
harness.screen_contents()
)
});
assert!(
harness.contains_text(EDIT_ONE_MARK) && harness.contains_text(EDIT_THREE_MARK),
"expanded body must include hunks from the first AND last merged edit\nscreen:\n{}",
harness.screen_contents()
);
// Fold the block back so the final screen keeps every row visible.
let screen = harness.screen_contents();
let (row, col) = locate_screen_text(&screen, &format!("Edit {FIXTURE}")).unwrap_or_else(|| {
panic!("could not re-locate the merged Edit header; screen:\n{screen}");
});
let dbl = format!(
"{}{}{}{}",
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
sgr_mouse(0, row, col, 'M'),
sgr_mouse(0, row, col, 'm'),
);
harness
.inject_keys(dbl.as_bytes())
.expect("double-click to fold");
let deadline = Instant::now() + Duration::from_secs(10);
while harness.contains_text(EDIT_ONE_MARK) {
assert!(
Instant::now() < deadline,
"merged block must fold back\nscreen:\n{}",
harness.screen_contents()
);
harness.update(Duration::from_millis(100));
}
// BREAK case: a fourth same-file edit after the agent-text turn stays a
// separate row instead of merging across the visible text entry.
harness
.wait_for_turn_idle(Duration::from_secs(15))
.expect("turn idle after break-case edit");
enqueue_tool_turn(
&content,
"call_sr_4",
"search_replace",
edit_args(&abs, "v10", "juliett", "EDIT_FOUR_MARK"),
);
harness
.inject_keys(b"go again\r")
.expect("submit second prompt");
harness
.wait_for_text(DONE_SENTINEL, Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"expected settled second turn; got:\n{}",
harness.screen_contents()
)
});
harness
.wait_for_text(&format!("Edit {FIXTURE} +1/-1"), Duration::from_secs(20))
.unwrap_or_else(|_| {
panic!(
"the post-break edit must keep its own `+1/-1` row; got:\n{}",
harness.screen_contents()
)
});
// The second submit page-flipped the viewport (the new prompt pins to the
// pane top — dispatch/queue.rs `scroll_to_entry_top` +
// `enable_follow_with_preserve`), so turn 1 legitimately sits above the
// fold. Wheel back to the transcript top — the whole thing fits one
// screen — before counting rows across both turns.
send_wheel_burst(
&mut harness,
SGR_SCROLL_UP,
30,
WHEEL_ROW,
WHEEL_COL,
Duration::ZERO,
);
harness
.wait_for_text(&format!("Edit {FIXTURE} +3/-3"), Duration::from_secs(10))
.unwrap_or_else(|_| {
panic!(
"the merged row must scroll back into view with its summed \
diffstat intact; got:\n{}",
harness.screen_contents()
)
});
let screen = harness.screen_contents();
assert_eq!(
edit_header_rows(&screen),
2,
"expected the merged row plus one separate post-break row\nscreen:\n{screen}"
);
assert!(
screen.contains("+1/-1"),
"the post-break row keeps its own diffstat\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
#[cfg(unix)]
write_cast_if_requested(&harness, "edit_merge_sequential.cast");
harness.quit().expect("clean quit");
}
@@ -0,0 +1,90 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Mid-turn: queue a follow-up with Enter, then bare Enter on the empty
/// composer sends that top row now — cancel-and-send: the running turn is
/// cancelled silently and the row runs as its own next turn, arriving on the
/// wire as a standard `<user_query>` prompt with no interjection preamble.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn empty_enter_force_sends_top_queued() {
let content = ContentController::start().await.expect("start content");
// Gate turn 1's terminal event so the queue + empty-Enter provably land
// mid-turn regardless of suite load.
content.hold_agent_completions();
content.set_turns([
slow_turn_text("TURNONE"),
"TURNTWO reply to the promoted follow-up.".to_owned(),
]);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("TURNONE", Duration::from_secs(30))
.expect("turn 1 streaming");
harness
.inject_keys(b"please also check the logs\r")
.expect("queue follow-up via Enter");
harness
.wait_for_text("please also check the logs", Duration::from_secs(10))
.expect("queued text visible");
// Composer is empty after queue; bare Enter sends the top row now. The
// shell cancels turn 1 (the abort beats the held completion) and promotes
// the row to run as turn 2.
harness.inject_keys(b"\r").expect("empty Enter send-now");
content.release_agent_completions();
// The promoted row renders as a standard " " prompt block via the
// turn-start adoption (the arrow prefix distinguishes the committed block
// from the prefix-less queue row).
harness
.wait_for_text(
"\u{276F} please also check the logs",
Duration::from_secs(15),
)
.expect("promoted prompt scrollback chrome");
harness
.wait_for_text("TURNTWO", Duration::from_secs(40))
.expect("promoted turn reply");
// The send-now cancel is silent: no cancelled marker between the partial
// turn-1 output and the promoted prompt.
assert!(
!harness.contains_text("Turn cancelled by user"),
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
harness.screen_contents()
);
let users = all_user_message_blobs(&content);
let promoted = users
.iter()
.find(|u| u.contains("please also check the logs"))
.unwrap_or_else(|| panic!("queued follow-up never reached the wire: {users:#?}"));
assert!(
!promoted.contains(INTERJECTION_WIRE_PREFIX),
"send-now must not use the interjection preamble: {promoted}"
);
assert!(
promoted.contains("<user_query>"),
"send-now must arrive as a standard user_query prompt: {promoted}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,119 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// With two mid-turn queued rows, empty Enter sends the **top** (first) row
/// now — not the most recently typed one. Cancel-and-send: the running turn
/// is cancelled silently, alpha runs as its own next turn (no interjection
/// preamble), and bravo stays queued to promote afterwards.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn empty_enter_sends_top_not_last_of_two() {
let content = ContentController::start().await.expect("start content");
// Gate turn 1's terminal event so both queues + the empty Enter provably
// land while turn 1 is still the running turn.
content.hold_agent_completions();
content.set_turns([
slow_turn_text("TURNONE"),
"TURNTWO top-row send-now acknowledged.".to_owned(),
"TURNTHREE remaining queue promoted.".to_owned(),
]);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("TURNONE", Duration::from_secs(45))
.expect("turn 1 streaming");
harness
.inject_keys(b"queue-alpha-top\r")
.expect("queue alpha");
harness
.wait_for_text("queue-alpha-top", Duration::from_secs(20))
.expect("alpha visible");
harness
.inject_keys(b"queue-bravo-later\r")
.expect("queue bravo");
harness
.wait_for_text("queue-bravo-later", Duration::from_secs(20))
.expect("bravo visible");
harness
.inject_keys(b"\r")
.expect("empty Enter send-now top");
content.release_agent_completions();
// Alpha (the promoted TOP row) then bravo drain back-to-back. Each
// promoted " …" block and the intermediate TURNTWO reply is scrolled
// above the viewport by the next turn's start-adoption before a 100ms poll
// can observe it, so gating on those transient markers is inherently racy.
// Gate only on the FINAL reply (stable at the viewport head) and prove the
// top-row order + send-now silence via the recorded wire below, which is
// not subject to scrolling.
harness
.wait_for_text("TURNTHREE", Duration::from_secs(90))
.expect("all queued turns drained through to the final reply");
// The send-now cancel of turn 1 is silent.
assert!(
!harness.contains_text("Turn cancelled by user"),
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
harness.screen_contents()
);
let users = all_user_message_blobs(&content);
let alpha = users
.iter()
.find(|u| u.contains("queue-alpha-top"))
.unwrap_or_else(|| panic!("top row never on wire: {users:#?}"));
assert!(
!alpha.contains(INTERJECTION_WIRE_PREFIX),
"send-now must not use the interjection preamble: {alpha}"
);
assert!(
alpha.contains("<user_query>"),
"send-now must arrive as a standard user_query prompt: {alpha}"
);
// The final request's user sequence proves the order: prompt, then the
// TOP row (alpha), then bravo — never bravo before alpha.
let bodies = content.request_bodies();
let last = bodies.last().expect("final request recorded");
let finals: Vec<String> = last["messages"]
.as_array()
.expect("messages array")
.iter()
.filter(|m| {
m["role"] == "user"
&& m["content"]
.as_str()
.is_some_and(|c| c.contains("<user_query>"))
})
.map(|m| m["content"].as_str().unwrap_or_default().to_owned())
.collect();
assert_eq!(3, finals.len(), "expected 3 user messages: {finals:#?}");
assert!(finals[0].contains(PROMPT), "first: {finals:#?}");
assert!(
finals[1].contains("queue-alpha-top"),
"second must be the TOP row: {finals:#?}"
);
assert!(
finals[2].contains("queue-bravo-later"),
"third must be bravo: {finals:#?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,222 @@
//! PTY: a parked wait produces two static markers — the park pushes "Turn
//! completed in X. 1 command still running…" and the turn that follows ends
//! with its own marker below. A prompt typed mid-park is cancel-and-send:
//! the shell silently cancels the parked turn (no "Turn cancelled by user"
//! marker) and runs the message as its OWN next turn, whose completion pushes
//! the second marker; the park line is never edited, so the transcript holds
//! BOTH markers with the park text intact, in order.
//!
//! Wire journey, fully flag-file driven — no timing windows: the model
//! backgrounds a flag-gated command, then runs a flag-gated foreground hold
//! while the test extracts the runtime task id from the request bodies
//! (`<task-id>` envelope; a UUID minted by the terminal actor, so it cannot
//! be scripted statically) and enqueues the blocking
//! `get_command_or_subagent_output(timeout)` on the real id — the pager
//! parks. Typing mid-park cancels-and-sends and the fixed-text reply ends
//! the new turn with the second, final marker.
#[allow(unused_imports)]
use super::common::*;
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn endline_park_two_static_markers() {
let content = ContentController::start().await.expect("start content");
// Gates the background command the markers count (released at the end).
let park_flag = content.home().join("endline_park_flag");
// Gates the id-extraction hold: created once the wait script is enqueued.
let id_ready_flag = content.home().join("endline_id_ready_flag");
let gated_loop = |flag: &std::path::Path| {
format!("while [ ! -e {} ]; do /bin/sleep 0.2; done", flag.display())
};
// Tool call 1: a flag-gated background command — the work both markers
// snapshot ("1 command still running…").
let bg_args = json!({
"command": gated_loop(&park_flag),
"description": "flag-gated command",
"is_background": true
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_endline_bg",
"run_terminal_command",
&bg_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_endline_bg",
"run_terminal_command",
&bg_args,
)),
);
// Tool call 2: the flag-gated foreground hold — the turn idles here (no
// deadline) until the test has extracted the task id and enqueued the
// wait script.
let id_hold_args = json!({
"command": gated_loop(&id_ready_flag),
"description": "hold for id extraction"
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_endline_id_hold",
"run_terminal_command",
&id_hold_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_endline_id_hold",
"run_terminal_command",
&id_hold_args,
)),
);
// Fallback for the cancel-and-sent prompt's turn: plain text ends it.
content.set_response("ENDLINE_FINAL_ANSWER");
let binary = pager_binary().expect("resolve pager binary");
// --yolo skips the bash permission prompt; --trust skips the folder-trust gate.
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// The runtime task id rides in the tool result of the follow-up request
// (the same request the id-hold script answers), inside a
// <task-id>…</task-id> envelope.
let task_id = poll_for(Duration::from_secs(60), || {
content
.request_bodies()
.iter()
.find_map(|b| extract_task_id(&b.to_string()))
})
.unwrap_or_else(|| {
panic!(
"no <task-id> in any request body\n--- non-system messages ---\n{}\n--- screen ---\n{}",
dump_non_system_messages(&content.request_bodies()),
harness.screen_contents()
)
});
// Tool call 3: block on the REAL task — the interruptible wait the pager
// parks on (600s survives the wait cap; the send-now cancel aborts it).
let wait_args = json!({
"task_ids": [task_id],
"timeout_ms": 600_000
})
.to_string();
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
"call_endline_wait",
"get_command_or_subagent_output",
&wait_args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
"call_endline_wait",
"get_command_or_subagent_output",
&wait_args,
)),
);
// Everything downstream is scripted — let the id-extraction hold finish.
std::fs::write(&id_ready_flag, b"ready").expect("release id-extraction hold");
// Park: the first static marker reads as a completion with the count.
harness
.wait_for_text("1 command still running", Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"parked marker never appeared; screen:\n{}\n--- non-system messages ---\n{}",
harness.screen_contents(),
dump_non_system_messages(&content.request_bodies())
)
});
assert!(
harness.screen_contents().contains("Worked for"),
"the parked marker keeps the completion prefix; screen:\n{}",
harness.screen_contents()
);
// Type mid-park: Enter is cancel-and-send (the wait makes it a sendable
// parked turn) — the parked turn is cancelled silently and the message
// runs as its own next turn.
harness
.inject_keys(b"hurry up please")
.expect("type mid-park");
harness.update(Duration::from_millis(300));
harness.inject_keys(b"\r").expect("submit mid-park prompt");
harness
.wait_for_text("ENDLINE_FINAL_ANSWER", Duration::from_secs(90))
.unwrap_or_else(|_| {
panic!(
"cancel-and-sent turn never settled; screen:\n{}",
harness.screen_contents()
)
});
// The turn-start adoption scrolls the promoted " hurry up please" block
// to the viewport top, pushing the park marker above the screen. Scroll
// the transcript back to its head so both markers are inspectable.
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
harness.update(Duration::from_millis(300));
harness.inject_keys(b"g").expect("goto transcript top");
// Two static markers: the park line unchanged above the promoted prompt
// and the new turn's final marker (also counting the still-gated command)
// below it — with NO cancelled marker anywhere (silent send-now cancel).
let two_markers = wait_until(Duration::from_secs(90), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
// Positional: park marker ABOVE the promoted prompt ABOVE the final
// marker (screen text is row-major), both markers intact.
screen.matches("Worked for").count() == 2
&& screen.matches("1 command still running").count() == 2
&& !screen.contains("Turn cancelled by user")
&& matches!(
(
screen.find("Worked for"),
screen.find("hurry up please"),
screen.rfind("Worked for"),
),
(Some(park), Some(prompt), Some(fin)) if park < prompt && prompt < fin
)
});
assert!(
two_markers,
"expected park marker, promoted prompt, then the fresh final marker in order \
(and no cancelled marker); screen:\n{}",
harness.screen_contents()
);
write_cast_if_requested(&harness, "endline_park_two_static_markers.cast");
// Release the flag-gated command so nothing outlives the harness teardown.
std::fs::write(&park_flag, b"done").expect("release flag");
}
@@ -0,0 +1,226 @@
//! PTY: every wake turn ends with a real marker — a turn ends with three
//! flag-gated background commands running ("3 commands still running…"), and
//! each released flag lands a completion chip, the auto-wake response, then a
//! FRESH wake-end marker snapshotting the remaining counts ("2 …", "1 …"),
//! while every earlier line stays unchanged above (nothing mutates). The last
//! wake's marker is the plain form ("Worked for X." — zero left), and
//! no after-chip work-only status lines appear anywhere: the shell stamps
//! `will_wake` on each completion, so the wake markers carry the counts.
//!
//! Positional chain asserted at the end: marker(3) < chip < wake reply <
//! marker(2) < chip < reply < marker(1) < chip < reply < plain final marker.
#[allow(unused_imports)]
use super::common::*;
/// One flag-gated background command per released flag file.
#[cfg(unix)]
const TASKS: usize = 3;
/// Taller than [`DEFAULT_ROWS`]: the full chain (initial turn + three wake
/// turns) must stay on screen at once for the positional asserts.
#[cfg(unix)]
const ROWS: u16 = 70;
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn endline_wake_markers_close_each_wakeup() {
let content = ContentController::start().await.expect("start content");
let flags: Vec<std::path::PathBuf> = (0..TASKS)
.map(|i| content.home().join(format!("endline_status_flag_{i}")))
.collect();
// The turn backgrounds one flag-gated command per tool call…
for (i, flag) in flags.iter().enumerate() {
let args = json!({
"command": format!(
"while [ ! -e {} ]; do /bin/sleep 0.2; done",
flag.display()
),
"description": format!("flag-gated command {i}"),
"is_background": true
})
.to_string();
let call_id = format!("call_endline_status_{i}");
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_tool_call_events(
&call_id,
"run_terminal_command",
&args,
)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_tool_call_events_with_id(
&call_id,
"run_terminal_command",
&args,
)),
);
}
// …then a text response ends it with all three still running, and each
// auto-wake turn consumes one distinct scripted reply (FIFO per path; the
// stage gating below keeps the consumption order deterministic).
for text in [
"STATUS_TURN_SETTLED",
"WAKE_REPLY_ONE",
"WAKE_REPLY_TWO",
"WAKE_REPLY_THREE",
] {
content.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_message_events(text)),
);
content.enqueue_response(
"/v1/chat/completions",
ScriptedResponse::sse(chat_completions_message_events(text)),
);
}
content.set_response("STATUS_FALLBACK");
let binary = pager_binary().expect("resolve pager binary");
// --yolo skips the bash permission prompt; --trust skips the folder-trust gate.
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust"],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// The turn ends with all three commands running: the final marker
// carries the snapshot count.
harness
.wait_for_text("STATUS_TURN_SETTLED", Duration::from_secs(60))
.unwrap_or_else(|_| {
panic!(
"turn never settled; screen:\n{}\n--- non-system messages ---\n{}",
harness.screen_contents(),
dump_non_system_messages(&content.request_bodies())
)
});
harness
.wait_for_text("3 commands still running", Duration::from_secs(30))
.unwrap_or_else(|_| {
panic!(
"marker never showed the snapshot count; screen:\n{}",
harness.screen_contents()
)
});
assert!(
harness.screen_contents().contains("Worked for"),
"the marker keeps the completion prefix; screen:\n{}",
harness.screen_contents()
);
// Release flag 0: chip → wake reply → a fresh "2 commands" wake-end
// marker below, with the original "3 commands" marker intact above
// (screen text is row-major, so find offsets order the lines).
std::fs::write(&flags[0], b"done").expect("release flag 0");
let wake_one = wait_until(Duration::from_secs(45), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
matches!(
(
screen.find("3 commands still running"),
screen.find("WAKE_REPLY_ONE"),
screen.find("2 commands still running"),
),
(Some(three), Some(reply), Some(two)) if three < reply && reply < two
)
});
assert!(
wake_one,
"expected chip → wake reply → fresh '2 commands' marker below the intact '3 commands' one; screen:\n{}",
harness.screen_contents()
);
// Release flag 1: the "1 command" wake marker joins below its reply.
std::fs::write(&flags[1], b"done").expect("release flag 1");
let wake_two = wait_until(Duration::from_secs(45), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
matches!(
(
screen.find("2 commands still running"),
screen.find("WAKE_REPLY_TWO"),
screen.find("1 command still running"),
),
(Some(two), Some(reply), Some(one)) if two < reply && reply < one
)
});
assert!(
wake_two,
"expected the second wake chain below the earlier lines; screen:\n{}",
harness.screen_contents()
);
// Release flag 2: zero left — the last wake ends with the PLAIN marker
// (fourth "Worked for", no new "still running" suffix).
std::fs::write(&flags[2], b"done").expect("release flag 2");
let wake_three = wait_until(Duration::from_secs(45), || {
harness.update(Duration::from_millis(100));
let screen = harness.screen_contents();
screen.contains("WAKE_REPLY_THREE") && screen.matches("Worked for").count() == 4
});
assert!(
wake_three,
"the final plain wake marker never landed; screen:\n{}",
harness.screen_contents()
);
// Full chain, positional: marker(3) < chip < reply < marker(2) < chip <
// reply < marker(1) < chip < reply < plain final marker — and exactly
// three "still running" lines total (the markers'), i.e. the stamped
// `will_wake` suppressed every after-chip work-only status line.
let screen = harness.screen_contents();
let chips: Vec<usize> = screen
.match_indices("Task completed")
.map(|(i, _)| i)
.collect();
assert_eq!(
chips.len(),
TASKS,
"one completion chip per task; screen:\n{screen}"
);
let m3 = screen.find("3 commands still running").expect("marker 3");
let w1 = screen.find("WAKE_REPLY_ONE").expect("wake reply 1");
let m2 = screen.find("2 commands still running").expect("marker 2");
let w2 = screen.find("WAKE_REPLY_TWO").expect("wake reply 2");
let m1 = screen.find("1 command still running").expect("marker 1");
let w3 = screen.find("WAKE_REPLY_THREE").expect("wake reply 3");
let final_marker = screen
.match_indices("Worked for")
.map(|(i, _)| i)
.last()
.expect("final marker");
assert!(
m3 < chips[0]
&& chips[0] < w1
&& w1 < m2
&& m2 < chips[1]
&& chips[1] < w2
&& w2 < m1
&& m1 < chips[2]
&& chips[2] < w3
&& w3 < final_marker,
"chain out of order; screen:\n{screen}"
);
assert_eq!(
screen.matches("still running").count(),
3,
"wake-bound completions must not add work-only status lines; screen:\n{screen}"
);
write_cast_if_requested(&harness, "endline_wake_markers_close_each_wakeup.cast");
}
@@ -0,0 +1,79 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Esc double-press policy (idle, non-empty prompt): the **first Esc shows
/// "press again to clear"** and the **second Esc clears the prompt**, recording
/// the cleared text into prompt history (recallable via the Up-arrow history
/// panel). Proves `try_handle_esc_policy`'s idle clear arm +
/// `dispatch_clear_prompt` end-to-end on the real binary.
///
/// Uses [`spawn_esc_double_press_pager`] so a slow inter-press round-trip
/// can't expire the arm.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn esc_esc_clears_idle_prompt_and_records_history() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} done."));
let mut harness = spawn_esc_double_press_pager(&content);
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Establish a real idle session first (so Esc runs the agent policy, not
// welcome-screen handling), then type a fresh draft to clear.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("first turn rendered");
harness
.wait_for_turn_idle(Duration::from_secs(15))
.expect("turn idle");
let draft = "ZZCLEARDRAFT";
harness.inject_keys(draft.as_bytes()).expect("type draft");
harness
.wait_for_text(draft, Duration::from_secs(10))
.expect("draft renders in the composer");
// Wait for the confirm hint between the presses: it proves the arm landed,
// and a single `ESC ESC` byte pair collapses to one `Esc` in crossterm.
harness.inject_keys(keys::ESC).expect("first esc");
harness
.wait_for_text("press again to clear", Duration::from_secs(15))
.expect("first idle Esc must show the clear confirm hint");
// Second Esc fires the clear.
harness.inject_keys(keys::ESC).expect("second esc");
wait_for_labels_absent(&mut harness, &[draft], Duration::from_secs(5));
assert!(
!harness.contains_text(draft),
"second Esc must clear the draft\nscreen:\n{}",
harness.screen_contents()
);
// The confirm hint must be gone once the pending fired.
assert!(
!harness.contains_text("press again to clear"),
"clear-confirm hint must clear after the second Esc fires\nscreen:\n{}",
harness.screen_contents()
);
// The cleared text was recorded into prompt history: Up on the now-empty
// prompt opens the history panel, whose list surfaces the cleared draft.
harness.inject_keys(keys::UP).expect("open history panel");
harness
.wait_for_text(draft, Duration::from_secs(10))
.expect("cleared draft recorded in prompt history");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,114 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Esc double-press policy (idle, empty prompt, conversation has a user turn):
/// **Esc Esc opens the rewind picker**, and the **first Esc is silent** — no
/// "press again" hint, no toast (unlike the idle clear arm). Proves the
/// rewind arm of `try_handle_esc_policy` (gated on `scrollback.turn_count() > 0`)
/// with a `label: None` silent pending, end-to-end.
///
/// Phase 2 repeats the gesture with the SCROLLBACK pane focused (Tab, footer
/// shows "Space:prompt"): the rewind arm is either-pane, so double-Esc must
/// open the picker from there too — through the scrollback key routing, where
/// the prompt-only regression would hide.
///
/// Uses [`spawn_esc_double_press_pager`] so a slow inter-press round-trip
/// can't expire the arm.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn esc_esc_opens_rewind_picker_silent_first_press() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} done."));
let mut harness = spawn_esc_double_press_pager(&content);
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// One real turn → one user-prompt block → turn_count() > 0, so the rewind
// arm is eligible. The prompt is empty and idle afterwards.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn rendered (has a user turn)");
harness
.wait_for_turn_idle(Duration::from_secs(15))
.expect("turn idle");
// First Esc: arm the rewind picker SILENTLY — no clear/rewind confirm hint.
// Settle between the presses: a single `ESC ESC` byte pair collapses to
// one `Esc` in crossterm.
harness.inject_keys(keys::ESC).expect("first esc");
harness.update(Duration::from_millis(200));
let after_first = harness.screen_contents();
assert!(
!after_first.contains("press again"),
"first rewind Esc must be silent (no confirm hint)\nscreen:\n{after_first}"
);
assert!(
!after_first.contains("Rewind to which turn?"),
"rewind picker must not open until the second Esc\nscreen:\n{after_first}"
);
// Second Esc opens the rewind picker (same as /rewind).
harness.inject_keys(keys::ESC).expect("second esc");
harness
.wait_for_text("Rewind to which turn?", Duration::from_secs(15))
.expect("rewind picker opens on the second Esc");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
// Dismiss the picker (Esc) before the scrollback phase.
harness.inject_keys(keys::ESC).expect("dismiss rewind");
harness.update(Duration::from_millis(200));
// Phase 2: the same double-Esc must arm and open from the SCROLLBACK pane.
// Single Tab leaves the prompt; the "Space:prompt" footer proves the
// scrollback owns keys (Tab toggles, so poll the render instead of
// re-pressing).
harness.inject_keys(b"\t").expect("tab to scrollback");
harness
.wait_for_text("Space:prompt", Duration::from_secs(10))
.expect("scrollback must own keys before the rewind Esc");
harness
.inject_keys(keys::ESC)
.expect("first esc (scrollback)");
harness.update(Duration::from_millis(200));
let after_first = harness.screen_contents();
assert!(
!after_first.contains("press again"),
"first scrollback rewind Esc must be silent (no confirm hint)\nscreen:\n{after_first}"
);
assert!(
!after_first.contains("Rewind to which turn?"),
"rewind picker must not open until the second scrollback Esc\nscreen:\n{after_first}"
);
harness
.inject_keys(keys::ESC)
.expect("second esc (scrollback)");
harness
.wait_for_text("Rewind to which turn?", Duration::from_secs(15))
.expect("rewind picker opens on the second Esc from scrollback");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
// Dismiss the picker (Esc) so the child can exit cleanly.
harness.inject_keys(keys::ESC).expect("dismiss rewind");
harness.update(Duration::from_millis(200));
harness.quit().expect("clean quit");
}
@@ -0,0 +1,76 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Esc policy (idle, empty prompt, NO user turns): **Esc is a swallowed
/// no-op** — it must NOT focus the scrollback (the pre-port behavior) and must
/// not panic or arm anything. Guards the `try_handle_esc_policy` final
/// `Some(InputOutcome::Changed)` swallow branch on a fresh session.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn esc_idle_empty_no_messages_is_swallowed_noop() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} unused."));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Promote the welcome screen into a real (idle) agent session by typing,
// then wipe the draft so the prompt is empty with NO submitted user turn.
harness
.inject_keys(b"NOMSG")
.expect("type to promote session");
harness
.wait_for_text("NOMSG", Duration::from_secs(10))
.expect("draft renders in the promoted agent prompt");
harness.inject_keys(b"\x15").expect("Ctrl+U clear to empty");
wait_for_labels_absent(&mut harness, &["NOMSG"], Duration::from_secs(5));
// Baseline: the prompt owns keys (scrollback's "Space:prompt" hint absent).
assert!(
!harness.contains_text("Space:prompt"),
"precondition: prompt should be focused before Esc\nscreen:\n{}",
harness.screen_contents()
);
// Esc TWICE on idle + empty + no-messages: a true swallow stays a no-op on
// the second press. Pressing once and typing couldn't distinguish a swallow
// from a silently-armed rewind (the next keystroke clears any pending); a
// wrongly-armed rewind would instead OPEN the picker on the second Esc.
harness.inject_keys(keys::ESC).expect("press esc (1)");
harness.update(Duration::from_millis(250));
harness.inject_keys(keys::ESC).expect("press esc (2)");
harness.update(Duration::from_millis(400));
let screen = harness.screen_contents();
assert!(
!screen.contains("Space:prompt"),
"idle empty Esc must be swallowed, NOT focus scrollback\nscreen:\n{screen}"
);
assert!(
!screen.contains("press again"),
"idle empty Esc must not arm any double-press\nscreen:\n{screen}"
);
assert!(
!screen.contains("Rewind to which turn?"),
"Esc Esc with no messages must NOT open the rewind picker\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{screen}"
);
// The prompt is still live: typing lands in the composer.
harness.inject_keys(b"STILLHERE").expect("type after esc");
harness
.wait_for_text("STILLHERE", Duration::from_secs(10))
.expect("prompt still accepts input after the no-op Esc");
harness.quit().expect("clean quit");
}
@@ -0,0 +1,86 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Mid-turn Esc from the PROMPT pane is a swallowed no-op: it must NOT cancel
/// the turn and must NOT arm idle clear/rewind, even with a non-empty draft.
/// Draft text stays in the composer; cancel remains on Ctrl+C / palette / etc.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn esc_mid_turn_from_prompt_is_swallowed_preserves_draft() {
let content = ContentController::start().await.expect("start content");
// Long paced stream so the turn is still visibly running when Esc lands.
let long_response = format!(
"{MOCK_RESPONSE_SENTINEL} {}",
"streaming filler words for the cancellation window. ".repeat(120)
);
content.set_response(long_response);
content.set_chunk_delay(Some(Duration::from_millis(50)));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("stream started");
// Type a draft into the prompt WHILE the turn streams (prompt stays focused
// after submit). A distinctive single token avoids any wrapping ambiguity.
let draft = "DRAFTKEEPME";
harness.inject_keys(draft.as_bytes()).expect("type draft");
harness
.wait_for_text(draft, Duration::from_secs(10))
.expect("draft renders in the composer");
// 1× Esc mid-turn must swallow (not cancel, not arm clear).
harness.inject_keys(keys::ESC).expect("press esc");
harness.update(Duration::from_millis(1000));
let screen = harness.screen_contents();
assert!(
!screen.contains("Turn cancelled by user"),
"mid-turn Esc must NOT cancel the turn\nscreen:\n{screen}"
);
assert!(
screen.contains(draft),
"mid-turn Esc must preserve the draft\nscreen:\n{screen}"
);
assert!(
!screen.contains("press again to clear"),
"running-turn Esc must not arm the idle clear\nscreen:\n{screen}"
);
// 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 from this pane. With a
// non-empty draft the first Ctrl+C clears the draft and keeps the turn;
// the second (now on an empty prompt) cancels it.
harness.inject_keys(keys::CTRL_C).expect("first ctrl+c");
wait_for_labels_absent(&mut harness, &[draft], Duration::from_secs(10));
assert!(
!harness.contains_text(draft),
"first Ctrl+C must clear the draft, not cancel\nscreen:\n{}",
harness.screen_contents()
);
harness.inject_keys(keys::CTRL_C).expect("second ctrl+c");
harness
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
.expect("Ctrl+C on the empty prompt must cancel the still-running turn");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,66 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Mid-turn Esc from the SCROLLBACK pane is a swallowed no-op: it must NOT
/// cancel the running turn. Cancel remains on Ctrl+C / palette / etc.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn esc_mid_turn_from_scrollback_is_swallowed() {
let content = ContentController::start().await.expect("start content");
let long_response = format!(
"{MOCK_RESPONSE_SENTINEL} {}",
"streaming filler words for the cancellation window. ".repeat(120)
);
content.set_response(long_response);
content.set_chunk_delay(Some(Duration::from_millis(50)));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("stream started");
// Leave the prompt with a SINGLE Tab, then wait for the footer to prove the
// scrollback owns keys. Tab TOGGLES focus, so re-pressing it could bounce
// focus back to the prompt — press once and poll the render instead.
harness.inject_keys(b"\t").expect("tab to scrollback");
harness
.wait_for_text("Space:prompt", Duration::from_secs(10))
.expect("scrollback must own keys before the mid-turn Esc");
// 1× Esc from scrollback must swallow (not cancel).
harness.inject_keys(keys::ESC).expect("press esc");
harness.update(Duration::from_millis(1000));
let screen = harness.screen_contents();
assert!(
!screen.contains("Turn cancelled by user"),
"mid-turn Esc from scrollback must NOT cancel\nscreen:\n{screen}"
);
// 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 from the scrollback pane.
harness.inject_keys(keys::CTRL_C).expect("press ctrl+c");
harness
.wait_for_text("Turn cancelled by user", Duration::from_secs(15))
.expect("Ctrl+C from scrollback must cancel the still-running turn");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,194 @@
// Per-test-case module for the `pty_e2e` integration test crate.
//
// Plugins-tab footer shows contextual Space enable/disable (not "toggle")
// and freeform `a install` (not `a add`). Run with `--nocapture` to dump
// screen contents when debugging failures.
#[allow(unused_imports)]
use super::common::*;
const ENABLED_PLUGIN: &str = "copy-enabled";
const DISABLED_PLUGIN: &str = "copy-disabled";
fn seed_plugins_for_copy_hints(content: &ContentController) {
let kigi_home = content.home().join(".kigi");
let plugins_dir = kigi_home.join("plugins");
for name in [ENABLED_PLUGIN, DISABLED_PLUGIN] {
let dir = plugins_dir.join(name);
std::fs::create_dir_all(&dir).expect("create plugin dir");
std::fs::write(
dir.join("plugin.json"),
format!(
r#"{{"name":"{name}","version":"0.0.1","description":"extensions modal copy fixture"}}"#
),
)
.expect("write plugin.json");
}
std::fs::create_dir_all(&kigi_home).expect("create .kigi");
// User plugins default to disabled unless listed under enabled.
let config = format!(
"[plugins]\nenabled = [\"{ENABLED_PLUGIN}\"]\ndisabled = [\"{DISABLED_PLUGIN}\"]\n"
);
std::fs::write(kigi_home.join("config.toml"), config).expect("write config.toml");
}
fn dump_screen(label: &str, harness: &PtyHarness) {
let screen = harness.screen_contents();
eprintln!(
"\n========== PTY CAPTURE: {label} ==========\n{screen}\n========== END: {label} ==========\n"
);
}
/// True when the screen shows the Space footer verb we asked for.
///
/// Important: `space enable` is a substring of the fallback
/// `space enable/disable`, so a naive `contains("space enable")` would
/// pass while the contextual hint is still the combined form.
fn screen_has_space_verb(screen: &str, verb: &str) -> bool {
match verb {
"enable/disable" => screen.contains("space enable/disable"),
"enable" => screen.contains("space enable") && !screen.contains("space enable/disable"),
"disable" => screen.contains("space disable"),
_ => screen.contains(&format!("space {verb}")),
}
}
fn wait_for_space_verb(harness: &mut PtyHarness, verb: &str) {
let needle = format!("space {verb}");
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if screen_has_space_verb(&harness.screen_contents(), verb) {
return;
}
harness.update(Duration::from_millis(150));
}
dump_screen(&format!("missing {needle}"), harness);
panic!("expected footer hint `{needle}` (not a fallback false-positive)");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn extensions_modal_copy_hints_pty() {
let content = ContentController::start().await.expect("start content");
seed_plugins_for_copy_hints(&content);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness.inject_keys(b"/plugins\r").expect("submit /plugins");
harness
.wait_for_text("Plugins", Duration::from_secs(15))
.expect("extensions modal Plugins tab chrome");
// Both fixture plugins share one source group, seeded collapsed on load.
// Expand it (selection starts on the header row) so the rows are visible.
harness
.wait_for_text("(2 plugins)", Duration::from_secs(20))
.expect("plugin source group header");
harness.inject_keys(b"l").expect("expand plugin group");
harness
.wait_for_text(ENABLED_PLUGIN, Duration::from_secs(20))
.expect("enabled plugin row");
harness
.wait_for_text(DISABLED_PLUGIN, Duration::from_secs(10))
.expect("disabled plugin row");
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if harness.contains_text("a install") {
break;
}
harness.update(Duration::from_millis(150));
}
assert!(
harness.contains_text("a install"),
"Plugins tab must show `a install` (not `a add`)\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("a add"),
"Plugins tab must not show legacy `a add`\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("space toggle"),
"Plugins tab must not show legacy `space toggle`\nscreen:\n{}",
harness.screen_contents()
);
harness.inject_keys(b"/").expect("start search");
harness.update(Duration::from_millis(200));
for ch in DISABLED_PLUGIN.bytes() {
harness
.inject_keys(std::slice::from_ref(&ch))
.expect("type filter char");
harness.update(Duration::from_millis(30));
}
harness.update(Duration::from_millis(300));
harness.inject_keys(b"\r").expect("commit search");
harness.update(Duration::from_millis(400));
// Search results keep the group header as row 0; step onto the plugin row.
harness.inject_keys(b"j").expect("move to plugin row");
harness.update(Duration::from_millis(200));
wait_for_space_verb(&mut harness, "enable");
dump_screen("plugins-tab disabled selected → space enable", &harness);
assert!(
harness.contains_text("a install"),
"install hint must remain with space enable\nscreen:\n{}",
harness.screen_contents()
);
harness.inject_keys(b"/").expect("start search again");
harness.update(Duration::from_millis(200));
harness.inject_keys(b"\x15").expect("Ctrl+U clear query");
harness.update(Duration::from_millis(100));
for ch in ENABLED_PLUGIN.bytes() {
harness
.inject_keys(std::slice::from_ref(&ch))
.expect("type filter char");
harness.update(Duration::from_millis(30));
}
harness.update(Duration::from_millis(300));
harness.inject_keys(b"\r").expect("commit search enabled");
harness.update(Duration::from_millis(400));
harness.inject_keys(b"j").expect("move to plugin row");
harness.update(Duration::from_millis(200));
wait_for_space_verb(&mut harness, "disable");
dump_screen("plugins-tab enabled selected → space disable", &harness);
assert!(
harness.contains_text("a install"),
"install hint must remain with space disable\nscreen:\n{}",
harness.screen_contents()
);
harness.inject_keys(b"/").expect("start search empty");
harness.update(Duration::from_millis(200));
harness.inject_keys(b"\x15").expect("Ctrl+U clear");
for ch in b"zzz-no-such-plugin" {
harness
.inject_keys(std::slice::from_ref(ch))
.expect("type no-match filter");
harness.update(Duration::from_millis(30));
}
harness.update(Duration::from_millis(300));
harness.inject_keys(b"\r").expect("commit empty search");
harness.update(Duration::from_millis(400));
wait_for_space_verb(&mut harness, "enable/disable");
dump_screen(
"plugins-tab no matching selection → space enable/disable",
&harness,
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,93 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Tutor report: plain-text file paths with spaces only partially linkify —
/// OSC 8 / click / underline stopped at the first space in a synthetic
/// macOS app-bundle path (`Demo` vs `Demo App.app`). Prove the full path is
/// on screen AND the PTY stream carries an OSC 8 hyperlink whose `file://`
/// URL encodes the space (`%20`), so the click target spans the whole
/// filename — not a truncated prefix.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn file_path_with_space_emits_full_osc8_hyperlink() {
// Synthetic macOS app-bundle path with a space in the final segment.
const PATH_PREFIX: &str = "/Users/alice/src/app/release/mac-arm64/Demo";
const FULL_PATH: &str = "/Users/alice/src/app/release/mac-arm64/Demo App.app";
// file:// URL percent-encodes the space; this is what OSC 8 must carry.
const FILE_URL_MARKER: &str = "Demo%20App.app";
let content = ContentController::start().await.expect("start content");
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} open \"{FULL_PATH}\" (or release/mac/ on Intel)."
));
let binary = pager_binary().expect("resolve pager binary");
// OSC 8 emission is gated on a Native-capable brand (`hyperlink_route`).
// The default harness PTY only sets `TERM=xterm-256color`, so brand is
// `Unknown` and the pager deliberately skips OSC 8. Pin WezTerm so the
// byte-level proof below is meaningful (same override as `pty_xtversion`).
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "WezTerm".into()));
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
// Wide enough that the path does not wrap mid-segment (wrap would still
// linkify, but we want a single-row assertion on the screen text).
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, 160, &[], &env_refs)
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
// Wait until the distinctive path segment is painted (streaming may lag).
harness
.wait_for_text(PATH_PREFIX, Duration::from_secs(30))
.expect("path prefix on screen");
// Give the frame a beat to flush OSC 8 for the completed line.
harness.update(Duration::from_millis(400));
let screen = harness.screen_contents();
assert!(
screen.contains(FULL_PATH) || (screen.contains(PATH_PREFIX) && screen.contains("App.app")),
"full path (incl. space + `App.app`) must render on screen;\n\
missing either the prefix or `App.app` means the link still truncates.\n\
screen excerpt:\n{}",
screen.chars().take(2000).collect::<String>()
);
assert!(
screen.contains("App.app"),
"filename suffix after the space must be visible"
);
// Strongest proof: OSC 8 in the raw PTY stream targets the *full* path.
// Pre-fix the scanner stopped at the space, so the hyperlink URL would
// end at `…/Demo` with no `%20App.app`.
let raw = String::from_utf8_lossy(harness.raw_output());
assert!(
raw.contains("\x1b]8;"),
"expected OSC 8 hyperlink sequences in PTY output (path should be clickable)"
);
assert!(
raw.contains(FILE_URL_MARKER),
"OSC 8 file:// URL must include the space-encoded suffix `{FILE_URL_MARKER}` so the \
click/underline region covers `Demo App.app`, not just `Demo`.\n\
(truncated link would omit this marker.)\n\
OSC 8 snippets: {}",
osc8_snippets(&raw)
);
// Guard against a partial link *and* a full one: the truncated form must
// not be the only match. A naive prefix link would use `…/Demo` with
// no following `%20`.
let has_truncated_only =
raw.contains("mac-arm64/Demo\x07") || raw.contains("mac-arm64/Demo\x1b\\");
assert!(
!has_truncated_only || raw.contains(FILE_URL_MARKER),
"must not emit a truncated OSC 8 ending at `Demo` without the space suffix"
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,46 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Folder-trust Case 2 — cwd IS `$HOME` (a git repo) => no prompt, no re-prompt
/// loop. `$HOME` (and its default `~/.kigi`) can never be recorded by the trust
/// store, so `decide` resolves Trusted rather than prompting on a key that could
/// never persist. The pager boots straight to the normal welcome; without the
/// fix the trust question would render (and re-appear every session).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn folder_trust_cwd_is_home_git_repo_no_prompt() {
let content = ContentController::start().await.expect("start content");
// $HOME is a git repo with a home-level repo-local marker, and cwd IS $HOME —
// so there is genuinely "something to gate", yet the key is unrecordable.
git2::Repository::init(content.home()).expect("git init $HOME");
std::fs::write(content.home().join(".mcp.json"), "{}").expect("write $HOME/.mcp.json");
let env = trust_env(&content, true);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let cwd = content.home().to_str().expect("utf8 home path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--cwd", cwd],
&env_refs,
)
.expect("spawn pager");
// Normal welcome boots; the trust question never appears (unrecordable key =>
// Trusted), so the session can proceed and never re-prompts.
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("normal welcome renders");
assert!(
!harness.contains_text(TRUST_QUESTION_SENTINEL),
"cwd == $HOME must not render the trust question (unrecordable key => Trusted)\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,46 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 14. **Folder-trust decline quits the pager (no grant).**
/// Same setup; pressing `n` exits the pager (the process ends) and writes NO
/// grant — the product decision is decline => quit, not proceed-gated.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn folder_trust_decline_quits_without_grant() {
let content = ContentController::start().await.expect("start content");
let repo = git_repo_with_mcp_json();
let env = trust_env(&content, true);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let cwd = repo.path().to_str().expect("utf8 repo path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--cwd", cwd],
&env_refs,
)
.expect("spawn pager");
harness
.wait_for_text(TRUST_QUESTION_SENTINEL, WELCOME_TIMEOUT)
.expect("trust question renders");
// Decline => the pager quits (no session, no grant).
harness.inject_keys(b"n").expect("inject n");
let deadline = Instant::now() + Duration::from_secs(10);
while harness.is_running() && Instant::now() < deadline {
harness.update(Duration::from_millis(100));
}
assert!(
!harness.is_running(),
"declining the trust question must quit the pager\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!folder_is_trusted(&content, repo.path()),
"declining must NOT persist a grant",
);
}
@@ -0,0 +1,38 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 15. **Feature off => no trust question.**
/// With `KIGI_FOLDER_TRUST=0` (explicit opt-out) the feature is off, so the repo
/// boots straight to the welcome (the default is now on).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn folder_trust_feature_off_shows_no_question() {
let content = ContentController::start().await.expect("start content");
let repo = git_repo_with_mcp_json();
let env = trust_env(&content, false);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let cwd = repo.path().to_str().expect("utf8 repo path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--cwd", cwd],
&env_refs,
)
.expect("spawn pager");
// Normal welcome boots; the question never appears.
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("normal welcome renders");
assert!(
!harness.contains_text(TRUST_QUESTION_SENTINEL),
"feature off must not render the trust question\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,79 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
use kigi_workspace::trust::{TRUST_FILE_NAME, TrustStore};
/// Folder-trust home-is-a-git-repo (dotfiles-in-home), Case 1 — the reported bug.
/// `$HOME` is itself a git repo; the session is launched in a SUBDIR
/// (`<home>/proj`) that has its own repo-local `.mcp.json`. The trust question
/// must render for — and the accepted grant must persist keyed on — the SUBDIR,
/// NEVER on `$HOME` (the bug resolved the prompt/key up to `$HOME` because the
/// git up-walk landed on the home repo root).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn folder_trust_home_git_repo_subdir_keys_on_subdir() {
let content = ContentController::start().await.expect("start content");
// $HOME is a dotfiles-style git repo; the launch dir is a subdir carrying its
// own repo-local code-exec config (so the SUBDIR has something to gate).
git2::Repository::init(content.home()).expect("git init $HOME");
let proj = content.home().join("proj");
std::fs::create_dir_all(&proj).expect("create proj subdir");
std::fs::write(proj.join(".mcp.json"), "{}").expect("write proj/.mcp.json");
let env = trust_env(&content, true);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let cwd = proj.to_str().expect("utf8 proj path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--cwd", cwd],
&env_refs,
)
.expect("spawn pager");
// Check the trust store DIRECTLY, not via `folder_is_trusted` (which
// re-derives `workspace_key` in the TEST process, whose `$HOME` is NOT
// `content.home()`, so its home guard wouldn't fire and it would resolve the
// wrong key). `TrustStore::is_trusted` canonicalizes + ancestor-prefix matches
// internally, so passing the raw path is HOME-independent.
let store_path = content.home().join(".kigi").join(TRUST_FILE_NAME);
// The question renders (keyed on the subdir), and the store is empty first.
harness
.wait_for_text(TRUST_QUESTION_SENTINEL, WELCOME_TIMEOUT)
.expect("trust question renders for the subdir");
assert!(
!TrustStore::load_from(store_path.clone()).is_trusted(&proj),
"store must be empty before the user answers",
);
// Accept => the grant persists, trusting the SUBDIR. The child writes the
// store async after `y`, so reload it each poll iteration.
harness.inject_keys(b"y").expect("inject y");
let deadline = Instant::now() + Duration::from_secs(10);
let mut trusted = false;
while Instant::now() < deadline {
if TrustStore::load_from(store_path.clone()).is_trusted(&proj) {
trusted = true;
break;
}
harness.update(Duration::from_millis(100));
}
assert!(
trusted,
"accepting must persist a grant that trusts the subdir\nscreen:\n{}",
harness.screen_contents()
);
// Core regression: the grant must NOT trust $HOME (the reported bug keyed on $HOME).
assert!(
!TrustStore::load_from(store_path).is_trusted(content.home()),
"trust must key on the subdir, never on $HOME",
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,77 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 13. **Folder-trust question renders + accept persists the grant.**
/// Feature on + an untrusted git repo with `.mcp.json` + empty store => the
/// trust question renders BEFORE any session. Pressing `y` writes the grant to
/// `trusted_folders.toml` and lets the session proceed (prompt streams back).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn folder_trust_question_renders_and_accept_persists_grant() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} trusted and running."));
let repo = git_repo_with_mcp_json();
let env = trust_env(&content, true);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let cwd = repo.path().to_str().expect("utf8 repo path");
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--cwd", cwd],
&env_refs,
)
.expect("spawn pager");
// The question must render before any session exists.
harness
.wait_for_text(TRUST_QUESTION_SENTINEL, WELCOME_TIMEOUT)
.expect("trust question renders");
assert!(
!folder_is_trusted(&content, repo.path()),
"store must be empty before the user answers",
);
// BUG A regression: the global `Ctrl+N` shortcut bypasses the welcome
// interceptor (it confirms a pending action consumed above the interceptor),
// but the dispatch chokepoint must still refuse to create a session while
// trust is Pending. Two presses (NewSession requires confirmation) must
// leave us on the trust question, NOT in a session.
harness.inject_keys(b"\x0e").expect("inject Ctrl+N"); // arm
harness.inject_keys(b"\x0e").expect("inject Ctrl+N"); // confirm
harness.update(Duration::from_millis(400));
assert!(
harness.contains_text(TRUST_QUESTION_SENTINEL),
"Ctrl+N must not start a session while trust is Pending\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!folder_is_trusted(&content, repo.path()),
"Ctrl+N must not write a trust grant",
);
// Accept => grant persists.
harness.inject_keys(b"y").expect("inject y");
let deadline = Instant::now() + Duration::from_secs(10);
while !folder_is_trusted(&content, repo.path()) && Instant::now() < deadline {
harness.update(Duration::from_millis(100));
}
assert!(
folder_is_trusted(&content, repo.path()),
"accepting must persist the trust grant\nscreen:\n{}",
harness.screen_contents()
);
// Session proceeds: typing a prompt now starts a session and streams.
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("session proceeds after trusting");
harness.quit().expect("clean quit");
}
@@ -0,0 +1,99 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
#[allow(unused_imports)]
use super::scroll::*;
// ── A5: env-forced scroll settings reach the live scroll config ───────────
//
// `KIGI_SCROLL_MODE=wheel` + `KIGI_SCROLL_LINES=1` on a `TERM_PROGRAM=zed`
// harness (Zed profile: ept=1, wheel_lines=3) must price a 3-event burst at
// exactly 3 rows — one row per event:
//
// desired = 3 events x (1 line / 1 ept) x speed 1.0 = 3
//
// Each env var is observably load-bearing:
// - Without KIGI_SCROLL_LINES=1, Zed's wheel profile prices the burst at
// 3 lines/event → 9 rows.
// - Without KIGI_SCROLL_MODE=wheel, Auto finalizes a 3-event ZERO-interval
// burst as Trackpad on ept=1 (event_count > 2), repricing it at the
// normalized trackpad divisor (~1 row) — and mid-burst timing jitter picks
// between the two. Forcing wheel removes that classification variance
// entirely, which is what makes an EXACT row assertion CI-safe.
//
// Remaining determinism notes: back-to-back PTY writes only compress arrival
// gaps (no mid-burst >80ms split; see the driver contract in `scroll.rs`),
// the per-flush cap floor (6) exceeds the 3-line total, the wheel path has
// no acceleration, and the harness's hermetic KIGI_SHARE_DIR pins scroll_speed at
// its default (50 → 1.0x).
/// 120 one-row markers ≫ the 50-row PTY: early markers sit off-screen-top.
const MARKER_COUNT: usize = 120;
/// One wheel notch worth of reports on an ept=1 brand: 3 distinct events.
const BURST_EVENTS: usize = 3;
/// Exactly 1 row per event under the forced env (see header math).
const EXPECTED_ROWS: usize = 3;
/// **Env-forced wheel pricing e2e.** The `KIGI_SCROLL_MODE` /
/// `KIGI_SCROLL_LINES` overrides must reach the live config: a 3-event burst
/// scrolls the viewport up by exactly 3 rows.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn forced_wheel_mode_env_scrolls_exact_rows() {
let (mut harness, _content, top_before) = spawn_bottom_pinned_marker_scrollback_with_env(
MARKER_COUNT,
&[
("TERM_PROGRAM", "zed"),
("KIGI_SCROLL_MODE", "wheel"),
("KIGI_SCROLL_LINES", "1"),
],
)
.await;
send_wheel_burst(
&mut harness,
SGR_SCROLL_UP,
BURST_EVENTS,
WHEEL_ROW,
WHEEL_COL,
std::time::Duration::ZERO,
);
// Outlasts the 80ms stream gap + finalize cadence with CI slack.
harness.update(std::time::Duration::from_millis(600));
assert!(
harness.is_running() && !harness.contains_text("panicked"),
"pager broke during the forced-wheel burst\nscreen:\n{}",
harness.screen_contents()
);
let top_after = topmost_visible_marker(&harness).unwrap_or_else(|| {
panic!(
"no marker visible after the burst\nscreen:\n{}",
harness.screen_contents()
)
});
// Direction guard first: a wrong-direction regression must fail with the
// suite's screen-dump diagnostics, not a bare usize subtract overflow.
assert!(
top_after < top_before,
"wheel-up burst did not scroll the viewport up: topmost visible marker \
{} → {} (expected a decrease)\nscreen:\n{}",
marker_line(top_before),
marker_line(top_after),
harness.screen_contents()
);
assert_eq!(
top_before - top_after,
EXPECTED_ROWS,
"forced wheel + 1 line/tick must move exactly {EXPECTED_ROWS} rows \
({} → {})\nscreen:\n{}",
marker_line(top_before),
marker_line(top_after),
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,97 @@
#[allow(unused_imports)]
use super::common::*;
/// Verifies path-free preview metadata appears and dismisses on a non-graphics PTY.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn image_chip_preview_path_free_pty() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} image preview turn."));
let png_name = "preview-fixture.png";
let png_path = content.home().join(png_name);
std::fs::write(&png_path, PNG_8X8_GRAY).expect("write png fixture");
let path_str = png_path.display().to_string();
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// Bracketed-paste the bare path alone so drop-classifier turns it into a chip.
harness
.inject_keys(format!("\x1b[200~{}\x1b[201~", path_str).as_bytes())
.expect("paste png path");
harness
.wait_for_text("Image #1", Duration::from_secs(15))
.expect("image chip attached");
// Allow a frame for the preview overlay to paint (cursor is post-chip).
harness.update(Duration::from_millis(400));
let screen = harness.screen_contents();
assert!(
screen.contains("Image #1"),
"chip label must render: {screen}"
);
assert!(
!screen.contains("Image #1:") && !screen.contains(&format!("[Image #1: {path_str}]")),
"chip must be path-free; screen still shows path-in-chip form:\n{screen}"
);
assert!(
screen.contains("Format:"),
"expected format metadata after path-paste; screen:\n{screen}"
);
assert!(
screen.contains(png_name),
"expected the pasted filename in preview; screen:\n{screen}"
);
let dismiss_sentinel = "PREVIEW_DISMISSED_64280529";
harness
.inject_keys(dismiss_sentinel.as_bytes())
.expect("type after chip");
harness
.wait_for_text(dismiss_sentinel, Duration::from_secs(5))
.expect("typed sentinel echoes");
harness.update(Duration::from_millis(300));
let after = harness.screen_contents();
assert!(
after.contains("Image #1"),
"chip remains after typing: {after}"
);
assert!(
after.contains(dismiss_sentinel),
"dismissal sentinel must be visible: {after}"
);
assert!(
!after.contains("Format:") && !after.contains("Path:") && !after.contains(png_name),
"preview metadata/path must disappear after typing:\n{after}"
);
#[cfg(unix)]
write_cast_if_requested(&harness, "image_chip_preview_path_free.cast");
// Keep both states for video stills.
if let Ok(dir) = std::env::var("PTY_E2E_ARTIFACT_DIR") {
let path = std::path::Path::new(&dir).join("image_chip_preview_path_free_pty.txt");
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(
&path,
format!("--- after paste ---\n{screen}\n--- after type ---\n{after}\n"),
);
}
}
/// Minimal valid 8×8 grayscale PNG (same fixture as edit-interject e2e).
const PNG_8X8_GRAY: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x08, 0x00, 0x00, 0x00, 0x00, 0xe1, 0x64, 0xe1,
0x57, 0x00, 0x00, 0x00, 0x0e, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x68, 0x80, 0x02, 0x06,
0xca, 0x18, 0x00, 0x80, 0x84, 0x20, 0x01, 0x0d, 0x80, 0x24, 0x61, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
@@ -0,0 +1,42 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 11. **Initial prompt positional.**
/// Launching `grok "<prompt>"` (the prompt passed as a positional CLI arg)
/// auto-starts a new session and submits the prompt as the first turn —
/// no keystrokes injected. This exercises the full loop end-to-end:
/// CLI positional → TUI launch → NewSession → SendPrompt → shell agent →
/// mock inference → streamed chunks → pager render.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn initial_prompt_positional_auto_submits() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!(
"{MOCK_RESPONSE_SENTINEL} hello from the auto-submitted initial prompt."
));
let binary = pager_binary().expect("resolve pager binary");
// Pass the prompt as a positional argument, exactly like `grok "go"`.
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[PROMPT])
.expect("spawn pager with initial prompt");
// No keys are injected: the positional prompt must auto-run and the
// mock response must appear on screen on its own.
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("mock response from auto-submitted initial prompt");
assert!(
content.has_chat_completion(),
"mock inference server never received a chat completion request\nrequests: {:?}",
content.requests()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,56 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Idle-input regression (enterprise deploy report): after a turn completes and the
/// session goes idle, a keystroke must echo promptly. The pager used to rely on
/// an always-on `tracing_rx` animation tick to wake the parked event loop;
/// removing it exposed that crossterm's
/// `EventStream` strands its waker when its `next()` future is dropped by a
/// losing `select!` arm (crossterm #936), so idle input was not serviced until
/// an unrelated poll timer fired. Reading input on a dedicated thread behind a
/// cancellation-safe channel fixes the wake; this test FAILS without that fix.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn input_echoes_at_idle_prompt() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} short idle reply."));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("response on screen");
// Let the turn finish and post-turn animation settle so the loop goes fully
// idle (needs_animation() == false) and parks. 3s reliably reaches true idle
// here — the guard was empirically red->green at this settle.
harness.update(Duration::from_secs(3));
// Type a distinctive marker at the idle prompt (no Enter): it must echo.
const TYPED: &str = "ZZIDLEKEYSTROKEZZ";
harness.inject_keys(TYPED.as_bytes()).expect("type at idle");
if harness
.wait_for_text(TYPED, Duration::from_secs(2))
.is_err()
{
panic!(
"typed text did not echo at an idle prompt within 2s: the parked event loop \
was not woken by input (idle wake regression).\nscreen:\n{}",
harness.screen_contents()
);
}
harness.quit().expect("clean quit");
}
@@ -0,0 +1,82 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 19b. **VS Code family: Ctrl+L (form feed) is the send-now chord** with the
/// same cancel-and-send semantics as the default Ctrl+Enter binding: the
/// running turn is cancelled silently and the composer text runs as its own
/// next turn (standard `<user_query>` prompt, no interjection preamble).
/// Harness strips `TERM_PROGRAM` then applies env — pass `vscode` so
/// defaults bind the chord to Ctrl+L.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn interjection_reaches_model_ctrl_l_in_vscode_family() {
let content = ContentController::start().await.expect("start content");
// Gate turn 1's terminal event so the typed text + chord provably land
// mid-turn regardless of suite load.
content.hold_agent_completions();
content.set_turns([
slow_turn_text("TURNONE"),
"TURNTWO reply to the sent-now message.".to_owned(),
]);
let binary = pager_binary().expect("resolve pager binary");
let mut env = content.env_for_pager();
env.push(("TERM_PROGRAM".into(), "vscode".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, &[], &env_refs)
.expect("spawn pager with vscode brand");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("TURNONE", Duration::from_secs(30))
.expect("turn 1 streaming");
harness
.inject_keys(b"please also check the logs")
.expect("type message");
harness.inject_keys(CTRL_L).expect("send-now via Ctrl+L");
content.release_agent_completions();
harness
.wait_for_text(
"\u{276F} please also check the logs",
Duration::from_secs(15),
)
.expect("send-now prompt block");
harness
.wait_for_text("TURNTWO", Duration::from_secs(40))
.expect("sent-now message ran as the next turn");
// The send-now cancel of turn 1 is silent.
assert!(
!harness.contains_text("Turn cancelled by user"),
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
harness.screen_contents()
);
let users = all_user_messages(&content);
let sent = users
.iter()
.find(|u| u.contains("please also check the logs"))
.unwrap_or_else(|| panic!("sent-now message never reached the 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()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,103 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 19. **Send-now chord delivers the composer text as its own next turn.**
/// (Historical name: the chord used to interject into the SAME turn.)
/// Ctrl+Enter with text mid-stream is cancel-and-send: the running turn is
/// cancelled silently and the text runs as the next turn — a standard
/// `<user_query>` prompt with no interjection preamble, rendered as a " "
/// user block via the turn-start adoption.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn interjection_reaches_model_in_same_turn() {
let content = ContentController::start().await.expect("start content");
// Gate turn 1's terminal event so the typed text + chord provably land
// mid-turn regardless of suite load. Chunk delay widens the mid-stream
// window under remote CI load (same shape as cancel_discards_*).
content.hold_agent_completions();
content.set_chunk_delay(Some(Duration::from_millis(100)));
content.set_turns([
slow_turn_text("TURNONE"),
"TURNTWO reply to the sent-now message.".to_owned(),
]);
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text("TURNONE", Duration::from_secs(30))
.expect("turn 1 streaming");
// Still mid-stream (hold gates completion) — not "Worked for".
assert!(
!harness.contains_text("Worked for"),
"turn must still be open before send-now\nscreen:\n{}",
harness.screen_contents()
);
harness
.inject_keys(b"please also check the logs")
.expect("type message");
harness
.wait_for_text("please also check the logs", Duration::from_secs(5))
.expect("draft visible in composer");
harness.inject_keys(CTRL_ENTER).expect("send-now chord");
content.release_agent_completions();
// Cancel-and-send: message leaves the composer and commits as a scrollback
// user block (not just the draft line that also carries ).
let deadline = Instant::now() + Duration::from_secs(20);
loop {
harness.update(Duration::from_millis(100));
if !composer_holds(&harness, "please also check the logs")
&& block_lines_containing(&harness, "please also check the logs") >= 1
{
break;
}
if Instant::now() >= deadline {
panic!(
"send-now did not commit draft to scrollback\nscreen:\n{}",
harness.screen_contents()
);
}
}
harness
.wait_for_text("TURNTWO", Duration::from_secs(40))
.expect("sent-now message ran as the next turn");
// The send-now cancel of turn 1 is silent.
assert!(
!harness.contains_text("Turn cancelled by user"),
"send-now cancel must not render a cancelled marker\nscreen:\n{}",
harness.screen_contents()
);
let users = all_user_messages(&content);
let sent = users
.iter()
.find(|u| u.contains("please also check the logs"))
.unwrap_or_else(|| panic!("sent-now message never reached the 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()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,72 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Seed `[ui] keep_text_selection = "hold"`, open Settings (F2) after a turn,
/// and assert the Mouse row label is visible (modal registration smoke).
/// flash-vs-hold is covered by unit tests (UiConfig / cache / dispatch).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn keep_text_selection_settings_visible_pty() {
let content = ContentController::start().await.expect("start content");
seed_keep_text_selection_config(&content);
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} keep selection turn."));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn rendered");
harness.update(Duration::from_millis(400));
// F2 opens settings on AgentScreen (SS3 form used by most terminals).
const F2: &[u8] = b"\x1bOQ";
harness.inject_keys(F2).expect("F2 open settings");
harness.update(Duration::from_millis(500));
let deadline = Instant::now() + Duration::from_secs(8);
let mut saw_label = false;
while Instant::now() < deadline {
if harness.contains_text("Text selection")
|| harness.contains_text("Flash after copy")
|| harness.contains_text("Hold until dismissed")
{
saw_label = true;
break;
}
// Filter search narrows Mouse rows if the viewport is short.
harness
.inject_keys(b"/selection")
.expect("filter selection");
harness.update(Duration::from_millis(400));
if harness.contains_text("Text selection")
|| harness.contains_text("Flash after copy")
|| harness.contains_text("Hold until dismissed")
{
saw_label = true;
break;
}
harness.inject_keys(keys::ESC).expect("clear filter");
harness.update(Duration::from_millis(200));
harness.inject_keys(F2).expect("re-open settings");
harness.update(Duration::from_millis(400));
}
assert!(
saw_label,
"settings modal must show Text selection row (config_seeded={})\nscreen:\n{}",
content.home().join(".kigi").join("config.toml").exists(),
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,85 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// The fail-closed gate refuses during bootstrap, after the TUI redirects fd 2 to `/dev/null`; its refusal must
/// still reach the real terminal (a regression that doesn't restore fd 2 leaves a managed user a blank `exit 1`).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn managed_policy_gate_refusal_reaches_real_terminal() {
let home = tempfile::tempdir().expect("tempdir");
let home_path = home.path();
std::fs::write(
home_path.join("config.toml"),
// Dead local port so any incidental fetch fails fast offline (the gate is synchronous anyway).
"[endpoints]\n\
deployment_key = \"KEY-AAA\"\n\
managed_config_url = \"http://127.0.0.1:1/deployment/config\"\n\
cli_chat_proxy_base_url = \"http://127.0.0.1:1\"\n",
)
.expect("write config.toml");
std::fs::write(
home_path.join("managed_config.toml"),
"[cli]\ntheme = \"dark\"\n",
)
.expect("write managed_config.toml");
// requirements.toml is deliberately absent (served-then-deleted).
let synced_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_secs();
std::fs::write(
home_path.join("managed_config_cache.json"),
format!(
"{{\"synced_at\":{synced_at},\"principal\":\"deploy-A\",\
\"had_managed_config\":true,\"had_requirements\":true,\
\"key_fingerprint\":null,\"fail_closed\":true}}"
),
)
.expect("write marker");
let binary = pager_binary().expect("resolve pager binary");
let home_str = home_path.to_str().expect("utf8 home path");
let mut harness = PtyHarness::new(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--no-auto-update"],
// KIGI_MANAGED_CONFIG=0 disables the background refetch so the gate decision is deterministic and offline.
&[
("KIGI_SHARE_DIR", home_str),
("KIGI_MANAGED_CONFIG", "0"),
("NO_COLOR", "1"),
],
)
.expect("spawn pager");
// The gate refuses synchronously and exits; drain output, capturing the exit code once.
let gate_msg = "Managed policy is required for this account";
let deadline = Instant::now() + Duration::from_secs(30);
let mut exit_code = None;
while Instant::now() < deadline {
harness.update(Duration::from_millis(100));
// Poll non-blocking; `wait_exit_code` reaps, so capture it exactly once.
if exit_code.is_none() {
exit_code = harness.wait_exit_code(Duration::ZERO);
if exit_code.is_some() {
harness.update(Duration::from_millis(200)); // final drain after exit
break;
}
}
}
let raw = String::from_utf8_lossy(harness.raw_output()).into_owned();
assert!(
harness.contains_text(gate_msg) || raw.contains(gate_msg),
"fail-closed gate refusal must reach the real terminal (fd 2 restored \
from the /dev/null redirect); screen:\n{}\nraw:\n{raw}",
harness.screen_contents()
);
assert_eq!(
exit_code,
Some(1),
"fail-closed gate must exit 1 (refusal); got {exit_code:?}\nraw:\n{raw}"
);
}
@@ -0,0 +1,16 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 14. **MCP menu loads in a non-project dir** (fake `$HOME` as cwd).
/// Guards the create-on-demand path: `/mcps` bypasses the project picker,
/// so the deferred session must be created when the menu needs it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn mcp_menu_loads_servers_in_non_project_dir() {
let content = ContentController::start().await.expect("start content");
// cwd == home_dir() classifies as non-project.
let home = content.home().to_path_buf();
drive_mcp_menu_load(&content, &home).await;
}
@@ -0,0 +1,16 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// 13. **MCP menu loads in a project dir** (`.git` ancestor → eager session).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn mcp_menu_loads_servers_in_project_dir() {
let content = ContentController::start().await.expect("start content");
let project = tempfile::tempdir().expect("create project dir");
// The `.git` ancestor check precedes the system-temp exclusion in `is_project_dir`.
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
drive_mcp_menu_load(&content, project.path()).await;
}
@@ -0,0 +1,147 @@
// Per-test-case module for the `pty_e2e` integration test crate.
//
// A plain prompt with a mid-text `/skill` token must keep
// the composer's teal token highlight in the scrollback echo — live, with the
// real shell advertising the skill from an on-disk SKILL.md.
#[allow(unused_imports)]
use super::common::*;
use kigi_pager_pty_harness::StyledLine;
/// Typed prompt: plain text with the skill referenced mid-message.
const TYPED: &str = "great /test-skill do it";
/// The token the composer (and the echo) must render in the skill accent.
const TOKEN: &str = "/test-skill";
/// Leading word of the prompt — must stay in the plain body color.
const BODY_WORD: &str = "great";
const DONE_SENTINEL: &str = "MIDTEXT_SKILL_ECHO_DONE";
/// On the first screen row whose text contains `row_marker`, return the fg of
/// the styled run containing `needle`. `None` until the row (or run) exists.
fn run_fg_on_row(rows: &[StyledLine], row_marker: &str, needle: &str) -> Option<Option<String>> {
for row in rows {
let text: String = row.runs.iter().map(|r| r.text.as_str()).collect();
if !text.contains(row_marker) {
continue;
}
for run in &row.runs {
if run.text.contains(needle) {
return Some(run.fg.clone());
}
}
}
None
}
/// Seed a user-invocable skill under `dir/.kigi/skills`. The workspace must
/// be a git repo: the live session advertises workspace-local skills only for
/// git workspaces (offline `grok inspect` scans plain dirs too) — same shape
/// as the mid_text_skill_token_echo.yaml scenario's `git_init: true`.
fn seed_test_skill(dir: &Path) {
let skill_dir = dir.join(".kigi").join("skills").join("test-skill");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: test-skill\ndescription: Test skill for echo styling.\n---\n\nDo the thing.\n",
)
.expect("write SKILL.md");
}
/// Type `great /test-skill do it`, wait for the composer to highlight the
/// advertised skill token, submit, and assert the scrollback echo renders the
/// SAME token in the same accent fg while the body word stays a different fg.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn mid_text_skill_token_echo_styled_pty() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{DONE_SENTINEL} echo styling verified."));
let workspace = tempfile::tempdir().expect("workspace tempdir");
git2::Repository::init(workspace.path()).expect("git init workspace");
seed_test_skill(workspace.path());
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--trust"],
Some(workspace.path()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
// The first char promotes the welcome prompt to a session.
inject_keys_paced(&mut harness, TYPED.as_bytes());
harness
.wait_for_text(TYPED, Duration::from_secs(10))
.expect("typed prompt echoed in the composer");
// Wait for the shell to advertise the skill: once the registry syncs, the
// composer restyles the token — its fg diverges from the body word's fg.
let composer_deadline = Instant::now() + Duration::from_secs(30);
let composer_token_fg = loop {
harness.update(Duration::from_millis(100));
let rows = harness.screen_styled();
let token_fg = run_fg_on_row(&rows, TYPED, TOKEN);
let body_fg = run_fg_on_row(&rows, TYPED, BODY_WORD);
if let (Some(token_fg), Some(body_fg)) = (token_fg, body_fg)
&& token_fg != body_fg
{
break token_fg;
}
assert!(
Instant::now() < composer_deadline,
"composer never highlighted {TOKEN} (skill not advertised?)\nscreen:\n{}",
harness.screen_contents()
);
};
harness.inject_keys(b"\r").expect("submit prompt");
harness
.wait_for_text(DONE_SENTINEL, Duration::from_secs(30))
.expect("mock response rendered (turn finished)");
// The composer cleared on submit, so the only row with the full typed
// text is the scrollback echo. It may take a paint to settle.
let echo_deadline = Instant::now() + Duration::from_secs(10);
let (echo_token_fg, echo_body_fg) = loop {
harness.update(Duration::from_millis(100));
let rows = harness.screen_styled();
if let (Some(token_fg), Some(body_fg)) = (
run_fg_on_row(&rows, TYPED, TOKEN),
run_fg_on_row(&rows, TYPED, BODY_WORD),
) {
break (token_fg, body_fg);
}
assert!(
Instant::now() < echo_deadline,
"echo row never painted\nscreen:\n{}",
harness.screen_contents()
);
};
assert_ne!(
echo_token_fg,
echo_body_fg,
"echo must style {TOKEN} differently from the body\nscreen:\n{}",
harness.screen_contents()
);
assert_eq!(
echo_token_fg,
composer_token_fg,
"echo token fg must match the composer's highlight fg\nscreen:\n{}",
harness.screen_contents()
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,71 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
/// Overlay-steal precedence: while a turn is streaming, opening the slash
/// dropdown and pressing **Esc dismisses the dropdown and does NOT cancel the
/// turn** (and does not hit the mid-turn swallow). The pane-level slash handler
/// returns `Changed` before `try_handle_esc_policy` ever runs.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn mid_turn_slash_dropdown_esc_dismisses_not_cancel() {
let content = ContentController::start().await.expect("start content");
let long_response = format!(
"{MOCK_RESPONSE_SENTINEL} {}",
"streaming filler words while the dropdown is open. ".repeat(120)
);
content.set_response(long_response);
content.set_chunk_delay(Some(Duration::from_millis(50)));
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::spawn_with_content(&binary, DEFAULT_ROWS, DEFAULT_COLS, &content, &[])
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("stream started");
// Open the slash dropdown mid-turn. "/mod" narrows to `/model`, whose
// description renders only in the dropdown (not in the typed text).
inject_keys_paced(&mut harness, b"/mod");
harness
.wait_for_text("Switch the active model", Duration::from_secs(10))
.expect("slash dropdown open mid-turn");
// Esc: dropdown steals it (dismiss), the turn must keep running.
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}"
);
assert!(
!screen.contains("Turn cancelled by user"),
"slash-dropdown Esc must NOT cancel the turn\nscreen:\n{screen}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{screen}"
);
// Settle a little more and re-confirm the turn was never cancelled by the
// dropdown-dismiss Esc.
harness.update(Duration::from_millis(600));
assert!(
!harness.contains_text("Turn cancelled by user"),
"turn must still be running after the dropdown-dismiss Esc\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -0,0 +1,132 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use super::common::*;
#[cfg(target_os = "linux")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "Linux X11 PTY e2e; run in CI with the built pager"]
async fn middle_click_pastes_primary_linux() {
use std::os::unix::fs::PermissionsExt as _;
const PRIMARY: &str = "X11PRIMARYQQQ exact selection";
const CLIPBOARD: &str = "X11CLIPBOARDQQQ must stay unused";
let tmp = tempfile::tempdir().expect("tempdir for fake X11 clipboard tools");
let bin_dir = tmp.path().join("bin");
std::fs::create_dir_all(&bin_dir).expect("mkdir fake bin");
let argv_log = tmp.path().join("clipboard-argv.log");
let recorder = format!(
"tool=${{0##*/}}\nprintf '%s' \"$tool\" >> '{log}'\n\
for arg in \"$@\"; do printf '\\t%s' \"$arg\" >> '{log}'; done\n\
printf '\\n' >> '{log}'\n",
log = argv_log.display(),
);
let xclip = format!(
"#!/bin/sh\n{recorder}case \"$*\" in\n\
\"--version\") exit 0 ;;\n\
\"-o -selection primary\") printf '%s' '{PRIMARY}' ;;\n\
\"-o -selection clipboard\") printf '%s' '{CLIPBOARD}' ;;\n\
*) exit 1 ;;\nesac\n"
);
let xsel = format!(
"#!/bin/sh\n{recorder}case \"$*\" in\n\
\"--version\") exit 0 ;;\n\
\"--primary --output\") printf '%s' '{PRIMARY}' ;;\n\
\"--clipboard --output\") printf '%s' '{CLIPBOARD}' ;;\n\
*) exit 1 ;;\nesac\n"
);
for (name, body) in [("xclip", xclip), ("xsel", xsel)] {
let path = bin_dir.join(name);
std::fs::write(&path, body).expect("write fake X11 clipboard tool");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("chmod fake X11 clipboard tool");
}
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} primary paste turn."));
let path_env = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let env: Vec<(String, String)> = {
let mut env = content.env_for_pager();
env.push(("PATH".into(), path_env));
env.push(("TERM".into(), "xterm".into()));
env.push(("DISPLAY".into(), ":99".into()));
env.push(("WAYLAND_DISPLAY".into(), String::new()));
env
};
let env_refs: Vec<(&str, &str)> = env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness =
PtyHarness::new_in_dir(&binary, DEFAULT_ROWS, DEFAULT_COLS, &[], &env_refs, None)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(b"\x1b[<1;10;10M\x1b[<1;10;10m")
.expect("inject SGR middle down and up");
harness
.wait_for_text(PRIMARY, Duration::from_secs(10))
.expect("PRIMARY reaches the promoted session prompt");
let prompt_screen = harness.screen_contents();
assert_eq!(
prompt_screen.matches(PRIMARY).count(),
1,
"middle down must insert PRIMARY exactly once\nscreen:\n{prompt_screen}"
);
assert!(
!prompt_screen.contains(CLIPBOARD),
"middle click must never insert CLIPBOARD\nscreen:\n{}",
prompt_screen
);
harness.inject_keys(b"\r").expect("submit PRIMARY");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("PRIMARY prompt reaches the mock backend");
// Recorded requests repeat cumulative history, so wire presence is the stable assertion here.
let user_messages = all_user_message_blobs(&content);
assert!(
user_messages
.iter()
.any(|message| message.contains(PRIMARY)),
"PRIMARY must reach the model; messages: {user_messages:#?}"
);
assert!(
user_messages
.iter()
.all(|message| !message.contains(CLIPBOARD)),
"CLIPBOARD must remain untouched; messages: {user_messages:#?}"
);
let argv = std::fs::read_to_string(&argv_log).expect("read fake-tool argv log");
let primary_argv = "xclip\t-o\t-selection\tprimary";
assert_eq!(
argv.lines().filter(|line| *line == primary_argv).count(),
1,
"middle release and welcome forwarding must not re-read PRIMARY:\n{argv}"
);
assert!(
!argv.lines().any(|line| {
line.contains("\t-selection\tclipboard") || line.contains("\t--clipboard")
}),
"middle click must not invoke CLIPBOARD argv:\n{argv}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
harness.quit().expect("clean quit");
}
@@ -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(".kigi").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");
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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()
);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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}"
);
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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(".kigi").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");
}
@@ -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(".kigi")).expect("mk .kigi");
std::fs::write(
content.home().join(".kigi/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);
}
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More