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:
+5
-3
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)))}`;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+114
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user