+
@@ -261,6 +275,13 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
bottomNavHeight={BOTTOM_NAV_HEIGHT}
/>
+ {/* Terminal key-bar — docks above OS keyboard or bottom nav on the terminal panel */}
+
+
{/* Fixed Bottom Navigation */}
) => void;
}) {
- const { xtermRef, onDataDisposableRef, isReadOnlyMode, taskId, sessionId, send } = opts;
+ const { xtermRef, onDataDisposableRef, isReadOnlyMode, taskId, sessionId } = opts;
useEffect(() => {
if (!xtermRef.current || isReadOnlyMode) return;
onDataDisposableRef.current?.dispose();
@@ -342,13 +344,13 @@ function useShellInputHandler(opts: {
if (!taskId || !sessionId) return;
onDataDisposableRef.current = xtermRef.current.onData((data) => {
if (/^\x1b\[\d+;\d+R$/.test(data) || /^\x1b\[\d+R$/.test(data)) return;
- send("shell.input", { session_id: sessionId, data });
+ sendShellInput(sessionId, data);
});
return () => {
onDataDisposableRef.current?.dispose();
onDataDisposableRef.current = null;
};
- }, [taskId, sessionId, send, isReadOnlyMode, xtermRef, onDataDisposableRef]);
+ }, [taskId, sessionId, isReadOnlyMode, xtermRef, onDataDisposableRef]);
}
function useShellSessionState(propSessionId: string | undefined, isReadOnlyMode: boolean) {
@@ -429,7 +431,7 @@ export function ShellTerminal({
onClose: search.close,
});
- useShellInputHandler({ xtermRef, onDataDisposableRef, isReadOnlyMode, taskId, sessionId, send });
+ useShellInputHandler({ xtermRef, onDataDisposableRef, isReadOnlyMode, taskId, sessionId });
useShellTerminalKeyHandler({
xtermRef,
isReadOnlyMode,
diff --git a/apps/web/e2e/helpers/ws-capture.ts b/apps/web/e2e/helpers/ws-capture.ts
new file mode 100644
index 0000000000..181fcad61d
--- /dev/null
+++ b/apps/web/e2e/helpers/ws-capture.ts
@@ -0,0 +1,45 @@
+import type { Page } from "@playwright/test";
+
+export type ShellInputFrame = {
+ sessionId: string;
+ data: string;
+};
+
+type ParsedFrame = {
+ type?: string;
+ action?: string;
+ payload?: { session_id?: string; data?: string };
+};
+
+/**
+ * Subscribe to outgoing WS frames on the given page and collect every
+ * `shell.input` request with its `{ session_id, data }` payload. Useful for
+ * asserting that UI actions translate to the correct escape sequences without
+ * depending on xterm paint timing.
+ *
+ * Returns a live array that tests can poll via `expect.poll`. Must be called
+ * before the page navigates, since `framesent` events fire before your next
+ * tick.
+ */
+export function attachShellInputCapture(page: Page): { frames: ShellInputFrame[] } {
+ const frames: ShellInputFrame[] = [];
+ page.on("websocket", (ws) => {
+ ws.on("framesent", (event) => {
+ const raw =
+ typeof event.payload === "string" ? event.payload : (event.payload?.toString("utf8") ?? "");
+ if (!raw || !raw.includes('"shell.input"')) return;
+ try {
+ const msg = JSON.parse(raw) as ParsedFrame;
+ if (msg.action !== "shell.input") return;
+ const sessionId = msg.payload?.session_id;
+ const data = msg.payload?.data;
+ if (typeof sessionId === "string" && typeof data === "string") {
+ frames.push({ sessionId, data });
+ }
+ } catch {
+ /* non-JSON frames (binary, etc.) — ignore */
+ }
+ });
+ });
+ return { frames };
+}
diff --git a/apps/web/e2e/pages/mobile-terminal-keybar-page.ts b/apps/web/e2e/pages/mobile-terminal-keybar-page.ts
new file mode 100644
index 0000000000..fc77fbf44b
--- /dev/null
+++ b/apps/web/e2e/pages/mobile-terminal-keybar-page.ts
@@ -0,0 +1,25 @@
+import type { Page, Locator } from "@playwright/test";
+
+export class MobileTerminalKeybarPage {
+ readonly root: Locator;
+ readonly ctrl: Locator;
+ readonly shift: Locator;
+ readonly ctrlC: Locator;
+ readonly ctrlD: Locator;
+
+ constructor(private readonly page: Page) {
+ this.root = page.getByTestId("mobile-terminal-keybar");
+ this.ctrl = page.getByTestId("keybar-key-ctrl");
+ this.shift = page.getByTestId("keybar-key-shift");
+ this.ctrlC = page.getByTestId("keybar-key-ctrl-c");
+ this.ctrlD = page.getByTestId("keybar-key-ctrl-d");
+ }
+
+ key(id: string): Locator {
+ return this.page.getByTestId(`keybar-key-${id}`);
+ }
+
+ async tap(id: string): Promise {
+ await this.key(id).tap();
+ }
+}
diff --git a/apps/web/e2e/tests/terminal/mobile-terminal-keybar.spec.ts b/apps/web/e2e/tests/terminal/mobile-terminal-keybar.spec.ts
new file mode 100644
index 0000000000..95af2e4456
--- /dev/null
+++ b/apps/web/e2e/tests/terminal/mobile-terminal-keybar.spec.ts
@@ -0,0 +1,472 @@
+// Routing: /t/{taskId} (task-keyed). File name starts with "mobile-" so it
+// runs on the mobile-chrome Playwright project (Pixel 5 emulation).
+//
+// These tests model real mobile user flows: every action is a button tap or
+// an OS-keyboard keystroke. We never poke React state directly. Verification
+// is a mix of (a) WS frame capture — a passive observer of `shell.input`
+// requests, and (b) terminal buffer text, which is what the user sees.
+import { type Page, type Locator, expect as baseExpect } from "@playwright/test";
+import { test, expect } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { SessionPage } from "../../pages/session-page";
+import { MobileTerminalKeybarPage } from "../../pages/mobile-terminal-keybar-page";
+import { attachShellInputCapture, type ShellInputFrame } from "../../helpers/ws-capture";
+
+async function seedTaskWithSession(
+ testPage: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+ title: string,
+): Promise {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ title,
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await expect(session.idleInput()).toBeVisible({ timeout: 45_000 });
+ return session;
+}
+
+async function switchToTerminalPanel(testPage: Page): Promise {
+ await testPage.getByRole("button", { name: "Terminal" }).tap();
+}
+
+/**
+ * Wait for the mobile shell to be ready by tailing xterm's buffer until it has
+ * any content (a prompt is enough). Mobile mounts the terminal lazily on tab
+ * switch so this can take longer than desktop.
+ */
+async function waitForShellReady(testPage: Page, timeout = 45_000): Promise {
+ await expect
+ .poll(() => readTerminalBuffer(testPage).then((b) => b.length > 0), {
+ timeout,
+ message: "Waiting for mobile terminal shell to connect",
+ })
+ .toBe(true);
+}
+
+/**
+ * Bring up the OS keyboard the way a user would: tap inside the terminal so
+ * xterm focuses its hidden textarea.
+ */
+async function focusTerminalForTyping(session: SessionPage): Promise {
+ await session.terminal.locator(".xterm").click();
+}
+
+/**
+ * Drives the page's `visualViewport` the way the OS would when the keyboard
+ * opens or the user scrolls with it open. Pure event-firing — nothing about
+ * the keybar's behavior is touched.
+ */
+async function simulateKeyboardOpen(testPage: Page, height: number): Promise {
+ await testPage.evaluate((px) => {
+ const vv = window.visualViewport;
+ if (!vv) return;
+ Object.defineProperty(vv, "height", { configurable: true, value: window.innerHeight - px });
+ vv.dispatchEvent(new Event("resize"));
+ }, height);
+}
+
+async function simulateViewportScroll(testPage: Page, offsetTop: number): Promise {
+ await testPage.evaluate((y) => {
+ const vv = window.visualViewport;
+ if (!vv) return;
+ Object.defineProperty(vv, "offsetTop", { configurable: true, value: y });
+ vv.dispatchEvent(new Event("scroll"));
+ }, offsetTop);
+}
+
+function frameMatching(
+ frames: ShellInputFrame[],
+ predicate: (f: ShellInputFrame) => boolean,
+): ShellInputFrame | undefined {
+ return frames.find(predicate);
+}
+
+async function expectFrame(
+ frames: ShellInputFrame[],
+ data: string,
+ timeout = 5_000,
+): Promise {
+ await baseExpect
+ .poll(() => frameMatching(frames, (f) => f.data === data), {
+ timeout,
+ message: `Expected shell.input frame with data ${JSON.stringify(data)}`,
+ })
+ .toBeTruthy();
+}
+
+async function readTerminalBuffer(page: Page): Promise {
+ return page.evaluate(() => {
+ const panel = document.querySelector('[data-testid="terminal-panel"]');
+ const xtermEl = panel?.querySelector(".xterm");
+ type XC = HTMLElement & { __xtermReadBuffer?: () => string };
+ const container = xtermEl?.parentElement as XC | null | undefined;
+ return container?.__xtermReadBuffer?.() ?? "";
+ });
+}
+
+async function inlineStyleProp(loc: Locator, prop: "top" | "bottom"): Promise {
+ return loc.evaluate((el, p) => (el as HTMLElement).style[p], prop);
+}
+
+test.describe("Mobile terminal key-bar — user flows", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("user cancels a long-running command with the ^C button", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const session = await seedTaskWithSession(testPage, apiClient, seedData, "Keybar ^C cancels");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+
+ await waitForShellReady(testPage);
+ await focusTerminalForTyping(session);
+
+ // User types a command on the OS keyboard, then runs it.
+ await testPage.keyboard.type("sleep 30");
+ await testPage.keyboard.press("Enter");
+
+ // User taps the dedicated ^C button to abort.
+ await keybar.ctrlC.tap();
+
+ // Shells (bash/zsh) echo "^C" when they receive SIGINT — an end-to-end
+ // signal that the WS round-trip and shell forwarding both work.
+ await session.expectTerminalHasText("^C");
+ });
+
+ test("user clears the screen with Ctrl + (OS keyboard) L", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const { frames } = attachShellInputCapture(testPage);
+ const session = await seedTaskWithSession(testPage, apiClient, seedData, "Keybar Ctrl+L");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+ await waitForShellReady(testPage);
+ await focusTerminalForTyping(session);
+
+ // User taps Ctrl on the bar — visible state change.
+ await keybar.ctrl.tap();
+ await expect(keybar.ctrl).toHaveAttribute("aria-pressed", "true");
+
+ // User types "l" on their OS keyboard. The transform applies a Ctrl chord
+ // to whatever single letter comes through xterm's onData.
+ await testPage.keyboard.press("l");
+
+ // The wire saw \x0c (form feed = Ctrl+L), and Ctrl auto-released.
+ await expectFrame(frames, "\x0c");
+ await expect(keybar.ctrl).toHaveAttribute("aria-pressed", "false");
+ });
+
+ test("user double-taps Ctrl for sticky and chords A then E without re-tapping", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const { frames } = attachShellInputCapture(testPage);
+ const session = await seedTaskWithSession(testPage, apiClient, seedData, "Keybar sticky Ctrl");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+ await waitForShellReady(testPage);
+ await focusTerminalForTyping(session);
+
+ // Two taps → sticky.
+ await keybar.ctrl.tap();
+ await keybar.ctrl.tap();
+ await expect(keybar.ctrl).toHaveAttribute("data-sticky", "true");
+
+ // Multiple chords without re-arming.
+ await testPage.keyboard.press("a");
+ await testPage.keyboard.press("e");
+
+ await expectFrame(frames, "\x01"); // Ctrl+A
+ await expectFrame(frames, "\x05"); // Ctrl+E
+ await expect(keybar.ctrl).toHaveAttribute("aria-pressed", "true");
+
+ // Third tap clears the latch entirely.
+ await keybar.ctrl.tap();
+ await expect(keybar.ctrl).toHaveAttribute("aria-pressed", "false");
+ await expect(keybar.ctrl).not.toHaveAttribute("data-sticky", "true");
+ });
+
+ test("user latches Shift then taps Tab on the bar — emits reverse-tab CSI Z", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const { frames } = attachShellInputCapture(testPage);
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar Shift+Tab");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+ await waitForShellReady(testPage);
+
+ await keybar.shift.tap();
+ await expect(keybar.shift).toHaveAttribute("aria-pressed", "true");
+
+ await keybar.tap("tab");
+
+ await expectFrame(frames, "\x1b[Z");
+ await expect(keybar.shift).toHaveAttribute("aria-pressed", "false");
+ });
+
+ test("user presses an OS-keyboard letter while no modifier is active — passes through verbatim", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const { frames } = attachShellInputCapture(testPage);
+ const session = await seedTaskWithSession(testPage, apiClient, seedData, "Keybar passthrough");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+ await waitForShellReady(testPage);
+ await focusTerminalForTyping(session);
+
+ await testPage.keyboard.press("c");
+
+ // No modifier → just the letter.
+ await expectFrame(frames, "c");
+ // No frame for \x03 (which would mean Ctrl was incorrectly applied).
+ expect(frameMatching(frames, (f) => f.data === "\x03")).toBeUndefined();
+ });
+
+ test("first Ctrl tap is visibly active (aria-pressed + ring class) — not just a faint shade", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar Ctrl visual");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+
+ await keybar.ctrl.tap();
+
+ await expect(keybar.ctrl).toHaveAttribute("aria-pressed", "true");
+ const klass = (await keybar.ctrl.getAttribute("class")) ?? "";
+ expect(klass).toContain("ring-");
+ });
+
+ test("no in-bar letter buttons exist — even with Ctrl latched", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar no letters");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+
+ await keybar.ctrl.tap();
+
+ for (const letter of ["a", "c", "d", "z"]) {
+ await expect(testPage.getByTestId(`keybar-key-letter-${letter}`)).toHaveCount(0);
+ }
+ });
+
+ test("bar is hidden on Chat, Files, Plan, and Changes panels", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar hidden non-terminal");
+ const keybar = new MobileTerminalKeybarPage(testPage);
+
+ // Default panel (chat) — hidden.
+ await expect(keybar.root).not.toBeVisible();
+
+ for (const panel of ["Files", "Plan", "Changes"] as const) {
+ await testPage.getByRole("button", { name: panel }).tap();
+ await expect(keybar.root).not.toBeVisible();
+ }
+ });
+
+ test("user navigates command history with the ↑ button", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const { frames } = attachShellInputCapture(testPage);
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar arrow up");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+ await waitForShellReady(testPage);
+
+ await keybar.tap("up");
+
+ await expectFrame(frames, "\x1b[A");
+ });
+
+ test("user taps Esc — sends \\x1b (vim-style insert-mode exit)", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const { frames } = attachShellInputCapture(testPage);
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar Esc");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+ await waitForShellReady(testPage);
+
+ await keybar.tap("esc");
+
+ await expectFrame(frames, "\x1b");
+ });
+
+ test("symbol keys (| ~ / - _) all reach the wire on tap", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const { frames } = attachShellInputCapture(testPage);
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar symbols");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+ await waitForShellReady(testPage);
+
+ for (const [id, ch] of [
+ ["pipe", "|"],
+ ["tilde", "~"],
+ ["slash", "/"],
+ ["dash", "-"],
+ ["underscore", "_"],
+ ] as const) {
+ await keybar.tap(id);
+ await expectFrame(frames, ch);
+ }
+ });
+
+ test("bar switches to top-anchored positioning when the OS keyboard opens", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar position keyboard");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+
+ // Closed: bottom-anchored.
+ expect(await inlineStyleProp(keybar.root, "bottom")).not.toBe("auto");
+
+ // Opening the keyboard switches the bar to top-anchored — the iOS Safari
+ // fix for `position: fixed; bottom:` drift during visual-viewport scroll.
+ await simulateKeyboardOpen(testPage, 300);
+
+ await expect
+ .poll(() => inlineStyleProp(keybar.root, "bottom"), { timeout: 3_000 })
+ .toBe("auto");
+ expect(await inlineStyleProp(keybar.root, "top")).not.toBe("");
+ });
+
+ test("with the keyboard open, the bar tracks the visual viewport on scroll", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar position scroll");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+
+ await simulateKeyboardOpen(testPage, 300);
+ const initialTop = await inlineStyleProp(keybar.root, "top");
+ expect(initialTop).not.toBe("");
+
+ // User scrolls the page — visualViewport.offsetTop changes.
+ await simulateViewportScroll(testPage, 80);
+
+ await expect
+ .poll(() => inlineStyleProp(keybar.root, "top"), { timeout: 3_000 })
+ .not.toBe(initialTop);
+ });
+
+ test("terminal panel reserves bottom space so the bar doesn't cover content", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar terminal padding");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+
+ const panel = testPage.getByTestId("terminal-panel");
+ const padBefore = await panel.evaluate(
+ (el) => parseFloat(getComputedStyle(el).paddingBottom) || 0,
+ );
+ expect(padBefore).toBeGreaterThan(0);
+
+ // Open the keyboard and the panel pads further so its bottom matches the
+ // bar's new top edge instead of being eaten by the keyboard.
+ await simulateKeyboardOpen(testPage, 300);
+
+ await expect
+ .poll(() => panel.evaluate((el) => parseFloat(getComputedStyle(el).paddingBottom) || 0), {
+ timeout: 3_000,
+ })
+ .toBeGreaterThan(padBefore);
+ });
+
+ test("every key button has a non-empty aria-label (a11y)", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await seedTaskWithSession(testPage, apiClient, seedData, "Keybar a11y");
+ await switchToTerminalPanel(testPage);
+
+ const keybar = new MobileTerminalKeybarPage(testPage);
+ await expect(keybar.root).toBeVisible({ timeout: 10_000 });
+
+ const labels = await testPage
+ .locator('[data-testid^="keybar-key-"]')
+ .evaluateAll((nodes) => nodes.map((n) => n.getAttribute("aria-label") ?? ""));
+ expect(labels.length).toBeGreaterThan(5);
+ for (const label of labels) {
+ expect(label.length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-shell-key-sender.ts b/apps/web/hooks/domains/session/use-shell-key-sender.ts
new file mode 100644
index 0000000000..9772614726
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-shell-key-sender.ts
@@ -0,0 +1,16 @@
+import { useCallback } from "react";
+import { sendShellInput } from "@/lib/terminal/send-shell-input";
+
+/**
+ * Returns a sender for shell keystrokes and escape sequences. Applies the
+ * active ctrl/shift modifiers before sending. No-op when no session.
+ */
+export function useShellKeySender(sessionId: string | null | undefined) {
+ return useCallback(
+ (data: string) => {
+ if (!sessionId) return;
+ sendShellInput(sessionId, data);
+ },
+ [sessionId],
+ );
+}
diff --git a/apps/web/hooks/use-visual-viewport-offset.test.ts b/apps/web/hooks/use-visual-viewport-offset.test.ts
new file mode 100644
index 0000000000..34ca6e19bf
--- /dev/null
+++ b/apps/web/hooks/use-visual-viewport-offset.test.ts
@@ -0,0 +1,121 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useVisualViewportOffset } from "./use-visual-viewport-offset";
+
+class MockVisualViewport extends EventTarget {
+ height = 800;
+ width = 400;
+ offsetTop = 0;
+ offsetLeft = 0;
+ pageLeft = 0;
+ pageTop = 0;
+ scale = 1;
+}
+
+function setInnerHeight(h: number) {
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: h, writable: true });
+}
+
+describe("useVisualViewportOffset", () => {
+ let originalVV: VisualViewport | undefined;
+ let vv: MockVisualViewport;
+
+ beforeEach(() => {
+ originalVV = window.visualViewport ?? undefined;
+ vv = new MockVisualViewport();
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: vv,
+ writable: true,
+ });
+ setInnerHeight(800);
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: originalVV,
+ writable: true,
+ });
+ });
+
+ it("returns zero offset when no keyboard is open", () => {
+ const { result } = renderHook(() => useVisualViewportOffset());
+ expect(result.current.bottomOffset).toBe(0);
+ expect(result.current.keyboardOpen).toBe(false);
+ expect(result.current.viewportBottom).toBe(800);
+ });
+
+ it("reports keyboard offset when visualViewport shrinks", () => {
+ const { result } = renderHook(() => useVisualViewportOffset());
+
+ act(() => {
+ vv.height = 500;
+ vv.dispatchEvent(new Event("resize"));
+ });
+
+ expect(result.current.bottomOffset).toBe(300);
+ expect(result.current.keyboardOpen).toBe(true);
+ expect(result.current.viewportBottom).toBe(500);
+ });
+
+ it("viewportBottom tracks offsetTop + height for top-anchored positioning", () => {
+ const { result } = renderHook(() => useVisualViewportOffset());
+ act(() => {
+ vv.height = 600;
+ vv.offsetTop = 50;
+ vv.dispatchEvent(new Event("resize"));
+ });
+ expect(result.current.viewportBottom).toBe(650);
+ });
+
+ it("accounts for offsetTop", () => {
+ const { result } = renderHook(() => useVisualViewportOffset());
+
+ act(() => {
+ vv.height = 600;
+ vv.offsetTop = 50;
+ vv.dispatchEvent(new Event("resize"));
+ });
+
+ // 800 - 600 - 50 = 150
+ expect(result.current.bottomOffset).toBe(150);
+ expect(result.current.keyboardOpen).toBe(true);
+ });
+
+ it("stays below the keyboard-open threshold for small offsets", () => {
+ const { result } = renderHook(() => useVisualViewportOffset());
+
+ act(() => {
+ vv.height = 750; // 50px offset, under 80 threshold
+ vv.dispatchEvent(new Event("resize"));
+ });
+
+ expect(result.current.bottomOffset).toBe(50);
+ expect(result.current.keyboardOpen).toBe(false);
+ });
+
+ it("stops updating after unmount", () => {
+ const { result, unmount } = renderHook(() => useVisualViewportOffset());
+ unmount();
+
+ act(() => {
+ vv.height = 400;
+ vv.dispatchEvent(new Event("resize"));
+ });
+
+ expect(result.current.bottomOffset).toBe(0);
+ });
+
+ it("returns zeros when visualViewport is unavailable", () => {
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: undefined,
+ writable: true,
+ });
+ const { result } = renderHook(() => useVisualViewportOffset());
+ expect(result.current.bottomOffset).toBe(0);
+ expect(result.current.keyboardOpen).toBe(false);
+ expect(result.current.viewportBottom).toBe(0);
+ });
+});
diff --git a/apps/web/hooks/use-visual-viewport-offset.ts b/apps/web/hooks/use-visual-viewport-offset.ts
new file mode 100644
index 0000000000..8ff3e2ca22
--- /dev/null
+++ b/apps/web/hooks/use-visual-viewport-offset.ts
@@ -0,0 +1,56 @@
+import { useEffect, useState } from "react";
+
+export type VisualViewportOffset = {
+ /** Pixels between the bottom of `visualViewport` and the bottom of the layout viewport. */
+ bottomOffset: number;
+ /** True when the visual viewport is noticeably shorter than the layout viewport (virtual keyboard open). */
+ keyboardOpen: boolean;
+ /** Y-coordinate of the visual viewport's bottom edge in the layout viewport — `vv.offsetTop + vv.height`. Use with `position: fixed; top:` to pin an element above the keyboard reliably on iOS even during scrolls. */
+ viewportBottom: number;
+};
+
+const KEYBOARD_THRESHOLD_PX = 80;
+
+function readOffset(): VisualViewportOffset {
+ if (typeof window === "undefined" || !window.visualViewport) {
+ return { bottomOffset: 0, keyboardOpen: false, viewportBottom: 0 };
+ }
+ const vv = window.visualViewport;
+ const bottomOffset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
+ return {
+ bottomOffset,
+ keyboardOpen: bottomOffset > KEYBOARD_THRESHOLD_PX,
+ viewportBottom: vv.offsetTop + vv.height,
+ };
+}
+
+/**
+ * Tracks the visual viewport's bottom offset relative to the layout viewport so
+ * floating mobile UI (e.g., terminal key-bar) can dock above the on-screen
+ * keyboard. Returns zeros on the server and on browsers without
+ * `window.visualViewport`.
+ */
+export function useVisualViewportOffset(): VisualViewportOffset {
+ const [offset, setOffset] = useState(() => ({
+ bottomOffset: 0,
+ keyboardOpen: false,
+ viewportBottom: 0,
+ }));
+
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.visualViewport) return;
+ const vv = window.visualViewport;
+ const update = () => setOffset(readOffset());
+ update();
+ vv.addEventListener("resize", update);
+ vv.addEventListener("scroll", update);
+ window.addEventListener("resize", update);
+ return () => {
+ vv.removeEventListener("resize", update);
+ vv.removeEventListener("scroll", update);
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+
+ return offset;
+}
diff --git a/apps/web/lib/terminal/apply-shell-modifiers.test.ts b/apps/web/lib/terminal/apply-shell-modifiers.test.ts
new file mode 100644
index 0000000000..efbc8d9aac
--- /dev/null
+++ b/apps/web/lib/terminal/apply-shell-modifiers.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from "vitest";
+import { applyShellModifiers } from "./apply-shell-modifiers";
+
+const OFF = { ctrl: false, shift: false };
+
+describe("applyShellModifiers", () => {
+ it("passes data through when no modifier is active", () => {
+ expect(applyShellModifiers("c", OFF)).toBe("c");
+ expect(applyShellModifiers("\t", OFF)).toBe("\t");
+ expect(applyShellModifiers("hello", OFF)).toBe("hello");
+ });
+
+ it("maps ctrl + lowercase letter to control byte", () => {
+ expect(applyShellModifiers("c", { ctrl: true, shift: false })).toBe("\x03");
+ expect(applyShellModifiers("a", { ctrl: true, shift: false })).toBe("\x01");
+ expect(applyShellModifiers("z", { ctrl: true, shift: false })).toBe("\x1a");
+ });
+
+ it("maps ctrl + uppercase letter to control byte", () => {
+ expect(applyShellModifiers("C", { ctrl: true, shift: false })).toBe("\x03");
+ expect(applyShellModifiers("Z", { ctrl: true, shift: false })).toBe("\x1a");
+ });
+
+ it("passes ctrl + non-letter through unchanged", () => {
+ expect(applyShellModifiers("1", { ctrl: true, shift: false })).toBe("1");
+ expect(applyShellModifiers("\t", { ctrl: true, shift: false })).toBe("\t");
+ });
+
+ it("does not transform multi-char input even when ctrl is active", () => {
+ expect(applyShellModifiers("hello", { ctrl: true, shift: false })).toBe("hello");
+ });
+
+ it("maps shift + tab to CSI Z (reverse tab)", () => {
+ expect(applyShellModifiers("\t", { ctrl: false, shift: true })).toBe("\x1b[Z");
+ });
+
+ it("passes shift + non-tab letters through unchanged", () => {
+ expect(applyShellModifiers("c", { ctrl: false, shift: true })).toBe("c");
+ });
+
+ it("ctrl takes precedence over shift for letters", () => {
+ expect(applyShellModifiers("c", { ctrl: true, shift: true })).toBe("\x03");
+ });
+});
diff --git a/apps/web/lib/terminal/apply-shell-modifiers.ts b/apps/web/lib/terminal/apply-shell-modifiers.ts
new file mode 100644
index 0000000000..f8f1928e64
--- /dev/null
+++ b/apps/web/lib/terminal/apply-shell-modifiers.ts
@@ -0,0 +1,18 @@
+/**
+ * Transform a single input chunk based on active modifier state.
+ *
+ * - ctrl + single a-z/A-Z → the corresponding control byte (`\x01`..`\x1a`)
+ * - shift + tab → CSI Z (reverse tab / backtab)
+ *
+ * Only single-character data is transformed — multi-char paste / IME output
+ * passes through unchanged to avoid mangling dictated or autocompleted text.
+ */
+export function applyShellModifiers(data: string, mods: { ctrl: boolean; shift: boolean }): string {
+ if (mods.ctrl && data.length === 1) {
+ const code = data.charCodeAt(0);
+ if (code >= 97 && code <= 122) return String.fromCharCode(code - 96);
+ if (code >= 65 && code <= 90) return String.fromCharCode(code - 64);
+ }
+ if (mods.shift && data === "\t") return "\x1b[Z";
+ return data;
+}
diff --git a/apps/web/lib/terminal/key-sequences.test.ts b/apps/web/lib/terminal/key-sequences.test.ts
new file mode 100644
index 0000000000..c6db785b66
--- /dev/null
+++ b/apps/web/lib/terminal/key-sequences.test.ts
@@ -0,0 +1,31 @@
+import { describe, it, expect } from "vitest";
+import { KEY_SEQUENCES } from "./key-sequences";
+
+describe("KEY_SEQUENCES", () => {
+ it("maps Ctrl+C to \\x03", () => {
+ expect(KEY_SEQUENCES.ctrlC).toBe("\x03");
+ });
+
+ it("maps Ctrl+D to \\x04", () => {
+ expect(KEY_SEQUENCES.ctrlD).toBe("\x04");
+ });
+
+ it("maps Esc / Tab", () => {
+ expect(KEY_SEQUENCES.esc).toBe("\x1b");
+ expect(KEY_SEQUENCES.tab).toBe("\t");
+ });
+
+ it("maps arrow keys", () => {
+ expect(KEY_SEQUENCES.up).toBe("\x1b[A");
+ expect(KEY_SEQUENCES.down).toBe("\x1b[B");
+ expect(KEY_SEQUENCES.right).toBe("\x1b[C");
+ expect(KEY_SEQUENCES.left).toBe("\x1b[D");
+ });
+
+ it("maps Home / End / PgUp / PgDn", () => {
+ expect(KEY_SEQUENCES.home).toBe("\x01");
+ expect(KEY_SEQUENCES.end).toBe("\x05");
+ expect(KEY_SEQUENCES.pageUp).toBe("\x1b[5~");
+ expect(KEY_SEQUENCES.pageDown).toBe("\x1b[6~");
+ });
+});
diff --git a/apps/web/lib/terminal/key-sequences.ts b/apps/web/lib/terminal/key-sequences.ts
new file mode 100644
index 0000000000..ecbc8c8541
--- /dev/null
+++ b/apps/web/lib/terminal/key-sequences.ts
@@ -0,0 +1,23 @@
+/**
+ * ANSI escape / control sequences for common terminal keys.
+ *
+ * Note: `home` / `end` send readline shortcuts (Ctrl+A / Ctrl+E) rather than
+ * the VT sequences (`\x1b[H` / `\x1b[F`). This matches what most shells expect
+ * for line-start / line-end navigation; full-screen apps like vim/nano that
+ * key off VT sequences will not interpret these as Home/End.
+ */
+export const KEY_SEQUENCES = {
+ esc: "\x1b",
+ tab: "\t",
+ up: "\x1b[A",
+ down: "\x1b[B",
+ right: "\x1b[C",
+ left: "\x1b[D",
+ home: "\x01",
+ end: "\x05",
+ pageUp: "\x1b[5~",
+ pageDown: "\x1b[6~",
+ ctrlC: "\x03",
+ ctrlD: "\x04",
+ ctrlZ: "\x1a",
+} as const;
diff --git a/apps/web/lib/terminal/refocus-xterm.test.ts b/apps/web/lib/terminal/refocus-xterm.test.ts
new file mode 100644
index 0000000000..18ec8e9090
--- /dev/null
+++ b/apps/web/lib/terminal/refocus-xterm.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { refocusXtermTextarea } from "./refocus-xterm";
+
+describe("refocusXtermTextarea", () => {
+ let textarea: HTMLTextAreaElement;
+
+ beforeEach(() => {
+ textarea = document.createElement("textarea");
+ textarea.className = "xterm-helper-textarea";
+ document.body.appendChild(textarea);
+ });
+
+ afterEach(() => {
+ textarea.remove();
+ });
+
+ it("focuses the xterm helper textarea", () => {
+ const other = document.createElement("input");
+ document.body.appendChild(other);
+ other.focus();
+ expect(document.activeElement).toBe(other);
+
+ refocusXtermTextarea();
+ expect(document.activeElement).toBe(textarea);
+ other.remove();
+ });
+
+ it("does not throw when no xterm textarea is mounted", () => {
+ textarea.remove();
+ expect(() => refocusXtermTextarea()).not.toThrow();
+ });
+});
diff --git a/apps/web/lib/terminal/refocus-xterm.ts b/apps/web/lib/terminal/refocus-xterm.ts
new file mode 100644
index 0000000000..fe71c10c48
--- /dev/null
+++ b/apps/web/lib/terminal/refocus-xterm.ts
@@ -0,0 +1,13 @@
+/**
+ * Synchronously re-focus xterm.js's hidden textarea after a virtual-key tap.
+ *
+ * Why: on iOS Safari, tapping a button in a virtual key-bar can dismiss the
+ * soft keyboard even with preventDefault on pointerdown/mousedown, because
+ * WebKit transfers focus on touchend. Calling .focus() on the xterm textarea
+ * inside the same user gesture (the onClick handler) re-shows the keyboard.
+ */
+export function refocusXtermTextarea(): void {
+ if (typeof document === "undefined") return;
+ const textarea = document.querySelector(".xterm-helper-textarea");
+ textarea?.focus({ preventScroll: true });
+}
diff --git a/apps/web/lib/terminal/send-shell-input.test.ts b/apps/web/lib/terminal/send-shell-input.test.ts
new file mode 100644
index 0000000000..8009ee8eea
--- /dev/null
+++ b/apps/web/lib/terminal/send-shell-input.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { useShellModifiersStore } from "./shell-modifiers";
+
+const sendMock = vi.fn();
+let clientFactory: () => { send: typeof sendMock } | null = () => ({ send: sendMock });
+
+vi.mock("@/lib/ws/connection", () => ({
+ getWebSocketClient: () => clientFactory(),
+}));
+
+// Imported after the mock so it picks up the mocked module.
+const { sendShellInput } = await import("./send-shell-input");
+
+const SESSION = "sess-1";
+
+beforeEach(() => {
+ sendMock.mockReset();
+ clientFactory = () => ({ send: sendMock });
+ useShellModifiersStore.getState().reset();
+});
+
+function lastFrameData(): string | undefined {
+ const call = sendMock.mock.calls.at(-1)?.[0] as { payload?: { data?: string } } | undefined;
+ return call?.payload?.data;
+}
+
+describe("sendShellInput", () => {
+ it("no-ops on empty data", () => {
+ sendShellInput(SESSION, "");
+ expect(sendMock).not.toHaveBeenCalled();
+ });
+
+ it("sends raw data and shapes the WS frame", () => {
+ sendShellInput(SESSION, "ls");
+ expect(sendMock).toHaveBeenCalledWith({
+ type: "request",
+ action: "shell.input",
+ payload: { session_id: SESSION, data: "ls" },
+ });
+ });
+
+ it("applies the Ctrl chord transform when Ctrl is latched and consumes it", () => {
+ useShellModifiersStore.getState().toggleCtrl();
+ sendShellInput(SESSION, "c");
+ expect(lastFrameData()).toBe("\x03");
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: false, sticky: false });
+ });
+
+ it("keeps Ctrl latched when sticky after a chord", () => {
+ useShellModifiersStore.getState().toggleCtrl();
+ useShellModifiersStore.getState().toggleCtrl();
+ expect(useShellModifiersStore.getState().ctrl.sticky).toBe(true);
+
+ sendShellInput(SESSION, "a");
+ expect(lastFrameData()).toBe("\x01");
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: true, sticky: true });
+ });
+
+ it("translates Shift+Tab to CSI Z and consumes Shift", () => {
+ useShellModifiersStore.getState().toggleShift();
+ sendShellInput(SESSION, "\t");
+ expect(lastFrameData()).toBe("\x1b[Z");
+ expect(useShellModifiersStore.getState().shift).toEqual({ latched: false, sticky: false });
+ });
+
+ it("Shift on a non-Tab character is a passthrough; modifier still consumes", () => {
+ useShellModifiersStore.getState().toggleShift();
+ sendShellInput(SESSION, "a");
+ expect(lastFrameData()).toBe("a");
+ expect(useShellModifiersStore.getState().shift).toEqual({ latched: false, sticky: false });
+ });
+
+ it("when the WS client is null: nothing sent AND modifiers stay armed", () => {
+ clientFactory = () => null;
+ useShellModifiersStore.getState().toggleCtrl();
+ sendShellInput(SESSION, "c");
+ expect(sendMock).not.toHaveBeenCalled();
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: true, sticky: false });
+ });
+});
diff --git a/apps/web/lib/terminal/send-shell-input.ts b/apps/web/lib/terminal/send-shell-input.ts
new file mode 100644
index 0000000000..0531398409
--- /dev/null
+++ b/apps/web/lib/terminal/send-shell-input.ts
@@ -0,0 +1,28 @@
+import { getWebSocketClient } from "@/lib/ws/connection";
+import { applyShellModifiers } from "./apply-shell-modifiers";
+import { useShellModifiersStore, isActive } from "./shell-modifiers";
+
+/**
+ * Single entry point for shell input. Applies active ctrl/shift modifiers,
+ * sends over the WS, then consumes latched (non-sticky) modifiers.
+ *
+ * Used by both the virtual key-bar and xterm's own `onData` callback so that
+ * a Ctrl latch set from the key-bar modifies the next character the user
+ * types on the OS keyboard — which is the whole point of the modifier.
+ */
+export function sendShellInput(sessionId: string, data: string): void {
+ if (!data) return;
+ const client = getWebSocketClient();
+ if (!client) return; // keep modifiers armed; the user can retry once reconnected
+ const store = useShellModifiersStore.getState();
+ const ctrlActive = isActive(store.ctrl);
+ const shiftActive = isActive(store.shift);
+ const transformed = applyShellModifiers(data, { ctrl: ctrlActive, shift: shiftActive });
+ client.send({
+ type: "request",
+ action: "shell.input",
+ payload: { session_id: sessionId, data: transformed },
+ });
+ if (ctrlActive) store.consumeCtrl();
+ if (shiftActive) store.consumeShift();
+}
diff --git a/apps/web/lib/terminal/shell-modifiers.test.ts b/apps/web/lib/terminal/shell-modifiers.test.ts
new file mode 100644
index 0000000000..7141a3e018
--- /dev/null
+++ b/apps/web/lib/terminal/shell-modifiers.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { useShellModifiersStore, isActive } from "./shell-modifiers";
+
+beforeEach(() => {
+ useShellModifiersStore.getState().reset();
+});
+
+describe("shell-modifiers store", () => {
+ it("toggles ctrl through off → latched → sticky → off", () => {
+ const { toggleCtrl } = useShellModifiersStore.getState();
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: false, sticky: false });
+
+ toggleCtrl();
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: true, sticky: false });
+
+ toggleCtrl();
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: true, sticky: true });
+
+ toggleCtrl();
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: false, sticky: false });
+ });
+
+ it("consumeCtrl clears a latched (non-sticky) modifier", () => {
+ useShellModifiersStore.getState().toggleCtrl();
+ useShellModifiersStore.getState().consumeCtrl();
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: false, sticky: false });
+ });
+
+ it("consumeCtrl is a no-op when sticky", () => {
+ useShellModifiersStore.getState().toggleCtrl();
+ useShellModifiersStore.getState().toggleCtrl();
+ useShellModifiersStore.getState().consumeCtrl();
+ expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: true, sticky: true });
+ });
+
+ it("shift mirrors ctrl behavior", () => {
+ const { toggleShift, consumeShift } = useShellModifiersStore.getState();
+ toggleShift();
+ expect(useShellModifiersStore.getState().shift.latched).toBe(true);
+ consumeShift();
+ expect(useShellModifiersStore.getState().shift.latched).toBe(false);
+ });
+
+ it("isActive returns true for latched or sticky", () => {
+ expect(isActive({ latched: false, sticky: false })).toBe(false);
+ expect(isActive({ latched: true, sticky: false })).toBe(true);
+ expect(isActive({ latched: false, sticky: true })).toBe(true);
+ expect(isActive({ latched: true, sticky: true })).toBe(true);
+ });
+});
diff --git a/apps/web/lib/terminal/shell-modifiers.ts b/apps/web/lib/terminal/shell-modifiers.ts
new file mode 100644
index 0000000000..305d571b93
--- /dev/null
+++ b/apps/web/lib/terminal/shell-modifiers.ts
@@ -0,0 +1,46 @@
+import { create } from "zustand";
+
+export type ModifierState = { latched: boolean; sticky: boolean };
+
+export type ShellModifiersStore = {
+ ctrl: ModifierState;
+ shift: ModifierState;
+ toggleCtrl: () => void;
+ toggleShift: () => void;
+ consumeCtrl: () => void;
+ consumeShift: () => void;
+ reset: () => void;
+};
+
+const INITIAL: ModifierState = { latched: false, sticky: false };
+
+function nextToggle(cur: ModifierState): ModifierState {
+ if (cur.sticky) return { latched: false, sticky: false };
+ if (cur.latched) return { latched: true, sticky: true };
+ return { latched: true, sticky: false };
+}
+
+function nextConsume(cur: ModifierState): ModifierState {
+ if (cur.sticky) return cur;
+ return { latched: false, sticky: false };
+}
+
+/**
+ * Transient, module-level modifier state for the mobile terminal key-bar.
+ * Only one terminal is typed into at a time, so per-session scoping isn't
+ * needed. Shared between `MobileTerminalKeybar` (UI + toggles) and the xterm
+ * `onData` path (consumes on every keystroke).
+ */
+export const useShellModifiersStore = create((set) => ({
+ ctrl: INITIAL,
+ shift: INITIAL,
+ toggleCtrl: () => set((s) => ({ ctrl: nextToggle(s.ctrl) })),
+ toggleShift: () => set((s) => ({ shift: nextToggle(s.shift) })),
+ consumeCtrl: () => set((s) => ({ ctrl: nextConsume(s.ctrl) })),
+ consumeShift: () => set((s) => ({ shift: nextConsume(s.shift) })),
+ reset: () => set({ ctrl: INITIAL, shift: INITIAL }),
+}));
+
+export function isActive(m: ModifierState): boolean {
+ return m.latched || m.sticky;
+}
diff --git a/apps/web/lib/terminal/suppress-ios-keyboard-assists.test.ts b/apps/web/lib/terminal/suppress-ios-keyboard-assists.test.ts
new file mode 100644
index 0000000000..90a4e587a8
--- /dev/null
+++ b/apps/web/lib/terminal/suppress-ios-keyboard-assists.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import { suppressIOSKeyboardAssists } from "./suppress-ios-keyboard-assists";
+
+function hostWithTextarea(): { host: HTMLElement; textarea: HTMLTextAreaElement } {
+ const host = document.createElement("div");
+ const textarea = document.createElement("textarea");
+ textarea.className = "xterm-helper-textarea";
+ host.appendChild(textarea);
+ return { host, textarea };
+}
+
+describe("suppressIOSKeyboardAssists", () => {
+ it("signals password managers and iOS to skip the xterm textarea for autofill", () => {
+ const { host, textarea } = hostWithTextarea();
+ suppressIOSKeyboardAssists(host);
+ expect(textarea.getAttribute("autocomplete")).toBe("off");
+ expect(textarea.getAttribute("name")).toBe("__xterm_stdin__");
+ expect(textarea.getAttribute("data-form-type")).toBe("other");
+ expect(textarea.getAttribute("data-lpignore")).toBe("true");
+ expect(textarea.getAttribute("data-1p-ignore")).toBe("true");
+ expect(textarea.getAttribute("data-bwignore")).toBe("true");
+ });
+
+ it("is a no-op when the host has no xterm textarea", () => {
+ const host = document.createElement("div");
+ expect(() => suppressIOSKeyboardAssists(host)).not.toThrow();
+ });
+});
diff --git a/apps/web/lib/terminal/suppress-ios-keyboard-assists.ts b/apps/web/lib/terminal/suppress-ios-keyboard-assists.ts
new file mode 100644
index 0000000000..d8a23b8e4c
--- /dev/null
+++ b/apps/web/lib/terminal/suppress-ios-keyboard-assists.ts
@@ -0,0 +1,21 @@
+/**
+ * Signal to iOS / password managers that xterm's helper textarea isn't a
+ * fillable form field, so the AutoFill suggestion bar above the keyboard
+ * doesn't appear.
+ *
+ * xterm already sets autocorrect/autocapitalize/spellcheck itself, but that
+ * isn't enough — iOS still heuristically shows the AutoFill bar for generic
+ * textareas. Adding `autocomplete="off"`, an opaque `name`, and the password-
+ * manager ignore hints (1Password, LastPass, Bitwarden) makes iOS and those
+ * extensions skip the field. Safe no-op on desktop.
+ */
+export function suppressIOSKeyboardAssists(host: HTMLElement): void {
+ const textarea = host.querySelector(".xterm-helper-textarea");
+ if (!textarea) return;
+ textarea.setAttribute("autocomplete", "off");
+ textarea.setAttribute("name", "__xterm_stdin__");
+ textarea.setAttribute("data-form-type", "other");
+ textarea.setAttribute("data-lpignore", "true");
+ textarea.setAttribute("data-1p-ignore", "true");
+ textarea.setAttribute("data-bwignore", "true");
+}