feat: added scrollback rebuild controls and prewalk status-line visibility

- Added `tui.scrollbackRebuild` configuration with interactive startup/controller wiring to apply `setScrollbackRebuild`.
- Exposed prewalk session state in `SegmentContext` and rendered a dedicated prewalk segment/icon in the status line.
- Added divergence-aware TUI full-paint logic that enables scrollback erase-and-replay rebuilds for non-multiplexer divergence cases.
- Updated rendering and streaming tests to verify rebuild behavior (`3J`) and eliminate stale marker expectations under drift scenarios.
This commit is contained in:
can1357
2026-07-14 00:39:34 +02:00
parent f9f6ed9e8d
commit da24614d5a
22 changed files with 418 additions and 310 deletions
+2 -6
View File
@@ -4,12 +4,8 @@
### Added
- Added automated image-dropping rescue tier to compaction dead-end recovery
- Added visual warnings to the session timeline when compaction fails to free sufficient space
### Changed
- Improved compaction dead-end notifications with specific recovery instructions
- Added an automated image-dropping rescue tier to compaction dead-end recovery.
- Added visual warnings and detailed recovery instructions to the session timeline when compaction fails to free sufficient space.
## [16.4.5] - 2026-07-11
+7 -8
View File
@@ -4,20 +4,19 @@
### Added
- Added diagnostic response headers to auth-gateway inference endpoints: `x-request-id`/`request-id` (correlates with gateway logs; surfaced by OpenAI/Anthropic SDKs) and LiteLLM-style `x-litellm-model-id`/`x-litellm-model-api-base` on every response, plus `x-litellm-response-cost`, `x-litellm-response-duration-ms`, and `openai-processing-ms` on non-streaming responses
- Added diagnostic response headers to auth-gateway inference endpoints, including request IDs (x-request-id/request-id), LiteLLM model metadata (x-litellm-model-id/x-litellm-model-api-base), and performance/cost metrics (x-litellm-response-cost, x-litellm-response-duration-ms, openai-processing-ms) on non-streaming responses.
### Changed
- Switched Google and Google Vertex providers to always use `streamGenerateContent` requests
### Removed
- Removed automatic `/interactions` chaining for follow-up turns in Google provider calls
- Removed `useInteractionsApi`, `storeInteraction`, and `previousInteractionId` from stream options
- Updated Google and Google Vertex providers to always use streamGenerateContent requests.
### Fixed
- Fixed empty provider responses (e.g. "Cloud Code Assist API returned an empty response") being classified as non-retryable: `ProviderResponseError` with kind `empty-body` now carries the transient flag, so session retry and configured model-fallback chains engage instead of hard-failing the turn
- Fixed empty provider responses (such as from Cloud Code Assist API) being classified as non-retryable, allowing session retries and model-fallback chains to engage instead of failing the turn.
### Removed
- Removed automatic /interactions chaining for follow-up turns in Google provider calls, along with the useInteractionsApi, storeInteraction, and previousInteractionId stream options.
## [16.4.6] - 2026-07-12
+30 -37
View File
@@ -4,54 +4,47 @@
### Breaking Changes
- Replaced the `--reasoning-slide-*` flag family (`--reasoning-slide-model`, `--reasoning-slide-turns`, `--reasoning-slide-on-action`, `--reasoning-slide-plan`, `--reasoning-slide-plan-at`, `--reasoning-slide-checklist`) with a single prewalk mechanism: `--prewalk` switches from the starting model to a fast/cheap target at the first completed turn that starts execution — the todo-list init the plan nudge asks for, or any edit/write tool — always with the hidden plan nudge before the switch and the verify-before-finishing checklist after it (the configuration that won benchmark testing). `--prewalk-into <model>` overrides the default "smol"-role target and implies `--prewalk`; `--no-prewalk` force-disables. The fixed-turn trigger and per-piece plan/checklist toggles are gone.
- Replaced the `--reasoning-slide-*` flag family with a unified `--prewalk` mechanism (`--prewalk`, `--prewalk-into <model>`, and `--no-prewalk`) to manage model handoffs during execution.
### Added
- Added `--prewalk` / `--prewalk-into <model>` / `--no-prewalk`: start on a strong model, then hand off to a fast/cheap one (default the `smol` role) at the first edit/write tool call *after* the todo list has been initialized. The starting model handles all planning and todo initialization, and begins implementation, before handing off; the fast model includes a verify checklist before finishing. Enable per-user with the `prewalk.enabled` setting; force it mid-session with the new `/prewalk` slash command, which arms the switch.
- Added display setting to toggle between collapsing or keeping compacted history inline, now applied to live session displays
- Added a compact session-only model picker (Alt+P) for quick model switching without changing roles
- Added `@` search to the Alt+P / `/switch` picker: it lists configured Ctrl+P quick roles in matching segment colors and applies the selected role's model and thinking for the current session.
- Redesigned Agent Hub entries as two-line cards: identity (status glyph, name, agent type, parent when nested) on the left, active model + reasoning level and age right-aligned, with the task description on its own line; dropped the redundant `sub · of Main` noise
- Added a project-scoped `launch` tool for shared long-running services and debuggers, with readiness probes, bounded logs, PTY input, restart policies, and automatic teardown after the last omp instance exits. Gated behind the `launch.enabled` setting (default on); when disabled the tool is withdrawn and the bash prompt drops its "use launch" guidance.
- Added `detached` `launch` starts for standalone services that survive every omp instance and broker shutdown, then reconnect to the next broker for logs and explicit stop.
- Added a new `--prewalk` execution flow (with `--prewalk-into <model>` and `--no-prewalk` overrides) that starts tasks on a strong model for planning and todo initialization before handing off to a faster, cheaper model for implementation.
- Added a status line annotation for the active prewalk phase (armed or active).
- Added the `tui.scrollbackRebuild` setting to gate the erase-and-replay native scrollback rebuild mechanism (defaults to off).
- Added a display setting to toggle between collapsing or keeping compacted history inline in live session displays.
- Added a compact session-only model picker (Alt+P) for quick model switching, featuring `@` search to quickly list and apply configured quick roles.
- Redesigned Agent Hub entries into a cleaner two-line card layout showing identity, active model, reasoning level, age, and task description.
- Added a project-scoped `launch` tool (gated by `launch.enabled`) for managing shared long-running services and debuggers, featuring readiness probes, bounded logs, PTY input, restart policies, and automatic teardown.
- Added support for `detached` launches, allowing standalone services to survive broker shutdowns and reconnect to subsequent sessions.
### Changed
- Updated `--mode json` logs to include provider payloads in auto-compaction events
- Refined prewalk planning to require 5-9 meaningful todo items with concrete targets and checks
- Updated tangential agent forks to ignore parent session history and focus exclusively on the new request
- Hardened `/tan` fork isolation: the clone's inherited todo list is cleared at fork (parent todo reminders no longer drag the tan back onto the parent's task), the fork notice warns that the parent is concurrently editing the same working directory, and the notice is re-injected after each compaction so the fork boundary survives summarization
- Added visual markers in the transcript for elided tool calls that have no corresponding result
- Updated status event log to prioritize the most recent entries in the display window
- Updated the snapcompact shape preview transcript to use the compact scope format shown to models during compaction.
- Bumped `@agentclientprotocol/sdk` 0.25.0 → 1.2.1 (major); patched the package's `exports` map to restore the `dist/schema/zod.gen.js` subpath the SDK no longer publishes, which our tests import for response-shape validation.
### Removed
- Removed the `--prewalk-boomerang` feature and its associated configuration setting
- Removed the unreliable Bing and Yahoo HTML-scraping web search providers
- Updated JSON logs (`--mode json`) to include provider payloads in auto-compaction events.
- Updated tangential agent forks (`/tan`) to ignore parent session history and focus exclusively on the new request, hardening isolation with cleared todo lists and concurrent editing warnings.
- Added visual markers in the transcript for elided tool calls that have no corresponding result.
- Updated the status event log to prioritize the most recent entries in the display window.
- Upgraded `@agentclientprotocol/sdk` to version 1.2.1.
### Fixed
- Fixed expanded (ctrl+O) streaming edit previews duplicating the tool box in terminal scrollback: an unbounded live diff scrolled above the native-scrollback commit boundary mid-stream, freezing a stale preview snapshot that the finalized render then recommitted below. Expanded previews now use a viewport-sized tail window; the full diff still renders once the result finalizes.
- Fixed configured custom model roles resolving from `--model`; canonical role selectors now use `@role`, legacy selectors remain supported, and `*` selects the default role. Thinking suffixes split correctly off the bare `*` alias (`*:xhigh`), thinking selectors accept unambiguous abbreviations (`:xhi`, `:med`) on every surface, and role aliases now also work in `--models` and `enabledModels` scopes (each role contributes its resolved model).
- Fixed quadratic growth of `--mode json` logs by eliding redundant message snapshots and payloads
- Fixed `/tan` and `/fork` clones cold-missing the provider prompt cache: the per-turn supersede/useless-result prune rewrote the live context without persisting it, so file-based forks and resume rebuilt a divergent (un-pruned) prefix and re-wrote the entire cache
- Fixed `/tan` pinning the clone's prompt-cache key to the parent's session id instead of the parent's effective cache key, dropping shard affinity when the parent was itself a fork or tan
- Fixed inconsistent history rendering when toggling the display setting for compacted items
- Fixed configured `retry.fallbackChains` never engaging on non-retryable provider errors (e.g. "Cloud Code Assist API returned an empty response"): a hard error on a model covered by a fallback chain now switches to the next candidate instead of failing the turn, while still never backoff-retrying the failing model itself
- Fixed transcript rebuilds (compaction, `/compact`, and toggling history display) repainting content below stale scrollback when collapsing history; rebuilds now correctly clear the scrollback buffer when history is collapsed
- Improved auto-compaction to automatically drop images and elide content when context is tight, and added persistent warning badges to the compaction divider when manual intervention is required
- Fixed backgrounded Bash blocks continuing to repaint with live and final job output; they now freeze with a compact job notice while completion is delivered separately
- Fixed the prewalk plan nudge silently ending the run with no code written when the model answered with a text-only reply (no tool call): the agent loop treats a tool-call-free turn as a natural stop and never prompts again, which the nudge's own "write the plan in your next reply" instruction makes common. The nudge now explicitly tells the model this is a checkpoint, not a final answer, and the session forces one more turn whenever a post-nudge reply lands with zero tool calls
- Fixed launch tool rendering stacking a stale pending header over a bare `✓ Launch` line and raw text: the tool now uses a merged registry renderer with one per-op status header (op, target, `state · pid · uptime` meta), stripped log cursor suffixes, capped collapsed log/list previews, and a launch tool glyph
- Fixed `launch logs` flattening PTY control sequences into repeated or diagonally wrapped debugger fragments: the bounded raw PTY stream is now replayed before row selection through the shared xterm screen renderer used by Bash PTY mode, blank terminal cells retain their columns, and the final colored/styled viewport renders in the same bordered output block as Bash while model-facing text remains sanitized
- Fixed confusing launch start/wait results when readiness timed out with the log pattern already matched (readiness needs log AND port): the result printed a contradictory `Ready: <match>` next to `Readiness timed out` without naming the failing condition. Daemon snapshots now carry the unmet conditions (`readyPending`), and start/wait results state exactly what never happened (e.g. `port 3100 on 127.0.0.1 never accepted connections`); the TUI shows a `waiting on port` badge on starting daemons
- Fixed the in-process `stat` builtin mangling BSD-style invocations like `stat -f "%Sm %N" file` (macOS muscle memory): GNU `-f` means `--file-system`, so the format string was treated as a file operand — printing filesystem info for the real operands and erroring with `cannot read file system information for '%Sm %N'`. A `-f` whose format value contains `%` is now detected as BSD syntax and translated to the GNU equivalent (`%Sm`→`%y`, `%N`→`%n`, `%z`→`%s`, epoch/`S`-form times, owner/group/permission and `H`/`L` sub-field directives, `-L`/`-n`/`-q`/`-F` flag clusters, with `%n`/`%t` as literal newline/tab); directives with no GNU counterpart fail with a clear `unsupported BSD format directive` error
- Fixed the remaining GNU-flavored shell builtins that broke under macOS/BSD muscle memory, using the same unambiguous-detection approach as the `stat` fix (only invocations that are invalid or nonsensical under GNU semantics are reinterpreted; unsupported BSD forms fail loudly instead of producing wrong output): `date -r <epoch>` formats the epoch when no such file exists (GNU `-r FILE` mtime preserved), signed `date -v±N<unit>` adjustments translate to `-d` relative dates and `-j` is accepted (`-j -f` strptime parse mode and field-set `-v` error clearly); `sed -i '' 's/…/…/' file` drops the BSD empty backup-suffix token instead of treating it as the script; `mktemp -t prefix` without X's creates `$TMPDIR/prefix.XXXXXXXXXX` (the GNU `too few X's` error path); `tail -r` reverses input by delegating to `tac` (with `-n`/`-c`/`-f` combinations erroring clearly); `find -E` maps to `-regextype posix-extended` ahead of the expression; `base64 -D` decodes as an alias of `-d`; and `ln -sfh` works via a `-h` alias of `--no-dereference` (clap's `-h` help short is dropped to match real GNU/BSD ln; `--help` unchanged)
- Fixed terminal scrollback duplication issues with expanded streaming edit previews (Ctrl+O) by using a viewport-sized tail window.
- Fixed custom model role resolution and alias parsing, ensuring canonical role selectors (`@role`) and thinking suffixes resolve correctly across all configuration surfaces.
- Fixed quadratic growth in JSON logs by eliding redundant message snapshots and payloads.
- Fixed prompt cache misses and incorrect cache key pinning for `/tan` and `/fork` clones.
- Fixed inconsistent history rendering and scrollback repainting when toggling the display setting for compacted items.
- Fixed `retry.fallbackChains` failing to engage on non-retryable provider errors, ensuring the agent correctly falls back to the next candidate model.
- Improved auto-compaction to automatically drop images and elide content when context is tight, and added persistent warning badges when manual intervention is required.
- Fixed backgrounded Bash blocks continuing to repaint with live output; they now freeze with a compact job notice while completion is delivered separately.
- Fixed rendering, status display, and PTY control sequence formatting issues in the `launch` tool.
- Fixed in-process shell builtins (including `stat`, `date`, `sed`, `mktemp`, `tail`, `find`, `base64`, and `ln`) to correctly detect and translate macOS/BSD-style arguments and flags, preventing failures caused by GNU-only assumptions.
### Removed
- Removed the `--prewalk-boomerang` feature and its associated configuration setting.
- Removed the unreliable Bing and Yahoo HTML-scraping web search providers.
## [16.4.8] - 2026-07-12
### Added
- Added a predicate form to the browser run's `wait()` helper: `wait(fn, { timeout?, interval? })` polls the function (sync or async) until truthy and resolves with that value, failing with a named timeout error (deadline clamped under the cell budget so it always beats the opaque whole-cell timeout) instead of Bun's `sleep expects a number` or a whole-cell stall from in-page polling Promises; both `wait` forms now register in the stall diagnosis of cell timeouts
@@ -897,6 +897,17 @@ export const SETTINGS_SCHEMA = {
description: "Remove the 1-character horizontal padding from the left and right of the terminal output",
},
},
"tui.scrollbackRebuild": {
type: "boolean",
default: false,
ui: {
tab: "appearance",
group: "Display",
label: "Rewrite Scrollback",
description:
"Erase and replay terminal scrollback when a block's final form replaces its live preview. When off (default), stale preview copies remain in history and the final content is appended below.",
},
},
"display.shimmer": {
type: "enum",
@@ -4,15 +4,43 @@ import type { AgentSession } from "../../../session/agent-session";
import { getThemeByName, setThemeInstance } from "../../theme/theme";
import { StatusLineComponent } from "./component";
function makeSessionWithLastMessage(lastMessage: unknown) {
function makeSessionWithLastMessage(lastMessage: unknown, prewalkArmed: boolean = false) {
return {
messages: [lastMessage],
messages: lastMessage ? [lastMessage] : [],
model: { contextWindow: 128000 },
contextUsageRevision: 0,
systemPrompt: [],
agent: { state: { tools: [] } },
skills: [],
getContextUsage: () => ({ tokens: 42, contextWindow: 128000 }),
state: {
messages: lastMessage ? [lastMessage] : [],
model: { contextWindow: 128000 },
},
sessionManager: {
getUsageStatistics: () => ({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
orchestrationInput: 0,
orchestrationOutput: 0,
orchestrationCacheRead: 0,
premiumRequests: 0,
cost: 0,
tokensPerSecond: null,
}),
getSessionName: () => "test-session",
},
getPrewalkState: () => (prewalkArmed ? { target: { id: "cheap-model", provider: "openai" } } : undefined),
getAsyncJobSnapshot: () => undefined,
isAdvisorActive: () => false,
isFastModeActive: () => false,
configuredThinkingLevel: () => undefined,
modelRegistry: {
isUsingOAuth: () => false,
},
};
}
@@ -41,4 +69,15 @@ describe("StatusLineComponent", () => {
expect(statusLine.getCachedContextBreakdown()).toEqual({ usedTokens: 42, contextWindow: 128000 });
});
it("renders Prewalk annotation when prewalk is armed", () => {
const statusLine = new StatusLineComponent(makeSessionWithLastMessage(null, true) as unknown as AgentSession);
// By default preset, 'mode' segment is included in left/right segments.
// Let's get the border and see if Prewalk is rendered.
const border = statusLine.getTopBorder(100);
// SGR codes might be included, so we check if the stripped content contains "Prewalk"
const stripped = border.content.replace(/\x1b\[[0-9;]*m/g, "");
expect(stripped).toContain("Prewalk");
});
});
@@ -1051,6 +1051,10 @@ export class StatusLineComponent implements Component {
compactThinkingLevel: this.#resolveSettings().compactThinkingLevel ?? false,
planMode: this.#planModeStatus,
loopMode: this.#loopModeStatus,
prewalk:
typeof this.session.getPrewalkState === "function" && this.session.getPrewalkState()
? { enabled: true }
: null,
goalMode: this.#goalModeStatus,
vibeMode: this.#vibeModeStatus,
collab: this.#collabStatus,
@@ -208,6 +208,12 @@ const modeSegment: StatusLineSegment = {
return { content: theme.fg(color, content), visible: true };
}
const prewalk = ctx.prewalk;
if (prewalk?.enabled) {
const content = withIcon(theme.icon.prewalk, "Prewalk");
return { content: theme.fg("accent", content), visible: true };
}
const goal = ctx.goalMode;
if (goal && (goal.enabled || goal.paused)) {
return renderGoalMode(ctx, goal);
@@ -60,6 +60,9 @@ export interface SegmentContext {
enabled: boolean;
paused: boolean;
} | null;
prewalk: {
enabled: boolean;
} | null;
loopMode: {
enabled: boolean;
} | null;
@@ -466,6 +466,10 @@ export class SelectorController {
this.ctx.ui.requestRender();
break;
case "tui.scrollbackRebuild":
this.ctx.ui.setScrollbackRebuild(value as boolean);
break;
case "tui.renderMermaid":
setMarkdownMermaidRendering(value as boolean);
this.ctx.session.refreshBaseSystemPrompt().catch(err => {
@@ -665,6 +665,7 @@ export class InteractiveMode implements InteractiveModeContext {
setMarkdownMermaidRendering(settings.get("tui.renderMermaid"));
this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
this.ui.setScrollbackRebuild(settings.get("tui.scrollbackRebuild"));
// OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's
// capability (`TERMINAL.textSizing` defaults on for Kitty) so it stays off
// unless the user opts in, and never emits raw escapes on other terminals.
@@ -92,6 +92,7 @@ export type SymbolKey =
// Icons
| "icon.model"
| "icon.plan"
| "icon.prewalk"
| "icon.goal"
| "icon.pause"
| "icon.loop"
@@ -301,6 +302,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
// Icons
"icon.model": "⬢",
"icon.plan": "🗺",
"icon.prewalk": "🏃",
"icon.goal": "🎯",
"icon.pause": "⏸",
"icon.loop": "↻",
@@ -562,6 +564,7 @@ const NERD_SYMBOLS: SymbolMap = {
"icon.model": "\uec19",
// pick:  | alt:  
"icon.plan": "\uf2d2",
"icon.prewalk": "\uf29d",
// pick: (nf-fa-bullseye) | alt: (nf-md-target) ◎ ⌖
"icon.goal": "\uf140",
// pick: (nf-fa-pause) | alt: ⏸ ||
@@ -819,6 +822,7 @@ const ASCII_SYMBOLS: SymbolMap = {
// Icons
"icon.model": "[M]",
"icon.plan": "plan",
"icon.prewalk": "prewalk",
"icon.goal": "goal",
"icon.pause": "||",
"icon.loop": "loop",
@@ -1819,6 +1823,7 @@ export class Theme {
return {
model: this.#symbols["icon.model"],
plan: this.#symbols["icon.plan"],
prewalk: this.#symbols["icon.prewalk"],
goal: this.#symbols["icon.goal"],
pause: this.#symbols["icon.pause"],
loop: this.#symbols["icon.loop"],
@@ -7473,6 +7473,11 @@ export class AgentSession {
return this.#planModeState;
}
/** Prewalk state, if armed and active */
getPrewalkState(): Prewalk | undefined {
return this.#prewalk;
}
setPlanModeState(state: PlanModeState | undefined): void {
this.#planModeState = state;
if (state?.enabled) {
@@ -22,6 +22,7 @@ function createModelContext(advisorActive: boolean): SegmentContext {
options: {},
planMode: null,
loopMode: null,
prewalk: null,
goalMode: null,
vibeMode: null,
collab: null,
@@ -45,6 +45,7 @@ function createCtx(overrides?: { pathMaxLength?: number; branch?: string | null
},
planMode: null,
loopMode: null,
prewalk: null,
goalMode: null,
vibeMode: null,
collab: null,
@@ -31,6 +31,7 @@ function createPathContext(): SegmentContext {
},
planMode: null,
loopMode: null,
prewalk: null,
goalMode: null,
vibeMode: null,
collab: null,
@@ -41,6 +41,7 @@ function createCtx(activeMs: number): SegmentContext {
options: {},
planMode: null,
loopMode: null,
prewalk: null,
goalMode: null,
vibeMode: null,
collab: null,
+3 -3
View File
@@ -4,9 +4,9 @@
### Fixed
- Rejected ambiguous swaps that risk silent deletion of range boundaries
- Prevented ambiguous auto-repairing of structural closing lines when payload placement is unclear
- Prevented stale-hash recovery from relocating edits onto duplicated context after the original target changed
- Fixed a critical issue where ambiguous swaps could silently delete range boundaries.
- Prevented incorrect auto-repairing of structural closing lines when payload placement is ambiguous.
- Fixed a bug in stale-hash recovery that could incorrectly relocate edits onto duplicated context after the original target changed.
## [16.3.3] - 2026-07-02
+1 -1
View File
@@ -4,7 +4,7 @@
### Changed
- Changed archived transcript rendering to compact `¶user:`, `¶think:`, `¶ai:`, and `¶call:` scopes; repeated adjacent scopes now continue as plain lines, tool-call intents trail calls as `//` comments, and the compaction prompt documents the format.
- Updated archived transcript rendering to use a more compact format with `¶user:`, `¶think:`, `¶ai:`, and `¶call:` scopes, omitting repeated adjacent scope headers and appending tool-call intents as comments.
## [16.3.7] - 2026-07-05
+5 -1
View File
@@ -2,9 +2,13 @@
## [Unreleased]
### Changed
- Improved native scrollback history management by introducing an optional erase-and-replay mechanism to rebuild scrollback when mutated rows (such as finalized tool blocks or collapsed transcripts) diverge. This is now gated behind the `tui.scrollbackRebuild` setting and defaults to off.
### Fixed
- Fixed forced renders (tool finalization, `resetDisplay`, image reconciliation) landing during a resize drag preempting the alternate-screen viewport fast path: each one left the borrowed alt screen, erased native scrollback (ED3), and visibly replayed the whole transcript on the normal screen mid-drag — then the settle replayed it again. Forced intent now folds into the single authoritative settle paint.
- Fixed a rendering issue where resizing the terminal during forced renders (such as tool finalization or image reconciliation) caused the entire transcript to visibly replay and flicker. Forced renders are now consolidated into a single paint once the resize settles.
## [16.4.7] - 2026-07-12
+66 -22
View File
@@ -5,11 +5,15 @@
* immutable — the tape is the terminal's visual record. Whatever scrolls
* above the window enters history exactly once, in order: as exact-final
* bytes when the component seam (`NativeScrollbackLiveRegion`) declared them
* final, else as a frozen snapshot of what was on screen. ED3 (`CSI 3 J`) is
* emitted only for gesture-driven replays (session replace, resize,
* resetDisplay) where snapping the viewport is acceptable. The engine never
* probes or guesses the terminal's scroll position, and the hot path clamps
* over-wide lines instead of throwing. See `docs/tui-core-renderer.md`.
* final, else as a frozen snapshot of what was on screen. When recorded
* history diverges from the frame (a finalized block replacing its
* scrolled-off live render), the engine erases and replays (ED3, `CSI 3 J`)
* so history holds the content exactly once — the same replay used for
* gestures (session replace, resize, resetDisplay). Multiplexer panes, where
* ED3 is unsafe, instead re-anchor and recommit below the stale fragment —
* duplication, never loss. The engine never probes or guesses the terminal's
* scroll position, and the hot path clamps over-wide lines instead of
* throwing. See `docs/tui-core-renderer.md`.
*/
import * as fs from "node:fs";
import { performance } from "node:perf_hooks";
@@ -783,9 +787,11 @@ const RESYNC_TAIL_SAMPLES = 8;
* source just became declared-final (the block finalized / a barrier
* cleared). Hard-scanned in FULL with no tolerance: any content change
* (a pending header settling, a preview replaced by its result, a tail
* shifting up after a barrier removal) re-anchors so the final content
* recommits below the frozen snapshot — duplication, never loss —
* instead of being committed nowhere and painted nowhere.
* shifting up after a barrier removal) re-anchors so the engine can
* erase-and-replay history with the final content exactly once (or, on
* ED3-unsafe multiplexers, recommit it below the frozen snapshot —
* duplication, never loss) instead of committing it nowhere and
* painting it nowhere.
* [finalTo, prefix.length) FROZEN visual snapshots of still-live rows —
* exempt: their drift is expected (a collapsing preview, a ticking
* progress tree) and must never spray re-anchors mid-run.
@@ -1001,6 +1007,8 @@ export class TUI extends Container {
#clearScrollbackOnNextRender = false;
#forceViewportRepaintOnNextRender = false;
#hasEverRendered = false;
#scrollbackRebuildEnabled =
Bun.env.PI_TUI_SCROLLBACK_REBUILD === "1" || Bun.env.PI_TUI_SCROLLBACK_REBUILD === "true";
// Set by the terminal resize callback; consumed by the next render. A resize
// event invalidates the committed screen even when the dimensions net out
// unchanged by render time (e.g. a 6→4→6 round trip coalesced into one frame
@@ -1290,6 +1298,23 @@ export class TUI extends Container {
this.#imageBudget.setCap(cap);
}
/**
* Get whether scrollback divergence rebuild is enabled.
*/
getScrollbackRebuild(): boolean {
return this.#scrollbackRebuildEnabled;
}
/**
* Enable or disable scrollback divergence rebuild (default off).
* When enabled, the engine will erase and replay the terminal's
* scrollback (using ED3 / alt buffer / scrollback replay) to avoid
* duplicate blocks when a block's final form replaces its live preview.
*/
setScrollbackRebuild(enabled: boolean): void {
this.#scrollbackRebuildEnabled = enabled;
}
getShowHardwareCursor(): boolean {
return this.#showHardwareCursor;
}
@@ -2778,8 +2803,9 @@ export class TUI extends Container {
// verified once (a pending header settling, a barrier clearing above a
// shifted tail); rows past the boundary are still-live frozen snapshots,
// exempt so a collapsing preview can never spray re-anchors mid-run. A
// divergence re-anchors and recommits — duplication, never loss —
// instead of silently skipping rows (committed nowhere, painted
// divergence re-anchors — feeding the divergenceRebuild erase-and-replay
// below (mux fallback: recommit below the stale copy; duplication, never
// loss) — instead of silently skipping rows (committed nowhere, painted
// nowhere). Skipped on geometry frames (a rewrap legitimately reflows
// every row), and skipped when the composed frame's stable prefix
// covers every verified row and no rows newly became final.
@@ -2852,7 +2878,21 @@ export class TUI extends Container {
const firstPaint = !this.#hasEverRendered;
const replaceRequested = this.#clearScrollbackOnNextRender;
const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
const fullPaint = firstPaint || replaceRequested || geometryRebuild;
// Committed history no longer matches the frame: a finalized block
// replaced its scrolled-off live render, or the frame collapsed into
// recorded rows. Native scrollback is a render cache, not a court
// record — erase and replay so history holds the content exactly once,
// instead of recommitting the final form below the stale fragment
// (a visibly duplicated block). Multiplexer panes cannot ED3 safely
// and keep the repair-below fallback in the branches under this one.
const divergenceRebuild =
this.#scrollbackRebuildEnabled &&
!firstPaint &&
!replaceRequested &&
!geometryChanged &&
!isMultiplexerSession() &&
(committedRowsResynced || frameLength <= this.#committedRows);
const fullPaint = firstPaint || replaceRequested || geometryRebuild || divergenceRebuild;
let windowTop: number;
let chunkTo: number;
if (fullPaint) {
@@ -2865,16 +2905,17 @@ export class TUI extends Container {
frameLength - this.#committedRows < height &&
cursorMarkers.some(marker => marker.row >= this.#committedRows))
) {
// Either the frame shrank into the committed prefix, or a
// committed-prefix resync left a focused cursor tail shorter than the
// viewport. The latter happens when a streaming/live block had an
// append-only prefix committed, then collapses on abort/finalize:
// the audit re-anchors #committedRows at the first divergent row, but
// flooring windowTop there would pin the editor near the top and
// leave blank rows underneath. Re-show the frame tail instead. The
// stale committed copy stays in native history; duplicating a few rows
// is preferable to a live editor gap and matches the existing
// "duplication, never loss" resync contract.
// Multiplexer fallback (a direct terminal takes the divergenceRebuild
// full paint above): either the frame shrank into the committed
// prefix, or a committed-prefix resync left a focused cursor tail
// shorter than the viewport. The latter happens when a streaming/live
// block had an append-only prefix committed, then collapses on
// abort/finalize: the audit re-anchors #committedRows at the first
// divergent row, but flooring windowTop there would pin the editor
// near the top and leave blank rows underneath. Re-show the frame
// tail instead. The stale committed copy stays in native history;
// duplicating a few rows is preferable to a live editor gap —
// "duplication, never loss" is the ED3-unsafe fallback contract.
committedPrefixResliced = true;
windowTop = Math.max(0, frameLength - height);
chunkTo = windowTop;
@@ -2927,7 +2968,10 @@ export class TUI extends Container {
const cursorTrackingLineCount = hasVisibleOverlay ? Math.max(frame.length, windowTop + height) : frame.length;
const intent: RenderIntent = fullPaint
? { kind: "fullPaint", clearScrollback: replaceRequested || geometryRebuild ? !isMultiplexerSession() : false }
? {
kind: "fullPaint",
clearScrollback: divergenceRebuild || ((replaceRequested || geometryRebuild) && !isMultiplexerSession()),
}
: { kind: "update", chunkTo, windowTop };
this.#logRedraw(intent, frameLength, height);
+95 -169
View File
@@ -1,3 +1,5 @@
process.env.PI_TUI_SCROLLBACK_REBUILD = "true";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import {
type Component,
@@ -1746,7 +1748,7 @@ describe("TUI terminal-state regressions", () => {
tui.stop();
}
});
it("recommits an offscreen expansion behind the stale prefix while seam commits continue in order", async () => {
it("rebuilds history when an offscreen expansion lands with a tail append", async () => {
const term = new VirtualTerminal(32, 6);
const tui = new TUI(term);
const component = new MutableLinesComponent(["status-0", ...rows("line-", 11)]);
@@ -1767,7 +1769,8 @@ describe("TUI terminal-state regressions", () => {
// Rows 0..5 (status-0, line-0..line-4) are committed. The frame edits
// row 0 and inserts a row above the commit boundary while a tail
// append lands in the same frame: 2+ prefix tail samples change, so
// the committed-prefix audit re-anchors at row 0 and recommits.
// the committed-prefix audit re-anchors and the engine erases and
// replays — history holds the expanded frame exactly once.
component.setLines(["status-1", "expanded-details", ...rows("line-", 12)]);
tui.requestRender();
await settle(term);
@@ -1782,19 +1785,11 @@ describe("TUI terminal-state regressions", () => {
]);
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
const history = buffer.slice(0, term.getBufferPosition().baseY);
// RESYNC law: native history keeps the stale committed copy AND gains
// a fresh copy of the diverged frame from row 0 — the offscreen edit
// and the expansion reach history (duplication, never loss).
expect(history).toEqual([
// stale committed prefix, never rewritten
"status-0",
...rows("line-", 5),
// recommitted frame rows 0..7 (new committed = 14 - height)
"status-1",
"expanded-details",
...rows("line-", 6),
]);
// The appended tail row reaches the screen exactly once.
// REBUILD law: the stale committed copy is erased; history is the
// diverged frame's own prefix — the offscreen edit and the expansion
// reach history exactly once.
expect(history).toEqual(["status-1", "expanded-details", ...rows("line-", 6)]);
expect(buffer).not.toContain("status-0");
expect(buffer.filter(row => row === "line-11").length).toBe(1);
} finally {
tui.stop();
@@ -1836,11 +1831,11 @@ describe("TUI terminal-state regressions", () => {
}
});
it("keeps stale collapsed ctrl-o markers in history and recommits the expanded rows behind them", async () => {
it("erases stale collapsed ctrl-o markers and rebuilds history with the expanded rows", async () => {
// A Ctrl+O expansion mutates committed rows, so the committed-prefix
// audit resyncs: the collapsed markers that already scrolled into native
// history stay there — one stale copy each, never rewritten — and the
// expanded rows recommit behind them (duplication, never loss).
// audit resyncs and the engine erases-and-replays: the collapsed
// markers that already scrolled into native history are erased with
// the rebuild and the expanded rows land in history exactly once.
const term = new VirtualTerminal(48, 6);
const tui = new TUI(term);
const collapsedLines = [
@@ -1871,7 +1866,6 @@ describe("TUI terminal-state regressions", () => {
]);
tui.requestRender();
await settle(term);
const history = term.getScrollBuffer().slice(0, term.getBufferPosition().baseY);
expect(visible(term).map(line => line.trim())).toEqual([
"json-6",
@@ -1881,79 +1875,32 @@ describe("TUI terminal-state regressions", () => {
"status",
"editor",
]);
const scrollback = term.getScrollBuffer();
expect(countMatches(scrollback, /Ctrl\+O: Expand/)).toBe(1);
expect(countMatches(scrollback, /ctrl\+o/)).toBe(1);
// The resync re-anchors at the first diverged row (the code marker)
// and recommits from there: the expanded rows reach history exactly
// once, right behind the stale markers.
for (const line of ["code line 0", "code line 1", "output line 0", "output line 1"]) {
expect(countMatches(history, new RegExp(`^${line}\\s*$`)), `${line} recommits exactly once`).toBe(1);
}
// json rows inside the recommitted span carry one stale + one fresh
// copy; rows still in the live window appear exactly once.
for (let i = 0; i < 6; i++) {
const pattern = new RegExp(`\\bjson-${i}\\b`);
expect(countMatches(scrollback, pattern), `json-${i} appears twice (stale + recommit)`).toBe(2);
}
for (let i = 6; i < 10; i++) {
const pattern = new RegExp(`\\bjson-${i}\\b`);
expect(countMatches(scrollback, pattern), `json-${i} should appear exactly once`).toBe(1);
}
} finally {
tui.stop();
}
});
it("defers offscreen expansion rebuild when the viewport position is unknown", async () => {
// POSIX terminals cannot report whether the user scrolled up, so an
// ordinary offscreen expansion must NOT destructively rebuild scrollback
// (anti-yank). The collapsed ctrl+o markers that scrolled into history
// therefore stay stale until the next checkpoint — this is the deferral
// that makes an un-flagged Ctrl+O expand look broken above the fold.
const term = new UnknownViewportTerminal(48, 6);
const tui = new TUI(term);
const component = new MutableLinesComponent([
"frame-top",
"code preview … 16 more lines ⟨Ctrl+O: Expand⟩",
"output preview … 106 more lines (ctrl+o to expand)",
...rows("json-", 10),
"status",
"editor",
]);
tui.addChild(component);
try {
tui.start();
await settle(term);
expect(term.isNativeViewportAtBottom()).toBeUndefined();
expect(term.getScrollBuffer().join("\n")).toContain("ctrl+o");
component.setLines([
// Rebuilt history: the expanded rows exactly once, no stale markers
// anywhere on the tape.
expect(term.getScrollBuffer().join("\n")).not.toContain("ctrl+o");
expect(term.getScrollBuffer().join("\n")).not.toContain("Ctrl+O");
const history = term
.getScrollBuffer()
.slice(0, term.getBufferPosition().baseY)
.map(line => line.trimEnd());
expect(history).toEqual([
"frame-top",
"code line 0",
"code line 1",
"output line 0",
"output line 1",
...rows("json-", 10),
"status",
"editor",
...rows("json-", 6),
]);
tui.requestRender();
await settle(term);
// No flag: the rebuild is deferred, so the stale markers survive offscreen.
expect(term.getScrollBuffer().join("\n")).toContain("ctrl+o");
} finally {
tui.stop();
}
});
it("paints an offscreen expansion identically when the viewport probe is unavailable", async () => {
// Law 3: there is no probe and no platform fork. A terminal that cannot
// report its native viewport position gets exactly the same treatment as
// one that can: committed rows stay immutable, the live window repaints,
// and no clear/home bytes are emitted.
it("rebuilds an offscreen expansion identically when the viewport probe is unavailable", async () => {
// There is no probe and no platform fork: a terminal that cannot
// report its native viewport position gets exactly the same
// erase-and-replay as one that can — exactly-once history wins over
// the parked-reader anchor (upstream pi semantics).
const term = new UnknownViewportTerminal(48, 6);
const tui = new TUI(term);
const component = new MutableLinesComponent([
@@ -1986,9 +1933,7 @@ describe("TUI terminal-state regressions", () => {
tui.requestRender();
await settle(term);
const paint = writes.join("");
expect(paint).not.toContain("\x1b[3J");
expect(paint).not.toContain("\x1b[2J");
expect((writes.join("").match(/\x1b\[3J/g) ?? []).length).toBe(1);
expect(visible(term).map(line => line.trim())).toEqual([
"json-6",
"json-7",
@@ -1997,8 +1942,8 @@ describe("TUI terminal-state regressions", () => {
"status",
"editor",
]);
// History is never rewritten: the stale markers survive offscreen.
expect(term.getScrollBuffer().join("\n")).toContain("ctrl+o");
// History was rebuilt, not left stale: the markers are gone.
expect(term.getScrollBuffer().join("\n")).not.toContain("ctrl+o");
} finally {
tui.stop();
}
@@ -2053,14 +1998,11 @@ describe("TUI terminal-state regressions", () => {
}
});
it("re-anchors a bottom-anchored high-water collapse at the divergence and recommits the tail into history", async () => {
// RESYNC law: the collapse shrinks the frame below the committed count,
// so the engine re-anchors the commit index at the first diverged row
// (row 8, where preview-* became result-*). Native history is
// append-only: the high-water preview copy stays in scrollback above
// (accepted artifact) — never clawed back. The window starts at the
// re-anchored commit index, which here sits past `length - height`, so
// the short tail is blank-padded rather than overwriting committed rows.
it("rebuilds a bottom-anchored high-water collapse with the final tail exactly once", async () => {
// REBUILD law: the collapse shrinks the frame below the committed
// count, so the engine erases and replays the final frame — the
// high-water preview copy is erased instead of surviving above as a
// stale artifact, and the result rows are history's only copy.
const term = new VirtualTerminal(40, 5);
const highWaterFrame = [...rows("base-", 8), ...rows("preview-", 10)];
const finalFrame = [...rows("base-", 8), "result-0", "result-1"];
@@ -2079,13 +2021,18 @@ describe("TUI terminal-state regressions", () => {
tui.requestRender();
await settle(term);
expect(writes.join("")).not.toContain("\x1b[3J");
expect(visible(term).map(line => line.trim())).toEqual(["result-0", "result-1", "", "", ""]);
const history = term.getScrollBuffer().slice(0, term.getBufferPosition().baseY);
expect(history.map(line => line.trimEnd())).toEqual(highWaterFrame.slice(0, 13));
expect((writes.join("").match(/\x1b\[3J/g) ?? []).length).toBe(1);
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(finalFrame);
expect(visible(term).map(line => line.trim())).toEqual([
"base-5",
"base-6",
"base-7",
"result-0",
"result-1",
]);
// Once the transcript grows past the window again, the post-collapse
// tail commits: result rows REACH history instead of being ignored.
// tail commits normally on the update path — exactly once.
component.setLines([...finalFrame, ...rows("tail-", 5)]);
tui.requestRender();
await settle(term);
@@ -2095,7 +2042,7 @@ describe("TUI terminal-state regressions", () => {
.getScrollBuffer()
.slice(0, term.getBufferPosition().baseY)
.map(line => line.trimEnd());
expect(grownHistory).toEqual([...highWaterFrame.slice(0, 13), "result-0", "result-1"]);
expect(grownHistory).toEqual(finalFrame);
} finally {
tui.stop();
}
@@ -2127,12 +2074,11 @@ describe("TUI terminal-state regressions", () => {
}
});
it("recommits an offscreen expansion at the seam while the reader is parked in scrollback", async () => {
it("rebuilds an offscreen expansion and snaps a parked reader to the tail", async () => {
// An expansion above the commit boundary triggers the committed-prefix
// resync: the inserted rows (and the shifted committed rows) recommit
// at the seam, so the expansion reaches native history instead of
// being skipped. The reader scrolled into scrollback keeps a stable
// view — the recommit only appends below their anchor.
// resync: the engine erases and replays, so the expansion reaches
// native history exactly once. The parked reader is snapped to the
// bottom — the price of exactly-once history (upstream pi semantics).
const term = new VirtualTerminal(32, 5);
const tui = new TUI(term);
const component = new MutableLinesComponent(rows("line-", 12));
@@ -2151,23 +2097,13 @@ describe("TUI terminal-state regressions", () => {
tui.requestRender();
await settle(term);
const paint = writes.join("");
expect(paint).not.toContain("\x1b[3J");
expect(paint).not.toContain("\x1b[2J");
expect(paint).not.toContain("\x1b[H");
expect(term.getBufferPosition().viewportY).toBe(before.viewportY);
expect(visible(term).map(line => line.trim())).toEqual(["line-2", "line-3", "line-4", "line-5", "line-6"]);
// The resync recommits the expansion: it reaches native history
// exactly because the commit index re-anchored at the divergence.
expect(term.getScrollBuffer().join("\n")).toContain("expanded-0");
expect((writes.join("").match(/\x1b\[3J/g) ?? []).length).toBe(1);
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
expect(buffer).toEqual(["line-0", "line-1", "expanded-0", "expanded-1", ...rows("line-", 12).slice(2)]);
term.scrollLines(999);
tui.requestRender();
await settle(term);
const finalPosition = term.getBufferPosition();
expect(finalPosition.viewportY).toBe(finalPosition.baseY);
expect(term.getScrollBuffer().join("\n")).toContain("expanded-0");
expect(visible(term).map(line => line.trim())).toEqual([
"line-7",
"line-8",
@@ -2356,13 +2292,12 @@ describe("TUI terminal-state regressions", () => {
scrolledTui.stop();
}
});
it("re-anchors a huge completion-style collapse at the new tail and recommits the diverged head behind stale history", async () => {
// RESYNC law (#1599 lineage): a 100-row transcript collapsing to 20 rows
// in a 10-row window must keep the new tail (including the prompt) on
// screen. The frame no longer covers the committed prefix, so the commit
// index re-anchors at the first diverged row (row 0) and recommits up to
// `newLength - height`: short-0..short-9 land in history right behind
// the stale line-* copy — duplication of the stale prefix, never loss.
it("rebuilds a huge completion-style collapse with the new tail exactly once", async () => {
// REBUILD law (#1599 lineage): a 100-row transcript collapsing to 20
// rows in a 10-row window keeps the new tail (including the prompt) on
// screen. The frame no longer covers the committed prefix, so the
// engine erases and replays: short-0..short-9 are history's only
// rows — the stale line-* transcript is gone.
const term = new UnknownViewportTerminal(40, 10);
const tui = new TUI(term);
const body = rows("line-", 99);
@@ -2391,25 +2326,20 @@ describe("TUI terminal-state regressions", () => {
"short-18",
"prompt-row",
]);
const buffer = term.getScrollBuffer();
// The recommit puts short-0..short-9 into history exactly once and
// the re-anchored window holds short-10..prompt-row exactly once —
// nothing is lost, nothing duplicates.
for (let i = 0; i < short.length; i++) {
expect(countMatches(buffer, new RegExp(`\\bshort-${i}\\b`)), `short-${i} appears once`).toBe(1);
}
const history = buffer.slice(0, term.getBufferPosition().baseY).map(line => line.trimEnd());
expect(history).toEqual([...body.slice(0, 90), ...rows("short-", 10)]);
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
expect(buffer).toEqual([...short, "prompt-row"]);
const history = buffer.slice(0, term.getBufferPosition().baseY);
expect(history).toEqual(rows("short-", 10));
} finally {
tui.stop();
}
});
it("recommits a huge collapse without clears while the reader is parked in scrollback", async () => {
// RESYNC + no-clear law: even a 100→20 row collapse never emits ED2/ED3
// — the re-anchor recommits the diverged frame head behind the stale
// prefix and rewrites the window, so a reader parked in native
// scrollback keeps a byte-stable view and their anchor.
it("rebuilds a huge collapse and snaps a parked reader to the tail", async () => {
// REBUILD law: a 100→20 row collapse erases and replays (one ED3).
// The reader parked in native scrollback is snapped to the bottom —
// stale history is no longer preserved for them (upstream pi
// semantics: exactly-once history wins over the anchored view).
const term = new UnknownViewportTerminal(40, 10);
const tui = new TUI(term);
const body = rows("line-", 99);
@@ -2420,9 +2350,7 @@ describe("TUI terminal-state regressions", () => {
tui.start();
await settle(term);
term.scrollLines(-10);
const before = term.getBufferPosition();
const beforeViewport = visible(term).map(line => line.trim());
expect(before.viewportY).toBeGreaterThan(0);
expect(term.getBufferPosition().viewportY).toBeGreaterThan(0);
const writes = captureWrites(term);
const short = rows("short-", 19);
@@ -2430,11 +2358,7 @@ describe("TUI terminal-state regressions", () => {
tui.requestRender();
await settle(term);
const paint = writes.join("");
expect(paint).not.toContain("\x1b[3J");
expect(paint).not.toContain("\x1b[2J");
expect(term.getBufferPosition().viewportY).toBe(before.viewportY);
expect(visible(term).map(line => line.trim())).toEqual(beforeViewport);
expect((writes.join("").match(/\x1b\[3J/g) ?? []).length).toBe(1);
term.scrollLines(999);
await settle(term);
@@ -2451,21 +2375,18 @@ describe("TUI terminal-state regressions", () => {
"short-18",
"prompt-row",
]);
// Stale committed history stays above — never clawed back — and the
// recommitted frame head follows it (no row loss).
const history = term.getScrollBuffer().slice(0, term.getBufferPosition().baseY);
expect(history.join("\n")).toContain("line-89");
for (let i = 0; i < 10; i++) {
expect(countMatches(history, new RegExp(`\\bshort-${i}\\b`)), `short-${i} recommits once`).toBe(1);
}
for (let i = 10; i < short.length; i++) {
expect(countMatches(history, new RegExp(`\\bshort-${i}\\b`)), `short-${i} stays in the window`).toBe(0);
}
// History was rebuilt: the short head exactly once, the stale
// line-* transcript erased.
const history = term
.getScrollBuffer()
.slice(0, term.getBufferPosition().baseY)
.map(line => line.trimEnd());
expect(history).toEqual(rows("short-", 10));
} finally {
tui.stop();
}
});
it("resyncs an offscreen-edit grow and re-anchors the following collapse", async () => {
it("rebuilds on an offscreen-edit grow and again on the following collapse", async () => {
const term = new UnknownViewportTerminal(40, 10);
const tui = new TUI(term);
const initial = rows("line-", 19);
@@ -2477,9 +2398,9 @@ describe("TUI terminal-state regressions", () => {
await settle(term);
// Offscreen edit (row 0) + 100-row growth in one frame: the edit is
// an insertion above the commit boundary, so the audit re-anchors and
// the edited transcript recommits behind the stale original (law 1
// content-at-commit-time, duplication never loss).
// an insertion above the commit boundary, so the audit re-anchors
// and the engine erases-and-replays — the edited transcript is
// history's only copy.
const expanded = ["edited-line", ...rows("line-", 118), "prompt-row"];
component.setLines(expanded);
tui.requestRender();
@@ -2496,10 +2417,14 @@ describe("TUI terminal-state regressions", () => {
"line-117",
"prompt-row",
]);
expect(term.getScrollBuffer().join("\n")).toContain("edited-line");
const grownHistory = term
.getScrollBuffer()
.slice(0, term.getBufferPosition().baseY)
.map(line => line.trimEnd());
expect(grownHistory).toEqual(expanded.slice(0, 110));
// Collapse far below the commit boundary: law 4 re-anchors the window
// at the new tail; the stale committed transcript stays above.
// Collapse far below the commit boundary: a second rebuild replays
// the short frame; the tall transcript is erased.
const short = [...rows("short-", 14), "prompt-row"];
component.setLines(short);
tui.requestRender();
@@ -2517,10 +2442,11 @@ describe("TUI terminal-state regressions", () => {
"short-13",
"prompt-row",
]);
const history = term.getScrollBuffer().slice(0, term.getBufferPosition().baseY).join("\n");
expect(history).toContain("line-108");
expect(history).toContain("short-4");
expect(history).toContain("edited-line");
const history = term
.getScrollBuffer()
.slice(0, term.getBufferPosition().baseY)
.map(line => line.trimEnd());
expect(history).toEqual(rows("short-", 5));
} finally {
tui.stop();
}
@@ -1,3 +1,5 @@
process.env.PI_TUI_SCROLLBACK_REBUILD = "true";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
type Component,
@@ -10,7 +12,7 @@ import { VirtualTerminal } from "./virtual-terminal";
// Law-encoding suite for native-scrollback commits.
//
// The tape is the terminal's visual record: whatever scrolls above the window
// enters history exactly once, in order. The component seam
// enters history, in order. The component seam
// (`getNativeScrollbackLiveRegionStart`) classifies HOW a row commits:
// ► below the boundary — exact-final bytes, hard-verified, audited;
// ► above the boundary — a frozen snapshot of what was on screen, exempt
@@ -18,9 +20,11 @@ import { VirtualTerminal } from "./virtual-terminal";
// never spray duplicates mid-run);
// ► when the boundary rises past frozen snapshots (the block finalized, a
// barrier cleared), they are strict-scanned exactly once: a divergence
// re-anchors and recommits the final content below the frozen snapshot —
// duplication, never loss; rows are never committed-nowhere-and-painted-
// nowhere.
// erases native history and replays the frame (one ED3), so the tape
// holds the final content exactly once — never a stale fragment above a
// recommit. Multiplexer panes, where ED3 is unsafe, keep the repair-below
// fallback: the final content recommits below the frozen snapshot —
// duplication, never loss.
class LineList implements Component {
#lines: string[];
@@ -306,7 +310,7 @@ describe("streaming scrollback — visual record", () => {
}
});
it("repairs a wholesale-replaced live block once at finalize — full result, single stale fragment", async () => {
it("rebuilds history once at finalize when a wholesale-replaced live block diverged", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(24, 4);
overrideProbe(term, undefined);
@@ -342,16 +346,15 @@ describe("streaming scrollback — visual record", () => {
]);
// Finalize: the one-time strict verification catches the divergence
// and recommits the final content below the frozen fragment. Every
// fresh row is on the tape (no loss); the stale fragment appears
// exactly once (no spray).
// and erases-and-replays, so the tape holds the final content exactly
// once — the stale frozen fragment is gone, nothing recommits below it.
live.seam = undefined;
tui.requestRender();
await settle(term);
const buffer = tape(term);
expect(eraseScrollbackCount(writes)).toBe(0);
expect(buffer).toEqual([...rows("prior-", 12), ...rows("pending-stale-", 6), ...rows("running-fresh-", 10)]);
expect(eraseScrollbackCount(writes)).toBe(1);
expect(buffer).toEqual([...rows("prior-", 12), ...rows("running-fresh-", 10)]);
} finally {
tui.stop();
}
@@ -387,16 +390,21 @@ describe("streaming scrollback — visual record", () => {
tui.requestRender();
await settle(term);
// Mid-run: frozen snapshots are exempt — a wholesale replace while
// live must not trigger a rebuild. A lower sibling's seam winning
// would verify the live rows as final and re-anchor right here.
expect(eraseScrollbackCount(writes)).toBe(0);
live.seam = undefined;
tui.requestRender();
await settle(term);
const buffer = tape(term);
expect(eraseScrollbackCount(writes)).toBe(0);
// Full fresh content present in order (no loss), stale head fragment
// exactly once (no spray), loader still live at the bottom.
// Finalize rebuild: full fresh content exactly once, the stale
// preview fragment erased, loader still live at the bottom.
expect(eraseScrollbackCount(writes)).toBe(1);
expect(contiguousAt(buffer, rows("running-fresh-", 10))).toHaveLength(1);
expect(buffer.filter(line => line.startsWith("pending-stale-"))).toEqual(rows("pending-stale-", 7));
expect(buffer.filter(line => line.startsWith("pending-stale-"))).toEqual([]);
expect(buffer.at(-1)).toBe("Working...");
} finally {
tui.stop();
@@ -502,14 +510,14 @@ describe("streaming scrollback — visual record", () => {
await settle(term);
expect(term.getScrollBuffer().filter(line => line.startsWith("prior-"))).toEqual(rows("prior-", 12));
// Live block collapses to its compact result. The bottom-anchored
// viewport would re-expose committed sealed rows; the pin must clamp
// the repaint to the committed boundary instead of duplicating them.
// Live block collapses to its compact result: the frame shrank into
// recorded rows, so history is rebuilt — the sealed rows appear
// exactly once, never appended a second time below their old copy.
live.setLines(["done"]);
tui.requestRender();
await settle(term);
expect(eraseScrollbackCount(writes)).toBe(0);
expect(eraseScrollbackCount(writes)).toBe(1);
expect(term.getScrollBuffer().filter(line => line.startsWith("prior-"))).toEqual(rows("prior-", 12));
} finally {
tui.stop();
@@ -536,16 +544,17 @@ describe("streaming scrollback — visual record", () => {
expect(eraseScrollbackCount(writes)).toBe(0);
// A later frame introduces a live region after the same sealed prefix.
// The already-committed base rows must stay accounted — never appended
// to native history a second time.
// A later frame introduces a live region after the same sealed prefix
// and drops the transient tail: the frame shrank into recorded rows,
// so one rebuild replays history with the base rows exactly once —
// never appended to native history a second time.
const live = new SeamLineList(rows("live-", 20));
sealed.setLines(rows("base-", 12));
tui.addChild(live);
tui.requestRender();
await settle(term);
expect(eraseScrollbackCount(writes)).toBe(0);
expect(eraseScrollbackCount(writes)).toBe(1);
expect(term.getScrollBuffer().filter(line => line.startsWith("base-"))).toEqual(rows("base-", 12));
} finally {
tui.stop();
@@ -745,7 +754,7 @@ describe("streaming scrollback — visual record", () => {
}
});
it("never re-anchors a re-laying-out live block mid-run, repairs once at finalize", async () => {
it("never re-anchors a re-laying-out live block mid-run, rebuilds once at finalize", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
@@ -753,7 +762,7 @@ describe("streaming scrollback — visual record", () => {
// A block that rewrites an interior row every frame (a streaming table
// re-aligning, a collapsing preview). Its scrolled rows are frozen
// snapshots: drift never sprays re-anchors; the single strict scan at
// finalize recommits the final form once.
// finalize erases-and-replays the final form once.
const live = new SeamLineList([]);
try {
@@ -772,8 +781,9 @@ describe("streaming scrollback — visual record", () => {
}
// Mid-run: exactly the scrolled snapshots + the grid — one copy each,
// no spray despite nine drift frames.
// no spray and no rebuild despite nine drift frames.
const streaming = tape(term);
expect(eraseScrollbackCount(writes)).toBe(0);
expect(streaming).toHaveLength(12);
expect(streaming.filter(line => line.startsWith("tbl-1 ")).length).toBe(1);
@@ -781,32 +791,34 @@ describe("streaming scrollback — visual record", () => {
tui.requestRender();
await settle(term);
// Finalize: one repair recommits the final layout below the frozen
// snapshot; the final form of the drifted row is on the tape.
// Finalize: one erase-and-replay puts the final layout on the tape
// exactly once — the drifted row's final form is the only copy.
const finalLines = rows("tbl-", 12);
finalLines[1] = "tbl-1 [w12]";
const buffer = tape(term);
expect(eraseScrollbackCount(writes)).toBe(0);
expect(buffer.join("\n")).toContain("tbl-1 [w12]");
// Bounded: 8 snapshots + one repair recommit (7 rows) + 4 grid rows.
expect(buffer.length).toBeLessThanOrEqual(19);
expect(eraseScrollbackCount(writes)).toBe(1);
expect(buffer).toEqual(finalLines);
// Stability: identical follow-up frames must not grow the tape.
// Stability: identical follow-up frames must not grow the tape or
// erase again.
tui.requestRender();
await settle(term);
expect(tape(term)).toEqual(buffer);
expect(eraseScrollbackCount(writes)).toBe(1);
} finally {
tui.stop();
}
});
it("repairs a declared-final violation by re-anchoring once, never spraying", async () => {
it("repairs a declared-final violation with one rebuild, never spraying", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
// The block declares its whole body final, commits, then violates the
// contract by rewriting TWO committed rows (alignment breaks, so the
// tail-sample tolerance cannot absorb it). The audit re-anchors and
// recommits — duplication, never loss — and stays quiet afterwards.
// tail-sample tolerance cannot absorb it). The audit re-anchors, the
// engine erases-and-replays once, and stays quiet afterwards.
const live = new SeamLineList(rows("row-", 12));
live.seam = Number.POSITIVE_INFINITY;
@@ -824,15 +836,15 @@ describe("streaming scrollback — visual record", () => {
await settle(term);
const afterViolation = tape(term);
expect(afterViolation).toContain("row-5 [edited]");
expect(afterViolation).toContain("row-6 [edited]");
expect(eraseScrollbackCount(writes)).toBe(1);
expect(afterViolation).toEqual(violated);
for (let i = 0; i < 5; i++) {
tui.requestRender();
await settle(term);
}
expect(tape(term)).toEqual(afterViolation);
expect(eraseScrollbackCount(writes)).toBe(0);
expect(eraseScrollbackCount(writes)).toBe(1);
} finally {
tui.stop();
}
@@ -871,18 +883,17 @@ describe("scrollback commit gap — live barriers", () => {
expect(tape(term)).toEqual(["[tool pending]", ...rows("ans-", 8)]);
// Barrier removed: the tail shifts up. The one-time strict scan
// catches the shift and recommits — every ans row survives, in order,
// contiguous at the tape bottom.
// catches the shift and rebuilds — every ans row survives, in order,
// and the stale barrier row is erased from history.
root.setLines(rows("ans-", 8));
root.seam = undefined;
tui.requestRender();
await settle(term);
const buffer = tape(term);
expect(buffer.slice(-8)).toEqual(rows("ans-", 8));
expect(buffer.filter(line => line === "[tool pending]")).toHaveLength(1);
expect(buffer).toEqual(rows("ans-", 8));
expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("ans-", 8).slice(-4));
expect(eraseScrollbackCount(writes)).toBe(0);
expect(eraseScrollbackCount(writes)).toBe(1);
} finally {
tui.stop();
}
@@ -915,12 +926,11 @@ describe("scrollback commit gap — live barriers", () => {
await settle(term);
const buffer = tape(term);
// Full result contiguous at the bottom; the recorded preview head
// stays above it as the visual record — once, no spray.
expect(buffer.slice(-9)).toEqual(result);
expect(contiguousAt(buffer, result)).toHaveLength(1);
// History rebuilt: the full result exactly once; the provisional
// preview is erased rather than left above as a stale record.
expect(buffer).toEqual(result);
expect(term.getViewport().map(line => line.trimEnd())).toEqual(result.slice(-4));
expect(eraseScrollbackCount(writes)).toBe(0);
expect(eraseScrollbackCount(writes)).toBe(1);
} finally {
tui.stop();
}
@@ -948,7 +958,7 @@ describe("scrollback commit gap — live barriers", () => {
// Barrier collapses to 1 row but the frame stays longer than the
// committed prefix (NOT the shrink-into-prefix branch); the strict
// scan must catch the upward tail shift.
// scan must catch the upward tail shift and rebuild.
const f2 = ["bar-collapsed", ...rows("tail-", 8)];
root.setLines(f2);
root.seam = undefined;
@@ -956,9 +966,9 @@ describe("scrollback commit gap — live barriers", () => {
await settle(term);
const buffer = tape(term);
expect(buffer.slice(-9)).toEqual(f2);
expect(buffer).toEqual(f2);
expect(term.getViewport().map(line => line.trimEnd())).toEqual(f2.slice(-4));
expect(eraseScrollbackCount(writes)).toBe(0);
expect(eraseScrollbackCount(writes)).toBe(1);
} finally {
tui.stop();
}
@@ -986,16 +996,16 @@ describe("scrollback commit gap — live barriers", () => {
expect(tape(term)).toEqual(["[tool pending]", ...rows("out-", 10)]);
// Remove the barrier. The tail shifts up by one row; the strict scan
// recommits so every out-* row remains, in order, contiguous at the
// tape bottom.
// rebuilds so every out-* row remains, in order, and the stale
// barrier row is erased.
tui.removeChild(barrier);
tui.requestRender();
await settle(term);
const buffer = tape(term);
expect(buffer.slice(-10)).toEqual(rows("out-", 10));
expect(buffer).toEqual(rows("out-", 10));
expect(term.getViewport().map(line => line.trimEnd())).toEqual(rows("out-", 10).slice(-5));
expect(eraseScrollbackCount(writes)).toBe(0);
expect(eraseScrollbackCount(writes)).toBe(1);
} finally {
tui.stop();
}
@@ -1030,8 +1040,7 @@ describe("scrollback commit gap — live barriers", () => {
await settle(term);
const buffer = tape(term);
expect(buffer.slice(-20)).toEqual(final);
expect(buffer.filter(line => line === "[pending]")).toHaveLength(1);
expect(buffer).toEqual(final);
expect(term.getViewport().map(line => line.trimEnd())).toEqual(final.slice(-5));
} finally {
tui.stop();
@@ -1104,7 +1113,7 @@ describe("scrollback commit gap — live barriers", () => {
// Finalize: ONLY row 0 changes (preview → result); the whole tail is
// byte-identical. The tail-sample tolerance alone would eat the single
// mismatch and "result" would never reach the tape; the strict scan of
// the newly-final span forces the recommit.
// the newly-final span forces the rebuild.
const f2 = ["result", ...rows("tail-", 8)];
root.setLines(f2);
root.seam = undefined;
@@ -1112,10 +1121,10 @@ describe("scrollback commit gap — live barriers", () => {
await settle(term);
const buffer = tape(term);
expect(buffer).toContain("result");
expect(buffer).toEqual(f2);
expect(buffer.filter(line => line === "result")).toHaveLength(1);
expect(term.getViewport().map(line => line.trimEnd())).toEqual(f2.slice(-4));
expect(eraseScrollbackCount(writes)).toBe(0);
expect(eraseScrollbackCount(writes)).toBe(1);
} finally {
tui.stop();
}
@@ -1148,8 +1157,63 @@ describe("scrollback commit gap — live barriers", () => {
await settle(term);
const buffer = tape(term);
expect(buffer).toContain("result");
expect(buffer).toEqual(["result", ...rows("tail-", 30)]);
expect(buffer.filter(line => line === "result")).toHaveLength(1);
expect(eraseScrollbackCount(writes)).toBe(1);
} finally {
tui.stop();
}
});
});
describe("scrollback divergence — multiplexer fallback", () => {
let savedTerminalEnv: Record<string, string | undefined> = {};
let savedTmux: string | undefined;
beforeEach(() => {
savedTerminalEnv = saveTerminalEnv();
savedTmux = Bun.env.TMUX;
Bun.env.TMUX = "/tmp/tmux-1000/default,12345,0";
});
afterEach(() => {
if (savedTmux === undefined) delete Bun.env.TMUX;
else Bun.env.TMUX = savedTmux;
restoreTerminalEnv(savedTerminalEnv);
savedTerminalEnv = {};
});
it("repairs below the stale fragment without ED3 when the pane cannot be cleared", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(20, 4);
overrideProbe(term, undefined);
const tui = new TUI(term);
const root = new SeamLineList([]);
try {
tui.addChild(root);
tui.start();
await settle(term);
const writes = capture(term);
const preview = rows("preview-", 10);
root.setLines(preview);
root.seam = 0;
tui.requestRender();
await settle(term);
expect(tape(term)).toEqual(preview);
// Finalize divergence inside a tmux pane: ED3 would corrupt the
// pane's own history, so the engine keeps the repair-below contract —
// the full result reaches the tape contiguously, the frozen preview
// head stays above it exactly once, and nothing is erased.
const result = rows("result-", 9);
root.setLines(result);
root.seam = undefined;
tui.requestRender();
await settle(term);
const buffer = tape(term);
expect(buffer.slice(-9)).toEqual(result);
expect(contiguousAt(buffer, result)).toHaveLength(1);
expect(eraseScrollbackCount(writes)).toBe(0);
} finally {
tui.stop();