fix(dashboard): repaint stalled terminal renderer so first shell prompt shows without a keypress
On some systems the terminal opened blank — buffered prompt present but never painted (renderer stalled at init: WebGL activation on a zero-sized canvas or context-loss fallback). Every automatic recovery path fit() but never refreshed, and fit() with unchanged cols/rows triggers no internal xterm repaint, so only user input, a font-size change, or a new tab repaired it. Observer/geometry-driven fits in TerminalModal and SessionTerminal now always follow fit() with an explicit full-viewport terminal.refresh(0, rows-1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/terminal-blank-until-keypress-refresh.md
Normal file
7
.changeset/terminal-blank-until-keypress-refresh.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix terminal opening blank (no shell prompt) on some systems until a keypress, font-size change, or new tab.
|
||||
category: fix
|
||||
dev: Observer/geometry-driven fits in TerminalModal (`fitAndResizeForSession`, initial fit) and SessionTerminal now always follow `fit()` with `terminal.refresh(0, rows-1)`, so a renderer stalled at init repaints even when cols/rows are unchanged.
|
||||
@@ -633,6 +633,14 @@ export function SessionTerminal({
|
||||
try {
|
||||
(fitAddon as unknown as { fit: () => void }).fit();
|
||||
sendResize(term.cols, term.rows);
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-21:05:
|
||||
Blank-first-terminal recurrence (shared invariant with TerminalModal.fitAndResizeForSession):
|
||||
a renderer stalled at init leaves buffered output unpainted, and fit() with unchanged cols/rows
|
||||
triggers no internal repaint. Follow every observer-driven fit with an explicit full-viewport
|
||||
refresh so the initial ResizeObserver notification repairs a stalled renderer.
|
||||
*/
|
||||
term.refresh(0, Math.max(0, term.rows - 1));
|
||||
} catch {
|
||||
/* ignore transient measure failures */
|
||||
}
|
||||
|
||||
@@ -931,6 +931,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
try {
|
||||
(fitAddonRef.current as InstanceType<typeof import("@xterm/addon-fit").FitAddon>).fit();
|
||||
resizeRef.current?.(xtermRef.current.cols, xtermRef.current.rows);
|
||||
xtermRef.current.refresh(0, Math.max(0, xtermRef.current.rows - 1));
|
||||
} catch {
|
||||
// Ignore fit errors during viewport transitions
|
||||
}
|
||||
@@ -944,6 +945,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
if (currentResize) {
|
||||
currentResize(currentXterm.cols, currentXterm.rows);
|
||||
}
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-21:05:
|
||||
Blank-first-terminal recurrence: on some systems the renderer stalls at init (WebGL activation on a
|
||||
zero-sized canvas, or context-loss fallback to the DOM renderer) while the shell prompt sits unpainted
|
||||
in xterm's buffer. Every automatic recovery path funnels through this fit — but when fit() computes an
|
||||
UNCHANGED cols/rows, xterm skips its internal resize event and never repaints, so the stall was
|
||||
permanent until the user typed (new output), changed font size (the only path that refreshed), or
|
||||
opened a new tab (fresh renderer). Always follow fit with an explicit full-viewport refresh so the
|
||||
FN-7620 container ResizeObserver's guaranteed initial notification — and every later geometry event —
|
||||
repairs a stalled renderer even when dimensions did not change. refresh() is cheap and idempotent.
|
||||
*/
|
||||
currentXterm.refresh(0, Math.max(0, currentXterm.rows - 1));
|
||||
} catch {
|
||||
// Ignore fit errors during viewport transitions
|
||||
}
|
||||
@@ -1684,6 +1697,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
// Initial fit
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
// FNXC:Terminal 2026-07-23-21:05: Explicit refresh after the first fit — see fitAndResizeForSession. A renderer that stalled during open() (zero-sized canvas WebGL activation / context-loss fallback) must be repainted here even when fit() left cols/rows unchanged, or the already-buffered shell prompt stays invisible until user input.
|
||||
terminal.refresh(0, Math.max(0, terminal.rows - 1));
|
||||
// FNXC:Terminal 2026-06-22-22:00: After the first synchronous fit, schedule one deferred re-fit so a terminal opened mid-fold (narrow foldable, where the container width has not settled to its final integer box yet) re-measures columns once layout stabilizes — preventing the collapsed-column spaced-glyph render. Guarded by container width and live session so jsdom/tab-teardown paths stay no-ops.
|
||||
if ((terminalRef.current?.clientWidth ?? 0) > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1695,6 +1710,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
try {
|
||||
fitAddon.fit();
|
||||
resizeRef.current?.(terminal.cols, terminal.rows);
|
||||
terminal.refresh(0, Math.max(0, terminal.rows - 1));
|
||||
} catch {
|
||||
// Ignore fit errors during viewport transitions
|
||||
}
|
||||
|
||||
@@ -841,4 +841,34 @@ describe("SessionTerminal — FN-7620 mobile blank render (container geometry re
|
||||
|
||||
await waitFor(() => expect(mockFitAddon.fit).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-21:05:
|
||||
Blank-until-keypress recurrence (shared invariant with TerminalModal): a renderer stalled at init
|
||||
leaves buffered output unpainted, and an observer-driven fit() whose cols/rows come out UNCHANGED
|
||||
triggers no internal xterm repaint. The observer path must therefore always follow fit with an
|
||||
explicit full-viewport refresh(0, rows-1).
|
||||
*/
|
||||
it("follows every observer-driven fit with an explicit terminal.refresh so a stalled renderer repaints even when dimensions are unchanged", async () => {
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
await waitFor(() => expect(mockTerm.open).toHaveBeenCalled());
|
||||
|
||||
const container = screen.getByTestId("cli-terminal-viewport");
|
||||
const matches = captured.filter((entry) => entry.target === container);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
|
||||
// Unchanged-geometry notification: fit() will recompute identical
|
||||
// cols/rows, so the repaint must come from the explicit refresh.
|
||||
mockTerm.refresh.mockClear();
|
||||
act(() => {
|
||||
for (const entry of matches) {
|
||||
entry.callback([] as unknown as ResizeObserverEntry[], entry as unknown as ResizeObserver);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockTerm.refresh).toHaveBeenCalledWith(0, Math.max(0, mockTerm.rows - 1)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5119,6 +5119,7 @@ describe("TerminalModal — FN-1234 mobile tab switch with keyboard", () => {
|
||||
write: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
options: { fontSize: 14 },
|
||||
cols,
|
||||
rows,
|
||||
@@ -9364,4 +9365,50 @@ describe("TerminalModal — FN-7620 mobile blank render (zero-geometry xterm con
|
||||
expect(mockTerminalInstance.rows).toBe(Math.max(1, Math.floor(400 / CHAR_HEIGHT_PX)));
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-21:05:
|
||||
Blank-until-keypress recurrence: on some systems the renderer stalls during init while the shell
|
||||
prompt sits in xterm's buffer, and the container's box never CHANGES — so fit() computes the same
|
||||
cols/rows and xterm's internal resize-driven repaint never fires. The observer-driven fit path must
|
||||
therefore ALWAYS follow fit with an explicit full-viewport `refresh(0, rows-1)`; before this fix the
|
||||
only paths that repainted were user input (new PTY output), a manual font-size change (refitTerminal's
|
||||
refresh), or opening a new tab (fresh renderer).
|
||||
*/
|
||||
it("repaints (terminal.refresh) on a container notification even when fit() leaves dimensions UNCHANGED (blank-until-keypress stall)", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
const container = screen.getByTestId("terminal-xterm");
|
||||
overrideContainerBox(container, { width: 640, height: 400 });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Settle geometry once via the container observer.
|
||||
const settledCols = Math.max(2, Math.floor(640 / CHAR_WIDTH_PX));
|
||||
const settledRows = Math.max(1, Math.floor(400 / CHAR_HEIGHT_PX));
|
||||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.cols).toBe(settledCols);
|
||||
expect(mockTerminalInstance.rows).toBe(settledRows);
|
||||
});
|
||||
|
||||
// The stalled-renderer scenario: buffer has content, nothing painted, and
|
||||
// a later ResizeObserver notification arrives with the SAME box. fit()
|
||||
// recomputes identical cols/rows — the repair must come from an explicit
|
||||
// refresh, not from a resize side effect.
|
||||
mockTerminalInstance.refresh.mockClear();
|
||||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.refresh).toHaveBeenCalledWith(0, settledRows - 1);
|
||||
});
|
||||
// Dimensions genuinely unchanged — proving the refresh was not a
|
||||
// consequence of a resize.
|
||||
expect(mockTerminalInstance.cols).toBe(settledCols);
|
||||
expect(mockTerminalInstance.rows).toBe(settledRows);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user