Merge remote-tracking branch 'origin/farm/b7f4bc6d/fix-tmux-resize-viewport-flash'
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
|
||||
- Fixed DEC 2048 in-band resize reports (`CSI 48;rows;cols;hpx;wpx t`) leaking into the focused editor as literal text during a rapid resize. When the window is resized quickly the event loop stays busy long enough for the `StdinBuffer` flush timeout to fire mid-report; the `\x1b[48;…` prefix was emitted as one event and the tail (e.g. `8;125;1156;1125t`) arrived as bare printable characters that the editor inserted. `ProcessTerminal` now reassembles a split in-band report (including a split at the bare `\x1b[4` type field) until its terminator and then drives the resize. A reassembled sequence that turns out not to be a resize report — such as a split kitty key like `\x1b[48;5u` (codepoint 48 = `0`) — is forwarded to the input handler as a single escape sequence rather than dropped or leaked.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Coalesced terminal-multiplexer SIGWINCH events into a single forced render once the pane stops resizing so closing/dragging a tmux/screen/zellij split no longer flashes the viewport blank before the new geometry repaints ([#2088](https://github.com/can1357/oh-my-pi/issues/2088)).
|
||||
|
||||
## [15.10.2] - 2026-06-08
|
||||
### Added
|
||||
|
||||
|
||||
+104
-5
@@ -484,6 +484,16 @@ export class TUI extends Container {
|
||||
#renderScheduler: RenderScheduler;
|
||||
#lastRenderAt = 0;
|
||||
static readonly #MIN_RENDER_INTERVAL_MS = 1000 / 30;
|
||||
// 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
|
||||
// drag-resize/pane-close animations fire several events in flight. A forced
|
||||
// render on each SIGWINCH races those mid-reflow paints — the multiplexer's
|
||||
// catch-up paint then partially overwrites the TUI output, which the user
|
||||
// sees as a viewport flash or blank screen before the next throttled frame
|
||||
// arrives (issue #2088). Coalescing every SIGWINCH inside this window into
|
||||
// a single forced render lets the multiplexer settle first.
|
||||
static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
|
||||
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
||||
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
||||
#hardwareCursorState: HardwareCursorState | null = null;
|
||||
@@ -551,6 +561,13 @@ export class TUI extends Container {
|
||||
// between the viewport and scrollback, so the previous frame no longer
|
||||
// describes the screen. Tracking only the dimension delta misses this.
|
||||
#resizeEventPending = false;
|
||||
// Active multiplexer SIGWINCH debounce. Reset on each event so the timer
|
||||
// only fires once the pane stops resizing. Forced renders (resetDisplay,
|
||||
// finishSixelProbe, …) issued during the settle window route through the
|
||||
// same timer; their `clearScrollback` intent is OR'd into the deferred
|
||||
// flag below so the settled paint still honours every caller's request.
|
||||
#multiplexerResizeTimer: RenderTimer | undefined;
|
||||
#deferredForcedClearScrollback = false;
|
||||
#stopped = false;
|
||||
|
||||
// Transient alternate-screen state for a fullscreen overlay. While active, the
|
||||
@@ -860,12 +877,29 @@ export class TUI extends Container {
|
||||
this.terminal.start(
|
||||
data => this.#handleInput(data),
|
||||
() => {
|
||||
// Repaint immediately rather than via the throttled path: a resize must
|
||||
// clear and replay at the fresh geometry before the terminal's reflow
|
||||
// settles into a state a throttled frame would race. Forced render skips
|
||||
// the 30fps coalescing window, matching resetDisplay()'s prompt repaint.
|
||||
// Real terminals deliver SIGWINCH (and the equivalent ConPTY
|
||||
// notification) atomically with the new `process.stdout` geometry, so
|
||||
// a forced render must fire immediately: it clears and replays at the
|
||||
// fresh size before the terminal's reflow settles into a state a
|
||||
// throttled frame would race. Multiplexer panes (tmux/screen/zellij)
|
||||
// do not give that guarantee. The host receives SIGWINCH while the
|
||||
// multiplexer is still mid-reflow — it has not finished repainting
|
||||
// the pane buffer at the new size — and a drag-resize or pane-close
|
||||
// animation fires several events in flight. Forcing a render on each
|
||||
// event races those mid-reflow paints: the multiplexer's catch-up
|
||||
// paint then partially overwrites the TUI output, which the user sees
|
||||
// as a viewport flash or blank screen before the next throttled
|
||||
// frame arrives (issue #2088). `#armMultiplexerResizeTimer` coalesces
|
||||
// SIGWINCHes (and any forced repaints arriving during the settle
|
||||
// window) into a single render once the pane is quiet —
|
||||
// `#resizeEventPending` is set first so the eventual render still
|
||||
// classifies as a resize.
|
||||
this.#resizeEventPending = true;
|
||||
this.requestRender(true);
|
||||
if (!isMultiplexerSession()) {
|
||||
this.requestRender(true);
|
||||
return;
|
||||
}
|
||||
this.#armMultiplexerResizeTimer(false);
|
||||
},
|
||||
);
|
||||
for (const listener of this.#startListeners) {
|
||||
@@ -1068,6 +1102,11 @@ export class TUI extends Container {
|
||||
this.#renderTimer.cancel();
|
||||
this.#renderTimer = undefined;
|
||||
}
|
||||
if (this.#multiplexerResizeTimer) {
|
||||
this.#multiplexerResizeTimer.cancel();
|
||||
this.#multiplexerResizeTimer = undefined;
|
||||
}
|
||||
this.#deferredForcedClearScrollback = false;
|
||||
// Place the parent shell on the first line after the rendered content. When
|
||||
// that line is still inside the viewport, moving there and writing `\r` is
|
||||
// enough; emitting `\r\n` would create an extra blank row. If the content
|
||||
@@ -1142,6 +1181,15 @@ export class TUI extends Container {
|
||||
resetDisplay(): void {
|
||||
if (this.#stopped) return;
|
||||
this.invalidate();
|
||||
// A reset that lands inside a tmux/screen/zellij resize burst would
|
||||
// paint mid-reflow and re-introduce the flash race (issue #2088).
|
||||
// Fold it into the in-flight debounce instead; the settled paint runs
|
||||
// the same `#prepareForcedRender(!isMultiplexerSession())` path via
|
||||
// `requestRender(true)`, so the clear-scrollback intent is preserved.
|
||||
if (this.#multiplexerResizeTimer) {
|
||||
this.#armMultiplexerResizeTimer(!isMultiplexerSession());
|
||||
return;
|
||||
}
|
||||
this.#prepareForcedRender(!isMultiplexerSession());
|
||||
this.#resizeEventPending = true;
|
||||
this.#renderRequested = false;
|
||||
@@ -1153,6 +1201,19 @@ export class TUI extends Container {
|
||||
const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
|
||||
this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
|
||||
if (force) {
|
||||
// Forced repaints landing inside the multiplexer resize debounce
|
||||
// (e.g. `#finishSixelProbe`, image-budget eviction, a programmatic
|
||||
// `requestRender(true)`) would paint into a still-reflowing pane
|
||||
// and reintroduce the flash race. Fold them into the in-flight
|
||||
// debounce while preserving the caller's `clearScrollback` intent
|
||||
// for the settled paint. The timer's own callback clears
|
||||
// `#multiplexerResizeTimer` before re-entering `requestRender(true)`,
|
||||
// so this guard only catches external callers — the deferred render
|
||||
// itself proceeds straight to `#prepareForcedRender`.
|
||||
if (this.#multiplexerResizeTimer) {
|
||||
this.#armMultiplexerResizeTimer(options?.clearScrollback === true);
|
||||
return;
|
||||
}
|
||||
this.#prepareForcedRender(options?.clearScrollback === true);
|
||||
this.#renderRequested = true;
|
||||
this.#renderScheduler.scheduleImmediate(() => {
|
||||
@@ -1170,6 +1231,37 @@ export class TUI extends Container {
|
||||
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm or extend the multiplexer-resize debounce so a single forced render
|
||||
* fires once the pane is quiet. Called by the SIGWINCH callback on every
|
||||
* resize event, and by `requestRender(true)` / `resetDisplay()` when they
|
||||
* land inside an in-flight settle window. Each call cancels the prior
|
||||
* timer, supersedes any queued throttled render (otherwise it would race
|
||||
* tmux's mid-reflow paint), and OR's the caller's `clearScrollback`
|
||||
* intent into `#deferredForcedClearScrollback` — the timer's callback
|
||||
* consumes that flag exactly once when it re-enters `requestRender(true)`.
|
||||
*/
|
||||
#armMultiplexerResizeTimer(clearScrollback: boolean): void {
|
||||
this.#deferredForcedClearScrollback ||= clearScrollback;
|
||||
if (this.#renderTimer) {
|
||||
this.#renderTimer.cancel();
|
||||
this.#renderTimer = undefined;
|
||||
}
|
||||
this.#renderRequested = false;
|
||||
if (this.#multiplexerResizeTimer) {
|
||||
this.#multiplexerResizeTimer.cancel();
|
||||
}
|
||||
this.#multiplexerResizeTimer = this.#renderScheduler.scheduleRender(() => {
|
||||
this.#multiplexerResizeTimer = undefined;
|
||||
if (this.#stopped) {
|
||||
this.#deferredForcedClearScrollback = false;
|
||||
return;
|
||||
}
|
||||
const deferredClearScrollback = this.#deferredForcedClearScrollback;
|
||||
this.#deferredForcedClearScrollback = false;
|
||||
this.requestRender(true, { clearScrollback: deferredClearScrollback });
|
||||
}, TUI.#MULTIPLEXER_RESIZE_DEBOUNCE_MS);
|
||||
}
|
||||
#prepareForcedRender(clearScrollback: boolean): void {
|
||||
const geometryChanged =
|
||||
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
|
||||
@@ -1193,6 +1285,13 @@ export class TUI extends Container {
|
||||
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
||||
return;
|
||||
}
|
||||
// Defer any new throttled render scheduled inside the multiplexer
|
||||
// resize settle window: it would race tmux's mid-reflow pane repaint.
|
||||
// `#renderRequested` stays set so the eventual forced render — armed
|
||||
// by the SIGWINCH callback — picks up the latest component state.
|
||||
if (this.#multiplexerResizeTimer) {
|
||||
return;
|
||||
}
|
||||
const elapsed = this.#renderScheduler.now() - this.#lastRenderAt;
|
||||
const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
|
||||
this.#renderTimer = this.#renderScheduler.scheduleRender(() => {
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { type Component, TUI } from "@oh-my-pi/pi-tui";
|
||||
import { VirtualTerminal } from "./virtual-terminal";
|
||||
|
||||
// Regression test for https://github.com/can1357/oh-my-pi/issues/2088
|
||||
//
|
||||
// Closing a tmux horizontal split widens the surviving pane. SIGWINCH fires
|
||||
// on the host process before tmux finishes repainting the pane buffer at
|
||||
// the new size, and drag-resize/pane-close animations also fire several
|
||||
// SIGWINCHes in flight. Forcing an immediate render on every event raced
|
||||
// those mid-reflow paints — tmux's catch-up paint then partially overwrote
|
||||
// the TUI output, which the user saw as a viewport flash or blank screen
|
||||
// before the next throttled frame arrived.
|
||||
//
|
||||
// Fix: coalesce SIGWINCHes inside a multiplexer settle window so a single
|
||||
// forced render fires once the pane is quiet. `#resizeEventPending` is set
|
||||
// on every event so the eventual render still classifies as a resize.
|
||||
|
||||
// Pad the production debounce by 30 ms so the test consistently observes the
|
||||
// settled render without re-encoding the constant.
|
||||
const DEBOUNCE_SETTLE_WAIT_MS = 80;
|
||||
|
||||
class MutableLinesComponent implements Component {
|
||||
#lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.#lines = [...lines];
|
||||
}
|
||||
|
||||
setLines(lines: string[]): void {
|
||||
this.#lines = [...lines];
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.#lines.map(line => line.slice(0, width));
|
||||
}
|
||||
}
|
||||
|
||||
async function withEnvPatch<T>(patch: Record<string, string | undefined>, run: () => T | Promise<T>): Promise<T> {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const key in patch) {
|
||||
saved[key] = Bun.env[key];
|
||||
const value = patch[key];
|
||||
if (value === undefined) {
|
||||
delete Bun.env[key];
|
||||
} else {
|
||||
Bun.env[key] = value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
for (const key in saved) {
|
||||
const value = saved[key];
|
||||
if (value === undefined) {
|
||||
delete Bun.env[key];
|
||||
} else {
|
||||
Bun.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settle(term: VirtualTerminal): Promise<void> {
|
||||
const nextTick = Promise.withResolvers<void>();
|
||||
process.nextTick(nextTick.resolve);
|
||||
await nextTick.promise;
|
||||
await Bun.sleep(1);
|
||||
await term.flush();
|
||||
}
|
||||
|
||||
function captureWrites(term: VirtualTerminal): string[] {
|
||||
const writes: string[] = [];
|
||||
const realWrite = term.write.bind(term);
|
||||
vi.spyOn(term, "write").mockImplementation((data: string) => {
|
||||
writes.push(data);
|
||||
realWrite(data);
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
function visible(term: VirtualTerminal): string[] {
|
||||
return term.getViewport().map(line => line.trimEnd());
|
||||
}
|
||||
|
||||
const TMUX_ENV: Record<string, string | undefined> = { TMUX: "1", STY: undefined, ZELLIJ: undefined };
|
||||
const NO_MULTIPLEXER_ENV: Record<string, string | undefined> = { TMUX: undefined, STY: undefined, ZELLIJ: undefined };
|
||||
|
||||
describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
|
||||
let monotonicNow = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
monotonicNow = 0;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => {
|
||||
monotonicNow += 40;
|
||||
return monotonicNow;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("coalesces a burst of multiplexer resize events into a single settled render", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_v, i) => `line-${i}`)));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const baselineRedraws = tui.fullRedraws;
|
||||
const writes = captureWrites(term);
|
||||
|
||||
// Simulate a tmux pane-close animation: several SIGWINCHes arrive
|
||||
// while tmux is still mid-reflow, each carrying an intermediate
|
||||
// width. Only the final width should be painted, and only once.
|
||||
term.resize(60, 10);
|
||||
term.resize(75, 10);
|
||||
term.resize(80, 10);
|
||||
|
||||
// Inside the debounce window: no new paint must have landed yet,
|
||||
// otherwise the TUI would be writing into a pane tmux has not
|
||||
// finished reflowing.
|
||||
await Bun.sleep(10);
|
||||
expect(tui.fullRedraws).toBe(baselineRedraws);
|
||||
expect(writes.length).toBe(0);
|
||||
|
||||
// After the settle window the single coalesced render fires at the
|
||||
// final geometry — exactly one paint covering 80×10.
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
expect(tui.fullRedraws - baselineRedraws).toBe(1);
|
||||
expect(visible(term)).toEqual(Array.from({ length: 10 }, (_v, i) => `line-${i + 10}`));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("renders immediately on resize outside a multiplexer", async () => {
|
||||
await withEnvPatch(NO_MULTIPLEXER_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_v, i) => `line-${i}`)));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const baselineRedraws = tui.fullRedraws;
|
||||
term.resize(80, 10);
|
||||
await settle(term);
|
||||
expect(tui.fullRedraws).toBeGreaterThan(baselineRedraws);
|
||||
expect(visible(term)).toEqual(Array.from({ length: 10 }, (_v, i) => `line-${i + 10}`));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels a pending multiplexer resize timer on stop()", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_v, i) => `line-${i}`)));
|
||||
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const writes = captureWrites(term);
|
||||
term.resize(80, 10);
|
||||
tui.stop();
|
||||
|
||||
// stop() must cancel the pending debounce; no render bytes appear
|
||||
// after the settle window has elapsed, even though the resize was
|
||||
// armed only moments ago.
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
const lateRepaintBytes = writes.filter(chunk => chunk.includes("\x1b[H")).length;
|
||||
expect(lateRepaintBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("supersedes a throttled render queued just before a multiplexer SIGWINCH", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const tui = new TUI(term);
|
||||
const lines = Array.from({ length: 20 }, (_v, i) => `line-${i}`);
|
||||
const component = new MutableLinesComponent(lines);
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const baselineRedraws = tui.fullRedraws;
|
||||
const writes = captureWrites(term);
|
||||
|
||||
// A streamed token lands in the same 30fps frame as the SIGWINCH:
|
||||
// `requestRender(false)` arms `#renderTimer`, then `term.resize`
|
||||
// fires the SIGWINCH that arms the multiplexer debounce. If the
|
||||
// queued throttled render were left active it would fire inside
|
||||
// the 50 ms settle window and paint mid-reflow.
|
||||
lines[19] = "line-19 streamed";
|
||||
component.setLines(lines);
|
||||
tui.requestRender();
|
||||
term.resize(80, 10);
|
||||
|
||||
// During the debounce window: no paint must land. The queued
|
||||
// throttled timer was canceled and any follow-on
|
||||
// `requestRender(false)` is held off until the multiplexer
|
||||
// settles.
|
||||
await Bun.sleep(10);
|
||||
expect(tui.fullRedraws).toBe(baselineRedraws);
|
||||
expect(writes.length).toBe(0);
|
||||
|
||||
// After the settle window: exactly one forced render lands, at
|
||||
// the new geometry, with the streamed token visible.
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
expect(tui.fullRedraws - baselineRedraws).toBe(1);
|
||||
expect(visible(term).at(-1)).toBe("line-19 streamed");
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("defers a forced repaint that lands inside the multiplexer settle window", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_v, i) => `line-${i}`)));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const baselineRedraws = tui.fullRedraws;
|
||||
const writes = captureWrites(term);
|
||||
|
||||
// A SIGWINCH starts the debounce. Then a `requestRender(true)`
|
||||
// (e.g. from finishSixelProbe or an image-budget eviction)
|
||||
// arrives mid-window. Without deferral it would paint
|
||||
// immediately into a still-reflowing pane.
|
||||
term.resize(80, 10);
|
||||
await Bun.sleep(10);
|
||||
tui.requestRender(true);
|
||||
|
||||
// Inside the window: still no paint. The forced render was
|
||||
// folded into the in-flight debounce.
|
||||
await Bun.sleep(20);
|
||||
expect(tui.fullRedraws).toBe(baselineRedraws);
|
||||
expect(writes.length).toBe(0);
|
||||
|
||||
// After the window: exactly one settled paint at the final
|
||||
// geometry.
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
expect(tui.fullRedraws - baselineRedraws).toBe(1);
|
||||
expect(visible(term)).toEqual(Array.from({ length: 10 }, (_v, i) => `line-${i + 10}`));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("defers resetDisplay() that lands inside the multiplexer settle window", async () => {
|
||||
await withEnvPatch(TMUX_ENV, async () => {
|
||||
const term = new VirtualTerminal(40, 10, 1000);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableLinesComponent(Array.from({ length: 20 }, (_v, i) => `line-${i}`)));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const baselineRedraws = tui.fullRedraws;
|
||||
const writes = captureWrites(term);
|
||||
|
||||
term.resize(80, 10);
|
||||
await Bun.sleep(10);
|
||||
tui.resetDisplay();
|
||||
|
||||
// resetDisplay normally repaints synchronously; here it must
|
||||
// route through the multiplexer debounce so no paint lands
|
||||
// while tmux is still reflowing.
|
||||
await Bun.sleep(20);
|
||||
expect(tui.fullRedraws).toBe(baselineRedraws);
|
||||
expect(writes.length).toBe(0);
|
||||
|
||||
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
|
||||
await settle(term);
|
||||
expect(tui.fullRedraws - baselineRedraws).toBe(1);
|
||||
expect(visible(term)).toEqual(Array.from({ length: 10 }, (_v, i) => `line-${i + 10}`));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1527,11 +1527,14 @@ describe("TUI terminal-state regressions", () => {
|
||||
await settle(term);
|
||||
|
||||
// SIGWINCH (height shrink) and a streamed token arrive inside the
|
||||
// same ~33ms frame budget. The TUI's own resize handler schedules a
|
||||
// non-forced render; the append rides along.
|
||||
// same multiplexer-resize debounce window. The TUI coalesces every
|
||||
// SIGWINCH into one settled forced render once the pane stops
|
||||
// resizing (issue #2088); the streamed append rides along on the
|
||||
// eventual render at the new geometry.
|
||||
lines.push("line-40 streamed");
|
||||
component.setLines(lines);
|
||||
term.resize(40, 6);
|
||||
await Bun.sleep(80);
|
||||
await settle(term);
|
||||
|
||||
// The visible pane must show the frame tail at the new geometry —
|
||||
|
||||
Reference in New Issue
Block a user