mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Improve mobile terminal input, selection, and resizing (#1607)
* Improve mobile terminal typing and selection * Address mobile terminal review feedback * Remove dead native terminal input branch * Replay bracketed paste in terminal preamble * Stop per-key terminal resize claims * Stabilize terminal resize browser test * Use tracked terminal mode for native paste * Restore terminal toolbar focus handling * Adapt native terminal to active visibility state * Stabilize native mobile terminal interactions * Add native terminal fallback boundary * test(app): expect explicit terminal resize reclaim * fix(app): stabilize terminal buffer coordinates * fix(app): restrict terminal resize ownership * fix(terminal): gate restored input modes * fix(app): recover stalled terminal WebViews * chore(app): remove obsolete terminal harnesses * fix(app): make terminal resize claims explicit * test(app): stabilize native terminal device flows * test(app): track absolute scrollback coordinates * fix(app): isolate terminal WebView streams * fix(terminal): arbitrate size ownership across clients The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it. * test(server): make worker resize assertion portable Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
This commit is contained in:
@@ -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.
|
||||
|
||||
+42
-1
@@ -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 <path> --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/`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<TerminalSize> {
|
||||
// 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<TerminalSize | null> {
|
||||
const text = await getTerminalBufferText(page);
|
||||
async function readLatestPtySize(
|
||||
harness: TerminalE2EHarness,
|
||||
terminalId: string,
|
||||
): Promise<TerminalSize | null> {
|
||||
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<number | null> {
|
||||
return (await readXtermSize(page)).rows;
|
||||
}
|
||||
|
||||
async function ptyRowsMatchXtermRows(page: Page): Promise<boolean> {
|
||||
async function ptyRowsMatchXtermRows(
|
||||
page: Page,
|
||||
harness: TerminalE2EHarness,
|
||||
terminalId: string,
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
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] },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<TerminalActionButton
|
||||
label="Paste"
|
||||
accessibilityLabel="Paste"
|
||||
testID="terminal-paste"
|
||||
disabled={!hasClipboardText}
|
||||
onPress={onPaste}
|
||||
variant="key"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TerminalFloatingCopyActionProps {
|
||||
hasSelection: boolean;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
export function TerminalFloatingCopyAction({
|
||||
hasSelection,
|
||||
onCopy,
|
||||
}: TerminalFloatingCopyActionProps) {
|
||||
if (!hasSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalActionButton
|
||||
label="Copy"
|
||||
accessibilityLabel="Copy"
|
||||
testID="terminal-copy"
|
||||
onPress={onCopy}
|
||||
variant="floating"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
testID={testID}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
>
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}));
|
||||
@@ -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<string>;
|
||||
clear: () => void;
|
||||
claimSize: () => void;
|
||||
showKeyboard: () => void;
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
export interface TerminalEmulatorProps {
|
||||
dom?: unknown;
|
||||
ref: Ref<TerminalEmulatorHandle>;
|
||||
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> | void;
|
||||
onFocus?: () => Promise<void> | void;
|
||||
onResize?: (input: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim: boolean;
|
||||
forceClaim?: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||
onSelectionChange?: (hasSelection: boolean) => void;
|
||||
onResolveLocalFileLink?: (
|
||||
source: TerminalLocalFileLinkSource,
|
||||
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
|
||||
onOpenLocalFileLink?: (
|
||||
target: TerminalLocalFileLinkTarget,
|
||||
disposition: "main" | "side",
|
||||
) => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<typeof WebView>;
|
||||
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<WebView>(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<BridgeInboundMessage[]>([]);
|
||||
const outputDecoderRef = useRef(new TextDecoder());
|
||||
const mountRequestedStreamKeyRef = useRef<string | null>(null);
|
||||
const rendererReadyStreamKeyRef = useRef<string | null>(null);
|
||||
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingTapRef = useRef<PendingTerminalTap | null>(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<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
|
||||
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<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
|
||||
) => {
|
||||
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<NonNullable<WebViewProps["onLoadStart"]>>(() => {
|
||||
bridgeReadyRef.current = false;
|
||||
mountRequestedStreamKeyRef.current = null;
|
||||
rendererReadyStreamKeyRef.current = null;
|
||||
scheduleBridgeReadyWatchdog();
|
||||
}, [scheduleBridgeReadyWatchdog]);
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback<
|
||||
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
|
||||
>(() => {
|
||||
resetWebViewDocument();
|
||||
}, [resetWebViewDocument]);
|
||||
|
||||
const handleRenderProcessGone = useCallback<
|
||||
NonNullable<WebViewProps["onRenderProcessGone"]>
|
||||
>(() => {
|
||||
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<StyleProp<ViewStyle>>(
|
||||
() => [styles.root, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webView, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webViewContainer, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rootStyle} testID={testId}>
|
||||
<WebView
|
||||
key={webViewEpoch}
|
||||
ref={webViewRef}
|
||||
source={TERMINAL_WEBVIEW_SOURCE}
|
||||
style={webViewStyle}
|
||||
containerStyle={webViewContainerStyle}
|
||||
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
|
||||
scrollEnabled
|
||||
nestedScrollEnabled
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
textInteractionEnabled
|
||||
allowsLinkPreview={false}
|
||||
setSupportMultipleWindows={false}
|
||||
setBuiltInZoomControls={false}
|
||||
setDisplayZoomControls={false}
|
||||
textZoom={100}
|
||||
onTouchStart={handleWebViewTouchStart}
|
||||
onTouchMove={handleWebViewTouchMove}
|
||||
onTouchEnd={handleWebViewTouchEnd}
|
||||
onTouchCancel={handleWebViewTouchCancel}
|
||||
onMessage={handleMessage}
|
||||
onLoadStart={handleLoadStart}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
webViewContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -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<TerminalEmulatorHandle>;
|
||||
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> | void;
|
||||
onFocus?: () => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||
onResolveLocalFileLink?: (
|
||||
source: TerminalLocalFileLinkSource,
|
||||
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
|
||||
onOpenLocalFileLink?: (
|
||||
target: TerminalLocalFileLinkTarget,
|
||||
disposition: "main" | "side",
|
||||
) => Promise<void> | 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<typeof WebView>;
|
||||
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<WebView>(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<BridgeInboundMessage[]>([]);
|
||||
const outputDecoderRef = useRef(new TextDecoder());
|
||||
const mountedStreamKeyRef = useRef<string | null>(null);
|
||||
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingTapRef = useRef<PendingTerminalTap | null>(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<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
|
||||
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<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
|
||||
) => {
|
||||
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<NonNullable<WebViewProps["onLoadStart"]>>(() => {
|
||||
bridgeReadyRef.current = false;
|
||||
mountedStreamKeyRef.current = null;
|
||||
scheduleBridgeReadyWatchdog();
|
||||
}, [scheduleBridgeReadyWatchdog]);
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback<
|
||||
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
|
||||
>(() => {
|
||||
resetWebViewDocument();
|
||||
}, [resetWebViewDocument]);
|
||||
|
||||
const handleRenderProcessGone = useCallback<
|
||||
NonNullable<WebViewProps["onRenderProcessGone"]>
|
||||
>(() => {
|
||||
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<StyleProp<ViewStyle>>(
|
||||
() => [styles.root, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webView, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.webViewContainer, { backgroundColor }],
|
||||
[backgroundColor],
|
||||
);
|
||||
onRendererReadyChange?.({ streamKey, isReady: false });
|
||||
}, [onRendererReadyChange, streamKey]);
|
||||
|
||||
return (
|
||||
<View style={rootStyle} testID={testId}>
|
||||
<WebView
|
||||
key={webViewEpoch}
|
||||
ref={webViewRef}
|
||||
source={TERMINAL_WEBVIEW_SOURCE}
|
||||
style={webViewStyle}
|
||||
containerStyle={webViewContainerStyle}
|
||||
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
|
||||
scrollEnabled
|
||||
nestedScrollEnabled
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
textInteractionEnabled
|
||||
allowsLinkPreview={false}
|
||||
setSupportMultipleWindows={false}
|
||||
setBuiltInZoomControls={false}
|
||||
setDisplayZoomControls={false}
|
||||
textZoom={100}
|
||||
onTouchStart={handleWebViewTouchStart}
|
||||
onTouchMove={handleWebViewTouchMove}
|
||||
onTouchEnd={handleWebViewTouchEnd}
|
||||
onTouchCancel={handleWebViewTouchCancel}
|
||||
onMessage={handleMessage}
|
||||
onLoadStart={handleLoadStart}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
/>
|
||||
<View style={styles.unsupportedContainer}>
|
||||
<Text style={styles.unsupportedText}>{t("workspace.terminal.updateHost")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 <UnsupportedNativeTerminal {...props} />;
|
||||
}
|
||||
|
||||
const Renderer =
|
||||
rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator;
|
||||
|
||||
return <Renderer key={rendererSelection} {...props} />;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -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<string>;
|
||||
clear: () => void;
|
||||
claimSize: () => void;
|
||||
showKeyboard: () => void;
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
@@ -93,18 +99,25 @@ interface TerminalEmulatorProps {
|
||||
dom?: DOMProps;
|
||||
ref: Ref<TerminalEmulatorHandle>;
|
||||
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> | void;
|
||||
onFocus?: () => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
|
||||
onResize?: (input: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim: boolean;
|
||||
forceClaim?: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
@@ -114,6 +127,7 @@ interface TerminalEmulatorProps {
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||
onSelectionChange?: (hasSelection: boolean) => void;
|
||||
onResolveLocalFileLink?: (
|
||||
source: TerminalLocalFileLinkSource,
|
||||
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
|
||||
@@ -211,6 +225,18 @@ export default function TerminalEmulator({
|
||||
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const domBridgeRef = useRef<DOMImperativeFactory | null>(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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
testID="terminal-keyboard-toggle"
|
||||
onPress={onToggle}
|
||||
style={pressableStyle}
|
||||
>
|
||||
<Icon color={iconColor} size={16} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(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<TerminalEmulatorHandle>(null);
|
||||
const terminalIdRef = useRef<string>(terminalId);
|
||||
const terminalActiveRef = useRef(isTerminalActive);
|
||||
terminalActiveRef.current = isTerminalActive;
|
||||
const inputModeRef = useRef<TerminalInputModeState>({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
});
|
||||
const inputModeRef = useRef<TerminalInputModeState>(DEFAULT_TERMINAL_INPUT_MODE_STATE);
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(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 (
|
||||
<VirtualKeyButton
|
||||
key={controlId}
|
||||
id={control.button.id}
|
||||
label={control.button.label}
|
||||
keyValue={control.button.key}
|
||||
onSend={sendVirtualKey}
|
||||
/>
|
||||
);
|
||||
case "modifier":
|
||||
return (
|
||||
<ModifierButton
|
||||
key={controlId}
|
||||
modifier={control.modifier}
|
||||
active={modifiers[control.modifier]}
|
||||
onToggle={toggleModifier}
|
||||
/>
|
||||
);
|
||||
case "paste":
|
||||
return showPasteAction ? (
|
||||
<TerminalPasteAction
|
||||
key={controlId}
|
||||
hasClipboardText={hasClipboardText}
|
||||
onPaste={handleTerminalPaste}
|
||||
/>
|
||||
) : null;
|
||||
case "keyboardToggle":
|
||||
return (
|
||||
<KeyboardToggleButton
|
||||
key={controlId}
|
||||
iconColor={keyboardToggleIconColor}
|
||||
isKeyboardVisible={isKeyboardToggleVisible}
|
||||
onToggle={handleKeyboardToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
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({
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{showFloatingCopyAction ? (
|
||||
<View pointerEvents="box-none" style={styles.floatingCopyContainer}>
|
||||
<TerminalFloatingCopyAction hasSelection={hasSelection} onCopy={handleTerminalCopy} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{streamError ? (
|
||||
@@ -847,55 +1058,14 @@ export function TerminalPane({
|
||||
{isMobile ? (
|
||||
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
|
||||
<View style={styles.keyboardRows}>
|
||||
<View style={styles.keyboardRow}>
|
||||
{[KEY_BUTTONS.esc, KEY_BUTTONS.tab].map((button) => (
|
||||
<VirtualKeyButton
|
||||
key={button.id}
|
||||
id={button.id}
|
||||
label={button.label}
|
||||
keyValue={button.key}
|
||||
onSend={sendVirtualKey}
|
||||
/>
|
||||
))}
|
||||
|
||||
<ModifierButton modifier="ctrl" active={modifiers.ctrl} onToggle={toggleModifier} />
|
||||
|
||||
<VirtualKeyButton
|
||||
id={KEY_BUTTONS.up.id}
|
||||
label={KEY_BUTTONS.up.label}
|
||||
keyValue={KEY_BUTTONS.up.key}
|
||||
onSend={sendVirtualKey}
|
||||
/>
|
||||
|
||||
<ModifierButton modifier="shift" active={modifiers.shift} onToggle={toggleModifier} />
|
||||
|
||||
<VirtualKeyButton
|
||||
id={KEY_BUTTONS.backspace.id}
|
||||
label={KEY_BUTTONS.backspace.label}
|
||||
keyValue={KEY_BUTTONS.backspace.key}
|
||||
onSend={sendVirtualKey}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.keyboardRow}>
|
||||
<ModifierButton modifier="alt" active={modifiers.alt} onToggle={toggleModifier} />
|
||||
|
||||
{[
|
||||
KEY_BUTTONS.space,
|
||||
KEY_BUTTONS.left,
|
||||
KEY_BUTTONS.down,
|
||||
KEY_BUTTONS.right,
|
||||
KEY_BUTTONS.enter,
|
||||
].map((button) => (
|
||||
<VirtualKeyButton
|
||||
key={button.id}
|
||||
id={button.id}
|
||||
label={button.label}
|
||||
keyValue={button.key}
|
||||
onSend={sendVirtualKey}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => (
|
||||
<View
|
||||
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
|
||||
style={styles.keyboardRow}
|
||||
>
|
||||
{row.map(renderVirtualKeyboardControl)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : 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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.useLegacyTerminalRenderer === "boolean") {
|
||||
result.useLegacyTerminalRenderer = stored.useLegacyTerminalRenderer;
|
||||
}
|
||||
if (typeof stored.vimKeybindings === "boolean") {
|
||||
result.vimKeybindings = stored.vimKeybindings;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: "جارٍ اللعب...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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: "再生中...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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: "Игра...",
|
||||
|
||||
@@ -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: "正在播放...",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -57,10 +58,12 @@ interface MobilePanelsRuntime {
|
||||
leftCloseGestureRef: RefObject<GestureType | undefined>;
|
||||
leftOpenGestureRef: RefObject<GestureType | undefined>;
|
||||
motionState: SharedValue<MobilePanelMotionState>;
|
||||
openGesturesBlocked: SharedValue<boolean>;
|
||||
position: SharedValue<number>;
|
||||
rightCloseGestureRef: RefObject<GestureType | undefined>;
|
||||
rightOpenGestureRef: RefObject<GestureType | undefined>;
|
||||
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<symbol>());
|
||||
const leftOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const rightCloseGestureRef = useRef<GestureType | undefined>(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]);
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
@@ -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<typeof useVoiceAudioEngineOptional>;
|
||||
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 (
|
||||
<SettingsSection title={t("settings.diagnostics.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{isNative ? (
|
||||
<View style={settingsStyles.row} testID="legacy-terminal-renderer-row">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.diagnostics.legacyTerminalRenderer.label")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.diagnostics.legacyTerminalRenderer.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={useLegacyTerminalRenderer}
|
||||
onValueChange={onUseLegacyTerminalRendererChange}
|
||||
accessibilityLabel={t(
|
||||
"settings.diagnostics.legacyTerminalRenderer.accessibilityLabel",
|
||||
)}
|
||||
testID="legacy-terminal-renderer-switch"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={settingsStyles.row} testID="app-diagnostic-row">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.app.rowTitle")}</Text>
|
||||
@@ -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 (
|
||||
<DiagnosticsSection
|
||||
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
|
||||
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
|
||||
voiceAudioEngine={voiceAudioEngine}
|
||||
isPlaybackTestRunning={isPlaybackTestRunning}
|
||||
playbackTestResult={playbackTestResult}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TextStyle } from "react-native";
|
||||
import type { TerminalCell } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
|
||||
const ANSI_THEME_KEYS = [
|
||||
"black",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"white",
|
||||
"brightBlack",
|
||||
"brightRed",
|
||||
"brightGreen",
|
||||
"brightYellow",
|
||||
"brightBlue",
|
||||
"brightMagenta",
|
||||
"brightCyan",
|
||||
"brightWhite",
|
||||
] as const;
|
||||
|
||||
const CUBE_LEVELS = [0, 0x5f, 0x87, 0xaf, 0xd7, 0xff] as const;
|
||||
const DEFAULT_COLOR_MODE = 0;
|
||||
const ANSI_COLOR_MODE = 1;
|
||||
const INDEXED_COLOR_MODE = 2;
|
||||
const RGB_COLOR_MODE = 3;
|
||||
const FLAG_BOLD = 1 << 0;
|
||||
const FLAG_ITALIC = 1 << 1;
|
||||
const FLAG_UNDERLINE = 1 << 2;
|
||||
const FLAG_DIM = 1 << 3;
|
||||
const FLAG_INVERSE = 1 << 4;
|
||||
const FLAG_STRIKETHROUGH = 1 << 5;
|
||||
|
||||
interface ResolvedCellStyle {
|
||||
key: string;
|
||||
style: TextStyle;
|
||||
}
|
||||
|
||||
interface ResolvedCellColors {
|
||||
foreground: string;
|
||||
background: string;
|
||||
paintsBackground: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalCellStyleResolver {
|
||||
readonly themeKey: string;
|
||||
readonly backgroundColor: string;
|
||||
readonly cursorColor: string;
|
||||
resolve(cell: TerminalCell): ResolvedCellStyle;
|
||||
}
|
||||
|
||||
export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
|
||||
|
||||
function build256Palette(theme: ITheme): string[] {
|
||||
const palette = Array.from({ length: 256 }, () => "");
|
||||
|
||||
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<string, TextStyle>();
|
||||
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Platform } from "react-native";
|
||||
import { resolveTerminalFontFamily } from "../runtime/terminal-font";
|
||||
|
||||
const IOS_LOADED_MONO_FAMILIES: ReadonlySet<string> = new Set(["courier", "courier new", "menlo"]);
|
||||
|
||||
const ANDROID_LOADED_MONO_FAMILIES: ReadonlySet<string> = new Set([
|
||||
"monospace",
|
||||
"sans-serif-monospace",
|
||||
]);
|
||||
|
||||
const IOS_MONO_ALIASES = new Map<string, string>([
|
||||
["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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
}): Promise<void> {
|
||||
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<typeof createNativeHeadlessTerminal>,
|
||||
model: ReturnType<typeof createNativeTerminalScreenModel>,
|
||||
) {
|
||||
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<ReturnType<typeof benchmarkSnapshot>>) {
|
||||
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)],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
dispose(): void;
|
||||
getQueuedChars(): number;
|
||||
}
|
||||
|
||||
export interface NativeTerminalOutputDrainOptions {
|
||||
write: (chunk: string) => Promise<void>;
|
||||
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<typeof setTimeout>);
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
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<object, TerminalCoordinateStorage>();
|
||||
|
||||
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<void> {
|
||||
const text = typeof data === "string" ? data : decoder.decode(data, { stream: true });
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<TextStyle>;
|
||||
}
|
||||
|
||||
interface TerminalGridSelectionRectProps {
|
||||
rect: TerminalSelectionRect;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface TerminalGridViewProps {
|
||||
state: TerminalViewportState;
|
||||
xtermTheme?: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
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<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
styles.run,
|
||||
{
|
||||
backgroundColor: run.style.backgroundColor,
|
||||
height: cellHeight,
|
||||
width: run.cellCount * cellWidth,
|
||||
},
|
||||
],
|
||||
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
|
||||
);
|
||||
const runTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [textStyle, run.style],
|
||||
[run.style, textStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={runStyle}>
|
||||
<Text numberOfLines={1} style={runTextStyle}>
|
||||
{run.text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
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 <View pointerEvents="none" style={rectStyle} />;
|
||||
}
|
||||
|
||||
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<StyleProp<ViewStyle>>(
|
||||
() => [styles.row, { height: cellHeight }],
|
||||
[cellHeight],
|
||||
);
|
||||
const textStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [
|
||||
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 (
|
||||
<View
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessible={accessibilityLabel.length > 0}
|
||||
style={rowStyle}
|
||||
testID={`terminal-row-${row.index}`}
|
||||
>
|
||||
{row.runs.map((run) => (
|
||||
<MemoTerminalGridRun
|
||||
key={run.key}
|
||||
run={run}
|
||||
cellWidth={cellWidth}
|
||||
cellHeight={cellHeight}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<CellMetrics>(() => estimateCellMetrics(fontSize));
|
||||
const measuredMetricsRef = useRef<TerminalGridCellMetrics | null>(null);
|
||||
const [viewport, setViewport] = useState<TerminalGridViewport | null>(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<StyleProp<ViewStyle>>(
|
||||
() => [styles.root, { backgroundColor: resolver.backgroundColor }, style],
|
||||
[resolver.backgroundColor, style],
|
||||
);
|
||||
const gridStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
styles.grid,
|
||||
{
|
||||
width: visibleCols * metrics.cellWidth,
|
||||
height: state.grid.length * metrics.cellHeight,
|
||||
},
|
||||
],
|
||||
[metrics.cellHeight, metrics.cellWidth, state.grid.length, visibleCols],
|
||||
);
|
||||
const measureStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [styles.measureText, { fontFamily: resolvedFontFamily, fontSize }],
|
||||
[resolvedFontFamily, fontSize],
|
||||
);
|
||||
const cursorStyle = useMemo<StyleProp<ViewStyle>>(() => {
|
||||
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 (
|
||||
<View onLayout={handleContainerLayout} pointerEvents="none" style={containerStyle}>
|
||||
<View style={gridStyle}>
|
||||
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
|
||||
{MEASURE_TEXT}
|
||||
</Text>
|
||||
{rows.map((row) => (
|
||||
<MemoTerminalGridRow
|
||||
key={row.index}
|
||||
row={row}
|
||||
cellWidth={metrics.cellWidth}
|
||||
cellHeight={metrics.cellHeight}
|
||||
fontFamily={resolvedFontFamily}
|
||||
fontSize={fontSize}
|
||||
styleEpoch={resolver.themeKey}
|
||||
/>
|
||||
))}
|
||||
{selectionRects.map((rect) => (
|
||||
<MemoTerminalGridSelectionRect key={rect.key} rect={rect} color={selectionColor} />
|
||||
))}
|
||||
{!state.cursor.hidden && <View pointerEvents="none" style={cursorStyle} />}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export type NativeTerminalSizeClaimAction = "focus" | "key" | "paste" | "showKeyboard" | "text";
|
||||
|
||||
export function shouldClaimNativeTerminalSize(action: NativeTerminalSizeClaimAction): boolean {
|
||||
return action !== "text" && action !== "key";
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TextStyle>;
|
||||
}
|
||||
|
||||
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<TerminalInputHandle, TerminalInputProps>(
|
||||
function TerminalInput({ onFocus, onInput, onTerminalKey, style }, ref) {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
const pendingFocusFrameRef = useRef<number | null>(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<TextInputKeyPressEventData>) => {
|
||||
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 (
|
||||
<TextInput
|
||||
key={inputEpoch}
|
||||
ref={inputRef}
|
||||
accessibilityLabel="Terminal input"
|
||||
accessible={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
caretHidden={true}
|
||||
contextMenuHidden={TERMINAL_INPUT_CONTEXT_MENU_HIDDEN}
|
||||
defaultValue=""
|
||||
blurOnSubmit={false}
|
||||
importantForAutofill="no"
|
||||
keyboardType="ascii-capable"
|
||||
multiline={true}
|
||||
onChangeText={handleChangeText}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyPress={handleKeyPress}
|
||||
showSoftInputOnFocus={true}
|
||||
spellCheck={false}
|
||||
style={inputStyle}
|
||||
testID="terminal-native-input"
|
||||
textContentType="none"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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> | 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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function nativeTerminalPerformanceNow(): number {
|
||||
return globalThis.performance?.now?.() ?? Date.now();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<TerminalCell> & { 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<readonly [number, number]> = [
|
||||
[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 }),
|
||||
);
|
||||
}
|
||||
@@ -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<typeof createNativeTerminalScreenModel>) {
|
||||
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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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<typeof createNativeHeadlessTerminal>,
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<string> {
|
||||
const text = extractTerminalSelectedText({
|
||||
terminal: input.terminal,
|
||||
selection: input.selection,
|
||||
});
|
||||
if (text.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
await input.clipboard.writeText(text);
|
||||
return text;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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> | void;
|
||||
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
|
||||
onResize?: (input: TerminalResizeEvent) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
@@ -63,6 +64,33 @@ export interface TerminalEmulatorRuntimeCallbacks {
|
||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface TerminalPasteInput {
|
||||
text: string;
|
||||
bracketedPaste: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalClipboardReader {
|
||||
readText: () => Promise<string>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
const text = await input.clipboard.readText();
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.terminal.paste(text);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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?.({
|
||||
|
||||
@@ -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<string, ControlPosition> {
|
||||
const positions = new Map<string, ControlPosition>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ export class TerminalStreamRouter {
|
||||
payload: encodeTerminalResizePayload({
|
||||
rows: message.rows,
|
||||
cols: message.cols,
|
||||
...(message.intent ? { intent: message.intent } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user