Synced from monorepo

Synced from monorepo

Changes:
- Workspace server: surface preview-proxy metrics through the hub metric pump
- Shell: reclaim a session’s retained state in one entry
- Shell: reclaim a session’s resident state in one entry
- Pager: withhold key event types from Alacritty builds that double keys
- Tools: cancel a session’s subagents when it closes
- Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode
- Pager: probe terminal version over DA2 and include it with feedback
- SuperGrok Plus: identity, CLI, and analytics tier surfaces
- Shell: inherit the session process scope into subagents
- Pager: build @-file-search matcher lazily on first use
- Tools: fix description and output contradictions in tool definitions
- Workspace: degrade @-file-search instead of aborting on thread exhaustion
- Tools: reap a session’s LSP servers when it closes
- Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools
- MCP: reap stdio MCP children on session close
- Shell: reuse spawn-time skill discovery for session telemetry
- Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell)
- Shell: self-heal corrupt session-search SQLite cache
- Workspace: cap workspace-server tokio workers on many-core hosts
- Shell: reap a session’s child processes when it closes
- Crash handler: capture SIGABRT so panic-aborts leave crash reports
- CLI chat proxy: team-scoped Grok Code managed-config admin routes
- MCP: add CLI enable/disable for MCP servers
- Shell: cap tokio worker threads for startup thread demand
- Workspace: harden git_commit and add git_sync_base operation
- Circuit breaker: add feature-gated gRPC retry policy

Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
grokkybara[bot] 2026-07-28 22:50:19 +00:00
commit 5da6962e4a
192 changed files with 10337 additions and 3421 deletions

View file

@ -115,7 +115,15 @@ pub fn is_committable(entry: &ScrollbackEntry, turn_running: bool, is_last: bool
}
/// The display mode a block should be committed in (minimal mode, print-once).
pub fn minimal_commit_display_mode(block: &RenderBlock) -> DisplayMode {
///
/// Stamps BOTH the entry being committed and the still-uncommitted live-tail
/// entries ([`commit_active`]), so a block's height is identical either side of
/// the commit frontier and the prompt does not jerk when it crosses.
pub fn minimal_commit_display_mode(
block: &RenderBlock,
appearance: &AppearanceConfig,
) -> DisplayMode {
let collapse_thinking = appearance.minimal_collapse_thinking;
match block {
RenderBlock::ToolCall(ToolCallBlock::Edit(_)) => DisplayMode::Expanded,
RenderBlock::ToolCall(
@ -126,6 +134,7 @@ pub fn minimal_commit_display_mode(block: &RenderBlock) -> DisplayMode {
| ToolCallBlock::IntegrationSearch(_)),
) if tc.is_success() => DisplayMode::Collapsed,
RenderBlock::ToolCall(_) => DisplayMode::Truncated,
RenderBlock::Thinking(_) if collapse_thinking => DisplayMode::Collapsed,
RenderBlock::Thinking(_) => DisplayMode::Expanded,
_ => DisplayMode::Expanded,
}
@ -245,37 +254,55 @@ pub fn commit_leading_run(
/// on would make the reserved `insert_before` height disagree with the painted
/// rows (design decision K5). Block horizontal padding is zeroed so committed
/// content is flush-left with the welcome card (which paints edge-to-edge);
/// paired with [`EntryRenderer::with_hide_accent`] reclaiming the accent
/// column, glyphs start at column 0. The live region's prompt / status /
/// info rows mirror that via [`super::live::live_left_inset`].
/// paired with [`minimal_renderer`] reclaiming the accent column, glyphs start
/// at column 0. The live region's prompt / status / info rows mirror that via
/// [`super::live::live_left_inset`].
///
/// The two reasoning-legibility toggles are set here rather than in
/// `pager.toml` so the full TUI stays provably untouched — design doc §6.16.
pub(crate) fn committed_appearance(base: &AppearanceConfig) -> AppearanceConfig {
let mut a = base.clone();
a.show_timestamps = false;
// Flush-left minimal look: no block horizontal padding (align with the
// welcome card, which paints edge-to-edge with no outer h-pad).
a.scrollback.layout.block_pad_left = 0;
a.scrollback.layout.block_pad_right = 0;
a.scrollback.blocks.thinking.body_dim_italic = true;
a.scrollback.blocks.thinking.collapsed_expand_hint = true;
a
}
/// Build the renderer used for a committed (print-once) block: no selection
/// highlight, a static tick (no running-wave animation), timestamps off.
fn committed_renderer<'a>(
pub(crate) const COMMITTED_TICK: u64 = 0;
/// The renderer for one minimal-mode entry, on **either** side of the commit
/// frontier — `tick` is the only difference. Chrome here decides a block's
/// wrapped height, so both sides must agree or the prompt jumps on commit (K5);
/// keeping it one constructor is what makes that unbreakable.
///
/// Reasoning alone keeps the accent column, as the marker that separates it
/// from the answer. Design doc §6.16.
pub(crate) fn minimal_renderer<'a>(
entry: &'a ScrollbackEntry,
theme: &'a Theme,
appearance: AppearanceConfig,
cwd: &'a std::path::Path,
tick: u64,
) -> EntryRenderer<'a> {
// Reserved only where it is actually painted: `ThinkingBlock::accent`
// returns `None` when collapsed, and reserving a column nothing paints
// would indent the header over a blank gutter. Collapsed reasoning has no
// body to delimit anyway — the folded `Thought for Xs` header cannot be
// mistaken for the answer. `only_thinking_spends_the_accent_column` pins
// reserved == painted so the two rules cannot drift apart.
let hide_accent = !matches!(entry.block, RenderBlock::Thinking(_))
|| entry.display_mode() == DisplayMode::Collapsed;
EntryRenderer::new(entry, theme)
.with_appearance(appearance)
.with_cwd(Some(cwd))
.with_tick(0)
// Blend committed blocks with the real terminal background (no
// user-message `bg_light` band etc.).
.with_tick(tick)
.with_flat_background(true)
// Drop the left accent bar for a cleaner, un-gutter'd minimal look; the
// per-block `◆`/bullet marker still reads the block boundary.
.with_hide_accent(true)
.with_hide_accent(hide_accent)
// The accent resolves to `Color::Reset` under the terminal-native
// palette — full-brightness default fg, which would shout.
.with_dim_accent(true)
}
/// Emit one committed block into native scrollback via `insert_before`, capping
@ -430,7 +457,7 @@ pub fn commit_active(app: &mut AppView, terminal: &mut PagerTerminal) {
}
// Stamp the print-once display mode before measuring/rendering.
if let Some(e) = sb.get_mut(i) {
let mode = minimal_commit_display_mode(&e.block);
let mode = minimal_commit_display_mode(&e.block, &appearance);
e.set_display_mode(mode);
}
if let Some(e) = sb.get(i) {
@ -444,7 +471,7 @@ pub fn commit_active(app: &mut AppView, terminal: &mut PagerTerminal) {
// place later (`get_by_id_mut` + edit, the `/recap` fill pattern)
// will NOT reach the screen — append a fresh block instead (see
// the `SessionRecap` handler in `acp_handler.rs`).
let renderer = committed_renderer(e, &theme, appearance.clone(), cwd);
let renderer = minimal_renderer(e, &theme, appearance.clone(), cwd, COMMITTED_TICK);
if insert_committed(terminal, renderer, width, max_rows, footer_style).is_err() {
return false;
}
@ -470,7 +497,7 @@ pub fn commit_active(app: &mut AppView, terminal: &mut PagerTerminal) {
// commit. Idempotent: `set_display_mode` no-ops when unchanged.
let mut j = minimal_api::commit_scan_cursor(sb);
while let Some(e) = sb.get_mut(j) {
let mode = minimal_commit_display_mode(&e.block);
let mode = minimal_commit_display_mode(&e.block, &appearance);
e.set_display_mode(mode);
j += 1;
}
@ -538,7 +565,7 @@ pub fn expand_pending(app: &mut AppView, terminal: &mut PagerTerminal) {
e.set_display_mode(DisplayMode::Expanded);
}
if let Some(e) = sb.get(idx) {
let renderer = committed_renderer(e, &theme, appearance.clone(), cwd);
let renderer = minimal_renderer(e, &theme, appearance.clone(), cwd, COMMITTED_TICK);
if insert_committed(terminal, renderer, width, 0, footer_style).is_err() {
// Terminal write failed: keep this id and the rest queued
// so the request retries next frame instead of vanishing.
@ -573,823 +600,5 @@ pub fn sync_pending_marks(app: &mut AppView) {
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
use xai_grok_pager::scrollback::block::RenderBlock;
use xai_grok_pager::scrollback::entry::ScrollbackEntry;
use xai_grok_pager::scrollback::state::ScrollbackState;
fn test_cwd() -> &'static std::path::Path {
std::path::Path::new("/test/session")
}
fn finalized(text: &str) -> ScrollbackEntry {
ScrollbackEntry::new(RenderBlock::stub(text, Color::Blue))
}
fn running(text: &str) -> ScrollbackEntry {
ScrollbackEntry::running(RenderBlock::stub(text, Color::Blue))
}
/// Run a commit pass for a RUNNING turn, returning the emitted indices.
fn commit_collect(state: &mut ScrollbackState) -> Vec<usize> {
let mut seen = Vec::new();
commit_leading_run(state, true, |_, i| {
seen.push(i);
true
});
seen
}
#[test]
fn commits_leading_finalized_run_and_stops_at_running() {
let mut s = ScrollbackState::new();
s.push(finalized("a"));
s.push(finalized("b"));
s.push(running("c"));
s.push(finalized("d")); // after the running block — must NOT commit yet
assert_eq!(commit_collect(&mut s), vec![0, 1]);
assert_eq!(minimal_api::commit_scan_cursor(&s), 2);
assert!(minimal_api::is_committed(&s, s.get(0).unwrap()));
assert!(minimal_api::is_committed(&s, s.get(1).unwrap()));
assert!(!minimal_api::is_committed(&s, s.get(2).unwrap()));
assert!(!minimal_api::is_committed(&s, s.get(3).unwrap()));
// Finalize "c"; the next pass commits "c" then "d".
s.get_mut(2).unwrap().mark_completed();
assert_eq!(commit_collect(&mut s), vec![2, 3]);
assert_eq!(minimal_api::commit_scan_cursor(&s), 4);
}
#[test]
fn pending_user_input_holds_the_frontier() {
let mut s = ScrollbackState::new();
s.push(finalized("a"));
let tool = s.push(finalized("tool")); // finalized but awaiting permission
s.push(finalized("after"));
assert!(s.set_pending_user_input(tool, true));
// Stops before the pending tool, even though it (and "after") are finalized.
assert_eq!(commit_collect(&mut s), vec![0]);
// Resolving the prompt releases the rest of the run.
assert!(s.set_pending_user_input(tool, false));
assert_eq!(commit_collect(&mut s), vec![1, 2]);
}
#[test]
fn running_agent_message_commits_once_a_later_block_exists() {
// The tracker leaves an agent message's `is_running` flag set until turn
// end (handle_tool_call resets current_agent_msg without finishing the
// entry when a tool follows). Minimal must still commit that message
// mid-turn once a later block proves it's complete — otherwise the rest
// of the turn piles up in the fixed-height live tail and scrolls instead
// of accumulating into native scrollback.
let mut s = ScrollbackState::new();
s.push(ScrollbackEntry::running(RenderBlock::agent_message(
"answer text",
)));
// While it's the last entry it may still be streaming → stays live.
assert_eq!(commit_collect(&mut s), Vec::<usize>::new());
assert_eq!(minimal_api::commit_scan_cursor(&s), 0);
// A later block (the tracker moved on) proves the message is done → it
// commits even though its is_running flag still lingers. The new
// last/running entry stays in the live tail.
s.push(running("tool"));
assert_eq!(commit_collect(&mut s), vec![0]);
assert!(minimal_api::is_committed(&s, s.get(0).unwrap()));
assert!(!minimal_api::is_committed(&s, s.get(1).unwrap()));
}
#[test]
fn running_tool_still_holds_the_frontier_even_with_a_later_block() {
// The agent-message relaxation must NOT extend to tools: a running tool
// can still update its result, so committing it (print-once) would lose
// the update. It holds the frontier regardless of later blocks.
let mut s = ScrollbackState::new();
s.push(finalized("a"));
s.push(running("running tool")); // stub == not an AgentMessage
s.push(finalized("after"));
assert_eq!(commit_collect(&mut s), vec![0]);
assert_eq!(minimal_api::commit_scan_cursor(&s), 1);
}
#[test]
fn bg_task_started_commits_while_running_and_does_not_wedge_frontier() {
// A fresh background task is pushed as a running "started" block
// (`set_last_running(true)`). Its `is_running` flag is animation-only —
// the block is a finalized lifecycle event whose content never changes —
// so it must commit immediately even mid-turn. Otherwise it wedges the
// frontier and the task (plus everything after it) stays hidden in the
// live tail until the task finishes (the reported dogfood bug).
let mut s = ScrollbackState::new();
s.push(finalized("a"));
s.push(ScrollbackEntry::running(RenderBlock::bg_task(
"sleep 60", "task-1",
)));
s.push(running("later tool")); // more turn output after the bg task
// "a" + the running bg task commit; only the trailing running tool stays.
assert_eq!(commit_collect(&mut s), vec![0, 1]);
assert!(minimal_api::is_committed(&s, s.get(1).unwrap()));
assert!(!minimal_api::is_committed(&s, s.get(2).unwrap()));
}
#[test]
fn bg_task_started_commits_as_last_running_entry() {
// Even as the last entry of a still-running turn the bg "started" block
// commits — a lifecycle block never streams more content (completion is
// a separate block).
let mut s = ScrollbackState::new();
s.push(finalized("a"));
s.push(ScrollbackEntry::running(RenderBlock::bg_task(
"sleep 60", "task-1",
)));
assert_eq!(commit_collect(&mut s), vec![0, 1]);
}
#[test]
fn no_double_commit_after_mid_list_shift_remove() {
let mut s = ScrollbackState::new();
let a = s.push(finalized("a"));
s.push(finalized("b"));
s.push(finalized("c"));
assert_eq!(commit_collect(&mut s), vec![0, 1, 2]);
assert_eq!(minimal_api::commit_scan_cursor(&s), 3);
// Remove an already-committed entry below the cursor (shift_remove shifts
// the remaining indices down). The cursor is clamped; the per-entry
// `committed` flags travel with "b"/"c", so neither is re-emitted.
assert!(s.remove_entry(a));
s.push(finalized("d")); // now at index 2
assert_eq!(commit_collect(&mut s), vec![2]);
assert!(minimal_api::is_committed(&s, s.get(0).unwrap())); // b
assert!(minimal_api::is_committed(&s, s.get(1).unwrap())); // c
assert!(minimal_api::is_committed(&s, s.get(2).unwrap())); // d
}
#[test]
fn mid_list_removal_below_cursor_does_not_strand_uncommitted_entries() {
// Regression (review bug 2): a committed placeholder ("Loading
// session...") is removed AFTER new uncommitted entries were appended
// past the cursor — the `/resume` / reconnect `SessionLoaded` ordering.
// Removing below the cursor shifts the uncommitted entries down one;
// without the cursor decrement in `remove_entry` the first of them
// slid below the cursor and was never committed NOR drawn in the live
// tail (silently missing from minimal mode).
let mut s = ScrollbackState::new();
s.push(finalized("old-1"));
let placeholder = s.push(finalized("Loading session..."));
// A draw commits both; cursor = 2.
let mut seen = Vec::new();
commit_leading_run(&mut s, false, |_, i| {
seen.push(i);
true
});
assert_eq!(seen, vec![0, 1]);
assert_eq!(minimal_api::commit_scan_cursor(&s), 2);
// Replay appends entries, then the placeholder is removed in the same
// event cycle (before the next commit pass).
s.push(finalized("replayed-A"));
s.push(finalized("replayed-B"));
assert!(s.remove_entry(placeholder));
// The cursor moved down with the shifted entries.
assert_eq!(minimal_api::commit_scan_cursor(&s), 1);
// The next pass commits BOTH replayed entries — none stranded.
let mut seen = Vec::new();
commit_leading_run(&mut s, false, |_, i| {
seen.push(i);
true
});
assert_eq!(seen, vec![1, 2]);
assert!(minimal_api::is_committed(&s, s.get(1).unwrap())); // replayed-A
assert!(minimal_api::is_committed(&s, s.get(2).unwrap())); // replayed-B
}
#[test]
fn pending_user_input_holds_the_frontier_even_when_idle() {
// A block awaiting a permission / question answer must never commit,
// even if the turn state reads idle (e.g. a prompt outliving its turn):
// its rendered form still changes when the prompt resolves, and a
// committed copy is frozen. The idle relaxation only applies to
// *stale-running* flags, not pending-input marks.
let mut s = ScrollbackState::new();
s.push(finalized("a"));
let tool = s.push(finalized("tool"));
assert!(s.set_pending_user_input(tool, true));
let mut seen = Vec::new();
commit_leading_run(&mut s, false, |_, i| {
seen.push(i);
true
});
assert_eq!(seen, vec![0], "pending entry must hold the frontier");
assert!(!minimal_api::is_committed(&s, s.get(1).unwrap()));
// Resolving the prompt releases it.
assert!(s.set_pending_user_input(tool, false));
let mut seen = Vec::new();
commit_leading_run(&mut s, false, |_, i| {
seen.push(i);
true
});
assert_eq!(seen, vec![1]);
}
#[test]
fn failed_emit_leaves_entry_uncommitted_for_retry() {
// Regression (bugbot "Committed flag set on IO failure"): a terminal
// write failure must NOT mark the entry committed — print-once means a
// marked-but-unprinted block can never be emitted again. The walk stops
// with the cursor before the failed entry and retries next frame.
let mut s = ScrollbackState::new();
s.push(finalized("a"));
s.push(finalized("b"));
// First pass: the emit fails on the first entry.
let mut calls = 0usize;
let n = commit_leading_run(&mut s, false, |_, _| {
calls += 1;
false
});
assert_eq!(n, 0, "nothing committed on failure");
assert_eq!(calls, 1, "walk stops at the first failure");
assert!(!minimal_api::is_committed(&s, s.get(0).unwrap()));
assert_eq!(minimal_api::commit_scan_cursor(&s), 0, "cursor holds");
// Retry pass succeeds and commits both.
let n = commit_leading_run(&mut s, false, |_, _| true);
assert_eq!(n, 2);
assert!(minimal_api::is_committed(&s, s.get(0).unwrap()));
assert!(minimal_api::is_committed(&s, s.get(1).unwrap()));
}
#[test]
fn scan_frontier_mirrors_commit_leading_run() {
// `scan_frontier` (read-only: viewport sizing + the will-commit gate)
// must agree exactly with the mutating walk, in every phase.
let mut s = ScrollbackState::new();
s.push(finalized("a"));
s.push(finalized("b"));
s.push(running("c"));
s.push(finalized("d"));
// Pre-commit: the pass would commit a+b and stop at the running entry.
let scan = scan_frontier(&s, true);
assert!(scan.will_commit);
assert_eq!(scan.tail_start, 2);
let n = commit_leading_run(&mut s, true, |_, _| true);
assert_eq!(n, 2);
assert_eq!(minimal_api::commit_scan_cursor(&s), scan.tail_start);
// Post-commit: nothing left to commit; the tail starts at the cursor.
let scan = scan_frontier(&s, true);
assert!(!scan.will_commit);
assert_eq!(scan.tail_start, 2);
// Idle with no entries pending: everything committable.
let scan = scan_frontier(&s, false);
assert!(scan.will_commit);
assert_eq!(scan.tail_start, 4);
}
#[test]
fn remove_from_below_frontier_then_push_still_commits() {
let mut s = ScrollbackState::new();
s.push(finalized("a"));
s.push(finalized("b"));
s.push(finalized("c"));
assert_eq!(commit_collect(&mut s), vec![0, 1, 2]);
// Rewind: drop everything from index 1 (keep only "a"). Without the
// cursor clamp this would strand the cursor at 3 and silently skip the
// next pushes.
let removed = s.remove_from(1);
assert_eq!(removed.len(), 2);
assert_eq!(minimal_api::commit_scan_cursor(&s), 1);
s.push(finalized("d")); // index 1
assert_eq!(commit_collect(&mut s), vec![1]);
}
#[test]
fn btw_block_emits_once_across_repeated_frontier_passes() {
let mut s = ScrollbackState::new();
s.push(ScrollbackEntry::new(RenderBlock::Btw(
xai_grok_pager::scrollback::blocks::BtwBlock::new(
"original question",
"original answer",
),
)));
let mut emitted = Vec::new();
assert_eq!(
commit_leading_run(&mut s, false, |state, i| {
let RenderBlock::Btw(block) = &state.get(i).unwrap().block else {
panic!("expected Btw block")
};
assert_eq!(block.question, "original question");
assert_eq!(block.content().text(), "original answer");
emitted.push(i);
true
}),
1
);
assert!(minimal_api::is_committed(&s, s.get(0).unwrap()));
assert_eq!(
commit_leading_run(&mut s, false, |_, i| {
emitted.push(i);
true
}),
0
);
assert_eq!(emitted, vec![0]);
assert!(!scan_frontier(&s, false).will_commit);
}
#[test]
fn commit_leading_run_advances_frontier_and_marks_committed_once() {
let mut s = ScrollbackState::new();
s.push(finalized("h1"));
s.push(finalized("h2"));
s.push(finalized("h3"));
// Advances the frontier, marking the leading finalized run committed.
let mut emitted = Vec::new();
let n = commit_leading_run(&mut s, false, |_, i| {
emitted.push(i);
true
});
assert_eq!(n, 3);
assert_eq!(emitted, vec![0, 1, 2]);
assert_eq!(minimal_api::commit_scan_cursor(&s), 3);
assert!((0..3).all(|i| minimal_api::is_committed(&s, s.get(i).unwrap())));
// A second pass commits nothing (already-committed entries are skipped).
let mut again = Vec::new();
commit_leading_run(&mut s, false, |_, i| {
again.push(i);
true
});
assert!(again.is_empty());
}
#[test]
fn idle_turn_commits_past_stale_running_entry() {
// Regression for the missing-edit/stuck-spinner bug: the agent tracker
// can leave an entry's `is_running` flag set after the turn ends (e.g. a
// thinking block whose finalize was missed at the thinking→tool
// transition). While the turn runs, that entry correctly holds the
// frontier; once the turn is idle the frontier must advance past it.
let mut s = ScrollbackState::new();
s.push(finalized("a"));
s.push(running("stale")); // stale is_running flag
s.push(finalized("c"));
// Running turn: blocked at the running entry.
let mut seen = Vec::new();
commit_leading_run(&mut s, true, |_, i| {
seen.push(i);
true
});
assert_eq!(seen, vec![0]);
assert_eq!(minimal_api::commit_scan_cursor(&s), 1);
// Idle turn: commit everything past the stale flag.
let mut seen = Vec::new();
commit_leading_run(&mut s, false, |_, i| {
seen.push(i);
true
});
assert_eq!(seen, vec![1, 2]);
assert_eq!(minimal_api::commit_scan_cursor(&s), 3);
}
#[test]
fn clear_resets_the_frontier() {
let mut s = ScrollbackState::new();
s.push(finalized("a"));
commit_collect(&mut s);
assert_eq!(minimal_api::commit_scan_cursor(&s), 1);
s.clear();
assert_eq!(minimal_api::commit_scan_cursor(&s), 0);
}
/// Height-exactness guard (design K5 / risk #1). `commit_active` reserves
/// exactly `desired_height(width)` rows via `insert_before`; if `render`
/// paints real content beyond that, those rows are silently clipped (lost)
/// from native scrollback. Render each block type into an over-tall buffer
/// and assert no non-space glyph lands past `desired_height`. (Background
/// fill of blank spaces past `h` is fine — only real content matters.)
fn assert_committed_fits(label: &str, block: RenderBlock, width: u16) {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let mut entry = ScrollbackEntry::new(block);
entry.set_display_mode(minimal_commit_display_mode(&entry.block));
let theme = Theme::current();
let appearance = committed_appearance(&AppearanceConfig::default());
let renderer = committed_renderer(&entry, &theme, appearance, test_cwd());
let h = renderer.desired_height(width);
assert!(h > 0, "{label}@{width}: desired_height was 0");
// The accent bar and background fill intentionally stretch to the given
// area height (chrome, not content). Only the content columns
// (x >= chrome_width) carry real text that `insert_before` would clip.
let chrome = renderer.chrome_width();
let extra = 8u16;
let area = Rect::new(0, 0, width, h + extra);
let mut buf = Buffer::empty(area);
renderer.render(area, &mut buf);
for y in h..(h + extra) {
for x in chrome..width {
let sym = buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" ");
assert!(
sym.trim().is_empty(),
"{label}@{width}: content {sym:?} painted at row {y} col {x}, past \
desired_height {h} insert_before would clip it from scrollback"
);
}
}
}
#[test]
fn committed_renderer_uses_owning_session_cwd_for_tool_paths() {
use ratatui::buffer::Buffer;
let cwd = std::path::Path::new("/alternate/worktree");
let mut entry =
ScrollbackEntry::new(RenderBlock::edit("/alternate/worktree/src/main.rs", None));
entry.set_display_mode(DisplayMode::Expanded);
let theme = Theme::current();
let appearance = committed_appearance(&AppearanceConfig::default());
let renderer = committed_renderer(&entry, &theme, appearance, cwd);
let width = 80;
let height = renderer.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
renderer.render(area, &mut buf);
let mut text = String::new();
for y in 0..height {
for x in 0..width {
text.push_str(buf[(x, y)].symbol());
}
}
assert!(text.contains("src/main.rs"), "rendered text: {text:?}");
assert!(
!text.contains("/alternate/worktree"),
"session prefix should be elided: {text:?}"
);
}
#[test]
fn committed_blocks_fit_desired_height() {
// Thinking blocks render zero rows unless `show_thinking_blocks` is on.
// The toggle is a thread-local, so pin it on here so the thinking
// block's committed height is actually exercised.
minimal_api::set_show_thinking_blocks(true);
let long = "Hello there — this is a longer message that should wrap across \
several lines at narrow widths to exercise the wrapping math, with \
enough words to overflow eighty columns comfortably.";
for width in [40u16, 80, 120] {
assert_committed_fits("user_prompt", RenderBlock::user_prompt(long), width);
assert_committed_fits(
"agent_message",
RenderBlock::agent_message(format!(
"{long}\n\n- bullet one\n- bullet two\n\n```rust\nfn main() {{}}\n```"
)),
width,
);
assert_committed_fits("thinking", RenderBlock::thinking(long), width);
assert_committed_fits(
"execute",
RenderBlock::execute_with_output(
"cargo build --release",
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6",
None::<String>,
),
width,
);
assert_committed_fits("edit", RenderBlock::edit("src/main.rs", None), width);
assert_committed_fits("read", RenderBlock::read("src/main.rs", None), width);
assert_committed_fits(
"list_dir",
RenderBlock::list_dir_with_output("src", "a.rs\nb.rs\nc.rs"),
width,
);
assert_committed_fits("search", RenderBlock::search("TODO", 0, vec![]), width);
assert_committed_fits("system", RenderBlock::system("Session restored"), width);
assert_committed_fits("bg_task", RenderBlock::bg_task("sleep 30", "task-1"), width);
}
}
/// Regression pinned: `md_style::to_anstyle` used to map `Color::Reset`
/// to a concrete ANSI-7 silver, washing out assistant/thinking markdown
/// body text on light terminals; `highlight_bash_command` leaked raw
/// syntect RGB.
#[test]
fn terminal_native_lock_paints_only_native_colors() {
use ratatui::buffer::Buffer;
use xai_grok_pager::theme::cache as theme_cache;
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
struct LockReset;
impl Drop for LockReset {
fn drop(&mut self) {
xai_grok_pager::theme::cache::set_terminal_native_lock(false);
}
}
let _reset = LockReset;
theme_cache::set_terminal_native_lock(true);
minimal_api::set_show_thinking_blocks(true);
let md = "Intro paragraph with **bold**, _italic_, `inline code`, and a \
[link](https://example.com).\n\n# Heading one\n\n## Heading two\n\n\
- item one\n- item two\n\n> a quote\n\n```rust\nfn main() { println!(\"hi\"); }\n```";
use similar::ChangeTag;
let hunk = vec![
xai_grok_pager::diff::DiffLine {
text: "let x = 1;\n".into(),
lo: 1,
ln: 1,
tag: ChangeTag::Equal,
},
xai_grok_pager::diff::DiffLine {
text: "let y = 2;\n".into(),
lo: 2,
ln: 0,
tag: ChangeTag::Delete,
},
xai_grok_pager::diff::DiffLine {
text: "let y = 3;\n".into(),
lo: 0,
ln: 2,
tag: ChangeTag::Insert,
},
];
let blocks = vec![
("agent_message", RenderBlock::agent_message(md)),
(
"thinking",
RenderBlock::thinking("Weighing the *options* with `care`."),
),
("user_prompt", RenderBlock::user_prompt("run the tests")),
(
"edit",
RenderBlock::edit_with_hunks("src/main.rs", vec![hunk]),
),
(
"execute",
RenderBlock::execute_with_output("cargo build", "ok\n", None::<String>),
),
("system", RenderBlock::system("Session restored")),
];
for (label, block) in blocks {
let mut entry = ScrollbackEntry::new(block);
entry.set_display_mode(minimal_commit_display_mode(&entry.block));
let theme = Theme::current();
let appearance = committed_appearance(&AppearanceConfig::default());
let renderer = committed_renderer(&entry, &theme, appearance, test_cwd());
let width = 100u16;
let h = renderer.desired_height(width).max(1);
let area = Rect::new(0, 0, width, h);
let mut buf = Buffer::empty(area);
renderer.render(area, &mut buf);
for y in 0..h {
for x in 0..width {
let Some(cell) = buf.cell((x, y)) else {
continue;
};
for (which, c) in [("fg", cell.fg), ("bg", cell.bg)] {
assert!(
!matches!(c, Color::Rgb(..) | Color::Indexed(_)),
"{label}: non-native {which} {c:?} at ({x},{y}) under \
symbol {:?} minimal must only use Reset / named \
ANSI-16 so the terminal palette controls rendering",
cell.symbol()
);
}
}
}
}
}
#[test]
fn large_commit_is_capped_with_footer() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let appearance = committed_appearance(&AppearanceConfig::default());
// A tall block: a fenced code block keeps each line on its own row
// (markdown would otherwise join soft-wrapped prose into one paragraph),
// so the block is comfortably taller than the cap.
let lines: Vec<String> = (0..60).map(|i| format!("line {i}")).collect();
let body = format!("```\n{}\n```", lines.join("\n"));
let mut entry = ScrollbackEntry::new(RenderBlock::agent_message(body));
entry.set_display_mode(minimal_commit_display_mode(&entry.block));
let width = 80u16;
let renderer = committed_renderer(&entry, &theme, appearance, test_cwd());
let full_h = renderer.desired_height(width);
assert!(full_h > 12, "expected a tall block, got {full_h}");
// Paint into a cap-height buffer (what `insert_committed` allocates).
let cap = 12u16;
let area = Rect::new(0, 0, width, cap);
let mut buf = Buffer::empty(area);
paint_committed(&mut buf, renderer, width, full_h, theme.dim());
// The final row is the overflow footer naming the hidden line count and
// pointing at /transcript; the buffer is exactly `cap` rows (bounded).
let last: String = (0..width)
.filter_map(|x| buf.cell((x, cap - 1)).map(|c| c.symbol().to_string()))
.collect();
assert!(last.contains("more lines"), "footer row: {last:?}");
assert!(last.contains("/transcript"), "footer row: {last:?}");
// A hidden-line count is present (full_h minus the kept content rows).
let hidden = full_h - (cap - 1);
assert!(
last.contains(&hidden.to_string()),
"footer should name {hidden} hidden lines: {last:?}"
);
}
#[test]
fn small_commit_is_not_capped() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let appearance = committed_appearance(&AppearanceConfig::default());
let mut entry = ScrollbackEntry::new(RenderBlock::agent_message("one short line"));
entry.set_display_mode(minimal_commit_display_mode(&entry.block));
let width = 80u16;
let renderer = committed_renderer(&entry, &theme, appearance, test_cwd());
let full_h = renderer.desired_height(width);
// Buffer is exactly the block's height → no footer (uncapped path).
let area = Rect::new(0, 0, width, full_h);
let mut buf = Buffer::empty(area);
paint_committed(&mut buf, renderer, width, full_h, theme.dim());
let mut all = String::new();
for y in 0..full_h {
for x in 0..width {
all.push_str(buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "));
}
}
assert!(
!all.contains("more lines"),
"no footer when uncapped: {all:?}"
);
}
#[test]
fn committed_edit_keeps_diff_line_backgrounds() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use similar::ChangeTag;
use xai_grok_pager::diff::DiffLine;
let hunk = vec![
DiffLine {
text: "let x = 1;\n".into(),
lo: 10,
ln: 10,
tag: ChangeTag::Equal,
},
DiffLine {
text: "let y = 2;\n".into(),
lo: 11,
ln: 0,
tag: ChangeTag::Delete,
},
DiffLine {
text: "let y = 3;\n".into(),
lo: 0,
ln: 11,
tag: ChangeTag::Insert,
},
DiffLine {
text: "let z = 4;\n".into(),
lo: 12,
ln: 12,
tag: ChangeTag::Equal,
},
];
let block = RenderBlock::edit_with_hunks("src/main.rs", vec![hunk]);
let mut entry = ScrollbackEntry::new(block);
entry.set_display_mode(minimal_commit_display_mode(&entry.block));
let theme = Theme::current();
let appearance = committed_appearance(&AppearanceConfig::default());
let renderer = committed_renderer(&entry, &theme, appearance, test_cwd());
let width = 80u16;
let h = renderer.desired_height(width);
let area = Rect::new(0, 0, width, h);
let mut buf = Buffer::empty(area);
renderer.render(area, &mut buf);
// The committed edit uses a flat background (terminal transparency), but
// must still paint the per-line diff backgrounds — otherwise an added /
// removed line is indistinguishable from context.
let mut saw_insert = false;
let mut saw_delete = false;
for y in 0..h {
for x in 0..width {
if let Some(cell) = buf.cell((x, y)) {
saw_insert |= cell.bg == theme.diff_insert_bg;
saw_delete |= cell.bg == theme.diff_delete_bg;
}
}
}
assert!(
saw_insert,
"committed edit lost the insert (green) diff background"
);
assert!(
saw_delete,
"committed edit lost the delete (red) diff background"
);
}
#[test]
fn commit_display_mode_policy() {
assert_eq!(
minimal_commit_display_mode(&RenderBlock::thinking("reasoning")),
DisplayMode::Expanded
);
assert_eq!(
minimal_commit_display_mode(&RenderBlock::edit("file.rs", None)),
DisplayMode::Expanded
);
assert_eq!(
minimal_commit_display_mode(&RenderBlock::execute("ls")),
DisplayMode::Truncated
);
assert_eq!(
minimal_commit_display_mode(&RenderBlock::agent_message("hi")),
DisplayMode::Expanded
);
}
#[test]
fn commit_display_mode_lookups_collapse_on_success_only() {
use xai_grok_pager::scrollback::blocks::{
ListDirToolCallBlock, ReadToolCallBlock, SearchToolCallBlock,
};
assert_eq!(
minimal_commit_display_mode(&RenderBlock::search("pat", 3, vec![])),
DisplayMode::Collapsed
);
assert_eq!(
minimal_commit_display_mode(&RenderBlock::read("src/lib.rs", None)),
DisplayMode::Collapsed
);
assert_eq!(
minimal_commit_display_mode(&RenderBlock::list_dir_with_output("src", "a.rs\nb.rs")),
DisplayMode::Collapsed
);
for failed in [
RenderBlock::ToolCall(ToolCallBlock::Search(
SearchToolCallBlock::new("pat").with_error("regex parse error"),
)),
RenderBlock::ToolCall(ToolCallBlock::Read(
ReadToolCallBlock::new("gone.rs").with_error("file not found"),
)),
RenderBlock::ToolCall(ToolCallBlock::ListDir(
ListDirToolCallBlock::new("gone/").with_error("no such directory"),
)),
] {
assert_eq!(
minimal_commit_display_mode(&failed),
DisplayMode::Truncated,
"failed lookup must stay truncated: {failed:?}"
);
}
}
}
#[path = "commit_tests.rs"]
mod tests;

File diff suppressed because it is too large Load diff

View file

@ -367,6 +367,41 @@ mod tests {
);
}
/// `/transcript` is the escape hatch `minimal_collapse_thinking` leans on,
/// so it must ignore the committed display mode.
#[test]
fn transcript_expands_thinking_committed_collapsed() {
let theme = Theme::current();
let appearance = super::super::commit::committed_appearance(
&xai_grok_pager::appearance::AppearanceConfig {
minimal_collapse_thinking: true,
..Default::default()
},
);
let mut entry = ScrollbackEntry::new(RenderBlock::thinking(
"REASONINGBODY folded away at commit time",
));
entry.set_display_mode(super::super::commit::minimal_commit_display_mode(
&entry.block,
&appearance,
));
assert_eq!(entry.display_mode(), DisplayMode::Collapsed);
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
let mut out = String::new();
render_entry_to_ansi(&entry, &theme, &appearance, test_cwd(), &mut out);
xai_grok_pager::appearance::cache::set_show_thinking_blocks(false);
assert!(
out.contains("REASONINGBODY"),
"a collapsed commit must still expand in /transcript: {out:?}"
);
assert!(
!out.contains("ctrl+e to expand"),
"no expand hint in the fully-expanded transcript: {out:?}"
);
}
#[test]
fn transcript_uses_owning_session_cwd_for_tool_paths() {
let theme = Theme::current();

View file

@ -403,12 +403,7 @@ fn live_tail_renderer<'a>(
cwd: &'a std::path::Path,
tick: u64,
) -> EntryRenderer<'a> {
EntryRenderer::new(entry, theme)
.with_appearance(appearance.clone())
.with_cwd(Some(cwd))
.with_tick(tick)
.with_flat_background(true)
.with_hide_accent(true)
super::commit::minimal_renderer(entry, theme, appearance.clone(), cwd, tick)
}
/// Render the uncommitted tail (entries past the commit frontier), bottom-anchored
/// so the most recent output is always visible; the topmost visible entry is
@ -813,6 +808,46 @@ mod tests {
painted_height.saturating_add(super::super::commit::MINIMAL_BLOCK_GAP)
);
}
/// The tail and the committed footprint are one builder with a different
/// tick; this is the net for anyone tempted to fork them again.
#[test]
fn the_animation_tick_never_changes_a_blocks_height() {
use xai_grok_pager::scrollback::RenderBlock;
use xai_grok_pager::scrollback::entry::ScrollbackEntry;
minimal_api::set_show_thinking_blocks(true);
let theme = Theme::current();
let cwd = std::path::PathBuf::from("/tmp");
let appearance = super::super::commit::committed_appearance(
&xai_grok_pager::appearance::AppearanceConfig::default(),
);
let long = "reasoning that wraps a good few times even at a hundred and \
twenty columns because it simply keeps going and going and going";
for block in [
RenderBlock::thinking(long),
RenderBlock::agent_message(long),
RenderBlock::execute("ls -la"),
] {
let entry = ScrollbackEntry::new(block);
for width in [20u16, 40, 80, 120] {
let live =
live_tail_renderer(&entry, &theme, &appearance, &cwd, 7).desired_height(width);
let committed = live_tail_renderer(
&entry,
&theme,
&appearance,
&cwd,
super::super::commit::COMMITTED_TICK,
)
.desired_height(width);
assert_eq!(
live, committed,
"{:?} @{width}: a block's height must not depend on the tick, or the \
prompt jumps on commit",
entry.block
);
}
}
}
#[test]
fn minimal_status_shows_rich_activity_and_idle_hint() {
use xai_grok_pager::acp::tracker::TurnActivity;

View file

@ -74,6 +74,12 @@ fn plan_scrollback_body(plan_content: Option<&str>) -> String {
/// a short notice so the user sees *why* approval is parked (otherwise only the
/// controls strip appears and the session looks stuck).
///
/// The block is anchored **above** the still-running `exit_plan_mode` tool row,
/// not appended after it, so the commit frontier reaches the plan while the
/// approval is still parked. Users reported losing the head of a plan to the
/// clipped live tail; design doc §6.16 has the full argument and the rejected
/// alternatives.
///
/// NOTE (draw-path state mutation + replay durability): this pushes into
/// `ScrollbackState` from the render path — a deliberate exception, since the
/// plan block must enter the normal commit pipeline. The pushed block is
@ -110,9 +116,17 @@ pub fn maybe_commit_plan(app: &mut AppView) {
// if it ever did, stamping the id anyway would treat the plan as committed
// while nothing ever reaches native scrollback.
if let Some(agent) = app.agents.get_mut(&id) {
agent
.scrollback
.push_block(RenderBlock::agent_message(content));
let block = RenderBlock::agent_message(content);
// No anchor (the tool was reaped): append, and the plan commits at turn
// end — the pre-fix behavior, still better than dropping it.
match minimal_api::pending_tool_entry_id(agent, &tool_call_id) {
Some(anchor) => {
agent.scrollback.insert_block_before(anchor, block);
}
None => {
agent.scrollback.push_block(block);
}
}
minimal_api::set_minimal_committed_plan_id(app, Some(tool_call_id));
}
}