diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b4a6a3fc6..b07f44968 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed render scheduling under heavy streaming output by coalescing `requestRender()` calls to a 16ms frame budget while preserving immediate `requestRender(true)` behavior. + ## [0.65.1] - 2026-04-05 ## [0.65.0] - 2026-04-03 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index b8189db3c..0c1fa4d75 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5,6 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { performance } from "node:perf_hooks"; import { isKeyRelease, matchesKey } from "./keys.js"; import type { Terminal } from "./terminal.js"; import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js"; @@ -221,6 +222,9 @@ export class TUI extends Container { /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ public onDebug?: () => void; private renderRequested = false; + private renderTimer: NodeJS.Timeout | undefined; + private lastRenderAt = 0; + private static readonly MIN_RENDER_INTERVAL_MS = 16; private cursorRow = 0; // Logical cursor row (end of rendered content) private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; @@ -442,6 +446,10 @@ export class TUI extends Container { stop(): void { this.stopped = true; + if (this.renderTimer) { + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } // Move cursor to the end of the content to prevent overwriting/artifacts on exit if (this.previousLines.length > 0) { const targetRow = this.previousLines.length; // Line after the last content @@ -467,13 +475,44 @@ export class TUI extends Container { this.hardwareCursorRow = 0; this.maxLinesRendered = 0; this.previousViewportTop = 0; + if (this.renderTimer) { + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } + this.renderRequested = true; + process.nextTick(() => { + if (this.stopped || !this.renderRequested) { + return; + } + this.renderRequested = false; + this.lastRenderAt = performance.now(); + this.doRender(); + }); + return; } if (this.renderRequested) return; this.renderRequested = true; - process.nextTick(() => { + process.nextTick(() => this.scheduleRender()); + } + + private scheduleRender(): void { + if (this.stopped || this.renderTimer || !this.renderRequested) { + return; + } + const elapsed = performance.now() - this.lastRenderAt; + const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed); + this.renderTimer = setTimeout(() => { + this.renderTimer = undefined; + if (this.stopped || !this.renderRequested) { + return; + } this.renderRequested = false; + this.lastRenderAt = performance.now(); this.doRender(); - }); + if (this.renderRequested) { + this.scheduleRender(); + } + }, delay); } private handleInput(data: string): void {