diff --git a/.changeset/fn-7289-mobile-terminal-folded-keyboard.md b/.changeset/fn-7289-mobile-terminal-folded-keyboard.md
new file mode 100644
index 0000000000..79b5d7c69d
--- /dev/null
+++ b/.changeset/fn-7289-mobile-terminal-folded-keyboard.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix mobile terminal spacing when folded phones open with the keyboard already visible.
+category: fix
+dev: TerminalModal now uses layout-viewport height for the initial focused keyboard-open folded posture and tests cover TerminalModal plus SessionTerminal.
diff --git a/docs/solutions/ui-bugs/mobile-terminal-folded-viewport-baseline.md b/docs/solutions/ui-bugs/mobile-terminal-folded-viewport-baseline.md
index fedbc91bc9..c6dbcf410f 100644
--- a/docs/solutions/ui-bugs/mobile-terminal-folded-viewport-baseline.md
+++ b/docs/solutions/ui-bugs/mobile-terminal-folded-viewport-baseline.md
@@ -10,7 +10,8 @@ symptoms:
- "Terminal commands render with excessive inter-character spacing or premature wrapping in folded mobile posture"
- "Keyboard-open terminal height/overlap is computed from an earlier unfolded viewport"
- "Embedded CLI session terminal input bar is lifted too far after a fold/narrow transition"
-root_cause: stale_unfolded_visualviewport_baseline
+ - "Terminal spacing fixes itself only after a later unfold/orientation event"
+root_cause: stale_or_missing_folded_visualviewport_baseline
resolution_type: code_fix
severity: medium
related_components:
@@ -18,6 +19,7 @@ related_components:
- packages/dashboard/app/hooks/useMobileKeyboard.ts
- packages/dashboard/app/components/SessionTerminal.tsx
- FN-7281
+ - FN-7289
tags:
- terminal
- xterm
@@ -33,7 +35,7 @@ tags:
The terminal has two mobile xterm surfaces: the PTY `TerminalModal` and the embedded `SessionTerminal`. Both depend on visualViewport-derived keyboard metrics before fitting xterm rows/cols. On iOS-style browsers, `innerHeight` can shrink with the keyboard, so the code keeps a baseline viewport height captured while the keyboard is closed.
-A foldable device can first expose an unfolded/wide closed baseline, then settle to a narrower folded baseline before the keyboard opens. If the folded closed sample is shorter than the previous baseline and the baseline only ever grows, the later keyboard-open sample overestimates the overlap. Conversely, a fold/orientation width sample can arrive after xterm's helper textarea is focused and the soft keyboard is already open; if that focused sample replaces the baseline, the keyboard-open height looks closed and clears the terminal CSS variables. Both stale geometries make the terminal fit against the wrong box and can surface as premature wrapping or spaced ASCII such as `p n p m b u i l d`.
+A foldable device can first expose an unfolded/wide closed baseline, then settle to a narrower folded baseline before the keyboard opens. If the folded closed sample is shorter than the previous baseline and the baseline only ever grows, the later keyboard-open sample overestimates the overlap. Conversely, a fold/orientation width sample can arrive after xterm's helper textarea is focused and the soft keyboard is already open; if that focused sample replaces the baseline, the keyboard-open height looks closed and clears the terminal CSS variables. A recurrence also appears when the terminal first renders after the helper/input is already focused and the soft keyboard is open: there is no prior closed visualViewport sample, so using the shrunken visualViewport as the baseline clears the overlap until a later unfold/orientation event supplies a usable layout. These stale or missing geometries make the terminal fit against the wrong box and can surface as premature wrapping or spaced ASCII such as `p n p m b u i l d`.
## Solution
@@ -43,14 +45,15 @@ Treat a keyboard-closed width/posture change as a new baseline, not as keyboard
- Preserve the max-observed baseline behavior for same-posture recovery from keyboard-open first samples.
- When width changes and the viewport height is a settled folded value, replace the baseline before computing iOS fallback overlap.
- Gate that replacement to keyboard-closed samples; if a keyboard-focusable element is active, keep the previous baseline so a focused keyboard-open folded sample cannot zero out the overlap.
+- When the first sample is already focused and keyboard-open, prefer the layout viewport height (`documentElement.clientHeight` when available) over the shrunken visualViewport height so overlap and `--vv-height` are meaningful before any unfold repair.
- Keep xterm's measured font family symbols-free; the fix is viewport measurement, not a letter-spacing or cell-width workaround.
## Regression coverage
Guard the invariant at three seams:
-- `TerminalModal.test.tsx` simulates unfolded closed → folded closed → folded keyboard-open and asserts `--keyboard-overlap` / `--vv-height` use the folded baseline. It also covers a focused folded keyboard-open sample so posture re-baselining cannot clear those CSS variables.
-- `SessionTerminal.mobile.test.tsx` proves the embedded mobile input bar uses the folded baseline instead of the stale unfolded height.
+- `TerminalModal.test.tsx` simulates initial folded keyboard-open startup with ASCII (`pnpm build`) and prompt glyph output, duplicate visualViewport/orientation events, and asserts `--keyboard-overlap` / `--vv-height` plus xterm resize happen before any unfold. It also simulates unfolded closed → folded closed → folded keyboard-open and covers a focused folded keyboard-open sample so posture re-baselining cannot clear those CSS variables.
+- `SessionTerminal.mobile.test.tsx` proves the embedded mobile input bar uses layout/folded metrics for both initial focused keyboard-open startup and folded-baseline replacement, while keeping the xterm measured font stack symbols-free.
- `useMobileKeyboard.test.ts` covers the shared hook so future consumers inherit the posture-aware baseline behavior.
Existing terminal tests continue to cover symbols-free xterm font stacks, glyph fallback for Nerd Font/powerline output, duplicate visualViewport resize coalescing, keyboard close clearing, undefined visualViewport, tab-switch scrollback replay, and desktop/tablet terminal modes.
diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx
index 127bc4bb91..9942c04a3e 100644
--- a/packages/dashboard/app/components/TerminalModal.tsx
+++ b/packages/dashboard/app/components/TerminalModal.tsx
@@ -189,8 +189,6 @@ const TERMINAL_KEY_LABELS = {
pxUnit: "px",
} as const;
-const SETTLED_FOLDED_VIEWPORT_MIN_HEIGHT_PX = 480;
-
export function ctrlChar(key: string): string {
if (!key) {
return "";
@@ -344,8 +342,9 @@ function getKeyboardOverlap(): number {
if (typeof window === "undefined" || !window.visualViewport) return 0;
const vv = window.visualViewport;
const viewportWidth = vv.width > 0 ? vv.width : window.innerWidth;
- const viewportHeight = Math.max(window.innerHeight, vv.height);
- const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
+ const layoutViewportHeight = Math.max(window.innerHeight, document.documentElement?.clientHeight || 0);
+ const viewportHeight = Math.max(layoutViewportHeight, vv.height);
+ const chromeOverlap = Math.max(0, layoutViewportHeight - vv.offsetTop - vv.height);
if (chromeOverlap > 0) return chromeOverlap;
/*
@@ -354,8 +353,14 @@ function getKeyboardOverlap(): number {
FNXC:Terminal 2026-06-30-09:38:
A later folded-posture width sample can arrive while xterm's helper textarea is focused and the soft keyboard is already open. Never re-baseline from that focused keyboard-open sample, because it makes the keyboard height look like the closed viewport and clears --keyboard-overlap/--vv-height before the final fit.
+
+ FNXC:Terminal 2026-06-30-10:36:
+ The reported recurrence starts with the folded phone already focused and keyboard-open, so there is no prior closed visualViewport sample to seed the iOS fallback baseline. Prefer the current layout viewport height before falling back to visualViewport height; this preserves --keyboard-overlap/--vv-height and the post-layout xterm fit before any later unfold can repair stale geometry.
+
+ FNXC:Terminal 2026-06-30-11:42:
+ Touch-primary short landscape and folded closed postures can be <=480px tall. A keyboard-closed width/posture sample must replace an unfolded baseline even at that height, while focused keyboard-open samples remain excluded so xterm does not clear overlap before the first correct folded fit.
*/
- if (!isKeyboardFocusableElement(document.activeElement) && hasSettledViewportPostureChange(viewportWidth, viewportHeight)) {
+ if (!isKeyboardFocusableElement(document.activeElement) && hasSettledViewportPostureChange(viewportWidth)) {
setInitialViewportBaseline(viewportHeight, viewportWidth);
}
@@ -383,12 +388,11 @@ function setInitialViewportBaseline(height: number, width: number): void {
_initialViewportWidth = width;
}
-function hasSettledViewportPostureChange(width: number, height: number): boolean {
+function hasSettledViewportPostureChange(width: number): boolean {
return (
_initialViewportHeight !== null &&
_initialViewportWidth !== null &&
- Math.abs(width - _initialViewportWidth) >= 1 &&
- height >= SETTLED_FOLDED_VIEWPORT_MIN_HEIGHT_PX
+ Math.abs(width - _initialViewportWidth) >= 1
);
}
diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx
index d6904c2c3e..1681f09c67 100644
--- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx
+++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx
@@ -385,6 +385,7 @@ describe("SessionTerminal (mobile)", () => {
// ── Keyboard-open (fixed-footer) + pinch-zoom guard ──────────────────────────
describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
let savedVisualViewport: typeof window.visualViewport;
+ let savedDocumentElementClientHeight: number;
function installVisualViewport({
innerHeight,
@@ -438,6 +439,7 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
installMatchMedia(true);
_resetInitialViewportHeight();
savedVisualViewport = window.visualViewport;
+ savedDocumentElementClientHeight = document.documentElement.clientHeight;
});
afterEach(() => {
@@ -446,6 +448,10 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
writable: true,
configurable: true,
});
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: savedDocumentElementClientHeight,
+ configurable: true,
+ });
_resetInitialViewportHeight();
vi.clearAllMocks();
});
@@ -469,6 +475,30 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
input.remove();
});
+ it("keeps initial folded keyboard-open metrics without waiting for unfold", async () => {
+ installVisualViewport({ innerHeight: 300, vvHeight: 300 });
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: 667,
+ configurable: true,
+ });
+ const input = document.createElement("textarea");
+ document.body.appendChild(input);
+ input.focus();
+
+ try {
+ await renderMobile();
+
+ await waitFor(() => {
+ const bar = screen.getByTestId("cli-terminal-mobile-bar");
+ expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
+ expect(bar.style.bottom).toBe("367px");
+ });
+ expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
+ } finally {
+ input.remove();
+ }
+ });
+
it("re-baselines folded iOS viewport before lifting the mobile input bar", async () => {
const { listeners, mockVV } = installVisualViewport({ innerHeight: 844, vvHeight: 844 });
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
index 8fa4a56042..959008bc7c 100644
--- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
@@ -4617,18 +4617,26 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
let savedInnerWidth: typeof window.innerWidth;
let savedInnerHeight: typeof window.innerHeight;
let savedOntouchstart: typeof window.ontouchstart;
+ let savedDocumentElementClientHeight: number;
beforeEach(() => {
vi.clearAllMocks();
_resetInitialViewportHeight();
mockUseTerminal.mockReturnValue(createMockTerminalState());
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
+ mockUseWorkspaces.mockReturnValue({
+ projectName: "kb",
+ workspaces: [],
+ loading: false,
+ error: null,
+ });
// Stash originals
savedVisualViewport = window.visualViewport;
savedInnerWidth = window.innerWidth;
savedInnerHeight = window.innerHeight;
savedOntouchstart = window.ontouchstart;
+ savedDocumentElementClientHeight = document.documentElement.clientHeight;
});
afterEach(() => {
@@ -4653,6 +4661,10 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
writable: true,
configurable: true,
});
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: savedDocumentElementClientHeight,
+ configurable: true,
+ });
vi.restoreAllMocks();
});
@@ -4750,6 +4762,71 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
return { listeners, mockVV, initialHeight };
}
+ it("keeps initial folded keyboard-open terminal metrics before any unfold repair", async () => {
+ const { listeners } = simulateIOSSafari(true, 300);
+ const onDataListeners: Array<(data: string) => void> = [];
+ const resizeForFoldedKeyboard = vi.fn();
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: 667,
+ configurable: true,
+ });
+ const helperTextarea = document.createElement("textarea");
+ document.body.appendChild(helperTextarea);
+ helperTextarea.focus();
+
+ mockUseTerminal.mockReturnValue(createMockTerminalState({
+ connectionStatus: "connected",
+ resize: resizeForFoldedKeyboard,
+ onData: vi.fn((cb: (data: string) => void) => {
+ onDataListeners.push(cb);
+ return vi.fn();
+ }),
+ onScrollback: vi.fn((cb: (data: string) => void) => {
+ onDataListeners.push(cb);
+ return vi.fn();
+ }),
+ }));
+
+ try {
+ render();
+
+ act(() => {
+ for (const cb of listeners.resize) cb();
+ });
+
+ await waitFor(() => {
+ const modal = screen.getByTestId("terminal-modal");
+ expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
+ expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
+ });
+ await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
+ act(() => {
+ for (const cb of onDataListeners) {
+ cb("❯ pnpm build\r\n✔ built packages/dashboard main\r\n");
+ }
+ });
+ await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("pnpm build")));
+ await waitFor(() => expect(resizeForFoldedKeyboard).toHaveBeenCalledWith(80, 24));
+
+ resizeForFoldedKeyboard.mockClear();
+ act(() => {
+ for (const cb of listeners.resize) cb();
+ for (const cb of listeners.resize) cb();
+ window.dispatchEvent(new Event("orientationchange"));
+ window.dispatchEvent(new Event("orientationchange"));
+ });
+
+ await waitFor(() => {
+ const modal = screen.getByTestId("terminal-modal");
+ expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
+ expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
+ expect(resizeForFoldedKeyboard).toHaveBeenCalledWith(80, 24);
+ });
+ } finally {
+ helperTextarea.remove();
+ }
+ });
+
it("detects keyboard on iOS Safari where innerHeight shrinks with visualViewport", async () => {
// On iOS Safari, both window.innerHeight and visualViewport.height shrink.
// The primary formula (innerHeight - vv.offsetTop - vv.height) returns 0
@@ -4878,6 +4955,56 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
});
});
+ it("re-baselines keyboard-closed folded landscape samples below 480px", async () => {
+ const { listeners, mockVV } = simulateIOSSafari(false, 844);
+ Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
+ Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: 844,
+ configurable: true,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
+ });
+
+ Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
+ Object.defineProperty(window, "innerHeight", { value: 375, writable: true, configurable: true });
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: 375,
+ configurable: true,
+ });
+ Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
+ Object.defineProperty(mockVV, "height", { value: 375, writable: true, configurable: true });
+
+ act(() => {
+ for (const cb of listeners.resize) cb();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
+ });
+
+ Object.defineProperty(window, "innerHeight", { value: 250, writable: true, configurable: true });
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: 250,
+ configurable: true,
+ });
+ Object.defineProperty(mockVV, "height", { value: 250, writable: true, configurable: true });
+
+ act(() => {
+ for (const cb of listeners.resize) cb();
+ });
+
+ await waitFor(() => {
+ const modal = screen.getByTestId("terminal-modal");
+ expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("125px");
+ expect(modal.style.getPropertyValue("--vv-height")).toBe("250px");
+ });
+ });
+
it("does not re-baseline folded viewport width changes from focused keyboard-open samples", async () => {
const { listeners, mockVV } = simulateIOSSafari(false, 844);
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });