diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 28ee87f1df..100aacf9dc 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -70,6 +70,10 @@ definition, no longer eligible to begin. - Callers request semantic targets through `panel-store`; they never write shared values. - Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`. +- A focused interactive surface may block panel-opening gestures through + `useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and + unregister when the surface is hidden or unfocused. The blocker never disables gestures that close + an already-open panel. - Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View` so its injected `collapsable={false}` reaches Android/Fabric. diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 450ab3b4b7..3f71b3fe81 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -1,6 +1,47 @@ # Mobile Testing -## Maestro +## Agent Device + +Agent Device `.ad` scripts are the primary mobile E2E format. An agent discovers a working flow interactively, saves the successful commands, then the replay runner executes the same typed plan locally or in CI. + +Record a flow while driving the app normally: + +```bash +agent-device open sh.paseo.debug \ + --platform ios \ + --session terminal-author \ + --save-script ./packages/app/e2e/mobile/agent-device/terminal.ios.ad +agent-device snapshot -i --session terminal-author +agent-device press 'id="workspace-header-menu-trigger"' --session terminal-author +# Continue the flow and verify its result with wait/get/is/find. +agent-device close --session terminal-author +``` + +`close` writes the script. Keep selectors based on stable app IDs. Keep assertions as `wait`, `get`, `is`, or `find` commands; screenshots are evidence, not assertions. + +Run the Paseo mobile suite: + +```bash +npm run test:e2e:mobile +``` + +The runner uses an isolated Agent Device state directory, verifies or starts Metro for this checkout, prewarms the iOS runner, discovers each script's platform from its `context` header, and cleans its sessions, runner lease, daemon, and any Metro process it started. Attempt results, timings, logs, and failure artifacts go under `.dev/agent-device-artifacts`. + +Set `PASEO_MOBILE_E2E_METRO_PORT` when this worktree already has Metro on a non-default port: + +```bash +PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile +``` + +[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace. + +When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts. + +## Maestro compatibility + +Existing Maestro flows live in `packages/app/maestro/`. Agent Device can execute its supported subset with `agent-device test --maestro`, which provides a migration path while `.ad` coverage replaces these flows. + +### Existing flows Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`. diff --git a/docs/terminal-performance.md b/docs/terminal-performance.md index 6a6eba020b..a0e6020f54 100644 --- a/docs/terminal-performance.md +++ b/docs/terminal-performance.md @@ -25,6 +25,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev - **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop. - **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches. - **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output. +- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size. ## Measuring diff --git a/package.json b/package.json index 0c697550c9..e23c01b64b 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"", "dev:server:raw": "npm run dev --workspace=@getpaseo/server", "dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh", + "test:e2e:mobile": "./scripts/test-mobile-agent-device.sh", "dev:website": "npm run dev --workspace=@getpaseo/website", "postinstall": "node scripts/postinstall-patches.mjs", "prepare": "lefthook install --force", diff --git a/packages/app/e2e/browser/terminal-split-resize.spec.ts b/packages/app/e2e/browser/terminal-split-resize.spec.ts index 7b443907a5..809cb88317 100644 --- a/packages/app/e2e/browser/terminal-split-resize.spec.ts +++ b/packages/app/e2e/browser/terminal-split-resize.spec.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test"; import { test, expect } from "../support/fixtures"; import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl"; -import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf"; +import { waitForTerminalContent } from "../support/helpers/terminal-perf"; interface TerminalSize { rows: number | null; @@ -24,8 +24,12 @@ async function readXtermSize(page: Page): Promise { // The PTY's own view of its size, reported by an `stty size` loop running in the // shell. This needs no focus or click, so it observes whether the daemon-side PTY // actually received the resize frame after the split. -async function readLatestPtySize(page: Page): Promise { - const text = await getTerminalBufferText(page); +async function readLatestPtySize( + harness: TerminalE2EHarness, + terminalId: string, +): Promise { + const capture = await harness.client.captureTerminal(terminalId, { stripAnsi: true }); + const text = capture.lines.join("\n"); const matches = [...text.matchAll(/PTYSIZE (\d+) (\d+)/g)]; const last = matches.at(-1); if (!last) { @@ -42,16 +46,20 @@ async function readXtermRows(page: Page): Promise { return (await readXtermSize(page)).rows; } -async function ptyRowsMatchXtermRows(page: Page): Promise { +async function ptyRowsMatchXtermRows( + page: Page, + harness: TerminalE2EHarness, + terminalId: string, +): Promise { const xterm = await readXtermSize(page); - const pty = await readLatestPtySize(page); + const pty = await readLatestPtySize(harness, terminalId); return pty?.rows === xterm.rows; } async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness): Promise { await page.setViewportSize({ width: 1280, height: 900 }); - await withTerminalInApp(page, harness, { name: "split-resize" }, async () => { + await withTerminalInApp(page, harness, { name: "split-resize" }, async (terminalInstance) => { await harness.setupPrompt(page); const terminal = harness.terminalSurface(page); @@ -65,7 +73,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness await waitForTerminalContent(page, hasPtySizeReport, 10_000); const beforeXterm = await readXtermSize(page); - const beforePty = await readLatestPtySize(page); + const beforePty = await readLatestPtySize(harness, terminalInstance.id); expect(beforePty, "the PTY should report its size before splitting").not.toBeNull(); expect(beforeXterm.rows, "xterm should report its row count before splitting").not.toBeNull(); expect(beforePty?.rows, "while focused, the PTY size should already match the xterm size").toBe( @@ -89,7 +97,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness // new pane. Today it stays stuck at the pre-split size until the terminal is // clicked back into focus. This poll fails without the fix. await expect - .poll(() => ptyRowsMatchXtermRows(page), { + .poll(() => ptyRowsMatchXtermRows(page, harness, terminalInstance.id), { message: "the PTY rows should match the resized terminal after split-down", timeout: 8_000, }) diff --git a/packages/app/e2e/browser/terminal-stuck-size.spec.ts b/packages/app/e2e/browser/terminal-stuck-size.spec.ts index 4c41cb5988..bf1a5541fa 100644 --- a/packages/app/e2e/browser/terminal-stuck-size.spec.ts +++ b/packages/app/e2e/browser/terminal-stuck-size.spec.ts @@ -1,6 +1,5 @@ import { test, expect, type Page } from "../support/fixtures"; import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; -import { getTerminalBufferText } from "../support/helpers/terminal-perf"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { getServerId } from "../support/helpers/server-id"; @@ -90,6 +89,14 @@ function parseLatestSttySize(bufferText: string): RenderedTerminalSize | null { return match?.[1] && match[2] ? { rows: Number(match[1]), cols: Number(match[2]) } : null; } +async function captureTerminalText( + harness: TerminalE2EHarness, + terminalId: string, +): Promise { + const capture = await harness.client.captureTerminal(terminalId, { stripAnsi: true }); + return capture.lines.join("\n"); +} + test.describe("terminal PTY size claim under lost window focus", () => { let harness: TerminalE2EHarness; @@ -140,7 +147,7 @@ test.describe("terminal PTY size claim under lost window focus", () => { data: 'echo "S0=$(stty size)="\n', }); await expect - .poll(async () => getTerminalBufferText(page), { timeout: 15_000 }) + .poll(async () => captureTerminalText(harness, newTerminalId), { timeout: 15_000 }) .toMatch(/S0=24 80=/); // ...and comes back. @@ -161,7 +168,7 @@ test.describe("terminal PTY size claim under lost window focus", () => { type: "input", data: `echo "S${probe++}=$(stty size)="\n`, }); - return parseLatestSttySize(await getTerminalBufferText(page)); + return parseLatestSttySize(await captureTerminalText(harness, newTerminalId)); }, { timeout: 15_000, intervals: [100, 250, 500] }, ) diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad b/packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad new file mode 100644 index 0000000000..f39395fb01 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad @@ -0,0 +1,14 @@ +context platform=android timeout=90000 retries=1 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-header-menu-trigger\"" 45000 +press "id=\"workspace-header-menu-trigger\"" +wait "role=\"button\" label=\"New terminal\"" 10000 +press "role=\"button\" label=\"New terminal\"" +wait "id=\"terminal-keyboard-toggle\"" 10000 +press "id=\"terminal-keyboard-toggle\"" +type "echo __AD_OUTPUT_OK__" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "text" "__AD_OUTPUT_OK__" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad new file mode 100644 index 0000000000..63f5fb0728 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad @@ -0,0 +1,13 @@ +context platform=ios timeout=60000 retries=1 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-header-menu-trigger\"" 20000 +press "id=\"workspace-header-menu-trigger\"" +press "id=\"workspace-header-new-terminal\"" +wait "id=\"terminal-keyboard-toggle\"" 10000 +press "id=\"terminal-keyboard-toggle\"" +type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "text" "__AD_OUTPUT_OK__" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-hardware-keyboard.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-hardware-keyboard.ios.ad new file mode 100644 index 0000000000..bf9a04ec3f --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-hardware-keyboard.ios.ad @@ -0,0 +1,10 @@ +context platform=ios timeout=90000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"terminal-surface\"" 30000 +press "id=\"terminal-surface\"" +type "echo __AD_IOS_HARDWARE_OK__" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "text" "__AD_IOS_HARDWARE_OK__" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad new file mode 100644 index 0000000000..4988c9afdd --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad @@ -0,0 +1,19 @@ +context platform=ios timeout=60000 retries=1 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-header-menu-trigger\"" 20000 +press "id=\"workspace-header-menu-trigger\"" +press "id=\"workspace-header-new-terminal\"" +wait "id=\"terminal-surface\"" 10000 +press "id=\"terminal-surface\"" +type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "id=\"terminal-row-1\" label=\"AD_SCROLL_MARKER\"" 10000 +gesture pan 200 380 0 28 600 +wait "id=\"terminal-row-3\" label=\"AD_SCROLL_MARKER\"" 10000 +gesture pan 200 380 0 28 600 +wait "id=\"terminal-row-5\" label=\"AD_SCROLL_MARKER\"" 10000 +gesture pan 200 380 0 28 600 +wait "id=\"terminal-row-7\" label=\"AD_SCROLL_MARKER\"" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.android.ad b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.android.ad new file mode 100644 index 0000000000..282cc4d39c --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.android.ad @@ -0,0 +1,21 @@ +context platform=android timeout=60000 retries=1 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-header-menu-trigger\"" 20000 +press "id=\"workspace-header-menu-trigger\"" +wait "role=\"button\" label=\"New terminal\"" 10000 +press "role=\"button\" label=\"New terminal\"" +wait "id=\"terminal-keyboard-toggle\"" 10000 +press "id=\"terminal-keyboard-toggle\"" +type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "text" "PASEO_TARGET" 10000 +longpress "label=\"PASEO_TARGET\"" 550 +wait "id=\"terminal-copy\"" 10000 +is visible "id=\"terminal-copy\"" +gesture pan 532 346 400 0 700 +is visible "id=\"terminal-copy\"" +gesture pan 532 346 -400 0 700 +is visible "id=\"terminal-copy\"" +close diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad new file mode 100644 index 0000000000..9ef099b5a7 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad @@ -0,0 +1,20 @@ +context platform=ios timeout=60000 retries=1 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-header-menu-trigger\"" 20000 +press "id=\"workspace-header-menu-trigger\"" +press "id=\"workspace-header-new-terminal\"" +wait "id=\"terminal-keyboard-toggle\"" 10000 +press "id=\"terminal-keyboard-toggle\"" +type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0 +press "id=\"terminal-key-enter\"" +wait "text" "PASEO_TARGET" 10000 +longpress "label=\"PASEO_TARGET\"" 550 +wait "id=\"terminal-copy\"" 10000 +is visible "id=\"terminal-copy\"" +gesture pan 198 167 170 0 700 +is visible "id=\"terminal-copy\"" +gesture pan 198 167 -170 0 700 +is visible "id=\"terminal-copy\"" +close diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index f498fb382c..71a27dde5b 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -160,8 +160,14 @@ export interface SeedDaemonClient { ): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>; sendTerminalInput( terminalId: string, - message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number }, + message: + | { type: "input"; data: string } + | { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" }, ): void; + captureTerminal( + terminalId: string, + options?: { start?: number; end?: number; stripAnsi?: boolean }, + ): Promise<{ terminalId: string; lines: string[]; totalLines: number; requestId: string }>; onTerminalStreamEvent( handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void, ): () => void; diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 12d8b289b8..ec8496aa3c 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -92,6 +92,7 @@ import { MobilePanelsProvider } from "@/mobile-panels/provider"; import { I18nProvider } from "@/i18n/provider"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { polyfillCrypto } from "@/polyfills/crypto"; +import { polyfillNavigator } from "@/polyfills/navigator"; import { queryClient } from "@/data/query-client"; import { getHostRuntimeStore, @@ -128,6 +129,7 @@ import { type WebNotificationClickDetail, } from "@/utils/os-notifications"; +polyfillNavigator(); polyfillCrypto(); export interface HostRuntimeBootstrapState { diff --git a/packages/app/src/components/terminal-copy-paste-actions.tsx b/packages/app/src/components/terminal-copy-paste-actions.tsx new file mode 100644 index 0000000000..ce0ac833d1 --- /dev/null +++ b/packages/app/src/components/terminal-copy-paste-actions.tsx @@ -0,0 +1,141 @@ +import { useCallback, useMemo } from "react"; +import { Pressable, Text, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +export interface TerminalPasteActionProps { + hasClipboardText: boolean; + onPaste: () => void; +} + +export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) { + return ( + + ); +} + +export interface TerminalFloatingCopyActionProps { + hasSelection: boolean; + onCopy: () => void; +} + +export function TerminalFloatingCopyAction({ + hasSelection, + onCopy, +}: TerminalFloatingCopyActionProps) { + if (!hasSelection) { + return null; + } + + return ( + + ); +} + +interface TerminalActionButtonProps { + label: string; + accessibilityLabel: string; + testID: string; + disabled?: boolean; + onPress: () => void; + variant: "key" | "floating"; +} + +function TerminalActionButton({ + label, + accessibilityLabel, + testID, + disabled = false, + onPress, + variant, +}: TerminalActionButtonProps) { + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + variant === "floating" ? styles.floatingButton : styles.keyButton, + disabled && styles.keyButtonDisabled, + (Boolean(hovered) || pressed) && + !disabled && + (variant === "floating" ? styles.floatingButtonHovered : styles.keyButtonHovered), + ], + [disabled, variant], + ); + const textStyle = useMemo( + () => [variant === "floating" ? styles.floatingButtonText : styles.keyButtonText], + [variant], + ); + const accessibilityState = useMemo(() => ({ disabled }), [disabled]); + + const handlePress = useCallback(() => { + if (!disabled) { + onPress(); + } + }, [disabled, onPress]); + + return ( + + {label} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + keyButton: { + flex: 1, + minWidth: 0, + height: 34, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[1], + backgroundColor: theme.colors.surface1, + }, + keyButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + keyButtonDisabled: { + opacity: 0.45, + }, + keyButtonText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + textAlign: "center", + }, + floatingButton: { + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + backgroundColor: "rgba(32, 32, 32, 0.86)", + }, + floatingButtonHovered: { + backgroundColor: "rgba(48, 48, 48, 0.92)", + }, + floatingButtonText: { + color: "#f5f5f5", + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + textAlign: "center", + }, +})); diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts new file mode 100644 index 0000000000..4db0b8eb46 --- /dev/null +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -0,0 +1,70 @@ +import type { Ref } from "react"; +import type { ITheme } from "@xterm/xterm"; +import type { TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; +import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; +import type { + TerminalLocalFileLinkSource, + TerminalLocalFileLinkTarget, +} from "../terminal/local-links/terminal-local-link-provider"; +import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection"; +import type { PendingTerminalModifiers } from "../utils/terminal-keys"; +import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; + +export interface TerminalEmulatorHandle { + writeOutput: (data: TerminalOutputData) => void; + restoreOutput: (data: TerminalOutputData) => void; + renderSnapshot: (state: TerminalState | null) => void; + paste: (text: string) => void; + copySelection: (clipboard: TerminalClipboardWriter) => Promise; + clear: () => void; + claimSize: () => void; + showKeyboard: () => void; + blur: () => void; +} + +export interface TerminalEmulatorProps { + dom?: unknown; + ref: Ref; + streamKey: string; + supportsTerminalInputModeReplay: boolean; + testId?: string; + xtermTheme?: ITheme; + scrollbackLines: number; + fontFamily?: string; + fontSize?: number; + keyboardInset?: number; + swipeGesturesEnabled?: boolean; + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + initialSnapshot?: TerminalState | null; + onInput?: (data: string) => Promise | void; + onFocus?: () => Promise | void; + onResize?: (input: { + rows: number; + cols: number; + shouldClaim: boolean; + forceClaim?: boolean; + }) => Promise | void; + onTerminalKey?: (input: { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + }) => Promise | void; + onPendingModifiersConsumed?: () => Promise | void; + onInputModeChange?: (state: TerminalInputModeState) => Promise | void; + onSelectionChange?: (hasSelection: boolean) => void; + onResolveLocalFileLink?: ( + source: TerminalLocalFileLinkSource, + ) => Promise | TerminalLocalFileLinkTarget | null; + onOpenLocalFileLink?: ( + target: TerminalLocalFileLinkTarget, + disposition: "main" | "side", + ) => Promise | void; + onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; + pendingModifiers?: PendingTerminalModifiers; + focusRequestToken?: number; + resizeRequestToken?: number; +} diff --git a/packages/app/src/components/terminal-emulator-native-grid.native.tsx b/packages/app/src/components/terminal-emulator-native-grid.native.tsx new file mode 100644 index 0000000000..59957c5289 --- /dev/null +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -0,0 +1,1050 @@ +import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { + Keyboard, + type LayoutChangeEvent, + PanResponder, + type PanResponderGestureState, + Pressable, + StyleSheet, + Text, + View, + type GestureResponderEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import type { ITheme } from "@xterm/xterm"; +import type { TerminalState } from "@getpaseo/protocol/messages"; +import { + TerminalInputModeTracker, + terminalInputModeStatesEqual, + type TerminalInputModeState, +} from "@getpaseo/protocol/terminal-input-mode"; +import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract"; +import { + createNativeHeadlessTerminal, + type NativeHeadlessTerminal, + type TerminalViewportState, +} from "../terminal/native-renderer/headless-terminal-state"; +import { + createNativeTerminalOutputDrain, + type NativeTerminalOutputDrain, +} from "../terminal/native-renderer/headless-terminal-output-drain"; +import { TerminalGridView } from "../terminal/native-renderer/terminal-grid-view.native"; +import type { TerminalGridCellMetrics } from "../terminal/native-renderer/terminal-grid-metrics"; +import { + createNativeTerminalScreenModel, + type TerminalScreenModel, + type TerminalScreenState, +} from "../terminal/native-renderer/terminal-screen-model"; +import { + copyTerminalSelection, + createTerminalSelectionModel, + hitTestTerminalSelectionCell, + resolveTerminalWordSelection, + type TerminalBufferCoordinate, + type TerminalClipboardWriter, + type TerminalSelectionModel, + type TerminalSelectionRange, + type TerminalSelectionViewport, +} from "../terminal/native-renderer/terminal-selection"; +import { + TERMINAL_GESTURE_LONG_PRESS_MS, + TERMINAL_GESTURE_TAP_TOLERANCE_PX, + advanceTerminalScrollGesture, + classifyTerminalGestureIntent, + resolveTerminalGestureReleaseAction, + type TerminalGestureIntent, +} from "../terminal/native-renderer/terminal-selection-gesture"; +import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy"; +import { + forwardNativeTerminalKey, + type NativeTerminalKey, +} from "../terminal/native-renderer/terminal-key-events"; +import { + TerminalInput, + type TerminalInputHandle, +} from "../terminal/native-renderer/terminal-input.native"; +import { + shouldClaimNativeTerminalSize, + type NativeTerminalSizeClaimAction, +} from "../terminal/native-renderer/terminal-input-resize-policy"; +import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste"; +import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; +import { + createTerminalResizePolicy, + updateTerminalResizePolicy, + type TerminalResizeSource, +} from "../terminal/native-renderer/terminal-resize-policy"; +import { + resolveMeasuredNativeTerminalSize, + type TerminalMeasuredLayout, + type TerminalMeasuredSize, +} from "../terminal/native-renderer/terminal-size-measurement"; + +const MIN_NATIVE_TERMINAL_ROWS = 1; +const MIN_NATIVE_TERMINAL_COLS = 1; +const DEFAULT_XTERM_THEME: ITheme = { + background: "#0b0b0b", + foreground: "#e6e6e6", + cursor: "#e6e6e6", +}; +interface NativeTerminalPanState { + lastDy: number; + rowRemainder: number; + didScroll: boolean; + didNavigate: boolean; + movedBeyondTapTolerance: boolean; + intent: TerminalGestureIntent | null; +} + +interface NativeTerminalSelectionPress { + startX: number; + startY: number; + startedAt: number; + startedWithSelection: boolean; + status: "pressing" | "selecting"; +} + +function resolvePanResponderPoint( + press: NativeTerminalSelectionPress, + event: GestureResponderEvent, + gesture: PanResponderGestureState, +): { x: number; y: number } { + const eventDx = event.nativeEvent.locationX - press.startX; + const eventDy = event.nativeEvent.locationY - press.startY; + const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx; + const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy; + return { + x: press.startX + dx, + y: press.startY + dy, + }; +} + +function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize { + return { + rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS, + cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS, + }; +} + +function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState { + const visibleRows = viewport.grid.length; + const currentViewport = { + firstRow: viewport.firstRow, + lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1), + }; + return { + viewport, + scroll: { + mode: "following", + firstRow: viewport.firstRow, + visibleRows, + oldestRow: viewport.oldestRow, + newestRow: viewport.newestRow, + currentViewport, + bottomViewport: currentViewport, + }, + }; +} + +function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState { + return { + rows: state.grid.length, + cols: state.cols, + firstRow: 0, + oldestRow: 0, + newestRow: Math.max(0, state.grid.length - 1), + grid: state.grid, + cursor: state.cursor, + }; +} + +function NativeTerminalEmulator({ + ref, + streamKey, + testId = "terminal-surface", + xtermTheme = DEFAULT_XTERM_THEME, + scrollbackLines, + fontFamily, + fontSize, + keyboardInset = 0, + initialSnapshot = null, + onInput, + onTerminalKey, + onResize, + onInputModeChange, + onSelectionChange, + onRendererReadyChange, + onFocus, + focusRequestToken = 0, + resizeRequestToken = 0, +}: TerminalEmulatorProps) { + const terminalRef = useRef(null); + const mountInputRef = useRef({ streamKey, initialSnapshot }); + if ( + resolveNativeTerminalMountAction({ + mountedStreamKey: mountInputRef.current.streamKey, + nextStreamKey: streamKey, + }) === "mount" + ) { + mountInputRef.current = { streamKey, initialSnapshot }; + } + const inputRef = useRef(null); + const terminalSizeRef = useRef(initialNativeTerminalSize(initialSnapshot)); + const resizePolicyRef = useRef( + createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)), + ); + const activeResizeClaimTokenRef = useRef(0); + const layoutRef = useRef(null); + const metricsRef = useRef(null); + const hasNotifiedMeasuredSizeRef = useRef(false); + const outputDrainRef = useRef(null); + const screenModelRef = useRef(null); + const selectionModelRef = useRef(createTerminalSelectionModel()); + const selectionLongPressTimeoutRef = useRef | null>(null); + const selectionPressRef = useRef(null); + const latestResolvedScreenRef = useRef(null); + const paintFrameRef = useRef(null); + const outputDecoderRef = useRef(new TextDecoder()); + const panStateRef = useRef({ + lastDy: 0, + rowRemainder: 0, + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + intent: null, + }); + const inputModeTrackerRef = useRef(new TerminalInputModeTracker()); + const lastInputModeStateRef = useRef({ + ...inputModeTrackerRef.current.getState(), + applicationCursorKeys: false, + }); + const [terminalScreen, setTerminalScreen] = useState(() => { + return initialSnapshot + ? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot)) + : null; + }); + const [selectionRange, setSelectionRange] = useState(null); + const selectionRangeRef = useRef(null); + const hasNotifiedSelectionRef = useRef(false); + const terminalScreenRef = useRef(terminalScreen); + const callbacksRef = useRef({ + onFocus, + onInput, + onTerminalKey, + onInputModeChange, + onSelectionChange, + onRendererReadyChange, + onResize, + }); + callbacksRef.current = { + onFocus, + onInput, + onTerminalKey, + onInputModeChange, + onSelectionChange, + onRendererReadyChange, + onResize, + }; + + const getInputModeState = useCallback((): TerminalInputModeState => { + return inputModeTrackerRef.current.getState(); + }, []); + + const emitInputModeChange = useCallback(() => { + const state = getInputModeState(); + if (terminalInputModeStatesEqual(state, lastInputModeStateRef.current)) { + return; + } + lastInputModeStateRef.current = state; + callbacksRef.current.onInputModeChange?.(state); + }, [getInputModeState]); + + const resetInputModeTracker = useCallback(() => { + inputModeTrackerRef.current.reset(); + emitInputModeChange(); + }, [emitInputModeChange]); + + const commitTerminalScreen = useCallback((screen: TerminalScreenState) => { + latestResolvedScreenRef.current = screen; + terminalScreenRef.current = screen; + setTerminalScreen(screen); + }, []); + + const commitSelectionRange = useCallback((range: TerminalSelectionRange | null) => { + selectionRangeRef.current = range; + const hasSelection = range !== null; + if (hasNotifiedSelectionRef.current !== hasSelection) { + hasNotifiedSelectionRef.current = hasSelection; + callbacksRef.current.onSelectionChange?.(hasSelection); + } + setSelectionRange((current) => { + if (current === range) { + return current; + } + return range; + }); + }, []); + + const clearSelection = useCallback(() => { + const snapshot = selectionModelRef.current.clear(); + commitSelectionRange(snapshot.range); + }, [commitSelectionRange]); + + useEffect(() => { + terminalScreenRef.current = terminalScreen; + }, [terminalScreen]); + + const syncSelectionWithTerminal = useCallback( + (terminal: NativeHeadlessTerminal) => { + const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() }); + commitSelectionRange(snapshot.range); + }, + [commitSelectionRange], + ); + + const clearSelectionLongPressTimeout = useCallback(() => { + if (selectionLongPressTimeoutRef.current === null) { + return; + } + clearTimeout(selectionLongPressTimeoutRef.current); + selectionLongPressTimeoutRef.current = null; + }, []); + + const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => { + return ( + resolveMeasuredNativeTerminalSize({ + layout: layoutRef.current, + metrics: metricsRef.current, + })?.rows ?? terminal.getBufferBounds().rows + ); + }, []); + + const resolveTerminalScreen = useCallback( + (terminal: NativeHeadlessTerminal): TerminalScreenState => { + if (!screenModelRef.current) { + screenModelRef.current = createNativeTerminalScreenModel({ terminal }); + } + const screen = screenModelRef.current.sync({ visibleRows: resolveVisibleRows(terminal) }); + latestResolvedScreenRef.current = screen; + return screen; + }, + [resolveVisibleRows], + ); + + const paintTerminalScreen = useCallback( + (terminal: NativeHeadlessTerminal): TerminalViewportState => { + const screen = resolveTerminalScreen(terminal); + commitTerminalScreen(screen); + syncSelectionWithTerminal(terminal); + return screen.viewport; + }, + [commitTerminalScreen, resolveTerminalScreen, syncSelectionWithTerminal], + ); + + const schedulePaint = useCallback(() => { + if (paintFrameRef.current !== null) { + return; + } + paintFrameRef.current = requestAnimationFrame(() => { + paintFrameRef.current = null; + const terminal = terminalRef.current; + if (!terminal) { + return; + } + paintTerminalScreen(terminal); + }); + }, [paintTerminalScreen]); + + const createOutputDrain = useCallback( + (terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain => + createNativeTerminalOutputDrain({ + write: async (chunk) => { + await terminal.write(chunk); + emitInputModeChange(); + }, + reset: () => { + terminal.reset(); + emitInputModeChange(); + }, + getViewportState: () => { + const screen = resolveTerminalScreen(terminal); + syncSelectionWithTerminal(terminal); + return screen.viewport; + }, + onPaint: () => { + const screen = latestResolvedScreenRef.current; + if (screen) { + commitTerminalScreen(screen); + } + }, + scheduleFrame: requestAnimationFrame, + cancelFrame: cancelAnimationFrame, + }), + [commitTerminalScreen, emitInputModeChange, resolveTerminalScreen, syncSelectionWithTerminal], + ); + + const emitMeasuredSize = useCallback( + (input: { source: TerminalResizeSource; claimToken?: number }) => { + const nextSize = resolveMeasuredNativeTerminalSize({ + layout: layoutRef.current, + metrics: metricsRef.current, + }); + if (!nextSize) { + if (input.source === "claim") { + resizePolicyRef.current = updateTerminalResizePolicy(resizePolicyRef.current, { + source: input.source, + size: null, + claimToken: input.claimToken, + }).state; + } + return; + } + const policyResult = updateTerminalResizePolicy(resizePolicyRef.current, { + source: input.source, + size: nextSize, + claimToken: input.claimToken, + }); + resizePolicyRef.current = policyResult.state; + if ( + !policyResult.measuredSizeChanged && + !policyResult.resizeClaim && + hasNotifiedMeasuredSizeRef.current + ) { + return; + } + + hasNotifiedMeasuredSizeRef.current = true; + terminalSizeRef.current = policyResult.measuredSize; + if (policyResult.measuredSizeChanged) { + terminalRef.current?.resize(policyResult.measuredSize); + schedulePaint(); + } + callbacksRef.current.onResize?.({ + rows: policyResult.measuredSize.rows, + cols: policyResult.measuredSize.cols, + shouldClaim: policyResult.resizeClaim !== null, + forceClaim: policyResult.resizeClaim?.force, + }); + }, + [schedulePaint], + ); + + const resetTerminal = useCallback( + (snapshot: TerminalState | null) => { + outputDrainRef.current?.dispose(); + outputDrainRef.current = null; + terminalRef.current?.dispose(); + screenModelRef.current = null; + latestResolvedScreenRef.current = null; + clearSelection(); + const measuredSize = resolveMeasuredNativeTerminalSize({ + layout: layoutRef.current, + metrics: metricsRef.current, + }); + const size = + measuredSize ?? + (snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current); + terminalSizeRef.current = size; + resizePolicyRef.current = createTerminalResizePolicy(size); + outputDecoderRef.current.decode(); + const terminal = createNativeHeadlessTerminal({ + rows: size.rows, + cols: size.cols, + scrollbackLines, + }); + terminalRef.current = terminal; + screenModelRef.current = createNativeTerminalScreenModel({ terminal }); + const outputDrain = createOutputDrain(terminal); + outputDrainRef.current = outputDrain; + const screen = snapshot + ? screenStateFromViewport(viewportStateFromSnapshot(snapshot)) + : resolveTerminalScreen(terminal); + commitTerminalScreen(screen); + resetInputModeTracker(); + if (snapshot) { + outputDrain.restoreSnapshot(snapshot); + } + }, + [ + commitTerminalScreen, + createOutputDrain, + resetInputModeTracker, + resolveTerminalScreen, + scrollbackLines, + clearSelection, + ], + ); + + const enqueueOutputText = useCallback( + (text: string) => { + if (text.length === 0) { + return; + } + const inputModeUpdate = inputModeTrackerRef.current.feed(text); + if (inputModeUpdate.changed) { + emitInputModeChange(); + } + outputDrainRef.current?.enqueueText(text); + }, + [emitInputModeChange], + ); + + const claimActiveTerminalSize = useCallback(() => { + activeResizeClaimTokenRef.current += 1; + emitMeasuredSize({ source: "claim", claimToken: activeResizeClaimTokenRef.current }); + }, [emitMeasuredSize]); + + const claimActiveTerminalSizeForAction = useCallback( + (action: NativeTerminalSizeClaimAction) => { + if (shouldClaimNativeTerminalSize(action)) { + claimActiveTerminalSize(); + } + }, + [claimActiveTerminalSize], + ); + + useImperativeHandle( + ref, + (): TerminalEmulatorHandle => ({ + writeOutput: (data: TerminalOutputData) => { + const text = outputDecoderRef.current.decode(data, { stream: true }); + enqueueOutputText(text); + }, + restoreOutput: (data: TerminalOutputData) => { + outputDecoderRef.current.decode(); + resetInputModeTracker(); + const text = outputDecoderRef.current.decode(data, { stream: false }); + outputDrainRef.current?.restoreText(text); + }, + renderSnapshot: (state: TerminalState | null) => { + outputDecoderRef.current.decode(); + resetInputModeTracker(); + if (!state) { + resetTerminal(null); + return; + } + clearSelection(); + const outputDrain = outputDrainRef.current; + if (!outputDrain) { + resetTerminal(state); + return; + } + outputDrain.restoreSnapshot(state); + }, + paste: (text: string) => { + if (text.length === 0) { + return; + } + claimActiveTerminalSizeForAction("paste"); + callbacksRef.current.onInput?.( + encodeTerminalPaste({ + text, + bracketedPaste: inputModeTrackerRef.current.getState().bracketedPaste ?? false, + }), + ); + }, + copySelection: async (clipboard: TerminalClipboardWriter): Promise => { + const terminal = terminalRef.current; + if (!terminal) { + return ""; + } + const selection = selectionModelRef.current.getSnapshot().range; + const copied = await copyTerminalSelection({ terminal, selection, clipboard }); + if (copied.length > 0) { + clearSelection(); + } + return copied; + }, + clear: () => { + outputDecoderRef.current.decode(); + outputDrainRef.current?.clear(); + screenModelRef.current?.reset(); + clearSelection(); + resetInputModeTracker(); + terminalRef.current?.reset(); + schedulePaint(); + }, + claimSize: claimActiveTerminalSize, + showKeyboard: () => { + inputRef.current?.showKeyboard(); + claimActiveTerminalSizeForAction("showKeyboard"); + }, + blur: () => { + inputRef.current?.blur(); + Keyboard.dismiss(); + }, + }), + [ + enqueueOutputText, + claimActiveTerminalSize, + claimActiveTerminalSizeForAction, + clearSelection, + resetInputModeTracker, + resetTerminal, + schedulePaint, + ], + ); + + useEffect(() => { + hasNotifiedMeasuredSizeRef.current = false; + resetTerminal(mountInputRef.current.initialSnapshot); + callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true }); + return () => { + callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); + clearSelectionLongPressTimeout(); + selectionPressRef.current = null; + if (paintFrameRef.current !== null) { + cancelAnimationFrame(paintFrameRef.current); + paintFrameRef.current = null; + } + outputDrainRef.current?.dispose(); + outputDrainRef.current = null; + terminalRef.current?.dispose(); + terminalRef.current = null; + }; + }, [clearSelectionLongPressTimeout, resetTerminal, streamKey]); + + useEffect(() => { + if (focusRequestToken <= 0) return; + inputRef.current?.focus(); + }, [focusRequestToken]); + + const handleTerminalFocus = useCallback(() => { + callbacksRef.current.onFocus?.(); + claimActiveTerminalSizeForAction("focus"); + }, [claimActiveTerminalSizeForAction]); + + const handleTerminalInput = useCallback( + (data: string) => { + claimActiveTerminalSizeForAction("text"); + callbacksRef.current.onInput?.(data); + }, + [claimActiveTerminalSizeForAction], + ); + + const handleNativeTerminalKey = useCallback( + (key: NativeTerminalKey) => { + claimActiveTerminalSizeForAction("key"); + forwardNativeTerminalKey({ key, onTerminalKey: callbacksRef.current.onTerminalKey }); + }, + [claimActiveTerminalSizeForAction], + ); + + const focusTerminalFromTap = useCallback(() => { + clearSelection(); + inputRef.current?.focus(); + claimActiveTerminalSizeForAction("focus"); + }, [claimActiveTerminalSizeForAction, clearSelection]); + + useEffect(() => { + if (resizeRequestToken <= 0) return; + emitMeasuredSize({ source: "measure" }); + }, [emitMeasuredSize, resizeRequestToken]); + + useEffect(() => { + emitMeasuredSize({ source: "measure" }); + }, [emitMeasuredSize, keyboardInset]); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + layoutRef.current = { + width: event.nativeEvent.layout.width, + height: event.nativeEvent.layout.height, + }; + emitMeasuredSize({ source: "measure" }); + }, + [emitMeasuredSize], + ); + + const handleCellMetricsChange = useCallback( + (metrics: TerminalGridCellMetrics) => { + metricsRef.current = metrics; + emitMeasuredSize({ source: "measure" }); + }, + [emitMeasuredSize], + ); + + const applyScroll = useCallback( + (direction: "up" | "down", rows: number) => { + const terminal = terminalRef.current; + const model = screenModelRef.current; + if (!terminal || !model || rows <= 0) { + return; + } + const visibleRows = resolveVisibleRows(terminal); + const screen = + direction === "up" + ? model.scrollUp({ rows, visibleRows }) + : model.scrollDown({ rows, visibleRows }); + commitTerminalScreen(screen); + }, + [commitTerminalScreen, resolveVisibleRows], + ); + + const returnToBottom = useCallback(() => { + const terminal = terminalRef.current; + const model = screenModelRef.current; + if (!terminal || !model) { + return; + } + const screen = model.returnToBottom({ visibleRows: resolveVisibleRows(terminal) }); + commitTerminalScreen(screen); + }, [commitTerminalScreen, resolveVisibleRows]); + + const resolveSelectionViewport = useCallback((): TerminalSelectionViewport | null => { + const screen = terminalScreenRef.current; + const metrics = metricsRef.current; + const layout = layoutRef.current; + if (!screen || !metrics) { + return null; + } + + const visibleCols = layout + ? Math.min(screen.viewport.cols, Math.max(1, Math.floor(layout.width / metrics.cellWidth))) + : screen.viewport.cols; + return { + firstRow: screen.viewport.firstRow, + rows: screen.viewport.grid.length, + cols: visibleCols, + }; + }, []); + + const resolveSelectionCoordinate = useCallback( + (point: { x: number; y: number }): TerminalBufferCoordinate | null => { + const metrics = metricsRef.current; + const viewport = resolveSelectionViewport(); + if (!metrics || !viewport) { + return null; + } + return hitTestTerminalSelectionCell({ point, metrics, viewport }); + }, + [resolveSelectionViewport], + ); + + const beginSelectionAtPoint = useCallback( + (point: { x: number; y: number }) => { + const terminal = terminalRef.current; + if (!terminal) { + return; + } + const coordinate = resolveSelectionCoordinate(point); + if (!coordinate) { + return; + } + const existingPress = selectionPressRef.current; + selectionPressRef.current = { + startX: point.x, + startY: point.y, + startedAt: existingPress?.startedAt ?? Date.now(), + startedWithSelection: existingPress?.startedWithSelection ?? false, + status: "selecting", + }; + const bounds = terminal.getBufferBounds(); + const wordSelection = resolveTerminalWordSelection({ terminal, coordinate }); + let snapshot = selectionModelRef.current.begin({ + coordinate: wordSelection?.start ?? coordinate, + bounds, + }); + if (wordSelection && wordSelection.end.col !== wordSelection.start.col) { + snapshot = selectionModelRef.current.update({ coordinate: wordSelection.end, bounds }); + } + commitSelectionRange(snapshot.range); + }, + [commitSelectionRange, resolveSelectionCoordinate], + ); + + const updateSelectionAtPoint = useCallback( + (point: { x: number; y: number }) => { + const terminal = terminalRef.current; + if (!terminal || selectionPressRef.current?.status !== "selecting") { + return; + } + const coordinate = resolveSelectionCoordinate(point); + if (!coordinate) { + return; + } + const snapshot = selectionModelRef.current.update({ + coordinate, + bounds: terminal.getBufferBounds(), + }); + commitSelectionRange(snapshot.range); + }, + [commitSelectionRange, resolveSelectionCoordinate], + ); + + const handlePanResponderScroll = useCallback( + (currentDy: number) => { + const cellHeight = metricsRef.current?.cellHeight; + if (!cellHeight || cellHeight <= 0) { + return; + } + const panState = panStateRef.current; + const update = advanceTerminalScrollGesture({ + state: panState, + currentDy, + cellHeight, + }); + panState.lastDy = update.state.lastDy; + panState.rowRemainder = update.state.rowRemainder; + panState.didScroll = update.state.didScroll; + const { rowDelta } = update; + if (rowDelta === 0) { + return; + } + if (rowDelta > 0) { + applyScroll("up", rowDelta); + return; + } + applyScroll("down", Math.abs(rowDelta)); + }, + [applyScroll], + ); + + const panResponder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: (_event, gesture) => { + const intent = classifyTerminalGestureIntent({ + status: selectionRangeRef.current ? "selecting" : "pressing", + dx: gesture.dx, + dy: gesture.dy, + vx: gesture.vx, + vy: gesture.vy, + }); + return intent === "scroll" || intent === "select"; + }, + onMoveShouldSetPanResponderCapture: (_event, gesture) => { + const intent = classifyTerminalGestureIntent({ + status: selectionRangeRef.current ? "selecting" : "pressing", + dx: gesture.dx, + dy: gesture.dy, + vx: gesture.vx, + vy: gesture.vy, + }); + return intent === "scroll" || intent === "select"; + }, + onPanResponderGrant: (event) => { + panStateRef.current = { + lastDy: 0, + rowRemainder: 0, + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + intent: null, + }; + const point = { + x: event.nativeEvent.locationX, + y: event.nativeEvent.locationY, + }; + const pressStatus = selectionRangeRef.current ? "selecting" : "pressing"; + selectionPressRef.current = { + startX: point.x, + startY: point.y, + startedAt: Date.now(), + startedWithSelection: pressStatus === "selecting", + status: pressStatus, + }; + clearSelectionLongPressTimeout(); + if (pressStatus === "selecting") { + return; + } + selectionLongPressTimeoutRef.current = setTimeout(() => { + selectionLongPressTimeoutRef.current = null; + const press = selectionPressRef.current; + if (press?.status !== "pressing") { + return; + } + beginSelectionAtPoint({ x: press.startX, y: press.startY }); + }, TERMINAL_GESTURE_LONG_PRESS_MS); + }, + onPanResponderMove: (event, gesture) => { + const selectionPress = selectionPressRef.current; + const panState = panStateRef.current; + if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { + panState.movedBeyondTapTolerance = true; + } + if (selectionPress?.status === "selecting") { + updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); + return; + } + + if (panState.intent === "scroll") { + handlePanResponderScroll(gesture.dy); + return; + } + if (panState.didNavigate) { + return; + } + + if (selectionPress?.status === "pressing") { + const point = resolvePanResponderPoint(selectionPress, event, gesture); + const dx = point.x - selectionPress.startX; + const dy = point.y - selectionPress.startY; + const intent = classifyTerminalGestureIntent({ + status: "pressing", + dx, + dy, + vx: gesture.vx, + vy: gesture.vy, + elapsedMs: Date.now() - selectionPress.startedAt, + }); + if (intent === "tap" || intent === "pending") { + return; + } + + clearSelectionLongPressTimeout(); + panState.intent = intent; + + if (intent === "scroll") { + selectionPressRef.current = null; + handlePanResponderScroll(gesture.dy); + return; + } + + if (intent === "select") { + beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); + updateSelectionAtPoint(point); + return; + } + } + }, + onPanResponderRelease: (event, gesture) => { + clearSelectionLongPressTimeout(); + const selectionPress = selectionPressRef.current; + if (selectionPress?.status === "selecting") { + updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); + } + selectionPressRef.current = null; + const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current; + panStateRef.current = { + lastDy: 0, + rowRemainder: 0, + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + intent: null, + }; + const releaseAction = resolveTerminalGestureReleaseAction({ + status: selectionPress?.status ?? "idle", + startedWithSelection: selectionPress?.startedWithSelection, + didScroll, + didNavigate, + movedBeyondTapTolerance, + pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: terminalScreenRef.current?.scroll.mode ?? "following", + }); + if (releaseAction === "select" && selectionPress) { + beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); + return; + } + if (releaseAction === "focus" || releaseAction === "clear") { + focusTerminalFromTap(); + } + }, + onPanResponderTerminate: () => { + clearSelectionLongPressTimeout(); + selectionPressRef.current = null; + panStateRef.current = { + lastDy: 0, + rowRemainder: 0, + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + intent: null, + }; + }, + }), + [ + beginSelectionAtPoint, + clearSelectionLongPressTimeout, + focusTerminalFromTap, + handlePanResponderScroll, + updateSelectionAtPoint, + ], + ); + + const backgroundColor = xtermTheme.background ?? "#0b0b0b"; + const rootStyle = useMemo>( + () => [styles.root, { backgroundColor }], + [backgroundColor], + ); + + const terminalState = terminalScreen?.viewport ?? null; + const isScrolled = terminalScreen?.scroll.mode === "scrolled"; + const terminalGrid = terminalState ? ( + + ) : null; + + return ( + + + {terminalGrid} + + + {isScrolled ? ( + + Bottom + + ) : null} + + ); +} + +export default function NativeGridTerminalEmulator(props: TerminalEmulatorProps) { + return ; +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + minHeight: 0, + minWidth: 0, + overflow: "hidden", + position: "relative", + }, + nativeSurface: { + backgroundColor: "transparent", + flex: 1, + }, + nativeGrid: { + flex: 1, + }, + followButton: { + position: "absolute", + right: 12, + bottom: 12, + zIndex: 2, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: "rgba(32, 32, 32, 0.86)", + }, + followButtonText: { + color: "#f5f5f5", + fontSize: 12, + fontWeight: "600", + }, +}); diff --git a/packages/app/src/components/terminal-emulator-webview.native.tsx b/packages/app/src/components/terminal-emulator-webview.native.tsx new file mode 100644 index 0000000000..77a5fbb94b --- /dev/null +++ b/packages/app/src/components/terminal-emulator-webview.native.tsx @@ -0,0 +1,688 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; +import { + Keyboard, + StyleSheet, + View, + type GestureResponderEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; +import type { ITheme } from "@xterm/xterm"; +import type { TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; +import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; +import type { + TerminalLocalFileLinkSource, + TerminalLocalFileLinkTarget, +} from "../terminal/local-links/terminal-local-link-provider"; +import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html"; +import type { PendingTerminalModifiers } from "../utils/terminal-keys"; +import { openExternalUrl } from "../utils/open-external-url"; +import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract"; +import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing"; +import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness"; + +type BridgeInboundMessage = + | { + type: "mount"; + streamKey: string; + initialSnapshot: TerminalState | null; + scrollbackLines: number; + theme: ITheme; + fontFamily?: string; + fontSize?: number; + pendingModifiers: PendingTerminalModifiers; + swipeGesturesEnabled: boolean; + } + | { type: "unmount"; streamKey: string } + | { type: "writeOutput"; streamKey: string; text: string } + | { type: "restoreOutput"; streamKey: string; text: string } + | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } + | { type: "paste"; streamKey: string; text: string } + | { type: "clear"; streamKey: string } + | { type: "focus"; streamKey: string; forceRefocus?: boolean } + | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean } + | { type: "setTheme"; streamKey: string; theme: ITheme } + | { type: "setScrollback"; streamKey: string; lines: number } + | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } + | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } + | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean } + | { + type: "resolveLocalFileLinkResponse"; + streamKey: string; + requestId: number; + target: TerminalLocalFileLinkTarget | null; + }; + +type BridgeOutboundMessage = + | { type: "bridgeReady" } + | { type: "rendererReady"; streamKey: string; isReady: boolean } + | { type: "input"; streamKey: string; data: string } + | { + type: "resize"; + streamKey: string; + rows: number; + cols: number; + shouldClaim?: boolean; + forceClaim?: boolean; + } + | { + type: "terminalKey"; + streamKey: string; + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + } + | { type: "pendingModifiersConsumed"; streamKey: string } + | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } + | { type: "openExternalUrl"; streamKey: string; url: string } + | { + type: "resolveLocalFileLink"; + streamKey: string; + requestId: number; + source: TerminalLocalFileLinkSource; + } + | { + type: "openLocalFileLink"; + streamKey: string; + target: TerminalLocalFileLinkTarget; + disposition: "main" | "side"; + } + | { type: "swipeLeft"; streamKey: string } + | { type: "swipeRight"; streamKey: string } + | { type: "debug"; message: string; details?: unknown }; + +const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml }; +const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"]; +const BRIDGE_READY_TIMEOUT_MS = 2_500; +const RENDERER_READY_TIMEOUT_MS = 2_500; +const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8; +type WebViewProps = ComponentProps; +interface PendingTerminalTap { + startX: number; + startY: number; + moved: boolean; +} + +function buildThemeKey(theme: ITheme): string { + return JSON.stringify(theme); +} + +function serializeForInjectedJavaScript(message: BridgeInboundMessage): string { + return JSON.stringify(message).replace(/<\/script/gi, "<\\/script"); +} + +function createMountMessage(input: { + streamKey: string; + initialSnapshot: TerminalState | null; + scrollbackLines: number; + theme: ITheme; + fontFamily?: string; + fontSize?: number; + pendingModifiers: PendingTerminalModifiers; + swipeGesturesEnabled: boolean; +}): BridgeInboundMessage { + return { + type: "mount", + streamKey: input.streamKey, + initialSnapshot: input.initialSnapshot, + scrollbackLines: input.scrollbackLines, + theme: input.theme, + fontFamily: input.fontFamily, + fontSize: input.fontSize, + pendingModifiers: input.pendingModifiers, + swipeGesturesEnabled: input.swipeGesturesEnabled, + }; +} + +export default function WebViewTerminalEmulator({ + ref, + streamKey, + testId = "terminal-surface", + xtermTheme = { + background: "#0b0b0b", + foreground: "#e6e6e6", + cursor: "#e6e6e6", + }, + scrollbackLines, + fontFamily, + fontSize, + swipeGesturesEnabled = false, + onSwipeLeft, + onSwipeRight, + initialSnapshot = null, + onInput, + onFocus, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + onInputModeChange, + onResolveLocalFileLink, + onOpenLocalFileLink, + onRendererReadyChange, + pendingModifiers = { ctrl: false, shift: false, alt: false }, + focusRequestToken = 0, + resizeRequestToken = 0, +}: TerminalEmulatorProps) { + const webViewRef = useRef(null); + const [webViewEpoch, setWebViewEpoch] = useState(0); + const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0); + const bridgeReadyRef = useRef(false); + const bridgeReadyVersionRef = useRef(0); + const rendererReadyVersionRef = useRef(0); + const pendingMessagesRef = useRef([]); + const outputDecoderRef = useRef(new TextDecoder()); + const mountRequestedStreamKeyRef = useRef(null); + const rendererReadyStreamKeyRef = useRef(null); + const bridgeReadyTimeoutRef = useRef | null>(null); + const rendererReadyTimeoutRef = useRef | null>(null); + const pendingTapRef = useRef(null); + const mountConfigRef = useRef({ + streamKey, + initialSnapshot, + scrollbackLines, + theme: xtermTheme, + fontFamily, + fontSize, + pendingModifiers, + swipeGesturesEnabled, + }); + mountConfigRef.current = { + streamKey, + initialSnapshot, + scrollbackLines, + theme: xtermTheme, + fontFamily, + fontSize, + pendingModifiers, + swipeGesturesEnabled, + }; + const callbacksRef = useRef({ + onInput, + onFocus, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + onInputModeChange, + onRendererReadyChange, + onResolveLocalFileLink, + onOpenLocalFileLink, + onSwipeLeft, + onSwipeRight, + }); + callbacksRef.current = { + onInput, + onFocus, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + onInputModeChange, + onRendererReadyChange, + onResolveLocalFileLink, + onOpenLocalFileLink, + onSwipeLeft, + onSwipeRight, + }; + + const clearBridgeReadyTimeout = useCallback(() => { + if (bridgeReadyTimeoutRef.current === null) return; + clearTimeout(bridgeReadyTimeoutRef.current); + bridgeReadyTimeoutRef.current = null; + }, []); + + const clearRendererReadyTimeout = useCallback(() => { + if (rendererReadyTimeoutRef.current === null) return; + clearTimeout(rendererReadyTimeoutRef.current); + rendererReadyTimeoutRef.current = null; + }, []); + + const resetWebViewDocument = useCallback(() => { + clearBridgeReadyTimeout(); + clearRendererReadyTimeout(); + bridgeReadyRef.current = false; + pendingMessagesRef.current = []; + mountRequestedStreamKeyRef.current = null; + rendererReadyStreamKeyRef.current = null; + callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); + setWebViewEpoch((value) => value + 1); + }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]); + + const scheduleBridgeReadyWatchdog = useCallback(() => { + clearBridgeReadyTimeout(); + const expectedBridgeReadyVersion = bridgeReadyVersionRef.current; + bridgeReadyTimeoutRef.current = setTimeout(() => { + bridgeReadyTimeoutRef.current = null; + if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) { + return; + } + resetWebViewDocument(); + }, BRIDGE_READY_TIMEOUT_MS); + }, [clearBridgeReadyTimeout, resetWebViewDocument]); + + const scheduleRendererReadyWatchdog = useCallback(() => { + clearRendererReadyTimeout(); + const expectedRendererReadyVersion = rendererReadyVersionRef.current; + rendererReadyTimeoutRef.current = setTimeout(() => { + rendererReadyTimeoutRef.current = null; + if ( + !shouldResetForMissingRendererReady({ + expectedReadyVersion: expectedRendererReadyVersion, + currentReadyVersion: rendererReadyVersionRef.current, + expectedStreamKey: streamKey, + mountRequestedStreamKey: mountRequestedStreamKeyRef.current, + rendererReadyStreamKey: rendererReadyStreamKeyRef.current, + }) + ) { + return; + } + resetWebViewDocument(); + }, RENDERER_READY_TIMEOUT_MS); + }, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]); + + const flushPendingMessages = useCallback(() => { + if (!bridgeReadyRef.current || !webViewRef.current) return; + const pending = pendingMessagesRef.current.splice(0); + for (const message of pending) { + const payload = serializeForInjectedJavaScript(message); + webViewRef.current.injectJavaScript( + `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, + ); + } + }, []); + + const sendToWebView = useCallback((message: BridgeInboundMessage) => { + if (!bridgeReadyRef.current || !webViewRef.current) { + pendingMessagesRef.current.push(message); + return; + } + const payload = serializeForInjectedJavaScript(message); + webViewRef.current.injectJavaScript( + `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, + ); + }, []); + + useImperativeHandle( + ref, + (): TerminalEmulatorHandle => ({ + writeOutput: (data: TerminalOutputData) => { + const output = outputDecoderRef.current.decode(data, { stream: true }); + if (output.length === 0) { + return; + } + sendToWebView({ type: "writeOutput", streamKey, text: output }); + }, + restoreOutput: (data: TerminalOutputData) => { + outputDecoderRef.current.decode(); + const text = outputDecoderRef.current.decode(data, { stream: false }); + if (text.length === 0) { + return; + } + sendToWebView({ type: "restoreOutput", streamKey, text }); + }, + renderSnapshot: (state: TerminalState | null) => { + outputDecoderRef.current.decode(); + sendToWebView({ type: "renderSnapshot", streamKey, state }); + }, + paste: (text: string) => { + sendToWebView({ type: "paste", streamKey, text }); + }, + copySelection: async () => "", + clear: () => { + outputDecoderRef.current.decode(); + sendToWebView({ type: "clear", streamKey }); + }, + claimSize: () => { + sendToWebView({ type: "resize", streamKey, forceClaim: true }); + }, + showKeyboard: () => { + sendToWebView({ type: "focus", streamKey, forceRefocus: true }); + webViewRef.current?.requestFocus(); + }, + blur: () => { + webViewRef.current?.injectJavaScript( + "window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;", + ); + Keyboard.dismiss(); + }, + }), + [sendToWebView, streamKey], + ); + + useEffect(() => { + outputDecoderRef.current.decode(); + }, [streamKey]); + + useEffect(() => { + if (bridgeReadyVersion <= 0) return; + const mountMessage = createMountMessage(mountConfigRef.current); + mountRequestedStreamKeyRef.current = streamKey; + rendererReadyStreamKeyRef.current = null; + sendToWebView(mountMessage); + flushPendingMessages(); + scheduleRendererReadyWatchdog(); + }, [ + bridgeReadyVersion, + flushPendingMessages, + scheduleRendererReadyWatchdog, + sendToWebView, + streamKey, + ]); + + const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]); + useEffect(() => { + if (!mountRequestedStreamKeyRef.current) return; + sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme }); + }, [sendToWebView, streamKey, themeKey, xtermTheme]); + + useEffect(() => { + if (!mountRequestedStreamKeyRef.current) return; + sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines }); + }, [scrollbackLines, sendToWebView, streamKey]); + + useEffect(() => { + if (!mountRequestedStreamKeyRef.current) return; + sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize }); + }, [fontFamily, fontSize, sendToWebView, streamKey]); + + useEffect(() => { + if (!mountRequestedStreamKeyRef.current) return; + sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers }); + }, [pendingModifiers, sendToWebView, streamKey]); + + useEffect(() => { + if (!mountRequestedStreamKeyRef.current) return; + sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled }); + }, [sendToWebView, streamKey, swipeGesturesEnabled]); + + useEffect(() => { + if (focusRequestToken <= 0) return; + sendToWebView({ type: "resize", streamKey, forceClaim: true }); + sendToWebView({ type: "focus", streamKey }); + webViewRef.current?.requestFocus(); + }, [focusRequestToken, sendToWebView, streamKey]); + + useEffect(() => { + if (resizeRequestToken <= 0) return; + sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false }); + }, [resizeRequestToken, sendToWebView, streamKey]); + + useEffect(() => { + return () => { + if (mountRequestedStreamKeyRef.current) { + const previousStreamKey = mountRequestedStreamKeyRef.current; + callbacksRef.current.onRendererReadyChange?.({ + streamKey: previousStreamKey, + isReady: false, + }); + sendToWebView({ type: "unmount", streamKey: previousStreamKey }); + } + bridgeReadyRef.current = false; + pendingMessagesRef.current = []; + mountRequestedStreamKeyRef.current = null; + rendererReadyStreamKeyRef.current = null; + clearBridgeReadyTimeout(); + clearRendererReadyTimeout(); + }; + }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]); + + const handleLifecycleMessage = useCallback( + (message: BridgeOutboundMessage): boolean => { + if (message.type === "bridgeReady") { + bridgeReadyRef.current = true; + bridgeReadyVersionRef.current += 1; + clearBridgeReadyTimeout(); + setBridgeReadyVersion((value) => value + 1); + return true; + } + if (message.type === "rendererReady") { + if (message.streamKey === mountRequestedStreamKeyRef.current) { + rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null; + if (message.isReady) { + rendererReadyVersionRef.current += 1; + clearRendererReadyTimeout(); + } + } + callbacksRef.current.onRendererReadyChange?.({ + streamKey: message.streamKey, + isReady: message.isReady, + }); + return true; + } + return false; + }, + [clearBridgeReadyTimeout, clearRendererReadyTimeout], + ); + + const resolveLocalFileLink = useCallback( + async (message: Extract) => { + try { + const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ?? + null); + sendToWebView({ + type: "resolveLocalFileLinkResponse", + streamKey: message.streamKey, + requestId: message.requestId, + target, + }); + } catch { + sendToWebView({ + type: "resolveLocalFileLinkResponse", + streamKey: message.streamKey, + requestId: message.requestId, + target: null, + }); + } + }, + [sendToWebView], + ); + + const handleTerminalMessage = useCallback( + ( + message: Exclude, + ) => { + if (message.type === "resolveLocalFileLink") { + void resolveLocalFileLink(message); + return; + } + if (message.type === "openLocalFileLink") { + callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition); + return; + } + switch (message.type) { + case "input": + callbacksRef.current.onInput?.(message.data); + break; + case "resize": + callbacksRef.current.onResize?.({ + rows: message.rows, + cols: message.cols, + shouldClaim: message.shouldClaim !== false, + forceClaim: message.forceClaim, + }); + break; + case "terminalKey": + callbacksRef.current.onTerminalKey?.({ + key: message.key, + ctrl: message.ctrl, + shift: message.shift, + alt: message.alt, + meta: message.meta, + }); + break; + case "pendingModifiersConsumed": + callbacksRef.current.onPendingModifiersConsumed?.(); + break; + case "inputModeChange": + callbacksRef.current.onInputModeChange?.(message.state); + break; + case "openExternalUrl": + void openExternalUrl(message.url); + break; + case "swipeLeft": + callbacksRef.current.onSwipeLeft?.(); + break; + case "swipeRight": + callbacksRef.current.onSwipeRight?.(); + break; + case "debug": + break; + } + }, + [resolveLocalFileLink], + ); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let message: BridgeOutboundMessage; + try { + message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage; + } catch { + return; + } + + if (message.type === "bridgeReady" || message.type === "rendererReady") { + handleLifecycleMessage(message); + return; + } + if ( + !shouldHandleTerminalBridgeMessage(message, { + desiredStreamKey: streamKey, + mountedStreamKey: mountRequestedStreamKeyRef.current, + }) + ) { + return; + } + handleTerminalMessage(message); + }, + [handleLifecycleMessage, handleTerminalMessage, streamKey], + ); + + const handleLoadStart = useCallback>(() => { + bridgeReadyRef.current = false; + mountRequestedStreamKeyRef.current = null; + rendererReadyStreamKeyRef.current = null; + scheduleBridgeReadyWatchdog(); + }, [scheduleBridgeReadyWatchdog]); + + const handleContentProcessDidTerminate = useCallback< + NonNullable + >(() => { + resetWebViewDocument(); + }, [resetWebViewDocument]); + + const handleRenderProcessGone = useCallback< + NonNullable + >(() => { + resetWebViewDocument(); + }, [resetWebViewDocument]); + + const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => { + pendingTapRef.current = { + startX: event.nativeEvent.pageX, + startY: event.nativeEvent.pageY, + moved: false, + }; + }, []); + + const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => { + const pendingTap = pendingTapRef.current; + if (!pendingTap) return; + const dx = event.nativeEvent.pageX - pendingTap.startX; + const dy = event.nativeEvent.pageY - pendingTap.startY; + if ( + Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX || + Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX + ) { + pendingTap.moved = true; + } + }, []); + + const handleWebViewTouchEnd = useCallback(() => { + const pendingTap = pendingTapRef.current; + pendingTapRef.current = null; + if (!pendingTap || pendingTap.moved) { + return; + } + webViewRef.current?.requestFocus(); + callbacksRef.current.onFocus?.(); + sendToWebView({ type: "focus", streamKey, forceRefocus: true }); + }, [sendToWebView, streamKey]); + + const handleWebViewTouchCancel = useCallback(() => { + pendingTapRef.current = null; + }, []); + + const backgroundColor = xtermTheme.background ?? "#0b0b0b"; + const rootStyle = useMemo>( + () => [styles.root, { backgroundColor }], + [backgroundColor], + ); + const webViewStyle = useMemo>( + () => [styles.webView, { backgroundColor }], + [backgroundColor], + ); + const webViewContainerStyle = useMemo>( + () => [styles.webViewContainer, { backgroundColor }], + [backgroundColor], + ); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + minHeight: 0, + minWidth: 0, + overflow: "hidden", + }, + webView: { + flex: 1, + }, + webViewContainer: { + flex: 1, + }, +}); diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index ec1c41fdd2..052d7a02f0 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -1,693 +1,56 @@ -import { - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, - type ComponentProps, - type Ref, -} from "react"; -import { - Keyboard, - StyleSheet, - View, - type GestureResponderEvent, - type StyleProp, - type ViewStyle, -} from "react-native"; -import { WebView, type WebViewMessageEvent } from "react-native-webview"; -import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; -import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; -import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; -import type { - TerminalLocalFileLinkSource, - TerminalLocalFileLinkTarget, -} from "../terminal/local-links/terminal-local-link-provider"; -import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html"; -import type { PendingTerminalModifiers } from "../utils/terminal-keys"; -import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; -import { openExternalUrl } from "../utils/open-external-url"; +import { useEffect } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { useAppSettings } from "../hooks/use-settings"; +import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability"; +import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native"; +import WebViewTerminalEmulator from "./terminal-emulator-webview.native"; +import type { TerminalEmulatorProps } from "./terminal-emulator-contract"; -export interface TerminalEmulatorHandle { - writeOutput: (data: TerminalOutputData) => void; - restoreOutput: (data: TerminalOutputData) => void; - renderSnapshot: (state: TerminalState | null) => void; - clear: () => void; - blur: () => void; -} +export type { TerminalEmulatorHandle } from "./terminal-emulator-contract"; -interface TerminalEmulatorProps { - dom?: unknown; - ref: Ref; - streamKey: string; - testId?: string; - xtermTheme?: ITheme; - scrollbackLines: number; - fontFamily?: string; - fontSize?: number; - swipeGesturesEnabled?: boolean; - onSwipeLeft?: () => void; - onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; - onInput?: (data: string) => Promise | void; - onFocus?: () => Promise | void; - onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise | void; - onTerminalKey?: (input: { - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; - }) => Promise | void; - onPendingModifiersConsumed?: () => Promise | void; - onInputModeChange?: (state: TerminalInputModeState) => Promise | void; - onResolveLocalFileLink?: ( - source: TerminalLocalFileLinkSource, - ) => Promise | TerminalLocalFileLinkTarget | null; - onOpenLocalFileLink?: ( - target: TerminalLocalFileLinkTarget, - disposition: "main" | "side", - ) => Promise | void; - onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; - pendingModifiers?: PendingTerminalModifiers; - focusRequestToken?: number; - resizeRequestToken?: number; -} - -type BridgeInboundMessage = - | { - type: "mount"; - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; - } - | { type: "unmount"; streamKey: string } - | { type: "writeOutput"; streamKey: string; text: string } - | { type: "restoreOutput"; streamKey: string; text: string } - | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } - | { type: "clear"; streamKey: string } - | { type: "focus"; streamKey: string; forceRefocus?: boolean } - | { type: "resize"; streamKey: string; shouldClaim?: boolean } - | { type: "setTheme"; streamKey: string; theme: ITheme } - | { type: "setScrollback"; streamKey: string; lines: number } - | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } - | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } - | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean } - | { - type: "resolveLocalFileLinkResponse"; - streamKey: string; - requestId: number; - target: TerminalLocalFileLinkTarget | null; - }; - -type BridgeOutboundMessage = - | { type: "bridgeReady" } - | { type: "rendererReady"; streamKey: string; isReady: boolean } - | { type: "input"; streamKey: string; data: string } - | { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean } - | { - type: "terminalKey"; - streamKey: string; - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; - } - | { type: "pendingModifiersConsumed"; streamKey: string } - | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } - | { type: "openExternalUrl"; streamKey: string; url: string } - | { - type: "resolveLocalFileLink"; - streamKey: string; - requestId: number; - source: TerminalLocalFileLinkSource; - } - | { - type: "openLocalFileLink"; - streamKey: string; - target: TerminalLocalFileLinkTarget; - disposition: "main" | "side"; - } - | { type: "swipeLeft"; streamKey: string } - | { type: "swipeRight"; streamKey: string } - | { type: "debug"; message: string; details?: unknown }; - -const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml }; -const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"]; -const BRIDGE_READY_TIMEOUT_MS = 2_500; -const RENDERER_READY_TIMEOUT_MS = 2_500; -const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8; -type WebViewProps = ComponentProps; -interface PendingTerminalTap { - startX: number; - startY: number; - moved: boolean; -} - -function buildThemeKey(theme: ITheme): string { - return JSON.stringify(theme); -} - -function serializeForInjectedJavaScript(message: BridgeInboundMessage): string { - return JSON.stringify(message).replace(/<\/script/gi, "<\\/script"); -} - -function createMountMessage(input: { - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; -}): BridgeInboundMessage { - return { - type: "mount", - streamKey: input.streamKey, - initialSnapshot: input.initialSnapshot, - scrollbackLines: input.scrollbackLines, - theme: input.theme, - fontFamily: input.fontFamily, - fontSize: input.fontSize, - pendingModifiers: input.pendingModifiers, - swipeGesturesEnabled: input.swipeGesturesEnabled, - }; -} - -export default function TerminalEmulator({ - ref, - streamKey, - testId = "terminal-surface", - xtermTheme = { - background: "#0b0b0b", - foreground: "#e6e6e6", - cursor: "#e6e6e6", - }, - scrollbackLines, - fontFamily, - fontSize, - swipeGesturesEnabled = false, - onSwipeLeft, - onSwipeRight, - initialSnapshot = null, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onRendererReadyChange, - pendingModifiers = { ctrl: false, shift: false, alt: false }, - focusRequestToken = 0, - resizeRequestToken = 0, -}: TerminalEmulatorProps) { - const webViewRef = useRef(null); - const [webViewEpoch, setWebViewEpoch] = useState(0); - const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0); - const bridgeReadyRef = useRef(false); - const bridgeReadyVersionRef = useRef(0); - const rendererReadyVersionRef = useRef(0); - const pendingMessagesRef = useRef([]); - const outputDecoderRef = useRef(new TextDecoder()); - const mountedStreamKeyRef = useRef(null); - const bridgeReadyTimeoutRef = useRef | null>(null); - const rendererReadyTimeoutRef = useRef | null>(null); - const pendingTapRef = useRef(null); - const mountConfigRef = useRef({ - streamKey, - initialSnapshot, - scrollbackLines, - theme: xtermTheme, - fontFamily, - fontSize, - pendingModifiers, - swipeGesturesEnabled, - }); - mountConfigRef.current = { - streamKey, - initialSnapshot, - scrollbackLines, - theme: xtermTheme, - fontFamily, - fontSize, - pendingModifiers, - swipeGesturesEnabled, - }; - const callbacksRef = useRef({ - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onRendererReadyChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onSwipeLeft, - onSwipeRight, - }); - callbacksRef.current = { - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onRendererReadyChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onSwipeLeft, - onSwipeRight, - }; - - const clearBridgeReadyTimeout = useCallback(() => { - if (bridgeReadyTimeoutRef.current === null) return; - clearTimeout(bridgeReadyTimeoutRef.current); - bridgeReadyTimeoutRef.current = null; - }, []); - - const clearRendererReadyTimeout = useCallback(() => { - if (rendererReadyTimeoutRef.current === null) return; - clearTimeout(rendererReadyTimeoutRef.current); - rendererReadyTimeoutRef.current = null; - }, []); - - const resetWebViewDocument = useCallback(() => { - clearBridgeReadyTimeout(); - clearRendererReadyTimeout(); - bridgeReadyRef.current = false; - pendingMessagesRef.current = []; - mountedStreamKeyRef.current = null; - callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); - setWebViewEpoch((value) => value + 1); - }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]); - - const scheduleBridgeReadyWatchdog = useCallback(() => { - clearBridgeReadyTimeout(); - const expectedBridgeReadyVersion = bridgeReadyVersionRef.current; - bridgeReadyTimeoutRef.current = setTimeout(() => { - bridgeReadyTimeoutRef.current = null; - if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) { - return; - } - resetWebViewDocument(); - }, BRIDGE_READY_TIMEOUT_MS); - }, [clearBridgeReadyTimeout, resetWebViewDocument]); - - const scheduleRendererReadyWatchdog = useCallback(() => { - clearRendererReadyTimeout(); - const expectedRendererReadyVersion = rendererReadyVersionRef.current; - rendererReadyTimeoutRef.current = setTimeout(() => { - rendererReadyTimeoutRef.current = null; - if ( - rendererReadyVersionRef.current !== expectedRendererReadyVersion || - mountedStreamKeyRef.current === streamKey - ) { - return; - } - resetWebViewDocument(); - }, RENDERER_READY_TIMEOUT_MS); - }, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]); - - const flushPendingMessages = useCallback(() => { - if (!bridgeReadyRef.current || !webViewRef.current) return; - const pending = pendingMessagesRef.current.splice(0); - for (const message of pending) { - const payload = serializeForInjectedJavaScript(message); - webViewRef.current.injectJavaScript( - `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, - ); - } - }, []); - - const sendToWebView = useCallback((message: BridgeInboundMessage) => { - if (!bridgeReadyRef.current || !webViewRef.current) { - pendingMessagesRef.current.push(message); - return; - } - const payload = serializeForInjectedJavaScript(message); - webViewRef.current.injectJavaScript( - `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, - ); - }, []); - - useImperativeHandle( - ref, - (): TerminalEmulatorHandle => ({ - writeOutput: (data: TerminalOutputData) => { - const output = outputDecoderRef.current.decode(data, { stream: true }); - if (output.length === 0) { - return; - } - sendToWebView({ type: "writeOutput", streamKey, text: output }); - }, - restoreOutput: (data: TerminalOutputData) => { - outputDecoderRef.current.decode(); - const text = outputDecoderRef.current.decode(data, { stream: false }); - if (text.length === 0) { - return; - } - sendToWebView({ type: "restoreOutput", streamKey, text }); - }, - renderSnapshot: (state: TerminalState | null) => { - outputDecoderRef.current.decode(); - sendToWebView({ type: "renderSnapshot", streamKey, state }); - }, - clear: () => { - outputDecoderRef.current.decode(); - sendToWebView({ type: "clear", streamKey }); - }, - blur: () => { - webViewRef.current?.injectJavaScript( - "window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;", - ); - Keyboard.dismiss(); - }, - }), - [sendToWebView, streamKey], - ); +function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: TerminalEmulatorProps) { + const { t } = useTranslation(); useEffect(() => { - outputDecoderRef.current.decode(); - }, [streamKey]); - - useEffect(() => { - if (bridgeReadyVersion <= 0) return; - const mountMessage = createMountMessage(mountConfigRef.current); - mountedStreamKeyRef.current = streamKey; - sendToWebView(mountMessage); - flushPendingMessages(); - scheduleRendererReadyWatchdog(); - }, [ - bridgeReadyVersion, - flushPendingMessages, - scheduleRendererReadyWatchdog, - sendToWebView, - streamKey, - ]); - - const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]); - useEffect(() => { - if (!mountedStreamKeyRef.current) return; - sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme }); - }, [sendToWebView, streamKey, themeKey, xtermTheme]); - - useEffect(() => { - if (!mountedStreamKeyRef.current) return; - sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines }); - }, [scrollbackLines, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountedStreamKeyRef.current) return; - sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize }); - }, [fontFamily, fontSize, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountedStreamKeyRef.current) return; - sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers }); - }, [pendingModifiers, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountedStreamKeyRef.current) return; - sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled }); - }, [sendToWebView, streamKey, swipeGesturesEnabled]); - - useEffect(() => { - if (focusRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey, shouldClaim: true }); - sendToWebView({ type: "focus", streamKey }); - webViewRef.current?.requestFocus(); - }, [focusRequestToken, sendToWebView, streamKey]); - - useEffect(() => { - if (resizeRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey, shouldClaim: true }); - }, [resizeRequestToken, sendToWebView, streamKey]); - - useEffect(() => { - return () => { - if (mountedStreamKeyRef.current) { - const previousStreamKey = mountedStreamKeyRef.current; - callbacksRef.current.onRendererReadyChange?.({ - streamKey: previousStreamKey, - isReady: false, - }); - sendToWebView({ type: "unmount", streamKey: previousStreamKey }); - } - bridgeReadyRef.current = false; - pendingMessagesRef.current = []; - mountedStreamKeyRef.current = null; - clearBridgeReadyTimeout(); - clearRendererReadyTimeout(); - }; - }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]); - - const handleLifecycleMessage = useCallback( - (message: BridgeOutboundMessage): boolean => { - if (message.type === "bridgeReady") { - bridgeReadyRef.current = true; - bridgeReadyVersionRef.current += 1; - clearBridgeReadyTimeout(); - setBridgeReadyVersion((value) => value + 1); - return true; - } - if (message.type === "rendererReady") { - mountedStreamKeyRef.current = message.isReady ? message.streamKey : null; - if (message.isReady) { - rendererReadyVersionRef.current += 1; - clearRendererReadyTimeout(); - } - callbacksRef.current.onRendererReadyChange?.({ - streamKey: message.streamKey, - isReady: message.isReady, - }); - return true; - } - return false; - }, - [clearBridgeReadyTimeout, clearRendererReadyTimeout], - ); - - const resolveLocalFileLink = useCallback( - async (message: Extract) => { - try { - const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ?? - null); - sendToWebView({ - type: "resolveLocalFileLinkResponse", - streamKey: message.streamKey, - requestId: message.requestId, - target, - }); - } catch { - sendToWebView({ - type: "resolveLocalFileLinkResponse", - streamKey: message.streamKey, - requestId: message.requestId, - target: null, - }); - } - }, - [sendToWebView], - ); - - const handleTerminalMessage = useCallback( - ( - message: Exclude, - ) => { - if (message.type === "resolveLocalFileLink") { - void resolveLocalFileLink(message); - return; - } - if (message.type === "openLocalFileLink") { - callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition); - return; - } - switch (message.type) { - case "input": - callbacksRef.current.onInput?.(message.data); - break; - case "resize": - callbacksRef.current.onResize?.({ - rows: message.rows, - cols: message.cols, - shouldClaim: message.shouldClaim !== false, - }); - break; - case "terminalKey": - callbacksRef.current.onTerminalKey?.({ - key: message.key, - ctrl: message.ctrl, - shift: message.shift, - alt: message.alt, - meta: message.meta, - }); - break; - case "pendingModifiersConsumed": - callbacksRef.current.onPendingModifiersConsumed?.(); - break; - case "inputModeChange": - callbacksRef.current.onInputModeChange?.(message.state); - break; - case "openExternalUrl": - void openExternalUrl(message.url); - break; - case "swipeLeft": - callbacksRef.current.onSwipeLeft?.(); - break; - case "swipeRight": - callbacksRef.current.onSwipeRight?.(); - break; - case "debug": - break; - } - }, - [resolveLocalFileLink], - ); - - const handleMessage = useCallback( - (event: WebViewMessageEvent) => { - let message: BridgeOutboundMessage; - try { - message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage; - } catch { - return; - } - - if (message.type === "bridgeReady" || message.type === "rendererReady") { - handleLifecycleMessage(message); - return; - } - handleTerminalMessage(message); - }, - [handleLifecycleMessage, handleTerminalMessage], - ); - - const handleLoadStart = useCallback>(() => { - bridgeReadyRef.current = false; - mountedStreamKeyRef.current = null; - scheduleBridgeReadyWatchdog(); - }, [scheduleBridgeReadyWatchdog]); - - const handleContentProcessDidTerminate = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleRenderProcessGone = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => { - pendingTapRef.current = { - startX: event.nativeEvent.pageX, - startY: event.nativeEvent.pageY, - moved: false, - }; - }, []); - - const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => { - const pendingTap = pendingTapRef.current; - if (!pendingTap) return; - const dx = event.nativeEvent.pageX - pendingTap.startX; - const dy = event.nativeEvent.pageY - pendingTap.startY; - if ( - Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX || - Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX - ) { - pendingTap.moved = true; - } - }, []); - - const handleWebViewTouchEnd = useCallback(() => { - const pendingTap = pendingTapRef.current; - pendingTapRef.current = null; - if (!pendingTap || pendingTap.moved) { - return; - } - webViewRef.current?.requestFocus(); - callbacksRef.current.onFocus?.(); - sendToWebView({ type: "focus", streamKey, forceRefocus: true }); - }, [sendToWebView, streamKey]); - - const handleWebViewTouchCancel = useCallback(() => { - pendingTapRef.current = null; - }, []); - - const backgroundColor = xtermTheme.background ?? "#0b0b0b"; - const rootStyle = useMemo>( - () => [styles.root, { backgroundColor }], - [backgroundColor], - ); - const webViewStyle = useMemo>( - () => [styles.webView, { backgroundColor }], - [backgroundColor], - ); - const webViewContainerStyle = useMemo>( - () => [styles.webViewContainer, { backgroundColor }], - [backgroundColor], - ); + onRendererReadyChange?.({ streamKey, isReady: false }); + }, [onRendererReadyChange, streamKey]); return ( - - + + {t("workspace.terminal.updateHost")} ); } -const styles = StyleSheet.create({ - root: { +export default function TerminalEmulator(props: TerminalEmulatorProps) { + const { settings } = useAppSettings(); + const { useLegacyTerminalRenderer } = settings; + const rendererSelection = resolveTerminalRendererSelection({ + useLegacyTerminalRenderer, + supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay, + }); + + if (rendererSelection === "update-host") { + return ; + } + + const Renderer = + rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator; + + return ; +} + +const styles = StyleSheet.create((theme) => ({ + unsupportedContainer: { flex: 1, - minHeight: 0, - minWidth: 0, - overflow: "hidden", + alignItems: "center", + justifyContent: "center", + padding: 24, }, - webView: { - flex: 1, + unsupportedText: { + color: theme.colors.foregroundMuted, + textAlign: "center", }, - webViewContainer: { - flex: 1, - }, -}); +})); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index a6054888d2..e3396f7277 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -23,10 +23,12 @@ import { TerminalEmulatorRuntime, type TerminalOutputData, } from "../terminal/runtime/terminal-emulator-runtime"; +import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste"; import type { TerminalLocalFileLinkSource, TerminalLocalFileLinkTarget, } from "../terminal/local-links/terminal-local-link-provider"; +import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import { openExternalUrl } from "../utils/open-external-url"; import { focusWithRetries } from "../utils/web-focus"; @@ -42,7 +44,11 @@ export interface TerminalEmulatorHandle { writeOutput: (data: TerminalOutputData) => void; restoreOutput: (data: TerminalOutputData) => void; renderSnapshot: (state: TerminalState | null) => void; + paste: (text: string) => void; + copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; + claimSize: () => void; + showKeyboard: () => void; blur: () => void; } @@ -93,18 +99,25 @@ interface TerminalEmulatorProps { dom?: DOMProps; ref: Ref; streamKey: string; + supportsTerminalInputModeReplay: boolean; testId?: string; xtermTheme?: ITheme; scrollbackLines: number; fontFamily?: string; fontSize?: number; + keyboardInset?: number; swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; initialSnapshot?: TerminalState | null; onInput?: (data: string) => Promise | void; onFocus?: () => Promise | void; - onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise | void; + onResize?: (input: { + rows: number; + cols: number; + shouldClaim: boolean; + forceClaim?: boolean; + }) => Promise | void; onTerminalKey?: (input: { key: string; ctrl: boolean; @@ -114,6 +127,7 @@ interface TerminalEmulatorProps { }) => Promise | void; onPendingModifiersConsumed?: () => Promise | void; onInputModeChange?: (state: TerminalInputModeState) => Promise | void; + onSelectionChange?: (hasSelection: boolean) => void; onResolveLocalFileLink?: ( source: TerminalLocalFileLinkSource, ) => Promise | TerminalLocalFileLinkTarget | null; @@ -211,6 +225,18 @@ export default function TerminalEmulator({ const dropActiveTimeoutRef = useRef | null>(null); const domBridgeRef = useRef(null); + const pasteText = useCallback((text: string) => { + if (text.length === 0) { + return; + } + mountCallbacksRef.current.onInput?.( + encodeTerminalPaste({ + text, + bracketedPaste: runtimeRef.current?.getInputModeState().bracketedPaste ?? false, + }), + ); + }, []); + useDOMImperativeHandle( domBridgeRef, (): DOMImperativeFactory => ({ @@ -230,14 +256,28 @@ export default function TerminalEmulator({ runtimeRef.current?.renderSnapshot({ state }); } }, + paste: (...args) => { + const text = args[0]; + if (typeof text === "string" && text.length > 0) { + pasteText(text); + } + }, + copySelection: async () => "", clear: () => { runtimeRef.current?.clear(); }, + claimSize: () => { + runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true }); + }, + showKeyboard: () => { + runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true }); + runtimeRef.current?.focus(); + }, blur: () => { runtimeRef.current?.blur(); }, }), - [], + [pasteText], ); useImperativeHandle( ref, @@ -251,14 +291,25 @@ export default function TerminalEmulator({ renderSnapshot: (state: TerminalState | null) => { runtimeRef.current?.renderSnapshot({ state }); }, + paste: (text: string) => { + pasteText(text); + }, + copySelection: async () => "", clear: () => { runtimeRef.current?.clear(); }, + claimSize: () => { + runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true }); + }, + showKeyboard: () => { + runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true }); + runtimeRef.current?.focus(); + }, blur: () => { runtimeRef.current?.blur(); }, }), - [], + [pasteText], ); useEffect(() => { @@ -458,7 +509,7 @@ export default function TerminalEmulator({ if (focusRequestToken <= 0) { return () => {}; } - runtimeRef.current?.resize({ force: true, shouldClaim: true }); + runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true }); return focusWithRetries({ focus: () => { runtimeRef.current?.focus(); @@ -478,7 +529,7 @@ export default function TerminalEmulator({ if (resizeRequestToken <= 0) { return; } - runtimeRef.current?.resize({ force: true, shouldClaim: true }); + runtimeRef.current?.resize({ forceClaim: false, shouldClaim: false }); }, [resizeRequestToken]); const showTerminalContextMenu = useCallback(() => { diff --git a/packages/app/src/components/terminal-pane-focus-claim.test.ts b/packages/app/src/components/terminal-pane-focus-claim.test.ts index 24edd4a572..528cdffc9b 100644 --- a/packages/app/src/components/terminal-pane-focus-claim.test.ts +++ b/packages/app/src/components/terminal-pane-focus-claim.test.ts @@ -3,6 +3,7 @@ import { EMPTY_FOCUS_CLAIM_STATE, canRequestFocusClaim, reconcileFocusClaim, + resolveTerminalResizeClaim, settleFocusClaim, type FocusClaimState, } from "./terminal-pane-focus-claim"; @@ -15,9 +16,124 @@ function request( } describe("terminal pane focus claim", () => { + it("forwards resize callbacks only from the active focused ready candidate", () => { + const readiness = { + isWorkspaceFocused: true, + isAppActivelyVisible: true, + isClientReady: true, + isConnected: true, + isRendererReady: true, + }; + const candidates = [ + resolveTerminalResizeClaim({ + size: { rows: 30, cols: 90 }, + previousSentSize: null, + shouldClaim: true, + forceClaim: false, + supportsTerminalSizeOwnership: true, + readiness: { ...readiness, isPaneFocused: false }, + }), + resolveTerminalResizeClaim({ + size: { rows: 42, cols: 120 }, + previousSentSize: null, + shouldClaim: true, + forceClaim: false, + supportsTerminalSizeOwnership: true, + readiness: { ...readiness, isPaneFocused: true }, + }), + ]; + + expect(candidates).toEqual([ + { shouldSend: false, intent: "claim" }, + { shouldSend: true, intent: "claim" }, + ]); + }); + + it("sends passive refits as owner-only updates and lets interaction reclaim the same size", () => { + const readiness = { + isWorkspaceFocused: true, + isPaneFocused: true, + isAppActivelyVisible: true, + isClientReady: true, + isConnected: true, + isRendererReady: true, + }; + const size = { rows: 42, cols: 120 }; + + const passiveRefit = resolveTerminalResizeClaim({ + size, + previousSentSize: size, + shouldClaim: false, + forceClaim: false, + supportsTerminalSizeOwnership: true, + readiness, + }); + const ordinarySameSizeMeasurement = resolveTerminalResizeClaim({ + size, + previousSentSize: size, + shouldClaim: true, + forceClaim: false, + supportsTerminalSizeOwnership: true, + readiness, + }); + const explicitReclaim = resolveTerminalResizeClaim({ + size, + previousSentSize: size, + shouldClaim: true, + forceClaim: true, + supportsTerminalSizeOwnership: true, + readiness, + }); + + expect(passiveRefit).toEqual({ shouldSend: false, intent: "update" }); + expect(ordinarySameSizeMeasurement).toEqual({ shouldSend: true, intent: "claim" }); + expect(explicitReclaim.shouldSend).toBe(true); + }); + + it("lets the current owner update after pane focus moves without transferring ownership", () => { + const result = resolveTerminalResizeClaim({ + size: { rows: 20, cols: 100 }, + previousSentSize: { rows: 40, cols: 100 }, + shouldClaim: false, + forceClaim: false, + supportsTerminalSizeOwnership: true, + readiness: { + isWorkspaceFocused: true, + isPaneFocused: false, + isAppActivelyVisible: true, + isClientReady: true, + isConnected: true, + isRendererReady: true, + }, + }); + + expect(result).toEqual({ shouldSend: true, intent: "update" }); + }); + + it("keeps passive refits local against legacy daemons", () => { + const result = resolveTerminalResizeClaim({ + size: { rows: 20, cols: 100 }, + previousSentSize: { rows: 40, cols: 100 }, + shouldClaim: false, + forceClaim: false, + supportsTerminalSizeOwnership: false, + readiness: { + isWorkspaceFocused: true, + isPaneFocused: true, + isAppActivelyVisible: true, + isClientReady: true, + isConnected: true, + isRendererReady: true, + }, + }); + + expect(result).toEqual({ shouldSend: false, intent: "update" }); + }); + it("waits for both the client and renderer before requesting a claim", () => { const withoutClient = canRequestFocusClaim({ isWorkspaceFocused: true, + isPaneFocused: true, isAppActivelyVisible: true, isClientReady: false, isConnected: true, @@ -25,6 +141,7 @@ describe("terminal pane focus claim", () => { }); const withoutRenderer = canRequestFocusClaim({ isWorkspaceFocused: true, + isPaneFocused: true, isAppActivelyVisible: true, isClientReady: true, isConnected: true, @@ -37,6 +154,7 @@ describe("terminal pane focus claim", () => { it("does not deliver a requested claim after the host disconnects", () => { const disconnected = canRequestFocusClaim({ isWorkspaceFocused: true, + isPaneFocused: true, isAppActivelyVisible: true, isClientReady: true, isConnected: false, diff --git a/packages/app/src/components/terminal-pane-focus-claim.ts b/packages/app/src/components/terminal-pane-focus-claim.ts index 21118ad191..c22f96bdb4 100644 --- a/packages/app/src/components/terminal-pane-focus-claim.ts +++ b/packages/app/src/components/terminal-pane-focus-claim.ts @@ -8,14 +8,69 @@ export interface FocusClaimStep { shouldRequest: boolean; } -interface FocusClaimReadiness { +export interface FocusClaimReadiness { isWorkspaceFocused: boolean; + isPaneFocused: boolean; isAppActivelyVisible: boolean; isClientReady: boolean; isConnected: boolean; isRendererReady: boolean; } +interface TerminalSize { + rows: number; + cols: number; +} + +export function resolveTerminalResizeClaim(input: { + size: TerminalSize; + previousSentSize: TerminalSize | null; + shouldClaim: boolean; + forceClaim: boolean; + supportsTerminalSizeOwnership: boolean; + readiness: FocusClaimReadiness; +}): { shouldSend: boolean; intent: "claim" | "update" } { + const intent = input.shouldClaim ? "claim" : "update"; + if (intent === "claim") { + if (!canRequestFocusClaim(input.readiness)) { + return { shouldSend: false, intent }; + } + if (input.supportsTerminalSizeOwnership) { + return { shouldSend: true, intent }; + } + return { + shouldSend: + input.forceClaim || + input.previousSentSize === null || + input.previousSentSize.rows !== input.size.rows || + input.previousSentSize.cols !== input.size.cols, + intent, + }; + } + + if (!input.supportsTerminalSizeOwnership || !canRequestOwnedSizeUpdate(input.readiness)) { + return { shouldSend: false, intent }; + } + + return { + shouldSend: + input.previousSentSize === null || + input.previousSentSize.rows !== input.size.rows || + input.previousSentSize.cols !== input.size.cols, + intent, + }; +} + +function canRequestOwnedSizeUpdate(input: FocusClaimReadiness): boolean { + return ( + input.isWorkspaceFocused && + input.isAppActivelyVisible && + input.isClientReady && + input.isConnected && + input.isRendererReady + ); +} + export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = { claimedKey: null, requestedKey: null, @@ -24,6 +79,7 @@ export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = { export function canRequestFocusClaim(input: FocusClaimReadiness): boolean { return ( input.isWorkspaceFocused && + input.isPaneFocused && input.isAppActivelyVisible && input.isClientReady && input.isConnected && diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index e326446736..f5a88cfde7 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,11 +1,16 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import * as Clipboard from "expo-clipboard"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; -import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; +import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native"; +import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; +import { + DEFAULT_TERMINAL_INPUT_MODE_STATE, + type TerminalInputModeState, +} from "@getpaseo/protocol/terminal-input-mode"; import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; @@ -13,14 +18,28 @@ import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { useStableEvent } from "@/hooks/use-stable-event"; import { hasPendingTerminalModifiers, - normalizeTerminalTransportKey, resolvePendingModifierDataInput, } from "@/utils/terminal-keys"; +import { + createTerminalKeyInput, + dispatchTerminalKeyInput, + EMPTY_TERMINAL_KEY_MODIFIERS, + type TerminalKeyModifierState, +} from "@/terminal/runtime/terminal-key-dispatch"; +import { + getTerminalVirtualKeyboardControlId, + shouldShowTerminalFloatingCopyAction, + shouldShowTerminalPasteAction, + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + type TerminalVirtualKeyboardControl, +} from "@/terminal/runtime/terminal-virtual-keyboard"; +import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { EMPTY_FOCUS_CLAIM_STATE, canRequestFocusClaim, reconcileFocusClaim, + resolveTerminalResizeClaim, settleFocusClaim, } from "./terminal-pane-focus-claim"; import { @@ -29,12 +48,16 @@ import { } from "@/terminal/runtime/terminal-stream-controller"; import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options"; import { usePanelStore } from "@/stores/panel-store"; +import { useBlockMobilePanelOpenGestures } from "@/mobile-panels/provider"; import { useSessionStore } from "@/stores/session-store"; import { toXtermTheme } from "@/utils/to-xterm-theme"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; +import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; import { applyTerminalRendererReadyChange, + resolveTerminalStreamTarget, shouldReplayTerminalSnapshotForRenderer, shouldShowTerminalLoadingOverlay, type TerminalRendererReadyChange, @@ -69,23 +92,9 @@ const MODIFIER_LABELS = { alt: "Alt", } as const; -const KEY_BUTTONS = { - esc: { id: "esc", label: "Esc", key: "Escape" }, - tab: { id: "tab", label: "Tab", key: "Tab" }, - up: { id: "up", label: "↑", key: "ArrowUp" }, - down: { id: "down", label: "↓", key: "ArrowDown" }, - left: { id: "left", label: "←", key: "ArrowLeft" }, - right: { id: "right", label: "→", key: "ArrowRight" }, - enter: { id: "enter", label: "Enter", key: "Enter" }, - backspace: { id: "backspace", label: "⌫", key: "Backspace" }, - space: { id: "space", label: "Space", key: " " }, -} as const; +const EMPTY_MODIFIERS = EMPTY_TERMINAL_KEY_MODIFIERS; -interface ModifierState { - ctrl: boolean; - shift: boolean; - alt: boolean; -} +type ModifierState = TerminalKeyModifierState; type PendingTerminalInput = | { @@ -94,21 +103,9 @@ type PendingTerminalInput = } | { type: "key"; - input: { - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta?: boolean; - }; + input: TerminalKeyInput; }; -const EMPTY_MODIFIERS: ModifierState = { - ctrl: false, - shift: false, - alt: false, -}; - function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } @@ -163,6 +160,40 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps ); } +interface KeyboardToggleButtonProps { + isKeyboardVisible: boolean; + iconColor: string; + onToggle: () => void; +} + +function KeyboardToggleButton({ + isKeyboardVisible, + iconColor, + onToggle, +}: KeyboardToggleButtonProps) { + const label = isKeyboardVisible ? "Hide keyboard" : "Show keyboard"; + const Icon = isKeyboardVisible ? KeyboardOffIcon : KeyboardIcon; + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.keyButton, + (Boolean(hovered) || pressed) && styles.keyButtonHovered, + ], + [], + ); + + return ( + + + + ); +} + export function TerminalPane({ serverId, cwd, @@ -185,11 +216,12 @@ export function TerminalPane({ const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobilePanel.target); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); - const swipeGesturesEnabled = isMobile && mobileView === "agent"; + const swipeGesturesEnabled = isMobile; const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ mode: "padding", enabled: isMobile, }); + const [keyboardInset, setKeyboardInset] = useState(0); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -197,6 +229,13 @@ export function TerminalPane({ const supportsTerminalRestoreModes = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true, ); + const supportsTerminalInputModeReplay = useSessionStore( + (state) => + state.sessions[serverId]?.serverInfo?.features?.["terminal-input-mode-replay"] === true, + ); + const supportsTerminalSizeOwnership = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true, + ); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); @@ -214,16 +253,17 @@ export function TerminalPane({ const [streamError, setStreamError] = useState(null); const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); + const [hasSelection, setHasSelection] = useState(false); + const [hasClipboardText, setHasClipboardText] = useState(false); + const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false); const [focusRequestToken, setFocusRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0); + useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection); const emulatorRef = useRef(null); const terminalIdRef = useRef(terminalId); const terminalActiveRef = useRef(isTerminalActive); terminalActiveRef.current = isTerminalActive; - const inputModeRef = useRef({ - kittyKeyboardFlags: 0, - win32InputMode: false, - }); + const inputModeRef = useRef(DEFAULT_TERMINAL_INPUT_MODE_STATE); const pendingTerminalInputRef = useRef([]); const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); @@ -232,12 +272,39 @@ export function TerminalPane({ useEffect(() => { terminalIdRef.current = terminalId; - inputModeRef.current = { - kittyKeyboardFlags: 0, - win32InputMode: false, - }; + inputModeRef.current = DEFAULT_TERMINAL_INPUT_MODE_STATE; + setHasSelection(false); }, [terminalId]); + const refreshClipboardAvailability = useCallback(async () => { + if (!isMobile) { + setHasClipboardText(false); + return; + } + try { + const hasText = await Clipboard.hasStringAsync(); + setHasClipboardText(hasText); + } catch { + setHasClipboardText(false); + } + }, [isMobile]); + + useEffect(() => { + void refreshClipboardAvailability(); + }, [refreshClipboardAvailability, isAppActivelyVisible]); + + useEffect(() => { + void refreshClipboardAvailability(); + }, [keyboardInset, refreshClipboardAvailability]); + + useEffect(() => { + setIsKeyboardToggleVisible(keyboardInset > 0); + }, [keyboardInset]); + + const handleSelectionChange = useCallback((nextHasSelection: boolean) => { + setHasSelection(nextHasSelection); + }, []); + const requestTerminalFocus = useCallback(() => { setFocusRequestToken((current) => current + 1); }, []); @@ -282,7 +349,8 @@ export function TerminalPane({ useEffect(() => { const canRequest = canRequestFocusClaim({ - isWorkspaceFocused, + isWorkspaceFocused: isTerminalActive, + isPaneFocused, isAppActivelyVisible, isClientReady: client !== null, isConnected, @@ -296,13 +364,14 @@ export function TerminalPane({ if (step.shouldRequest) { lastSentTerminalSizeRef.current = null; requestTerminalReflow(); + emulatorRef.current?.claimSize(); } }, [ client, isAppActivelyVisible, isConnected, isPaneFocused, - isWorkspaceFocused, + isTerminalActive, rendererReadyStreamKey, requestTerminalReflow, scopeKey, @@ -316,6 +385,7 @@ export function TerminalPane({ } lastSentTerminalSizeRef.current = null; requestTerminalReflow(); + emulatorRef.current?.claimSize(); }, [ isPaneFocused, isWorkspaceFocused, @@ -338,26 +408,37 @@ export function TerminalPane({ const pulseKeyboardRefits = useCallback(() => { clearKeyboardRefitTimeouts(); requestTerminalReflow(); - keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => + keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs, index) => setTimeout(() => { requestTerminalReflow(); + if (index === TERMINAL_REFIT_DELAYS_MS.length - 1) { + emulatorRef.current?.claimSize(); + } }, delayMs), ); }, [clearKeyboardRefitTimeouts, requestTerminalReflow]); + const handleKeyboardInsetChange = useCallback( + (nextInset: number) => { + setKeyboardInset(nextInset); + pulseKeyboardRefits(); + }, + [pulseKeyboardRefits], + ); + useEffect(() => { return () => clearKeyboardRefitTimeouts(); }, [clearKeyboardRefitTimeouts]); useAnimatedReaction( - () => isMobile && keyboardShift.value > 0, + () => (isMobile ? Math.round(keyboardShift.value) : 0), (next, prev) => { if (next === prev) { return; } - runOnJS(pulseKeyboardRefits)(); + runOnJS(handleKeyboardInsetChange)(next); }, - [isMobile, pulseKeyboardRefits], + [isMobile, handleKeyboardInsetChange], ); useEffect(() => { @@ -408,7 +489,21 @@ export function TerminalPane({ const controller = new TerminalStreamController({ client, - getPreferredSize: () => lastSentTerminalSizeRef.current, + getPreferredSize: () => { + if ( + !canRequestFocusClaim({ + isWorkspaceFocused: terminalActiveRef.current, + isPaneFocused, + isAppActivelyVisible, + isClientReady: client !== null, + isConnected, + isRendererReady: rendererReadyStreamKey === terminalStreamKey, + }) + ) { + return null; + } + return measuredTerminalSizeRef.current; + }, onOutput: ({ terminalId: outputTerminalId, data }) => { if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) { return; @@ -432,6 +527,14 @@ export function TerminalPane({ getRestoreOptions: () => { return resolveTerminalRestoreOptions({ supportsTerminalRestoreModes, + canClaimSize: canRequestFocusClaim({ + isWorkspaceFocused: terminalActiveRef.current, + isPaneFocused, + isAppActivelyVisible, + isClientReady: client !== null, + isConnected, + isRendererReady: rendererReadyStreamKey === terminalStreamKey, + }), size: measuredTerminalSizeRef.current, }); }, @@ -439,9 +542,6 @@ export function TerminalPane({ }); streamControllerRef.current = controller; - controller.setTerminal({ - terminalId: terminalActiveRef.current ? terminalIdRef.current : null, - }); return () => { controller.dispose(); @@ -453,17 +553,33 @@ export function TerminalPane({ client, handleStreamControllerStatus, isConnected, + isAppActivelyVisible, + isPaneFocused, + rendererReadyStreamKey, supportsTerminalRestoreModes, + terminalStreamKey, workspaceTerminalSession.snapshots, ]); useEffect(() => { pendingTerminalInputRef.current = []; - const nextTerminalId = isTerminalActive ? terminalId : null; + const nextTerminalId = resolveTerminalStreamTarget({ + terminalId, + terminalStreamKey, + rendererReadyStreamKey, + isWorkspaceFocused: isTerminalActive, + }); streamControllerRef.current?.setTerminal({ terminalId: nextTerminalId, }); - }, [isTerminalActive, terminalId]); + }, [ + client, + isConnected, + isTerminalActive, + rendererReadyStreamKey, + terminalId, + terminalStreamKey, + ]); const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { const queue = pendingTerminalInputRef.current; @@ -492,15 +608,14 @@ export function TerminalPane({ return true; } - const encoded = encodeTerminalKeyInput(entry.input, { + dispatchTerminalKeyInput({ + keyInput: entry.input, inputMode: inputModeRef.current, - }); - if (encoded.length === 0) { - return true; - } - client.sendTerminalInput(currentTerminalId, { - type: "input", - data: encoded, + sendData: (data) => + client.sendTerminalInput(currentTerminalId, { + type: "input", + data, + }), }); return true; }, @@ -552,26 +667,31 @@ export function TerminalPane({ enqueuePendingTerminalInput({ type: "key", input: { - key: normalizeTerminalTransportKey(input.key), - ctrl: input.ctrl, - shift: input.shift, - alt: input.alt, - meta: input.meta, + ...createTerminalKeyInput({ + key: input.key, + modifiers: { + ctrl: input.ctrl, + shift: input.shift, + alt: input.alt, + }, + meta: input.meta, + }), }, }); return true; } - const normalizedKey = normalizeTerminalTransportKey(input.key); const pendingEntry: PendingTerminalInput = { type: "key", - input: { - key: normalizedKey, - ctrl: input.ctrl, - shift: input.shift, - alt: input.alt, + input: createTerminalKeyInput({ + key: input.key, + modifiers: { + ctrl: input.ctrl, + shift: input.shift, + alt: input.alt, + }, meta: input.meta, - }, + }), }; if (!dispatchTerminalInputEntry(pendingEntry)) { enqueuePendingTerminalInput(pendingEntry); @@ -638,7 +758,7 @@ export function TerminalPane({ ); const handleTerminalResize = useStableEvent( - (input: { rows: number; cols: number; shouldClaim: boolean }) => { + (input: { rows: number; cols: number; shouldClaim: boolean; forceClaim?: boolean }) => { const { rows, cols } = input; if (rows <= 0 || cols <= 0) { return; @@ -647,35 +767,34 @@ export function TerminalPane({ const normalizedCols = Math.floor(cols); const nextSize = { rows: normalizedRows, cols: normalizedCols }; measuredTerminalSizeRef.current = nextSize; - if (!input.shouldClaim) { - return; - } - let sent = false; - const canSend = canRequestFocusClaim({ - isWorkspaceFocused, - isAppActivelyVisible, - isClientReady: client !== null, - isConnected, - isRendererReady: true, + const claim = resolveTerminalResizeClaim({ + size: nextSize, + previousSentSize: lastSentTerminalSizeRef.current, + shouldClaim: input.shouldClaim, + forceClaim: input.forceClaim ?? false, + supportsTerminalSizeOwnership, + readiness: { + isWorkspaceFocused: isTerminalActive, + isPaneFocused, + isAppActivelyVisible, + isClientReady: client !== null, + isConnected, + isRendererReady: rendererReadyStreamKey === terminalStreamKey, + }, }); - if (client && terminalId && canSend) { - const previousSent = lastSentTerminalSizeRef.current; - if ( - !previousSent || - previousSent.rows !== normalizedRows || - previousSent.cols !== normalizedCols - ) { - lastSentTerminalSizeRef.current = nextSize; - client.sendTerminalInput(terminalId, { - type: "resize", - rows: normalizedRows, - cols: normalizedCols, - }); - } + let sent = false; + if (client && terminalId && claim.shouldSend) { + lastSentTerminalSizeRef.current = nextSize; + client.sendTerminalInput(terminalId, { + type: "resize", + rows: normalizedRows, + cols: normalizedCols, + intent: claim.intent, + }); sent = true; } const requestedKey = paneFocusResizeClaimRef.current.requestedKey; - if (requestedKey) { + if (requestedKey && claim.intent === "claim") { paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, { key: requestedKey, sent, @@ -695,6 +814,38 @@ export function TerminalPane({ clearPendingModifiers(); }, [clearPendingModifiers]); + const handleTerminalPaste = useCallback(() => { + requestTerminalReflow(); + void pasteTerminalClipboard({ + clipboard: { readText: () => Clipboard.getStringAsync() }, + terminal: { paste: (text) => emulatorRef.current?.paste(text) }, + }).then(() => refreshClipboardAvailability()); + }, [requestTerminalReflow, refreshClipboardAvailability]); + + const handleTerminalCopy = useCallback(() => { + void emulatorRef.current + ?.copySelection({ + writeText: async (text: string) => { + await Clipboard.setStringAsync(text); + }, + }) + .then(() => refreshClipboardAvailability()); + }, [refreshClipboardAvailability]); + + const handleKeyboardToggle = useCallback(() => { + if (isKeyboardToggleVisible) { + setIsKeyboardToggleVisible(false); + emulatorRef.current?.blur(); + requestTerminalReflow(); + return; + } + + setIsKeyboardToggleVisible(true); + emulatorRef.current?.showKeyboard(); + requestTerminalFocus(); + requestTerminalReflow(); + }, [isKeyboardToggleVisible, requestTerminalFocus, requestTerminalReflow]); + const handleInputModeChange = useCallback((state: TerminalInputModeState) => { inputModeRef.current = state; }, []); @@ -739,8 +890,9 @@ export function TerminalPane({ (modifier: keyof ModifierState) => { setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); requestTerminalFocus(); + requestTerminalReflow(); }, - [requestTerminalFocus], + [requestTerminalFocus, requestTerminalReflow], ); const sendVirtualKey = useCallback( @@ -754,6 +906,7 @@ export function TerminalPane({ }); clearPendingModifiers(); requestTerminalFocus(); + requestTerminalReflow(); }, [ clearPendingModifiers, @@ -761,6 +914,7 @@ export function TerminalPane({ modifiers.ctrl, modifiers.shift, requestTerminalFocus, + requestTerminalReflow, sendTerminalKey, ], ); @@ -781,6 +935,54 @@ export function TerminalPane({ emulatorRef.current?.blur(); onOpenFileExplorer(); }, [swipeGesturesEnabled, onOpenFileExplorer]); + const showPasteAction = shouldShowTerminalPasteAction({ isNative }); + const showFloatingCopyAction = shouldShowTerminalFloatingCopyAction({ + hasSelection, + isNative, + }); + const keyboardToggleIconColor = theme.colors.foregroundMuted; + + const renderVirtualKeyboardControl = (control: TerminalVirtualKeyboardControl) => { + const controlId = getTerminalVirtualKeyboardControlId(control); + switch (control.type) { + case "key": + return ( + + ); + case "modifier": + return ( + + ); + case "paste": + return showPasteAction ? ( + + ) : null; + case "keyboardToggle": + return ( + + ); + } + }; const showLoadingOverlay = shouldShowTerminalLoadingOverlay({ isWorkspaceFocused: isTerminalActive, hasStreamError: Boolean(streamError), @@ -805,11 +1007,13 @@ export function TerminalPane({ ref={emulatorRef} dom={TERMINAL_EMULATOR_DOM_PROPS} streamKey={terminalStreamKey} + supportsTerminalInputModeReplay={supportsTerminalInputModeReplay} testId="terminal-surface" xtermTheme={xtermTheme} scrollbackLines={settings.terminalScrollbackLines} fontFamily={terminalFontFamily} fontSize={settings.codeFontSize} + keyboardInset={keyboardInset} swipeGesturesEnabled={swipeGesturesEnabled} initialSnapshot={initialSnapshot} onRendererReadyChange={handleRendererReadyChange} @@ -820,6 +1024,7 @@ export function TerminalPane({ onResize={handleTerminalResize} onTerminalKey={handleTerminalKey} onInputModeChange={handleInputModeChange} + onSelectionChange={handleSelectionChange} onResolveLocalFileLink={handleResolveLocalFileLink} onOpenLocalFileLink={handleOpenLocalFileLink} onPendingModifiersConsumed={handlePendingModifiersConsumed} @@ -834,6 +1039,12 @@ export function TerminalPane({ ) : null} + + {showFloatingCopyAction ? ( + + + + ) : null} {streamError ? ( @@ -847,55 +1058,14 @@ export function TerminalPane({ {isMobile ? ( - - {[KEY_BUTTONS.esc, KEY_BUTTONS.tab].map((button) => ( - - ))} - - - - - - - - - - - - - - {[ - KEY_BUTTONS.space, - KEY_BUTTONS.left, - KEY_BUTTONS.down, - KEY_BUTTONS.right, - KEY_BUTTONS.enter, - ].map((button) => ( - - ))} - + {TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => ( + + {row.map(renderVirtualKeyboardControl)} + + ))} ) : null} @@ -925,6 +1095,12 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", backgroundColor: "rgba(0, 0, 0, 0.16)", }, + floatingCopyContainer: { + position: "absolute", + right: theme.spacing[3], + bottom: theme.spacing[12], + zIndex: 2, + }, errorRow: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], diff --git a/packages/app/src/components/terminal-webview-message-routing.test.ts b/packages/app/src/components/terminal-webview-message-routing.test.ts new file mode 100644 index 0000000000..7a08aa19d9 --- /dev/null +++ b/packages/app/src/components/terminal-webview-message-routing.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing"; + +describe("terminal WebView message routing", () => { + it("rejects terminal messages from a stale stream", () => { + expect( + shouldHandleTerminalBridgeMessage( + { type: "input", streamKey: "terminal-a" }, + { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, + ), + ).toBe(false); + expect( + shouldHandleTerminalBridgeMessage( + { type: "resize", streamKey: "terminal-a" }, + { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, + ), + ).toBe(false); + }); + + it("rejects the old WebView stream while React is switching callbacks", () => { + expect( + shouldHandleTerminalBridgeMessage( + { type: "input", streamKey: "terminal-a" }, + { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" }, + ), + ).toBe(false); + }); + + it("accepts messages from the mounted stream and unscoped diagnostics", () => { + expect( + shouldHandleTerminalBridgeMessage( + { type: "input", streamKey: "terminal-b" }, + { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, + ), + ).toBe(true); + expect( + shouldHandleTerminalBridgeMessage( + { type: "debug" }, + { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, + ), + ).toBe(true); + }); +}); diff --git a/packages/app/src/components/terminal-webview-message-routing.ts b/packages/app/src/components/terminal-webview-message-routing.ts new file mode 100644 index 0000000000..c23c6b7244 --- /dev/null +++ b/packages/app/src/components/terminal-webview-message-routing.ts @@ -0,0 +1,17 @@ +interface TerminalBridgeMessageIdentity { + type: string; + streamKey?: string; +} + +export function shouldHandleTerminalBridgeMessage( + message: TerminalBridgeMessageIdentity, + stream: { + desiredStreamKey: string; + mountedStreamKey: string | null; + }, +): boolean { + return ( + message.streamKey === undefined || + (message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey) + ); +} diff --git a/packages/app/src/components/terminal-webview-readiness.test.ts b/packages/app/src/components/terminal-webview-readiness.test.ts new file mode 100644 index 0000000000..00db851676 --- /dev/null +++ b/packages/app/src/components/terminal-webview-readiness.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness"; + +describe("legacy terminal WebView readiness", () => { + it("resets when a requested mount never reports renderer readiness", () => { + expect( + shouldResetForMissingRendererReady({ + expectedReadyVersion: 0, + currentReadyVersion: 0, + expectedStreamKey: "terminal-a", + mountRequestedStreamKey: "terminal-a", + rendererReadyStreamKey: null, + }), + ).toBe(true); + }); + + it("keeps the document after the requested renderer confirms readiness", () => { + expect( + shouldResetForMissingRendererReady({ + expectedReadyVersion: 0, + currentReadyVersion: 0, + expectedStreamKey: "terminal-a", + mountRequestedStreamKey: "terminal-a", + rendererReadyStreamKey: "terminal-a", + }), + ).toBe(false); + }); + + it("ignores a watchdog from an earlier ready generation", () => { + expect( + shouldResetForMissingRendererReady({ + expectedReadyVersion: 0, + currentReadyVersion: 1, + expectedStreamKey: "terminal-a", + mountRequestedStreamKey: "terminal-a", + rendererReadyStreamKey: null, + }), + ).toBe(false); + }); + + it("does not let readiness from a stale stream satisfy the requested mount", () => { + expect( + shouldResetForMissingRendererReady({ + expectedReadyVersion: 0, + currentReadyVersion: 0, + expectedStreamKey: "terminal-b", + mountRequestedStreamKey: "terminal-b", + rendererReadyStreamKey: "terminal-a", + }), + ).toBe(true); + }); + + it("does not reset a newer mount from an expired watchdog", () => { + expect( + shouldResetForMissingRendererReady({ + expectedReadyVersion: 0, + currentReadyVersion: 0, + expectedStreamKey: "terminal-a", + mountRequestedStreamKey: "terminal-b", + rendererReadyStreamKey: null, + }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/components/terminal-webview-readiness.ts b/packages/app/src/components/terminal-webview-readiness.ts new file mode 100644 index 0000000000..a0cf804156 --- /dev/null +++ b/packages/app/src/components/terminal-webview-readiness.ts @@ -0,0 +1,13 @@ +export function shouldResetForMissingRendererReady(input: { + expectedReadyVersion: number; + currentReadyVersion: number; + expectedStreamKey: string; + mountRequestedStreamKey: string | null; + rendererReadyStreamKey: string | null; +}): boolean { + return ( + input.currentReadyVersion === input.expectedReadyVersion && + input.mountRequestedStreamKey === input.expectedStreamKey && + input.rendererReadyStreamKey !== input.expectedStreamKey + ); +} diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index a6fcd2e098..efc961a906 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -90,6 +90,26 @@ describe("loadAppSettingsFromStorage", () => { expect(result.chatOutlineEnabled).toBe(false); }); + it("uses the native terminal renderer by default", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.useLegacyTerminalRenderer).toBe(false); + }); + + it("loads the per-device legacy terminal renderer preference", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ useLegacyTerminalRenderer: true }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.useLegacyTerminalRenderer).toBe(true); + }); + it("loads configured terminal scrollback lines from app settings", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 94288f44b5..e13430fe43 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -35,6 +35,7 @@ export interface AppSettings { sendBehavior: SendBehavior; serviceUrlBehavior: ServiceUrlBehavior; terminalScrollbackLines: number; + useLegacyTerminalRenderer: boolean; uiFontFamily: string; // "" = platform default UI stack monoFontFamily: string; // "" = platform default mono stack uiFontSize: number; // clamped px, default 16 @@ -60,6 +61,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sendBehavior: "interrupt", serviceUrlBehavior: "ask", terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, + useLegacyTerminalRenderer: false, uiFontFamily: "", monoFontFamily: "", uiFontSize: DEFAULT_UI_FONT_SIZE, @@ -192,6 +194,9 @@ function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLeve function pickBooleanAppSettings(stored: StoredAppSettings): Partial { const result: Partial = {}; + if (typeof stored.useLegacyTerminalRenderer === "boolean") { + result.useLegacyTerminalRenderer = stored.useLegacyTerminalRenderer; + } if (typeof stored.vimKeybindings === "boolean") { result.vimKeybindings = stored.vimKeybindings; } diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index fcc16aa67c..82494998ab 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -222,6 +222,9 @@ describe("translation resources", () => { it("includes Settings expansion keys for the Batch 3A migration", () => { expect(en.settings.diagnostics.title).toBe("Diagnostics"); + expect(en.settings.diagnostics.legacyTerminalRenderer.label).toBe( + "Use legacy terminal renderer", + ); expect(en.settings.about.title).toBe("About"); expect(en.settings.about.releaseChannel.label).toBe("Release channel"); expect(en.settings.appearance.theme.title).toBe("Theme"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e9a66a3328..3e40dd2ab8 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -481,6 +481,7 @@ export const ar: TranslationResources = { }, terminal: { hostDisconnected: "Host غير متصل", + updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", }, tabs: { @@ -1716,6 +1717,11 @@ export const ar: TranslationResources = { }, diagnostics: { title: "التشخيص", + legacyTerminalRenderer: { + label: "استخدام عارض الطرفية السابق", + description: "استخدام طرفية WebView السابقة بعد إعادة فتح الطرفية", + accessibilityLabel: "استخدام عارض الطرفية السابق", + }, testAudio: "اختبار الصوت", playTest: "لعب الاختبار", playing: "جارٍ اللعب...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3c5d830f97..ea61556947 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -480,6 +480,7 @@ export const en = { }, terminal: { hostDisconnected: "Host is not connected", + updateHost: "Update the host to use the native terminal renderer.", unableToSubscribe: "Unable to subscribe to terminal", }, tabs: { @@ -1726,6 +1727,11 @@ export const en = { }, diagnostics: { title: "Diagnostics", + legacyTerminalRenderer: { + label: "Use legacy terminal renderer", + description: "Use the previous WebView terminal after reopening a terminal", + accessibilityLabel: "Use legacy terminal renderer", + }, testAudio: "Test audio", playTest: "Play test", playing: "Playing...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 69a117e3fc..22ab36e418 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -485,6 +485,7 @@ export const es: TranslationResources = { }, terminal: { hostDisconnected: "Hostno está conectado", + updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", unableToSubscribe: "No se puede suscribir al terminal", }, tabs: { @@ -1763,6 +1764,11 @@ export const es: TranslationResources = { }, diagnostics: { title: "Diagnóstico", + legacyTerminalRenderer: { + label: "Usar el renderizador de terminal anterior", + description: "Usa el terminal WebView anterior al volver a abrir un terminal", + accessibilityLabel: "Usar el renderizador de terminal anterior", + }, testAudio: "audio de prueba", playTest: "Prueba de juego", playing: "Jugando...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 35bf6f1b42..961ab00d56 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -485,6 +485,7 @@ export const fr: TranslationResources = { }, terminal: { hostDisconnected: "Hostn'est pas connecté", + updateHost: "Mettez à jour l’hôte pour utiliser le terminal natif.", unableToSubscribe: "Impossible de s'abonner au terminal", }, tabs: { @@ -1765,6 +1766,11 @@ export const fr: TranslationResources = { }, diagnostics: { title: "Diagnostic", + legacyTerminalRenderer: { + label: "Utiliser l’ancien rendu du terminal", + description: "Utilise l’ancien terminal WebView après la réouverture d’un terminal", + accessibilityLabel: "Utiliser l’ancien rendu du terminal", + }, testAudio: "Tester le son", playTest: "Jouer à l'essai", playing: "Jouant...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 426fa78dd3..44d4ac565d 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -485,6 +485,7 @@ export const ja: TranslationResources = { }, terminal: { hostDisconnected: "ホストが接続されていません", + updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", }, tabs: { @@ -1731,6 +1732,11 @@ export const ja: TranslationResources = { }, diagnostics: { title: "診断", + legacyTerminalRenderer: { + label: "以前のターミナルレンダラーを使用", + description: "ターミナルを開き直した後、以前の WebView ターミナルを使用します", + accessibilityLabel: "以前のターミナルレンダラーを使用", + }, testAudio: "音声をテスト", playTest: "テスト再生", playing: "再生中...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 7445e0842d..df69e2889b 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -485,6 +485,7 @@ export const ptBR: TranslationResources = { }, terminal: { hostDisconnected: "Host não está conectado", + updateHost: "Atualize o host para usar o renderizador de terminal nativo.", unableToSubscribe: "Não foi possível assinar o terminal", }, tabs: { @@ -1747,6 +1748,11 @@ export const ptBR: TranslationResources = { }, diagnostics: { title: "Diagnósticos", + legacyTerminalRenderer: { + label: "Usar renderizador de terminal anterior", + description: "Usa o terminal WebView anterior após reabrir um terminal", + accessibilityLabel: "Usar renderizador de terminal anterior", + }, testAudio: "Testar áudio", playTest: "Reproduzir teste", playing: "Reproduzindo...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7e949a58d0..ede4f74b90 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -485,6 +485,7 @@ export const ru: TranslationResources = { }, terminal: { hostDisconnected: "Host не подключен", + updateHost: "Обновите хост, чтобы использовать нативный терминал.", unableToSubscribe: "Невозможно подписаться на терминал", }, tabs: { @@ -1751,6 +1752,11 @@ export const ru: TranslationResources = { }, diagnostics: { title: "Диагностика", + legacyTerminalRenderer: { + label: "Использовать прежний рендерер терминала", + description: "Использовать прежний WebView-терминал после повторного открытия", + accessibilityLabel: "Использовать прежний рендерер терминала", + }, testAudio: "Тестирование звука", playTest: "Игровой тест", playing: "Игра...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1829777060..d2e6fce42e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -481,6 +481,7 @@ export const zhCN: TranslationResources = { }, terminal: { hostDisconnected: "Host 未连接", + updateHost: "请更新主机以使用原生终端渲染器。", unableToSubscribe: "无法订阅 Terminal", }, tabs: { @@ -1695,6 +1696,11 @@ export const zhCN: TranslationResources = { }, diagnostics: { title: "诊断", + legacyTerminalRenderer: { + label: "使用旧版终端渲染器", + description: "重新打开终端后使用之前的 WebView 终端", + accessibilityLabel: "使用旧版终端渲染器", + }, testAudio: "测试音频", playTest: "播放测试", playing: "正在播放...", diff --git a/packages/app/src/mobile-panels/gesture-intent.test.ts b/packages/app/src/mobile-panels/gesture-intent.test.ts new file mode 100644 index 0000000000..74035096fb --- /dev/null +++ b/packages/app/src/mobile-panels/gesture-intent.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMobilePanelGestureIntent } from "./gesture-intent"; + +describe("mobile panel gesture intent", () => { + it("blocks both panel-opening directions while the active surface owns horizontal dragging", () => { + expect([ + resolveMobilePanelGestureIntent({ + deltaX: 40, + deltaY: 2, + direction: 1, + openGesturesBlocked: true, + }), + resolveMobilePanelGestureIntent({ + deltaX: -40, + deltaY: 2, + direction: -1, + openGesturesBlocked: true, + }), + ]).toEqual(["fail", "fail"]); + }); + + it("keeps ordinary horizontal panel gestures available when unblocked", () => { + expect([ + resolveMobilePanelGestureIntent({ + deltaX: 40, + deltaY: 2, + direction: 1, + openGesturesBlocked: false, + }), + resolveMobilePanelGestureIntent({ + deltaX: -40, + deltaY: 2, + direction: -1, + openGesturesBlocked: false, + }), + ]).toEqual(["activate", "activate"]); + }); +}); diff --git a/packages/app/src/mobile-panels/gesture-intent.ts b/packages/app/src/mobile-panels/gesture-intent.ts new file mode 100644 index 0000000000..3399670924 --- /dev/null +++ b/packages/app/src/mobile-panels/gesture-intent.ts @@ -0,0 +1,24 @@ +export type MobilePanelGestureDirection = -1 | 1; +export type MobilePanelGestureIntent = "activate" | "fail" | "wait"; + +export function resolveMobilePanelGestureIntent(input: { + deltaX: number; + deltaY: number; + direction: MobilePanelGestureDirection; + openGesturesBlocked: boolean; +}): MobilePanelGestureIntent { + "worklet"; + if (input.openGesturesBlocked) { + return "fail"; + } + const directedDelta = input.deltaX * input.direction; + const absDeltaX = Math.abs(input.deltaX); + const absDeltaY = Math.abs(input.deltaY); + if (directedDelta <= -10 || (absDeltaY > 10 && absDeltaY > absDeltaX)) { + return "fail"; + } + if (directedDelta >= 15 && absDeltaX > absDeltaY) { + return "activate"; + } + return "wait"; +} diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts index 4daa44a5ea..aca2d64ce6 100644 --- a/packages/app/src/mobile-panels/gestures.ts +++ b/packages/app/src/mobile-panels/gestures.ts @@ -7,36 +7,14 @@ import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-contex import { usePanelStore } from "@/stores/panel-store"; import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model"; import { useMobilePanelsRuntime } from "./provider"; +import { resolveMobilePanelGestureIntent } from "./gesture-intent"; const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32; -const PAN_INTENT_FAIL = -1; -const PAN_INTENT_WAIT = 0; -const PAN_INTENT_ACTIVATE = 1; -type PanDirection = -1 | 1; -type PanIntent = typeof PAN_INTENT_ACTIVATE | typeof PAN_INTENT_FAIL | typeof PAN_INTENT_WAIT; function isCurrentSelection(startedRevision: number): boolean { return usePanelStore.getState().mobilePanel.revision === startedRevision; } -function getHorizontalPanIntent( - deltaX: number, - deltaY: number, - direction: PanDirection, -): PanIntent { - "worklet"; - const directedDelta = deltaX * direction; - const absDeltaX = Math.abs(deltaX); - const absDeltaY = Math.abs(deltaY); - if (directedDelta <= -10 || (absDeltaY > 10 && absDeltaY > absDeltaX)) { - return PAN_INTENT_FAIL; - } - if (directedDelta >= 15 && absDeltaX > absDeltaY) { - return PAN_INTENT_ACTIVATE; - } - return PAN_INTENT_WAIT; -} - function useGestureState() { return { startedRevision: useSharedValue(-1), @@ -62,6 +40,7 @@ export function useOpenAgentListGesture(enabled: boolean) { finishGesture, leftOpenGestureRef, motionState, + openGesturesBlocked, position, updateGesture, windowWidth, @@ -96,17 +75,22 @@ export function useOpenAgentListGesture(enabled: boolean) { return; } - const panIntent = getHorizontalPanIntent(deltaX, deltaY, 1); + const panIntent = resolveMobilePanelGestureIntent({ + deltaX, + deltaY, + direction: 1, + openGesturesBlocked: openGesturesBlocked.value, + }); if ( !canBeginMobilePanelGesture(motionState.value, "agent", position.value) || horizontalScroll?.isAnyScrolledRight.value || (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) || - panIntent === PAN_INTENT_FAIL + panIntent === "fail" ) { stateManager.fail(); return; } - if (panIntent === PAN_INTENT_ACTIVATE) { + if (panIntent === "activate") { stateManager.activate(); } }) @@ -135,6 +119,7 @@ export function useOpenAgentListGesture(enabled: boolean) { horizontalScroll?.isAnyScrolledRight, leftOpenGestureRef, motionState, + openGesturesBlocked, position, startedRevision, touchStartX, @@ -183,15 +168,20 @@ export function useCloseAgentListGesture() { return; } - const panIntent = getHorizontalPanIntent(deltaX, deltaY, -1); + const panIntent = resolveMobilePanelGestureIntent({ + deltaX, + deltaY, + direction: -1, + openGesturesBlocked: false, + }); if ( !canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) || - panIntent === PAN_INTENT_FAIL + panIntent === "fail" ) { stateManager.fail(); return; } - if (panIntent === PAN_INTENT_ACTIVATE) { + if (panIntent === "activate") { stateManager.activate(); } }) @@ -244,6 +234,7 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer finishGesture, leftOpenGestureRef, motionState, + openGesturesBlocked, position, rightOpenGestureRef, updateGesture, @@ -278,16 +269,21 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer return; } - const panIntent = getHorizontalPanIntent(deltaX, deltaY, -1); + const panIntent = resolveMobilePanelGestureIntent({ + deltaX, + deltaY, + direction: -1, + openGesturesBlocked: openGesturesBlocked.value, + }); if ( !canBeginMobilePanelGesture(motionState.value, "agent", position.value) || (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) || - panIntent === PAN_INTENT_FAIL + panIntent === "fail" ) { stateManager.fail(); return; } - if (panIntent === PAN_INTENT_ACTIVATE) { + if (panIntent === "activate") { stateManager.activate(); } }) @@ -318,6 +314,7 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer finishGesture, leftOpenGestureRef, motionState, + openGesturesBlocked, position, rightOpenGestureRef, startedRevision, @@ -367,15 +364,20 @@ export function useCloseFileExplorerGesture() { return; } - const panIntent = getHorizontalPanIntent(deltaX, deltaY, 1); + const panIntent = resolveMobilePanelGestureIntent({ + deltaX, + deltaY, + direction: 1, + openGesturesBlocked: false, + }); if ( !canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) || - panIntent === PAN_INTENT_FAIL + panIntent === "fail" ) { stateManager.fail(); return; } - if (panIntent === PAN_INTENT_ACTIVATE) { + if (panIntent === "activate") { stateManager.activate(); } }) diff --git a/packages/app/src/mobile-panels/provider.tsx b/packages/app/src/mobile-panels/provider.tsx index 77d134b9d7..93e92ab39c 100644 --- a/packages/app/src/mobile-panels/provider.tsx +++ b/packages/app/src/mobile-panels/provider.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -57,10 +58,12 @@ interface MobilePanelsRuntime { leftCloseGestureRef: RefObject; leftOpenGestureRef: RefObject; motionState: SharedValue; + openGesturesBlocked: SharedValue; position: SharedValue; rightCloseGestureRef: RefObject; rightOpenGestureRef: RefObject; updateGesture: (startedRevision: number, nextPosition: number) => boolean; + setOpenGestureBlocked: (owner: symbol, blocked: boolean) => void; windowWidth: number; } @@ -83,12 +86,26 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { const initialSelection = useRef(usePanelStore.getState().mobilePanel).current; const position = useSharedValue(getMobilePanelAnchor(initialSelection.target)); const motionState = useSharedValue(createMobilePanelMotionState(initialSelection)); + const openGesturesBlocked = useSharedValue(false); + const openGestureBlockersRef = useRef(new Set()); const leftOpenGestureRef = useRef(undefined); const leftCloseGestureRef = useRef(undefined); const rightOpenGestureRef = useRef(undefined); const rightCloseGestureRef = useRef(undefined); const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target)); + const setOpenGestureBlocked = useCallback( + (owner: symbol, blocked: boolean) => { + if (blocked) { + openGestureBlockersRef.current.add(owner); + } else { + openGestureBlockersRef.current.delete(owner); + } + openGesturesBlocked.value = openGestureBlockersRef.current.size > 0; + }, + [openGesturesBlocked], + ); + const presentPanel = useCallback((panel: MobilePanelView) => { const mask = getPanelMask(panel); if (mask) { @@ -227,13 +244,24 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { leftCloseGestureRef, leftOpenGestureRef, motionState, + openGesturesBlocked, position, rightCloseGestureRef, rightOpenGestureRef, updateGesture, + setOpenGestureBlocked, windowWidth, }), - [beginGesture, finishGesture, motionState, position, updateGesture, windowWidth], + [ + beginGesture, + finishGesture, + motionState, + openGesturesBlocked, + position, + setOpenGestureBlocked, + updateGesture, + windowWidth, + ], ); return ( @@ -258,3 +286,13 @@ export function useIsMobilePanelPresented(panel: MobilePanelView): boolean { const presentedPanels = useContext(MobilePanelPresentationContext); return (presentedPanels & getPanelMask(panel)) !== 0; } + +export function useBlockMobilePanelOpenGestures(blocked: boolean): void { + const { setOpenGestureBlocked } = useMobilePanelsRuntime(); + const owner = useRef(Symbol("mobile-panel-open-gesture-blocker")).current; + + useLayoutEffect(() => { + setOpenGestureBlocked(owner, blocked); + return () => setOpenGestureBlocked(owner, false); + }, [blocked, owner, setOpenGestureBlocked]); +} diff --git a/packages/app/src/polyfills/navigator.ts b/packages/app/src/polyfills/navigator.ts new file mode 100644 index 0000000000..59bc746bb9 --- /dev/null +++ b/packages/app/src/polyfills/navigator.ts @@ -0,0 +1,28 @@ +interface NavigatorPolyfillValue { + userAgent?: unknown; + platform?: unknown; +} + +interface NavigatorPolyfillTarget { + navigator?: NavigatorPolyfillValue; +} + +function hasNavigator(value: NavigatorPolyfillValue | undefined): value is NavigatorPolyfillValue { + return typeof value === "object" && value !== null; +} + +export function polyfillNavigator(): void { + const target = globalThis as unknown as NavigatorPolyfillTarget; + + if (!hasNavigator(target.navigator)) { + target.navigator = {}; + } + + const navigator = target.navigator; + if (typeof navigator.userAgent !== "string") { + navigator.userAgent = "ReactNative"; + } + if (typeof navigator.platform !== "string") { + navigator.platform = ""; + } +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 0e0e85191f..3ba244202f 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -73,6 +73,7 @@ import { PairLinkModal } from "@/components/pair-link-modal"; import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; import { EditorSection } from "@/screens/settings/editor-section"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; @@ -107,6 +108,7 @@ import { import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { type EnableBuiltInDaemonOption, @@ -452,6 +454,8 @@ function GeneralSection({ } interface DiagnosticsSectionProps { + useLegacyTerminalRenderer: boolean; + onUseLegacyTerminalRendererChange: (value: boolean) => void; voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; playbackTestResult: string | null; @@ -459,6 +463,8 @@ interface DiagnosticsSectionProps { } function DiagnosticsSection({ + useLegacyTerminalRenderer, + onUseLegacyTerminalRendererChange, voiceAudioEngine, isPlaybackTestRunning, playbackTestResult, @@ -472,6 +478,26 @@ function DiagnosticsSection({ return ( + {isNative ? ( + + + + {t("settings.diagnostics.legacyTerminalRenderer.label")} + + + {t("settings.diagnostics.legacyTerminalRenderer.description")} + + + + + ) : null} {t("settings.diagnostics.app.rowTitle")} @@ -1163,6 +1189,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti [updateSettings], ); + const handleUseLegacyTerminalRendererChange = useCallback( + (useLegacyTerminalRenderer: boolean) => { + void updateSettings({ useLegacyTerminalRenderer }); + }, + [updateSettings], + ); + const handlePlaybackTest = useCallback(async () => { if (!voiceAudioEngine || isPlaybackTestRunning) { return; @@ -1391,6 +1424,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti case "diagnostics": return ( ""); + + for (let index = 0; index < ANSI_THEME_KEYS.length; index += 1) { + const key = ANSI_THEME_KEYS[index]; + palette[index] = theme[key] ?? DEFAULT_TERMINAL_THEME[key] ?? "#000000"; + } + + for (let red = 0; red < CUBE_LEVELS.length; red += 1) { + for (let green = 0; green < CUBE_LEVELS.length; green += 1) { + for (let blue = 0; blue < CUBE_LEVELS.length; blue += 1) { + const index = 16 + red * 36 + green * 6 + blue; + palette[index] = `rgb(${CUBE_LEVELS[red]}, ${CUBE_LEVELS[green]}, ${CUBE_LEVELS[blue]})`; + } + } + } + + for (let index = 0; index < 24; index += 1) { + const value = 8 + index * 10; + palette[232 + index] = `rgb(${value}, ${value}, ${value})`; + } + + return palette; +} + +function rgbColor(value: number): string { + const red = (value >> 16) & 0xff; + const green = (value >> 8) & 0xff; + const blue = value & 0xff; + return `rgb(${red}, ${green}, ${blue})`; +} + +function resolveColor(input: { + value: number | undefined; + mode: number | undefined; + fallback: string; + palette: string[]; +}): string { + const mode = input.mode ?? DEFAULT_COLOR_MODE; + const value = input.value ?? 0; + + if (mode === ANSI_COLOR_MODE || mode === INDEXED_COLOR_MODE) { + return input.palette[value] ?? input.fallback; + } + if (mode === RGB_COLOR_MODE) { + return rgbColor(value); + } + return input.fallback; +} + +function cellFlags(cell: TerminalCell): number { + let flags = 0; + if (cell.bold) flags |= FLAG_BOLD; + if (cell.italic) flags |= FLAG_ITALIC; + if (cell.underline) flags |= FLAG_UNDERLINE; + if (cell.dim) flags |= FLAG_DIM; + if (cell.inverse) flags |= FLAG_INVERSE; + if (cell.strikethrough) flags |= FLAG_STRIKETHROUGH; + return flags; +} + +function resolveCellColors(input: { + cell: TerminalCell; + palette: string[]; + foregroundColor: string; + backgroundColor: string; +}): ResolvedCellColors { + const foreground = resolveColor({ + value: input.cell.fg, + mode: input.cell.fgMode, + fallback: input.foregroundColor, + palette: input.palette, + }); + const background = resolveColor({ + value: input.cell.bg, + mode: input.cell.bgMode, + fallback: input.backgroundColor, + palette: input.palette, + }); + + const resolvedForeground = input.cell.inverse ? background : foreground; + const resolvedBackground = input.cell.inverse ? foreground : background; + return { + foreground: resolvedForeground, + background: resolvedBackground, + paintsBackground: resolvedBackground !== input.backgroundColor, + }; +} + +function textDecorationLine(flags: number): TextStyle["textDecorationLine"] | undefined { + const underline = (flags & FLAG_UNDERLINE) !== 0; + const strikethrough = (flags & FLAG_STRIKETHROUGH) !== 0; + if (underline && strikethrough) return "underline line-through"; + if (underline) return "underline"; + if (strikethrough) return "line-through"; + return undefined; +} + +function createTextStyle(input: { + foreground: string; + background: string; + paintsBackground: boolean; + flags: number; +}): TextStyle { + const style: TextStyle = { + color: input.foreground, + }; + + if (input.paintsBackground) { + style.backgroundColor = input.background; + } + if ((input.flags & FLAG_BOLD) !== 0) { + style.fontWeight = "700"; + } + if ((input.flags & FLAG_ITALIC) !== 0) { + style.fontStyle = "italic"; + } + if ((input.flags & FLAG_DIM) !== 0) { + style.opacity = 0.65; + } + + const decorationLine = textDecorationLine(input.flags); + if (decorationLine) { + style.textDecorationLine = decorationLine; + style.textDecorationColor = input.foreground; + } + + return style; +} + +function buildStyleKey(input: { + foreground: string; + background: string; + paintsBackground: boolean; + flags: number; +}): string { + const background = input.paintsBackground ? input.background : "default"; + return `${input.foreground}|${background}|${input.flags}`; +} + +function buildThemeKey(theme: ITheme): string { + return JSON.stringify(theme); +} + +export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyleResolver { + const palette = build256Palette(theme); + const foregroundColor = theme.foreground ?? DEFAULT_TERMINAL_THEME.foreground ?? "#fafafa"; + const backgroundColor = theme.background ?? DEFAULT_TERMINAL_THEME.background ?? "#181B1A"; + const cursorColor = theme.cursor ?? foregroundColor; + const styleCache = new Map(); + + return { + themeKey: buildThemeKey(theme), + backgroundColor, + cursorColor, + resolve(cell: TerminalCell): ResolvedCellStyle { + const flags = cellFlags(cell); + const colors = resolveCellColors({ cell, palette, foregroundColor, backgroundColor }); + const key = buildStyleKey({ + foreground: colors.foreground, + background: colors.background, + paintsBackground: colors.paintsBackground, + flags, + }); + const cachedStyle = styleCache.get(key); + if (cachedStyle) { + return { key, style: cachedStyle }; + } + + const style = createTextStyle({ + foreground: colors.foreground, + background: colors.background, + paintsBackground: colors.paintsBackground, + flags, + }); + styleCache.set(key, style); + return { key, style }; + }, + }; +} diff --git a/packages/app/src/terminal/native-renderer/font.native.ts b/packages/app/src/terminal/native-renderer/font.native.ts new file mode 100644 index 0000000000..5a83c4505a --- /dev/null +++ b/packages/app/src/terminal/native-renderer/font.native.ts @@ -0,0 +1,57 @@ +import { Platform } from "react-native"; +import { resolveTerminalFontFamily } from "../runtime/terminal-font"; + +const IOS_LOADED_MONO_FAMILIES: ReadonlySet = new Set(["courier", "courier new", "menlo"]); + +const ANDROID_LOADED_MONO_FAMILIES: ReadonlySet = new Set([ + "monospace", + "sans-serif-monospace", +]); + +const IOS_MONO_ALIASES = new Map([ + ["sf mono", "Menlo"], + ["sfmono-regular", "Menlo"], + ["ui-monospace", "Menlo"], +]); + +const NATIVE_MONO_FALLBACK = + Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" }) ?? "monospace"; + +function stripFontQuotes(family: string): string { + const trimmed = family.trim(); + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +function splitFontStack(fontFamily: string): string[] { + return fontFamily + .split(",") + .map(stripFontQuotes) + .filter((family) => family.length > 0); +} + +function loadedNativeFontFamily(family: string): string | null { + const normalized = family.toLowerCase(); + if (Platform.OS === "ios") { + const alias = IOS_MONO_ALIASES.get(normalized); + if (alias) return alias; + return IOS_LOADED_MONO_FAMILIES.has(normalized) ? family : null; + } + if (Platform.OS === "android") { + return ANDROID_LOADED_MONO_FAMILIES.has(normalized) ? family : null; + } + return normalized === "monospace" ? family : null; +} + +export function resolveNativeTerminalFontFamily(fontFamily: string | undefined): string { + const stack = resolveTerminalFontFamily(fontFamily); + const loadedFamily = splitFontStack(stack) + .map(loadedNativeFontFamily) + .find((family): family is string => family !== null); + return loadedFamily ?? NATIVE_MONO_FALLBACK; +} diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts new file mode 100644 index 0000000000..fb94588a01 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "vitest"; + +import { + createNativeHeadlessTerminal, + type TerminalCellRow, + type TerminalViewportState, +} from "./headless-terminal-state"; +import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload"; +import { + createNativeTerminalOutputDrain, + NATIVE_TERMINAL_PARSE_CHUNK_CHARS, +} from "./headless-terminal-output-drain"; +import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model"; +import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot"; + +const ROWS = 12; +const COLS = 80; + +function rowText(row: TerminalViewportState["grid"][number]): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +function expectedBenchmarkLine(index: number): string { + const label = `line ${index.toString().padStart(6, "0")} `; + const body = "abcdefghijklmnopqrstuvwxyz0123456789 ".repeat(4); + return (label + body).slice(0, COLS - 1); +} + +function createFrameRecorder() { + const frames: Array<() => void> = []; + return { + frames, + scheduleFrame: (callback: () => void) => { + frames.push(callback); + return frames.length; + }, + cancelFrame: () => {}, + }; +} + +function lineCells(line: string, cols: number): TerminalCellRow { + const cells: TerminalCellRow = []; + for (let index = 0; index < cols; index += 1) { + cells.push({ char: line[index] ?? " ", width: 1 }); + } + return cells; +} + +function terminalStateFromLines(input: { + lines: string[]; + rows: number; + cols: number; + cursor: TerminalViewportState["cursor"]; +}) { + const grid: TerminalCellRow[] = []; + for (let row = 0; row < input.rows; row += 1) { + grid.push(lineCells(input.lines[row] ?? "", input.cols)); + } + return { + rows: input.rows, + cols: input.cols, + grid, + scrollback: [], + cursor: input.cursor, + }; +} + +async function runScheduledWork(input: { + callbacks: Array<() => void>; + flush: Promise; +}): Promise { + while (input.callbacks.length > 0) { + input.callbacks.shift()?.(); + await Promise.resolve(); + } + await input.flush; +} + +async function benchmarkSnapshot(lineCount: number) { + const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 }); + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }), + ); + return terminal.getState(); +} + +function createScreenRestorer( + terminal: ReturnType, + model: ReturnType, +) { + const frameRecorder = createFrameRecorder(); + let screen: TerminalScreenState | null = null; + const drain = createNativeTerminalOutputDrain({ + write: (chunk) => terminal.write(chunk), + reset: () => terminal.reset(), + getViewportState: () => { + screen = model.sync({ visibleRows: ROWS }); + return screen.viewport; + }, + onPaint: () => {}, + scheduleFrame: frameRecorder.scheduleFrame, + cancelFrame: frameRecorder.cancelFrame, + }); + + return { + async restore(snapshot: Awaited>) { + drain.restoreSnapshot(snapshot); + await drain.flush(); + frameRecorder.frames[0]?.(); + return screen; + }, + }; +} + +describe("native terminal output drain", () => { + test("drains large bursts without waiting one frame per 2KB parse slice", async () => { + const writes: string[] = []; + const parseSamples: Array<{ parsedChars: number; queuedChars: number }> = []; + const frameRecorder = createFrameRecorder(); + const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2); + const drain = createNativeTerminalOutputDrain({ + write: async (chunk) => { + writes.push(chunk); + }, + reset: () => {}, + getViewportState: () => ({ + rows: 1, + cols: 1, + firstRow: 0, + oldestRow: 0, + newestRow: 0, + grid: [[{ char: "x" }]], + cursor: { row: 0, col: 1 }, + }), + onPaint: () => {}, + onParse: (sample) => parseSamples.push(sample), + scheduleFrame: frameRecorder.scheduleFrame, + cancelFrame: frameRecorder.cancelFrame, + }); + + drain.enqueueText(payload); + await drain.flush(); + + expect(writes.map((write) => write.length)).toEqual([ + NATIVE_TERMINAL_PARSE_CHUNK_CHARS, + NATIVE_TERMINAL_PARSE_CHUNK_CHARS, + ]); + expect(writes.join("")).toBe(payload); + expect( + parseSamples.map(({ parsedChars, queuedChars }) => ({ parsedChars, queuedChars })), + ).toEqual([ + { + parsedChars: NATIVE_TERMINAL_PARSE_CHUNK_CHARS, + queuedChars: NATIVE_TERMINAL_PARSE_CHUNK_CHARS, + }, + { parsedChars: NATIVE_TERMINAL_PARSE_CHUNK_CHARS, queuedChars: 0 }, + ]); + expect(frameRecorder.frames).toHaveLength(1); + }); + + test("paints the final bottom viewport after a high-output byte burst", async () => { + const terminal = createNativeHeadlessTerminal({ + rows: ROWS, + cols: COLS, + scrollbackLines: 1000, + }); + const paintedStates: TerminalViewportState[] = []; + const frameRecorder = createFrameRecorder(); + const drain = createNativeTerminalOutputDrain({ + write: (chunk) => terminal.write(chunk), + reset: () => terminal.reset(), + getViewportState: () => terminal.getViewportState(), + onPaint: (state) => paintedStates.push(state), + scheduleFrame: frameRecorder.scheduleFrame, + cancelFrame: frameRecorder.cancelFrame, + }); + + drain.enqueueText( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount: 250, cols: COLS }), + ); + await drain.flush(); + frameRecorder.frames[0]?.(); + + expect( + paintedStates.map((state) => ({ + cursor: state.cursor, + bottomRows: state.grid.slice(-2).map(rowText), + })), + ).toEqual([ + { + cursor: { row: ROWS - 1, col: 0 }, + bottomRows: [expectedBenchmarkLine(249), ""], + }, + ]); + }); + + test("restores replace old terminal state and discard stale queued output", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 100 }); + const scheduledWork: Array<() => void> = []; + const frameRecorder = createFrameRecorder(); + const drain = createNativeTerminalOutputDrain({ + write: (chunk) => terminal.write(chunk), + reset: () => terminal.reset(), + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + scheduleWork: (callback) => scheduledWork.push(callback), + scheduleFrame: frameRecorder.scheduleFrame, + cancelFrame: frameRecorder.cancelFrame, + }); + const restoredSnapshot = terminalStateFromLines({ + rows: 4, + cols: 32, + lines: ["restored-one", "restored-two"], + cursor: { row: 1, col: "restored-two".length }, + }); + + await terminal.write("old-one\r\nold-two\r\nold-three\r\n"); + drain.enqueueText("stale-queued\r\n"); + drain.restoreText(renderTerminalSnapshotToAnsi(restoredSnapshot)); + await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() }); + + const state = terminal.getViewportState(); + expect(state.grid.map(rowText)).toEqual(["restored-one", "restored-two", "", ""]); + expect(state.cursor).toEqual(restoredSnapshot.cursor); + }); + + test("snapshot restore preserves a scrolled viewport anchor while replacing terminal state", async () => { + const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 }); + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount: 30, cols: COLS }), + ); + const model = createNativeTerminalScreenModel({ terminal }); + const before = model.scrollUp({ rows: 5, visibleRows: ROWS }); + const beforeTopRow = rowText(before.viewport.grid[0] ?? []); + + const screen = await createScreenRestorer(terminal, model).restore(await benchmarkSnapshot(35)); + + expect({ + paintedMode: screen?.scroll.mode, + paintedTopRow: rowText(screen?.viewport.grid[0] ?? []), + beforeTopRow, + restoredBottomRows: terminal.getState().grid.slice(-2).map(rowText), + }).toEqual({ + paintedMode: "scrolled", + paintedTopRow: beforeTopRow, + beforeTopRow, + restoredBottomRows: [expectedBenchmarkLine(34), ""], + }); + }); + + test("snapshot restore keeps a following viewport at the new bottom", async () => { + const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 }); + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount: 10, cols: COLS }), + ); + const model = createNativeTerminalScreenModel({ terminal }); + model.sync({ visibleRows: ROWS }); + + const screen = await createScreenRestorer(terminal, model).restore(await benchmarkSnapshot(20)); + const paintedRows = screen?.viewport.grid.map(rowText).filter((line) => line.length > 0) ?? []; + + expect({ paintedMode: screen?.scroll.mode, bottomRows: paintedRows.slice(-2) }).toEqual({ + paintedMode: "following", + bottomRows: [expectedBenchmarkLine(18), expectedBenchmarkLine(19)], + }); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts new file mode 100644 index 0000000000..d853165371 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts @@ -0,0 +1,229 @@ +import type { TerminalState } from "@getpaseo/protocol/messages"; +import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot"; +import type { TerminalViewportState } from "./headless-terminal-state"; +import { nativeTerminalPerformanceNow } from "./terminal-performance"; + +export const NATIVE_TERMINAL_PARSE_CHUNK_CHARS = 64 * 1024; + +const NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN = 32; + +export interface NativeTerminalParseSample { + durationMs: number; + parsedChars: number; + queuedChars: number; +} + +export interface NativeTerminalOutputDrain { + enqueueText(text: string): void; + restoreText(text: string): void; + restoreSnapshot(snapshot: TerminalState): void; + clear(): void; + flush(): Promise; + dispose(): void; + getQueuedChars(): number; +} + +export interface NativeTerminalOutputDrainOptions { + write: (chunk: string) => Promise; + reset: () => void; + getViewportState: () => TerminalViewportState | null; + onPaint: (state: TerminalViewportState) => void; + onParse?: (sample: NativeTerminalParseSample) => void; + scheduleWork?: (callback: () => void) => void; + scheduleFrame?: (callback: () => void) => number; + cancelFrame?: (frame: number) => void; + chunkChars?: number; + chunksPerTurn?: number; +} + +function defaultScheduleWork(callback: () => void): void { + if (typeof queueMicrotask === "function") { + queueMicrotask(callback); + return; + } + setTimeout(callback, 0); +} + +function defaultScheduleFrame(callback: () => void): number { + if (typeof requestAnimationFrame === "function") { + return requestAnimationFrame(callback); + } + return setTimeout(callback, 16) as unknown as number; +} + +function defaultCancelFrame(frame: number): void { + if (typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(frame); + return; + } + clearTimeout(frame as unknown as ReturnType); +} + +export function createNativeTerminalOutputDrain( + options: NativeTerminalOutputDrainOptions, +): NativeTerminalOutputDrain { + const scheduleWork = options.scheduleWork ?? defaultScheduleWork; + const scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame; + const cancelFrame = options.cancelFrame ?? defaultCancelFrame; + const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS; + const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN; + const idleWaiters: Array<() => void> = []; + let pendingText = ""; + let drainScheduled = false; + let draining = false; + let disposed = false; + let paintFrame: number | null = null; + let generation = 0; + let resetBeforeNextWrite = false; + + function resolveIdleIfReady(): void { + if (drainScheduled || draining || pendingText.length > 0) { + return; + } + while (idleWaiters.length > 0) { + idleWaiters.shift()?.(); + } + } + + function requestPaint(): void { + if (disposed || paintFrame !== null) { + return; + } + paintFrame = scheduleFrame(() => { + paintFrame = null; + if (disposed) { + return; + } + const state = options.getViewportState(); + if (state) { + options.onPaint(state); + } + }); + } + + function cancelPendingPaint(): void { + if (paintFrame === null) { + return; + } + cancelFrame(paintFrame); + paintFrame = null; + } + + function scheduleDrain(): void { + if (disposed || drainScheduled || draining) { + return; + } + drainScheduled = true; + scheduleWork(() => { + void drainPending(); + }); + } + + function replacePendingText(text: string): void { + generation += 1; + pendingText = text; + resetBeforeNextWrite = true; + cancelPendingPaint(); + scheduleDrain(); + resolveIdleIfReady(); + } + + async function drainPending(): Promise { + if (disposed || draining) { + return; + } + drainScheduled = false; + draining = true; + const activeGeneration = generation; + let parsedChunks = 0; + + try { + while (pendingText.length > 0 && parsedChunks < chunksPerTurn) { + if (disposed || activeGeneration !== generation) { + return; + } + if (resetBeforeNextWrite) { + options.reset(); + resetBeforeNextWrite = false; + } + const chunk = pendingText.slice(0, chunkChars); + pendingText = pendingText.slice(chunk.length); + const parseStart = nativeTerminalPerformanceNow(); + await options.write(chunk); + if (disposed || activeGeneration !== generation) { + return; + } + options.onParse?.({ + durationMs: nativeTerminalPerformanceNow() - parseStart, + parsedChars: chunk.length, + queuedChars: pendingText.length, + }); + requestPaint(); + parsedChunks += 1; + } + } finally { + draining = false; + if (!disposed && pendingText.length > 0) { + scheduleDrain(); + } + resolveIdleIfReady(); + } + } + + return { + enqueueText(text: string): void { + if (disposed || text.length === 0) { + return; + } + pendingText += text; + scheduleDrain(); + }, + + restoreText(text: string): void { + if (disposed) { + return; + } + replacePendingText(text); + }, + + restoreSnapshot(snapshot: TerminalState): void { + if (disposed) { + return; + } + replacePendingText(renderTerminalSnapshotToAnsi(snapshot)); + }, + + clear(): void { + generation += 1; + pendingText = ""; + resetBeforeNextWrite = false; + cancelPendingPaint(); + resolveIdleIfReady(); + }, + + flush(): Promise { + if (!drainScheduled && !draining && pendingText.length === 0) { + return Promise.resolve(); + } + scheduleDrain(); + return new Promise((resolve) => { + idleWaiters.push(resolve); + }); + }, + + dispose(): void { + disposed = true; + generation += 1; + pendingText = ""; + resetBeforeNextWrite = false; + cancelPendingPaint(); + while (idleWaiters.length > 0) { + idleWaiters.shift()?.(); + } + }, + + getQueuedChars(): number { + return pendingText.length; + }, + }; +} diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts b/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts new file mode 100644 index 0000000000..c1025568c9 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "vitest"; + +import { createNativeHeadlessTerminal, type TerminalCellRow } from "./headless-terminal-state"; +import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload"; + +const COLS = 80; + +function rowText(row: TerminalCellRow): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +function expectedBenchmarkLine(index: number): string { + const label = `line ${index.toString().padStart(6, "0")} `; + const body = "abcdefghijklmnopqrstuvwxyz0123456789 ".repeat(4); + return (label + body).slice(0, COLS - 1); +} + +describe("native headless terminal state", () => { + test("exposes application cursor key mode from headless xterm", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 5, cols: COLS }); + + expect(terminal.getInputModeState()).toEqual({ + applicationCursorKeys: false, + bracketedPaste: false, + }); + await terminal.write("\x1b[?1h"); + expect(terminal.getInputModeState()).toEqual({ + applicationCursorKeys: true, + bracketedPaste: false, + }); + await terminal.write("\x1b[?1l"); + expect(terminal.getInputModeState()).toEqual({ + applicationCursorKeys: false, + bracketedPaste: false, + }); + }); + + test("exposes bracketed paste mode from headless xterm", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 5, cols: COLS }); + + expect(terminal.getInputModeState()).toEqual({ + applicationCursorKeys: false, + bracketedPaste: false, + }); + await terminal.write("\x1b[?2004h"); + expect(terminal.getInputModeState()).toEqual({ + applicationCursorKeys: false, + bracketedPaste: true, + }); + await terminal.write("\x1b[?2004l"); + expect(terminal.getInputModeState()).toEqual({ + applicationCursorKeys: false, + bracketedPaste: false, + }); + }); + + test("reads a scrollback window from the active buffer without extracting full scrollback", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 5, cols: COLS, scrollbackLines: 100 }); + + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount: 80, cols: COLS }), + ); + const window = terminal.getBufferWindow({ startRow: 70, rowCount: 3 }); + + expect({ + startRow: window.startRow, + rows: window.rows.map(rowText), + }).toEqual({ + startRow: 70, + rows: [expectedBenchmarkLine(70), expectedBenchmarkLine(71), expectedBenchmarkLine(72)], + }); + }); + + test("keeps buffer coordinates stable across append-only output before eviction", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 5, cols: COLS, scrollbackLines: 100 }); + + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount: 5, cols: COLS }), + ); + const before = terminal.getBufferBounds(); + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 5, lineCount: 5, cols: COLS }), + ); + const after = terminal.getBufferBounds(); + + expect({ + beforeEpoch: before.coordinateEpoch, + afterEpoch: after.coordinateEpoch, + grew: after.newestRow > before.newestRow, + }).toEqual({ + beforeEpoch: before.coordinateEpoch, + afterEpoch: before.coordinateEpoch, + grew: true, + }); + }); + + test("advances the oldest stable coordinate when scrollback rows are evicted", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: COLS, scrollbackLines: 2 }); + + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount: 8, cols: COLS }), + ); + const before = terminal.getBufferBounds(); + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 8, lineCount: 4, cols: COLS }), + ); + const after = terminal.getBufferBounds(); + + expect({ + sameRetainedRowCount: + after.newestRow - after.oldestRow === before.newestRow - before.oldestRow, + oldestRowAdvanced: after.oldestRow > before.oldestRow, + sameCoordinateSpace: after.coordinateEpoch === before.coordinateEpoch, + }).toEqual({ + sameRetainedRowCount: true, + oldestRowAdvanced: true, + sameCoordinateSpace: true, + }); + }); + + test("keeps buffer coordinates stable for writes that do not evict a saturated buffer", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: COLS, scrollbackLines: 2 }); + + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount: 8, cols: COLS }), + ); + const before = terminal.getBufferBounds(); + await terminal.write("still on the current row"); + const after = terminal.getBufferBounds(); + + expect(after.coordinateEpoch).toEqual(before.coordinateEpoch); + }); + + test("changes buffer coordinate epoch on reset", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 5, cols: COLS, scrollbackLines: 100 }); + + await terminal.write("before reset\r\n"); + const before = terminal.getBufferBounds(); + terminal.reset(); + await terminal.write("after reset\r\n"); + const after = terminal.getBufferBounds(); + + expect(after.coordinateEpoch > before.coordinateEpoch).toEqual(true); + }); + + test("preserves xterm's authoritative wide-cell metadata", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 2, cols: 8 }); + + await terminal.write("A界B"); + const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows; + + expect(row.slice(0, 4).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([ + { char: "A", width: 1 }, + { char: "界", width: 2 }, + { char: " ", width: 0 }, + { char: "B", width: 1 }, + ]); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-state.ts b/packages/app/src/terminal/native-renderer/headless-terminal-state.ts new file mode 100644 index 0000000000..c25399c9b2 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/headless-terminal-state.ts @@ -0,0 +1,421 @@ +import { + Terminal as HeadlessTerminal, + type IBufferCell, + type IBufferLine, + type Terminal as HeadlessTerminalInstance, +} from "@xterm/headless"; +import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; + +export type NativeTerminalWriteData = Uint8Array | string; +export interface NativeTerminalCell extends TerminalCell { + width?: number; +} + +export type TerminalCellRow = NativeTerminalCell[]; + +export interface NativeHeadlessTerminalOptions { + rows: number; + cols: number; + scrollbackLines?: number; +} + +export interface NativeHeadlessTerminal { + write(data: NativeTerminalWriteData): Promise; + getState(): TerminalState; + getViewportState(input?: TerminalViewportStateInput): TerminalViewportState; + getInputModeState(): NativeHeadlessTerminalInputModeState; + getBufferBounds(): TerminalBufferBounds; + getBufferWindow(input: TerminalBufferWindowInput): TerminalBufferWindow; + resize(input: { rows: number; cols: number }): void; + reset(): void; + dispose(): void; +} + +export interface NativeHeadlessTerminalInputModeState { + applicationCursorKeys: boolean; + bracketedPaste: boolean; +} + +export interface TerminalBufferWindowInput { + startRow: number; + rowCount: number; +} + +export interface TerminalViewportStateInput { + firstRow: number; + rowCount: number; +} + +export interface TerminalRowRange { + firstRow: number; + lastRow: number; +} + +export interface TerminalBufferBounds { + rows: number; + cols: number; + oldestRow: number; + newestRow: number; + coordinateEpoch: number; + currentViewport: TerminalRowRange; + bottomViewport: TerminalRowRange; + cursorRow: number; + cursorCol: number; +} + +export interface TerminalBufferWindow { + startRow: number; + rows: TerminalCellRow[]; + wrappedRows: boolean[]; +} + +export interface TerminalViewportState { + rows: number; + cols: number; + firstRow: number; + oldestRow: number; + newestRow: number; + grid: TerminalCell[][]; + cursor: TerminalState["cursor"]; +} + +interface XtermCoreService { + decPrivateModes?: { + applicationCursorKeys?: unknown; + bracketedPasteMode?: unknown; + cursorStyle?: unknown; + cursorBlink?: unknown; + }; + isCursorHidden?: unknown; +} + +interface HeadlessTerminalWithCore { + _core?: { + coreService?: XtermCoreService; + _bufferService?: { + buffer?: { + lines?: { + onTrim?: (listener: (amount: number) => void) => { dispose(): void }; + }; + }; + }; + }; +} + +interface TerminalCoordinateStorage { + oldestRow: number; + trimSubscription: { dispose(): void } | null; +} + +function activeBufferLines(terminal: HeadlessTerminalInstance) { + return (terminal as unknown as HeadlessTerminalWithCore)._core?._bufferService?.buffer?.lines; +} + +function blankCell(): NativeTerminalCell { + return { char: " ", width: 1, fg: undefined, bg: undefined }; +} + +function extractCell( + line: IBufferLine | undefined, + col: number, + reusableCell: IBufferCell, +): NativeTerminalCell { + if (!line) { + return blankCell(); + } + + const cell = line.getCell(col, reusableCell); + if (!cell) { + return blankCell(); + } + + const fgModeRaw = cell.getFgColorMode(); + const bgModeRaw = cell.getBgColorMode(); + const fgMode = fgModeRaw >> 24; + const bgMode = bgModeRaw >> 24; + const fg = fgMode !== 0 ? cell.getFgColor() : undefined; + const bg = bgMode !== 0 ? cell.getBgColor() : undefined; + + return { + char: cell.getChars() || " ", + width: cell.getWidth(), + fg, + bg, + fgMode: fgMode !== 0 ? fgMode : undefined, + bgMode: bgMode !== 0 ? bgMode : undefined, + bold: cell.isBold() !== 0, + italic: cell.isItalic() !== 0, + underline: cell.isUnderline() !== 0, + dim: cell.isDim() !== 0, + inverse: cell.isInverse() !== 0, + strikethrough: cell.isStrikethrough() !== 0, + }; +} + +function extractRow( + terminal: HeadlessTerminalInstance, + row: number, + reusableCell: IBufferCell, +): TerminalCellRow { + const line = terminal.buffer.active.getLine(row); + const rowCells: NativeTerminalCell[] = []; + for (let col = 0; col < terminal.cols; col += 1) { + rowCells.push(extractCell(line, col, reusableCell)); + } + return rowCells; +} + +function extractGrid(terminal: HeadlessTerminalInstance): TerminalCellRow[] { + const grid: TerminalCellRow[] = []; + const baseY = terminal.buffer.active.baseY; + const reusableCell = terminal.buffer.active.getNullCell(); + for (let row = 0; row < terminal.rows; row += 1) { + grid.push(extractRow(terminal, baseY + row, reusableCell)); + } + return grid; +} + +function extractScrollback( + terminal: HeadlessTerminalInstance, + options: { scrollbackLines?: number }, +): TerminalCellRow[] { + const scrollback: TerminalCellRow[] = []; + const scrollbackLines = terminal.buffer.active.baseY; + const startRow = + typeof options.scrollbackLines === "number" + ? Math.max(0, scrollbackLines - options.scrollbackLines) + : 0; + const reusableCell = terminal.buffer.active.getNullCell(); + + for (let row = startRow; row < scrollbackLines; row += 1) { + scrollback.push(extractRow(terminal, row, reusableCell)); + } + + return scrollback; +} + +function extractBufferWindow( + terminal: HeadlessTerminalInstance, + oldestRow: number, + input: TerminalBufferWindowInput, +): TerminalBufferWindow { + const rowCount = Math.max(0, Math.floor(input.rowCount)); + const bufferLength = terminal.buffer.active.length; + const newestRow = oldestRow + bufferLength - 1; + const startRow = Math.min(Math.max(oldestRow, Math.floor(input.startRow)), newestRow + 1); + const endRow = Math.min(newestRow + 1, startRow + rowCount); + const reusableCell = terminal.buffer.active.getNullCell(); + const rows: TerminalCellRow[] = []; + const wrappedRows: boolean[] = []; + + for (let row = startRow; row < endRow; row += 1) { + const bufferRow = row - oldestRow; + const line = terminal.buffer.active.getLine(bufferRow); + rows.push(extractRow(terminal, bufferRow, reusableCell)); + wrappedRows.push(line?.isWrapped === true); + } + + return { startRow, rows, wrappedRows }; +} + +function rowRange(firstRow: number, rowCount: number): TerminalRowRange { + return { + firstRow, + lastRow: Math.max(firstRow, firstRow + Math.max(1, rowCount) - 1), + }; +} + +function extractBufferBounds( + terminal: HeadlessTerminalInstance, + coordinateEpoch: number, + oldestRow: number, +): TerminalBufferBounds { + const buffer = terminal.buffer.active; + const length = Math.max(1, buffer.length); + const newestRow = oldestRow + length - 1; + const bottomFirstRow = Math.max(oldestRow, newestRow - terminal.rows + 1); + const currentFirstRow = Math.min(Math.max(oldestRow, oldestRow + buffer.baseY), bottomFirstRow); + + return { + rows: terminal.rows, + cols: terminal.cols, + oldestRow, + newestRow, + coordinateEpoch, + currentViewport: rowRange(currentFirstRow, terminal.rows), + bottomViewport: rowRange(bottomFirstRow, terminal.rows), + cursorRow: oldestRow + buffer.baseY + buffer.cursorY, + cursorCol: buffer.cursorX, + }; +} + +function extractCursorState( + terminal: HeadlessTerminalInstance, + window?: TerminalViewportStateInput, + oldestRow = 0, +): TerminalState["cursor"] { + const coreService = (terminal as unknown as HeadlessTerminalWithCore)._core?.coreService; + const cursorStyle = coreService?.decPrivateModes?.cursorStyle; + const normalizedCursorStyle = + cursorStyle === "block" || cursorStyle === "underline" || cursorStyle === "bar" + ? cursorStyle + : undefined; + const cursorBlink = + typeof coreService?.decPrivateModes?.cursorBlink === "boolean" + ? coreService.decPrivateModes.cursorBlink + : undefined; + const cursorRow = oldestRow + terminal.buffer.active.baseY + terminal.buffer.active.cursorY; + const relativeCursorRow = window ? cursorRow - window.firstRow : terminal.buffer.active.cursorY; + const hiddenByWindow = window + ? relativeCursorRow < 0 || relativeCursorRow >= Math.max(0, Math.floor(window.rowCount)) + : false; + const hidden = Boolean(coreService?.isCursorHidden) || hiddenByWindow; + + return { + row: relativeCursorRow, + col: terminal.buffer.active.cursorX, + ...(hidden ? { hidden: true } : {}), + ...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}), + ...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}), + }; +} + +function extractInputModeState( + terminal: HeadlessTerminalInstance, +): NativeHeadlessTerminalInputModeState { + const coreService = (terminal as unknown as HeadlessTerminalWithCore)._core?.coreService; + return { + applicationCursorKeys: coreService?.decPrivateModes?.applicationCursorKeys === true, + bracketedPaste: coreService?.decPrivateModes?.bracketedPasteMode === true, + }; +} + +function extractState( + terminal: HeadlessTerminalInstance, + options: { scrollbackLines?: number }, +): TerminalState { + return { + rows: terminal.rows, + cols: terminal.cols, + grid: extractGrid(terminal), + scrollback: extractScrollback(terminal, options), + cursor: extractCursorState(terminal), + }; +} + +function extractViewportState( + terminal: HeadlessTerminalInstance, + coordinateEpoch: number, + oldestRow: number, + input?: TerminalViewportStateInput, +): TerminalViewportState { + const bounds = extractBufferBounds(terminal, coordinateEpoch, oldestRow); + const rowCount = input ? Math.max(0, Math.floor(input.rowCount)) : terminal.rows; + const firstRow = input ? input.firstRow : bounds.currentViewport.firstRow; + const window = extractBufferWindow(terminal, oldestRow, { startRow: firstRow, rowCount }); + return { + rows: window.rows.length, + cols: terminal.cols, + firstRow: window.startRow, + oldestRow: bounds.oldestRow, + newestRow: bounds.newestRow, + grid: window.rows, + cursor: extractCursorState( + terminal, + { + firstRow: window.startRow, + rowCount: window.rows.length, + }, + oldestRow, + ), + }; +} + +export function createNativeHeadlessTerminal( + options: NativeHeadlessTerminalOptions, +): NativeHeadlessTerminal { + const terminal = new HeadlessTerminal({ + rows: options.rows, + cols: options.cols, + scrollback: options.scrollbackLines ?? 1000, + allowProposedApi: true, + }); + const decoder = new TextDecoder(); + let coordinateEpoch = 0; + const coordinateStorage = new Map(); + + function getActiveCoordinateStorage(): TerminalCoordinateStorage { + const lines = activeBufferLines(terminal); + if (!lines) { + throw new Error("Native terminal buffer storage is unavailable"); + } + const existing = coordinateStorage.get(lines); + if (existing) { + return existing; + } + const storage: TerminalCoordinateStorage = { oldestRow: 0, trimSubscription: null }; + storage.trimSubscription = + lines.onTrim?.((amount) => { + storage.oldestRow += amount; + }) ?? null; + coordinateStorage.set(lines, storage); + return storage; + } + + getActiveCoordinateStorage(); + const bufferChangeSubscription = terminal.buffer.onBufferChange(() => { + coordinateEpoch += 1; + getActiveCoordinateStorage(); + }); + + return { + async write(data: NativeTerminalWriteData): Promise { + const text = typeof data === "string" ? data : decoder.decode(data, { stream: true }); + if (text.length === 0) { + return; + } + await new Promise((resolve) => { + terminal.write(text, () => { + resolve(); + }); + }); + }, + getState(): TerminalState { + return extractState(terminal, { scrollbackLines: options.scrollbackLines }); + }, + getViewportState(input?: TerminalViewportStateInput): TerminalViewportState { + return extractViewportState( + terminal, + coordinateEpoch, + getActiveCoordinateStorage().oldestRow, + input, + ); + }, + getInputModeState(): NativeHeadlessTerminalInputModeState { + return extractInputModeState(terminal); + }, + getBufferBounds(): TerminalBufferBounds { + return extractBufferBounds(terminal, coordinateEpoch, getActiveCoordinateStorage().oldestRow); + }, + getBufferWindow(input: TerminalBufferWindowInput): TerminalBufferWindow { + return extractBufferWindow(terminal, getActiveCoordinateStorage().oldestRow, input); + }, + resize(input: { rows: number; cols: number }): void { + coordinateEpoch += 1; + terminal.resize(input.cols, input.rows); + }, + reset(): void { + coordinateEpoch += 1; + decoder.decode(); + terminal.reset(); + getActiveCoordinateStorage().oldestRow = 0; + }, + dispose(): void { + bufferChangeSubscription.dispose(); + for (const storage of coordinateStorage.values()) { + storage.trimSubscription?.dispose(); + } + terminal.dispose(); + }, + }; +} diff --git a/packages/app/src/terminal/native-renderer/native-terminal-benchmark-payload.ts b/packages/app/src/terminal/native-renderer/native-terminal-benchmark-payload.ts new file mode 100644 index 0000000000..93e311cad1 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/native-terminal-benchmark-payload.ts @@ -0,0 +1,25 @@ +const DEFAULT_COLS = 120; + +export interface NativeTerminalBenchmarkPayloadInput { + startLine: number; + lineCount: number; + cols?: number; +} + +function terminalLine(input: { index: number; cols: number }): string { + const color = 31 + (input.index % 7); + const label = `line ${input.index.toString().padStart(6, "0")} `; + const body = "abcdefghijklmnopqrstuvwxyz0123456789 ".repeat(4); + return `\x1b[${color}m${(label + body).slice(0, input.cols - 1)}\x1b[0m\r\n`; +} + +export function createNativeTerminalBenchmarkPayload( + input: NativeTerminalBenchmarkPayloadInput, +): string { + const cols = input.cols ?? DEFAULT_COLS; + let payload = ""; + for (let index = 0; index < input.lineCount; index += 1) { + payload += terminalLine({ index: input.startLine + index, cols }); + } + return payload; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts new file mode 100644 index 0000000000..32e7fd7746 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveTerminalGridMetricsMeasurement, + resolveMeasuredTerminalCellMetrics, + resolveTerminalCursorOffset, +} from "./terminal-grid-metrics"; + +describe("native terminal grid metrics", () => { + it("preserves fractional measured cell width for horizontal layout", () => { + expect( + resolveMeasuredTerminalCellMetrics({ + measuredTextWidth: 72.246, + measuredTextHeight: 16.2, + measureTextLength: 10, + roundToNearestPixel: (value) => value, + }), + ).toEqual({ + cellWidth: 7.2246, + cellHeight: 16.2, + }); + }); + + it.each([1, 2, 2.75, 3, 3.5])( + "aligns cells to the physical pixel grid at density %s", + (density) => { + const metrics = resolveMeasuredTerminalCellMetrics({ + measuredTextWidth: 72.246, + measuredTextHeight: 16.2, + measureTextLength: 10, + roundToNearestPixel: (value) => Math.round(value * density) / density, + }); + + expect(metrics.cellWidth * density).toBeCloseTo(Math.round(metrics.cellWidth * density), 10); + expect(metrics.cellHeight * density).toBeCloseTo( + Math.round(metrics.cellHeight * density), + 10, + ); + }, + ); + + it("positions the cursor with the measured fractional cell width", () => { + expect( + resolveTerminalCursorOffset({ + cursorCol: 80, + cursorRow: 3, + metrics: { cellWidth: 7.2246, cellHeight: 16 }, + }), + ).toEqual({ + x: 577.968, + y: 48, + }); + }); + + it("emits measured metrics once until the measurement changes", () => { + const measured = { cellWidth: 7.2, cellHeight: 16 }; + + expect(resolveTerminalGridMetricsMeasurement(null, measured)).toEqual(measured); + expect(resolveTerminalGridMetricsMeasurement(measured, measured)).toBeNull(); + expect( + resolveTerminalGridMetricsMeasurement(measured, { + cellWidth: 7.3, + cellHeight: 16, + }), + ).toEqual({ cellWidth: 7.3, cellHeight: 16 }); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts new file mode 100644 index 0000000000..5a8a1b0b3c --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts @@ -0,0 +1,55 @@ +export interface TerminalGridCellMetrics { + cellWidth: number; + cellHeight: number; +} + +export interface TerminalGridCellMetricsInput { + measuredTextWidth: number; + measuredTextHeight: number; + measureTextLength: number; + roundToNearestPixel: (value: number) => number; +} + +export interface TerminalCursorOffsetInput { + cursorCol: number; + cursorRow: number; + metrics: TerminalGridCellMetrics; +} + +export interface TerminalCursorOffset { + x: number; + y: number; +} + +export function resolveMeasuredTerminalCellMetrics( + input: TerminalGridCellMetricsInput, +): TerminalGridCellMetrics { + const textLength = Math.max(1, input.measureTextLength); + return { + cellWidth: snapCellMetric(input.measuredTextWidth / textLength, input.roundToNearestPixel), + cellHeight: snapCellMetric(input.measuredTextHeight, input.roundToNearestPixel), + }; +} + +export function resolveTerminalGridMetricsMeasurement( + previous: TerminalGridCellMetrics | null, + next: TerminalGridCellMetrics, +): TerminalGridCellMetrics | null { + if (previous?.cellWidth === next.cellWidth && previous.cellHeight === next.cellHeight) { + return null; + } + return next; +} + +export function resolveTerminalCursorOffset( + input: TerminalCursorOffsetInput, +): TerminalCursorOffset { + return { + x: input.cursorCol * input.metrics.cellWidth, + y: input.cursorRow * input.metrics.cellHeight, + }; +} + +function snapCellMetric(value: number, roundToNearestPixel: (value: number) => number): number { + return Math.max(1, roundToNearestPixel(value)); +} diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx new file mode 100644 index 0000000000..b9f24f7376 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx @@ -0,0 +1,393 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { + PixelRatio, + Text, + View, + type LayoutChangeEvent, + type StyleProp, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { ITheme } from "@xterm/xterm"; + +import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME } from "./colors"; +import { resolveNativeTerminalFontFamily } from "./font.native"; +import type { TerminalViewportState } from "./headless-terminal-state"; +import { + resolveMeasuredTerminalCellMetrics, + resolveTerminalGridMetricsMeasurement, + resolveTerminalCursorOffset, + type TerminalGridCellMetrics, +} from "./terminal-grid-metrics"; +import { buildRows, type TerminalRowModel, type TerminalRun } from "./terminal-row-model"; +import { + resolveTerminalSelectionRects, + type TerminalSelectionRange, + type TerminalSelectionRect, +} from "./terminal-selection"; + +const MEASURE_TEXT = "mmmmmmmmmm"; +const DEFAULT_FONT_SIZE = 12; +const INITIAL_CELL_WIDTH_RATIO = 0.62; +const INITIAL_CELL_HEIGHT_RATIO = 1.35; + +interface CellMetrics { + cellWidth: number; + cellHeight: number; +} + +interface TerminalGridViewport { + width: number; + height: number; +} + +interface TerminalGridRowProps { + row: TerminalRowModel; + cellWidth: number; + cellHeight: number; + fontFamily?: string; + fontSize: number; + styleEpoch: string; +} + +interface TerminalGridRunProps { + run: TerminalRun; + cellWidth: number; + cellHeight: number; + textStyle: StyleProp; +} + +interface TerminalGridSelectionRectProps { + rect: TerminalSelectionRect; + color: string; +} + +export interface TerminalGridViewProps { + state: TerminalViewportState; + xtermTheme?: ITheme; + fontFamily?: string; + fontSize?: number; + style?: StyleProp; + selection?: TerminalSelectionRange | null; + onCellMetricsChange?: (metrics: TerminalGridCellMetrics) => void; +} + +function estimateCellMetrics(fontSize: number): CellMetrics { + return { + cellWidth: snapPixel(fontSize * INITIAL_CELL_WIDTH_RATIO), + cellHeight: snapPixel(fontSize * INITIAL_CELL_HEIGHT_RATIO), + }; +} + +function snapPixel(value: number): number { + return Math.max(1, PixelRatio.roundToNearestPixel(value)); +} + +function resolveVisibleCols(input: { + viewportWidth: number; + cellWidth: number; + gridCols: number; +}): number { + if (input.viewportWidth <= 0 || input.cellWidth <= 0 || input.gridCols <= 0) { + return 0; + } + return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth))); +} + +function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGridRunProps) { + const runStyle = useMemo>( + () => [ + styles.run, + { + backgroundColor: run.style.backgroundColor, + height: cellHeight, + width: run.cellCount * cellWidth, + }, + ], + [cellHeight, cellWidth, run.cellCount, run.style.backgroundColor], + ); + const runTextStyle = useMemo>( + () => [textStyle, run.style], + [run.style, textStyle], + ); + + return ( + + + {run.text} + + + ); +} + +const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => { + return ( + previous.run === next.run && + previous.cellWidth === next.cellWidth && + previous.cellHeight === next.cellHeight && + previous.textStyle === next.textStyle + ); +}); + +function TerminalGridSelectionRect({ rect, color }: TerminalGridSelectionRectProps) { + const rectStyle = useMemo>( + () => [ + styles.selectionRect, + { + backgroundColor: color, + height: rect.height, + transform: [{ translateX: rect.x }, { translateY: rect.y }], + width: rect.width, + }, + ], + [color, rect.height, rect.width, rect.x, rect.y], + ); + + return ; +} + +const MemoTerminalGridSelectionRect = memo(TerminalGridSelectionRect, (previous, next) => { + return previous.rect === next.rect && previous.color === next.color; +}); + +function TerminalGridRow({ + row, + cellWidth, + cellHeight, + fontFamily, + fontSize, +}: TerminalGridRowProps) { + const rowStyle = useMemo>( + () => [styles.row, { height: cellHeight }], + [cellHeight], + ); + const textStyle = useMemo>( + () => [ + styles.rowText, + { + height: cellHeight, + lineHeight: cellHeight, + fontFamily, + fontSize, + }, + ], + [cellHeight, fontFamily, fontSize], + ); + const accessibilityLabel = useMemo( + () => + row.runs + .map((run) => run.text) + .join("") + .trimEnd(), + [row.runs], + ); + + return ( + 0} + style={rowStyle} + testID={`terminal-row-${row.index}`} + > + {row.runs.map((run) => ( + + ))} + + ); +} + +const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => { + return ( + previous.row.hash === next.row.hash && + previous.cellWidth === next.cellWidth && + previous.cellHeight === next.cellHeight && + previous.fontFamily === next.fontFamily && + previous.fontSize === next.fontSize && + previous.styleEpoch === next.styleEpoch + ); +}); + +export function TerminalGridView({ + state, + xtermTheme = DEFAULT_TERMINAL_THEME, + fontFamily, + fontSize = DEFAULT_FONT_SIZE, + style, + selection = null, + onCellMetricsChange, +}: TerminalGridViewProps) { + const [metrics, setMetrics] = useState(() => estimateCellMetrics(fontSize)); + const measuredMetricsRef = useRef(null); + const [viewport, setViewport] = useState(null); + const resolvedFontFamily = useMemo( + () => resolveNativeTerminalFontFamily(fontFamily), + [fontFamily], + ); + const resolver = useMemo(() => createTerminalCellStyleResolver(xtermTheme), [xtermTheme]); + const visibleCols = useMemo(() => { + const viewportWidth = viewport?.width ?? state.cols * metrics.cellWidth; + return resolveVisibleCols({ + viewportWidth, + cellWidth: metrics.cellWidth, + gridCols: state.cols, + }); + }, [metrics.cellWidth, state.cols, viewport?.width]); + const projectedGrid = useMemo( + () => state.grid.map((row) => row.slice(0, visibleCols)), + [state.grid, visibleCols], + ); + const rows = useMemo( + () => buildRows({ grid: projectedGrid, resolver }), + [projectedGrid, resolver], + ); + const selectionRects = useMemo( + () => + resolveTerminalSelectionRects({ + selection, + viewport: { + firstRow: state.firstRow, + rows: state.grid.length, + cols: visibleCols, + }, + metrics, + }), + [metrics, selection, state.firstRow, state.grid.length, visibleCols], + ); + const selectionColor = xtermTheme.selectionBackground ?? "rgba(90, 160, 255, 0.35)"; + + const containerStyle = useMemo>( + () => [styles.root, { backgroundColor: resolver.backgroundColor }, style], + [resolver.backgroundColor, style], + ); + const gridStyle = useMemo>( + () => [ + styles.grid, + { + width: visibleCols * metrics.cellWidth, + height: state.grid.length * metrics.cellHeight, + }, + ], + [metrics.cellHeight, metrics.cellWidth, state.grid.length, visibleCols], + ); + const measureStyle = useMemo>( + () => [styles.measureText, { fontFamily: resolvedFontFamily, fontSize }], + [resolvedFontFamily, fontSize], + ); + const cursorStyle = useMemo>(() => { + const cursorOffset = resolveTerminalCursorOffset({ + cursorCol: state.cursor.col, + cursorRow: state.cursor.row, + metrics, + }); + return [ + styles.cursor, + { + backgroundColor: resolver.cursorColor, + width: metrics.cellWidth, + height: metrics.cellHeight, + transform: [{ translateX: cursorOffset.x }, { translateY: cursorOffset.y }], + }, + ]; + }, [metrics, state.cursor.col, state.cursor.row, resolver.cursorColor]); + + const handleMeasure = useCallback( + (event: LayoutChangeEvent) => { + const nextMetrics = resolveMeasuredTerminalCellMetrics({ + measuredTextWidth: event.nativeEvent.layout.width, + measuredTextHeight: event.nativeEvent.layout.height, + measureTextLength: MEASURE_TEXT.length, + roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value), + }); + const changedMetrics = resolveTerminalGridMetricsMeasurement( + measuredMetricsRef.current, + nextMetrics, + ); + if (!changedMetrics) { + return; + } + measuredMetricsRef.current = changedMetrics; + setMetrics(changedMetrics); + onCellMetricsChange?.(changedMetrics); + }, + [onCellMetricsChange], + ); + + const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setViewport((current) => { + if (current?.width === width && current.height === height) { + return current; + } + return { width, height }; + }); + }, []); + + return ( + + + + {MEASURE_TEXT} + + {rows.map((row) => ( + + ))} + {selectionRects.map((rect) => ( + + ))} + {!state.cursor.hidden && } + + + ); +} + +const styles = StyleSheet.create({ + root: { + overflow: "hidden", + }, + grid: { + position: "relative", + }, + row: { + flexDirection: "row", + }, + run: { + overflow: "hidden", + }, + rowText: { + includeFontPadding: false, + margin: 0, + padding: 0, + }, + measureText: { + includeFontPadding: false, + opacity: 0, + position: "absolute", + }, + cursor: { + left: 0, + opacity: 0.45, + position: "absolute", + top: 0, + }, + selectionRect: { + left: 0, + opacity: 0.8, + position: "absolute", + top: 0, + }, +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-input-resize-policy.test.ts b/packages/app/src/terminal/native-renderer/terminal-input-resize-policy.test.ts new file mode 100644 index 0000000000..a35a56035c --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-input-resize-policy.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { shouldClaimNativeTerminalSize } from "./terminal-input-resize-policy"; + +describe("native terminal input resize policy", () => { + it("does not claim terminal size for text or virtual-key input", () => { + expect(shouldClaimNativeTerminalSize("text")).toEqual(false); + expect(shouldClaimNativeTerminalSize("key")).toEqual(false); + }); + + it("claims terminal size at focus and explicit keyboard/paste boundaries", () => { + expect(shouldClaimNativeTerminalSize("focus")).toEqual(true); + expect(shouldClaimNativeTerminalSize("showKeyboard")).toEqual(true); + expect(shouldClaimNativeTerminalSize("paste")).toEqual(true); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-input-resize-policy.ts b/packages/app/src/terminal/native-renderer/terminal-input-resize-policy.ts new file mode 100644 index 0000000000..0613853bfa --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-input-resize-policy.ts @@ -0,0 +1,5 @@ +export type NativeTerminalSizeClaimAction = "focus" | "key" | "paste" | "showKeyboard" | "text"; + +export function shouldClaimNativeTerminalSize(action: NativeTerminalSizeClaimAction): boolean { + return action !== "text" && action !== "key"; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts b/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts new file mode 100644 index 0000000000..cbfa46114e --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; + +import { + createTerminalTextInputState, + resolveTerminalInputFocusRequest, + TERMINAL_INPUT_CONTEXT_MENU_HIDDEN, + TERMINAL_INPUT_HITBOX_SIZE, +} from "./terminal-input.native"; +import { forwardNativeTerminalKey, type NativeTerminalKeyEvent } from "./terminal-key-events"; + +describe("native terminal typed input", () => { + function inputData(change: { data: string; shouldClear: boolean }): string { + return change.data; + } + + it("dispatches appended text once and ignores reset clears", () => { + const input = createTerminalTextInputState(); + + expect(inputData(input.receiveTextChange("h"))).toEqual("h"); + expect(inputData(input.receiveTextChange("hi"))).toEqual("i"); + + input.reset(); + + expect(input.receiveTextChange("")).toEqual({ data: "", shouldClear: false }); + expect(inputData(input.receiveTextChange(" there"))).toEqual(" there"); + }); + + it("dispatches single-line multi-character inserts as one text payload", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("npm run typecheck")).toEqual({ + data: "npm run typecheck", + shouldClear: false, + }); + }); + + it("dispatches printable keypresses when focused input does not report text changes", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveKeyPress("p")).toEqual({ data: "p", shouldClear: false }); + expect(input.receiveKeyPress("w")).toEqual({ data: "w", shouldClear: false }); + expect(input.receiveKeyPress("d")).toEqual({ data: "d", shouldClear: false }); + expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); + }); + + it("does not duplicate printable keypresses when the native text change also arrives", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveKeyPress("p")).toEqual({ data: "p", shouldClear: false }); + expect(input.receiveTextChange("p")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveKeyPress("w")).toEqual({ data: "w", shouldClear: false }); + expect(input.receiveTextChange("pw")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveKeyPress("d")).toEqual({ data: "d", shouldClear: false }); + expect(input.receiveTextChange("pwd")).toEqual({ data: "", shouldClear: false }); + }); + + it("keeps anticipated text aligned after software keyboard Backspace", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveKeyPress("a")).toEqual({ data: "a", shouldClear: false }); + expect(input.receiveKeyPress("b")).toEqual({ data: "b", shouldClear: false }); + expect(input.receiveKeyPress("c")).toEqual({ data: "c", shouldClear: false }); + expect(input.receiveKeyPress("Backspace")).toEqual({ data: "\x7f", shouldClear: false }); + expect(input.receiveTextChange("ab")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveKeyPress("X")).toEqual({ data: "X", shouldClear: false }); + expect(input.receiveTextChange("abX")).toEqual({ data: "", shouldClear: false }); + }); + + it("does not dispatch raw multiline hidden-input paste", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("printf one\nprintf two")).toEqual({ + data: "", + shouldClear: true, + }); + }); + + it("does not dispatch raw carriage-return hidden-input paste", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("printf one\rprintf two")).toEqual({ + data: "", + shouldClear: true, + }); + }); + + it("does not translate replacement edits into terminal text", () => { + const input = createTerminalTextInputState(); + + expect(inputData(input.receiveTextChange("abc"))).toEqual("abc"); + expect(input.receiveTextChange("aXc")).toEqual({ data: "", shouldClear: false }); + }); + + it("clears accumulated hidden input text after terminal submit", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("echo hello")).toEqual({ + data: "echo hello", + shouldClear: false, + }); + expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); + + input.reset(); + + expect(input.receiveTextChange("")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveTextChange("y")).toEqual({ data: "y", shouldClear: false }); + }); + + it("ignores late newline text change after Return keypress submits", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("echo hello")).toEqual({ + data: "echo hello", + shouldClear: false, + }); + expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); + + input.reset(); + + expect(input.receiveTextChange("echo hello\n")).toEqual({ data: "", shouldClear: false }); + }); + + it("keeps long paste-like input intact until terminal submit", () => { + const input = createTerminalTextInputState(); + const longText = "x".repeat(512); + + expect(input.receiveTextChange(longText)).toEqual({ data: longText, shouldClear: false }); + expect(input.receiveTextChange(`${longText}y`)).toEqual({ data: "y", shouldClear: false }); + }); + + it("translates software keyboard terminal control keys", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveKeyPress("Backspace")).toEqual({ data: "\x7f", shouldClear: false }); + expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); + expect(input.receiveKeyPress("Return")).toEqual({ data: "\r", shouldClear: true }); + expect(input.receiveKeyPress("return")).toEqual({ data: "\r", shouldClear: true }); + expect(input.receiveTextChange("hello\n")).toEqual({ data: "", shouldClear: true }); + }); + + it("emits semantic terminal key events for native arrow keypresses", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveKeyPress("ArrowUp")).toEqual({ + data: "", + key: "ArrowUp", + shouldClear: false, + }); + }); + + it("forwards native arrow key events to the terminal key path", () => { + const events: NativeTerminalKeyEvent[] = []; + + forwardNativeTerminalKey({ + key: "ArrowUp", + onTerminalKey: (event) => { + events.push(event); + }, + }); + + expect(events).toEqual([ + { + key: "ArrowUp", + ctrl: false, + shift: false, + alt: false, + meta: false, + }, + ]); + }); + + it("keeps the hidden input from owning terminal long-press selection", () => { + expect({ + contextMenuHidden: TERMINAL_INPUT_CONTEXT_MENU_HIDDEN, + hitboxSize: TERMINAL_INPUT_HITBOX_SIZE, + }).toEqual({ + contextMenuHidden: true, + hitboxSize: 1, + }); + }); + + it("refocuses an already-focused hidden input without relying on software-keyboard state", () => { + expect(resolveTerminalInputFocusRequest({ isInputFocused: true })).toEqual("refocus"); + expect(resolveTerminalInputFocusRequest({ isInputFocused: false })).toEqual("focus"); + }); + + it("accepts fresh printable text after keyboard-hide reset and tap refocus", () => { + const input = createTerminalTextInputState(); + + expect(input.receiveTextChange("echo stale")).toEqual({ + data: "echo stale", + shouldClear: false, + }); + + input.reset(); + + expect(input.receiveTextChange("printf 'M1_TAP_OK\\n'")).toEqual({ + data: "printf 'M1_TAP_OK\\n'", + shouldClear: false, + }); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-input.native.tsx b/packages/app/src/terminal/native-renderer/terminal-input.native.tsx new file mode 100644 index 0000000000..24932c373e --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-input.native.tsx @@ -0,0 +1,302 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { + StyleSheet, + TextInput, + type NativeSyntheticEvent, + type StyleProp, + type TextInputKeyPressEventData, + type TextStyle, +} from "react-native"; +import { resolveNativeTerminalKey, type NativeTerminalKey } from "./terminal-key-events"; + +export const TERMINAL_INPUT_CONTEXT_MENU_HIDDEN = true; +export const TERMINAL_INPUT_HITBOX_SIZE = 1; + +export interface TerminalTextInputChange { + data: string; + key?: NativeTerminalKey; + shouldClear: boolean; +} + +export interface TerminalTextInputState { + receiveKeyPress: (key: string) => TerminalTextInputChange; + receiveTextChange: (text: string) => TerminalTextInputChange; + reset: () => void; +} + +export type TerminalInputFocusRequest = "focus" | "refocus"; + +export interface TerminalInputHandle { + focus: () => void; + showKeyboard: () => void; + blur: () => void; +} + +interface TerminalInputProps { + onFocus?: () => void; + onInput?: (data: string) => void; + onTerminalKey?: (key: NativeTerminalKey) => void; + style?: StyleProp; +} + +function isPrintableKey(key: string): boolean { + return key.length === 1 && key >= " " && key !== "\x7f"; +} + +export function resolveTerminalInputFocusRequest(input: { + isInputFocused: boolean; +}): TerminalInputFocusRequest { + return input.isInputFocused ? "refocus" : "focus"; +} + +export function createTerminalTextInputState(): TerminalTextInputState { + let previousText = ""; + let submittedText: string | null = null; + + return { + receiveKeyPress(key: string): TerminalTextInputChange { + const terminalKey = resolveNativeTerminalKey(key); + if (terminalKey) { + return { data: "", key: terminalKey, shouldClear: false }; + } + if (key === "Backspace") { + previousText = previousText.slice(0, -1); + return { data: "\x7f", shouldClear: false }; + } + if (key === "Enter" || key === "Return" || key === "return") { + submittedText = previousText; + return { data: "\r", shouldClear: true }; + } + if (isPrintableKey(key)) { + previousText += key; + return { data: key, shouldClear: false }; + } + return { data: "", shouldClear: false }; + }, + receiveTextChange(text: string): TerminalTextInputChange { + if (submittedText !== null) { + const lateSubmitText = `${submittedText}\n`; + submittedText = null; + if (text === lateSubmitText) { + previousText = ""; + return { data: "", shouldClear: false }; + } + } + + if (text.length === 0) { + previousText = ""; + return { data: "", shouldClear: false }; + } + + if (text.includes("\n") || text.includes("\r")) { + previousText = ""; + return { data: "", shouldClear: true }; + } + + if (!text.startsWith(previousText)) { + previousText = text; + return { data: "", shouldClear: false }; + } + + const appendedText = text.slice(previousText.length); + previousText = text; + return { + data: appendedText, + shouldClear: false, + }; + }, + reset(): void { + previousText = ""; + }, + }; +} + +export const TerminalInput = forwardRef( + function TerminalInput({ onFocus, onInput, onTerminalKey, style }, ref) { + const inputRef = useRef(null); + const isFocusedRef = useRef(false); + const pendingFocusFrameRef = useRef(null); + const shouldRefocusAfterClearRef = useRef(false); + const inputState = useMemo(() => createTerminalTextInputState(), []); + const inputStyle = useMemo(() => [styles.input, style], [style]); + const [inputEpoch, setInputEpoch] = useState(0); + + const clearPendingFocus = useCallback(() => { + if (pendingFocusFrameRef.current === null) { + return; + } + cancelAnimationFrame(pendingFocusFrameRef.current); + pendingFocusFrameRef.current = null; + }, []); + + const resetNativeInput = useCallback(() => { + inputState.reset(); + inputRef.current?.clear(); + }, [inputState]); + + const showNativeKeyboard = useCallback(() => { + clearPendingFocus(); + const input = inputRef.current; + if (!input) { + return; + } + + input.blur(); + isFocusedRef.current = false; + resetNativeInput(); + input.focus(); + pendingFocusFrameRef.current = requestAnimationFrame(() => { + pendingFocusFrameRef.current = null; + inputRef.current?.focus(); + }); + }, [clearPendingFocus, resetNativeInput]); + + const blurNativeInput = useCallback(() => { + clearPendingFocus(); + inputRef.current?.blur(); + isFocusedRef.current = false; + resetNativeInput(); + }, [clearPendingFocus, resetNativeInput]); + + const focusNativeInput = useCallback(() => { + clearPendingFocus(); + const input = inputRef.current; + if (!input) { + return; + } + + const focusRequest = resolveTerminalInputFocusRequest({ + isInputFocused: isFocusedRef.current || input.isFocused(), + }); + if (focusRequest === "focus") { + input.focus(); + return; + } + + showNativeKeyboard(); + }, [clearPendingFocus, showNativeKeyboard]); + + useEffect(() => clearPendingFocus, [clearPendingFocus]); + + useEffect(() => { + if (!shouldRefocusAfterClearRef.current) { + return; + } + shouldRefocusAfterClearRef.current = false; + const frame = requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + return () => { + cancelAnimationFrame(frame); + }; + }, [inputEpoch]); + + useImperativeHandle( + ref, + () => ({ + focus: () => { + focusNativeInput(); + }, + showKeyboard: () => { + showNativeKeyboard(); + }, + blur: () => { + blurNativeInput(); + }, + }), + [blurNativeInput, focusNativeInput, showNativeKeyboard], + ); + + const handleFocus = useCallback(() => { + isFocusedRef.current = true; + onFocus?.(); + }, [onFocus]); + + const handleBlur = useCallback(() => { + isFocusedRef.current = false; + resetNativeInput(); + }, [resetNativeInput]); + + const handleChangeText = useCallback( + (text: string) => { + const change = inputState.receiveTextChange(text); + if (change.data.length > 0) { + onInput?.(change.data); + } + if (change.shouldClear) { + inputState.reset(); + shouldRefocusAfterClearRef.current = true; + setInputEpoch((current) => current + 1); + } + }, + [inputState, onInput], + ); + + const handleKeyPress = useCallback( + (event: NativeSyntheticEvent) => { + const change = inputState.receiveKeyPress(event.nativeEvent.key); + if (change.key) { + onTerminalKey?.(change.key); + } + if (change.data.length > 0) { + onInput?.(change.data); + } + if (change.shouldClear) { + inputState.reset(); + shouldRefocusAfterClearRef.current = true; + setInputEpoch((current) => current + 1); + } + }, + [inputState, onInput, onTerminalKey], + ); + + return ( + + ); + }, +); + +const styles = StyleSheet.create({ + input: { + backgroundColor: "transparent", + color: "transparent", + height: TERMINAL_INPUT_HITBOX_SIZE, + left: 0, + opacity: 0.01, + padding: 0, + position: "absolute", + top: 0, + width: TERMINAL_INPUT_HITBOX_SIZE, + }, +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-key-events.ts b/packages/app/src/terminal/native-renderer/terminal-key-events.ts new file mode 100644 index 0000000000..35b0a54f72 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-key-events.ts @@ -0,0 +1,40 @@ +export type NativeTerminalKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + +export interface NativeTerminalKeyEvent { + key: NativeTerminalKey; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; +} + +export type NativeTerminalKeyHandler = (event: NativeTerminalKeyEvent) => Promise | void; + +export function resolveNativeTerminalKey(key: string): NativeTerminalKey | null { + switch (key) { + case "ArrowUp": + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": + return key; + default: + return null; + } +} + +export function createNativeTerminalKeyEvent(key: NativeTerminalKey): NativeTerminalKeyEvent { + return { + key, + ctrl: false, + shift: false, + alt: false, + meta: false, + }; +} + +export function forwardNativeTerminalKey(input: { + key: NativeTerminalKey; + onTerminalKey?: NativeTerminalKeyHandler; +}): void { + input.onTerminalKey?.(createNativeTerminalKeyEvent(input.key)); +} diff --git a/packages/app/src/terminal/native-renderer/terminal-mount-policy.test.ts b/packages/app/src/terminal/native-renderer/terminal-mount-policy.test.ts new file mode 100644 index 0000000000..96907775fa --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-mount-policy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { resolveNativeTerminalMountAction } from "./terminal-mount-policy"; + +describe("native terminal mount policy", () => { + it("does not remount when a snapshot changes within the same stream", () => { + expect( + resolveNativeTerminalMountAction({ + mountedStreamKey: "terminal:1", + nextStreamKey: "terminal:1", + }), + ).toEqual("keep"); + }); + + it("mounts the first stream and remounts when the stream changes", () => { + expect( + resolveNativeTerminalMountAction({ + mountedStreamKey: null, + nextStreamKey: "terminal:1", + }), + ).toEqual("mount"); + expect( + resolveNativeTerminalMountAction({ + mountedStreamKey: "terminal:1", + nextStreamKey: "terminal:2", + }), + ).toEqual("mount"); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-mount-policy.ts b/packages/app/src/terminal/native-renderer/terminal-mount-policy.ts new file mode 100644 index 0000000000..51fe506d90 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-mount-policy.ts @@ -0,0 +1,8 @@ +export type NativeTerminalMountAction = "keep" | "mount"; + +export function resolveNativeTerminalMountAction(input: { + mountedStreamKey: string | null; + nextStreamKey: string; +}): NativeTerminalMountAction { + return input.mountedStreamKey === input.nextStreamKey ? "keep" : "mount"; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-performance.ts b/packages/app/src/terminal/native-renderer/terminal-performance.ts new file mode 100644 index 0000000000..5e21f883d8 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-performance.ts @@ -0,0 +1,3 @@ +export function nativeTerminalPerformanceNow(): number { + return globalThis.performance?.now?.() ?? Date.now(); +} diff --git a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.test.ts b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.test.ts new file mode 100644 index 0000000000..52e920c90e --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTerminalRendererSelection } from "./terminal-renderer-capability"; + +describe("terminal renderer capability", () => { + it("requires input mode replay before attaching the native renderer", () => { + expect( + resolveTerminalRendererSelection({ + useLegacyTerminalRenderer: false, + supportsTerminalInputModeReplay: false, + }), + ).toBe("update-host"); + }); + + it("allows the native renderer when the daemon advertises input mode replay", () => { + expect( + resolveTerminalRendererSelection({ + useLegacyTerminalRenderer: false, + supportsTerminalInputModeReplay: true, + }), + ).toBe("native-grid"); + }); + + it("keeps the explicit legacy renderer available with older daemons", () => { + expect( + resolveTerminalRendererSelection({ + useLegacyTerminalRenderer: true, + supportsTerminalInputModeReplay: false, + }), + ).toBe("webview"); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts new file mode 100644 index 0000000000..5c5c7ca608 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts @@ -0,0 +1,11 @@ +export type TerminalRendererSelection = "native-grid" | "webview" | "update-host"; + +export function resolveTerminalRendererSelection(input: { + useLegacyTerminalRenderer: boolean; + supportsTerminalInputModeReplay: boolean; +}): TerminalRendererSelection { + if (input.useLegacyTerminalRenderer) { + return "webview"; + } + return input.supportsTerminalInputModeReplay ? "native-grid" : "update-host"; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-resize-policy.test.ts b/packages/app/src/terminal/native-renderer/terminal-resize-policy.test.ts new file mode 100644 index 0000000000..42df5afb8f --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-resize-policy.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from "vitest"; + +import { createTerminalResizePolicy, updateTerminalResizePolicy } from "./terminal-resize-policy"; + +describe("terminal resize policy", () => { + test("passive first layout records measured size without claiming terminal size", () => { + const initial = createTerminalResizePolicy({ rows: 24, cols: 80 }); + + const result = updateTerminalResizePolicy(initial, { + source: "measure", + size: { rows: 41, cols: 48 }, + }); + + expect(result).toEqual({ + state: { + measuredSize: { rows: 41, cols: 48 }, + claimedSize: null, + lastClaimToken: null, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 41, cols: 48 }, + resizeClaim: null, + measuredSizeChanged: true, + }); + }); + + test("focus or explicit ownership claims the measured size", () => { + const measured = updateTerminalResizePolicy( + createTerminalResizePolicy({ rows: 24, cols: 80 }), + { + source: "measure", + size: { rows: 41, cols: 48 }, + }, + ); + + const result = updateTerminalResizePolicy(measured.state, { + source: "claim", + size: { rows: 41, cols: 48 }, + }); + + expect(result).toEqual({ + state: { + measuredSize: { rows: 41, cols: 48 }, + claimedSize: { rows: 41, cols: 48 }, + lastClaimToken: null, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 41, cols: 48 }, + resizeClaim: { rows: 41, cols: 48, force: false }, + measuredSizeChanged: false, + }); + }); + + test("focus or explicit ownership before measurement claims the next measured size", () => { + const pendingClaim = updateTerminalResizePolicy( + createTerminalResizePolicy({ rows: 24, cols: 80 }), + { + source: "claim", + size: null, + }, + ); + + const result = updateTerminalResizePolicy(pendingClaim.state, { + source: "measure", + size: { rows: 41, cols: 48 }, + }); + + expect(pendingClaim).toEqual({ + state: { + measuredSize: { rows: 24, cols: 80 }, + claimedSize: null, + lastClaimToken: null, + pendingClaim: true, + pendingForceClaim: false, + }, + measuredSize: { rows: 24, cols: 80 }, + resizeClaim: null, + measuredSizeChanged: false, + }); + expect(result).toEqual({ + state: { + measuredSize: { rows: 41, cols: 48 }, + claimedSize: { rows: 41, cols: 48 }, + lastClaimToken: null, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 41, cols: 48 }, + resizeClaim: { rows: 41, cols: 48, force: false }, + measuredSizeChanged: true, + }); + }); + + test("duplicate claimed sizes are deduped", () => { + const firstClaim = updateTerminalResizePolicy( + createTerminalResizePolicy({ rows: 24, cols: 80 }), + { + source: "claim", + size: { rows: 41, cols: 48 }, + claimToken: 1, + }, + ); + + const result = updateTerminalResizePolicy(firstClaim.state, { + source: "claim", + size: { rows: 41, cols: 48 }, + claimToken: 1, + }); + + expect(result).toEqual({ + state: { + measuredSize: { rows: 41, cols: 48 }, + claimedSize: { rows: 41, cols: 48 }, + lastClaimToken: 1, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 41, cols: 48 }, + resizeClaim: null, + measuredSizeChanged: false, + }); + }); + + test("explicit interaction can reclaim the same measured size", () => { + const firstClaim = updateTerminalResizePolicy( + createTerminalResizePolicy({ rows: 24, cols: 80 }), + { + source: "claim", + size: { rows: 21, cols: 55 }, + claimToken: 1, + }, + ); + + const result = updateTerminalResizePolicy(firstClaim.state, { + source: "claim", + size: { rows: 21, cols: 55 }, + claimToken: 2, + }); + + expect(result).toEqual({ + state: { + measuredSize: { rows: 21, cols: 55 }, + claimedSize: { rows: 21, cols: 55 }, + lastClaimToken: 2, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 21, cols: 55 }, + resizeClaim: { rows: 21, cols: 55, force: true }, + measuredSizeChanged: false, + }); + }); + + test("explicit interaction can reclaim after policy reset loses the previous claimed size", () => { + const resetPolicy = createTerminalResizePolicy({ rows: 21, cols: 55 }); + + const result = updateTerminalResizePolicy(resetPolicy, { + source: "claim", + size: { rows: 21, cols: 55 }, + claimToken: 2, + }); + + expect(result).toEqual({ + state: { + measuredSize: { rows: 21, cols: 55 }, + claimedSize: { rows: 21, cols: 55 }, + lastClaimToken: 2, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 21, cols: 55 }, + resizeClaim: { rows: 21, cols: 55, force: true }, + measuredSizeChanged: false, + }); + }); + + test("explicit interaction before measurement forces the next measured reclaim after policy reset", () => { + const pendingClaim = updateTerminalResizePolicy( + createTerminalResizePolicy({ rows: 21, cols: 55 }), + { + source: "claim", + size: null, + claimToken: 2, + }, + ); + + const result = updateTerminalResizePolicy(pendingClaim.state, { + source: "measure", + size: { rows: 21, cols: 55 }, + }); + + expect(result).toEqual({ + state: { + measuredSize: { rows: 21, cols: 55 }, + claimedSize: { rows: 21, cols: 55 }, + lastClaimToken: 2, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 21, cols: 55 }, + resizeClaim: { rows: 21, cols: 55, force: true }, + measuredSizeChanged: false, + }); + }); + + test("passive measurement of the same size still dedupes after a claim", () => { + const firstClaim = updateTerminalResizePolicy( + createTerminalResizePolicy({ rows: 24, cols: 80 }), + { + source: "claim", + size: { rows: 21, cols: 55 }, + claimToken: 1, + }, + ); + + const result = updateTerminalResizePolicy(firstClaim.state, { + source: "measure", + size: { rows: 21, cols: 55 }, + }); + + expect(result).toEqual({ + state: { + measuredSize: { rows: 21, cols: 55 }, + claimedSize: { rows: 21, cols: 55 }, + lastClaimToken: 1, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 21, cols: 55 }, + resizeClaim: null, + measuredSizeChanged: false, + }); + }); + + test("passive layout changes never reuse an earlier explicit claim", () => { + const firstClaim = updateTerminalResizePolicy( + createTerminalResizePolicy({ rows: 24, cols: 80 }), + { + source: "claim", + size: { rows: 41, cols: 48 }, + }, + ); + const duplicateLayout = updateTerminalResizePolicy(firstClaim.state, { + source: "measure", + size: { rows: 41, cols: 48 }, + }); + + const changedLayout = updateTerminalResizePolicy(duplicateLayout.state, { + source: "measure", + size: { rows: 43, cols: 50 }, + }); + + expect(duplicateLayout.resizeClaim).toEqual(null); + expect(changedLayout.resizeClaim).toEqual(null); + expect(changedLayout.measuredSize).toEqual({ rows: 43, cols: 50 }); + }); + + test("keyboard measurement stays passive until the interaction explicitly reclaims", () => { + const focused = updateTerminalResizePolicy(createTerminalResizePolicy({ rows: 24, cols: 80 }), { + source: "claim", + size: { rows: 41, cols: 53 }, + }); + + const keyboardOpen = updateTerminalResizePolicy(focused.state, { + source: "measure", + size: { rows: 26, cols: 53 }, + }); + + expect(keyboardOpen).toEqual({ + state: { + measuredSize: { rows: 26, cols: 53 }, + claimedSize: { rows: 41, cols: 53 }, + lastClaimToken: null, + pendingClaim: false, + pendingForceClaim: false, + }, + measuredSize: { rows: 26, cols: 53 }, + resizeClaim: null, + measuredSizeChanged: true, + }); + + const settledKeyboardClaim = updateTerminalResizePolicy(keyboardOpen.state, { + source: "claim", + size: { rows: 26, cols: 53 }, + claimToken: 1, + }); + + expect(settledKeyboardClaim.resizeClaim).toEqual({ rows: 26, cols: 53, force: true }); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-resize-policy.ts b/packages/app/src/terminal/native-renderer/terminal-resize-policy.ts new file mode 100644 index 0000000000..cb3b640c13 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-resize-policy.ts @@ -0,0 +1,119 @@ +export interface TerminalSize { + rows: number; + cols: number; +} + +export type TerminalResizeSource = "measure" | "claim"; + +export interface TerminalResizePolicyState { + measuredSize: TerminalSize; + claimedSize: TerminalSize | null; + lastClaimToken: number | null; + pendingClaim: boolean; + pendingForceClaim: boolean; +} + +export interface TerminalResizeClaim extends TerminalSize { + force: boolean; +} + +export interface TerminalResizePolicyInput { + source: TerminalResizeSource; + size: TerminalSize | null; + claimToken?: number; +} + +export interface TerminalResizePolicyResult { + state: TerminalResizePolicyState; + measuredSize: TerminalSize; + resizeClaim: TerminalResizeClaim | null; + measuredSizeChanged: boolean; +} + +export function createTerminalResizePolicy(initialSize: TerminalSize): TerminalResizePolicyState { + return { + measuredSize: initialSize, + claimedSize: null, + lastClaimToken: null, + pendingClaim: false, + pendingForceClaim: false, + }; +} + +export function updateTerminalResizePolicy( + state: TerminalResizePolicyState, + input: TerminalResizePolicyInput, +): TerminalResizePolicyResult { + if (!input.size) { + const claimRequested = isNewClaim(state, input); + const nextState = { + ...state, + lastClaimToken: nextClaimToken(state, input), + pendingClaim: state.pendingClaim || claimRequested, + pendingForceClaim: state.pendingForceClaim || hasNewClaimToken(state, input), + }; + + return { + state: nextState, + measuredSize: nextState.measuredSize, + resizeClaim: null, + measuredSizeChanged: false, + }; + } + + const measuredSizeChanged = !terminalSizesEqual(state.measuredSize, input.size); + const claimToken = nextClaimToken(state, input); + const forceReclaim = state.pendingForceClaim || hasNewClaimToken(state, input); + const shouldClaim = state.pendingClaim || isNewClaim(state, input); + const resizeClaim = shouldClaim ? { ...input.size, force: forceReclaim } : null; + const claimedSize = resizeClaim ? input.size : state.claimedSize; + const nextState = { + measuredSize: input.size, + claimedSize, + lastClaimToken: claimToken, + pendingClaim: shouldClaim ? false : state.pendingClaim, + pendingForceClaim: shouldClaim ? false : state.pendingForceClaim, + }; + + return { + state: nextState, + measuredSize: input.size, + resizeClaim, + measuredSizeChanged, + }; +} + +function terminalSizesEqual(left: TerminalSize | null, right: TerminalSize): boolean { + return left !== null && left.rows === right.rows && left.cols === right.cols; +} + +function nextClaimToken( + state: TerminalResizePolicyState, + input: TerminalResizePolicyInput, +): number | null { + if (input.source !== "claim") { + return state.lastClaimToken; + } + return input.claimToken ?? state.lastClaimToken; +} + +function hasNewClaimToken( + state: TerminalResizePolicyState, + input: TerminalResizePolicyInput, +): boolean { + if (input.source !== "claim") { + return false; + } + const claimToken = nextClaimToken(state, input); + return claimToken !== state.lastClaimToken; +} + +function isNewClaim(state: TerminalResizePolicyState, input: TerminalResizePolicyInput): boolean { + if (input.source !== "claim") { + return false; + } + if (input.claimToken !== undefined) { + return hasNewClaimToken(state, input); + } + return input.size === null || !terminalSizesEqual(state.claimedSize, input.size); +} diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts new file mode 100644 index 0000000000..d10fe6b361 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts @@ -0,0 +1,43 @@ +import type { TerminalCell } from "@getpaseo/protocol/messages"; +import { describe, expect, test } from "vitest"; + +import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME } from "./colors"; +import { buildRows } from "./terminal-row-model"; + +function cell( + char: string, + overrides: Partial & { width?: number } = {}, +): TerminalCell & { width?: number } { + return { char, ...overrides }; +} + +describe("terminal row model", () => { + test("preserves terminal cell width for styled runs and trailing spaces", () => { + const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); + + const rows = buildRows({ + grid: [ + [cell("n", { bg: 2, bgMode: 1 }), cell("v", { bg: 2, bgMode: 1 }), cell(" "), cell(" ")], + ], + resolver, + }); + + expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([ + { text: "nv", cellCount: 2 }, + { text: " ", cellCount: 2 }, + ]); + }); + + test("uses xterm's authoritative width instead of guessing from the code point", () => { + const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); + + const rows = buildRows({ + grid: [[cell("A", { width: 2 }), cell(" ", { width: 0 }), cell("界", { width: 1 })]], + resolver, + }); + + expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([ + { text: "A界", cellCount: 3 }, + ]); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.ts new file mode 100644 index 0000000000..c490957a89 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-row-model.ts @@ -0,0 +1,140 @@ +import type { TextStyle } from "react-native"; +import type { TerminalCell } from "@getpaseo/protocol/messages"; + +import type { TerminalCellStyleResolver } from "./colors"; + +const WIDE_CHAR_RANGES: ReadonlyArray = [ + [0x1100, 0x115f], + [0x2329, 0x232a], + [0x2e80, 0xa4cf], + [0xac00, 0xd7a3], + [0xf900, 0xfaff], + [0xfe10, 0xfe19], + [0xfe30, 0xfe6f], + [0xff00, 0xff60], + [0xffe0, 0xffe6], + [0x1f300, 0x1faff], +]; + +export interface TerminalRun { + key: string; + text: string; + cellCount: number; + styleKey: string; + style: TextStyle; +} + +export interface TerminalRowModel { + index: number; + hash: string; + runs: TerminalRun[]; +} + +type TerminalRenderableCell = TerminalCell & { width?: number }; + +function hashStringPart(hash: number, value: string): number { + let nextHash = hash; + for (let index = 0; index < value.length; index += 1) { + nextHash = Math.imul(nextHash ^ value.charCodeAt(index), 16777619); + } + return nextHash; +} + +function finishHash(hash: number): string { + return (hash >>> 0).toString(36); +} + +function terminalCharWidth(char: string): number { + const codePoint = char.codePointAt(0); + if (codePoint === undefined) return 1; + const isWide = WIDE_CHAR_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end); + return isWide ? 2 : 1; +} + +function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean { + if (terminalCharWidth(char) < 2) { + return false; + } + return cells[col + 1]?.char === " "; +} + +function terminalCellCount(cells: TerminalRenderableCell[], col: number, char: string): number { + const authoritativeWidth = cells[col]?.width; + if (authoritativeWidth !== undefined) { + return Math.max(1, authoritativeWidth); + } + if (shouldSkipSpacerCell(cells, col, char)) { + return 2; + } + return 1; +} + +function appendRun(input: { + runs: TerminalRun[]; + text: string; + cellCount: number; + styleKey: string; + style: TextStyle; + col: number; +}): void { + const previousRun = input.runs[input.runs.length - 1]; + if (previousRun && previousRun.styleKey === input.styleKey) { + previousRun.text += input.text; + previousRun.cellCount += input.cellCount; + return; + } + + input.runs.push({ + key: `${input.col}:${input.styleKey}`, + text: input.text, + cellCount: input.cellCount, + styleKey: input.styleKey, + style: input.style, + }); +} + +function buildRowModel(input: { + cells: TerminalRenderableCell[]; + index: number; + resolver: TerminalCellStyleResolver; +}): TerminalRowModel { + const runs: TerminalRun[] = []; + let hash = 2166136261; + + for (let col = 0; col < input.cells.length; col += 1) { + const cell = input.cells[col]; + const text = cell.char || " "; + const resolvedStyle = input.resolver.resolve(cell); + const cellCount = terminalCellCount(input.cells, col, text); + appendRun({ + runs, + text, + cellCount, + styleKey: resolvedStyle.key, + style: resolvedStyle.style, + col, + }); + hash = hashStringPart(hash, text); + hash = hashStringPart(hash, String(cellCount)); + hash = hashStringPart(hash, resolvedStyle.key); + + if (cellCount > 1) { + col += 1; + } + } + + return { + index: input.index, + hash: finishHash(hash), + runs, + }; +} + +export function buildRows(input: { + grid: TerminalRenderableCell[][]; + resolver: TerminalCellStyleResolver; +}): TerminalRowModel[] { + return input.grid.map((cells, index) => + buildRowModel({ cells, index, resolver: input.resolver }), + ); +} diff --git a/packages/app/src/terminal/native-renderer/terminal-screen-model.test.ts b/packages/app/src/terminal/native-renderer/terminal-screen-model.test.ts new file mode 100644 index 0000000000..65359c74c0 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-screen-model.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, test } from "vitest"; + +import { createNativeHeadlessTerminal, type TerminalCellRow } from "./headless-terminal-state"; +import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload"; +import { createNativeTerminalScreenModel } from "./terminal-screen-model"; + +const ROWS = 5; +const COLS = 80; + +function rowText(row: TerminalCellRow): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +function expectedBenchmarkLine(index: number): string { + const label = `line ${index.toString().padStart(6, "0")} `; + const body = "abcdefghijklmnopqrstuvwxyz0123456789 ".repeat(4); + return (label + body).slice(0, COLS - 1); +} + +function filledRows(rows: TerminalCellRow[]): string[] { + return rows.map(rowText).filter((text) => text.length > 0); +} + +function viewportRows(model: ReturnType) { + return filledRows(model.sync({ visibleRows: ROWS }).viewport.grid); +} + +async function createScrolledTerminal(lineCount: number) { + const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 }); + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }), + ); + return terminal; +} + +describe("native terminal screen model", () => { + test("scrolling up changes the visible window after output exceeds the viewport", async () => { + const terminal = await createScrolledTerminal(20); + const model = createNativeTerminalScreenModel({ terminal }); + const bottomRows = viewportRows(model); + + const scrolled = model.scrollUp({ rows: 3, visibleRows: ROWS }); + + expect({ + mode: scrolled.scroll.mode, + firstRowChanged: scrolled.scroll.firstRow < scrolled.scroll.bottomViewport.firstRow, + bottomRows, + rows: filledRows(scrolled.viewport.grid), + }).toEqual({ + mode: "scrolled", + firstRowChanged: true, + bottomRows: [16, 17, 18, 19].map(expectedBenchmarkLine), + rows: [13, 14, 15, 16, 17].map(expectedBenchmarkLine), + }); + }); + + test("following mode tracks the bottom when new output arrives", async () => { + const terminal = await createScrolledTerminal(10); + const model = createNativeTerminalScreenModel({ terminal }); + + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 10, lineCount: 5, cols: COLS }), + ); + const screen = model.sync({ visibleRows: ROWS }); + + expect({ + mode: screen.scroll.mode, + firstRow: screen.scroll.firstRow, + bottomFirstRow: screen.scroll.bottomViewport.firstRow, + rows: filledRows(screen.viewport.grid), + }).toEqual({ + mode: "following", + firstRow: screen.scroll.bottomViewport.firstRow, + bottomFirstRow: screen.scroll.bottomViewport.firstRow, + rows: [11, 12, 13, 14].map(expectedBenchmarkLine), + }); + }); + + test("scrolled mode preserves the user's first row when new output arrives", async () => { + const terminal = await createScrolledTerminal(20); + const model = createNativeTerminalScreenModel({ terminal }); + const before = model.scrollUp({ rows: 4, visibleRows: ROWS }); + + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 20, lineCount: 5, cols: COLS }), + ); + const after = model.sync({ visibleRows: ROWS }); + + expect({ + mode: after.scroll.mode, + firstRowBefore: before.scroll.firstRow, + firstRowAfter: after.scroll.firstRow, + rows: filledRows(after.viewport.grid), + }).toEqual({ + mode: "scrolled", + firstRowBefore: before.scroll.firstRow, + firstRowAfter: before.scroll.firstRow, + rows: [12, 13, 14, 15, 16].map(expectedBenchmarkLine), + }); + }); + + test("scrolled mode does not follow new output when scrollback is already bounded", async () => { + const terminal = await createScrolledTerminal(300); + const model = createNativeTerminalScreenModel({ terminal }); + const before = model.scrollUp({ rows: 18, visibleRows: ROWS }); + const beforeRows = filledRows(before.viewport.grid); + + await terminal.write( + createNativeTerminalBenchmarkPayload({ startLine: 300, lineCount: 5, cols: COLS }), + ); + const after = model.sync({ visibleRows: ROWS }); + const afterRows = filledRows(after.viewport.grid); + + expect({ + mode: after.scroll.mode, + topRowBefore: beforeRows[0], + topRowAfter: afterRows[0], + newOutputRows: [300, 301, 302, 303, 304] + .map(expectedBenchmarkLine) + .filter((line) => afterRows.includes(line)), + }).toEqual({ + mode: "scrolled", + topRowBefore: beforeRows[0], + topRowAfter: beforeRows[0], + newOutputRows: [], + }); + }); + + test("returning to bottom restores following", async () => { + const terminal = await createScrolledTerminal(20); + const model = createNativeTerminalScreenModel({ terminal }); + + model.scrollUp({ rows: 5, visibleRows: ROWS }); + const bottom = model.returnToBottom({ visibleRows: ROWS }); + + expect({ + mode: bottom.scroll.mode, + firstRow: bottom.scroll.firstRow, + bottomFirstRow: bottom.scroll.bottomViewport.firstRow, + rows: filledRows(bottom.viewport.grid), + }).toEqual({ + mode: "following", + firstRow: bottom.scroll.bottomViewport.firstRow, + bottomFirstRow: bottom.scroll.bottomViewport.firstRow, + rows: [16, 17, 18, 19].map(expectedBenchmarkLine), + }); + }); + + test("scroll boundaries clamp at the oldest and newest rows", async () => { + const terminal = await createScrolledTerminal(20); + const model = createNativeTerminalScreenModel({ terminal }); + + const oldest = model.scrollUp({ rows: 500, visibleRows: ROWS }); + const newest = model.scrollDown({ rows: 500, visibleRows: ROWS }); + + expect({ + oldest: { + mode: oldest.scroll.mode, + firstRow: oldest.scroll.firstRow, + oldestRow: oldest.scroll.oldestRow, + }, + newest: { + mode: newest.scroll.mode, + firstRow: newest.scroll.firstRow, + bottomFirstRow: newest.scroll.bottomViewport.firstRow, + }, + }).toEqual({ + oldest: { + mode: "scrolled", + firstRow: oldest.scroll.oldestRow, + oldestRow: oldest.scroll.oldestRow, + }, + newest: { + mode: "following", + firstRow: newest.scroll.bottomViewport.firstRow, + bottomFirstRow: newest.scroll.bottomViewport.firstRow, + }, + }); + }); + + test("repeated rows do not jump to an older matching anchor on consecutive slow scrolls", async () => { + const terminal = createNativeHeadlessTerminal({ + rows: ROWS, + cols: COLS, + scrollbackLines: 100, + }); + const output = Array.from({ length: 8 }, (_, blockIndex) => { + return `BLOCK_${blockIndex + 1}\r\n${"SAME\r\n".repeat(8)}`; + }).join(""); + await terminal.write(output); + const model = createNativeTerminalScreenModel({ terminal }); + + const first = model.scrollUp({ rows: 2, visibleRows: ROWS }); + const second = model.scrollUp({ rows: 2, visibleRows: ROWS }); + const third = model.scrollUp({ rows: 2, visibleRows: ROWS }); + + expect([first.scroll.firstRow, second.scroll.firstRow, third.scroll.firstRow]).toEqual([ + first.scroll.bottomViewport.firstRow - 2, + first.scroll.bottomViewport.firstRow - 4, + first.scroll.bottomViewport.firstRow - 6, + ]); + }); + + test("keeps a retained viewport anchored across a burst larger than the old search window", async () => { + const terminal = createNativeHeadlessTerminal({ + rows: ROWS, + cols: COLS, + scrollbackLines: 995, + }); + const initial = Array.from({ length: 999 }, (_, index) => + index === 600 ? "ANCHOR_600\r\n" : "SAME\r\n", + ).join(""); + await terminal.write(initial); + const model = createNativeTerminalScreenModel({ terminal }); + const bottom = model.sync({ visibleRows: ROWS }); + const before = model.scrollUp({ + rows: bottom.scroll.bottomViewport.firstRow - 600, + visibleRows: ROWS, + }); + + await terminal.write("SAME\r\n".repeat(300)); + const after = model.sync({ visibleRows: ROWS }); + + expect({ + firstRowBefore: before.scroll.firstRow, + firstRowAfter: after.scroll.firstRow, + firstVisibleRowBefore: rowText(before.viewport.grid[0] ?? []), + firstVisibleRowAfter: rowText(after.viewport.grid[0] ?? []), + }).toEqual({ + firstRowBefore: 600, + firstRowAfter: 600, + firstVisibleRowBefore: "ANCHOR_600", + firstVisibleRowAfter: "ANCHOR_600", + }); + }); + + test("reset while scrolled drops the old viewport before restored content is read", async () => { + const terminal = await createScrolledTerminal(20); + const model = createNativeTerminalScreenModel({ terminal }); + + model.scrollUp({ rows: 5, visibleRows: ROWS }); + terminal.reset(); + await terminal.write("restored one\r\nrestored two\r\n"); + model.reset(); + const restored = model.sync({ visibleRows: ROWS }); + + expect({ + mode: restored.scroll.mode, + rows: filledRows(restored.viewport.grid), + }).toEqual({ + mode: "following", + rows: ["restored one", "restored two"], + }); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-screen-model.ts b/packages/app/src/terminal/native-renderer/terminal-screen-model.ts new file mode 100644 index 0000000000..8eab364ff6 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-screen-model.ts @@ -0,0 +1,149 @@ +import type { + NativeHeadlessTerminal, + TerminalBufferBounds, + TerminalRowRange, + TerminalViewportState, +} from "./headless-terminal-state"; + +export type TerminalScrollMode = "following" | "scrolled"; + +export interface TerminalScrollSnapshot { + mode: TerminalScrollMode; + firstRow: number; + visibleRows: number; + oldestRow: number; + newestRow: number; + currentViewport: TerminalRowRange; + bottomViewport: TerminalRowRange; +} + +export interface TerminalScreenState { + viewport: TerminalViewportState; + scroll: TerminalScrollSnapshot; +} + +export interface TerminalScreenModel { + sync(input: TerminalScreenSyncInput): TerminalScreenState; + scrollUp(input: TerminalScreenScrollInput): TerminalScreenState; + scrollDown(input: TerminalScreenScrollInput): TerminalScreenState; + returnToBottom(input: TerminalScreenSyncInput): TerminalScreenState; + reset(): void; +} + +export interface TerminalScreenModelOptions { + terminal: NativeHeadlessTerminal; +} + +export interface TerminalScreenSyncInput { + visibleRows: number; +} + +export interface TerminalScreenScrollInput extends TerminalScreenSyncInput { + rows: number; +} + +interface TerminalScrollState { + mode: TerminalScrollMode; + firstRow: number; +} + +export function createNativeTerminalScreenModel( + options: TerminalScreenModelOptions, +): TerminalScreenModel { + let scrollState: TerminalScrollState = { mode: "following", firstRow: 0 }; + + function render(input: TerminalScreenSyncInput): TerminalScreenState { + const bounds = options.terminal.getBufferBounds(); + const visibleRows = resolveVisibleRows({ requestedRows: input.visibleRows, bounds }); + const bottomViewport = resolveBottomViewport({ bounds, visibleRows }); + const hasScrollableHistory = bounds.oldestRow < bottomViewport.firstRow; + const mode = hasScrollableHistory ? scrollState.mode : "following"; + const firstRow = + mode === "following" + ? bottomViewport.firstRow + : clamp(scrollState.firstRow, bounds.oldestRow, bottomViewport.firstRow); + const viewport = options.terminal.getViewportState({ firstRow, rowCount: visibleRows }); + scrollState = { + mode, + firstRow, + }; + + return { + viewport, + scroll: { + mode, + firstRow, + visibleRows, + oldestRow: bounds.oldestRow, + newestRow: bounds.newestRow, + currentViewport: rowRange({ firstRow, visibleRows, newestRow: bounds.newestRow }), + bottomViewport, + }, + }; + } + + function scrollBy(input: TerminalScreenScrollInput & { direction: -1 | 1 }): TerminalScreenState { + const screen = render(input); + const rowDelta = Math.max(0, Math.floor(input.rows)) * input.direction; + const nextFirstRow = clamp( + screen.scroll.firstRow + rowDelta, + screen.scroll.oldestRow, + screen.scroll.bottomViewport.firstRow, + ); + const mode = nextFirstRow >= screen.scroll.bottomViewport.firstRow ? "following" : "scrolled"; + scrollState = { mode, firstRow: nextFirstRow }; + return render(input); + } + + return { + sync: render, + scrollUp(input: TerminalScreenScrollInput): TerminalScreenState { + return scrollBy({ ...input, direction: -1 }); + }, + scrollDown(input: TerminalScreenScrollInput): TerminalScreenState { + return scrollBy({ ...input, direction: 1 }); + }, + returnToBottom(input: TerminalScreenSyncInput): TerminalScreenState { + scrollState = { mode: "following", firstRow: 0 }; + return render(input); + }, + reset(): void { + scrollState = { mode: "following", firstRow: 0 }; + }, + }; +} + +function resolveVisibleRows(input: { + requestedRows: number; + bounds: TerminalBufferBounds; +}): number { + const availableRows = input.bounds.newestRow - input.bounds.oldestRow + 1; + const requestedRows = Math.floor(input.requestedRows); + if (requestedRows <= 0) { + return Math.min(input.bounds.rows, availableRows); + } + return clamp(requestedRows, 1, availableRows); +} + +function resolveBottomViewport(input: { + bounds: TerminalBufferBounds; + visibleRows: number; +}): TerminalRowRange { + const firstRow = Math.max(input.bounds.oldestRow, input.bounds.newestRow - input.visibleRows + 1); + return rowRange({ firstRow, visibleRows: input.visibleRows, newestRow: input.bounds.newestRow }); +} + +function rowRange(input: { + firstRow: number; + visibleRows: number; + newestRow: number; +}): TerminalRowRange { + return { + firstRow: input.firstRow, + lastRow: Math.min(input.newestRow, input.firstRow + input.visibleRows - 1), + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts b/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts new file mode 100644 index 0000000000..3464bcd973 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "vitest"; + +import { + createNativeHeadlessTerminal, + type TerminalCellRow, + type TerminalViewportState, +} from "./headless-terminal-state"; +import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload"; +import { createNativeTerminalScreenModel } from "./terminal-screen-model"; + +const ROWS = 10; +const COLS = 80; +const SCROLLBACK = 100; + +function rowText(row: TerminalCellRow | TerminalViewportState["grid"][number]): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +function expectedBenchmarkLine(index: number): string { + const label = `line ${index.toString().padStart(6, "0")} `; + const body = "abcdefghijklmnopqrstuvwxyz0123456789 ".repeat(4); + return (label + body).slice(0, COLS - 1); +} + +async function seedLargeOutput(input: { + rows: number; + cols: number; + scrollbackLines: number; + lineCount: number; +}) { + const terminal = createNativeHeadlessTerminal({ + rows: input.rows, + cols: input.cols, + scrollbackLines: input.scrollbackLines, + }); + await terminal.write( + createNativeTerminalBenchmarkPayload({ + startLine: 0, + lineCount: input.lineCount, + cols: input.cols, + }), + ); + return terminal; +} + +describe("native terminal scrollback retention", () => { + test("large output retains bounded scrollback with absolute rows advanced past evictions", async () => { + const terminal = await seedLargeOutput({ + rows: ROWS, + cols: COLS, + scrollbackLines: SCROLLBACK, + lineCount: SCROLLBACK + ROWS + 10, + }); + const state = terminal.getState(); + const bounds = terminal.getBufferBounds(); + + expect(state.scrollback.length).toBeGreaterThan(0); + expect(state.scrollback.length).toBeLessThanOrEqual(SCROLLBACK); + expect(bounds.newestRow).toBeGreaterThan(ROWS); + expect(bounds.oldestRow).toBeGreaterThan(0); + expect(bounds.newestRow - bounds.oldestRow + 1).toBe( + state.scrollback.length + state.grid.length, + ); + }); + + test("observable scrollback count is bounded by configured lines", async () => { + const terminal = await seedLargeOutput({ + rows: ROWS, + cols: COLS, + scrollbackLines: SCROLLBACK, + lineCount: SCROLLBACK * 3, + }); + const state = terminal.getState(); + + expect(state.scrollback.length).toBe(SCROLLBACK); + }); + + test("scroll up moves viewport into retained history", async () => { + const terminal = await seedLargeOutput({ + rows: ROWS, + cols: COLS, + scrollbackLines: SCROLLBACK, + lineCount: ROWS + 15, + }); + const model = createNativeTerminalScreenModel({ terminal }); + + const bottom = model.sync({ visibleRows: ROWS }); + const scrolled = model.scrollUp({ rows: 5, visibleRows: ROWS }); + + expect(bottom.scroll.mode).toBe("following"); + expect(scrolled.scroll.mode).toBe("scrolled"); + expect(scrolled.scroll.firstRow).toBeLessThan(bottom.scroll.firstRow); + expect(scrolled.viewport.firstRow).toBe(scrolled.scroll.firstRow); + expect(scrolled.viewport.grid.map(rowText).slice(0, 3)).toEqual([ + expectedBenchmarkLine(scrolled.scroll.firstRow), + expectedBenchmarkLine(scrolled.scroll.firstRow + 1), + expectedBenchmarkLine(scrolled.scroll.firstRow + 2), + ]); + }); + + test("scrolled state is preserved when new output arrives", async () => { + const terminal = await seedLargeOutput({ + rows: ROWS, + cols: COLS, + scrollbackLines: SCROLLBACK, + lineCount: ROWS + 20, + }); + const model = createNativeTerminalScreenModel({ terminal }); + + model.sync({ visibleRows: ROWS }); + const scrolled = model.scrollUp({ rows: 8, visibleRows: ROWS }); + const topRowBefore = scrolled.scroll.firstRow; + + await terminal.write( + createNativeTerminalBenchmarkPayload({ + startLine: ROWS + 20, + lineCount: 5, + cols: COLS, + }), + ); + const after = model.sync({ visibleRows: ROWS }); + + expect(after.scroll.mode).toBe("scrolled"); + expect(after.scroll.firstRow).toBe(topRowBefore); + }); + + test("bottom affordance returns to tail and resumes following", async () => { + const terminal = await seedLargeOutput({ + rows: ROWS, + cols: COLS, + scrollbackLines: SCROLLBACK, + lineCount: ROWS + 20, + }); + const model = createNativeTerminalScreenModel({ terminal }); + + model.sync({ visibleRows: ROWS }); + model.scrollUp({ rows: 10, visibleRows: ROWS }); + const bottom = model.returnToBottom({ visibleRows: ROWS }); + + expect(bottom.scroll.mode).toBe("following"); + expect(bottom.scroll.firstRow).toBe(bottom.scroll.bottomViewport.firstRow); + expect(bottom.viewport.firstRow).toBe(bottom.scroll.bottomViewport.firstRow); + }); + + test("following mode tracks bottom as live output continues", async () => { + const terminal = await seedLargeOutput({ + rows: ROWS, + cols: COLS, + scrollbackLines: SCROLLBACK, + lineCount: ROWS + 2, + }); + const model = createNativeTerminalScreenModel({ terminal }); + + const before = model.sync({ visibleRows: ROWS }); + await terminal.write( + createNativeTerminalBenchmarkPayload({ + startLine: ROWS + 2, + lineCount: 8, + cols: COLS, + }), + ); + const after = model.sync({ visibleRows: ROWS }); + + expect(after.scroll.mode).toBe("following"); + expect(after.scroll.firstRow).toBeGreaterThan(before.scroll.firstRow); + expect(after.scroll.bottomViewport.lastRow).toBeGreaterThan( + before.scroll.bottomViewport.lastRow, + ); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts new file mode 100644 index 0000000000..aed6857d5c --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from "vitest"; + +import { + TERMINAL_GESTURE_LONG_PRESS_MS, + TERMINAL_GESTURE_TAP_TOLERANCE_PX, + TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX, + advanceTerminalScrollGesture, + classifyTerminalGestureIntent, + resolveTerminalGestureReleaseAction, +} from "./terminal-selection-gesture"; + +describe("native terminal gesture policy", () => { + describe("advanceTerminalScrollGesture", () => { + it("keeps consuming a slow drag after scroll intent arms below one row", () => { + let state = { lastDy: 0, rowRemainder: 0, didScroll: false }; + const rowDeltas: number[] = []; + + for (const currentDy of [13, 15, 18, 22, 28]) { + const update = advanceTerminalScrollGesture({ + state, + currentDy, + cellHeight: 14, + }); + state = update.state; + rowDeltas.push(update.rowDelta); + } + + expect({ rowDeltas, state }).toEqual({ + rowDeltas: [0, 1, 0, 0, 1], + state: { lastDy: 28, rowRemainder: 0, didScroll: true }, + }); + }); + }); + + describe("classifyTerminalGestureIntent", () => { + it("treats a motionless press as a tap", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: 0, + dy: 0, + vx: 0, + vy: 0, + }), + ).toEqual("tap"); + }); + + it("treats a tiny jitter within tap tolerance as a tap", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4, + dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4, + vx: 0, + vy: 0, + }), + ).toEqual("tap"); + }); + + it("leaves horizontal movement available to terminal selection", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20, + dy: 4, + vx: 0, + vy: 0, + }), + ).toEqual("pending"); + }); + + it("does not infer selection from slow horizontal drag dwell", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, + dy: 3, + vx: 0, + vy: 0, + elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, + }), + ).toEqual("pending"); + }); + + it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1, + dy: 1, + vx: 2, + vy: 0, + elapsedMs: 20, + }), + ).toEqual("tap"); + }); + + it("keeps horizontal movement below navigation distance pending", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10, + dy: 2, + vx: 0, + vy: 0, + }), + ).toEqual("pending"); + }); + + it("treats a clearly vertical drag as scroll", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: 4, + dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5, + vx: 0, + vy: 0.2, + }), + ).toEqual("scroll"); + }); + + it("does not navigate on a fast rightward swipe", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, + dy: 4, + vx: 2, + vy: 0, + }), + ).toEqual("pending"); + }); + + it("does not navigate on a slow rightward swipe", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, + dy: 4, + vx: 0, + vy: 0, + }), + ).toEqual("pending"); + }); + + it("does not navigate on a fast leftward swipe", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40), + dy: -4, + vx: -2, + vy: 0, + }), + ).toEqual("pending"); + }); + + it("does not navigate while movement stays inside tap tolerance", () => { + expect( + classifyTerminalGestureIntent({ + status: "pressing", + dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1, + dy: 2, + vx: 2, + vy: 0, + }), + ).toEqual("tap"); + }); + + it("keeps selection intent once selection is active", () => { + expect( + classifyTerminalGestureIntent({ + status: "selecting", + dx: 100, + dy: 50, + vx: 2, + vy: 1, + }), + ).toEqual("select"); + }); + }); + + describe("resolveTerminalGestureReleaseAction", () => { + it("focuses the terminal after a normal tap", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "pressing", + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("focus"); + }); + + it("focuses the terminal after a normal tap while scrolled", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "pressing", + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "scrolled", + }), + ).toEqual("focus"); + }); + + it("starts selection after a long press", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "pressing", + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("select"); + }); + + it("starts selection after a 500ms hold", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "pressing", + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: 500, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("select"); + }); + + it("keeps a 400ms press as a tap", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "pressing", + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: 400, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("focus"); + }); + + it("does nothing after a scroll gesture", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "pressing", + didScroll: true, + didNavigate: false, + movedBeyondTapTolerance: true, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "scrolled", + }), + ).toEqual("none"); + }); + + it("does nothing after a navigation swipe", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "pressing", + didScroll: false, + didNavigate: true, + movedBeyondTapTolerance: true, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("none"); + }); + + it("does nothing while actively selecting", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "selecting", + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: true, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("none"); + }); + + it("clears an existing selection after a short tap", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "selecting", + startedWithSelection: true, + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("clear"); + }); + + it("preserves a word selection created by the current long press", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "selecting", + startedWithSelection: false, + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("none"); + }); + + it("does not focus after an undecided fast drag", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "pressing", + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: true, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("none"); + }); + + it("does nothing from idle", () => { + expect( + resolveTerminalGestureReleaseAction({ + status: "idle", + didScroll: false, + didNavigate: false, + movedBeyondTapTolerance: false, + pressDurationMs: 80, + longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, + scrollMode: "following", + }), + ).toEqual("none"); + }); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts new file mode 100644 index 0000000000..07b5267e87 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts @@ -0,0 +1,108 @@ +export type TerminalGestureStatus = "idle" | "pressing" | "selecting"; +export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll"; +export type TerminalGestureScrollMode = "following" | "scrolled"; + +export interface TerminalGestureMoveInput { + status: TerminalGestureStatus; + dx: number; + dy: number; + vx: number; + vy: number; + elapsedMs?: number; +} + +export interface TerminalGestureReleaseInput { + status: TerminalGestureStatus; + startedWithSelection?: boolean; + didScroll: boolean; + didNavigate: boolean; + movedBeyondTapTolerance: boolean; + pressDurationMs: number; + longPressMs: number; + scrollMode: TerminalGestureScrollMode; +} + +export interface TerminalScrollGestureState { + lastDy: number; + rowRemainder: number; + didScroll: boolean; +} + +export interface TerminalScrollGestureUpdate { + state: TerminalScrollGestureState; + rowDelta: number; +} + +export type TerminalGestureAction = "none" | "focus" | "select" | "clear"; + +export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8; +export const TERMINAL_GESTURE_LONG_PRESS_MS = 450; +export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12; + +export function classifyTerminalGestureIntent( + input: TerminalGestureMoveInput, +): TerminalGestureIntent { + if (input.status === "selecting") { + return "select"; + } + + const absDx = Math.abs(input.dx); + const absDy = Math.abs(input.dy); + + if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) { + return "scroll"; + } + + if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { + return "pending"; + } + + return "tap"; +} + +export function advanceTerminalScrollGesture(input: { + state: TerminalScrollGestureState; + currentDy: number; + cellHeight: number; +}): TerminalScrollGestureUpdate { + const deltaY = input.currentDy - input.state.lastDy; + const accumulatedRows = input.state.rowRemainder + deltaY; + const rowDelta = Math.trunc(accumulatedRows / input.cellHeight); + return { + state: { + lastDy: input.currentDy, + rowRemainder: accumulatedRows - rowDelta * input.cellHeight, + didScroll: input.state.didScroll || rowDelta !== 0, + }, + rowDelta, + }; +} + +export function resolveTerminalGestureReleaseAction( + input: TerminalGestureReleaseInput, +): TerminalGestureAction { + if (input.didScroll || input.didNavigate) { + return "none"; + } + + if (input.status === "selecting") { + if (!input.startedWithSelection) { + return "none"; + } + return input.movedBeyondTapTolerance ? "none" : "clear"; + } + + if (input.status !== "pressing") { + return "none"; + } + + if (input.pressDurationMs >= input.longPressMs) { + return "select"; + } + + if (input.movedBeyondTapTolerance) { + return "none"; + } + + return "focus"; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts new file mode 100644 index 0000000000..4c1dc06f99 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from "vitest"; + +import { createNativeHeadlessTerminal } from "./headless-terminal-state"; +import { + copyTerminalSelection, + createTerminalSelectionModel, + extractTerminalSelectedText, + hitTestTerminalSelectionCell, + normalizeTerminalSelection, + resolveTerminalWordSelection, + resolveTerminalSelectionRects, +} from "./terminal-selection"; + +function terminalLines(input: { startLine: number; lineCount: number }): string { + return Array.from( + { length: input.lineCount }, + (_, index) => `line-${input.startLine + index}\r\n`, + ).join(""); +} + +function rowWithText( + terminal: ReturnType, + text: string, +): number { + const bounds = terminal.getBufferBounds(); + const window = terminal.getBufferWindow({ + startRow: bounds.oldestRow, + rowCount: bounds.newestRow - bounds.oldestRow + 1, + }); + const rowOffset = window.rows.findIndex((row) => { + const rowText = row + .map((cell) => cell.char) + .join("") + .trimEnd(); + return rowText === text; + }); + if (rowOffset < 0) { + throw new Error(`missing terminal row: ${text}`); + } + return window.startRow + rowOffset; +} + +describe("native terminal selection", () => { + it("expands a long-press coordinate to the whole terminal word", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 }); + await terminal.write("before PASEO_TARGET after\r\n"); + const row = rowWithText(terminal, "before PASEO_TARGET after"); + + const selection = resolveTerminalWordSelection({ + terminal, + coordinate: { row, col: 12 }, + }); + + expect({ + selection, + text: extractTerminalSelectedText({ terminal, selection }), + }).toEqual({ + selection: { + start: { row, col: 7 }, + end: { row, col: 18 }, + coordinateEpoch: terminal.getBufferBounds().coordinateEpoch, + }, + text: "PASEO_TARGET", + }); + }); + + it("maps screen coordinates through the current visible window", () => { + expect( + hitTestTerminalSelectionCell({ + point: { x: 18, y: 25 }, + metrics: { cellWidth: 8, cellHeight: 10 }, + viewport: { firstRow: 120, rows: 4, cols: 80 }, + }), + ).toEqual({ row: 122, col: 2 }); + }); + + it("maps screen coordinates through a scrolled visible window", () => { + expect( + hitTestTerminalSelectionCell({ + point: { x: 0, y: 0 }, + metrics: { cellWidth: 8, cellHeight: 10 }, + viewport: { firstRow: 250, rows: 4, cols: 80 }, + }), + ).toEqual({ row: 250, col: 0 }); + + expect( + hitTestTerminalSelectionCell({ + point: { x: 24, y: 35 }, + metrics: { cellWidth: 8, cellHeight: 10 }, + viewport: { firstRow: 250, rows: 4, cols: 80 }, + }), + ).toEqual({ row: 253, col: 3 }); + }); + + it("normalizes reverse selections across rows", () => { + expect( + normalizeTerminalSelection({ + anchor: { row: 12, col: 9 }, + focus: { row: 10, col: 4 }, + coordinateEpoch: 7, + }), + ).toEqual({ + start: { row: 10, col: 4 }, + end: { row: 12, col: 9 }, + coordinateEpoch: 7, + }); + }); + + it("renders selected cells only inside the current viewport", () => { + const selection = normalizeTerminalSelection({ + anchor: { row: 11, col: 2 }, + focus: { row: 13, col: 4 }, + coordinateEpoch: 3, + }); + + expect( + resolveTerminalSelectionRects({ + selection, + viewport: { firstRow: 12, rows: 3, cols: 10 }, + metrics: { cellWidth: 8, cellHeight: 10 }, + }), + ).toEqual([ + { key: "12:0:9", x: 0, y: 0, width: 80, height: 10 }, + { key: "13:0:4", x: 0, y: 10, width: 40, height: 10 }, + ]); + }); + + it("extracts only selected terminal cells with row breaks", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 }); + await terminal.write("alpha beta\r\ngamma delta\r\n"); + const bounds = terminal.getBufferBounds(); + const alphaRow = rowWithText(terminal, "alpha beta"); + const gammaRow = rowWithText(terminal, "gamma delta"); + + expect( + extractTerminalSelectedText({ + terminal, + selection: { + start: { row: alphaRow, col: 6 }, + end: { row: gammaRow, col: 4 }, + coordinateEpoch: bounds.coordinateEpoch, + }, + }), + ).toEqual("beta\ngamma"); + }); + + it("copies soft-wrapped rows without fake line breaks", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 4, scrollbackLines: 20 }); + await terminal.write("abcdef"); + const bounds = terminal.getBufferBounds(); + const firstRow = rowWithText(terminal, "abcd"); + const secondRow = rowWithText(terminal, "ef"); + + expect( + extractTerminalSelectedText({ + terminal, + selection: { + start: { row: firstRow, col: 0 }, + end: { row: secondRow, col: 1 }, + coordinateEpoch: bounds.coordinateEpoch, + }, + }), + ).toEqual("abcdef"); + }); + + it("invalidates selection when buffer coordinates become unsafe", () => { + const selection = createTerminalSelectionModel(); + + selection.begin({ + coordinate: { row: 4, col: 2 }, + bounds: { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 }, + }); + selection.update({ + coordinate: { row: 5, col: 6 }, + bounds: { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 }, + }); + + expect(selection.sync({ bounds: { oldestRow: 0, newestRow: 10, coordinateEpoch: 2 } })).toEqual( + { + range: null, + }, + ); + }); + + it("invalidates a selection when a near-full burst evicts scrollback", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 16, scrollbackLines: 10 }); + await terminal.write(terminalLines({ startLine: 0, lineCount: 6 })); + const selection = createTerminalSelectionModel(); + const before = terminal.getBufferBounds(); + selection.begin({ + coordinate: { row: before.oldestRow, col: 0 }, + bounds: before, + }); + selection.update({ + coordinate: { row: before.oldestRow, col: 4 }, + bounds: before, + }); + + await terminal.write(terminalLines({ startLine: 6, lineCount: 20 })); + const after = terminal.getBufferBounds(); + + expect({ + selectedRowsEvicted: after.oldestRow > before.oldestRow, + sameCoordinateSpace: after.coordinateEpoch === before.coordinateEpoch, + selection: selection.sync({ bounds: after }), + }).toEqual({ + selectedRowsEvicted: true, + sameCoordinateSpace: true, + selection: { range: null }, + }); + }); + + it("keeps a selection when saturated-buffer output does not evict rows", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 16, scrollbackLines: 2 }); + await terminal.write(terminalLines({ startLine: 0, lineCount: 8 })); + const selection = createTerminalSelectionModel(); + const before = terminal.getBufferBounds(); + selection.begin({ coordinate: { row: 1, col: 0 }, bounds: before }); + selection.update({ coordinate: { row: 1, col: 3 }, bounds: before }); + + await terminal.write("tail"); + + expect(selection.sync({ bounds: terminal.getBufferBounds() }).range).not.toBeNull(); + }); + + it("keeps and copies selected rows until those rows are evicted", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 16, scrollbackLines: 10 }); + await terminal.write(terminalLines({ startLine: 0, lineCount: 13 })); + const selection = createTerminalSelectionModel(); + const selectedRow = rowWithText(terminal, "line-9"); + const before = terminal.getBufferBounds(); + selection.begin({ coordinate: { row: selectedRow, col: 0 }, bounds: before }); + selection.update({ coordinate: { row: selectedRow, col: 5 }, bounds: before }); + + await terminal.write(terminalLines({ startLine: 13, lineCount: 3 })); + const retained = selection.sync({ bounds: terminal.getBufferBounds() }).range; + + expect({ + retained, + copied: extractTerminalSelectedText({ terminal, selection: retained }), + }).toEqual({ + retained: { + start: { row: selectedRow, col: 0 }, + end: { row: selectedRow, col: 5 }, + coordinateEpoch: before.coordinateEpoch, + }, + copied: "line-9", + }); + + await terminal.write(terminalLines({ startLine: 16, lineCount: 10 })); + + expect(selection.sync({ bounds: terminal.getBufferBounds() })).toEqual({ range: null }); + }); + + it("copies exactly the selected known visible text", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 }); + await terminal.write("COPY_OK_123\r\n"); + const bounds = terminal.getBufferBounds(); + const copyRow = rowWithText(terminal, "COPY_OK_123"); + const copied: string[] = []; + + await copyTerminalSelection({ + terminal, + selection: { + start: { row: copyRow, col: 0 }, + end: { row: copyRow, col: 10 }, + coordinateEpoch: bounds.coordinateEpoch, + }, + clipboard: { + writeText: async (text) => { + copied.push(text); + }, + }, + }); + + expect(copied).toEqual(["COPY_OK_123"]); + }); + + it("copies known text after it has scrolled into retained history", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 }); + await terminal.write("KEEP_THIS_LINE\r\n"); + await terminal.write(Array.from({ length: 12 }, (_, index) => `filler-${index}\r\n`).join("")); + const bounds = terminal.getBufferBounds(); + const targetRow = rowWithText(terminal, "KEEP_THIS_LINE"); + expect(targetRow).toBeGreaterThanOrEqual(bounds.oldestRow); + expect(targetRow).toBeLessThanOrEqual(bounds.newestRow); + + const copied: string[] = []; + await copyTerminalSelection({ + terminal, + selection: { + start: { row: targetRow, col: 0 }, + end: { row: targetRow, col: 13 }, + coordinateEpoch: bounds.coordinateEpoch, + }, + clipboard: { + writeText: async (text) => { + copied.push(text); + }, + }, + }); + + expect(copied).toEqual(["KEEP_THIS_LINE"]); + }); + + it("does not write clipboard when there is no selection", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 }); + await terminal.write("copy target\r\n"); + const copied: string[] = []; + + await copyTerminalSelection({ + terminal, + selection: null, + clipboard: { + writeText: async (text) => { + copied.push(text); + }, + }, + }); + + expect(copied).toEqual([]); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.ts b/packages/app/src/terminal/native-renderer/terminal-selection.ts new file mode 100644 index 0000000000..1bda469e42 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-selection.ts @@ -0,0 +1,351 @@ +import type { NativeHeadlessTerminal } from "./headless-terminal-state"; +import type { TerminalGridCellMetrics } from "./terminal-grid-metrics"; + +export interface TerminalBufferCoordinate { + row: number; + col: number; +} + +export interface TerminalSelectionRange { + start: TerminalBufferCoordinate; + end: TerminalBufferCoordinate; + coordinateEpoch: number; +} + +export interface TerminalSelectionSnapshot { + range: TerminalSelectionRange | null; +} + +export interface TerminalSelectionBounds { + oldestRow: number; + newestRow: number; + coordinateEpoch: number; +} + +export interface TerminalSelectionModel { + begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; + update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; + clear(): TerminalSelectionSnapshot; + sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot; + getSnapshot(): TerminalSelectionSnapshot; +} + +export interface TerminalSelectionUpdateInput { + coordinate: TerminalBufferCoordinate; + bounds: TerminalSelectionBounds; +} + +export interface TerminalSelectionSyncInput { + bounds: TerminalSelectionBounds; +} + +export interface TerminalSelectionNormalizeInput { + anchor: TerminalBufferCoordinate; + focus: TerminalBufferCoordinate; + coordinateEpoch: number; +} + +export interface TerminalSelectionPoint { + x: number; + y: number; +} + +export interface TerminalSelectionViewport { + firstRow: number; + rows: number; + cols: number; +} + +export interface TerminalSelectionHitTestInput { + point: TerminalSelectionPoint; + metrics: TerminalGridCellMetrics; + viewport: TerminalSelectionViewport; +} + +export interface TerminalSelectionRect { + key: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface TerminalSelectionRectsInput { + selection: TerminalSelectionRange | null; + viewport: TerminalSelectionViewport; + metrics: TerminalGridCellMetrics; +} + +export interface TerminalSelectedTextInput { + terminal: NativeHeadlessTerminal; + selection: TerminalSelectionRange | null; +} + +export interface TerminalWordSelectionInput { + terminal: NativeHeadlessTerminal; + coordinate: TerminalBufferCoordinate; +} + +export interface TerminalClipboardWriter { + writeText: (text: string) => Promise; +} + +export interface CopyTerminalSelectionInput extends TerminalSelectedTextInput { + clipboard: TerminalClipboardWriter; +} + +interface TerminalSelectionState { + anchor: TerminalBufferCoordinate | null; + range: TerminalSelectionRange | null; +} + +const TERMINAL_WORD_SEPARATORS = " ()[]{}',\"`"; + +function compareCoordinates( + left: TerminalBufferCoordinate, + right: TerminalBufferCoordinate, +): number { + if (left.row !== right.row) { + return left.row - right.row; + } + return left.col - right.col; +} + +function clampCoordinate( + coordinate: TerminalBufferCoordinate, + bounds: TerminalSelectionBounds, +): TerminalBufferCoordinate { + return { + row: Math.min(bounds.newestRow, Math.max(bounds.oldestRow, Math.floor(coordinate.row))), + col: Math.max(0, Math.floor(coordinate.col)), + }; +} + +function rangeIsSafe(range: TerminalSelectionRange, bounds: TerminalSelectionBounds): boolean { + return ( + range.coordinateEpoch === bounds.coordinateEpoch && + range.start.row >= bounds.oldestRow && + range.end.row <= bounds.newestRow + ); +} + +export function normalizeTerminalSelection( + input: TerminalSelectionNormalizeInput, +): TerminalSelectionRange { + if (compareCoordinates(input.anchor, input.focus) <= 0) { + return { + start: input.anchor, + end: input.focus, + coordinateEpoch: input.coordinateEpoch, + }; + } + + return { + start: input.focus, + end: input.anchor, + coordinateEpoch: input.coordinateEpoch, + }; +} + +export function hitTestTerminalSelectionCell( + input: TerminalSelectionHitTestInput, +): TerminalBufferCoordinate | null { + if ( + input.point.x < 0 || + input.point.y < 0 || + input.metrics.cellWidth <= 0 || + input.metrics.cellHeight <= 0 + ) { + return null; + } + + const localRow = Math.floor(input.point.y / input.metrics.cellHeight); + const col = Math.floor(input.point.x / input.metrics.cellWidth); + if (localRow < 0 || localRow >= input.viewport.rows || col < 0 || col >= input.viewport.cols) { + return null; + } + + return { + row: input.viewport.firstRow + localRow, + col, + }; +} + +export function resolveTerminalWordSelection( + input: TerminalWordSelectionInput, +): TerminalSelectionRange | null { + const bounds = input.terminal.getBufferBounds(); + if (input.coordinate.row < bounds.oldestRow || input.coordinate.row > bounds.newestRow) { + return null; + } + const window = input.terminal.getBufferWindow({ startRow: input.coordinate.row, rowCount: 1 }); + const cells = window.rows[0]; + if (!cells || input.coordinate.col < 0 || input.coordinate.col >= cells.length) { + return null; + } + const target = cells[input.coordinate.col]?.char || " "; + if (TERMINAL_WORD_SEPARATORS.includes(target)) { + return { + start: input.coordinate, + end: input.coordinate, + coordinateEpoch: bounds.coordinateEpoch, + }; + } + + let startCol = input.coordinate.col; + let endCol = input.coordinate.col; + while (startCol > 0) { + const previous = cells[startCol - 1]?.char || " "; + if (TERMINAL_WORD_SEPARATORS.includes(previous)) break; + startCol -= 1; + } + while (endCol + 1 < cells.length) { + const next = cells[endCol + 1]?.char || " "; + if (TERMINAL_WORD_SEPARATORS.includes(next)) break; + endCol += 1; + } + + return { + start: { row: input.coordinate.row, col: startCol }, + end: { row: input.coordinate.row, col: endCol }, + coordinateEpoch: bounds.coordinateEpoch, + }; +} + +export function resolveTerminalSelectionRects( + input: TerminalSelectionRectsInput, +): TerminalSelectionRect[] { + const selection = input.selection; + if (!selection) { + return []; + } + + const firstVisibleRow = input.viewport.firstRow; + const lastVisibleRow = input.viewport.firstRow + input.viewport.rows - 1; + const firstRow = Math.max(selection.start.row, firstVisibleRow); + const lastRow = Math.min(selection.end.row, lastVisibleRow); + const rects: TerminalSelectionRect[] = []; + + for (let row = firstRow; row <= lastRow; row += 1) { + const startsOnThisRow = row === selection.start.row; + const endsOnThisRow = row === selection.end.row; + const startCol = startsOnThisRow ? selection.start.col : 0; + const endCol = endsOnThisRow ? selection.end.col : input.viewport.cols - 1; + const clampedStartCol = Math.min(input.viewport.cols - 1, Math.max(0, startCol)); + const clampedEndCol = Math.min(input.viewport.cols - 1, Math.max(0, endCol)); + if (clampedEndCol < clampedStartCol) { + continue; + } + + rects.push({ + key: `${row}:${clampedStartCol}:${clampedEndCol}`, + x: clampedStartCol * input.metrics.cellWidth, + y: (row - input.viewport.firstRow) * input.metrics.cellHeight, + width: (clampedEndCol - clampedStartCol + 1) * input.metrics.cellWidth, + height: input.metrics.cellHeight, + }); + } + + return rects; +} + +export function createTerminalSelectionModel(): TerminalSelectionModel { + let state: TerminalSelectionState = { anchor: null, range: null }; + + function snapshot(): TerminalSelectionSnapshot { + return { range: state.range }; + } + + function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { + const coordinate = clampCoordinate(input.coordinate, input.bounds); + const anchor = state.anchor ?? coordinate; + const range = normalizeTerminalSelection({ + anchor, + focus: coordinate, + coordinateEpoch: input.bounds.coordinateEpoch, + }); + state = { anchor, range }; + return snapshot(); + } + + return { + begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { + const coordinate = clampCoordinate(input.coordinate, input.bounds); + state = { + anchor: coordinate, + range: normalizeTerminalSelection({ + anchor: coordinate, + focus: coordinate, + coordinateEpoch: input.bounds.coordinateEpoch, + }), + }; + return snapshot(); + }, + update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { + return setSelection(input); + }, + clear(): TerminalSelectionSnapshot { + state = { anchor: null, range: null }; + return snapshot(); + }, + sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot { + if (!state.range || rangeIsSafe(state.range, input.bounds)) { + return snapshot(); + } + state = { anchor: null, range: null }; + return snapshot(); + }, + getSnapshot(): TerminalSelectionSnapshot { + return snapshot(); + }, + }; +} + +export function extractTerminalSelectedText(input: TerminalSelectedTextInput): string { + const selection = input.selection; + if (!selection) { + return ""; + } + + const bounds = input.terminal.getBufferBounds(); + if (!rangeIsSafe(selection, bounds)) { + return ""; + } + + const rowCount = selection.end.row - selection.start.row + 1; + const window = input.terminal.getBufferWindow({ + startRow: selection.start.row, + rowCount, + }); + let text = ""; + + for (let offset = 0; offset < window.rows.length; offset += 1) { + const row = window.startRow + offset; + const cells = window.rows[offset] ?? []; + const startsOnThisRow = row === selection.start.row; + const endsOnThisRow = row === selection.end.row; + const startCol = startsOnThisRow ? selection.start.col : 0; + const endCol = endsOnThisRow ? selection.end.col : cells.length - 1; + const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1); + const line = selectedCells.map((cell) => cell.char || " ").join(""); + if (offset > 0 && window.wrappedRows[offset] !== true) { + text += "\n"; + } + text += line.trimEnd(); + } + + return text; +} + +export async function copyTerminalSelection(input: CopyTerminalSelectionInput): Promise { + const text = extractTerminalSelectedText({ + terminal: input.terminal, + selection: input.selection, + }); + if (text.length === 0) { + return ""; + } + + await input.clipboard.writeText(text); + return text; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-size-measurement.test.ts b/packages/app/src/terminal/native-renderer/terminal-size-measurement.test.ts new file mode 100644 index 0000000000..7f24dbf303 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-size-measurement.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "vitest"; + +import { resolveMeasuredNativeTerminalSize } from "./terminal-size-measurement"; + +describe("terminal size measurement", () => { + test("measured layout height owns terminal rows", () => { + expect( + resolveMeasuredNativeTerminalSize({ + layout: { width: 530, height: 580 }, + metrics: { cellWidth: 10, cellHeight: 20 }, + }), + ).toEqual({ rows: 29, cols: 53 }); + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-size-measurement.ts b/packages/app/src/terminal/native-renderer/terminal-size-measurement.ts new file mode 100644 index 0000000000..9333d78589 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-size-measurement.ts @@ -0,0 +1,29 @@ +export interface TerminalMeasuredLayout { + width: number; + height: number; +} + +export interface TerminalMeasuredCellMetrics { + cellWidth: number; + cellHeight: number; +} + +export interface TerminalMeasuredSize { + rows: number; + cols: number; +} + +export function resolveMeasuredNativeTerminalSize(input: { + layout: TerminalMeasuredLayout | null; + metrics: TerminalMeasuredCellMetrics | null; +}): TerminalMeasuredSize | null { + if (!input.layout || !input.metrics) { + return null; + } + const cols = Math.floor(input.layout.width / input.metrics.cellWidth); + const rows = Math.floor(input.layout.height / input.metrics.cellHeight); + if (cols <= 0 || rows <= 0) { + return null; + } + return { rows, cols }; +} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 1c191b1b10..58dfd7ff38 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -15,6 +15,7 @@ interface TerminalSize { rows: number; cols: number; shouldClaim: boolean; + forceClaim?: boolean; } interface TerminalKeyRecord { @@ -26,6 +27,7 @@ interface TerminalKeyRecord { } type BrowserTerminal = TerminalSize & { + input: (data: string, wasUserInput?: boolean) => void; refresh: (start: number, end: number) => void; reset: () => void; }; @@ -135,6 +137,23 @@ function latestSize(sizes: TerminalSize[]): TerminalSize { return size; } +function expectNoForcedSameSizeClaim(input: { + sizes: TerminalSize[]; + startIndex: number; + baseline: TerminalSize; +}): void { + const forcedSameSizeClaims = input.sizes + .slice(input.startIndex) + .filter( + (size) => + size.rows === input.baseline.rows && + size.cols === input.baseline.cols && + size.shouldClaim && + size.forceClaim, + ); + expect(forcedSameSizeClaims).toEqual([]); +} + function getBrowserTerminal(): BrowserTerminal { const terminal = window.__paseoTerminal as BrowserTerminal | undefined; if (!terminal) { @@ -214,10 +233,10 @@ describe("terminal emulator runtime in a real browser", () => { expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([]); const settledSize = latestSize(mounted.sizes); - mounted.runtime.resize({ force: true, shouldClaim: true }); + mounted.runtime.resize({ forceClaim: true, shouldClaim: true }); expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([ - { ...settledSize, shouldClaim: true }, + { ...settledSize, shouldClaim: true, forceClaim: true }, ]); }); @@ -231,7 +250,7 @@ describe("terminal emulator runtime in a real browser", () => { mounted.root.style.width = "720px"; mounted.root.style.height = "360px"; await nextFrame(); - mounted.runtime.resize({ force: true }); + mounted.runtime.resize({ forceRefresh: true, shouldClaim: true }); await waitFor({ predicate: () => { @@ -246,6 +265,92 @@ describe("terminal emulator runtime in a real browser", () => { expect(grownSize.shouldClaim).toBe(true); }); + it("keeps passive container measurements local after another client can claim", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 360, height: 180 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + await settleMountRefits(); + const initialSize = latestSize(mounted.sizes); + mounted.sizes.length = 0; + + mounted.root.style.width = "720px"; + mounted.root.style.height = "360px"; + + await waitFor({ + predicate: () => + mounted.sizes.some((size) => size.cols > initialSize.cols && size.rows > initialSize.rows), + }); + + expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([]); + }); + + it("keeps visual viewport keyboard refits passive", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 360, height: 180 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + await settleMountRefits(); + mounted.sizes.length = 0; + + mounted.root.style.width = "720px"; + mounted.root.style.height = "360px"; + expect(window.visualViewport).not.toBeNull(); + window.visualViewport?.dispatchEvent(new Event("resize")); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([]); + }); + + it("keeps browser window refits passive", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 360, height: 180 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + await settleMountRefits(); + mounted.sizes.length = 0; + + mounted.root.style.width = "720px"; + mounted.root.style.height = "360px"; + window.dispatchEvent(new Event("resize")); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([]); + }); + + it("does not force-claim a same-size resize while forwarding ordinary terminal input", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + const sizeCount = mounted.sizes.length; + const sizeBeforeInput = latestSize(mounted.sizes); + const terminal = getBrowserTerminal(); + + terminal.input("a", true); + + await waitFor({ predicate: () => mounted.inputs.length > 0 }); + + expect(mounted.inputs.at(-1)).toBe("a"); + expectNoForcedSameSizeClaim({ + sizes: mounted.sizes, + startIndex: sizeCount, + baseline: sizeBeforeInput, + }); + }); + + it("pastes through xterm's input producer", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + mounted.runtime.paste("legacy renderer paste"); + + await waitFor({ predicate: () => mounted.inputs.length > 0 }); + expect(mounted.inputs).toEqual(["legacy renderer paste"]); + }); + it("refreshes visible rows on a forced same-size resize", async () => { await page.viewport(900, 600); const mounted = createTerminalHost({ width: 720, height: 360 }); @@ -260,7 +365,7 @@ describe("terminal emulator runtime in a real browser", () => { originalRefresh(start, end); }; - mounted.runtime.resize({ force: true }); + mounted.runtime.resize({ forceRefresh: true, shouldClaim: false }); await waitFor({ predicate: () => refreshCalls.length > 0 }); expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]); @@ -331,6 +436,32 @@ describe("terminal emulator runtime in a real browser", () => { meta: false, }, ]); + + const sizeCount = mounted.sizes.length; + const sizeBeforeKey = latestSize(mounted.sizes); + mounted.terminalKeys.length = 0; + + dispatchTerminalKey({ + host: mounted.host, + key: "Enter", + shiftKey: true, + }); + await nextFrame(); + + expect(mounted.terminalKeys).toEqual([ + { + key: "Enter", + ctrl: false, + shift: true, + alt: false, + meta: false, + }, + ]); + expectNoForcedSameSizeClaim({ + sizes: mounted.sizes, + startIndex: sizeCount, + baseline: sizeBeforeKey, + }); }); it.each([ diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 965e3912be..d739dcac34 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -78,7 +78,11 @@ vi.mock("@xterm/xterm", () => ({ }, })); -import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; +import { + createTerminalResizeEvent, + encodeTerminalOutput, + TerminalEmulatorRuntime, +} from "./terminal-emulator-runtime"; interface StubTerminal { write: (data: string | Uint8Array, callback?: () => void) => void; @@ -92,7 +96,11 @@ interface StubTerminal { } interface RuntimeFitProbe { - fitAndEmitResize: (input?: { force?: boolean; shouldClaim?: boolean }) => void; + fitAndEmitResize: (input?: { + forceRefresh?: boolean; + shouldClaim?: boolean; + forceClaim?: boolean; + }) => void; } function createRuntimeWithTerminal(): { @@ -258,11 +266,19 @@ describe("terminal-emulator-runtime", () => { it("reports input mode changes from terminal output and resets them on snapshots", () => { const { runtime, writeCallbacks } = createRuntimeWithTerminal(); - const inputModeChanges: Array<{ kittyKeyboardFlags: number; win32InputMode: boolean }> = []; + const inputModeChanges: Array<{ + kittyKeyboardFlags: number; + win32InputMode: boolean; + bracketedPaste: boolean; + }> = []; runtime.setCallbacks({ callbacks: { onInputModeChange: (state) => { - inputModeChanges.push(state); + inputModeChanges.push({ + kittyKeyboardFlags: state.kittyKeyboardFlags, + win32InputMode: state.win32InputMode, + bracketedPaste: Boolean(state.bracketedPaste), + }); }, }, }); @@ -282,18 +298,30 @@ describe("terminal-emulator-runtime", () => { }); // The plain write reports kitty flags synchronously during drain; the snapshot resets // them only after its barrier gate (the sentinel write callback) resolves. - expect(inputModeChanges).toEqual([{ kittyKeyboardFlags: 7, win32InputMode: false }]); + expect(inputModeChanges).toEqual([ + { kittyKeyboardFlags: 7, win32InputMode: false, bracketedPaste: false }, + ]); // The plain write carries no onCommitted, so it registers no callback; writeCallbacks[0] // is the barrier gate sentinel. writeCallbacks[0]?.(); expect(inputModeChanges).toEqual([ - { kittyKeyboardFlags: 7, win32InputMode: false }, - { kittyKeyboardFlags: 0, win32InputMode: false }, + { kittyKeyboardFlags: 7, win32InputMode: false, bracketedPaste: false }, + { kittyKeyboardFlags: 0, win32InputMode: false, bracketedPaste: false }, ]); }); + it("exposes the tracked input mode state", () => { + const { runtime } = createRuntimeWithTerminal(); + + runtime.write({ data: terminalOutput("\x1b[?2004h") }); + + expect(runtime.getInputModeState()).toMatchObject({ + bracketedPaste: true, + }); + }); + it("commits each drained plain write through its own xterm callback", () => { const { runtime, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); const committed: string[] = []; @@ -476,10 +504,29 @@ describe("terminal-emulator-runtime", () => { (runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize; runtime.resize(); - runtime.resize({ force: true }); + runtime.resize({ forceRefresh: true, shouldClaim: false }); expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, undefined); - expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, { force: true }); + expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, { + forceRefresh: true, + shouldClaim: false, + }); + }); + + it("marks explicit resize claims as forced so another client can reclaim the same size", () => { + expect( + createTerminalResizeEvent({ + rows: 34, + cols: 181, + shouldClaim: true, + forceClaim: true, + }), + ).toEqual({ + rows: 34, + cols: 181, + shouldClaim: true, + forceClaim: true, + }); }); it("updates terminal theme without remounting", () => { @@ -539,14 +586,16 @@ describe("terminal-emulator-runtime", () => { cols: 40, }; (runtime as unknown as { terminal: StubTerminal }).terminal = terminal; - (runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = - fitAndEmitResize; + (runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize; runtime.setFont({ fontFamily: " Menlo ", fontSize: 18 }); expect(terminal.options?.fontFamily).toBe("Menlo"); expect(terminal.options?.fontSize).toBe(18); - expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true }); + expect(fitAndEmitResize).toHaveBeenCalledWith({ + forceRefresh: true, + shouldClaim: false, + }); expect(refresh).toHaveBeenCalledWith(0, 11); }); @@ -565,7 +614,10 @@ describe("terminal-emulator-runtime", () => { } ).handleVisibilityRestore(); - expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true, shouldClaim: false }); + expect(fitAndEmitResize).toHaveBeenCalledWith({ + forceRefresh: true, + shouldClaim: false, + }); }); it("does not refit while the page is still hidden", () => { diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 8f9955bf25..56350ef9b7 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -28,6 +28,7 @@ import { type TerminalLocalFileLinkSource, type TerminalLocalFileLinkTarget, } from "../local-links/terminal-local-link-provider"; +import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font"; export type TerminalOutputData = Uint8Array; @@ -43,7 +44,7 @@ export interface TerminalEmulatorRuntimeMountInput { export interface TerminalEmulatorRuntimeCallbacks { onInput?: (data: string) => Promise | void; - onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise | void; + onResize?: (input: TerminalResizeEvent) => Promise | void; onTerminalKey?: (input: { key: string; ctrl: boolean; @@ -63,6 +64,33 @@ export interface TerminalEmulatorRuntimeCallbacks { onInputModeChange?: (state: TerminalInputModeState) => Promise | void; } +export interface TerminalResizeEvent { + rows: number; + cols: number; + shouldClaim: boolean; + forceClaim?: boolean; +} + +export interface TerminalResizeRequest { + forceRefresh?: boolean; + shouldClaim?: boolean; + forceClaim?: boolean; +} + +export function createTerminalResizeEvent(input: { + rows: number; + cols: number; + shouldClaim: boolean; + forceClaim: boolean; +}): TerminalResizeEvent { + return { + rows: input.rows, + cols: input.cols, + shouldClaim: input.shouldClaim, + forceClaim: input.shouldClaim && input.forceClaim, + }; +} + interface TerminalEmulatorRuntimeDisposables { disposeInput: () => void; disconnectResizeObserver: () => void; @@ -109,7 +137,6 @@ const isAppleHandheld = }); const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; -const DEFAULT_TERMINAL_FONT_SIZE = 13; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0); @@ -130,37 +157,6 @@ function prependTerminalOutput( return output; } -const DEFAULT_TERMINAL_FONT_FAMILY = [ - // Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs. - "JetBrains Mono", - "JetBrainsMono Nerd Font", - "JetBrainsMono NF", - "MesloLGM Nerd Font", - "MesloLGM NF", - "Hack Nerd Font", - "FiraCode Nerd Font", - // PUA-only fallback (many Nerd glyphs live here on some systems). - "Symbols Nerd Font", - // System fallbacks. - "SF Mono", - "Menlo", - "Monaco", - "Consolas", - "'Liberation Mono'", - "monospace", -].join(", "); - -function resolveTerminalFontFamily(fontFamily: string | undefined): string { - const trimmed = fontFamily?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_TERMINAL_FONT_FAMILY; -} - -function resolveTerminalFontSize(fontSize: number | undefined): number { - return typeof fontSize === "number" && Number.isFinite(fontSize) && fontSize > 0 - ? fontSize - : DEFAULT_TERMINAL_FONT_SIZE; -} - function withOverviewRulerBorderHidden(theme: ITheme): ITheme { return { ...theme, @@ -177,8 +173,7 @@ export class TerminalEmulatorRuntime { }; private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; - private fitAndEmitResize: ((input?: { force?: boolean; shouldClaim?: boolean }) => void) | null = - null; + private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null; private lastSize: { rows: number; cols: number } | null = null; private cleanup: (() => void) | null = null; private outputOperations: TerminalOutputOperation[] = []; @@ -204,10 +199,10 @@ export class TerminalEmulatorRuntime { return; } - this.fitAndEmitResize?.({ force: true, shouldClaim: false }); + this.fitAndEmitResize?.({ forceRefresh: true, shouldClaim: false }); if (typeof window.requestAnimationFrame === "function") { window.requestAnimationFrame(() => { - this.fitAndEmitResize?.({ force: true, shouldClaim: false }); + this.fitAndEmitResize?.({ forceRefresh: true, shouldClaim: false }); }); } }; @@ -220,6 +215,10 @@ export class TerminalEmulatorRuntime { this.pendingModifiers = input.pendingModifiers; } + getInputModeState(): TerminalInputModeState { + return this.inputModeTracker.getState(); + } + mount(input: TerminalEmulatorRuntimeMountInput): void { this.unmount(); @@ -304,7 +303,7 @@ export class TerminalEmulatorRuntime { webglAddon = null; disposeImageAddon(); // WebGL and DOM renderers can have different cell dimensions. - this.fitAndEmitResize?.({ force: true, shouldClaim: false }); + this.fitAndEmitResize?.({ forceRefresh: true, shouldClaim: false }); }; // Browser xterm is a renderer only; it never replies to terminal protocol queries. @@ -342,7 +341,7 @@ export class TerminalEmulatorRuntime { imageAddon = new ImageAddon(); terminal.loadAddon(imageAddon); registerProtocolQuerySuppression(); - this.fitAndEmitResize?.({ force: true, shouldClaim: false }); + this.fitAndEmitResize?.({ forceRefresh: true, shouldClaim: false }); } catch { disposeWebglRenderer(); } @@ -359,9 +358,10 @@ export class TerminalEmulatorRuntime { this.fitAddon = fitAddon; window.__paseoTerminal = terminal; - const fitAndEmitResize = (resizeInput?: { force?: boolean; shouldClaim?: boolean }): void => { - const force = resizeInput?.force ?? false; + const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => { + const forceRefresh = resizeInput?.forceRefresh ?? false; const shouldClaim = resizeInput?.shouldClaim ?? true; + const forceClaim = resizeInput?.forceClaim ?? false; const currentTerminal = this.terminal; const currentFitAddon = this.fitAddon; if (!currentTerminal || !currentFitAddon) { @@ -381,21 +381,30 @@ export class TerminalEmulatorRuntime { const nextRows = currentTerminal.rows; const nextCols = currentTerminal.cols; const previous = this.lastSize; - if (!force && previous && previous.rows === nextRows && previous.cols === nextCols) { + if ( + !forceRefresh && + !forceClaim && + previous && + previous.rows === nextRows && + previous.cols === nextCols + ) { return; } this.lastSize = { rows: nextRows, cols: nextCols }; this.refreshVisibleRows(); - this.callbacks.onResize?.({ - rows: nextRows, - cols: nextCols, - shouldClaim, - }); + this.callbacks.onResize?.( + createTerminalResizeEvent({ + rows: nextRows, + cols: nextCols, + shouldClaim, + forceClaim, + }), + ); }; this.fitAndEmitResize = fitAndEmitResize; - fitAndEmitResize({ force: true, shouldClaim: false }); + fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); const inputDisposable = terminal.onData((data) => { if (this.suppressInput) { @@ -480,12 +489,14 @@ export class TerminalEmulatorRuntime { terminal, }); const resizeObserver = new ResizeObserver(() => { - fitAndEmitResize({ shouldClaim: true }); + fitAndEmitResize({ shouldClaim: false }); }); resizeObserver.observe(input.root); resizeObserver.observe(input.host); - const windowResizeHandler = () => fitAndEmitResize({ shouldClaim: true }); + const windowResizeHandler = () => { + fitAndEmitResize({ shouldClaim: false }); + }; window.addEventListener("resize", windowResizeHandler); const windowFocusHandler = () => { this.handleVisibilityRestore(); @@ -498,23 +509,25 @@ export class TerminalEmulatorRuntime { document.addEventListener("visibilitychange", documentVisibilityChangeHandler); const visualViewport = window.visualViewport; - const visualViewportResizeHandler = () => fitAndEmitResize({ shouldClaim: true }); + const visualViewportResizeHandler = () => { + fitAndEmitResize({ shouldClaim: false }); + }; visualViewport?.addEventListener("resize", visualViewportResizeHandler); const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) => window.setTimeout(() => { - fitAndEmitResize({ force: true, shouldClaim: false }); + fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); }, delayMs), ); const fontSet = document.fonts; const fontReadyHandler = () => { - fitAndEmitResize({ force: true, shouldClaim: false }); + fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); }; fontSet?.addEventListener?.("loadingdone", fontReadyHandler); void fontSet?.ready .then(() => { - fitAndEmitResize({ force: true, shouldClaim: false }); + fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); return; }) .catch(() => { @@ -522,7 +535,7 @@ export class TerminalEmulatorRuntime { }); window.setTimeout(() => { - fitAndEmitResize({ force: true, shouldClaim: false }); + fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); }, 0); if (input.initialSnapshot) { @@ -624,6 +637,10 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); } + paste(text: string): void { + this.terminal?.paste(text); + } + renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { if (!input.state) { this.clear(input); @@ -654,7 +671,7 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); } - resize(input?: { force?: boolean; shouldClaim?: boolean }): void { + resize(input?: TerminalResizeRequest): void { this.fitAndEmitResize?.(input); } @@ -705,7 +722,7 @@ export class TerminalEmulatorRuntime { return; } - this.fitAndEmitResize?.({ force: true }); + this.fitAndEmitResize?.({ forceRefresh: true, shouldClaim: false }); this.refreshVisibleRows(); } diff --git a/packages/app/src/terminal/runtime/terminal-font.ts b/packages/app/src/terminal/runtime/terminal-font.ts new file mode 100644 index 0000000000..8d83433768 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-font.ts @@ -0,0 +1,32 @@ +const DEFAULT_TERMINAL_FONT_SIZE = 13; + +export const DEFAULT_TERMINAL_FONT_FAMILY = [ + // Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs. + "JetBrains Mono", + "JetBrainsMono Nerd Font", + "JetBrainsMono NF", + "MesloLGM Nerd Font", + "MesloLGM NF", + "Hack Nerd Font", + "FiraCode Nerd Font", + // PUA-only fallback (many Nerd glyphs live here on some systems). + "Symbols Nerd Font", + // System fallbacks. + "SF Mono", + "Menlo", + "Monaco", + "Consolas", + "'Liberation Mono'", + "monospace", +].join(", "); + +export function resolveTerminalFontFamily(fontFamily: string | undefined): string { + const trimmed = fontFamily?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_TERMINAL_FONT_FAMILY; +} + +export function resolveTerminalFontSize(fontSize: number | undefined): number { + return typeof fontSize === "number" && Number.isFinite(fontSize) && fontSize > 0 + ? fontSize + : DEFAULT_TERMINAL_FONT_SIZE; +} diff --git a/packages/app/src/terminal/runtime/terminal-key-dispatch.test.ts b/packages/app/src/terminal/runtime/terminal-key-dispatch.test.ts new file mode 100644 index 0000000000..0ddbd11729 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-key-dispatch.test.ts @@ -0,0 +1,81 @@ +import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; +import { describe, expect, it } from "vitest"; +import { + createTerminalKeyInput, + dispatchTerminalKeyInput, + EMPTY_TERMINAL_KEY_MODIFIERS, + TERMINAL_VIRTUAL_KEY_BUTTONS, +} from "./terminal-key-dispatch"; + +const NORMAL_MODE: TerminalInputModeState = { + kittyKeyboardFlags: 0, + win32InputMode: false, + applicationCursorKeys: false, +}; + +const APPLICATION_CURSOR_MODE: TerminalInputModeState = { + kittyKeyboardFlags: 0, + win32InputMode: false, + applicationCursorKeys: true, +}; + +function dispatchToolbarKey(input: { key: string; inputMode: TerminalInputModeState }): string[] { + const sent: string[] = []; + dispatchTerminalKeyInput({ + keyInput: createTerminalKeyInput({ + key: input.key, + modifiers: EMPTY_TERMINAL_KEY_MODIFIERS, + }), + inputMode: input.inputMode, + sendData: (data) => sent.push(data), + }); + return sent; +} + +describe("terminal key dispatch", () => { + it("dispatches visible toolbar arrows through mode-aware terminal key encoding", () => { + expect({ + normalUp: dispatchToolbarKey({ + key: TERMINAL_VIRTUAL_KEY_BUTTONS.up.key, + inputMode: NORMAL_MODE, + }), + normalDown: dispatchToolbarKey({ + key: TERMINAL_VIRTUAL_KEY_BUTTONS.down.key, + inputMode: NORMAL_MODE, + }), + normalLeft: dispatchToolbarKey({ + key: TERMINAL_VIRTUAL_KEY_BUTTONS.left.key, + inputMode: NORMAL_MODE, + }), + normalRight: dispatchToolbarKey({ + key: TERMINAL_VIRTUAL_KEY_BUTTONS.right.key, + inputMode: NORMAL_MODE, + }), + applicationCursorUp: dispatchToolbarKey({ + key: TERMINAL_VIRTUAL_KEY_BUTTONS.up.key, + inputMode: APPLICATION_CURSOR_MODE, + }), + applicationCursorDown: dispatchToolbarKey({ + key: TERMINAL_VIRTUAL_KEY_BUTTONS.down.key, + inputMode: APPLICATION_CURSOR_MODE, + }), + applicationCursorLeft: dispatchToolbarKey({ + key: TERMINAL_VIRTUAL_KEY_BUTTONS.left.key, + inputMode: APPLICATION_CURSOR_MODE, + }), + applicationCursorRight: dispatchToolbarKey({ + key: TERMINAL_VIRTUAL_KEY_BUTTONS.right.key, + inputMode: APPLICATION_CURSOR_MODE, + }), + }).toEqual({ + normalUp: ["\x1b[A"], + normalDown: ["\x1b[B"], + normalLeft: ["\x1b[D"], + normalRight: ["\x1b[C"], + applicationCursorUp: ["\x1bOA"], + applicationCursorDown: ["\x1bOB"], + applicationCursorLeft: ["\x1bOD"], + applicationCursorRight: ["\x1bOC"], + }); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-key-dispatch.ts b/packages/app/src/terminal/runtime/terminal-key-dispatch.ts new file mode 100644 index 0000000000..195082cec4 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-key-dispatch.ts @@ -0,0 +1,55 @@ +import { + encodeTerminalKeyInput, + type TerminalKeyInput, +} from "@getpaseo/protocol/terminal-key-input"; +import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; +import { normalizeTerminalTransportKey } from "@/utils/terminal-keys"; + +export interface TerminalKeyModifierState { + ctrl: boolean; + shift: boolean; + alt: boolean; +} + +export const EMPTY_TERMINAL_KEY_MODIFIERS: TerminalKeyModifierState = { + ctrl: false, + shift: false, + alt: false, +}; + +export const TERMINAL_VIRTUAL_KEY_BUTTONS = { + esc: { id: "esc", label: "Esc", key: "Escape" }, + tab: { id: "tab", label: "Tab", key: "Tab" }, + up: { id: "up", label: "↑", key: "ArrowUp" }, + down: { id: "down", label: "↓", key: "ArrowDown" }, + left: { id: "left", label: "←", key: "ArrowLeft" }, + right: { id: "right", label: "→", key: "ArrowRight" }, + enter: { id: "enter", label: "Enter", key: "Enter" }, + backspace: { id: "backspace", label: "⌫", key: "Backspace" }, + space: { id: "space", label: "Space", key: " " }, +} as const; + +export function createTerminalKeyInput(input: { + key: string; + modifiers: TerminalKeyModifierState; + meta?: boolean; +}): TerminalKeyInput { + return { + key: normalizeTerminalTransportKey(input.key), + ctrl: input.modifiers.ctrl, + shift: input.modifiers.shift, + alt: input.modifiers.alt, + meta: input.meta, + }; +} + +export function dispatchTerminalKeyInput(input: { + keyInput: TerminalKeyInput; + inputMode: TerminalInputModeState; + sendData: (data: string) => void; +}): void { + const encoded = encodeTerminalKeyInput(input.keyInput, { inputMode: input.inputMode }); + if (encoded.length > 0) { + input.sendData(encoded); + } +} diff --git a/packages/app/src/terminal/runtime/terminal-paste.test.ts b/packages/app/src/terminal/runtime/terminal-paste.test.ts new file mode 100644 index 0000000000..48832003c9 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-paste.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { encodeTerminalPaste, pasteTerminalClipboard } from "./terminal-paste"; + +describe("terminal paste encoding", () => { + it("sends pasted text unchanged when bracketed paste mode is off", () => { + expect(encodeTerminalPaste({ text: "echo hello\n", bracketedPaste: false })).toEqual( + "echo hello\n", + ); + }); + + it("wraps pasted text when bracketed paste mode is on", () => { + expect(encodeTerminalPaste({ text: "echo hello\n", bracketedPaste: true })).toEqual( + "\x1b[200~echo hello\n\x1b[201~", + ); + }); + + it("neutralizes embedded bracketed paste end markers before wrapping", () => { + expect( + encodeTerminalPaste({ + text: "echo safe\x1b[201~echo unsafe\n", + bracketedPaste: true, + }), + ).toEqual("\x1b[200~echo safe[201~echo unsafe\n\x1b[201~"); + }); + + it("reads clipboard through the explicit paste adapter", async () => { + const pasted: string[] = []; + + await pasteTerminalClipboard({ + clipboard: { readText: async () => "printf one\nprintf two" }, + terminal: { paste: (text) => pasted.push(text) }, + }); + + expect(pasted).toEqual(["printf one\nprintf two"]); + }); + + it("does not dispatch terminal input when clipboard is empty", async () => { + const pasted: string[] = []; + + await pasteTerminalClipboard({ + clipboard: { readText: async () => "" }, + terminal: { paste: (text) => pasted.push(text) }, + }); + + expect(pasted).toEqual([]); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-paste.ts b/packages/app/src/terminal/runtime/terminal-paste.ts new file mode 100644 index 0000000000..f02d301e07 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-paste.ts @@ -0,0 +1,38 @@ +export interface TerminalPasteInput { + text: string; + bracketedPaste: boolean; +} + +export interface TerminalClipboardReader { + readText: () => Promise; +} + +export interface TerminalPaster { + paste: (text: string) => void; +} + +export interface PasteTerminalClipboardInput { + clipboard: TerminalClipboardReader; + terminal: TerminalPaster; +} + +const BRACKETED_PASTE_START = "\x1b[200~"; +const BRACKETED_PASTE_END = "\x1b[201~"; + +export function encodeTerminalPaste(input: TerminalPasteInput): string { + if (!input.bracketedPaste) { + return input.text; + } + + const payload = input.text.replaceAll(BRACKETED_PASTE_END, "[201~"); + return `${BRACKETED_PASTE_START}${payload}${BRACKETED_PASTE_END}`; +} + +export async function pasteTerminalClipboard(input: PasteTerminalClipboardInput): Promise { + const text = await input.clipboard.readText(); + if (text.length === 0) { + return; + } + + input.terminal.paste(text); +} diff --git a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts index cdabe417a3..35b3492b71 100644 --- a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts @@ -161,7 +161,7 @@ describe("terminal resize reflow repro (Paseo terminal)", () => { // 3) User resizes the terminal wider. m.root.style.width = "1480px"; await nextFrame(); - m.runtime.resize({ force: true, shouldClaim: true }); + m.runtime.resize({ shouldClaim: true }); await nextFrame(); await nextFrame(); const wideCols = m.terminal.cols; diff --git a/packages/app/src/terminal/runtime/terminal-restore-options.test.ts b/packages/app/src/terminal/runtime/terminal-restore-options.test.ts index 7838d9b3e2..14f2ce4d8f 100644 --- a/packages/app/src/terminal/runtime/terminal-restore-options.test.ts +++ b/packages/app/src/terminal/runtime/terminal-restore-options.test.ts @@ -7,6 +7,7 @@ describe("terminal restore options", () => { expect( resolveTerminalRestoreOptions({ supportsTerminalRestoreModes: false, + canClaimSize: true, size: { rows: 24, cols: 80 }, }), ).toBeUndefined(); @@ -16,6 +17,7 @@ describe("terminal restore options", () => { expect( resolveTerminalRestoreOptions({ supportsTerminalRestoreModes: true, + canClaimSize: true, size: { rows: 24, cols: 80 }, }), ).toEqual({ @@ -29,6 +31,7 @@ describe("terminal restore options", () => { expect( resolveTerminalRestoreOptions({ supportsTerminalRestoreModes: true, + canClaimSize: true, size: null, }), ).toEqual({ @@ -36,4 +39,17 @@ describe("terminal restore options", () => { scrollbackLines: 200, }); }); + + it("does not let a background attach resize the PTY", () => { + expect( + resolveTerminalRestoreOptions({ + supportsTerminalRestoreModes: true, + canClaimSize: false, + size: { rows: 24, cols: 80 }, + }), + ).toEqual({ + mode: "visible-snapshot", + scrollbackLines: 200, + }); + }); }); diff --git a/packages/app/src/terminal/runtime/terminal-restore-options.ts b/packages/app/src/terminal/runtime/terminal-restore-options.ts index 753ea86b5a..d1e304cec7 100644 --- a/packages/app/src/terminal/runtime/terminal-restore-options.ts +++ b/packages/app/src/terminal/runtime/terminal-restore-options.ts @@ -4,6 +4,7 @@ export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200; export interface ResolveTerminalRestoreOptionsInput { supportsTerminalRestoreModes: boolean; + canClaimSize: boolean; size: { rows: number; cols: number } | null; } @@ -17,6 +18,6 @@ export function resolveTerminalRestoreOptions( return { mode: "visible-snapshot", scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES, - ...(input.size ? { size: input.size } : {}), + ...(input.canClaimSize && input.size ? { size: input.size } : {}), }; } diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts index 0c1e1c3632..ccac3ecc93 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -39,7 +39,12 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = []; public unsubscribeCalls: string[] = []; - public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = []; + public resizeCalls: Array<{ + terminalId: string; + rows: number; + cols: number; + intent?: "claim" | "update"; + }> = []; public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = []; async subscribeTerminal(terminalId: string, options?: unknown) { @@ -57,9 +62,14 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { sendTerminalInput( terminalId: string, - message: { type: "resize"; rows: number; cols: number }, + message: { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" }, ): void { - this.resizeCalls.push({ terminalId, rows: message.rows, cols: message.cols }); + this.resizeCalls.push({ + terminalId, + rows: message.rows, + cols: message.cols, + ...(message.intent ? { intent: message.intent } : {}), + }); } onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { @@ -137,7 +147,9 @@ describe("terminal-stream-controller", () => { }); expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]); - expect(harness.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]); + expect(harness.client.resizeCalls).toEqual([ + { terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, + ]); expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]); expect(harness.outputs[0]?.data).toBe(outputData); expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]); @@ -227,6 +239,7 @@ describe("terminal-stream-controller", () => { }, }, ]); + expect(client.resizeCalls).toEqual([]); expect(harness.restores[0]?.data).toBe(restoreData); expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]); expect(harness.outputs).toEqual([]); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index af50ed91ab..6b008a4ff0 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -13,7 +13,7 @@ export interface TerminalStreamControllerClient { unsubscribeTerminal: (terminalId: string) => void; sendTerminalInput: ( terminalId: string, - message: { type: "resize"; rows: number; cols: number }, + message: { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" }, ) => void; onTerminalStreamEvent: ( handler: ( @@ -105,12 +105,13 @@ export class TerminalStreamController { }); return; } - const preferredSize = this.options.getPreferredSize(); + const preferredSize = restore?.size ? null : this.options.getPreferredSize(); if (preferredSize) { this.options.client.sendTerminalInput(nextTerminalId, { type: "resize", rows: preferredSize.rows, cols: preferredSize.cols, + intent: "claim", }); } this.options.onStatusChange?.({ diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts new file mode 100644 index 0000000000..b8541bcfe5 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + getTerminalVirtualKeyboardControlId, + shouldShowTerminalFloatingCopyAction, + shouldShowTerminalPasteAction, + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + type TerminalVirtualKeyboardControl, +} from "./terminal-virtual-keyboard"; + +interface ControlPosition { + row: number; + col: number; +} + +function controlIds(): string[] { + return TERMINAL_VIRTUAL_KEYBOARD_ROWS.flatMap((row) => + row.map((control) => getTerminalVirtualKeyboardControlId(control)), + ); +} + +function controlPositions(): Map { + const positions = new Map(); + TERMINAL_VIRTUAL_KEYBOARD_ROWS.forEach((row, rowIndex) => { + row.forEach((control, colIndex) => { + positions.set(getTerminalVirtualKeyboardControlId(control), { + row: rowIndex, + col: colIndex, + }); + }); + }); + return positions; +} + +function controlsByType( + type: TerminalVirtualKeyboardControl["type"], +): TerminalVirtualKeyboardControl[] { + return TERMINAL_VIRTUAL_KEYBOARD_ROWS.flatMap((row) => + row.filter((control) => control.type === type), + ); +} + +describe("terminal virtual keyboard policy", () => { + it("does not expose redundant Space or Backspace controls", () => { + expect(controlIds()).toEqual([ + "terminal-key-esc", + "terminal-key-tab", + "terminal-key-ctrl", + "terminal-key-up", + "terminal-key-shift", + "terminal-keyboard-toggle", + "terminal-key-alt", + "terminal-paste", + "terminal-key-left", + "terminal-key-down", + "terminal-key-right", + "terminal-key-enter", + ]); + }); + + it("keeps the arrows in an inverted-T cluster", () => { + const positions = controlPositions(); + + expect({ + up: positions.get("terminal-key-up"), + left: positions.get("terminal-key-left"), + down: positions.get("terminal-key-down"), + right: positions.get("terminal-key-right"), + }).toEqual({ + up: { row: 0, col: 3 }, + left: { row: 1, col: 2 }, + down: { row: 1, col: 3 }, + right: { row: 1, col: 4 }, + }); + }); + + it("keeps Paste in the keyboard and Copy out of the permanent row", () => { + expect(controlsByType("paste")).toHaveLength(1); + expect(controlIds()).toContain("terminal-paste"); + expect(controlIds()).not.toContain("terminal-copy"); + }); + + it("shows Copy only as a native selection affordance", () => { + expect( + shouldShowTerminalFloatingCopyAction({ + hasSelection: false, + isNative: true, + }), + ).toBe(false); + expect( + shouldShowTerminalFloatingCopyAction({ + hasSelection: true, + isNative: false, + }), + ).toBe(false); + expect( + shouldShowTerminalFloatingCopyAction({ + hasSelection: true, + isNative: true, + }), + ).toBe(true); + }); + + it("keeps Paste native-gated", () => { + expect(shouldShowTerminalPasteAction({ isNative: true })).toBe(true); + expect(shouldShowTerminalPasteAction({ isNative: false })).toBe(false); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts new file mode 100644 index 0000000000..f931881bce --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts @@ -0,0 +1,68 @@ +import { + TERMINAL_VIRTUAL_KEY_BUTTONS, + type TerminalKeyModifierState, +} from "./terminal-key-dispatch"; + +type TerminalVirtualKeyButton = + (typeof TERMINAL_VIRTUAL_KEY_BUTTONS)[keyof typeof TERMINAL_VIRTUAL_KEY_BUTTONS]; + +export type TerminalVirtualKeyboardControl = + | { + type: "key"; + button: TerminalVirtualKeyButton; + } + | { + type: "modifier"; + modifier: keyof TerminalKeyModifierState; + } + | { + type: "paste"; + } + | { + type: "keyboardToggle"; + }; + +export const TERMINAL_VIRTUAL_KEYBOARD_ROWS = [ + [ + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab }, + { type: "modifier", modifier: "ctrl" }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, + { type: "modifier", modifier: "shift" }, + { type: "keyboardToggle" }, + ], + [ + { type: "modifier", modifier: "alt" }, + { type: "paste" }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, + ], +] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[]; + +export function getTerminalVirtualKeyboardControlId( + control: TerminalVirtualKeyboardControl, +): string { + switch (control.type) { + case "key": + return `terminal-key-${control.button.id}`; + case "modifier": + return `terminal-key-${control.modifier}`; + case "paste": + return "terminal-paste"; + case "keyboardToggle": + return "terminal-keyboard-toggle"; + } +} + +export function shouldShowTerminalPasteAction(input: { isNative: boolean }): boolean { + return input.isNative; +} + +export function shouldShowTerminalFloatingCopyAction(input: { + hasSelection: boolean; + isNative: boolean; +}): boolean { + return input.isNative && input.hasSelection; +} diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts index 5d410f87f5..99abe14e38 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -30,9 +30,10 @@ type InboundMessage = | { type: "writeOutput"; streamKey: string; text: string } | { type: "restoreOutput"; streamKey: string; text: string } | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } + | { type: "paste"; streamKey: string; text: string } | { type: "clear"; streamKey: string } | { type: "focus"; streamKey: string; forceRefocus?: boolean } - | { type: "resize"; streamKey: string; shouldClaim?: boolean } + | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean } | { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setScrollback"; streamKey: string; lines: number } | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } @@ -49,7 +50,14 @@ type OutboundMessage = | { type: "bridgeReady" } | { type: "rendererReady"; streamKey: string; isReady: boolean } | { type: "input"; streamKey: string; data: string } - | { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean } + | { + type: "resize"; + streamKey: string; + rows: number; + cols: number; + shouldClaim?: boolean; + forceClaim?: boolean; + } | { type: "terminalKey"; streamKey: string; @@ -242,6 +250,9 @@ class TerminalWebViewBridge { case "renderSnapshot": this.runtime?.renderSnapshot({ state: message.state }); break; + case "paste": + this.runtime?.paste(message.text); + break; case "clear": this.runtime?.clear(); break; @@ -249,7 +260,10 @@ class TerminalWebViewBridge { this.runtime?.focus({ forceRefocus: message.forceRefocus }); break; case "resize": - this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false }); + this.runtime?.resize({ + shouldClaim: message.shouldClaim ?? true, + forceClaim: message.forceClaim, + }); break; } } @@ -293,8 +307,15 @@ class TerminalWebViewBridge { runtime.setCallbacks({ callbacks: { onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }), - onResize: ({ rows, cols, shouldClaim }) => - sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols, shouldClaim }), + onResize: ({ rows, cols, shouldClaim, forceClaim }) => + sendToNative({ + type: "resize", + streamKey: message.streamKey, + rows, + cols, + shouldClaim, + forceClaim, + }), onTerminalKey: (input) => sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }), onPendingModifiersConsumed: () => diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index 89a80730d0..41403e20e2 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/utils/terminal-renderer-readiness.test.ts b/packages/app/src/utils/terminal-renderer-readiness.test.ts index 664b57c2c6..45866f8df6 100644 --- a/packages/app/src/utils/terminal-renderer-readiness.test.ts +++ b/packages/app/src/utils/terminal-renderer-readiness.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyTerminalRendererReadyChange, + resolveTerminalStreamTarget, shouldReplayTerminalSnapshotForRenderer, shouldShowTerminalLoadingOverlay, } from "./terminal-renderer-readiness"; @@ -93,4 +94,42 @@ describe("terminal-renderer-readiness", () => { }), ).toBe(false); }); + + it("does not attach the stream before the matching renderer is ready", () => { + expect( + resolveTerminalStreamTarget({ + terminalId: "terminal-1", + terminalStreamKey: "scope:terminal-1", + rendererReadyStreamKey: null, + isWorkspaceFocused: true, + }), + ).toBeNull(); + }); + + it("attaches the stream after the matching renderer is ready", () => { + expect( + resolveTerminalStreamTarget({ + terminalId: "terminal-1", + terminalStreamKey: "scope:terminal-1", + rendererReadyStreamKey: "scope:terminal-1", + isWorkspaceFocused: true, + }), + ).toBe("terminal-1"); + }); + + it("detaches and restores the same stream across renderer replacement", () => { + const targetForRenderer = (rendererReadyStreamKey: string | null) => + resolveTerminalStreamTarget({ + terminalId: "terminal-1", + terminalStreamKey: "scope:terminal-1", + rendererReadyStreamKey, + isWorkspaceFocused: true, + }); + + expect([ + targetForRenderer("scope:terminal-1"), + targetForRenderer(null), + targetForRenderer("scope:terminal-1"), + ]).toEqual(["terminal-1", null, "terminal-1"]); + }); }); diff --git a/packages/app/src/utils/terminal-renderer-readiness.ts b/packages/app/src/utils/terminal-renderer-readiness.ts index c1cbda26f1..7d4f855aba 100644 --- a/packages/app/src/utils/terminal-renderer-readiness.ts +++ b/packages/app/src/utils/terminal-renderer-readiness.ts @@ -21,6 +21,17 @@ export function shouldReplayTerminalSnapshotForRenderer(input: { return input.change.isReady && input.change.streamKey === input.terminalStreamKey; } +export function resolveTerminalStreamTarget(input: { + terminalId: string; + terminalStreamKey: string; + rendererReadyStreamKey: string | null; + isWorkspaceFocused: boolean; +}): string | null { + return input.isWorkspaceFocused && input.rendererReadyStreamKey === input.terminalStreamKey + ? input.terminalId + : null; +} + export function shouldShowTerminalLoadingOverlay(input: { isWorkspaceFocused: boolean; hasStreamError: boolean; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index ee7c684229..d3425fb797 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -4754,6 +4754,7 @@ test("sends input and resize frames for the subscribed terminal slot", async () type: "resize", rows: 24, cols: 80, + intent: "update", }); const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); @@ -4767,6 +4768,7 @@ test("sends input and resize frames for the subscribed terminal slot", async () expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({ rows: 24, cols: 80, + intent: "update", }); }); diff --git a/packages/client/src/terminal-stream-router.ts b/packages/client/src/terminal-stream-router.ts index 4b0e8395c2..5760af28eb 100644 --- a/packages/client/src/terminal-stream-router.ts +++ b/packages/client/src/terminal-stream-router.ts @@ -76,6 +76,7 @@ export class TerminalStreamRouter { payload: encodeTerminalResizePayload({ rows: message.rows, cols: message.cols, + ...(message.intent ? { intent: message.intent } : {}), }), }); } diff --git a/packages/protocol/src/binary-frames/terminal.test.ts b/packages/protocol/src/binary-frames/terminal.test.ts index 308e2bc918..f0e08e5660 100644 --- a/packages/protocol/src/binary-frames/terminal.test.ts +++ b/packages/protocol/src/binary-frames/terminal.test.ts @@ -31,16 +31,16 @@ describe("terminal binary frames", () => { }); }); - it("round-trips resize payloads", () => { - const payload = encodeTerminalResizePayload({ - rows: 24, - cols: 80, - }); + it.each(["claim", "update"] as const)("round-trips %s resize payloads", (intent) => { + const payload = encodeTerminalResizePayload({ rows: 24, cols: 80, intent }); - expect(decodeTerminalResizePayload(payload)).toEqual({ - rows: 24, - cols: 80, - }); + expect(decodeTerminalResizePayload(payload)).toEqual({ rows: 24, cols: 80, intent }); + }); + + it("accepts legacy resize payloads without an intent", () => { + const payload = encodeTerminalResizePayload({ rows: 24, cols: 80 }); + + expect(decodeTerminalResizePayload(payload)).toEqual({ rows: 24, cols: 80 }); }); it("round-trips snapshot payloads", () => { diff --git a/packages/protocol/src/binary-frames/terminal.ts b/packages/protocol/src/binary-frames/terminal.ts index bf9cff055a..3936502c89 100644 --- a/packages/protocol/src/binary-frames/terminal.ts +++ b/packages/protocol/src/binary-frames/terminal.ts @@ -4,6 +4,7 @@ import { TerminalStateSchema } from "../messages.js"; export const TerminalStreamResizeSchema = z.object({ rows: z.number().int().positive(), cols: z.number().int().positive(), + intent: z.enum(["claim", "update"]).optional(), }); export const TerminalStreamOpcode = { diff --git a/packages/protocol/src/messages.terminal-resize.test.ts b/packages/protocol/src/messages.terminal-resize.test.ts new file mode 100644 index 0000000000..a7d4f0d991 --- /dev/null +++ b/packages/protocol/src/messages.terminal-resize.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { TerminalInputSchema } from "./messages.js"; + +describe("terminal resize messages", () => { + it.each(["claim", "update"] as const)("accepts the optional %s intent", (intent) => { + const result = TerminalInputSchema.safeParse({ + type: "terminal_input", + terminalId: "terminal-1", + message: { type: "resize", rows: 24, cols: 80, intent }, + }); + + expect(result).toMatchObject({ + success: true, + data: { + message: { type: "resize", rows: 24, cols: 80, intent }, + }, + }); + }); + + it("accepts legacy resize messages without an intent", () => { + const result = TerminalInputSchema.safeParse({ + type: "terminal_input", + terminalId: "terminal-1", + message: { type: "resize", rows: 24, cols: 80 }, + }); + + expect(result).toMatchObject({ + success: true, + data: { + message: { type: "resize", rows: 24, cols: 80 }, + }, + }); + }); +}); diff --git a/packages/protocol/src/messages.terminal-restore.test.ts b/packages/protocol/src/messages.terminal-restore.test.ts index ef8022c680..0643ff33d1 100644 --- a/packages/protocol/src/messages.terminal-restore.test.ts +++ b/packages/protocol/src/messages.terminal-restore.test.ts @@ -58,4 +58,28 @@ describe("terminal restore schemas", () => { }).features?.["terminal-restore-modes"], ).toBe(true); }); + + test("keeps terminal input mode replay metadata optional for older daemons", () => { + const parsed = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: { + "terminal-restore-modes": true, + }, + }); + + expect(parsed.features?.["terminal-input-mode-replay"]).toBeUndefined(); + }); + + test("accepts terminal input mode replay feature metadata", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: { + "terminal-input-mode-replay": true, + }, + }).features?.["terminal-input-mode-replay"], + ).toBe(true); + }); }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1da7b79141..ed0603ceee 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2434,7 +2434,12 @@ export const UnsubscribeTerminalRequestSchema = z.object({ const TerminalClientMessageSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("input"), data: z.string() }), - z.object({ type: z.literal("resize"), rows: z.number(), cols: z.number() }), + z.object({ + type: z.literal("resize"), + rows: z.number(), + cols: z.number(), + intent: z.enum(["claim", "update"]).optional(), + }), z.object({ type: z.literal("mouse"), row: z.number(), @@ -2839,6 +2844,10 @@ export const ServerInfoStatusPayloadSchema = z relayConfig: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), + // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. + "terminal-input-mode-replay": z.boolean().optional(), + // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. + "terminal-size-ownership": z.boolean().optional(), // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: z.boolean().optional(), // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. diff --git a/packages/protocol/src/terminal-input-mode.test.ts b/packages/protocol/src/terminal-input-mode.test.ts index 813ee89f1e..f17b6b7e7b 100644 --- a/packages/protocol/src/terminal-input-mode.test.ts +++ b/packages/protocol/src/terminal-input-mode.test.ts @@ -51,6 +51,8 @@ describe("TerminalInputModeTracker", () => { expect(tracker.getState()).toEqual({ kittyKeyboardFlags: 0, win32InputMode: true, + applicationCursorKeys: false, + bracketedPaste: false, }); expect(tracker.supportsModifiedEnter()).toBe(true); expect(tracker.getPreamble()).toBe("\x1b[?9001h"); @@ -67,10 +69,55 @@ describe("TerminalInputModeTracker", () => { expect(tracker.getState()).toEqual({ kittyKeyboardFlags: 7, win32InputMode: true, + applicationCursorKeys: false, + bracketedPaste: false, }); expect(tracker.getPreamble()).toBe("\x1b[=7;1u\x1b[?9001h"); }); + it("tracks application cursor keys mode independently", () => { + const tracker = new TerminalInputModeTracker(); + + expect(tracker.feed("\x1b[?1h").changed).toBe(true); + expect(tracker.getState()).toEqual({ + kittyKeyboardFlags: 0, + win32InputMode: false, + applicationCursorKeys: true, + bracketedPaste: false, + }); + expect(tracker.getPreamble()).toBe("\x1b[?1h"); + + expect(tracker.feed("\x1b[?1l").changed).toBe(true); + expect(tracker.getState()).toEqual({ + kittyKeyboardFlags: 0, + win32InputMode: false, + applicationCursorKeys: false, + bracketedPaste: false, + }); + }); + + it("tracks bracketed paste mode independently", () => { + const tracker = new TerminalInputModeTracker(); + + expect(tracker.feed("\x1b[?2004h").changed).toBe(true); + expect(tracker.getState()).toEqual({ + kittyKeyboardFlags: 0, + win32InputMode: false, + applicationCursorKeys: false, + bracketedPaste: true, + }); + expect(tracker.getPreamble()).toBe("\x1b[?2004h"); + + expect(tracker.feed("\x1b[?2004l").changed).toBe(true); + expect(tracker.getState()).toEqual({ + kittyKeyboardFlags: 0, + win32InputMode: false, + applicationCursorKeys: false, + bracketedPaste: false, + }); + expect(tracker.getPreamble()).toBe(""); + }); + it("ignores encoded key input sequences", () => { const tracker = new TerminalInputModeTracker(); diff --git a/packages/protocol/src/terminal-input-mode.ts b/packages/protocol/src/terminal-input-mode.ts index e9e15d5cee..f35156eb5c 100644 --- a/packages/protocol/src/terminal-input-mode.ts +++ b/packages/protocol/src/terminal-input-mode.ts @@ -6,15 +6,21 @@ export interface TerminalInputModeFeedResult { export interface TerminalInputModeState { kittyKeyboardFlags: number; win32InputMode: boolean; + applicationCursorKeys?: boolean; + bracketedPaste?: boolean; } export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = { kittyKeyboardFlags: 0, win32InputMode: false, + applicationCursorKeys: false, + bracketedPaste: false, }; const ESC = String.fromCharCode(0x1b); +const APPLICATION_CURSOR_KEYS_MODE = 1; const WIN32_INPUT_MODE = 9001; +const BRACKETED_PASTE_MODE = 2004; const CSI_INPUT_MODE_SEQUENCE = new RegExp( `${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`, "g", @@ -57,13 +63,17 @@ export function terminalInputModeStatesEqual( ): boolean { return ( left.kittyKeyboardFlags === right.kittyKeyboardFlags && - left.win32InputMode === right.win32InputMode + left.win32InputMode === right.win32InputMode && + Boolean(left.applicationCursorKeys) === Boolean(right.applicationCursorKeys) && + Boolean(left.bracketedPaste) === Boolean(right.bracketedPaste) ); } export class TerminalInputModeTracker { private kittyKeyboardFlags = 0; private win32InputMode = false; + private applicationCursorKeys = false; + private bracketedPaste = false; private readonly kittyKeyboardStack: number[] = []; private pending = ""; @@ -112,6 +122,8 @@ export class TerminalInputModeTracker { reset(): void { this.kittyKeyboardFlags = 0; this.win32InputMode = false; + this.applicationCursorKeys = false; + this.bracketedPaste = false; this.kittyKeyboardStack.length = 0; this.pending = ""; } @@ -120,6 +132,8 @@ export class TerminalInputModeTracker { return { kittyKeyboardFlags: this.kittyKeyboardFlags, win32InputMode: this.win32InputMode, + applicationCursorKeys: this.applicationCursorKeys, + bracketedPaste: this.bracketedPaste, }; } @@ -139,6 +153,12 @@ export class TerminalInputModeTracker { if (this.win32InputMode) { parts.push("\x1b[?9001h"); } + if (this.applicationCursorKeys) { + parts.push("\x1b[?1h"); + } + if (this.bracketedPaste) { + parts.push("\x1b[?2004h"); + } return parts.join(""); } @@ -183,12 +203,26 @@ export class TerminalInputModeTracker { private applyPrivateModeSequence(params: string, final: string): boolean { const modes = parsePrivateModeParams(params); - if (!modes.has(WIN32_INPUT_MODE)) { - return false; + let changed = false; + + if (modes.has(WIN32_INPUT_MODE)) { + const previous = this.win32InputMode; + this.win32InputMode = final === "h"; + changed = this.win32InputMode !== previous || changed; } - const previous = this.win32InputMode; - this.win32InputMode = final === "h"; - return this.win32InputMode !== previous; + if (modes.has(APPLICATION_CURSOR_KEYS_MODE)) { + const previous = this.applicationCursorKeys; + this.applicationCursorKeys = final === "h"; + changed = this.applicationCursorKeys !== previous || changed; + } + + if (modes.has(BRACKETED_PASTE_MODE)) { + const previous = this.bracketedPaste; + this.bracketedPaste = final === "h"; + changed = this.bracketedPaste !== previous || changed; + } + + return changed; } } diff --git a/packages/protocol/src/terminal-key-input.test.ts b/packages/protocol/src/terminal-key-input.test.ts index 7bb46437eb..98ee976ede 100644 --- a/packages/protocol/src/terminal-key-input.test.ts +++ b/packages/protocol/src/terminal-key-input.test.ts @@ -2,6 +2,58 @@ import { describe, expect, it } from "vitest"; import { encodeTerminalKeyInput } from "./terminal-key-input.js"; describe("encodeTerminalKeyInput", () => { + it("encodes plain arrows as CSI when application cursor mode is inactive", () => { + const options = { inputMode: { kittyKeyboardFlags: 0, win32InputMode: false } }; + + expect({ + up: encodeTerminalKeyInput({ key: "ArrowUp" }, options), + down: encodeTerminalKeyInput({ key: "ArrowDown" }, options), + left: encodeTerminalKeyInput({ key: "ArrowLeft" }, options), + right: encodeTerminalKeyInput({ key: "ArrowRight" }, options), + }).toEqual({ + up: "\x1b[A", + down: "\x1b[B", + left: "\x1b[D", + right: "\x1b[C", + }); + }); + + it("encodes plain arrows as SS3 when application cursor mode is active", () => { + const options = { + inputMode: { kittyKeyboardFlags: 0, win32InputMode: false, applicationCursorKeys: true }, + }; + + expect({ + up: encodeTerminalKeyInput({ key: "ArrowUp" }, options), + down: encodeTerminalKeyInput({ key: "ArrowDown" }, options), + left: encodeTerminalKeyInput({ key: "ArrowLeft" }, options), + right: encodeTerminalKeyInput({ key: "ArrowRight" }, options), + }).toEqual({ + up: "\x1bOA", + down: "\x1bOB", + left: "\x1bOD", + right: "\x1bOC", + }); + }); + + it("keeps modified arrows on CSI modifier sequences in application cursor mode", () => { + const options = { + inputMode: { kittyKeyboardFlags: 0, win32InputMode: false, applicationCursorKeys: true }, + }; + + expect({ + shiftLeft: encodeTerminalKeyInput({ key: "ArrowLeft", shift: true }, options), + altDown: encodeTerminalKeyInput({ key: "ArrowDown", alt: true }, options), + ctrlRight: encodeTerminalKeyInput({ key: "ArrowRight", ctrl: true }, options), + metaUp: encodeTerminalKeyInput({ key: "ArrowUp", meta: true }, options), + }).toEqual({ + shiftLeft: "\x1b[1;2D", + altDown: "\x1b[1;3B", + ctrlRight: "\x1b[1;5C", + metaUp: "\x1b[1;9A", + }); + }); + it("encodes ctrl+b for tmux prefix", () => { expect(encodeTerminalKeyInput({ key: "b", ctrl: true })).toBe("\x02"); }); diff --git a/packages/protocol/src/terminal-key-input.ts b/packages/protocol/src/terminal-key-input.ts index 3d1cd5cce3..dcb709096d 100644 --- a/packages/protocol/src/terminal-key-input.ts +++ b/packages/protocol/src/terminal-key-input.ts @@ -125,6 +125,10 @@ function csiWithModifier(finalByte: string, input: TerminalKeyInput): string { return mod === 1 ? `\x1b[${finalByte}` : `\x1b[1;${mod}${finalByte}`; } +function ss3WithModifier(finalByte: string, input: TerminalKeyInput): string { + return modifierParam(input) === 1 ? `\x1bO${finalByte}` : csiWithModifier(finalByte, input); +} + function csiTilde(base: number, input: TerminalKeyInput): string { const mod = modifierParam(input); return mod === 1 ? `\x1b[${base}~` : `\x1b[${base};${mod}~`; @@ -161,16 +165,31 @@ function encodeFunctionKey(key: string, input: TerminalKeyInput): string | null } } -function encodeNavigationKey(key: string, input: TerminalKeyInput): string | null { +function encodeArrowKey( + finalByte: string, + input: TerminalKeyInput, + options: TerminalKeyInputEncodingOptions, +): string { + if (options.inputMode?.applicationCursorKeys) { + return ss3WithModifier(finalByte, input); + } + return csiWithModifier(finalByte, input); +} + +function encodeNavigationKey( + key: string, + input: TerminalKeyInput, + options: TerminalKeyInputEncodingOptions, +): string | null { switch (key) { case "ArrowUp": - return csiWithModifier("A", input); + return encodeArrowKey("A", input, options); case "ArrowDown": - return csiWithModifier("B", input); + return encodeArrowKey("B", input, options); case "ArrowRight": - return csiWithModifier("C", input); + return encodeArrowKey("C", input, options); case "ArrowLeft": - return csiWithModifier("D", input); + return encodeArrowKey("D", input, options); case "Home": return csiWithModifier("H", input); case "End": @@ -225,7 +244,7 @@ export function encodeTerminalKeyInput( break; } - const nav = encodeNavigationKey(key, input); + const nav = encodeNavigationKey(key, input, options); if (nav !== null) return nav; const fn = encodeFunctionKey(key, input); if (fn !== null) return fn; diff --git a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts new file mode 100644 index 0000000000..f21f5f9b36 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts @@ -0,0 +1,341 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from "@xterm/headless"; +import { expect, test } from "vitest"; + +import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; +import type { TerminalStreamEvent } from "@getpaseo/client/internal/terminal-stream-router"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +const BYTE_DONE_MARKER = "__PASEO_BYTE_PACKAGE_LOCK_DONE__"; +const BYTE_TEST_SIZE = { rows: 24, cols: 100 }; + +interface PackageLockTerminalCwd { + path: string; + gatePath: string; +} + +interface CreatedTerminal { + id: string; +} + +test("byte-stream headless terminal matches daemon state after high-output attach and restore", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.96", + clientType: "mobile", + }); + const terminalCwd = await createPackageLockTerminalCwd(); + + try { + await client.connect(); + const terminal = await createPackageLockTerminal({ + client, + cwd: terminalCwd.path, + gatePath: terminalCwd.gatePath, + }); + const liveHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE); + const stopLive = pipeTerminalStreamToHeadless({ + client, + terminalId: terminal.id, + headless: liveHeadless, + }); + const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot"); + + await subscribeTerminal(client, terminal.id); + await initialSnapshot; + client.sendTerminalInput(terminal.id, { type: "resize", ...BYTE_TEST_SIZE }); + liveHeadless.resize(BYTE_TEST_SIZE); + + await startPackageLockOutput(terminalCwd.gatePath); + const capturedViewport = await waitForCapturedViewport({ + client, + terminalId: terminal.id, + text: BYTE_DONE_MARKER, + rows: BYTE_TEST_SIZE.rows, + }); + const daemonSnapshot = await readTerminalSnapshot({ + port: daemon.port, + terminalId: terminal.id, + }); + await liveHeadless.flush(); + + expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot)); + expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor); + expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines); + + client.unsubscribeTerminal(terminal.id); + stopLive(); + + const restoreHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE); + const stopRestore = pipeTerminalStreamToHeadless({ + client, + terminalId: terminal.id, + headless: restoreHeadless, + }); + const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore"); + + await subscribeTerminal(client, terminal.id, { + restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE }, + }); + await restoreFrame; + await restoreHeadless.flush(); + + expect(restoreHeadless.visibleLines()).toEqual(capturedViewport.lines); + expect(restoreHeadless.cursor()).toEqual(daemonSnapshot.cursor); + stopRestore(); + } finally { + await client.close(); + await daemon.close(); + await rm(terminalCwd.path, { recursive: true, force: true }); + } +}, 30_000); + +class ClientHeadlessTerminal { + private readonly terminal: HeadlessTerminal; + private readonly decoder = new TextDecoder(); + private pendingWrite = Promise.resolve(); + + constructor(input: { rows: number; cols: number }) { + this.terminal = new HeadlessTerminal({ + rows: input.rows, + cols: input.cols, + scrollback: 10_000, + allowProposedApi: true, + }); + } + + resize(input: { rows: number; cols: number }): void { + this.terminal.resize(input.cols, input.rows); + } + + applyEvent(event: TerminalStreamEvent): void { + if (event.type === "snapshot") { + this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true }); + return; + } + if (event.type === "restore") { + this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true }); + return; + } + this.enqueueText(this.decoder.decode(event.data, { stream: true })); + } + + async flush(): Promise { + await this.pendingWrite; + } + + visibleLines(): string[] { + const baseY = this.terminal.buffer.active.baseY; + const cell = this.terminal.buffer.active.getNullCell(); + const lines: string[] = []; + for (let row = 0; row < this.terminal.rows; row += 1) { + lines.push(bufferLineText(this.terminal.buffer.active.getLine(baseY + row), cell)); + } + return lines; + } + + cursor(): TerminalState["cursor"] { + return { + row: this.terminal.buffer.active.cursorY, + col: this.terminal.buffer.active.cursorX, + }; + } + + private enqueueText(text: string, options?: { resetDecoder?: boolean }): void { + if (options?.resetDecoder) { + this.decoder.decode(); + this.terminal.reset(); + } + this.pendingWrite = this.pendingWrite.then( + () => + new Promise((resolve) => { + this.terminal.write(text, () => resolve()); + }), + ); + } +} + +function pipeTerminalStreamToHeadless(input: { + client: DaemonClient; + terminalId: string; + headless: ClientHeadlessTerminal; +}): () => void { + return input.client.onTerminalStreamEvent((event) => { + if (event.terminalId !== input.terminalId) { + return; + } + input.headless.applyEvent(event); + }); +} + +async function subscribeTerminal( + client: DaemonClient, + terminalId: string, + options?: Parameters[1], +): Promise { + const response = await client.subscribeTerminal(terminalId, options); + if (response.error) { + throw new Error(response.error); + } +} + +async function readTerminalSnapshot(input: { + port: number; + terminalId: string; +}): Promise { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${input.port}/ws`, + appVersion: "0.1.96", + clientType: "mobile", + }); + try { + await client.connect(); + const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot"); + await subscribeTerminal(client, input.terminalId); + const event = await snapshot; + return event.state; + } finally { + await client.close(); + } +} + +function waitForTerminalStreamEvent( + client: DaemonClient, + terminalId: string, + type: TType, +): Promise> { + return client.waitForTerminalStreamEvent( + (event) => event.terminalId === terminalId && event.type === type, + 10_000, + ) as Promise>; +} + +async function createPackageLockTerminalCwd(): Promise { + const cwd = await mkdtemp(path.join(tmpdir(), "paseo-byte-package-lock-")); + return { + path: cwd, + gatePath: path.join(cwd, "start-package-lock-output"), + }; +} + +async function createPackageLockTerminal(input: { + client: DaemonClient; + cwd: string; + gatePath: string; +}): Promise { + const opened = await input.client.openProject(input.cwd); + if (!opened.workspace) { + throw new Error(opened.error ?? `Failed to open workspace for ${input.cwd}`); + } + + const response = await input.client.createTerminal(input.cwd, "byte-package-lock", undefined, { + command: process.execPath, + args: [ + "-e", + packageLockStreamScript(), + repoPackageLockPath(), + input.gatePath, + BYTE_DONE_MARKER, + ], + workspaceId: opened.workspace.id, + }); + if (response.error || !response.terminal) { + throw new Error(response.error ?? "Terminal was not created"); + } + return { id: response.terminal.id }; +} + +async function startPackageLockOutput(gatePath: string): Promise { + await writeFile(gatePath, "go"); +} + +async function waitForCapturedViewport(input: { + client: DaemonClient; + terminalId: string; + text: string; + rows: number; +}): Promise<{ lines: string[] }> { + return waitForCondition(async () => { + const capture = await input.client.captureTerminal(input.terminalId, { + start: -input.rows, + stripAnsi: true, + }); + return capture.lines.join("\n").includes(input.text) ? { lines: capture.lines } : null; + }, 15_000); +} + +async function waitForCondition( + predicate: () => Promise | T | null, + timeoutMs: number, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const value = await predicate(); + if (value) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for terminal byte parity`); +} + +function visibleTerminalLines(state: TerminalState): string[] { + return state.grid.map(terminalRowText); +} + +function terminalRowText(row: TerminalState["grid"][number]): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string { + const cells: TerminalCell[] = []; + for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) { + const cell = line?.getCell(col, reusableCell); + cells.push({ char: cell?.getChars() || " " }); + } + return terminalRowText(cells); +} + +function packageLockStreamScript(): string { + return ` +const fs = require("node:fs"); +const packageLockPath = process.argv[1]; +const gatePath = process.argv[2]; +const marker = process.argv[3]; + +function waitForGate() { + if (!fs.existsSync(gatePath)) { + setTimeout(waitForGate, 10); + return; + } + process.stdout.write(fs.readFileSync(packageLockPath, "utf8")); + process.stdout.write("\\n" + marker + "\\n"); +} + +waitForGate(); +setInterval(() => {}, 1000); +`; +} + +function repoPackageLockPath(): string { + const start = path.dirname(fileURLToPath(import.meta.url)); + let current = start; + while (current !== path.dirname(current)) { + const candidate = path.join(current, "package-lock.json"); + if (existsSync(candidate)) { + return candidate; + } + current = path.dirname(current); + } + throw new Error(`Could not find package-lock.json from ${start}`); +} diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index d96d85fa3e..6d22b4695e 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -1264,6 +1264,68 @@ test("resize updates server dimensions without sending a live snapshot", async ( rmSync(cwd, { recursive: true, force: true }); }, 30000); +test("the latest resize from any attached client owns the PTY size", async () => { + const cwd = tmpCwd(); + const created = await createTerminalInWorkspace(ctx.client, { cwd }); + const terminalId = created.terminal!.id; + const mobileClient = await connectClient( + ctx.daemon.port, + `terminal-mobile-resize-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + const readSize = () => { + const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState(); + return state ? { rows: state.rows, cols: state.cols } : null; + }; + const expectSize = async (size: { rows: number; cols: number }) => { + await expect.poll(readSize, { timeout: 5_000 }).toEqual(size); + }; + + try { + await ctx.client.subscribeTerminal(terminalId); + await mobileClient.subscribeTerminal(terminalId); + + ctx.client.sendTerminalInput(terminalId, { + type: "resize", + rows: 56, + cols: 200, + }); + await expectSize({ rows: 56, cols: 200 }); + + mobileClient.sendTerminalInput(terminalId, { + type: "resize", + rows: 42, + cols: 54, + }); + await expectSize({ rows: 42, cols: 54 }); + + // Staying attached and receiving output cannot implicitly take ownership back. + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "printf 'passive-client-output\\n'\r", + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(readSize()).toEqual({ rows: 42, cols: 54 }); + + ctx.client.sendTerminalInput(terminalId, { + type: "resize", + rows: 38, + cols: 81, + }); + await expectSize({ rows: 38, cols: 81 }); + + mobileClient.sendTerminalInput(terminalId, { + type: "resize", + rows: 42, + cols: 54, + }); + await expectSize({ rows: 42, cols: 54 }); + } finally { + await mobileClient.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("resize does not stall streamed output for an attached client", async () => { const cwd = tmpCwd(); const created = await createTerminalInWorkspace(ctx.client, { cwd }); diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 650e5150f4..800fa3c8c6 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -933,6 +933,8 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.stableProjectIdentity).toBe(true); expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); + expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); + expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true); expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); await server.close(); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index c1980c3d2a..fa70b99058 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1531,6 +1531,10 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, + // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. + "terminal-input-mode-replay": true, + // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. + "terminal-size-ownership": true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. diff --git a/packages/server/src/terminal/terminal-session-controller.resize.test.ts b/packages/server/src/terminal/terminal-session-controller.resize.test.ts new file mode 100644 index 0000000000..63181b2482 --- /dev/null +++ b/packages/server/src/terminal/terminal-session-controller.resize.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "vitest"; + +import { + TerminalStreamOpcode, + encodeTerminalResizePayload, + type TerminalStreamFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import type { SessionOutboundMessage, TerminalState } from "@getpaseo/protocol/messages"; +import type pino from "pino"; + +import { TerminalSessionController } from "./terminal-session-controller.js"; +import type { TerminalManager } from "./terminal-manager.js"; +import type { TerminalSession } from "./terminal.js"; + +function createFixture(): { + terminal: TerminalSession; + terminalManager: TerminalManager; + appliedSizes: string[]; +} { + const appliedSizes: string[] = []; + let size = { rows: 24, cols: 80 }; + const state = (): TerminalState => ({ + rows: size.rows, + cols: size.cols, + grid: [], + scrollback: [], + cursor: { row: 0, col: 0 }, + }); + const terminal = { + id: "terminal-1", + name: "Terminal", + cwd: "/tmp", + workspaceId: "workspace-1", + getSize: () => size, + send: (message: { type: string; rows?: number; cols?: number }) => { + if (message.type !== "resize" || message.rows === undefined || message.cols === undefined) { + return; + } + size = { rows: message.rows, cols: message.cols }; + appliedSizes.push(`${message.cols}x${message.rows}`); + }, + subscribe: () => () => {}, + onExit: () => () => {}, + } as unknown as TerminalSession; + const terminalManager = { + getTerminal: () => terminal, + getTerminalState: async () => ({ state: state(), revision: 0 }), + } as unknown as TerminalManager; + return { terminal, terminalManager, appliedSizes }; +} + +function createController( + terminalManager: TerminalManager, + outbound: SessionOutboundMessage[] = [], +): TerminalSessionController { + return new TerminalSessionController({ + terminalManager, + emit: (message) => outbound.push(message), + emitBinary: () => {}, + hasBinaryChannel: () => true, + isPathWithinRoot: () => false, + sessionLogger: { warn: () => {}, error: () => {} } as unknown as pino.Logger, + }); +} + +function resizeFrame(input: { + rows: number; + cols: number; + intent?: "claim" | "update"; +}): TerminalStreamFrame { + return { + opcode: TerminalStreamOpcode.Resize, + slot: 0, + payload: encodeTerminalResizePayload(input), + }; +} + +describe("terminal session controller size ownership", () => { + test("routes JSON, binary, and attach sizes through one connection-owned arbiter", async () => { + const { terminalManager, appliedSizes } = createFixture(); + const controllerA = createController(terminalManager); + const controllerB = createController(terminalManager); + + controllerA.dispatch({ + type: "terminal_input", + terminalId: "terminal-1", + message: { type: "resize", rows: 30, cols: 100, intent: "claim" }, + }); + controllerA.dispatch({ + type: "terminal_input", + terminalId: "terminal-1", + message: { type: "resize", rows: 31, cols: 101, intent: "update" }, + }); + controllerB.dispatch({ + type: "terminal_input", + terminalId: "terminal-1", + message: { type: "resize", rows: 32, cols: 102, intent: "update" }, + }); + + await controllerB.dispatch({ + type: "subscribe_terminal_request", + terminalId: "terminal-1", + requestId: "subscribe-b", + restore: { mode: "live" }, + }); + controllerB.handleBinaryFrame(resizeFrame({ rows: 33, cols: 103, intent: "claim" })); + controllerA.dispatch({ + type: "terminal_input", + terminalId: "terminal-1", + message: { type: "resize", rows: 34, cols: 104, intent: "update" }, + }); + + await controllerA.dispatch({ + type: "subscribe_terminal_request", + terminalId: "terminal-1", + requestId: "subscribe-a", + restore: { mode: "live", size: { rows: 35, cols: 105 } }, + }); + controllerB.handleBinaryFrame(resizeFrame({ rows: 36, cols: 106, intent: "update" })); + + expect(appliedSizes).toEqual(["100x30", "101x31", "103x33", "105x35"]); + }); +}); diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 81c3a15d6c..e2e07ce1d3 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -74,7 +74,7 @@ describe("terminal-session-controller restore", () => { getSize: () => ({ rows: 1, cols: 80 }), getState: () => terminalState("restore-before"), getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }), - getReplayPreamble: () => "", + getReplayPreamble: () => "\x1b[?1h\x1b[?2004h", getTitle: () => undefined, getActivity: () => null, setActivity: vi.fn(), @@ -146,7 +146,9 @@ describe("terminal-session-controller restore", () => { TerminalStreamOpcode.Output, ]); expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before"); - expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n"); + expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe( + "\x1b[?1h\x1b[?2004hrestore-after\n", + ); }); }); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index dd7d053d62..05d544dcc2 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -33,6 +33,7 @@ import { } from "./terminal-restore.js"; import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; +import { applyTerminalSize } from "./terminal-size-ownership.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; @@ -130,6 +131,7 @@ export class TerminalSessionController { private readonly listTerminalWorkspaceRoots: () => Promise; private readonly clientSupportsWrapReflow: () => boolean; private readonly getClientBufferedAmount: () => number | null; + private readonly terminalSizeOwner = {}; // A subscription is scoped to a (cwd, workspaceId) pair, keyed by // terminalSubscriptionKey: two workspaces sharing a cwd subscribe and unsub @@ -239,7 +241,7 @@ export class TerminalSessionController { if (!resize) { return; } - terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols }); + applyTerminalSize(terminal, this.terminalSizeOwner, resize); return; } @@ -660,17 +662,10 @@ export class TerminalSessionController { this.ensureExitSubscription(session); if (msg.restore?.size) { - const currentSize = session.getSize(); - if ( - currentSize.rows !== msg.restore.size.rows || - currentSize.cols !== msg.restore.size.cols - ) { - session.send({ - type: "resize", - rows: msg.restore.size.rows, - cols: msg.restore.size.cols, - }); - } + applyTerminalSize(session, this.terminalSizeOwner, { + ...msg.restore.size, + intent: "claim", + }); } const slot = this.bindActiveStream(session, { restore: msg.restore }); @@ -725,10 +720,8 @@ export class TerminalSessionController { this.ensureExitSubscription(session); if (msg.message.type === "resize") { - const currentSize = session.getSize(); - if (currentSize.rows === msg.message.rows && currentSize.cols === msg.message.cols) { - return; - } + applyTerminalSize(session, this.terminalSizeOwner, msg.message); + return; } session.send(msg.message); diff --git a/packages/server/src/terminal/terminal-size-ownership.test.ts b/packages/server/src/terminal/terminal-size-ownership.test.ts new file mode 100644 index 0000000000..2fb0fb3d2a --- /dev/null +++ b/packages/server/src/terminal/terminal-size-ownership.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest"; + +import type { TerminalSession } from "./terminal.js"; +import { applyTerminalSize } from "./terminal-size-ownership.js"; + +function createTerminal(): { terminal: TerminalSession; appliedSizes: string[] } { + const appliedSizes: string[] = []; + let size = { rows: 24, cols: 80 }; + const terminal = { + getSize: () => size, + send: (message: { type: "resize"; rows: number; cols: number }) => { + size = { rows: message.rows, cols: message.cols }; + appliedSizes.push(`${message.cols}x${message.rows}`); + }, + } as TerminalSession; + return { terminal, appliedSizes }; +} + +describe("terminal size ownership", () => { + test("only the latest claimant can update a terminal size", () => { + const { terminal, appliedSizes } = createTerminal(); + const ownerA = {}; + const ownerB = {}; + + expect(applyTerminalSize(terminal, ownerA, { rows: 30, cols: 100, intent: "claim" })).toBe( + true, + ); + expect(applyTerminalSize(terminal, ownerA, { rows: 31, cols: 101, intent: "update" })).toBe( + true, + ); + expect(applyTerminalSize(terminal, ownerB, { rows: 32, cols: 102, intent: "update" })).toBe( + false, + ); + expect(applyTerminalSize(terminal, ownerB, { rows: 33, cols: 103, intent: "claim" })).toBe( + true, + ); + expect(applyTerminalSize(terminal, ownerA, { rows: 34, cols: 104, intent: "update" })).toBe( + false, + ); + expect(applyTerminalSize(terminal, ownerA, { rows: 35, cols: 105 })).toBe(true); + expect(applyTerminalSize(terminal, ownerB, { rows: 36, cols: 106, intent: "update" })).toBe( + false, + ); + + expect(appliedSizes).toEqual(["100x30", "101x31", "103x33", "105x35"]); + }); + + test("transfers ownership when a new claimant reports the current size", () => { + const { terminal, appliedSizes } = createTerminal(); + const ownerA = {}; + const ownerB = {}; + + applyTerminalSize(terminal, ownerA, { rows: 30, cols: 100, intent: "claim" }); + applyTerminalSize(terminal, ownerB, { rows: 30, cols: 100, intent: "claim" }); + applyTerminalSize(terminal, ownerA, { rows: 31, cols: 101, intent: "update" }); + applyTerminalSize(terminal, ownerB, { rows: 32, cols: 102, intent: "update" }); + + expect(appliedSizes).toEqual(["100x30", "102x32"]); + }); +}); diff --git a/packages/server/src/terminal/terminal-size-ownership.ts b/packages/server/src/terminal/terminal-size-ownership.ts new file mode 100644 index 0000000000..626ae8a634 --- /dev/null +++ b/packages/server/src/terminal/terminal-size-ownership.ts @@ -0,0 +1,38 @@ +import type { TerminalSession } from "./terminal.js"; + +interface TerminalSizeRequest { + rows: number; + cols: number; + intent?: "claim" | "update"; +} + +const terminalSizeOwners = new WeakMap(); + +export function applyTerminalSize( + terminal: TerminalSession, + owner: object, + request: TerminalSizeRequest, +): boolean { + const intent = resolveTerminalSizeIntent(request.intent); + if (intent === "update" && terminalSizeOwners.get(terminal) !== owner) { + return false; + } + + if (intent === "claim") { + terminalSizeOwners.set(terminal, owner); + } + + const currentSize = terminal.getSize(); + if (currentSize.rows !== request.rows || currentSize.cols !== request.cols) { + terminal.send({ type: "resize", rows: request.rows, cols: request.cols }); + } + return true; +} + +function resolveTerminalSizeIntent(intent: TerminalSizeRequest["intent"]): "claim" | "update" { + if (intent) { + return intent; + } + // COMPAT(terminalSizeOwnership): added in v0.2.6, remove after 2027-02-02 once the client floor sends resize intent. + return "claim"; +} diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 5a3f36ef97..4e16f8de04 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -130,21 +130,27 @@ function watchTerminal(session: TerminalSession): void { }); outputCoalescerByTerminalId.set(session.id, outputCoalescer); - const unsubscribeMessage = session.subscribe((message) => { - if (message.type === "output") { - pendingOutputRevision = message.revision; - outputCoalescer.handle(message.data); - return; - } - // Non-output messages (snapshot/snapshotReady/titleChange) must not jump - // ahead of buffered output: flush the coalescer first, then forward. - outputCoalescer.flush(); - sendToParent({ - type: "terminalMessage", - terminalId: session.id, - message, - }); - }); + const unsubscribeMessage = session.subscribe( + (message) => { + if (message.type === "output") { + pendingOutputRevision = message.revision; + outputCoalescer.handle(message.data); + return; + } + // Non-output messages (snapshot/snapshotReady/titleChange) must not jump + // ahead of buffered output: flush the coalescer first, then forward. + outputCoalescer.flush(); + sendToParent({ + type: "terminalMessage", + terminalId: session.id, + message, + }); + }, + // Creation already sends an authoritative state in terminalCreated. A second + // asynchronous state snapshot can arrive after the create response and + // overwrite a resize issued immediately by the caller. + { initialSnapshot: "ready" }, + ); const unsubscribeExit = session.onExit((info) => { outputCoalescer.flush(); clearTerminalSubscriptions(session.id); diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index edbe9a5c2d..2dc999c0c3 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -352,12 +352,10 @@ it("refreshes cached terminal size after worker resize", async () => { const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); session.send({ type: "resize", rows: 10, cols: 40 }); + const snapshot = await manager.getTerminalState(session.id); - await waitForCondition(() => { - const size = session.getSize(); - return size.rows === 10 && size.cols === 40; - }, 10000); - + expect(snapshot?.state.rows).toBe(10); + expect(snapshot?.state.cols).toBe(40); expect(session.getState().rows).toBe(10); expect(session.getState().cols).toBe(40); }); diff --git a/scripts/test-mobile-agent-device.sh b/scripts/test-mobile-agent-device.sh new file mode 100755 index 0000000000..4645770f55 --- /dev/null +++ b/scripts/test-mobile-agent-device.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STATE_DIR="${PASEO_MOBILE_E2E_STATE_DIR:-${REPO_ROOT}/.dev/agent-device-e2e}" +ARTIFACTS_DIR="${PASEO_MOBILE_E2E_ARTIFACTS_DIR:-${REPO_ROOT}/.dev/agent-device-artifacts}" +METRO_PORT="${PASEO_MOBILE_E2E_METRO_PORT:-8081}" +METRO_PID=0 + +cleanup() { + AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device daemon stop --clean >/dev/null 2>&1 || true + if [[ "${METRO_PID}" -gt 0 ]]; then + pkill -TERM -P "${METRO_PID}" >/dev/null 2>&1 || true + kill -TERM "${METRO_PID}" >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT INT TERM +cleanup +mkdir -p "${STATE_DIR}" "${ARTIFACTS_DIR}" + +METRO_RESULT="$({ + AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device metro prepare \ + --project-root "${REPO_ROOT}/packages/app" \ + --kind expo \ + --port "${METRO_PORT}" \ + --public-base-url "http://127.0.0.1:${METRO_PORT}" \ + --json +})" +printf '%s\n' "${METRO_RESULT}" +METRO_PID="$(printf '%s' "${METRO_RESULT}" | node -e ' + let input = ""; + process.stdin.on("data", (chunk) => { input += chunk; }); + process.stdin.on("end", () => { + const result = JSON.parse(input); + process.stdout.write(String(result.data?.started ? result.data.pid : 0)); + }); +')" + +AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device prepare ios-runner \ + --platform ios \ + --timeout 120000 + +AGENT_DEVICE_STATE_DIR="${STATE_DIR}" agent-device test \ + "${REPO_ROOT}/packages/app/e2e/mobile/agent-device" \ + --metro-port "${METRO_PORT}" \ + --timeout 120000 \ + --fail-fast \ + --artifacts-dir "${ARTIFACTS_DIR}"