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:
parent
02d9359435
commit
5da6962e4a
192 changed files with 10337 additions and 3421 deletions
|
|
@ -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;
|
||||
|
|
|
|||
1158
crates/codegen/xai-grok-pager-minimal/src/commit_tests.rs
Normal file
1158
crates/codegen/xai-grok-pager-minimal/src/commit_tests.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue