mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Fix mobile terminal query reply authority
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user