diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6e144a6d1..d1b91c491 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -11,6 +11,9 @@ ### Fixed - Fixed bordered `Editor` rendering 1–2 cells past the terminal width when the end-of-line cursor glyph landed past a wide trailing grapheme (CJK comma `,`, emoji, etc.), wrapping the bottom-right corner (`╯`) to its own row. The right chrome (padding + `─` + corner) now shrinks by the exact cursor overflow cell count instead of a 1-cell boolean, so the box stays inside `width` for any `paddingX` ([#3431](https://github.com/can1357/oh-my-pi/issues/3431)). +### Fixed + +- Kept queued interrupt keys ahead of ordinary repaints so a slow long-transcript frame cannot consume the Ctrl+C/Esc double-press window before the second key is handled. ## [16.1.17] - 2026-06-24 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ef778fe6e..643bb2bf3 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -924,6 +924,8 @@ export class TUI extends Container { #renderScheduler: RenderScheduler; #lastRenderAt = 0; static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30; + static readonly #INPUT_RENDER_GRACE_MS = TUI.#MIN_RENDER_INTERVAL_MS; + #inputRenderGraceUntilMs = 0; // Pane-reflow settle window for tmux/screen/zellij. The host process gets // SIGWINCH (and `process.stdout` already reports the new geometry) before // the multiplexer finishes repainting the pane at the new size, and @@ -2109,8 +2111,11 @@ export class TUI extends Container { if (this.#multiplexerResizeTimer) { return; } - const elapsed = this.#renderScheduler.now() - this.#lastRenderAt; - const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed); + const now = this.#renderScheduler.now(); + const elapsed = now - this.#lastRenderAt; + const cadenceDelay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed); + const inputGraceDelay = Math.max(0, this.#inputRenderGraceUntilMs - now); + const delay = Math.max(cadenceDelay, inputGraceDelay); this.#renderTimer = this.#renderScheduler.scheduleRender(() => { this.#renderTimer = undefined; if (this.#stopped || !this.#renderRequested) { @@ -2126,6 +2131,12 @@ export class TUI extends Container { } #handleInput(data: string): void { + // Raw-mode Ctrl+C/Esc arrive as stdin data, not process signals. If the + // first key in a double-key gesture schedules an immediate slow repaint, + // the queued second key can sit behind that repaint long enough for the + // app-level double-press window to expire. Give the input queue one frame + // before ordinary paints; forced repaints still bypass this path. + this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS; if (this.#inputListeners.size > 0) { let current = data; for (const listener of this.#inputListeners) { diff --git a/packages/tui/test/input-priority.test.ts b/packages/tui/test/input-priority.test.ts new file mode 100644 index 000000000..1ba28047d --- /dev/null +++ b/packages/tui/test/input-priority.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, setSystemTime, vi } from "bun:test"; +import { type Component, type RenderScheduler, TUI } from "@oh-my-pi/pi-tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +class BlockingDoubleInterruptComponent implements Component { + interruptsHandled = 0; + exitRequests = 0; + #firstInterruptAt = 0; + #blockNextRenderMs = 0; + secondInterruptSeen = false; + slowRenderBeforeSecond = false; + + armSlowRender(blockMs: number): void { + this.#blockNextRenderMs = blockMs; + } + + handleInput(data: string): void { + if (data !== "\x03") return; + this.interruptsHandled++; + if (this.interruptsHandled === 1) { + this.#firstInterruptAt = Date.now(); + return; + } + this.secondInterruptSeen = true; + const now = Date.now(); + if (!this.slowRenderBeforeSecond && this.#firstInterruptAt !== 0 && now - this.#firstInterruptAt < 500) { + this.exitRequests++; + } + this.#firstInterruptAt = 0; + } + + render(_width: number): readonly string[] { + const blockMs = this.#blockNextRenderMs; + this.#blockNextRenderMs = 0; + if (blockMs > 0) { + if (!this.secondInterruptSeen) this.slowRenderBeforeSecond = true; + setSystemTime(new Date(Date.now() + blockMs)); + } + return ["ready"]; + } +} + +async function drainNextTick(): Promise { + const nextTick = Promise.withResolvers(); + process.nextTick(nextTick.resolve); + await nextTick.promise; +} + +function fakeTimerScheduler(): RenderScheduler { + return { + now: () => Date.now(), + scheduleImmediate: callback => { + process.nextTick(callback); + }, + scheduleRender: (callback, delayMs) => { + if (delayMs <= 0) { + let cancelled = false; + process.nextTick(() => { + if (!cancelled) callback(); + }); + return { + cancel: () => { + cancelled = true; + }, + }; + } + const handle = setTimeout(callback, delayMs); + return { + cancel: () => clearTimeout(handle), + }; + }, + }; +} + +describe("TUI input priority", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("handles a queued second Ctrl+C before a slow repaint can consume the double-interrupt window", async () => { + vi.useFakeTimers(); + setSystemTime(new Date(1_000)); + const terminal = new VirtualTerminal(40, 8); + const tui = new TUI(terminal, undefined, { renderScheduler: fakeTimerScheduler() }); + const component = new BlockingDoubleInterruptComponent(); + tui.addChild(component); + tui.setFocus(component); + tui.start(); + await drainNextTick(); + component.armSlowRender(650); + vi.advanceTimersByTime(40); + + terminal.sendInput("\x03"); + setTimeout(() => terminal.sendInput("\x03"), 10); + await drainNextTick(); + vi.advanceTimersByTime(0); + vi.advanceTimersByTime(10); + + tui.stop(); + + expect(component.slowRenderBeforeSecond).toBe(false); + expect(component.interruptsHandled).toBe(2); + expect(component.exitRequests).toBe(1); + }); +});