diff --git a/.changeset/terminal-blank-until-keypress-refresh.md b/.changeset/terminal-blank-until-keypress-refresh.md new file mode 100644 index 0000000000..6867f12cee --- /dev/null +++ b/.changeset/terminal-blank-until-keypress-refresh.md @@ -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. diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index 036587819f..96fdbe7337 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -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 */ } diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 404d157bb5..c1b6f38a3d 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -931,6 +931,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG try { (fitAddonRef.current as InstanceType).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 } diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx index 7c90b91ee9..50a7c7e9ec 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx @@ -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(); + 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)), + ); + }); }); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 28db07caed..e1ecd5bcf9 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -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(); + 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); + }); });