Synced from monorepo

Synced from monorepo

Changes:
- grok-shell: send an expired external-provider credential to the sign-in flow, not a 401 loop
- pager: clickable ▲ jumps to the top of the response being read
- grok-shell: keep a large task log from making the completion message too long
- Plan viewer scrollbar: widen grab zone to the border column; fix striped thumb in Terminal.app
- pager: poll the tmux probe teardown grace instead of sleeping it
- security: vendor-compat MCP kill switch is now actually enforced when reported as on
- grok-shell: restore session eviction when a leader client disconnects
- Bump rust-toolchain to 1.93.0
- workspace: lexical-normalize permission path patterns before glob matching
- pager: reject garbage Enter in the /resume picker
- pager: show Mermaid affordances in plan mode preview
- pager: drop manage-account link from /session-info
- workspace: auto-approve read-only git queries; defer write floor to auto classifier
- Add free-form pattern editor to the "Always allow" command prompt
- grok-shell: fix /btw caching
- pager: Tab walks answers in the ask_user_question card
- External-provider auth refresh: single 7s attempt instead of 3×5s
- pager: don't resurrect finished background tasks as Running when completion arrives first
- pager: report tmux truecolor clamping in Doctor
- Fix plan viewer scrollbar click+drag hijacked by comment gutter
- pager/shell: stop double Recap after the same last turn
- sampler: preserve x-should-retry through stream collection
- pager: clear plan-mode indicator immediately when the user approves a plan
- pager: tmux does not re-read its config on reattach

Source-Revision: 64c4de99cc822b25ce9c54ab5a4f372093d0885d
This commit is contained in:
grokkybara[bot] 2026-08-03 08:17:57 +00:00
commit 780d1388ff
323 changed files with 12258 additions and 7226 deletions

View file

@ -1251,6 +1251,34 @@ pub(crate) fn write_cast_if_requested(harness: &PtyHarness, file_name: &str) {
}
}
/// Dump the current screen (plain text and HTML) into
/// `$GROK_PTY_CAST_DIR/<file_stem>.{txt,html}` when the env var is set.
/// Failures are logged, never fatal — same opt-in as
/// [`write_cast_if_requested`].
pub(crate) fn write_screen_dump_if_requested(harness: &PtyHarness, file_stem: &str) {
let Ok(dir) = std::env::var("GROK_PTY_CAST_DIR") else {
return;
};
if dir.is_empty() {
return;
}
let dir = std::path::PathBuf::from(dir);
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("failed to create dump dir {}: {e}", dir.display());
return;
}
for (ext, body) in [
("txt", harness.screen_contents()),
("html", harness.screen_html()),
] {
let path = dir.join(format!("{file_stem}.{ext}"));
match std::fs::write(&path, body) {
Ok(()) => eprintln!("wrote screen dump: {}", path.display()),
Err(e) => eprintln!("failed to write screen dump {}: {e}", path.display()),
}
}
}
// ── Clipboard paste e2e tests ───────────────────────────────────────────
/// Serialized `content` of every user message across recorded requests, in

View file

@ -0,0 +1,164 @@
#[allow(unused_imports)]
use super::common::*;
const TAG: &str = "SBGRAB";
const PLAN_LINES: usize = 120;
/// PTY: presses, wheels, and drags on the modal border column next to the
/// scrollbar track must scroll the plan. Users read the thumb + border as
/// one two-column scrollbar and press the border half (reported on macOS
/// Terminal.app and ghostty over SSH), which used to fall into the
/// click-outside-modal path.
///
/// Also pins the thumb contract `bg == fg`: Terminal.app leaves line-gap
/// pixels unpainted under a foreground-only `█`, striping the thumb with
/// dark bars.
#[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 plan_scrollbar_grab_zone_pty() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} first turn done."));
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_env_in_dir(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
&["--yolo", "--trust", "--no-leader"],
&[],
Some(content.home()),
)
.expect("spawn pager");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome");
harness.inject_keys(b"go\r").expect("first turn");
harness
.wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(40))
.expect("first turn streams");
let dir = session_dir(&content, &mut harness);
std::fs::write(dir.join("plan.md"), plan_body(TAG, PLAN_LINES)).expect("seed plan.md");
let _expectation = expect_tool_turn(&content, "call_plan_sb", "exit_plan_mode", "{}".into());
harness
.inject_keys(b"present the plan\r")
.expect("submit plan prompt");
harness
.wait_for_text("request changes", Duration::from_secs(60))
.unwrap_or_else(|e| {
panic!(
"plan approval never parked: {e}\nscreen:\n{}",
harness.screen_contents()
)
});
let before = harness.screen_contents();
assert!(
before.contains(&format!("{TAG}000")),
"viewer must open at the top of the plan; screen:\n{before}"
);
let last = format!("{TAG}{:03}", PLAN_LINES - 1);
assert!(
!before.contains(&last),
"the plan tail must start off-screen; screen:\n{before}"
);
let (title_row, _) = locate_screen_text(&before, "plan.md").expect("plan viewer title visible");
let border_col = before
.lines()
.nth(title_row as usize)
.map(|l| l.trim_end().chars().count() as u16 - 1)
.expect("title row present");
let (approve_row, _) = locate_screen_text(&before, "approve").expect("footer approve visible");
let track_bottom_row = approve_row - 2;
let track_top_row = title_row + 1;
let track_col = (border_col - 1) as usize;
let styled = harness.screen_styled();
let mut thumb_cells = 0;
for line in &styled {
let mut col = 0usize;
for run in &line.runs {
let run_width = run.text.chars().count();
if col <= track_col && track_col < col + run_width && run.text.contains('\u{2588}') {
thumb_cells += 1;
assert!(run.fg.is_some(), "thumb must have a color (run {run:?})");
assert_eq!(
run.bg, run.fg,
"thumb cell background must match the glyph color (run {run:?})"
);
}
col += run_width;
}
}
assert!(thumb_cells > 0, "scrollbar thumb must be visible");
let mut click = String::new();
click.push_str(&sgr_mouse(0, track_bottom_row, border_col, 'M'));
click.push_str(&sgr_mouse(0, track_bottom_row, border_col, 'm'));
harness.inject_keys(click.as_bytes()).expect("border click");
harness
.wait_for_text(&last, Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"border-column click must scroll to the plan tail: {e}\nscreen:\n{}",
harness.screen_contents()
)
});
// A single synthetic notch was observed to be swallowed by the
// scroll-stream cadence; real wheels emit bursts.
let wheel_up: String = (0..6)
.map(|_| sgr_mouse(64, track_bottom_row, border_col, 'M'))
.collect();
harness
.inject_keys(wheel_up.as_bytes())
.expect("border wheel up");
harness
.wait_for_text_absent(&last, Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"wheel-up on the border column must scroll the plan: {e}\nscreen:\n{}",
harness.screen_contents()
)
});
let mut drag = String::new();
drag.push_str(&sgr_mouse(0, track_bottom_row, border_col, 'M'));
drag.push_str(&sgr_mouse(
32,
(track_top_row + track_bottom_row) / 2,
border_col,
'M',
));
drag.push_str(&sgr_mouse(32, track_top_row, border_col, 'M'));
drag.push_str(&sgr_mouse(0, track_top_row, border_col, 'm'));
harness.inject_keys(drag.as_bytes()).expect("border drag");
harness
.wait_for_text(&format!("{TAG}000"), Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"border-column drag must scroll back to the plan top: {e}\nscreen:\n{}",
harness.screen_contents()
)
});
let after = harness.screen_contents();
assert!(
after.contains("request changes"),
"approval chrome must stay open after scrollbar gestures; screen:\n{after}"
);
assert!(
!after.contains("Type your comment...") && !after.contains("commenting L"),
"scrollbar gestures must not enter commenting; screen:\n{after}"
);
assert!(
!after.contains("panicked"),
"pager panicked\nscreen:\n{after}"
);
harness.quit().expect("clean quit");
}

View file

@ -0,0 +1,236 @@
// Per-test-case module for the `pty_e2e` integration test crate.
//
// Regression pin: Tab inside the `ask_user_question` card used to hand focus
// to the scrollback while the card stayed drawn and the shortcuts bar kept
// advertising card keys.
#[allow(unused_imports)]
use super::common::*;
const FIRST_QUESTION: &str = "What kind of work are you most interested in right now?";
const SECOND_QUESTION: &str = "How deep should the answer go?";
/// Answer rows of each question in render order.
const FIRST_ROWS: [&str; 4] = [
"Writing or editing code",
"Explaining the codebase",
"Debugging a failure",
"Type your answer here",
];
const SECOND_ROWS: [&str; 3] = ["A quick summary", "Every detail", "Type your answer here"];
const DONE_SENTINEL: &str = "QUESTIONTABDONE";
const TAB: &[u8] = b"\t";
/// `BackTab` — the xterm encoding of Shift+Tab.
const SHIFT_TAB: &[u8] = b"\x1b[Z";
const FOCUSED_HINT: &str = "Tab:next answer";
fn ask_user_question_args() -> String {
let option =
|label: &str, description: &str| json!({ "label": label, "description": description });
json!({
"questions": [
{
"question": FIRST_QUESTION,
"options": [
option(FIRST_ROWS[0], "Implement features or fix bugs"),
option(FIRST_ROWS[1], "Understand how things work"),
option(FIRST_ROWS[2], "Track down a flake"),
],
},
{
"question": SECOND_QUESTION,
"options": [
option(SECOND_ROWS[0], "Just the headline"),
option(SECOND_ROWS[1], "Walk me through it"),
],
},
]
})
.to_string()
}
/// Text of the answer row that currently carries the cursor band.
///
/// Reading the styled screen is the only way to observe cursor position from
/// outside the process.
fn cursor_row(harness: &PtyHarness) -> Option<String> {
let labels: Vec<&str> = FIRST_ROWS
.iter()
.chain(SECOND_ROWS.iter())
.copied()
.collect();
let mut rows: Vec<(String, String)> = Vec::new();
for line in harness.screen_styled() {
let text: String = line.runs.iter().map(|r| r.text.as_str()).collect();
let Some(label) = labels.iter().find(|label| text.contains(**label)) else {
continue;
};
let bg = line
.runs
.iter()
.find(|run| run.text.contains(*label))
.and_then(|run| run.bg.clone())
.unwrap_or_default();
rows.push(((*label).to_string(), bg));
}
rows.iter()
.find(|(_, bg)| rows.iter().filter(|(_, other)| other == bg).count() == 1)
.map(|(label, _)| label.clone())
}
fn expect_cursor_row(harness: &mut PtyHarness, expected: &str, step: &str) {
let outcome = harness.wait_until(
&format!("{step}: cursor on {expected:?}"),
CURSOR_TIMEOUT,
|h| cursor_row(h).as_deref() == Some(expected),
);
eprintln!("[cursor] {step}: {:?}", cursor_row(harness));
outcome.unwrap_or_else(|e| panic!("{e}"));
}
fn expect_text(harness: &mut PtyHarness, needle: &str, step: &str) {
eprintln!("[screen] {step}: waiting for {needle:?}");
harness
.wait_for_text(needle, CURSOR_TIMEOUT)
.unwrap_or_else(|e| panic!("{step}: {e}"));
}
const CURSOR_TIMEOUT: Duration = Duration::from_secs(10);
/// Every string leaf of a recorded request body.
fn string_leaves(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::String(text) => out.push(text.clone()),
serde_json::Value::Array(items) => items.iter().for_each(|item| string_leaves(item, out)),
serde_json::Value::Object(fields) => {
fields.values().for_each(|field| string_leaves(field, out))
}
_ => {}
}
}
/// Drive a real two-question `ask_user_question` card all the way round its
/// answer walk, both directions, and submit from where Tab left off.
#[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 question_tab_cycles_answers() {
let content = ContentController::start().await.expect("start content");
let _turn = expect_tool_turn(
&content,
"call_ask_tab",
"ask_user_question",
ask_user_question_args(),
);
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 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(FIRST_ROWS[2], Duration::from_secs(30))
.expect("question card renders");
expect_cursor_row(
&mut harness,
FIRST_ROWS[0],
"card opens on the first answer",
);
write_screen_dump_if_requested(&harness, "question_tab_00_card_open");
harness.inject_keys(b" ").expect("Space marks the answer");
for (step, expected) in FIRST_ROWS.iter().enumerate().skip(1) {
harness.inject_keys(TAB).expect("Tab");
expect_cursor_row(&mut harness, expected, &format!("Tab #{step}"));
}
write_screen_dump_if_requested(&harness, "question_tab_01_first_question_walked");
harness.inject_keys(TAB).expect("Tab into question 2");
expect_cursor_row(&mut harness, SECOND_ROWS[0], "Tab crosses into question 2");
expect_text(
&mut harness,
"[2/2]",
"question counter follows the answer walk",
);
write_screen_dump_if_requested(&harness, "question_tab_02_second_question");
harness.inject_keys(SHIFT_TAB).expect("Shift+Tab back");
expect_cursor_row(&mut harness, FIRST_ROWS[3], "Shift+Tab crosses back");
harness.inject_keys(TAB).expect("Tab into question 2 again");
expect_cursor_row(&mut harness, SECOND_ROWS[0], "and forward again");
for _ in 0..SECOND_ROWS.len() {
harness.inject_keys(TAB).expect("Tab");
}
expect_cursor_row(
&mut harness,
FIRST_ROWS[0],
"Tab past the last answer wraps",
);
expect_text(&mut harness, "[1/2]", "the wrap lands back on question 1");
write_screen_dump_if_requested(&harness, "question_tab_03_wrapped_to_first");
assert!(
harness.contains_text(FOCUSED_HINT),
"the card still owns the keyboard after the wrap\nscreen:\n{}",
harness.screen_contents()
);
harness
.inject_keys(SHIFT_TAB)
.expect("Shift+Tab wraps back");
expect_cursor_row(&mut harness, SECOND_ROWS[2], "Shift+Tab wraps to the last");
expect_text(
&mut harness,
"[2/2]",
"the backwards wrap lands on question 2",
);
write_screen_dump_if_requested(&harness, "question_tab_04_wrapped_to_last");
harness.inject_keys(SHIFT_TAB).expect("Shift+Tab");
expect_cursor_row(
&mut harness,
SECOND_ROWS[1],
"Shift+Tab back onto an answer",
);
harness.inject_keys(b"\r").expect("submit answers");
harness
.wait_for_text(DONE_SENTINEL, Duration::from_secs(30))
.expect("agent turn resumes after the answers");
write_screen_dump_if_requested(&harness, "question_tab_99_submitted");
let mut leaves = Vec::new();
for body in content.request_bodies() {
string_leaves(&body, &mut leaves);
}
let answered: Vec<&String> = leaves
.iter()
.filter(|leaf| leaf.contains("has answered your questions"))
.collect();
eprintln!("[tool result] {answered:?}");
for expected in [
format!("\"{FIRST_QUESTION}\"=\"{}\"", FIRST_ROWS[0]),
format!("\"{SECOND_QUESTION}\"=\"{}\"", SECOND_ROWS[1]),
] {
assert!(
answered.iter().any(|leaf| leaf.contains(&expected)),
"tool result should carry {expected:?}, got {answered:?}"
);
}
harness.quit().expect("clean quit");
}

View file

@ -0,0 +1,133 @@
// Per-test-case module for the `pty_e2e` integration test crate.
//
// The ▲ jump-to-response-top indicator: an answer taller than the screen
// leaves the reader at its tail, and the sticky header's gap row offers a
// clickable ▲ that snaps the answer's first line to the top — the mouse
// mirror of the K (previous response) key, and the discoverable fix for
// "grok doesn't show you its answer from the top, so you have to scroll a
// lot".
#[allow(unused_imports)]
use super::common::*;
/// First and last line of the streamed answer. 60 bullets over a 50-row
/// terminal guarantees the answer overflows the viewport.
const FIRST_LINE: &str = "ANSWERLINE001";
const LAST_LINE: &str = "ANSWERLINE060";
const UP_INDICATOR: &str = "";
/// Unique prompt text so the sticky-header row is unambiguous on screen
/// (the shared [`PROMPT`] "go" collides with hint/footer copy).
const TOP_PROMPT: &str = "SHOWTOPPROMPT";
fn long_answer() -> String {
(1..=60)
.map(|i| format!("- ANSWERLINE{i:03} lorem ipsum dolor sit amet consectetur"))
.collect::<Vec<_>>()
.join("\n")
}
/// Row index of `needle` on screen, for the artifact trace.
fn row_of(screen: &str, needle: &str) -> Option<usize> {
screen.lines().position(|line| line.contains(needle))
}
/// Artifact trace: the viewport's top rows, where the sticky header, the ▲,
/// and (after the click) the answer's first line all live.
fn dump_top_rows(label: &str, screen: &str) {
eprintln!("[{label}] top of viewport:");
for line in screen.lines().take(10) {
eprintln!(" {}", line.trim_end());
}
}
/// PTY: stream an answer taller than the terminal, check the ▲ indicator
/// appears under the sticky prompt header, click it, and check the viewport
/// lands on the answer's first line.
#[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 response_top_indicator_jumps_to_answer_start() {
let content = ContentController::start().await.expect("start content");
content.set_response(long_answer());
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!("{TOP_PROMPT}\r").as_bytes())
.expect("submit prompt");
// Follow mode parks the finished turn at the answer's tail; the ▲
// renders in the sticky header's gap row as soon as the answer's first
// line scrolls off the top.
harness
.wait_for_text(LAST_LINE, Duration::from_secs(60))
.expect("answer tail visible (follow mode at the bottom)");
harness
.wait_for_text(UP_INDICATOR, Duration::from_secs(10))
.expect("▲ indicator rendered under the sticky prompt header");
let screen = harness.screen_contents();
assert!(
!screen.contains(FIRST_LINE),
"the answer must overflow the viewport for this test to prove \
anything\nscreen:\n{screen}"
);
let (arrow_row, arrow_col) =
locate_screen_text(&screen, UP_INDICATOR).expect("locate ▲ on screen");
let prompt_row = row_of(&screen, TOP_PROMPT);
eprintln!(
"[before click] ▲ at ({arrow_row},{arrow_col}), sticky prompt row {prompt_row:?}, \
answer tail row {:?}",
row_of(&screen, LAST_LINE)
);
dump_top_rows("before click", &screen);
// "Under the last user prompt block": the sticky header pins the prompt
// above the gap row that hosts the ▲.
let prompt_row = prompt_row.expect("sticky header shows the prompt");
assert!(
prompt_row < arrow_row as usize && (arrow_row as usize) < prompt_row + 6,
"▲ should sit in the gap row just below the pinned prompt\nscreen:\n{screen}"
);
// Click the ▲ (SGR left press + release at its cell).
let click = format!(
"{}{}",
sgr_mouse(0, arrow_row, arrow_col, 'M'),
sgr_mouse(0, arrow_row, arrow_col, 'm')
);
harness.inject_keys(click.as_bytes()).expect("click ▲");
harness
.wait_for_text(FIRST_LINE, Duration::from_secs(10))
.expect("answer snaps to its first line after the click");
let screen = harness.screen_contents();
let first_row = row_of(&screen, FIRST_LINE);
eprintln!(
"[after click] first answer line at row {first_row:?}, last answer line at row {:?}",
row_of(&screen, LAST_LINE)
);
dump_top_rows("after click", &screen);
assert!(
!screen.contains(LAST_LINE),
"after the jump the tail must be off screen again\nscreen:\n{screen}"
);
let first_row = first_row.expect("first line is on screen");
assert!(
first_row < usize::from(DEFAULT_ROWS) / 3,
"the answer's first line should be parked near the top of the \
viewport, found it at row {first_row}\nscreen:\n{screen}"
);
assert!(
!harness.contains_text(UP_INDICATOR),
"with the answer's top on screen the ▲ must disappear\nscreen:\n{screen}"
);
harness.quit().expect("clean quit");
}

View file

@ -40,6 +40,8 @@ mod mouse_reporting_toggle_sticky_persists_pty;
mod nested_quote_drag_copy_excludes_bars_pty;
#[path = "pty_e2e/page_flip_on_send_pty.rs"]
mod page_flip_on_send_pty;
#[path = "pty_e2e/plan_scrollbar_grab_zone_pty.rs"]
mod plan_scrollbar_grab_zone_pty;
#[path = "pty_e2e/quote_block_drag_copy_excludes_bars_pty.rs"]
mod quote_block_drag_copy_excludes_bars_pty;
#[path = "pty_e2e/quote_block_raw_mode_copy_keeps_source_pty.rs"]
@ -50,6 +52,8 @@ mod read_tool_header_selection_copies_path_only_pty;
mod recap_header_not_in_selection_pty;
#[path = "pty_e2e/resize_preserves_scroll_position.rs"]
mod resize_preserves_scroll_position;
#[path = "pty_e2e/response_top_indicator_pty.rs"]
mod response_top_indicator_pty;
#[path = "pty_e2e/scroll_debug_hud_env_toggles_overlay.rs"]
mod scroll_debug_hud_env_toggles_overlay;
#[path = "pty_e2e/scroll_does_not_crash.rs"]

View file

@ -25,6 +25,8 @@ mod initial_prompt_positional_auto_submits;
mod input_echoes_at_idle_prompt;
#[path = "pty_e2e/plan_revise_empty_enter_does_not_approve.rs"]
mod plan_revise_empty_enter_does_not_approve;
#[path = "pty_e2e/question_tab_cycles_answers.rs"]
mod question_tab_cycles_answers;
#[path = "pty_e2e/renders_on_action.rs"]
mod renders_on_action;
#[path = "pty_e2e/requirements_version_failure_exits_2_with_guidance.rs"]