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"); +}