diff --git a/.changeset/fn-7456-ios-mobile-terminal-spacing.md b/.changeset/fn-7456-ios-mobile-terminal-spacing.md
new file mode 100644
index 0000000000..e02e283399
--- /dev/null
+++ b/.changeset/fn-7456-ios-mobile-terminal-spacing.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix iOS mobile terminal spacing when opening terminals with the keyboard already visible.
+category: fix
+dev: Seeds iOS keyboard-open viewport baselines for TerminalModal and SessionTerminal before xterm fit/resize.
diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx
index 501ff59d7b..6a0cef26db 100644
--- a/packages/dashboard/app/components/TerminalModal.tsx
+++ b/packages/dashboard/app/components/TerminalModal.tsx
@@ -363,6 +363,26 @@ function isKeyboardFocusableElement(el: Element | null): boolean {
* - Fallback: initial viewport height - vv.height - vv.offsetTop
* Works on iOS Safari where window.innerHeight shrinks with the keyboard.
*/
+function getScreenViewportBaselineCandidate(viewportWidth: number, viewportHeight: number): number | null {
+ if (typeof window === "undefined" || !window.screen) return null;
+ const screenWidth = window.screen.width;
+ const screenHeight = window.screen.height;
+ if (!Number.isFinite(screenWidth) || !Number.isFinite(screenHeight) || screenWidth <= 0 || screenHeight <= 0) {
+ return null;
+ }
+
+ const portraitLike = viewportHeight >= viewportWidth;
+ const candidate = portraitLike
+ ? Math.max(screenWidth, screenHeight)
+ : Math.min(screenWidth, screenHeight);
+ const gap = candidate - viewportHeight;
+ const minMeaningfulGap = portraitLike
+ ? Math.max(220, candidate * 0.25)
+ : Math.max(80, candidate * 0.25);
+
+ return gap >= minMeaningfulGap ? candidate : null;
+}
+
function getKeyboardOverlap(): number {
if (typeof window === "undefined" || !window.visualViewport) return 0;
const vv = window.visualViewport;
@@ -384,6 +404,9 @@ function getKeyboardOverlap(): number {
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.
+
+ FNXC:Terminal 2026-07-02-18:12:
+ iOS Safari can deliver the very first terminal sample with the helper textarea focused, the soft keyboard already open, and both `innerHeight` and `documentElement.clientHeight` shrunk to the visual viewport. Seed that initial focused sample from the device screen only when the missing height is large enough to be a keyboard, so 10px/12px terminals publish --keyboard-overlap/--vv-height/--vv-width before any close/open, orientation, reconnect, or font reset side effect can repair spaced ASCII cells.
*/
if (!isKeyboardFocusableElement(document.activeElement) && hasSettledViewportPostureChange(viewportWidth)) {
setInitialViewportBaseline(viewportHeight, viewportWidth);
@@ -392,7 +415,13 @@ function getKeyboardOverlap(): number {
// On iOS Safari, window.innerHeight shrinks to match visualViewport.
// Detect keyboard by checking if visual viewport is shorter than initial
// height by more than 80px (with a 30px noise filter).
- const initialHeight = getInitialViewportHeight(viewportWidth, viewportHeight);
+ const screenBaselineCandidate = isKeyboardFocusableElement(document.activeElement)
+ ? getScreenViewportBaselineCandidate(viewportWidth, viewportHeight)
+ : null;
+ const initialHeight = Math.max(
+ getInitialViewportHeight(viewportWidth, screenBaselineCandidate ?? viewportHeight),
+ screenBaselineCandidate ?? 0,
+ );
const gap = initialHeight - vv.offsetTop - vv.height;
// Minimum 30px gap required to filter noise (address bar, toolbar changes).
// Threshold of 80px: only consider keyboard present when gap exceeds this.
diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx
index 2caed22ca8..aab44461e5 100644
--- a/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx
+++ b/packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx
@@ -558,6 +558,48 @@ describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
input.remove();
});
+ it("keeps initial iOS keyboard-open 12px metrics when layout height already shrank", async () => {
+ installMatchMedia(true);
+ const originalScreen = window.screen;
+ installVisualViewport({ innerHeight: 390, vvHeight: 390, vvWidth: 390 });
+ Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true });
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: 390,
+ configurable: true,
+ });
+ Object.defineProperty(window, "screen", {
+ configurable: true,
+ value: { width: 390, height: 844 },
+ });
+ window.localStorage.setItem(
+ TERMINAL_PREFERENCES_KEY,
+ JSON.stringify({ ...DEFAULT_TERMINAL_PREFERENCES, fontSize: 12 }),
+ );
+ const input = document.createElement("textarea");
+ document.body.appendChild(input);
+ input.focus();
+
+ try {
+ const { ws } = await renderMobile();
+
+ await waitFor(() => {
+ const root = screen.getByTestId("cli-terminal-mobile-bar").closest(".cli-session-terminal");
+ expect(root).toHaveClass("cli-session-terminal--mobile");
+ expect(root).toHaveAttribute("data-keyboard-open", "true");
+ const bar = screen.getByTestId("cli-terminal-mobile-bar");
+ expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
+ expect(bar.style.bottom).toBe("454px");
+ });
+ expect(mockTerm.options.fontSize).toBe(12);
+ expectMeasurementSafeFontStack(mockTerm.options.fontFamily as string);
+ await waitFor(() => expect(mockFitAddon.fit).toHaveBeenCalled());
+ expect(ws.sent.some((raw) => JSON.parse(raw).type === "resize")).toBe(true);
+ } finally {
+ input.remove();
+ Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });
+ }
+ });
+
it("keeps Android keyboard-open 10px metrics on visualViewport mobile width", async () => {
installMatchMedia({ width: false, height: false });
installVisualViewport({ innerHeight: 700, vvHeight: 320, vvWidth: 390 });
diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
index f8bb0d7f80..5cc9df833a 100644
--- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
@@ -5030,6 +5030,122 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
}
});
+ it("fits initial iOS keyboard-open 12px terminal from the visible viewport before any repair event", async () => {
+ (window as any).ontouchstart = null;
+ window.localStorage.setItem(TERMINAL_PREFERENCES_KEY, JSON.stringify({
+ ...DEFAULT_TERMINAL_PREFERENCES,
+ fontSize: 12,
+ }));
+ const originalScreen = window.screen;
+ const { listeners, mockVV } = simulateIOSSafari(true, 390);
+ Object.defineProperty(mockVV, "width", { value: 390, writable: true, configurable: true });
+ Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true });
+ Object.defineProperty(document.documentElement, "clientWidth", {
+ value: 390,
+ configurable: true,
+ });
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: 390,
+ configurable: true,
+ });
+ Object.defineProperty(window, "screen", {
+ configurable: true,
+ value: { width: 390, height: 844 },
+ });
+ const helperTextarea = document.createElement("textarea");
+ document.body.appendChild(helperTextarea);
+ helperTextarea.focus();
+ const onDataListeners: Array<(data: string) => void> = [];
+ const resizeForInitialIOSKeyboard = vi.fn();
+ mockUseTerminal.mockReturnValue(createMockTerminalState({
+ connectionStatus: "connected",
+ resize: resizeForInitialIOSKeyboard,
+ 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();
+
+ await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("12px"));
+ await waitFor(() => {
+ const modal = screen.getByTestId("terminal-modal");
+ expect(modal).toHaveClass("terminal-modal--mobile");
+ expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("454px");
+ expect(modal.style.getPropertyValue("--vv-height")).toBe("390px");
+ expect(modal.style.getPropertyValue("--vv-width")).toBe("390px");
+ });
+ await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
+
+ act(() => {
+ for (const cb of onDataListeners) {
+ cb("❯ test\r\n❯ ls\r\nAGENTS.md README.md package.json main\r\n");
+ }
+ for (const cb of listeners.resize) cb();
+ });
+
+ await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("test")));
+ await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md")));
+ await waitFor(() => expect(resizeForInitialIOSKeyboard).toHaveBeenCalledWith(80, 24));
+ expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
+ expect(mockTerminalInstance.options.fontSize).toBe(12);
+ } finally {
+ helperTextarea.remove();
+ Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });
+ window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
+ }
+ });
+
+ it("fits initial iOS keyboard-open 10px terminal when layout height already shrank", async () => {
+ (window as any).ontouchstart = null;
+ window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
+ const originalScreen = window.screen;
+ const { mockVV } = simulateIOSSafari(true, 390);
+ Object.defineProperty(mockVV, "width", { value: 390, writable: true, configurable: true });
+ Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true });
+ Object.defineProperty(document.documentElement, "clientHeight", {
+ value: 390,
+ configurable: true,
+ });
+ Object.defineProperty(window, "screen", {
+ configurable: true,
+ value: { width: 390, height: 844 },
+ });
+ const helperTextarea = document.createElement("textarea");
+ document.body.appendChild(helperTextarea);
+ helperTextarea.focus();
+ const resizeForInitialIOSSmallFont = vi.fn();
+ mockUseTerminal.mockReturnValue(createMockTerminalState({
+ connectionStatus: "connected",
+ resize: resizeForInitialIOSSmallFont,
+ }));
+
+ try {
+ render();
+
+ await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px"));
+ await waitFor(() => {
+ const modal = screen.getByTestId("terminal-modal");
+ expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("454px");
+ expect(modal.style.getPropertyValue("--vv-height")).toBe("390px");
+ expect(modal.style.getPropertyValue("--vv-width")).toBe("390px");
+ });
+ await waitFor(() => expect(resizeForInitialIOSSmallFont).toHaveBeenCalledWith(80, 24));
+ expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
+ expect(mockTerminalInstance.options.fontSize).toBe(10);
+ } finally {
+ helperTextarea.remove();
+ Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });
+ window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
+ }
+ });
+
it("fits Android keyboard-open 10px terminal to visual viewport width before any repair event", async () => {
(window as any).ontouchstart = null;
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
diff --git a/packages/dashboard/app/hooks/useMobileKeyboard.ts b/packages/dashboard/app/hooks/useMobileKeyboard.ts
index b8b4d97882..5994381d68 100644
--- a/packages/dashboard/app/hooks/useMobileKeyboard.ts
+++ b/packages/dashboard/app/hooks/useMobileKeyboard.ts
@@ -105,6 +105,28 @@ function isCollapsedRestoreViewportSample(baselineHeight: number): boolean {
return window.visualViewport.height >= baselineHeight - IOS_VIEWPORT_SHRINK_MIN_PX;
}
+function getScreenViewportBaselineCandidate(viewportWidth: number, viewportHeight: number): number | null {
+ if (typeof window === "undefined" || !window.screen) {
+ return null;
+ }
+ const screenWidth = window.screen.width;
+ const screenHeight = window.screen.height;
+ if (!Number.isFinite(screenWidth) || !Number.isFinite(screenHeight) || screenWidth <= 0 || screenHeight <= 0) {
+ return null;
+ }
+
+ const portraitLike = viewportHeight >= viewportWidth;
+ const candidate = portraitLike
+ ? Math.max(screenWidth, screenHeight)
+ : Math.min(screenWidth, screenHeight);
+ const gap = candidate - viewportHeight;
+ const minMeaningfulGap = portraitLike
+ ? Math.max(220, candidate * 0.25)
+ : Math.max(80, candidate * 0.25);
+
+ return gap >= minMeaningfulGap ? candidate : null;
+}
+
function getKeyboardMetrics(
previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS,
{ bypassImpossibleSampleHold = false }: { bypassImpossibleSampleHold?: boolean } = {},
@@ -159,7 +181,14 @@ function getKeyboardMetrics(
// iOS fallback (window.innerHeight shrinks with keyboard). Same focused
// requirement as above — the dismissal animation otherwise leaves the
// gap > the open-threshold for the duration of the slide.
- const baselineHeight = getBaselineViewportHeight();
+ /*
+ FNXC:Terminal 2026-07-02-18:18:
+ SessionTerminal uses this shared hook, so it has the same initial iOS keyboard-open failure mode as TerminalModal: the first focused sample can have `innerHeight`, `clientHeight`, and `visualViewport.height` already shrunk. Use a guarded screen-derived baseline only when the missing height is large enough to be a real keyboard, keeping the mobile input bar and xterm resize bridge correct at 10px/12px before later viewport events can repair spacing.
+ */
+ const screenBaselineCandidate = focused
+ ? getScreenViewportBaselineCandidate(getCurrentViewportWidth(), vv.height)
+ : null;
+ const baselineHeight = Math.max(getBaselineViewportHeight(), screenBaselineCandidate ?? 0);
const gap = Math.max(0, baselineHeight - vv.offsetTop - vv.height);
if (gap >= IOS_FALLBACK_MIN_GAP_PX && focused) {