mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 00:02:21 +00:00
fix(terminal): restore the mouse format with mouse tracking, so phone swipes don't type into Codex (#23946)
* fix(terminal): restore the mouse encoding with mouse tracking in every snapshot Swiping to scroll Codex from the phone on a Windows host typed legacy `ESC [ M` mouse reports into the Codex composer (#23818). SerializeAddon re-arms mouse tracking (?1000h/?1002h/?1003h) but never the SGR encoding (?1006h/?1016h). Any snapshot taken from a desktop pane's xterm (the runtime seeds its headless model from it after a reattach, and serves it to remote viewers when no model exists) therefore restored "tracking on, legacy encoding", and the phone encoded wheel events as X10 bytes, which ConPTY hands to Codex as keystrokes. serializeWithAbsoluteCursor, the one wrapper every Orca snapshot producer uses, now appends the encoding xterm itself parsed, read from xterm's mouse state service. The daemon/runtime headless model reads tracking and encoding from xterm too, so its regex mirror of the DECSET stream is deleted (one source of truth; one less regex pass per PTY chunk). Mixed versions: no wire field changes. A new host's snapshot carries an extra DECSET that old desktop and phone clients already parse; an old host's snapshot restores exactly as before. With tracking off the encoding alone sends no reports, so the wheel still scrolls scrollback. * test(terminal): pin the mouse-encoding read against the renderer xterm build * fix(terminal): type the xterm mouse-state read behind named shapes
This commit is contained in:
@@ -29,9 +29,9 @@ export function mulberry32(seed: number): () => number {
|
||||
export type AgentTuiStreamDims = { cols: number; rows: number }
|
||||
|
||||
export type AgentTuiStreamProfile = {
|
||||
/** Mouse-mode toggles require mirroring TerminalMouseModeMirror to build
|
||||
* rehydrate parity; the renderer-side fuzz cannot import that main-only
|
||||
* module (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */
|
||||
/** Mouse-mode toggles need the daemon's rehydrate for parity; the
|
||||
* renderer-side fuzz cannot import that main-only module
|
||||
* (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */
|
||||
includeMouseModes: boolean
|
||||
/** OSC 8 hyperlinks mark their cells underlined in the xterm buffer, but
|
||||
* SerializeAddon never re-emits OSC 8 — production restores link ranges
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { readTerminalMouseEncoding } from './terminal-mouse-encoding'
|
||||
|
||||
function write(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, () => resolve()))
|
||||
}
|
||||
|
||||
describe('readTerminalMouseEncoding', () => {
|
||||
it('reads the encoding from the real vendored renderer xterm build', async () => {
|
||||
// Pinned on purpose: if an upgrade moves the private service, this must fail
|
||||
// loudly — a silent 'default' would bring back X10 reports (#23818).
|
||||
const terminal = new Terminal({ allowProposedApi: true })
|
||||
expect(readTerminalMouseEncoding(terminal)).toBe('default')
|
||||
|
||||
await write(terminal, '\x1b[?1003h\x1b[?1006h')
|
||||
expect(readTerminalMouseEncoding(terminal)).toBe('sgr')
|
||||
|
||||
await write(terminal, '\x1b[?1016h')
|
||||
expect(readTerminalMouseEncoding(terminal)).toBe('sgr-pixels')
|
||||
|
||||
await write(terminal, '\x1bc')
|
||||
expect(readTerminalMouseEncoding(terminal)).toBe('default')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('answers default when the private internals are missing', () => {
|
||||
expect(readTerminalMouseEncoding({ cols: 80, rows: 24 })).toBe('default')
|
||||
expect(readTerminalMouseEncoding({ cols: 80, rows: 24, _core: {} })).toBe('default')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Mouse report encoding armed by DECSET 1006 (SGR) or 1016 (SGR pixels); independent of the tracking protocol. */
|
||||
export type TerminalMouseEncoding = 'default' | 'sgr' | 'sgr-pixels'
|
||||
|
||||
/** Private xterm surface read below; optional because an xterm upgrade can move or drop it. */
|
||||
type TerminalWithMouseStateCore = {
|
||||
// Why: required public members keep this from being a weak type, so xterm Terminals assign uncast.
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
_core?: { mouseStateService?: { activeEncoding?: unknown } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the encoding xterm itself parsed. Its public `modes` exposes the
|
||||
* tracking protocol but not the encoding, and SerializeAddon omits it, so a
|
||||
* restored pane otherwise emits legacy `ESC [ M` reports that a ConPTY host
|
||||
* types into the app as text (#23818).
|
||||
*/
|
||||
export function readTerminalMouseEncoding(
|
||||
terminal: TerminalWithMouseStateCore
|
||||
): TerminalMouseEncoding {
|
||||
const encoding = terminal._core?.mouseStateService?.activeEncoding
|
||||
if (encoding === 'SGR') {
|
||||
return 'sgr'
|
||||
}
|
||||
if (encoding === 'SGR_PIXELS') {
|
||||
return 'sgr-pixels'
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function buildMouseEncodingRestoreSequence(encoding: TerminalMouseEncoding): string {
|
||||
switch (encoding) {
|
||||
case 'sgr':
|
||||
return '\x1b[?1006h'
|
||||
case 'sgr-pixels':
|
||||
return '\x1b[?1016h'
|
||||
case 'default':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -155,8 +155,8 @@ export function advancePartialEscapeTail(pendingTail: string, chunk: string): st
|
||||
// Why the pre-filter: `extractPartialEscapeTail` only leaves `ground` on an ESC byte, so with
|
||||
// no pending tail and no ESC in the chunk the answer is always ''. Taking it here skips both
|
||||
// the full-chunk concat and the per-code-unit walk on ESC-free output (build logs, `cat`,
|
||||
// piped tool output) — the same gate `TerminalOscCwdTitleScanner.scan` and
|
||||
// `TerminalMouseModeMirror.scan` already apply on the very same ingest path.
|
||||
// piped tool output) — the same gate `TerminalOscCwdTitleScanner.scan` already
|
||||
// applies on the very same ingest path.
|
||||
if (pendingTail.length === 0 && !chunk.includes('\x1b')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -255,8 +255,8 @@ export function buildParityMainBufferSnapshot(
|
||||
if (!alternateScreen && terminal.modes.applicationCursorKeysMode) {
|
||||
seqs.push('\x1b[?1h')
|
||||
}
|
||||
// Mouse-mode rehydrate omitted: TerminalMouseModeMirror is main-only and
|
||||
// mouse reporting is input encoding — it cannot alter rendered output.
|
||||
// Mouse-mode rehydrate omitted: mouse reporting is input encoding — it
|
||||
// cannot alter rendered output.
|
||||
const snapshot: ParityMainSnapshot = {
|
||||
data: seqs.join('') + snapshotAnsi,
|
||||
cols: terminal.cols,
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
// snapshot that will be replayed into another terminal must therefore end
|
||||
// with an absolute CUP derived from the SOURCE terminal's authoritative
|
||||
// cursor position. Snapshot producers that also need the VT100 DECSC
|
||||
// saved-cursor register carried across the restore compose it here too.
|
||||
// saved-cursor register carried across the restore compose it here too, and
|
||||
// the mouse encoding the addon's mode trailer omits rides beside it.
|
||||
|
||||
import {
|
||||
buildMouseEncodingRestoreSequence,
|
||||
readTerminalMouseEncoding
|
||||
} from './terminal-mouse-encoding'
|
||||
|
||||
type SerializeCursorTerminal = {
|
||||
cols: number
|
||||
@@ -81,7 +87,20 @@ export function serializeWithAbsoluteCursor<TOpts>(
|
||||
if (serialized.length === 0) {
|
||||
return serialized
|
||||
}
|
||||
return `${serialized}${buildAbsoluteCursorRestoreSequence(terminal, savedCursor)}`
|
||||
// Why: the addon re-arms mouse tracking but not its encoding; restoring one without the other makes X10 reports.
|
||||
const mouseEncodingRestore = serializesModes(opts)
|
||||
? buildMouseEncodingRestoreSequence(readTerminalMouseEncoding(terminal))
|
||||
: ''
|
||||
return `${serialized}${mouseEncodingRestore}${buildAbsoluteCursorRestoreSequence(terminal, savedCursor)}`
|
||||
}
|
||||
|
||||
function serializesModes(opts: unknown): boolean {
|
||||
return !(
|
||||
typeof opts === 'object' &&
|
||||
opts !== null &&
|
||||
'excludeModes' in opts &&
|
||||
opts.excludeModes === true
|
||||
)
|
||||
}
|
||||
|
||||
/** Cursor state appended after serialized modes; safe to replay without the frame body. */
|
||||
|
||||
Reference in New Issue
Block a user