Merge pull request #3465 from cexll/local-build-pr-3428
fix(tui): keep interrupts ahead of repaint
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user