From 61a57c1d21f67f834de24c1f85db3055b16c2ec3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 10 Jun 2026 07:24:45 +0200 Subject: [PATCH] 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. --- docs/tui.md | 8 +- packages/coding-agent/CHANGELOG.md | 5 + .../coding-agent/examples/extensions/tools.ts | 9 +- .../src/autoresearch/dashboard.ts | 2 +- packages/coding-agent/src/cli/gallery-cli.ts | 2 +- .../src/cli/gallery-fixtures/fs.ts | 2 +- .../src/cli/gallery-fixtures/types.ts | 6 +- .../coding-agent/src/commit/agentic/agent.ts | 2 +- packages/coding-agent/src/debug/log-viewer.ts | 2 +- packages/coding-agent/src/debug/raw-sse.ts | 2 +- packages/coding-agent/src/edit/renderer.ts | 43 ++- packages/coding-agent/src/lsp/render.ts | 2 +- .../src/modes/components/agent-dashboard.ts | 17 +- .../src/modes/components/bash-execution.ts | 2 +- .../src/modes/components/copy-selector.ts | 2 +- .../coding-agent/src/modes/components/diff.ts | 8 +- .../src/modes/components/dynamic-border.ts | 15 +- .../extensions/extension-dashboard.ts | 13 +- .../components/extensions/extension-list.ts | 2 +- .../components/extensions/inspector-panel.ts | 2 +- .../src/modes/components/footer.ts | 2 +- .../src/modes/components/history-search.ts | 2 +- .../src/modes/components/hook-selector.ts | 4 +- .../modes/components/plan-review-overlay.ts | 2 +- .../components/session-observer-overlay.ts | 4 +- .../src/modes/components/session-selector.ts | 2 +- .../modes/components/status-line/component.ts | 2 +- .../tiny-title-download-progress.ts | 2 +- .../modes/components/transcript-container.ts | 266 ++++++++++++--- .../src/modes/components/tree-selector.ts | 6 +- .../modes/components/user-message-selector.ts | 2 +- .../src/modes/components/user-message.ts | 22 +- .../src/modes/components/visual-truncate.ts | 2 +- .../src/modes/controllers/event-controller.ts | 20 ++ .../controllers/mcp-command-controller.ts | 2 +- .../src/modes/setup-wizard/scenes/glyph.ts | 2 +- .../modes/setup-wizard/scenes/providers.ts | 2 +- .../src/modes/setup-wizard/scenes/sign-in.ts | 2 +- .../src/modes/setup-wizard/scenes/theme.ts | 2 +- .../src/modes/setup-wizard/scenes/types.ts | 2 +- .../modes/setup-wizard/scenes/web-search.ts | 2 +- .../src/modes/setup-wizard/wizard-overlay.ts | 2 +- packages/coding-agent/src/task/render.ts | 4 +- packages/coding-agent/src/tools/ask.ts | 29 +- .../src/tools/bash-interactive.ts | 2 +- packages/coding-agent/src/tools/bash.ts | 4 +- .../coding-agent/src/tools/browser/render.ts | 4 +- packages/coding-agent/src/tools/debug.ts | 2 +- .../coding-agent/src/tools/eval-render.ts | 10 +- packages/coding-agent/src/tools/job.ts | 2 +- .../coding-agent/src/tools/render-utils.ts | 2 +- packages/coding-agent/src/tools/resolve.ts | 2 +- packages/coding-agent/src/tools/ssh.ts | 4 +- packages/coding-agent/src/tools/write.ts | 32 +- packages/coding-agent/src/tui/output-block.ts | 8 +- .../coding-agent/src/web/search/render.ts | 14 +- .../session-selector-viewport.test.ts | 2 +- .../components/transcript-container.test.ts | 99 +++++- ...event-controller-toolcall-finalize.test.ts | 114 +++++++ .../test/streaming-preview-height.test.ts | 4 +- .../test/task/task-progress-render.test.ts | 2 +- .../test/tool-live-region-scrollback.test.ts | 238 ++++++++++--- packages/coding-agent/test/tools/ask.test.ts | 36 ++ .../test/tools/memory-renderer.test.ts | 2 +- .../test/welcome-fixed-height.test.ts | 8 +- .../write-streaming-preview-expand.test.ts | 2 +- packages/tui/CHANGELOG.md | 1 + packages/tui/README.md | 10 +- packages/tui/src/components/box.ts | 112 +++--- packages/tui/src/components/editor.ts | 2 +- packages/tui/src/components/image.ts | 2 +- packages/tui/src/components/input.ts | 2 +- packages/tui/src/components/loader.ts | 2 +- packages/tui/src/components/markdown.ts | 25 +- packages/tui/src/components/scroll-view.ts | 2 +- packages/tui/src/components/select-list.ts | 2 +- packages/tui/src/components/settings-list.ts | 2 +- packages/tui/src/components/spacer.ts | 14 +- packages/tui/src/components/tab-bar.ts | 2 +- packages/tui/src/components/text.ts | 2 +- packages/tui/src/components/truncated-text.ts | 12 +- packages/tui/src/tui.ts | 319 +++++++++++++++--- packages/tui/test/container-memo.test.ts | 182 ++++++++++ packages/tui/test/image-budget.test.ts | 4 +- packages/tui/test/markdown.test.ts | 86 ++--- .../tui/test/render-stable-prefix.test.ts | 180 ++++++++++ packages/tui/test/render-stress-harness.ts | 2 +- 87 files changed, 1671 insertions(+), 420 deletions(-) create mode 100644 packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts create mode 100644 packages/tui/test/container-memo.test.ts create mode 100644 packages/tui/test/render-stable-prefix.test.ts diff --git a/docs/tui.md b/docs/tui.md index cba829fbe..479100e2c 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -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)); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0ae5a536c..d27534f10 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/examples/extensions/tools.ts b/packages/coding-agent/examples/extensions/tools.ts index 0856d0702..178e1fc5e 100644 --- a/packages/coding-agent/examples/extensions/tools.ts +++ b/packages/coding-agent/examples/extensions/tools.ts @@ -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() { diff --git a/packages/coding-agent/src/autoresearch/dashboard.ts b/packages/coding-agent/src/autoresearch/dashboard.ts index 4ea76e0a7..7467e4cc0 100644 --- a/packages/coding-agent/src/autoresearch/dashboard.ts +++ b/packages/coding-agent/src/autoresearch/dashboard.ts @@ -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); diff --git a/packages/coding-agent/src/cli/gallery-cli.ts b/packages/coding-agent/src/cli/gallery-cli.ts index ffa19592f..7f4eb8c67 100644 --- a/packages/coding-agent/src/cli/gallery-cli.ts +++ b/packages/coding-agent/src/cli/gallery-cli.ts @@ -104,7 +104,7 @@ export async function renderGalleryState( state: GalleryState, width: number, expanded = false, -): Promise { +): Promise { if (fixture.renderState) { return await fixture.renderState(state, width, expanded); } diff --git a/packages/coding-agent/src/cli/gallery-fixtures/fs.ts b/packages/coding-agent/src/cli/gallery-fixtures/fs.ts index cc217011e..d62389b59 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/fs.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/fs.ts @@ -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); diff --git a/packages/coding-agent/src/cli/gallery-fixtures/types.ts b/packages/coding-agent/src/cli/gallery-fixtures/types.ts index de19d2745..da4b9b2e4 100644 --- a/packages/coding-agent/src/cli/gallery-fixtures/types.ts +++ b/packages/coding-agent/src/cli/gallery-fixtures/types.ts @@ -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; + renderState?: ( + state: GalleryFixtureState, + width: number, + expanded: boolean, + ) => readonly string[] | Promise; /** * Set for tools whose real `AgentTool` attaches `renderCall`/`renderResult` * directly on the instance (e.g. `task`). The harness then attaches diff --git a/packages/coding-agent/src/commit/agentic/agent.ts b/packages/coding-agent/src/commit/agentic/agent.ts index 36907d959..ca76ca3ca 100644 --- a/packages/coding-agent/src/commit/agentic/agent.ts +++ b/packages/coding-agent/src/commit/agentic/agent.ts @@ -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); diff --git a/packages/coding-agent/src/debug/log-viewer.ts b/packages/coding-agent/src/debug/log-viewer.ts index 43a6f2db0..eb612a31c 100644 --- a/packages/coding-agent/src/debug/log-viewer.ts +++ b/packages/coding-agent/src/debug/log-viewer.ts @@ -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(); diff --git a/packages/coding-agent/src/debug/raw-sse.ts b/packages/coding-agent/src/debug/raw-sse.ts index 3be286152..b5c406a77 100644 --- a/packages/coding-agent/src/debug/raw-sse.ts +++ b/packages/coding-agent/src/debug/raw-sse.ts @@ -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(); diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 275bbb612..e300ee913 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -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)))}`; } diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index 82fcb30b4..746b7d444 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -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; diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index c4496ac55..1a9ad6c1a 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -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 { diff --git a/packages/coding-agent/src/modes/components/bash-execution.ts b/packages/coding-agent/src/modes/components/bash-execution.ts index 2427e5507..2d5ac236a 100644 --- a/packages/coding-agent/src/modes/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/components/bash-execution.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/components/copy-selector.ts b/packages/coding-agent/src/modes/components/copy-selector.ts index ebc1f64d3..02fe40e0b 100644 --- a/packages/coding-agent/src/modes/components/copy-selector.ts +++ b/packages/coding-agent/src/modes/components/copy-selector.ts @@ -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( diff --git a/packages/coding-agent/src/modes/components/diff.ts b/packages/coding-agent/src/modes/components/diff.ts index 3248bc571..33d1c161f 100644 --- a/packages/coding-agent/src/modes/components/diff.ts +++ b/packages/coding-agent/src/modes/components/diff.ts @@ -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 diff --git a/packages/coding-agent/src/modes/components/dynamic-border.ts b/packages/coding-agent/src/modes/components/dynamic-border.ts index f61fc46ee..17dd0adf0 100644 --- a/packages/coding-agent/src/modes/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/components/dynamic-border.ts @@ -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; } } diff --git a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts index 9665e60c3..0b94d6018 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts @@ -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); diff --git a/packages/coding-agent/src/modes/components/extensions/extension-list.ts b/packages/coding-agent/src/modes/components/extensions/extension-list.ts index f813ff191..5d5260101 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-list.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-list.ts @@ -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 diff --git a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts index 56059e29d..1f9a2c509 100644 --- a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts +++ b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts @@ -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")]; } diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index 424f2303a..c9e2f9619 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -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) diff --git a/packages/coding-agent/src/modes/components/history-search.ts b/packages/coding-agent/src/modes/components/history-search.ts index feb8de9bb..a75768c04 100644 --- a/packages/coding-agent/src/modes/components/history-search.ts +++ b/packages/coding-agent/src/modes/components/history-search.ts @@ -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) { diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 6d2ded83f..1baf873a6 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/components/plan-review-overlay.ts b/packages/coding-agent/src/modes/components/plan-review-overlay.ts index dd692d7c8..c22022bd9 100644 --- a/packages/coding-agent/src/modes/components/plan-review-overlay.ts +++ b/packages/coding-agent/src/modes/components/plan-review-overlay.ts @@ -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; diff --git a/packages/coding-agent/src/modes/components/session-observer-overlay.ts b/packages/coding-agent/src/modes/components/session-observer-overlay.ts index c484cf593..73ab1d185 100644 --- a/packages/coding-agent/src/modes/components/session-observer-overlay.ts +++ b/packages/coding-agent/src/modes/components/session-observer-overlay.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index ce1fd0908..74e57f814 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -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 diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 63e34d120..434a9d523 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -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) { diff --git a/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts b/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts index 61e899493..4a5683182 100644 --- a/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts +++ b/packages/coding-agent/src/modes/components/tiny-title-download-progress.ts @@ -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)); diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 2cf439787..7e05f2974 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -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; } } diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index 362ad2308..011364ddb 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/components/user-message-selector.ts b/packages/coding-agent/src/modes/components/user-message-selector.ts index a1845eb7c..9de367c6b 100644 --- a/packages/coding-agent/src/modes/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/components/user-message-selector.ts @@ -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) { diff --git a/packages/coding-agent/src/modes/components/user-message.ts b/packages/coding-agent/src/modes/components/user-message.ts index dc2614cf4..c1178bde2 100644 --- a/packages/coding-agent/src/modes/components/user-message.ts +++ b/packages/coding-agent/src/modes/components/user-message.ts @@ -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; } } diff --git a/packages/coding-agent/src/modes/components/visual-truncate.ts b/packages/coding-agent/src/modes/components/visual-truncate.ts index 9c95b748c..65b768916 100644 --- a/packages/coding-agent/src/modes/components/visual-truncate.ts +++ b/packages/coding-agent/src/modes/components/visual-truncate.ts @@ -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; } diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index edddc463d..d9a48f3fb 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -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") { diff --git a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts index 08f933cdd..bf0a5e79c 100644 --- a/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/mcp-command-controller.ts @@ -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:")}`, diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/glyph.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/glyph.ts index 1a728bfa0..942902397 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/glyph.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/glyph.ts @@ -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."), "", diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/providers.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/providers.ts index 2a387e91a..14c55236f 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/providers.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/providers.ts @@ -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)]; } diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts index df054f18c..80f4b9c42 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts @@ -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}`)); diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/theme.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/theme.ts index f512252c7..a45c1a85a 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/theme.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/theme.ts @@ -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" diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/types.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/types.ts index 97633287a..0ea010437 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/types.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/types.ts @@ -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). */ diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts index 221da8b4a..d70fd7bf4 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts @@ -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."), "", diff --git a/packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts b/packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts index 0d39020de..230625bb3 100644 --- a/packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts +++ b/packages/coding-agent/src/modes/setup-wizard/wizard-overlay.ts @@ -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[]; diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 2725f868f..022b7245c 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -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)); diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 96a3853b6..411d0a03a 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -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")); diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index b6186a80b..fc34c7296 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -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)); diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index a200018fe..dfd39bbcd 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1163,7 +1163,7 @@ export function createShellRenderer(config: ShellRendererConfig) { : 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(config: ShellRendererConfig) { 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; diff --git a/packages/coding-agent/src/tools/browser/render.ts b/packages/coding-agent/src/tools/browser/render.ts index b2172d486..6014e3288 100644 --- a/packages/coding-agent/src/tools/browser/render.ts +++ b/packages/coding-agent/src/tools/browser/render.ts @@ -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() diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index 6dcf2b9b2..c0107848c 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -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 diff --git a/packages/coding-agent/src/tools/eval-render.ts b/packages/coding-agent/src/tools/eval-render.ts index adc8288ab..df581955b 100644 --- a/packages/coding-agent/src/tools/eval-render.ts +++ b/packages/coding-agent/src/tools/eval-render.ts @@ -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); diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index 070d17710..61760e7e9 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -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(); diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index f34500c61..166b71db3 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -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; diff --git a/packages/coding-agent/src/tools/resolve.ts b/packages/coding-agent/src/tools/resolve.ts index 4caf26626..751375f06 100644 --- a/packages/coding-agent/src/tools/resolve.ts +++ b/packages/coding-agent/src/tools/resolve.ts @@ -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 => { diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 80dc8ae1a..ed3608c99 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -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. diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 5645a0126..cf51c1712 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -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(); diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index 18c90b861..c08b0792e 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -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(), }); } diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index 5c3f4e5a9..aec5b3f90 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -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; } diff --git a/packages/coding-agent/test/modes/components/session-selector-viewport.test.ts b/packages/coding-agent/test/modes/components/session-selector-viewport.test.ts index 5780f10d5..28465656c 100644 --- a/packages/coding-agent/test/modes/components/session-selector-viewport.test.ts +++ b/packages/coding-agent/test/modes/components/session-selector-viewport.test.ts @@ -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; } diff --git a/packages/coding-agent/test/modes/components/transcript-container.test.ts b/packages/coding-agent/test/modes/components/transcript-container.test.ts index 8ac9b507c..f0e1cd5db 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -85,7 +85,7 @@ function makeAssistantMessage(overrides: Partial = {}): 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); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts new file mode 100644 index 000000000..31e908679 --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts @@ -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; + 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(); + }); +}); diff --git a/packages/coding-agent/test/streaming-preview-height.test.ts b/packages/coding-agent/test/streaming-preview-height.test.ts index 009b08a37..a01905e7f 100644 --- a/packages/coding-agent/test/streaming-preview-height.test.ts +++ b/packages/coding-agent/test/streaming-preview-height.test.ts @@ -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()); diff --git a/packages/coding-agent/test/task/task-progress-render.test.ts b/packages/coding-agent/test/task/task-progress-render.test.ts index f8df9b809..d66326073 100644 --- a/packages/coding-agent/test/task/task-progress-render.test.ts +++ b/packages/coding-agent/test/task/task-progress-render.test.ts @@ -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") diff --git a/packages/coding-agent/test/tool-live-region-scrollback.test.ts b/packages/coding-agent/test/tool-live-region-scrollback.test.ts index d4e3397e4..d94b5691b 100644 --- a/packages/coding-agent/test/tool-live-region-scrollback.test.ts +++ b/packages/coding-agent/test/tool-live-region-scrollback.test.ts @@ -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; diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index 475c4fb61..9f7424678 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -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(); diff --git a/packages/coding-agent/test/tools/memory-renderer.test.ts b/packages/coding-agent/test/tools/memory-renderer.test.ts index 7614a5fef..46758ad73 100644 --- a/packages/coding-agent/test/tools/memory-renderer.test.ts +++ b/packages/coding-agent/test/tools/memory-renderer.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/welcome-fixed-height.test.ts b/packages/coding-agent/test/welcome-fixed-height.test.ts index 605155f24..228da228d 100644 --- a/packages/coding-agent/test/welcome-fixed-height.test.ts +++ b/packages/coding-agent/test/welcome-fixed-height.test.ts @@ -39,7 +39,13 @@ describe("WelcomeComponent fixed geometry", () => { const heights = new Set(); 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); } } diff --git a/packages/coding-agent/test/write-streaming-preview-expand.test.ts b/packages/coding-agent/test/write-streaming-preview-expand.test.ts index c8b0fa5ec..d5b067aff 100644 --- a/packages/coding-agent/test/write-streaming-preview-expand.test.ts +++ b/packages/coding-agent/test/write-streaming-preview-expand.test.ts @@ -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", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3ae30a7b1..700a355f8 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/README.md b/packages/tui/README.md index a7c2812c3..d38b0e20b 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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; } diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index ec3ae61e6..cb42a5c72 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -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; } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 161555cb3..35a5e40fb 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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); diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index ac88e7629..7c78e6a25 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -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 diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 8eb8f3f31..d4cba45a5 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -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; diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 399c99b59..aa7b68682 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -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]; diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 15081426b..0831c2d04 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -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; diff --git a/packages/tui/src/components/scroll-view.ts b/packages/tui/src/components/scroll-view.ts index 1bb5de9bd..62fad4d3f 100644 --- a/packages/tui/src/components/scroll-view.ts +++ b/packages/tui/src/components/scroll-view.ts @@ -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 []; diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index f4407bb53..ab0ab7e01 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -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(); diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 4b1e61175..6ab745373 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -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); diff --git a/packages/tui/src/components/spacer.ts b/packages/tui/src/components/spacer.ts index 79cc01f2a..238a19b25 100644 --- a/packages/tui/src/components/spacer.ts +++ b/packages/tui/src/components/spacer.ts @@ -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; } } diff --git a/packages/tui/src/components/tab-bar.ts b/packages/tui/src/components/tab-bar.ts index 89f512465..0d236c8f5 100644 --- a/packages/tui/src/components/tab-bar.ts +++ b/packages/tui/src/components/tab-bar.ts @@ -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[] = []; diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 57c0c5b90..3563a7fa0 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -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; diff --git a/packages/tui/src/components/truncated-text.ts b/packages/tui/src/components/truncated-text.ts index 143fb0aec..ab3e7944b 100644 --- a/packages/tui/src/components/truncated-text.ts +++ b/packages/tui/src/components/truncated-text.ts @@ -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; } } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 6a3297445..c564d6cd1 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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).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).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); } diff --git a/packages/tui/test/container-memo.test.ts b/packages/tui/test/container-memo.test.ts new file mode 100644 index 000000000..0363e1dcb --- /dev/null +++ b/packages/tui/test/container-memo.test.ts @@ -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}`); + box.addChild(new Probe(["row"])); + + const first = box.render(10); + expect(first[0]).toBe("row "); + // 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("row "); + }); +}); diff --git a/packages/tui/test/image-budget.test.ts b/packages/tui/test/image-budget.test.ts index cc21a8627..6f27e36a8 100644 --- a/packages/tui/test/image-budget.test.ts +++ b/packages/tui/test/image-budget.test.ts @@ -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); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 2794b45d9..e0ab32f4b 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -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); }); }); diff --git a/packages/tui/test/render-stable-prefix.test.ts b/packages/tui/test/render-stable-prefix.test.ts new file mode 100644 index 000000000..cb842e13a --- /dev/null +++ b/packages/tui/test/render-stable-prefix.test.ts @@ -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(); + } + }); +}); diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 3c7462e29..ef56669c4 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -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 ||