refactor(packages/coding-agent): simplified terminal transcript rendering
- Removed terminal risk mode toggles from config, terminal state collection, and render controllers. - Dropped snapshot freezing, thaw tracking, and finalized-block replay in transcript rendering. - Removed clear-on-shrink settings and initialization hooks from selector and interactive mode flows. - Simplified render scheduling by using requestRender() without mutation flags or stream checkpoints.
This commit is contained in:
@@ -686,16 +686,6 @@ export const SETTINGS_SCHEMA = {
|
||||
ui: { tab: "appearance", label: "Show Hardware Cursor", description: "Show terminal cursor for IME support" },
|
||||
},
|
||||
|
||||
clearOnShrink: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
ui: {
|
||||
tab: "appearance",
|
||||
label: "Clear on Shrink",
|
||||
description: "Clear empty rows when content shrinks (may cause flicker)",
|
||||
},
|
||||
},
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Model
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface TerminalStateInfo {
|
||||
hyperlinks: boolean;
|
||||
deccara: boolean;
|
||||
screenToScrollback: boolean;
|
||||
eagerEraseScrollbackRisk: boolean;
|
||||
synchronizedOutput: boolean;
|
||||
multiplexer: string | null;
|
||||
env: { TERM?: string; TERM_PROGRAM?: string; TERM_PROGRAM_VERSION?: string; COLORTERM?: string };
|
||||
@@ -82,7 +81,6 @@ export function collectTerminalState(runtime: TerminalRuntimeState): TerminalSta
|
||||
hyperlinks: TERMINAL.hyperlinks,
|
||||
deccara: TERMINAL.deccara,
|
||||
screenToScrollback: TERMINAL.supportsScreenToScrollback,
|
||||
eagerEraseScrollbackRisk: TERMINAL.eagerEraseScrollbackRisk,
|
||||
synchronizedOutput: runtime.synchronizedOutput,
|
||||
multiplexer: detectMultiplexer(env),
|
||||
env: {
|
||||
@@ -115,7 +113,6 @@ export function formatTerminalState(info: TerminalStateInfo): string {
|
||||
"",
|
||||
"Scrollback",
|
||||
` Screen->history clear: ${info.screenToScrollback ? "CSI 22 J" : "CSI 2 J (redraw)"}`,
|
||||
` Eager-erase risk: ${yesNo(info.eagerEraseScrollbackRisk)} (ED3 may yank scrolled readers)`,
|
||||
"",
|
||||
"Detection signals",
|
||||
` TERM: ${info.env.TERM ?? "(unset)"}`,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { type Component, Container, type NativeScrollbackLiveRegion, TERMINAL } from "@oh-my-pi/pi-tui";
|
||||
import { type Component, Container, type NativeScrollbackLiveRegion } from "@oh-my-pi/pi-tui";
|
||||
|
||||
const kSnapshot = Symbol("transcript.frozenRender");
|
||||
const kSnapshot = Symbol("transcript.liveDiffSnapshot");
|
||||
|
||||
interface FrozenRender {
|
||||
/**
|
||||
* Per-block diff cache: the block's previous stripped contribution plus the
|
||||
* derived append-only state. Purely an input to {@link deriveLiveCommitState}
|
||||
* for still-live blocks — it is never replayed as render output. Every block
|
||||
* renders its current content on every frame.
|
||||
*/
|
||||
interface LiveDiffSnapshot {
|
||||
width: number;
|
||||
lines: string[];
|
||||
generation: number;
|
||||
@@ -15,7 +21,7 @@ interface FrozenRender {
|
||||
}
|
||||
|
||||
interface SnapshotCarrier {
|
||||
[kSnapshot]?: FrozenRender;
|
||||
[kSnapshot]?: LiveDiffSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,10 +97,10 @@ function rowsVisiblyEqual(prev: string, cur: string): boolean {
|
||||
}
|
||||
|
||||
function hasValidSnapshot(
|
||||
snapshot: FrozenRender | undefined,
|
||||
snapshot: LiveDiffSnapshot | undefined,
|
||||
width: number,
|
||||
generation: number,
|
||||
): snapshot is FrozenRender {
|
||||
): snapshot is LiveDiffSnapshot {
|
||||
return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
|
||||
}
|
||||
|
||||
@@ -113,7 +119,7 @@ function commonSuffixLength(prev: string[], cur: string[], prefixLength: number)
|
||||
}
|
||||
|
||||
function deriveLiveCommitState(
|
||||
previous: FrozenRender | undefined,
|
||||
previous: LiveDiffSnapshot | undefined,
|
||||
current: string[],
|
||||
width: number,
|
||||
generation: number,
|
||||
@@ -173,55 +179,40 @@ function deriveLiveCommitState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript container that freezes the rendered output of every block except
|
||||
* the bottom-most (live) one on terminals where committed native scrollback is
|
||||
* immutable.
|
||||
* Transcript container that always renders every block's current content and
|
||||
* reports the live-region seam (`NativeScrollbackLiveRegion`) that gates the
|
||||
* engine's append-only scrollback commits.
|
||||
*
|
||||
* On ED3-risk terminals with an unobservable viewport (ghostty/kitty/iTerm2/…)
|
||||
* the renderer cannot clear saved lines (`\x1b[3J` may yank a reader) or query
|
||||
* whether the user has scrolled, so any block that re-lays-out *after* it has
|
||||
* scrolled past the viewport leaves a stale duplicate above the live region
|
||||
* (a finalized assistant message re-wrapping, a tool preview collapsing to its
|
||||
* compact result, a late async tool completion). The renderer's only safe move
|
||||
* for such an offscreen edit is to not repaint — which is correct only if the
|
||||
* committed region never changes underneath it.
|
||||
*
|
||||
* This container provides that guarantee: a block's render is snapshotted while
|
||||
* it is the live (bottom-most) block, and once a newer block is appended it
|
||||
* replays the snapshot instead of recomputing. Mutations after a block leaves
|
||||
* live are intentionally deferred until the next checkpoint {@link thaw} (prompt
|
||||
* submit → native-scrollback rebuild), where the whole transcript is replayed
|
||||
* and any drift reconciles safely. On terminals that can rebuild history this
|
||||
* freezing is unnecessary, so it renders every block live for full fidelity.
|
||||
* The engine never rewrites committed history: rows above the seam that have
|
||||
* entered the tape keep whatever bytes they were committed with ("let the
|
||||
* history be"), while the visible window always repaints from each block's
|
||||
* latest render — a late tool result, a post-finalize error pin, or an expand
|
||||
* toggle is always reflected on screen. Blocks that are still mutating (an
|
||||
* unfinalized tool, a streaming assistant message) stay below the seam so
|
||||
* their rows do not enter history while they can still change; a streaming
|
||||
* block whose render grows append-only deepens the seam through its settled
|
||||
* head so a long reply's scrolled-off rows still reach scrollback mid-stream.
|
||||
*/
|
||||
export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
|
||||
// Bumped to invalidate every block's snapshot at once; a snapshot is only
|
||||
// honored when its stored generation still matches.
|
||||
// Bumped to retire every block's diff snapshot at once (theme change /
|
||||
// clear); a snapshot is only honored when its stored generation matches.
|
||||
#generation = 0;
|
||||
// Line index where the live (repaintable) region began on the previous
|
||||
// render — the start of the earliest still-mutating block, or the bottom
|
||||
// block when everything is finalized. A block leaves the live region only
|
||||
// once it has finalized AND a finalized block sits below it; the frame it
|
||||
// crosses out is recomputed so it freezes at its true final content, not the
|
||||
// mid-stream snapshot it last rendered while live (TUI render coalescing can
|
||||
// advance a block's content in the very frame it stops being live).
|
||||
#prevLiveStartIndex = 0;
|
||||
// Local line index where the current live region begins in the most recent
|
||||
// render. TUI extends the native-scrollback pinned region from this point
|
||||
// through the live blocks and the root chrome rendered below them.
|
||||
// render. TUI commits rows to native scrollback only above this seam (or
|
||||
// the deeper commit-safe end below).
|
||||
#nativeScrollbackLiveRegionStart: number | undefined;
|
||||
// Local line index up to which the leading run of live blocks is safe to
|
||||
// commit. Finalized blocks contribute their full frozen body; still-live
|
||||
// blocks contribute only while their render has been observed growing
|
||||
// without visibly rewriting a previously rendered interior row (escape
|
||||
// placement and pad drift are ignored). A rewrite suspends the block's
|
||||
// contribution until it re-earns append-only via VOLATILE_REARM_FRAMES
|
||||
// clean frames; the pinned emitter then backfills the stalled gap.
|
||||
// commit. Finalized blocks contribute their full body; still-live blocks
|
||||
// contribute only while their render has been observed growing without
|
||||
// visibly rewriting a previously rendered interior row (escape placement
|
||||
// and pad drift are ignored). A rewrite suspends the block's contribution
|
||||
// until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames;
|
||||
// the engine then backfills the stalled gap.
|
||||
#nativeScrollbackCommitSafeEnd: number | undefined;
|
||||
|
||||
override invalidate(): void {
|
||||
// A theme/global invalidation forces a full recompute on the rebuild that
|
||||
// follows; retire every snapshot.
|
||||
// Theme/global invalidation: retire every diff snapshot so stale styling
|
||||
// is not diffed against the recolored render.
|
||||
this.#generation++;
|
||||
super.invalidate();
|
||||
}
|
||||
@@ -239,32 +230,19 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
||||
return this.#nativeScrollbackCommitSafeEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire all frozen snapshots so the next render reflects each block's current
|
||||
* state. Call at reconciliation checkpoints (prompt submit) where the whole
|
||||
* transcript is replayed into native scrollback and any drift a frozen block
|
||||
* accumulated is reconciled.
|
||||
*/
|
||||
thaw(): void {
|
||||
this.#generation++;
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
width = Math.max(1, width);
|
||||
this.#nativeScrollbackLiveRegionStart = undefined;
|
||||
this.#nativeScrollbackCommitSafeEnd = undefined;
|
||||
|
||||
// Freezing/snapshotting only applies on ED3-risk terminals; elsewhere every
|
||||
// block renders live. Inter-block spacing applies on BOTH paths so the gap
|
||||
// between blocks is identical regardless of terminal.
|
||||
const risk = TERMINAL.eagerEraseScrollbackRisk;
|
||||
const count = this.children.length;
|
||||
|
||||
// The live region spans from the earliest still-mutating block through the
|
||||
// bottom. A block that has not finalized must stay repaintable: out-of-band
|
||||
// inserts (TTSR/todo cards) can append a finalized block *below* a tool that
|
||||
// is still awaiting its result, and freezing the tool there would strand its
|
||||
// committed rows on the mid-stream preview the late result never reaches.
|
||||
// bottom. A block that has not finalized must stay below the seam: out-of-
|
||||
// band inserts (TTSR/todo cards) can append a finalized block *below* a
|
||||
// tool that is still awaiting its result, and committing the tool there
|
||||
// would strand its history rows on the mid-stream preview the late result
|
||||
// never reaches.
|
||||
let liveStartIndex = count - 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (!isBlockFinalized(this.children[i]!)) {
|
||||
@@ -272,62 +250,45 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
|
||||
// recompute them so they freeze at their final content. Everything below
|
||||
// the lower of the two cutoffs was already frozen last frame and replays.
|
||||
const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
|
||||
if (risk) this.#prevLiveStartIndex = liveStartIndex;
|
||||
|
||||
const lines: string[] = [];
|
||||
// Tracks whether we are still inside the leading run of commit-safe live
|
||||
// blocks. The first still-live volatile block closes it, but rendering
|
||||
// continues so lower blocks remain visible.
|
||||
let commitSafeOpen = true;
|
||||
// The live-region start is recorded at the first visible row at/after the
|
||||
// cutoff; empty leading blocks (or a separator) must not claim it early.
|
||||
// The live-region start is recorded at the first visible row at/after
|
||||
// liveStartIndex; empty leading blocks (or a separator) must not claim it
|
||||
// early.
|
||||
let liveRecorded = false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const child = this.children[i]! as Component & SnapshotCarrier;
|
||||
|
||||
// Resolve this child's contribution — its visible body with plain-blank
|
||||
// top/bottom edges stripped (the container owns inter-block gaps). On
|
||||
// ED3-risk terminals a frozen, scrolled-off block replays its snapshot
|
||||
// instead of recomputing; a stale generation (post-thaw) or width
|
||||
// mismatch (resize) recomputes, as does a block still live last frame.
|
||||
let contribution: string[] | undefined;
|
||||
const previousSnapshot = risk ? child[kSnapshot] : undefined;
|
||||
if (risk && i < liveStartIndex && i < replayCutoff) {
|
||||
if (hasValidSnapshot(previousSnapshot, width, this.#generation)) {
|
||||
contribution = previousSnapshot.lines;
|
||||
}
|
||||
}
|
||||
// This child's contribution: its current render with plain-blank
|
||||
// top/bottom edges stripped (the container owns inter-block gaps).
|
||||
// Always the latest content — committed history keeps whatever bytes
|
||||
// it was written with, but the window must reflect the present state
|
||||
// (late tool results, post-finalize re-layouts, expand toggles).
|
||||
const previousSnapshot = child[kSnapshot];
|
||||
const contribution = stripPlainBlankEdges(child.render(width));
|
||||
let liveCommitState: LiveCommitState | undefined;
|
||||
if (contribution === undefined) {
|
||||
const rendered = child.render(width);
|
||||
contribution = stripPlainBlankEdges(rendered);
|
||||
if (risk && i >= liveStartIndex && !isBlockFinalized(child)) {
|
||||
liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
|
||||
}
|
||||
// Cache every block's latest contribution. While a block is in the
|
||||
// live region this keeps its snapshot current; on the frame it crosses
|
||||
// out, the recompute above refreshes it before it freezes.
|
||||
if (risk) {
|
||||
child[kSnapshot] = {
|
||||
width,
|
||||
lines: contribution,
|
||||
generation: this.#generation,
|
||||
appendOnly: liveCommitState?.appendOnly ?? false,
|
||||
volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
|
||||
};
|
||||
}
|
||||
if (i >= liveStartIndex && !isBlockFinalized(child)) {
|
||||
liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
|
||||
}
|
||||
// Cache the latest contribution as the next frame's diff input.
|
||||
child[kSnapshot] = {
|
||||
width,
|
||||
lines: contribution,
|
||||
generation: this.#generation,
|
||||
appendOnly: liveCommitState?.appendOnly ?? false,
|
||||
volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
|
||||
};
|
||||
|
||||
// Empty (or stripped-to-nothing) children contribute nothing and never
|
||||
// affect spacing or the live-region offsets. An empty still-live child
|
||||
// still closes the commit-safe run: if it later gains rows, it pushes
|
||||
// everything below it.
|
||||
if (contribution.length === 0) {
|
||||
if (risk && i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
|
||||
if (i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -336,10 +297,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
||||
// already a plain blank (a fragment's own trailing pad), never doubling.
|
||||
const sep = lines.length > 0 && !isPlainBlank(lines[lines.length - 1]!) ? 1 : 0;
|
||||
|
||||
// The separator before the first live block stays in the committed prefix
|
||||
// (it is deterministic and never changes once the prior block is frozen),
|
||||
// The separator before the first live block stays in the committed
|
||||
// prefix (it is deterministic once the prior block's body is settled),
|
||||
// so the live region begins at the block's first content row.
|
||||
if (risk && !liveRecorded && i >= liveStartIndex) {
|
||||
if (!liveRecorded && i >= liveStartIndex) {
|
||||
this.#nativeScrollbackLiveRegionStart = lines.length + sep;
|
||||
liveRecorded = true;
|
||||
}
|
||||
@@ -348,7 +309,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
||||
const blockStart = lines.length;
|
||||
for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
|
||||
|
||||
if (risk && i >= liveStartIndex && commitSafeOpen) {
|
||||
if (i >= liveStartIndex && commitSafeOpen) {
|
||||
const finalized = isBlockFinalized(child);
|
||||
const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
|
||||
if (safeLength > 0) {
|
||||
|
||||
@@ -36,19 +36,6 @@ const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
|
||||
*/
|
||||
export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…";
|
||||
|
||||
// Events that change foreground streaming state, or that reset a turn. The TUI
|
||||
// eager native-scrollback rebuild mode is recomputed only on these so unrelated
|
||||
// IRC/notices/status refreshes do not toggle scrollback replay policy.
|
||||
const STREAM_RENDER_MODE_EVENTS: Record<string, true> = {
|
||||
agent_start: true,
|
||||
agent_end: true,
|
||||
message_start: true,
|
||||
message_end: true,
|
||||
tool_execution_start: true,
|
||||
tool_execution_update: true,
|
||||
tool_execution_end: true,
|
||||
};
|
||||
|
||||
type AgentSessionEventHandlers = {
|
||||
[E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
|
||||
};
|
||||
@@ -65,7 +52,6 @@ export class EventController {
|
||||
#renderedCustomMessages = new Set<string>();
|
||||
#lastIntent: string | undefined = undefined;
|
||||
#backgroundToolCallIds = new Set<string>();
|
||||
#assistantMessageStreaming = false;
|
||||
#agentTurnActive = false;
|
||||
#interrupting = false;
|
||||
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
||||
@@ -217,30 +203,6 @@ export class EventController {
|
||||
|
||||
const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
|
||||
await run(event);
|
||||
// While an assistant turn is active, visible status chrome and foreground
|
||||
// transcript blocks can re-render after rows have entered native scrollback
|
||||
// (idle Working loader, Markdown fences, wrapping, tool previews). Let the
|
||||
// TUI use its foreground live-region path instead of idle deferral, which
|
||||
// can otherwise leave the loader/status frame frozen until the next input.
|
||||
// Background-running tools after the turn ends are excluded so late async
|
||||
// updates keep the no-yank deferral; agent_start/agent_end bracket the
|
||||
// foreground turn.
|
||||
if (STREAM_RENDER_MODE_EVENTS[event.type]) {
|
||||
this.#refreshToolRenderMode();
|
||||
}
|
||||
}
|
||||
|
||||
#refreshToolRenderMode(): void {
|
||||
let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming;
|
||||
if (!foregroundToolActive) {
|
||||
for (const toolCallId of this.ctx.pendingTools.keys()) {
|
||||
if (!this.#backgroundToolCallIds.has(toolCallId)) {
|
||||
foregroundToolActive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
|
||||
}
|
||||
|
||||
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
||||
@@ -250,7 +212,6 @@ export class EventController {
|
||||
this.#readToolCallArgs.clear();
|
||||
this.#readToolCallAssistantComponents.clear();
|
||||
this.#resetReadGroup();
|
||||
this.#assistantMessageStreaming = false;
|
||||
this.#lastAssistantComponent = undefined;
|
||||
// Restore the previous turn's inline error in the transcript before dropping
|
||||
// the banner, so the error stays in history once the banner is gone.
|
||||
@@ -267,7 +228,6 @@ export class EventController {
|
||||
this.ctx.statusContainer.clear();
|
||||
}
|
||||
this.#cancelIdleCompaction();
|
||||
this.#refreshToolRenderMode();
|
||||
this.ctx.ensureLoadingAnimation();
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
@@ -340,7 +300,6 @@ export class EventController {
|
||||
this.ctx.addMessageToChat(event.message);
|
||||
this.ctx.ui.requestRender();
|
||||
} else if (event.message.role === "assistant") {
|
||||
this.#assistantMessageStreaming = true;
|
||||
this.#lastVisibleBlockCount = 0;
|
||||
this.ctx.streamingComponent = new AssistantMessageComponent(
|
||||
undefined,
|
||||
@@ -491,9 +450,6 @@ export class EventController {
|
||||
|
||||
async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
|
||||
if (event.message.role === "user") return;
|
||||
if (event.message.role === "assistant") {
|
||||
this.#assistantMessageStreaming = false;
|
||||
}
|
||||
if (this.ctx.streamingComponent && event.message.role === "assistant") {
|
||||
this.ctx.streamingMessage = event.message;
|
||||
this.#streamingReveal.stop();
|
||||
@@ -701,7 +657,6 @@ export class EventController {
|
||||
}
|
||||
async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
|
||||
this.#agentTurnActive = false;
|
||||
this.#assistantMessageStreaming = false;
|
||||
this.#streamingReveal.stop();
|
||||
if (this.ctx.loadingAnimation) {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
|
||||
@@ -267,7 +267,7 @@ export class InputController {
|
||||
const focused = this.ctx.ui.getFocused();
|
||||
const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
|
||||
target.pasteText(text);
|
||||
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
|
||||
this.ctx.ui.requestRender();
|
||||
},
|
||||
pasteImage: async image => {
|
||||
// Images can only land in the main editor — when a modal Input is
|
||||
@@ -755,7 +755,7 @@ export class InputController {
|
||||
const dims = await this.#imageDimensions(imageData);
|
||||
const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`;
|
||||
this.ctx.editor.insertText(`${label} `);
|
||||
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
/** Probe pixel dimensions for the marker label (`[Image #N, WxH]`). Returns undefined when the
|
||||
@@ -801,7 +801,7 @@ export class InputController {
|
||||
});
|
||||
if (!image) {
|
||||
this.ctx.editor.pasteText(path);
|
||||
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.showStatus("Pasted path is not a supported image");
|
||||
return;
|
||||
}
|
||||
@@ -811,7 +811,7 @@ export class InputController {
|
||||
);
|
||||
} catch (error) {
|
||||
this.ctx.editor.pasteText(path);
|
||||
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.showStatus(
|
||||
error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path",
|
||||
);
|
||||
|
||||
@@ -266,10 +266,6 @@ export class SelectorController {
|
||||
this.ctx.updateEditorBorderColor();
|
||||
break;
|
||||
|
||||
case "clearOnShrink":
|
||||
this.ctx.ui.setClearOnShrink(value as boolean);
|
||||
break;
|
||||
|
||||
case "autocompleteMaxVisible":
|
||||
this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));
|
||||
break;
|
||||
|
||||
@@ -409,7 +409,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
|
||||
this.ui.setClearOnShrink(settings.get("clearOnShrink"));
|
||||
this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
|
||||
// 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
|
||||
@@ -429,7 +428,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.ui.requestRender(true);
|
||||
};
|
||||
this.editor.onAutocompleteUpdate = () => {
|
||||
this.ui.requestRender(false, { allowUnknownViewportMutation: true });
|
||||
this.ui.requestRender();
|
||||
};
|
||||
this.#syncEditorMaxHeight();
|
||||
this.#resizeHandler = () => {
|
||||
@@ -959,13 +958,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
this.editor.setText("");
|
||||
this.editor.imageLinks = undefined;
|
||||
// Reconciliation checkpoint: only retire frozen block snapshots after TUI
|
||||
// proves the native viewport is at the tail and replays scrollback safely.
|
||||
// Unknown host viewports stay frozen; thawing them would expose live rows
|
||||
// over stale native history and can yank or duplicate when ED3 is unsafe.
|
||||
if (this.ui.refreshNativeScrollbackIfDirty()) {
|
||||
this.chatContainer.thaw();
|
||||
}
|
||||
this.ensureLoadingAnimation();
|
||||
this.ui.requestRender();
|
||||
return submission;
|
||||
@@ -2587,7 +2579,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.ui.requestRender(true);
|
||||
};
|
||||
nextEditor.onAutocompleteUpdate = () => {
|
||||
this.ui.requestRender(false, { allowUnknownViewportMutation: true });
|
||||
this.ui.requestRender();
|
||||
};
|
||||
nextEditor.setMaxHeight(this.#computeEditorMaxHeight());
|
||||
if (this.historyStorage) {
|
||||
|
||||
@@ -19,7 +19,6 @@ const sample: TerminalStateInfo = {
|
||||
hyperlinks: false,
|
||||
deccara: true,
|
||||
screenToScrollback: true,
|
||||
eagerEraseScrollbackRisk: true,
|
||||
synchronizedOutput: false,
|
||||
multiplexer: null,
|
||||
env: { TERM: "xterm-kitty", TERM_PROGRAM: undefined, TERM_PROGRAM_VERSION: undefined, COLORTERM: "truecolor" },
|
||||
@@ -42,7 +41,6 @@ describe("formatTerminalState", () => {
|
||||
expect(out).toContain("120x40 cells · cell 9x18px");
|
||||
// supportsScreenToScrollback -> the non-destructive CSI 22 J clear.
|
||||
expect(out).toContain("Screen->history clear: CSI 22 J");
|
||||
expect(out).toContain("Eager-erase risk: yes");
|
||||
});
|
||||
|
||||
it("renders the redraw fallback when screen-to-scrollback is unsupported", () => {
|
||||
|
||||
@@ -6,11 +6,11 @@ import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/compo
|
||||
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||
import { type Component, TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
||||
import { type Component, Text } from "@oh-my-pi/pi-tui";
|
||||
|
||||
// Models a transcript block that re-lays-out (tool preview collapsing, assistant
|
||||
// message finalizing, late async result) after it has scrolled past the live
|
||||
// region — the mutation that leaves a stale duplicate on ED3-risk terminals.
|
||||
// message finalizing, late async result) after newer blocks were appended below
|
||||
// it — the window must always reflect its current content.
|
||||
class MutableBlock implements Component {
|
||||
#lines: string[];
|
||||
constructor(lines: string[]) {
|
||||
@@ -51,9 +51,6 @@ class StreamingBlock implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
const riskFlag = TERMINAL as unknown as { eagerEraseScrollbackRisk: boolean };
|
||||
const original = riskFlag.eagerEraseScrollbackRisk;
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme();
|
||||
});
|
||||
@@ -64,7 +61,6 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
riskFlag.eagerEraseScrollbackRisk = original;
|
||||
resetSettingsForTest();
|
||||
});
|
||||
|
||||
@@ -94,34 +90,34 @@ function plain(lines: string[]): string {
|
||||
}
|
||||
|
||||
describe("TranscriptContainer", () => {
|
||||
it("freezes a block at its last live render once a newer block is appended (ED3-risk)", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
it("always renders a block's current content, even after newer blocks append below it", () => {
|
||||
const container = new TranscriptContainer();
|
||||
const a = new MutableBlock(["a1"]);
|
||||
container.addChild(a);
|
||||
expect(container.render(40)).toEqual(["a1"]);
|
||||
|
||||
// While `a` is still the live (bottom-most) block its render tracks updates.
|
||||
a.set(["a2"]);
|
||||
expect(container.render(40)).toEqual(["a2"]);
|
||||
|
||||
// A newer block makes `a` non-live; it now replays its last live render.
|
||||
const b = new MutableBlock(["b1"]);
|
||||
container.addChild(b);
|
||||
expect(container.render(40)).toEqual(["a2", "", "b1"]);
|
||||
|
||||
// A post-freeze mutation of `a` (its collapse/re-layout) is NOT reflected —
|
||||
// the committed rows stay stable so no stale duplicate enters scrollback.
|
||||
// A late re-layout of `a` (collapse, late async result, expand toggle) is
|
||||
// reflected immediately: committed history keeps its old bytes, but the
|
||||
// visible window always shows the present state.
|
||||
a.set(["a3-collapsed"]);
|
||||
expect(container.render(40)).toEqual(["a2", "", "b1"]);
|
||||
expect(container.render(40)).toEqual(["a3-collapsed", "", "b1"]);
|
||||
|
||||
// The live block still updates freely.
|
||||
b.set(["b2"]);
|
||||
expect(container.render(40)).toEqual(["a2", "", "b2"]);
|
||||
expect(container.render(40)).toEqual(["a3-collapsed", "", "b2"]);
|
||||
|
||||
// Width changes recompute like any other frame.
|
||||
a.set(["a-reflowed"]);
|
||||
expect(container.render(80)).toEqual(["a-reflowed", "", "b2"]);
|
||||
});
|
||||
|
||||
it("reports the live block start for native scrollback pinning (ED3-risk)", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
it("reports the live block start that gates native scrollback commits", () => {
|
||||
const container = new TranscriptContainer();
|
||||
const a = new MutableBlock(["a1", "a2"]);
|
||||
const b = new MutableBlock(["b1"]);
|
||||
@@ -136,93 +132,7 @@ describe("TranscriptContainer", () => {
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
|
||||
});
|
||||
|
||||
it("seals the prior block at its final content when finalize+append coalesce (ED3-risk)", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
const container = new TranscriptContainer();
|
||||
const a = new MutableBlock(["Nat"]);
|
||||
container.addChild(a);
|
||||
// `a` streamed a partial chunk and rendered while live.
|
||||
expect(container.render(40)).toEqual(["Nat"]);
|
||||
|
||||
// TUI render coalescing: `a` finalizes AND a newer block is appended within
|
||||
// one throttled frame, so no render happens between the two mutations.
|
||||
a.set(["Natives built, now..."]);
|
||||
const b = new MutableBlock(["b1"]);
|
||||
container.addChild(b);
|
||||
|
||||
// The transition frame must seal `a` at its final content, not the stale
|
||||
// mid-stream snapshot ("Nat") it last rendered while live.
|
||||
expect(container.render(40)).toEqual(["Natives built, now...", "", "b1"]);
|
||||
|
||||
// Once sealed, a later re-layout of `a` stays frozen until the next thaw.
|
||||
a.set(["a-collapsed"]);
|
||||
expect(container.render(40)).toEqual(["Natives built, now...", "", "b1"]);
|
||||
});
|
||||
|
||||
it("thaw() reconciles frozen blocks to their current state", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
const container = new TranscriptContainer();
|
||||
const a = new MutableBlock(["a1"]);
|
||||
const b = new MutableBlock(["b1"]);
|
||||
container.addChild(a);
|
||||
container.addChild(b);
|
||||
container.render(40);
|
||||
a.set(["a-final"]);
|
||||
expect(container.render(40)).toEqual(["a1", "", "b1"]); // frozen
|
||||
|
||||
container.thaw();
|
||||
expect(container.render(40)).toEqual(["a-final", "", "b1"]); // reconciled
|
||||
});
|
||||
|
||||
it("invalidate() retires frozen snapshots so resetDisplay reflects current state", () => {
|
||||
// resetDisplay() (Ctrl+L, and the Ctrl+O expand path) reflows by calling
|
||||
// TUI.invalidate(), which propagates to this container. That must retire the
|
||||
// frozen snapshots the same way thaw() does, or a forced full replay would
|
||||
// still emit the pre-mutation (e.g. collapsed) render.
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
const container = new TranscriptContainer();
|
||||
const a = new MutableBlock(["a-collapsed"]);
|
||||
const b = new MutableBlock(["b1"]);
|
||||
container.addChild(a);
|
||||
container.addChild(b);
|
||||
container.render(40);
|
||||
a.set(["a-expanded-1", "a-expanded-2"]);
|
||||
expect(container.render(40)).toEqual(["a-collapsed", "", "b1"]); // frozen
|
||||
|
||||
container.invalidate();
|
||||
expect(container.render(40)).toEqual(["a-expanded-1", "a-expanded-2", "", "b1"]);
|
||||
});
|
||||
|
||||
it("recomputes a frozen block on a width change", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
const container = new TranscriptContainer();
|
||||
const a = new MutableBlock(["a1"]);
|
||||
const b = new MutableBlock(["b1"]);
|
||||
container.addChild(a);
|
||||
container.addChild(b);
|
||||
container.render(40);
|
||||
a.set(["a-reflowed"]);
|
||||
expect(container.render(40)).toEqual(["a1", "", "b1"]); // frozen at width 40
|
||||
// A resize is an explicit rebuild that reconciles history, so recompute.
|
||||
expect(container.render(80)).toEqual(["a-reflowed", "", "b1"]);
|
||||
});
|
||||
|
||||
it("renders every block live on terminals that can rebuild history", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = false;
|
||||
const container = new TranscriptContainer();
|
||||
const a = new MutableBlock(["a1"]);
|
||||
const b = new MutableBlock(["b1"]);
|
||||
container.addChild(a);
|
||||
container.addChild(b);
|
||||
container.render(40);
|
||||
// No freezing: a non-live block's mutation is reflected (the renderer can
|
||||
// rebuild committed history on these terminals).
|
||||
a.set(["a-updated"]);
|
||||
expect(container.render(40)).toEqual(["a-updated", "", "b1"]);
|
||||
});
|
||||
|
||||
it("keeps an unfinalized block live when a finalized block is appended below it (ED3-risk)", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
it("keeps an unfinalized block below the seam when a finalized block is appended below it", () => {
|
||||
const container = new TranscriptContainer();
|
||||
// A foreground tool whose args are still streaming (no result yet).
|
||||
const tool = new StreamingBlock(["write (streaming)"]);
|
||||
@@ -230,26 +140,25 @@ describe("TranscriptContainer", () => {
|
||||
expect(container.render(40)).toEqual(["write (streaming)"]);
|
||||
|
||||
// An out-of-band card (TTSR/todo reminder) is appended below the in-flight
|
||||
// tool while it is still streaming. The tool must NOT freeze here.
|
||||
// tool while it is still streaming. The tool's rows must not commit here.
|
||||
const card = new MutableBlock(["rule card"]);
|
||||
container.addChild(card);
|
||||
expect(container.render(40)).toEqual(["write (streaming)", "", "rule card"]);
|
||||
// The live region begins at the unfinalized tool, not the bottom card.
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(0);
|
||||
|
||||
// The tool's result lands after the card is already below it. Because the
|
||||
// tool was kept live, its final content is reflected — the bug was it
|
||||
// freezing on the streaming preview and never showing the result.
|
||||
// The tool's result lands after the card is already below it.
|
||||
tool.finalize(["✔ write: 4 lines"]);
|
||||
expect(container.render(40)).toEqual(["✔ write: 4 lines", "", "rule card"]);
|
||||
// The seam moves past the now-finalized tool.
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
|
||||
|
||||
// Now finalized, it freezes: a later re-layout stays put until the next thaw.
|
||||
// Even after finalizing, a late re-layout still repaints in the window.
|
||||
tool.set(["collapsed"]);
|
||||
expect(container.render(40)).toEqual(["✔ write: 4 lines", "", "rule card"]);
|
||||
expect(container.render(40)).toEqual(["collapsed", "", "rule card"]);
|
||||
});
|
||||
|
||||
it("keeps a streaming assistant live so final interrupted content can land after status rows below it (ED3-risk)", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
it("keeps a streaming assistant live so final interrupted content can land after status rows below it", () => {
|
||||
const container = new TranscriptContainer();
|
||||
const assistant = new AssistantMessageComponent();
|
||||
assistant.updateContent(
|
||||
@@ -283,8 +192,7 @@ describe("TranscriptContainer", () => {
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).not.toBe(0);
|
||||
});
|
||||
|
||||
it("seals the live region at the earliest of several unfinalized blocks (ED3-risk)", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
it("starts the live region at the earliest of several unfinalized blocks", () => {
|
||||
const container = new TranscriptContainer();
|
||||
const sealed = new StreamingBlock(["done"], true);
|
||||
const pending = new StreamingBlock(["pending"]);
|
||||
@@ -297,20 +205,21 @@ describe("TranscriptContainer", () => {
|
||||
// leading block can commit while pending + card stay repaintable.
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
|
||||
|
||||
// The leading sealed block freezes; its re-layout is not reflected.
|
||||
// The sealed block's late re-layout still renders current content; the
|
||||
// seam is unaffected (it keys off finalization, not row diffs).
|
||||
sealed.set(["done-collapsed"]);
|
||||
expect(container.render(40)).toEqual(["done", "", "pending", "", "card"]);
|
||||
expect(container.render(40)).toEqual(["done-collapsed", "", "pending", "", "card"]);
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
|
||||
|
||||
// The pending block updates freely while live.
|
||||
pending.finalize(["pending-final"]);
|
||||
expect(container.render(40)).toEqual(["done", "", "pending-final", "", "card"]);
|
||||
expect(container.render(40)).toEqual(["done-collapsed", "", "pending-final", "", "card"]);
|
||||
expect(container.getNativeScrollbackLiveRegionStart()).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TranscriptContainer spacing", () => {
|
||||
it("inserts exactly one blank line between consecutive blocks", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = false;
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["a"]));
|
||||
container.addChild(new MutableBlock(["b"]));
|
||||
@@ -320,7 +229,6 @@ describe("TranscriptContainer spacing", () => {
|
||||
});
|
||||
|
||||
it("strips a block's plain-blank top/bottom padding", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = false;
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["a"]));
|
||||
// Leading Spacer rows + a trailing paddingY row collapse to just the body.
|
||||
@@ -329,7 +237,6 @@ describe("TranscriptContainer spacing", () => {
|
||||
});
|
||||
|
||||
it("preserves background-colored padding rows (block-internal design)", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = false;
|
||||
const bgPad = "\x1b[48;2;0;0;0m \x1b[0m";
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["a"]));
|
||||
@@ -339,7 +246,6 @@ describe("TranscriptContainer spacing", () => {
|
||||
});
|
||||
|
||||
it("does not double the gap when a block carries its own trailing blank", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = false;
|
||||
const container = new TranscriptContainer();
|
||||
// The trailing blank is stripped, so only the container's separator remains.
|
||||
container.addChild(new MutableBlock(["note", ""]));
|
||||
@@ -348,7 +254,6 @@ describe("TranscriptContainer spacing", () => {
|
||||
});
|
||||
|
||||
it("does not inject separators within a single block's rows", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = false;
|
||||
const container = new TranscriptContainer();
|
||||
// An IRC card / file-mention list wrapped as one block stays tight inside.
|
||||
container.addChild(new MutableBlock(["header", " body1", " body2"]));
|
||||
@@ -356,7 +261,6 @@ describe("TranscriptContainer spacing", () => {
|
||||
});
|
||||
|
||||
it("drops a blank-only block without leaving a stray gap", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = false;
|
||||
const container = new TranscriptContainer();
|
||||
container.addChild(new MutableBlock(["a"]));
|
||||
container.addChild(new MutableBlock(["", " "]));
|
||||
@@ -365,7 +269,6 @@ describe("TranscriptContainer spacing", () => {
|
||||
});
|
||||
|
||||
it("counts the separator into the committed prefix below the live region (ED3-risk)", () => {
|
||||
riskFlag.eagerEraseScrollbackRisk = true;
|
||||
const container = new TranscriptContainer();
|
||||
// A finalized block, then a still-live block below it.
|
||||
container.addChild(new MutableBlock(["a1", "a2"]));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-ag
|
||||
import { ReadToolGroupComponent } from "@oh-my-pi/pi-coding-agent/modes/components/read-tool-group";
|
||||
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
|
||||
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { type Component, TERMINAL } from "@oh-my-pi/pi-tui";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
|
||||
/** Minimal transcript block whose finalized state is fixed at construction. */
|
||||
class StubBlock implements Component {
|
||||
@@ -21,8 +21,6 @@ function successResult() {
|
||||
}
|
||||
|
||||
describe("ReadToolGroupComponent transcript freezing", () => {
|
||||
let prevRisk: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true });
|
||||
@@ -31,7 +29,6 @@ describe("ReadToolGroupComponent transcript freezing", () => {
|
||||
|
||||
afterEach(() => {
|
||||
settings.clearOverride("tui.hyperlinks");
|
||||
TERMINAL.eagerEraseScrollbackRisk = prevRisk;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -42,9 +39,6 @@ describe("ReadToolGroupComponent transcript freezing", () => {
|
||||
// ED3-risk terminals the container froze the group at its pending preview, so
|
||||
// the late success result never repainted — the read stuck on "⏳ Read <path>".
|
||||
it("repaints a late read result instead of freezing the pending preview", () => {
|
||||
prevRisk = TERMINAL.eagerEraseScrollbackRisk;
|
||||
TERMINAL.eagerEraseScrollbackRisk = true;
|
||||
|
||||
const tc = new TranscriptContainer();
|
||||
const group = new ReadToolGroupComponent();
|
||||
group.updateArgs({ path: "/tmp/example.ts", sel: "280-345" }, "id1");
|
||||
@@ -67,7 +61,6 @@ describe("ReadToolGroupComponent transcript freezing", () => {
|
||||
|
||||
// The finalization seam the TranscriptContainer keys off of.
|
||||
it("stays live until pending entries settle, then reports finalized", () => {
|
||||
prevRisk = TERMINAL.eagerEraseScrollbackRisk;
|
||||
const group = new ReadToolGroupComponent();
|
||||
group.updateArgs({ path: "/tmp/a.ts" }, "id1");
|
||||
|
||||
@@ -87,7 +80,6 @@ describe("ReadToolGroupComponent transcript freezing", () => {
|
||||
// Turn-end safety: a read that never delivers a result (aborted turn) must not
|
||||
// pin the live region forever. seal() forces it terminal.
|
||||
it("seals a never-resolved pending read so it can freeze", () => {
|
||||
prevRisk = TERMINAL.eagerEraseScrollbackRisk;
|
||||
const group = new ReadToolGroupComponent();
|
||||
group.updateArgs({ path: "/tmp/a.ts" }, "id1");
|
||||
group.finalize();
|
||||
|
||||
@@ -242,18 +242,12 @@ describe("streaming edit preview height (stable, full tail window)", () => {
|
||||
expect(sawPreviewSentinel).toBe(true);
|
||||
expect(maxStreamingHeight).toBeGreaterThan(term.rows);
|
||||
|
||||
const preCheckpointBufferText = normalizedBufferRows(term).join("\n");
|
||||
const stalePreviewRowsExistedBeforeCheckpoint = preCheckpointBufferText.includes(previewPrefix);
|
||||
term.scrollLines(1_000);
|
||||
const checkpointRefreshed = tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true });
|
||||
await settleTerminal(term);
|
||||
|
||||
const finalBufferText = normalizedBufferRows(term).join("\n");
|
||||
expect(finalBufferText).toContain(finalSentinel);
|
||||
expect(finalBufferText).not.toContain(previewPrefix);
|
||||
if (stalePreviewRowsExistedBeforeCheckpoint) {
|
||||
expect(checkpointRefreshed).toBe(true);
|
||||
}
|
||||
|
||||
term.scrollLines(-1_000);
|
||||
await term.flush();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
|
||||
import { type Component, TERMINAL } from "@oh-my-pi/pi-tui";
|
||||
|
||||
type MutableTerminalInfo = { eagerEraseScrollbackRisk: boolean };
|
||||
const mutableTerminalInfo = TERMINAL as unknown as MutableTerminalInfo;
|
||||
|
||||
async function withTerminalRisk<T>(risk: boolean, run: () => T | Promise<T>): Promise<T> {
|
||||
const saved = TERMINAL.eagerEraseScrollbackRisk;
|
||||
mutableTerminalInfo.eagerEraseScrollbackRisk = risk;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
mutableTerminalInfo.eagerEraseScrollbackRisk = saved;
|
||||
}
|
||||
}
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
|
||||
class MutableLiveBlock implements Component {
|
||||
#lines: string[];
|
||||
@@ -32,21 +19,19 @@ class MutableLiveBlock implements Component {
|
||||
}
|
||||
|
||||
describe("transcript streaming commit (assistant text)", () => {
|
||||
it("treats in-place growth of the trailing line as append-only", async () => {
|
||||
await withTerminalRisk(true, () => {
|
||||
const chat = new TranscriptContainer();
|
||||
// Models a streaming assistant reply: stable head rows plus a current
|
||||
// line that grows token-by-token without adding a new row.
|
||||
const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]);
|
||||
chat.addChild(block);
|
||||
it("treats in-place growth of the trailing line as append-only", () => {
|
||||
const chat = new TranscriptContainer();
|
||||
// Models a streaming assistant reply: stable head rows plus a current
|
||||
// line that grows token-by-token without adding a new row.
|
||||
const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]);
|
||||
chat.addChild(block);
|
||||
|
||||
chat.render(80);
|
||||
chat.render(80);
|
||||
|
||||
block.setLines(["para one", "para two", "the quick brown fox"]);
|
||||
chat.render(80);
|
||||
// The head rows never changed; only the trailing line grew. Its scrolled-
|
||||
// off head must be committable to native scrollback (tmux pane history).
|
||||
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3);
|
||||
});
|
||||
block.setLines(["para one", "para two", "the quick brown fox"]);
|
||||
chat.render(80);
|
||||
// The head rows never changed; only the trailing line grew. Its scrolled-
|
||||
// off head must be committable to native scrollback (tmux pane history).
|
||||
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user