Fix mobile terminal query reply authority

This commit is contained in:
Neil
2026-07-11 01:53:03 -07:00
parent 20ebe858af
commit d3d20b6ca2
22 changed files with 1129 additions and 50 deletions
+144
View File
@@ -2736,6 +2736,150 @@
],
"demotionRule": "Cannot promote if success is based only on absence of visible artifacts."
},
{
"id": "terminal-query.mobile-view-authority",
"title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "mobile-runtime-rpc-contract",
"surfaces": [
"mobile terminal WebView",
"terminal query authority",
"runtime RPC",
"mobile subscription replay",
"multi-mobile input floor"
],
"platforms": [
"macos",
"linux",
"windows",
"mobile"
],
"providers": [
"local",
"daemon",
"ssh",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [],
"coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8128",
"https://github.com/stablyai/orca/pull/8206"
],
"invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.",
"oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.",
"commands": [
"pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"testFiles": [
"mobile/src/terminal/terminal-webview-query-reply.test.ts",
"mobile/src/terminal/terminal-webview-query-reply-routing.test.ts",
"mobile/src/terminal/mobile-terminal-query-reply.test.ts",
"src/main/runtime/rpc/terminal-send.test.ts",
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/mobile-presence-lock.test.ts",
"src/shared/terminal-query-reply.test.ts",
"src/shared/terminal-reply-query-scan.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"assertionRefs": [
{
"file": "mobile/src/terminal/terminal-webview-query-reply.test.ts",
"assertions": [
"snapshot replay emits no terminal-data message before the replay boundary",
"a live query after the boundary emits exactly one terminal-data message",
"a superseded terminal generation cannot regain reply authority",
"xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert"
]
},
{
"file": "src/main/runtime/rpc/terminal-send.test.ts",
"assertions": [
"only the elected mobile subscriber can write a claimed query reply",
"valid replies skip mobileTookFloor",
"ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected"
]
},
{
"file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"assertions": [
"live terminal data and view authority register before asynchronous mobile fit",
"a query received during that await is retained and emitted after the snapshot"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"desktop xterm onData replies are dropped while mobile owns the terminal driver",
"desktop capability-handler replies outside onData honor the same mobile authority lock"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
"result": "passed",
"durationSeconds": 1,
"summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed."
},
{
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
"result": "passed",
"durationSeconds": 4,
"summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes."
},
{
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"result": "passed",
"durationSeconds": 11,
"summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "mobile and runtime deterministic unit gate"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet."
},
"performanceBudget": {
"required": true,
"evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue."
},
"promotionCriteria": [
"Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.",
"Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.",
"Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.",
"Accumulate the normal soak window with zero unexplained flakes."
],
"knownGaps": [
"No live iOS or Android WebView run is registered.",
"No real SSH, WSL, daemon, or two-device provider-contract run is registered.",
"New mobile talking to an older desktop host writes the reply but the old host strips inputKind and may treat it as floor-taking input."
],
"demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use."
},
{
"id": "terminal-runtime.mobile-stream-budget",
"title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded",
+13 -1
View File
@@ -96,6 +96,17 @@ ingestion and an async write; the decision must not be re-read at reply time):
stream (CLI `terminal.read`, automation observers) do not suppress — they
also do not answer; that bounded no-reply case matches today's behavior.
Mobile streams preserve that singularity across snapshot startup and multiple
views. The mobile WebView suppresses `onData` during snapshot replay, then
enables replies at an explicit live-output boundary. If a live query arrived
while the initial snapshot was being built and its output sequence is covered
by that snapshot, runtime RPC re-emits only the bounded query sequence after
the snapshot; ordinary covered output stays deduplicated. `terminal.send`
accepts the resulting `inputKind: query-reply` only from the earliest active
mobile subscriber while mobile owns the terminal driver. Peer phones are
rejected, the next subscriber is promoted on unsubscribe, and desktop parser
and capability handlers stay silent until desktop retakes the driver.
Everything the emulator emits outside a forwarding window is discarded, which
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
triggered by option mutations).
@@ -237,7 +248,8 @@ any spawn-window drops).
the documented ConPTY DA1 variant and the view-attribute parser handlers
the headless core cannot serve.
6. Remote views keep view authority; main yields whenever a remote view
subscriber is attached.
subscriber is attached. When multiple desktop/mobile views coexist, the
terminal driver and server-side mobile election keep one reply writer.
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
replaced by:
@@ -112,6 +112,7 @@ import {
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import {
getTerminalCommandKeyboardType,
@@ -3531,6 +3532,17 @@ export default function SessionScreen() {
[allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput]
)
const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => {
void sendMobileTerminalQueryReply({
bytes,
client: clientRef.current,
clientId: deviceTokenRef.current,
connected: connStateRef.current === 'connected',
handle,
subscribedTerminals: terminalUnsubsRef.current
})
}, [])
async function handleClearTerminal(target: Terminal) {
if (!client) {
return
@@ -4800,6 +4812,7 @@ export default function SessionScreen() {
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
onHaptic={handleHaptic}
onTerminalInput={handleTerminalInput}
onTerminalQueryReply={handleTerminalQueryReply}
onTerminalTap={handleTerminalTap}
onFileTap={handleFileTap}
onOpenUrl={handleTerminalOpenUrl}
+3
View File
@@ -23,6 +23,7 @@ type TerminalPaneViewProps = {
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput: (handle: string, bytes: string) => void
onTerminalQueryReply: (handle: string, bytes: string) => void
onTerminalTap: (handle: string) => void
onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void
onOpenUrl: (handle: string, url: string) => void
@@ -44,6 +45,7 @@ export function TerminalPaneView({
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
@@ -80,6 +82,7 @@ export function TerminalPaneView({
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
onHaptic={onHaptic}
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)}
onTerminalTap={() => onTerminalTap(handle)}
onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)}
onOpenUrl={(url) => onOpenUrl(handle, url)}
+5 -2
View File
@@ -1,7 +1,6 @@
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { Platform, View } from 'react-native'
import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import {
@@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
type Props = TerminalWebViewProps
@@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
@@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
} catch {
return
}
routeTerminalQueryReply(msg, onTerminalQueryReply)
if (msg.type === 'web-ready') {
confirmWebReady(true)
@@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply'
function createClient() {
return {
sendRequest: vi.fn().mockResolvedValue({
id: '1',
ok: true,
result: { send: { accepted: true, bytesWritten: 6 } },
_meta: { runtimeId: 'runtime-1' }
})
} as unknown as Pick<RpcClient, 'sendRequest'>
}
describe('sendMobileTerminalQueryReply', () => {
it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => {
const client = createClient()
await expect(
sendMobileTerminalQueryReply({
bytes: '\x1b[3;4R',
client,
clientId: 'mobile-1',
connected: true,
handle: 'terminal-1',
subscribedTerminals: new Set(['terminal-1'])
})
).resolves.toBe(true)
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
enter: false,
inputKind: 'query-reply',
client: { id: 'mobile-1', type: 'mobile' }
})
})
it.each([
['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'],
['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'],
['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'],
['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1']
])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => {
const client = createClient()
await expect(
sendMobileTerminalQueryReply({
bytes,
client,
clientId: 'mobile-1',
connected,
handle,
subscribedTerminals
})
).resolves.toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,41 @@
import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
import type { RpcClient } from '../transport/rpc-client'
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
type TerminalSubscriptionRegistry = {
has: (handle: string) => boolean
}
type MobileTerminalQueryReplyOptions = {
bytes: string
client: Pick<RpcClient, 'sendRequest'> | null
clientId: string | null
connected: boolean
handle: string
subscribedTerminals: TerminalSubscriptionRegistry
}
export function sendMobileTerminalQueryReply({
bytes,
client,
clientId,
connected,
handle,
subscribedTerminals
}: MobileTerminalQueryReplyOptions): Promise<boolean> {
// Why: every subscribed mobile xterm suppresses main's responder, including
// hidden panes, so ownership follows the subscription rather than focus.
if (!client || !connected || !subscribedTerminals.has(handle) || !isTerminalQueryReply(bytes)) {
return Promise.resolve(false)
}
return client
.sendRequest('terminal.send', {
terminal: handle,
text: bytes,
enter: false,
inputKind: 'query-reply',
...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {})
})
.then(isTerminalSendRpcAccepted, () => false)
}
@@ -28,6 +28,7 @@ export type TerminalSelectionEvents = {
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalQueryReply?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
+14 -2
View File
@@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
@@ -215,7 +216,7 @@ window.onerror = function(msg) {
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
var statusDotPendingSelector = false;
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
@@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS}
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
@@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS}
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
@@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
terminalGeneration++;
var gen = terminalGeneration;
// Why: snapshot replay can contain old queries whose replies must never
// re-enter the live PTY. Each replacement terminal earns authority anew.
resetTerminalDataReplyAuthority();
cancelWebglContextRecovery();
webglAddon = null;
ready = false;
@@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
fontWeight: '300',
fontWeightBold: '500',
scrollback: 5000,
disableStdin: true,
// Why: xterm suppresses parser-generated query replies when disableStdin
// is true. Native accepts only validated reply grammars from onData.
disableStdin: false,
cursorBlink: false,
cursorStyle: 'bar',
cursorInactiveStyle: 'none',
@@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
resetEvictionCounter();
cancelSelect();
attachTermObservers();
attachTerminalQueryReplyBridge(term, gen);
requestAnimationFrame(function() {
if (gen !== terminalGeneration) return;
@@ -0,0 +1,40 @@
// Kept as one injectable unit so tests execute the same replay/generation gate
// that the WebView document runs, rather than a TypeScript reimplementation.
export const TERMINAL_QUERY_REPLY_JS = `
var terminalDataRepliesEnabled = false;
function resetTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = false;
}
function forwardTerminalDataReply(data) {
if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data });
}
function enqueueTerminalDataReplyBoundary(gen) {
enqueueWriteBoundary(function() {
if (gen === terminalGeneration) terminalDataRepliesEnabled = true;
});
}
function attachTerminalQueryReplyBridge(term, gen) {
// Why: parser replies require stdin enabled, but mobile input is owned by
// native controls. Keep xterm's textarea inert for touch/hardware keys.
try {
term.attachCustomKeyEventHandler(function() { return false; });
if (term.textarea) {
term.textarea.readOnly = true;
term.textarea.tabIndex = -1;
term.textarea.setAttribute('inputmode', 'none');
}
} catch (e) {}
try {
termObserverDisposables.push(term.onData(function(data) {
forwardTerminalDataReply(data);
}));
} catch (e) {}
// Why: live output can queue before initial replay finishes. Enable replies
// at the replay boundary so those live queries are answered, never replayed ones.
enqueueTerminalDataReplyBoundary(gen);
}
`
@@ -0,0 +1,69 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TerminalWebView } from './TerminalWebView'
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
StyleSheet: {
absoluteFillObject: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0
},
create: (styles: unknown) => styles
},
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-webview', async () => {
const React = await import('react')
const WebView = React.forwardRef((props: Record<string, unknown>, _ref) =>
React.createElement('WebView', props)
)
return { WebView, default: WebView }
})
vi.mock('lucide-react-native', () => ({
RefreshCw: 'RefreshCw'
}))
let renderer: ReactTestRenderer | null = null
function postWebViewMessage(payload: Record<string, unknown>): void {
if (!renderer) {
throw new Error('TerminalWebView did not render')
}
const webView = renderer.root.findByType('WebView')
act(() => {
webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
})
}
describe('TerminalWebView query reply routing', () => {
afterEach(() => {
if (renderer) {
act(() => renderer?.unmount())
renderer = null
}
vi.restoreAllMocks()
})
it('routes only complete terminal query replies to native', () => {
const onTerminalQueryReply = vi.fn()
vi.spyOn(console, 'error').mockImplementation(() => {})
act(() => {
renderer = create(createElement(TerminalWebView, { onTerminalQueryReply }))
})
postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' })
postWebViewMessage({ type: 'terminal-data', bytes: 'a' })
postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' })
expect(onTerminalQueryReply).toHaveBeenCalledTimes(1)
expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R')
})
})
@@ -0,0 +1,14 @@
import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
export function routeTerminalQueryReply(
message: Record<string, unknown>,
onTerminalQueryReply: ((bytes: string) => void) | undefined
): void {
if (
message.type === 'terminal-data' &&
typeof message.bytes === 'string' &&
isTerminalQueryReply(message.bytes)
) {
onTerminalQueryReply?.(message.bytes)
}
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
type QueryReplyGate = {
forward: (data: string) => void
queueBoundary: (generation: number) => void
reset: () => void
setGeneration: (generation: number) => void
}
function createQueryReplyGate(notify: (message: unknown) => void): {
gate: QueryReplyGate
queuedBoundaries: Array<() => void>
} {
const queuedBoundaries: Array<() => void> = []
const factory = new Function(
'notify',
'enqueueWriteBoundary',
`var terminalGeneration = 0;
${TERMINAL_QUERY_REPLY_JS}
return {
forward: forwardTerminalDataReply,
queueBoundary: enqueueTerminalDataReplyBoundary,
reset: resetTerminalDataReplyAuthority,
setGeneration: function(next) { terminalGeneration = next; }
};`
) as (
notify: (message: unknown) => void,
enqueueBoundary: (callback: () => void) => void
) => QueryReplyGate
const gate = factory(notify, (callback) => queuedBoundaries.push(callback))
return { gate, queuedBoundaries }
}
describe('mobile terminal query replies', () => {
it('forwards xterm-generated data only after initial replay drains', () => {
const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)')
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'attachTerminalQueryReplyBridge(term, gen)',
listenerIndex
)
const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'forwardTerminalDataReply(data)',
listenerIndex
)
expect(listenerIndex).toBeGreaterThan(-1)
expect(enableIndex).toBeGreaterThan(listenerIndex)
expect(notifyIndex).toBeGreaterThan(listenerIndex)
expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false')
expect(XTERM_WEBVIEW_SOURCE.html).toContain(
'term.attachCustomKeyEventHandler(function() { return false; })'
)
expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true')
})
it('mutes a replacement terminal until its own replay drains', () => {
const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData')
const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'resetTerminalDataReplyAuthority()',
initIndex
)
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'attachTerminalQueryReplyBridge(term, gen)',
disableIndex
)
expect(initIndex).toBeGreaterThan(-1)
expect(disableIndex).toBeGreaterThan(initIndex)
expect(enableIndex).toBeGreaterThan(disableIndex)
})
it('suppresses replay, then forwards live queries queued behind its boundary', () => {
const messages: unknown[] = []
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
gate.setGeneration(1)
gate.reset()
gate.queueBoundary(1)
gate.forward('\x1b[1;1R')
expect(messages).toEqual([])
queuedBoundaries[0]?.()
gate.forward('\x1b[2;2R')
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }])
})
it('does not let a superseded generation reclaim reply authority', () => {
const messages: unknown[] = []
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
gate.setGeneration(1)
gate.queueBoundary(1)
gate.setGeneration(2)
gate.reset()
queuedBoundaries[0]?.()
gate.forward('\x1b[1;1R')
expect(messages).toEqual([])
})
})
@@ -243,6 +243,21 @@ describe('mobile presence lock — multi-mobile semantics', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('elects one query responder and promotes the survivor on unsubscribe', async () => {
const { runtime } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true)
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true)
await runtime.reclaimTerminalForDesktop('pty-1')
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
})
it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => {
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
+15
View File
@@ -6476,6 +6476,21 @@ export class OrcaRuntimeService {
return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0
}
isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean {
// Why: a passive phone watching desktop-sized output must not race the
// desktop xterm. Mobile becomes reply authority only with the mobile floor.
if (this.getDriver(ptyId).kind !== 'mobile') {
return false
}
const subscribers = this.mobileSubscribers.get(ptyId)
if (!subscribers) {
return false
}
// Why: Map insertion order elects one stable responder without a per-query
// scan; deleting the winner promotes the earliest surviving subscriber.
return subscribers.keys().next().value === clientId
}
subscribeToFitOverrideChanges(
ptyId: string,
listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void
+149 -33
View File
@@ -26,6 +26,13 @@ import {
} from '../../../../shared/terminal-input'
import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
import {
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
scanTerminalReplyQuerySequences,
type TerminalReplyQuerySequence,
type TerminalReplyQueryScanState
} from '../../../../shared/terminal-reply-query-scan'
import {
MOBILE_SNAPSHOT_BYTE_BUDGET,
MOBILE_SUBSCRIBE_SCROLLBACK_ROWS
@@ -42,6 +49,7 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024
// Why: pending output is held for later binary frames, so cap the encoded
// payload bytes rather than UTF-16 code units.
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024
let nextTerminalStreamId = 1
type SnapshotFrameOptions = {
@@ -372,6 +380,29 @@ function getOutputAfterSnapshotSeq(
return chunk.data.slice(snapshotSeq - chunkStartSeq)
}
function stripSnapshotBoundaryQuerySuffixes(
data: string,
dataStartSeq: number,
snapshotSeq: number,
queries: TerminalReplyQuerySequence[]
): string {
let output = ''
let offset = 0
for (const query of queries) {
if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) {
continue
}
const removeStart = Math.max(0, query.startSeq - dataStartSeq)
const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq)
if (removeEnd <= offset || removeStart >= data.length) {
continue
}
output += data.slice(offset, removeStart)
offset = removeEnd
}
return output + data.slice(offset)
}
function appendAckPendingOutput(
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
@@ -699,6 +730,9 @@ const TerminalSend = TerminalHandle.extend({
enter: z.unknown().optional(),
interrupt: z.unknown().optional(),
requireAgentStatus: z.enum(['sendable']).optional(),
// Why: terminal-generated replies are valid input bytes but are not a user
// action that should transfer the shared terminal floor.
inputKind: z.enum(['query-reply']).optional(),
// Why: identifies the caller for the driver state machine. Optional for
// backward compatibility with older mobile clients (server falls back to
// the most recent mobile actor when absent). New mobile builds populate
@@ -983,13 +1017,40 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.send',
params: TerminalSend,
handler: async (params, { runtime }) => {
handler: async (params, { runtime, clientId }) => {
await assertTerminalSendTextWithinLimit(params.text)
const queryReplyClientId = clientId ?? params.client?.id
if (
params.inputKind === 'query-reply' &&
(!params.text ||
!isTerminalQueryReply(params.text) ||
params.enter === true ||
params.interrupt === true ||
params.requireAgentStatus !== undefined ||
params.client?.type !== 'mobile' ||
!queryReplyClientId ||
(clientId !== undefined && params.client.id !== clientId))
) {
throw new InvalidArgumentError('Invalid terminal query reply')
}
// Why: guarded resolution — a stale handle must fail with
// terminal_handle_stale (clients recover by re-deriving the handle)
// instead of evaluating driver/lock state against the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
if (
params.inputKind === 'query-reply' &&
leaf?.ptyId &&
!runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!)
) {
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0
}
}
}
if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
return {
send: {
@@ -1096,7 +1157,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// the most recent actor. Clientless sends are old mobile builds, so use
// the current mobile driver as their compatibility identity.
const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client)
if (leaf?.ptyId && mobileFloorClientId) {
if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) {
await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId)
}
return { send: result }
@@ -2134,6 +2195,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let pendingOutput: TerminalOutputChunk[] = []
let pendingOutputBytes = 0
let pendingOutputOverflowed = false
let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
const pendingQuerySequences: TerminalReplyQuerySequence[] = []
let pendingQueryChars = 0
let pendingQueryOverflowed = false
let unsubscribeData = (): void => {}
let unsubscribeResize = (): void => {}
let unsubscribeFit = (): void => {}
@@ -2236,6 +2301,57 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
).catch(() => {})
}
}) ?? (() => {})
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed) {
return
}
if (buffering) {
const rawLength = meta?.rawLength
if (
typeof meta?.seq === 'number' &&
typeof rawLength === 'number' &&
rawLength === data.length
) {
const scan = scanTerminalReplyQuerySequences(
data,
meta.seq - rawLength,
pendingQueryScanState
)
pendingQueryScanState = scan.state
for (const query of scan.queries) {
if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) {
pendingQueryOverflowed = true
break
}
pendingQuerySequences.push(query)
pendingQueryChars += query.data.length
}
} else {
pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
}
const remainingBudget = Math.max(
1,
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
pendingOutputBytes = trimmed.bytes
pendingOutputOverflowed ||= trimmed.overflowed
return
}
outputBatcher?.push(data, meta)
})
// Why: live bytes must be captured before mobile fit awaits. Registering
// mobile presence first would suppress main while no view held the query.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
try {
if (isMobile && clientId) {
@@ -2245,36 +2361,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed) {
return
}
if (buffering) {
const remainingBudget = Math.max(
1,
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
pendingOutputBytes = trimmed.bytes
pendingOutputOverflowed ||= trimmed.overflowed
return
}
outputBatcher?.push(data, meta)
})
// Why: binary subscribe streams feed remote xterm views (mobile and
// binary-capable desktop clients) that answer queries with view
// authority; the main model responder yields while attached.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
let read = await runtime.readTerminal(params.terminal)
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (closed) {
@@ -2395,9 +2481,39 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
buffering = false
const bufferedOutput = pendingOutput.splice(0)
const queryReplayData = pendingQueryOverflowed
? ''
: pendingQuerySequences
.filter(
(query) =>
initialOutputOverflowed ||
(typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq)
)
.map((query) => query.data)
.join('')
if (queryReplayData) {
// Why: serialized snapshots omit control queries, yet their output seq
// can trim the live chunk. Replay only the query after snapshot so the
// mobile xterm answers once while ordinary output stays deduplicated.
outputBatcher.push(queryReplayData)
}
if (!initialOutputOverflowed) {
for (const item of bufferedOutput) {
const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
if (
uncoveredData &&
uncoveredData !== item.data &&
typeof snapshotOutputSeq === 'number' &&
typeof item.meta?.seq === 'number' &&
typeof item.meta.rawLength === 'number'
) {
uncoveredData = stripSnapshotBoundaryQuerySuffixes(
uncoveredData,
snapshotOutputSeq,
snapshotOutputSeq,
pendingQuerySequences
)
}
if (uncoveredData) {
outputBatcher.push(uncoveredData, item.meta)
}
+142
View File
@@ -169,6 +169,148 @@ describe('terminal send RPC', () => {
expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1')
})
it('writes a validated terminal query reply without taking the mobile floor', async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
accepted: true,
bytesWritten: 6
}),
isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true),
mobileTookFloor: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
enter: false,
inputKind: 'query-reply',
client: { id: 'mobile-1', type: 'mobile' }
})
)
expect(response.ok).toBe(true)
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{ text: '\x1b[3;4R', enter: false, interrupt: false },
{ beforeWrite: undefined }
)
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
})
it('accepts a query reply from only the elected mobile subscriber', async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
isMobileTerminalQueryReplyAuthority: vi.fn(
(_ptyId: string, clientId: string) => clientId === 'mobile-1'
),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
accepted: true,
bytesWritten: 6
}),
mobileTookFloor: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const makeReply = (clientId: string) =>
dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
inputKind: 'query-reply',
client: { id: clientId, type: 'mobile' }
})
)
const [winner, peer] = await Promise.all([makeReply('mobile-1'), makeReply('mobile-2')])
expect(winner).toMatchObject({ ok: true, result: { send: { accepted: true } } })
expect(peer).toMatchObject({ ok: true, result: { send: { accepted: false } } })
expect(runtime.sendTerminal).toHaveBeenCalledTimes(1)
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
})
it('rejects ordinary input that claims to be a terminal query reply', async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(),
sendTerminal: vi.fn(),
mobileTookFloor: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: 'a',
inputKind: 'query-reply',
client: { id: 'mobile-1', type: 'mobile' }
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
expect(runtime.sendTerminal).not.toHaveBeenCalled()
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
})
it.each([
['enter', { enter: true }],
['interrupt', { interrupt: true }],
['agent guard', { requireAgentStatus: 'sendable' }]
])('rejects a terminal query reply combined with %s semantics', async (_case, extra) => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(),
sendTerminal: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
inputKind: 'query-reply',
client: { id: 'mobile-1', type: 'mobile' },
...extra
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
expect(runtime.sendTerminal).not.toHaveBeenCalled()
})
it('rejects query replies that spoof a different authenticated mobile client', async () => {
const replies: string[] = []
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(),
sendTerminal: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
await dispatcher.dispatchStreaming(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
inputKind: 'query-reply',
client: { id: 'spoofed-mobile', type: 'mobile' }
}),
(reply) => replies.push(reply),
{ clientId: 'authenticated-mobile' }
)
expect(JSON.parse(replies[0]!)).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
expect(runtime.sendTerminal).not.toHaveBeenCalled()
})
it('rejects oversized terminal send text before runtime dispatch', async () => {
const secret = 'terminal-send-secret'
const runtime = stubRuntime({
@@ -79,6 +79,95 @@ describe('terminal subscribe buffering', () => {
}
})
it('captures live queries before awaiting mobile fit and delivers them after the snapshot', async () => {
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
let dataListener:
| ((data: string, meta?: { seq?: number; rawLength?: number }) => void)
| undefined
let resolveMobileSubscribe: () => void = () => {}
const registerRemoteTerminalViewSubscriber = vi.fn(() => vi.fn())
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
handleMobileSubscribe: vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveMobileSubscribe = () => resolve(true)
})
),
handleMobileUnsubscribe: vi.fn(),
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
dataListener = listener
return vi.fn()
}),
registerRemoteTerminalViewSubscriber,
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
.mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 4 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
cleanups.get(id)?.()
cleanups.delete(id)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'phone-1', type: 'mobile' },
capabilities: { terminalBinaryStream: 1 }
}),
vi.fn(),
{
connectionId: 'conn-phone',
sendBinary: (bytes) => {
binaryFrames.push(bytes)
},
registerBinaryStreamHandler: vi.fn(() => vi.fn())
}
)
await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled())
expect(dataListener).toBeDefined()
expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1')
dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 })
resolveMobileSubscribe()
await vi.waitFor(() =>
expect(
binaryFrames.some((bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
return (
frame?.opcode === TerminalStreamOpcode.Output &&
decodeTerminalStreamText(frame.payload) === '\x1b[6n'
)
})
).toBe(true)
)
const queryOutputFrames = binaryFrames.filter((bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
return (
frame?.opcode === TerminalStreamOpcode.Output &&
decodeTerminalStreamText(frame.payload) === '\x1b[6n'
)
})
expect(queryOutputFrames).toHaveLength(1)
runtime.cleanupSubscription('terminal-1:phone-1')
await dispatchPromise
})
it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => {
const messages: string[] = []
const runtime = stubRuntime({
@@ -4286,10 +4286,21 @@ describe('connectPanePty', () => {
}
// Simulate xterm answering a TUI's DA1 query while the phone owns the PTY.
;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c')
// Capability handlers are registered outside onData and must honor the
// same mobile query-authority lock.
const csiCalls = (
pane.terminal.parser.registerCsiHandler as unknown as {
mock: { calls: [{ final: string }, (params: (number | number[])[]) => boolean][] }
}
).mock.calls
const da1Handler = csiCalls.find(([id]) => id.final === 'c')?.[1]
expect(da1Handler).toBeTypeOf('function')
da1Handler?.([])
await flushAsyncTicks()
expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled()
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.sendInputImmediate).not.toHaveBeenCalled()
} finally {
setDriverForPty(ptyId, { kind: 'idle' })
}
@@ -3254,6 +3254,14 @@ export function connectPanePty(
const transport = runtimeEnvironmentId
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
: createIpcPtyTransport(transportOptions)
const canSendDesktopQueryReply = (): boolean => {
const ptyId = transport.getPtyId()
return !ptyId || !isPtyLocked(ptyId)
}
// Why: parser/capability handlers bypass the ordinary onData guard. Keep
// desktop silent while the elected mobile xterm owns query replies.
const sendDesktopQueryReplyImmediate = (data: string): boolean =>
canSendDesktopQueryReply() && transport.sendInputImmediate(data)
// Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031
// responder — visible, hidden, marked or not. Conditioning the reply on the
// hidden mark double-fired (mark set + bytes delivered live via interest →
@@ -3271,7 +3279,7 @@ export function connectPanePty(
)
// Why immediate: a mode-2031 query reply must beat the remote input debounce
// or it can miss the querying program's read window (#7329).
transport.sendInputImmediate(mode2031SequenceFor(mode))
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
// Why: register the subscription exactly like the xterm CSI handler
// would — without the registry entry, later theme flips never push the
// CSI 997 update and the TUI keeps a stale theme after reveal.
@@ -3285,13 +3293,13 @@ export function connectPanePty(
// Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode
// read window; the remote transport's input debounce would corrupt them
// (#7329), so send immediately.
sendInput: (data) => transport.sendInputImmediate(data),
sendInput: sendDesktopQueryReplyImmediate,
isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id),
...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {})
})
const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder(
pane.terminal,
(data) => transport.sendInputImmediate(data)
sendDesktopQueryReplyImmediate
)
const onDataDisposable = pane.terminal.onData((data) => {
@@ -3339,7 +3347,7 @@ export function connectPanePty(
// isTerminalQueryReply (it requires length >= 3 and a full reply grammar),
// so a real keystroke never reaches this branch.
if (isTerminalQueryReply(data)) {
transport.sendInputImmediate(data)
sendDesktopQueryReplyImmediate(data)
return
}
const intent = pendingTerminalInputIntent
@@ -5031,7 +5039,7 @@ export function connectPanePty(
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer
// immediately so the reply cannot outlive the program's read window.
deps.paneMode2031Ref.current.set(pane.id, true)
transport.sendInputImmediate(mode2031SequenceFor(mode))
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
deps.paneLastThemeModeRef.current.set(pane.id, mode)
recordHiddenMode2031Reply()
}
@@ -5202,8 +5210,10 @@ export function connectPanePty(
// Why: Codex's startup palette probe has a 100 ms budget. Answer
// hidden color queries directly and immediately so neither renderer
// scheduling nor the remote input debounce (#7329) can miss it.
sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) =>
transport.sendInputImmediate(reply)
sendTerminalOscColorQueryReplies(
extracted.oscColorQueryData,
pane.terminal,
sendDesktopQueryReplyImmediate
)
}
if (extracted.statelessQueryData) {
@@ -5322,8 +5332,10 @@ export function connectPanePty(
}
const extracted = extractHiddenStartupRendererQueryData(data, '')
if (extracted.oscColorQueryData) {
sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) =>
transport.sendInputImmediate(reply)
sendTerminalOscColorQueryReplies(
extracted.oscColorQueryData,
pane.terminal,
sendDesktopQueryReplyImmediate
)
}
let unansweredQueryData = ''
@@ -5337,9 +5349,9 @@ export function connectPanePty(
const buffer = pane.terminal.buffer.active
const row = Math.min(buffer.cursorY + 1, pane.terminal.rows)
const col = Math.min(buffer.cursorX + 1, pane.terminal.cols)
transport.sendInputImmediate(`\x1b[${row};${col}R`)
sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`)
} else if (sequence === '\x1b[c' || sequence === '\x1b[0c') {
transport.sendInputImmediate(DEFAULT_DA1_RESPONSE)
sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE)
} else {
unansweredQueryData += sequence
}
@@ -6293,7 +6305,7 @@ export function connectPanePty(
pane.terminal,
// Why: OSC color reply — immediate so the remote debounce cannot delay
// it past the querying program's read window (#7329).
(reply) => transport.sendInputImmediate(reply)
sendDesktopQueryReplyImmediate
)
}
const restoreAppliesToCurrentPty =
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
scanTerminalReplyQuerySequences
} from './terminal-reply-query-scan'
describe('terminal reply query scan', () => {
it('records reply-eliciting queries with their output high-water sequence', () => {
const data = `before\x1b[6nafter\x1b[?2031h`
const result = scanTerminalReplyQuerySequences(data, 100, EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE)
expect(result.queries).toEqual([
{ data: '\x1b[6n', startSeq: 106, endSeq: 110 },
{ data: '\x1b[?2031h', startSeq: 115, endSeq: 123 }
])
expect(result.state).toEqual(EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE)
})
it('assembles a query split across contiguous PTY chunks', () => {
const first = scanTerminalReplyQuerySequences(
'\x1b[?',
20,
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
)
const second = scanTerminalReplyQuerySequences('2026$p', 23, first.state)
expect(first.queries).toEqual([])
expect(second.queries).toEqual([{ data: '\x1b[?2026$p', startSeq: 20, endSeq: 29 }])
})
it('drops a partial query when output sequence continuity is lost', () => {
const first = scanTerminalReplyQuerySequences(
'\x1b[?',
20,
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
)
const second = scanTerminalReplyQuerySequences('2026$p', 30, first.state)
expect(second.queries).toEqual([])
})
})
+125
View File
@@ -0,0 +1,125 @@
import { findCsiFinalByteIndex } from './terminal-reply-query-extraction'
import { parseTerminalOscColorQuery } from './terminal-osc-color-reply'
const ESC = '\x1b'
const MAX_PENDING_QUERY_CHARS = 4096
/* oxlint-disable no-control-regex -- terminal query grammars contain ESC by definition */
const DEVICE_ATTRIBUTES_QUERY_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$')
const MODE_QUERY_RE = new RegExp('^\\u001b\\[\\??[0-9;]+\\$p$')
/* oxlint-enable no-control-regex */
export type TerminalReplyQuerySequence = {
data: string
startSeq: number
endSeq: number
}
export type TerminalReplyQueryScanState = {
pending: string
pendingStartSeq: number | null
}
export const EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE: TerminalReplyQueryScanState = {
pending: '',
pendingStartSeq: null
}
function isReplyElicitingCsi(sequence: string): boolean {
if (DEVICE_ATTRIBUTES_QUERY_RE.test(sequence)) {
return true
}
if (MODE_QUERY_RE.test(sequence)) {
return true
}
return (
sequence === '\x1b[5n' ||
sequence === '\x1b[6n' ||
sequence === '\x1b[?6n' ||
sequence === '\x1b[?996n' ||
sequence === '\x1b[>q' ||
sequence === '\x1b[14t' ||
sequence === '\x1b[16t' ||
sequence === '\x1b[18t' ||
sequence === '\x1b[?u' ||
sequence === '\x1b[?2031h'
)
}
function boundedPending(input: string, startIndex: number): string {
return input.slice(startIndex, startIndex + MAX_PENDING_QUERY_CHARS)
}
export function scanTerminalReplyQuerySequences(
data: string,
chunkStartSeq: number,
previous: TerminalReplyQueryScanState
): { queries: TerminalReplyQuerySequence[]; state: TerminalReplyQueryScanState } {
const continuesPending =
previous.pendingStartSeq !== null &&
previous.pendingStartSeq + previous.pending.length === chunkStartSeq
const pending = continuesPending ? previous.pending : ''
const input = pending + data
const inputStartSeq = chunkStartSeq - pending.length
const queries: TerminalReplyQuerySequence[] = []
let offset = 0
while (offset < input.length) {
const candidateIndex = input.indexOf(ESC, offset)
if (candidateIndex === -1) {
break
}
if (candidateIndex + 1 >= input.length) {
const nextPending = boundedPending(input, candidateIndex)
return {
queries,
state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex }
}
}
let endIndex = -1
let matches = false
if (input.startsWith(`${ESC}[`, candidateIndex)) {
endIndex = findCsiFinalByteIndex(input, candidateIndex + 2)
if (endIndex !== -1) {
matches = isReplyElicitingCsi(input.slice(candidateIndex, endIndex + 1))
}
} else if (input.startsWith(`${ESC}]`, candidateIndex)) {
const osc = parseTerminalOscColorQuery(input, candidateIndex)
if (osc.kind === 'partial') {
endIndex = -1
} else if (osc.kind === 'match') {
endIndex = osc.endIndex - 1
matches = true
} else {
endIndex = candidateIndex + 1
}
} else if (input.startsWith(`${ESC}P`, candidateIndex)) {
const terminatorIndex = input.indexOf(`${ESC}\\`, candidateIndex + 2)
if (terminatorIndex !== -1) {
endIndex = terminatorIndex + 1
const body = input.slice(candidateIndex + 2, terminatorIndex)
matches = body.startsWith('$q') || body.startsWith('+q')
}
} else {
endIndex = candidateIndex
}
if (endIndex === -1) {
const nextPending = boundedPending(input, candidateIndex)
return {
queries,
state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex }
}
}
if (matches) {
queries.push({
data: input.slice(candidateIndex, endIndex + 1),
startSeq: inputStartSeq + candidateIndex,
endSeq: inputStartSeq + endIndex + 1
})
}
offset = endIndex + 1
}
return { queries, state: EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE }
}