Merge pull request #3465 from cexll/local-build-pr-3428

fix(tui): keep interrupts ahead of repaint
This commit is contained in:
Can Bölük
2026-06-25 22:41:21 +02:00
committed by GitHub
3 changed files with 121 additions and 2 deletions
+3
View File
@@ -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
+13 -2
View File
@@ -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) {
+105
View File
@@ -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<void> {
const nextTick = Promise.withResolvers<void>();
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);
});
});