From aa70b7dfa47a436281e33645bc0291b99e1c92c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:05:35 +0100 Subject: [PATCH] =?UTF-8?q?feat(web):=20mobile=20terminal=20key-bar=20?= =?UTF-8?q?=E2=80=94=20Ctrl/Shift=20modify=20OS-keyboard=20input=20+=20iOS?= =?UTF-8?q?=20keyboard=20fixes=20(#741)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add mobile terminal key-bar with sticky Ctrl modifier Mobile software keyboards lack Ctrl, Esc, Tab, arrows, and shell symbols, so users had no way to interrupt a running command (Ctrl+C), escape an editor, or navigate the shell. Adds a horizontally scrolling key-bar above the virtual keyboard — sticky Ctrl modifier, dedicated Ctrl+C / Ctrl+D buttons, and visualViewport-based docking so the bar tracks the OS keyboard as it opens and closes. * test(web): add E2E coverage for mobile terminal key-bar Nine Playwright tests on the mobile-chrome project cover visibility gating, key emission verified via captured WS frames, the sticky Ctrl state machine, dedicated Ctrl+C / Ctrl+D buttons, keyboard-aware docking, accessibility, and an end-to-end ^C echo to prove the shell path works. Adds a reusable shell.input frame capture helper and a small page-object for the key-bar. * feat(web): shared shell input helpers for the mobile terminal Introduces a small toolkit the next commit will wire into the mobile key-bar: - `refocusXtermTextarea` — re-focuses xterm's hidden textarea after a virtual-key tap so iOS Safari doesn't dismiss the soft keyboard. - `suppressIOSKeyboardAssists` — strips name/autocomplete/data-* hints on the textarea so iOS / 1Password / LastPass / Bitwarden skip the AutoFill suggestion bar. - `useShellModifiersStore` (Zustand) — module-level Ctrl/Shift latch state with toggle (off → latched → sticky → off) and consume actions. - `applyShellModifiers` — pure transform: ctrl + a-z → control byte; shift + tab → CSI Z (reverse tab); single-char only. - `sendShellInput` — single entry point that reads the store, applies the transform, sends over WS, and consumes latched modifiers. `useShellKeySender` and the xterm `onData` path in `shell-terminal.tsx` now route through `sendShellInput` so any future modifier state propagates uniformly. `suppressIOSKeyboardAssists` is wired into `useTerminalInit` immediately after `terminal.open()`. * feat(web): mobile key-bar pivots to OS-keyboard input + pins to keyboard Reworks the mobile terminal key-bar around two insights: 1. The OS soft keyboard already provides letters — duplicating them in-bar was confusing and didn't address the real problem (Ctrl couldn't modify what the user typed via the OS keyboard, because that input went straight to xterm and bypassed React state). 2. iOS Safari `position: fixed; bottom:` drifts during visual-viewport scroll while the keyboard is open. Anchoring via `top` tied to the visual viewport's bottom edge stays glued. Changes: - `MobileTerminalKeybar` reads Ctrl/Shift state from the shared `useShellModifiersStore` and toggles it on tap. The actual chord transform now happens in `sendShellInput`, which both the bar and xterm's `onData` path call — so a Ctrl latch set from the bar modifies the next character typed on the OS keyboard. - Drops `LETTER_KEYS` and `chordChar` from the bar; adds a Shift toggle (Shift+Tab → reverse-tab from the bar; pairs with the modifier transform for OS-keyboard taps). - Latched state now shows the same ring as sticky (with sticky using a stronger `ring-primary`), so the first tap is visibly active. - `touch-action: manipulation` on each button so taps in the `overflow-x-auto` row don't get reinterpreted as scroll. - `useVisualViewportOffset` exports `viewportBottom = offsetTop + height`. When the keyboard is open the bar uses `top: viewportBottom - KEYBAR_HEIGHT_PX` and `bottom: auto`; closed, it keeps the `bottom`-based positioning so it sits above the bottom nav. - `SessionMobileLayout` computes the terminal panel's `paddingBottom` so terminal content's bottom edge sits exactly at the bar's top edge whether the keyboard is up or not. Tests cover the new modifier flow, Shift+Tab, the absence of in-bar letter buttons, and the `top`-anchored positioning when the keyboard is reported as open. * test(web): rewrite mobile key-bar e2e around real user flows The prior e2e leaned on in-bar letter buttons that no longer exist. Rewrites the spec around the journeys a mobile user actually performs: tap ^C to cancel, latch Ctrl + type a letter on the OS keyboard, sticky Ctrl across multiple chords, Shift+Tab from the bar, history nav with the arrow button, Esc, symbol keys, and the iOS positioning fixes (top-anchored when the keyboard opens, tracks visual viewport scroll, terminal panel reserves space for the bar). Adds a `shift` getter to the page object. * style(web): apply prettier formatting * fix(web): keep modifiers armed on dropped sends + tighten input handler Addresses bot review on PR #741: - `sendShellInput` no longer consumes Ctrl/Shift when the WS client isn't available — the chord stays armed so the user can retry once reconnected instead of seeing the latch silently clear. - Adds `send-shell-input.test.ts` with the matrix the helper needs: empty data, ctrl chord + auto-release, sticky retention, shift+tab → CSI Z, shift passthrough on non-tab, and the WS-disconnected no-send/no-consume path. - Removes dead `ctrlChord` export and its tests — the only consumer was its own test, and `applyShellModifiers` covers the same transform end-to-end. - Drops the unused `send` parameter from `useShellInputHandler` (the effect now goes through `sendShellInput` directly), removing a spurious dep that would otherwise re-fire the effect on identity changes. - Documents that `KEY_SEQUENCES.home`/`end` are intentionally readline shortcuts (Ctrl+A/E), not VT Home/End sequences, so future readers don't mistake it for a bug. * refactor(web): split mobile key-bar helpers, tighten null-viewport test Addresses two non-blocking suggestions from PR #741 review: - Move `KeyDef`, `KEYS`, `ModifierButton`, and `KeybarButton` out of `mobile-terminal-keybar.tsx` into a sibling helpers file. The main component drops from 213 to 115 lines, comfortably under the 200-line component convention. - Assert `viewportBottom === 0` in the null-viewport branch of `use-visual-viewport-offset.test.ts` so all three return-shape fields are covered there. --- .../mobile/mobile-terminal-keybar-helpers.tsx | 104 ++++ .../mobile/mobile-terminal-keybar.test.tsx | 251 ++++++++++ .../task/mobile/mobile-terminal-keybar.tsx | 115 +++++ .../task/mobile/session-mobile-layout.tsx | 23 +- apps/web/components/task/shell-terminal.tsx | 12 +- apps/web/e2e/helpers/ws-capture.ts | 45 ++ .../e2e/pages/mobile-terminal-keybar-page.ts | 25 + .../terminal/mobile-terminal-keybar.spec.ts | 472 ++++++++++++++++++ .../domains/session/use-shell-key-sender.ts | 16 + .../hooks/use-visual-viewport-offset.test.ts | 121 +++++ apps/web/hooks/use-visual-viewport-offset.ts | 56 +++ .../terminal/apply-shell-modifiers.test.ts | 44 ++ .../web/lib/terminal/apply-shell-modifiers.ts | 18 + apps/web/lib/terminal/key-sequences.test.ts | 31 ++ apps/web/lib/terminal/key-sequences.ts | 23 + apps/web/lib/terminal/refocus-xterm.test.ts | 32 ++ apps/web/lib/terminal/refocus-xterm.ts | 13 + .../web/lib/terminal/send-shell-input.test.ts | 80 +++ apps/web/lib/terminal/send-shell-input.ts | 28 ++ apps/web/lib/terminal/shell-modifiers.test.ts | 50 ++ apps/web/lib/terminal/shell-modifiers.ts | 46 ++ .../suppress-ios-keyboard-assists.test.ts | 28 ++ .../terminal/suppress-ios-keyboard-assists.ts | 21 + 23 files changed, 1648 insertions(+), 6 deletions(-) create mode 100644 apps/web/components/task/mobile/mobile-terminal-keybar-helpers.tsx create mode 100644 apps/web/components/task/mobile/mobile-terminal-keybar.test.tsx create mode 100644 apps/web/components/task/mobile/mobile-terminal-keybar.tsx create mode 100644 apps/web/e2e/helpers/ws-capture.ts create mode 100644 apps/web/e2e/pages/mobile-terminal-keybar-page.ts create mode 100644 apps/web/e2e/tests/terminal/mobile-terminal-keybar.spec.ts create mode 100644 apps/web/hooks/domains/session/use-shell-key-sender.ts create mode 100644 apps/web/hooks/use-visual-viewport-offset.test.ts create mode 100644 apps/web/hooks/use-visual-viewport-offset.ts create mode 100644 apps/web/lib/terminal/apply-shell-modifiers.test.ts create mode 100644 apps/web/lib/terminal/apply-shell-modifiers.ts create mode 100644 apps/web/lib/terminal/key-sequences.test.ts create mode 100644 apps/web/lib/terminal/key-sequences.ts create mode 100644 apps/web/lib/terminal/refocus-xterm.test.ts create mode 100644 apps/web/lib/terminal/refocus-xterm.ts create mode 100644 apps/web/lib/terminal/send-shell-input.test.ts create mode 100644 apps/web/lib/terminal/send-shell-input.ts create mode 100644 apps/web/lib/terminal/shell-modifiers.test.ts create mode 100644 apps/web/lib/terminal/shell-modifiers.ts create mode 100644 apps/web/lib/terminal/suppress-ios-keyboard-assists.test.ts create mode 100644 apps/web/lib/terminal/suppress-ios-keyboard-assists.ts diff --git a/apps/web/components/task/mobile/mobile-terminal-keybar-helpers.tsx b/apps/web/components/task/mobile/mobile-terminal-keybar-helpers.tsx new file mode 100644 index 0000000000..7946b14f31 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminal-keybar-helpers.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { type ReactNode } from "react"; +import { Button } from "@kandev/ui/button"; +import { cn } from "@/lib/utils"; +import { KEY_SEQUENCES } from "@/lib/terminal/key-sequences"; +import { isActive } from "@/lib/terminal/shell-modifiers"; + +export type KeyDef = { + /** Stable id used for data-testid (e.g. `keybar-key-esc`). */ + id: string; + /** Rendered button label. */ + label: ReactNode; + /** Accessible label. */ + ariaLabel: string; + /** Sequence emitted on tap. Ctrl/Shift transforms are applied downstream. */ + seq: string; +}; + +export const KEYS: readonly KeyDef[] = [ + { id: "esc", label: "Esc", ariaLabel: "Escape", seq: KEY_SEQUENCES.esc }, + { id: "tab", label: "Tab", ariaLabel: "Tab", seq: KEY_SEQUENCES.tab }, + { id: "up", label: "↑", ariaLabel: "Arrow Up", seq: KEY_SEQUENCES.up }, + { id: "down", label: "↓", ariaLabel: "Arrow Down", seq: KEY_SEQUENCES.down }, + { id: "left", label: "←", ariaLabel: "Arrow Left", seq: KEY_SEQUENCES.left }, + { id: "right", label: "→", ariaLabel: "Arrow Right", seq: KEY_SEQUENCES.right }, + { id: "home", label: "Home", ariaLabel: "Home", seq: KEY_SEQUENCES.home }, + { id: "end", label: "End", ariaLabel: "End", seq: KEY_SEQUENCES.end }, + { id: "pageup", label: "PgUp", ariaLabel: "Page Up", seq: KEY_SEQUENCES.pageUp }, + { id: "pagedown", label: "PgDn", ariaLabel: "Page Down", seq: KEY_SEQUENCES.pageDown }, + { id: "pipe", label: "|", ariaLabel: "Pipe", seq: "|" }, + { id: "tilde", label: "~", ariaLabel: "Tilde", seq: "~" }, + { id: "slash", label: "/", ariaLabel: "Slash", seq: "/" }, + { id: "dash", label: "-", ariaLabel: "Dash", seq: "-" }, + { id: "underscore", label: "_", ariaLabel: "Underscore", seq: "_" }, +]; + +type KeybarButtonProps = { + id: string; + ariaLabel: string; + ariaPressed?: boolean; + active?: boolean; + sticky?: boolean; + variant?: "default" | "destructive"; + onTap: () => void; + children: ReactNode; +}; + +export function KeybarButton({ + id, + ariaLabel, + ariaPressed, + active, + sticky, + variant, + onTap, + children, +}: KeybarButtonProps) { + return ( + + ); +} + +type ModifierButtonProps = { + id: string; + label: string; + ariaLabel: string; + state: { latched: boolean; sticky: boolean }; + onTap: () => void; +}; + +export function ModifierButton({ id, label, ariaLabel, state, onTap }: ModifierButtonProps) { + return ( + + {label} + + ); +} diff --git a/apps/web/components/task/mobile/mobile-terminal-keybar.test.tsx b/apps/web/components/task/mobile/mobile-terminal-keybar.test.tsx new file mode 100644 index 0000000000..6faa8f9a60 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminal-keybar.test.tsx @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { MobileTerminalKeybar } from "./mobile-terminal-keybar"; +import { useShellModifiersStore } from "@/lib/terminal/shell-modifiers"; + +const sendMock = vi.fn(); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => ({ send: sendMock }), +})); + +vi.mock("@/hooks/use-visual-viewport-offset", () => ({ + useVisualViewportOffset: () => ({ bottomOffset: 0, keyboardOpen: false, viewportBottom: 0 }), +})); + +const KEYBAR_ROOT = "mobile-terminal-keybar"; +const CTRL_KEY = "keybar-key-ctrl"; +const SHIFT_KEY = "keybar-key-shift"; +const ESC_KEY = "keybar-key-esc"; +const ARIA_PRESSED = "aria-pressed"; +const SESSION_ID = "s1"; + +function tap(testId: string): void { + fireEvent.click(screen.getByTestId(testId)); +} + +function lastData(): string | undefined { + const call = sendMock.mock.calls.at(-1)?.[0] as { payload?: { data?: string } } | undefined; + return call?.payload?.data; +} + +beforeEach(() => { + sendMock.mockReset(); + useShellModifiersStore.getState().reset(); + cleanup(); +}); + +describe("MobileTerminalKeybar visibility", () => { + it("renders when visible and has a sessionId", () => { + render(); + expect(screen.getByTestId(KEYBAR_ROOT)).toBeDefined(); + }); + + it("renders nothing when not visible", () => { + render(); + expect(screen.queryByTestId(KEYBAR_ROOT)).toBeNull(); + }); + + it("renders nothing without a sessionId", () => { + render(); + expect(screen.queryByTestId(KEYBAR_ROOT)).toBeNull(); + }); +}); + +describe("MobileTerminalKeybar single-key emission", () => { + it("sends \\x1b on Esc tap", () => { + render(); + tap(ESC_KEY); + expect(lastData()).toBe("\x1b"); + }); + + it("sends \\t on Tab tap", () => { + render(); + tap("keybar-key-tab"); + expect(lastData()).toBe("\t"); + }); + + it("sends arrow escape sequences", () => { + render(); + tap("keybar-key-up"); + expect(lastData()).toBe("\x1b[A"); + tap("keybar-key-down"); + expect(lastData()).toBe("\x1b[B"); + tap("keybar-key-left"); + expect(lastData()).toBe("\x1b[D"); + tap("keybar-key-right"); + expect(lastData()).toBe("\x1b[C"); + }); + + it("sends literal symbols", () => { + render(); + tap("keybar-key-pipe"); + expect(lastData()).toBe("|"); + tap("keybar-key-tilde"); + expect(lastData()).toBe("~"); + }); +}); + +describe("MobileTerminalKeybar dedicated Ctrl+C / Ctrl+D", () => { + it("Ctrl+C button sends \\x03 in one tap", () => { + render(); + tap("keybar-key-ctrl-c"); + expect(lastData()).toBe("\x03"); + }); + + it("Ctrl+D button sends \\x04 in one tap", () => { + render(); + tap("keybar-key-ctrl-d"); + expect(lastData()).toBe("\x04"); + }); +}); + +describe("MobileTerminalKeybar Ctrl modifier", () => { + it("tapping Ctrl latches the modifier in the shared store", () => { + render(); + const ctrl = screen.getByTestId(CTRL_KEY); + expect(ctrl.getAttribute(ARIA_PRESSED)).toBe("false"); + + tap(CTRL_KEY); + expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: true, sticky: false }); + expect(ctrl.getAttribute(ARIA_PRESSED)).toBe("true"); + }); + + it("double-tap makes Ctrl sticky", () => { + render(); + const ctrl = screen.getByTestId(CTRL_KEY); + tap(CTRL_KEY); + tap(CTRL_KEY); + expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: true, sticky: true }); + expect(ctrl.getAttribute("data-sticky")).toBe("true"); + }); + + it("third tap clears Ctrl entirely", () => { + render(); + tap(CTRL_KEY); + tap(CTRL_KEY); + tap(CTRL_KEY); + expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: false, sticky: false }); + }); + + it("tapping a bar key with Ctrl latched chords it and auto-releases", () => { + render(); + tap(CTRL_KEY); + // Tab becomes Ctrl+I (\x09) when ctrl is active — but the chord transform + // only runs on single-char data. Use a symbol key which is single-char. + tap("keybar-key-pipe"); + expect(lastData()).toBe("|"); // "|" transforms to itself (non-letter) + // Ctrl was latched (non-sticky), so it clears after consumption. + expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: false, sticky: false }); + }); + + it("does not render in-bar letter buttons — OS keyboard drives letters", () => { + render(); + expect(screen.queryByTestId("keybar-key-letter-c")).toBeNull(); + expect(screen.queryByTestId("keybar-key-letter-a")).toBeNull(); + }); +}); + +describe("MobileTerminalKeybar Shift modifier", () => { + it("exposes a Shift toggle that latches in the shared store", () => { + render(); + const shift = screen.getByTestId(SHIFT_KEY); + expect(shift.getAttribute(ARIA_PRESSED)).toBe("false"); + + tap(SHIFT_KEY); + expect(useShellModifiersStore.getState().shift).toEqual({ latched: true, sticky: false }); + expect(shift.getAttribute(ARIA_PRESSED)).toBe("true"); + }); + + it("Shift+Tab from the bar emits CSI Z (reverse tab)", () => { + render(); + tap(SHIFT_KEY); + tap("keybar-key-tab"); + expect(lastData()).toBe("\x1b[Z"); + expect(useShellModifiersStore.getState().shift).toEqual({ latched: false, sticky: false }); + }); +}); + +describe("MobileTerminalKeybar iOS focus retention", () => { + function mountXtermTextarea(): HTMLTextAreaElement { + const ta = document.createElement("textarea"); + ta.className = "xterm-helper-textarea"; + document.body.appendChild(ta); + return ta; + } + + it("refocuses the xterm textarea on every key tap so the OS keyboard stays up", () => { + const ta = mountXtermTextarea(); + render(); + + tap(ESC_KEY); + expect(document.activeElement).toBe(ta); + + (document.activeElement as HTMLElement).blur(); + tap(CTRL_KEY); + expect(document.activeElement).toBe(ta); + + (document.activeElement as HTMLElement).blur(); + tap(SHIFT_KEY); + expect(document.activeElement).toBe(ta); + + (document.activeElement as HTMLElement).blur(); + tap("keybar-key-ctrl-c"); + expect(document.activeElement).toBe(ta); + + ta.remove(); + }); +}); + +describe("MobileTerminalKeybar iOS keyboard retention", () => { + it("calls preventDefault on pointerdown so the OS keyboard stays open", () => { + render(); + const esc = screen.getByTestId(ESC_KEY); + + const pointerEvent = new Event("pointerdown", { bubbles: true, cancelable: true }); + const mouseEvent = new Event("mousedown", { bubbles: true, cancelable: true }); + esc.dispatchEvent(pointerEvent); + esc.dispatchEvent(mouseEvent); + + expect(pointerEvent.defaultPrevented).toBe(true); + expect(mouseEvent.defaultPrevented).toBe(true); + }); + + it("still fires onClick after preventing focus transfer", () => { + render(); + const esc = screen.getByTestId(ESC_KEY); + esc.dispatchEvent(new Event("pointerdown", { bubbles: true, cancelable: true })); + fireEvent.click(esc); + expect(lastData()).toBe("\x1b"); + }); +}); + +describe("MobileTerminalKeybar positioning", () => { + it("anchors via top (not bottom) when the keyboard is open — iOS fixed-positioning fix", async () => { + // Re-mock the hook to report a keyboard-open state. + vi.doMock("@/hooks/use-visual-viewport-offset", () => ({ + useVisualViewportOffset: () => ({ + bottomOffset: 300, + keyboardOpen: true, + viewportBottom: 500, + }), + })); + vi.resetModules(); + const { MobileTerminalKeybar: Reloaded } = await import("./mobile-terminal-keybar"); + render(); + const bar = screen.getByTestId(KEYBAR_ROOT); + expect(bar.style.top).toBeTruthy(); + expect(bar.style.bottom).toBe("auto"); + }); +}); + +describe("MobileTerminalKeybar accessibility", () => { + it("every key has a non-empty aria-label", () => { + render(); + const buttons = document.querySelectorAll('[data-testid^="keybar-key-"]'); + expect(buttons.length).toBeGreaterThan(0); + buttons.forEach((b) => { + expect(b.getAttribute("aria-label")?.length ?? 0).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/web/components/task/mobile/mobile-terminal-keybar.tsx b/apps/web/components/task/mobile/mobile-terminal-keybar.tsx new file mode 100644 index 0000000000..28a7841f3a --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminal-keybar.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useCallback } from "react"; +import { KEY_SEQUENCES } from "@/lib/terminal/key-sequences"; +import { useShellKeySender } from "@/hooks/domains/session/use-shell-key-sender"; +import { useVisualViewportOffset } from "@/hooks/use-visual-viewport-offset"; +import { refocusXtermTextarea } from "@/lib/terminal/refocus-xterm"; +import { useShellModifiersStore } from "@/lib/terminal/shell-modifiers"; +import { KEYS, KeybarButton, ModifierButton } from "./mobile-terminal-keybar-helpers"; + +export type MobileTerminalKeybarProps = { + sessionId: string | null | undefined; + visible: boolean; + /** CSS length used as minimum bottom offset when the on-screen keyboard is closed (e.g., to clear the bottom nav). */ + baseBottomOffset?: string; +}; + +/** Height of the bar in px (border-t + py-1.5 + h-8 button). Used by the mobile layout to pad the terminal so content doesn't hide behind the bar. */ +export const KEYBAR_HEIGHT_PX = 48; + +export function MobileTerminalKeybar({ + sessionId, + visible, + baseBottomOffset, +}: MobileTerminalKeybarProps) { + const send = useShellKeySender(sessionId); + const { keyboardOpen, viewportBottom } = useVisualViewportOffset(); + const ctrl = useShellModifiersStore((s) => s.ctrl); + const shift = useShellModifiersStore((s) => s.shift); + const toggleCtrl = useShellModifiersStore((s) => s.toggleCtrl); + const toggleShift = useShellModifiersStore((s) => s.toggleShift); + + const tapSend = useCallback( + (data: string) => { + refocusXtermTextarea(); + send(data); + }, + [send], + ); + + const onCtrlTap = useCallback(() => { + refocusXtermTextarea(); + toggleCtrl(); + }, [toggleCtrl]); + + const onShiftTap = useCallback(() => { + refocusXtermTextarea(); + toggleShift(); + }, [toggleShift]); + + if (!visible || !sessionId) return null; + + const position = resolvePosition({ keyboardOpen, viewportBottom, baseBottomOffset }); + + return ( +
+
+ + + tapSend(KEY_SEQUENCES.ctrlC)} + variant="destructive" + > + ^C + + tapSend(KEY_SEQUENCES.ctrlD)}> + ^D + + {KEYS.map((key) => ( + tapSend(key.seq)} + > + {key.label} + + ))} +
+
+ ); +} + +function resolvePosition({ + keyboardOpen, + viewportBottom, + baseBottomOffset, +}: { + keyboardOpen: boolean; + viewportBottom: number; + baseBottomOffset: string | undefined; +}): React.CSSProperties { + // iOS Safari drifts fixed elements positioned via `bottom` while the visual + // viewport scrolls with the keyboard up. Anchoring via `top` tied to the + // visual viewport's bottom edge stays glued to the keyboard. + if (keyboardOpen) { + return { top: `${viewportBottom - KEYBAR_HEIGHT_PX}px`, bottom: "auto" }; + } + const base = baseBottomOffset + ? `calc(${baseBottomOffset} + env(safe-area-inset-bottom, 0px))` + : "env(safe-area-inset-bottom, 0px)"; + return { bottom: base }; +} diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index 81a548d98d..60244b822b 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -10,8 +10,10 @@ import { TaskChangesPanel } from "../task-changes-panel"; import { TaskFilesPanel } from "../task-files-panel"; import { ShellTerminal } from "../shell-terminal"; import { PassthroughTerminal } from "../passthrough-terminal"; +import { MobileTerminalKeybar, KEYBAR_HEIGHT_PX } from "./mobile-terminal-keybar"; import { SessionPanelContent } from "@kandev/ui/pannel-session"; import { useSessionLayoutState } from "@/hooks/use-session-layout-state"; +import { useVisualViewportOffset } from "@/hooks/use-visual-viewport-offset"; import type { MobileSessionPanel } from "@/lib/state/slices/ui/types"; const TOP_NAV_HEIGHT = "3.5rem"; @@ -92,6 +94,14 @@ function MobilePanelArea({ topNavHeight, bottomNavHeight, }: MobilePanelAreaProps) { + const { keyboardOpen, bottomOffset } = useVisualViewportOffset(); + // Keep terminal content's visible bottom glued to the keybar top. When the + // keyboard is up, the content area already pads for the bottom nav (which + // is now under the keyboard), so we subtract it back out and add the + // keyboard height instead. + const terminalPaddingBottom = keyboardOpen + ? `calc(${bottomOffset + KEYBAR_HEIGHT_PX}px - ${bottomNavHeight} - env(safe-area-inset-bottom, 0px))` + : `${KEYBAR_HEIGHT_PX}px`; return (
)} {currentMobilePanel === "terminal" && ( -
+
@@ -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"); +}