Compare commits

...

8 Commits

Author SHA1 Message Date
Mohamed Boudra
9376de1ef1 Restore terminal toolbar focus handling 2026-06-19 11:54:12 +07:00
Mohamed Boudra
ad30f65549 Use tracked terminal mode for native paste 2026-06-19 11:41:41 +07:00
Mohamed Boudra
ee92843965 Stabilize terminal resize browser test 2026-06-19 11:39:03 +07:00
Mohamed Boudra
e39cbb4643 Stop per-key terminal resize claims 2026-06-19 11:24:38 +07:00
Mohamed Boudra
25349e0fe5 Replay bracketed paste in terminal preamble 2026-06-19 11:08:49 +07:00
Mohamed Boudra
e5322b5b63 Remove dead native terminal input branch 2026-06-19 10:57:14 +07:00
Mohamed Boudra
2b4f133a6c Address mobile terminal review feedback 2026-06-19 10:43:21 +07:00
Mohamed Boudra
ff9991acc2 Improve mobile terminal typing and selection 2026-06-19 10:20:34 +07:00
65 changed files with 7792 additions and 666 deletions

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# Common helpers for native-terminal Maestro flows. Each per-flow harness
# sources this file and calls run_flow_with_setup.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
MAESTRO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
SIMULATOR_UDID="${PASEO_MAESTRO_UDID:-47FB40E1-3304-4516-B8BC-D75853EF1B47}"
log() {
echo "[native-terminal-harness] $*" >&2
}
terminal_ids() {
cd "$REPO_ROOT"
npm run --silent cli -- terminal ls --all --json 2>/dev/null \
| node -e 'const data=[]; process.stdin.on("data", c=>data.push(c)); process.stdin.on("end", ()=>{ const list=JSON.parse(data.join("")); for (const terminal of list) console.log(terminal.id); });'
}
send_to_terminal() {
local terminal_id="$1"
shift
cd "$REPO_ROOT"
npm run --silent cli -- terminal send-keys "$terminal_id" "$@"
}
set_simulator_clipboard() {
local text="$1"
if xcrun simctl pbcopy "$SIMULATOR_UDID" <<< "$text" 2>/dev/null; then
log "Set simulator clipboard"
else
log "WARNING: could not set simulator clipboard; flow may fail"
fi
}
read_simulator_clipboard() {
xcrun simctl pbpaste "$SIMULATOR_UDID" 2>/dev/null || true
}
capture_terminal() {
local terminal_id="$1"
cd "$REPO_ROOT"
npm run --silent cli -- terminal capture --scrollback "$terminal_id"
}
write_temp_flow() {
local name="$1"
local flow_dir="${TMPDIR:-/tmp}/paseo-native-terminal-maestro-flows"
mkdir -p "$flow_dir"
local flow_file
flow_file="$(mktemp "$flow_dir/$name.XXXXXX")"
cat >"$flow_file"
printf '%s\n' "$flow_file"
}
maestro_bin() {
local bin
bin="$(command -v maestro || true)"
if [[ -z "$bin" ]]; then
log "ERROR: maestro not found on PATH"
return 1
fi
printf '%s\n' "$bin"
}
run_maestro_flow() {
local flow_file="$1"
local bin
bin="$(maestro_bin)"
local output_dir
output_dir="$(mktemp -d "${TMPDIR:-/tmp}/paseo-native-terminal-maestro-output.XXXXXX")"
log "Running Maestro flow $(basename "$flow_file") with screenshots in $output_dir"
(cd "$output_dir" && "$bin" test --udid "$SIMULATOR_UDID" "$flow_file") >&2
}
run_maestro_flow_allow_failure() {
local flow_file="$1"
local bin
bin="$(maestro_bin)"
local output_dir
output_dir="$(mktemp -d "${TMPDIR:-/tmp}/paseo-native-terminal-maestro-output.XXXXXX")"
log "Running Maestro probe $(basename "$flow_file") with screenshots in $output_dir"
set +e
(cd "$output_dir" && "$bin" test --udid "$SIMULATOR_UDID" "$flow_file") >&2
local status=$?
set -e
return "$status"
}
assert_terminal_surface_visible() {
local flow_file
flow_file="$(write_temp_flow terminal-visible <<YAML
appId: $APP_ID
---
- assertVisible:
id: "terminal-virtual-keyboard"
YAML
)"
run_maestro_flow_allow_failure "$flow_file"
}
ensure_terminal_surface_visible() {
if assert_terminal_surface_visible; then
return
fi
log "Terminal surface is not visible; trying to create/open a terminal from the workspace header"
local flow_file
flow_file="$(write_temp_flow ensure-terminal <<YAML
appId: $APP_ID
---
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- tapOn:
id: "sidebar-close"
- waitForAnimationToEnd
- tapOn:
id: "workspace-header-menu-trigger"
- tapOn:
id: "workspace-header-new-terminal"
- extendedWaitUntil:
visible:
id: "terminal-virtual-keyboard"
timeout: 30000
YAML
)"
run_maestro_flow "$flow_file"
}
assert_text_visible() {
local text="$1"
local flow_file
flow_file="$(write_temp_flow text-visible <<YAML
appId: $APP_ID
---
- extendedWaitUntil:
visible: "$text"
timeout: 8000
YAML
)"
run_maestro_flow_allow_failure "$flow_file"
}
visible_terminal_id() {
ensure_terminal_surface_visible
local ids
ids="$(terminal_ids)"
if [[ -z "$ids" ]]; then
log "ERROR: no terminals found via 'paseo terminal ls --all'"
return 1
fi
local terminal_id
while IFS= read -r terminal_id; do
[[ -n "$terminal_id" ]] || continue
local marker="VT_$RANDOM"
log "Probing visible terminal candidate $terminal_id"
send_to_terminal "$terminal_id" "clear" Enter >/dev/null
send_to_terminal "$terminal_id" "echo $marker" Enter >/dev/null
if assert_text_visible "$marker"; then
printf '%s\n' "$terminal_id"
return
fi
done <<< "$ids"
log "ERROR: no listed terminal matched the visible terminal surface"
return 1
}
assert_terminal_output_contains() {
local terminal_id="$1"
local marker="$2"
local output
output="$(capture_terminal "$terminal_id")"
if [[ "$output" == *"$marker"* ]]; then
log "PASS: terminal output contains $marker"
return
fi
log "FAIL: terminal output did not contain $marker"
return 1
}
assert_terminal_output_count_at_least() {
local terminal_id="$1"
local marker="$2"
local minimum="$3"
local output
output="$(capture_terminal "$terminal_id")"
local count
count="$(MARKER="$marker" OUTPUT="$output" node -e 'const marker = process.env.MARKER ?? ""; const output = process.env.OUTPUT ?? ""; process.stdout.write(String(marker ? output.split(marker).length - 1 : 0));')"
if (( count >= minimum )); then
log "PASS: terminal output contains $marker $count times"
return
fi
log "FAIL: terminal output contained $marker $count times, expected at least $minimum"
return 1
}
assert_maestro_input_does_not_reach_terminal() {
local terminal_id="$1"
local marker="$2"
local flow_file
flow_file="$(write_temp_flow no-focus-input-probe <<YAML
appId: $APP_ID
---
- inputText: "$marker"
YAML
)"
if run_maestro_flow_allow_failure "$flow_file"; then
log "Maestro inputText completed; checking that terminal did not receive $marker"
else
log "Maestro inputText had no focused terminal target; checking output anyway"
fi
local output
output="$(capture_terminal "$terminal_id")"
if [[ "$output" == *"$marker"* ]]; then
log "FAIL: terminal received no-focus probe $marker"
return 1
fi
log "PASS: terminal did not receive no-focus probe $marker"
}
# Default per-flow setup: find the visible terminal and optionally seed it.
# Callers can override NATIVE_TERMINAL_ID before sourcing.
: "${NATIVE_TERMINAL_ID:=}"
require_terminal_id() {
if [[ -z "$NATIVE_TERMINAL_ID" ]]; then
NATIVE_TERMINAL_ID="$(visible_terminal_id)"
fi
if [[ -z "$NATIVE_TERMINAL_ID" ]]; then
log "ERROR: no visible active terminal found"
return 1
fi
log "Using terminal $NATIVE_TERMINAL_ID"
}

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding shell history for history-arrows flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo MAESTRO_HISTORY_OK-1" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-history-arrows.yaml"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding scrollback for scroll-no-focus flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
# Emit a top marker, a screenful of filler, and a bottom marker so scrolling
# up reveals the top marker while the bottom marker is initially visible.
seed_command='{ echo SCROLL_TOP_MARKER; for i in $(seq 1 80); do echo filler-$i; done; echo SCROLL_BOTTOM_MARKER; }'
send_to_terminal "$NATIVE_TERMINAL_ID" "$seed_command" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-scroll-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SCROLL_SHOULD_NOT_TYPE_$RANDOM"

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding selectable text for selection-drag flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo SELECT_DRAG_OK" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-selection-drag-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SELECTION_DRAG_SHOULD_NOT_TYPE_$RANDOM"
log "Reading simulator clipboard"
CLIPBOARD="$(read_simulator_clipboard)"
if [[ -n "$CLIPBOARD" && ("SELECT_DRAG_OK" == "$CLIPBOARD"* || "$CLIPBOARD" == *"SELECT_DRAG_OK"*) ]]; then
log "PASS: Copy wrote selected terminal marker text: $CLIPBOARD"
else
log "FAIL: Copy did not write selected terminal marker text: $CLIPBOARD"
exit 1
fi

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Ensuring terminal is visible for sidebar-swipe flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo SIDEBAR_SWIPE_OK" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-sidebar-swipe-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SIDEBAR_SWIPE_SHOULD_NOT_TYPE_$RANDOM"

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Clearing terminal for tap-focus flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-tap-focus-keyboard.yaml"
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_TAP_FOCUS_OK"

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding terminal virtual keyboard UX flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "printf '\n\n\n\nMAESTRO_COPY_OK\n'" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo MAESTRO_VK_HISTORY_OK" Enter
set_simulator_clipboard "echo MAESTRO_PASTE_OK"
run_maestro_flow "$MAESTRO_DIR/native-terminal-virtual-keyboard-ux.yaml"
assert_terminal_output_count_at_least "$NATIVE_TERMINAL_ID" "MAESTRO_VK_HISTORY_OK" 2
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_TOGGLE_INPUT_OK"
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_PASTE_OK"
log "Reading simulator clipboard after Copy"
CLIPBOARD="$(read_simulator_clipboard)"
if [[ -n "$CLIPBOARD" && ("MAESTRO_COPY_OK" == "$CLIPBOARD"* || "$CLIPBOARD" == *"MAESTRO_COPY_OK"*) ]]; then
log "PASS: Copy wrote selected terminal text: $CLIPBOARD"
else
log "FAIL: Copy did not write selected terminal text: $CLIPBOARD"
exit 1
fi

View File

@@ -0,0 +1,29 @@
appId: sh.paseo.debug
---
# Self-contained history/arrows flow.
# Precondition: the harness has run `echo MAESTRO_HISTORY_OK-1` in the
# terminal, so shell history contains the command.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# The seeded command output should already be visible.
- assertVisible:
text: "MAESTRO_HISTORY_OK-1"
# Tap the virtual up arrow to recall the last command.
- tapOn:
id: "terminal-key-up"
# Submit it again.
- tapOn:
id: "terminal-key-enter"
# The command ran a second time, so the marker is still visible.
- extendedWaitUntil:
visible: "MAESTRO_HISTORY_OK-1"
timeout: 15000
- takeScreenshot: native-terminal-history-arrows

View File

@@ -0,0 +1,38 @@
appId: sh.paseo.debug
---
# Self-contained scroll-no-focus flow.
# Precondition: the harness has filled scrollback with a top marker, filler
# lines, and a bottom marker.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# The bottom marker should be visible before scrolling.
- assertVisible:
text: "SCROLL_BOTTOM_MARKER"
# Scroll up in history by dragging downward across the terminal surface.
- swipe:
start: "50%,40%"
end: "50%,80%"
duration: 400
- waitForAnimationToEnd
- swipe:
start: "50%,40%"
end: "50%,80%"
duration: 400
- waitForAnimationToEnd
# Scrolling should reveal the older top marker without focusing the terminal.
- assertVisible:
text: "SCROLL_TOP_MARKER"
- assertVisible:
id: "terminal-follow-bottom"
# The scroll gesture must not show the OS keyboard. The harness also probes with
# Maestro inputText and fails if text reaches the terminal after this gesture.
- assertNotVisible: "return"
- takeScreenshot: native-terminal-scroll-no-focus

View File

@@ -0,0 +1,45 @@
appId: sh.paseo.debug
---
# Self-contained long-press selection flow.
# Precondition: the harness has printed SELECT_DRAG_OK in the terminal.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# Selection starts with long press.
- longPressOn:
point: "1%,21%"
- waitForAnimationToEnd
- assertVisible:
id: "terminal-copy"
enabled: true
# Once selection is active, horizontal drag extends selection instead of opening
# navigation or focusing the OS keyboard.
- swipe:
start: "1%,21%"
end: "100%,21%"
duration: 1200
- waitForAnimationToEnd
# The active-selection drag must not open the sidebar.
- assertNotVisible:
id: "sidebar-sessions"
# The active-selection drag must not show the OS keyboard. The harness also
# probes with Maestro inputText and fails if text reaches the terminal after
# this gesture.
- assertNotVisible: "return"
# Copy remains usable after extending the selection.
- assertVisible:
id: "terminal-copy"
enabled: true
- tapOn:
id: "terminal-copy"
- takeScreenshot: native-terminal-selection-drag
# The harness will read the simulator clipboard and verify SELECT_DRAG_OK.

View File

@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
# Self-contained sidebar-swipe flow.
# Precondition: the harness has ensured the terminal is visible and the
# sidebar is closed.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# Sidebar should start closed.
- assertNotVisible:
id: "sidebar-sessions"
# Horizontal swipe from the terminal surface opens navigation. This is
# intentionally not speed-based and not a right-edge-only gesture.
- swipe:
start: "25%,50%"
end: "80%,50%"
duration: 900
- waitForAnimationToEnd
- assertVisible:
id: "sidebar-sessions"
# If the OS keyboard appears, iOS exposes a lowercase return key. This assertion
# is a UI-level guard; the harness also probes with Maestro inputText and fails
# if text reaches the terminal after the swipe.
- assertNotVisible: "return"
# Close the sidebar to leave a clean state.
- tapOn:
id: "sidebar-close"
- waitForAnimationToEnd
- assertNotVisible:
id: "sidebar-sessions"
- takeScreenshot: native-terminal-sidebar-swipe

View File

@@ -0,0 +1,31 @@
appId: sh.paseo.debug
---
# Self-contained tap-focus flow.
# Precondition: the harness has cleared the visible terminal.
- assertVisible:
id: "terminal-virtual-keyboard"
# First focus and hide the OS keyboard. The regression this covers was a hidden
# keyboard with the native TextInput still focused, making the next focus() a
# no-op.
- tapOn:
id: "terminal-surface"
- waitForAnimationToEnd
- hideKeyboard
- waitForAnimationToEnd
# Tap terminal content again, then type through Maestro's OS-keyboard route.
# Product Paste and CLI terminal input are deliberately not used here.
- tapOn:
id: "terminal-surface"
- waitForAnimationToEnd
- inputText: "printf 'MAESTRO_TAP_FOCUS_OK\\n'"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_TAP_FOCUS_OK"
timeout: 15000
- takeScreenshot: native-terminal-tap-focus

View File

@@ -0,0 +1,109 @@
appId: sh.paseo.debug
---
# Self-contained terminal virtual keyboard UX flow.
# Precondition: the harness has opened the visible terminal, seeded shell history,
# and set the simulator clipboard.
- assertVisible:
id: "terminal-virtual-keyboard"
# Space and Backspace are redundant with the OS keyboard and should not be part
# of the virtual keyboard surface.
- assertNotVisible:
id: "terminal-key-space"
- assertNotVisible:
id: "terminal-key-backspace"
# Copy is selection-only now; it should not be a permanent virtual key.
- assertNotVisible:
id: "terminal-copy"
# Arrow cluster controls are present. Up sits over Down in product layout; this
# flow verifies the actionable controls stay addressable and working.
- assertVisible:
id: "terminal-key-up"
- assertVisible:
id: "terminal-key-left"
- assertVisible:
id: "terminal-key-down"
- assertVisible:
id: "terminal-key-right"
- assertVisible:
id: "terminal-key-enter"
# Up + Enter reruns the seeded shell-history command.
- assertVisible:
text: "MAESTRO_VK_HISTORY_OK"
- tapOn:
id: "terminal-key-up"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_VK_HISTORY_OK"
timeout: 15000
# Keyboard toggle: start hidden, show, hide, show again. After the second show,
# Maestro inputText must reach the PTY/output.
- hideKeyboard
- waitForAnimationToEnd
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- assertNotVisible: "return"
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- inputText: "printf 'MAESTRO_TOGGLE_INPUT_OK\\n'"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_TOGGLE_INPUT_OK"
timeout: 15000
# Paste remains available in the virtual keyboard and sends clipboard text.
- assertVisible:
id: "terminal-paste"
enabled: true
- tapOn:
id: "terminal-paste"
- runFlow:
when:
visible: "Allow Paste"
commands:
- tapOn: "Allow Paste"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_PASTE_OK"
timeout: 15000
# Long-press selection makes Copy appear as a floating action; dragging while
# selected extends the selection without opening navigation or the OS keyboard.
- hideKeyboard
- waitForAnimationToEnd
- assertVisible:
text: "MAESTRO_COPY_OK"
- longPressOn:
point: "1%,28%"
- waitForAnimationToEnd
- assertVisible:
id: "terminal-copy"
enabled: true
- swipe:
start: "1%,28%"
end: "100%,28%"
duration: 1200
- waitForAnimationToEnd
- assertNotVisible:
id: "sidebar-sessions"
- assertNotVisible: "return"
- assertVisible:
id: "terminal-copy"
enabled: true
- tapOn:
id: "terminal-copy"
- takeScreenshot: native-terminal-virtual-keyboard-ux

View File

@@ -74,6 +74,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
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 "@/query/query-client";
import {
getHostRuntimeStore,
@@ -103,6 +104,7 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -24,10 +24,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";
@@ -47,7 +49,10 @@ 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;
showKeyboard: () => void;
blur: () => void;
}
@@ -136,13 +141,19 @@ interface TerminalEmulatorProps {
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;
@@ -152,6 +163,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;
@@ -292,6 +304,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 => ({
@@ -311,14 +335,25 @@ 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();
},
showKeyboard: () => {
runtimeRef.current?.resize({ force: true, shouldClaim: true });
runtimeRef.current?.focus();
},
blur: () => {
runtimeRef.current?.blur();
},
}),
[],
[pasteText],
);
useImperativeHandle(
ref,
@@ -332,14 +367,22 @@ export default function TerminalEmulator({
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
paste: (text: string) => {
pasteText(text);
},
copySelection: async () => "",
clear: () => {
runtimeRef.current?.clear();
},
showKeyboard: () => {
runtimeRef.current?.resize({ force: true, shouldClaim: true });
runtimeRef.current?.focus();
},
blur: () => {
runtimeRef.current?.blur();
},
}),
[],
[pasteText],
);
useEffect(() => {

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as Clipboard from "expo-clipboard";
import {
ActivityIndicator,
Pressable,
@@ -8,8 +9,12 @@ import {
} 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";
@@ -17,9 +22,22 @@ import { useAppVisible } 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 {
TerminalStreamController,
@@ -30,7 +48,9 @@ import { usePanelStore } from "@/stores/panel-store";
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,
shouldReplayTerminalSnapshotForRenderer,
@@ -67,23 +87,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 =
| {
@@ -92,21 +98,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}`;
}
@@ -161,6 +155,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,
@@ -182,11 +210,12 @@ export function TerminalPane({
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
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);
@@ -210,14 +239,14 @@ 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);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId);
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);
@@ -226,12 +255,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, isAppVisible]);
useEffect(() => {
void refreshClipboardAvailability();
}, [keyboardInset, refreshClipboardAvailability]);
useEffect(() => {
setIsKeyboardToggleVisible(keyboardInset > 0);
}, [keyboardInset]);
const handleSelectionChange = useCallback((nextHasSelection: boolean) => {
setHasSelection(nextHasSelection);
}, []);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
@@ -332,19 +388,27 @@ export function TerminalPane({
);
}, [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(() => {
@@ -480,15 +544,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;
},
@@ -540,26 +603,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);
@@ -626,7 +694,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;
@@ -640,6 +708,7 @@ export function TerminalPane({
}
const previousSent = lastSentTerminalSizeRef.current;
if (
!input.forceClaim &&
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
@@ -666,6 +735,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;
}, []);
@@ -710,8 +811,9 @@ export function TerminalPane({
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
requestTerminalFocus();
requestTerminalReflow();
},
[requestTerminalFocus],
[requestTerminalFocus, requestTerminalReflow],
);
const sendVirtualKey = useCallback(
@@ -725,6 +827,7 @@ export function TerminalPane({
});
clearPendingModifiers();
requestTerminalFocus();
requestTerminalReflow();
},
[
clearPendingModifiers,
@@ -732,6 +835,7 @@ export function TerminalPane({
modifiers.ctrl,
modifiers.shift,
requestTerminalFocus,
requestTerminalReflow,
sendTerminalKey,
],
);
@@ -752,6 +856,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,
hasStreamError: Boolean(streamError),
@@ -782,6 +934,7 @@ export function TerminalPane({
scrollbackLines={settings.terminalScrollbackLines}
fontFamily={terminalFontFamily}
fontSize={settings.codeFontSize}
keyboardInset={keyboardInset}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
@@ -792,6 +945,7 @@ export function TerminalPane({
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onSelectionChange={handleSelectionChange}
onResolveLocalFileLink={handleResolveLocalFileLink}
onOpenLocalFileLink={handleOpenLocalFileLink}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
@@ -809,6 +963,12 @@ export function TerminalPane({
<ActivityIndicator 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 ? (
@@ -822,55 +982,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}
@@ -900,6 +1019,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],

View File

@@ -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 = "";
}
}

View File

@@ -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 };
},
};
}

View File

@@ -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;
}

View File

@@ -0,0 +1,192 @@
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 { 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] ?? " " });
}
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;
}
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);
});
});

View File

@@ -0,0 +1,215 @@
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;
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();
});
}
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;
}
generation += 1;
pendingText = text;
resetBeforeNextWrite = true;
cancelPendingPaint();
scheduleDrain();
resolveIdleIfReady();
},
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;
},
};
}

View File

@@ -0,0 +1,132 @@
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("changes buffer coordinate epoch when scrollback eviction can shift rows", 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({
sameNewestRow: after.newestRow === before.newestRow,
epochChanged: after.coordinateEpoch > before.coordinateEpoch,
}).toEqual({
sameNewestRow: true,
epochChanged: true,
});
});
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);
});
});

View File

@@ -0,0 +1,369 @@
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 type TerminalCellRow = TerminalCell[];
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;
};
}
function blankCell(): TerminalCell {
return { char: " ", fg: undefined, bg: undefined };
}
function extractCell(
line: IBufferLine | undefined,
col: number,
reusableCell: IBufferCell,
): TerminalCell {
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() || " ",
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: TerminalCell[] = [];
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,
input: TerminalBufferWindowInput,
): TerminalBufferWindow {
const rowCount = Math.max(0, Math.floor(input.rowCount));
const bufferLength = terminal.buffer.active.length;
const startRow = Math.min(Math.max(0, Math.floor(input.startRow)), bufferLength);
const endRow = Math.min(bufferLength, startRow + rowCount);
const reusableCell = terminal.buffer.active.getNullCell();
const rows: TerminalCellRow[] = [];
const wrappedRows: boolean[] = [];
for (let row = startRow; row < endRow; row += 1) {
const line = terminal.buffer.active.getLine(row);
rows.push(extractRow(terminal, row, 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,
): TerminalBufferBounds {
const buffer = terminal.buffer.active;
const length = Math.max(1, buffer.length);
const oldestRow = 0;
const newestRow = length - 1;
const bottomFirstRow = Math.max(oldestRow, newestRow - terminal.rows + 1);
const currentFirstRow = Math.min(Math.max(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: buffer.baseY + buffer.cursorY,
cursorCol: buffer.cursorX,
};
}
function extractCursorState(
terminal: HeadlessTerminalInstance,
window?: TerminalViewportStateInput,
): 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 = 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,
input?: TerminalViewportStateInput,
): TerminalViewportState {
const bounds = extractBufferBounds(terminal, coordinateEpoch);
const rowCount = input ? Math.max(0, Math.floor(input.rowCount)) : terminal.rows;
const firstRow = input ? input.firstRow : bounds.currentViewport.firstRow;
const window = extractBufferWindow(terminal, { 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,
}),
};
}
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;
function maxBufferLength(): number {
return terminal.rows + (options.scrollbackLines ?? 1000);
}
function updateCoordinateEpochAfterWrite(previousLength: number): void {
const nextLength = terminal.buffer.active.length;
if (nextLength < previousLength || nextLength >= maxBufferLength()) {
coordinateEpoch += 1;
}
}
return {
async write(data: NativeTerminalWriteData): Promise<void> {
const text = typeof data === "string" ? data : decoder.decode(data, { stream: true });
if (text.length === 0) {
return;
}
const previousLength = terminal.buffer.active.length;
await new Promise<void>((resolve) => {
terminal.write(text, () => {
updateCoordinateEpochAfterWrite(previousLength);
resolve();
});
});
},
getState(): TerminalState {
return extractState(terminal, { scrollbackLines: options.scrollbackLines });
},
getViewportState(input?: TerminalViewportStateInput): TerminalViewportState {
return extractViewportState(terminal, coordinateEpoch, input);
},
getInputModeState(): NativeHeadlessTerminalInputModeState {
return extractInputModeState(terminal);
},
getBufferBounds(): TerminalBufferBounds {
return extractBufferBounds(terminal, coordinateEpoch);
},
getBufferWindow(input: TerminalBufferWindowInput): TerminalBufferWindow {
return extractBufferWindow(terminal, input);
},
resize(input: { rows: number; cols: number }): void {
coordinateEpoch += 1;
terminal.resize(input.cols, input.rows);
},
reset(): void {
coordinateEpoch += 1;
decoder.decode();
terminal.reset();
},
dispose(): void {
terminal.dispose();
},
};
}

View File

@@ -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;
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
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: 17,
});
});
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,
});
});
});

View File

@@ -0,0 +1,45 @@
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: Math.max(1, input.measuredTextWidth / textLength),
cellHeight: snapCellHeight(input.measuredTextHeight, input.roundToNearestPixel),
};
}
export function resolveTerminalCursorOffset(
input: TerminalCursorOffsetInput,
): TerminalCursorOffset {
return {
x: input.cursorCol * input.metrics.cellWidth,
y: input.cursorRow * input.metrics.cellHeight,
};
}
function snapCellHeight(value: number, roundToNearestPixel: (value: number) => number): number {
return Math.max(1, Math.ceil(roundToNearestPixel(value)));
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, test } from "vitest";
import { resolveTerminalGridProjection } from "./terminal-grid-projection";
describe("terminal grid projection", () => {
test("shows the tail when a desktop-sized terminal finishes at the bottom on phone", () => {
expect(
resolveTerminalGridProjection({
gridRows: 62,
gridCols: 204,
cursor: { row: 61, col: 2 },
viewportWidth: 480,
viewportHeight: 656,
cellWidth: 10,
cellHeight: 16,
}),
).toEqual({
firstRow: 21,
visibleRows: 41,
firstCol: 0,
visibleCols: 48,
cursorRow: 40,
cursorCol: 2,
});
});
test("shows the top when clear leaves the prompt near the top of a desktop-sized terminal", () => {
expect(
resolveTerminalGridProjection({
gridRows: 62,
gridCols: 204,
cursor: { row: 1, col: 2 },
viewportWidth: 480,
viewportHeight: 656,
cellWidth: 10,
cellHeight: 16,
}),
).toEqual({
firstRow: 0,
visibleRows: 41,
firstCol: 0,
visibleCols: 48,
cursorRow: 1,
cursorCol: 2,
});
});
test("does not crop a terminal grid that already fits the renderer viewport", () => {
expect(
resolveTerminalGridProjection({
gridRows: 24,
gridCols: 80,
cursor: { row: 23, col: 2 },
viewportWidth: 900,
viewportHeight: 500,
cellWidth: 10,
cellHeight: 16,
}),
).toEqual({
firstRow: 0,
visibleRows: 24,
firstCol: 0,
visibleCols: 80,
cursorRow: 23,
cursorCol: 2,
});
});
});

View File

@@ -0,0 +1,78 @@
export interface TerminalGridProjectionCursor {
row: number;
col: number;
}
export interface TerminalGridProjectionInput {
gridRows: number;
gridCols: number;
cursor: TerminalGridProjectionCursor;
viewportWidth: number;
viewportHeight: number;
cellWidth: number;
cellHeight: number;
}
export interface TerminalGridProjection {
firstRow: number;
visibleRows: number;
firstCol: number;
visibleCols: number;
cursorRow: number;
cursorCol: number;
}
export function resolveTerminalGridProjection(
input: TerminalGridProjectionInput,
): TerminalGridProjection {
const visibleRows = resolveVisibleCells({
viewportSize: input.viewportHeight,
cellSize: input.cellHeight,
gridCells: input.gridRows,
});
const visibleCols = resolveVisibleCells({
viewportSize: input.viewportWidth,
cellSize: input.cellWidth,
gridCells: input.gridCols,
});
const firstRow = resolveFirstVisibleRow({
gridRows: input.gridRows,
visibleRows,
cursorRow: input.cursor.row,
});
const firstCol = 0;
return {
firstRow,
visibleRows,
firstCol,
visibleCols,
cursorRow: input.cursor.row - firstRow,
cursorCol: input.cursor.col - firstCol,
};
}
function resolveVisibleCells(input: {
viewportSize: number;
cellSize: number;
gridCells: number;
}): number {
if (input.viewportSize <= 0 || input.cellSize <= 0 || input.gridCells <= 0) {
return 0;
}
return Math.min(input.gridCells, Math.max(1, Math.floor(input.viewportSize / input.cellSize)));
}
function resolveFirstVisibleRow(input: {
gridRows: number;
visibleRows: number;
cursorRow: number;
}): number {
const maxFirstRow = Math.max(0, input.gridRows - input.visibleRows);
const cursorAnchoredRow = input.cursorRow - input.visibleRows + 1;
return clamp(cursorAnchoredRow, 0, maxFirstRow);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

View File

@@ -0,0 +1,391 @@
import { memo, useCallback, useMemo, 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,
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, Math.ceil(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}
>
{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 [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),
});
setMetrics((current) => {
if (
current.cellWidth === nextMetrics.cellWidth &&
current.cellHeight === nextMetrics.cellHeight
) {
onCellMetricsChange?.(nextMetrics);
return current;
}
onCellMetricsChange?.(nextMetrics);
return nextMetrics;
});
},
[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,
},
});

View File

@@ -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 after the keyboard hides while the hidden input stayed focused", () => {
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,
});
});
});

View File

@@ -0,0 +1,313 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import {
Keyboard,
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(() => {
const subscription = Keyboard.addListener("keyboardDidHide", () => {
if (!isFocusedRef.current) {
return;
}
blurNativeInput();
});
return () => subscription.remove();
}, [blurNativeInput]);
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,
},
});

View File

@@ -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));
}

View File

@@ -0,0 +1,3 @@
export function nativeTerminalPerformanceNow(): number {
return globalThis.performance?.now?.() ?? Date.now();
}

View File

@@ -0,0 +1,143 @@
import { performance } from "node:perf_hooks";
import { describe, expect, test } from "vitest";
import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME } from "./colors";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload";
import { buildRows } from "./terminal-row-model";
const RUN_BENCHMARKS = process.env.PASEO_NATIVE_TERMINAL_BENCH === "1";
const ROWS = 40;
const COLS = 120;
const SCROLLBACK_LINES = [100, 1000, 10_000] as const;
const BURST_LINES = [10, 100, 1000] as const;
const SAMPLE_COUNT = 3;
interface NativeTerminalBenchmarkResult {
scrollback: number;
burstLines: number;
feedMs: number;
fullStateMs: number;
viewportStateMs: number;
visibleRowModelsMs: number;
hypotheticalAllRowModelsMs: number;
scrollbackRowsRead: number;
visibleRowsRead: number;
visibleRowsBuilt: number;
hypotheticalAllRowsBuilt: number;
}
function fixed(value: number): number {
return Number(value.toFixed(2));
}
function median(values: number[]): number {
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.floor(sorted.length / 2)] ?? 0;
}
async function seedScrollback(input: { scrollback: number }) {
const terminal = createNativeHeadlessTerminal({
rows: ROWS,
cols: COLS,
scrollbackLines: input.scrollback,
});
await terminal.write(
createNativeTerminalBenchmarkPayload({
startLine: 0,
lineCount: input.scrollback + ROWS + 5,
cols: COLS,
}),
);
return terminal;
}
async function measureScenario(input: {
scrollback: number;
burstLines: number;
}): Promise<NativeTerminalBenchmarkResult> {
const terminal = await seedScrollback({ scrollback: input.scrollback });
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
const feedTimes: number[] = [];
const fullStateTimes: number[] = [];
const viewportStateTimes: number[] = [];
const visibleRowModelTimes: number[] = [];
const allRowModelTimes: number[] = [];
let scrollbackRowsRead = 0;
let visibleRowsRead = 0;
let visibleRowsBuilt = 0;
let hypotheticalAllRowsBuilt = 0;
for (let sample = 0; sample < SAMPLE_COUNT; sample += 1) {
const payload = createNativeTerminalBenchmarkPayload({
startLine: input.scrollback + sample * input.burstLines,
lineCount: input.burstLines,
cols: COLS,
});
const feedStart = performance.now();
await terminal.write(payload);
feedTimes.push(performance.now() - feedStart);
const stateStart = performance.now();
const state = terminal.getState();
fullStateTimes.push(performance.now() - stateStart);
const viewportStateStart = performance.now();
const viewportState = terminal.getViewportState();
viewportStateTimes.push(performance.now() - viewportStateStart);
const visibleRowsStart = performance.now();
const visibleRows = buildRows({ grid: viewportState.grid, resolver });
visibleRowModelTimes.push(performance.now() - visibleRowsStart);
const allRowsStart = performance.now();
const allRows = buildRows({ grid: [...state.scrollback, ...state.grid], resolver });
allRowModelTimes.push(performance.now() - allRowsStart);
scrollbackRowsRead = state.scrollback.length;
visibleRowsRead = state.grid.length;
visibleRowsBuilt = visibleRows.length;
hypotheticalAllRowsBuilt = allRows.length;
}
terminal.dispose();
return {
scrollback: input.scrollback,
burstLines: input.burstLines,
feedMs: fixed(median(feedTimes)),
fullStateMs: fixed(median(fullStateTimes)),
viewportStateMs: fixed(median(viewportStateTimes)),
visibleRowModelsMs: fixed(median(visibleRowModelTimes)),
hypotheticalAllRowModelsMs: fixed(median(allRowModelTimes)),
scrollbackRowsRead,
visibleRowsRead,
visibleRowsBuilt,
hypotheticalAllRowsBuilt,
};
}
(RUN_BENCHMARKS ? describe : describe.skip)("native terminal renderer benchmark", () => {
test("measures parse, state extraction, and row model cost against scrollback size", async () => {
const results: NativeTerminalBenchmarkResult[] = [];
for (const scrollback of SCROLLBACK_LINES) {
for (const burstLines of BURST_LINES) {
results.push(await measureScenario({ scrollback, burstLines }));
}
}
console.table(results);
console.log(`PASEO_NATIVE_TERMINAL_BENCH=${JSON.stringify(results, null, 2)}`);
expect(results).toHaveLength(SCROLLBACK_LINES.length * BURST_LINES.length);
}, 120_000);
});
if (!RUN_BENCHMARKS) {
describe("native terminal renderer benchmark", () => {
test("is opt-in", () => {
expect(process.env.PASEO_NATIVE_TERMINAL_BENCH).not.toBe("1");
});
});
}

View File

@@ -0,0 +1,294 @@
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,
ownsTerminalSize: false,
lastClaimToken: null,
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 },
ownsTerminalSize: true,
lastClaimToken: null,
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,
ownsTerminalSize: true,
lastClaimToken: null,
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 },
ownsTerminalSize: true,
lastClaimToken: null,
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 },
ownsTerminalSize: true,
lastClaimToken: 1,
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 },
ownsTerminalSize: true,
lastClaimToken: 2,
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 },
ownsTerminalSize: true,
lastClaimToken: 2,
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 },
ownsTerminalSize: true,
lastClaimToken: 2,
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 },
ownsTerminalSize: true,
lastClaimToken: 1,
pendingForceClaim: false,
},
measuredSize: { rows: 21, cols: 55 },
resizeClaim: null,
measuredSizeChanged: false,
});
});
test("layout after claim only claims changed sizes while ownership is active", () => {
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).toEqual({
state: {
measuredSize: { rows: 43, cols: 50 },
claimedSize: { rows: 43, cols: 50 },
ownsTerminalSize: true,
lastClaimToken: null,
pendingForceClaim: false,
},
measuredSize: { rows: 43, cols: 50 },
resizeClaim: { rows: 43, cols: 50, force: false },
measuredSizeChanged: true,
});
});
test("keyboard measurement after focus claims the smaller focused viewport", () => {
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: 26, cols: 53 },
ownsTerminalSize: true,
lastClaimToken: null,
pendingForceClaim: false,
},
measuredSize: { rows: 26, cols: 53 },
resizeClaim: { rows: 26, cols: 53, force: false },
measuredSizeChanged: true,
});
});
});

View File

@@ -0,0 +1,110 @@
export interface TerminalSize {
rows: number;
cols: number;
}
export type TerminalResizeSource = "measure" | "claim";
export interface TerminalResizePolicyState {
measuredSize: TerminalSize;
claimedSize: TerminalSize | null;
ownsTerminalSize: boolean;
lastClaimToken: number | null;
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,
ownsTerminalSize: false,
lastClaimToken: null,
pendingForceClaim: false,
};
}
export function updateTerminalResizePolicy(
state: TerminalResizePolicyState,
input: TerminalResizePolicyInput,
): TerminalResizePolicyResult {
if (!input.size) {
const nextState = {
...state,
ownsTerminalSize: state.ownsTerminalSize || input.source === "claim",
lastClaimToken: nextClaimToken(state, input),
pendingForceClaim: state.pendingForceClaim || hasNewClaimToken(state, input),
};
return {
state: nextState,
measuredSize: nextState.measuredSize,
resizeClaim: null,
measuredSizeChanged: false,
};
}
const measuredSizeChanged = !terminalSizesEqual(state.measuredSize, input.size);
const ownsTerminalSize = state.ownsTerminalSize || input.source === "claim";
const claimToken = nextClaimToken(state, input);
const forceReclaim = state.pendingForceClaim || hasNewClaimToken(state, input);
const sizeClaimChanged = !terminalSizesEqual(state.claimedSize, input.size);
const shouldClaim = ownsTerminalSize && (sizeClaimChanged || forceReclaim);
const resizeClaim = shouldClaim ? { ...input.size, force: forceReclaim } : null;
const claimedSize = resizeClaim ? input.size : state.claimedSize;
const nextState = {
measuredSize: input.size,
claimedSize,
ownsTerminalSize,
lastClaimToken: claimToken,
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;
}

View File

@@ -0,0 +1,27 @@
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> = {}): TerminalCell {
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 },
]);
});
});

View File

@@ -0,0 +1,133 @@
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[];
}
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: TerminalCell[], col: number, char: string): boolean {
if (terminalCharWidth(char) < 2) {
return false;
}
return cells[col + 1]?.char === " ";
}
function terminalCellCount(cells: TerminalCell[], col: number, char: string): number {
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: TerminalCell[];
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, resolvedStyle.key);
if (cellCount > 1) {
col += 1;
}
}
return {
index: input.index,
hash: finishHash(hash),
runs,
};
}
export function buildRows(input: {
grid: TerminalCell[][];
resolver: TerminalCellStyleResolver;
}): TerminalRowModel[] {
return input.grid.map((cells, index) =>
buildRowModel({ cells, index, resolver: input.resolver }),
);
}

View File

@@ -0,0 +1,202 @@
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("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"],
});
});
});

View File

@@ -0,0 +1,242 @@
import type {
TerminalCellRow,
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 TerminalRowAnchor {
offset: number;
text: string;
}
interface TerminalScrollState {
mode: TerminalScrollMode;
firstRow: number;
anchor: TerminalRowAnchor | null;
}
const ROW_ANCHOR_SEARCH_RADIUS = 256;
export function createNativeTerminalScreenModel(
options: TerminalScreenModelOptions,
): TerminalScreenModel {
let scrollState: TerminalScrollState = { mode: "following", firstRow: 0, anchor: null };
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
: resolveScrolledFirstRow({
terminal: options.terminal,
bounds,
bottomViewport,
visibleRows,
scrollState,
});
const viewport = options.terminal.getViewportState({ firstRow, rowCount: visibleRows });
scrollState = {
mode,
firstRow,
anchor: mode === "scrolled" ? resolveViewportAnchor(viewport.grid) : null,
};
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, anchor: null };
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, anchor: null };
return render(input);
},
reset(): void {
scrollState = { mode: "following", firstRow: 0, anchor: null };
},
};
}
function resolveScrolledFirstRow(input: {
terminal: NativeHeadlessTerminal;
bounds: TerminalBufferBounds;
bottomViewport: TerminalRowRange;
visibleRows: number;
scrollState: TerminalScrollState;
}): number {
const clampedFirstRow = clamp(
input.scrollState.firstRow,
input.bounds.oldestRow,
input.bottomViewport.firstRow,
);
if (!input.scrollState.anchor) {
return clampedFirstRow;
}
const anchoredFirstRow = findAnchoredFirstRow({
terminal: input.terminal,
bounds: input.bounds,
bottomViewport: input.bottomViewport,
visibleRows: input.visibleRows,
preferredFirstRow: clampedFirstRow,
anchor: input.scrollState.anchor,
});
return anchoredFirstRow ?? clampedFirstRow;
}
function findAnchoredFirstRow(input: {
terminal: NativeHeadlessTerminal;
bounds: TerminalBufferBounds;
bottomViewport: TerminalRowRange;
visibleRows: number;
preferredFirstRow: number;
anchor: TerminalRowAnchor;
}): number | null {
const preferredAnchorRow = input.preferredFirstRow + input.anchor.offset;
const firstSearchRow = Math.max(
input.bounds.oldestRow,
preferredAnchorRow - ROW_ANCHOR_SEARCH_RADIUS,
);
const lastSearchRow = Math.min(
input.bounds.newestRow,
preferredAnchorRow + ROW_ANCHOR_SEARCH_RADIUS,
);
for (let row = firstSearchRow; row <= lastSearchRow; row += 1) {
const window = input.terminal.getBufferWindow({ startRow: row, rowCount: 1 });
const text = rowSignature(window.rows[0] ?? []);
if (text === input.anchor.text) {
return clamp(
row - input.anchor.offset,
input.bounds.oldestRow,
input.bottomViewport.firstRow,
);
}
}
return null;
}
function resolveViewportAnchor(rows: TerminalCellRow[]): TerminalRowAnchor | null {
for (let offset = 0; offset < rows.length; offset += 1) {
const text = rowSignature(rows[offset] ?? []);
if (text.length > 0) {
return { offset, text };
}
}
return null;
}
function rowSignature(row: TerminalCellRow): string {
return row
.map((cell) => cell.char)
.join("")
.trimEnd();
}
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));
}

View File

@@ -0,0 +1,170 @@
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 non-zero scrollback matching configured lines", 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).toBe(0);
});
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,
);
});
});

View File

@@ -0,0 +1,274 @@
import { describe, expect, it } from "vitest";
import {
TERMINAL_GESTURE_HORIZONTAL_NAVIGATION_THRESHOLD_PX,
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
} from "./terminal-selection-gesture";
describe("native terminal gesture policy", () => {
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("treats horizontal movement beyond tap tolerance as navigation", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_HORIZONTAL_NAVIGATION_THRESHOLD_PX + 1,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("swipeRight");
});
it("does not infer selection from slow horizontal drag dwell", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_HORIZONTAL_NAVIGATION_THRESHOLD_PX + 20,
dy: 3,
vx: 0,
vy: 0,
elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
}),
).toEqual("swipeRight");
});
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_HORIZONTAL_NAVIGATION_THRESHOLD_PX - 1,
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("treats a fast rightward swipe as sidebar navigation", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_HORIZONTAL_NAVIGATION_THRESHOLD_PX + 20,
dy: 4,
vx: 2,
vy: 0,
}),
).toEqual("swipeRight");
});
it("treats a slow rightward swipe as sidebar navigation", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_HORIZONTAL_NAVIGATION_THRESHOLD_PX + 20,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("swipeRight");
});
it("treats a fast leftward swipe as explorer navigation", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: -(TERMINAL_GESTURE_HORIZONTAL_NAVIGATION_THRESHOLD_PX + 20),
dy: -4,
vx: -2,
vy: 0,
}),
).toEqual("swipeLeft");
});
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("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("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");
});
});
});

View File

@@ -0,0 +1,85 @@
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent =
| "tap"
| "pending"
| "select"
| "scroll"
| "swipeLeft"
| "swipeRight";
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;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
pressDurationMs: number;
longPressMs: number;
scrollMode: TerminalGestureScrollMode;
}
export type TerminalGestureAction = "none" | "focus" | "select";
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 650;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
export const TERMINAL_GESTURE_HORIZONTAL_NAVIGATION_THRESHOLD_PX = 24;
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) {
if (absDx > absDy) {
if (absDx <= TERMINAL_GESTURE_HORIZONTAL_NAVIGATION_THRESHOLD_PX) {
return "pending";
}
return input.dx > 0 ? "swipeRight" : "swipeLeft";
}
return "pending";
}
return "tap";
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
if (input.status === "selecting" || input.didScroll || input.didNavigate) {
return "none";
}
if (input.status !== "pressing") {
return "none";
}
if (input.pressDurationMs >= input.longPressMs) {
return "select";
}
if (input.movedBeyondTapTolerance) {
return "none";
}
return "focus";
}

View File

@@ -0,0 +1,257 @@
import { describe, expect, it } from "vitest";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import {
copyTerminalSelection,
createTerminalSelectionModel,
extractTerminalSelectedText,
hitTestTerminalSelectionCell,
normalizeTerminalSelection,
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("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({
saturated: after.newestRow === 12,
epochChanged: after.coordinateEpoch > before.coordinateEpoch,
selection: selection.sync({ bounds: after }),
}).toEqual({
saturated: true,
epochChanged: true,
selection: { 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([]);
});
});

View File

@@ -0,0 +1,303 @@
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 TerminalClipboardWriter {
writeText: (text: string) => Promise<void>;
}
export interface CopyTerminalSelectionInput extends TerminalSelectedTextInput {
clipboard: TerminalClipboardWriter;
}
interface TerminalSelectionState {
anchor: TerminalBufferCoordinate | null;
range: TerminalSelectionRange | null;
}
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 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;
}

View File

@@ -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 });
});
});

View File

@@ -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 };
}

View File

@@ -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;
};
@@ -131,6 +133,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) {
@@ -233,6 +252,27 @@ describe("terminal emulator runtime in a real browser", () => {
expect(grownSize.shouldClaim).toBe(true);
});
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("refreshes visible rows on a forced same-size resize", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
@@ -318,6 +358,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([

View File

@@ -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;
@@ -258,11 +262,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 +294,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[] = [];
@@ -482,6 +506,22 @@ describe("terminal-emulator-runtime", () => {
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, { force: true });
});
it("marks explicit resize claims as forced so another client can reclaim the same size", () => {
expect(
createTerminalResizeEvent({
rows: 34,
cols: 181,
shouldClaim: true,
force: true,
}),
).toEqual({
rows: 34,
cols: 181,
shouldClaim: true,
forceClaim: true,
});
});
it("updates terminal theme without remounting", () => {
const runtime = new TerminalEmulatorRuntime();
const refresh = vi.fn();

View File

@@ -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,27 @@ export interface TerminalEmulatorRuntimeCallbacks {
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
}
export interface TerminalResizeEvent {
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}
export function createTerminalResizeEvent(input: {
rows: number;
cols: number;
shouldClaim: boolean;
force: boolean;
}): TerminalResizeEvent {
return {
rows: input.rows,
cols: input.cols,
shouldClaim: input.shouldClaim,
forceClaim: input.shouldClaim && input.force,
};
}
interface TerminalEmulatorRuntimeDisposables {
disposeInput: () => void;
disconnectResizeObserver: () => void;
@@ -109,7 +131,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 +151,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,
@@ -220,6 +210,10 @@ export class TerminalEmulatorRuntime {
this.pendingModifiers = input.pendingModifiers;
}
getInputModeState(): TerminalInputModeState {
return this.inputModeTracker.getState();
}
mount(input: TerminalEmulatorRuntimeMountInput): void {
this.unmount();
@@ -387,11 +381,14 @@ export class TerminalEmulatorRuntime {
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,
force,
}),
);
};
this.fitAndEmitResize = fitAndEmitResize;

View File

@@ -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;
}

View File

@@ -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"],
});
});
});

View File

@@ -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);
}
}

View File

@@ -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([]);
});
});

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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;
}

File diff suppressed because one or more lines are too long

View File

@@ -51,6 +51,8 @@ describe("TerminalInputModeTracker", () => {
expect(tracker.getState()).toEqual({
kittyKeyboardFlags: 0,
win32InputMode: true,
applicationCursorKeys: false,
bracketedPaste: false,
});
expect(tracker.supportsModifiedEnter()).toBe(true);
expect(tracker.getPreamble()).toBe("\x1b[?9001h");
@@ -67,10 +69,55 @@ describe("TerminalInputModeTracker", () => {
expect(tracker.getState()).toEqual({
kittyKeyboardFlags: 7,
win32InputMode: true,
applicationCursorKeys: false,
bracketedPaste: false,
});
expect(tracker.getPreamble()).toBe("\x1b[=7;1u\x1b[?9001h");
});
it("tracks application cursor keys mode independently", () => {
const tracker = new TerminalInputModeTracker();
expect(tracker.feed("\x1b[?1h").changed).toBe(true);
expect(tracker.getState()).toEqual({
kittyKeyboardFlags: 0,
win32InputMode: false,
applicationCursorKeys: true,
bracketedPaste: false,
});
expect(tracker.getPreamble()).toBe("\x1b[?1h");
expect(tracker.feed("\x1b[?1l").changed).toBe(true);
expect(tracker.getState()).toEqual({
kittyKeyboardFlags: 0,
win32InputMode: false,
applicationCursorKeys: false,
bracketedPaste: false,
});
});
it("tracks bracketed paste mode independently", () => {
const tracker = new TerminalInputModeTracker();
expect(tracker.feed("\x1b[?2004h").changed).toBe(true);
expect(tracker.getState()).toEqual({
kittyKeyboardFlags: 0,
win32InputMode: false,
applicationCursorKeys: false,
bracketedPaste: true,
});
expect(tracker.getPreamble()).toBe("\x1b[?2004h");
expect(tracker.feed("\x1b[?2004l").changed).toBe(true);
expect(tracker.getState()).toEqual({
kittyKeyboardFlags: 0,
win32InputMode: false,
applicationCursorKeys: false,
bracketedPaste: false,
});
expect(tracker.getPreamble()).toBe("");
});
it("ignores encoded key input sequences", () => {
const tracker = new TerminalInputModeTracker();

View File

@@ -6,15 +6,21 @@ export interface TerminalInputModeFeedResult {
export interface TerminalInputModeState {
kittyKeyboardFlags: number;
win32InputMode: boolean;
applicationCursorKeys?: boolean;
bracketedPaste?: boolean;
}
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
kittyKeyboardFlags: 0,
win32InputMode: false,
applicationCursorKeys: false,
bracketedPaste: false,
};
const ESC = String.fromCharCode(0x1b);
const APPLICATION_CURSOR_KEYS_MODE = 1;
const WIN32_INPUT_MODE = 9001;
const BRACKETED_PASTE_MODE = 2004;
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
"g",
@@ -57,13 +63,17 @@ export function terminalInputModeStatesEqual(
): boolean {
return (
left.kittyKeyboardFlags === right.kittyKeyboardFlags &&
left.win32InputMode === right.win32InputMode
left.win32InputMode === right.win32InputMode &&
Boolean(left.applicationCursorKeys) === Boolean(right.applicationCursorKeys) &&
Boolean(left.bracketedPaste) === Boolean(right.bracketedPaste)
);
}
export class TerminalInputModeTracker {
private kittyKeyboardFlags = 0;
private win32InputMode = false;
private applicationCursorKeys = false;
private bracketedPaste = false;
private readonly kittyKeyboardStack: number[] = [];
private pending = "";
@@ -112,6 +122,8 @@ export class TerminalInputModeTracker {
reset(): void {
this.kittyKeyboardFlags = 0;
this.win32InputMode = false;
this.applicationCursorKeys = false;
this.bracketedPaste = false;
this.kittyKeyboardStack.length = 0;
this.pending = "";
}
@@ -120,6 +132,8 @@ export class TerminalInputModeTracker {
return {
kittyKeyboardFlags: this.kittyKeyboardFlags,
win32InputMode: this.win32InputMode,
applicationCursorKeys: this.applicationCursorKeys,
bracketedPaste: this.bracketedPaste,
};
}
@@ -139,6 +153,12 @@ export class TerminalInputModeTracker {
if (this.win32InputMode) {
parts.push("\x1b[?9001h");
}
if (this.applicationCursorKeys) {
parts.push("\x1b[?1h");
}
if (this.bracketedPaste) {
parts.push("\x1b[?2004h");
}
return parts.join("");
}
@@ -183,12 +203,26 @@ export class TerminalInputModeTracker {
private applyPrivateModeSequence(params: string, final: string): boolean {
const modes = parsePrivateModeParams(params);
if (!modes.has(WIN32_INPUT_MODE)) {
return false;
let changed = false;
if (modes.has(WIN32_INPUT_MODE)) {
const previous = this.win32InputMode;
this.win32InputMode = final === "h";
changed = this.win32InputMode !== previous || changed;
}
const previous = this.win32InputMode;
this.win32InputMode = final === "h";
return this.win32InputMode !== previous;
if (modes.has(APPLICATION_CURSOR_KEYS_MODE)) {
const previous = this.applicationCursorKeys;
this.applicationCursorKeys = final === "h";
changed = this.applicationCursorKeys !== previous || changed;
}
if (modes.has(BRACKETED_PASTE_MODE)) {
const previous = this.bracketedPaste;
this.bracketedPaste = final === "h";
changed = this.bracketedPaste !== previous || changed;
}
return changed;
}
}

View File

@@ -2,6 +2,58 @@ import { describe, expect, it } from "vitest";
import { encodeTerminalKeyInput } from "./terminal-key-input.js";
describe("encodeTerminalKeyInput", () => {
it("encodes plain arrows as CSI when application cursor mode is inactive", () => {
const options = { inputMode: { kittyKeyboardFlags: 0, win32InputMode: false } };
expect({
up: encodeTerminalKeyInput({ key: "ArrowUp" }, options),
down: encodeTerminalKeyInput({ key: "ArrowDown" }, options),
left: encodeTerminalKeyInput({ key: "ArrowLeft" }, options),
right: encodeTerminalKeyInput({ key: "ArrowRight" }, options),
}).toEqual({
up: "\x1b[A",
down: "\x1b[B",
left: "\x1b[D",
right: "\x1b[C",
});
});
it("encodes plain arrows as SS3 when application cursor mode is active", () => {
const options = {
inputMode: { kittyKeyboardFlags: 0, win32InputMode: false, applicationCursorKeys: true },
};
expect({
up: encodeTerminalKeyInput({ key: "ArrowUp" }, options),
down: encodeTerminalKeyInput({ key: "ArrowDown" }, options),
left: encodeTerminalKeyInput({ key: "ArrowLeft" }, options),
right: encodeTerminalKeyInput({ key: "ArrowRight" }, options),
}).toEqual({
up: "\x1bOA",
down: "\x1bOB",
left: "\x1bOD",
right: "\x1bOC",
});
});
it("keeps modified arrows on CSI modifier sequences in application cursor mode", () => {
const options = {
inputMode: { kittyKeyboardFlags: 0, win32InputMode: false, applicationCursorKeys: true },
};
expect({
shiftLeft: encodeTerminalKeyInput({ key: "ArrowLeft", shift: true }, options),
altDown: encodeTerminalKeyInput({ key: "ArrowDown", alt: true }, options),
ctrlRight: encodeTerminalKeyInput({ key: "ArrowRight", ctrl: true }, options),
metaUp: encodeTerminalKeyInput({ key: "ArrowUp", meta: true }, options),
}).toEqual({
shiftLeft: "\x1b[1;2D",
altDown: "\x1b[1;3B",
ctrlRight: "\x1b[1;5C",
metaUp: "\x1b[1;9A",
});
});
it("encodes ctrl+b for tmux prefix", () => {
expect(encodeTerminalKeyInput({ key: "b", ctrl: true })).toBe("\x02");
});

View File

@@ -125,6 +125,10 @@ function csiWithModifier(finalByte: string, input: TerminalKeyInput): string {
return mod === 1 ? `\x1b[${finalByte}` : `\x1b[1;${mod}${finalByte}`;
}
function ss3WithModifier(finalByte: string, input: TerminalKeyInput): string {
return modifierParam(input) === 1 ? `\x1bO${finalByte}` : csiWithModifier(finalByte, input);
}
function csiTilde(base: number, input: TerminalKeyInput): string {
const mod = modifierParam(input);
return mod === 1 ? `\x1b[${base}~` : `\x1b[${base};${mod}~`;
@@ -161,16 +165,31 @@ function encodeFunctionKey(key: string, input: TerminalKeyInput): string | null
}
}
function encodeNavigationKey(key: string, input: TerminalKeyInput): string | null {
function encodeArrowKey(
finalByte: string,
input: TerminalKeyInput,
options: TerminalKeyInputEncodingOptions,
): string {
if (options.inputMode?.applicationCursorKeys) {
return ss3WithModifier(finalByte, input);
}
return csiWithModifier(finalByte, input);
}
function encodeNavigationKey(
key: string,
input: TerminalKeyInput,
options: TerminalKeyInputEncodingOptions,
): string | null {
switch (key) {
case "ArrowUp":
return csiWithModifier("A", input);
return encodeArrowKey("A", input, options);
case "ArrowDown":
return csiWithModifier("B", input);
return encodeArrowKey("B", input, options);
case "ArrowRight":
return csiWithModifier("C", input);
return encodeArrowKey("C", input, options);
case "ArrowLeft":
return csiWithModifier("D", input);
return encodeArrowKey("D", input, options);
case "Home":
return csiWithModifier("H", input);
case "End":
@@ -225,7 +244,7 @@ export function encodeTerminalKeyInput(
break;
}
const nav = encodeNavigationKey(key, input);
const nav = encodeNavigationKey(key, input, options);
if (nav !== null) return nav;
const fn = encodeFunctionKey(key, input);
if (fn !== null) return fn;

View File

@@ -0,0 +1,341 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from "@xterm/headless";
import { expect, test } from "vitest";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import type { TerminalStreamEvent } from "@getpaseo/client/internal/terminal-stream-router";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
const BYTE_DONE_MARKER = "__PASEO_BYTE_PACKAGE_LOCK_DONE__";
const BYTE_TEST_SIZE = { rows: 24, cols: 100 };
interface PackageLockTerminalCwd {
path: string;
gatePath: string;
}
interface CreatedTerminal {
id: string;
}
test("byte-stream headless terminal matches daemon state after high-output attach and restore", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.96",
clientType: "mobile",
});
const terminalCwd = await createPackageLockTerminalCwd();
try {
await client.connect();
const terminal = await createPackageLockTerminal({
client,
cwd: terminalCwd.path,
gatePath: terminalCwd.gatePath,
});
const liveHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE);
const stopLive = pipeTerminalStreamToHeadless({
client,
terminalId: terminal.id,
headless: liveHeadless,
});
const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot");
await subscribeTerminal(client, terminal.id);
await initialSnapshot;
client.sendTerminalInput(terminal.id, { type: "resize", ...BYTE_TEST_SIZE });
liveHeadless.resize(BYTE_TEST_SIZE);
await startPackageLockOutput(terminalCwd.gatePath);
const capturedViewport = await waitForCapturedViewport({
client,
terminalId: terminal.id,
text: BYTE_DONE_MARKER,
rows: BYTE_TEST_SIZE.rows,
});
const daemonSnapshot = await readTerminalSnapshot({
port: daemon.port,
terminalId: terminal.id,
});
await liveHeadless.flush();
expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot));
expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor);
expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines);
client.unsubscribeTerminal(terminal.id);
stopLive();
const restoreHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE);
const stopRestore = pipeTerminalStreamToHeadless({
client,
terminalId: terminal.id,
headless: restoreHeadless,
});
const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore");
await subscribeTerminal(client, terminal.id, {
restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE },
});
await restoreFrame;
await restoreHeadless.flush();
expect(restoreHeadless.visibleLines()).toEqual(capturedViewport.lines);
expect(restoreHeadless.cursor()).toEqual(daemonSnapshot.cursor);
stopRestore();
} finally {
await client.close();
await daemon.close();
await rm(terminalCwd.path, { recursive: true, force: true });
}
}, 30_000);
class ClientHeadlessTerminal {
private readonly terminal: HeadlessTerminal;
private readonly decoder = new TextDecoder();
private pendingWrite = Promise.resolve();
constructor(input: { rows: number; cols: number }) {
this.terminal = new HeadlessTerminal({
rows: input.rows,
cols: input.cols,
scrollback: 10_000,
allowProposedApi: true,
});
}
resize(input: { rows: number; cols: number }): void {
this.terminal.resize(input.cols, input.rows);
}
applyEvent(event: TerminalStreamEvent): void {
if (event.type === "snapshot") {
this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true });
return;
}
if (event.type === "restore") {
this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true });
return;
}
this.enqueueText(this.decoder.decode(event.data, { stream: true }));
}
async flush(): Promise<void> {
await this.pendingWrite;
}
visibleLines(): string[] {
const baseY = this.terminal.buffer.active.baseY;
const cell = this.terminal.buffer.active.getNullCell();
const lines: string[] = [];
for (let row = 0; row < this.terminal.rows; row += 1) {
lines.push(bufferLineText(this.terminal.buffer.active.getLine(baseY + row), cell));
}
return lines;
}
cursor(): TerminalState["cursor"] {
return {
row: this.terminal.buffer.active.cursorY,
col: this.terminal.buffer.active.cursorX,
};
}
private enqueueText(text: string, options?: { resetDecoder?: boolean }): void {
if (options?.resetDecoder) {
this.decoder.decode();
this.terminal.reset();
}
this.pendingWrite = this.pendingWrite.then(
() =>
new Promise<void>((resolve) => {
this.terminal.write(text, () => resolve());
}),
);
}
}
function pipeTerminalStreamToHeadless(input: {
client: DaemonClient;
terminalId: string;
headless: ClientHeadlessTerminal;
}): () => void {
return input.client.onTerminalStreamEvent((event) => {
if (event.terminalId !== input.terminalId) {
return;
}
input.headless.applyEvent(event);
});
}
async function subscribeTerminal(
client: DaemonClient,
terminalId: string,
options?: Parameters<DaemonClient["subscribeTerminal"]>[1],
): Promise<void> {
const response = await client.subscribeTerminal(terminalId, options);
if (response.error) {
throw new Error(response.error);
}
}
async function readTerminalSnapshot(input: {
port: number;
terminalId: string;
}): Promise<TerminalState> {
const client = new DaemonClient({
url: `ws://127.0.0.1:${input.port}/ws`,
appVersion: "0.1.96",
clientType: "mobile",
});
try {
await client.connect();
const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot");
await subscribeTerminal(client, input.terminalId);
const event = await snapshot;
return event.state;
} finally {
await client.close();
}
}
function waitForTerminalStreamEvent<TType extends TerminalStreamEvent["type"]>(
client: DaemonClient,
terminalId: string,
type: TType,
): Promise<Extract<TerminalStreamEvent, { type: TType }>> {
return client.waitForTerminalStreamEvent(
(event) => event.terminalId === terminalId && event.type === type,
10_000,
) as Promise<Extract<TerminalStreamEvent, { type: TType }>>;
}
async function createPackageLockTerminalCwd(): Promise<PackageLockTerminalCwd> {
const cwd = await mkdtemp(path.join(tmpdir(), "paseo-byte-package-lock-"));
return {
path: cwd,
gatePath: path.join(cwd, "start-package-lock-output"),
};
}
async function createPackageLockTerminal(input: {
client: DaemonClient;
cwd: string;
gatePath: string;
}): Promise<CreatedTerminal> {
const opened = await input.client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open workspace for ${input.cwd}`);
}
const response = await input.client.createTerminal(input.cwd, "byte-package-lock", undefined, {
command: process.execPath,
args: [
"-e",
packageLockStreamScript(),
repoPackageLockPath(),
input.gatePath,
BYTE_DONE_MARKER,
],
workspaceId: opened.workspace.id,
});
if (response.error || !response.terminal) {
throw new Error(response.error ?? "Terminal was not created");
}
return { id: response.terminal.id };
}
async function startPackageLockOutput(gatePath: string): Promise<void> {
await writeFile(gatePath, "go");
}
async function waitForCapturedViewport(input: {
client: DaemonClient;
terminalId: string;
text: string;
rows: number;
}): Promise<{ lines: string[] }> {
return waitForCondition(async () => {
const capture = await input.client.captureTerminal(input.terminalId, {
start: -input.rows,
stripAnsi: true,
});
return capture.lines.join("\n").includes(input.text) ? { lines: capture.lines } : null;
}, 15_000);
}
async function waitForCondition<T>(
predicate: () => Promise<T | null> | T | null,
timeoutMs: number,
): Promise<T> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const value = await predicate();
if (value) {
return value;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`Timed out after ${timeoutMs}ms waiting for terminal byte parity`);
}
function visibleTerminalLines(state: TerminalState): string[] {
return state.grid.map(terminalRowText);
}
function terminalRowText(row: TerminalState["grid"][number]): string {
return row
.map((cell) => cell.char)
.join("")
.trimEnd();
}
function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string {
const cells: TerminalCell[] = [];
for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) {
const cell = line?.getCell(col, reusableCell);
cells.push({ char: cell?.getChars() || " " });
}
return terminalRowText(cells);
}
function packageLockStreamScript(): string {
return `
const fs = require("node:fs");
const packageLockPath = process.argv[1];
const gatePath = process.argv[2];
const marker = process.argv[3];
function waitForGate() {
if (!fs.existsSync(gatePath)) {
setTimeout(waitForGate, 10);
return;
}
process.stdout.write(fs.readFileSync(packageLockPath, "utf8"));
process.stdout.write("\\n" + marker + "\\n");
}
waitForGate();
setInterval(() => {}, 1000);
`;
}
function repoPackageLockPath(): string {
const start = path.dirname(fileURLToPath(import.meta.url));
let current = start;
while (current !== path.dirname(current)) {
const candidate = path.join(current, "package-lock.json");
if (existsSync(candidate)) {
return candidate;
}
current = path.dirname(current);
}
throw new Error(`Could not find package-lock.json from ${start}`);
}