From 29d9f087cc7b9064998f91e97ab5e89e5b67dff7 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 5 Aug 2026 20:41:32 +0200 Subject: [PATCH] fix(tui): avoid throttling keyboard input --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui.ts | 52 ++++++++++++++++------------ packages/tui/test/tui-render.test.ts | 39 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e2c3555df..3ed00de5b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed alternate-screen Kitty images crossing vertical layout clip boundaries and overlapping sticky regions while scrolling. - Fixed alternate-screen redraws retransmitting Kitty image data when placements move or recently offscreen images return, dropping adjacent row content when reusing placements, rendering fixed-basis scroll content twice per frame, and scanning clipped transcript rows while painting. - Fixed fullscreen transcript navigation leaving no editor-accessible `Home`, `End`, `PageUp`, or `PageDown` variants by adding Ctrl-modified editor bindings ([#7574](https://github.com/earendil-works/pi/issues/7574)). +- Fixed keyboard input rendering latency on Windows by letting input preempt the throttled render timer. ### Added diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 374e01bc3..5242fa72b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -337,6 +337,7 @@ export abstract class TuiBase extends Container implements TUI { /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ public onDebug?: () => void; private renderRequested = false; + private immediateRenderScheduled = false; private renderTimer: NodeJS.Timeout | undefined; private lastRenderAt = 0; private static readonly MIN_RENDER_INTERVAL_MS = 16; @@ -743,10 +744,7 @@ export abstract class TuiBase extends Container implements TUI { stop(options: TuiStopOptions = {}): void { this.stopped = true; - if (this.renderTimer) { - clearTimeout(this.renderTimer); - this.renderTimer = undefined; - } + this.cancelRenderTimer(); if (this.terminalColorSchemeNotificationsEnabled) { this.terminal.write("\x1b[?2031l"); } @@ -759,10 +757,7 @@ export abstract class TuiBase extends Container implements TUI { renderNow(force = false): void { if (force) this.resetRenderState(); this.renderRequested = false; - if (this.renderTimer) { - clearTimeout(this.renderTimer); - this.renderTimer = undefined; - } + this.cancelRenderTimer(); this.lastRenderAt = performance.now(); this.doRender(); } @@ -770,19 +765,7 @@ export abstract class TuiBase extends Container implements TUI { requestRender(force = false): void { if (force) { this.resetRenderState(); - 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(); - }); + this.requestImmediateRender(); return; } if (this.renderRequested) return; @@ -790,6 +773,29 @@ export abstract class TuiBase extends Container implements TUI { process.nextTick(() => this.scheduleRender()); } + private requestImmediateRender(): void { + this.cancelRenderTimer(); + this.renderRequested = true; + if (this.immediateRenderScheduled) return; + this.immediateRenderScheduled = true; + process.nextTick(() => { + this.immediateRenderScheduled = false; + if (this.stopped || !this.renderRequested) return; + // A previously queued scheduleRender() can create a timer before this + // callback runs. User input must preempt that throttled frame. + this.cancelRenderTimer(); + this.renderRequested = false; + this.lastRenderAt = performance.now(); + this.doRender(); + }); + } + + private cancelRenderTimer(): void { + if (!this.renderTimer) return; + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } + private scheduleRender(): void { if (this.stopped || this.renderTimer || !this.renderRequested) { return; @@ -882,7 +888,9 @@ export abstract class TuiBase extends Container implements TUI { return; } this.focusedComponent.handleInput(data); - this.requestRender(); + // Keyboard input is latency-sensitive. Avoid the throttled timer path, + // where even setTimeout(0) can take a full 16 ms tick on Windows. + this.requestImmediateRender(); } } diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index ea28c7c96..a6aab959f 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -24,6 +24,19 @@ class TestComponent implements Component { invalidate(): void {} } +class InputComponent extends TestComponent { + renderCount = 0; + + override render(width: number): string[] { + this.renderCount += 1; + return super.render(width); + } + + handleInput(data: string): void { + this.lines = [data]; + } +} + class LoggingVirtualTerminal extends VirtualTerminal { private writes: string[] = []; @@ -75,6 +88,32 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num return cell.isItalic(); } +describe("TUI render scheduling", () => { + it("renders keyboard input without waiting for a throttled frame", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui: TUI = new TuiMainScreen(terminal); + const component = new InputComponent(); + component.lines = ["initial"]; + tui.addChild(component); + tui.setFocus(component); + tui.start(); + tui.renderNow(); + const renderCountBeforeInput = component.renderCount; + + // Queue a normal throttled render first. Keyboard input should preempt it. + component.lines = ["pending"]; + tui.requestRender(); + terminal.sendInput("first"); + terminal.sendInput("second"); + terminal.sendInput("typed"); + await new Promise((resolve) => process.nextTick(resolve)); + + assert.strictEqual(component.renderCount, renderCountBeforeInput + 1); + assert.deepStrictEqual(component.lines, ["typed"]); + tui.stop(); + }); +}); + describe("TUI debug logging", () => { it("writes redraw logs to the provided directory", async () => { const logDir = mkdtempSync(join(tmpdir(), "pi-tui-log-"));