mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
8 Commits
v0.1.101
...
mobile-ter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9376de1ef1 | ||
|
|
ad30f65549 | ||
|
|
ee92843965 | ||
|
|
e39cbb4643 | ||
|
|
25349e0fe5 | ||
|
|
e5322b5b63 | ||
|
|
2b4f133a6c | ||
|
|
ff9991acc2 |
242
packages/app/maestro/harness/native-terminal-maestro-common.sh
Executable file
242
packages/app/maestro/harness/native-terminal-maestro-common.sh
Executable 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"
|
||||
}
|
||||
14
packages/app/maestro/harness/run-native-terminal-history-arrows.sh
Executable file
14
packages/app/maestro/harness/run-native-terminal-history-arrows.sh
Executable 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"
|
||||
21
packages/app/maestro/harness/run-native-terminal-scroll-no-focus.sh
Executable file
21
packages/app/maestro/harness/run-native-terminal-scroll-no-focus.sh
Executable 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"
|
||||
26
packages/app/maestro/harness/run-native-terminal-selection-drag.sh
Executable file
26
packages/app/maestro/harness/run-native-terminal-selection-drag.sh
Executable 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
|
||||
17
packages/app/maestro/harness/run-native-terminal-sidebar-swipe.sh
Executable file
17
packages/app/maestro/harness/run-native-terminal-sidebar-swipe.sh
Executable 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"
|
||||
14
packages/app/maestro/harness/run-native-terminal-tap-focus.sh
Executable file
14
packages/app/maestro/harness/run-native-terminal-tap-focus.sh
Executable 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"
|
||||
29
packages/app/maestro/harness/run-native-terminal-virtual-keyboard-ux.sh
Executable file
29
packages/app/maestro/harness/run-native-terminal-virtual-keyboard-ux.sh
Executable 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
|
||||
29
packages/app/maestro/native-terminal-history-arrows.yaml
Normal file
29
packages/app/maestro/native-terminal-history-arrows.yaml
Normal 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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
31
packages/app/maestro/native-terminal-tap-focus-keyboard.yaml
Normal file
31
packages/app/maestro/native-terminal-tap-focus-keyboard.yaml
Normal 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
|
||||
109
packages/app/maestro/native-terminal-virtual-keyboard-ux.yaml
Normal file
109
packages/app/maestro/native-terminal-virtual-keyboard-ux.yaml
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
141
packages/app/src/components/terminal-copy-paste-actions.tsx
Normal file
141
packages/app/src/components/terminal-copy-paste-actions.tsx
Normal 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
@@ -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(() => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
28
packages/app/src/polyfills/navigator.ts
Normal file
28
packages/app/src/polyfills/navigator.ts
Normal 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 = "";
|
||||
}
|
||||
}
|
||||
238
packages/app/src/terminal/native-renderer/colors.ts
Normal file
238
packages/app/src/terminal/native-renderer/colors.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
57
packages/app/src/terminal/native-renderer/font.native.ts
Normal file
57
packages/app/src/terminal/native-renderer/font.native.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const DEFAULT_COLS = 120;
|
||||
|
||||
export interface NativeTerminalBenchmarkPayloadInput {
|
||||
startLine: number;
|
||||
lineCount: number;
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
function terminalLine(input: { index: number; cols: number }): string {
|
||||
const color = 31 + (input.index % 7);
|
||||
const label = `line ${input.index.toString().padStart(6, "0")} `;
|
||||
const body = "abcdefghijklmnopqrstuvwxyz0123456789 ".repeat(4);
|
||||
return `\x1b[${color}m${(label + body).slice(0, input.cols - 1)}\x1b[0m\r\n`;
|
||||
}
|
||||
|
||||
export function createNativeTerminalBenchmarkPayload(
|
||||
input: NativeTerminalBenchmarkPayloadInput,
|
||||
): string {
|
||||
const cols = input.cols ?? DEFAULT_COLS;
|
||||
let payload = "";
|
||||
for (let index = 0; index < input.lineCount; index += 1) {
|
||||
payload += terminalLine({ index: input.startLine + index, cols });
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export type NativeTerminalKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight";
|
||||
|
||||
export interface NativeTerminalKeyEvent {
|
||||
key: NativeTerminalKey;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
export type NativeTerminalKeyHandler = (event: NativeTerminalKeyEvent) => Promise<void> | void;
|
||||
|
||||
export function resolveNativeTerminalKey(key: string): NativeTerminalKey | null {
|
||||
switch (key) {
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight":
|
||||
return key;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createNativeTerminalKeyEvent(key: NativeTerminalKey): NativeTerminalKeyEvent {
|
||||
return {
|
||||
key,
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function forwardNativeTerminalKey(input: {
|
||||
key: NativeTerminalKey;
|
||||
onTerminalKey?: NativeTerminalKeyHandler;
|
||||
}): void {
|
||||
input.onTerminalKey?.(createNativeTerminalKeyEvent(input.key));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function nativeTerminalPerformanceNow(): number {
|
||||
return globalThis.performance?.now?.() ?? Date.now();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
133
packages/app/src/terminal/native-renderer/terminal-row-model.ts
Normal file
133
packages/app/src/terminal/native-renderer/terminal-row-model.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
303
packages/app/src/terminal/native-renderer/terminal-selection.ts
Normal file
303
packages/app/src/terminal/native-renderer/terminal-selection.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveMeasuredNativeTerminalSize } from "./terminal-size-measurement";
|
||||
|
||||
describe("terminal size measurement", () => {
|
||||
test("measured layout height owns terminal rows", () => {
|
||||
expect(
|
||||
resolveMeasuredNativeTerminalSize({
|
||||
layout: { width: 530, height: 580 },
|
||||
metrics: { cellWidth: 10, cellHeight: 20 },
|
||||
}),
|
||||
).toEqual({ rows: 29, cols: 53 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface TerminalMeasuredLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TerminalMeasuredCellMetrics {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
}
|
||||
|
||||
export interface TerminalMeasuredSize {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export function resolveMeasuredNativeTerminalSize(input: {
|
||||
layout: TerminalMeasuredLayout | null;
|
||||
metrics: TerminalMeasuredCellMetrics | null;
|
||||
}): TerminalMeasuredSize | null {
|
||||
if (!input.layout || !input.metrics) {
|
||||
return null;
|
||||
}
|
||||
const cols = Math.floor(input.layout.width / input.metrics.cellWidth);
|
||||
const rows = Math.floor(input.layout.height / input.metrics.cellHeight);
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { rows, cols };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ interface TerminalSize {
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim: boolean;
|
||||
forceClaim?: boolean;
|
||||
}
|
||||
|
||||
interface TerminalKeyRecord {
|
||||
@@ -26,6 +27,7 @@ interface TerminalKeyRecord {
|
||||
}
|
||||
|
||||
type BrowserTerminal = TerminalSize & {
|
||||
input: (data: string, wasUserInput?: boolean) => void;
|
||||
refresh: (start: number, end: number) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
32
packages/app/src/terminal/runtime/terminal-font.ts
Normal file
32
packages/app/src/terminal/runtime/terminal-font.ts
Normal 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;
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
55
packages/app/src/terminal/runtime/terminal-key-dispatch.ts
Normal file
55
packages/app/src/terminal/runtime/terminal-key-dispatch.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
48
packages/app/src/terminal/runtime/terminal-paste.test.ts
Normal file
48
packages/app/src/terminal/runtime/terminal-paste.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
38
packages/app/src/terminal/runtime/terminal-paste.ts
Normal file
38
packages/app/src/terminal/runtime/terminal-paste.ts
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getTerminalVirtualKeyboardControlId,
|
||||
shouldShowTerminalFloatingCopyAction,
|
||||
shouldShowTerminalPasteAction,
|
||||
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
|
||||
type TerminalVirtualKeyboardControl,
|
||||
} from "./terminal-virtual-keyboard";
|
||||
|
||||
interface ControlPosition {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
function controlIds(): string[] {
|
||||
return TERMINAL_VIRTUAL_KEYBOARD_ROWS.flatMap((row) =>
|
||||
row.map((control) => getTerminalVirtualKeyboardControlId(control)),
|
||||
);
|
||||
}
|
||||
|
||||
function controlPositions(): Map<string, ControlPosition> {
|
||||
const positions = new Map<string, ControlPosition>();
|
||||
TERMINAL_VIRTUAL_KEYBOARD_ROWS.forEach((row, rowIndex) => {
|
||||
row.forEach((control, colIndex) => {
|
||||
positions.set(getTerminalVirtualKeyboardControlId(control), {
|
||||
row: rowIndex,
|
||||
col: colIndex,
|
||||
});
|
||||
});
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
function controlsByType(
|
||||
type: TerminalVirtualKeyboardControl["type"],
|
||||
): TerminalVirtualKeyboardControl[] {
|
||||
return TERMINAL_VIRTUAL_KEYBOARD_ROWS.flatMap((row) =>
|
||||
row.filter((control) => control.type === type),
|
||||
);
|
||||
}
|
||||
|
||||
describe("terminal virtual keyboard policy", () => {
|
||||
it("does not expose redundant Space or Backspace controls", () => {
|
||||
expect(controlIds()).toEqual([
|
||||
"terminal-key-esc",
|
||||
"terminal-key-tab",
|
||||
"terminal-key-ctrl",
|
||||
"terminal-key-up",
|
||||
"terminal-key-shift",
|
||||
"terminal-keyboard-toggle",
|
||||
"terminal-key-alt",
|
||||
"terminal-paste",
|
||||
"terminal-key-left",
|
||||
"terminal-key-down",
|
||||
"terminal-key-right",
|
||||
"terminal-key-enter",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the arrows in an inverted-T cluster", () => {
|
||||
const positions = controlPositions();
|
||||
|
||||
expect({
|
||||
up: positions.get("terminal-key-up"),
|
||||
left: positions.get("terminal-key-left"),
|
||||
down: positions.get("terminal-key-down"),
|
||||
right: positions.get("terminal-key-right"),
|
||||
}).toEqual({
|
||||
up: { row: 0, col: 3 },
|
||||
left: { row: 1, col: 2 },
|
||||
down: { row: 1, col: 3 },
|
||||
right: { row: 1, col: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Paste in the keyboard and Copy out of the permanent row", () => {
|
||||
expect(controlsByType("paste")).toHaveLength(1);
|
||||
expect(controlIds()).toContain("terminal-paste");
|
||||
expect(controlIds()).not.toContain("terminal-copy");
|
||||
});
|
||||
|
||||
it("shows Copy only as a native selection affordance", () => {
|
||||
expect(
|
||||
shouldShowTerminalFloatingCopyAction({
|
||||
hasSelection: false,
|
||||
isNative: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowTerminalFloatingCopyAction({
|
||||
hasSelection: true,
|
||||
isNative: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowTerminalFloatingCopyAction({
|
||||
hasSelection: true,
|
||||
isNative: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Paste native-gated", () => {
|
||||
expect(shouldShowTerminalPasteAction({ isNative: true })).toBe(true);
|
||||
expect(shouldShowTerminalPasteAction({ isNative: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
TERMINAL_VIRTUAL_KEY_BUTTONS,
|
||||
type TerminalKeyModifierState,
|
||||
} from "./terminal-key-dispatch";
|
||||
|
||||
type TerminalVirtualKeyButton =
|
||||
(typeof TERMINAL_VIRTUAL_KEY_BUTTONS)[keyof typeof TERMINAL_VIRTUAL_KEY_BUTTONS];
|
||||
|
||||
export type TerminalVirtualKeyboardControl =
|
||||
| {
|
||||
type: "key";
|
||||
button: TerminalVirtualKeyButton;
|
||||
}
|
||||
| {
|
||||
type: "modifier";
|
||||
modifier: keyof TerminalKeyModifierState;
|
||||
}
|
||||
| {
|
||||
type: "paste";
|
||||
}
|
||||
| {
|
||||
type: "keyboardToggle";
|
||||
};
|
||||
|
||||
export const TERMINAL_VIRTUAL_KEYBOARD_ROWS = [
|
||||
[
|
||||
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc },
|
||||
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab },
|
||||
{ type: "modifier", modifier: "ctrl" },
|
||||
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up },
|
||||
{ type: "modifier", modifier: "shift" },
|
||||
{ type: "keyboardToggle" },
|
||||
],
|
||||
[
|
||||
{ type: "modifier", modifier: "alt" },
|
||||
{ type: "paste" },
|
||||
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left },
|
||||
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down },
|
||||
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right },
|
||||
{ type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter },
|
||||
],
|
||||
] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[];
|
||||
|
||||
export function getTerminalVirtualKeyboardControlId(
|
||||
control: TerminalVirtualKeyboardControl,
|
||||
): string {
|
||||
switch (control.type) {
|
||||
case "key":
|
||||
return `terminal-key-${control.button.id}`;
|
||||
case "modifier":
|
||||
return `terminal-key-${control.modifier}`;
|
||||
case "paste":
|
||||
return "terminal-paste";
|
||||
case "keyboardToggle":
|
||||
return "terminal-keyboard-toggle";
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowTerminalPasteAction(input: { isNative: boolean }): boolean {
|
||||
return input.isNative;
|
||||
}
|
||||
|
||||
export function shouldShowTerminalFloatingCopyAction(input: {
|
||||
hasSelection: boolean;
|
||||
isNative: boolean;
|
||||
}): boolean {
|
||||
return input.isNative && input.hasSelection;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user