diff --git a/.changeset/fn-7298-mobile-terminal-character-spacing.md b/.changeset/fn-7298-mobile-terminal-character-spacing.md new file mode 100644 index 0000000000..d1be0d652b --- /dev/null +++ b/.changeset/fn-7298-mobile-terminal-character-spacing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix mobile terminal character spacing after small font-size changes. +category: fix +dev: Reapplies settled xterm font metrics for TerminalModal and SessionTerminal at 10px keyboard-open states. diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index 9a1e62b548..3b3a337f8e 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -270,6 +270,13 @@ export function SessionTerminal({ if (showConfirmAdvance) setAdvanceDismissed(false); }, [showConfirmAdvance, sessionId]); + const sendResizeMessage = useCallback((cols: number, rows: number) => { + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }, []); + const applyLiveTerminalPreferences = useCallback(() => { const terminal = xtermRef.current; if (!terminal) { @@ -277,7 +284,8 @@ export function SessionTerminal({ } const terminalPreferences = readTerminalPreferences(); - terminal.options.fontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); + const resolvedFontFamily = resolveTerminalFontFamily(terminalPreferences.fontFamily); + terminal.options.fontFamily = resolvedFontFamily; containerRef.current?.style.setProperty( "--terminal-glyph-font-family", resolveTerminalGlyphFontFamily(terminalPreferences.fontFamily), @@ -288,10 +296,43 @@ export function SessionTerminal({ try { (fitAddonRef.current as { fit?: () => void } | null)?.fit?.(); + sendResizeMessage(terminal.cols, terminal.rows); } catch { /* ignore transient measure failures */ } - }, [canAcceptInput]); + + /* + FNXC:Terminal 2026-06-30-13:22: + SessionTerminal shares TerminalModal's mobile 10px font-size invariant through the same preference storage. Storage-driven font changes must wait for the symbols-free measured stack, then refit, send one resize frame, and refresh so embedded CLI attach surfaces do not keep stale wide ASCII cells after the soft keyboard has constrained the viewport. + + FNXC:Terminal 2026-06-30-22:47: + Async font-metric waits can resolve out of order when a second terminal preference change lands first. Reapply only if the currently mounted xterm options still match the preference snapshot that scheduled this wait, preserving the latest small-font cell metrics instead of resurrecting stale spacing. + */ + void waitForTerminalFontMetrics(terminalPreferences.fontSize, resolvedFontFamily).then( + (fontMetricsSettled) => { + if ( + !fontMetricsSettled || + xtermRef.current !== terminal || + terminal.options.fontSize !== terminalPreferences.fontSize || + terminal.options.fontFamily !== resolvedFontFamily + ) { + return; + } + terminal.options.fontFamily = resolvedFontFamily; + terminal.options.fontSize = terminalPreferences.fontSize; + try { + (fitAddonRef.current as { fit?: () => void } | null)?.fit?.(); + sendResizeMessage(terminal.cols, terminal.rows); + terminal.refresh(0, Math.max(0, terminal.rows - 1)); + } catch { + /* ignore teardown or transient measure failures */ + } + }, + () => { + /* FontFaceSet failures are non-fatal; the immediate fit above remains. */ + }, + ); + }, [canAcceptInput, sendResizeMessage]); /* FNXC:Terminal 2026-06-17-01:05: @@ -322,12 +363,7 @@ export function SessionTerminal({ let unackedBytes = 0; setTicketReadOnly(null); - const sendResize = (cols: number, rows: number) => { - const ws = wsRef.current; - if (ws?.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "resize", cols, rows })); - } - }; + const sendResize = sendResizeMessage; const ackBytes = (n: number) => { unackedBytes += n; @@ -440,7 +476,9 @@ export function SessionTerminal({ !fontMetricsSettled || disposed || xtermRef.current !== term || - fitAddonRef.current !== fitAddon + fitAddonRef.current !== fitAddon || + term.options.fontSize !== terminalPreferences.fontSize || + term.options.fontFamily !== resolvedFontFamily ) { return; } @@ -579,7 +617,7 @@ export function SessionTerminal({ } fitAddonRef.current = null; }; - }, [sessionId, readOnly, mode, projectId]); + }, [sessionId, readOnly, mode, projectId, sendResizeMessage]); const replayLabel = useMemo(() => { if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle"); diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 9942c04a3e..5b31b5907f 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -1646,23 +1646,60 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermRef.current.options.cursorStyle = terminalPreferences.cursorStyle; xtermRef.current.options.cursorBlink = terminalPreferences.cursorBlink; + let cancelled = false; + // Defer fit until the next frame so layout reflects the new font metrics // before FitAddon measures rows/cols. Reuse pendingFitRef so font changes and // visualViewport-triggered fits are coalesced into a single scheduled fit. - if (pendingFitRef.current !== null) { - cancelAnimationFrame(pendingFitRef.current); - pendingFitRef.current = null; - } + const scheduleRefit = () => { + if (pendingFitRef.current !== null) { + cancelAnimationFrame(pendingFitRef.current); + pendingFitRef.current = null; + } - const frame = requestAnimationFrame(() => { - pendingFitRef.current = null; - refitTerminal(); - }); - pendingFitRef.current = frame; + const frame = requestAnimationFrame(() => { + pendingFitRef.current = null; + if (cancelled) { + return; + } + refitTerminal(); + xtermRef.current?.refresh?.(0, Math.max(0, xtermRef.current.rows - 1)); + }); + pendingFitRef.current = frame; + return frame; + }; + + const immediateFrame = scheduleRefit(); + + /* + FNXC:Terminal 2026-06-30-13:18: + The mobile screenshot recurrence happens at the visible 10px setting with the soft keyboard already open. A live font-size preference change must wait for the symbols-free measured stack to settle, then reapply xterm font options, refit, resize, and refresh; otherwise canvas/DOM metrics can keep the old wider cells until an unfold/orientation event forces a later measurement. + */ + void waitForTerminalFontMetrics(terminalPreferences.fontSize, resolvedFontFamily).then( + (fontMetricsSettled) => { + if ( + cancelled || + !fontMetricsSettled || + !xtermRef.current || + xtermRef.current.options.fontSize !== terminalPreferences.fontSize || + xtermRef.current.options.fontFamily !== resolvedFontFamily + ) { + return; + } + xtermRef.current.options.fontFamily = resolvedFontFamily; + xtermRef.current.options.fontSize = terminalPreferences.fontSize; + scheduleRefit(); + }, + () => { + // FontFaceSet failures are non-fatal; the immediate frame above still + // applies the current preference and keeps terminal input usable. + }, + ); return () => { - if (pendingFitRef.current === frame) { - cancelAnimationFrame(frame); + cancelled = true; + if (immediateFrame !== undefined && pendingFitRef.current === immediateFrame) { + cancelAnimationFrame(immediateFrame); pendingFitRef.current = null; } }; diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx index 1681f09c67..704a285905 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx @@ -29,6 +29,16 @@ vi.mock("@xterm/addon-webgl", () => ({ const apiMock = vi.fn(); vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) })); vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u })); +const terminalPreferenceMocks = vi.hoisted(() => ({ + waitForTerminalFontMetrics: vi.fn(() => Promise.resolve(true)), +})); +vi.mock("../../utils/terminalPreferences", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForTerminalFontMetrics: terminalPreferenceMocks.waitForTerminalFontMetrics, + }; +}); // ── Minimal WebSocket stub ────────────────────────────────────────────────── class FakeWS { @@ -145,6 +155,8 @@ beforeEach(() => { mockTerm.dispose.mockClear(); mockTerm.options = {}; mockFitAddon.fit.mockClear(); + terminalPreferenceMocks.waitForTerminalFontMetrics.mockReset(); + terminalPreferenceMocks.waitForTerminalFontMetrics.mockResolvedValue(true); apiMock.mockReset(); apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false }); installMatchMedia(true); // mobile by default @@ -380,6 +392,44 @@ describe("SessionTerminal (mobile)", () => { expect(screen.getByTestId("cli-key-arrow-left")).toBeTruthy(); expect(screen.getByTestId("cli-key-arrow-right")).toBeTruthy(); }); + + it("does not let an older mobile font-metric wait overwrite newer terminal preferences", async () => { + await renderMobile(); + await waitFor(() => expect(terminalPreferenceMocks.waitForTerminalFontMetrics).toHaveBeenCalled()); + + const pendingFontWaits: Array<(value: boolean) => void> = []; + terminalPreferenceMocks.waitForTerminalFontMetrics.mockReset(); + terminalPreferenceMocks.waitForTerminalFontMetrics.mockImplementation( + () => new Promise((resolve) => pendingFontWaits.push(resolve)), + ); + + const setPreferenceFontSize = (fontSize: number) => { + window.localStorage.setItem( + TERMINAL_PREFERENCES_KEY, + JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize }), + ); + window.dispatchEvent(new StorageEvent("storage", { key: TERMINAL_PREFERENCES_KEY })); + }; + + setPreferenceFontSize(10); + expect(mockTerm.options.fontSize).toBe(10); + setPreferenceFontSize(14); + expect(mockTerm.options.fontSize).toBe(14); + expect(terminalPreferenceMocks.waitForTerminalFontMetrics).toHaveBeenCalledTimes(2); + + await act(async () => { + pendingFontWaits[0]?.(true); + await Promise.resolve(); + }); + expect(mockTerm.options.fontSize).toBe(14); + + await act(async () => { + pendingFontWaits[1]?.(true); + await Promise.resolve(); + }); + expect(mockTerm.options.fontSize).toBe(14); + expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string); + }); }); // ── Keyboard-open (fixed-footer) + pinch-zoom guard ────────────────────────── diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx index 211602f231..613a7f16f8 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx @@ -378,15 +378,25 @@ describe("SessionTerminal", () => { }); it("live-applies font and cursor preference changes from storage events", async () => { + const fontLoad = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(document, "fonts", { + value: { + load: fontLoad, + ready: Promise.resolve(), + }, + configurable: true, + }); render(); await waitFor(() => expect(FakeWS.instances.length).toBe(1)); mockFitAddon.fit.mockClear(); + mockTerm.refresh.mockClear(); + fontLoad.mockClear(); window.localStorage.setItem( TERMINAL_PREFERENCES_KEY, JSON.stringify({ fontFamily: "jetbrains-mono", - fontSize: 20, + fontSize: 10, cursorStyle: "bar", cursorBlink: false, renderer: "canvas", @@ -397,11 +407,13 @@ describe("SessionTerminal", () => { await waitFor(() => { expect(mockTerm.options).toMatchObject({ fontFamily: resolveTerminalFontFamily("jetbrains-mono"), - fontSize: 20, + fontSize: 10, cursorStyle: "bar", cursorBlink: false, }); }); + await waitFor(() => expect(fontLoad).toHaveBeenCalledWith(expect.stringContaining("10px"))); + await waitFor(() => expect(mockTerm.refresh).toHaveBeenCalledWith(0, mockTerm.rows - 1)); expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string); expect(mockFitAddon.fit).toHaveBeenCalled(); }); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 959008bc7c..78eb79ecdb 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -4665,6 +4665,8 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", () value: savedDocumentElementClientHeight, configurable: true, }); + window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY); + window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY); vi.restoreAllMocks(); }); @@ -4762,6 +4764,69 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", () return { listeners, mockVV, initialHeight }; } + it("remeasures the mobile keyboard-open terminal when reducing the persisted font size to 10px", async () => { + const { listeners } = simulateIOSSafari(true, 300); + const fontLoad = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(document, "fonts", { + value: { + load: fontLoad, + ready: Promise.resolve(), + }, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: 667, + configurable: true, + }); + const onDataListeners: Array<(data: string) => void> = []; + const resizeForSmallFont = vi.fn(); + mockUseTerminal.mockReturnValue(createMockTerminalState({ + connectionStatus: "connected", + resize: resizeForSmallFont, + onData: vi.fn((cb: (data: string) => void) => { + onDataListeners.push(cb); + return vi.fn(); + }), + })); + + render(); + + await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("14px")); + await waitFor(() => { + const modal = screen.getByTestId("terminal-modal"); + expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px"); + expect(modal.style.getPropertyValue("--vv-height")).toBe("300px"); + }); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); + + fontLoad.mockClear(); + resizeForSmallFont.mockClear(); + const decrease = screen.getByTestId("terminal-font-size-decrease"); + for (let i = 0; i < 4; i += 1) { + fireEvent.click(decrease); + } + + await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px")); + await waitFor(() => { + expect(fontLoad).toHaveBeenCalledWith(expect.stringContaining("10px")); + }); + await waitFor(() => expect(mockTerminalInstance.options.fontSize).toBe(10)); + await waitFor(() => expect(mockTerminalInstance.refresh).toHaveBeenCalledWith(0, 23)); + await waitFor(() => expect(resizeForSmallFont).toHaveBeenCalledWith(80, 24)); + + act(() => { + for (const cb of onDataListeners) { + cb("❯ pnpm build\r\n@fusion/dashboard build complete  main\r\n"); + } + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("pnpm build"))); + expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string); + window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY); + }); + it("keeps initial folded keyboard-open terminal metrics before any unfold repair", async () => { const { listeners } = simulateIOSSafari(true, 300); const onDataListeners: Array<(data: string) => void> = [];