fix(coding-agent): stabilized streaming TUI rendering with readonly rows and memoized reuse

- Changed render methods to return component-owned `readonly string[]` rows.
- Added `RenderStablePrefix` row reuse to avoid repainting unchanged streaming content.
- Stabilized streaming gutters and spinner placement to reduce preview jitter and flicker.
- Finalized commit-safe transcript behavior for tool-call previews and added streaming edge-case tests.
This commit is contained in:
can1357
2026-06-10 07:24:45 +02:00
parent 53b8950072
commit 61a57c1d21
87 changed files with 1671 additions and 420 deletions
+5 -3
View File
@@ -25,13 +25,15 @@ If your extension/tool can run in non-interactive mode, guard with `ctx.hasUI` /
```ts
export interface Component {
render(width: number): string[];
render(width: number): readonly string[];
handleInput?(data: string): void;
wantsKeyRelease?: boolean;
invalidate?(): void;
}
```
Render results are component-owned and immutable to callers; a component that did not change should return the **same array reference** it returned last time (reference equality is what enables the renderer's memoization and row virtualization), and must return a new array whenever its content changed.
`Focusable` is separate:
```ts
@@ -56,7 +58,7 @@ Minimal pattern:
```ts
import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
render(width: number): string[] {
render(width: number): readonly string[] {
return this.lines.map(line => truncateToWidth(replaceTabs(line), width));
}
```
@@ -218,7 +220,7 @@ class Picker implements Component {
this.list.handleInput(data);
}
render(width: number): string[] {
render(width: number): readonly string[] {
return this.list
.render(width)
.map((line) => truncateToWidth(replaceTabs(line), width));
+5
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Added `supportsReasoningParams`, `alwaysSendMaxTokens`, `strictResponsesPairing`, and a recursive `whenThinking` overlay (alongside `streamIdleTimeoutMs`/`supportsLongPromptCacheRetention`/`requiresToolResultId`/`replayUnsignedThinking`) to the OpenAI/Anthropic `compat` schema so custom model entries can configure those provider-specific capabilities
@@ -41,9 +42,13 @@
- Multi-entry edits now stop at the first failing entry and report exactly which entries were applied and which were not — continuing after a failure applied later entries authored against line numbers that assumed the failed entry succeeded, and a retry of the whole batch then double-applied the survivors.
- Decomposed `config/model-registry.ts` further: model roles (`MODEL_ROLES`, `getRoleInfo`, `getKnownRoleIds`) moved to `config/model-roles.ts`, the `models.json` config handle and provider validation moved to `config/models-config.ts`, the two provider+id merge scaffolds collapsed into one `mergeByModelKey` helper, the four ~15-field override/overlay enumerations now share a `ModelPatch` type applied by a single `applyModelPatch(base, patch, transport)` core (the `merge` vs `replace` transport policies preserve the same-id custom-definition replacement semantics), and canonical-variant selection delegates to `@oh-my-pi/pi-catalog/identity`'s new `resolveCanonicalVariant`
- Resolver cleanup: five duplicated trailing-`:level` suffix parses collapsed into `splitThinkingSuffix`, the matching engine is now the documented `matchModel` core with the selector grammar and entry points layered on top, and `resolveCliModel`'s hand-rolled decomposed provider/id lookup reuses `findExactModelReferenceMatch`; runtime discovery tests split out of `test/model-registry.test.ts` into `test/model-discovery.test.ts`
- `TranscriptContainer` assembles the transcript incrementally: each block's render is reference-compared and its stripped contribution, separator, and row placement are reused when unchanged, with the persistent row array truncated and re-pushed only from the first divergent block; the leading byte-identical row count is reported to the renderer through pi-tui's new `RenderStablePrefix` seam so off-screen transcript rows are no longer re-rendered, re-prepared, or re-audited every frame. Block components became reference-stable to make this effective: `UserMessageComponent` memoizes its OSC 133 zone wrapping, `WelcomeComponent` and `DynamicBorder` cache their renders, and dashboards copy before padding (render results are `readonly` under the new pi-tui contract)
- A live block whose trailing row grows in place as a visible prefix (token streaming into the cursor line) is now commit-safe through its full body instead of being held back by the volatile-tail margin — the growing row itself is the block's last and can never commit while it remains last, so a streaming reply's scrolled-off head reaches native scrollback (tmux pane history) mid-stream
### Fixed
- Fixed `ask` question/result renders so option and answer rows are no longer duplicated when the component is re-rendered
- Fixed streaming `write`/`diff` previews to keep line-number gutter widths stable while content grows, preventing already-rendered preview rows from being reflowed mid-stream
- Fixed the welcome screen showing "No LSP servers" when `lsp.lazy` is enabled: recognized servers are now still discovered at startup and listed with a dim "available" dot (no warmup), and `/status` reports them as `available` instead of omitting the section
- Fixed edit-tool diffs stacking adjacent `...` markers around inserted block-context rows (each row added its own gap markers from a snapshot of the diff, so neighboring insertions doubled them, and a marker could be left stranded between contiguous lines): non-contiguous regions are now separated by a single blank row, normalized after insertion, and rendered as one dim `…` in the TUI and HTML export
- Fixed an uncaught `questions.map is not a function` TUI crash in the ask tool's call renderer when a model double-encoded the `questions` array as a JSON string (a bare string passes a truthy `.length` check but has no `.map`): the renderer now normalizes untrusted call args — parsing double-encoded `questions`, dropping malformed entries/options, and falling back to the "No question provided" frame instead of throwing
@@ -68,7 +68,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
// Refresh tool list
allTools = pi.getAllTools();
await ctx.ui.custom((tui, theme, done) => {
await ctx.ui.custom((tui, theme, _keybindings, done) => {
// Build settings items for each tool
const items: SettingItem[] = allTools.map(tool => ({
id: tool,
@@ -78,10 +78,11 @@ export default function toolsExtension(pi: ExtensionAPI) {
}));
const container = new Container();
const header: readonly string[] = [theme.fg("accent", theme.bold("Tool Configuration")), ""];
container.addChild(
new (class {
render(_width: number) {
return [theme.fg("accent", theme.bold("Tool Configuration")), ""];
render(_width: number): readonly string[] {
return header;
}
invalidate() {}
})(),
@@ -110,7 +111,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
container.addChild(settingsList);
const component = {
render(width: number) {
render(width: number): readonly string[] {
return container.render(width);
},
invalidate() {
@@ -66,7 +66,7 @@ export function createDashboardController(): DashboardController {
let scrollOffset = 0;
return {
render(width: number): string[] {
render(width: number): readonly string[] {
const terminalRows = process.stdout.rows ?? 40;
const header = renderExpandedHeader(runtime, width, theme);
const body = renderDashboardLines(runtime, width, theme, 0);
+1 -1
View File
@@ -104,7 +104,7 @@ export async function renderGalleryState(
state: GalleryState,
width: number,
expanded = false,
): Promise<string[]> {
): Promise<readonly string[]> {
if (fixture.renderState) {
return await fixture.renderState(state, width, expanded);
}
@@ -56,7 +56,7 @@ function addGroupedReadArgs(component: ReadToolGroupComponent): void {
component.updateArgs({ path: groupedReadRepeatedRanges }, "read-ranges");
}
function renderReadGroupFixtureState(state: GalleryFixtureState, width: number, expanded: boolean): string[] {
function renderReadGroupFixtureState(state: GalleryFixtureState, width: number, expanded: boolean): readonly string[] {
const component = new ReadToolGroupComponent();
component.setExpanded(expanded);
@@ -22,7 +22,11 @@ export interface GalleryFixture {
* Custom gallery-only renderer for fixtures that are not one ToolExecutionComponent
* (for example the read-group transcript component).
*/
renderState?: (state: GalleryFixtureState, width: number, expanded: boolean) => string[] | Promise<string[]>;
renderState?: (
state: GalleryFixtureState,
width: number,
expanded: boolean,
) => readonly string[] | Promise<readonly string[]>;
/**
* Set for tools whose real `AgentTool` attaches `renderCall`/`renderResult`
* directly on the instance (e.g. `task`). The harness then attaches
@@ -213,7 +213,7 @@ function writeAssistantMessage(message: string): void {
}
}
function renderMarkdownLines(message: string): string[] {
function renderMarkdownLines(message: string): readonly string[] {
const width = Math.max(40, process.stdout.columns ?? 100);
const markdown = new Markdown(message, 0, 0, getMarkdownTheme());
return markdown.render(width);
@@ -602,7 +602,7 @@ export class DebugLogViewerComponent implements Component {
// no cached child state
}
render(width: number): string[] {
render(width: number): readonly string[] {
this.#lastRenderWidth = Math.max(20, width);
this.#ensureCursorVisible();
+1 -1
View File
@@ -147,7 +147,7 @@ export class RawSseViewerComponent implements Component {
invalidate(): void {}
render(width: number): string[] {
render(width: number): readonly string[] {
this.#lastRenderWidth = Math.max(MIN_VIEWER_WIDTH, width);
this.#followIfNeeded();
+33 -10
View File
@@ -261,7 +261,6 @@ function renderEditHeader(
options: {
icon: "pending" | "success" | "error";
iconOverride?: string;
spinnerFrame?: number;
op?: Operation;
rawPath: string;
rename?: string;
@@ -284,7 +283,6 @@ function renderEditHeader(
{
icon: options.icon,
iconOverride: options.iconOverride,
spinnerFrame: options.spinnerFrame,
title,
description,
},
@@ -322,6 +320,7 @@ function formatStreamingDiff(
uiTheme: Theme,
expanded: boolean,
label = "streaming",
spinnerFrame?: number,
): string {
if (!diff) return "";
// Collapsed uses a "Cursor" tail window: pin the last
@@ -342,11 +341,23 @@ function formatStreamingDiff(
text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
}
text += renderDiffColored(visible.join("\n"), { filePath: rawPath });
if (!expanded || label !== "preview") text += uiTheme.fg("dim", `\n(${label})`);
// The animated glyph rides this trailing line — inside the transcript's
// volatile-tail holdback — never the block header: an animating head row
// pins the native-scrollback commit boundary at the top of the block, so a
// tall expanded preview could never scroll-append mid-stream.
const spinner = spinnerFrame !== undefined ? `${formatStatusIcon("running", uiTheme, spinnerFrame)} ` : "";
if (spinner || !expanded || label !== "preview") {
text += `\n${spinner}${uiTheme.fg("dim", `(${label})`)}`;
}
return text;
}
function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme, expanded: boolean): string {
function formatMultiFileStreamingDiff(
previews: PerFileDiffPreview[],
uiTheme: Theme,
expanded: boolean,
spinnerFrame?: number,
): string {
const parts: string[] = [];
for (const preview of previews) {
if (!preview.diff && !preview.error) continue;
@@ -356,7 +367,13 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
continue;
}
if (preview.diff) {
parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview")}`);
// Only the last file's preview carries the animated streaming glyph;
// earlier files have settled and must stay byte-stable so their rows
// can commit to native scrollback mid-stream.
const isLast = preview === previews[previews.length - 1];
parts.push(
`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined)}`,
);
}
}
return parts.join("");
@@ -368,16 +385,17 @@ function getCallPreview(
uiTheme: Theme,
renderContext: EditRenderContext | undefined,
expanded: boolean,
spinnerFrame?: number,
): string {
const multi = renderContext?.perFileDiffPreview;
if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
return formatMultiFileStreamingDiff(multi, uiTheme, expanded);
return formatMultiFileStreamingDiff(multi, uiTheme, expanded, spinnerFrame);
}
if (args.previewDiff) {
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview");
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview", spinnerFrame);
}
if (args.diff && args.op) {
return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded);
return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded, "streaming", spinnerFrame);
}
if (args.diff) {
return renderPlainTextPreview(args.diff, uiTheme, rawPath);
@@ -554,15 +572,20 @@ export const editToolRenderer = {
fileCount = countEditFiles(editArgs.edits);
}
return framedBlock(uiTheme, width => {
// Static pending icon, never the animated glyph: the header is the
// head row of the framed block, and native-scrollback commits are
// prefix-only — an animating head row would pin the commit boundary
// at the top and keep a tall expanded preview from scroll-appending
// mid-stream. The liveness cue rides the trailing "(preview)" /
// "(streaming)" line instead.
const header = renderEditHeader(width, uiTheme, {
icon: "pending",
spinnerFrame: options?.spinnerFrame,
op,
rawPath,
rename,
extraSuffix: fileCount > 1 ? uiTheme.fg("dim", ` (+${fileCount - 1} more)`) : undefined,
});
let body = getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded);
let body = getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded, options?.spinnerFrame);
if (applyPatchSummary?.error) {
body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`;
}
+1 -1
View File
@@ -139,7 +139,7 @@ export function renderResult(
const outputBlock = new CachedOutputBlock();
return markFramedBlockComponent({
render(width: number): string[] {
render(width: number): readonly string[] {
// Read mutable state at render time
const { expanded, isPartial, spinnerFrame } = options;
@@ -194,7 +194,7 @@ class AgentListPane implements Component {
private readonly maxVisible: number,
) {}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
const searchPrefix = theme.fg("muted", "Search: ");
const searchText = this.searchQuery || theme.fg("dim", "type to filter");
@@ -255,7 +255,7 @@ class AgentInspectorPane implements Component {
private readonly effectiveResolution: ModelResolution | undefined,
) {}
render(width: number): string[] {
render(width: number): readonly string[] {
if (!this.agent) {
return [theme.fg("muted", "Select an agent"), theme.fg("dim", "to inspect settings")];
}
@@ -314,7 +314,7 @@ class TwoColumnBody implements Component {
private readonly maxHeight: number,
) {}
render(width: number): string[] {
render(width: number): readonly string[] {
const leftWidth = Math.floor(width * 0.5);
const rightWidth = width - leftWidth - 3;
const leftLines = this.leftPane.render(leftWidth);
@@ -507,7 +507,7 @@ export class AgentDashboard extends Container {
return Math.max(3, this.#computeBodyHeight() - 3);
}
override render(width: number): string[] {
override render(width: number): readonly string[] {
// Rebuild when terminal geometry changes so the full-screen overlay
// re-fits on resize.
if (this.#terminalRows() !== this.#builtRows || this.#uiWidth() !== this.#builtCols) {
@@ -516,10 +516,13 @@ export class AgentDashboard extends Container {
const lines = super.render(width);
// Pad to the full viewport so every state (list, edit, create) covers the
// screen as a true full-screen view instead of letting the transcript peek
// through below it.
// through below it. Copy before padding — the container's render result is
// component-owned and must not be mutated.
const rows = this.#terminalRows();
while (lines.length < rows) lines.push("");
return lines;
if (lines.length >= rows) return lines;
const padded = lines.slice();
while (padded.length < rows) padded.push("");
return padded;
}
#clampSelection(): void {
@@ -126,7 +126,7 @@ export class BashExecutionComponent extends Container {
this.#updateDisplay();
}
override render(width: number): string[] {
override render(width: number): readonly string[] {
if (this.#displayDirty) {
this.#displayDirty = false;
this.#updateDisplay();
@@ -173,7 +173,7 @@ export class CopySelectorComponent implements Component {
return out;
}
render(width: number): string[] {
render(width: number): readonly string[] {
const height = process.stdout.rows || 40;
const flat = this.#flatten();
const cursorIdx = Math.max(
@@ -109,10 +109,16 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
const lines = sanitizeText(diffText).split("\n");
const result: string[] = [];
const parsedLines = lines.map(parseDiffLine);
// Reserve 3 gutter digits: a streaming preview re-renders this diff as it
// grows, and a width derived purely from the current max line number widens
// at the 100-line crossing — re-padding every already-rendered row, which
// breaks the transcript's append-only commit detection and forces a full
// recommit of the block into native scrollback. A constant gutter through
// 999 lines keeps streamed rows byte-identical to the final result render.
const lineNumberWidth = parsedLines.reduce((width, parsed) => {
const lineNumber = parsed?.lineNum.trim() ?? "";
return Math.max(width, lineNumber.length);
}, 0);
}, 3);
// Batch-highlight context (unedited) lines so consecutive lines tokenize
// with full multi-line context. Highlighting is a no-op when no language
@@ -10,16 +10,25 @@ import { theme } from "../../modes/theme/theme";
*/
export class DynamicBorder implements Component {
#color: (str: string) => string;
#cachedWidth = -1;
#cachedLines: string[] | undefined;
constructor(color: (str: string) => string = str => theme.fg("border", str)) {
this.#color = color;
}
invalidate(): void {
// No cached state to invalidate currently
this.#cachedWidth = -1;
this.#cachedLines = undefined;
}
render(width: number): string[] {
return [this.#color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))];
render(width: number): readonly string[] {
if (this.#cachedLines && this.#cachedWidth === width) {
return this.#cachedLines;
}
const lines = [this.#color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))];
this.#cachedWidth = width;
this.#cachedLines = lines;
return lines;
}
}
@@ -137,7 +137,7 @@ export class ExtensionDashboard extends Container {
return Math.max(3, this.#computeBodyHeight() - 3);
}
override render(width: number): string[] {
override render(width: number): readonly string[] {
// Rebuild when terminal geometry changes so the full-screen overlay
// re-fits on resize.
if (this.#terminalRows() !== this.#builtRows || this.#uiWidth() !== this.#builtCols) {
@@ -145,10 +145,13 @@ export class ExtensionDashboard extends Container {
}
const lines = super.render(width);
// Pad to the full viewport so the dashboard covers the screen instead of
// letting the transcript peek through below it.
// letting the transcript peek through below it. Copy before padding — the
// container's render result is component-owned and must not be mutated.
const rows = this.#terminalRows();
while (lines.length < rows) lines.push("");
return lines;
if (lines.length >= rows) return lines;
const padded = lines.slice();
while (padded.length < rows) padded.push("");
return padded;
}
#buildLayout(): void {
@@ -367,7 +370,7 @@ class TwoColumnBody implements Component {
private readonly maxHeight: number,
) {}
render(width: number): string[] {
render(width: number): readonly string[] {
const leftWidth = Math.floor(width * 0.5);
const rightWidth = Math.max(0, width - leftWidth - 3);
@@ -113,7 +113,7 @@ export class ExtensionList implements Component {
invalidate(): void {}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
// Search bar
@@ -18,7 +18,7 @@ export class InspectorPanel implements Component {
invalidate(): void {}
render(width: number): string[] {
render(width: number): readonly string[] {
if (!this.#extension) {
return [theme.fg("muted", "Select an extension"), theme.fg("dim", "to view details")];
}
@@ -110,7 +110,7 @@ export class FooterComponent implements Component {
return this.#cachedBranch;
}
render(width: number): string[] {
render(width: number): readonly string[] {
const state = this.session.state;
// Calculate cumulative usage from ALL session entries (not just post-compaction messages)
@@ -98,7 +98,7 @@ class HistoryResultsList implements Component {
// No cached state to invalidate currently
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
if (this.#results.length === 0) {
@@ -122,7 +122,7 @@ class OutlinedList extends Container {
this.invalidate();
}
render(width: number): string[] {
render(width: number): readonly string[] {
const borderColor = (text: string) => theme.fg("border", text);
const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
const innerWidth = Math.max(1, width - 2);
@@ -645,7 +645,7 @@ export class HookSelectorComponent extends Container {
}
}
override render(width: number): string[] {
override render(width: number): readonly string[] {
const renderWidth = Math.max(1, width);
if (this.#lastRenderWidth !== renderWidth) {
this.#lastRenderWidth = renderWidth;
@@ -754,7 +754,7 @@ export class PlanReviewOverlay implements Component {
return [theme.fg("dim", this.#buildHelp())];
}
render(width: number): string[] {
render(width: number): readonly string[] {
const termHeight = process.stdout.rows || 40;
const sidebarShown = this.#sidebarVisible(width);
this.#sidebarShown = sidebarShown;
@@ -118,12 +118,12 @@ export class SessionObserverOverlayComponent extends Container {
return pool.sort((a, b) => b.lastUpdate - a.lastUpdate)[0];
}
override render(width: number): string[] {
override render(width: number): readonly string[] {
return this.#renderViewer(width);
}
#setupViewer(): void {
this.children = [];
this.clear();
this.#scrollOffset = 0;
this.#selectedEntryIndex = 0;
this.#expandedEntries.clear();
@@ -255,7 +255,7 @@ class SessionList implements Component {
// No cached state to invalidate currently
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
// Render search input
@@ -769,7 +769,7 @@ export class StatusLineComponent implements Component {
};
}
render(width: number): string[] {
render(width: number): readonly string[] {
// Only render hook statuses - main status is in editor's top border
const showHooks = this.#settings.showHookStatus ?? true;
if (!showHooks || this.#hookStatuses.size === 0) {
@@ -71,7 +71,7 @@ export class TinyTitleDownloadProgressComponent implements Component {
// No cached state.
}
render(width: number): string[] {
render(width: number): readonly string[] {
width = Math.max(1, width);
const spec = getTinyTitleModelSpec(this.#modelKey);
const border = theme.fg("border", theme.boxSharp.horizontal.repeat(width));
@@ -1,4 +1,4 @@
import { type Component, Container, type NativeScrollbackLiveRegion } from "@oh-my-pi/pi-tui";
import { type Component, Container, type NativeScrollbackLiveRegion, type RenderStablePrefix } from "@oh-my-pi/pi-tui";
const kSnapshot = Symbol("transcript.liveDiffSnapshot");
@@ -10,7 +10,7 @@ const kSnapshot = Symbol("transcript.liveDiffSnapshot");
*/
interface LiveDiffSnapshot {
width: number;
lines: string[];
lines: readonly string[];
generation: number;
appendOnly: boolean;
/**
@@ -66,7 +66,7 @@ function isPlainBlank(line: string): boolean {
// Strip leading/trailing plain-blank rows so each block contributes only its
// visible body; the container owns the gaps between blocks. Returns the input
// array unchanged when there is nothing to trim (no allocation on the hot path).
function stripPlainBlankEdges(lines: string[]): string[] {
function stripPlainBlankEdges(lines: readonly string[]): readonly string[] {
let start = 0;
let end = lines.length;
while (start < end && isPlainBlank(lines[start]!)) start++;
@@ -74,6 +74,28 @@ function stripPlainBlankEdges(lines: string[]): string[] {
return start === 0 && end === lines.length ? lines : lines.slice(start, end);
}
/**
* One block's recorded contribution to the assembled transcript: the raw array
* reference its render() returned, the stripped contribution derived from it,
* and where those rows landed. Reference-compared on the next render — per the
* Component render contract, an identical raw reference proves the block's
* rows are byte-identical, so the stripped contribution and the assembled rows
* can be reused without re-deriving anything.
*/
interface BlockSegment {
component: Component;
rawRef: readonly string[];
contribution: readonly string[];
width: number;
/** Frame row of this block's first emitted row (the separator when present). */
startRow: number;
/** Rows emitted: separator + contribution (0 for empty contributions). */
rowCount: number;
sep: number;
}
const EMPTY_SEGMENTS: BlockSegment[] = [];
interface LiveCommitState {
appendOnly: boolean;
volatileCooldown: number;
@@ -113,6 +135,22 @@ const VOLATILE_REARM_FRAMES = 30;
*/
const STABLE_PREFIX_COMMIT_FRAMES = 30;
/**
* Rows at a live block's tail treated as the volatile streaming edge. Real
* streaming is not strictly append-only at the bottom: the in-flight markdown
* paragraph re-wraps as words arrive (rewriting its last 1-2 visual rows), an
* unclosed token (`**bold`, a half-streamed link) re-renders when its closer
* arrives, and a wrap-shrink moves the last word onto a new row. Divergence
* confined to this zone is clean growth, and the zone itself is held back
* from the offered commit boundary — so a tolerated rewrite can never touch a
* row the engine may have committed. Width 4 covers the observed shapes (≤2
* rows) with margin for wide glyphs and multi-row token spans; the cost is
* only that the last 4 rows of a live block commit at finalization instead of
* mid-stream, which is invisible (they are on screen — the viewport is always
* taller than the holdback).
*/
const TAIL_VOLATILITY_ROWS = 4;
/**
* Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
* write framing, not content. A styled line's closing escape moves when the
@@ -131,6 +169,15 @@ function rowsVisiblyEqual(prev: string, cur: string): boolean {
return prev === cur || normalizeRow(prev) === normalizeRow(cur);
}
/**
* Whether `cur` is `prev` grown in place: the visible content of `prev` is a
* strict-or-equal prefix of `cur`'s (token streaming appending to the cursor
* row). Escape placement and pad drift are ignored, same as rowsVisiblyEqual.
*/
function rowVisiblyGrew(prev: string, cur: string): boolean {
return normalizeRow(cur).startsWith(normalizeRow(prev));
}
function hasValidSnapshot(
snapshot: LiveDiffSnapshot | undefined,
width: number,
@@ -139,14 +186,14 @@ function hasValidSnapshot(
return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
}
function commonPrefixLength(prev: string[], cur: string[]): number {
function commonPrefixLength(prev: readonly string[], cur: readonly string[]): number {
const limit = Math.min(prev.length, cur.length);
let i = 0;
while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++;
return i;
}
function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
function commonSuffixLength(prev: readonly string[], cur: readonly string[], prefixLength: number): number {
const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
let i = 0;
while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++;
@@ -155,7 +202,7 @@ function commonSuffixLength(prev: string[], cur: string[], prefixLength: number)
function deriveLiveCommitState(
previous: LiveDiffSnapshot | undefined,
current: string[],
current: readonly string[],
width: number,
generation: number,
): LiveCommitState {
@@ -165,6 +212,7 @@ function deriveLiveCommitState(
let candidatePrefixLength = 0;
let candidatePrefixAge = 0;
let rewriteFloor = Number.POSITIVE_INFINITY;
let trailingRowGrowth = false;
if (hasValidSnapshot(previous, width, generation)) {
appendOnly = previous.appendOnly;
volatileCooldown = previous.volatileCooldown;
@@ -179,40 +227,49 @@ function deriveLiveCommitState(
if (!staticRender) {
const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
// Append-only growth never rewrites a row that may already have scrolled
// into native scrollback; it only grows the block at/near its tail. Four
// shapes qualify: a pure bottom append, an insertion above stable trailing
// chrome (a streaming tool's footer/border), an in-place extension of the
// current line by one streamed token (line count unchanged), and a
// wrap-shrink of the current line where its last word grew past the wrap
// column and moved down onto an appended row. The first two preserve every
// previous row across a matching prefix + suffix; the last two leave a
// single divergent previous row — the block's in-flight bottom line, which
// cannot have been committed (commits stop at the viewport top and the
// bottom line is by definition on screen). Any other divergent interior
// row means the block re-laid-out committed-candidate content — a rewrite,
// which suspends commits until the block re-earns append-only.
// into native scrollback; it only grows the block at/near its tail. Two
// shapes qualify:
// - a pure insertion that preserves every previous row across a
// matching prefix + suffix (a bottom append, or an insertion above
// stable trailing chrome like a streaming tool's footer/border);
// - a rewrite whose divergence BEGINS inside the trailing
// TAIL_VOLATILITY_ROWS of the previous render — the streaming edge:
// the in-flight paragraph re-wrapping as words arrive (its last 1-2
// visual rows), an unclosed markdown token (`**bold`) re-rendering
// when its closer streams in, a wrap-shrink pushing the last word
// onto an appended row. That zone is held back from `safeLength`
// below, so a tolerated rewrite can never touch a row that was
// offered for commit.
// The anchor matters: the gap must START in the tail zone, not merely
// be small — a one-row ticker mid-block with stable rows beneath it
// would otherwise classify clean, get offered past, and rewrite
// committed rows on every tick. Any deeper divergent row means the
// block re-laid-out committed-candidate content — a rewrite, which
// suspends commits until the block re-earns append-only.
const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
let tailExtendedInPlace = false;
if (
!preservedEveryRow &&
prefixLength + suffixLength === previous.lines.length - 1 &&
prefixLength < current.length
) {
const prevTail = normalizeRow(previous.lines[prefixLength]!);
const curTail = normalizeRow(current[prefixLength]!);
tailExtendedInPlace =
curTail.startsWith(prevTail) || (current.length > previous.lines.length && prevTail.startsWith(curTail));
}
if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length) {
const tailConfined = preservedEveryRow || prefixLength >= previous.lines.length - TAIL_VOLATILITY_ROWS;
if (tailConfined && current.length >= previous.lines.length) {
// Strict trailing-row growth: every previous row except the last
// is visibly unchanged and the last grew in place as a visible
// prefix, with no rows appended — a line accumulating tokens.
// The sole divergent row is the block's physical last row, which
// the engine's window floor never commits while it stays last
// (chunkTo ≤ windowTop ≤ last row index), so the volatile-tail
// holdback below is unnecessary: the whole body is offerable and
// the block's scrolled-off head reaches native scrollback.
trailingRowGrowth =
current.length === previous.lines.length &&
prefixLength === previous.lines.length - 1 &&
rowVisiblyGrew(previous.lines[prefixLength]!, current[prefixLength]!);
if (volatileCooldown === 0) appendOnly = true;
// Clean growth inserts rows at the divergence; rows the floor
// points at travel down with the preserved suffix. (On a tail
// extension the divergent row itself stays put — only rows
// strictly below it shift.)
// Clean growth inserts/rewrites rows at the divergence; a floor
// inside the preserved suffix travels down with it, a floor at or
// above the divergent zone stays put (conservative: a stale floor
// index can only point at an earlier row, never a later one).
const delta = current.length - previous.lines.length;
if (delta > 0 && Number.isFinite(rewriteFloor)) {
const floorShifts = preservedEveryRow ? rewriteFloor >= prefixLength : rewriteFloor > prefixLength;
if (floorShifts) rewriteFloor += delta;
const suffixStart = Math.max(prefixLength, previous.lines.length - suffixLength);
if (rewriteFloor >= suffixStart) rewriteFloor += delta;
}
} else {
cleanFrame = false;
@@ -253,7 +310,15 @@ function deriveLiveCommitState(
candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength);
candidatePrefixAge++;
if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) {
stablePrefixLength = Math.min(candidatePrefixLength, rewriteFloor);
// Cap at the volatile-tail holdback: a long static stretch would
// otherwise promote the streaming edge itself (min prefix == full
// length), and the next chunk's tail re-wrap would then rewrite
// offered rows.
stablePrefixLength = Math.min(
candidatePrefixLength,
rewriteFloor,
Math.max(0, current.length - TAIL_VOLATILITY_ROWS),
);
candidatePrefixLength = prefixLength;
candidatePrefixAge = 0;
}
@@ -267,16 +332,24 @@ function deriveLiveCommitState(
candidatePrefixLength,
candidatePrefixAge,
rewriteFloor,
// An append-only block's whole body is committable; otherwise the
// settled head still is — only the volatile tail stays deferred.
safeLength: appendOnly ? current.length : stablePrefixLength,
// A clean-streaming block's body is committable up to the volatile-tail
// holdback (the streaming edge is never offered, so its tolerated
// rewrites can never touch committed rows); otherwise the settled head
// still is — only the volatile tail stays deferred. Strict in-place
// growth of the trailing row skips the holdback: its only mutable row
// is the block's last, which cannot commit while it remains last.
safeLength: appendOnly
? trailingRowGrowth
? current.length
: Math.max(stablePrefixLength, current.length - TAIL_VOLATILITY_ROWS, 0)
: stablePrefixLength,
};
}
/**
* 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.
* Transcript container that renders every block's current content each frame
* and reports the live-region seam (`NativeScrollbackLiveRegion`) that gates
* the engine's append-only scrollback commits.
*
* The engine never rewrites committed history: rows above the seam that have
* entered the tape keep whatever bytes they were committed with ("let the
@@ -287,8 +360,16 @@ function deriveLiveCommitState(
* 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.
*
* Assembly is incremental: the returned array is persistent and mutated in
* place. Each block's render is still called every frame, but a block whose
* render returned the same array reference at an unchanged offset reuses its
* previously assembled rows; the array is truncated and re-pushed only from
* the first divergent block. The leading byte-identical row count is reported
* through {@link RenderStablePrefix} so the engine can skip marker scanning,
* line preparation, and the committed-prefix audit for those rows.
*/
export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion, RenderStablePrefix {
// 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;
@@ -304,7 +385,16 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
// until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames;
// the engine then backfills the stalled gap.
#nativeScrollbackCommitSafeEnd: number | undefined;
// Persistent assembled transcript rows. Rows before the stable floor are
// byte-identical to the previous render; rows at/after it were re-pushed.
#lines: string[] = [];
#segments: BlockSegment[] = EMPTY_SEGMENTS;
#renderWidth = -1;
// Stable-prefix floor accumulated across renders since the last
// getRenderStablePrefixRows() read (see RenderStablePrefix: reading
// consumes the report and re-bases the baseline). Out-of-band renders
// between engine frames lower it; they can never inflate it.
#stableRowsFloor = 0;
override invalidate(): void {
// Theme/global invalidation: retire every diff snapshot so stale styling
// is not diffed against the recolored render.
@@ -317,6 +407,12 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
super.clear();
}
getRenderStablePrefixRows(): number {
const value = Math.min(this.#stableRowsFloor, this.#lines.length);
this.#stableRowsFloor = this.#lines.length;
return value;
}
getNativeScrollbackLiveRegionStart(): number | undefined {
return this.#nativeScrollbackLiveRegionStart;
}
@@ -343,7 +439,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
return false;
}
override render(width: number): string[] {
override render(width: number): readonly string[] {
width = Math.max(1, width);
this.#nativeScrollbackLiveRegionStart = undefined;
this.#nativeScrollbackCommitSafeEnd = undefined;
@@ -364,7 +460,27 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
}
}
const lines: string[] = [];
const lines = this.#lines;
const previousSegments = this.#segments;
const segments: BlockSegment[] = new Array(count);
// Poisoned until the walk completes: a block render throwing mid-walk
// leaves the persistent array half-rebuilt, and the next render must
// not trust stale segments against it. Restored at the end.
this.#segments = EMPTY_SEGMENTS;
const stableFloorBefore = this.#stableRowsFloor;
this.#stableRowsFloor = 0;
// Stability requires the same width and, per segment, the same block at
// the same offset returning the same array reference. The first
// divergence truncates the persistent array there; everything after
// re-pushes.
let chainStable = this.#renderWidth === width;
this.#renderWidth = width;
// Entry-unstable (width change): the divergence truncation inside the
// loop only fires on a stable→unstable transition, so reset the
// persistent array here to keep the `!chainStable ⇒ lines.length === row`
// invariant — otherwise re-pushed rows land after the stale frame.
if (!chainStable) lines.length = 0;
// 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.
@@ -373,6 +489,9 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
// liveStartIndex; empty leading blocks (or a separator) must not claim it
// early.
let liveRecorded = false;
// Frame row cursor: rows emitted (reused or pushed) so far.
let row = 0;
let stableRows = 0;
for (let i = 0; i < count; i++) {
const child = this.children[i]! as Component & SnapshotCarrier;
@@ -381,10 +500,20 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
// 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).
// A block whose render returned the same array reference reuses the
// previously stripped contribution (same ref ⇒ identical rows).
const previousSnapshot = child[kSnapshot];
const contribution = stripPlainBlankEdges(child.render(width));
const raw = child.render(width);
const previous = previousSegments[i];
const reusable =
previous !== undefined &&
previous.component === child &&
previous.rawRef === raw &&
previous.width === width;
const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
const finalized = isBlockFinalized(child);
let liveCommitState: LiveCommitState | undefined;
if (i >= liveStartIndex && !isBlockFinalized(child)) {
if (i >= liveStartIndex && !finalized) {
liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
}
// Cache the latest contribution as the next frame's diff input.
@@ -405,29 +534,46 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
// still closes the commit-safe run: if it later gains rows, it pushes
// everything below it.
if (contribution.length === 0) {
if (i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false;
if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) {
chainStable = false;
lines.length = row;
}
if (chainStable) stableRows = row;
segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount: 0, sep: 0 };
continue;
}
// Every block is separated from preceding visible content by exactly one
// blank row — skipped when it opens the transcript or the prior row is
// already a plain blank (a fragment's own trailing pad), never doubling.
const sep = lines.length > 0 && !isPlainBlank(lines[lines.length - 1]!) ? 1 : 0;
// `lines[row - 1]` is valid in both modes: reused rows are still present
// in the persistent array, re-pushed rows were just written.
const sep = row > 0 && !isPlainBlank(lines[row - 1]!) ? 1 : 0;
// 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 (!liveRecorded && i >= liveStartIndex) {
this.#nativeScrollbackLiveRegionStart = lines.length + sep;
this.#nativeScrollbackLiveRegionStart = row + sep;
liveRecorded = true;
}
if (sep) lines.push("");
const blockStart = lines.length;
for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
const rowCount = sep + contribution.length;
const stable = chainStable && reusable && previous.startRow === row && previous.sep === sep;
if (stable) {
stableRows = row + rowCount;
} else {
if (chainStable) {
chainStable = false;
lines.length = row;
}
if (sep) lines.push("");
for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
}
const blockStart = row + sep;
if (i >= liveStartIndex && commitSafeOpen) {
const finalized = isBlockFinalized(child);
const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
if (safeLength > 0) {
this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
@@ -437,7 +583,15 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
// rows around as it grows, so the run closes there.
if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
}
segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount, sep };
row += rowCount;
}
// Trailing shrink: blocks removed from the tail leave stale rows behind
// when every surviving segment was reused.
if (lines.length !== row) lines.length = row;
this.#segments = segments;
this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row);
return lines;
}
}
@@ -438,7 +438,7 @@ class TreeList implements Component {
}
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
if (this.#filteredNodes.length === 0) {
@@ -835,7 +835,7 @@ class SearchLine implements Component {
invalidate(): void {}
render(width: number): string[] {
render(width: number): readonly string[] {
const query = this.treeList.getSearchQuery();
if (query) {
return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)];
@@ -864,7 +864,7 @@ class LabelInput implements Component {
invalidate(): void {}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
const indent = " ";
const availableWidth = width - indent.length;
@@ -82,7 +82,7 @@ class UserMessageList implements Component {
return true;
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
if (this.messages.length === 0) {
@@ -12,6 +12,13 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
* Component that renders a user message
*/
export class UserMessageComponent extends Container {
// Memoized OSC 133 zone wrapping keyed on the underlying container render
// (same source ref ⇒ identical rows ⇒ reuse the wrapped copy). Keeps this
// component reference-stable for the transcript's incremental assembly and
// never mutates the container's cached array.
#zoneSource: readonly string[] | undefined;
#zoneLines: string[] | undefined;
constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
super();
const bgColor = (value: string) => theme.bg("userMessageBg", value);
@@ -41,14 +48,19 @@ export class UserMessageComponent extends Container {
);
}
override render(width: number): string[] {
override render(width: number): readonly string[] {
const lines = super.render(width);
if (lines.length === 0) {
return lines;
}
lines[0] = OSC133_ZONE_START + lines[0];
lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
return lines;
if (this.#zoneSource === lines && this.#zoneLines !== undefined) {
return this.#zoneLines;
}
const wrapped = lines.slice();
wrapped[0] = OSC133_ZONE_START + wrapped[0];
wrapped[wrapped.length - 1] = wrapped[wrapped.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
this.#zoneSource = lines;
this.#zoneLines = wrapped;
return wrapped;
}
}
@@ -6,7 +6,7 @@ import { Text } from "@oh-my-pi/pi-tui";
export interface VisualTruncateResult {
/** The visual lines to display */
visualLines: string[];
visualLines: readonly string[];
/** Number of visual lines that were skipped (hidden) */
skippedCount: number;
}
@@ -414,6 +414,26 @@ export class EventController {
this.#resetReadGroup();
this.#lastVisibleBlockCount = visibleBlockCount;
}
// Content blocks stream sequentially: a toolCall block can only begin
// after every preceding thinking/text block has closed, and the
// reveal's setTarget above force-completes the visible text for
// toolCall messages. Finalize the assistant block now instead of at
// message_end so the transcript's commit-safe run can extend through
// it into the streaming tool preview below — otherwise a long args
// stream (a big write/edit/eval) sits below a still-live block and
// can never reach native scrollback: the head of the preview is
// neither committed nor on screen and the transcript reads as cut.
// Skipped when the per-turn usage row is enabled: that row is only
// known at message_end and appends to this block, which would shift
// committed tool rows below it every turn (audit recommit →
// duplicated preview copies in scrollback).
if (
this.ctx.streamingMessage.content.some(content => content.type === "toolCall") &&
!settings.get("display.showTokenUsage")
) {
this.ctx.streamingComponent.markTranscriptBlockFinalized();
}
for (const content of this.ctx.streamingMessage.content) {
if (content.type !== "toolCall") continue;
if (content.name === "read") {
@@ -62,7 +62,7 @@ export class MCPAuthorizationLinkPrompt implements Component {
invalidate(): void {}
render(_width: number): string[] {
render(_width: number): readonly string[] {
const link = urlHyperlinkAlways(this.#url, "Click here to authorize");
return [
` ${theme.fg("success", "Open authorization URL:")}`,
@@ -60,7 +60,7 @@ class GlyphSceneController implements SetupSceneController {
this.#selectList.handleInput(data);
}
render(width: number): string[] {
render(width: number): readonly string[] {
return [
theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, pick another."),
"",
@@ -52,7 +52,7 @@ class ProvidersSceneController implements SetupSceneController {
tab.handleInput(data);
}
render(width: number): string[] {
render(width: number): readonly string[] {
return [...this.#tabBar.render(width), "", ...this.#activeTab().render(width)];
}
@@ -68,7 +68,7 @@ export class SignInTab implements SetupTab {
this.#selector.handleInput(data);
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
if (this.#loggingInProvider) {
lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`));
@@ -117,7 +117,7 @@ class ThemeSceneController implements SetupSceneController {
this.#selectList.handleInput(data);
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines = [
theme.fg("muted", "Theme changes preview live. Nothing is saved until you press Enter."),
this.#mode === "all"
@@ -31,7 +31,7 @@ export interface SetupTab {
* login). The parent scene MUST NOT switch tabs or finish while modal.
*/
readonly modal: boolean;
render(width: number): string[];
render(width: number): readonly string[];
handleInput(data: string): void;
invalidate(): void;
/** Called when the tab becomes active (including initial mount). */
@@ -63,7 +63,7 @@ export class WebSearchTab implements SetupTab {
this.#disposed = true;
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines = [
theme.fg("muted", "Choose the provider the web_search tool should prefer."),
"",
@@ -116,7 +116,7 @@ export class SetupWizardComponent implements Component {
this.#activeScene?.handleInput?.(data);
}
render(width: number): string[] {
render(width: number): readonly string[] {
const safeWidth = Math.max(1, width);
const height = Math.max(1, this.ctx.ui.terminal.rows);
let lines: string[];
+2 -2
View File
@@ -541,7 +541,7 @@ function renderTaskItemLines(tasks: TaskItem[] | undefined, expanded: boolean, t
* the merged result frame so the brief stays visible for the whole task
* lifecycle — not just until the first progress snapshot replaces the call view.
*/
type TaskRenderSection = { lines: string[] };
type TaskRenderSection = { lines: readonly string[] };
type ContextSectionRenderer = (width: number) => TaskRenderSection;
// Default output-block layout is: left border + one-cell content inset + right
@@ -578,7 +578,7 @@ export function renderCall(
const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme);
const contextSectionRenderer = createContextSectionRenderer(args, theme);
return framedBlock(theme, width => {
const sections: Array<{ label?: string; lines: string[]; separator?: boolean }> = [];
const sections: Array<{ label?: string; lines: readonly string[]; separator?: boolean }> = [];
if (contextSectionRenderer) sections.push(contextSectionRenderer(width));
+19 -10
View File
@@ -785,8 +785,11 @@ export const askToolRenderer = {
if (q.multi) meta.push("multi");
if (q.options?.length) meta.push(`options:${q.options.length}`);
const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : "";
const lines = md(q.question, width);
if (q.options?.length) lines.push(...renderQuestionOptionLines(uiTheme, mdTheme, q.options, q.multi));
// md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
const mdLines = md(q.question, width);
const lines = q.options?.length
? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, q.options, q.multi)]
: mdLines;
return { label: `${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, lines };
});
return { header, sections, state: "pending", borderColor: "borderMuted", width };
@@ -813,9 +816,11 @@ export const askToolRenderer = {
const header = `${label}${formatMeta(meta, uiTheme)}`;
const multi = args.multi;
return framedBlock(uiTheme, width => {
const bodyLines = md(question, width);
if (questionOptions?.length)
bodyLines.push(...renderQuestionOptionLines(uiTheme, mdTheme, questionOptions, multi));
// md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
const mdLines = md(question, width);
const bodyLines = questionOptions?.length
? [...mdLines, ...renderQuestionOptionLines(uiTheme, mdTheme, questionOptions, multi)]
: mdLines;
return {
header,
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
@@ -861,10 +866,11 @@ export const askToolRenderer = {
);
return framedBlock(uiTheme, width => {
const sections = results.map(r => {
const lines = md(r.question, width);
lines.push(
// md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
const lines = [
...md(r.question, width),
...renderAnswerOptionLines(uiTheme, mdTheme, r.options, r.selectedOptions, r.multi, r.customInput),
);
];
return { label: uiTheme.fg("dim", `[${r.id}]`), lines };
});
return {
@@ -899,8 +905,11 @@ export const askToolRenderer = {
const dCustom = details.customInput;
const dTimedOut = details.timedOut;
return framedBlock(uiTheme, width => {
const bodyLines = md(question, width);
bodyLines.push(...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom));
// md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
const bodyLines = [
...md(question, width),
...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom),
];
if (dTimedOut) {
// Distinguish auto-selection from a real user choice in the transcript.
bodyLines.push(uiTheme.fg("dim", "auto-selected after timeout — not a user choice"));
@@ -234,7 +234,7 @@ class BashInteractiveOverlayComponent implements Component {
}
return visibleLines;
}
render(width: number): string[] {
render(width: number): readonly string[] {
const safeWidth = Math.max(20, width);
const innerWidth = Math.max(1, safeWidth - 2);
const maxOverlayRows = Math.max(5, Math.floor(this.getTerminalRows() * 0.8));
+2 -2
View File
@@ -1163,7 +1163,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
: renderStatusLine({ icon: "pending", title: config.resolveTitle(args, options) }, uiTheme);
const outputBlock = new CachedOutputBlock();
return markFramedBlockComponent({
render: (width: number): string[] =>
render: (width: number): readonly string[] =>
outputBlock.render(
{
header,
@@ -1213,7 +1213,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
const outputBlock = new CachedOutputBlock();
return markFramedBlockComponent({
render: (width: number): string[] => {
render: (width: number): readonly string[] => {
// REACTIVE: read mutable options at render time
const { renderContext } = options;
const expanded = renderContext?.expanded ?? options.expanded;
@@ -66,7 +66,7 @@ function dropTrailingBlankLines(text: string): string {
function appendLine(component: Component, line: string | undefined): Component {
if (!line) return component;
const wrapped = {
render: (width: number): string[] => {
render: (width: number): readonly string[] => {
const base = component.render(width);
return [...base, line];
},
@@ -95,7 +95,7 @@ function renderRunCell(
let cached: { key: bigint; width: number; lines: string[] } | undefined;
return markFramedBlockComponent({
render: (width: number): string[] => {
render: (width: number): readonly string[] => {
const expanded = options.renderContext?.expanded ?? options.expanded;
const previewLines = options.renderContext?.previewLines ?? BROWSER_DEFAULT_PREVIEW_LINES;
const key = new Hasher()
+1 -1
View File
@@ -592,7 +592,7 @@ export const debugToolRenderer = {
): Component {
const outputBlock = new CachedOutputBlock();
return markFramedBlockComponent({
render(width: number): string[] {
render(width: number): readonly string[] {
const action = (args?.action ?? result.details?.action ?? "debug").replaceAll("_", " ");
const success = !options.isPartial && !result.isError;
const statusIcon = success
@@ -455,7 +455,7 @@ function formatCellOutputLines(
previewLines: number,
theme: Theme,
width: number,
): { lines: string[]; hiddenCount: number } {
): { lines: readonly string[]; hiddenCount: number } {
if (!cell.output) {
return { lines: [], hiddenCount: 0 };
}
@@ -492,7 +492,7 @@ export const evalToolRenderer = {
let cached: { key: string; width: number; result: string[] } | undefined;
return markFramedBlockComponent({
render: (width: number): string[] => {
render: (width: number): readonly string[] => {
const key = `${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
if (cached && cached.key === key && cached.width === width) {
return cached.result;
@@ -573,7 +573,7 @@ export const evalToolRenderer = {
let cached: { key: string; width: number; result: string[] } | undefined;
return markFramedBlockComponent({
render: (width: number): string[] => {
render: (width: number): readonly string[] => {
const expanded = options.renderContext?.expanded ?? options.expanded;
const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
@@ -697,12 +697,12 @@ export const evalToolRenderer = {
const textContent = `\n${styledOutput}`;
let cachedWidth: number | undefined;
let cachedLines: string[] | undefined;
let cachedLines: readonly string[] | undefined;
let cachedSkipped: number | undefined;
let cachedPreviewLines: number | undefined;
return {
render: (width: number): string[] => {
render: (width: number): readonly string[] => {
const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
if (cachedLines === undefined || cachedWidth !== width || cachedPreviewLines !== previewLines) {
const result = truncateToVisualLines(textContent, previewLines, width);
+1 -1
View File
@@ -454,7 +454,7 @@ export const jobToolRenderer = {
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
render(width: number): readonly string[] {
const expanded = options.expanded;
const spinnerFrame = options.spinnerFrame ?? 0;
const key = new Hasher().bool(expanded).u32(width).u32(spinnerFrame).digest();
@@ -761,7 +761,7 @@ export function createCachedComponent(
): Component {
let cached: { key: bigint; lines: string[] } | undefined;
return {
render(width: number): string[] {
render(width: number): readonly string[] {
const expanded = getExpanded();
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
+1 -1
View File
@@ -254,7 +254,7 @@ export const resolveToolRenderer = {
const lines = ["", headerLine, "", uiTheme.italic(reason), ""];
return {
render(width: number) {
render(width: number): readonly string[] {
const lineWidth = Math.max(3, width);
const innerWidth = Math.max(1, lineWidth - 2);
return lines.map(line => {
+2 -2
View File
@@ -245,7 +245,7 @@ export const sshToolRenderer = {
const cmdLines = formatSshCommandLines(command, uiTheme);
const outputBlock = new CachedOutputBlock();
return markFramedBlockComponent({
render: (width: number): string[] =>
render: (width: number): readonly string[] =>
outputBlock.render(
{
header,
@@ -282,7 +282,7 @@ export const sshToolRenderer = {
const outputBlock = new CachedOutputBlock();
return markFramedBlockComponent({
render: (width: number): string[] => {
render: (width: number): readonly string[] => {
// REACTIVE: read mutable options at render time
const { expanded, renderContext } = options;
// Strip LLM-facing notice so we don't echo it next to the styled warning.
+27 -5
View File
@@ -41,6 +41,7 @@ import {
formatErrorDetail,
formatExpandHint,
formatMoreItems,
formatStatusIcon,
getLspBatchRequest,
replaceTabs,
shortenPath,
@@ -1024,11 +1025,23 @@ function normalizeDisplayText(text: string): string {
return text.replace(/\r/g, "");
}
/**
* Minimum line-number gutter width for write previews. The streaming preview's
* gutter must stay byte-stable as the line count grows: a width derived purely
* from `String(totalLines).length` widens at the 10/100/1000-line crossings,
* rewriting every already-rendered row — which forces the transcript's commit
* audit to recommit the block's committed prefix (a full duplicate in native
* scrollback). Reserving 3 digits keeps the gutter constant through 999 lines
* and keeps the streamed rows byte-identical to the final result render.
*/
const WRITE_GUTTER_MIN_WIDTH = 3;
function formatStreamingContent(
content: string,
expanded: boolean,
language: string | undefined,
uiTheme: Theme,
spinnerFrame?: number,
): string {
if (!content) return "";
const lines = normalizeDisplayText(content).split("\n");
@@ -1041,7 +1054,7 @@ function formatStreamingContent(
const visibleLines = lines.slice(startIndex);
const hidden = startIndex;
const highlighted = highlightCode(visibleLines.join("\n"), language);
const lineNumberWidth = String(totalLines).length;
const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length);
let text = "\n\n";
if (hidden > 0) {
@@ -1053,7 +1066,12 @@ function formatStreamingContent(
const body = replaceTabs(highlighted[i] ?? "");
text += `${gutter}${body}\n`;
}
text += uiTheme.fg("dim", `… (streaming)`);
// The animated glyph lives on this trailing line — inside the transcript's
// volatile-tail holdback — never in the header: an animating head row pins
// the native-scrollback commit boundary at the top of the block, so a long
// expanded preview could never scroll-append mid-stream.
const spinner = spinnerFrame !== undefined ? `${formatStatusIcon("running", uiTheme, spinnerFrame)} ` : "";
text += `${spinner}${uiTheme.fg("dim", `… (streaming)`)}`;
return text;
}
@@ -1069,7 +1087,7 @@ function renderContentPreview(
const maxLines = expanded ? totalLines : Math.min(totalLines, WRITE_PREVIEW_LINES);
const visibleLines = rawLines.slice(0, maxLines);
const highlighted = highlightCode(visibleLines.join("\n"), language);
const lineNumberWidth = String(maxLines).length;
const lineNumberWidth = Math.max(WRITE_GUTTER_MIN_WIDTH, String(totalLines).length);
const hidden = totalLines - maxLines;
let text = "\n\n";
@@ -1094,10 +1112,14 @@ export const writeToolRenderer = {
const lang = getLanguageFromPath(rawPath) ?? "text";
const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
// Static pending icon, never the animated glyph: the header is the head
// row of the framed block, and native-scrollback commits are prefix-only
// — an animating head row would pin the commit boundary at the top and
// keep a tall expanded preview from scroll-appending mid-stream. The
// liveness cue rides the trailing "(streaming)" line instead.
const header = renderStatusLine(
{
icon: "pending",
spinnerFrame: options?.spinnerFrame,
title: "Write",
description: `${langIcon} ${pathDisplay}`,
},
@@ -1105,7 +1127,7 @@ export const writeToolRenderer = {
);
return framedBlock(uiTheme, width => {
const body = args.content
? formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme)
? formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme, options?.spinnerFrame)
: "";
const bodyLines = body ? body.split("\n") : [];
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
@@ -13,7 +13,7 @@ export interface OutputBlockOptions {
header?: string;
headerMeta?: string;
state?: State;
sections?: Array<{ label?: string; lines: string[]; separator?: boolean }>;
sections?: Array<{ label?: string; lines: readonly string[]; separator?: boolean }>;
width: number;
applyBg?: boolean;
contentPaddingLeft?: number;
@@ -186,8 +186,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
export class CachedOutputBlock {
#cache?: RenderCache;
/** Render with caching. Returns cached result if options haven't changed. */
render(options: OutputBlockOptions, theme: Theme): string[] {
/** Render with caching. Returns the cached (shared, caller-immutable) lines if options haven't changed. */
render(options: OutputBlockOptions, theme: Theme): readonly string[] {
const key = this.#buildKey(options);
if (this.#cache?.key === key) return this.#cache.lines;
const lines = renderOutputBlock(options, theme);
@@ -234,7 +234,7 @@ export function framedBlock(theme: Theme, build: (width: number) => OutputBlockO
// flush, no extra padding/background) the same way `markFramedBlockComponent`
// blocks are treated.
return markFramedBlockComponent({
render: (width: number): string[] => block.render(build(width), theme),
render: (width: number): readonly string[] => block.render(build(width), theme),
invalidate: () => block.invalidate(),
});
}
@@ -65,7 +65,7 @@ function renderSearchErrorPanel(message: string, providerLabel: string | undefin
const body = theme.fg("error", `Error: ${replaceTabs(message)}`);
const outputBlock = new CachedOutputBlock();
return markFramedBlockComponent({
render(width: number): string[] {
render(width: number): readonly string[] {
return outputBlock.render({ header, state: "error", sections: [{ lines: [body] }], width }, theme);
},
invalidate() {
@@ -154,23 +154,25 @@ export function renderSearchResult(
const outputBlock = new CachedOutputBlock();
return markFramedBlockComponent({
render(width: number): string[] {
render(width: number): readonly string[] {
// Read mutable state at render time
const { expanded } = options;
// Answer lines: full markdown when expanded, capped markdown preview when collapsed.
const answerWidth = Math.max(20, width - 3);
const renderedAnswer = answerMarkdown ? answerMarkdown.render(answerWidth) : [];
let answerLines: string[];
let answerLines: readonly string[];
if (renderedAnswer.length === 0) {
answerLines = [theme.fg("muted", "No answer text returned")];
} else if (args?.maxAnswerLines !== undefined && !expanded) {
// CLI compact mode (`omp q`) caps the answer; the TUI passes no cap and shows it in full.
answerLines = renderedAnswer.slice(0, args.maxAnswerLines);
const remaining = renderedAnswer.length - answerLines.length;
// `renderedAnswer` is the Markdown component's shared cache — slice copies before appending.
const capped = renderedAnswer.slice(0, args.maxAnswerLines);
const remaining = renderedAnswer.length - capped.length;
if (remaining > 0) {
answerLines.push(theme.fg("muted", formatMoreItems(remaining, "line")));
capped.push(theme.fg("muted", formatMoreItems(remaining, "line")));
}
answerLines = capped;
} else {
answerLines = renderedAnswer;
}
@@ -35,7 +35,7 @@ function makeSelector(rows: number): SessionSelectorComponent {
}
/** Number of session entries actually shown (one title line per visible entry). */
function visibleEntries(lines: string[]): number {
function visibleEntries(lines: readonly string[]): number {
return lines.filter(line => line.includes("TITLE_")).length;
}
@@ -85,7 +85,7 @@ function makeAssistantMessage(overrides: Partial<AssistantMessage> = {}): Assist
};
}
function plain(lines: string[]): string {
function plain(lines: readonly string[]): string {
return stripVTControlCharacters(lines.join("\n"));
}
@@ -278,3 +278,100 @@ describe("TranscriptContainer spacing", () => {
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
});
});
// The consumable stable-prefix floor (RenderStablePrefix): render() returns
// the SAME persistent array every call, mutated in place, so the engine relies
// on this report — not reference equality — to know which leading rows
// survived. Reading consumes the report (re-bases the baseline to the current
// array state); between reads the floor accumulates the MIN across renders.
// `Text` children are ref-stable per (text, width), so an unchanged block's
// segment is reused and counts toward the floor.
describe("TranscriptContainer getRenderStablePrefixRows", () => {
it("reports 0 until a second render proves the rows, then the full length", () => {
const container = new TranscriptContainer();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
// First render only pushed rows; nothing is proven stable yet.
expect(container.render(40)).toHaveLength(3); // alpha, separator, beta
expect(container.getRenderStablePrefixRows()).toBe(0);
// Unchanged finalized blocks: the second render reuses every row.
const second = container.render(40);
expect(container.getRenderStablePrefixRows()).toBe(second.length);
});
it("keeps the previous rows stable when a finalized block is appended", () => {
const container = new TranscriptContainer();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
const before = container.render(40).length;
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
container.addChild(new Text("gamma", 0, 0));
const grown = container.render(40);
expect(grown.length).toBeGreaterThan(before);
// Only the appended block's separator + body are new rows.
expect(container.getRenderStablePrefixRows()).toBe(before);
});
it("lowers the report to a mutated early block's start row", () => {
const container = new TranscriptContainer();
const beta = new Text("beta", 0, 0);
container.addChild(new Text("alpha", 0, 0));
container.addChild(beta);
container.addChild(new Text("gamma", 0, 0));
expect(container.render(40)).toHaveLength(5);
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
beta.setText("beta-edited");
container.render(40);
// alpha's single row survives; beta's segment (separator + body, start
// row 1) and everything below it was re-pushed.
expect(container.getRenderStablePrefixRows()).toBe(1);
});
it("accumulates the minimum across renders between reads", () => {
const container = new TranscriptContainer();
const gamma = new Text("gamma", 0, 0);
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
container.addChild(gamma);
expect(container.render(40)).toHaveLength(5);
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
// First render after the edit drops the floor to gamma's segment start
// (row 3); a second, fully stable render must NOT lift it back — an
// out-of-band render between engine frames can only lower the report.
gamma.setText("gamma-edited");
container.render(40);
container.render(40);
expect(container.getRenderStablePrefixRows()).toBe(3);
});
it("reports 0 after a width change", () => {
const container = new TranscriptContainer();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
container.render(40);
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
// A width change re-renders every block; no row carries over.
container.render(80);
expect(container.getRenderStablePrefixRows()).toBe(0);
});
it("consumes on read: an immediate second read re-bases to the current rows", () => {
const container = new TranscriptContainer();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
container.render(40);
container.getRenderStablePrefixRows(); // consume: re-base to the current rows
const reflowed = container.render(80);
expect(container.getRenderStablePrefixRows()).toBe(0);
// The read above re-based the baseline to the just-returned state, so
// without any render in between the full array now counts as stable.
expect(container.getRenderStablePrefixRows()).toBe(reflowed.length);
});
});
@@ -0,0 +1,114 @@
/**
* Regression: while tool-call args stream, the assistant component above the
* tool preview must be transcript-finalized as soon as a toolCall block
* appears in the streaming message. Content blocks stream sequentially, so a
* toolCall implies every preceding thinking/text block has closed — and an
* unfinalized assistant block pins the transcript's commit-safe run, which
* keeps a long streaming preview (a big write/edit/eval) from ever reaching
* native scrollback: its head is neither committed nor on screen and the
* transcript reads as cut off for the whole args stream.
*/
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
beforeAll(async () => {
await initTheme();
});
function makeStreamingMessage(content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
};
}
function createFixture(streamingMessage: AssistantMessage) {
const markTranscriptBlockFinalized = vi.fn();
const streamingComponent = {
updateContent: vi.fn(),
markTranscriptBlockFinalized,
};
const ctx = {
isInitialized: true,
init: vi.fn(async () => {}),
ui: { requestRender: vi.fn() },
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
streamingComponent,
streamingMessage,
pendingTools: new Map(),
chatContainer: { addChild: vi.fn() },
toolOutputExpanded: false,
session: { getToolByName: () => undefined },
sessionManager: { getCwd: () => process.cwd() },
} as unknown as InteractiveModeContext;
const controller = new EventController(ctx);
return { controller, markTranscriptBlockFinalized };
}
async function dispatchUpdate(message: AssistantMessage) {
const { controller, markTranscriptBlockFinalized } = createFixture(message);
// #handleMessageUpdate only reads `event.message`; the raw provider stream
// event is irrelevant to the finalization contract under test.
const event = {
type: "message_update",
message,
assistantMessageEvent: undefined as never,
} as Extract<AgentSessionEvent, { type: "message_update" }>;
await controller.handleEvent(event);
return markTranscriptBlockFinalized;
}
describe("EventController finalizes assistant block when tool-call args stream", () => {
afterEach(() => {
resetSettingsForTest();
vi.restoreAllMocks();
});
it("marks the streaming assistant finalized once a toolCall block appears", async () => {
await Settings.init({ inMemory: true, cwd: process.cwd() });
const message = makeStreamingMessage([
{ type: "thinking", thinking: "planning the file" },
{ type: "toolCall", id: "tc-1", name: "write", arguments: { file_path: "/tmp/a.ts", content: "x" } },
]);
const finalized = await dispatchUpdate(message);
expect(finalized).toHaveBeenCalled();
});
it("keeps the assistant live while only text/thinking is streaming", async () => {
await Settings.init({ inMemory: true, cwd: process.cwd() });
const message = makeStreamingMessage([{ type: "thinking", thinking: "still thinking" }]);
const finalized = await dispatchUpdate(message);
expect(finalized).not.toHaveBeenCalled();
});
it("defers finalization to message_end when the per-turn usage row is enabled", async () => {
await Settings.init({ inMemory: true, cwd: process.cwd() });
settings.set("display.showTokenUsage", true);
const message = makeStreamingMessage([
{ type: "thinking", thinking: "planning" },
{ type: "toolCall", id: "tc-2", name: "write", arguments: { file_path: "/tmp/b.ts", content: "y" } },
]);
const finalized = await dispatchUpdate(message);
expect(finalized).not.toHaveBeenCalled();
});
});
@@ -126,7 +126,7 @@ describe("streaming edit preview height (stable, full tail window)", () => {
// resolves only when this chunk's recompute has updated the preview.
await component.whenPreviewSettled();
const trailingBlankRows = (rows: string[]): number => {
const trailingBlankRows = (rows: readonly string[]): number => {
let n = 0;
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].replace(/\x1b\[[0-9;]*m/gu, "").trimEnd() === "") n++;
@@ -308,7 +308,7 @@ describe("streaming tool call preview height (bounded across renderers)", () =>
resetSettingsForTest();
});
function renderPending(toolName: string, args: unknown): { lines: string[]; text: string } {
function renderPending(toolName: string, args: unknown): { lines: readonly string[]; text: string } {
const term = new VirtualTerminal(80, 20);
const tui = new TUI(term);
const component = new ToolExecutionComponent(toolName, args, {}, undefined, tui, process.cwd());
@@ -27,7 +27,7 @@ function detailsFor(progress: AgentProgress): TaskToolDetails {
return { projectAgentsDir: null, results: [], totalDurationMs: 0, progress: [progress] };
}
function findRow(component: { render: (w: number) => string[] }, needle: string): string {
function findRow(component: { render: (w: number) => readonly string[] }, needle: string): string {
const row = component
.render(120)
.join("\n")
@@ -41,14 +41,17 @@ function stripRows(rows: string[]): string {
describe("transcript reactive commit boundary", () => {
it("treats growth before stable trailing chrome as append-only", async () => {
const chat = new TranscriptContainer();
const block = new MutableLiveBlock(["top", "stable", "bottom"]);
const head = markerLines("head-", 6);
const block = new MutableLiveBlock([...head, "bottom"]);
chat.addChild(block);
expect(chat.render(80)).toEqual(["top", "stable", "bottom"]);
expect(chat.render(80)).toEqual([...head, "bottom"]);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined();
block.setLines(["top", "stable", "inserted", "bottom"]);
expect(chat.render(80)).toEqual(["top", "stable", "inserted", "bottom"]);
block.setLines([...head, "inserted", "bottom"]);
expect(chat.render(80)).toEqual([...head, "inserted", "bottom"]);
// Append-only earned; the body is offered up to the volatile-tail
// holdback (8 rows - 4).
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4);
});
@@ -69,15 +72,18 @@ describe("transcript reactive commit boundary", () => {
it("marks interior live re-layout volatile and defers commit", async () => {
const chat = new TranscriptContainer();
const block = new MutableLiveBlock(["top", "old", "bottom"]);
const mid = markerLines("mid-", 8);
const block = new MutableLiveBlock(["top", "old", ...mid]);
chat.addChild(block);
chat.render(80);
block.setLines(["top", "new", "extra", "bottom"]);
expect(chat.render(80)).toEqual(["top", "new", "extra", "bottom"]);
// A rewrite above the volatile-tail zone is a re-layout of
// committed-candidate content, no matter how small the gap.
block.setLines(["top", "new", ...mid]);
expect(chat.render(80)).toEqual(["top", "new", ...mid]);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined();
block.setLines(["top", "new", "extra", "more", "bottom"]);
block.setLines(["top", "new", ...mid, "more"]);
chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined();
});
@@ -89,48 +95,54 @@ describe("transcript reactive commit boundary", () => {
// paragraph wrapped onto a new row, the close moved to the new last row
// while the first row's visible cells stayed identical.
const sty = "\x1b[38;2;156;163;176m";
const block = new MutableLiveBlock([`${sty}alpha beta\x1b[39m `]);
const head = markerLines("head-", 6);
const block = new MutableLiveBlock([...head, `${sty}alpha beta\x1b[39m `]);
chat.addChild(block);
chat.render(80);
block.setLines([`${sty}alpha beta `, `${sty}gamma\x1b[39m `]);
block.setLines([...head, `${sty}alpha beta `, `${sty}gamma\x1b[39m `]);
chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(2);
// Append-only earned despite the escape drift: offered up to the
// volatile-tail holdback (8 rows - 4).
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4);
});
it("treats a wrap-shrink of the trailing line as append-only", async () => {
const chat = new TranscriptContainer();
// A streamed token extends the last word past the wrap column, so the
// word moves down onto an appended row and the previous bottom line
// shrinks. The bottom line is on screen by definition, so this is not a
// rewrite of committed-candidate rows.
const block = new MutableLiveBlock(["para one", "foo bar baz"]);
// shrinks. The bottom line sits inside the volatile-tail zone, so this
// is not a rewrite of committed-candidate rows.
const head = markerLines("head-", 6);
const block = new MutableLiveBlock([...head, "foo bar baz"]);
chat.addChild(block);
chat.render(80);
block.setLines(["para one", "foo bar", "bazqux and more"]);
block.setLines([...head, "foo bar", "bazqux and more"]);
chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4);
});
it("re-earns append-only after a one-off interior rewrite heals", async () => {
const chat = new TranscriptContainer();
const block = new MutableLiveBlock(["top", "old", "bottom"]);
const mid = markerLines("mid-", 8);
const block = new MutableLiveBlock(["top", "old", ...mid]);
chat.addChild(block);
chat.render(80);
// Interior rewrite (a codespan finalizing across a wrap) suspends commits.
block.setLines(["top", "new", "bottom"]);
block.setLines(["top", "new", ...mid]);
chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined();
// Clean static frames re-arm the block...
for (let i = 0; i < 30; i++) chat.render(80);
// ...and the next append-shaped frame resumes committing the full block,
// so the pinned emitter can backfill the stalled gap contiguously.
block.setLines(["top", "new", "bottom", "appended"]);
// ...and the next append-shaped frame resumes committing up to the
// volatile-tail holdback (11 rows - 4), so the pinned emitter can
// backfill the stalled gap contiguously.
block.setLines(["top", "new", ...mid, "appended"]);
chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(7);
});
it("keeps a periodically rewriting block (spinner) deferred", async () => {
@@ -160,17 +172,18 @@ describe("transcript reactive commit boundary", () => {
chat.addChild(block);
chat.render(80);
// The progress tail rewrites every frame, so append-only is never
// earned — but the head rows stay visibly identical the whole time.
// The progress tail rewrites every frame, but it lives inside the
// volatile-tail zone, so the block still classifies as clean streaming
// and the settled head is offered immediately — up to the holdback
// (9 rows - 4). Otherwise a tall block's scrolled-off head is neither
// committed nor on screen for the whole run — the transcript reads as
// cut off until the tool seals.
for (let i = 1; i <= 62; i++) {
block.setLines([...head, `⠋ agents running · ${i} tools`]);
chat.render(80);
}
// The settled head must become commit-safe; otherwise a tall block's
// scrolled-off head is neither committed nor on screen for the whole
// run — the transcript reads as cut off until the tool seals.
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(8);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(5);
});
it("retreats the settled-head boundary when a promoted row is rewritten", () => {
@@ -183,7 +196,8 @@ describe("transcript reactive commit boundary", () => {
block.setLines([...head, `tail-${i}`]);
chat.render(80);
}
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(8);
// Offered up to the volatile-tail holdback (9 rows - 4).
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(5);
// A collapse/re-layout rewrites a promoted row: the boundary retreats
// to the divergence (the engine audit owns rows already committed).
@@ -210,54 +224,121 @@ describe("transcript reactive commit boundary", () => {
chat.addChild(block);
chat.render(80);
// Stagger slow updates with quiet stretches longer than the promotion
// window. The floor arms the first time an already-promoted row ticks
// and descends to each promoted ticker as it re-ticks; after the
// topmost ticker has re-ticked once post-promotion, the boundary must
// converge to the static head and never reach into the tree again.
let maxSafeEndAfterConvergence = 0;
// Tickers in the trailing volatile zone are never offered: the boundary
// converges to the holdback (11 rows - 4) and never reaches into the
// tree, so no tick can rewrite a committed row.
let maxSafeEnd = 0;
const counters: [number, number, number] = [0, 0, 0];
for (let tick = 0; tick < 9; tick++) {
counters[tick % 3] += 1;
block.setLines([...head, ...tree(...counters)]);
for (let frame = 0; frame < 40; frame++) {
chat.render(80);
const safeEnd = chat.getNativeScrollbackCommitSafeEnd() ?? 0;
if (tick >= 4) maxSafeEndAfterConvergence = Math.max(maxSafeEndAfterConvergence, safeEnd);
maxSafeEnd = Math.max(maxSafeEnd, chat.getNativeScrollbackCommitSafeEnd() ?? 0);
}
}
// The static head still commits; the slow-ticking tree stays deferred.
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(8);
expect(maxSafeEndAfterConvergence).toBe(8);
// The static head commits; the ticking tree stays deferred forever.
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(7);
expect(maxSafeEnd).toBe(7);
});
it("keeps the rewrite floor anchored across append growth below it", () => {
const chat = new TranscriptContainer();
// The ticker sits ABOVE the volatile-tail zone: 4 head rows, the ticker,
// then 6 rows of stable trailing chrome. Quiet stretches promote through
// it; its first tick is a genuine committed-candidate rewrite.
const head = markerLines("head-", 4);
const block = new MutableLiveBlock([...head, "ticker · 0"]);
const chrome = markerLines("chrome-", 6);
const block = new MutableLiveBlock([...head, "ticker · 0", ...chrome]);
chat.addChild(block);
chat.render(80);
// Let the ratchet over-promote through the quiet ticker, then tick it:
// the floor lands on the ticker row (index 4).
// Let the ratchet over-promote through the quiet ticker (up to the
// holdback: 11 rows - 4), then tick it: the floor lands on the ticker
// row (index 4) and the boundary retreats to it.
for (let i = 0; i < 70; i++) chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(5);
block.setLines([...head, "ticker · 1"]);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(7);
block.setLines([...head, "ticker · 1", ...chrome]);
chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4);
// Settled rows are inserted above the ticker (append above stable
// trailing chrome): the ticker shifts down and the floor must travel
// with it, or the new settled rows would be barred from promoting.
block.setLines([...head, "settled-a", "settled-b", "ticker · 1"]);
block.setLines([...head, "settled-a", "settled-b", "ticker · 1", ...chrome]);
for (let i = 0; i < 70; i++) chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(6);
// And the shifted ticker itself never re-promotes.
block.setLines([...head, "settled-a", "settled-b", "ticker · 2"]);
block.setLines([...head, "settled-a", "settled-b", "ticker · 2", ...chrome]);
for (let i = 0; i < 70; i++) chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(6);
});
it("keeps committing through streaming markdown tail jitter (re-wrap + token resolution)", () => {
// Regression: real markdown streaming is not strictly append-only at the
// bottom — the in-flight paragraph re-wraps (rewriting its last 2 rows)
// and unclosed tokens (`**bold`) re-render when the closer arrives. The
// old classifier treated every such frame as a rewrite and tripped a
// 30-frame cooldown, so a continuously streaming reply never re-earned
// append-only: the boundary crawled via the ratchet (~12 rows committed
// out of 109) and the engine rewrote the window in place instead of
// scroll-appending ("replaces instead of appending").
const chat = new TranscriptContainer();
const block = new MutableLiveBlock(["row-0"]);
chat.addChild(block);
chat.render(80);
const rows: string[] = ["row-0"];
let maxLag = 0;
for (let i = 1; i <= 80; i++) {
if (i % 7 === 0 && rows.length >= 2) {
// Token resolution: the trailing row is replaced (not a prefix
// extension) — e.g. literal `**thin` re-rendering as bold text.
rows[rows.length - 1] = `resolved-${i}`;
rows.push(`row-${i}`);
} else if (i % 5 === 0 && rows.length >= 2) {
// Trailing-paragraph re-wrap: the last TWO rows rewrite while
// new rows append below.
rows[rows.length - 2] = `rewrapped-${i}`;
rows[rows.length - 1] = `rewrapped-tail-${i}`;
rows.push(`row-${i}`);
} else {
rows.push(`row-${i}`);
}
block.setLines(rows);
chat.render(80);
const safeEnd = chat.getNativeScrollbackCommitSafeEnd() ?? 0;
maxLag = Math.max(maxLag, rows.length - safeEnd);
}
// The boundary must track the stream the whole way: never more than the
// volatile-tail holdback behind the frame.
expect(maxLag).toBeLessThanOrEqual(4);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(rows.length - 4);
});
it("defers a tall block whose head row keeps animating", () => {
// A streaming block with an animated glyph in its header (the old
// edit/write streaming shape) can never commit anything: commits are
// prefix-only, and the head row rewrites every glyph advance. The
// classifier must treat a head-row rewrite as volatile, not as
// tail-confined jitter, regardless of how small the divergence is.
const chat = new TranscriptContainer();
const body = markerLines("body-", 12);
const block = new MutableLiveBlock(["⠋ streaming", ...body]);
chat.addChild(block);
chat.render(80);
const glyphs = ["⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠋"];
for (const [i, glyph] of glyphs.entries()) {
block.setLines([`${glyph} streaming`, ...body, ...markerLines(`grow-${i}-`, i)]);
chat.render(80);
chat.render(80);
}
expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined();
});
});
describe("tool live-region scrollback", () => {
@@ -315,6 +396,69 @@ describe("tool live-region scrollback", () => {
}
});
it("scroll-appends a tall expanded streaming write into native scrollback mid-stream", async () => {
if (process.platform === "win32") return;
// Regression for "streaming previews replace instead of appending": a
// tall expanded write preview must reach pane history WHILE args are
// still streaming — not only after the result lands. Two ingredients:
// the commit classifier tolerating streaming-edge jitter, and the
// renderer keeping the animated glyph out of the block's head row.
const term = new VirtualTerminal(120, 12);
const tui = new TUI(term);
const chat = new TranscriptContainer();
const fullContent = Array.from({ length: 60 }, (_unused, i) => `const streamed_line_${i} = ${i};`).join("\n");
const component = new ToolExecutionComponent(
"write",
{ file_path: "packages/coding-agent/test/probe.ts", content: "" },
{},
undefined,
tui,
process.cwd(),
);
component.setExpanded(true);
try {
chat.addChild(new Text("prior filler", 0, 0));
tui.addChild(chat);
tui.start();
await term.waitForRender();
chat.addChild(component);
tui.requestRender();
await term.waitForRender();
const chunk = Math.ceil(fullContent.length / 12);
for (let off = chunk; off < fullContent.length; off += chunk) {
component.updateArgs({
file_path: "packages/coding-agent/test/probe.ts",
content: fullContent.slice(0, off),
});
tui.requestRender();
await term.waitForRender();
}
// Still streaming: no result, args incomplete. The head of the
// preview must already be in the buffer (committed above the
// window), not cut off — and the viewport itself only shows the
// streaming tail.
const rows = term.getScrollBuffer().map(row => Bun.stripANSI(row).trimEnd());
const bufferText = rows.join("\n");
expect(bufferText).toContain("const streamed_line_0 = 0;");
expect(bufferText).toContain("const streamed_line_30 = 30;");
expect(rows.length).toBeGreaterThan(term.rows);
const viewportText = term
.getViewport()
.map(row => Bun.stripANSI(row).trimEnd())
.join("\n");
expect(viewportText).not.toContain("const streamed_line_0 = 0;");
} finally {
component.stopAnimation();
tui.stop();
await term.flush();
}
});
it("repaints a finalized write whose result lands after a card was appended below it", async () => {
if (process.platform === "win32") return;
@@ -1205,6 +1205,42 @@ describe("AskTool option markers", () => {
expect(secondResult.match(/TypeScript/g)?.length).toBe(1);
expect(secondResult.match(/Haskell/g)?.length).toBe(1);
});
it("keeps single-question option rows stable across repeated renders", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
// The question body comes from the Markdown render cache, which returns
// the SAME array on every render of identical text at identical width.
// Appending option rows in place would poison that cached entry, so a
// second render of the component would duplicate the options.
const renderedCall = askToolRenderer.renderCall(
{ question: "Which **language** do you prefer?", options: [{ label: "OptionDupCanary" }] },
{ expanded: true, isPartial: false },
theme!,
);
const first = stripAnsi(renderedCall.render(120).join("\n"));
const second = stripAnsi(renderedCall.render(120).join("\n"));
expect(second).toBe(first);
expect(second.match(/OptionDupCanary/g)?.length).toBe(1);
const renderedResult = askToolRenderer.renderResult(
{
content: [{ type: "text", text: "" }],
details: {
question: "Which **language** do you prefer?",
multi: false,
options: ["OptionDupCanary"],
selectedOptions: ["OptionDupCanary"],
},
},
{ expanded: true, isPartial: false },
theme!,
);
const firstResult = stripAnsi(renderedResult.render(120).join("\n"));
const secondResult = stripAnsi(renderedResult.render(120).join("\n"));
expect(secondResult).toBe(firstResult);
expect(secondResult.match(/OptionDupCanary/g)?.length).toBe(1);
});
it("renders single-choice result selection with a filled radio marker", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
@@ -13,7 +13,7 @@ async function theme() {
return t!;
}
const lines = (component: { render: (w: number) => string[] }, width = 200) =>
const lines = (component: { render: (w: number) => readonly string[] }, width = 200) =>
sanitizeText(component.render(width).join("\n")).split("\n");
describe("retainToolRenderer", () => {
@@ -39,7 +39,13 @@ describe("WelcomeComponent fixed geometry", () => {
const heights = new Set<number>();
for (const sessionCount of [0, 1, 4, 6]) {
for (const lspCount of [0, 1, 4, 6]) {
const welcome = new WelcomeComponent("1.0.0", "Model", "provider", sessions(sessionCount), lspServers(lspCount));
const welcome = new WelcomeComponent(
"1.0.0",
"Model",
"provider",
sessions(sessionCount),
lspServers(lspCount),
);
heights.add(welcome.render(120).length);
}
}
@@ -4,7 +4,7 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { TUI } from "@oh-my-pi/pi-tui";
const stripAnsi = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, "");
const hasLine = (lines: string[], n: number): boolean =>
const hasLine = (lines: readonly string[], n: number): boolean =>
new RegExp(`\\bline ${n}\\b`).test(stripAnsi(lines.join("\n")));
describe("write streaming preview honors Ctrl+O expansion", () => {
+1
View File
@@ -14,6 +14,7 @@
- Lengthened the OSC 11 appearance poll on terminals without Mode 2031 from 2s to 30s — each poll's query write cleared the user's active text selection, breaking copy every two seconds on Alacritty/Warp/older WezTerm
- Rewrote `StdinBuffer.extractCompleteSequences` to index-based scanning: the previous per-iteration `slice` + `Array.from(remaining)[0]` made plain-text bursts O(n²), turning a 100KB non-bracketed paste into a multi-second freeze
- Capped the editor undo stack at 100 entries with word-level coalescing of consecutive single-character inserts (matching `Input`), capped the kill ring at 60 entries, cached word-wrap layout per (line, width) so each render and key handler shares one wrap pass, and batched ≤1000-char single-line pastes into one insert + one trigger-detection pass instead of per-character replay
- Virtualized the frame pipeline around a stable-prefix contract — the renderer no longer does O(total transcript) work per frame. `Component.render` now returns `readonly string[]`: results are component-owned, callers must not mutate them, and an unchanged component returns the same array reference (reference equality proves byte-identical rows). `Container.render` memoizes its concatenation on child references (children are still rendered every frame for their side effects); `Box` replaced its content-hashing cache with the same child-reference memo (no more per-frame `leftPad + line` rebuilds and full-content hashing); `Markdown`, `Spacer`, and `TruncatedText` return their cached arrays by reference instead of defensive copies. The TUI composes a persistent frame from per-child segments and an opt-in `RenderStablePrefix` report (consumable floor semantics for in-place mutators like the transcript), so marker extraction, line preparation (persistent prepared-frame replacing the per-frame rebuilt cache arrays), and the committed-prefix audit now run only over rows at/after the first changed row instead of every line of the transcript every frame
### Fixed
+5 -5
View File
@@ -62,7 +62,7 @@ All components implement:
```typescript
interface Component {
render(width: number): string[];
render(width: number): readonly string[];
handleInput?(data: string): void;
invalidate?(): void;
}
@@ -70,7 +70,7 @@ interface Component {
| Method | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. |
| `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. The result is component-owned and immutable to callers; return the same array reference when unchanged (enables renderer memoization) and a new array when content changed. |
| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
| `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
@@ -590,7 +590,7 @@ class MyInteractiveComponent implements Component {
}
}
render(width: number): string[] {
render(width: number): readonly string[] {
return this.items.map((item, i) => {
const prefix = i === this.selectedIndex ? "> " : " ";
return truncateToWidth(prefix + item, width);
@@ -614,7 +614,7 @@ class MyComponent implements Component {
this.text = text;
}
render(width: number): string[] {
render(width: number): readonly string[] {
// Option 1: Truncate long lines
return [truncateToWidth(this.text, width)];
@@ -656,7 +656,7 @@ class CachedComponent implements Component {
private cachedWidth?: number;
private cachedLines?: string[];
render(width: number): string[] {
render(width: number): readonly string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
+46 -66
View File
@@ -2,7 +2,9 @@ import type { Component } from "../tui";
import { applyBackgroundToLine, padding, visibleWidth } from "../utils";
type Cache = {
key: bigint | number;
width: number;
bgSample: string | undefined;
childLines: (readonly string[])[];
result: string[];
};
@@ -63,24 +65,6 @@ export class Box implements Component {
this.#cached = undefined;
}
static #tmp = new Uint32Array(2);
#computeCacheKey(width: number, childLines: string[], bgSample: string | undefined): bigint | number {
Box.#tmp[0] = width;
Box.#tmp[1] = childLines.length;
let h = Bun.hash(Box.#tmp);
for (const line of childLines) {
h = Bun.hash(line, h);
}
if (bgSample) {
h = Bun.hash(bgSample, h);
}
return h;
}
#matchCache(cacheKey: bigint | number): boolean {
return this.#cached?.key === cacheKey;
}
invalidate(): void {
this.#invalidateCache();
for (const child of this.children) {
@@ -88,58 +72,54 @@ export class Box implements Component {
}
}
render(width: number): string[] {
if (this.children.length === 0) {
return [];
}
render(width: number): readonly string[] {
const children = this.children;
const count = children.length;
const contentWidth = Math.max(1, width - this.#paddingX * 2);
const leftPad = padding(this.#paddingX);
// bgFn output can change without the function reference changing (theme
// mutation); sample it so a silent palette swap still misses the cache.
const bgSample = this.#bgFn ? this.#bgFn("test") : undefined;
// Render all children
const childLines: string[] = [];
for (const child of this.children) {
const lines = child.render(contentWidth);
for (const line of lines) {
childLines.push(leftPad + line);
// Render every child every frame (renders may carry side effects); the
// memo only skips re-deriving the padded/background rows. Per the
// Component render contract, identical child array references prove the
// content is unchanged.
const cached = this.#cached;
let unchanged =
cached !== undefined &&
cached.width === width &&
cached.bgSample === bgSample &&
cached.childLines.length === count;
const childLines: (readonly string[])[] = new Array(count);
let contentRows = 0;
for (let i = 0; i < count; i++) {
const lines = children[i]!.render(contentWidth);
childLines[i] = lines;
contentRows += lines.length;
if (unchanged && cached!.childLines[i] !== lines) unchanged = false;
}
if (unchanged) return cached!.result;
const result: string[] = [];
if (contentRows > 0) {
const leftPad = padding(this.#paddingX);
// Top padding
for (let i = 0; i < this.#paddingY; i++) {
result.push(this.#applyBg("", width));
}
// Content
for (const lines of childLines) {
for (const line of lines) {
result.push(this.#applyBg(leftPad + line, width));
}
}
// Bottom padding
for (let i = 0; i < this.#paddingY; i++) {
result.push(this.#applyBg("", width));
}
}
if (childLines.length === 0) {
return [];
}
// Check if bgFn output changed by sampling
const bgSample = this.#bgFn ? this.#bgFn("test") : undefined;
const cacheKey = this.#computeCacheKey(width, childLines, bgSample);
// Check cache validity
if (this.#matchCache(cacheKey)) {
return this.#cached!.result;
}
// Apply background and padding
const result: string[] = [];
// Top padding
for (let i = 0; i < this.#paddingY; i++) {
result.push(this.#applyBg("", width));
}
// Content
for (const line of childLines) {
result.push(this.#applyBg(line, width));
}
// Bottom padding
for (let i = 0; i < this.#paddingY; i++) {
result.push(this.#applyBg("", width));
}
// Update cache
this.#cached = { key: cacheKey, result };
this.#cached = { width, bgSample, childLines, result };
return result;
}
+1 -1
View File
@@ -758,7 +758,7 @@ export class Editor implements Component, Focusable {
this.#scrollOffset = Math.min(this.#scrollOffset, maxOffset);
}
render(width: number): string[] {
render(width: number): readonly string[] {
const paddingX = this.#getEditorPaddingX();
const borderVisible = this.#borderVisible;
const promptGutter = this.#getPromptGutter(width, paddingX);
+1 -1
View File
@@ -266,7 +266,7 @@ export class Image implements Component {
this.#cachedWidth = undefined;
}
render(width: number): string[] {
render(width: number): readonly string[] {
const hasProtocol = TERMINAL.imageProtocol != null;
// observe() must run on every pass — even a cache hit — so the image keeps
// its display-order slot in the budget. Only graphics-capable frames count
+1 -1
View File
@@ -397,7 +397,7 @@ export class Input implements Component, Focusable {
// No cached state to invalidate currently
}
render(width: number): string[] {
render(width: number): readonly string[] {
// Calculate visible window
const prompt = "> ";
const availableWidth = width - prompt.length;
+1 -1
View File
@@ -39,7 +39,7 @@ export class Loader extends Text {
this.start();
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines = ["", ...super.render(width)];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
+14 -11
View File
@@ -289,8 +289,9 @@ export class Markdown implements Component {
/** Number of spaces used to indent code block content. */
#codeBlockIndent: number;
// Cache for rendered output. Cached arrays are internal snapshots; render()
// returns caller-owned arrays because several renderers append surrounding rows.
// Cache for rendered output. Cached arrays are shared and returned by
// reference (render contract: results are component-owned and immutable to
// callers); the L2 LRU may hand the same array to multiple instances.
#cachedText?: string;
#cachedWidth?: number;
#cachedLines?: readonly string[];
@@ -326,11 +327,13 @@ export class Markdown implements Component {
this.#cachedLines = undefined;
}
render(width: number): string[] {
render(width: number): readonly string[] {
// L1: per-instance cache — fastest path for repeated renders of the same
// instance at the same width (e.g. resize debounce, repeated redraws).
// Returning the cached reference is load-bearing: parents memoize their
// concatenation on reference equality.
if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
return this.#cachedLines.slice();
return this.#cachedLines;
}
// Calculate available width for content (subtract horizontal padding)
@@ -341,7 +344,7 @@ export class Markdown implements Component {
this.#cachedText = this.#text;
this.#cachedWidth = width;
this.#cachedLines = EMPTY_RENDER_LINES;
return [];
return EMPTY_RENDER_LINES;
}
// Replace tabs with 3 spaces for consistent rendering
@@ -370,7 +373,7 @@ export class Markdown implements Component {
this.#cachedText = this.#text;
this.#cachedWidth = width;
this.#cachedLines = cached;
return cached.slice();
return cached;
}
}
@@ -452,17 +455,17 @@ export class Markdown implements Component {
const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
const result = rawResult.length > 0 ? rawResult : [""];
// Update caches with a private snapshot. The returned array remains owned by
// the caller, so push/splice by tool renderers cannot poison future redraws.
const cachedLines = result.slice();
// Update caches and hand the array out by reference. Callers must not
// mutate it (Component render contract); the L2 entry is shared across
// instances keyed on identical inputs.
this.#cachedText = this.#text;
this.#cachedWidth = width;
this.#cachedLines = cachedLines;
this.#cachedLines = result;
// Update L2 module-level LRU so future instances with the same key skip
// the marked.lexer + highlightCode (Rust FFI) work entirely.
if (cacheKey !== undefined) {
renderCache.set(cacheKey, cachedLines);
renderCache.set(cacheKey, result);
}
return result;
+1 -1
View File
@@ -178,7 +178,7 @@ export class ScrollView implements Component {
// No cached layout to invalidate.
}
render(width: number): string[] {
render(width: number): readonly string[] {
this.#clampScrollOffset();
const safeWidth = Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0;
if (this.#height === 0) return [];
+1 -1
View File
@@ -107,7 +107,7 @@ export class SelectList implements Component {
// No cached state to invalidate currently
}
render(width: number): string[] {
render(width: number): readonly string[] {
const lines: string[] = [];
const showSearchStatus = this.#shouldRenderSearchStatus();
+1 -1
View File
@@ -178,7 +178,7 @@ export class SettingsList implements Component {
this.#submenuComponent?.invalidate?.();
}
render(width: number): string[] {
render(width: number): readonly string[] {
// If submenu is active, render it instead
if (this.#submenuComponent) {
return this.#submenuComponent.render(width);
+9 -5
View File
@@ -5,24 +5,28 @@ import type { Component } from "../tui";
*/
export class Spacer implements Component {
#lines: number;
#cached: string[] | undefined;
constructor(lines: number = 1) {
this.#lines = lines;
}
setLines(lines: number): void {
if (lines === this.#lines) return;
this.#lines = lines;
this.#cached = undefined;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(_width: number): string[] {
const result: string[] = [];
for (let i = 0; i < this.#lines; i++) {
result.push("");
render(_width: number): readonly string[] {
let cached = this.#cached;
if (cached === undefined) {
cached = new Array(this.#lines).fill("");
this.#cached = cached;
}
return result;
return cached;
}
}
+1 -1
View File
@@ -111,7 +111,7 @@ export class TabBar implements Component {
}
/** Render the tab bar, wrapping to multiple lines if needed */
render(width: number): string[] {
render(width: number): readonly string[] {
const maxWidth = Math.max(1, width);
const chunks: string[] = [];
+1 -1
View File
@@ -50,7 +50,7 @@ export class Text implements Component {
this.#cachedLines = undefined;
}
render(width: number): string[] {
render(width: number): readonly string[] {
// Check cache
if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
return this.#cachedLines;
+10 -2
View File
@@ -8,6 +8,8 @@ export class TruncatedText implements Component {
#text: string;
#paddingX: number;
#paddingY: number;
#cachedWidth = -1;
#cachedLines: string[] | undefined;
constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
this.#text = text;
@@ -16,10 +18,14 @@ export class TruncatedText implements Component {
}
invalidate(): void {
// No cached state to invalidate currently
this.#cachedWidth = -1;
this.#cachedLines = undefined;
}
render(width: number): string[] {
render(width: number): readonly string[] {
if (this.#cachedLines && this.#cachedWidth === width) {
return this.#cachedLines;
}
const result: string[] = [];
// Empty line padded to width
@@ -56,6 +62,8 @@ export class TruncatedText implements Component {
result.push(emptyLine);
}
this.#cachedWidth = width;
this.#cachedLines = result;
return result;
}
}
+269 -50
View File
@@ -121,14 +121,26 @@ const DEFAULT_RENDER_SCHEDULER: RenderScheduler = {
/**
* Component interface - all components must implement this
*
* Render contract: the returned array (and its rows) belongs to the component.
* Callers MUST NOT mutate it — components are allowed to return a cached array
* and will return the exact same reference for as long as their rendered
* content is unchanged. Conversely, a component MUST return a fresh array
* reference whenever its content changed; reference equality across two
* render() calls is the engine's proof that the rows are byte-identical
* (containers memoize their concatenation on it, and the TUI derives the
* frame's stable prefix from it). A component that mutates a previously
* returned array in place must implement {@link RenderStablePrefix} to declare
* which leading rows survived.
*/
export interface Component {
/**
* Render the component to lines for the given viewport width
* @param width - Current viewport width
* @returns Array of strings, each representing a line
* Render the component to an array of physical rows at the given width.
* The result is component-owned and `readonly` to the caller; an unchanged
* component may (and should) return the same array reference it returned
* last time.
*/
render(width: number): string[];
render(width: number): readonly string[];
/**
* Optional handler for keyboard input when component has focus
@@ -187,6 +199,34 @@ function getNativeScrollbackCommitSafeEnd(component: Component): number | undefi
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
}
/**
* Opt-in stability report for components that mutate their returned render
* array in place across frames (instead of returning a fresh array per
* change). The engine reads it right after the component's `render()` returns:
* the report counts the leading rows of the just-returned array that are
* byte-identical to the array state the reader last observed. The engine uses
* it to reuse the composed frame's prefix — skipping marker extraction, line
* preparation, and the committed-prefix audit for those rows.
*
* Contract:
* - Reading CONSUMES the report: it re-bases the baseline to the current
* array state. The accumulated count therefore covers every render since
* the previous read, so out-of-band `render()` calls between engine frames
* (an exporter walking the tree) can only lower the report, never inflate
* it past what the engine actually has.
* - An implementer that cannot prove stability for a frame must lower the
* accumulated count to 0 for that render.
* - Rows at or beyond the report may have been mutated in place; rows before
* it must be the identical string values at the identical indices.
*/
export interface RenderStablePrefix {
getRenderStablePrefixRows(): number;
}
function getRenderStablePrefixRows(component: Component): number | undefined {
return (component as Component & Partial<RenderStablePrefix>).getRenderStablePrefixRows?.();
}
/**
* Interface for components that can receive focus and display a cursor.
* When focused, the component should emit CURSOR_MARKER at the cursor position
@@ -338,22 +378,37 @@ export interface OverlayHandle {
export class Container implements Component {
children: Component[] = [];
// Memoized concatenation of the children's latest renders. Children are
// still rendered every frame (renders carry side effects: image placement
// registration, seam/stability reports); the memo only skips rebuilding
// the concatenated array when every child returned the exact same array
// reference at the same width — which, per the Component render contract,
// proves the rows are byte-identical. Cleared on any child-list change and
// on invalidate().
#memoLines: string[] | undefined;
#memoChildLines: (readonly string[])[] = [];
#memoWidth = -1;
addChild(component: Component): void {
this.children.push(component);
this.#memoLines = undefined;
}
removeChild(component: Component): void {
const index = this.children.indexOf(component);
if (index !== -1) {
this.children.splice(index, 1);
this.#memoLines = undefined;
}
}
clear(): void {
this.children = [];
this.#memoLines = undefined;
}
invalidate(): void {
this.#memoLines = undefined;
for (const child of this.children) {
child.invalidate?.();
}
@@ -370,13 +425,31 @@ export class Container implements Component {
}
}
render(width: number): string[] {
render(width: number): readonly string[] {
width = Math.max(1, width);
const lines: string[] = [];
for (const child of this.children) {
const childLines = child.render(width);
for (let i = 0; i < childLines.length; i++) lines.push(childLines[i]);
const children = this.children;
const count = children.length;
let refs = this.#memoChildLines;
let unchanged = this.#memoLines !== undefined && this.#memoWidth === width && refs.length === count;
if (refs.length !== count) {
refs = new Array(count);
this.#memoChildLines = refs;
}
for (let i = 0; i < count; i++) {
const childLines = children[i]!.render(width);
if (refs[i] !== childLines) {
unchanged = false;
refs[i] = childLines;
}
}
this.#memoWidth = width;
if (unchanged) return this.#memoLines!;
const lines: string[] = [];
for (let i = 0; i < count; i++) {
const childLines = refs[i]!;
for (let j = 0; j < childLines.length; j++) lines.push(childLines[j]!);
}
this.#memoLines = lines;
return lines;
}
}
@@ -415,6 +488,18 @@ interface CursorControlResult extends HardwareCursorUpdate {
visible: boolean;
}
/**
* One root child's contribution to the composed frame: the array reference its
* render() returned, the frame row it starts at, and the row count recorded at
* compose time (in-place mutators keep the reference but may change length).
*/
interface FrameSegment {
component: Component;
lines: readonly string[];
start: number;
rowCount: number;
}
interface PreparedLine {
raw: string;
width: number;
@@ -493,7 +578,7 @@ export function findCommittedPrefixResync(frame: readonly string[], prefix: read
*/
export class TUI extends Container {
terminal: Terminal;
#previousLines: string[] = [];
#previousFrameLength = 0;
#previousWidth = 0;
#previousHeight = 0;
#focusedComponent: Component | null = null;
@@ -605,7 +690,7 @@ export class TUI extends Container {
// Transient alternate-screen state for a fullscreen overlay. While active, the
// engine paints only the modal on the alt buffer and leaves every
// normal-screen accounting field (#previousLines, #viewportTopRow, …)
// normal-screen accounting field (#previousFrameLength, #viewportTopRow, …)
// untouched, so exiting reconciles cleanly against the terminal-restored
// normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
#altActive = false;
@@ -613,10 +698,30 @@ export class TUI extends Container {
#altEnterWidth = 0;
#altEnterHeight = 0;
// Last-frame line preparation cache. Entries store normalized, width-fitted
// content rows without the per-line terminal terminator; terminators are
// appended only at write time so width checks stay on content, not reset bytes.
#preparedLineCache: PreparedLine[] = [];
// Persistent composed frame. The render override splices only rows at/after
// the stable prefix each frame; cursor markers are stripped at ingestion so
// the frame never carries them. Returned to render() callers — treated as
// immutable by them per the Component render contract.
#composedFrame: string[] = [];
// Per-root-child segment ledger backing the stable-prefix computation.
#frameSegments: FrameSegment[] = [];
#composeWidth = -1;
// Cursor markers stripped at ingestion, ascending by frame row.
#frameCursorMarkers: { row: number; col: number }[] = [];
// Leading rows of #composedFrame byte-identical to the previous compose.
#renderStablePrefixRows = 0;
// Persistent prepared frame, row-aligned with #composedFrame. Entries store
// normalized, width-fitted content rows without the per-line terminal
// terminator; terminators are appended only at write time so width checks
// stay on content, not reset bytes. #preparedValidRows counts the leading
// rows known prepared against the CURRENT composed frame: a compose lowers
// it to the stable prefix, a completed prepare raises it to the frame
// length, and an abandoned frame (ghostty image defer) leaves it lowered so
// the next prepare revalidates the splice.
#preparedFrame: string[] = [];
#preparedMeta: PreparedLine[] = [];
#preparedValidRows = 0;
// Overlay stack for modal components rendered on top of base content
overlayStack: {
@@ -633,13 +738,20 @@ export class TUI extends Container {
this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
}
override render(width: number): string[] {
override render(width: number): readonly string[] {
width = Math.max(1, width);
this.#nativeScrollbackLiveRegionStart = undefined;
this.#nativeScrollbackCommitSafeEnd = undefined;
const lines: string[] = [];
for (const child of this.children) {
const offset = lines.length;
const children = this.children;
const previousSegments = this.#frameSegments;
const segments: FrameSegment[] = new Array(children.length);
// A width change re-renders every child; nothing carries over.
let chainStable = this.#composeWidth === width;
this.#composeWidth = width;
let offset = 0;
let stableRows = 0;
for (let index = 0; index < children.length; index++) {
const child = children[index]!;
const childLines = child.render(width);
const liveRegionStart = getNativeScrollbackLiveRegionStart(child);
if (liveRegionStart !== undefined) {
@@ -655,9 +767,88 @@ export class TUI extends Container {
this.#nativeScrollbackCommitSafeEnd = offset + boundedEnd;
}
}
for (let i = 0; i < childLines.length; i++) lines.push(childLines[i]);
// Consume the stability report unconditionally for implementers:
// reading re-bases the component's baseline to the state this
// compose is about to ingest (used or not, the current rows are
// what ends up in the composed frame).
const reported = getRenderStablePrefixRows(child);
if (chainStable) {
const previous = previousSegments[index];
if (previous !== undefined && previous.component === child && previous.start === offset) {
let stableCount = 0;
if (reported !== undefined) {
// In-place mutator: its report overrides reference equality.
// Rows beyond the previous row count cannot be "unchanged".
stableCount = Number.isFinite(reported)
? Math.max(0, Math.min(childLines.length, previous.rowCount, Math.trunc(reported)))
: 0;
} else if (previous.lines === childLines) {
stableCount = childLines.length;
}
stableRows += stableCount;
// The chain survives only a fully stable segment: identical rows
// AND identical row count (a grown/shrunk segment shifts every
// row below it).
if (stableCount < childLines.length || previous.rowCount !== childLines.length) chainStable = false;
} else {
chainStable = false;
}
}
segments[index] = { component: child, lines: childLines, start: offset, rowCount: childLines.length };
offset += childLines.length;
}
return lines;
this.#frameSegments = segments;
const frame = this.#composedFrame;
// Defensive clamp: stable rows can never exceed what the previous
// compose actually materialized (only reachable if a child render threw
// mid-compose on the previous frame).
if (stableRows > frame.length) stableRows = frame.length;
if (stableRows !== offset || frame.length !== offset) {
// Re-ingest every row at/after the stable prefix: truncate, strip
// cursor markers, record their positions.
frame.length = stableRows;
this.#pruneFrameCursorMarkers(stableRows);
for (const segment of segments) {
const lines = segment.lines;
const from = segment.start >= stableRows ? 0 : stableRows - segment.start;
for (let i = from; i < lines.length; i++) this.#ingestFrameRow(lines[i]!);
}
}
this.#renderStablePrefixRows = stableRows;
this.#preparedValidRows = Math.min(this.#preparedValidRows, stableRows);
return frame;
}
/** Drop cached cursor markers at/after `fromRow` (those rows re-ingest). */
#pruneFrameCursorMarkers(fromRow: number): void {
const markers = this.#frameCursorMarkers;
let keep = markers.length;
while (keep > 0 && markers[keep - 1]!.row >= fromRow) keep--;
markers.length = keep;
}
/**
* Append one row to the composed frame, stripping CURSOR_MARKER occurrences
* (internal sentinels that must never reach the terminal, the committed
* prefix, or the resync audit) and recording the first marker's position.
*/
#ingestFrameRow(line: string): void {
let markerIndex = line.indexOf(CURSOR_MARKER);
if (markerIndex === -1) {
this.#composedFrame.push(line);
return;
}
this.#frameCursorMarkers.push({
row: this.#composedFrame.length,
col: visibleWidth(line.slice(0, markerIndex)),
});
let stripped = line;
while (markerIndex !== -1) {
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
}
this.#composedFrame.push(stripped);
}
#syncTerminalCursorMode(component: Component | null): void {
@@ -1091,8 +1282,8 @@ export class TUI extends Container {
// enough; emitting `\r\n` would create an extra blank row. If the content
// already reaches the viewport bottom, scroll exactly once so the prompt
// lands directly below the last visible TUI row.
if (this.#previousLines.length > 0) {
const targetRow = this.#previousLines.length;
if (this.#previousFrameLength > 0) {
const targetRow = this.#previousFrameLength;
const viewportBottom = this.#windowTopRow + this.terminal.rows - 1;
const clampedCursorRow = Math.max(this.#windowTopRow, Math.min(this.#hardwareCursorRow, viewportBottom));
const moveTargetRow = Math.min(targetRow, viewportBottom);
@@ -1731,10 +1922,11 @@ export class TUI extends Container {
// render recomposes from scratch, so consuming state here would
// misclassify a pending resize as an ordinary diff and corrupt the paint.
if (this.#maybeDeferGhosttyInitialImagePaint()) return;
// Strip cursor markers immediately (they are internal sentinels and
// must never reach the terminal, the committed prefix, or the audit);
// the visible marker is chosen after the window top is known.
const cursorMarkers = this.#extractCursorMarkers(rawFrame);
// Cursor markers were stripped at compose time (they are internal
// sentinels and must never reach the terminal, the committed prefix, or
// the audit); the visible marker is chosen after the window top is
// known. Ascending by frame row.
const cursorMarkers = this.#frameCursorMarkers;
const liveRegionStart = this.#nativeScrollbackLiveRegionStart;
const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd;
@@ -1762,8 +1954,16 @@ export class TUI extends Container {
// the stale copy stays in history and rows recommit from there —
// duplication, never loss. Skipped on geometry frames (a rewrap
// legitimately reflows every row; the mux branch re-bases the prefix
// and non-mux geometry replays from scratch).
if (this.#hasEverRendered && !geometryChanged && !this.#clearScrollbackOnNextRender) {
// and non-mux geometry replays from scratch), and skipped when the
// composed frame's stable prefix covers every committed row — bytes
// that provably did not change since the last (aligned) frame cannot
// have diverged.
if (
this.#hasEverRendered &&
!geometryChanged &&
!this.#clearScrollbackOnNextRender &&
this.#renderStablePrefixRows < this.#committedRows
) {
this.#auditCommittedPrefix(rawFrame);
}
@@ -1833,13 +2033,14 @@ export class TUI extends Container {
// 5. Pick the visible cursor marker (bottom-most at or below the window
// top), prepare lines, and build the visible window slice.
let cursorPos: { row: number; col: number } | null = null;
for (const marker of cursorMarkers) {
for (let i = cursorMarkers.length - 1; i >= 0; i--) {
const marker = cursorMarkers[i]!;
if (marker.row >= windowTop) {
cursorPos = marker;
break;
}
}
const frame = this.#prepareLines(rawFrame, width, true);
const frame = this.#prepareFrame(rawFrame, width);
let window: string[] = new Array(height);
for (let r = 0; r < height; r++) window[r] = frame[windowTop + r] ?? "";
if (hasVisibleOverlay) {
@@ -1848,7 +2049,7 @@ export class TUI extends Container {
if (overlayMarkers.length > 0) {
cursorPos = { row: windowTop + overlayMarkers[0]!.row, col: overlayMarkers[0]!.col };
}
window = this.#prepareLines(window, width, false);
window = this.#prepareLinesArray(window, width);
}
const intent: RenderIntent = fullPaint
@@ -1909,7 +2110,7 @@ export class TUI extends Container {
* restyles keep their alignment and are left alone (stale styling in
* history was always the accepted artifact).
*/
#auditCommittedPrefix(rawFrame: string[]): void {
#auditCommittedPrefix(rawFrame: readonly string[]): void {
const prefix = this.#committedPrefix;
if (prefix.length === 0) return;
const resyncTo = findCommittedPrefixResync(rawFrame, prefix);
@@ -1922,23 +2123,41 @@ export class TUI extends Container {
}
}
#prepareLines(lines: string[], width: number, useCache: boolean): string[] {
const prepared: string[] = new Array(lines.length);
const previous = useCache ? this.#preparedLineCache : [];
const nextCache: PreparedLine[] | undefined = useCache ? new Array(lines.length) : undefined;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]!;
const cached = previous[i];
if (cached && cached.raw === raw && cached.width === width) {
/**
* Prepare the composed frame for emission, in place. Rows below
* `#preparedValidRows` are already prepared against the current frame (the
* compose lowered that floor to the stable prefix); rows at/after it are
* revalidated positionally — a row whose raw content and width match its
* cached entry reuses the prepared line, anything else re-prepares.
*/
#prepareFrame(frame: readonly string[], width: number): string[] {
const prepared = this.#preparedFrame;
const meta = this.#preparedMeta;
if (prepared.length > frame.length) {
prepared.length = frame.length;
meta.length = frame.length;
}
for (let i = Math.min(this.#preparedValidRows, prepared.length); i < frame.length; i++) {
const raw = frame[i]!;
const cached = meta[i];
if (cached !== undefined && cached.raw === raw && cached.width === width) {
prepared[i] = cached.line;
if (nextCache) nextCache[i] = cached;
continue;
}
const entry = this.#prepareLine(raw, width);
meta[i] = entry;
prepared[i] = entry.line;
if (nextCache) nextCache[i] = entry;
}
if (nextCache) this.#preparedLineCache = nextCache;
this.#preparedValidRows = frame.length;
return prepared;
}
/** Stateless variant for overlay-composited windows and alt-screen frames. */
#prepareLinesArray(lines: readonly string[], width: number): string[] {
const prepared: string[] = new Array(lines.length);
for (let i = 0; i < lines.length; i++) {
prepared[i] = this.#prepareLine(lines[i]!, width).line;
}
return prepared;
}
@@ -2117,13 +2336,13 @@ export class TUI extends Container {
* the end so cursor/window accounting stays consistent.
*/
#commit(
lines: string[],
lines: readonly string[],
window: string[],
width: number,
height: number,
hardwareCursor: HardwareCursorUpdate,
): void {
this.#previousLines = lines;
this.#previousFrameLength = lines.length;
this.#previousWindow = window;
this.#forceViewportRepaintOnNextRender = false;
this.#previousWidth = width;
@@ -2194,7 +2413,7 @@ export class TUI extends Container {
* `clearScrollback` initial paint).
*/
#emitFullPaint(
frame: string[],
frame: readonly string[],
window: string[],
width: number,
height: number,
@@ -2271,7 +2490,7 @@ export class TUI extends Container {
const base: string[] = new Array(Math.max(0, height)).fill("");
let lines = this.#compositeOverlaysIntoWindow(base, width, height);
this.#extractCursorMarkers(lines);
lines = this.#prepareLines(lines, width, false);
lines = this.#prepareLinesArray(lines, width);
this.#emitAltFrame(lines, width, height);
}
@@ -2328,7 +2547,7 @@ export class TUI extends Container {
* bottom on several terminal families.
*/
#emitUpdate(
frame: string[],
frame: readonly string[],
window: string[],
width: number,
height: number,
@@ -2501,7 +2720,7 @@ export class TUI extends Container {
const state =
`committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` +
`lrStart=${this.#nativeScrollbackLiveRegionStart}, commitSafeEnd=${this.#nativeScrollbackCommitSafeEnd}`;
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height}, ${state})\n`;
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`;
fs.appendFileSync(getDebugLogPath(), msg);
}
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, it } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { Box, type Component, Container, Text } from "@oh-my-pi/pi-tui";
/**
* Leaf component that returns a stable cached array and counts render calls.
* Used to prove the memo skips rebuilding the concatenation, not the child
* renders themselves (renders carry side effects per the Component contract).
*/
class Probe implements Component {
renderCount = 0;
#lines: string[];
constructor(lines: string[]) {
this.#lines = lines;
}
setLines(lines: string[]): void {
this.#lines = lines;
}
render(_width: number): readonly string[] {
this.renderCount++;
return this.#lines;
}
}
function plain(lines: readonly string[]): string[] {
return lines.map(line => stripVTControlCharacters(line).trimEnd());
}
describe("Container render memoization", () => {
it("returns the identical reference across renders while children are ref-stable", () => {
const container = new Container();
container.addChild(new Text("alpha", 0, 0));
container.addChild(new Text("beta", 0, 0));
const first = container.render(40);
expect(plain(first)).toEqual(["alpha", "beta"]);
expect(container.render(40)).toBe(first);
expect(container.render(40)).toBe(first);
});
it("returns a new reference with updated rows after a child setText", () => {
const container = new Container();
const text = new Text("before", 0, 0);
container.addChild(text);
const before = container.render(40);
text.setText("after");
const after = container.render(40);
expect(after).not.toBe(before);
expect(plain(after)).toEqual(["after"]);
// Stable again at the new content.
expect(container.render(40)).toBe(after);
});
it("drops the memo on addChild", () => {
const container = new Container();
container.addChild(new Text("first", 0, 0));
const before = container.render(40);
container.addChild(new Text("second", 0, 0));
const after = container.render(40);
expect(after).not.toBe(before);
expect(plain(after)).toEqual(["first", "second"]);
});
it("drops the memo on removeChild", () => {
const container = new Container();
const keep = new Text("keep", 0, 0);
const drop = new Text("drop", 0, 0);
container.addChild(keep);
container.addChild(drop);
const before = container.render(40);
container.removeChild(drop);
const after = container.render(40);
expect(after).not.toBe(before);
expect(plain(after)).toEqual(["keep"]);
});
it("drops the memo on clear", () => {
const container = new Container();
container.addChild(new Text("gone", 0, 0));
const before = container.render(40);
container.clear();
const after = container.render(40);
expect(after).not.toBe(before);
expect(after.length).toBe(0);
});
it("drops the memo on invalidate even when content is unchanged", () => {
const container = new Container();
container.addChild(new Text("same", 0, 0));
const before = container.render(40);
container.invalidate();
const after = container.render(40);
expect(after).not.toBe(before);
expect(plain(after)).toEqual(plain(before));
});
it("still renders every child on every call when the memo hits", () => {
const container = new Container();
const a = new Probe(["probe-a"]);
const b = new Probe(["probe-b"]);
container.addChild(a);
container.addChild(b);
const first = container.render(40);
const second = container.render(40);
const third = container.render(40);
// Memo hit: identical reference…
expect(second).toBe(first);
expect(third).toBe(first);
// …but children were rendered each frame regardless.
expect(a.renderCount).toBe(3);
expect(b.renderCount).toBe(3);
});
it("misses the memo on width change", () => {
const container = new Container();
container.addChild(new Probe(["constant-row"]));
const narrow = container.render(40);
const wide = container.render(60);
expect(wide).not.toBe(narrow);
// Stable at the new width.
expect(container.render(60)).toBe(wide);
});
});
describe("Box render memoization", () => {
it("returns the identical reference across renders at a fixed width", () => {
const box = new Box(1, 1);
box.addChild(new Text("content", 0, 0));
const first = box.render(40);
expect(plain(first)).toEqual(["", " content", ""]);
expect(box.render(40)).toBe(first);
});
it("returns a new reference with updated rows after a child change", () => {
const box = new Box(1, 0);
const text = new Text("old", 0, 0);
box.addChild(text);
const before = box.render(40);
text.setText("new");
const after = box.render(40);
expect(after).not.toBe(before);
expect(plain(after)).toEqual([" new"]);
expect(box.render(40)).toBe(after);
});
it("misses the cache when the bgFn output changes without the function reference changing", () => {
let tag = "A";
const box = new Box(0, 0, text => `<${tag}>${text}</${tag}>`);
box.addChild(new Probe(["row"]));
const first = box.render(10);
expect(first[0]).toBe("<A>row </A>");
// Same closure state → cache hit.
expect(box.render(10)).toBe(first);
// Mutate the closure: same function reference, different output. The
// bg sample in the cache key must force a rebuild.
tag = "B";
const second = box.render(10);
expect(second).not.toBe(first);
expect(second[0]).toBe("<B>row </B>");
});
});
+2 -2
View File
@@ -235,8 +235,8 @@ describe("Image budget integration", () => {
// First pass lets the budget notice the overflow; the second applies the
// demotion (older image is observed first, so it is demoted first).
let olderLines: string[] = [];
let newerLines: string[] = [];
let olderLines: readonly string[] = [];
let newerLines: readonly string[] = [];
for (let i = 0; i < 2; i++) {
budget.beginPass();
olderLines = older.render(20);
+44 -42
View File
@@ -1241,27 +1241,22 @@ describe("Module-level LRU render cache", () => {
expect(lines2).toEqual(lines1);
});
it("returns caller-owned arrays from L1 and L2 cache hits", () => {
it("returns the same array reference from L1 and L2 cache hits", () => {
clearRenderCache();
const text = "Cache mutability sentinel";
const text = "Cache identity sentinel";
const width = 80;
const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme);
// L1: same instance, same text, same width → exact same reference.
// Reference identity is load-bearing: parents memoize their
// concatenation on it (Container/TUI skip work for stable refs).
const first = markdown.render(width);
const expected = [...first];
first.push("mutated first render");
const l1Hit = markdown.render(width);
expect(l1Hit).toEqual(expected);
l1Hit.push("mutated L1 hit");
expect(markdown.render(width)).toEqual(expected);
expect(markdown.render(width)).toBe(first);
// L2: a distinct instance with identical inputs shares the module-level
// cache entry — same reference, not just equal content.
const l2Markdown = new Markdown(text, 0, 0, defaultMarkdownTheme);
const l2Hit = l2Markdown.render(width);
expect(l2Hit).toEqual(expected);
l2Hit.push("mutated L2 hit");
expect(l2Markdown.render(width)).toEqual(expected);
expect(new Markdown(text, 0, 0, defaultMarkdownTheme).render(width)).toEqual(expected);
expect(l2Markdown.render(width)).toBe(first);
});
});
@@ -1332,42 +1327,49 @@ describe("OSC 66 text-sizing headings", () => {
});
});
describe("Markdown.render cache ownership", () => {
// Regression: the ask tool renderer did `md(question).push(...optionLines)`,
// mutating Markdown's cached array in place. render() handed out the live L1
// (per-instance) and L2 (module-level, shared across instances) cache arrays,
// so every redraw re-pushed onto the same growing array (+N lines/frame). That
// inflated the chat block unboundedly and cascaded into native-scrollback
// duplication. render() must return a caller-owned copy so push/splice can
// never poison the cache or a future render.
describe("Markdown.render reference stability", () => {
// History: render() used to return caller-owned copies because the ask tool
// renderer did `md(question).push(...optionLines)` and grew the shared cache
// array every frame. The contract is now the opposite — render() hands out
// the live cached array by reference (parents memoize on reference identity)
// and callers that decorate results must copy first; ask.ts was fixed to
// copy. These tests pin the reference-identity contract.
afterEach(() => clearRenderCache());
it("does not let a caller's mutation grow the next render (per-instance cache)", () => {
it("returns the identical reference for repeated renders of an unchanged instance", () => {
const md = new Markdown("Question text", 1, 0, defaultMarkdownTheme);
const baseline = md.render(40).length;
md.render(40).push("INJECTED-A", "INJECTED-B");
const after = md.render(40);
expect(after.length).toBe(baseline);
expect(after.some(line => line.includes("INJECTED"))).toBe(false);
const first = md.render(40);
expect(md.render(40)).toBe(first);
expect(md.render(40)).toBe(first);
});
it("does not let one instance's mutation leak into another via the shared L2 cache", () => {
it("shares one array across instances with identical inputs via the L2 cache", () => {
const a = new Markdown("Shared markdown body", 1, 0, defaultMarkdownTheme);
const b = new Markdown("Shared markdown body", 1, 0, defaultMarkdownTheme);
const baseline = b.render(40).length;
// `a` populates L2; mutating its result must not corrupt the entry `b` reads.
a.render(40).push("LEAKED-1", "LEAKED-2", "LEAKED-3");
const fromB = b.render(40);
expect(fromB.length).toBe(baseline);
expect(fromB.some(line => line.includes("LEAKED"))).toBe(false);
expect(b.render(40)).toBe(a.render(40));
});
it("stays stable across many mutate-then-render cycles (no accumulation)", () => {
const md = new Markdown("Pick one", 1, 0, defaultMarkdownTheme);
const baseline = md.render(40).length;
for (let i = 0; i < 25; i++) {
md.render(40).push(`OPT-${i}`);
}
expect(md.render(40).length).toBe(baseline);
it("returns a new reference with updated content after setText", () => {
const md = new Markdown("Before edit", 1, 0, defaultMarkdownTheme);
const before = md.render(40);
expect(before.some(line => stripVTControlCharacters(line).includes("Before edit"))).toBe(true);
md.setText("After edit");
const after = md.render(40);
expect(after).not.toBe(before);
expect(after.some(line => stripVTControlCharacters(line).includes("After edit"))).toBe(true);
expect(after.some(line => stripVTControlCharacters(line).includes("Before edit"))).toBe(false);
// Re-render after the change is stable again at the new reference.
expect(md.render(40)).toBe(after);
});
it("returns a different reference per width, each with correctly fitted rows", () => {
const md = new Markdown("Width sentinel content", 1, 0, defaultMarkdownTheme);
const narrow = md.render(30);
const wide = md.render(60);
expect(wide).not.toBe(narrow);
expect(narrow.every(line => visibleWidth(line) <= 30)).toBe(true);
expect(wide.every(line => visibleWidth(line) <= 60)).toBe(true);
});
});
@@ -0,0 +1,180 @@
import { describe, expect, it } from "bun:test";
import { type Component, CURSOR_MARKER, type RenderStablePrefix, TUI } from "@oh-my-pi/pi-tui";
import { StressRenderScheduler } from "./render-stress-scheduler";
import { VirtualTerminal } from "./virtual-terminal";
// Behavioral tests for the RenderStablePrefix engine seam: a component that
// mutates its returned render array in place (instead of returning a fresh
// array per change) reports how many leading rows survived since the last
// read. The engine trusts that report — it skips marker extraction, line
// preparation, and the committed-prefix audit for those rows — so the report
// must be both honored (stable rows are not re-emitted into history) and
// consumed (re-ingestion repaints everything at/after the reported floor).
/**
* In-place mutator implementing the consumable-floor contract: render()
* always returns the SAME persistent array, `append` grows it at the bottom,
* `mutate` rewrites an interior row and lowers the accumulated floor to it.
* Reading the report re-bases the baseline to the current array state.
*/
class StableList implements Component, RenderStablePrefix {
#lines: string[] = [];
#stableFloor = 0;
invalidate(): void {}
append(...rows: string[]): void {
this.#lines.push(...rows);
}
mutate(index: number, row: string): void {
this.#lines[index] = row;
this.#stableFloor = Math.min(this.#stableFloor, index);
}
render(_width: number): readonly string[] {
return this.#lines;
}
getRenderStablePrefixRows(): number {
const value = this.#stableFloor;
this.#stableFloor = this.#lines.length;
return value;
}
}
/** Ref-stable bottom component: a fresh array per change, cached otherwise. */
class PromptLine implements Component {
#lines: string[];
constructor(lines: string[]) {
this.#lines = lines;
}
invalidate(): void {}
set(lines: string[]): void {
this.#lines = lines;
}
render(_width: number): readonly string[] {
return this.#lines;
}
}
function strip(rows: string[]): string[] {
return rows.map(row => Bun.stripANSI(row).trimEnd());
}
describe("RenderStablePrefix engine contract", () => {
it("emits appended rows exactly once and in order while the stable prefix is honored", async () => {
const term = new VirtualTerminal(80, 8, 10_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const list = new StableList();
tui.addChild(list);
const markers = Array.from({ length: 36 }, (_unused, i) => `ROW-${String(i).padStart(3, "0")}`);
try {
tui.start();
await scheduler.drain(term);
// Grow the persistent array in place across several frames. Each
// chunk overflows the 8-row viewport a bit more, so committed rows
// must scroll into history exactly once while the live tail keeps
// repainting.
for (let chunk = 6; chunk <= markers.length; chunk += 6) {
list.append(...markers.slice(chunk - 6, chunk));
tui.requestRender();
await scheduler.drain(term);
}
// History + active grid together must contain every appended row
// exactly once: committed rows are not re-emitted, no row is lost.
const buffer = strip(term.getScrollBuffer()).join("\n");
const missing = markers.filter(mark => buffer.split(mark).length - 1 === 0);
const duplicated = markers.filter(mark => buffer.split(mark).length - 1 > 1);
expect(missing).toEqual([]);
expect(duplicated).toEqual([]);
// And in original append order.
expect(buffer.match(/ROW-\d{3}/g) ?? []).toEqual(markers);
} finally {
tui.stop();
await term.flush();
}
});
it("repaints an interior row mutated in place when the report lowers the floor", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const list = new StableList();
list.append("alpha", "beta", "gamma", "delta");
tui.addChild(list);
try {
tui.start();
await scheduler.drain(term);
// A second unchanged frame so the engine has consumed a full-length
// report and trusts the prefix.
tui.requestRender();
await scheduler.drain(term);
let viewport = strip(term.getViewport()).filter(row => row.length > 0);
expect(viewport).toEqual(["alpha", "beta", "gamma", "delta"]);
// Rewrite row 1 in place: same array reference, lowered floor.
list.mutate(1, "beta-edited");
tui.requestRender();
await scheduler.drain(term);
viewport = strip(term.getViewport()).filter(row => row.length > 0);
expect(viewport).toEqual(["alpha", "beta-edited", "gamma", "delta"]);
} finally {
tui.stop();
await term.flush();
}
});
it("honors the cursor marker of a changing bottom component below a stable prefix", async () => {
const term = new VirtualTerminal(40, 6, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, true, { renderScheduler: scheduler });
const head = new StableList();
head.append("head-0", "head-1", "head-2");
const prompt = new PromptLine([`> abc${CURSOR_MARKER}`]);
tui.addChild(head);
tui.addChild(prompt);
try {
tui.start();
await scheduler.drain(term);
expect(term.getCursor()).toEqual({ row: 3, col: 5 });
// Only the bottom component changes; the head's rows ride the
// stable prefix (their marker scan is skipped), yet the bottom's
// marker must still be extracted and honored each frame.
prompt.set([`> abcd${CURSOR_MARKER}`]);
tui.requestRender();
await scheduler.drain(term);
expect(strip(term.getViewport()).filter(row => row.length > 0)).toEqual([
"head-0",
"head-1",
"head-2",
"> abcd",
]);
expect(term.getCursor()).toEqual({ row: 3, col: 6 });
prompt.set([`> ab${CURSOR_MARKER}cd`]);
tui.requestRender();
await scheduler.drain(term);
expect(term.getCursor()).toEqual({ row: 3, col: 4 });
} finally {
tui.stop();
await term.flush();
}
});
});
+1 -1
View File
@@ -1193,7 +1193,7 @@ class StressDriver {
this.#tui = new TUI(this.#term, true, { renderScheduler: this.#scheduler });
this.#tui.addChild(this.#component);
const realRender = this.#tui.render.bind(this.#tui);
(this.#tui as { render: (width: number) => string[] }).render = (width: number) => {
(this.#tui as { render: (width: number) => readonly string[] }).render = (width: number) => {
const lines = realRender(width);
this.#shadowFrameGeometryChanged =
this.#shadowResizePending ||