Merge remote-tracking branch 'origin/main' into quick-commands-remote-server

# Conflicts:
#	src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx
#	src/renderer/src/components/terminal-pane/TerminalPane.tsx
This commit is contained in:
Jinwoo-H
2026-08-08 10:23:17 -04:00
759 changed files with 60934 additions and 16358 deletions
+5
View File
@@ -12,3 +12,8 @@
/src/cli/bundled-skill-guides.ts text eol=lf
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
/resources/plugins/** text eol=lf
# pnpm hashes this generated patch byte-for-byte, so a CRLF checkout breaks the install;
# its minified bundle lines also make a diff nobody can read. Review the hand-written
# source patch under xterm-src/ instead.
/config/patches/@xterm__xterm@*.patch -diff -text
/config/patches/xterm-src/*.patch text eol=lf
+3
View File
@@ -30,6 +30,9 @@ jobs:
# (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it
# ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26.
runs-on: macos-26
# Archive + upload is ~30–40m when healthy; 90m leaves margin without
# sitting multi-hour on ASC processing polls (see Fastfile pilot wait).
timeout-minutes: 90
defaults:
run:
+59 -1
View File
@@ -173,6 +173,34 @@ jobs:
done
exit "$status"
xterm_patch_sync:
name: xterm patch sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
- name: Restore upstream xterm build inputs
uses: actions/cache@v4
with:
path: |
~/.npm
${{ runner.temp }}/xterm-patch-build/upstream/.git
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
- name: Verify xterm patches match the pinned upstream build
env:
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
@@ -259,6 +287,29 @@ jobs:
- name: Old/new client and server terminal journey
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
managed_hook_node18:
name: managed hooks on Node 18
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Build relay companions
run: pnpm run build:relay
- name: Setup Node 18 runtime
uses: actions/setup-node@v6
with:
node-version: '18'
- name: Smoke managed-hook companions
run: node config/scripts/smoke-managed-hook-runtime-node18.mjs
package:
name: package
runs-on: ubuntu-latest
@@ -392,7 +443,8 @@ jobs:
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
# Native specs require their dedicated platform harnesses.
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' | grep -Ev -- '-native\.spec\.ts$' || true)"
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
@@ -421,8 +473,10 @@ jobs:
- root_directory_guard
- typecheck
- git_compatibility
- xterm_patch_sync
- shell_contracts
- test
- managed_hook_node18
- package
- package_windows
runs-on: ubuntu-latest
@@ -442,8 +496,10 @@ jobs:
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
PACKAGE: ${{ needs.package.result }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
run: |
@@ -452,8 +508,10 @@ jobs:
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "success" ]; then
+71 -9
View File
@@ -9,10 +9,20 @@ permissions:
contents: read
jobs:
linux-x11:
name: Linux X11 terminal IME
runs-on: ubuntu-22.04
linux:
name: Linux ${{ matrix.label }} terminal IME
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
- label: X11
os: ubuntu-22.04
display_server: x11
- label: Wayland
os: ubuntu-24.04
display_server: wayland
steps:
- name: Checkout
@@ -27,10 +37,17 @@ jobs:
build-essential
dbus-x11
dconf-gsettings-backend
fcitx5
fcitx5-chinese-addons
fcitx5-frontend-gtk3
fcitx5-hangul
ibus
ibus-hangul
ibus-libpinyin
libglib2.0-bin
python3
sway
wtype
xdotool
xfwm4
xvfb
@@ -56,7 +73,57 @@ jobs:
- name: Build Electron app for E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run native IBus exact-byte tests
if: matrix.display_server == 'x11'
env:
ORCA_E2E_NATIVE_IME: ibus
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload native IBus evidence
if: ${{ always() && matrix.display_server == 'x11' }}
uses: actions/upload-artifact@v7
with:
name: terminal-ime-native-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
- name: Run native Fcitx5 exact-byte tests
if: matrix.display_server == 'x11'
env:
ORCA_E2E_NATIVE_IME: fcitx5
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload native Fcitx5 evidence
if: ${{ always() && matrix.display_server == 'x11' }}
uses: actions/upload-artifact@v7
with:
name: terminal-ime-native-fcitx5-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
- name: Run native Fcitx5 Wayland exact-byte tests
if: matrix.display_server == 'wayland'
env:
ORCA_E2E_NATIVE_DISPLAY_SERVER: wayland
ORCA_E2E_NATIVE_IME: fcitx5
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload native Fcitx5 Wayland evidence
if: ${{ always() && matrix.display_server == 'wayland' }}
uses: actions/upload-artifact@v7
with:
name: terminal-ime-native-fcitx5-wayland-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
- name: Run deterministic terminal IME boundary tests
if: matrix.display_server == 'x11'
run: >-
xvfb-run --auto-servernum
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
@@ -64,13 +131,8 @@ jobs:
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
- name: Run native IBus Hangul exact-byte tests
env:
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload terminal IME evidence
if: always()
if: ${{ always() && matrix.display_server == 'x11' }}
uses: actions/upload-artifact@v7
with:
name: terminal-ime-evidence
+1
View File
@@ -102,6 +102,7 @@ docs/**
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/windows-setup-shell.md
!docs/reference/xterm-patch-regeneration.md
# Stably CLI (only docs/ are tracked)
.stably/*
File diff suppressed because one or more lines are too long
@@ -0,0 +1,363 @@
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
index 4557e1652c34737fdf853436bd9328d9918eee2b..c16096341dadfd215a8086e13f7a0551025c0e77 100644
--- a/src/browser/CoreBrowserTerminal.ts
+++ b/src/browser/CoreBrowserTerminal.ts
@@ -735,6 +735,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
paste(data, this.textarea!, this.coreService, this.optionsService);
}
+ public override input(data: string, wasUserInput: boolean = true): void {
+ if (!wasUserInput || !this._compositionHelper?.handleCompositionInput(data, false)) super.input(data, wasUserInput);
+ }
+
public attachCustomKeyEventHandler(customKeyEventHandler: CustomKeyEventHandler): void {
this._customKeyEventHandler = customKeyEventHandler;
}
@@ -1029,6 +1033,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to
// support reading out character input which can doubling up input characters
// Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679
+ if (ev.data && ev.inputType === 'insertText' && this._compositionHelper?.handleCompositionInput(ev.data, true)) return true;
if (ev.data && ev.inputType === 'insertText' && (!ev.composed || !this._keyDownSeen) && !this.optionsService.rawOptions.screenReaderMode) {
if (this._keyPressHandled) {
return false;
diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts
index 7b346459521dd77f9de25d43fba3c36a6f8459b2..4837a7c1a3eb36ec6f6223cbcec0f2dd6cc4e235 100644
--- a/src/browser/TestUtils.test.ts
+++ b/src/browser/TestUtils.test.ts
@@ -342,6 +342,9 @@ export class MockViewport implements IViewport {
}
export class MockCompositionHelper implements ICompositionHelper {
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
+ throw new Error('Method not implemented.');
+ }
public get isComposing(): boolean {
return false;
}
diff --git a/src/browser/Types.ts b/src/browser/Types.ts
index 497afcf535f3eaca00889525a77e15eb633ccd96..e3cad77734795f6cf34bb7120264fe81070941ed 100644
--- a/src/browser/Types.ts
+++ b/src/browser/Types.ts
@@ -39,6 +39,7 @@ export type LineData = CharData[];
export interface ICompositionHelper {
readonly isComposing: boolean;
+ handleCompositionInput(data: string, nativeCommit: boolean): boolean;
compositionstart(): void;
compositionupdate(ev: CompositionEvent): void;
compositionend(): void;
diff --git a/src/browser/input/CompositionHelper.test.ts b/src/browser/input/CompositionHelper.test.ts
index 5a1e6c38c9799f7f57d5df6d4a122a7beb0cf04f..2d78e414177804261acbbe2f53d71b5c8709ceb6 100644
--- a/src/browser/input/CompositionHelper.test.ts
+++ b/src/browser/input/CompositionHelper.test.ts
@@ -6,7 +6,7 @@
import { assert } from 'chai';
import { CompositionHelper } from './CompositionHelper';
import { MockRenderService } from '../TestUtils.test';
-import { MockCoreService, MockBufferService, MockOptionsService } from '../../common/TestUtils.test';
+import { MockCoreService, MockBufferService, MockOptionsService, MockUnicodeService } from '../../common/TestUtils.test';
describe('CompositionHelper', () => {
let compositionHelper: CompositionHelper;
@@ -42,7 +42,7 @@ describe('CompositionHelper', () => {
};
handledText = '';
const bufferService = new MockBufferService(10, 5);
- compositionHelper = new CompositionHelper(textarea, compositionView, bufferService, new MockOptionsService(), coreService, new MockRenderService());
+ compositionHelper = new CompositionHelper(textarea, compositionView, bufferService, new MockOptionsService(), coreService, new MockRenderService(), new MockUnicodeService());
});
describe('Input', () => {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..ea77d070322479e75201970d63fc41336d64dc93 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -4,8 +4,7 @@
*/
import { IRenderService } from '../services/Services';
-import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
-import { C0 } from '../../common/data/EscapeSequences';
+import { IBufferService, ICoreService, IOptionsService, IUnicodeService } from '../../common/services/Services';
interface IPosition {
start: number;
@@ -42,15 +41,12 @@ export class CompositionHelper {
*/
private _isSendingComposition: boolean;
- /**
- * Data already sent due to keydown event.
- */
- private _dataAlreadySent: string;
+ private _pendingCompositionStart?: number;
+ private _pendingInput = '';
+ private _sentComposition = '';
- /**
- * The pending textarea change timer, if any.
- */
- private _textareaChangeTimer?: number;
+ /** Text and cell width the current letter-spacing was measured for. */
+ private _gridAdvanceKey = '';
constructor(
private readonly _textarea: HTMLTextAreaElement,
@@ -58,13 +54,13 @@ export class CompositionHelper {
@IBufferService private readonly _bufferService: IBufferService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ICoreService private readonly _coreService: ICoreService,
- @IRenderService private readonly _renderService: IRenderService
+ @IRenderService private readonly _renderService: IRenderService,
+ @IUnicodeService private readonly _unicodeService: IUnicodeService
) {
this._isComposing = false;
this._isSendingComposition = false;
this._compositionPosition = { start: 0, end: 0 };
this._compositionSuffix = '';
- this._dataAlreadySent = '';
}
/**
@@ -80,10 +76,27 @@ export class CompositionHelper {
this._compositionPosition.end = Math.max(start, end);
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
this._compositionView.textContent = '';
- this._dataAlreadySent = '';
this._compositionView.classList.add('active');
}
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
+ if (nativeCommit) {
+ if (!this._isSendingComposition) return false;
+ // Once the deferred send has run, an IME that delivers its commit a task late (IBus) repeats
+ // text we already sent; anything else is new input and must not be discarded with it.
+ const alreadySent = this._pendingCompositionStart === undefined && data === this._sentComposition;
+ const input = (alreadySent ? '' : data) + this._pendingInput;
+ this._pendingCompositionStart = undefined;
+ this._pendingInput = '';
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
+ this._isSendingComposition = false;
+ return true;
+ }
+ if (!this._isComposing && !this._isSendingComposition) return false;
+ this._pendingInput += data;
+ return true;
+ }
+
/**
* Handles the compositionupdate event, updating the composition view.
* @param ev The event.
@@ -119,8 +132,10 @@ export class CompositionHelper {
// Continue composing if the keyCode is the "composition character"
return false;
}
- if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
- // Continue composing if the keyCode is a modifier key
+ if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18 || ev.keyCode === 91 || ev.keyCode === 93 || ev.keyCode === 224) {
+ // Continue composing if the keyCode is a modifier key. 91/93 are Chromium's left and
+ // right Meta, 224 is Firefox's. Only a lone modifier press reports these, and on macOS
+ // finalizing there hides the preedit for the rest of a word the IME is still composing.
return false;
}
// Finish composition immediately. This is mainly here for the case where enter is
@@ -129,9 +144,6 @@ export class CompositionHelper {
}
if (ev.keyCode === 229) {
- // If the "composition character" is used but gets to this point it means a non-composition
- // character (eg. numbers and punctuation) was pressed when the IME was active.
- this._handleAnyTextareaChanges();
return false;
}
@@ -153,7 +165,11 @@ export class CompositionHelper {
if (!waitForPropagation) {
// Cancel any delayed composition send requests and send the input immediately.
this._isSendingComposition = false;
- const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
+ const start = this._pendingCompositionStart ?? this._compositionPosition.start;
+ const end = Math.max(start, this._textarea.selectionEnd ?? this._compositionPosition.end);
+ this._pendingCompositionStart = undefined;
+ const input = this._textarea.value.substring(start, end) + this._pendingInput;
+ this._pendingInput = '';
this._coreService.triggerDataEvent(input, true);
} else {
// Make a deep copy of the composition position here as a new compositionstart event may
@@ -163,6 +179,7 @@ export class CompositionHelper {
end: this._compositionPosition.end
};
const currentCompositionSuffix = this._compositionSuffix;
+ this._pendingCompositionStart ??= currentCompositionPosition.start;
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -175,12 +192,10 @@ export class CompositionHelper {
this._isSendingComposition = true;
setTimeout(() => {
// Ensure that the input has not already been sent
- if (this._isSendingComposition) {
- this._isSendingComposition = false;
+ if (this._isSendingComposition && this._pendingCompositionStart !== undefined) {
+ currentCompositionPosition.start = this._pendingCompositionStart;
+ this._pendingCompositionStart = undefined;
let input;
- // Add length of data already sent due to keydown event,
- // otherwise input characters can be duplicated. (Issue #3191)
- currentCompositionPosition.start += this._dataAlreadySent.length;
if (this._isComposing) {
// Use the start position of the new composition to get the string
// if a new composition has started.
@@ -195,47 +210,22 @@ export class CompositionHelper {
: value.length;
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
}
- if (input.length > 0) {
- this._coreService.triggerDataEvent(input, true);
- }
+ this._sentComposition = input;
+ input += this._pendingInput;
+ this._pendingInput = '';
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
+ setTimeout(() => {
+ if (this._pendingCompositionStart === undefined) {
+ if (this._pendingInput.length > 0) this._coreService.triggerDataEvent(this._pendingInput, true);
+ this._pendingInput = '';
+ this._isSendingComposition = false;
+ }
+ }, 0);
}
}, 0);
}
}
- /**
- * Apply any changes made to the textarea after the current event chain is allowed to complete.
- * This should be called when not currently composing but a keydown event with the "composition
- * character" (229) is triggered, in order to allow non-composition text to be entered when an
- * IME is active.
- */
- private _handleAnyTextareaChanges(): void {
- if (this._textareaChangeTimer) {
- return;
- }
- const oldValue = this._textarea.value;
- this._textareaChangeTimer = window.setTimeout(() => {
- this._textareaChangeTimer = undefined;
- // Ignore if a composition has started since the timeout
- if (!this._isComposing) {
- const newValue = this._textarea.value;
-
- const diff = newValue.replace(oldValue, '');
-
- this._dataAlreadySent = diff;
-
- if (newValue.length > oldValue.length) {
- this._coreService.triggerDataEvent(diff, true);
- } else if (newValue.length < oldValue.length) {
- this._coreService.triggerDataEvent(`${C0.DEL}`, true);
- } else if ((newValue.length === oldValue.length) && (newValue !== oldValue)) {
- this._coreService.triggerDataEvent(newValue, true);
- }
-
- }
- }, 0);
- }
-
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
@@ -260,6 +250,7 @@ export class CompositionHelper {
this._compositionView.style.lineHeight = cellHeight + 'px';
this._compositionView.style.fontFamily = this._optionsService.rawOptions.fontFamily;
this._compositionView.style.fontSize = this._optionsService.rawOptions.fontSize + 'px';
+ this._alignPreeditToGrid(this._renderService.dimensions.css.cell.width);
// Limit the composition view width to the space between the cursor and
// the terminal's right edge, preventing it from overflowing the terminal.
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
@@ -281,4 +272,39 @@ export class CompositionHelper {
setTimeout(() => this.updateCompositionElements(true), 0);
}
}
+
+ /**
+ * The overlay is laid out as plain text, so its extent is whatever advance the font
+ * gives the preedit. For Hangul and CJK that is well under the cells the same text
+ * occupies once committed (measured on macOS/SF Mono: 0.70 for Hangul, 0.81 for CJK,
+ * against 1.00 for Latin), and the shortfall accumulates across the composition.
+ * Spread it as letter-spacing, which is how DomRendererRowFactory lands committed
+ * glyphs on the cell grid, so the preedit covers the cells it is about to become.
+ */
+ private _alignPreeditToGrid(cellWidth: number): void {
+ const text = this._compositionView.textContent ?? '';
+ // Runs on every render frame while composing; the measurement below forces layout.
+ const key = `${cellWidth}${text}`;
+ if (key === this._gridAdvanceKey) {
+ return;
+ }
+ this._gridAdvanceKey = key;
+ // Letter-spacing lands after each character that advances, so the LTR marks
+ // wrapping the preedit are not among the gaps the shortfall is divided over.
+ let advancing = 0;
+ for (const character of text) {
+ if (this._unicodeService.wcwidth(character.codePointAt(0)!) > 0) {
+ advancing++;
+ }
+ }
+ this._compositionView.style.letterSpacing = '';
+ if (advancing === 0) {
+ return;
+ }
+ // Measured with maxWidth cleared: a long preedit's natural advance exceeds it.
+ this._compositionView.style.maxWidth = '';
+ const naturalWidth = this._compositionView.getBoundingClientRect().width;
+ const gridWidth = this._unicodeService.getStringCellWidth(text) * cellWidth;
+ this._compositionView.style.letterSpacing = `${(gridWidth - naturalWidth) / advancing}px`;
+ }
}
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
--- a/src/common/SortedList.ts
+++ b/src/common/SortedList.ts
@@ -87,6 +87,24 @@ export class SortedList<T> {
if (key === undefined) {
return false;
}
+ if (this._deleteAtKey(value, key)) {
+ return true;
+ }
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
+ // order, so the binary search above can miss a value that is present.
+ // Compacting those entries out restores the order; retry before reporting
+ // the value absent, else its `onDecorationRemoved` never fires and the
+ // decoration paints forever. Miss path only, so the common bulk delete
+ // keeps its O(log n) search and deferred-compaction batching.
+ if (this._deletedIndices.length === 0) {
+ return false;
+ }
+ this._flushCleanupDeleted();
+ return this._deleteAtKey(value, key);
+ }
+
+ private _deleteAtKey(value: T, key: number): boolean {
i = this._search(key);
if (i === -1) {
return false;
diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts
index bedfa6ba1fdd6fb24646f52167e4881dc03498be..5d87be2d5cc52cb4504fd383243c2de72290bd04 100644
--- a/src/common/TestUtils.test.ts
+++ b/src/common/TestUtils.test.ts
@@ -225,8 +225,10 @@ export class MockUnicodeService implements IUnicodeService {
}
return UnicodeService.createPropertyValue(0, width, shouldJoin);
}
+ // Reuses the real traversal against this mock's own provider; CompositionHelper
+ // sizes the preedit overlay with it, so throwing here would fail its tests.
public getStringCellWidth(s: string): number {
- throw new Error('Method not implemented.');
+ return UnicodeService.prototype.getStringCellWidth.call(this, s);
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
"upstream": {
"repository": "https://github.com/xtermjs/xterm.js.git",
"commit": "53a98a720ae4a973e384fa2440880d09537132f3",
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
},
"sourcemaps": {
"policy": "delete",
"why": "The shipped .js and .mjs move under the patch, so a retained map would need to move with it: excluding just the map hunks ships offsets that no longer line up, which is the defect the addon patches still have. Deleting the maps is the honest form of that saving — the patch drops from 7.3MB to 1.5MB, the bundles stay byte-identical, and nothing in this repo consumes the maps at build or run time."
},
"toolchain": {
"why": "Pinned by the upstream package-lock at the commit above. The generator asserts these resolve as expected so a silent upstream resolution change surfaces as a toolchain error rather than a mystery patch diff.",
"esbuild": "0.28.1",
"webpack": "5.107.0",
"terser": "5.47.1",
"@typescript/native-preview": "7.0.0-dev.20260521.1"
},
"packages": [
{
"name": "@xterm/xterm",
"version": "6.1.0-beta.287",
"packageDir": ".",
"versionStampFile": "src/common/Version.ts",
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch",
"patch": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"generatedPaths": ["lib/"],
"build": [{ "cwd": ".", "command": "npm", "args": ["run", "package"] }]
}
],
"forbiddenBuildScripts": {
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. Publish order is: stamp Version.ts, then `npm run package` only.",
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
}
}
+409 -131
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-04",
"updatedAt": "2026-08-07",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,6 +10,103 @@
}
},
"gates": [
{
"id": "ai-vault.live-session-delete-authority",
"title": "AI Vault deletion fails closed against owning-runtime session liveness",
"maturity": "experimental",
"protection": "partial",
"owner": "ai-vault-runtime",
"layer": "main-process-filesystem-mutation",
"surfaces": [
"AI Vault transcript deletion",
"local and WSL agent sessions",
"paired-client-owned host sessions"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "wsl", "ssh", "paired-runtime"],
"coveredPlatforms": ["macos", "linux", "windows"],
"coveredProviders": ["local", "wsl", "ssh", "paired-runtime"],
"coverageNotes": "Platform-independent contracts cover local and WSL authority, paired-owned and unmanaged live sessions absent from renderer or managed-PTY state, identity retention across UI dismissal, inventory and foreground-inspection ordering, SSH exclusion, unavailable and ambiguous inventory, bounded inspection concurrency, oversized inventories, returned rejection, and real temporary-file survival. Live headed/headless paired-runtime, physical Windows/WSL, and Docker SSH journeys remain uncollected.",
"motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"],
"invariant": "Main may delete a local AI transcript only after the owning runtime freshly proves that no live local or WSL provider-session identity exists, including exact hook identities absent from managed PTY inventory; live and unknown liveness preserve the transcript regardless of renderer freshness.",
"oracle": "Open confirmation against a stale empty renderer snapshot, transition the authoritative session to live before main authorization and during a controlled foreground-inspection barrier, and separately model paired and unmanaged local owners absent from renderer or managed-PTY state. Dismiss a live row and require its identity-only authority to remain. Require session-live or session-liveness-unknown rejection and the real temporary transcript to survive. Make process inventory unavailable or ambiguous and require unknown with the file preserved; prove known other sessions permit deletion and inspection concurrency stays at or below eight.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot"
],
"testFiles": [
"src/main/ai-vault/session-delete-liveness.repro.test.ts",
"src/main/ai-vault/session-liveness.test.ts",
"src/main/agent-hooks/server-ai-vault-liveness.test.ts"
],
"assertionRefs": [
{
"file": "src/main/ai-vault/session-delete-liveness.repro.test.ts",
"assertions": [
"confirmation TOCTOU returns session-live and preserves the real transcript",
"paired ownership missing from the renderer snapshot returns session-live and preserves the real transcript",
"exact live local hook identity missing from managed PTY inventory returns unknown and preserves the real transcript",
"dismissed live status retained as identity-only authority returns unknown and preserves the real transcript",
"unknown authoritative liveness fails closed and preserves the real transcript"
]
},
{
"file": "src/main/ai-vault/session-liveness.test.ts",
"assertions": [
"local, WSL, paired-runtime, and SSH host partitions resolve without local fallback",
"renderer session identity must match the identity parsed from the validated transcript",
"hook identity is captured after the controlled process-inventory barrier",
"hook identity is captured after the controlled foreground-inspection barrier",
"missing identity, unavailable inventory, unattributed ownership, and oversized inventory remain unknown",
"foreground inspection concurrency never exceeds eight and stops scheduling at the shared deadline"
]
},
{
"file": "src/main/agent-hooks/server-ai-vault-liveness.test.ts",
"assertions": [
"user dismissal retains provider identity without retaining a visible turn-status row",
"restart hydration preserves dismissed identity as unconfirmed liveness-only authority"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1,
"summary": "Twenty deterministic deletion, identity-binding, inventory/inspection-ordering, dismissal/restart-retention, unmanaged-ownership, unknown-state, filesystem-survival, host-partition, deadline, and bounded-concurrency assertions passed."
}
],
"runtimeBudget": {
"p95Seconds": 2,
"scope": "focused main-process liveness and temporary-filesystem contracts"
},
"flakeHistory": {
"status": "unknown",
"evidence": "One local deterministic run is recorded; CI and soak history have not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical two-scenario oracle failed on v1.4.177-rc.0 (9e948fbdf4) and latest main (7a867f12aa): both returned deleted and both real transcripts were removed. On PR commit 8e2fcfd681, an exact live local hook identity absent from empty managed-PTY inventory returned deleted and removed the real transcript. Independent review then proved a session-other to session-live transition during foreground inspection returned not-live, and live-row dismissal erased the only external identity. The corrected candidate passed all 20 assertions, including restart hydration of dismissed identity; temporarily disabling only the original liveness decision made all three original deletion-safety tests red again."
},
"performanceBudget": {
"required": true,
"evidence": "Deletion parses only the validated target through the incremental parse cache, performs one local-provider process listing, makes no SSH or paired-runtime request, caps inventory at 512 PTYs, bounds foreground inspections to eight concurrent calls under one shared three-second fail-closed deadline, and adds no polling or background work."
},
"promotionCriteria": [
"Collect CI soak history for the deterministic gate.",
"Collect headed and headless paired-runtime deletion-refusal evidence.",
"Collect physical Windows/WSL and Docker SSH host-partition evidence."
],
"knownGaps": [
"No live headed/headless paired-runtime journey was run.",
"No physical Windows/WSL or Docker SSH journey was run.",
"The authorization check cannot make an external provider process start atomic with filesystem trashing; it closes the reported renderer-confirmation window at the main mutation boundary."
],
"demotionRule": "Keep experimental or demote if live or unknown ownership can reach trashing, remote ownership falls back locally, process inspection becomes unbounded, or the deterministic filesystem oracle flakes without a product or harness defect."
},
{
"id": "editor.restored-sibling-owner-reparent",
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",
@@ -1361,6 +1458,118 @@
],
"demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect."
},
{
"id": "browser-stream.bounded-reconnect",
"title": "Remote browser streams recover within a bounded retry budget",
"maturity": "experimental",
"protection": "partial",
"owner": "browser-runtime",
"layer": "renderer-state-machine-paired-runtime",
"surfaces": [
"paired remote browser panes",
"browser screencast disconnect and reconnect",
"remote browser viewport restart"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["remote-runtime"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": ["remote-runtime"],
"coverageNotes": "Deterministic fake-clock tests cover counted backoff, in-flight ownership and handoff, cancellation, supersession, transient and permanent failures, late stale rejection, silent hosts, flapping streams, viewport restarts, stale events, budget exhaustion, and manual recovery. A headless paired-runtime journey drops the real runtime connection, proves the bounded retry state and visible Reconnect control, reconnects the same host, and requires a newly painted frame. Headed paired-server, Windows, sleep/wake, and real network-partition evidence remain uncollected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/12787",
"https://linear.app/stably/issue/STA-3483"
],
"invariant": "A current remote browser stream that closes must retry transient failures on one counted, capped backoff chain. Cancellation, pane or runtime supersession, missing pages, and permanent failures stop that chain without stale state mutation. A stream that stays healthy refills the budget; a silent or flapping stream cannot retry forever. Exhaustion and every non-retrying failure leave an actionable Reconnect control, and manual reconnect creates a fresh stream without resurrecting stale subscriptions.",
"oracle": "With one fake clock and exact pane, operation, stream, page, and runtime identities, drop a confirmed stream and require the counted delay sequence, one active chain, stale-token rejection, exact cancellation, full cleanup, and either a new live subscription or one stopped state with Reconnect after exhaustion. Inject transport error without close, close racing rejection, never-ready subscriptions, ready-then-end flapping, viewport supersession, missing pages, and permanent failures. In a separate paired client against an isolated headless Orca host, paint one remote frame, disconnect the owning runtime, require the bounded-retry notice and Reconnect control, reconnect, click once, and require a different frame object URL from the recovered stream.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
"pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts",
"src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts",
"src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts",
"src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
"tests/e2e/paired-remote-browser-stream-reconnect.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts",
"assertions": [
"transient failures retry until recovery on counted delays",
"budget exhaustion stops exactly once",
"cancellation retires an in-flight attempt without re-arming",
"a close during an in-flight attempt queues one replacement chain"
]
},
{
"file": "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts",
"assertions": [
"dropped, silent, and flapping streams converge on a bounded retry or actionable stop",
"stale, superseded, missing-page, and permanent-failure work cannot mutate the current pane",
"manual reopen starts a fresh budget and adopts one current subscription",
"a late stale rejection cannot clear the replacement stream's ready deadline"
]
},
{
"file": "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts",
"assertions": [
"a real paired-runtime disconnect ends with an actionable Reconnect control",
"manual reconnect paints a new remote frame instead of retaining only the frozen frame",
"a pane whose first open fails can recover from no prior subscription"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
"result": "passed",
"durationSeconds": 0.36,
"summary": "Four focused files passed 61 deterministic error, status, scheduler, lifecycle, in-flight ownership, stale-rejection, liveness, and cleanup tests."
},
{
"date": "2026-08-07",
"runner": "ci",
"platform": "linux",
"command": "pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 59.8,
"summary": "Both isolated headless paired-runtime scenarios passed: bounded recovery after a live-stream disconnect and manual recovery when the first stream never opened."
}
],
"runtimeBudget": {
"p95Seconds": 120,
"scope": "focused fake-clock renderer contracts plus one isolated headless paired-runtime journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic suite and one changed-E2E CI run passed; soak history has not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical scheduler oracle fails with the reschedule disabled at one attempt instead of four and passes after restoration. On the PR head, the delayed handoff oracle counted two scheduled attempts instead of one, and the late stale-rejection oracle lost the replacement ready deadline and never offered reconnect; both pass after the ownership fixes. Current main structurally contains the original one-shot timer dead end. The focused suite passes 61 tests, and the isolated paired-runtime CI journey passes both disconnect and cold-open recovery scenarios."
},
"performanceBudget": {
"required": true,
"evidence": "Each pane retains at most one retry timer, one in-flight attempt, and one ready deadline. Automatic recovery makes at most five counted attempts with no global scan, subprocess, polling loop, or cross-pane fanout; exhaustion stops background work until explicit user reconnect."
},
"promotionCriteria": [
"Run the same journey against a headed Orca desktop server and retain independent host/client state evidence.",
"Collect Linux and physical Windows paired-server evidence plus sleep/wake or network-partition recovery.",
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.",
"Replace the E2E's post-exhaustion elapsed-time check with an exact subscribe-attempt oracle."
],
"knownGaps": [
"The paired journey covers headless orca serve, not the primary headed desktop-server topology.",
"The live fault is an explicit disconnect rather than sleep/wake or a real network partition.",
"The E2E observes UI convergence and new frame identity but does not count screencast subscribe attempts directly.",
"Live Linux and Windows evidence is uncollected."
],
"demotionRule": "Keep experimental or demote if retries can stop before recovery or exhaustion, continue after exhaustion, run in parallel, mutate a superseded pane, hide the Reconnect control, leak a subscription or timer, or fail to paint a new frame after manual reconnect."
},
{
"id": "editor.live-log-append-stability",
"title": "Long live session logs retain their Monaco viewport while appending",
@@ -6422,186 +6631,95 @@
},
{
"id": "terminal-input.ime-and-synthetic-forwarding",
"title": "IME, native text, and synthetic input commit exactly once and do not leak preedit bytes",
"title": "Native terminal composition commits once through stock xterm",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-input",
"layer": "renderer-unit-platform-soak",
"surfaces": [
"IME",
"native text forwarding",
"synthetic input",
"paste",
"keyboard bypass",
"JIS yen"
],
"surfaces": ["IME", "paste", "keyboard bypass", "JIS yen"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": [],
"coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"coveredProviders": ["local", "ssh", "remote-runtime"],
"coverageNotes": "Stock xterm owns preedit and commit derivation. Renderer tests cover same-task consecutive commits, commit-before-Enter, ordinary input, action refusal, and PTY-generation isolation. Native platform and rendering certification remains required.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6699",
"https://github.com/stablyai/orca/pull/6682",
"https://github.com/stablyai/orca/pull/6513",
"https://github.com/stablyai/orca/pull/6999"
"https://github.com/stablyai/orca/pull/11293",
"https://github.com/stablyai/orca/pull/12062"
],
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"invariant": "The stock terminal composition owner emits committed text exactly once; Orca refuses IME-owned application actions and never reroutes terminal data to a replacement PTY generation.",
"oracle": "Real xterm onData bytes are asserted for consecutive composition, ordinary ASCII, and physical Enter. Marked and ordinary shortcut shapes are paired, and a replaced PTY transport receives no stale input.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-stock-composition.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.test.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/lib/ime-composition-keyboard-event.test.ts",
"pnpm run test:e2e:terminal-ime-native"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
"src/renderer/src/components/terminal-pane/terminal-stock-composition.test.ts",
"src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.test.ts",
"src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/lib/ime-composition-keyboard-event.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"file": "src/renderer/src/components/terminal-pane/terminal-stock-composition.test.ts",
"assertions": [
"native text commits route once to the intended PTY",
"composition/preedit bookkeeping does not leak premature text"
"a commit is delivered before a same-task next composition",
"ordinary typing and physical Enter are unchanged",
"the commit precedes physical Enter"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
"file": "src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.test.ts",
"assertions": [
"synthetic and native input-source paths are classified for covered fixtures"
"a marked real key shape cannot trigger a copy action",
"the ordinary copy shortcut is unchanged"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"empty Sogou-style compositionupdate keeps composition active",
"stale composition state expires so editing keys cannot stay suppressed indefinitely",
"post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"assertions": [
"suppressed candidate keydown arms only the matching keypress/keyup release guard",
"held-key repeat keydowns stay guarded until their keyup, even past expiry",
"fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"assertions": [
"an orphaned plain-letter keyup arms exactly the next bare digit guard",
"ordinary, overlapping, shifted, and long-held letter keydowns keep following digits available",
"physical letter tracking survives cross-pane focus handoff and clears on renderer-window blur"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"assertions": [
"standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed",
"candidate Space/digit selectors are suppressed only while candidate guards are active"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"assertions": [
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
]
},
{
"file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"assertions": [
"Sogou-style Space candidate selection submits only the committed Chinese character",
"Sogou-style digit candidate selection submits only the committed Chinese phrase",
"Post-composition Sogou-style digit selection stays out of the PTY after compositionend",
"Legacy orphaned-letter-keyup digit selection submits no selector byte to the PTY; real legacy candidate commit preservation remains a platform gap"
"ordinary input reaches the bound transport",
"a replaced transport generation receives no stale input"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-11",
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-stock-composition.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.test.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/lib/ime-composition-keyboard-event.test.ts",
"result": "passed",
"durationSeconds": 0.619,
"summary": "1 state test file and 10 tests passed on macOS, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests."
},
{
"date": "2026-07-11",
"runner": "local",
"platform": "linux",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"result": "passed",
"durationSeconds": 0.747,
"summary": "1 state test file and 10 tests passed in Debian 12 arm64 Docker under Node 24, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests."
},
{
"date": "2026-07-11",
"runner": "local",
"platform": "linux",
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"result": "passed",
"durationSeconds": 30.3,
"summary": "Debian 12 arm64 Docker with Node 24 and Xvfb passed both live Electron/PTY IME scenarios; the orphaned-letter-keyup candidate digit submitted no selector byte, and the real-Codex opt-in scenario was skipped."
},
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"result": "passed",
"durationSeconds": 0.7,
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
},
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"result": "passed",
"durationSeconds": 72.0,
"summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME."
"durationSeconds": 20.43,
"summary": "Five files and 569 tests passed against the stock xterm composition path and PTY generation gate."
}
],
"runtimeBudget": {
"p95Seconds": 90,
"scope": "renderer unit plus focused Electron/CDP IME repro"
"scope": "renderer unit plus native Linux IME E2E"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion."
"evidence": "The new stock-owner slice has one local run; native soak history is still required."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
"evidence": "Removing the action guard triggers the marked action while the ordinary negative remains valid; bypassing the PTY-generation check sends stale input. Native platform mutations remain required."
},
"performanceBudget": {
"required": true,
"evidence": "Terminal IME hot-path audit clean on 2026-07-11: candidate guards add O(1) boolean checks plus bounded per-pane Space/digit state and one renderer-scoped currently pressed physical-letter set; ref-counted renderer and pane blur listeners are disposed with pane lifecycle; no per-pane keyboard-event fan-out, polling, IPC, subprocess work, or SSH/remote transport cost added."
"evidence": "The change deletes per-pane trackers, timers, event buses, candidate registries, and native-text forwarding from the input hot path."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
"Mark true OS IME automation gaps explicitly by platform.",
"Pair with Windows ConPTY keyboard reset for Windows standard-key behavior."
"Complete the native OS and input-method matrix.",
"Add Windows fractional-scale rendering evidence.",
"Retain paired ordinary input and mutation checks."
],
"knownGaps": [
"Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
"Windows Korean rendering, macOS Korean/Pinyin/Cangjie/Vietnamese, Wayland/Fcitx, and iOS/Android still require native evidence.",
"The retained Linux E2E must be rerun against this stock-owner head."
],
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
"demotionRule": "Cannot promote while any required goalpost environment lacks native evidence."
},
{
"id": "terminal-input.windows-conpty-keyboard-reset",
@@ -9874,6 +9992,166 @@
"tests/ is outside every tsconfig include, so the harness is linted and executed but not typechecked."
],
"demotionRule": "Demote if the baseline checkout cannot be materialized in CI, if a pairing has to be skipped to keep the lane green, or if the journey stops asserting the full step list and frame sequence."
},
{
"id": "codex.state-db-backfill-recovery-bounds",
"title": "Codex state DB backfill recovery has bounded process and timer use",
"maturity": "experimental",
"protection": "partial",
"owner": "codex-runtime",
"layer": "main-process-supervisor",
"surfaces": [
"Codex state DB backfill recovery",
"managed Codex home startup",
"Codex PTY and rate-limit recovery triggers"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "wsl", "paired-runtime-host"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "wsl"],
"coverageNotes": "A deterministic injected claimant and clock cover exits before and after the former ten-second classification boundary, exact spawn/poll/backoff counts, transient exit and spawn-error recovery, owner-lock reacquisition, WSL routing, and cleanup. Live Windows, WSL, and paired-runtime-host process evidence remains uncollected.",
"motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"],
"invariant": "One authoritative Codex state DB backfill supervisor spawns at most five claimants regardless of process lifetime, retains at most one child and one timer, rejects later automatic restarts after claimant exhaustion, and bounds pre-claim coordinator failures to five triggers while transient claimant and owner-lock failures can recover within those budgets.",
"oracle": "Use an injected process and clock to exit every claimant at 10,001ms without waiting. Permanent failure must give up after five spawns and exits with exactly 15 poll timers and four backoffs, no live child or pending timer, and no safety-fuse abort; repeated triggers must reuse the exhausted result with zero new lock or runner calls. Separately fail twice at 10,001ms and complete on the third claimant, recover from a first-spawn error on attempt two, reacquire after a transient owner-lock failure, and cap permanent lock-root failures at five attempts across six triggers.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/codex/codex-state-db-backfill-recovery.test.ts"
],
"testFiles": ["src/main/codex/codex-state-db-backfill-recovery.test.ts"],
"assertionRefs": [
{
"file": "src/main/codex/codex-state-db-backfill-recovery.test.ts",
"assertions": [
"does not restart an exhausted supervisor when later triggers arrive",
"releases a completed supervisor entry",
"releases a failed owner-lock attempt for later arbitration",
"bounds permanent coordinator failures across later triggers",
"bounds permanent claimant exits just beyond the fast-failure window",
"recovers after claimant exits just beyond the old fast-failure window",
"recovers after a transient process spawn error",
"routes a WSL managed home through its distro and Linux CODEX_HOME",
"does not interfere with a live supervisor from another Orca instance"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-07",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/codex/codex-state-db-backfill-recovery.test.ts",
"durationSeconds": 0.4,
"summary": "Twelve deterministic recovery and owner-lock tests passed, including exact permanent and transient 10,001ms exit accounting, spawn-error recovery, trigger-level exhaustion memoization, bounded coordinator failure, successful owner-lock reacquisition, and zero retained children or timers."
}
],
"runtimeBudget": {
"p95Seconds": 2,
"scope": "deterministic main-process supervisor and owner-lock contract"
},
"flakeHistory": {
"status": "not-started",
"evidence": "New fake-clock gate; no arbitrary sleeps or elapsed wall-time oracle."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical production source at release commit 9e948fbdf462ede3c0160c719474100fc5cbefb7 and origin/main spawned six claimants and reached the harness fuse with 18 polls and six backoffs. The candidate gives up after five claimants with 15 polls and four backoffs; removing the total-spawn cap restores the exact red result."
},
"performanceBudget": {
"required": true,
"evidence": "The supervisor retains at most one claimant and one awaited timer. A permanent process lifetime is bounded to five subprocesses, 15 status polls, and four backoffs; later triggers reuse one settled promise and spawn nothing. Pre-claim coordinator failures perform at most five status/lock attempts. The fix adds no polling, listener, provider call, scan, wire field, or hot-path work."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Collect live Windows/WSL and paired-runtime-host recovery evidence.",
"Keep exact spawn, exit, timer, backoff, cleanup, and transient-recovery assertions green."
],
"knownGaps": [
"Live child-process behavior is represented by an injected EventEmitter claimant.",
"Physical Windows, WSL, SSH, and paired-runtime-host journeys were not run."
],
"demotionRule": "Demote if any exit duration can evade the spawn budget, concurrent children or timers appear, cleanup retains resources, or a transient failure cannot recover within the budget."
},
{
"id": "ssh-managed-hooks.node18-runtime-compatibility",
"title": "SSH managed-hook companions load and install hooks on Node 18",
"maturity": "experimental",
"protection": "partial",
"owner": "agent-hooks",
"layer": "ssh-relay-companion-runtime",
"surfaces": [
"SSH managed-hook companion startup",
"remote Codex hook installation",
"remote Claude hook installation"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["ssh"],
"coverageNotes": "A real Node 18 process loads every platform's built companion and runs the bundled Codex and Claude installers against an isolated temporary home. The six artifacts share one platform-neutral source bundle; live SSH transport and physical remote hosts remain uncollected.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/12617"],
"invariant": "Every managed-hook companion shipped for Node 18 must load before agent installers execute, and importing desktop-only state inspection must not make unrelated remote installers depend on node:sqlite.",
"oracle": "Build all relay artifacts, require each managed-hook-runtime.js in a real Node 18 process, assert its installer export, then install only Codex and Claude into a fresh temporary home. Require two successful installer results, executable hook scripts, and both agent configs to reference their managed scripts without touching the user's home.",
"commands": [
"pnpm run build:relay && npx -y node@18 config/scripts/smoke-managed-hook-runtime-node18.mjs",
"pnpm exec vitest run --config config/vitest.config.ts config/scripts/pr-workflow-parallelism.test.mjs"
],
"testFiles": [
"config/scripts/smoke-managed-hook-runtime-node18.mjs",
"config/scripts/pr-workflow-parallelism.test.mjs"
],
"assertionRefs": [
{
"file": "config/scripts/smoke-managed-hook-runtime-node18.mjs",
"assertions": [
"all six built companions load and export installManagedHooks under Node 18",
"Codex and Claude installations both succeed in an isolated home",
"both generated configs reference executable managed hook scripts"
]
},
{
"file": "config/scripts/pr-workflow-parallelism.test.mjs",
"assertions": [
"PR CI builds the companions before switching to Node 18 and running the smoke oracle"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-08",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm run build:relay && npx -y node@18 config/scripts/smoke-managed-hook-runtime-node18.mjs",
"durationSeconds": 4,
"summary": "All six companions loaded under Node 18.20.8; Codex and Claude produced two successful results, executable hook scripts, and managed configs in a temporary home."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "six built-companion loads plus two isolated local-filesystem installer executions"
},
"flakeHistory": {
"status": "not-started",
"evidence": "New deterministic artifact smoke with no timing oracle; CI history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On main de4f272b31, the same Node 18 require exited 1 before exports with ERR_UNKNOWN_BUILTIN_MODULE for node:sqlite. The candidate passes all six loads and both installers; restoring the eager node:sqlite value import reproduces the original exit."
},
"performanceBudget": {
"required": false,
"evidence": "The product change replaces one eager built-in import with a constructor-time built-in lookup. It adds no polling, IPC, filesystem scan, subprocess, listener, or recurring work."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Collect one live SSH install on Linux and one on Windows OpenSSH.",
"Keep all-platform load, selected installer, isolated-home, and workflow-order assertions green."
],
"knownGaps": [
"The artifact journey uses local filesystem calls rather than a live SSH transport.",
"Platform-named bundles are loaded on macOS locally; physical Linux and Windows evidence is not yet recorded."
],
"demotionRule": "Demote if any shipped companion stops loading on Node 18, selected installers mutate outside the isolated home, or the CI lane no longer runs the built artifact with Node 18."
}
]
}
+22 -4
View File
@@ -54,6 +54,19 @@ function makeSocketPath(userDataDir) {
return join(userDataDir, 'daemon.sock')
}
// 'connected' only when something actually answers; a dead entry left on the name is not it.
function probeEndpoint(socketPath) {
return new Promise((resolveProbe) => {
const socket = connect(socketPath)
const settle = (result) => {
socket.destroy()
resolveProbe(result)
}
socket.on('connect', () => settle('connected'))
socket.on('error', () => settle('unreachable'))
})
}
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
return new Promise((resolveRpc, rejectRpc) => {
let settled = false
@@ -267,11 +280,16 @@ async function main() {
if (existsSync(pidPath)) {
throw new Error('daemon left its PID ownership record behind after shutdown')
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
throw new Error('daemon left its endpoint socket behind after shutdown')
// Why not "the entry is gone": a departing daemon deliberately leaves its endpoint behind
// for the next publisher to replace in one rename. What must be true is that nothing
// answers there any more.
if (process.platform !== 'win32' && (await probeEndpoint(socketPath)) === 'connected') {
throw new Error('daemon still answers its endpoint after shutdown')
}
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
// The private bind name is consumed by the publish, and nothing sweeps the runtime dir any
// more, so a leak here is permanent. Match what the code actually generates rather than a
// literal prefix: this check silently matched nothing after the namespace moved from .b.
const leaked = readdirSync(userDataDir).filter((entry) => /^\.[a-z][0-9a-f]{10}$/.test(entry))
if (leaked.length > 0) {
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
}
+174 -48
View File
@@ -1,11 +1,9 @@
/**
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
*
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
* alive used to delete the *replacement's* socket when it finally exited, because libuv
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
* run it, and that a user cannot fix by restarting the app.
* Two starting daemons race to replace one dead endpoint entry. The loser may exit after the
* winner has published, but its close must not remove the winner's canonical socket. The survivor
* must remain reachable through that path with its own token.
*
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
*
@@ -14,17 +12,36 @@
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
// Why read it from source: the launcher keys adoption of a live incumbent on this exact code,
// and hardcoding it here would let the two drift silently — which is the failure the assertion
// below exists to catch.
const DAEMON_EXIT_ENDPOINT_OCCUPIED = Number(
readFileSync(join(repoRoot, 'src/main/daemon/daemon-endpoint-ownership.ts'), 'utf8').match(
/DAEMON_EXIT_ENDPOINT_OCCUPIED = (\d+)/
)?.[1]
)
const READY_TIMEOUT_MS = 20_000
const EXIT_TIMEOUT_MS = 15_000
const REACHABILITY_TIMEOUT_MS = 2_000
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
function readProtocolVersion() {
const source = readFileSync(join(repoRoot, 'src/main/daemon/daemon-protocol-version.ts'), 'utf8')
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('could not read daemon protocol version')
}
return Number(match[1])
}
function bootDaemon(tag, dir, socketPath) {
const tokenPath = join(dir, `${tag}.token`)
const pidPath = join(dir, `${tag}.pid`)
@@ -53,7 +70,7 @@ function bootDaemon(tag, dir, socketPath) {
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
return new Promise((resolveReady, rejectReady) => {
const ready = new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
READY_TIMEOUT_MS
@@ -61,7 +78,7 @@ function bootDaemon(tag, dir, socketPath) {
child.on('message', (msg) => {
if (msg && typeof msg === 'object' && msg.type === 'ready') {
clearTimeout(timer)
resolveReady({ child, tokenPath, pidPath })
resolveReady()
}
})
child.on('exit', (code) => {
@@ -69,28 +86,80 @@ function bootDaemon(tag, dir, socketPath) {
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
})
})
return { child, tokenPath, pidPath, ready }
}
function isReachable(socketPath) {
return new Promise((resolveReachable) => {
const socket = connect({ path: socketPath })
socket.on('connect', () => {
socket.once('connect', () => {
socket.destroy()
resolveReachable(true)
})
socket.on('error', () => resolveReachable(false))
socket.once('error', () => {
socket.destroy()
resolveReachable(false)
})
})
}
function isDaemonReachable(socketPath, tokenPath, protocolVersion) {
if (!existsSync(tokenPath)) {
return Promise.resolve(false)
}
const token = readFileSync(tokenPath, 'utf8').trim()
return new Promise((resolveReachable) => {
let buffer = ''
let settled = false
const socket = connect({ path: socketPath })
const finish = (reachable) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.destroy()
resolveReachable(reachable)
}
const timer = setTimeout(() => finish(false), REACHABILITY_TIMEOUT_MS)
socket.once('error', () => finish(false))
socket.once('connect', () => {
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId: randomUUID(),
role: 'control'
})}\n`
)
})
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8')
const newlineIndex = buffer.indexOf('\n')
if (newlineIndex === -1) {
return
}
try {
const message = JSON.parse(buffer.slice(0, newlineIndex))
finish(message.type === 'hello' && message.ok === true)
} catch {
finish(false)
}
})
})
}
/** Resolves with the child's exit code, so callers can assert on it. */
function killAndWait(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve()
return Promise.resolve(child.exitCode)
}
return new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
child.on('exit', () => {
child.on('exit', (code) => {
clearTimeout(timer)
resolveExit()
resolveExit(code)
})
child.kill('SIGTERM')
})
@@ -107,46 +176,103 @@ async function main() {
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
const socketPath = join(dir, 'daemon.sock')
let replaced
let replacement
const protocolVersion = readProtocolVersion()
const daemons = []
try {
replaced = await bootDaemon('replaced', dir, socketPath)
const replacedInode = statSync(socketPath).ino
log('daemon A published the endpoint')
const departed = bootDaemon('departed', dir, socketPath)
daemons.push(departed)
await departed.ready
const deadInode = statSync(socketPath).ino
await killAndWait(departed.child)
if (!existsSync(socketPath) || statSync(socketPath).ino !== deadInode) {
throw new Error('departing daemon did not leave its dead endpoint entry in place')
}
if (await isReachable(socketPath)) {
throw new Error('departed daemon remains reachable')
}
log('daemon A departed and left a dead endpoint entry')
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
unlinkSync(socketPath)
replacement = await bootDaemon('replacement', dir, socketPath)
const replacementInode = statSync(socketPath).ino
if (replacedInode === replacementInode) {
throw new Error('daemon B did not publish a distinct endpoint')
}
if (!(await isReachable(socketPath))) {
throw new Error('daemon B is not reachable through the canonical endpoint')
}
log('daemon B took over the endpoint and is reachable')
// A exits long after losing the endpoint. This is the step that used to break B.
await killAndWait(replaced.child)
await new Promise((r) => setTimeout(r, 300))
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
throw new Error("daemon A's late exit deleted daemon B's endpoint")
}
if (!(await isReachable(socketPath))) {
throw new Error('daemon B became unreachable after daemon A exited')
}
if (!existsSync(replacement.pidPath)) {
throw new Error("daemon A's late exit removed daemon B's ownership record")
}
if (existsSync(replaced.pidPath)) {
throw new Error('daemon A left its own ownership record behind')
const racers = [bootDaemon('racer-b', dir, socketPath), bootDaemon('racer-c', dir, socketPath)]
daemons.push(...racers)
const readiness = await Promise.allSettled(racers.map((daemon) => daemon.ready))
if (readiness.every((result) => result.status === 'rejected')) {
throw new Error(
`neither racing daemon published the endpoint:\n${readiness
.map((result) => (result.status === 'rejected' ? result.reason.message : ''))
.join('\n')}`
)
}
log('PASS: the endpoint owner and the session host stayed the same daemon')
const owners = []
for (const daemon of racers) {
if (await isDaemonReachable(socketPath, daemon.tokenPath, protocolVersion)) {
owners.push(daemon)
}
}
if (owners.length !== 1) {
throw new Error(`expected one reachable racing daemon, found ${owners.length}`)
}
const survivor = owners[0]
const loser = racers.find((daemon) => daemon !== survivor)
const survivorInode = statSync(socketPath).ino
if (survivorInode === deadInode) {
throw new Error('survivor did not replace the dead endpoint entry')
}
log('racing daemon published over the dead entry and is reachable')
await killAndWait(loser.child)
if (!existsSync(socketPath) || statSync(socketPath).ino !== survivorInode) {
throw new Error("losing racer's exit removed the survivor's endpoint")
}
if (!(await isDaemonReachable(socketPath, survivor.tokenPath, protocolVersion))) {
throw new Error("survivor became unreachable after the losing racer's exit")
}
if (!existsSync(survivor.pidPath)) {
throw new Error('survivor lost its ownership record')
}
if (existsSync(loser.pidPath)) {
throw new Error('losing racer left its ownership record behind')
}
// Why a second, non-racing phase: above, both racers are awaited to ready-or-exit before a
// winner is identified, so the loser has usually already gone and killing it proves little.
// With a known-live incumbent the interleaving is forced rather than hoped for: the newcomer
// must find the endpoint occupied, refuse to take it, and damage nothing on its way out.
const survivorInodeBeforeBlocked = statSync(socketPath).ino
const blocked = bootDaemon('blocked', dir, socketPath)
daemons.push(blocked)
await blocked.ready.then(
() => {
throw new Error('a daemon published onto an endpoint a live daemon already owned')
},
() => {
// Expected: it cannot publish onto a live owner's name, so it exits instead.
}
)
// Why pin the code: the launcher keys adoption of a live incumbent on exactly this exit
// code, so a silent change to it would strand a concurrently starting app on local
// non-persistent terminals with nothing failing.
const blockedExit = await killAndWait(blocked.child)
if (blockedExit !== DAEMON_EXIT_ENDPOINT_OCCUPIED) {
throw new Error(
`a daemon that lost the endpoint exited ${blockedExit}, not ${DAEMON_EXIT_ENDPOINT_OCCUPIED}`
)
}
if (!existsSync(socketPath) || statSync(socketPath).ino !== survivorInodeBeforeBlocked) {
throw new Error("a daemon that could not publish removed the live owner's endpoint")
}
if (!(await isDaemonReachable(socketPath, survivor.tokenPath, protocolVersion))) {
throw new Error('live owner became unreachable after a newcomer failed to publish')
}
if (existsSync(blocked.pidPath)) {
throw new Error('a daemon that could not publish left an ownership record behind')
}
log('a newcomer refused the live owner’s endpoint and left it intact')
log('PASS: the racing survivor remains reachable through the canonical endpoint')
} finally {
for (const daemon of [replaced, replacement]) {
if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) {
for (const daemon of daemons) {
if (daemon.child.exitCode === null && daemon.child.signalCode === null) {
try {
daemon.child.kill('SIGKILL')
} catch {
@@ -106,6 +106,9 @@ export const SEARCH_KEYWORD_OVERRIDES = {
theme: '主题',
zoom: '缩放',
scale: '比例',
// Why: 用法 is "how to use". Keeping the keyword on 用量 also preserves the quota-shaped
// query that stopped matching once the labels moved from 使用量 to 使用情况.
usage: '用量',
proxy: '代理',
terminal: '终端',
workspace: '工作区',
@@ -94,6 +94,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'yarn',
'Kagi',
'kagi',
'kimi',
'Bitbucket',
'bitbucket',
'GNOME',
@@ -221,6 +222,10 @@ export const BRAND_MISTRANSLATIONS = {
Goose: ['鹅'],
Pi: ['圆周率'],
Droid: ['机器人'],
Kimi: ['基米'],
// Why: zh-only — 登月计划 is the literal "moon landing programme", not Kimi's vendor. ja and
// ko render this keyword their own way, so it must not go in the cross-locale preserve set.
moonshot: ['登月计划'],
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
Bitbucket: ['位桶'],
Linear: ['线性', '线形'],
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
// #12881 — the status bar item menu renders "<Brand> Usage" for every provider in one dropdown, so
// zh has to settle on a single word. Registering it here, not only in the catalog, is what makes a
// re-translation pass reproduce the decision instead of re-splitting the labels.
//
// Scope is the provider usage surfaces, not the word "usage" everywhere: a measured volume
// ("Daily usage" 每日使用量) stays 使用量. The toggle descriptions are pinned by the catalog test
// instead — LOCALE_VALUE_OVERRIDES keys on the exact English string, so full sentences do not fit.
//
// ja is split the same way zh was (使用量 vs の使用状況) and is not covered here; ko is uniform.
// Real catalog keys, so a future zh key override shadowing one of these is caught here.
const STATUS_BAR_PROVIDERS = [
['auto.components.status.bar.StatusBar.3885eb74d8', 'Claude'],
['auto.components.status.bar.StatusBar.c0909c686e', 'Codex'],
['auto.components.status.bar.StatusBar.c1df0d67ec', 'Gemini'],
['auto.components.status.bar.StatusBar.antigravityUsage', 'Antigravity'],
['auto.components.status.bar.StatusBar.8c86cd77b0', 'OpenCode Go'],
['auto.components.status.bar.StatusBar.5e59007df4', 'Kimi'],
['auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax'],
['auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok'],
['auto.components.settings.accounts.search.f4a8c2e1b7', 'Grok (xAI)']
]
describe('locale-translation-policy zh status bar usage labels', () => {
it('pins every provider usage label to 使用情况', () => {
for (const [key, brand] of STATUS_BAR_PROVIDERS) {
expect(
repairTranslatedValue({
key,
enValue: `${brand} Usage`,
localeValue: `${brand} 使用量`,
locale: 'zh'
}),
brand
).toBe(`${brand} 使用情况`)
}
})
it('reverts the Kimi transliteration in labels and search keywords', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.3a6c028ea8',
enValue: 'Kimi Usage',
localeValue: '基米用法',
locale: 'zh'
})
).toBe('Kimi 使用情况')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.40e5c3c285',
enValue: 'kimi',
localeValue: '基米',
locale: 'zh'
})
).toBe('kimi')
})
// Why: NEVER_TRANSLATE_VALUES is cross-locale. Moving the moonshot repair there would rewrite
// ja's ムーンショット, so it has to stay a zh-scoped BRAND_MISTRANSLATIONS entry.
it('keeps the moonshot keyword repair scoped to zh', () => {
const key = 'auto.components.settings.appearance.search.35565867cb'
expect(
repairTranslatedValue({ key, enValue: 'moonshot', localeValue: '登月计划', locale: 'zh' })
).toBe('moonshot')
expect(
repairTranslatedValue({
key,
enValue: 'moonshot',
localeValue: 'ムーンショット',
locale: 'ja'
})
).toBe('ムーンショット')
})
// Why: the labels moving to 使用情况 removed the only 用量-shaped match, so the search keyword
// carries it. 用法 means "how to use".
it('keeps 用量 as the zh search keyword for "usage"', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.00a028f25f',
enValue: 'usage',
localeValue: '用法',
locale: 'zh'
})
).toBe('用量')
})
})
@@ -201,6 +201,12 @@ export const LOCALE_VALUE_OVERRIDES = {
'Codex Usage': 'Codex 使用情况',
'Claude Usage': 'Claude 使用情况',
'Gemini Usage': 'Gemini 使用情况',
'Antigravity Usage': 'Antigravity 使用情况',
'OpenCode Go Usage': 'OpenCode Go 使用情况',
'Kimi Usage': 'Kimi 使用情况',
'MiniMax Usage': 'MiniMax 使用情况',
'Grok Usage': 'Grok 使用情况',
'Grok (xAI) Usage': 'Grok (xAI) 使用情况',
'Force Delete Branch': '强制删除分支',
'Time agents worked': '代理工作时间',
PR: 'PR',
@@ -57,6 +57,7 @@ describe('PR E2E gate contract', () => {
it('selects modified Playwright specs without running deleted tests', () => {
expect(filterStep.run).toContain('--diff-filter=AMCR')
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
expect(filterStep.run).toContain("'-native\\.spec\\.ts$'")
expect(filterStep.run).not.toContain('tests/playwright\\.')
})
@@ -97,6 +97,24 @@ describe('PR workflow parallelism', () => {
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
})
it('smokes managed-hook companions under their supported Node 18 runtime', () => {
const steps = workflow.jobs.managed_hook_node18.steps
const installIndex = steps.findIndex(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const buildIndex = steps.findIndex((step) => step.run === 'pnpm run build:relay')
const node18Index = steps.findIndex(
(step) => step.uses === 'actions/setup-node@v6' && step.with['node-version'] === '18'
)
const smokeIndex = steps.findIndex(
(step) => step.run === 'node config/scripts/smoke-managed-hook-runtime-node18.mjs'
)
expect(installIndex).toBeLessThan(buildIndex)
expect(buildIndex).toBeLessThan(node18Index)
expect(node18Index).toBeLessThan(smokeIndex)
})
it('restores the pnpm store before dependency installation', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
@@ -130,7 +148,12 @@ describe('PR workflow parallelism', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
for (const jobName of [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync'
]) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
@@ -176,10 +199,17 @@ describe('PR workflow parallelism', () => {
'root_directory_guard',
'typecheck',
'git_compatibility',
'xterm_patch_sync',
'shell_contracts',
'test',
'managed_hook_node18',
'package',
'package_windows'
])
const verifyStep = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
})
})
+727
View File
@@ -0,0 +1,727 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
/**
* Flags pnpm@10 passes to `git diff` in its own `diffFolders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
]
/**
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
* inside the upstream checkout is byte-comparable with the emitted patch.
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
/** Blanks the vars pnpm blanks so user and system git config cannot reach the diff. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function trimSurroundingSlashes(value) {
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
}
/**
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
* scratch folder prefixes, drop a trailing no-newline marker, and remove
* .DS_Store entries a macOS run would otherwise smuggle in.
*/
export function normalizePnpmDiff(stdout, folderA, folderB) {
const a = folderA.replace(/\\/g, '/')
const b = folderB.replace(/\\/g, '/')
return stdout
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
export function splitPatchEntries(patchText) {
return patchText
.split(/^(?=diff --git )/m)
.filter((entry) => entry.startsWith('diff --git '))
.map((text) => {
const header = text.slice(0, text.indexOf('\n'))
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
if (!match) {
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
}
return { path: match[1], text }
})
}
export function selectPatchEntries(patchText, matches) {
return splitPatchEntries(patchText)
.filter((entry) => matches(entry.path))
.map((entry) => entry.text)
.join('')
}
/** The hand-editable half of a patch: everything under `src/`. */
export function sourceHunks(patchText) {
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
}
/**
* The emitted patch can only ever name files the registry publishes, and
* upstream's `.npmignore` strips `src/**\/*.test.ts`. Deriving the source patch
* from the emitted patch therefore deletes any hunk against those files on the
* next `--write` — including the `MockCompositionHelper` implementation the
* patched `ICompositionHelper` requires to type-check. The two derivations must
* still agree everywhere they can both speak, or the source patch and the
* shipped patch have drifted.
*/
export function assertSourceDerivationsAgree(checkoutSource, patchText, publishedPaths) {
const published = selectPatchEntries(checkoutSource, (file) => publishedPaths.has(file))
const emitted = sourceHunks(patchText)
if (published === emitted) {
return
}
throw new Error(
[
'The checkout diff and the emitted patch disagree on a published source file.',
` from checkout: [${splitPatchEntries(published)
.map((e) => e.path)
.join(', ')}]`,
` from patch: [${splitPatchEntries(emitted)
.map((e) => e.path)
.join(', ')}]`,
` first difference at character ${firstDifferenceIndex(published, emitted)}`,
'',
'This is a generator bug, not a patch problem: the same content diffed two',
'ways must produce the same bytes.'
].join('\n')
)
}
/** The derived half of a patch: build output, never edited by hand. */
export function generatedHunks(patchText, generatedPaths) {
return selectPatchEntries(patchText, (file) =>
generatedPaths.some((prefix) => file.startsWith(prefix))
)
}
export function stampVersionSource(source, version) {
const stamped = source.replace(
/export const XTERM_VERSION = '[^']+';/,
`export const XTERM_VERSION = '${version}';`
)
if (stamped === source && !source.includes(`'${version}'`)) {
throw new Error('Version stamp file does not declare XTERM_VERSION')
}
return stamped
}
/**
* The published tarball names the commit it was built from, so a version bump
* that forgets the manifest fails here instead of producing a patch against the
* wrong tree.
*/
export function assertPublishedCommit(publishedPackageJson, packageEntry, upstreamCommit) {
if (publishedPackageJson.version !== packageEntry.version) {
throw new Error(
`${packageEntry.name}: registry served ${publishedPackageJson.version}, manifest pins ${packageEntry.version}`
)
}
if (publishedPackageJson.commit !== upstreamCommit) {
throw new Error(
[
`${packageEntry.name}@${packageEntry.version} was published from commit`,
` ${publishedPackageJson.commit ?? '(absent)'}`,
`but ${MANIFEST_RELATIVE_PATH} pins`,
` ${upstreamCommit}`,
'Update upstream.commit in the manifest to the published commit, then rerun with --write.'
].join('\n')
)
}
}
/** Guards the publish-order trap: a dev esbuild pass would silently de-minify lib/*.mjs. */
export function assertBuildStepsAllowed(manifest) {
const forbidden = new Set(manifest.forbiddenBuildScripts?.scripts ?? [])
for (const packageEntry of manifest.packages) {
for (const step of packageEntry.build) {
const script = step.command === 'npm' && step.args[0] === 'run' ? step.args[1] : undefined
if (script !== undefined && forbidden.has(script)) {
throw new Error(
`${packageEntry.name}: build step \`npm run ${script}\` is forbidden. ${manifest.forbiddenBuildScripts.why}`
)
}
}
}
}
export const SOURCEMAP_POLICIES = new Set(['include', 'delete'])
/** A typo would fall through to `include` and re-inflate the patch by 5.8 MB. */
export function assertSourcemapPolicy(manifest) {
const policy = manifest.sourcemaps?.policy
if (!SOURCEMAP_POLICIES.has(policy)) {
throw new Error(
`sourcemaps.policy must be one of ${[...SOURCEMAP_POLICIES].join(', ')}, got ${JSON.stringify(policy)}`
)
}
return policy
}
/**
* pnpm keys the patched package directory and the lockfile entry by the
* sha256 of the patch file itself, so a regenerated patch that leaves
* pnpm-lock.yaml alone fails `--frozen-lockfile` on every machine but the
* author's.
*/
export function patchHash(patchText) {
return createHash('sha256').update(patchText, 'utf8').digest('hex')
}
function lockfilePatchHashPattern(packageKey) {
// Unscoped keys such as `node-pty@1.1.0` are emitted unquoted.
return new RegExp(`(^ '?${escapeRegExp(packageKey)}'?:\\n hash: )([0-9a-f]{64})$`, 'm')
}
export function readLockfilePatchHash(lockfileText, packageKey) {
const match = lockfilePatchHashPattern(packageKey).exec(lockfileText)
if (!match) {
throw new Error(`pnpm-lock.yaml has no patchedDependencies entry for '${packageKey}'`)
}
return match[2]
}
function lockfileResolutionHashPattern(packageKey) {
const separator = packageKey.lastIndexOf('@')
const name = escapeRegExp(packageKey.slice(0, separator))
const version = escapeRegExp(packageKey.slice(separator + 1))
// Two spellings: `name@version(patch_hash=…)` in dependency keys, and a bare
// `: version(patch_hash=…)` under `version:` and in resolved dependency maps.
return new RegExp(`(?:${name}@|: )${version}\\(patch_hash=([0-9a-f]{64})\\)`, 'g')
}
/**
* pnpm repeats the hash inside every resolution key that depends on the patched
* package, not just in `patchedDependencies`. Updating one and not the other leaves
* a lockfile that installs on a warm store and drifts on a cold one, which is CI.
*/
export function readLockfileResolutionHashes(lockfileText, packageKey) {
return Array.from(
lockfileText.matchAll(lockfileResolutionHashPattern(packageKey)),
(match) => match[1]
)
}
export function lockfilePatchHashIsStale(lockfileText, packageKey, hash) {
return (
readLockfilePatchHash(lockfileText, packageKey) !== hash ||
readLockfileResolutionHashes(lockfileText, packageKey).some((value) => value !== hash)
)
}
export function updateLockfilePatchHash(lockfileText, packageKey, hash) {
readLockfilePatchHash(lockfileText, packageKey)
return lockfileText
.replace(lockfilePatchHashPattern(packageKey), `$1${hash}`)
.replace(lockfileResolutionHashPattern(packageKey), (match, current) =>
match.replace(`patch_hash=${current}`, `patch_hash=${hash}`)
)
}
export function firstDifferenceIndex(left, right) {
const limit = Math.min(left.length, right.length)
for (let index = 0; index < limit; index += 1) {
if (left[index] !== right[index]) {
return index
}
}
return left.length === right.length ? -1 : limit
}
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
const index = firstDifferenceIndex(committed, regenerated)
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
return [
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
` first difference at character ${index}`,
'',
'The bundle hunks are generated. Do not edit them. Change the source patch',
'instead and regenerate both files:',
'',
' node config/scripts/regenerate-xterm-patches.mjs --write',
'',
'See docs/reference/xterm-patch-regeneration.md.'
].join('\n')
}
function run(command, args, options = {}) {
return execFileSync(command, args, {
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'inherit'],
...options
})
}
function listFilesRelative(root, base = root) {
const files = []
for (const entry of readdirSync(base, { withFileTypes: true })) {
const absolute = path.join(base, entry.name)
if (entry.isDirectory()) {
files.push(...listFilesRelative(root, absolute))
} else if (entry.isFile()) {
files.push(path.relative(root, absolute))
}
}
return files.sort()
}
function sameBytes(left, right) {
return (
statSync(left).size === statSync(right).size && readFileSync(left).equals(readFileSync(right))
)
}
function fetchPristinePackage(packageEntry, workDir) {
const target = path.join(workDir, 'pristine', packageEntry.name.replace(/[@/]/g, '_'))
rmSync(target, { recursive: true, force: true })
mkdirSync(target, { recursive: true })
const spec = `${packageEntry.name}@${packageEntry.version}`
const output = run('npm', ['pack', spec, '--pack-destination', target, '--silent'], {
cwd: workDir
})
const tarball = path.join(target, output.trim().split('\n').at(-1).trim())
run('tar', ['xzf', tarball, '-C', target])
return path.join(target, 'package')
}
function hasCommit(root, commit) {
try {
return run('git', ['cat-file', '-t', commit], { cwd: root, stdio: 'pipe' }).trim() === 'commit'
} catch {
return false
}
}
function ensureUpstreamCheckout(manifest, workDir) {
const root = path.join(workDir, 'upstream')
const { repository, commit } = manifest.upstream
if (!existsSync(path.join(root, '.git'))) {
mkdirSync(root, { recursive: true })
run('git', ['init', '--quiet'], { cwd: root })
run('git', ['remote', 'add', 'origin', repository], { cwd: root })
}
if (!hasCommit(root, commit)) {
run('git', ['fetch', '--depth=1', 'origin', commit], { cwd: root, stdio: 'inherit' })
}
run('git', ['checkout', '--quiet', '--detach', commit], { cwd: root })
run('git', ['reset', '--quiet', '--hard', commit], { cwd: root })
return root
}
function ensureDependencies(upstreamRoot, manifest) {
const lockfile = path.join(upstreamRoot, 'package-lock.json')
const stamp = path.join(upstreamRoot, 'node_modules', '.orca-xterm-install-stamp')
const want = `${manifest.upstream.commit}\n${statSync(lockfile).size}\n`
if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want) {
return
}
run('npm', ['ci'], {
cwd: upstreamRoot,
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', PUPPETEER_SKIP_DOWNLOAD: '1' }
})
assertToolchain(upstreamRoot, manifest)
writeFileSync(stamp, want)
}
function assertToolchain(upstreamRoot, manifest) {
const expected = manifest.toolchain
for (const [name, version] of Object.entries(expected)) {
if (name === 'why') {
continue
}
const installed = path.join(upstreamRoot, 'node_modules', name, 'package.json')
if (!existsSync(installed)) {
throw new Error(
`Upstream install is missing ${name}. The pinned toolchain is no longer resolvable; see the tsgo note in docs/reference/xterm-patch-regeneration.md.`
)
}
const actual = JSON.parse(readFileSync(installed, 'utf8')).version
if (actual !== version) {
throw new Error(
`Upstream ${name} resolved to ${actual}, manifest expects ${version}. Update the toolchain block only together with a verified rebuild.`
)
}
}
}
/**
* The published src/ must equal the pinned commit's src/ apart from the version
* stamp publish.js rewrites. If it does not, the manifest points at the wrong
* commit and every hunk below would be nonsense.
*/
function assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry) {
const stampFile = packageEntry.versionStampFile
const sourceRoot = path.join(pristineDir, 'src')
const drifted = listFilesRelative(sourceRoot)
.map((relative) => path.join('src', relative))
.filter((relative) => relative !== stampFile)
.filter(
(relative) =>
!sameBytes(
path.join(pristineDir, relative),
path.join(upstreamRoot, packageEntry.packageDir, relative)
)
)
if (drifted.length > 0) {
throw new Error(
`Published src/ does not match ${packageEntry.packageDir} at the pinned commit: ${drifted.join(', ')}`
)
}
}
function buildPackage(upstreamRoot, packageEntry, manifest) {
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
for (const directory of ['lib', 'out', 'out-esbuild']) {
rmSync(path.join(packageRoot, directory), { recursive: true, force: true })
}
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
writeFileSync(
stampPath,
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
)
assertBuildStepsAllowed(manifest)
for (const step of packageEntry.build) {
run(step.command, step.args, { cwd: path.join(packageRoot, step.cwd), stdio: 'inherit' })
}
}
/** Proves the pinned toolchain still reproduces the untouched published bundles. */
function assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry) {
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
const drifted = listFilesRelative(pristineDir)
.filter((relative) =>
packageEntry.generatedPaths.some((prefix) => toPosix(relative).startsWith(prefix))
)
.filter(
(relative) => !sameBytes(path.join(pristineDir, relative), path.join(packageRoot, relative))
)
if (drifted.length > 0) {
throw new Error(
[
`Rebuilding ${packageEntry.name}@${packageEntry.version} from the pinned commit did not reproduce the published bundles:`,
...drifted.map((relative) => ` ${relative}`),
'',
'Refusing to emit a patch. Either the toolchain drifted or the build ran in the',
'wrong order (a dev `npm run setup` pass de-minifies lib/*.mjs).'
].join('\n')
)
}
}
function toPosix(value) {
return value.split(path.sep).join('/')
}
function overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, destination) {
rmSync(destination, { recursive: true, force: true })
cpSync(pristineDir, destination, { recursive: true })
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
for (const relative of listFilesRelative(pristineDir)) {
// package.json carries the registry's version/commit stamp, which the build
// tree has no way to reproduce and which we never want to patch.
if (relative === 'package.json') {
continue
}
const built = path.join(packageRoot, relative)
if (!existsSync(built)) {
throw new Error(`Published file has no build-tree counterpart: ${relative}`)
}
copyFileSync(built, path.join(destination, relative))
}
}
function diffFolders(folderA, folderB) {
let stdout
try {
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
encoding: 'utf8',
maxBuffer: 512 * 1024 * 1024,
env: pnpmDiffEnvironment(),
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
// `git diff --no-index` exits 1 whenever it finds differences.
if (error.status !== 1 || error.stderr?.length > 0) {
throw error
}
stdout = error.stdout
}
return normalizePnpmDiff(stdout, folderA, folderB)
}
// Why: dropping only the map hunks would ship maps whose offsets no longer line
// up with the patched bundle. Deleting the maps outright is the honest form of
// the same size saving, and the diff carries it as a file-deletion stanza.
function deleteGeneratedSourcemaps(patchedDir, packageEntry) {
for (const relative of listFilesRelative(patchedDir)) {
const posix = toPosix(relative)
if (!packageEntry.generatedPaths.some((prefix) => posix.startsWith(prefix))) {
continue
}
const absolute = path.join(patchedDir, relative)
if (posix.endsWith('.map')) {
rmSync(absolute)
continue
}
// The reference outlives the file it points at, so it goes with it.
const text = readFileSync(absolute, 'utf8')
const stripped = text.replace(/\n\/\/# sourceMappingURL=[^\n]*\n?$/, '')
if (stripped !== text) {
writeFileSync(absolute, stripped)
}
}
}
/** The source of truth for the hand-written half: what the checkout itself holds. */
function diffCheckoutSource(packageRoot) {
return run('git', [...CHECKOUT_DIFF_FLAGS, '--', 'src/'], {
cwd: packageRoot,
env: pnpmDiffEnvironment(),
maxBuffer: 64 * 1024 * 1024
})
}
function publishedSourcePaths(pristineDir) {
return new Set(
listFilesRelative(path.join(pristineDir, 'src')).map((relative) =>
toPosix(path.join('src', relative))
)
)
}
function regeneratePackage(packageEntry, manifest, context) {
const { workDir, repoRoot } = context
const pristineDir = fetchPristinePackage(packageEntry, workDir)
const published = JSON.parse(readFileSync(path.join(pristineDir, 'package.json'), 'utf8'))
assertPublishedCommit(published, packageEntry, manifest.upstream.commit)
const upstreamRoot = ensureUpstreamCheckout(manifest, workDir)
ensureDependencies(upstreamRoot, manifest)
assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry)
buildPackage(upstreamRoot, packageEntry, manifest)
assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry)
run('git', ['reset', '--quiet', '--hard', manifest.upstream.commit], { cwd: upstreamRoot })
run('git', ['apply', '--whitespace=nowarn', path.join(repoRoot, packageEntry.sourcePatch)], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
buildPackage(upstreamRoot, packageEntry, manifest)
const patchedDir = path.join(workDir, 'patched', packageEntry.name.replace(/[@/]/g, '_'))
overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, patchedDir)
if (assertSourcemapPolicy(manifest) === 'delete') {
deleteGeneratedSourcemaps(patchedDir, packageEntry)
}
// Leave the checkout diffable: the pinned commit plus the source patch, with
// no publish-time version stamp mixed in, so `git diff` there is the source
// patch and nothing else.
run('git', ['checkout', '--', packageEntry.versionStampFile], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
const source = diffCheckoutSource(path.join(upstreamRoot, packageEntry.packageDir))
const patch = diffFolders(pristineDir, patchedDir)
assertSourceDerivationsAgree(source, patch, publishedSourcePaths(pristineDir))
return { patch, source }
}
export function regenerateXtermPatches({
mode,
repoRoot = DEFAULT_REPO_ROOT,
workDir = path.join(tmpdir(), 'orca-xterm-patch-build'),
log = console.info
} = {}) {
const manifest = JSON.parse(readFileSync(path.join(repoRoot, MANIFEST_RELATIVE_PATH), 'utf8'))
assertBuildStepsAllowed(manifest)
assertSourcemapPolicy(manifest)
mkdirSync(workDir, { recursive: true })
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml')
let lockfile = readFileSync(lockfilePath, 'utf8')
let lockfileChanged = false
const failures = []
for (const packageEntry of manifest.packages) {
const shortCommit = manifest.upstream.commit.slice(0, 12)
log(`${packageEntry.name}@${packageEntry.version}: regenerating from ${shortCommit}`)
const { patch: regenerated, source: canonicalSource } = regeneratePackage(
packageEntry,
manifest,
{ workDir, repoRoot }
)
const patchPath = path.join(repoRoot, packageEntry.patch)
const sourcePatchPath = path.join(repoRoot, packageEntry.sourcePatch)
const packageKey = `${packageEntry.name}@${packageEntry.version}`
const hash = patchHash(regenerated)
if (mode === 'write') {
writeFileSync(patchPath, regenerated)
writeFileSync(sourcePatchPath, canonicalSource)
log(` wrote ${packageEntry.patch} (${Buffer.byteLength(regenerated)} bytes)`)
log(` wrote ${packageEntry.sourcePatch} (${Buffer.byteLength(canonicalSource)} bytes)`)
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
lockfile = updateLockfilePatchHash(lockfile, packageKey, hash)
lockfileChanged = true
log(` updated pnpm-lock.yaml patch hash to ${hash}`)
}
continue
}
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
const stale = Array.from(
new Set(readLockfileResolutionHashes(lockfile, packageKey).filter((v) => v !== hash))
)
failures.push(
[
`${packageKey}: pnpm-lock.yaml records a stale patch hash.`,
` patchedDependencies: ${readLockfilePatchHash(lockfile, packageKey)}`,
` resolution keys: ${stale.length > 0 ? stale.join(', ') : 'in sync'}`,
` patch: ${hash}`,
'',
'pnpm keys the patched package by the sha256 of the patch file, so',
'`pnpm install --frozen-lockfile` will fail. Rerun with --write.'
].join('\n')
)
}
const committed = readFileSync(patchPath, 'utf8')
if (committed !== regenerated) {
failures.push(
formatCheckFailure({
name: packageEntry.name,
patchPath: packageEntry.patch,
committed,
regenerated
})
)
continue
}
const committedSource = readFileSync(sourcePatchPath, 'utf8')
if (committedSource !== canonicalSource) {
failures.push(
formatCheckFailure({
name: packageEntry.name,
patchPath: packageEntry.sourcePatch,
committed: committedSource,
regenerated: canonicalSource
})
)
continue
}
log(` in sync (${Buffer.byteLength(regenerated)} bytes)`)
}
if (lockfileChanged) {
writeFileSync(lockfilePath, lockfile)
}
if (failures.length > 0) {
throw new Error(failures.join('\n\n'))
}
}
const USAGE =
'Usage: regenerate-xterm-patches.mjs [--check | --write] [--work-dir=<path>]\n' +
' --check (default) verifies the shipped patches match the pinned upstream build;\n' +
' --write regenerates them from config/patches/xterm-src/. Build outside this repo:\n' +
' tsc otherwise walks up into our node_modules. See\n' +
' docs/reference/xterm-patch-regeneration.md.'
function main(argv) {
if (argv.includes('--help') || argv.includes('-h')) {
console.info(USAGE)
return
}
// --check is the default, so an unrecognised flag would otherwise silently run a full
// upstream build instead of whatever the caller meant.
const known = (v) =>
!v.startsWith('-') || ['--write', '--check'].includes(v) || v.startsWith('--work-dir=')
const unknown = argv.filter((value) => !known(value))
if (unknown.length > 0) {
throw new Error(`Unknown option: ${unknown.join(', ')}\n${USAGE}`)
}
const write = argv.includes('--write')
const check = argv.includes('--check') || !write
if (write && argv.includes('--check')) {
throw new Error('Pass either --write or --check, not both')
}
const workDirArgument = argv.find((value) => value.startsWith('--work-dir='))
regenerateXtermPatches({
mode: write ? 'write' : 'check',
workDir: workDirArgument ? path.resolve(workDirArgument.slice('--work-dir='.length)) : undefined
})
if (check) {
console.info('xterm patches are in sync with the pinned upstream build.')
}
}
// realpathSync so a symlinked checkout path still registers as a direct run.
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
try {
main(process.argv.slice(2))
} catch (error) {
console.error(`\n${error.message}\n`)
process.exit(1)
}
}
@@ -0,0 +1,478 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertBuildStepsAllowed,
assertPublishedCommit,
assertSourceDerivationsAgree,
assertSourcemapPolicy,
firstDifferenceIndex,
formatCheckFailure,
generatedHunks,
lockfilePatchHashIsStale,
normalizePnpmDiff,
patchHash,
pnpmDiffEnvironment,
readLockfilePatchHash,
readLockfileResolutionHashes,
selectPatchEntries,
sourceHunks,
splitPatchEntries,
stampVersionSource,
updateLockfilePatchHash
} from './regenerate-xterm-patches.mjs'
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_PATH = path.join(REPO_ROOT, 'config', 'patches', 'xterm-upstream.json')
const temporaryDirectories = []
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
)
})
async function createDirectory() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-xterm-patch-'))
temporaryDirectories.push(directory)
return directory
}
async function writeTree(root, files) {
for (const [relative, contents] of Object.entries(files)) {
const target = path.join(root, relative)
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, contents)
}
}
/** The three exported diff pieces, composed the way the generator composes them. */
function diffFolders(folderA, folderB) {
let stdout
try {
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
encoding: 'utf8',
env: pnpmDiffEnvironment(),
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
if (error.status !== 1) {
throw error
}
stdout = error.stdout
}
return normalizePnpmDiff(stdout, folderA, folderB)
}
const PRISTINE = {
'src/Widget.ts': 'export function widget(): number {\n return 1\n}\n',
'src/Other.ts': 'export const other = 0\n',
'lib/widget.js': 'function widget(){return 1}\n',
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAA"}\n',
'package.json': '{\n "name": "@scope/widget"\n}\n'
}
const PATCHED = {
...PRISTINE,
'src/Widget.ts': 'export function widget(): number {\n return 2\n}\n',
'lib/widget.js': 'function widget(){return 2}\n',
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAC"}\n'
}
describe('pnpm diff format', () => {
it('keeps the exact git flags pnpm uses, so patches survive `pnpm patch-commit`', () => {
expect(PNPM_DIFF_FLAGS).toEqual([
'-c',
'core.safecrlf=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
])
})
it('blanks the config-bearing environment variables', () => {
const environment = pnpmDiffEnvironment({ PATH: '/usr/bin', HOME: '/Users/someone' })
expect(environment).toMatchObject({
PATH: '/usr/bin',
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
})
})
it('strips both scratch folder prefixes from headers and index lines', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(patch).not.toContain(root)
expect(patch).toContain('diff --git a/lib/widget.js b/lib/widget.js')
expect(patch).toContain('--- a/src/Widget.ts')
expect(patch).toContain('+++ b/src/Widget.ts')
})
it('drops a trailing no-newline marker and .DS_Store entries', () => {
const withMarker = 'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n'
expect(normalizePnpmDiff(withMarker, '/a', '/b')).toBe(
'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n'
)
const withJunk = [
'diff --git a/.DS_Store b/.DS_Store\n',
'index 000..111\n',
'Binary files differ\n',
'diff --git a/lib/x.js b/lib/x.js\n',
'@@ -1 +1 @@\n-a\n+b\n'
].join('')
expect(normalizePnpmDiff(withJunk, '/a', '/b')).toBe(
'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n'
)
})
})
describe('patch entry splitting', () => {
it('separates hand-edited source hunks from generated bundle hunks', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(splitPatchEntries(patch).map((entry) => entry.path)).toEqual([
'lib/widget.js',
'lib/widget.js.map',
'src/Widget.ts'
])
expect(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path)).toEqual([
'src/Widget.ts'
])
expect(splitPatchEntries(generatedHunks(patch, ['lib/'])).map((entry) => entry.path)).toEqual([
'lib/widget.js',
'lib/widget.js.map'
])
})
it('rejects renames rather than emitting a header it cannot round-trip', () => {
expect(() => splitPatchEntries('diff --git a/old.ts b/new.ts\n')).toThrow(
/renames are not supported/
)
})
it('concatenating the two halves reproduces the whole patch', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(generatedHunks(patch, ['lib/']) + sourceHunks(patch)).toBe(patch)
})
})
describe('round-trip stability', () => {
it('re-diffing an applied patch yields the identical patch', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
const replay = path.join(root, 'replay')
await writeTree(replay, PRISTINE)
const patchFile = path.join(root, 'round-trip.patch')
await writeFile(patchFile, patch)
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
PATCHED['lib/widget.js']
)
expect(diffFolders(folderA, replay)).toBe(patch)
})
it('applying only the source half leaves the bundle untouched', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patchFile = path.join(root, 'src.patch')
await writeFile(patchFile, sourceHunks(diffFolders(folderA, folderB)))
const replay = path.join(root, 'replay')
await writeTree(replay, PRISTINE)
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
expect(await readFile(path.join(replay, 'src/Widget.ts'), 'utf8')).toBe(
PATCHED['src/Widget.ts']
)
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
PRISTINE['lib/widget.js']
)
})
})
// The source patch is derived from the upstream checkout, not from the emitted
// patch, so a hunk against an unpublished file survives `--write` instead of
// deleting itself on the next run.
describe('unpublished source files', () => {
const publishedEntry = [
'diff --git a/src/browser/Types.ts b/src/browser/Types.ts',
'index 1111111..2222222 100644',
'--- a/src/browser/Types.ts',
'+++ b/src/browser/Types.ts',
'@@ -1 +1,2 @@',
' interface ICompositionHelper {',
'+ handleCompositionInput(data: string): boolean;',
''
].join('\n')
const unpublishedEntry = [
'diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts',
'index 3333333..4444444 100644',
'--- a/src/browser/TestUtils.test.ts',
'+++ b/src/browser/TestUtils.test.ts',
'@@ -1 +1,2 @@',
' class MockCompositionHelper {',
'+ public handleCompositionInput(): boolean { return false; }',
''
].join('\n')
const published = new Set(['src/browser/Types.ts'])
it('accepts a source diff that carries an extra unpublished file', () => {
expect(() =>
assertSourceDerivationsAgree(publishedEntry + unpublishedEntry, publishedEntry, published)
).not.toThrow()
})
it('fails when the two derivations disagree on a published file', () => {
expect(() =>
assertSourceDerivationsAgree(
publishedEntry.replace('boolean;', 'void;'),
publishedEntry,
published
)
).toThrow(/disagree on a published source file/)
})
it('diffs the checkout with pnpm formatting so the two halves stay comparable', () => {
expect(CHECKOUT_DIFF_FLAGS).toEqual(PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index'))
expect(CHECKOUT_DIFF_FLAGS).toContain('--full-index')
expect(CHECKOUT_DIFF_FLAGS).not.toContain('--no-index')
})
})
describe('manifest guards', () => {
const packageEntry = { name: '@xterm/xterm', version: '6.1.0-beta.287' }
const commit = '53a98a720ae4a973e384fa2440880d09537132f3'
it('accepts a tarball that names the pinned commit', () => {
const published = { version: '6.1.0-beta.287', commit }
expect(() => assertPublishedCommit(published, packageEntry, commit)).not.toThrow()
})
it('fails when a version bump moved the upstream commit', () => {
const published = { version: '6.1.0-beta.287', commit: 'f'.repeat(40) }
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(
/was published from commit[\s\S]*Update upstream\.commit/
)
})
it('fails when the registry serves a different version than the manifest pins', () => {
const published = { version: '6.1.0-beta.288', commit }
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(/registry served/)
})
it('fails when the tarball carries no commit stamp at all', () => {
expect(() =>
assertPublishedCommit({ version: '6.1.0-beta.287' }, packageEntry, commit)
).toThrow(/\(absent\)/)
})
it('refuses a build step that would de-minify the bundle', () => {
const manifest = {
forbiddenBuildScripts: { why: 'dev esbuild', scripts: ['setup'] },
packages: [
{
name: '@xterm/xterm',
build: [
{ cwd: '.', command: 'npm', args: ['run', 'setup'] },
{ cwd: '.', command: 'npm', args: ['run', 'package'] }
]
}
]
}
expect(() => assertBuildStepsAllowed(manifest)).toThrow(/`npm run setup` is forbidden/)
})
it('refuses a sourcemap policy it does not implement', () => {
expect(assertSourcemapPolicy({ sourcemaps: { policy: 'delete' } })).toBe('delete')
expect(assertSourcemapPolicy({ sourcemaps: { policy: 'include' } })).toBe('include')
expect(() => assertSourcemapPolicy({ sourcemaps: { policy: 'exclude' } })).toThrow(
/must be one of include, delete, got "exclude"/
)
expect(() => assertSourcemapPolicy({})).toThrow(/got undefined/)
})
it('stamps the published version into the version source', () => {
const source = "export const XTERM_VERSION = '6.0.0';\n"
expect(stampVersionSource(source, '6.1.0-beta.287')).toBe(
"export const XTERM_VERSION = '6.1.0-beta.287';\n"
)
expect(() => stampVersionSource('export const OTHER = 1\n', '6.1.0')).toThrow(/XTERM_VERSION/)
})
})
describe('lockfile coupling', () => {
const lockfile = [
'patchedDependencies:',
" '@xterm/xterm@6.1.0-beta.287':",
` hash: ${'0'.repeat(64)}`,
' path: config/patches/@xterm__xterm@6.1.0-beta.287.patch',
' node-pty@1.1.0:',
` hash: ${'1'.repeat(64)}`,
' path: config/patches/node-pty@1.1.0.patch',
'snapshots:',
` '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=${'0'.repeat(64)}))':`,
` '@xterm/xterm': 6.1.0-beta.287(patch_hash=${'0'.repeat(64)})`,
` node-pty@1.1.0(patch_hash=${'1'.repeat(64)}):`,
''
].join('\n')
it('hashes the patch the way pnpm keys the store directory', () => {
expect(patchHash('diff --git a/x b/x\n')).toBe(
createHash('sha256').update('diff --git a/x b/x\n').digest('hex')
)
})
it('reads quoted and unquoted package keys', () => {
expect(readLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287')).toBe('0'.repeat(64))
expect(readLockfilePatchHash(lockfile, 'node-pty@1.1.0')).toBe('1'.repeat(64))
})
it('rewrites only the targeted entry', () => {
const updated = updateLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287', 'a'.repeat(64))
expect(readLockfilePatchHash(updated, '@xterm/xterm@6.1.0-beta.287')).toBe('a'.repeat(64))
expect(readLockfilePatchHash(updated, 'node-pty@1.1.0')).toBe('1'.repeat(64))
expect(updated.split('\n')).toHaveLength(lockfile.split('\n').length)
})
// pnpm repeats the hash in every resolution key. Rewriting only patchedDependencies
// installs fine on a warm store and drifts on a cold one, so it fails in CI only.
it('rewrites the resolution keys as well as patchedDependencies', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
expect(readLockfileResolutionHashes(lockfile, key)).toEqual(['0'.repeat(64), '0'.repeat(64)])
const updated = updateLockfilePatchHash(lockfile, key, 'a'.repeat(64))
expect(readLockfileResolutionHashes(updated, key)).toEqual(['a'.repeat(64), 'a'.repeat(64)])
expect(readLockfileResolutionHashes(updated, 'node-pty@1.1.0')).toEqual(['1'.repeat(64)])
expect(updated).not.toContain('0'.repeat(64))
})
it('reports a lockfile stale in its resolution keys alone', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
const halfUpdated = lockfile.replace(`hash: ${'0'.repeat(64)}`, `hash: ${'a'.repeat(64)}`)
expect(readLockfilePatchHash(halfUpdated, key)).toBe('a'.repeat(64))
expect(lockfilePatchHashIsStale(halfUpdated, key, 'a'.repeat(64))).toBe(true)
expect(lockfilePatchHashIsStale(lockfile, key, '0'.repeat(64))).toBe(false)
})
it('fails loudly when the package is not patched at all', () => {
expect(() => readLockfilePatchHash(lockfile, '@xterm/addon-webgl@0.20.0-beta.286')).toThrow(
/no patchedDependencies entry/
)
})
})
describe('check-mode reporting', () => {
it('points at the source patch instead of the bundle', () => {
const message = formatCheckFailure({
name: '@xterm/xterm',
patchPath: 'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
committed: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n',
regenerated: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+c\n'
})
expect(message).toContain('Do not edit them')
expect(message).toContain('--write')
expect(message).toContain('docs/reference/xterm-patch-regeneration.md')
expect(message).toContain('files [lib/x.js]')
})
it('locates the first differing character', () => {
expect(firstDifferenceIndex('abc', 'abd')).toBe(2)
expect(firstDifferenceIndex('abc', 'abc')).toBe(-1)
expect(firstDifferenceIndex('abc', 'abcd')).toBe(3)
})
})
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
it('records the lockfile hash pnpm derives from the patch file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const key = `${packageEntry.name}@${packageEntry.version}`
expect(readLockfilePatchHash(lockfile, key)).toBe(patchHash(patch))
}
})
it('keeps the source patch equal to the full patch on every published file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
const published = new Set(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path))
expect(selectPatchEntries(source, (file) => published.has(file))).toBe(sourceHunks(patch))
expect(generatedHunks(patch, packageEntry.generatedPaths)).not.toBe('')
}
})
// Why: the source patch is deliberately a superset. Anything extra must be a
// file upstream's .npmignore strips, because a hunk against a published file
// that never reached the shipped patch would be a hunk that is not installed.
it('only exceeds the full patch on files the registry does not publish', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
const published = new Set(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path))
const unpublished = splitPatchEntries(source)
.map((entry) => entry.path)
.filter((file) => !published.has(file))
for (const file of unpublished) {
expect(file, `${file} is not an unpublished test file`).toMatch(/\.test\.ts$/)
}
}
})
it('pins a full upstream commit and a buildable package entry', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
expect(manifest.upstream.commit).toMatch(/^[0-9a-f]{40}$/)
expect(manifest.packages.length).toBeGreaterThan(0)
expect(() => assertBuildStepsAllowed(manifest)).not.toThrow()
})
})
@@ -1,306 +0,0 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
})
}
function processGroupMembers(processGroupId) {
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
encoding: 'utf8'
})
if (result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function stopOwnedProcessGroup(processGroupId) {
let members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
console.error(
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
)
try {
process.kill(-processGroupId, 'SIGTERM')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const deadline = Date.now() + processStopTimeoutMs
while (Date.now() < deadline) {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
}
try {
process.kill(-processGroupId, 'SIGKILL')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const killDeadline = Date.now() + processKillTimeoutMs
do {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
} while (Date.now() < killDeadline)
return members
}
function commandOutput(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
}
}
}
async function waitForHangulEngine(ibusProcess) {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
if (result.status === 0) {
return
}
await delay(100)
}
throw new Error('Timed out while selecting the IBus Hangul engine')
}
async function runInsideSession(evidenceDir) {
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
const ibusLogFd = openSync(ibusLogPath, 'w')
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
ibusDaemonPid: null,
ibusGroupBeforeCleanup: [],
ibusGroupAfterCleanup: [],
playwrightPid: null,
windowManagerPid: null,
windowManagerGroupAfterCleanup: []
}
let ibusProcess
let windowManagerProcess
let testExitCode = 1
try {
configureHangulEngine()
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
detached: true,
env: process.env,
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
})
if (!windowManagerProcess.pid) {
throw new Error('xfwm4 did not return a PID')
}
evidence.windowManagerPid = windowManagerProcess.pid
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
ibusProcess = spawn(
'ibus-daemon',
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
{
detached: true,
env: process.env,
stdio: ['ignore', ibusLogFd, ibusLogFd]
}
)
if (!ibusProcess.pid) {
throw new Error('ibus-daemon did not return a PID')
}
evidence.ibusDaemonPid = ibusProcess.pid
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
await waitForHangulEngine(ibusProcess)
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
},
stdio: 'inherit'
}
)
if (!testProcess.pid) {
throw new Error('Playwright did not return a PID')
}
evidence.playwrightPid = testProcess.pid
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
testExitCode = await waitForExit(testProcess)
} finally {
if (ibusProcess?.pid) {
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
}
if (windowManagerProcess?.pid) {
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
windowManagerProcess.pid
)
}
closeSync(ibusLogFd)
closeSync(windowManagerLogFd)
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
ibusLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
)
copyFileSync(
windowManagerLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
)
writeFileSync(
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
if (evidence.ibusGroupAfterCleanup.length > 0) {
throw new Error(
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
)
}
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
throw new Error(
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
)
}
return testExitCode
}
async function runOuter() {
if (process.platform !== 'linux') {
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
}
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
mkdirSync(runtimeDir, { mode: 0o700 })
mkdirSync(path.join(evidenceDir, 'config'))
mkdirSync(path.join(evidenceDir, 'cache'))
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
const sessionProcess = spawn(
'xvfb-run',
[
'--auto-servernum',
'dbus-run-session',
'--',
process.execPath,
scriptPath,
insideSessionFlag,
evidenceDir
],
{
cwd: projectDir,
detached: true,
env: {
...process.env,
GTK_IM_MODULE: 'ibus',
IBUS_ENABLE_SYNC_MODE: '1',
LANG: process.env.LANG || 'C.UTF-8',
QT_IM_MODULE: 'ibus',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
XDG_RUNTIME_DIR: runtimeDir,
XMODIFIERS: '@im=ibus'
},
stdio: 'inherit'
}
)
if (!sessionProcess.pid) {
throw new Error('xvfb-run did not return a PID')
}
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
const exitCode = await waitForExit(sessionProcess)
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
if (remaining.length > 0) {
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
}
return exitCode
}
const insideSession = process.argv[2] === insideSessionFlag
try {
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
@@ -0,0 +1,488 @@
import { spawn, spawnSync } from 'node:child_process'
import {
closeSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
openSync,
writeFileSync
} from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
const inputFramework = process.env.ORCA_E2E_NATIVE_IME ?? 'ibus'
const displayServer = process.env.ORCA_E2E_NATIVE_DISPLAY_SERVER ?? 'x11'
const isWayland = displayServer === 'wayland'
if (!['ibus', 'fcitx5'].includes(inputFramework)) {
throw new Error(`Unsupported native IME framework: ${inputFramework}`)
}
if (!['wayland', 'x11'].includes(displayServer)) {
throw new Error(`Unsupported native display server: ${displayServer}`)
}
if (isWayland && inputFramework !== 'fcitx5') {
throw new Error('Native Wayland coverage currently requires Fcitx5')
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
})
}
function processGroupMembers(processGroupId) {
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
encoding: 'utf8'
})
if (result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function stopOwnedProcessGroup(processGroupId) {
let members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
console.error(
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
)
try {
process.kill(-processGroupId, 'SIGTERM')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const deadline = Date.now() + processStopTimeoutMs
while (Date.now() < deadline) {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
}
try {
process.kill(-processGroupId, 'SIGKILL')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const killDeadline = Date.now() + processKillTimeoutMs
do {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
} while (Date.now() < killDeadline)
return members
}
function commandOutput(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
}
}
}
function configureFcitxProfile(evidenceDir) {
const fcitxConfigDir = path.join(evidenceDir, 'config', 'fcitx5')
mkdirSync(fcitxConfigDir, { recursive: true })
writeFileSync(
path.join(fcitxConfigDir, 'profile'),
`[Groups/0]
Name=Default
Default Layout=us
DefaultIM=hangul
[Groups/0/Items/0]
Name=keyboard-us
Layout=
[Groups/0/Items/1]
Name=hangul
Layout=
[Groups/0/Items/2]
Name=pinyin
Layout=
[GroupOrder]
0=Default
`
)
}
async function waitForIbusEngine(ibusProcess, engine) {
const busDeadline = Date.now() + 15_000
while (Date.now() < busDeadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine'], { encoding: 'utf8' })
if (result.status === 0) {
break
}
await delay(100)
}
spawnSync('ibus', ['engine', engine], { stdio: 'pipe' })
const engineDeadline = Date.now() + 15_000
while (Date.now() < engineDeadline) {
const result = spawnSync('ibus', ['engine'], { encoding: 'utf8' })
if (result.status === 0 && result.stdout.trim() === engine) {
return
}
await delay(100)
}
throw new Error(`Timed out while selecting the IBus ${engine} engine`)
}
async function waitForFcitx(fcitxProcess) {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (fcitxProcess.exitCode !== null) {
throw new Error(`fcitx5 exited early with code ${fcitxProcess.exitCode}`)
}
const owner = spawnSync(
'gdbus',
[
'call',
'--session',
'--dest',
'org.freedesktop.DBus',
'--object-path',
'/org/freedesktop/DBus',
'--method',
'org.freedesktop.DBus.NameHasOwner',
'org.fcitx.Fcitx5'
],
{ encoding: 'utf8' }
)
if (owner.status === 0 && owner.stdout.includes('true')) {
for (const engine of ['hangul', 'pinyin']) {
const addon = spawnSync('fcitx5-remote', ['-m', engine], { encoding: 'utf8' })
if (addon.status !== 0 || addon.stdout.trim().length === 0) {
throw new Error(`Fcitx5 input method is unavailable: ${engine}`)
}
}
return
}
await delay(100)
}
throw new Error('Timed out while starting Fcitx5')
}
async function waitForWaylandCompositor(compositorProcess) {
const runtimeDir = process.env.XDG_RUNTIME_DIR
const display = process.env.WAYLAND_DISPLAY
if (!runtimeDir || !display) {
throw new Error('Native Wayland coverage requires XDG_RUNTIME_DIR and WAYLAND_DISPLAY')
}
const socketPath = path.isAbsolute(display) ? display : path.join(runtimeDir, display)
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (compositorProcess.exitCode !== null) {
throw new Error(`sway exited early with code ${compositorProcess.exitCode}`)
}
if (existsSync(socketPath)) {
return
}
await delay(100)
}
throw new Error(`Timed out while waiting for Wayland socket: ${socketPath}`)
}
async function runInsideSession(evidenceDir) {
const inputMethodLogPath = path.join(evidenceDir, `${inputFramework}-daemon.log`)
const inputMethodLogFd = openSync(inputMethodLogPath, 'w')
const windowManagerName = isWayland ? 'sway' : 'xfwm4'
const displayEvidenceSuffix = isWayland ? '-wayland' : ''
const windowManagerLogPath = path.join(evidenceDir, `${windowManagerName}.log`)
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
displayServer,
inputFramework,
inputMethodDaemonPid: null,
inputMethodGroupBeforeCleanup: [],
inputMethodGroupAfterCleanup: [],
playwrightPid: null,
swaySocket: null,
waylandDisplay: process.env.WAYLAND_DISPLAY ?? null,
windowManagerPid: null,
windowManagerGroupAfterCleanup: []
}
let inputMethodProcess
let windowManagerProcess
let testExitCode = 1
try {
if (inputFramework === 'ibus') {
configureHangulEngine()
} else {
configureFcitxProfile(evidenceDir)
}
windowManagerProcess = spawn(
windowManagerName,
isWayland ? ['-c', '/dev/null'] : ['--compositor=off'],
{
detached: true,
env: process.env,
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
}
)
if (!windowManagerProcess.pid) {
throw new Error(`${windowManagerName} did not return a PID`)
}
evidence.windowManagerPid = windowManagerProcess.pid
console.error(`[terminal-ime] started ${windowManagerName} PID ${windowManagerProcess.pid}`)
if (isWayland) {
process.env.SWAYSOCK = path.join(
process.env.XDG_RUNTIME_DIR,
`sway-ipc.${process.getuid()}.${windowManagerProcess.pid}.sock`
)
evidence.swaySocket = process.env.SWAYSOCK
await waitForWaylandCompositor(windowManagerProcess)
}
const inputMethodCommand = inputFramework === 'ibus' ? 'ibus-daemon' : 'fcitx5'
const inputMethodArgs =
inputFramework === 'ibus'
? ['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']
: isWayland
? []
: ['--disable=wayland']
inputMethodProcess = spawn(inputMethodCommand, inputMethodArgs, {
detached: true,
env: process.env,
stdio: ['ignore', inputMethodLogFd, inputMethodLogFd]
})
if (!inputMethodProcess.pid) {
throw new Error(`${inputMethodCommand} did not return a PID`)
}
evidence.inputMethodDaemonPid = inputMethodProcess.pid
console.error(`[terminal-ime] started ${inputMethodCommand} PID ${inputMethodProcess.pid}`)
if (inputFramework === 'ibus') {
await waitForIbusEngine(inputMethodProcess, 'hangul')
await waitForIbusEngine(inputMethodProcess, 'libpinyin')
await waitForIbusEngine(inputMethodProcess, 'hangul')
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
} else {
await waitForFcitx(inputMethodProcess)
console.error(`[terminal-ime] Fcitx5 version: ${commandOutput('fcitx5', ['--version'])}`)
}
evidence.inputMethodGroupBeforeCleanup = processGroupMembers(inputMethodProcess.pid)
console.error(
`[terminal-ime] owned ${inputFramework} group: ${evidence.inputMethodGroupBeforeCleanup.join('; ')}`
)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-linux-ime-native.spec.ts'
],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IME: inputFramework
},
stdio: 'inherit'
}
)
if (!testProcess.pid) {
throw new Error('Playwright did not return a PID')
}
evidence.playwrightPid = testProcess.pid
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
testExitCode = await waitForExit(testProcess)
} finally {
if (inputMethodProcess?.pid) {
evidence.inputMethodGroupBeforeCleanup = processGroupMembers(inputMethodProcess.pid)
evidence.inputMethodGroupAfterCleanup = await stopOwnedProcessGroup(inputMethodProcess.pid)
}
if (windowManagerProcess?.pid) {
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
windowManagerProcess.pid
)
}
closeSync(inputMethodLogFd)
closeSync(windowManagerLogFd)
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
inputMethodLogPath,
path.join(projectDir, 'test-results', `terminal-${inputFramework}-native-daemon.log`)
)
copyFileSync(
windowManagerLogPath,
path.join(
projectDir,
'test-results',
`terminal-${inputFramework}-native${displayEvidenceSuffix}-${windowManagerName}.log`
)
)
writeFileSync(
path.join(
projectDir,
'test-results',
`terminal-${inputFramework}-native${displayEvidenceSuffix}-processes.json`
),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
if (evidence.inputMethodGroupAfterCleanup.length > 0) {
throw new Error(
`Owned ${inputFramework} processes survived cleanup: ${evidence.inputMethodGroupAfterCleanup.join('; ')}`
)
}
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
throw new Error(
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
)
}
return testExitCode
}
async function runOuter() {
if (process.platform !== 'linux') {
throw new Error('The native Linux IME E2E runner requires Linux')
}
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
mkdirSync(runtimeDir, { mode: 0o700 })
mkdirSync(path.join(evidenceDir, 'config'))
mkdirSync(path.join(evidenceDir, 'cache'))
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
const sessionCommand = isWayland ? 'dbus-run-session' : 'xvfb-run'
const sessionArgs = isWayland
? ['--', process.execPath, scriptPath, insideSessionFlag, evidenceDir]
: [
'--auto-servernum',
'dbus-run-session',
'--',
process.execPath,
scriptPath,
insideSessionFlag,
evidenceDir
]
const {
DISPLAY: _display,
GTK_IM_MODULE: _gtkImModule,
QT_IM_MODULE: _qtImModule,
XMODIFIERS: _xModifiers,
...waylandBaseEnv
} = process.env
void _display
void _gtkImModule
void _qtImModule
void _xModifiers
const sessionProcess = spawn(sessionCommand, sessionArgs, {
cwd: projectDir,
detached: true,
env: {
...(isWayland ? waylandBaseEnv : process.env),
...(isWayland
? {
ELECTRON_OZONE_PLATFORM_HINT: 'wayland',
WAYLAND_DISPLAY: 'wayland-1',
WLR_BACKENDS: 'headless',
WLR_HEADLESS_OUTPUTS: '1',
WLR_LIBINPUT_NO_DEVICES: '1',
XDG_SESSION_TYPE: 'wayland'
}
: {
GTK_IM_MODULE: inputFramework === 'fcitx5' ? 'fcitx' : 'ibus',
...(inputFramework === 'ibus' ? { IBUS_ENABLE_SYNC_MODE: '1' } : {}),
QT_IM_MODULE: inputFramework === 'fcitx5' ? 'fcitx' : 'ibus',
XMODIFIERS: inputFramework === 'fcitx5' ? '@im=fcitx' : '@im=ibus'
}),
LANG: process.env.LANG || 'C.UTF-8',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
XDG_RUNTIME_DIR: runtimeDir
},
stdio: 'inherit'
})
if (!sessionProcess.pid) {
throw new Error(`${sessionCommand} did not return a PID`)
}
console.error(
`[terminal-ime] started isolated ${displayServer} session PID ${sessionProcess.pid}`
)
const exitCode = await waitForExit(sessionProcess)
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
if (remaining.length > 0) {
throw new Error(
`Owned ${displayServer} session processes survived cleanup: ${remaining.join('; ')}`
)
}
return exitCode
}
const insideSession = process.argv[2] === insideSessionFlag
try {
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
@@ -0,0 +1,64 @@
#!/usr/bin/env node
import assert from 'node:assert/strict'
import { constants } from 'node:fs'
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const PLATFORMS = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64'
]
const ROOT = fileURLToPath(new URL('../..', import.meta.url))
const require = createRequire(import.meta.url)
assert.match(process.versions.node, /^18\./, 'This smoke test must run under Node 18')
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hook-node18-'))
const originalHome = process.env.HOME
const originalUserProfile = process.env.USERPROFILE
const originalGetuid = process.getuid
process.env.HOME = home
process.env.USERPROFILE = home
process.getuid = undefined
try {
const runtimes = PLATFORMS.map((platform) => {
const artifact = join(ROOT, 'out', 'relay', platform, 'managed-hook-runtime.js')
const runtime = require(artifact)
assert.equal(typeof runtime.installManagedHooks, 'function', `${platform} installer export`)
return runtime
})
const summary = await runtimes[0].installManagedHooks({ agents: ['codex', 'claude'] })
assert.deepEqual(summary, { installers: 2, errors: 0 })
const codexHooks = await readFile(join(home, '.codex', 'hooks.json'), 'utf8')
const claudeSettings = await readFile(join(home, '.claude', 'settings.json'), 'utf8')
assert.match(codexHooks, /\.orca\/agent-hooks\/codex-hook\.sh/)
assert.match(claudeSettings, /\.orca\/agent-hooks\/claude-hook\.sh/)
await access(join(home, '.orca', 'agent-hooks', 'codex-hook.sh'), constants.X_OK)
await access(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), constants.X_OK)
} finally {
if (originalHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = originalHome
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
process.getuid = originalGetuid
await rm(home, { recursive: true, force: true })
}
console.log('Node 18 managed-hook runtime smoke passed for all relay platforms.')
@@ -9,6 +9,7 @@ describe('terminal IME e2e workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
const linuxJob = workflow.jobs.linux
it('runs only on schedule or manual dispatch', () => {
expect(workflow.on.pull_request).toBeUndefined()
@@ -16,14 +17,18 @@ describe('terminal IME e2e workflow', () => {
expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }])
})
it('installs native IBus Hangul and X11 input tools', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
it('installs native IBus and Fcitx5 engines with X11 input tools', () => {
const runs = linuxJob.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const installRun = runs.find((run) => run.includes('apt-get install'))
expect(installRun).toBeDefined()
expect(installRun).toContain('ibus-hangul')
expect(installRun).toContain('ibus-libpinyin')
expect(installRun).toContain('fcitx5-chinese-addons')
expect(installRun).toContain('fcitx5-frontend-gtk3')
expect(installRun).toContain('fcitx5-hangul')
expect(installRun).toContain('sway')
expect(installRun).toContain('wtype')
expect(installRun).toContain('xdotool')
expect(installRun).toContain('xfwm4')
expect(installRun).toContain('xvfb')
@@ -32,36 +37,61 @@ describe('terminal IME e2e workflow', () => {
expect(installRun).toContain('libglib2.0-bin')
})
it('runs deterministic boundaries before the real IBus suite', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const deterministicIndex = runs.findIndex((run) =>
run.includes('terminal-ime-exact-byte.spec.ts')
)
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
it('pins X11 to Ubuntu 22.04 and Wayland to a current wlroots stack', () => {
expect(linuxJob.strategy.matrix.include).toEqual([
{ label: 'X11', os: 'ubuntu-22.04', display_server: 'x11' },
{ label: 'Wayland', os: 'ubuntu-24.04', display_server: 'wayland' }
])
})
it('keeps IBus lifecycle scoped to owned processes', () => {
it('runs both native framework suites before deterministic boundaries', () => {
const steps = linuxJob.steps
const deterministicIndex = steps.findIndex((step) =>
step.run?.includes('terminal-ime-exact-byte.spec.ts')
)
const nativeIndexes = steps
.map((step, index) => (step.run?.includes('test:e2e:terminal-ime-native') ? index : -1))
.filter((index) => index >= 0)
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndexes).toHaveLength(3)
expect(nativeIndexes.every((index) => deterministicIndex > index)).toBe(true)
expect(steps.some((step) => step.with?.name === 'terminal-ime-native-evidence')).toBe(true)
expect(steps.some((step) => step.with?.name === 'terminal-ime-native-fcitx5-evidence')).toBe(
true
)
expect(
steps.some((step) => step.with?.name === 'terminal-ime-native-fcitx5-wayland-evidence')
).toBe(true)
})
it('keeps native input framework lifecycle scoped to owned processes', () => {
const runner = readFileSync(
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
join(projectDir, 'config/scripts/run-terminal-linux-ime-e2e.mjs'),
'utf8'
)
expect(runner).toContain(
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
)
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
expect(runner).toContain("isWayland ? ['-c', '/dev/null'] : ['--compositor=off']")
expect(runner).toContain("['initial-input-mode', 'hangul']")
expect(runner).toContain("['hangul-keyboard', '2']")
expect(runner).toContain("await waitForIbusEngine(inputMethodProcess, 'libpinyin')")
expect(runner).toContain("inputFramework === 'ibus' ? 'ibus-daemon' : 'fcitx5'")
expect(runner).toContain("['--disable=wayland']")
expect(runner).toContain("WLR_BACKENDS: 'headless'")
expect(runner).toContain("WLR_LIBINPUT_NO_DEVICES: '1'")
expect(runner).toContain("for (const engine of ['hangul', 'pinyin'])")
expect(runner).toContain("'org.freedesktop.DBus.NameHasOwner'")
expect(runner).toContain("'org.fcitx.Fcitx5'")
expect(runner).not.toContain("['--check']")
expect(runner.match(/spawnSync\('ibus', \['engine', engine\]/g)).toHaveLength(1)
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
expect(runner).toMatch(
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
/'test:e2e:headful',[\s\S]*'--workers=1',[\s\S]*'--',[\s\S]*'tests\/e2e\/terminal-linux-ime-native\.spec\.ts'/
)
expect(runner).not.toContain("'--replace'")
expect(runner).not.toContain('killall')
@@ -70,10 +100,11 @@ describe('terminal IME e2e workflow', () => {
it('bounds blocking native input commands', () => {
const nativeSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
join(projectDir, 'tests/e2e/terminal-linux-ime-native.spec.ts'),
'utf8'
)
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(9)
expect(nativeSpec).toContain('{ timeout: 20_000 }')
})
})
@@ -251,6 +251,7 @@ async function runValidation(mode) {
app.evaluate(({ app: electronApp }) => ({
disableGpuSandbox: electronApp.commandLine.hasSwitch('disable-gpu-sandbox'),
disableGpu: electronApp.commandLine.hasSwitch('disable-gpu'),
enableWaylandIme: electronApp.commandLine.hasSwitch('enable-wayland-ime'),
ozonePlatform: electronApp.commandLine.getSwitchValue('ozone-platform'),
enableFeatures: electronApp.commandLine.getSwitchValue('enable-features')
})),
@@ -277,6 +278,9 @@ async function runValidation(mode) {
if (mode === 'verify-fix' && commandLineSwitches.disableGpu) {
throw new Error('Expected hardware acceleration to remain enabled, but --disable-gpu is set.')
}
if (mode === 'verify-fix' && !commandLineSwitches.enableWaylandIme) {
throw new Error('Expected --enable-wayland-ime on Linux Wayland, but it was absent.')
}
logPhase('window.first')
page = await runWithTimeout('first renderer window', () => app.firstWindow(), timeoutMs)
@@ -0,0 +1,397 @@
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import process from 'node:process'
import { createJiti } from 'jiti'
const DEFAULT_SAMPLES = 20
const PANE_KEY = 'wsl-relay-bench:11111111-1111-4111-8111-111111111111'
const WSL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
function parseArgs(argv) {
const options = { distro: null, samples: DEFAULT_SAMPLES }
for (let index = 0; index < argv.length; index++) {
const arg = argv[index]
if (arg === '--distro') {
options.distro = argv[++index] ?? null
} else if (arg === '--samples') {
options.samples = Number(argv[++index])
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (!Number.isInteger(options.samples) || options.samples < 5 || options.samples > 100) {
throw new Error('--samples must be an integer between 5 and 100')
}
return options
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
const stdout = []
const stderr = []
child.stdout.on('data', (chunk) => stdout.push(chunk))
child.stderr.on('data', (chunk) => stderr.push(chunk))
child.on('error', reject)
child.on('close', (status) => {
const result = {
status,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8')
}
if (status === 0 || options.allowFailure) {
resolve(result)
} else {
reject(new Error(`${command} exited ${status}: ${result.stderr.trim()}`))
}
})
child.stdin.end(options.input)
})
}
function wslArgs(distro, args) {
return ['-d', distro, '--exec', ...args]
}
async function resolveDistro(requested) {
if (requested) {
return requested
}
const listed = await run('wsl.exe', ['--list', '--quiet'], {
env: { ...process.env, WSL_UTF8: '1' }
})
const distro = listed.stdout
.replaceAll('\0', '')
.split(/\r?\n/)
.map((value) => value.trim())
.find(Boolean)
if (!distro) {
throw new Error('No WSL distro is installed')
}
return distro
}
async function readGuestFile(distro, path) {
const result = await run('wsl.exe', wslArgs(distro, ['/bin/cat', path]), {
allowFailure: true
})
return result.status === 0 ? result.stdout : null
}
async function waitFor(description, probe, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const value = await probe()
if (value) {
return value
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`Timed out waiting for ${description}`)
}
function parseEndpoint(contents) {
const port = Number(/ORCA_AGENT_HOOK_PORT=['"]?(\d+)/.exec(contents)?.[1])
const token = /ORCA_AGENT_HOOK_TOKEN=['"]?([^'"\r\n]+)/.exec(contents)?.[1]
return Number.isInteger(port) && port > 0 && token ? { port, token } : null
}
async function freeGuestPort(distro) {
const result = await run(
'wsl.exe',
wslArgs(distro, [
'/usr/bin/python3',
'-c',
'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()'
])
)
const port = Number(result.stdout.trim())
if (!Number.isInteger(port) || port <= 0) {
throw new Error(`Could not reserve a guest port: ${result.stdout}`)
}
return port
}
async function startStallingGuestServer(distro) {
const source = [
'import socket,sys,threading',
'stop=threading.Event()',
'threading.Thread(target=lambda:(sys.stdin.buffer.read(),stop.set()),daemon=True).start()',
'server=socket.socket()',
'server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)',
'server.bind(("127.0.0.1",0))',
'server.listen()',
'server.settimeout(0.1)',
'print(f"READY {server.getsockname()[1]}",flush=True)',
'held=[]',
'while not stop.is_set():',
' try:',
' client,_=server.accept(); held.append(client); print("ACCEPT",flush=True)',
' except TimeoutError: pass',
'for client in held: client.close()',
'server.close()'
].join('\n')
const child = spawn('wsl.exe', wslArgs(distro, ['/usr/bin/python3', '-u', '-c', source]), {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
let output = ''
let accepted = 0
child.stdout.on('data', (chunk) => {
output += chunk.toString('utf8')
accepted += chunk.toString('utf8').split('ACCEPT').length - 1
})
const port = await waitFor('stalling WSL listener', async () => {
const match = /READY (\d+)/.exec(output)
return match ? Number(match[1]) : null
})
return {
port,
accepted: () => accepted,
stop: async () => {
child.stdin.end()
await new Promise((resolve) => child.once('close', resolve))
}
}
}
async function writeEndpoint(distro, path, endpoint) {
const contents = [
`ORCA_AGENT_HOOK_PORT=${endpoint.port}`,
`ORCA_AGENT_HOOK_TOKEN=${endpoint.token}`,
'ORCA_AGENT_HOOK_ENV=benchmark',
'ORCA_AGENT_HOOK_VERSION=1',
''
].join('\n')
await run(
'wsl.exe',
wslArgs(distro, [
'/bin/sh',
'-c',
'umask 077; mkdir -p "$(dirname "$1")"; cat > "$1"',
'orca-wsl-benchmark',
path
]),
{ input: contents }
)
}
async function invokeHook(distro, scriptPath, endpointPath) {
const startedAt = performance.now()
const result = await run(
'wsl.exe',
wslArgs(distro, [
'/usr/bin/env',
`PATH=${WSL_PATH}`,
`ORCA_AGENT_HOOK_ENDPOINT=${endpointPath}`,
`ORCA_PANE_KEY=${PANE_KEY}`,
'ORCA_TAB_ID=wsl-relay-bench',
'ORCA_WORKTREE_ID=wsl-relay-bench',
'/bin/sh',
scriptPath
]),
{ input: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'benchmark' }) }
)
return { status: result.status, elapsedMs: performance.now() - startedAt }
}
function percentile(samples, percentileValue) {
const sorted = [...samples].sort((left, right) => left - right)
return sorted[Math.ceil((percentileValue / 100) * sorted.length) - 1]
}
function summarize(samples) {
return {
samples: samples.length,
medianMs: Number(percentile(samples, 50).toFixed(1)),
p95Ms: Number(percentile(samples, 95).toFixed(1)),
minMs: Number(Math.min(...samples).toFixed(1)),
maxMs: Number(Math.max(...samples).toFixed(1))
}
}
async function main() {
if (process.platform !== 'win32') {
throw new Error('This benchmark requires a Windows host with WSL')
}
const options = parseArgs(process.argv.slice(2))
const distro = await resolveDistro(options.distro)
const nodeVersion = await run(
'wsl.exe',
wslArgs(distro, [
'/bin/sh',
'-c',
'for node_bin in "$(command -v node 2>/dev/null || true)" "$HOME/.local/bin/node"; do [ -x "$node_bin" ] || continue; "$node_bin" -p process.versions.node && exit 0; done; exit 1'
]),
{ allowFailure: true }
)
if (nodeVersion.status !== 0 || Number(nodeVersion.stdout.trim().split('.')[0]) < 18) {
throw new Error(`WSL distro '${distro}' needs Node.js 18 or newer to run Orca's relay`)
}
const bundleDir = join(process.cwd(), 'out', 'relay', 'wsl')
const bundlePath = join(bundleDir, 'wsl-agent-hook-relay.js')
const versionPath = join(bundleDir, '.version')
if (!existsSync(bundlePath) || !existsSync(versionPath)) {
await run(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], {
cwd: process.cwd()
})
}
const jiti = createJiti(import.meta.url)
const [{ WslHookRelayManager }, { ensureWslHookRelayForReattach }, { codexHookService }] =
await Promise.all([
jiti.import('../../src/main/agent-hooks/wsl-hook-relay-manager.ts'),
jiti.import('../../src/main/agent-hooks/wsl-hook-relay-reattach.ts'),
jiti.import('../../src/main/codex/hook-service.ts')
])
const { MANAGED_AGENT_HOOK_TARGETS } = await jiti.import(
'../../src/shared/managed-agent-hook-targets.ts'
)
const guestHome = (
await run('wsl.exe', wslArgs(distro, ['/bin/sh', '-c', 'printf %s "$HOME"']))
).stdout.trim()
const instanceKey = `bench-${process.pid}-${Date.now().toString(36)}`
const benchmarkRoot = `${guestHome}/.orca-wsl/benchmarks/${instanceKey}`
const scriptPath = `${benchmarkRoot}/.orca/agent-hooks/codex-hook.sh`
const endpointPath = `${guestHome}/.orca-wsl/agent-hooks/instance-${instanceKey}/endpoint.env`
const cleanupPaths = [benchmarkRoot, `${guestHome}/.orca-wsl/agent-hooks/instance-${instanceKey}`]
if (cleanupPaths.some((cleanupPath) => !cleanupPath.startsWith(`${guestHome}/.orca-wsl/`))) {
throw new Error('Refusing to use an unexpected guest cleanup path')
}
const disabledTuiAgents = MANAGED_AGENT_HOOK_TARGETS.filter(
(target) => target.tuiAgent !== 'codex'
).map((target) => target.tuiAgent)
const bundleVersion = readFileSync(versionPath, 'utf8').trim()
const warnings = []
let delivered = 0
let manager = null
let staller = null
const createManager = async (token) => {
const preferredPort = await freeGuestPort(distro)
return new WslHookRelayManager({
platform: () => 'win32',
remoteHooksEnabled: () => true,
hookCoordsEnv: () => ({
ORCA_AGENT_HOOK_PORT: String(preferredPort),
ORCA_AGENT_HOOK_TOKEN: token,
ORCA_AGENT_HOOK_ENV: 'benchmark',
ORCA_AGENT_HOOK_VERSION: '1'
}),
instanceKey: () => instanceKey,
resolveBundle: () => ({ jsPath: bundlePath, version: bundleVersion }),
listDistros: async () => [distro],
ingest: () => {
delivered++
},
installHooks: async (sftp) => [
await codexHookService.installRemote(sftp, benchmarkRoot, {
codexHomeDir: `${benchmarkRoot}/codex-home`,
deferTrustUntilConfigToml: true
})
],
managedHookSettings: () => ({
agentCmdOverrides: { codex: '/bin/true' },
disabledTuiAgents
}),
pluginSources: () => ({}),
warn: (message) => warnings.push(message),
transientRetryDelayMs: 100
})
}
try {
manager = await createManager('before-restart-token')
manager.ensureForDistro(distro)
await waitFor('initial relay and generated Codex hook', async () => {
const [endpoint, script] = await Promise.all([
readGuestFile(distro, endpointPath),
readGuestFile(distro, scriptPath)
])
return endpoint?.includes('before-restart-token') && script?.includes('--max-time')
})
manager.disposeAll()
manager = null
staller = await startStallingGuestServer(distro)
await writeEndpoint(distro, endpointPath, {
port: staller.port,
token: 'stale-token'
})
await invokeHook(distro, scriptPath, endpointPath)
const staleSamples = []
for (let index = 0; index < options.samples; index++) {
staleSamples.push((await invokeHook(distro, scriptPath, endpointPath)).elapsedMs)
}
delivered = 0
manager = await createManager('after-restart-token')
ensureWslHookRelayForReattach({ isReattach: true, wslDistro: distro }, null, (ownedDistro) =>
manager.ensureForDistro(ownedDistro)
)
const refreshedEndpoint = await waitFor('reattached relay endpoint rewrite', async () => {
const contents = await readGuestFile(distro, endpointPath)
const parsed = contents ? parseEndpoint(contents) : null
return parsed && parsed.token === 'after-restart-token' ? parsed : null
})
await invokeHook(distro, scriptPath, endpointPath)
await waitFor('refreshed hook warmup delivery', async () => (delivered >= 1 ? true : null))
delivered = 0
const refreshedSamples = []
for (let index = 0; index < options.samples; index++) {
refreshedSamples.push((await invokeHook(distro, scriptPath, endpointPath)).elapsedMs)
}
await waitFor('all refreshed hooks to reach the host', async () =>
delivered >= options.samples ? true : null
)
const stale = summarize(staleSamples)
const refreshed = summarize(refreshedSamples)
const result = {
distro,
endpointPath,
endpointPortAfterReattach: refreshedEndpoint.port,
stale,
refreshed,
medianSpeedup: Number((stale.medianMs / refreshed.medianMs).toFixed(1)),
stalledConnections: staller.accepted(),
delivered,
warnings
}
console.log(JSON.stringify(result, null, 2))
if (stale.medianMs < 1_000) {
throw new Error(`Stale endpoint did not exercise the timeout path: ${stale.medianMs}ms`)
}
if (refreshed.p95Ms >= 1_000) {
throw new Error(`Refreshed endpoint p95 regressed: ${refreshed.p95Ms}ms`)
}
if (delivered !== options.samples) {
throw new Error(`Expected ${options.samples} delivered hooks, received ${delivered}`)
}
} finally {
manager?.disposeAll()
await staller?.stop()
for (const cleanupPath of cleanupPaths) {
await run('wsl.exe', wslArgs(distro, ['/bin/rm', '-rf', '--', cleanupPath]), {
allowFailure: true
})
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
+1
View File
@@ -12,6 +12,7 @@
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
"../src/main/ipc/worktree-metadata-merge.ts",
"../src/main/ipc/worktree-path-comparison.ts",
"../src/main/wsl-availability.ts",
"../src/main/wsl-distro-list-output.ts",
"../src/main/wsl-distro-retry.ts",
"../src/main/wsl.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 16m">
<title>downloads: 16m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 17m">
<title>downloads: 17m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">16m</text>
<text x="90" y="14">16m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">17m</text>
<text x="90" y="14">17m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+229
View File
@@ -0,0 +1,229 @@
# xterm Patch Regeneration
## Scope
Orca ships `@xterm/xterm` with four source changes it needs and upstream has
not taken: the IME composition hooks, the `xterm-composition-*` custom events
they raise, the `ITerminal` surface those events widen, and a `SortedList`
fix. pnpm applies them through `config/patches/@xterm__xterm@<version>.patch`.
That patch touches eight files. Four are hand-authored source
(`src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
`src/browser/input/CompositionHelper.ts`, `src/common/SortedList.ts`) and four
are the build output those sources produce (`lib/xterm.js`, `lib/xterm.mjs`,
and both sourcemaps). The bundle half is 7.3 MB of minified code. It is
generated, and this document exists so nobody edits it by hand.
The source patch carries a fifth file, `src/browser/TestUtils.test.ts`, which
the shipped patch does not and cannot; see
[The Source Patch Is a Superset](#the-source-patch-is-a-superset).
`config/patches/xterm-src/@xterm__xterm@<version>.src.patch` is the source of
truth. Everything else is derived from it by
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
upstream commit the published tarball was built from.
This policy covers `@xterm/xterm` only. The addon patches
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
and are tracked separately; see [Known Gaps](#known-gaps).
## Rules
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
patch and regenerate.
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
3. Every source change must land together with the regenerated bundle hunks and
the `pnpm-lock.yaml` hash bump, in one commit.
4. The upstream commit lives in `config/patches/xterm-upstream.json`, not in a
comment. A version bump that leaves it stale fails the generator, it does not
silently patch the wrong tree.
5. Sourcemaps are deleted, not patched and not silently omitted. The patch moves
the bundle, so a retained map would have to move with it; dropping only the
map hunks ships offsets that point at the wrong code. Deletion is the honest
form of that saving, and `sourcemaps.policy` in the manifest controls it.
6. `--check` is the authority on the lockfile, not `pnpm install`. pnpm writes the
patch hash in two places — `patchedDependencies` and every resolution key that
depends on the patched package — and on a warm store it will leave the
resolution keys at their previous value while reporting success. That installs
locally and drifts on CI's cold store. Always finish on step 4, and if it
reports a stale hash after an install, rerun `--write`.
## Workflow
```sh
# 1. Edit the source hunks.
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
node config/scripts/regenerate-xterm-patches.mjs --write
# 3. Reinstall so node_modules picks up the new patch hash.
pnpm install
# 4. Confirm the tree is self-consistent.
node config/scripts/regenerate-xterm-patches.mjs --check
```
Editing a patch file by hand is awkward for anything larger than a one-liner.
For a substantial change, work in the generator's own checkout instead — after
any run it is left at the pinned commit with the source patch applied:
```sh
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
```
`--write` rewrites the source patch into the canonical form it would emit on a
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
than fighting `--check` forever.
Run the checkout outside this repository. A build tree underneath it makes
`tsgo` walk up into Orca's own `node_modules` and fail with `TS2300: Duplicate
identifier`, which is a symptom of where the tree sits and not of the patch.
## How the Commit Is Known
Upstream `bin/publish.js` sets `packageJson.commit` before `npm publish`, so
each published tarball names the commit that built it. The generator asserts
that stamp against `xterm-upstream.json` and then compares the tarball's `src/`
against the checkout file by file. Only `src/common/Version.ts` may differ,
because `publish.js` rewrites the version immediately before packaging; the
generator applies the same stamp.
That pair of checks is what makes the rebuild trustworthy. Without them a wrong
commit would still produce a plausible-looking 7 MB patch.
## The Source Patch Is a Superset
Patching `ICompositionHelper` widens an interface, so every implementor has to
follow — including `MockCompositionHelper` in upstream's
`src/browser/TestUtils.test.ts`. Without that hunk the patched checkout does not
type-check and `npm run package` never reaches webpack, so the generator cannot
build the patched bundles at all.
Upstream's `.npmignore` strips `*.test.ts`, so that file is not in the published
tarball. The shipped patch is a diff against the published tarball, and it
therefore *cannot* name the file — correctly, since pnpm has nothing there to
patch.
That is why the source patch is derived from the upstream checkout
(`git diff -- src/`) and not from the emitted patch. Deriving it from the
emitted patch is the trap: `--write` would filter the hunk out through the
published file set and delete it, so the fix that makes the build work would
erase itself on the first run that used it.
The two derivations are still cross-checked. `assertSourceDerivationsAgree`
requires them to be byte-identical on every file the tarball publishes, so the
carve-out stays confined to files upstream does not ship rather than becoming a
place where the source patch and the shipped patch can quietly disagree. The
checkout diff uses pnpm's own formatting flags minus `--no-index`, which is what
makes that byte comparison meaningful.
## Build Order
Upstream's publish path is `npm ci` → stamp `Version.ts` → `npm run package`.
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
**Do not run `npm run setup` after the packaging build.** `setup` is the
development esbuild pass with `minify: false`. Running it afterwards overwrites
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
the resulting patch is silently wrong — the failure mode is a `.mjs` that is
50% larger than the published one, which is easy to miss inside a 7 MB diff.
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
to run a build step that names one of those scripts.
The generator also builds the *unmodified* commit first and asserts that it
reproduces the published `lib/` byte for byte before it emits anything. A
toolchain or build-order problem therefore surfaces as an explicit "did not
reproduce the published bundles" error rather than as 7 MB of mystery diff.
## The Lockfile Moves With the Patch
pnpm derives the `patchedDependencies` hash in `pnpm-lock.yaml` — and the
`.pnpm/@xterm+xterm@<version>_patch_hash=<hash>/` store directory name — from
the sha256 of the patch file itself. A regenerated patch without the lockfile
bump fails `pnpm install --frozen-lockfile` on every machine except the
author's. `--write` makes that edit; `--check` fails if it is missing.
`config/scripts/regenerate-xterm-patches.test.mjs` asserts the same thing
without a network or a build, so the ordinary test job catches lockfile drift
in milliseconds even though the full rebuild runs in its own CI lane.
## Toolchain Pin
`toolchain` in the manifest records what upstream's `package-lock.json` resolves
at the pinned commit, and the generator fails if `npm ci` produces something
else. The entry that matters is `@typescript/native-preview`
(`tsgo`), which upstream pins to a **dated development build** —
`7.0.0-dev.20260521.1` at the time of writing. It is a real published version
and npm does not prune old releases, but it is the one dependency of this scheme
that is not a stable release.
If that version ever becomes unresolvable the generator fails with a toolchain
error naming it. Recovery is to move the pin to the next upstream commit whose
`package-lock.json` resolves, re-verify that the rebuild still reproduces the
published bundles, and regenerate. The committed patch keeps working the whole
time — only regeneration is blocked, so this is never an outage.
## Version Bumps
Bumping `@xterm/xterm` is:
1. Update the version in `package.json` and run `pnpm install`.
2. Rename both patch files to the new version and update `patch`,
`sourcePatch`, and `version` in `xterm-upstream.json`.
3. Update `upstream.commit` to the `commit` field of the new tarball's
`package.json`, and `toolchain` to whatever the new `package-lock.json`
resolves.
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
Step 4 is where a real upstream conflict shows up: `git apply` of the source
patch fails against the new tree. Resolve it in the checkout, re-diff, and
rerun. The bundle hunks need no attention at any point.
## Why Not Vendor a Fork
A vendored `@xterm/xterm` fork removes the patch entirely, but it moves Orca off
the published package, so every upstream beta becomes a merge rather than a
version bump, and Orca inherits responsibility for building and publishing a
package it does not own. The patch is four small source hunks against a commit
that reproduces byte for byte; a fork is a much larger standing cost for the
same result.
## Why Not Handle Composition at Runtime
`CompositionHelper` hooks four private call sites upstream of `onData`, and
`SortedList` has no public surface at all. There is no supported extension point
that reaches either, so a runtime shim would mean reaching into `_core`
internals that upstream renames freely between betas. The patch is the smaller
risk.
## CI Contract
`xterm_patch_sync` in `.github/workflows/pr.yml` runs
`regenerate-xterm-patches.mjs --check` on every PR and is part of the `verify`
aggregate. It clones the pinned commit, installs upstream's toolchain, builds
twice, and byte-compares the result against the committed patch. A warm run is
about eight seconds of work around the clone and install.
`config/scripts/regenerate-xterm-patches.test.mjs` covers the pure pieces —
pnpm's diff flags and normalization, hunk splitting, round-trip stability, the
commit and build-order assertions, and lockfile coupling — with no network and
no build, so they run in the ordinary test shards.
## Known Gaps
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
minified code and parser round-trip artifacts, and neither patch touches its
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
the shipped bundle — the defect `sourcemaps.policy` now avoids for `@xterm/xterm`
and which folding them into this manifest would also fix. Both addons build from
the same pinned commit and reproduce
byte for byte, so they can be folded into this manifest as additional `packages`
entries; that change needs e2e sign-off because, unlike `@xterm/xterm`, it will
not be a byte-for-byte no-op.
+1
View File
@@ -0,0 +1 @@
minimum-release-age=4320
+1 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.41",
"version": "0.0.42",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
+14 -4
View File
@@ -115,6 +115,10 @@ import {
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import {
TerminalLiveInputField,
type TerminalLiveInputFieldHandle
} from '../../../../src/terminal/terminal-live-input-field'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import {
clearTerminalLiveInputFocusTimer,
@@ -127,7 +131,9 @@ import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { imeGuardedSubmitProps } from '../../../../src/ime/ime-submit-carry'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import { useTerminalLiveInputPreedit } from '../../../../src/terminal/use-terminal-live-input-preedit'
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
import {
buildTerminalSendParams,
@@ -1030,7 +1036,7 @@ export default function SessionScreen() {
const viewportRef = useRef<{ cols: number; rows: number } | null>(null)
const viewportMeasuredRef = useRef(false)
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const liveInputRef = useRef<TextInput>(null)
const liveInputRef = useRef<TerminalLiveInputFieldHandle>(null)
const commandInputRef = useRef<TextInput>(null)
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
@@ -1091,9 +1097,12 @@ export default function SessionScreen() {
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
platform: Platform.OS,
sendLiveTerminalInputRef,
setLiveInputCapture
})
const { isComposing: liveInputComposing, handleLiveInputChangeWithPreedit } =
useTerminalLiveInputPreedit(handleLiveInputChange)
const { canCompose, canSend } = resolveMobileTerminalInputGate({
connState,
activeHandle,
@@ -4987,6 +4996,7 @@ export default function SessionScreen() {
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
composingText={liveInputComposing ? liveInputCapture : ''}
/>
</Pressable>
<MobileTerminalInputActions
@@ -5004,11 +5014,11 @@ export default function SessionScreen() {
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
<TextInput
<TerminalLiveInputField
ref={liveInputRef}
style={styles.liveInputCapture}
value={liveInputCapture}
onChangeText={handleLiveInputChange}
onChange={handleLiveInputChangeWithPreedit}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit}
placeholder=""
@@ -5057,7 +5067,7 @@ export default function SessionScreen() {
returnKeyType="send"
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
onSubmitEditing={() => void handleSend()}
{...imeGuardedSubmitProps(Platform.OS, () => void handleSend())}
/>
<MobileTerminalInputActions
canSend={canSend}
+2 -1
View File
@@ -32,6 +32,7 @@ import {
Send,
X
} from 'lucide-react-native'
import { imeGuardedSubmitProps } from '../../../src/ime/ime-submit-carry'
import type { RpcClient } from '../../../src/transport/rpc-client'
import type { RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
@@ -10835,7 +10836,7 @@ export default function MobileTasksScreen() {
autoCorrect={false}
secureTextEntry
editable={linearConnectState !== 'connecting'}
onSubmitEditing={() => void connectLinearAccount()}
{...imeGuardedSubmitProps(Platform.OS, () => void connectLinearAccount())}
/>
{linearConnectState === 'error' && linearConnectError ? (
<Text style={styles.detailError}>{linearConnectError}</Text>
+8 -17
View File
@@ -28,8 +28,6 @@ WORKSPACE = File.join(MOBILE_ROOT, "ios", "Orca.xcworkspace")
BUILD_OUTPUT_DIRECTORY = File.join(MOBILE_ROOT, "build")
SCHEME = "Orca"
BUNDLE_ID = "com.stably.orca.mobile"
TESTFLIGHT_GROUPS = ["peeps"].freeze
DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze
# App Store version states in which the version "train" is terminally closed to
# new TestFlight build uploads (altool rejects with 90186 "train ... is
@@ -112,11 +110,6 @@ rescue StandardError => error
nil
end
def testflight_changelog
changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip
changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog
end
platform :ios do
desc "Resolve the iOS release version and next TestFlight build number"
lane :prepare_release_version do |options|
@@ -178,7 +171,7 @@ platform :ios do
UI.message("Prepared Orca Mobile iOS #{version} (#{build_number})")
end
desc "Build, sign, upload, and share Orca Mobile on TestFlight"
desc "Build, sign, and upload Orca Mobile to App Store Connect / TestFlight"
lane :release do
api_key = app_store_connect_api_key_from_env
@@ -221,15 +214,13 @@ platform :ios do
upload_to_testflight(
api_key: api_key,
# Why: fastlane can only assign external TestFlight groups after Apple
# finishes processing the uploaded build.
skip_waiting_for_build_processing: false,
distribute_external: true,
groups: TESTFLIGHT_GROUPS,
notify_external_testers: true,
# Why: fastlane requires release notes when distributing to external
# TestFlight testers, even for CI-triggered smoke releases.
changelog: testflight_changelog,
# Why: ASC processing wait hung for hours (0.0.42 builds 1–2) with no Ready
# build and no email. Exit after upload acceptance. Do not pass changelog —
# Pilot only fully skips waiting when changelog is nil (otherwise it still
# polls until the build appears). External distribute (e.g. peeps) from ASC
# or a follow-up lane once Ready.
skip_waiting_for_build_processing: true,
distribute_external: false,
)
end
end
+9 -4
View File
@@ -20,10 +20,11 @@
"dependencies": {
"@noble/hashes": "1.8.0",
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@orca/react-native-terminal-input": "file:./packages/react-native-terminal-input",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
"@xterm/addon-webgl": "0.20.0-beta.284",
"@xterm/xterm": "6.1.0-beta.285",
"@xterm/addon-unicode11": "0.10.0-beta.287",
"@xterm/addon-webgl": "0.20.0-beta.286",
"@xterm/xterm": "6.1.0-beta.287",
"buffer": "^6.0.3",
"expo": "^55.0.27",
"expo-build-properties": "^55.0.13",
@@ -48,7 +49,7 @@
"expo-status-bar": "^55.0.6",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"mermaid": "11.16.0",
"mermaid": "11.16.1",
"react": "^19.2.6",
"react-dom": "19.2.6",
"react-native": "^0.83.9",
@@ -85,6 +86,10 @@
"pnpm": {
"overrides": {
"xcode>uuid": "11.1.1"
},
"patchedDependencies": {
"react-native@0.83.9": "patches/react-native@0.83.9.patch",
"@xterm/xterm@6.1.0-beta.287": "../config/patches/@xterm__xterm@6.1.0-beta.287.patch"
}
}
}
@@ -0,0 +1,16 @@
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace 'com.orca.terminalinput'
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
}
}
dependencies {
implementation 'com.facebook.react:react-android'
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,250 @@
package com.orca.terminalinput
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.view.ViewGroup
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputConnectionWrapper
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.ViewManager
import com.facebook.react.uimanager.events.Event
import com.facebook.react.views.textinput.ReactEditText
import com.facebook.react.views.textinput.ReactTextInputManager
class TerminalInputPackage : ReactPackage {
override fun createViewManagers(
reactContext: ReactApplicationContext
): List<ViewManager<*, *>> = listOf(TerminalInputManager())
}
private class TerminalInputManager : ReactTextInputManager() {
override fun getName(): String = "OrcaTerminalInput"
override fun createViewInstance(context: ThemedReactContext): ReactEditText {
val editText = TerminalReactEditText(context)
editText.inputType = editText.inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE.inv()
editText.returnKeyType = "done"
editText.layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
return editText
}
override fun addEventEmitters(reactContext: ThemedReactContext, editText: ReactEditText) {
super.addEventEmitters(reactContext, editText)
val terminalEditText = editText as TerminalReactEditText
terminalEditText.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {
terminalEditText.dispatchTextChange(text, start, before, count)
}
override fun afterTextChanged(text: Editable) = Unit
}
)
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> {
val events = super.getExportedCustomDirectEventTypeConstants().orEmpty().toMutableMap()
events[TerminalInputEvent.NAME] = mapOf("registrationName" to "onTerminalInput")
return events
}
}
private class TerminalReactEditText(
private val reactContext: ThemedReactContext,
) : ReactEditText(reactContext) {
private var mutationIsComposing: Boolean? = null
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
val connection = super.onCreateInputConnection(outAttrs) ?: return null
return TerminalInputConnection(connection, this)
}
fun dispatchTextChange(text: CharSequence, start: Int, before: Int, count: Int) {
if (before == 0 && count == 0) return
if (mutationIsComposing != null) return
val end = start + count
if (start < 0 || end < start || end > text.length) return
dispatch(text.toString(), text.subSequence(start, end).toString(), start, start + before)
}
fun mutateInput(
isComposing: Boolean,
replacementText: String,
range: Pair<Int, Int>?,
mutation: () -> Boolean,
): Boolean {
mutationIsComposing = isComposing
return try {
val consumed = mutation()
if (consumed && range != null) {
text?.toString()?.let { dispatch(it, replacementText, range.first, range.second) }
}
consumed
} finally {
mutationIsComposing = null
}
}
fun replacementRange(): Pair<Int, Int>? {
composingRange()?.let { return it }
val start = selectionStart
val end = selectionEnd
return if (start >= 0 && end >= 0) Pair(minOf(start, end), maxOf(start, end)) else null
}
fun composingRange(): Pair<Int, Int>? {
val currentText = text ?: return null
val start = BaseInputConnection.getComposingSpanStart(currentText)
val end = BaseInputConnection.getComposingSpanEnd(currentText)
return if (start >= 0 && end >= start) Pair(start, end) else null
}
fun deletionReplacement(
beforeLength: Int,
afterLength: Int,
inCodePoints: Boolean,
): Triple<Int, Int, String>? {
if (beforeLength < 0 || afterLength < 0) return null
val currentText = text?.toString() ?: return null
val orderedSelectionStart = minOf(selectionStart, selectionEnd)
val orderedSelectionEnd = maxOf(selectionStart, selectionEnd)
if (orderedSelectionStart < 0 || orderedSelectionEnd > currentText.length) return null
val composing = composingRange()
val retainedStart = minOf(orderedSelectionStart, composing?.first ?: orderedSelectionStart)
val retainedEnd = maxOf(orderedSelectionEnd, composing?.second ?: orderedSelectionEnd)
val beforeStart =
if (inCodePoints) {
val available = Character.codePointCount(currentText, 0, retainedStart)
Character.offsetByCodePoints(currentText, retainedStart, -minOf(beforeLength, available))
} else {
maxOf(0, retainedStart - beforeLength)
}
val afterEnd =
if (inCodePoints) {
val available = Character.codePointCount(currentText, retainedEnd, currentText.length)
Character.offsetByCodePoints(currentText, retainedEnd, minOf(afterLength, available))
} else {
minOf(currentText.length, retainedEnd + afterLength)
}
return Triple(
beforeStart,
afterEnd,
currentText.substring(retainedStart, retainedEnd),
)
}
private fun dispatch(text: String, replacementText: String, start: Int, end: Int) {
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return
dispatcher.dispatchEvent(
TerminalInputEvent(
UIManagerHelper.getSurfaceId(this),
id,
text,
mutationIsComposing ?: false,
replacementText,
start,
end,
)
)
}
}
private class TerminalInputConnection(
target: InputConnection,
private val editText: TerminalReactEditText,
) : InputConnectionWrapper(target, false) {
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
val range = editText.replacementRange()
val consumed = editText.mutateInput(true, text.toString(), range) {
super.setComposingText(text, newCursorPosition)
}
// Let React Native deliver its synthetic Backspace while the IME still owns it.
if (text.isEmpty() && consumed) {
editText.post {
if (editText.composingRange() == null) editText.mutateInput(false, "", range) { true }
}
}
return consumed
}
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
return editText.mutateInput(false, text.toString(), editText.replacementRange()) {
super.commitText(text, newCursorPosition)
}
}
override fun finishComposingText(): Boolean {
val range = editText.composingRange() ?: return super.finishComposingText()
val replacementText = editText.text?.subSequence(range.first, range.second)?.toString()
?: return super.finishComposingText()
return editText.mutateInput(false, replacementText, range) { super.finishComposingText() }
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
val replacement = editText.deletionReplacement(beforeLength, afterLength, false)
return editText.mutateInput(
editText.composingRange() != null,
replacement?.third ?: "",
replacement?.let { Pair(it.first, it.second) },
) {
super.deleteSurroundingText(beforeLength, afterLength)
}
}
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
val replacement = editText.deletionReplacement(beforeLength, afterLength, true)
return editText.mutateInput(
editText.composingRange() != null,
replacement?.third ?: "",
replacement?.let { Pair(it.first, it.second) },
) {
super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
}
}
}
private class TerminalInputEvent(
surfaceId: Int,
viewId: Int,
private val text: String,
private val isComposing: Boolean,
private val replacementText: String,
private val replacementStart: Int,
private val replacementEnd: Int,
) : Event<TerminalInputEvent>(surfaceId, viewId) {
override fun getEventName(): String = NAME
override fun canCoalesce(): Boolean = false
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putString("text", text)
putBoolean("isComposing", isComposing)
putString("replacementText", replacementText)
putMap(
"replacementRange",
Arguments.createMap().apply {
putInt("start", replacementStart)
putInt("end", replacementEnd)
},
)
}
companion object {
const val NAME = "topTerminalInput"
}
}
@@ -0,0 +1,8 @@
{
"name": "@orca/react-native-terminal-input",
"version": "0.0.1",
"private": true,
"peerDependencies": {
"react-native": "*"
}
}
@@ -0,0 +1,12 @@
module.exports = {
dependency: {
platforms: {
android: {
sourceDir: './android',
packageImportPath: 'import com.orca.terminalinput.TerminalInputPackage;',
packageInstance: 'new TerminalInputPackage()'
},
ios: null
}
}
}
+120
View File
@@ -0,0 +1,120 @@
diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
index d02e003a439e410ddab597b2c036256c80db0395..304b4aa81e6257d5854a41757bcf24915eb774d4 100644
--- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
@@ -43,6 +43,9 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
UIView<RCTBackedTextInputViewProtocol> *_backedTextInputView;
NSUInteger _mostRecentEventCount;
NSAttributedString *_lastStringStateWasUpdatedWith;
+ NSString *_pendingReplacementText;
+ NSRange _pendingReplacementRange;
+ BOOL _hasPendingReplacement;
/*
* UIKit uses either UITextField or UITextView as its UIKit element for <TextInput>. UITextField is for single line
@@ -439,6 +442,10 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
- (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
{
+ _pendingReplacementText = text;
+ _pendingReplacementRange = range;
+ _hasPendingReplacement = YES;
+
const auto &props = static_cast<const TextInputProps &>(*_props);
if (!_backedTextInputView.textWasPasted) {
@@ -495,6 +502,10 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
const auto &textInputEventEmitter = static_cast<const TextInputEventEmitter &>(*_eventEmitter);
textInputEventEmitter.onChange([self _textInputMetrics]);
}
+ if (_backedTextInputView.markedTextRange == nil) {
+ _pendingReplacementText = nil;
+ _hasPendingReplacement = NO;
+ }
}
- (void)textInputDidChangeSelection
@@ -516,6 +527,11 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
if (_eventEmitter) {
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onSelectionChange([self _textInputMetrics]);
+ if (_hasPendingReplacement && _backedTextInputView.markedTextRange == nil) {
+ static_cast<const TextInputEventEmitter &>(*_eventEmitter).onChange([self _textInputMetrics]);
+ _pendingReplacementText = nil;
+ _hasPendingReplacement = NO;
+ }
}
}
@@ -710,6 +726,15 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
.contentSize = RCTSizeFromCGSize(_backedTextInputView.contentSize),
.layoutMeasurement = RCTSizeFromCGSize(_backedTextInputView.bounds.size),
.zoomScale = _backedTextInputView.zoomScale,
+ .isComposing = _backedTextInputView.markedTextRange != nil,
+ .replacementText = _hasPendingReplacement
+ ? std::optional<std::string>(RCTStringFromNSString(_pendingReplacementText))
+ : std::nullopt,
+ .replacementRange = _hasPendingReplacement
+ ? std::optional<AttributedString::Range>(
+ {.location = static_cast<int>(_pendingReplacementRange.location),
+ .length = static_cast<int>(_pendingReplacementRange.length)})
+ : std::nullopt,
};
}
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
index a9bc219f8bf729111e907423ca61b991fd712b46..0f6ec4299134f13d5a06467ce540e8ac350be03a 100644
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
@@ -24,6 +24,28 @@ static jsi::Value textInputMetricsPayload(
payload.setProperty(runtime, "eventCount", textInputMetrics.eventCount);
+ if (textInputMetrics.isComposing.has_value()) {
+ payload.setProperty(runtime, "isComposing", *textInputMetrics.isComposing);
+ }
+
+ if (textInputMetrics.replacementText.has_value() &&
+ textInputMetrics.replacementRange.has_value()) {
+ payload.setProperty(
+ runtime,
+ "replacementText",
+ jsi::String::createFromUtf8(
+ runtime, *textInputMetrics.replacementText));
+ auto replacementRange = jsi::Object(runtime);
+ replacementRange.setProperty(
+ runtime, "start", textInputMetrics.replacementRange->location);
+ replacementRange.setProperty(
+ runtime,
+ "end",
+ textInputMetrics.replacementRange->location +
+ textInputMetrics.replacementRange->length);
+ payload.setProperty(runtime, "replacementRange", replacementRange);
+ }
+
if (includeSelectionState) {
auto selection = jsi::Object(runtime);
selection.setProperty(
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..f2ad25d9f5d6260c063c8790a682b5622c2e11fa 100644
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
@@ -7,6 +7,8 @@
#pragma once
+#include <optional>
+
#include <react/renderer/attributedstring/AttributedString.h>
#include <react/renderer/components/view/ViewEventEmitter.h>
@@ -28,6 +30,9 @@ class TextInputEventEmitter : public ViewEventEmitter {
Size layoutMeasurement;
Float zoomScale;
Tag target;
+ std::optional<bool> isComposing;
+ std::optional<std::string> replacementText;
+ std::optional<AttributedString::Range> replacementRange;
};
struct KeyPressMetrics {
+287 -241
View File
File diff suppressed because it is too large Load Diff
@@ -113,13 +113,19 @@ export function handleMockTerminalRequest(
return true
}
case 'terminal.send':
case 'terminal.send': {
// Input-routing repros (#8818) assert on the exact bytes reaching the host.
const text = String(request.params?.text ?? '')
console.log(
`[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(request.params?.text)}`
`[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(text)}`
)
respond(
success(request.id, {
send: { handle: 'term-1', accepted: true, bytesWritten: Buffer.byteLength(text) }
})
)
respond(success(request.id, { send: { handle: 'term-1', ok: true } }))
return true
}
case 'terminal.unsubscribe':
clearTerminalStream(ws, String(request.params?.terminal ?? 'term-1'))
+2 -1
View File
@@ -19,6 +19,7 @@ import {
type PanResponderGestureState
} from 'react-native'
import { ArrowUp, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import { imeGuardedSubmitProps } from '../ime/ime-submit-carry'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import type {
@@ -1283,7 +1284,7 @@ export function MobileBrowserPane({
autoCapitalize="none"
autoCorrect={false}
editable={!controlsDisabled}
onSubmitEditing={() => void sendKeyboardText()}
{...imeGuardedSubmitProps(Platform.OS, () => void sendKeyboardText())}
/>
<Pressable
style={[styles.sendButton, (controlsDisabled || !keyboardValue) && styles.disabled]}
+161
View File
@@ -0,0 +1,161 @@
import { readFileSync } from 'node:fs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { imeGuardedSubmitProps, noteImeCompositionChange } from './ime-submit-carry'
// Captured on a physical iPhone 13 Pro Max, iOS 26.5.2, system Japanese Kana keyboard
// (lane-ios/metro.log, IME7427_NATIVE_EVENT eventCount 5-10). Five marked events, then the
// confirmation unmarks the same text — and iOS fires onSubmitEditing right after it.
const RECORDED_IOS_DEVICE_FLICK_VOWELS: readonly (boolean | undefined)[] = [
true,
true,
true,
true,
true,
false
]
// The paired ASCII control from the same capture arm: never marked.
const ORDINARY_ASCII: readonly (boolean | undefined)[] = [false, false, false]
// iOS Korean 2-set never marks — all 12 events in the retained #11235 trace are isComposing false.
const RECORDED_IOS_KOREAN: readonly (boolean | undefined)[] = Array.from(
{ length: 12 },
() => false
)
const frames: Array<() => void> = []
function flushFrame(): void {
for (const callback of frames.splice(0)) {
callback()
}
}
// `target` is React Native's view tag: every textInputMetrics payload carries it, on onChange and
// onSubmitEditing alike, so it identifies which field emitted an event. The device capture recorded
// target 3760 throughout.
function createField(
platform: string,
target = 3760
): {
readonly submits: number[]
readonly type: (trace: readonly (boolean | undefined)[]) => void
readonly pressReturn: () => void
} {
const submits: number[] = []
const props = imeGuardedSubmitProps(platform, () => submits.push(submits.length))
return {
submits,
type: (trace) => {
for (const isComposing of trace) {
props.onChange({ nativeEvent: { isComposing, target } })
}
},
pressReturn: () => props.onSubmitEditing({ nativeEvent: { target } })
}
}
beforeEach(() => {
frames.length = 0
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
frames.push(callback)
return frames.length
})
noteImeCompositionChange('android', true)
noteImeCompositionChange('android', false)
})
describe('ime submit carry', () => {
it('drops the submit iOS fires for the recorded device confirmation', () => {
const field = createField('ios')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
field.pressReturn()
expect(field.submits).toEqual([])
})
it('submits a deliberate Return taken one frame after that confirmation', () => {
const field = createField('ios')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
flushFrame()
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('submits the paired ASCII control on its genuine Return', () => {
const field = createField('ios')
field.type(ORDINARY_ASCII)
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('keeps the unmarking iOS Korean keyboard submitting on its confirming Return', () => {
const field = createField('ios')
field.type(RECORDED_IOS_KOREAN)
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('owns only the first submit, so a repeated Return still reaches the action', () => {
const field = createField('ios')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
field.pressReturn()
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('releases the carry when the confirmation is followed by another keystroke', () => {
const field = createField('ios')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
field.type([false])
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('stays inert on Android, whose editor-action behaviour has no device trace', () => {
const field = createField('android')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('lets a second field submit inside the frame a first field armed', () => {
const composing = createField('ios', 3760)
const other = createField('ios', 4210)
composing.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
other.pressReturn()
expect(other.submits).toEqual([0])
})
it('never arms without a marked event, so unpatched hosts are unchanged', () => {
const field = createField('ios')
field.type([undefined, undefined])
field.pressReturn()
expect(field.submits).toEqual([0])
})
})
function sourceOf(relativePath: string): string {
return readFileSync(new URL(`../../${relativePath}`, import.meta.url), 'utf8')
}
describe('irreversible mobile Return surfaces route through the carry', () => {
it.each([
['app/h/[hostId]/session/[worktreeId].tsx', '() => void handleSend()'],
['app/h/[hostId]/tasks.tsx', '() => void connectLinearAccount()'],
['src/browser/MobileBrowserPane.tsx', '() => void sendKeyboardText()'],
['src/source-control/MobileSourceControlContent.tsx', 'primaryAction.onPress']
])('%s guards %s', (relativePath, action) => {
const source = sourceOf(relativePath)
expect(source).toContain(`{...imeGuardedSubmitProps(Platform.OS, ${action})}`)
expect(source).not.toContain(`onSubmitEditing={${action}}`)
})
it('routes the terminal live input submit through the same carry', () => {
const commit = sourceOf('src/terminal/use-terminal-live-input-commit.ts')
expect(commit).toContain('imeOwnsSubmit((event?.nativeEvent as { target?: number }')
expect(commit).toContain(
'noteImeCompositionChange(platform, nativeEvent.isComposing, nativeEvent.target)'
)
expect(sourceOf('app/h/[hostId]/session/[worktreeId].tsx')).toContain('platform: Platform.OS')
})
})
+79
View File
@@ -0,0 +1,79 @@
// `nativeEvent: object` so React Native's own TextInputChangeEvent stays assignable: the patch
// that adds `isComposing` to the iOS metrics is not reflected in React Native's shipped types.
type ImeChangeEvent = { readonly nativeEvent: object }
export type ImeGuardedSubmitProps = {
readonly onChange: (event: ImeChangeEvent) => void
readonly onSubmitEditing: (event?: ImeChangeEvent) => void
}
// The carry models the in-flight IME gesture, so it is module state rather than per-field — but it
// records WHICH field armed it (React Native's view tag, on every textInputMetrics payload), so a
// confirmation in one field can never swallow a different field's Return.
const carry: { composing: boolean; pending: object | null; target: number | undefined } = {
composing: false,
pending: null,
target: undefined
}
/**
* Why: iOS confirms a marked composition by unmarking the text and *then* firing
* `onSubmitEditing` for that same key press — React Native's
* `textInputShouldSubmitOnReturn` never inspects `markedTextRange`. An
* "is composing" boolean cannot see it, because composition has already ended by
* the time submit arrives (the mobile analogue of the renderer's Mode B). So the
* confirming change hands ownership of the *next* submit to the IME.
*
* Ownership expires on the next frame, never on an event count: a user who
* deliberately presses Return after confirming produces no intervening change
* event, so anything counted would eat that Return. Android is excluded until a
* device trace exists — its marking IMEs may deliver a wanted Return in the same
* turn, and `isComposing` is absent there outside the terminal input anyway.
*/
export function noteImeCompositionChange(
platform: string,
isComposing: boolean | undefined,
target?: number
): void {
const composing = isComposing === true
const confirmed = carry.composing && !composing
carry.composing = composing
if (!confirmed || platform !== 'ios') {
carry.pending = null
return
}
const token = {}
carry.pending = token
carry.target = target
requestAnimationFrame(() => {
if (carry.pending === token) {
carry.pending = null
}
})
}
/** True when this submit belongs to an IME confirmation gesture rather than the user. */
export function imeOwnsSubmit(target?: number): boolean {
if (!carry.pending || carry.target !== target) {
return false
}
carry.pending = null
return true
}
type ImeChangeMetrics = { isComposing?: boolean; target?: number }
/** Spread onto any single-line `TextInput` whose Return commits something. */
export function imeGuardedSubmitProps(platform: string, submit: () => void): ImeGuardedSubmitProps {
return {
onChange: (event) => {
const metrics = event.nativeEvent as ImeChangeMetrics
noteImeCompositionChange(platform, metrics.isComposing, metrics.target)
},
onSubmitEditing: (event) => {
if (!imeOwnsSubmit((event?.nativeEvent as ImeChangeMetrics | undefined)?.target)) {
submit()
}
}
}
}
@@ -29,6 +29,12 @@ function listedTerminalWorktreeIds(worktree?: string): string[] {
}
describe('mock server terminal fixture routing', () => {
it('returns the production terminal.send acknowledgement contract', () => {
expect(sendMockRequest('terminal.send', { terminal: 'term-1', text: 'abc' }).result).toEqual({
send: { handle: 'term-1', accepted: true, bytesWritten: 3 }
})
})
it('follows worktree creation and activation', () => {
const worktreeResponse = sendMockRequest('worktree.ps')
const initialWorktreeId = (
@@ -11,13 +11,31 @@ type MobileTerminalLiveInputStatusProps = {
readonly dictation: DictationStatus
readonly isAttaching: boolean
readonly liveInputText: string
// Why: marking IMEs (Japanese kana, pinyin) withhold bytes until commit, so the
// terminal echo shows nothing mid-composition and this dock is the only preview.
// Empties on commit by design — the terminal echo takes over from there.
readonly composingText?: string
}
export function MobileTerminalLiveInputStatus({
dictation,
isAttaching,
liveInputText
liveInputText,
composingText = ''
}: MobileTerminalLiveInputStatusProps) {
if (composingText.length > 0) {
return (
<View style={styles.status}>
<Text style={styles.title} numberOfLines={1}>
Composing
</Text>
<Text style={styles.composing} numberOfLines={1} ellipsizeMode="head">
{composingText}
</Text>
</View>
)
}
const title = dictation.isRecording
? 'Listening'
: dictation.isProcessing
@@ -61,5 +79,13 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
// Underlined uncommitted text is the platform convention for marked text on both
// iOS and Android; brighter than `detail` because it is live content, not chrome.
composing: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
textDecorationLine: 'underline'
}
})
@@ -0,0 +1,182 @@
import { createElement, type RefObject } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
import type { TerminalLiveInputSender } from '../terminal/terminal-live-input-sender'
import {
useTerminalLiveInputCommit,
type TerminalLiveInputChangeEvent
} from '../terminal/use-terminal-live-input-commit'
import { MobileTerminalLiveInputStatus } from './MobileTerminalLiveInputStatus'
type RecordedChange = {
readonly text: string
readonly isComposing: boolean
readonly replacementText: string
readonly start: number
readonly end: number
}
// Captured from a PHYSICAL iPhone 13 Pro Max, iOS 26.5.2, system Japanese Kana
// keyboard, flicking い/う/え/お off the あ key. eventCount 5..10 of a gapless 1..23
// stream, sealed at swarm-scratch/lane-ios/metro.log.
const DEVICE_FLICK_TRACE: readonly RecordedChange[] = [
{ text: 'い', isComposing: true, replacementText: 'い', start: 0, end: 0 },
{ text: 'いう', isComposing: true, replacementText: 'う', start: 1, end: 1 },
{ text: 'いうえ', isComposing: true, replacementText: 'え', start: 2, end: 2 },
{ text: 'いうえお', isComposing: true, replacementText: 'お', start: 3, end: 3 },
{ text: 'いうえお', isComposing: true, replacementText: 'いうえお', start: 0, end: 4 },
{ text: 'いうえお', isComposing: false, replacementText: 'いうえお', start: 0, end: 4 }
]
const ORDINARY_ABC_TRACE: readonly RecordedChange[] = [
{ text: 'a', isComposing: false, replacementText: 'a', start: 0, end: 0 },
{ text: 'ab', isComposing: false, replacementText: 'b', start: 1, end: 1 },
{ text: 'abc', isComposing: false, replacementText: 'c', start: 2, end: 2 }
]
// iOS Korean 2-set never marks: every event reports isComposing:false, so it
// commits continuously and must never enter the preview path.
const IOS_KOREAN_2SET_TRACE: readonly RecordedChange[] = [
{ text: 'ㅇ', isComposing: false, replacementText: 'ㅇ', start: 0, end: 0 },
{ text: '아', isComposing: false, replacementText: 'ㅏ', start: 1, end: 1 },
{ text: '안', isComposing: false, replacementText: 'ㄴ', start: 1, end: 1 }
]
type TraceRun = {
readonly previews: string[]
readonly sent: string[]
}
/**
* Mirrors the production wiring in app/h/[hostId]/session/[worktreeId].tsx. The
* commit hook owns every send decision; a sibling flag mirrors the native
* marked-text bit into state so the dock can render a preview. Passing
* `withPreviewWiring: false` reproduces the pre-fix behavior for comparison.
*/
function runTrace(trace: readonly RecordedChange[], withPreviewWiring = true): TraceRun {
const activeHandle = 'terminal-a'
const sent: string[] = []
const previews: string[] = []
let capture = ''
let composing = false
const liveInputTerminalHandles = new Set([activeHandle])
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return true
}
}
let onChange: ((event: TerminalLiveInputChangeEvent) => void) | null = null
function Harness(): null {
const { handleLiveInputChange } = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef: { current: activeHandle },
activeSessionTabType: 'terminal',
activeSessionTabTypeRef: { current: 'terminal' },
connected: true,
liveInputRef: { current: { setNativeProps: vi.fn() } },
liveInputTerminalHandles,
liveInputTerminalHandlesRef: { current: liveInputTerminalHandles },
sendLiveTerminalInputRef,
setLiveInputCapture: (text) => {
capture = text
}
})
onChange = (event) => {
if (withPreviewWiring) {
composing = event.nativeEvent.isComposing === true
}
handleLiveInputChange(event)
}
return null
}
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
act(() => {
create(createElement(Harness))
})
for (const change of trace) {
act(() => {
onChange?.({
nativeEvent: {
text: change.text,
isComposing: change.isComposing,
replacementText: change.replacementText,
replacementRange: { start: change.start, end: change.end }
}
})
})
previews.push(renderPreview(composing ? capture : ''))
}
} finally {
consoleError.mockRestore()
}
return { previews, sent }
}
/** Renders the real dock component and returns the visible composing text. */
function renderPreview(composingText: string): string {
let tree: ReturnType<typeof create> | null = null
act(() => {
tree = create(
createElement(MobileTerminalLiveInputStatus, {
dictation: { isStarting: false, isRecording: false, isProcessing: false },
isAttaching: false,
composingText
})
)
})
const texts = tree!.root.findAllByType('Text')
const isComposingView = texts.some((t) => t.props.children === 'Composing')
return isComposingView ? String(texts[1]?.props.children ?? '') : ''
}
describe('mobile terminal live-input preedit preview', () => {
it('renders the composing text for the recorded physical-device flick trace', async () => {
const { previews, sent } = runTrace(DEVICE_FLICK_TRACE)
// THE DISPLAY DEFECT: these four states were invisible before the fix.
expect(previews.slice(0, 4)).toEqual(['い', 'いう', 'いうえ', 'いうえお'])
// The commit event clears the preview; the terminal echo takes over.
expect(previews.at(-1)).toBe('')
// Byte behavior unchanged: nothing reaches the PTY until the unmarked commit.
await vi.waitFor(() => expect(sent).toEqual(['いうえお']))
})
it('sends nothing while composing, for the same recorded trace', async () => {
const marked = DEVICE_FLICK_TRACE.slice(0, 5)
const { sent } = runTrace(marked)
// Five marked events, zero bytes. This is the byte-level form of the
// reporter's observation that nothing appeared in the terminal until commit.
await vi.waitFor(() => expect(sent).toEqual([]))
})
it('shows no preview for ordinary ASCII and leaves its bytes untouched', async () => {
const withFix = runTrace(ORDINARY_ABC_TRACE)
const withoutFix = runTrace(ORDINARY_ABC_TRACE, false)
expect(withFix.previews).toEqual(['', '', ''])
await vi.waitFor(() => expect(withFix.sent).toEqual(withoutFix.sent))
await vi.waitFor(() => expect(withFix.sent.join('')).toBe('abc'))
})
it('shows no preview for iOS Korean 2-set, which never marks', async () => {
const withFix = runTrace(IOS_KOREAN_2SET_TRACE)
const withoutFix = runTrace(IOS_KOREAN_2SET_TRACE, false)
// The exemption: unmarked input never enters the preview path, so this
// surface behaves exactly as it did before the fix.
expect(withFix.previews).toEqual(['', '', ''])
await vi.waitFor(() => expect(withFix.sent).toEqual(withoutFix.sent))
})
})
@@ -102,6 +102,9 @@ export function useMobileNativeChatSessionOptions(args: {
}): MobileNativeChatSessionOptionsController {
const { agent, scopeKey, reportedModel, dispatchCommand, onAgentPicker } = args
const catalog = useMemo(
// Widening this to a `defaultModelIsCliDefault` catalog (grok) also needs the
// effective-model resolution desktop does — `previousModelId` below is tracked-only,
// so a CLI-default model would render option rows that do nothing when tapped.
() => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null),
[agent]
)
@@ -1,5 +1,6 @@
import {
ActivityIndicator,
Platform,
Pressable,
ScrollView,
SectionList,
@@ -8,6 +9,7 @@ import {
View
} from 'react-native'
import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
import { imeGuardedSubmitProps } from '../ime/ime-submit-carry'
import { colors, spacing } from '../theme/mobile-theme'
import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry'
import { MobileCommitFailurePanel } from './MobileCommitFailurePanel'
@@ -212,7 +214,7 @@ export function MobileSourceControlContent({ state }: Props) {
placeholderTextColor={colors.textMuted}
editable={busyAction === null && openingPath === null && openingBranchPath === null}
returnKeyType="done"
onSubmitEditing={primaryAction.onPress}
{...imeGuardedSubmitProps(Platform.OS, primaryAction.onPress)}
/>
)}
{shouldShowGenerateButton ? (
@@ -85,7 +85,7 @@ describe('session route offline-compose wiring', () => {
it('keeps the buffered command box editable offline while the live capture stays send-gated', () => {
const bufferedInput = routeSlice(
'ref={commandInputRef}',
'onSubmitEditing={() => void handleSend()}'
'{...imeGuardedSubmitProps(Platform.OS, () => void handleSend())}'
)
expect(bufferedInput).toContain('editable={canCompose}')
@@ -22,4 +22,12 @@ describe('terminal iOS dictation write-back', () => {
it('still normalizes the buffered command text at send time', () => {
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)')
})
it('leaves buffered autocorrection native and remounts Android when it changes', () => {
expect(sessionRouteSource).toContain('onChangeText={setInput}')
expect(sessionRouteSource).toContain('autoCorrect={autocompleteEnabled}')
expect(sessionRouteSource).toContain('spellCheck={autocompleteEnabled}')
expect(sessionRouteSource).toContain("? 'cmd-input-ac-on'")
expect(sessionRouteSource).toContain(": 'cmd-input-ac-off'")
})
})
@@ -1,4 +1,4 @@
import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit'
type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryInput = {
readonly bytes: string
@@ -1,74 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
describe('terminal live control send order', () => {
it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return false
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return true
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(false)
expect(sendControlBytes).not.toHaveBeenCalled()
expect(events).toEqual(['flush'])
})
it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return true
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return true
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(true)
expect(events).toEqual(['flush', 'control'])
})
it('Given a failed control byte send When pending text flushed Then reports failure', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return true
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return false
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(false)
expect(events).toEqual(['flush', 'control'])
})
})
@@ -1,12 +0,0 @@
export type TerminalLiveAsyncSendStep = () => Promise<boolean>
export async function sendTerminalLiveControlAfterPendingFlush(
flushPendingText: TerminalLiveAsyncSendStep,
sendControlBytes: TerminalLiveAsyncSendStep
): Promise<boolean> {
const flushed = await flushPendingText()
if (!flushed) {
return false
}
return sendControlBytes()
}
@@ -1,178 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
isTerminalLiveHangulCodePoint,
type TerminalLiveMirrorStep
} from './terminal-live-hangul-mirror'
type MirrorRun = {
readonly payloads: readonly string[]
readonly sentText: string
readonly heldText: string
}
function runMirrorSequence(
fieldStates: readonly string[],
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
): MirrorRun {
const payloads: string[] = []
let sentText = ''
let heldText = ''
for (const fieldText of fieldStates) {
const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
if (options.commitAtEnd) {
const lastField = sentText + heldText
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
return { payloads, sentText, heldText }
}
describe('terminal live hangul mirror', () => {
it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한'])
expect(run.sentText).toBe('한')
expect(run.heldText).toBe('')
})
it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한', '글'])
expect(run.sentText).toBe('한글')
})
it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => {
// Given / When
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['가', '나'])
expect(run.sentText).toBe('가나')
})
it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => {
// Given: '하' was committed by the settle timer
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
expect(commit.nextSentText).toBe('하')
// When: user keeps composing '하' → '한'
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
commitHeld: false
})
// Then: one DEL erases the stale syllable; the new one is held again
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
expect(correction.nextSentText).toBe('')
expect(correction.heldText).toBe('한')
const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한')
})
it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => {
// Given / When
const run = runMirrorSequence(['a', 'ab', 'abc'])
// Then
expect(run.payloads).toEqual(['a', 'b', 'c'])
expect(run.heldText).toBe('')
})
it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => {
// Given: '한글' typed, '한' streamed, '글' held
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'])
expect(beforeSpace.sentText).toBe('한')
expect(beforeSpace.heldText).toBe('글')
// When
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', {
commitHeld: false
})
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ')
expect(step.heldText).toBe('')
expect(step.nextSentText).toBe('한글 ')
})
it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => {
// Given
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
expect(held.heldText).toBe('한')
// When
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
expect(step.heldText).toBe('')
})
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
// Then
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 3,
appendText: '',
nextSentText: '',
heldText: ''
})
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
})
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 0,
appendText: '',
nextSentText: '',
heldText: ''
})
})
it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => {
// Given / When
const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false })
const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好')
expect(chinese.heldText).toBe('')
expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng')
expect(vietnamese.heldText).toBe('')
})
it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => {
expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false)
expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false)
})
})
@@ -1,61 +0,0 @@
// Why: paused composition should still reach the PTY quickly; corrections make
// a premature commit safe, so this can be short without leaking jamo forever.
export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300
const TERMINAL_DEL_BYTE = '\x7f'
export function isTerminalLiveHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export type TerminalLiveMirrorStep = {
readonly eraseCount: number
readonly appendText: string
readonly nextSentText: string
readonly heldText: string
}
// Why: React Native exposes no composition events, but Hangul composition only
// mutates the trailing syllable. Holding just that code point keeps the PTY
// echo live while preedit jamo never leak; DEL corrections repair any commit
// that later turns out to be premature.
export function computeTerminalLiveMirrorStep(
sentText: string,
fieldText: string,
options: { readonly commitHeld: boolean }
): TerminalLiveMirrorStep {
const fieldCodePoints = Array.from(fieldText)
const lastCodePoint = fieldCodePoints.at(-1)
const holdLast =
!options.commitHeld &&
lastCodePoint !== undefined &&
isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0)
const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : ''
const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints
const sentCodePoints = Array.from(sentText)
let commonPrefixLength = 0
while (
commonPrefixLength < sentCodePoints.length &&
commonPrefixLength < targetCodePoints.length &&
sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength]
) {
commonPrefixLength += 1
}
return {
eraseCount: sentCodePoints.length - commonPrefixLength,
appendText: targetCodePoints.slice(commonPrefixLength).join(''),
nextSentText: targetCodePoints.join(''),
heldText
}
}
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
}
@@ -0,0 +1,53 @@
import { forwardRef, useImperativeHandle, useRef } from 'react'
import {
findNodeHandle,
Platform,
TextInput,
UIManager,
requireNativeComponent,
type TextInputProps
} from 'react-native'
import type { TerminalLiveInputChangeEvent } from './use-terminal-live-input-commit'
type TerminalLiveInputFieldProps = Omit<TextInputProps, 'onChange'> & {
readonly onChange: (event: TerminalLiveInputChangeEvent) => void
}
type AndroidTerminalInputProps = Omit<TerminalLiveInputFieldProps, 'onChange'> & {
readonly onTerminalInput: TerminalLiveInputFieldProps['onChange']
}
const AndroidTerminalInput =
Platform.OS === 'android'
? requireNativeComponent<AndroidTerminalInputProps>('OrcaTerminalInput')
: null
export type TerminalLiveInputFieldHandle = Pick<TextInput, 'blur' | 'focus' | 'setNativeProps'>
function runInputCommand(input: TextInput | null, command: 'blur' | 'focus'): void {
if (!AndroidTerminalInput) {
input?.[command]()
return
}
const tag = findNodeHandle(input)
if (tag !== null) {
UIManager.dispatchViewManagerCommand(tag, command, [])
}
}
export const TerminalLiveInputField = forwardRef<
TerminalLiveInputFieldHandle,
TerminalLiveInputFieldProps
>(function TerminalLiveInputField({ onChange, ...props }, ref) {
const inputRef = useRef<TextInput>(null)
useImperativeHandle(ref, () => ({
blur: () => runInputCommand(inputRef.current, 'blur'),
focus: () => runInputCommand(inputRef.current, 'focus'),
setNativeProps: (nativeProps) => inputRef.current?.setNativeProps(nativeProps)
}))
if (AndroidTerminalInput) {
return <AndroidTerminalInput {...props} ref={inputRef as never} onTerminalInput={onChange} />
}
return <TextInput {...props} ref={inputRef} onChange={onChange as TextInputProps['onChange']} />
})
@@ -0,0 +1,129 @@
import { createElement, type RefObject } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type Handlers = ReturnType<typeof useTerminalLiveInputCommit<string>>
type DeferredSend = {
readonly resolve: (sent: boolean) => void
readonly sender: TerminalLiveInputSender
readonly started: string[]
}
function createDeferredSend(): DeferredSend {
const started: string[] = []
let resolve: (sent: boolean) => void = () => {
throw new Error('terminal send did not start')
}
return {
resolve: (sent) => resolve(sent),
sender: async (_handle, bytes) =>
new Promise<boolean>((resolveSend) => {
started.push(bytes)
resolve = resolveSend
}),
started
}
}
function createHarness(sender: TerminalLiveInputSender): Handlers {
const activeHandle = 'terminal-a'
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const liveInputTerminalHandles = new Set([activeHandle])
let handlers: Handlers | null = null
function Harness(): null {
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: 'terminal',
activeSessionTabTypeRef,
connected: true,
liveInputRef: { current: { setNativeProps: vi.fn() } },
liveInputTerminalHandles,
liveInputTerminalHandlesRef: { current: liveInputTerminalHandles },
platform: 'android',
sendLiveTerminalInputRef: { current: sender },
setLiveInputCapture: () => {}
})
return null
}
const originalConsoleError = console.error
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
originalConsoleError(...args)
}
})
try {
act(() => {
create(createElement(Harness))
})
} finally {
consoleError.mockRestore()
}
if (!handlers) {
throw new Error('terminal live input hook did not render')
}
return handlers
}
function change(
handlers: Handlers,
text: string,
isComposing: boolean,
replacementText: string,
start: number,
end: number
): void {
handlers.handleLiveInputChange({
nativeEvent: {
text,
isComposing,
replacementText,
replacementRange: { start, end }
}
})
}
describe('terminal live native IME accessory order', () => {
it('sends only committed Hangul and holds accessory bytes behind that send', async () => {
const pendingSend = createDeferredSend()
const handlers = createHarness(pendingSend.sender)
change(handlers, 'ㅎ', true, 'ㅎ', 0, 0)
change(handlers, '하', true, '하', 0, 1)
change(handlers, '한', true, '한', 0, 1)
expect(pendingSend.started).toEqual([])
change(handlers, '한', false, '한', 0, 1)
expect(pendingSend.started).toEqual(['한'])
let accessorySettled = false
const accessory = handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' }).then((result) => {
accessorySettled = true
return result
})
await Promise.resolve()
expect(accessorySettled).toBe(false)
pendingSend.resolve(true)
await expect(accessory).resolves.toEqual({ kind: 'allow-raw' })
expect(pendingSend.started).toEqual(['한'])
})
it('suppresses accessory bytes when the preceding commit fails', async () => {
const pendingSend = createDeferredSend()
const handlers = createHarness(pendingSend.sender)
change(handlers, '한', false, '한', 0, 0)
const accessory = handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' })
pendingSend.resolve(false)
await expect(accessory).resolves.toEqual({ kind: 'suppress-raw' })
expect(pendingSend.started).toEqual(['한'])
})
})
@@ -1,168 +0,0 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
describe('terminal live pending flush state', () => {
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
// Given
const state = createTerminalLivePendingFlushState()
// When / Then
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
})
it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => {
// Given
const events: string[] = []
let resolveFlush: (value: boolean) => void = () => {}
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state = createTerminalLivePendingFlushState()
state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
await Promise.resolve()
// Then
expect(events).toEqual([])
resolveFlush(true)
await expect(controlSend).resolves.toBe(true)
expect(events).toEqual(['control'])
})
it('Given an in-flight flush fails When control input waits Then control is skipped', async () => {
// Given
const events: string[] = []
let resolveFlush: (value: boolean) => void = () => {}
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state = createTerminalLivePendingFlushState()
state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
resolveFlush(false)
// Then
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
})
})
describe('terminal live mirror send queue', () => {
it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const payloads: string[] = []
let resolveFirstSend: (value: boolean) => void = () => {}
const sender = async (_handle: string, payload: string): Promise<boolean> => {
payloads.push(payload)
if (payloads.length === 1) {
return new Promise<boolean>((resolve) => {
resolveFirstSend = resolve
})
}
return true
}
// When
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender)
await Promise.resolve()
// Then
expect(payloads).toEqual(['a'])
resolveFirstSend(true)
await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true])
expect(payloads).toEqual(['a', 'bc'])
})
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const order: string[] = []
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
order.push('first')
return false
})
// When
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => {
order.push('second')
return true
})
// Then
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
expect(order).toEqual(['first', 'second'])
})
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
throw new Error('boom')
})
// When
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true)
// Then
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
})
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
// Given
const state = createTerminalLivePendingFlushState()
// When
await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true)
await Promise.resolve()
// Then
expect(state.current).toBeNull()
})
it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => {
// Given
const state = createTerminalLivePendingFlushState()
let resolveSend: (value: boolean) => void = () => {}
const sender = async (): Promise<boolean> =>
new Promise((resolve) => {
resolveSend = resolve
})
const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
// When
cancelTerminalLivePendingFlush(state)
// Then
await expect(Promise.all([active, pending])).resolves.toEqual([false, false])
expect(state.current).toBeNull()
resolveSend(true)
})
})
@@ -1,108 +0,0 @@
type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise<boolean>
type TerminalLivePendingRequest = {
readonly resolve: (sent: boolean) => void
}
type TerminalLivePendingBatch = {
readonly handle: string
payload: string
readonly requests: TerminalLivePendingRequest[]
readonly sender: TerminalLiveMirrorSender
}
export type TerminalLivePendingFlushState = {
current: Promise<boolean> | null
activeRequests: TerminalLivePendingRequest[]
generation: number
pendingBatches: TerminalLivePendingBatch[]
}
export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState {
return {
current: null,
activeRequests: [],
generation: 0,
pendingBatches: []
}
}
export function waitForTerminalLivePendingFlush(
state: TerminalLivePendingFlushState
): Promise<boolean> {
return state.current ?? Promise.resolve(true)
}
export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void {
state.generation += 1
const requests = [
...state.activeRequests,
...state.pendingBatches.flatMap((batch) => batch.requests)
]
state.activeRequests = []
state.pendingBatches = []
state.current = null
requests.forEach(({ resolve }) => resolve(false))
}
async function drainTerminalLiveMirrorSends(
state: TerminalLivePendingFlushState,
generation: number
): Promise<boolean> {
let allSent = true
while (state.generation === generation) {
const batch = state.pendingBatches.shift()
if (!batch) {
state.current = null
return allSent
}
state.activeRequests = batch.requests
const sent = await batch.sender(batch.handle, batch.payload).catch(() => false)
if (state.generation !== generation) {
return false
}
state.activeRequests = []
batch.requests.forEach(({ resolve }) => resolve(sent))
allSent &&= sent
}
return false
}
// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke.
export function queueTerminalLiveMirrorSend(
state: TerminalLivePendingFlushState,
handle: string,
payload: string,
sender: TerminalLiveMirrorSender
): Promise<boolean> {
let resolveRequest: (sent: boolean) => void = () => {}
const request = new Promise<boolean>((resolve) => {
resolveRequest = resolve
})
const pendingTail = state.pendingBatches.at(-1)
if (pendingTail?.handle === handle && pendingTail.sender === sender) {
pendingTail.payload += payload
pendingTail.requests.push({ resolve: resolveRequest })
} else {
state.pendingBatches.push({
handle,
payload,
requests: [{ resolve: resolveRequest }],
sender
})
}
if (!state.current) {
const generation = state.generation
const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false)
state.current = drain
void drain.then(() => {
if (state.current === drain) {
state.current = null
}
})
}
return request
}
@@ -1,118 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
deriveTerminalLiveCommit,
getTerminalLiveSpecialKeyDecision
} from './terminal-live-text-commit'
describe('terminal live special key decision', () => {
it('Given an unmapped key Then ignores it', () => {
expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({
kind: 'ignore'
})
})
it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => {
// Held syllable present
describe('terminal live native replacement commits', () => {
it('commits a recorded Pinyin candidate without replaying its preedit', () => {
expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' })
).toEqual({ kind: 'local-edit' })
// Only mirrored text present — native edit fires onChangeText and the diff erases
expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' })
).toEqual({ kind: 'local-edit' })
})
it('Given Backspace with an empty field Then sends terminal backspace bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Backspace',
heldText: '',
sentText: ''
})
expect(decision.kind).toBe('send-now')
})
it('Given a control key with a held syllable Then commits the held text before the bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Tab',
heldText: '글',
sentText: '한'
})
expect(decision.kind).toBe('commit-held-then-send')
})
it('Given a control key with no held syllable Then sends immediately', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'ArrowUp',
heldText: '',
sentText: 'ls'
})
expect(decision.kind).toBe('send-now')
})
})
describe('terminal live accessory bytes decision', () => {
it('Given a local-edit accessory key with field text Then edits locally', () => {
expect(
getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '한',
sentText: ''
deriveTerminalLiveCommit('', {
text: '中',
replacementText: '中',
replacementRange: { start: 0, end: 5 }
})
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
).toEqual({ committedText: '中', payload: '中' })
})
it('keeps ordinary append input unchanged', () => {
expect(
getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: 'abc'
deriveTerminalLiveCommit('a', {
text: 'ab',
replacementText: 'b',
replacementRange: { start: 1, end: 1 }
})
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
).toEqual({ committedText: 'ab', payload: 'b' })
})
it('Given raw accessory bytes with a held syllable Then commits held text first', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '한',
sentText: ''
})
expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' })
})
it('Given raw accessory bytes with nothing held Then sends immediately', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '',
sentText: 'abc'
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' })
})
it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: ''
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' })
})
})
describe('terminal live accessory local edit text', () => {
it('Given backspace Then drops the last code point of the field text', () => {
it('replaces the full field when UIKit transforms the proposed input', () => {
expect(
getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' })
).toBe('한')
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한' })).toBe(
''
)
deriveTerminalLiveCommit('ㅇ', {
text: '아',
replacementText: 'ㅏ',
replacementRange: { start: 1, end: 1 }
})
).toEqual({ committedText: '아', payload: '\x7f아' })
})
it('Given forward delete Then keeps the field text unchanged', () => {
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe(
'한글'
)
it('emits nothing when a proposed transform leaves the field unchanged', () => {
expect(
deriveTerminalLiveCommit('a', {
text: 'a',
replacementText: '´',
replacementRange: { start: 1, end: 1 }
})
).toEqual({ committedText: 'a', payload: '' })
})
it('derives deletion from the native range and counts emoji as one terminal character', () => {
expect(
deriveTerminalLiveCommit('a😀', {
text: 'a',
replacementText: '',
replacementRange: { start: 1, end: 3 }
})
).toEqual({ committedText: 'a', payload: '\x7f' })
})
it('uses the authoritative field text for a collapsed transformed deletion', () => {
expect(
deriveTerminalLiveCommit('a', {
text: '',
replacementText: '',
replacementRange: { start: 1, end: 1 }
})
).toEqual({ committedText: '', payload: '\x7f' })
expect(
deriveTerminalLiveCommit('😀', {
text: '',
replacementText: '',
replacementRange: { start: 2, end: 2 }
})
).toEqual({ committedText: '', payload: '\x7f' })
})
it('emits nothing for a cancelled preedit or ambiguous non-suffix replacement', () => {
expect(
deriveTerminalLiveCommit('', {
text: '',
replacementText: '',
replacementRange: { start: 0, end: 5 }
})
).toEqual({ committedText: '', payload: '' })
expect(
deriveTerminalLiveCommit('abc', {
text: 'aXc',
replacementText: 'X',
replacementRange: { start: 1, end: 2 }
})
).toBeNull()
})
})
describe('terminal live special keys', () => {
it('lets the native replacement own Backspace while the field has committed text', () => {
expect(getTerminalLiveSpecialKeyDecision('Backspace', true)).toEqual({ kind: 'ignore' })
expect(getTerminalLiveSpecialKeyDecision('Backspace', false)).toEqual({
kind: 'send',
bytes: '\x7f'
})
})
})
@@ -1,83 +1,69 @@
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
export type TerminalLiveSpecialKeyDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'local-edit' }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
const TERMINAL_DEL_BYTE = '\x7f'
export type TerminalLiveSpecialKeyDecisionInput = {
readonly key: string
readonly heldText: string
readonly sentText: string
export type TerminalLiveReplacement = {
readonly text: string
readonly replacementText: string
readonly replacementRange: {
readonly start: number
readonly end: number
}
}
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryBytesDecision =
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
export type TerminalLiveAccessoryBytesDecisionInput = {
readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit
readonly heldText: string
readonly sentText: string
export type TerminalLiveCommit = {
readonly committedText: string
readonly payload: string
}
export function getTerminalLiveSpecialKeyDecision({
key,
heldText,
sentText
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
function splitsSurrogatePair(text: string, offset: number): boolean {
if (offset <= 0 || offset >= text.length) {
return false
}
const before = text.charCodeAt(offset - 1)
const after = text.charCodeAt(offset)
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff
}
export function deriveTerminalLiveCommit(
committedText: string,
change: TerminalLiveReplacement
): TerminalLiveCommit | null {
const { start, end } = change.replacementRange
if (
!Number.isInteger(start) ||
!Number.isInteger(end) ||
start < 0 ||
start > committedText.length ||
end < committedText.length ||
end < start ||
splitsSurrogatePair(committedText, start)
) {
return null
}
if (change.text === committedText) {
return { committedText, payload: '' }
}
const retainedText = committedText.slice(0, start)
const predictedText = retainedText + change.replacementText
const operationMatchesText = change.text === predictedText
const replacementStart = operationMatchesText ? start : 0
const eraseCount = Array.from(committedText.slice(replacementStart)).length
return {
committedText: change.text,
payload: TERMINAL_DEL_BYTE.repeat(eraseCount) + change.text.slice(replacementStart)
}
}
export function getTerminalLiveSpecialKeyDecision(
key: string,
hasCommittedText: boolean
): { readonly kind: 'ignore' } | { readonly kind: 'send'; readonly bytes: string } {
const bytes = getTerminalLiveSpecialKeyBytes(key)
if (bytes === null) {
if (bytes === null || ((key === 'Backspace' || key === 'Delete') && hasCommittedText)) {
return { kind: 'ignore' }
}
// Why: native field edits fire onChangeText and the mirror diff emits the
// matching PTY erase; sending raw DEL here as well would double-erase.
if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit' }
}
if (heldText.length > 0) {
return { kind: 'commit-held-then-send', bytes }
}
return { kind: 'send-now', bytes }
}
export function getTerminalLiveAccessoryBytesDecision({
bytes,
localEdit,
heldText,
sentText
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
if (localEdit && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit', localEdit }
}
if (heldText.length > 0) {
return { kind: 'commit-held-then-send', bytes }
}
return { kind: 'send-now', bytes }
}
export function getTerminalLiveAccessoryLocalEditText({
localEdit,
fieldText
}: {
readonly localEdit: TerminalLiveAccessoryLocalEdit
readonly fieldText: string
}): string {
if (localEdit === 'delete') {
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
// it stays local but does not remove the field text.
return fieldText
}
return Array.from(fieldText).slice(0, -1).join('')
return { kind: 'send', bytes }
}
@@ -11,4 +11,8 @@ describe('normalizeTerminalTextInput', () => {
it('keeps ASCII hyphens unchanged', () => {
expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file')
})
it.each(['the ', 'teh '])('passes through recorded Android buffered input %j', (text) => {
expect(normalizeTerminalTextInput(text)).toBe(text)
})
})
@@ -1,230 +0,0 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
getTerminalLiveAccessoryInactiveInputCommitResult,
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
type DeferredBoolean = {
readonly promise: Promise<boolean>
readonly resolve: (value: boolean) => void
}
function createDeferredBoolean(): DeferredBoolean {
let resolvePromise: (value: boolean) => void = () => {
throw new Error('deferred promise was resolved before initialization')
}
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve
})
return { promise, resolve: resolvePromise }
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
type AccessoryInputCommitHarnessOptions = {
readonly heldText?: string
readonly sentText?: string
readonly pendingHandle?: string | null
readonly sendResult?: boolean
readonly flushResult?: boolean
readonly waitResult?: boolean
}
type AccessoryInputCommitHarness = {
readonly commit: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly sent: readonly string[]
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
readonly flushPendingLiveInputText: ReturnType<typeof vi.fn>
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
readonly unmount: () => void
}
function createAccessoryInputCommitHarness({
heldText = '',
sentText = '',
pendingHandle = null,
sendResult = true,
flushResult = true,
waitResult = true
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
const activeHandle = 'terminal-a'
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const sent: string[] = []
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return sendResult
}
}
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
const setLiveInputCapture = vi.fn((_text: string) => {})
let commit: AccessoryInputCommitHarness['commit'] | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
commit = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
restoreConsoleError()
}
if (!commit || !renderer) {
throw new Error('terminal live accessory input hook did not render')
}
return {
commit,
sent,
applyLiveInputMirror,
flushPendingLiveInputText,
waitForPendingLiveInputFlush,
unmount: () => {
act(() => renderer?.unmount())
}
}
}
describe('terminal live accessory inactive input commit result', () => {
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
// Given
const deferredFlush = createDeferredBoolean()
let settled = false
// When
const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult(
() => deferredFlush.promise
)
void resultPromise.then(() => {
settled = true
})
await Promise.resolve()
// Then
expect(settled).toBe(false)
deferredFlush.resolve(true)
await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' })
})
it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => {
// Given
const waitForPendingLiveInputFlush = async (): Promise<boolean> => false
// When
const result = await getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush
)
// Then
expect(result).toEqual({ kind: 'suppress-raw' })
})
})
describe('terminal live accessory input commit hook', () => {
it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x1b' })
// Then
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
expect(harness.sent).toEqual(['\x1b'])
expect(result).toEqual({ kind: 'handled' })
})
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
// Given
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
// When
const result = await harness.commit({ bytes: '\x1b' })
// Then
expect(result).toEqual({ kind: 'allow-raw' })
expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled()
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
expect(result).toEqual({ kind: 'handled' })
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '',
sentText: 'ab',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
expect(result).toEqual({ kind: 'handled' })
})
})
@@ -1,113 +0,0 @@
import { useCallback, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText
} from './terminal-live-text-commit'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'allow-raw' }
| { readonly kind: 'handled' }
| { readonly kind: 'suppress-raw' }
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush: () => Promise<boolean>
): Promise<TerminalLiveAccessoryInputCommitResult> {
return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
}
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
export function useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
}: TerminalLiveAccessoryInputCommitOptions): (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult> {
return useCallback(
async (input: TerminalLiveAccessoryInput): Promise<TerminalLiveAccessoryInputCommitResult> => {
if (!activeHandle) {
return { kind: 'allow-raw' }
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
}
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
}
const heldText = ownsPendingState ? heldLiveInputTextRef.current : ''
const sentText = ownsPendingState ? sentLiveInputTextRef.current : ''
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, heldText, sentText })
switch (decision.kind) {
case 'send-now':
// Why: raw accessory bytes must wait behind any in-flight mirror send
// so composed Hangul reaches the PTY before follow-up controls.
return (await waitForPendingLiveInputFlush())
? { kind: 'allow-raw' }
: { kind: 'suppress-raw' }
case 'local-edit': {
const editedText = getTerminalLiveAccessoryLocalEditText({
localEdit: decision.localEdit,
fieldText: sentText + heldText
})
// Why: accessory buttons do not emit native TextInput edits, so the
// field is edited here and the mirror diff syncs the PTY echo.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
applyLiveInputMirror(activeHandle, editedText)
return { kind: 'handled' }
}
case 'commit-held-then-send':
await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return { kind: 'handled' }
default:
decision satisfies never
return { kind: 'handled' }
}
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
]
)
}
@@ -0,0 +1,197 @@
/**
* #7094 — mobile terminal keystrokes vanish when typed fast ("only `l` instead of `ls`").
*
* `queueSend` chains every keystroke behind the previous send, and a send that resolves false or
* throws aborts the whole queue behind it — the bytes are never attempted and the error is
* swallowed. Only keystrokes queued while a doomed send is still in flight are lost, which is why
* the loss is speed-dependent.
*
* SCOPE LIMIT, load-bearing: this covers a transport send-queue abort, which requires a real
* disconnect or RPC error. `REQUEST_TIMEOUT_MS = 30_000` (`transport/rpc-client.ts`) means latency
* alone cannot reach this branch — a flapping relay produces such errors, a steady-but-slow one does
* not. So this is consistent with the reporter's symptom class, NOT proven to be its cause; their
* word "latency" may be their own inference.
*/
import { createElement, type RefObject } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type Handlers = ReturnType<typeof useTerminalLiveInputCommit<string>>
type OrdinaryKeystroke = {
readonly text: string
readonly replacementText: string
readonly start: number
readonly end: number
}
// Ordinary ASCII, no composition — the reporter typed `ls`, not an IME sequence.
const ORDINARY_ABC_TRACE: readonly OrdinaryKeystroke[] = [
{ text: 'a', replacementText: 'a', start: 0, end: 0 },
{ text: 'ab', replacementText: 'b', start: 1, end: 1 },
{ text: 'abc', replacementText: 'c', start: 2, end: 2 }
]
const LATER_KEYSTROKE: OrdinaryKeystroke = {
text: 'abcd',
replacementText: 'd',
start: 3,
end: 3
}
function createLiveInputHarness(sender: TerminalLiveInputSender): Handlers {
const activeHandle = 'terminal-a'
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const liveInputTerminalHandles = new Set([activeHandle])
let handlers: Handlers | null = null
function Harness(): null {
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: 'terminal',
activeSessionTabTypeRef,
connected: true,
liveInputRef: { current: { setNativeProps: vi.fn() } },
liveInputTerminalHandles,
liveInputTerminalHandlesRef: { current: liveInputTerminalHandles },
platform: 'android',
sendLiveTerminalInputRef: { current: sender },
setLiveInputCapture: () => {}
})
return null
}
const originalConsoleError = console.error
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
originalConsoleError(...args)
}
})
try {
act(() => {
create(createElement(Harness))
})
} finally {
consoleError.mockRestore()
}
if (!handlers) {
throw new Error('terminal live input hook did not render')
}
return handlers
}
function type(handlers: Handlers, keystroke: OrdinaryKeystroke): void {
handlers.handleLiveInputChange({
nativeEvent: {
text: keystroke.text,
isComposing: false,
replacementText: keystroke.replacementText,
replacementRange: { start: keystroke.start, end: keystroke.end }
}
})
}
/** Holds each send open so keystrokes queue behind it, exactly as fast typing does. */
function createHeldSender(): {
readonly attempted: string[]
readonly settle: (delivered: boolean) => void
readonly sender: TerminalLiveInputSender
} {
const attempted: string[] = []
const waiting: Array<(delivered: boolean) => void> = []
return {
attempted,
settle: (delivered) => {
const resolve = waiting.shift()
if (!resolve) {
throw new Error('settle() called with no send in flight')
}
resolve(delivered)
},
sender: async (_handle, bytes) =>
new Promise<boolean>((resolve) => {
attempted.push(bytes)
waiting.push(resolve)
})
}
}
// The abort resolves through promise microtasks only — no timers, so draining ticks is exact.
async function flushSendChain(): Promise<void> {
for (let tick = 0; tick < 10; tick += 1) {
await Promise.resolve()
}
}
describe('#7094 mobile live input drops keystrokes queued behind a failed send', () => {
it('never attempts the keystrokes queued behind a send that fails', async () => {
const { attempted, settle, sender } = createHeldSender()
const handlers = createLiveInputHarness(sender)
for (const keystroke of ORDINARY_ABC_TRACE) {
type(handlers, keystroke)
}
expect(attempted).toEqual(['a'])
settle(false)
await flushSendChain()
// `b` and `c` are discarded without ever reaching the transport, and nothing reports it.
expect(attempted).toEqual(['a'])
})
it('still delivers every keystroke in order when the sends succeed', async () => {
const { attempted, settle, sender } = createHeldSender()
const handlers = createLiveInputHarness(sender)
for (const keystroke of ORDINARY_ABC_TRACE) {
type(handlers, keystroke)
}
expect(attempted).toEqual(['a'])
settle(true)
await flushSendChain()
expect(attempted).toEqual(['a', 'b'])
settle(true)
await flushSendChain()
expect(attempted).toEqual(['a', 'b', 'c'])
})
it('swallows a throwing send and drops the keystrokes queued behind it', async () => {
const attempted: string[] = []
const sender: TerminalLiveInputSender = async (_handle, bytes) => {
attempted.push(bytes)
throw new Error('relay dropped the frame')
}
const handlers = createLiveInputHarness(sender)
for (const keystroke of ORDINARY_ABC_TRACE) {
type(handlers, keystroke)
}
await flushSendChain()
expect(attempted).toEqual(['a'])
})
it('loses only the keystrokes queued during the failure, so slow typing survives it', async () => {
const { attempted, settle, sender } = createHeldSender()
const handlers = createLiveInputHarness(sender)
for (const keystroke of ORDINARY_ABC_TRACE) {
type(handlers, keystroke)
}
settle(false)
await flushSendChain()
expect(attempted).toEqual(['a'])
// Typed after the aborted chain settled: the path is alive, so the drop above is a real
// abort rather than a dead queue.
type(handlers, LATER_KEYSTROKE)
expect(attempted).toEqual(['a', 'd'])
})
})
@@ -1,392 +1,578 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { noteImeCompositionChange } from '../ime/ime-submit-carry'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[]
readonly setActiveSessionTabType: (next: string | undefined) => void
readonly setConnected: (next: boolean) => void
readonly setSendResult: (next: boolean) => void
readonly unmount: () => void
const frames: Array<() => void> = []
function flushFrame(): void {
const pending = frames.splice(0)
for (const callback of pending) {
callback()
}
}
type TerminalLiveInputCommitHarnessOptions = {
readonly sendResult?: boolean
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
beforeEach(() => {
frames.length = 0
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
frames.push(callback)
return frames.length
})
return () => consoleErrorSpy.mockRestore()
// Why: the IME submit carry is module state, so a prior test's marked event would otherwise
// make this test's first unmarked event look like a confirmation. Android never arms it.
noteImeCompositionChange('android', true)
noteImeCompositionChange('android', false)
})
type Handlers = ReturnType<typeof useTerminalLiveInputCommit<string>>
type RecordedChange = {
readonly text: string
readonly isComposing: boolean
readonly replacementText: string
readonly start: number
readonly end: number
}
function createTerminalLiveInputCommitHarness({
sendResult = true
}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
const RECORDED_IOS_KANA_TRACE: readonly RecordedChange[] = [
{ text: 'あ', isComposing: true, replacementText: 'あ', start: 0, end: 0 },
{ text: 'あ', isComposing: true, replacementText: 'あ', start: 0, end: 1 },
{ text: 'あ', isComposing: false, replacementText: 'あ', start: 0, end: 1 },
{ text: 'あき', isComposing: true, replacementText: 'き', start: 1, end: 1 },
{ text: 'あき', isComposing: true, replacementText: 'き', start: 1, end: 2 },
{ text: 'あき', isComposing: false, replacementText: 'き', start: 1, end: 2 },
{ text: 'あきか', isComposing: true, replacementText: 'か', start: 2, end: 2 },
{ text: 'あきかな', isComposing: true, replacementText: 'な', start: 3, end: 3 },
{ text: 'あきカナ', isComposing: true, replacementText: 'カナ', start: 2, end: 4 },
{ text: 'あきカナ', isComposing: false, replacementText: 'カナ', start: 2, end: 4 },
{ text: 'あきカナさ', isComposing: true, replacementText: 'さ', start: 4, end: 4 },
{ text: 'あきカナ', isComposing: false, replacementText: '', start: 4, end: 5 }
]
const RECORDED_IOS_7427_TRACE: readonly RecordedChange[] = [
{ text: 'つ', isComposing: true, replacementText: 'つ', start: 0, end: 0 },
{ text: 'っ', isComposing: true, replacementText: '゛', start: 1, end: 1 },
{ text: 'っ', isComposing: true, replacementText: 'っ', start: 0, end: 1 },
{ text: 'っ', isComposing: false, replacementText: 'っ', start: 0, end: 1 },
{ text: 'っか', isComposing: true, replacementText: 'か', start: 1, end: 1 },
{ text: 'っが', isComposing: true, replacementText: '゛', start: 2, end: 2 },
{ text: 'っが', isComposing: true, replacementText: 'が', start: 1, end: 2 },
{ text: 'っが', isComposing: false, replacementText: 'が', start: 1, end: 2 },
{ text: 'っがは', isComposing: true, replacementText: 'は', start: 2, end: 2 },
{ text: 'っがば', isComposing: true, replacementText: '゛', start: 3, end: 3 },
{ text: 'っがぱ', isComposing: true, replacementText: '゛', start: 3, end: 3 },
{ text: 'っがぱ', isComposing: true, replacementText: 'ぱ', start: 2, end: 3 },
{ text: 'っがぱ', isComposing: false, replacementText: 'ぱ', start: 2, end: 3 },
{ text: 'っがぱs', isComposing: true, replacementText: 's', start: 3, end: 3 },
{ text: 'っがぱさ', isComposing: true, replacementText: 'a', start: 4, end: 4 },
{ text: 'っがぱさ', isComposing: true, replacementText: 'さ', start: 3, end: 4 },
{ text: 'っがぱさ', isComposing: false, replacementText: 'さ', start: 3, end: 4 },
{ text: 'っがぱさk', isComposing: true, replacementText: 'k', start: 4, end: 4 },
{ text: 'っがぱさか', isComposing: true, replacementText: 'a', start: 5, end: 5 },
{ text: 'っがぱさかn', isComposing: true, replacementText: 'n', start: 5, end: 5 },
{ text: 'っがぱさかんj', isComposing: true, replacementText: 'j', start: 6, end: 6 },
{ text: 'っがぱさかんじ', isComposing: true, replacementText: 'i', start: 7, end: 7 },
{ text: 'っがぱさ漢字', isComposing: true, replacementText: '漢字', start: 4, end: 7 },
{ text: 'っがぱさ漢字', isComposing: false, replacementText: '漢字', start: 4, end: 7 },
{ text: 'っがぱさ漢字k', isComposing: true, replacementText: 'k', start: 6, end: 6 },
{ text: 'っがぱさ漢字か', isComposing: true, replacementText: 'a', start: 7, end: 7 },
{ text: 'っがぱさ漢字かn', isComposing: true, replacementText: 'n', start: 7, end: 7 },
{ text: 'っがぱさ漢字かな', isComposing: true, replacementText: 'a', start: 8, end: 8 },
{ text: 'っがぱさ漢字かな', isComposing: true, replacementText: 'かな', start: 6, end: 8 },
{ text: 'っがぱさ漢字かな', isComposing: false, replacementText: 'かな', start: 6, end: 8 }
]
// Captured on a physical iPhone 13 Pro Max, iOS 26.5.2, system Japanese Kana keyboard
// (lane-ios/metro.log, IME7427_NATIVE_EVENT eventCount 5-10). The same capture arm recorded
// 13 PTY bytes: the 12 expected UTF-8 bytes for いうえお plus a trailing 0d.
const RECORDED_IOS_DEVICE_FLICK_VOWELS_TRACE: readonly RecordedChange[] = [
{ text: 'い', isComposing: true, replacementText: 'い', start: 0, end: 0 },
{ text: 'いう', isComposing: true, replacementText: 'う', start: 1, end: 1 },
{ text: 'いうえ', isComposing: true, replacementText: 'え', start: 2, end: 2 },
{ text: 'いうえお', isComposing: true, replacementText: 'お', start: 3, end: 3 },
{ text: 'いうえお', isComposing: true, replacementText: 'いうえお', start: 0, end: 4 },
{ text: 'いうえお', isComposing: false, replacementText: 'いうえお', start: 0, end: 4 }
]
const RECORDED_ANDROID_FCITX_HANGUL_TRACE: readonly RecordedChange[] = [
{ text: 'ㅎ', isComposing: true, replacementText: 'ㅎ', start: 0, end: 0 },
{ text: '하', isComposing: true, replacementText: '하', start: 0, end: 1 },
{ text: '한', isComposing: true, replacementText: '한', start: 0, end: 1 },
{ text: '한', isComposing: false, replacementText: '한', start: 0, end: 1 },
{ text: '한ㄱ', isComposing: true, replacementText: 'ㄱ', start: 1, end: 1 },
{ text: '한그', isComposing: true, replacementText: '그', start: 1, end: 2 },
{ text: '한글', isComposing: true, replacementText: '글', start: 1, end: 2 },
{ text: '한글', isComposing: false, replacementText: '글', start: 1, end: 2 }
]
const RECORDED_ANDROID_FCITX_HANGUL_CANCELLATION_TRACE: readonly RecordedChange[] = [
{ text: 'ㅎ', isComposing: true, replacementText: 'ㅎ', start: 0, end: 0 },
{ text: '하', isComposing: true, replacementText: '하', start: 0, end: 1 },
{ text: '한', isComposing: true, replacementText: '한', start: 0, end: 1 },
{ text: '하', isComposing: true, replacementText: '하', start: 0, end: 1 },
{ text: 'ㅎ', isComposing: true, replacementText: 'ㅎ', start: 0, end: 1 },
{ text: '', isComposing: true, replacementText: '', start: 0, end: 1 },
{ text: '', isComposing: false, replacementText: '', start: 0, end: 1 }
]
const RECORDED_ANDROID_FCITX_ANTHY_TRACE: readonly RecordedChange[] = [
{ text: 's', isComposing: true, replacementText: 's', start: 0, end: 0 },
{ text: 'さ', isComposing: true, replacementText: 'さ', start: 0, end: 1 },
{ text: 'さ', isComposing: false, replacementText: 'さ', start: 0, end: 1 }
]
const RECORDED_ANDROID_FCITX_ANTHY_CANCELLATION_TRACE: readonly RecordedChange[] = [
{ text: 's', isComposing: true, replacementText: 's', start: 0, end: 0 },
{ text: 'さ', isComposing: true, replacementText: 'さ', start: 0, end: 1 },
{ text: '', isComposing: true, replacementText: '', start: 0, end: 1 },
{ text: '', isComposing: false, replacementText: '', start: 0, end: 1 }
]
const ORDINARY_ABC_TRACE: readonly RecordedChange[] = [
{ text: 'a', isComposing: false, replacementText: 'a', start: 0, end: 0 },
{ text: 'ab', isComposing: false, replacementText: 'b', start: 1, end: 1 },
{ text: 'abc', isComposing: false, replacementText: 'c', start: 2, end: 2 }
]
const RECORDED_IOS_KOREAN_TRANSFORM_TRACE: readonly RecordedChange[] = [
{ text: 'ㅇ', isComposing: false, replacementText: 'ㅇ', start: 0, end: 0 },
{ text: '아', isComposing: false, replacementText: 'ㅏ', start: 1, end: 1 },
{ text: '안', isComposing: false, replacementText: 'ㄴ', start: 1, end: 1 },
{ text: '안ㄴ', isComposing: false, replacementText: 'ㄴ', start: 1, end: 1 },
{ text: '안녀', isComposing: false, replacementText: 'ㅕ', start: 2, end: 2 },
{ text: '안녕', isComposing: false, replacementText: 'ㅇ', start: 2, end: 2 },
{ text: '안녕ㅎ', isComposing: false, replacementText: 'ㅎ', start: 2, end: 2 },
{ text: '안녕하', isComposing: false, replacementText: 'ㅏ', start: 3, end: 3 },
{ text: '안녕핫', isComposing: false, replacementText: 'ㅅ', start: 3, end: 3 },
{ text: '안녕하세', isComposing: false, replacementText: 'ㅔ', start: 3, end: 3 },
{ text: '안녕하셍', isComposing: false, replacementText: 'ㅇ', start: 4, end: 4 },
{ text: '안녕하세요', isComposing: false, replacementText: 'ㅛ', start: 4, end: 4 }
]
const RECORDED_ANDROID_GBOARD_BACKSPACE_TRACE: readonly RecordedChange[] = [
{ text: 'a', isComposing: false, replacementText: 'a', start: 0, end: 0 },
{ text: '', isComposing: false, replacementText: '', start: 0, end: 1 }
]
const IOS_ROMAJI_RECORDED_PREFIX = 'あきカナたあbcabc'
const RECORDED_IOS_ROMAJI_TRACE: readonly RecordedChange[] = [
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}k`,
isComposing: true,
replacementText: 'k',
start: 11,
end: 11
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}か`,
isComposing: true,
replacementText: 'a',
start: 12,
end: 12
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}かn`,
isComposing: true,
replacementText: 'n',
start: 12,
end: 12
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}かな`,
isComposing: true,
replacementText: 'a',
start: 13,
end: 13
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}かな`,
isComposing: true,
replacementText: 'かな',
start: 11,
end: 13
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}かな`,
isComposing: false,
replacementText: 'かな',
start: 11,
end: 13
}
]
function createHarness(
send?: TerminalLiveInputSender,
platform = 'ios'
): {
readonly captures: string[]
readonly handlers: Handlers
readonly sent: string[]
} {
const activeHandle = 'terminal-a'
const captures: string[] = []
const sent: string[] = []
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const captures: string[] = []
const setLiveInputCapture = (text: string): void => {
captures.push(text)
}
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
current: new Set([activeHandle])
}
const sent: string[] = []
let currentSendResult = sendResult
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return currentSendResult
}
current:
send ??
(async (_handle, bytes) => {
sent.push(bytes)
return true
})
}
// Refs never re-render; only these variables re-run the hook's clear effects.
let currentActiveSessionTabType: string | undefined = 'terminal'
let currentConnected = true
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let renderer: ReactTestRenderer | null = null
const liveInputRef = {
current: { setNativeProps: vi.fn() }
}
let handlers: Handlers | null = null
function Harness(): null {
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: currentActiveSessionTabType,
activeSessionTabType: 'terminal',
activeSessionTabTypeRef,
connected: currentConnected,
connected: true,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
liveInputTerminalHandlesRef: { current: liveInputTerminalHandles },
platform,
sendLiveTerminalInputRef,
setLiveInputCapture
setLiveInputCapture: (text) => captures.push(text)
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
const originalConsoleError = console.error
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
originalConsoleError(...args)
}
})
try {
act(() => {
renderer = create(createElement(Harness))
create(createElement(Harness))
})
} finally {
restoreConsoleError()
consoleError.mockRestore()
}
if (!handlers || !renderer) {
if (!handlers) {
throw new Error('terminal live input hook did not render')
}
return { captures, handlers, sent }
}
return {
captures,
handlers,
sent,
setActiveSessionTabType: (next: string | undefined): void => {
currentActiveSessionTabType = next
// Ref and prop derive from the same activeSessionTab in the real route, so
// they go null together during tab-list lag — keep the harness coupled.
activeSessionTabTypeRef.current = next ?? null
act(() => {
renderer?.update(createElement(Harness))
})
},
setConnected: (next: boolean): void => {
currentConnected = next
act(() => {
renderer?.update(createElement(Harness))
})
},
setSendResult: (next: boolean): void => {
currentSendResult = next
},
unmount: () => {
act(() => renderer?.unmount())
function change(handlers: Handlers, event: RecordedChange): void {
handlers.handleLiveInputChange({
nativeEvent: {
text: event.text,
isComposing: event.isComposing,
replacementText: event.replacementText,
replacementRange: { start: event.start, end: event.end }
}
})
}
function replay(handlers: Handlers, trace: readonly RecordedChange[]): void {
for (const event of trace) {
change(handlers, event)
}
}
describe('terminal live input commit hook', () => {
afterEach(() => {
vi.useRealTimers()
})
it('replays the recorded Android Fcitx Hangul commit and Enter trace', async () => {
const { handlers, sent } = createHarness(undefined, 'android')
it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
handlers.handleLiveInputChange(fieldText)
await vi.advanceTimersByTimeAsync(50)
}
// Then: only the stable prefix went out; the trailing syllable is held
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a timer-committed syllable When composition continues Then corrects with DEL and recommits', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('하')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
await vi.waitFor(() => expect(sent).toEqual(['하']))
// When
handlers.handleLiveInputChange('한')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한']))
})
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputSubmit()
// Then
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given no pending text When submit is requested Then sends only carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputSubmit()
// Then
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputSubmit()
await Promise.resolve()
await Promise.resolve()
// Then: the held commit went out but was not accepted, so no \r follows
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given ASCII typing When changes arrive Then mirrors immediately', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('a')
handlers.handleLiveInputChange('ab')
// Then
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
})
it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => {
// Given
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS smart punctuation rewrote "--" into an en dash inside the field
handlers.handleLiveInputChange('a–')
// Then: writing "a--" back into the controlled value would kill an active
// iOS dictation/IME session, so the capture must keep what iOS produced
expect(captures).toEqual(['a–'])
await vi.waitFor(() => expect(sent).toEqual(['a--']))
})
it('Given dictation-style hypothesis revisions When changes arrive Then the field is never rewritten and the PTY converges', async () => {
// Given
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS dictation replaces its hypothesis as recognition refines
handlers.handleLiveInputChange('high')
handlers.handleLiveInputChange('hi there')
// Then: captures only echo the field; the mirror repairs the PTY with DELs
expect(captures).toEqual(['high', 'hi there'])
await vi.waitFor(() => expect(sent).toEqual(['high', '\x7f\x7f there']))
})
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputChange('한 ')
// Then
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
})
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
// Then
expect(flushed).toBe(true)
expect(sent).toEqual(['한'])
})
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
// Then
expect(flushed).toBe(false)
expect(sent).toEqual(['한'])
})
it('Given non-Hangul IME text When changes arrive Then mirrors immediately without a settle window', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('你好')
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好']))
})
it('Given a held syllable When the hook unmounts Then cancels the settle timer', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
unmount()
await vi.advanceTimersByTimeAsync(1_000)
// Then
replay(handlers, RECORDED_ANDROID_FCITX_HANGUL_TRACE.slice(0, 3))
expect(sent).toEqual([])
change(handlers, RECORDED_ANDROID_FCITX_HANGUL_TRACE[3])
await vi.waitFor(() => expect(sent).toEqual(['한']))
replay(handlers, RECORDED_ANDROID_FCITX_HANGUL_TRACE.slice(4, 7))
expect(sent).toEqual(['한'])
change(handlers, RECORDED_ANDROID_FCITX_HANGUL_TRACE[7])
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent).toEqual(['한', '글', '\r']))
})
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
it('replays Fcitx Hangul cancellation without leaving terminal input gated', async () => {
const { handlers, sent } = createHarness(undefined, 'android')
// When
replay(handlers, RECORDED_ANDROID_FCITX_HANGUL_CANCELLATION_TRACE.slice(0, -2))
change(handlers, RECORDED_ANDROID_FCITX_HANGUL_CANCELLATION_TRACE.at(-2)!)
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
// Then
await vi.waitFor(() => expect(sent).toEqual([]))
})
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
// Then
await vi.waitFor(() => expect(sent).toEqual(['한', '\t']))
})
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When: the mobile tab list momentarily yields no active tab object
setActiveSessionTabType(undefined)
handlers.handleLiveInputSubmit()
// Then: an unknown tab type is not "left the terminal", so pending still flushes
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When: the active tab actually becomes a non-terminal (chat) tab
setActiveSessionTabType('chat')
handlers.handleLiveInputSubmit()
// Then: pending was dropped, so submit sends only the carriage return
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
it('Given bytes lost in a silent stall When the disconnect is detected Then the first post-recovery send carries no stale fragment or phantom erases', async () => {
// Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect)
const { captures, handlers, sent, setConnected, setSendResult } =
createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('XYZZY')
await vi.waitFor(() => expect(sent).toEqual(['XYZZY']))
// When: the outage is finally detected, then the link recovers
setConnected(false)
setSendResult(true)
setConnected(true)
// Then: the capture was wiped, and fresh typing sends verbatim bytes — not
// 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived
expect(captures.at(-1)).toBe('')
const sentBeforeRecovery = sent.length
handlers.handleLiveInputChange('echo CLEANLINE')
await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE']))
})
it('Given a held syllable during an outage When the disconnect is detected Then the settle timer cannot commit it later', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({
sendResult: false
})
handlers.handleLiveInputChange('한')
// When
setConnected(false)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then: the outage cleared the held text before the timer could send it
change(handlers, RECORDED_ANDROID_FCITX_HANGUL_CANCELLATION_TRACE.at(-1)!)
expect(sent).toEqual([])
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'allow-raw'
})
change(handlers, ORDINARY_ABC_TRACE[0])
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent).toEqual(['a', '\r']))
})
it('replays the recorded Android Fcitx Anthy trace with an English control', async () => {
const { handlers, sent } = createHarness(undefined, 'android')
replay(handlers, RECORDED_ANDROID_FCITX_ANTHY_TRACE.slice(0, -1))
expect(sent).toEqual([])
change(handlers, RECORDED_ANDROID_FCITX_ANTHY_TRACE.at(-1)!)
await vi.waitFor(() => expect(sent).toEqual(['さ']))
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'allow-raw'
})
replay(handlers, RECORDED_ANDROID_FCITX_ANTHY_CANCELLATION_TRACE)
expect(sent).toEqual(['さ'])
change(handlers, ORDINARY_ABC_TRACE[0])
await vi.waitFor(() => expect(sent).toEqual(['さ', 'a']))
})
it('keeps the recorded Pinyin preedit native and sends only its candidate commit', async () => {
const { captures, handlers, sent } = createHarness()
const preedit = [
{ text: 'z', replacementText: 'z', start: 0 },
{ text: 'zh', replacementText: 'h', start: 1 },
{ text: 'zho', replacementText: 'o', start: 2 },
{ text: 'zhon', replacementText: 'n', start: 3 },
{ text: 'zhong', replacementText: 'g', start: 4 }
]
for (const event of preedit) {
change(handlers, {
...event,
isComposing: true,
end: event.start
})
}
expect(sent).toEqual([])
change(handlers, {
text: '中',
isComposing: true,
replacementText: '中',
start: 0,
end: 5
})
expect(sent).toEqual([])
change(handlers, {
text: '中',
isComposing: false,
replacementText: '中',
start: 0,
end: 5
})
await vi.waitFor(() => expect(sent).toEqual(['中']))
expect(captures).toEqual(['z', 'zh', 'zho', 'zhon', 'zhong', '中', '中'])
})
it('replays the recorded Gboard Backspace replacement exactly once', async () => {
const { handlers, sent } = createHarness(undefined, 'android')
change(handlers, RECORDED_ANDROID_GBOARD_BACKSPACE_TRACE[0])
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
change(handlers, RECORDED_ANDROID_GBOARD_BACKSPACE_TRACE[1])
await vi.waitFor(() => expect(sent).toEqual(['a', '\x7f']))
})
it('preserves rapid input order while transport sends are delayed', async () => {
const started: string[] = []
const delivered: string[] = []
const release: Array<() => void> = []
const { handlers } = createHarness(
async (_handle, bytes) =>
new Promise<boolean>((resolve) => {
started.push(bytes)
release.push(() => {
delivered.push(bytes)
resolve(true)
})
})
)
replay(handlers, ORDINARY_ABC_TRACE)
await vi.waitFor(() => expect(started).toEqual(['a']))
release.shift()!()
await vi.waitFor(() => expect(started).toEqual(['a', 'b']))
release.shift()!()
await vi.waitFor(() => expect(started).toEqual(['a', 'b', 'c']))
release.shift()!()
await vi.waitFor(() => expect(delivered).toEqual(['a', 'b', 'c']))
})
it('replays the recorded iOS Kana tap, flick, candidate, and cancellation trace', async () => {
const { handlers, sent } = createHarness()
change(handlers, RECORDED_IOS_KANA_TRACE[0])
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'suppress-raw'
})
replay(handlers, RECORDED_IOS_KANA_TRACE.slice(1))
await vi.waitFor(() => expect(sent).toEqual(['あ', 'き', 'カナ']))
})
it('replays the recorded iOS Japanese Romaji candidate trace', async () => {
const { handlers, sent } = createHarness()
change(handlers, {
text: IOS_ROMAJI_RECORDED_PREFIX,
isComposing: false,
replacementText: IOS_ROMAJI_RECORDED_PREFIX,
start: 0,
end: 0
})
await vi.waitFor(() => expect(sent).toEqual([IOS_ROMAJI_RECORDED_PREFIX]))
sent.length = 0
replay(handlers, RECORDED_IOS_ROMAJI_TRACE)
await vi.waitFor(() => expect(sent).toEqual(['かな']))
})
it('replays the recorded iOS #7427 transforms, confirmation, and English control', async () => {
const japanese = createHarness()
replay(japanese.handlers, RECORDED_IOS_7427_TRACE)
await vi.waitFor(() => expect(japanese.sent).toEqual(['っ', 'が', 'ぱ', 'さ', '漢字', 'かな']))
expect(japanese.sent).not.toContain('\r')
const english = createHarness()
replay(english.handlers, ORDINARY_ABC_TRACE)
await vi.waitFor(() => expect(english.sent).toEqual(['a', 'b', 'c']))
})
it('replays iOS Korean post-change transforms without normalizing text', async () => {
const korean = createHarness()
replay(korean.handlers, RECORDED_IOS_KOREAN_TRANSFORM_TRACE)
korean.handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(korean.sent.at(-1)).toBe('\r'))
const terminalText = korean.sent
.join('')
.split('')
.reduce((text, character) =>
character === '\x7f' ? Array.from(text).slice(0, -1).join('') : text + character
)
expect(terminalText).toBe('안녕하세요\r')
const english = createHarness()
replay(english.handlers, ORDINARY_ABC_TRACE)
await vi.waitFor(() => expect(english.sent).toEqual(['a', 'b', 'c']))
})
it('emits nothing for the recorded Pinyin cancellation trace', () => {
const { handlers, sent } = createHarness()
const changes = [
{ text: 'z', isComposing: true, replacementText: 'z', start: 0, end: 0 },
{ text: 'zh', isComposing: true, replacementText: 'h', start: 1, end: 1 },
{ text: 'z', isComposing: true, replacementText: '', start: 1, end: 2 },
{ text: '', isComposing: false, replacementText: '', start: 0, end: 1 }
]
for (const event of changes) {
change(handlers, event)
}
expect(sent).toEqual([])
})
it('suppresses submit and accessory controls until native composition ends', async () => {
const { handlers, sent } = createHarness()
change(handlers, {
text: 'zhong',
isComposing: true,
replacementText: 'zhong',
start: 0,
end: 0
})
handlers.handleLiveInputSubmit()
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\t' })).resolves.toEqual({
kind: 'suppress-raw'
})
expect(sent).toEqual([])
})
it('blocks sends when native replacement evidence is absent', async () => {
const { handlers, sent } = createHarness()
handlers.handleLiveInputChange({
nativeEvent: {
text: 'mutable snapshot'
} as never
})
handlers.handleLiveInputSubmit()
expect(sent).toEqual([])
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'suppress-raw'
})
})
it('blocks an incomplete command until native evidence reconciles', async () => {
const { handlers, sent } = createHarness()
change(handlers, ORDINARY_ABC_TRACE[0])
change(handlers, {
text: 'ab',
isComposing: false,
replacementText: 'b',
start: -1,
end: 1
})
handlers.handleLiveInputSubmit()
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'suppress-raw'
})
expect(sent).toEqual(['a'])
change(handlers, ORDINARY_ABC_TRACE[1])
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent).toEqual(['a', 'b', '\r']))
})
it('drops the iOS device confirmation submit and keeps the ASCII control armed', async () => {
const japanese = createHarness()
replay(japanese.handlers, RECORDED_IOS_DEVICE_FLICK_VOWELS_TRACE)
japanese.handlers.handleLiveInputSubmit()
// Why: assert the drained queue, not a transient one — a suppressed '\r' and a '\r' that has
// merely not landed yet are indistinguishable while sends are still in flight.
await japanese.handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
expect(japanese.sent).toEqual(['いうえお'])
expect(Buffer.from(japanese.sent.join(''), 'utf8').toString('hex')).toBe(
'e38184e38186e38188e3818a'
)
const english = createHarness()
replay(english.handlers, ORDINARY_ABC_TRACE)
english.handlers.handleLiveInputSubmit()
await english.handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
expect(english.sent).toEqual(['a', 'b', 'c', '\r'])
expect(Buffer.from(english.sent.join(''), 'utf8').toString('hex')).toBe('6162630d')
})
it('sends a deliberate Return taken one frame after the iOS device confirmation', async () => {
const { handlers, sent } = createHarness()
replay(handlers, RECORDED_IOS_DEVICE_FLICK_VOWELS_TRACE)
await vi.waitFor(() => expect(sent).toEqual(['いうえお']))
flushFrame()
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent).toEqual(['いうえお', '\r']))
})
it('keeps the unmarking iOS Korean keyboard submitting on the confirming Return', async () => {
const { handlers, sent } = createHarness()
replay(handlers, RECORDED_IOS_KOREAN_TRANSFORM_TRACE)
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent.at(-1)).toBe('\r'))
})
})
@@ -1,15 +1,27 @@
import { useCallback, useEffect, type RefObject } from 'react'
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import { imeOwnsSubmit, noteImeCompositionChange } from '../ime/ime-submit-carry'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
import {
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
deriveTerminalLiveCommit,
getTerminalLiveSpecialKeyDecision,
type TerminalLiveReplacement
} from './terminal-live-text-commit'
export type TerminalLiveInputChangeEvent = {
readonly nativeEvent: {
readonly text: string
readonly isComposing?: boolean
readonly replacementText?: string
readonly replacementRange?: TerminalLiveReplacement['replacementRange']
readonly target?: number
}
}
// `nativeEvent: object` so React Native's own TextInputSubmitEditingEvent stays assignable; the
// view tag it carries at runtime is not declared on React Native's shipped event data types.
export type TerminalLiveInputSubmitEvent = { readonly nativeEvent?: object }
type TerminalLiveInputKeyPressEvent = {
readonly nativeEvent: {
@@ -23,22 +35,27 @@ type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeSessionTabType: TTabType | null | undefined
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly connected: boolean
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputRef: RefObject<Pick<TextInput, 'setNativeProps'> | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly platform: string
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'allow-raw' }
| { readonly kind: 'suppress-raw' }
type TerminalLiveInputCommitHandlers = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly handleLiveInputAccessoryBytes: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly handleLiveInputChange: (text: string) => void
readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
readonly handleLiveInputSubmit: () => void
readonly handleLiveInputSubmit: (event?: TerminalLiveInputSubmitEvent) => void
}
export function useTerminalLiveInputCommit<TTabType extends string>({
@@ -50,44 +67,55 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
platform,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
const committedTextRef = useRef('')
const isComposingRef = useRef(false)
const pendingSendRef = useRef<Promise<boolean> | null>(null)
const waitForPendingSend = useCallback(
(): Promise<boolean> => pendingSendRef.current ?? Promise.resolve(true),
[]
)
const queueSend = useCallback(
(handle: string, bytes: string): Promise<boolean> => {
const previousSend = pendingSendRef.current
const send = (async () => {
if (previousSend && !(await previousSend)) {
return false
}
return sendLiveTerminalInputRef.current(handle, bytes)
})().catch(() => false)
pendingSendRef.current = send
void send.then(() => {
if (pendingSendRef.current === send) {
pendingSendRef.current = null
}
})
return send
},
[sendLiveTerminalInputRef]
)
const clearPendingLiveInputCommit = useCallback(() => {
committedTextRef.current = ''
isComposingRef.current = false
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, setLiveInputCapture])
useEffect(() => {
// Why: what reached the PTY is unknowable across an outage — stale mirror state corrupts the first post-reconnect send.
if (!connected) {
clearPendingLiveInputCommit()
}
}, [connected, clearPendingLiveInputCommit])
}, [clearPendingLiveInputCommit, connected])
useEffect(() => {
const pendingHandle = pendingLiveInputHandleRef.current
if (!pendingHandle) {
return
}
// Why: a lagging mobile tab list briefly yields no active tab object; a
// null/undefined type is "unknown", not "left the terminal" — flush guards
// still block sends if the tab truly changed.
if (
!activeHandle ||
pendingHandle !== activeHandle ||
(activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
!liveInputTerminalHandles.has(activeHandle)
) {
@@ -95,111 +123,119 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
}
}, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles])
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
const pendingHandle = pendingLiveInputHandleRef.current
if (pendingHandle && pendingHandle !== handle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
// Why: external bytes (dictation/paste) land after the field's echo on the
// PTY; the field session must fully end or later diffs would erase them.
if (pendingHandle === handle) {
return flushPendingLiveInputText(handle)
}
return waitForPendingLiveInputFlush()
},
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
)
const handleLiveInputChange = useCallback(
(text: string) => {
({ nativeEvent }: TerminalLiveInputChangeEvent) => {
noteImeCompositionChange(platform, nativeEvent.isComposing, nativeEvent.target)
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
clearPendingLiveInputCommit()
return
}
// Why: iOS kills an active dictation/IME session when JS writes a value
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(text)
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
setLiveInputCapture(nativeEvent.text)
if (nativeEvent.isComposing === true) {
isComposingRef.current = true
return
}
if (
nativeEvent.isComposing !== false ||
typeof nativeEvent.replacementText !== 'string' ||
!nativeEvent.replacementRange
) {
isComposingRef.current = true
return
}
const commit = deriveTerminalLiveCommit(committedTextRef.current, {
text: nativeEvent.text,
replacementText: nativeEvent.replacementText,
replacementRange: nativeEvent.replacementRange
})
if (!commit) {
isComposingRef.current = true
return
}
isComposingRef.current = false
committedTextRef.current = commit.committedText
if (commit.payload.length > 0) {
void queueSend(activeHandle, commit.payload)
}
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
liveInputTerminalHandles,
platform,
queueSend,
setLiveInputCapture
]
)
const handleLiveInputKeyPress = useCallback(
(event: TerminalLiveInputKeyPressEvent) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
({ nativeEvent: { key } }: TerminalLiveInputKeyPressEvent) => {
if (!activeHandle || isComposingRef.current || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
const decision = getTerminalLiveSpecialKeyDecision(key, committedTextRef.current.length > 0)
if (decision.kind === 'send') {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key,
heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
})
switch (decision.kind) {
case 'ignore':
case 'local-edit':
return
case 'send-now':
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
case 'commit-held-then-send':
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
default:
decision satisfies never
void queueSend(activeHandle, decision.bytes)
}
},
[activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles, queueSend]
)
const handleLiveInputAccessoryBytes = useCallback(
async (_input: TerminalLiveAccessoryInput): Promise<TerminalLiveAccessoryInputCommitResult> => {
if (!activeHandle) {
return { kind: 'allow-raw' }
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return (await waitForPendingSend()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
}
if (isComposingRef.current) {
return { kind: 'suppress-raw' }
}
clearPendingLiveInputCommit()
return (await waitForPendingSend()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
},
[activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles, waitForPendingSend]
)
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
if (
isComposingRef.current ||
handle !== activeHandleRef.current ||
(activeSessionTabTypeRef.current != null &&
activeSessionTabTypeRef.current !== 'terminal') ||
!liveInputTerminalHandlesRef.current.has(handle)
) {
return false
}
clearPendingLiveInputCommit()
return waitForPendingSend()
},
[
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
liveInputTerminalHandlesRef,
waitForPendingSend
]
)
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
const handleLiveInputSubmit = useCallback(() => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
)
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
const handleLiveInputSubmit = useCallback(
(event?: TerminalLiveInputSubmitEvent) => {
if (imeOwnsSubmit((event?.nativeEvent as { target?: number } | undefined)?.target)) {
return
}
if (!activeHandle || isComposingRef.current || !liveInputTerminalHandles.has(activeHandle)) {
return
}
clearPendingLiveInputCommit()
void queueSend(activeHandle, '\r')
},
[activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles, queueSend]
)
return {
clearPendingLiveInputCommit,
@@ -0,0 +1,27 @@
import { useCallback, useState } from 'react'
import type { TerminalLiveInputChangeEvent } from './use-terminal-live-input-commit'
/**
* Marking IMEs (Japanese kana, pinyin) withhold bytes until commit, so the terminal
* echo shows nothing mid-composition and the input dock is the only place a preview
* can appear. The commit hook tracks composition in a ref, which cannot drive a
* render, so mirror the native marked-text bit into state alongside it.
*
* Byte behavior is untouched: the commit hook still owns every send decision.
*/
export function useTerminalLiveInputPreedit(
handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
): {
readonly isComposing: boolean
readonly handleLiveInputChangeWithPreedit: (event: TerminalLiveInputChangeEvent) => void
} {
const [isComposing, setIsComposing] = useState(false)
const handleLiveInputChangeWithPreedit = useCallback(
(event: TerminalLiveInputChangeEvent) => {
setIsComposing(event.nativeEvent.isComposing === true)
handleLiveInputChange(event)
},
[handleLiveInputChange]
)
return { isComposing, handleLiveInputChangeWithPreedit }
}
@@ -1,192 +0,0 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
type TerminalLivePendingInputFlush = {
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
export function useTerminalLivePendingInputFlush<TTabType extends string>({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<
(handle: string, fieldText: string, commitHeld: boolean) => Promise<boolean>
>(async () => false)
const clearHeldCommitTimer = useCallback(() => {
if (heldCommitTimerRef.current) {
clearTimeout(heldCommitTimerRef.current)
heldCommitTimerRef.current = null
}
}, [])
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
const clearPendingLiveInputCommit = useCallback(() => {
resetMirrorState()
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}, [])
const sendQueuedMirrorPayload = useCallback(
(handle: string, payload: string): Promise<boolean> =>
sendLiveTerminalInputRef.current(handle, payload),
[sendLiveTerminalInputRef]
)
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
if (
handle !== activeHandleRef.current ||
(activeSessionTabTypeRef.current != null &&
activeSessionTabTypeRef.current !== 'terminal') ||
!liveInputTerminalHandlesRef.current.has(handle)
) {
// Why: a stale handle must not keep local mirror state alive — the next
// active terminal would inherit wrong erase counts. A null tab type is
// "unknown" during tab-list lag, not "left the terminal", so it must not trip.
resetMirrorState()
return false
}
const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
commitHeld
})
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
pendingLiveInputHandleRef.current =
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
clearHeldCommitTimer()
if (step.heldText.length > 0) {
heldCommitTimerRef.current = setTimeout(() => {
heldCommitTimerRef.current = null
const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current
void runMirrorStepRef.current(handle, heldField, true)
}, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
}
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length === 0) {
return waitForPendingLiveInputFlush()
}
return queueTerminalLiveMirrorSend(
pendingLiveInputFlushRef.current,
handle,
payload,
sendQueuedMirrorPayload
)
},
[
activeHandleRef,
activeSessionTabTypeRef,
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
sendQueuedMirrorPayload,
waitForPendingLiveInputFlush
]
)
runMirrorStepRef.current = runMirrorStep
const applyLiveInputMirror = useCallback(
(handle: string, fieldText: string): void => {
void runMirrorStep(handle, fieldText, false)
},
[runMirrorStep]
)
const flushPendingLiveInputText = useCallback(
async (expectedHandle: string | null): Promise<boolean> => {
const handle = pendingLiveInputHandleRef.current
if (!handle) {
return waitForPendingLiveInputFlush()
}
if (expectedHandle !== null && handle !== expectedHandle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
const heldText = heldLiveInputTextRef.current
const result =
heldText.length > 0
? await runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true)
: await waitForPendingLiveInputFlush()
// Why: an explicit flush ends the field's editing session; the echoed PTY
// text stays, so local mirror state must restart from empty.
clearPendingLiveInputCommit()
return result
},
[clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush]
)
useEffect(() => {
return () => {
if (heldCommitTimerRef.current) {
clearTimeout(heldCommitTimerRef.current)
heldCommitTimerRef.current = null
}
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}
}, [])
return {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
}
}
@@ -601,7 +601,7 @@ final class Provider {
windowIndex: Int?,
restoreWindow: Bool
) throws -> Snapshot {
guard accessibilityTrusted() else {
guard accessibilityTrustedSettled() else {
// Why: agents retry failed observations. Only the explicit setup flow
// should open macOS privacy prompts/settings; runtime calls stay quiet.
throw ProviderError.coded(
@@ -619,7 +619,7 @@ final class Provider {
allowRecovery: restoreWindow
)
let focusedTitle = stringAttribute(focused, kAXTitleAttribute as String) ?? app.name
let canCaptureScreenshot = includeScreenshot && screenCaptureTrusted()
let canCaptureScreenshot = includeScreenshot && screenCaptureTrustedSettled()
guard let capture = WindowCapture.resolve(
candidates: windowCandidates,
titleHint: focusedTitle,
@@ -1034,10 +1034,64 @@ private func accessibilityTrusted() -> Bool {
AXIsProcessTrusted()
}
private func accessibilityTrustedSettled() -> Bool {
// Fresh helper processes can receive transient TCC preflight denials before the real grant settles.
PermissionTrustSettling.settle(probe: accessibilityTrusted).settled
}
private func screenCaptureTrusted() -> Bool {
CGPreflightScreenCaptureAccess()
}
private func screenCaptureTrustedSettled() -> Bool {
PermissionTrustSettling.settleWithFallback(
finalTimeoutMs: 500,
probe: screenCaptureTrusted,
fallbackProbe: screenCaptureTrustedByCaptureProbe
)
}
private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot {
PermissionStatusSnapshotProbe.capture(
accessibilityProbe: accessibilityTrustedSettled,
screenshotsProbe: screenCaptureTrustedSettled
)
}
private func screenCaptureTrustedByCaptureProbe() -> Bool {
guard let infos = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly],
kCGNullWindowID
) as? [[String: Any]] else {
return false
}
let windows = infos.compactMap { info -> ScreenCaptureProbeWindow? in
guard let layer = info[kCGWindowLayer as String] as? Int,
let ownerPid = info[kCGWindowOwnerPID as String] as? NSNumber,
let number = info[kCGWindowNumber as String] as? NSNumber
else {
return nil
}
return ScreenCaptureProbeWindow(
layer: layer,
ownerPid: ownerPid.int32Value,
windowId: number.uint32Value
)
}
guard let windowId = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
ownPid: ProcessInfo.processInfo.processIdentifier,
windows: windows
) else {
return false
}
return CGWindowListCreateImage(
.null,
[.optionIncludingWindow],
CGWindowID(windowId),
[.boundsIgnoreFraming]
) != nil
}
private func requestScreenCaptureAccess() -> Bool {
CGRequestScreenCaptureAccess()
}
@@ -1065,6 +1119,38 @@ private func enableManualAccessibilityIfNeeded(_ appElement: AXUIElement, app: A
private func focusedWindow(appElement: AXUIElement, app: AppDescriptor, visibleWindowCount: Int, allowRecovery: Bool) throws -> AXUIElement {
let systemWide = AXUIElementCreateSystemWide()
if let window = lookupUsableWindow(systemWide: systemWide, appElement: appElement, app: app) {
return window
}
if allowRecovery {
recoverWindow(app)
if let window = lookupUsableWindow(systemWide: systemWide, appElement: appElement, app: app) {
return window
}
}
if visibleWindowCount > 0 {
var settledWindow: AXUIElement?
let outcome = PermissionTrustSettling.settle {
settledWindow = lookupUsableWindow(
systemWide: systemWide,
appElement: appElement,
app: app
)
return settledWindow != nil
}
if let window = settledWindow, outcome.settled {
return window
}
throw ProviderError.coded("permission_denied", "app '\(app.name)' has visible windows but no accessibility window (AX reads stayed blocked for \(outcome.waitedMs)ms after retries). macOS Accessibility may need Orca Computer Use toggled off and on again in System Settings.")
}
throw ProviderError.coded("window_not_found", "app '\(app.name)' has no accessibility window; make sure the app has a visible window, then retry with --restore-window.")
}
private func lookupUsableWindow(
systemWide: AXUIElement,
appElement: AXUIElement,
app: AppDescriptor
) -> AXUIElement? {
if let window = focusedSystemWindow(systemWide: systemWide, app: app) {
return window
}
@@ -1072,31 +1158,9 @@ private func focusedWindow(appElement: AXUIElement, app: AppDescriptor, visibleW
return window
}
if let windows = copyArray(appElement, kAXWindowsAttribute as String) {
if let window = windows.first(where: usableWindow) {
return window
}
return windows.first(where: usableWindow)
}
if allowRecovery {
recoverWindow(app)
if let window = focusedSystemWindow(systemWide: systemWide, app: app) {
return window
}
if let window = copyElement(appElement, kAXFocusedWindowAttribute as String), usableWindow(window) {
return window
}
if let windows = copyArray(appElement, kAXWindowsAttribute as String) {
if let window = windows.first(where: usableWindow) {
return window
}
}
}
let permissionHint = visibleWindowCount > 0
? " The app has visible windows, so macOS Accessibility may need Orca Computer Use toggled off and on again in System Settings."
: ""
if visibleWindowCount > 0 {
throw ProviderError.coded("permission_denied", "app '\(app.name)' has visible windows but no accessibility window.\(permissionHint)")
}
throw ProviderError.coded("window_not_found", "app '\(app.name)' has no accessibility window; make sure the app has a visible window, then retry with --restore-window.")
return nil
}
private func focusedSystemWindow(systemWide: AXUIElement, app: AppDescriptor) -> AXUIElement? {
@@ -2874,6 +2938,7 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate {
windowController?.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
windowController?.refreshPermissions()
}
func applicationDidBecomeActive(_ notification: Notification) {
@@ -2890,6 +2955,7 @@ private final class PermissionWindowController: NSWindowController {
private var dragAssistantPermission: PermissionKind?
private let initialPermission: PermissionKind?
private let terminateWhenDragAssistantCloses: Bool
private var permissionStatusRefresh: PermissionStatusRefreshCoordinator?
convenience init(initialPermission: PermissionKind? = nil, terminateWhenDragAssistantCloses: Bool = false) {
let window = NSWindow(
@@ -2920,6 +2986,14 @@ private final class PermissionWindowController: NSWindowController {
self.initialPermission = initialPermission
self.terminateWhenDragAssistantCloses = terminateWhenDragAssistantCloses
super.init(window: window)
permissionStatusRefresh = PermissionStatusRefreshCoordinator(
probe: permissionStatusSnapshotSettled,
handler: { [weak self] snapshot in
Task { @MainActor in
self?.applyPermissionStatus(snapshot)
}
}
)
}
required init?(coder: NSCoder) {
@@ -2976,21 +3050,25 @@ private final class PermissionWindowController: NSWindowController {
}
func refreshPermissions() {
if let initialPermission, initialPermission.isGranted {
permissionStatusRefresh?.refresh()
}
private func applyPermissionStatus(_ snapshot: PermissionStatusSnapshot) {
if let initialPermission, initialPermission.isGranted(in: snapshot) {
// Why: targeted permission helpers should finish once the requested
// grant lands, even if other Computer Use permissions remain unset.
completeDragAssistant()
return
}
if dragAssistantPermission?.isGranted == true {
if dragAssistantPermission?.isGranted(in: snapshot) == true {
// Why: after one grant in full setup, the remaining missing permission
// needs fresh guidance instead of the old assistant's instructions.
closeDragAssistant()
}
if PermissionKind.allCases.allSatisfy(\.isGranted) {
if PermissionKind.allCases.allSatisfy({ $0.isGranted(in: snapshot) }) {
closeDragAssistant()
}
(window?.contentView as? PermissionView)?.refreshPermissions()
(window?.contentView as? PermissionView)?.refreshPermissions(snapshot)
}
}
@@ -3045,12 +3123,12 @@ private enum PermissionKind: CaseIterable {
}
}
var isGranted: Bool {
func isGranted(in snapshot: PermissionStatusSnapshot) -> Bool {
switch self {
case .accessibility:
accessibilityTrusted()
snapshot.accessibilityGranted
case .screenshots:
screenCaptureTrusted()
snapshot.screenshotsGranted
}
}
@@ -3071,6 +3149,7 @@ private final class PermissionView: NSView {
private let close: () -> Void
private var contentStack: NSStackView?
private var contentConstraints: [NSLayoutConstraint] = []
private var permissionStatus: PermissionStatusSnapshot?
init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void, close: @escaping () -> Void) {
self.showDragAssistant = showDragAssistant
@@ -3107,12 +3186,22 @@ private final class PermissionView: NSView {
icon.heightAnchor.constraint(equalToConstant: 58)
])
let missingPermissions = PermissionKind.allCases.filter { !$0.isGranted }
let ready = missingPermissions.isEmpty
let missingPermissions = permissionStatus.map { snapshot in
PermissionKind.allCases.filter { !$0.isGranted(in: snapshot) }
} ?? []
let checking = permissionStatus == nil
let ready = !checking && missingPermissions.isEmpty
let title = label(ready ? "Computer Use is Ready" : "Enable Orca Computer Use", size: 22, weight: .bold)
let titleText = checking
? "Checking Computer Use"
: (ready ? "Computer Use is Ready" : "Enable Orca Computer Use")
let title = label(titleText, size: 22, weight: .bold)
let subtitle = label(
ready ? "Orca can use local apps when you ask." : "Grant permissions so Orca can use apps when you ask.",
checking
? "Checking Accessibility and Screenshots."
: (ready
? "Orca can use local apps when you ask."
: "Grant permissions so Orca can use apps when you ask."),
size: 12,
weight: .regular
)
@@ -3129,7 +3218,16 @@ private final class PermissionView: NSView {
header.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -10).isActive = true
if ready {
if checking {
let progress = NSProgressIndicator()
progress.style = .spinning
progress.controlSize = .small
progress.translatesAutoresizingMaskIntoConstraints = false
progress.startAnimation(nil)
stack.addArrangedSubview(progress)
progress.setContentHuggingPriority(.required, for: .horizontal)
progress.centerXAnchor.constraint(equalTo: stack.centerXAnchor).isActive = true
} else if ready {
stack.addArrangedSubview(doneButton())
} else {
for permission in missingPermissions {
@@ -3149,8 +3247,9 @@ private final class PermissionView: NSView {
NSLayoutConstraint.activate(contentConstraints)
}
func refreshPermissions() {
// Why: TCC grants can change in System Settings while this window stays open.
func refreshPermissions(_ snapshot: PermissionStatusSnapshot) {
guard permissionStatus != snapshot else { return }
permissionStatus = snapshot
build()
}
@@ -4039,14 +4138,16 @@ private func runPermissionCheck(initialPermission: PermissionKind? = nil) {
}
private func printPermissionStatus() {
let accessibility = accessibilityTrusted() ? "granted" : "not-granted"
let screenshots = screenCaptureTrusted() ? "granted" : "not-granted"
let snapshot = permissionStatusSnapshotSettled()
let accessibility = snapshot.accessibilityGranted ? "granted" : "not-granted"
let screenshots = snapshot.screenshotsGranted ? "granted" : "not-granted"
print(#"{"accessibility":"\#(accessibility)","screenshots":"\#(screenshots)"}"#)
}
private func writePermissionStatus(to path: String) {
let accessibility = accessibilityTrusted() ? "granted" : "not-granted"
let screenshots = screenCaptureTrusted() ? "granted" : "not-granted"
let snapshot = permissionStatusSnapshotSettled()
let accessibility = snapshot.accessibilityGranted ? "granted" : "not-granted"
let screenshots = snapshot.screenshotsGranted ? "granted" : "not-granted"
let text = #"{"accessibility":"\#(accessibility)","screenshots":"\#(screenshots)"}"#
do {
try text.write(toFile: path, atomically: true, encoding: .utf8)
@@ -0,0 +1,116 @@
import Dispatch
import Foundation
public struct PermissionStatusSnapshot: Equatable, Sendable {
public let accessibilityGranted: Bool
public let screenshotsGranted: Bool
public init(accessibilityGranted: Bool, screenshotsGranted: Bool) {
self.accessibilityGranted = accessibilityGranted
self.screenshotsGranted = screenshotsGranted
}
}
public enum PermissionStatusSnapshotProbe {
public typealias Probe = @Sendable () -> Bool
private static let probeQueue = DispatchQueue(
label: "com.stablyai.orca.computer-use-permission-status",
attributes: .concurrent
)
public static func capture(
accessibilityProbe: @escaping Probe,
screenshotsProbe: @escaping Probe
) -> PermissionStatusSnapshot {
let result = PermissionStatusSnapshotResult()
let group = DispatchGroup()
group.enter()
probeQueue.async {
result.recordAccessibility(accessibilityProbe())
group.leave()
}
group.enter()
probeQueue.async {
result.recordScreenshots(screenshotsProbe())
group.leave()
}
group.wait()
return result.snapshot
}
}
public final class PermissionStatusRefreshCoordinator: @unchecked Sendable {
public typealias SnapshotProbe = @Sendable () -> PermissionStatusSnapshot
public typealias SnapshotHandler = @Sendable (PermissionStatusSnapshot) -> Void
private let callbackQueue: DispatchQueue
private let handler: SnapshotHandler
private let probe: SnapshotProbe
private let stateLock = NSLock()
private let workerQueue: DispatchQueue
private var refreshInFlight = false
public init(
workerQueue: DispatchQueue = DispatchQueue(
label: "com.stablyai.orca.computer-use-permission-refresh",
qos: .userInitiated
),
callbackQueue: DispatchQueue = .main,
probe: @escaping SnapshotProbe,
handler: @escaping SnapshotHandler
) {
self.workerQueue = workerQueue
self.callbackQueue = callbackQueue
self.probe = probe
self.handler = handler
}
public func refresh() {
stateLock.lock()
guard !refreshInFlight else {
stateLock.unlock()
return
}
refreshInFlight = true
stateLock.unlock()
workerQueue.async { [self] in
let snapshot = probe()
callbackQueue.async { [self] in
stateLock.lock()
refreshInFlight = false
stateLock.unlock()
handler(snapshot)
}
}
}
}
private final class PermissionStatusSnapshotResult: @unchecked Sendable {
private let lock = NSLock()
private var accessibilityGranted = false
private var screenshotsGranted = false
var snapshot: PermissionStatusSnapshot {
lock.lock()
defer { lock.unlock() }
return PermissionStatusSnapshot(
accessibilityGranted: accessibilityGranted,
screenshotsGranted: screenshotsGranted
)
}
func recordAccessibility(_ granted: Bool) {
lock.lock()
accessibilityGranted = granted
lock.unlock()
}
func recordScreenshots(_ granted: Bool) {
lock.lock()
screenshotsGranted = granted
lock.unlock()
}
}
@@ -0,0 +1,73 @@
import Foundation
public enum PermissionTrustSettling {
public struct Outcome: Equatable {
public let settled: Bool
public let attempts: Int
public let waitedMs: Int
public init(settled: Bool, attempts: Int, waitedMs: Int) {
self.settled = settled
self.attempts = attempts
self.waitedMs = waitedMs
}
}
public static let defaultTimeoutMs = 1_500
public static let defaultIntervalMs = 100
public static func settle(
timeoutMs: Int = defaultTimeoutMs,
intervalMs: Int = defaultIntervalMs,
sleepMs: (Int) -> Void = { interval in
Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000)
},
probe: () -> Bool
) -> Outcome {
precondition(timeoutMs >= 0, "timeoutMs must not be negative")
precondition(intervalMs > 0, "intervalMs must be positive")
var attempts = 0
var waitedMs = 0
while true {
attempts += 1
if probe() {
return Outcome(settled: true, attempts: attempts, waitedMs: waitedMs)
}
if waitedMs >= timeoutMs {
return Outcome(settled: false, attempts: attempts, waitedMs: waitedMs)
}
let interval = min(intervalMs, timeoutMs - waitedMs)
sleepMs(interval)
waitedMs += interval
}
}
public static func settleWithFallback(
initialTimeoutMs: Int = defaultTimeoutMs,
finalTimeoutMs: Int,
intervalMs: Int = defaultIntervalMs,
sleepMs: (Int) -> Void = { interval in
Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000)
},
probe: () -> Bool,
fallbackProbe: () -> Bool
) -> Bool {
if settle(
timeoutMs: initialTimeoutMs,
intervalMs: intervalMs,
sleepMs: sleepMs,
probe: probe
).settled {
return true
}
if fallbackProbe() {
return true
}
return settle(
timeoutMs: finalTimeoutMs,
intervalMs: intervalMs,
sleepMs: sleepMs,
probe: probe
).settled
}
}
@@ -0,0 +1,22 @@
public struct ScreenCaptureProbeWindow: Equatable {
public let layer: Int
public let ownerPid: Int32
public let windowId: UInt32
public init(layer: Int, ownerPid: Int32, windowId: UInt32) {
self.layer = layer
self.ownerPid = ownerPid
self.windowId = windowId
}
}
public enum ScreenCaptureProbeWindowSelection {
public static func firstCrossProcessNormalWindow(
ownPid: Int32,
windows: [ScreenCaptureProbeWindow]
) -> UInt32? {
windows.first { window in
window.layer == 0 && window.ownerPid != ownPid
}?.windowId
}
}
@@ -0,0 +1,126 @@
import Dispatch
@testable import OrcaComputerUseMacOSCore
import XCTest
final class PermissionStatusSnapshotTests: XCTestCase {
func testCapturesBothPermissionResults() {
let snapshot = PermissionStatusSnapshotProbe.capture(
accessibilityProbe: { true },
screenshotsProbe: { false }
)
XCTAssertEqual(
snapshot,
PermissionStatusSnapshot(accessibilityGranted: true, screenshotsGranted: false)
)
}
func testRunsPermissionProbesConcurrently() {
let accessibilityStarted = DispatchSemaphore(value: 0)
let screenshotsStarted = DispatchSemaphore(value: 0)
let releaseProbes = DispatchSemaphore(value: 0)
let finished = expectation(description: "permission snapshot")
DispatchQueue.global().async {
_ = PermissionStatusSnapshotProbe.capture(
accessibilityProbe: {
accessibilityStarted.signal()
releaseProbes.wait()
return true
},
screenshotsProbe: {
screenshotsStarted.signal()
releaseProbes.wait()
return true
}
)
finished.fulfill()
}
XCTAssertEqual(accessibilityStarted.wait(timeout: .now() + 1), .success)
XCTAssertEqual(screenshotsStarted.wait(timeout: .now() + 1), .success)
releaseProbes.signal()
releaseProbes.signal()
wait(for: [finished], timeout: 1)
}
func testCoalescesRefreshesWhileProbeIsRunning() {
let callbackQueue = DispatchQueue(label: "permission-status-test-callback")
let probeStarted = DispatchSemaphore(value: 0)
let releaseProbe = DispatchSemaphore(value: 0)
let handled = expectation(description: "snapshot handled")
let probeCount = LockedCounter()
let coordinator = PermissionStatusRefreshCoordinator(
callbackQueue: callbackQueue,
probe: {
probeCount.increment()
probeStarted.signal()
releaseProbe.wait()
return PermissionStatusSnapshot(
accessibilityGranted: true,
screenshotsGranted: true
)
},
handler: { _ in handled.fulfill() }
)
coordinator.refresh()
XCTAssertEqual(probeStarted.wait(timeout: .now() + 1), .success)
coordinator.refresh()
coordinator.refresh()
releaseProbe.signal()
wait(for: [handled], timeout: 1)
XCTAssertEqual(probeCount.value, 1)
}
func testAllowsRefreshAfterPreviousSnapshotWasHandled() {
let callbackQueue = DispatchQueue(label: "permission-status-test-callback")
let firstHandled = expectation(description: "first snapshot handled")
let secondHandled = expectation(description: "second snapshot handled")
let probeCount = LockedCounter()
let coordinator = PermissionStatusRefreshCoordinator(
callbackQueue: callbackQueue,
probe: {
let count = probeCount.increment()
return PermissionStatusSnapshot(
accessibilityGranted: count == 2,
screenshotsGranted: false
)
},
handler: { snapshot in
if snapshot.accessibilityGranted {
secondHandled.fulfill()
} else {
firstHandled.fulfill()
}
}
)
coordinator.refresh()
wait(for: [firstHandled], timeout: 1)
coordinator.refresh()
wait(for: [secondHandled], timeout: 1)
XCTAssertEqual(probeCount.value, 2)
}
}
private final class LockedCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
var value: Int {
lock.lock()
defer { lock.unlock() }
return count
}
@discardableResult
func increment() -> Int {
lock.lock()
defer { lock.unlock() }
count += 1
return count
}
}
@@ -0,0 +1,59 @@
import Testing
@testable import OrcaComputerUseMacOSCore
@Suite("PermissionTrustSettling")
struct PermissionTrustSettlingTests {
@Test("already-trusted probe returns immediately")
func immediateSuccess() {
var sleeps: [Int] = []
let outcome = PermissionTrustSettling.settle(
sleepMs: { sleeps.append($0) },
probe: { true }
)
#expect(outcome == .init(settled: true, attempts: 1, waitedMs: 0))
#expect(sleeps.isEmpty)
}
@Test("transient denial settles after retries")
func lateGrant() {
var calls = 0
var sleeps: [Int] = []
let outcome = PermissionTrustSettling.settle(
sleepMs: { sleeps.append($0) },
probe: {
calls += 1
return calls >= 4
}
)
#expect(outcome == .init(settled: true, attempts: 4, waitedMs: 300))
#expect(sleeps == [100, 100, 100])
}
@Test("persistent denial stops at the timeout")
func neverGranted() {
let outcome = PermissionTrustSettling.settle(
timeoutMs: 1_000,
intervalMs: 300,
sleepMs: { _ in },
probe: { false }
)
#expect(outcome == .init(settled: false, attempts: 5, waitedMs: 1_000))
}
@Test("final interval is clamped to the timeout")
func clampsFinalInterval() {
var sleeps: [Int] = []
let outcome = PermissionTrustSettling.settle(
timeoutMs: 250,
intervalMs: 100,
sleepMs: { sleeps.append($0) },
probe: { false }
)
#expect(sleeps == [100, 100, 50])
#expect(outcome == .init(settled: false, attempts: 4, waitedMs: 250))
}
}
@@ -0,0 +1,79 @@
import Testing
@testable import OrcaComputerUseMacOSCore
@Suite("Screen Capture permission settling")
struct ScreenCapturePermissionSettlingTests {
@Test("initial preflight success skips fallback")
func initialSuccess() {
var fallbackCalls = 0
let trusted = PermissionTrustSettling.settleWithFallback(
finalTimeoutMs: 500,
sleepMs: { _ in },
probe: { true },
fallbackProbe: {
fallbackCalls += 1
return true
}
)
#expect(trusted)
#expect(fallbackCalls == 0)
}
@Test("capture fallback can establish trust")
func fallbackSuccess() {
var fallbackCalls = 0
let trusted = PermissionTrustSettling.settleWithFallback(
initialTimeoutMs: 200,
finalTimeoutMs: 200,
intervalMs: 100,
sleepMs: { _ in },
probe: { false },
fallbackProbe: {
fallbackCalls += 1
return true
}
)
#expect(trusted)
#expect(fallbackCalls == 1)
}
@Test("preflight is retried after fallback failure")
func finalPreflightSuccess() {
var probeCalls = 0
let trusted = PermissionTrustSettling.settleWithFallback(
initialTimeoutMs: 100,
finalTimeoutMs: 200,
intervalMs: 100,
sleepMs: { _ in },
probe: {
probeCalls += 1
return probeCalls == 4
},
fallbackProbe: { false }
)
#expect(trusted)
#expect(probeCalls == 4)
}
@Test("persistent denial remains denied")
func persistentDenial() {
var fallbackCalls = 0
let trusted = PermissionTrustSettling.settleWithFallback(
initialTimeoutMs: 100,
finalTimeoutMs: 100,
intervalMs: 100,
sleepMs: { _ in },
probe: { false },
fallbackProbe: {
fallbackCalls += 1
return false
}
)
#expect(!trusted)
#expect(fallbackCalls == 1)
}
}
@@ -0,0 +1,44 @@
import Testing
@testable import OrcaComputerUseMacOSCore
@Suite("Screen capture probe window selection")
struct ScreenCaptureProbeWindowSelectionTests {
@Test("own process windows cannot prove cross-app capture permission")
func excludesOwnWindows() {
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
ownPid: 42,
windows: [
.init(layer: 0, ownerPid: 42, windowId: 1),
.init(layer: 0, ownerPid: 84, windowId: 2)
]
)
#expect(selected == 2)
}
@Test("non-normal windows are skipped")
func excludesNonNormalWindows() {
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
ownPid: 42,
windows: [
.init(layer: 1, ownerPid: 84, windowId: 1),
.init(layer: 0, ownerPid: 84, windowId: 2)
]
)
#expect(selected == 2)
}
@Test("no cross-process normal window returns nil")
func noCandidate() {
let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow(
ownPid: 42,
windows: [
.init(layer: 0, ownerPid: 42, windowId: 1),
.init(layer: 1, ownerPid: 84, windowId: 2)
]
)
#expect(selected == nil)
}
}
+6 -6
View File
@@ -103,12 +103,13 @@
"win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-linux-ime-e2e.mjs",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs",
"bench:startup": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/startup-time-bench.mjs",
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/daemon-coldstart-bench.mjs",
"bench:wsl-hook-relay-reattach": "pnpm run build:relay && node config/scripts/wsl-hook-relay-reattach-benchmark.mjs",
"bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs",
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs",
"bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs",
@@ -194,7 +195,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.4.12",
"dompurify": "^3.4.13",
"electron": "^43.1.0",
"electron-builder": "^26.15.3",
"electron-builder-squirrel-windows": "^26.15.3",
@@ -210,13 +211,13 @@
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"marked": "^17.0.1",
"mermaid": "^11.16.0",
"mermaid": "^11.16.1",
"monaco-editor": "^0.55.1",
"oxfmt": "^0.52.0",
"oxlint": "^1.75.0",
"oxlint-plugin-react-doctor": "0.9.1",
"oxlint-tsgolint": "7.0.2001",
"pdfjs-dist": "^5.7.284",
"pdfjs-dist": "^6.2.108",
"pngjs": "^7.0.0",
"radix-ui": "^1.6.2",
"react": "^19.2.7",
@@ -273,8 +274,7 @@
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
"pnpm": {
"overrides": {
"@modelcontextprotocol/sdk>@hono/node-server": "2.0.10",
"monaco-editor>dompurify": "3.4.12"
"monaco-editor>dompurify": "3.4.13"
},
"supportedArchitectures": {
"os": [
+164 -165
View File
@@ -5,8 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@modelcontextprotocol/sdk>@hono/node-server': 2.0.10
monaco-editor>dompurify: 3.4.12
monaco-editor>dompurify: 3.4.13
patchedDependencies:
'@xterm/addon-ligatures@0.11.0-beta.287':
@@ -19,7 +18,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8
hash: 4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -46,7 +45,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -209,25 +208,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
version: 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -238,8 +237,8 @@ importers:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
dompurify:
specifier: ^3.4.12
version: 3.4.12
specifier: ^3.4.13
version: 3.4.13
electron:
specifier: ^43.1.0
version: 43.1.0
@@ -286,8 +285,8 @@ importers:
specifier: ^17.0.1
version: 17.0.6
mermaid:
specifier: ^11.16.0
version: 11.16.0
specifier: ^11.16.1
version: 11.16.1
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
@@ -304,8 +303,8 @@ importers:
specifier: 7.0.2001
version: 7.0.2001
pdfjs-dist:
specifier: ^5.7.284
version: 5.7.284
specifier: ^6.2.108
version: 6.2.108
pngjs:
specifier: ^7.0.0
version: 7.0.0
@@ -866,8 +865,8 @@ packages:
peerDependencies:
graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
'@hono/node-server@2.0.10':
resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==}
'@hono/node-server@2.1.0':
resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==}
engines: {node: '>=20'}
peerDependencies:
hono: ^4
@@ -1047,8 +1046,8 @@ packages:
'@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
'@modelcontextprotocol/sdk@1.30.0':
resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
@@ -1071,79 +1070,79 @@ packages:
resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}
engines: {node: '>=18'}
'@napi-rs/canvas-android-arm64@0.1.100':
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
'@napi-rs/canvas-android-arm64@1.0.3':
resolution: {integrity: sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.100':
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
'@napi-rs/canvas-darwin-arm64@1.0.3':
resolution: {integrity: sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.100':
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
'@napi-rs/canvas-darwin-x64@1.0.3':
resolution: {integrity: sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.3':
resolution: {integrity: sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
'@napi-rs/canvas-linux-arm64-gnu@1.0.3':
resolution: {integrity: sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
'@napi-rs/canvas-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
'@napi-rs/canvas-linux-riscv64-gnu@1.0.3':
resolution: {integrity: sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
'@napi-rs/canvas-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.100':
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
'@napi-rs/canvas-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
'@napi-rs/canvas-win32-arm64-msvc@1.0.3':
resolution: {integrity: sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
'@napi-rs/canvas-win32-x64-msvc@1.0.3':
resolution: {integrity: sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.100':
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
'@napi-rs/canvas@1.0.3':
resolution: {integrity: sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.1.6':
@@ -3564,15 +3563,15 @@ packages:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
brace-expansion@1.1.16:
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@2.1.2:
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -4120,8 +4119,8 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
@@ -4381,8 +4380,8 @@ packages:
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-uri@3.1.4:
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
@@ -4639,8 +4638,8 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hono@4.12.31:
resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==}
hono@4.13.0:
resolution: {integrity: sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==}
engines: {node: '>=16.9.0'}
hosted-git-info@4.1.0:
@@ -4764,8 +4763,8 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ip-address@10.2.0:
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
ip-address@10.4.0:
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
@@ -4903,8 +4902,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsesc@3.1.0:
@@ -5214,8 +5213,8 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
mermaid@11.16.0:
resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==}
mermaid@11.16.1:
resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -5408,8 +5407,8 @@ packages:
nan@2.26.2:
resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
nanoid@3.3.17:
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -5639,8 +5638,8 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfjs-dist@5.7.284:
resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==}
pdfjs-dist@6.2.108:
resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==}
engines: {node: '>=22.13.0 || >=24'}
pe-library@0.4.1:
@@ -5701,8 +5700,8 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
postcss@8.5.14:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
postcss@8.5.25:
resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14}
posthog-node@5.33.3:
@@ -6402,8 +6401,8 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tar@7.5.20:
resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==}
tar@7.5.22:
resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==}
engines: {node: '>=18'}
temp-file@3.4.0:
@@ -6441,11 +6440,11 @@ packages:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tldts-core@7.4.9:
resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==}
tldts-core@7.4.10:
resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
tldts@7.4.9:
resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==}
tldts@7.4.10:
resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
hasBin: true
tmp-promise@3.0.3:
@@ -6533,12 +6532,12 @@ packages:
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
undici@6.27.0:
resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==}
undici@6.28.0:
resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==}
engines: {node: '>=18.17'}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
unicorn-magic@0.3.0:
@@ -7176,7 +7175,7 @@ snapshots:
semver: 7.8.1
sumchecker: 3.0.1
optionalDependencies:
undici: 7.28.0
undici: 7.29.0
transitivePeerDependencies:
- supports-color
@@ -7348,9 +7347,9 @@ snapshots:
dependencies:
graphql: 16.14.2
'@hono/node-server@2.0.10(hono@4.12.31)':
'@hono/node-server@2.1.0(hono@4.13.0)':
dependencies:
hono: 4.12.31
hono: 4.13.0
'@iconify/types@2.0.0': {}
@@ -7525,9 +7524,9 @@ snapshots:
dependencies:
'@chevrotain/types': 11.1.2
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
'@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 2.0.10(hono@4.12.31)
'@hono/node-server': 2.1.0(hono@4.13.0)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
@@ -7537,7 +7536,7 @@ snapshots:
eventsource-parser: 3.0.8
express: 5.2.1
express-rate-limit: 8.5.2(express@5.2.1)
hono: 4.12.31
hono: 4.13.0
jose: 6.2.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -7568,52 +7567,52 @@ snapshots:
strict-event-emitter: 0.5.1
optional: true
'@napi-rs/canvas-android-arm64@0.1.100':
'@napi-rs/canvas-android-arm64@1.0.3':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.100':
'@napi-rs/canvas-darwin-arm64@1.0.3':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.100':
'@napi-rs/canvas-darwin-x64@1.0.3':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.3':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
'@napi-rs/canvas-linux-arm64-gnu@1.0.3':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
'@napi-rs/canvas-linux-arm64-musl@1.0.3':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
'@napi-rs/canvas-linux-riscv64-gnu@1.0.3':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
'@napi-rs/canvas-linux-x64-gnu@1.0.3':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.100':
'@napi-rs/canvas-linux-x64-musl@1.0.3':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
'@napi-rs/canvas-win32-arm64-msvc@1.0.3':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
'@napi-rs/canvas-win32-x64-msvc@1.0.3':
optional: true
'@napi-rs/canvas@0.1.100':
'@napi-rs/canvas@1.0.3':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.100
'@napi-rs/canvas-darwin-arm64': 0.1.100
'@napi-rs/canvas-darwin-x64': 0.1.100
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-musl': 0.1.100
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
'@napi-rs/canvas-android-arm64': 1.0.3
'@napi-rs/canvas-darwin-arm64': 1.0.3
'@napi-rs/canvas-darwin-x64': 1.0.3
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.3
'@napi-rs/canvas-linux-arm64-gnu': 1.0.3
'@napi-rs/canvas-linux-arm64-musl': 1.0.3
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.3
'@napi-rs/canvas-linux-x64-gnu': 1.0.3
'@napi-rs/canvas-linux-x64-musl': 1.0.3
'@napi-rs/canvas-win32-arm64-msvc': 1.0.3
'@napi-rs/canvas-win32-x64-msvc': 1.0.3
optional: true
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
@@ -9592,39 +9591,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)': {}
abbrev@4.0.0: {}
@@ -9646,7 +9645,7 @@ snapshots:
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.4
fast-uri: 3.1.5
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -9697,7 +9696,7 @@ snapshots:
hosted-git-info: 4.1.0
isbinaryfile: 5.0.7
jiti: 2.7.0
js-yaml: 4.3.0
js-yaml: 4.3.1
json5: 2.2.3
lazy-val: 1.0.5
minimatch: 10.2.5
@@ -9706,7 +9705,7 @@ snapshots:
proper-lockfile: 4.1.2
resedit: 1.7.2
semver: 7.7.4
tar: 7.5.20
tar: 7.5.22
temp-file: 3.4.0
tiny-async-pool: 1.3.0
unzipper: 0.12.5
@@ -9787,16 +9786,16 @@ snapshots:
boolean@3.2.0:
optional: true
brace-expansion@1.1.16:
brace-expansion@1.1.18:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.1.2:
brace-expansion@2.1.4:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.7:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -9834,7 +9833,7 @@ snapshots:
fs-extra: 10.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
js-yaml: 4.3.0
js-yaml: 4.3.1
sanitize-filename: 1.6.4
source-map-support: 0.5.21
stat-mode: 1.0.0
@@ -10032,7 +10031,7 @@ snapshots:
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.3.0
js-yaml: 4.3.1
parse-json: 5.2.0
optionalDependencies:
typescript: 7.0.2
@@ -10322,7 +10321,7 @@ snapshots:
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
builder-util: 26.15.3
fs-extra: 10.1.0
js-yaml: 4.3.0
js-yaml: 4.3.1
transitivePeerDependencies:
- electron-builder-squirrel-windows
- supports-color
@@ -10331,7 +10330,7 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dompurify@3.4.12:
dompurify@3.4.13:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -10411,7 +10410,7 @@ snapshots:
dependencies:
builder-util-runtime: 9.7.0
fs-extra: 10.1.0
js-yaml: 4.3.0
js-yaml: 4.3.1
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
@@ -10623,7 +10622,7 @@ snapshots:
express-rate-limit@8.5.2(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.2.0
ip-address: 10.4.0
express@5.2.1:
dependencies:
@@ -10678,7 +10677,7 @@ snapshots:
dependencies:
fast-string-truncated-width: 3.0.3
fast-uri@3.1.4: {}
fast-uri@3.1.5: {}
fast-wrap-ansi@0.2.2:
dependencies:
@@ -11044,7 +11043,7 @@ snapshots:
highlight.js@11.11.1: {}
hono@4.12.31: {}
hono@4.13.0: {}
hosted-git-info@4.1.0:
dependencies:
@@ -11181,7 +11180,7 @@ snapshots:
internmap@2.0.3: {}
ip-address@10.2.0: {}
ip-address@10.4.0: {}
ipaddr.js@1.9.1: {}
@@ -11271,7 +11270,7 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -11657,7 +11656,7 @@ snapshots:
merge2@1.4.1: {}
mermaid@11.16.0:
mermaid@11.16.1:
dependencies:
'@braintree/sanitize-url': 7.1.2
'@iconify/utils': 3.1.1
@@ -11671,7 +11670,7 @@ snapshots:
d3-sankey: 0.12.3
dagre-d3-es: 7.0.14
dayjs: 1.11.20
dompurify: 3.4.12
dompurify: 3.4.13
es-toolkit: 1.46.1
katex: 0.16.45
khroma: 2.1.0
@@ -11920,19 +11919,19 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.7
brace-expansion: 5.0.9
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.16
brace-expansion: 1.1.18
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.2
brace-expansion: 2.1.4
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.2
brace-expansion: 2.1.4
minimist@1.2.8: {}
@@ -11955,7 +11954,7 @@ snapshots:
monaco-editor@0.55.1:
dependencies:
dompurify: 3.4.12
dompurify: 3.4.13
marked: 14.0.0
ms@2.1.3: {}
@@ -11991,7 +11990,7 @@ snapshots:
nan@2.26.2:
optional: true
nanoid@3.3.12: {}
nanoid@3.3.17: {}
negotiator@1.0.0: {}
@@ -12016,9 +12015,9 @@ snapshots:
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.1
tar: 7.5.20
tar: 7.5.22
tinyglobby: 0.2.16
undici: 6.27.0
undici: 6.28.0
which: 6.0.1
node-int64@0.4.0: {}
@@ -12274,9 +12273,9 @@ snapshots:
pathe@2.0.3: {}
pdfjs-dist@5.7.284:
pdfjs-dist@6.2.108:
optionalDependencies:
'@napi-rs/canvas': 0.1.100
'@napi-rs/canvas': 1.0.3
pe-library@0.4.1: {}
@@ -12333,9 +12332,9 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss@8.5.14:
postcss@8.5.25:
dependencies:
nanoid: 3.3.12
nanoid: 3.3.17
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -12822,7 +12821,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.14
postcss: 8.5.25
rolldown: 1.0.0-beta.53(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
tinyglobby: 0.2.16
optionalDependencies:
@@ -12958,7 +12957,7 @@ snapshots:
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@dotenvx/dotenvx': 1.66.0
'@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76)
'@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.2
commander: 14.0.3
@@ -12973,7 +12972,7 @@ snapshots:
kleur: 4.1.5
open: 11.0.0
ora: 8.2.0
postcss: 8.5.14
postcss: 8.5.25
postcss-selector-parser: 7.1.1
prompts: 2.4.2
recast: 0.23.11
@@ -12981,7 +12980,7 @@ snapshots:
tailwind-merge: 3.5.0
ts-morph: 26.0.0
tsconfig-paths: 4.2.0
undici: 7.28.0
undici: 7.29.0
validate-npm-package-name: 7.0.2
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
@@ -13191,7 +13190,7 @@ snapshots:
tapable@2.3.3: {}
tar@7.5.20:
tar@7.5.22:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
@@ -13230,12 +13229,12 @@ snapshots:
tinyrainbow@3.1.0: {}
tldts-core@7.4.9:
tldts-core@7.4.10:
optional: true
tldts@7.4.9:
tldts@7.4.10:
dependencies:
tldts-core: 7.4.9
tldts-core: 7.4.10
optional: true
tmp-promise@3.0.3:
@@ -13252,7 +13251,7 @@ snapshots:
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.9
tldts: 7.4.10
optional: true
trim-lines@3.0.1: {}
@@ -13331,9 +13330,9 @@ snapshots:
undici-types@7.24.6: {}
undici@6.27.0: {}
undici@6.28.0: {}
undici@7.28.0: {}
undici@7.29.0: {}
unicorn-magic@0.3.0: {}
+7 -7
View File
@@ -40,18 +40,18 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"releaseRevision": 35,
"packageDigest": "72f63dcaad3dea8bea54838835fe46c689c37b63cd96a9ef2f2537f0c2f6b8c8",
"gitTreeSha": "97f6596bb43a56c6e81246f077c35144c9025343",
"releaseRevision": 36,
"packageDigest": "6eaa0624f45402646d87d9a559f2b3dbbc0efd1d5e56cdfde219671ad62d320e",
"gitTreeSha": "a5a90c9731fbee834eca6d655b39eaa795dd2323",
"files": [
{
"path": "SKILL.md",
"size": 3835,
"size": 3913,
"executable": false,
"classification": "text",
"exactSha256": "90228630bc4daab4ccf67b691153976d1f28ec064d7069a4c170621ed2ea99b5",
"textNormalizedSha256": "90228630bc4daab4ccf67b691153976d1f28ec064d7069a4c170621ed2ea99b5",
"identitySha256": "90228630bc4daab4ccf67b691153976d1f28ec064d7069a4c170621ed2ea99b5"
"exactSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"textNormalizedSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
}
]
},
+16
View File
@@ -561,6 +561,22 @@
"identitySha256": "90228630bc4daab4ccf67b691153976d1f28ec064d7069a4c170621ed2ea99b5"
}
]
},
{
"releaseRevision": 36,
"packageDigest": "6eaa0624f45402646d87d9a559f2b3dbbc0efd1d5e56cdfde219671ad62d320e",
"gitTreeSha": "a5a90c9731fbee834eca6d655b39eaa795dd2323",
"files": [
{
"path": "SKILL.md",
"size": 3913,
"executable": false,
"classification": "text",
"exactSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"textNormalizedSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
}
]
}
],
"orchestration": [
+37 -5
View File
@@ -2,12 +2,13 @@
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, worktree comments, and the browser embedded
inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
terminals, repos, automations, artifacts, worktree comments, and the browser
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
"give this to another agent", "another worktree", "Orca browser", or
"control the browser inside Orca". Prefer this over raw `git worktree`, ad hoc
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
"share HTML/Markdown", "public artifact link", or "control the browser inside
Orca". Prefer this over raw `git worktree`, ad hoc
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
embedded browser.
@@ -225,6 +226,37 @@ Schedules accept `hourly`, `daily`, `weekdays`, `weekly`, 5-field cron, or RRULE
Use `--repo <selector>` for a new worktree per run, or `--workspace <selector>` / `--workspace-mode existing` for an existing Orca worktree. `--repo` and `--workspace` are mutually exclusive. Use `--reuse-session` only for existing-workspace automations; if the previous terminal is gone, Orca falls back to a fresh session. Prefer `--disabled` while testing setup.
## Artifacts
Artifacts publish HTML or Markdown files through the signed-in Orca account. The public
share URL is viewable without signing in; creating, listing, updating, and deleting
artifacts require the active Orca profile to be signed in.
```text
ORCA artifacts share <file> --json
ORCA artifacts update <file> --json
ORCA artifacts unshare <file> --json
ORCA artifacts list [--cursor <cursor>] --json
ORCA artifacts delete <id> --json
```
- `share`, `update`, and `unshare` accept `.html`, `.htm`, `.md`, and `.markdown` files.
- `share` saves the returned edit token in the active Orca profile and never includes it
in CLI output. `update` and `unshare` look up that record by the resolved local file
path, so use the same path and Orca profile that originally shared the file.
- `list` returns one page of artifacts owned by the signed-in account. If JSON output has
`nextCursor`, pass it back with `--cursor <cursor>`. `delete <id>` deletes an account-owned
artifact by the id returned from `list`; it does not need the original local file or its
edit-token record.
- Relative HTML assets are not uploaded. Share a self-contained HTML file or use absolute
asset URLs.
- If an upload exceeds the CLI transport limit, use the browser upload page as directed
by the error.
- For local or staging development, `--api-url <url>` overrides the artifact service;
`ORCA_ARTIFACTS_API_URL` provides the same override for the session.
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
## Built-In Browser
The built-in browser is Orca's embedded browser tab surface, scoped to Orca worktrees; it is not Chrome/Safari or desktop app UI.
@@ -296,7 +328,7 @@ Common recoveries:
## Next Action
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, or built-in browser `snapshot`.
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, or built-in browser `snapshot`.
## Mobile Emulator (iOS Simulator via serve-sim)
+5 -4
View File
@@ -2,12 +2,13 @@
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, worktree comments, and the browser embedded
inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
terminals, repos, automations, artifacts, worktree comments, and the browser
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
"give this to another agent", "another worktree", "Orca browser", or
"control the browser inside Orca". Prefer this over raw `git worktree`, ad hoc
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
"share HTML/Markdown", "public artifact link", or "control the browser inside
Orca". Prefer this over raw `git worktree`, ad hoc
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
embedded browser.
+2
View File
@@ -154,6 +154,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
if (
[
'account',
'artifacts',
'automations',
'project',
'repo',
@@ -201,6 +202,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
(commandPath.length === 1 &&
[
'account',
'artifacts',
'automations',
'project',
'repo',
+22
View File
@@ -0,0 +1,22 @@
import type { ArtifactListItem, ArtifactListPage } from '../shared/artifacts'
export function formatArtifactList(artifacts: readonly ArtifactListItem[]): string {
if (artifacts.length === 0) {
return 'No shared artifacts.'
}
return artifacts
.map(({ artifact, shareUrl }) => {
const name = artifact.title || artifact.originalFileName || artifact.slug
return `${name}\n id: ${artifact.slug}\n updated: ${artifact.updatedAt}\n url: ${shareUrl}`
})
.join('\n\n')
}
export function formatArtifactListPage(page: ArtifactListPage): string {
const rows = formatArtifactList(page.artifacts)
return page.nextCursor ? `${rows}\nMore artifacts: --cursor ${page.nextCursor}` : rows
}
export function formatArtifactShared(item: ArtifactListItem): string {
return item.shareUrl
}
File diff suppressed because one or more lines are too long
+11
View File
@@ -22,6 +22,17 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['account add', 'account list'],
load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS
},
{
name: 'artifacts',
keys: [
'artifacts list',
'artifacts share',
'artifacts update',
'artifacts unshare',
'artifacts delete'
],
load: async () => (await import('./handlers/artifacts.js')).ARTIFACT_HANDLERS
},
{
name: 'automations',
keys: [
+134
View File
@@ -0,0 +1,134 @@
import { mkdtemp, open, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ArtifactListItem } from '../../shared/artifacts'
import { ARTIFACT_HANDLERS } from './artifacts'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
const item: ArtifactListItem = {
artifact: {
version: 1,
slug: 'artifact-1',
title: null,
originalFileName: 'report.html',
sourceContentType: 'text/html',
renderedContentType: 'text/html',
createdAt: '2026-08-06T00:00:00.000Z',
updatedAt: '2026-08-06T00:00:00.000Z',
expiresAt: '2026-09-06T00:00:00.000Z',
byteSize: 12,
deletedAt: null
},
shareUrl: 'https://share.onorca.dev/a/artifact-1'
}
afterEach(() => vi.restoreAllMocks())
describe('artifact CLI handlers', () => {
it('reads a relative HTML file and sends sanitized content to the runtime', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
await writeFile(join(cwd, 'report.html'), '<h1>Hi</h1>', 'utf8')
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: { status: 'ok', value: item },
_meta: { runtimeId: 'runtime-1' }
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await ARTIFACT_HANDLERS['artifacts share']!({
client: { call } as never,
cwd,
flags: new Map([['file', 'report.html']]),
json: false
})
expect(call).toHaveBeenCalledWith(
'artifacts.share',
expect.objectContaining({
sourceKey: join(cwd, 'report.html'),
content: '<h1>Hi</h1>',
contentType: 'text/html',
fileName: 'report.html'
})
)
expect(log).toHaveBeenCalledWith(item.shareUrl)
})
it('rejects unsupported file extensions before calling the runtime', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
await writeFile(join(cwd, 'report.txt'), 'hello', 'utf8')
const call = vi.fn()
await expect(
ARTIFACT_HANDLERS['artifacts share']!({
client: { call } as never,
cwd,
flags: new Map([['file', 'report.txt']]),
json: false
})
).rejects.toThrow(/HTML or Markdown/)
expect(call).not.toHaveBeenCalled()
})
it('rejects a sparse oversized file before attempting the RPC', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'orca-artifact-cli-'))
const handle = await open(join(cwd, 'oversized.html'), 'w')
await handle.truncate(ARTIFACT_CLI_MAX_RPC_BYTES + 1)
await handle.close()
const call = vi.fn()
await expect(
ARTIFACT_HANDLERS['artifacts share']!({
client: { call } as never,
cwd,
flags: new Map([['file', 'oversized.html']]),
json: false
})
).rejects.toThrow(/too large/)
expect(call).not.toHaveBeenCalled()
})
it('passes an opaque list cursor through and prints the next cursor', async () => {
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: {
status: 'ok',
value: { artifacts: [item], nextCursor: 'next opaque page' }
},
_meta: { runtimeId: 'runtime-1' }
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await ARTIFACT_HANDLERS['artifacts list']!({
client: { call } as never,
cwd: '/repo',
flags: new Map([['cursor', 'current opaque page']]),
json: false
})
expect(call).toHaveBeenCalledWith('artifacts.list', { cursor: 'current opaque page' })
expect(log).toHaveBeenCalledWith(
expect.stringContaining('More artifacts: --cursor next opaque page')
)
})
it.each(['environment', 'pairing-code'])(
'rejects explicit remote selector --%s',
async (flag) => {
const call = vi.fn()
await expect(
ARTIFACT_HANDLERS['artifacts list']!({
client: { call } as never,
cwd: '/repo',
flags: new Map([[flag, 'remote-host']]),
json: false
})
).rejects.toThrow(/does not retarget artifact commands/)
expect(call).not.toHaveBeenCalled()
}
)
})
+187
View File
@@ -0,0 +1,187 @@
import { basename, extname, resolve } from 'node:path'
import type {
ArtifactCloudOperation,
ArtifactCloudOptions,
ArtifactListPage,
ArtifactListItem,
ArtifactWriteRequest
} from '../../shared/artifacts'
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
import {
parseRemoteArtifactInput,
REMOTE_ARTIFACT_INPUT_ENV
} from '../../shared/artifact-cli-bridge'
import { readArtifactFileWithinLimit } from '../../shared/artifact-file-read'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { RuntimeClientError } from '../runtime-client'
import { formatArtifactListPage, formatArtifactShared } from '../artifact-format'
import { printResult } from '../format'
function stringFlag(ctx: HandlerContext, name: string): string | undefined {
const value = ctx.flags.get(name)
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function requireStringFlag(ctx: HandlerContext, name: string): string {
const value = stringFlag(ctx, name)
if (!value) {
throw new RuntimeClientError('invalid_argument', `Missing required ${name}.`)
}
return value
}
function cloudOptions(ctx: HandlerContext): ArtifactCloudOptions {
const apiUrl = stringFlag(ctx, 'api-url') ?? process.env.ORCA_ARTIFACTS_API_URL?.trim()
const authToken = process.env.ORCA_CLOUD_AUTH_TOKEN?.trim()
return {
...(apiUrl ? { apiUrl } : {}),
...(authToken ? { authToken } : {})
}
}
function rejectRemoteSelectionFlags(ctx: HandlerContext): void {
for (const flag of ['environment', 'pairing-code']) {
if (ctx.flags.has(flag)) {
throw new RuntimeClientError(
'invalid_argument',
`\`--${flag}\` does not retarget artifact commands; artifacts use the signed-in desktop account.`
)
}
}
}
function artifactContentType(path: string): ArtifactWriteRequest['contentType'] | null {
const extension = extname(path).toLowerCase()
return ['.html', '.htm'].includes(extension)
? 'text/html'
: ['.md', '.markdown'].includes(extension)
? 'text/markdown'
: null
}
async function readStdinWithinLimit(maxBytes: number): Promise<string> {
const chunks: Buffer[] = []
let bytes = 0
for await (const chunk of process.stdin) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
bytes += buffer.length
if (bytes > maxBytes) {
throw new RuntimeClientError(
'invalid_argument',
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
}
chunks.push(buffer)
}
return Buffer.concat(chunks).toString('utf8')
}
async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRequest> {
const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV])
const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file'))
const contentType = remoteInput?.contentType ?? artifactContentType(sourceKey)
if (!contentType) {
throw new RuntimeClientError('invalid_argument', 'Artifacts must be HTML or Markdown files.')
}
const localRead = remoteInput
? null
: await readArtifactFileWithinLimit(sourceKey, ARTIFACT_CLI_MAX_RPC_BYTES)
if (localRead?.status === 'not-file') {
throw new RuntimeClientError(
'invalid_argument',
'Artifact file was not found or is not a file.'
)
}
if (localRead?.status === 'too-large') {
throw new RuntimeClientError(
'invalid_argument',
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
}
const content = remoteInput
? await readStdinWithinLimit(ARTIFACT_CLI_MAX_RPC_BYTES)
: localRead?.status === 'ok'
? localRead.content
: ''
if (!content) {
throw new RuntimeClientError('invalid_argument', 'Artifact file is empty.')
}
const request: ArtifactWriteRequest = {
sourceKey,
content,
contentType,
fileName: remoteInput?.fileName ?? basename(sourceKey),
...cloudOptions(ctx)
}
if (Buffer.byteLength(JSON.stringify(request), 'utf8') > ARTIFACT_CLI_MAX_RPC_BYTES) {
throw new RuntimeClientError(
'invalid_argument',
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
}
return request
}
function requireOperation<T>(operation: ArtifactCloudOperation<T>): T {
if (operation.status === 'ok') {
return operation.value
}
if (operation.status === 'reconnect-required') {
throw new RuntimeClientError('authentication_required', 'Sign in to Orca and try again.')
}
throw new RuntimeClientError('authentication_unconfigured', operation.message)
}
export const ARTIFACT_HANDLERS: Record<string, CommandHandler> = {
'artifacts list': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const cursor = stringFlag(ctx, 'cursor')
const response = await ctx.client.call<ArtifactCloudOperation<ArtifactListPage>>(
'artifacts.list',
{
...cloudOptions(ctx),
...(cursor ? { cursor } : {})
}
)
const value = requireOperation(response.result)
printResult({ ...response, result: value }, ctx.json, formatArtifactListPage)
},
'artifacts share': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const response = await ctx.client.call<ArtifactCloudOperation<ArtifactListItem>>(
'artifacts.share',
await readArtifactRequest(ctx)
)
const value = requireOperation(response.result)
printResult({ ...response, result: value }, ctx.json, formatArtifactShared)
},
'artifacts update': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const response = await ctx.client.call<ArtifactCloudOperation<ArtifactListItem>>(
'artifacts.update',
await readArtifactRequest(ctx)
)
const value = requireOperation(response.result)
printResult({ ...response, result: value }, ctx.json, formatArtifactShared)
},
'artifacts unshare': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV])
const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file'))
const response = await ctx.client.call<ArtifactCloudOperation<void>>('artifacts.unshare', {
sourceKey,
...cloudOptions(ctx)
})
requireOperation(response.result)
printResult({ ...response, result: { deleted: true } }, ctx.json, () => 'Artifact deleted.')
},
'artifacts delete': async (ctx) => {
rejectRemoteSelectionFlags(ctx)
const response = await ctx.client.call<ArtifactCloudOperation<void>>('artifacts.delete', {
id: requireStringFlag(ctx, 'id'),
...cloudOptions(ctx)
})
requireOperation(response.result)
printResult({ ...response, result: { deleted: true } }, ctx.json, () => 'Artifact deleted.')
}
}
+3
View File
@@ -448,6 +448,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'linear list-issues' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous list-issues page'
}
if (command === 'artifacts list' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous artifacts page'
}
if (command === 'orchestration worker-read' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous worker-read page'
}
+23 -1
View File
@@ -41,7 +41,7 @@ vi.mock('./runtime-client', async () => {
remotePairingCode?: string | null,
environmentSelector?: string | null
) {
runtimeClientConstructorMock()
runtimeClientConstructorMock(remotePairingCode, environmentSelector)
const effectivePairingCode =
remotePairingCode === undefined
? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
@@ -212,6 +212,28 @@ describe('command aliases dispatch to the canonical handler', () => {
})
})
describe('artifact runtime routing', () => {
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
process.exitCode = 0
})
it('uses the desktop runtime despite remote-selection environment fallbacks', async () => {
vi.stubEnv('ORCA_ENVIRONMENT', 'remote-environment')
vi.stubEnv('ORCA_PAIRING_CODE', 'remote-pairing-code')
vi.spyOn(console, 'log').mockImplementation(() => undefined)
callMock.mockResolvedValue(okFixture('artifact-list', { status: 'ok', value: [] }))
runtimeClientConstructorMock.mockClear()
await main(['artifacts', 'list', '--json'], '/folder-workspace')
expect(process.exitCode).not.toBe(1)
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null)
expect(callMock).toHaveBeenCalledWith('artifacts.list', {})
})
})
describe('unknown command surfaces a suggestion', () => {
let errorSpy: ReturnType<typeof vi.spyOn>
+1
View File
@@ -22,6 +22,7 @@ const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
return (
commandPath[0] === 'account' ||
commandPath[0] === 'artifacts' ||
commandPath[0] === 'environment' ||
commandPath[0] === 'serve' ||
commandPath[0] === 'agent' ||
+45
View File
@@ -0,0 +1,45 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
const CLOUD_FLAGS = ['api-url']
export const ARTIFACT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['artifacts', 'share'],
summary: 'Share an HTML or Markdown file with your Orca account',
usage: 'orca artifacts share <file> [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'file'],
positionalArgs: ['file'],
examples: ['orca artifacts share ./report.html', 'orca artifacts share ./notes.md --json']
},
{
path: ['artifacts', 'update'],
summary: 'Update a file previously shared from this Orca profile',
usage: 'orca artifacts update <file> [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'file'],
positionalArgs: ['file']
},
{
path: ['artifacts', 'unshare'],
destructive: true,
summary: 'Delete the artifact associated with a previously shared file',
usage: 'orca artifacts unshare <file> [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'file'],
positionalArgs: ['file']
},
{
path: ['artifacts', 'list'],
summary: 'List artifacts owned by the signed-in Orca account',
usage: 'orca artifacts list [--cursor <cursor>] [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'cursor']
},
{
path: ['artifacts', 'delete'],
aliases: [['artifacts', 'rm']],
destructive: true,
summary: 'Delete an artifact owned by the signed-in Orca account',
usage: 'orca artifacts delete <id> [--api-url <url>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'id'],
positionalArgs: ['id']
}
]
+2
View File
@@ -16,9 +16,11 @@ import { INTROSPECTION_COMMAND_SPECS } from './introspection'
import { LINEAR_COMMAND_SPECS } from './linear'
import { VM_COMMAND_SPECS } from './vm'
import { SKILL_COMMAND_SPECS } from './skills'
import { ARTIFACT_COMMAND_SPECS } from './artifacts'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...ARTIFACT_COMMAND_SPECS,
...ACCOUNT_COMMAND_SPECS,
...PROJECT_COMMAND_SPECS,
...FILE_COMMAND_SPECS,
@@ -0,0 +1,72 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
const PANE = makePaneKey('external-live', '11111111-1111-4111-8111-111111111111')
const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-live' }
function seedLocalIdentity(server: AgentHookServer): void {
server.ingestRemote(
{
paneKey: PANE,
providerSession: PROVIDER_SESSION,
payload: { state: 'working', prompt: 'test', agentType: 'gemini' }
},
'seed-connection'
)
server.ingestTerminalStatus({
paneKey: PANE,
connectionId: null,
payload: { state: 'working', prompt: 'test', agentType: 'gemini' }
})
}
describe('AgentHookServer AI Vault liveness identity', () => {
it('retains provider identity after a live row is dismissed', () => {
const server = new AgentHookServer()
seedLocalIdentity(server)
server.dropStatusEntry(PANE)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
connectionId: null,
providerSession: PROVIDER_SESSION,
providerSessionOnly: true
})
])
expect(server.getStatusChangeSnapshot()).toEqual([])
})
it('hydrates dismissed provider identity without reviving visible status', async () => {
const userDataPath = await mkdtemp(join(tmpdir(), 'orca-ai-vault-liveness-'))
const first = new AgentHookServer()
try {
await first.start({ env: 'production', userDataPath })
seedLocalIdentity(first)
first.dropStatusEntry(PANE)
first.flushStatusPersistSync()
first.stop()
const second = new AgentHookServer()
await second.start({ env: 'production', userDataPath })
try {
expect(second.getStatusSnapshot()).toEqual([
expect.objectContaining({
providerSession: PROVIDER_SESSION,
providerSessionOnly: true,
restoredUnconfirmed: true
})
])
expect(second.getStatusChangeSnapshot()).toEqual([])
} finally {
second.stop()
}
} finally {
first.stop()
await rm(userDataPath, { recursive: true, force: true })
}
})
})
+28 -4
View File
@@ -92,6 +92,8 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
stateStartedAt: number
/** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */
restoredUnconfirmed?: true
/** User-hidden resume identity retained solely for destructive liveness checks. */
retainedForLiveness?: true
}
type NormalizedLocalHook = {
@@ -234,7 +236,7 @@ function dropHydratedIdleClaudeSubagents(
}
}
// Why: the sole gate for keeping a providerSessionOnly row; shared so hydrate and relay-ingest can't drift.
// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead.
function isValidPiProviderSessionOnly(
providerSession: AgentProviderSessionMetadata | undefined,
agentType: AgentType | undefined
@@ -297,7 +299,15 @@ function sanitizeHydratedEntry(
}
const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined
const providerSessionOnly = record.providerSessionOnly === true
if (providerSessionOnly && !isValidPiProviderSessionOnly(providerSession, payload.agentType)) {
const retainedForLiveness = record.retainedForLiveness === true
const validRetainedIdentity = Boolean(
retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown'
)
if (
providerSessionOnly &&
!isValidPiProviderSessionOnly(providerSession, payload.agentType) &&
!validRetainedIdentity
) {
return null
}
const source = isAgentHookSource(record.source) ? record.source : undefined
@@ -322,6 +332,7 @@ function sanitizeHydratedEntry(
toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined,
providerSession,
providerSessionOnly: providerSessionOnly ? true : undefined,
retainedForLiveness: retainedForLiveness ? true : undefined,
payload,
receivedAt,
stateStartedAt
@@ -1144,7 +1155,7 @@ export class AgentHookServer {
this.connectionTimestampWatermarkById.set(payload.connectionId, now)
}
if (payload.providerSessionOnly) {
// Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status.
// Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status.
onAccepted?.()
const enriched = this.attachStatusTiming(payload, now)
this.clearAssistantMessageRetry(enriched.paneKey)
@@ -2229,9 +2240,22 @@ export class AgentHookServer {
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
dropStatusEntry(paneKey: string): void {
if (!this.deleteStatusEntry(paneKey, { preserveAuthority: true })) {
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
if (!deleted) {
return
}
if (
deleted.providerSession &&
deleted.payload.agentType &&
deleted.payload.agentType !== 'unknown'
) {
const retained: EnrichedAgentHookEventPayload = {
...deleted,
providerSessionOnly: true,
retainedForLiveness: true
}
this.state.lastStatusByPaneKey.set(deleted.paneKey, retained)
}
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureWslHookRelayForReattach } from './wsl-hook-relay-reattach'
describe('ensureWslHookRelayForReattach', () => {
it('refreshes the surviving session distro after a local reattach', () => {
const ensure = vi.fn()
ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Ubuntu-24.04' }, null, ensure)
expect(ensure).toHaveBeenCalledOnce()
expect(ensure).toHaveBeenCalledWith('Ubuntu-24.04')
})
it.each([
['fresh WSL spawn', { wslDistro: 'Ubuntu' }, null],
['native reattach', { isReattach: true, wslDistro: null }, null],
['legacy reattach without ownership context', { isReattach: true }, null],
['blank distro', { isReattach: true, wslDistro: ' ' }, null],
['SSH reattach', { isReattach: true, wslDistro: 'Ubuntu' }, 'ssh-1']
])('does not refresh a %s', (_label, result, connectionId) => {
const ensure = vi.fn()
ensureWslHookRelayForReattach(result, connectionId, ensure)
expect(ensure).not.toHaveBeenCalled()
})
it('preserves exact distro ownership across multiple local reattachments', () => {
const ensure = vi.fn()
ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Ubuntu' }, null, ensure)
ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Debian' }, null, ensure)
expect(ensure.mock.calls).toEqual([['Ubuntu'], ['Debian']])
})
})
@@ -0,0 +1,20 @@
import type { PtySpawnResult } from '../providers/pty-spawn-result'
import { wslHookRelayManager } from './wsl-hook-relay-manager'
type ReattachResult = Pick<PtySpawnResult, 'isReattach' | 'wslDistro'>
export function ensureWslHookRelayForReattach(
result: ReattachResult,
connectionId?: string | null,
ensureForDistro: (distro: string) => void = (distro) =>
wslHookRelayManager.ensureForDistro(distro)
): void {
// Why: the current renderer preference can differ from the surviving PTY's proven distro ownership.
if (connectionId || result.isReattach !== true || typeof result.wslDistro !== 'string') {
return
}
const distro = result.wslDistro.trim()
if (distro) {
ensureForDistro(distro)
}
}

Some files were not shown because too many files have changed in this diff Show More