Compare commits

...

10 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
Matt Cowger
b90baaff71 fix: daemon warns instead of crashing on missing OpenAI speech credentials (#1368)
* fix: daemon warns instead of crashing on missing OpenAI speech credentials

validateOpenAiCredentialRequirements was throwing an error when
OpenAI speech providers were configured without credentials,
causing the daemon to crash before it could start serving health
checks. Changed to log a warning instead — the daemon starts
successfully and reports speech services as unavailable.

Closes #1367

* chore: address Greptile review feedback

- Remove duplicate warning in initializeOpenAiSpeechServices when
  all credentials are missing (already warned by validate)
- Strengthen test assertions: check getListenTarget() after start
- Wrap daemon start/stop in try/finally for proper cleanup on failure
2026-06-19 11:09:39 +08:00
paseo-ai[bot]
d0189f3f65 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-18 22:51:53 +00:00
68 changed files with 7805 additions and 677 deletions

View File

@@ -1 +1 @@
sha256-0z+saIsaIOAeRV3lXS7glaooUDmz1d8iYYigPzRLbgA=
sha256-lwIf9Z0uwDdNyAFu+L03pVwlQSuasYWIpn1Fsx3zxJw=

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

@@ -339,7 +339,7 @@ describe("paseo daemon bootstrap", () => {
}
});
test("fails fast when OpenAI speech provider is configured without credentials", async () => {
test("starts when OpenAI speech provider is configured without credentials", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-openai-config-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
@@ -368,9 +368,14 @@ describe("paseo daemon bootstrap", () => {
};
try {
await expect(createPaseoDaemon(config, pino({ level: "silent" }))).rejects.toThrow(
"Missing OpenAI credentials",
);
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await daemon.start();
expect(daemon.getListenTarget()).toBeDefined();
// Must also stop without throwing
} finally {
await daemon.stop();
}
} finally {
await rm(paseoHomeRoot, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });

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

View File

@@ -85,7 +85,7 @@ export function validateOpenAiCredentialRequirements(params: {
}
if (missingOpenAiCredentialsFor.length > 0) {
logger.error(
logger.warn(
{
requestedProviders: {
dictationStt: providers.dictationStt.provider,
@@ -94,10 +94,7 @@ export function validateOpenAiCredentialRequirements(params: {
},
missingOpenAiCredentialsFor,
},
"Invalid speech configuration: OpenAI provider selected but credentials are missing",
);
throw new Error(
`Missing OpenAI credentials for configured speech features: ${missingOpenAiCredentialsFor.join(", ")}`,
"Invalid speech configuration: OpenAI provider selected but credentials are missing — speech features will be unavailable",
);
}
}
@@ -195,7 +192,7 @@ export function initializeOpenAiSpeechServices(params: {
);
}
} else if (needsAnyOpenAi) {
logger.warn("OpenAI speech providers are configured but credentials are missing");
// validateOpenAiCredentialRequirements already warned about missing credentials
}
return {