mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
P1 STA 3887 design Preview Kitty IME (#13940)
* fix(terminal): carry kitty flags through Preview snapshots and pair rele
Preview was omitting the live kitty mirror from the IME bridge and dropping kitty flags from snapshots, so every commit was evaluated at flags 0. A TUI that negotiated bit-3 (report_all_keys_as_escape_codes) would receive the legacy raw text it declined.
Now the snapshot carries proven kitty flags beside their sequence boundary, the forwarder reads flags once per commit, and bit-1 (report_event_types) commits are paired with exactly one release regardless of keyup/insertText ordering. Snapshot authorities expose only the active screen's proven flags, so an old host's absent field stays unknown rather than downgraded to a manufactured zero.
* fix(terminal): sync kitty flags and IME releases across snapshots
* trim wordinesss
* fix(terminal): settle owed IME release before fresh same-key press
When a keyup is lost and the same key is pressed again, settle the stale
record's owed release instead of discarding it — this maintains correct
IME state during recovery. Also refine Kitty flag propagation to only
carry proven baselines across snapshots, and tighten related comments.
* fix(terminal): gate kitty flags on sequence boundaries
- Remote snapshots only include flags when seq is present
- Daemon uses parsed flags value when defined
- Ensures correct flag ordering in snapshot replay
(cherry picked from commit 5bee7b5ce9)
This commit is contained in:
@@ -6701,7 +6701,7 @@
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.ime-and-synthetic-forwarding",
|
||||
"title": "IME, native text, and synthetic input commit exactly once and do not leak preedit bytes",
|
||||
"title": "IME, native text, and synthetic input commit exactly once, do not leak preedit bytes, and pair every claimed press with one release",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-input",
|
||||
@@ -6718,18 +6718,19 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos", "linux"],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
|
||||
"coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, Electron/CDP live-PTY Sogou-style repros, and a macOS renderer/main slice for Preview kitty commits: claimed press/release pairing in both keyup orderings, and snapshot flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts from daemon through the sequenced remote SnapshotStart frame. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6699",
|
||||
"https://github.com/stablyai/orca/pull/6682",
|
||||
"https://github.com/stablyai/orca/pull/6513",
|
||||
"https://github.com/stablyai/orca/pull/6999"
|
||||
],
|
||||
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
|
||||
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
||||
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY. When the snapshot source proves kitty state, macOS Preview commits must use the application's effective flags after the sequenced snapshot and every proven replay/live transition, and a claimed press delivered under report_event_types must produce exactly one matching release regardless of whether keyup arrives before or after insertText and regardless of xterm's defensive internal reset.",
|
||||
"oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
@@ -6742,6 +6743,14 @@
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts",
|
||||
"src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts",
|
||||
"src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx",
|
||||
"src/shared/terminal-kitty-keyboard-mode-tracker.test.ts",
|
||||
"src/main/ipc/terminal-preview.test.ts",
|
||||
"src/main/ipc/terminal-preview-output-stream.test.ts",
|
||||
"src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
@@ -6806,6 +6815,65 @@
|
||||
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts",
|
||||
"assertions": [
|
||||
"the real forwarder behind the Preview bridge encodes the commit from the snapshot-proven flags before any live output",
|
||||
"flags 0/1 commit raw text, flags 2 add one CSI-u release, flags 8 emit one CSI-u press, and flags 10 emit a CSI-u press followed by exactly one release",
|
||||
"both keydown->insertText->keyup and keydown->keyup->insertText orderings produce the same bytes",
|
||||
"the release is emitted by the forwarder while xterm's internal kitty flags stay 0",
|
||||
"the mutable flags getter is read exactly once, at commit time, never on keydown or keyup",
|
||||
"auto-repeat, two-key rollover, a swallowed commit, a retired claim, blur, and disposal each emit at most one release and never synthesize one"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts",
|
||||
"assertions": [
|
||||
"a Preview opened on a snapshot carrying flags 8 adopts 8 before any live output",
|
||||
"a replacement snapshot whose ANSI carries no kitty bytes keeps its proven flags, and a proven 0 clears an earlier 8",
|
||||
"a missing field leaves the mirror unproven at the raw-text fallback and a later live negotiation still upgrades it",
|
||||
"restored flags land on the screen the snapshot scan selected",
|
||||
"a proven post-snapshot suffix advances the stack while redelivered bytes apply idempotently"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx",
|
||||
"assertions": [
|
||||
"the Preview hands the forwarder the live kitty mirror seeded from the snapshot's proven flags"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts",
|
||||
"assertions": [
|
||||
"a fresh PTY's known 0 is distinguishable from a snapshot's unproven 0 fallback",
|
||||
"restoreSnapshotFlags proves only the active screen and rejects non-safe-integer values",
|
||||
"provenance survives absolute sets, screen switches, RIS and DECSTR, but a pop past omitted push history returns to unknown rather than a manufactured 0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/terminal-preview.test.ts",
|
||||
"assertions": [
|
||||
"terminalPreview:connect returns image, sequence, and kitty flags from the same capture, including after an overflow retry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/terminal-preview-output-stream.test.ts",
|
||||
"assertions": [
|
||||
"fully covered chunks are dropped, a sliced or strictly newer sequenced suffix gets live mode semantics, and an unsliceable or metadata-less overlap gets replay semantics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts",
|
||||
"assertions": [
|
||||
"SnapshotStart decodes proven 0 and 8 with their seq into requested and host-pushed snapshot paths",
|
||||
"negative, fractional, unsafe, and non-numeric values are treated as absent rather than clamped",
|
||||
"an old host that omits the field stays usable and is never coerced to a proven 0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"assertions": ["daemon provider snapshots publish proven kitty flags including a known 0"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
|
||||
"assertions": [
|
||||
@@ -6817,6 +6885,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-11",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 9.84,
|
||||
"summary": "8 test file(s) passed, 288 tests, covering the Preview kitty slice: real forwarder bytes at snapshot-proven flags, press/release pairing across both keyup/insertText orderings, and flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts — asserted on the daemon snapshot and on a host-pushed remote snapshot that carries the flags with their sequence. The emitter-side rule that a remote SnapshotStart withholds flags without a seq has no direct assertion yet; the client-side gate is what these files pin."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"runner": "local",
|
||||
@@ -6882,7 +6959,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
|
||||
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, live-PTY Sogou-style candidate commits, and the Preview kitty slice (press/release pairing in both keyup orderings, and snapshot flag provenance separating a proven 0 from an absent field). Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -6895,7 +6972,9 @@
|
||||
],
|
||||
"knownGaps": [
|
||||
"Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
|
||||
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
|
||||
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage.",
|
||||
"An older remote host omits the optional SnapshotStart kitty field, so a new client falls back to raw commits until live negotiation is observed.",
|
||||
"Snapshot authorities expose only the active screen's effective flags, not kitty's complete two-screen push stacks; a pop that crosses the restored boundary degrades to unknown rather than a proven value."
|
||||
],
|
||||
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
|
||||
},
|
||||
|
||||
@@ -1322,6 +1322,48 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A proven `0` and an absent field are different facts. Dropping
|
||||
// the zero left consumers unable to tell "the app negotiated nothing" from
|
||||
// "this source could not say", which is what makes Preview guess wrong.
|
||||
it('publishes proven kitty flags including a known zero', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
lastSubprocess._simulateData('plain output\r\n')
|
||||
|
||||
await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({
|
||||
kittyKeyboardFlags: 0
|
||||
})
|
||||
|
||||
lastSubprocess._simulateData('\x1b[>8u')
|
||||
await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({
|
||||
kittyKeyboardFlags: 8
|
||||
})
|
||||
})
|
||||
|
||||
// The other half of that contract: a daemon that cannot say must leave the
|
||||
// key absent, so consumers keep the state unknown instead of reading a `0`.
|
||||
it('omits kitty flags when the daemon snapshot has none', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
lastSubprocess._simulateData('plain output\r\n')
|
||||
const realRequest = DaemonClient.prototype.request
|
||||
const legacyClient = vi
|
||||
.spyOn(DaemonClient.prototype, 'request')
|
||||
.mockImplementation(async function (this: DaemonClient, type, payload, timeoutMs) {
|
||||
const result = await realRequest.call(this, type, payload, timeoutMs)
|
||||
if (type !== 'getSnapshot') {
|
||||
return result
|
||||
}
|
||||
const { snapshot } = result as { snapshot: { modes: Record<string, unknown> } }
|
||||
const { kittyKeyboardFlags: _omitted, ...modes } = snapshot.modes
|
||||
return { snapshot: { ...snapshot, modes } }
|
||||
})
|
||||
|
||||
const snapshot = await adapter.getBufferSnapshot(id)
|
||||
legacyClient.mockRestore()
|
||||
|
||||
expect(snapshot).not.toBeNull()
|
||||
expect(snapshot).not.toHaveProperty('kittyKeyboardFlags')
|
||||
})
|
||||
|
||||
it('exposes live state separately from the visible frame', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
lastSubprocess._simulateData('\x1b[?1049h\x1b[?1004h\x1b[?25lframe')
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
PtySpawnResult
|
||||
} from '../providers/types'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
import { resolveWslSessionContext } from './wsl-session-context'
|
||||
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
|
||||
@@ -835,7 +836,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
const snapshotFrame = result.snapshot.snapshotAnsi
|
||||
const snapshotPayload = snapshotPrefix + snapshotFrame
|
||||
// Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty).
|
||||
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
|
||||
// Why known `0` is no longer dropped: the pane tracker must be able to tell
|
||||
// "the app negotiated nothing" from "this reattach proved nothing".
|
||||
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(
|
||||
result.snapshot.modes.kittyKeyboardFlags
|
||||
)
|
||||
return {
|
||||
id: sessionId,
|
||||
...incarnationResult(),
|
||||
@@ -855,7 +860,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
: {}),
|
||||
...(providerSequence ? { providerSequence } : {}),
|
||||
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
|
||||
...(kittyKeyboardFlags !== undefined
|
||||
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
|
||||
: {}),
|
||||
isReattach: true,
|
||||
@@ -1221,6 +1226,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
|
||||
return null
|
||||
}
|
||||
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags)
|
||||
return {
|
||||
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
|
||||
frameRestoreAnsi: snapshot.frameRestoreAnsi,
|
||||
@@ -1233,6 +1239,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
source: 'headless',
|
||||
oscLinks: snapshot.oscLinks,
|
||||
alternateScreen: snapshot.modes.alternateScreen,
|
||||
// Why known `0` is carried too: it proves the app negotiated nothing at
|
||||
// this boundary, which is a different fact from a source that cannot say.
|
||||
...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}),
|
||||
...(snapshot.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
|
||||
: {})
|
||||
|
||||
@@ -17608,6 +17608,87 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('pairs the daemon kitty flags with the reconciled boundary when no bytes crossed mid-spawn', async () => {
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async () => ({
|
||||
id: 'pty-restored',
|
||||
isReattach: true,
|
||||
providerSequence: { value: 900, generation: 'continued' as const },
|
||||
snapshotKittyKeyboardFlags: 8
|
||||
})),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
onData: vi.fn(() => vi.fn()),
|
||||
onExit: vi.fn(() => vi.fn()),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
getForegroundProcess: vi.fn(async () => null)
|
||||
} as never)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getPtyOutputSequence: vi.fn().mockReturnValue(7),
|
||||
synchronizePtyOutputSequenceFromProvider: vi.fn().mockReturnValue(920),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
preAllocateHandleForPty: vi.fn()
|
||||
}
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
|
||||
const reply = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as {
|
||||
snapshotSeq?: number
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
expect(reply.snapshotKittyKeyboardFlags).toBe(8)
|
||||
expect(reply.snapshotSeq).toBe(920)
|
||||
})
|
||||
|
||||
it('drops the daemon kitty flags claim when bytes crossed the data socket mid-spawn', async () => {
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async () => ({
|
||||
id: 'pty-restored',
|
||||
isReattach: true,
|
||||
providerSequence: { value: 900, generation: 'continued' as const },
|
||||
snapshotKittyKeyboardFlags: 8
|
||||
})),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
onData: vi.fn(() => vi.fn()),
|
||||
onExit: vi.fn(() => vi.fn()),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
getForegroundProcess: vi.fn(async () => null)
|
||||
} as never)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
// 7 at spawn start, 20 at reconcile: bytes arrived during the spawn RPC.
|
||||
getPtyOutputSequence: vi.fn().mockReturnValueOnce(7).mockReturnValue(20),
|
||||
synchronizePtyOutputSequenceFromProvider: vi.fn().mockReturnValue(920),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
preAllocateHandleForPty: vi.fn()
|
||||
}
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
|
||||
const reply = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as {
|
||||
snapshotSeq?: number
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
// The reconciled boundary covers bytes the daemon's flags were proven
|
||||
// BEFORE — publishing both would erase a negotiation the pane scanned live.
|
||||
expect(reply.snapshotKittyKeyboardFlags).toBeUndefined()
|
||||
expect(reply.snapshotSeq).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records the launch Codex account for a fresh spawn but not for a reattach', async () => {
|
||||
const spawn = vi
|
||||
.fn()
|
||||
|
||||
+67
-10
@@ -136,6 +136,7 @@ import {
|
||||
parsePaneKey
|
||||
} from '../../shared/stable-pane-id'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
|
||||
import {
|
||||
resolveTerminalStartupCwdForWorkspace,
|
||||
type TerminalStartupCwdMissingDirFallback
|
||||
@@ -4055,6 +4056,7 @@ export function registerPtyHandlers(
|
||||
rows: number
|
||||
seq?: number
|
||||
lastTitle?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} | null
|
||||
const pendingSerializeRequests = new Map<
|
||||
string,
|
||||
@@ -4083,6 +4085,7 @@ export function registerPtyHandlers(
|
||||
rows?: unknown
|
||||
seq?: unknown
|
||||
lastTitle?: unknown
|
||||
kittyKeyboardFlags?: unknown
|
||||
} | null
|
||||
}
|
||||
) => {
|
||||
@@ -4102,6 +4105,7 @@ export function registerPtyHandlers(
|
||||
rows: number
|
||||
seq?: number
|
||||
lastTitle?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} = {
|
||||
data: snapshot.data,
|
||||
cols: snapshot.cols,
|
||||
@@ -4113,6 +4117,12 @@ export function registerPtyHandlers(
|
||||
if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) {
|
||||
result.lastTitle = snapshot.lastTitle
|
||||
}
|
||||
// Why gated on seq: without a boundary the flags cannot be reconciled
|
||||
// against live bytes, so they prove nothing.
|
||||
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags)
|
||||
if (result.seq !== undefined && kittyKeyboardFlags !== undefined) {
|
||||
result.kittyKeyboardFlags = kittyKeyboardFlags
|
||||
}
|
||||
settleSerializeRequest(args.requestId, result)
|
||||
} else {
|
||||
settleSerializeRequest(args.requestId, null)
|
||||
@@ -4892,6 +4902,15 @@ export function registerPtyHandlers(
|
||||
let stablePaneBindingPersisted = false
|
||||
let rejectedRegistrationCandidate: PtySpawnResult | null = null
|
||||
let pendingRegistrationPtyId: string | null = null
|
||||
// Why hoisted to the reply scope: main reconciles the provider sequence
|
||||
// deep inside the spawn path, but the pane needs that renderer-domain
|
||||
// boundary beside the daemon snapshot's kitty flags.
|
||||
let reconciledSnapshotSeq: number | null = null
|
||||
// False when bytes crossed the data socket during the spawn RPC: the
|
||||
// reconciled boundary covers them, but the daemon proved its kitty flags
|
||||
// before they existed, so the claim must not erase what the pane may
|
||||
// have scanned from those bytes live.
|
||||
let snapshotKittyFlagsCoverReconciledSeq = true
|
||||
let preparedProvisionalExecutionContext = false
|
||||
let releaseWorktreeSpawn: (() => void) | undefined
|
||||
try {
|
||||
@@ -5042,11 +5061,19 @@ export function registerPtyHandlers(
|
||||
// Why: admission precedes sequence/context state and every durable publication below.
|
||||
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
|
||||
if (result.providerSequence) {
|
||||
runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
result.id,
|
||||
result.providerSequence,
|
||||
sequenceBeforeProviderSpawn
|
||||
)
|
||||
const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0
|
||||
// Why kept: this is the reattach boundary in the RENDERER's sequence
|
||||
// domain, and the daemon snapshot's kitty flags mean nothing without
|
||||
// the boundary they were proven at.
|
||||
reconciledSnapshotSeq =
|
||||
runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
result.id,
|
||||
result.providerSequence,
|
||||
sequenceBeforeProviderSpawn
|
||||
) ?? null
|
||||
if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) {
|
||||
snapshotKittyFlagsCoverReconciledSeq = false
|
||||
}
|
||||
}
|
||||
ensureWslHookRelayForReattach(result, args.connectionId)
|
||||
runtime?.preparePtyExecutionContext?.(
|
||||
@@ -5348,6 +5375,11 @@ export function registerPtyHandlers(
|
||||
}
|
||||
resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, {
|
||||
...result,
|
||||
...(typeof result.snapshotKittyKeyboardFlags === 'number' &&
|
||||
reconciledSnapshotSeq !== null &&
|
||||
snapshotKittyFlagsCoverReconciledSeq
|
||||
? { snapshotSeq: reconciledSnapshotSeq }
|
||||
: { snapshotKittyKeyboardFlags: undefined }),
|
||||
isReattach: true
|
||||
})
|
||||
return response
|
||||
@@ -5724,6 +5756,7 @@ export function registerPtyHandlers(
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} | null> => {
|
||||
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
|
||||
return null
|
||||
@@ -5909,6 +5942,15 @@ export function registerPtyHandlers(
|
||||
let stablePaneBindingPersisted = false
|
||||
let rejectedRegistrationCandidate: PtySpawnResult | null = null
|
||||
let pendingRegistrationPtyId: string | null = null
|
||||
// Why hoisted to the reply scope: main reconciles the provider sequence
|
||||
// deep inside the spawn path, but the pane needs that renderer-domain
|
||||
// boundary beside the daemon snapshot's kitty flags.
|
||||
let reconciledSnapshotSeq: number | null = null
|
||||
// False when bytes crossed the data socket during the spawn RPC: the
|
||||
// reconciled boundary covers them, but the daemon proved its kitty flags
|
||||
// before they existed, so the claim must not erase what the pane may
|
||||
// have scanned from those bytes live.
|
||||
let snapshotKittyFlagsCoverReconciledSeq = true
|
||||
let preparedProvisionalExecutionContext = false
|
||||
let releaseWorktreeSpawn: (() => void) | undefined
|
||||
try {
|
||||
@@ -6529,11 +6571,19 @@ export function registerPtyHandlers(
|
||||
assertSpawnReplyWasLive(result)
|
||||
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
|
||||
if (result.providerSequence) {
|
||||
runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
result.id,
|
||||
result.providerSequence,
|
||||
sequenceBeforeProviderSpawn
|
||||
)
|
||||
const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0
|
||||
// Why kept: this is the reattach boundary in the RENDERER's sequence
|
||||
// domain, and the daemon snapshot's kitty flags mean nothing without
|
||||
// the boundary they were proven at.
|
||||
reconciledSnapshotSeq =
|
||||
runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
result.id,
|
||||
result.providerSequence,
|
||||
sequenceBeforeProviderSpawn
|
||||
) ?? null
|
||||
if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) {
|
||||
snapshotKittyFlagsCoverReconciledSeq = false
|
||||
}
|
||||
}
|
||||
ensureWslHookRelayForReattach(result, args.connectionId)
|
||||
runtime?.preparePtyExecutionContext?.(
|
||||
@@ -6909,6 +6959,13 @@ export function registerPtyHandlers(
|
||||
}
|
||||
const response = {
|
||||
...result,
|
||||
// Why both or neither: a pane can only adopt proven kitty flags together
|
||||
// with the sequence boundary they describe.
|
||||
...(typeof result.snapshotKittyKeyboardFlags === 'number' &&
|
||||
reconciledSnapshotSeq !== null &&
|
||||
snapshotKittyFlagsCoverReconciledSeq
|
||||
? { snapshotSeq: reconciledSnapshotSeq }
|
||||
: { snapshotKittyKeyboardFlags: undefined }),
|
||||
...(!result.isReattach && effectiveLaunchConfig
|
||||
? { launchConfig: effectiveLaunchConfig }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { WebContents } from 'electron'
|
||||
import { TerminalPreviewOutputStream } from './terminal-preview-output-stream'
|
||||
|
||||
function makeStream(): TerminalPreviewOutputStream {
|
||||
const contents = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn()
|
||||
} as unknown as WebContents
|
||||
return new TerminalPreviewOutputStream(
|
||||
contents,
|
||||
'p1',
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
// The renderer needs each buffered chunk's mode, not just its bytes.
|
||||
// Returning a bare string[] made a proven post-snapshot `CSI > u` look like
|
||||
// historical redelivery, so the TUI's single pop later landed on a stale frame.
|
||||
describe('TerminalPreviewOutputStream.completeSnapshot', () => {
|
||||
it('drops chunks the snapshot already covers', () => {
|
||||
const stream = makeStream()
|
||||
stream.append('abc', { seq: 5, rawLength: 3 })
|
||||
expect(stream.completeSnapshot(5)).toEqual([])
|
||||
})
|
||||
|
||||
it('gives a strictly newer sequenced chunk live mode', () => {
|
||||
const stream = makeStream()
|
||||
stream.append('abc', { seq: 9, rawLength: 3 })
|
||||
expect(stream.completeSnapshot(6)).toEqual([{ data: 'abc', mode: 'live' }])
|
||||
})
|
||||
|
||||
it('slices a partially covered chunk and keeps the suffix live', () => {
|
||||
const stream = makeStream()
|
||||
stream.append('abc', { seq: 7, rawLength: 3 })
|
||||
expect(stream.completeSnapshot(6)).toEqual([{ data: 'c', mode: 'live' }])
|
||||
})
|
||||
|
||||
it('falls back to replay mode for an unsliceable transformed overlap', () => {
|
||||
const stream = makeStream()
|
||||
// Renderer-side transforms make raw offsets unmappable, so the covered head
|
||||
// cannot be removed and the whole chunk stays uncertain redelivery.
|
||||
stream.append('abc', { seq: 7, rawLength: 3, transformed: true })
|
||||
expect(stream.completeSnapshot(6)).toEqual([{ data: 'abc', mode: 'replay' }])
|
||||
})
|
||||
|
||||
it('falls back to replay mode when the chunk carries no sequence metadata', () => {
|
||||
const stream = makeStream()
|
||||
stream.append('abc')
|
||||
expect(stream.completeSnapshot(6)).toEqual([{ data: 'abc', mode: 'replay' }])
|
||||
})
|
||||
|
||||
it('falls back to replay mode when the snapshot itself is unsequenced', () => {
|
||||
const stream = makeStream()
|
||||
stream.append('abc', { seq: 9, rawLength: 3 })
|
||||
expect(stream.completeSnapshot(undefined)).toEqual([{ data: 'abc', mode: 'replay' }])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import { iterateTerminalInputChunks } from '../../shared/terminal-input'
|
||||
import type { TerminalPreviewDataPayload } from '../../shared/terminal-preview'
|
||||
import type {
|
||||
TerminalPreviewDataPayload,
|
||||
TerminalPreviewReplayChunk
|
||||
} from '../../shared/terminal-preview'
|
||||
|
||||
const OUTPUT_BATCH_MS = 5
|
||||
export const TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES = 64 * 1024
|
||||
@@ -16,22 +19,37 @@ export type TerminalPreviewOutputMeta = {
|
||||
|
||||
type PendingOutput = { data: string; bytes: number; meta?: TerminalPreviewOutputMeta }
|
||||
|
||||
function outputAfterSnapshotSeq(output: PendingOutput, snapshotSeq?: number): string | null {
|
||||
/**
|
||||
* Split one buffered chunk against the snapshot boundary, keeping WHY it
|
||||
* survived. Only a chunk whose sequence metadata proves it is the suffix after
|
||||
* that boundary may advance kitty state with live stack semantics; an overlap
|
||||
* that cannot be sliced is redelivery, and a redelivered `CSI > u` applied as a
|
||||
* push would leave the TUI's single pop landing on a stale frame.
|
||||
*/
|
||||
function outputAfterSnapshotSeq(
|
||||
output: PendingOutput,
|
||||
snapshotSeq?: number
|
||||
): TerminalPreviewReplayChunk | null {
|
||||
if (
|
||||
typeof snapshotSeq !== 'number' ||
|
||||
typeof output.meta?.seq !== 'number' ||
|
||||
typeof output.meta.rawLength !== 'number'
|
||||
) {
|
||||
return output.data
|
||||
return { data: output.data, mode: 'replay' }
|
||||
}
|
||||
if (output.meta.seq <= snapshotSeq) {
|
||||
return null
|
||||
}
|
||||
const startSeq = output.meta.seq - output.meta.rawLength
|
||||
if (startSeq >= snapshotSeq || output.meta.transformed === true) {
|
||||
return output.data
|
||||
if (startSeq >= snapshotSeq) {
|
||||
return { data: output.data, mode: 'live' }
|
||||
}
|
||||
return output.data.slice(snapshotSeq - startSeq)
|
||||
if (output.meta.transformed === true) {
|
||||
// Transformed payloads make raw offsets unmappable, so the covered head
|
||||
// cannot be sliced off and the whole chunk stays uncertain redelivery.
|
||||
return { data: output.data, mode: 'replay' }
|
||||
}
|
||||
return { data: output.data.slice(snapshotSeq - startSeq), mode: 'live' }
|
||||
}
|
||||
|
||||
/** Owns one preview's bounded snapshot/live output queue and acknowledgements. */
|
||||
@@ -101,10 +119,10 @@ export class TerminalPreviewOutputStream {
|
||||
return true
|
||||
}
|
||||
|
||||
completeSnapshot(snapshotSeq?: number): string[] {
|
||||
completeSnapshot(snapshotSeq?: number): TerminalPreviewReplayChunk[] {
|
||||
const replay = this.initialPending.flatMap((output) => {
|
||||
const uncovered = outputAfterSnapshotSeq(output, snapshotSeq)
|
||||
return uncovered ? [uncovered] : []
|
||||
return uncovered && uncovered.data.length > 0 ? [uncovered] : []
|
||||
})
|
||||
this.initialPending = []
|
||||
this.initialPendingBytes = 0
|
||||
|
||||
@@ -122,7 +122,9 @@ describe('registerTerminalPreviewHandlers', () => {
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
snapshot: { data: 'screen', cols: 80, rows: 20, seq: 6 },
|
||||
replay: ['c']
|
||||
// A sliced suffix is proven post-snapshot output, so it applies with live
|
||||
// kitty semantics rather than as redelivery.
|
||||
replay: [{ data: 'c', mode: 'live' }]
|
||||
})
|
||||
expect(runtime.registerRawTerminalViewSubscriber).toHaveBeenCalledWith('p1')
|
||||
})
|
||||
@@ -239,6 +241,64 @@ describe('registerTerminalPreviewHandlers', () => {
|
||||
expect(sender.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Flags and image must come from ONE capture. Publishing flags from
|
||||
// a separate "current state" read would describe a different stream position,
|
||||
// so replaying the intervening bytes could duplicate or reorder transitions.
|
||||
it('returns the kitty flags of the capture that produced the returned image', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const snapshotResolvers: ((snapshot: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
kittyKeyboardFlags?: number
|
||||
}) => void)[] = []
|
||||
runtime.serializeTerminalBuffer.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
snapshotResolvers.push(resolve)
|
||||
})
|
||||
)
|
||||
registerTerminalPreviewHandlers(runtime as never)
|
||||
const sender = makeSender()
|
||||
|
||||
const resultPromise = handlers.get('terminalPreview:connect')!(eventFor(sender), {
|
||||
ptyId: 'p1'
|
||||
}) as Promise<unknown>
|
||||
const chunk = 'x'.repeat(64 * 1024)
|
||||
for (let index = 0; index < 5; index++) {
|
||||
runtime.listeners[0]!(chunk)
|
||||
}
|
||||
// The first capture overflowed; its flags must not survive onto the second image.
|
||||
snapshotResolvers[0]!({
|
||||
data: 'first',
|
||||
cols: 80,
|
||||
rows: 20,
|
||||
seq: 1,
|
||||
kittyKeyboardFlags: 0
|
||||
})
|
||||
await vi.waitFor(() => expect(snapshotResolvers).toHaveLength(2))
|
||||
snapshotResolvers[1]!({
|
||||
data: 'second',
|
||||
cols: 80,
|
||||
rows: 20,
|
||||
seq: 2,
|
||||
kittyKeyboardFlags: 8
|
||||
})
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
snapshot: {
|
||||
data: 'second',
|
||||
cols: 80,
|
||||
rows: 20,
|
||||
seq: 2,
|
||||
kittyKeyboardFlags: 8
|
||||
},
|
||||
replay: []
|
||||
})
|
||||
expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('releases output and raw-view presence on unsubscribe and sender destruction', async () => {
|
||||
const runtime = makeRuntime()
|
||||
registerTerminalPreviewHandlers(runtime as never)
|
||||
|
||||
@@ -32,6 +32,10 @@ export type PtyProviderBufferSnapshot = {
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Effective kitty keyboard flags PROVEN at this snapshot's own `seq`
|
||||
* boundary. Absent means the source could not prove them; readers must not
|
||||
* rewrite that silence into a known `0`. */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
export type PtySpawnOptions = {
|
||||
|
||||
@@ -49,6 +49,9 @@ export type PtySpawnResult = {
|
||||
* (terminal-query-authority.md §kitty). Never replayed into a renderer
|
||||
* xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
/** Renderer-domain sequence main reconciled for the attach boundary those
|
||||
* flags describe. Set by main, not the provider. */
|
||||
snapshotSeq?: number
|
||||
/** True when the spawn reattached to an existing daemon session. */
|
||||
isReattach?: boolean
|
||||
/** Last OSC title tracked by the daemon session the snapshot came from.
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
} from '../../shared/terminal-side-effect-facts'
|
||||
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
isFreshNonDoneAgentStatus,
|
||||
@@ -1618,6 +1619,9 @@ type RuntimeTerminalBufferSnapshot = {
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Effective kitty flags proven at this snapshot's own `seq`. Absent means
|
||||
* the winning source could not prove them. */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
type HeadlessSeedMetadata = {
|
||||
@@ -1730,7 +1734,14 @@ type RuntimePtyController = {
|
||||
serializeBuffer?(
|
||||
ptyId: string,
|
||||
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
||||
): Promise<{ data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null>
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
lastTitle?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} | null>
|
||||
/** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */
|
||||
serializeProviderBuffer?(
|
||||
ptyId: string,
|
||||
@@ -11601,6 +11612,7 @@ export class OrcaRuntimeService {
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} | null> {
|
||||
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
|
||||
// Why: pre-attach stream bytes only form a suffix of restored state. A
|
||||
@@ -11650,6 +11662,7 @@ export class OrcaRuntimeService {
|
||||
lastTitle?: string
|
||||
source?: 'renderer'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
kittyKeyboardFlags?: number
|
||||
} | null> {
|
||||
if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) {
|
||||
return null
|
||||
@@ -11662,6 +11675,7 @@ export class OrcaRuntimeService {
|
||||
cwd?: string | null
|
||||
lastTitle?: string
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
kittyKeyboardFlags?: number
|
||||
} | null = null
|
||||
try {
|
||||
// Why: recovery/read fallback wants visible alt-screen content (e.g. an
|
||||
@@ -12044,6 +12058,7 @@ export class OrcaRuntimeService {
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
kittyKeyboardFlags?: number
|
||||
// Why: dangling mid-escape tail the restorer must write LAST, after any
|
||||
// reset, so the next live chunk completes it instead of rendering it
|
||||
// literally (Bug E / #7329).
|
||||
@@ -12072,6 +12087,12 @@ export class OrcaRuntimeService {
|
||||
source: 'headless' as const,
|
||||
oscLinks: snapshot.oscLinks,
|
||||
scrollbackAnsi: snapshot.scrollbackAnsi,
|
||||
// Why beside outputSequence and never re-read later: the flags must
|
||||
// describe the same stream position as the image, or replay would
|
||||
// apply push/pop transitions twice or out of order.
|
||||
...(parseTerminalKittyKeyboardFlags(snapshot.modes?.kittyKeyboardFlags) !== undefined
|
||||
? { kittyKeyboardFlags: snapshot.modes.kittyKeyboardFlags }
|
||||
: {}),
|
||||
...(snapshot.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
|
||||
: {}),
|
||||
|
||||
@@ -99,6 +99,8 @@ type SnapshotFrameOptions = {
|
||||
source?: 'headless' | 'renderer'
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Effective kitty flags proven at this frame's own `seq`. */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
type SerializedSnapshot = {
|
||||
@@ -113,6 +115,7 @@ type SerializedSnapshot = {
|
||||
scrollbackRows: number
|
||||
truncatedByByteBudget: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} | null
|
||||
|
||||
type TerminalViewportClient = {
|
||||
@@ -648,6 +651,15 @@ function sendSnapshotFrames(
|
||||
source: options.source,
|
||||
oscLinks: options.oscLinks,
|
||||
pendingEscapeTailAnsi: options.pendingEscapeTailAnsi,
|
||||
// Why conditional and additive: old clients ignore the unknown field,
|
||||
// and a new client must read absence as unknown rather than zero, so
|
||||
// no opcode or capability negotiation is involved (Rule 1 of
|
||||
// docs/reference/remote-wire-compatibility.md).
|
||||
// Why `seq` is required: the flags are only proven at this frame's own
|
||||
// seq, so without a replay boundary the client cannot order them.
|
||||
...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined
|
||||
? { kittyKeyboardFlags: options.kittyKeyboardFlags }
|
||||
: {}),
|
||||
truncated: options.truncated === true,
|
||||
truncatedByByteBudget: options.truncatedByByteBudget === true
|
||||
})
|
||||
@@ -1840,6 +1852,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
reason: 'ack-pending-overflow',
|
||||
seq: serialized.seq,
|
||||
source: serialized.source,
|
||||
kittyKeyboardFlags: serialized.kittyKeyboardFlags,
|
||||
truncatedByByteBudget: serialized.truncatedByByteBudget,
|
||||
data: serialized.data
|
||||
}
|
||||
@@ -2327,6 +2340,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
seq: serialized?.seq,
|
||||
cwd: serialized?.cwd,
|
||||
source: serialized?.source,
|
||||
kittyKeyboardFlags: serialized?.kittyKeyboardFlags,
|
||||
oscLinks: serialized?.oscLinks,
|
||||
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
|
||||
truncated: false,
|
||||
@@ -2669,6 +2683,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
truncated: initialOutputOverflowed,
|
||||
truncatedByByteBudget: serialized?.truncatedByByteBudget,
|
||||
source: serialized?.source,
|
||||
kittyKeyboardFlags: serialized?.kittyKeyboardFlags,
|
||||
oscLinks: serialized?.oscLinks,
|
||||
pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi,
|
||||
data:
|
||||
|
||||
@@ -1490,6 +1490,8 @@ export type PreloadApi = {
|
||||
snapshotPrefixAnsi?: string
|
||||
snapshotFrameAnsi?: string
|
||||
snapshotFrameRestoreAnsi?: string
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
snapshotSeq?: number
|
||||
isReattach?: boolean
|
||||
isAlternateScreen?: boolean
|
||||
replay?: string
|
||||
@@ -1572,6 +1574,9 @@ export type PreloadApi = {
|
||||
/** Trailing incomplete escape the emulator ingested; the restorer must
|
||||
* write it after its post-replay resets, last before live chunks. */
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Effective kitty flags the snapshot owner proved at `seq`. Absent means
|
||||
* unknown; consumers must not turn that into a known `0`. */
|
||||
kittyKeyboardFlags?: number
|
||||
} | null>
|
||||
getRendererDeliveryDebugSnapshot: () => Promise<{
|
||||
pendingPtyCount: number
|
||||
@@ -1642,6 +1647,7 @@ export type PreloadApi = {
|
||||
rows: number
|
||||
seq?: number
|
||||
lastTitle?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} | null
|
||||
) => void
|
||||
declarePendingPaneSerializer: (paneKey: string) => Promise<number>
|
||||
|
||||
@@ -967,6 +967,8 @@ const api = {
|
||||
snapshotPrefixAnsi?: string
|
||||
snapshotFrameAnsi?: string
|
||||
snapshotFrameRestoreAnsi?: string
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
snapshotSeq?: number
|
||||
isReattach?: boolean
|
||||
isAlternateScreen?: boolean
|
||||
replay?: string
|
||||
@@ -1087,6 +1089,7 @@ const api = {
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
|
||||
|
||||
getRendererDeliveryDebugSnapshot: (): Promise<{
|
||||
@@ -1249,6 +1252,7 @@ const api = {
|
||||
rows: number
|
||||
seq?: number
|
||||
lastTitle?: string
|
||||
kittyKeyboardFlags?: number
|
||||
} | null
|
||||
): void => {
|
||||
ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot })
|
||||
|
||||
@@ -44,6 +44,7 @@ const imeHarness = vi.hoisted(() => ({
|
||||
claimKeyEvent: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
sendInput: (data: string) => void
|
||||
getKittyKeyboardFlags: () => number
|
||||
}[],
|
||||
trackers: [] as { dispose: ReturnType<typeof vi.fn> }[],
|
||||
claimResult: false
|
||||
@@ -115,11 +116,17 @@ vi.mock('@/lib/shortcut-platform', () => ({
|
||||
getShortcutPlatform: () => platformState.value
|
||||
}))
|
||||
vi.mock('@/components/terminal-pane/terminal-ime-native-text-forwarder', () => ({
|
||||
installTerminalImeNativeTextForwarder: (args: { sendInput: (data: string) => void }) => {
|
||||
installTerminalImeNativeTextForwarder: (args: {
|
||||
sendInput: (data: string) => void
|
||||
getKittyKeyboardFlags?: () => number
|
||||
}) => {
|
||||
const forwarder = {
|
||||
claimKeyEvent: vi.fn(() => imeHarness.claimResult),
|
||||
dispose: vi.fn(),
|
||||
sendInput: args.sendInput
|
||||
sendInput: args.sendInput,
|
||||
// Why captured: the bridge's whole job is handing the live mirror to the
|
||||
// forwarder, so the test reads what a real commit would read.
|
||||
getKittyKeyboardFlags: args.getKittyKeyboardFlags ?? ((): number => 0)
|
||||
}
|
||||
imeHarness.forwarders.push(forwarder)
|
||||
return forwarder
|
||||
@@ -270,6 +277,26 @@ describe('AgentTerminalPreview', () => {
|
||||
expect(imeHarness.trackers).toHaveLength(0)
|
||||
})
|
||||
|
||||
// The bridge omitted this dependency entirely, so every Preview
|
||||
// commit was evaluated at flags 0. Ordering and provenance live in
|
||||
// preview-terminal-snapshot-replay.test.ts; this pins the wiring.
|
||||
it('hands the forwarder the live mirror seeded from the snapshot flags', async () => {
|
||||
platformState.value = 'darwin'
|
||||
connect.mockResolvedValue({
|
||||
snapshot: { data: '', cols: 80, rows: 24, seq: 1, kittyKeyboardFlags: 8 },
|
||||
replay: []
|
||||
})
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
|
||||
await waitFor(() => expect(imeHarness.forwarders[0]!.getKittyKeyboardFlags()).toBe(8))
|
||||
|
||||
// Live output keeps advancing the same mirror the forwarder reads.
|
||||
act(() => {
|
||||
emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[<u', bytes: 4 })
|
||||
})
|
||||
expect(imeHarness.forwarders[0]!.getKittyKeyboardFlags()).toBe(0)
|
||||
})
|
||||
|
||||
it('disposes the IME bridge on unmount', async () => {
|
||||
platformState.value = 'darwin'
|
||||
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { subscribeToTerminalUserInput } from '@/components/terminal-pane/termina
|
||||
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
|
||||
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { replayPreviewConnectionSnapshot } from './preview-terminal-snapshot-replay'
|
||||
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
|
||||
import {
|
||||
buildPreviewAppearanceOptions,
|
||||
@@ -210,7 +211,11 @@ export function AgentTerminalPreview({
|
||||
|
||||
const installImeNativeTextBridge = (): void => {
|
||||
if (terminal) {
|
||||
imeBridge = installPreviewImeBridge(terminal)
|
||||
// Why a live getter: kitty state can change between keydown and commit,
|
||||
// and the tracker outlives every reconnect inside this effect.
|
||||
imeBridge = installPreviewImeBridge(terminal, {
|
||||
getKittyKeyboardFlags: () => kittyKeyboardModes.flags
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,22 +309,13 @@ export function AgentTerminalPreview({
|
||||
clamp(snap.rows ?? FALLBACK_ROWS, 2, 200)
|
||||
)
|
||||
terminal.reset()
|
||||
// Why: the reset drops xterm's kitty flags, so the mirror must restart
|
||||
// from the incoming snapshot instead of the dead session's state.
|
||||
kittyKeyboardModes.reset()
|
||||
}
|
||||
if (snap.scrollbackAnsi) {
|
||||
writeReplayed(snap.scrollbackAnsi)
|
||||
}
|
||||
if (snap.data) {
|
||||
writeReplayed(snap.data)
|
||||
}
|
||||
if (snap.pendingEscapeTailAnsi) {
|
||||
writeReplayed(snap.pendingEscapeTailAnsi)
|
||||
}
|
||||
for (const data of connection.replay) {
|
||||
writeReplayed(data)
|
||||
}
|
||||
replayPreviewConnectionSnapshot({
|
||||
snapshot: snap,
|
||||
replay: connection.replay,
|
||||
kittyKeyboardModes,
|
||||
write: (chunk, live) => writeReplayed(chunk, undefined, live)
|
||||
})
|
||||
for (const payload of pendingLivePayloads.splice(0)) {
|
||||
writeLive(payload)
|
||||
}
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
// @vitest-environment happy-dom
|
||||
// The release oracle: exact outbound bytes from the REAL shared
|
||||
// forwarder behind installPreviewImeBridge. A wiring-only mock cannot show that
|
||||
// a Preview opened on a bit-3 TUI commits CSI-u, nor that exactly one release
|
||||
// leaves the forwarder in either macOS event order.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installPreviewImeBridge } from './preview-terminal-ime-bridge'
|
||||
|
||||
vi.mock('@/lib/shortcut-platform', () => ({
|
||||
getShortcutPlatform: () => 'darwin'
|
||||
}))
|
||||
|
||||
const COMMA = { key: ',', code: 'Comma', keyCode: 188 }
|
||||
|
||||
type Session = {
|
||||
emitted: string[]
|
||||
flagReads: number
|
||||
setFlags: (flags: number) => void
|
||||
keydown: (init?: { shiftKey?: boolean; repeat?: boolean; key?: string; code?: string }) => void
|
||||
keyup: (init?: { shiftKey?: boolean; key?: string; code?: string }) => void
|
||||
commit: (text: string) => void
|
||||
/** An input event the text system produced without committing text. */
|
||||
swallow: () => void
|
||||
xtermKittyFlags: () => number
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function open(initialFlags: number): Session {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea!
|
||||
let flags = initialFlags
|
||||
let flagReads = 0
|
||||
const bridge = installPreviewImeBridge(terminal, {
|
||||
getKittyKeyboardFlags: () => {
|
||||
flagReads += 1
|
||||
return flags
|
||||
}
|
||||
})!
|
||||
terminal.attachCustomKeyEventHandler((event) => !bridge.claimKeyEvent(event))
|
||||
const emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
|
||||
const key = (
|
||||
type: string,
|
||||
init: { key?: string; code?: string; shiftKey?: boolean; repeat?: boolean }
|
||||
): void => {
|
||||
const event = new KeyboardEvent(type, {
|
||||
key: init.key ?? COMMA.key,
|
||||
code: init.code ?? COMMA.code,
|
||||
shiftKey: init.shiftKey === true,
|
||||
repeat: init.repeat === true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(event, 'keyCode', { value: COMMA.keyCode })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const input = (data: string | null): void => {
|
||||
for (const type of ['beforeinput', 'input']) {
|
||||
const event = new InputEvent(type, { bubbles: true })
|
||||
Object.defineProperty(event, 'inputType', { value: 'insertText' })
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
emitted,
|
||||
get flagReads() {
|
||||
return flagReads
|
||||
},
|
||||
setFlags: (next) => {
|
||||
flags = next
|
||||
},
|
||||
keydown: (init = {}) => key('keydown', init),
|
||||
keyup: (init = {}) => key('keyup', init),
|
||||
commit: (text) => input(text),
|
||||
swallow: () => input(null),
|
||||
xtermKittyFlags: () =>
|
||||
(
|
||||
terminal as unknown as {
|
||||
_core?: { coreService?: { kittyKeyboard?: { flags?: number } } }
|
||||
}
|
||||
)._core?.coreService?.kittyKeyboard?.flags ?? 0,
|
||||
dispose: () => {
|
||||
bridge.dispose()
|
||||
terminal.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('preview IME bridge outbound bytes', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
// The bug: the bridge omitted getKittyKeyboardFlags entirely, so every commit
|
||||
// was evaluated at flags 0 and a bit-3 TUI received the legacy text it declined.
|
||||
it('encodes the commit from the flags proven before any live output', () => {
|
||||
const session = open(8)
|
||||
session.keydown()
|
||||
session.commit(',')
|
||||
session.keyup()
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['keydown -> insertText -> keyup', false],
|
||||
// An ordering some macOS input sources use; the earlier design cleared the
|
||||
// claimed press here, so the later commit could never be encoded as CSI-u.
|
||||
['keydown -> keyup -> insertText', true]
|
||||
])('in %s order', (_name, keyupFirst) => {
|
||||
const type = (session: Session): void => {
|
||||
session.keydown()
|
||||
if (keyupFirst) {
|
||||
session.keyup()
|
||||
session.commit(',')
|
||||
} else {
|
||||
session.commit(',')
|
||||
session.keyup()
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
['no flags', 0, ','],
|
||||
['disambiguate only', 1, ','],
|
||||
['event types', 2, ',\x1b[44;1:3u'],
|
||||
['all keys as escape codes', 8, '\x1b[44u'],
|
||||
['all keys + event types', 10, '\x1b[44u\x1b[44;1:3u']
|
||||
])('emits the %s byte shape', (_flagName, flags, expected) => {
|
||||
const session = open(flags)
|
||||
type(session)
|
||||
expect(session.emitted.join('')).toBe(expected)
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('owns the release even though xterm holds no kitty state', () => {
|
||||
const session = open(10)
|
||||
// The forwarder must not delegate the keyup: Preview deliberately never
|
||||
// restores kitty flags into xterm, so xterm would emit nothing here.
|
||||
expect(session.xtermKittyFlags()).toBe(0)
|
||||
type(session)
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44;1:3u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('reads the mutable getter exactly once, at commit time', () => {
|
||||
const session = open(0)
|
||||
session.keydown()
|
||||
expect(session.flagReads).toBe(0)
|
||||
session.setFlags(8)
|
||||
if (keyupFirst) {
|
||||
session.keyup()
|
||||
expect(session.flagReads).toBe(0)
|
||||
session.commit(',')
|
||||
} else {
|
||||
session.commit(',')
|
||||
session.keyup()
|
||||
}
|
||||
expect(session.flagReads).toBe(1)
|
||||
// The commit-time read is what both the press AND its release encode from.
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('emits nothing when the input source swallowed the press', () => {
|
||||
const session = open(10)
|
||||
session.keydown()
|
||||
if (keyupFirst) {
|
||||
session.keyup()
|
||||
session.swallow()
|
||||
} else {
|
||||
session.swallow()
|
||||
session.keyup()
|
||||
}
|
||||
// A release describes a press the app received; none did.
|
||||
expect(session.emitted.join('')).toBe('')
|
||||
session.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('encodes the release from the keyup state when Shift is released first', () => {
|
||||
const session = open(10)
|
||||
// `!` on Digit1: the press reports the base key with the Shift modifier.
|
||||
session.keydown({ key: '!', code: 'Digit1', shiftKey: true })
|
||||
session.commit('!')
|
||||
session.keyup({ key: '1', code: 'Digit1', shiftKey: false })
|
||||
// 49 is `1`; the release carries no modifier because none was held at release.
|
||||
expect(session.emitted.join('')).toBe('\x1b[49;2u\x1b[49;1:3u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('emits one release for a held key across auto-repeat', () => {
|
||||
const session = open(10)
|
||||
session.keydown()
|
||||
session.commit(',')
|
||||
session.keydown({ repeat: true })
|
||||
session.commit(',')
|
||||
session.keyup()
|
||||
// Press, repeat, then exactly one release for the single physical press.
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44;1:2u\x1b[44;1:3u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('keeps an owed release when a later repeat commits under flags that owe none', () => {
|
||||
const session = open(10)
|
||||
session.keydown()
|
||||
session.commit(',')
|
||||
session.setFlags(8)
|
||||
session.keydown({ repeat: true })
|
||||
session.commit(',')
|
||||
session.setFlags(10)
|
||||
session.keyup()
|
||||
// The repeat requested no event types, but its commit must not erase the
|
||||
// release the delivered first press still owes under the flags it was
|
||||
// sent with.
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44u\x1b[44;1:3u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('suppresses the owed release when event types are no longer negotiated at keyup', () => {
|
||||
// xterm parity: KittyKeyboard.evaluate drops RELEASE reports the moment
|
||||
// report_event_types is gone, so an app that popped the mode — or the
|
||||
// successor shell of a TUI that quit on this very key — never receives
|
||||
// CSI-u bytes it did not negotiate.
|
||||
const session = open(10)
|
||||
session.keydown()
|
||||
session.commit(',')
|
||||
session.setFlags(0)
|
||||
session.keyup()
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('releases each key of a two-key rollover exactly once', () => {
|
||||
const session = open(10)
|
||||
session.keydown()
|
||||
session.commit(',')
|
||||
session.keydown({ key: '.', code: 'Period' })
|
||||
session.commit('。')
|
||||
session.keyup()
|
||||
session.keyup({ key: '.', code: 'Period' })
|
||||
expect(session.emitted.join('')).toBe('\x1b[44u\x1b[46u\x1b[44;1:3u\x1b[46;1:3u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('leaves no release behind when a claim is retired without committing', () => {
|
||||
const session = open(10)
|
||||
session.keydown()
|
||||
// The input source ate the first press; the next keydown retires it.
|
||||
session.keydown({ key: '.', code: 'Period' })
|
||||
session.commit('。')
|
||||
session.keyup()
|
||||
session.keyup({ key: '.', code: 'Period' })
|
||||
expect(session.emitted.join('')).toBe('\x1b[46u\x1b[46;1:3u')
|
||||
session.dispose()
|
||||
})
|
||||
|
||||
it('synthesizes nothing on blur or disposal', () => {
|
||||
const session = open(10)
|
||||
session.keydown()
|
||||
session.commit(',')
|
||||
session.emitted.length = 0
|
||||
document.querySelector('.xterm')?.dispatchEvent(new FocusEvent('blur', { bubbles: true }))
|
||||
session.keyup()
|
||||
session.dispose()
|
||||
expect(session.emitted.join('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -9,14 +9,29 @@ export type PreviewImeBridge = {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export type PreviewImeBridgeOptions = {
|
||||
/**
|
||||
* The preview's live kitty mirror, read once per commit. Required, not
|
||||
* optional: omitting it evaluated every commit at flags `0`, so a Preview on
|
||||
* a bit-3 TUI silently sent legacy text the app had declined.
|
||||
*/
|
||||
getKittyKeyboardFlags: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Native-text bridge for the preview terminal.
|
||||
*
|
||||
* Why: xterm's kitty encoder can encode+cancel a printable keydown before
|
||||
* Chromium commits IME/native text, silently dropping the glyph. Mirrors
|
||||
* TerminalPane's forwarder, macOS-only like the pane's install.
|
||||
*
|
||||
* Bit policy stays in terminal-ime-kitty-commit-encoding.ts: the getter is
|
||||
* forwarded unchanged, never masked or reduced to a boolean.
|
||||
*/
|
||||
export function installPreviewImeBridge(terminal: Terminal): PreviewImeBridge | null {
|
||||
export function installPreviewImeBridge(
|
||||
terminal: Terminal,
|
||||
options: PreviewImeBridgeOptions
|
||||
): PreviewImeBridge | null {
|
||||
if (getShortcutPlatform() !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
@@ -24,7 +39,8 @@ export function installPreviewImeBridge(terminal: Terminal): PreviewImeBridge |
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: terminal.element,
|
||||
isComposing: () => compositionTracker?.isActive() ?? false,
|
||||
sendInput: (data) => terminal.input(data)
|
||||
sendInput: (data) => terminal.input(data),
|
||||
getKittyKeyboardFlags: options.getKittyKeyboardFlags
|
||||
})
|
||||
return {
|
||||
claimKeyEvent: (event) => forwarder?.claimKeyEvent(event) ?? false,
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import type {
|
||||
TerminalPreviewReplayChunk,
|
||||
TerminalPreviewSnapshot
|
||||
} from '../../../../shared/terminal-preview'
|
||||
import { replayPreviewConnectionSnapshot } from './preview-terminal-snapshot-replay'
|
||||
|
||||
function apply(
|
||||
snapshot: Partial<TerminalPreviewSnapshot>,
|
||||
replay: TerminalPreviewReplayChunk[] = [],
|
||||
kittyKeyboardModes = new TerminalKittyKeyboardModeTracker()
|
||||
): { modes: TerminalKittyKeyboardModeTracker; written: string[] } {
|
||||
const written: string[] = []
|
||||
replayPreviewConnectionSnapshot({
|
||||
snapshot: { data: '', cols: 80, rows: 24, ...snapshot },
|
||||
replay,
|
||||
kittyKeyboardModes,
|
||||
write: (chunk, live) => {
|
||||
written.push(chunk)
|
||||
if (live) {
|
||||
kittyKeyboardModes.scan(chunk)
|
||||
} else {
|
||||
kittyKeyboardModes.scanReplay(chunk)
|
||||
}
|
||||
}
|
||||
})
|
||||
return { modes: kittyKeyboardModes, written }
|
||||
}
|
||||
|
||||
// The Preview's mirror is the only thing that can tell the IME
|
||||
// forwarder what the TUI negotiated, and snapshot ANSI never carries kitty
|
||||
// pushes — so the flags have to arrive as metadata and be applied in order.
|
||||
describe('replayPreviewConnectionSnapshot', () => {
|
||||
it('adopts flags the snapshot owner proved, before any live output', () => {
|
||||
const { modes } = apply({ kittyKeyboardFlags: 8 })
|
||||
expect(modes.flags).toBe(8)
|
||||
expect(modes.snapshotFlags).toBe(8)
|
||||
})
|
||||
|
||||
it('leaves the mirror unproven and at the raw-text fallback when the field is absent', () => {
|
||||
const { modes } = apply({ data: 'plain output' })
|
||||
expect(modes.flags).toBe(0)
|
||||
expect(modes.snapshotFlags).toBeUndefined()
|
||||
|
||||
// A later explicit negotiation still upgrades it.
|
||||
modes.scan('\x1b[>8u')
|
||||
expect(modes.flags).toBe(8)
|
||||
})
|
||||
|
||||
it('replaces an earlier 8 when a replacement snapshot proves 0', () => {
|
||||
const modes = new TerminalKittyKeyboardModeTracker()
|
||||
apply({ kittyKeyboardFlags: 8 }, [], modes)
|
||||
apply({ kittyKeyboardFlags: 0 }, [], modes)
|
||||
expect(modes.flags).toBe(0)
|
||||
expect(modes.snapshotFlags).toBe(0)
|
||||
})
|
||||
|
||||
it('carries live-proven flags across a resync snapshot that proves nothing', () => {
|
||||
// A grid change or capture overflow against an old host fetches a
|
||||
// replacement snapshot with no kitty metadata; wiping the mirror there
|
||||
// reintroduces the raw-text-into-a-bit-3-TUI bug on every resync.
|
||||
const modes = new TerminalKittyKeyboardModeTracker()
|
||||
apply({ data: 'first frame' }, [], modes)
|
||||
modes.scan('\x1b[>8u')
|
||||
apply({ data: 'second frame' }, [], modes)
|
||||
expect(modes.flags).toBe(8)
|
||||
expect(modes.snapshotFlags).toBe(8)
|
||||
})
|
||||
|
||||
it('carries nothing across a flagless snapshot when the mirror never proved state', () => {
|
||||
// The constructor's known-zero was never proven for the previewed PTY;
|
||||
// carrying it would launder a fresh default into a host-proven inactive.
|
||||
const modes = new TerminalKittyKeyboardModeTracker()
|
||||
apply({ data: 'first frame' }, [], modes)
|
||||
apply({ data: 'second frame' }, [], modes)
|
||||
expect(modes.flags).toBe(0)
|
||||
expect(modes.snapshotFlags).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps proven flags across a replacement snapshot whose ANSI carries no kitty bytes', () => {
|
||||
// The resync path used to reset the mirror and then scan kitty-free ANSI,
|
||||
// which is what silently dropped a live TUI's negotiation.
|
||||
const modes = new TerminalKittyKeyboardModeTracker()
|
||||
apply({ data: 'first frame' }, [], modes)
|
||||
apply({ data: 'second frame', cols: 90, rows: 30, kittyKeyboardFlags: 8 }, [], modes)
|
||||
expect(modes.flags).toBe(8)
|
||||
})
|
||||
|
||||
it('restores onto the screen the snapshot scan selected', () => {
|
||||
const { modes } = apply({ data: '\x1b[?1049h', kittyKeyboardFlags: 8 })
|
||||
expect(modes.isAlternateScreen).toBe(true)
|
||||
expect(modes.snapshotFlags).toBe(8)
|
||||
// Leaving the alternate screen returns to the main screen's own slot, which
|
||||
// this snapshot never proved.
|
||||
modes.scan('\x1b[?1049l')
|
||||
expect(modes.snapshotFlags).toBeUndefined()
|
||||
})
|
||||
|
||||
it('advances a proven suffix with live semantics and applies redelivery idempotently', () => {
|
||||
const { modes } = apply({ kittyKeyboardFlags: 0 }, [
|
||||
{ data: '\x1b[>8u', mode: 'live' },
|
||||
// A redelivered push applied as a push would leave the app's single pop
|
||||
// landing on a stale frame.
|
||||
{ data: '\x1b[>8u', mode: 'replay' }
|
||||
])
|
||||
expect(modes.flags).toBe(8)
|
||||
modes.scan('\x1b[<u')
|
||||
expect(modes.flags).toBe(0)
|
||||
expect(modes.snapshotFlags).toBe(0)
|
||||
})
|
||||
|
||||
it('writes scrollback, frame, escape tail, then replay, in that order', () => {
|
||||
const { written } = apply(
|
||||
{
|
||||
scrollbackAnsi: 'history',
|
||||
data: 'frame',
|
||||
pendingEscapeTailAnsi: '\x1b['
|
||||
},
|
||||
[{ data: 'tail', mode: 'live' }]
|
||||
)
|
||||
expect(written).toEqual(['history', 'frame', '\x1b[', 'tail'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags'
|
||||
import type {
|
||||
TerminalPreviewReplayChunk,
|
||||
TerminalPreviewSnapshot
|
||||
} from '../../../../shared/terminal-preview'
|
||||
|
||||
/**
|
||||
* Apply snapshot + buffered replay, restoring proven kitty flags after the
|
||||
* snapshot scan (snapshot ANSI omits kitty pushes). Synchronous so no browser
|
||||
* event observes the temporary reset.
|
||||
*/
|
||||
export function replayPreviewConnectionSnapshot(args: {
|
||||
snapshot: TerminalPreviewSnapshot
|
||||
replay: TerminalPreviewReplayChunk[]
|
||||
kittyKeyboardModes: TerminalKittyKeyboardModeTracker
|
||||
/** Scans the chunk into the mirror and queues it for xterm. */
|
||||
write: (chunk: string, live: boolean) => void
|
||||
}): void {
|
||||
const { snapshot, kittyKeyboardModes } = args
|
||||
// Why: carry only live-proven flags across a flagless resync; a fresh mirror's zero is ungrounded.
|
||||
const provenFlags =
|
||||
parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) ??
|
||||
(kittyKeyboardModes.hasProvenBaseline ? kittyKeyboardModes.snapshotFlags : undefined)
|
||||
kittyKeyboardModes.resetForSnapshot()
|
||||
if (snapshot.scrollbackAnsi) {
|
||||
args.write(snapshot.scrollbackAnsi, false)
|
||||
}
|
||||
if (snapshot.data) {
|
||||
args.write(snapshot.data, false)
|
||||
}
|
||||
if (snapshot.pendingEscapeTailAnsi) {
|
||||
args.write(snapshot.pendingEscapeTailAnsi, false)
|
||||
}
|
||||
if (provenFlags !== undefined) {
|
||||
kittyKeyboardModes.restoreSnapshotFlags(provenFlags)
|
||||
}
|
||||
for (const chunk of args.replay) {
|
||||
args.write(chunk.data, chunk.mode === 'live')
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export type SerializedBuffer = {
|
||||
rows: number
|
||||
seq?: number
|
||||
lastTitle?: string
|
||||
/** Kitty flags this pane's mirror could PROVE at `seq`. Published only from
|
||||
* the tracker's snapshotFlags, so an old host's unknown state is never
|
||||
* republished as a known `0`. */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
export type SerializeFn = (
|
||||
@@ -152,6 +156,11 @@ function ensureSerializerListener(): void {
|
||||
if (result.seq !== undefined) {
|
||||
payload.seq = result.seq
|
||||
}
|
||||
// Why gated on seq: flags describe a boundary, and an unsequenced
|
||||
// snapshot has none for a consumer to reconcile live bytes against.
|
||||
if (result.seq !== undefined && result.kittyKeyboardFlags !== undefined) {
|
||||
payload.kittyKeyboardFlags = result.kittyKeyboardFlags
|
||||
}
|
||||
if (lastTitle !== undefined) {
|
||||
payload.lastTitle = lastTitle
|
||||
} else if (result.lastTitle !== undefined) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id'
|
||||
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags'
|
||||
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic'
|
||||
import { isWorktreeRemovalFenceError } from '../../../../shared/worktree-removal-fence-error'
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
} from '@/lib/pane-manager/terminal-delivery-credit'
|
||||
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
|
||||
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
|
||||
import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport'
|
||||
import type { PtyBufferSnapshot, PtyConnectResult, PtyReplayDataMeta } from './pty-transport'
|
||||
import type { IpcPtyTransportOptions, PtyTransportRecoveryState } from './pty-transport-types'
|
||||
import { createIpcPtyTransport } from './pty-transport'
|
||||
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
|
||||
@@ -4749,12 +4750,21 @@ export function connectPanePty(
|
||||
: serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, {
|
||||
scrollback: opts?.scrollbackRows
|
||||
})
|
||||
const orderedSeq = rendererOrderedPtyId === ptyId ? rendererOrderedSeq : null
|
||||
// Why snapshotFlags and not `flags`: this pane may itself have
|
||||
// consumed an old-host snapshot that proved nothing, and its
|
||||
// conservative `0` fallback must not be republished downstream as
|
||||
// a host-proven inactive protocol.
|
||||
const provenKittyFlags = kittyKeyboardModes.hasProvenBaseline
|
||||
? kittyKeyboardModes.snapshotFlags
|
||||
: undefined
|
||||
return {
|
||||
data,
|
||||
cols: pane.terminal.cols,
|
||||
rows: pane.terminal.rows,
|
||||
...(rendererOrderedPtyId === ptyId && rendererOrderedSeq !== null
|
||||
? { seq: rendererOrderedSeq }
|
||||
...(orderedSeq !== null ? { seq: orderedSeq } : {}),
|
||||
...(orderedSeq !== null && provenKittyFlags !== undefined
|
||||
? { kittyKeyboardFlags: provenKittyFlags }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
@@ -5646,6 +5656,8 @@ export function connectPanePty(
|
||||
generation: number
|
||||
streamGeneration: number
|
||||
pendingEscapeTailAnsi?: string
|
||||
kittyKeyboardFlags?: number
|
||||
snapshotSeq?: number
|
||||
}
|
||||
|
||||
let pendingReplayData: PendingReplayData | null = null
|
||||
@@ -5699,7 +5711,7 @@ export function connectPanePty(
|
||||
// negotiation; the mirror must re-arm from them after a reload. Replay
|
||||
// semantics: relay reconnects redeliver the same window, so pushes
|
||||
// apply as sets to keep the mirrored stack from accumulating frames.
|
||||
kittyKeyboardModes.scanReplay(data)
|
||||
applySnapshotKittyKeyboardModes(data, payload)
|
||||
await writeReplayDataAsync(data)
|
||||
if (!isCurrentPayload()) {
|
||||
continue
|
||||
@@ -5775,7 +5787,7 @@ export function connectPanePty(
|
||||
}
|
||||
const replayDataCallback = (
|
||||
data: string,
|
||||
meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {},
|
||||
meta: PtyReplayDataMeta = {},
|
||||
streamGeneration = transportStreamGeneration
|
||||
): void => {
|
||||
pendingReplayData = {
|
||||
@@ -5784,7 +5796,15 @@ export function connectPanePty(
|
||||
ptyId: transport.getPtyId(),
|
||||
generation: (replayPayloadGeneration += 1),
|
||||
streamGeneration,
|
||||
...(meta.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {})
|
||||
...(meta.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi }
|
||||
: {}),
|
||||
...(meta.kittyKeyboardFlags !== undefined && meta.snapshotSeq !== undefined
|
||||
? {
|
||||
kittyKeyboardFlags: meta.kittyKeyboardFlags,
|
||||
snapshotSeq: meta.snapshotSeq
|
||||
}
|
||||
: {})
|
||||
}
|
||||
scheduleReplayDataDrain()
|
||||
}
|
||||
@@ -5816,10 +5836,7 @@ export function connectPanePty(
|
||||
dataCallback(data, meta, generation)
|
||||
}
|
||||
},
|
||||
onReplayData: (
|
||||
data: string,
|
||||
meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string }
|
||||
): void => {
|
||||
onReplayData: (data: string, meta?: PtyReplayDataMeta): void => {
|
||||
if (isCurrent()) {
|
||||
replayDataCallback(data, meta, generation)
|
||||
}
|
||||
@@ -6792,6 +6809,43 @@ export function connectPanePty(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an authoritative snapshot to the pane's kitty mirror in the order:
|
||||
* unproven reset, replay-semantics scan of the snapshot
|
||||
* bytes (so screen selection lands first), then the owner's proven flags.
|
||||
*
|
||||
* Why the reset only happens when the owner proved something: an old host
|
||||
* omits the field, and downgrading a mirror that is already tracking live
|
||||
* output would lose correct state instead of preserving it.
|
||||
*/
|
||||
function applySnapshotKittyKeyboardModes(
|
||||
snapshotData: string,
|
||||
snapshot: { kittyKeyboardFlags?: number; snapshotSeq?: number }
|
||||
): void {
|
||||
const proven =
|
||||
snapshot.snapshotSeq === undefined
|
||||
? undefined
|
||||
: parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags)
|
||||
if (proven === undefined) {
|
||||
// Why the demotion: a mirror grounded in this PTY's stream keeps its
|
||||
// state, but a constructor-fresh tracker (window reload) holds a
|
||||
// known-zero that was never proven for the reattached PTY — demote it
|
||||
// so the serializer cannot republish it downstream as host-proven
|
||||
// inactive.
|
||||
if (!kittyKeyboardModes.hasProvenBaseline) {
|
||||
kittyKeyboardModes.resetForSnapshot()
|
||||
}
|
||||
kittyKeyboardModes.scanReplay(snapshotData)
|
||||
return
|
||||
}
|
||||
kittyKeyboardModes.resetForSnapshot()
|
||||
kittyKeyboardModes.scanReplay(snapshotData)
|
||||
kittyKeyboardModes.restoreSnapshotFlags(proven)
|
||||
// Why in the same critical section: without the baseline a quiet pane
|
||||
// could not publish a coherent snapshot until unrelated output arrived.
|
||||
recordRendererOrderedSeq({ seq: snapshot.snapshotSeq })
|
||||
}
|
||||
|
||||
function recordRendererOrderedSeq(meta?: Pick<PtyDataMeta, 'seq'>): void {
|
||||
if (typeof meta?.seq !== 'number') {
|
||||
return
|
||||
@@ -7208,6 +7262,7 @@ export function connectPanePty(
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
kittyKeyboardFlags?: number
|
||||
}): Promise<void> {
|
||||
const restorePtyId = transport.getPtyId()
|
||||
const restoreGeneration = hiddenOutputRestoreGeneration
|
||||
@@ -7258,6 +7313,13 @@ export function connectPanePty(
|
||||
suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
// Why here and not from the writes below: the mirror tracks what the
|
||||
// APPLICATION negotiated, so it must see the snapshot's own bytes
|
||||
// before adopting the flags main proved for this boundary.
|
||||
applySnapshotKittyKeyboardModes(`${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`, {
|
||||
kittyKeyboardFlags: snapshot.kittyKeyboardFlags,
|
||||
snapshotSeq: snapshot.seq
|
||||
})
|
||||
// Why shared: the SSH reattach model paint inlines the same
|
||||
// choreography (coordinator nesting would deadlock there); one
|
||||
// builder keeps the alt-screen branches from drifting.
|
||||
@@ -7981,7 +8043,10 @@ export function connectPanePty(
|
||||
suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
kittyKeyboardModes.scanReplay(modelData)
|
||||
applySnapshotKittyKeyboardModes(modelData, {
|
||||
kittyKeyboardFlags: snapshot.kittyKeyboardFlags,
|
||||
snapshotSeq: snapshot.seq
|
||||
})
|
||||
// Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit.
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) {
|
||||
writeReplayData(replayChunk)
|
||||
@@ -8202,7 +8267,10 @@ export function connectPanePty(
|
||||
}
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
// Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload.
|
||||
kittyKeyboardModes.scanReplay(daemonSnapshotReplay)
|
||||
applySnapshotKittyKeyboardModes(daemonSnapshotReplay, {
|
||||
kittyKeyboardFlags: connectResult.snapshotKittyKeyboardFlags,
|
||||
snapshotSeq: connectResult.snapshotSeq
|
||||
})
|
||||
// A narrower fit clips the fixed-grid alt frame; drop it and let SIGWINCH repaint.
|
||||
// A dead-owner restore keeps history plus its restored-session treatment;
|
||||
// a frozen foreign-width frame would look live when no owner remains.
|
||||
@@ -8270,7 +8338,10 @@ export function connectPanePty(
|
||||
suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
kittyKeyboardModes.scanReplay(modelData)
|
||||
applySnapshotKittyKeyboardModes(modelData, {
|
||||
kittyKeyboardFlags: modelSnapshot.kittyKeyboardFlags,
|
||||
snapshotSeq: modelSnapshot.seq
|
||||
})
|
||||
// Why shared: park+reveal of an alt-screen TUI needs the same
|
||||
// ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips
|
||||
// the ?1049h marker when splitting scrollbackAnsi) — inlined here
|
||||
@@ -8309,6 +8380,11 @@ export function connectPanePty(
|
||||
// Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication.
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
// Why: raw relay replay may contain the app's own kitty pushes; re-arm with set semantics so redelivery can't grow the stack.
|
||||
// A constructor-fresh mirror (window reload) first demotes to unproven:
|
||||
// the replay window proves nothing about negotiations that predate it.
|
||||
if (!kittyKeyboardModes.hasProvenBaseline) {
|
||||
kittyKeyboardModes.resetForSnapshot()
|
||||
}
|
||||
kittyKeyboardModes.scanReplay(connectResult.replay)
|
||||
writeReplayData(connectResult.replay)
|
||||
writeReplayData(
|
||||
|
||||
@@ -41,6 +41,21 @@ export type PtyBufferSnapshot = {
|
||||
* before post-snapshot live chunks — so the continuation completes it
|
||||
* exactly as live instead of rendering literal (Bug E / #7329). */
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Effective kitty flags the owner of this image proved at `seq`. Absent
|
||||
* means unknown; never rewrite that silence into a known `0`. */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
/** Metadata for one authoritative replay payload. */
|
||||
export type PtyReplayDataMeta = {
|
||||
clearBeforeReplay?: boolean
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Kitty flags the snapshot's owner PROVED at `snapshotSeq`. Absent means
|
||||
* unknown; the pane tracker must stay unproven rather than assume zero. */
|
||||
kittyKeyboardFlags?: number
|
||||
/** The boundary `kittyKeyboardFlags` describes, recorded as the renderer's
|
||||
* ordered high-water so a quiet pane can still publish a coherent snapshot. */
|
||||
snapshotSeq?: number
|
||||
}
|
||||
|
||||
export type LocalPtySessionMetadata = {
|
||||
@@ -67,6 +82,11 @@ export type PtyConnectResult = {
|
||||
snapshotFrameAnsi?: string
|
||||
/** Live state to append when omitting `snapshotFrameAnsi`. */
|
||||
snapshotFrameRestoreAnsi?: string
|
||||
/** Kitty keyboard flags the daemon snapshot proved, paired with the renderer-
|
||||
* domain `snapshotSeq` main reconciled for the same attach boundary. Absent
|
||||
* means unknown, never a proven inactive protocol. */
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
snapshotSeq?: number
|
||||
isAlternateScreen?: boolean
|
||||
sessionExpired?: boolean
|
||||
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
|
||||
@@ -86,10 +106,7 @@ type PtyCallbacks = {
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onData?: (data: string, meta?: PtyDataMeta) => void
|
||||
onReplayData?: (
|
||||
data: string,
|
||||
meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string }
|
||||
) => void
|
||||
onReplayData?: (data: string, meta?: PtyReplayDataMeta) => void
|
||||
onStatus?: (shell: string) => void
|
||||
onError?: (message: string, errors?: string[]) => void
|
||||
onExit?: (code: number) => void
|
||||
|
||||
@@ -66,6 +66,7 @@ export type {
|
||||
LocalPtySessionMetadata,
|
||||
PtyBufferSnapshot,
|
||||
PtyConnectResult,
|
||||
PtyReplayDataMeta,
|
||||
PtyTransport
|
||||
} from './pty-transport-types'
|
||||
export { extractLastOscTitle } from '../../../../shared/agent-detection'
|
||||
@@ -104,6 +105,9 @@ type ProcessPtyOutputOptions = {
|
||||
clearBeforeReplay?: boolean
|
||||
// Why: a mid-escape tail; the replay consumer writes it LAST (after the post-replay reset) so the next live chunk completes it, not renders it literally (#7329).
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Kitty flags the snapshot owner proved at `snapshotSeq`. */
|
||||
kittyKeyboardFlags?: number
|
||||
snapshotSeq?: number
|
||||
}
|
||||
|
||||
type PendingPtySideEffect = {
|
||||
@@ -496,7 +500,13 @@ export function createPtyOutputProcessor({
|
||||
...(options.clearBeforeReplay === false ? { clearBeforeReplay: false } : {}),
|
||||
...(options.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: options.pendingEscapeTailAnsi }
|
||||
: {})
|
||||
: {}),
|
||||
// Why forwarded together: the flags are only valid for the boundary the
|
||||
// image describes, so they never travel without their sequence.
|
||||
...(options.kittyKeyboardFlags !== undefined
|
||||
? { kittyKeyboardFlags: options.kittyKeyboardFlags }
|
||||
: {}),
|
||||
...(options.snapshotSeq !== undefined ? { snapshotSeq: options.snapshotSeq } : {})
|
||||
}
|
||||
// Why: preserve the bare-data call shape when there's no replay metadata, so eager-buffer replay (which passes none) is unchanged.
|
||||
if (Object.keys(replayMeta).length > 0) {
|
||||
|
||||
@@ -1768,6 +1768,14 @@ export function createRemoteRuntimePtyTransport(
|
||||
suppressAttentionEvents: true,
|
||||
...(meta?.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi }
|
||||
: {}),
|
||||
// Why both or neither: the host's flags describe this image's own
|
||||
// boundary, so an unsequenced snapshot proves nothing.
|
||||
...(meta?.kittyKeyboardFlags !== undefined && meta.seq !== undefined
|
||||
? {
|
||||
kittyKeyboardFlags: meta.kittyKeyboardFlags,
|
||||
snapshotSeq: meta.seq
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,12 +10,17 @@ import { KittyKeyboard } from '@xterm/xterm/src/common/input/KittyKeyboard'
|
||||
*/
|
||||
const KITTY_REPORT_ALL_KEYS_AS_ESCAPE_CODES = 0b1000
|
||||
|
||||
/** `report_event_types`. The only flag that makes a printable press owe a release. */
|
||||
const KITTY_REPORT_EVENT_TYPES = 0b0010
|
||||
|
||||
/**
|
||||
* `KittyKeyboardEventType.PRESS` / `.REPEAT`. Inlined because the upstream enum is a
|
||||
* `const enum`, which does not survive an import across module boundaries.
|
||||
* `KittyKeyboardEventType.PRESS` / `.REPEAT` / `.RELEASE`. Inlined because the
|
||||
* upstream enum is a `const enum`, which does not survive an import across
|
||||
* module boundaries.
|
||||
*/
|
||||
const KITTY_EVENT_TYPE_PRESS = 1
|
||||
const KITTY_EVENT_TYPE_REPEAT = 2
|
||||
const KITTY_EVENT_TYPE_RELEASE = 3
|
||||
|
||||
const kittyKeyboardEncoder = new KittyKeyboard()
|
||||
|
||||
@@ -28,17 +33,70 @@ export type ImeCommitKeyPress = {
|
||||
repeat?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The matching physical release, read from the actual `keyup`.
|
||||
*
|
||||
* Why not reuse the press's fields: macOS lets Shift come up first, and the
|
||||
* browser then reports the printable keyup with the UNSHIFTED `key` and
|
||||
* `shiftKey: false`. Encoding that release from the press's shifted fields
|
||||
* would report a modifier the app no longer holds.
|
||||
*/
|
||||
export type ImeReleaseKeyEvent = {
|
||||
key: string
|
||||
code?: string
|
||||
shiftKey: boolean
|
||||
ctrlKey?: boolean
|
||||
altKey?: boolean
|
||||
metaKey?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque proof that a delivered press/repeat owes a release report, carrying
|
||||
* the flags read at commit time. Only `encodeImeReleaseForKitty` interprets it:
|
||||
* the forwarder must never re-test event-type bits or rebuild CSI-u itself.
|
||||
*/
|
||||
export type ImeCommitReleaseObligation = { readonly flags: number }
|
||||
|
||||
export type ImeCommitKittyEncoding = {
|
||||
/** CSI-u press/repeat report, or null when the commit goes out as raw text. */
|
||||
report: string | null
|
||||
/** Non-null when the commit-time flags requested press/repeat/release events. */
|
||||
release: ImeCommitReleaseObligation | null
|
||||
}
|
||||
|
||||
function evaluateKittyReport(
|
||||
press: { key: string; code?: string; shiftKey: boolean },
|
||||
modifiers: { ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean },
|
||||
kittyKeyboardFlags: number,
|
||||
eventType: number
|
||||
): string | null {
|
||||
const encoded = kittyKeyboardEncoder.evaluate(
|
||||
{
|
||||
type: eventType === KITTY_EVENT_TYPE_RELEASE ? 'keyup' : 'keydown',
|
||||
key: press.key,
|
||||
code: press.code ?? '',
|
||||
keyCode: 0,
|
||||
shiftKey: press.shiftKey,
|
||||
altKey: modifiers.altKey === true,
|
||||
ctrlKey: modifiers.ctrlKey === true,
|
||||
metaKey: modifiers.metaKey === true
|
||||
},
|
||||
kittyKeyboardFlags,
|
||||
eventType
|
||||
)
|
||||
return encoded.key ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane that negotiated bit 3 asked for every printable key as a CSI-u report,
|
||||
* so writing IME-committed text raw hands it the legacy byte stream it declined.
|
||||
* Re-encode the press that produced the commit instead.
|
||||
*
|
||||
* The gate is bit 3 ALONE. "Kitty is active" and `flags !== 0` are both wrong:
|
||||
* a pane negotiating only disambiguation or event types still expects printable
|
||||
* keys as text, and encoding there would drop every substituted character.
|
||||
*
|
||||
* Returns null when the commit should be written raw, which is every case except
|
||||
* bit 3.
|
||||
* The gate is bit 3 ALONE for the press. "Kitty is active" and `flags !== 0` are
|
||||
* both wrong: a pane negotiating only disambiguation or event types still
|
||||
* expects printable keys as text, and encoding there would drop every
|
||||
* substituted character. Bit 1 (`report_event_types`) is independent — it makes
|
||||
* even a raw-text commit owe exactly one release report.
|
||||
*
|
||||
* Known limit: the report carries the *physical* key's codepoint, not the
|
||||
* committed glyph — bit 3 is the app declaring it does not want text, and bit 4
|
||||
@@ -46,31 +104,65 @@ export type ImeCommitKeyPress = {
|
||||
* derives that text field from the same `key` it derives the keycode from, so
|
||||
* carrying the committed glyph under bit 4 needs an encoder change, not a flag.
|
||||
*/
|
||||
export function encodeImeCommitAsKittyReport(
|
||||
export function encodeImeCommitForKitty(
|
||||
press: ImeCommitKeyPress | null,
|
||||
kittyKeyboardFlags: number
|
||||
): ImeCommitKittyEncoding {
|
||||
if (!press) {
|
||||
return { report: null, release: null }
|
||||
}
|
||||
const report =
|
||||
(kittyKeyboardFlags & KITTY_REPORT_ALL_KEYS_AS_ESCAPE_CODES) === 0
|
||||
? null
|
||||
: evaluateKittyReport(
|
||||
press,
|
||||
// Why: the forwarder only claims presses with no control chord, so the
|
||||
// modifier fields are known-false rather than read from a live event.
|
||||
{},
|
||||
kittyKeyboardFlags,
|
||||
// Why: a held key emits repeated keydowns, and the protocol reports those as REPEAT.
|
||||
// Defaulting them all to PRESS would make one held key look like N separate strikes to
|
||||
// an app that counts presses or filters repeats.
|
||||
press.repeat === true ? KITTY_EVENT_TYPE_REPEAT : KITTY_EVENT_TYPE_PRESS
|
||||
)
|
||||
return {
|
||||
report,
|
||||
release:
|
||||
(kittyKeyboardFlags & KITTY_REPORT_EVENT_TYPES) === 0 ? null : { flags: kittyKeyboardFlags }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a press's release obligation against the physical `keyup` that
|
||||
* actually settled it. Returns null when no release report is owed anymore.
|
||||
*/
|
||||
export function encodeImeReleaseForKitty(
|
||||
obligation: ImeCommitReleaseObligation,
|
||||
release: ImeReleaseKeyEvent,
|
||||
context: {
|
||||
/** The claimed press's identity, for keyups whose `key` an input source rewrote. */
|
||||
press?: { key: string; code?: string }
|
||||
/** The pane's flags at RELEASE time, deciding whether a release is still wanted. */
|
||||
currentKittyKeyboardFlags: number
|
||||
}
|
||||
): string | null {
|
||||
if ((kittyKeyboardFlags & KITTY_REPORT_ALL_KEYS_AS_ESCAPE_CODES) === 0 || !press) {
|
||||
// Why the current-flags gate: the app can pop kitty mode between commit and
|
||||
// keyup (a TUI quitting on the pressed key). xterm suppresses releases the
|
||||
// moment `report_event_types` is gone — match it so the successor process
|
||||
// never receives CSI-u bytes it did not negotiate. The report itself still
|
||||
// encodes under the commit-time flags so the press/release pair stays
|
||||
// consistent.
|
||||
if ((context.currentKittyKeyboardFlags & KITTY_REPORT_EVENT_TYPES) === 0) {
|
||||
return null
|
||||
}
|
||||
// Why: the forwarder only claims presses with no control chord, so the
|
||||
// modifier fields are known-false rather than read from a live event.
|
||||
const encoded = kittyKeyboardEncoder.evaluate(
|
||||
{
|
||||
type: 'keydown',
|
||||
key: press.key,
|
||||
code: press.code ?? '',
|
||||
keyCode: 0,
|
||||
shiftKey: press.shiftKey,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false
|
||||
},
|
||||
kittyKeyboardFlags,
|
||||
// Why: a held key emits repeated keydowns, and the protocol reports those as REPEAT.
|
||||
// Defaulting them all to PRESS would make one held key look like N separate strikes to
|
||||
// an app that counts presses or filters repeats.
|
||||
press.repeat === true ? KITTY_EVENT_TYPE_REPEAT : KITTY_EVENT_TYPE_PRESS
|
||||
)
|
||||
return encoded.key ?? null
|
||||
// Why the key fallback: an input source can rewrite the keyup's `key`
|
||||
// ('Process' after a source switch mid-hold) while `code` still matches the
|
||||
// press; xterm's evaluate finds no encodable key there and would silently
|
||||
// drop the release, leaving the app with the key held forever. The keyup's
|
||||
// own single-char `key` wins (Shift-up-first correctly reports unshifted).
|
||||
const releaseKey =
|
||||
release.key.length === 1 || context.press === undefined
|
||||
? release
|
||||
: { ...release, key: context.press.key, code: context.press.code ?? release.code }
|
||||
return evaluateKittyReport(releaseKey, release, obligation.flags, KITTY_EVENT_TYPE_RELEASE)
|
||||
}
|
||||
|
||||
+87
-7
@@ -180,10 +180,10 @@ describe('installTerminalImeNativeTextForwarder', () => {
|
||||
)
|
||||
dispatchInsertText(textarea, 'á')
|
||||
|
||||
// The keypress is what must be suppressed (it would double-send the ASCII 'a').
|
||||
// The keyup is not: 'á' reached the pty, so the app is owed the matching release.
|
||||
// The keypress must be suppressed (it would double-send the ASCII 'a'), and so must the
|
||||
// keyup — the forwarder owns the whole claimed lifecycle and emits any owed release itself.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'a', code: 'KeyA' }))).toBe(
|
||||
false
|
||||
true
|
||||
)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('á')
|
||||
})
|
||||
@@ -273,13 +273,15 @@ describe('installTerminalImeNativeTextForwarder', () => {
|
||||
})
|
||||
|
||||
// An app that negotiated kitty `report_event_types` expects a release for every press it
|
||||
// received. The claim now takes every printable keydown, so swallowing the keyup
|
||||
// unconditionally would drop the release for ordinary typing and leave keys stuck down.
|
||||
it('lets the keyup through once the press has reached the pty, so its release still fires', () => {
|
||||
// received, but xterm's own kitty state is defensively reset while the application tracker
|
||||
// stays active — so the forwarder keeps the keyup and encodes the release from the flags it
|
||||
// read at commit time rather than delegating either decision to xterm.
|
||||
it('keeps the keyup inside the forwarder once the press has reached the pty', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(false)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
// Flags default to 0 here: no event types negotiated, so no release is owed.
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
@@ -292,6 +294,35 @@ describe('installTerminalImeNativeTextForwarder', () => {
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the claim armed across a bare modifier keydown before the commit lands', () => {
|
||||
// Fast typing: ',' is pressed and released, then Shift goes down for the
|
||||
// NEXT character while the text system is still delivering ','.
|
||||
const { forwarder, sendInput } = install()
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
expect(
|
||||
forwarder.claimKeyEvent(keyEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }))
|
||||
).toBe(false)
|
||||
dispatchInsertText(textarea, ',')
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith(',')
|
||||
})
|
||||
|
||||
it('lets a chorded keyup reach xterm after a fresh chorded press of the same key', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
// A claimed press whose input never arrived and whose keyup was swallowed
|
||||
// elsewhere leaves a tombstone behind.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'n', code: 'KeyN' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'x', code: 'KeyX' }))).toBe(true)
|
||||
// Ctrl+N is xterm's press; its keyup must not be eaten by the stale tombstone.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'n', code: 'KeyN', ctrlKey: true }))).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'n', code: 'KeyN', ctrlKey: true }))
|
||||
).toBe(false)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the claim on blur', () => {
|
||||
const { forwarder, sendInput } = install()
|
||||
forwarder.claimKeyEvent(keyEvent({ key: ',' }))
|
||||
@@ -396,6 +427,55 @@ describe('installTerminalImeNativeTextForwarder', () => {
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits the owed release before a same-key re-claim when the input event never came', () => {
|
||||
const { forwarder, sendInput } = installWithFlags(() => 0b1010)
|
||||
// The first press delivers and owes a release.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
// An auto-repeat claim whose text the input source swallows absorbs the
|
||||
// eventual keyup; the next fresh press must settle that owed release
|
||||
// instead of deleting the record that holds it.
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',', repeat: true }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: ',' }))).toBe(true)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
expect(sendInput.mock.calls.map((call) => call[0])).toEqual(['\x1b[44u', '\x1b[44;1:3u'])
|
||||
})
|
||||
|
||||
it('settles an owed release before a fresh same-key press after a lost keyup', () => {
|
||||
const { forwarder, sendInput } = installWithFlags(() => 0b1010)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',', code: 'Comma' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',', code: 'Comma' }))).toBe(true)
|
||||
expect(sendInput.mock.calls.map((call) => call[0])).toEqual(['\x1b[44u', '\x1b[44;1:3u'])
|
||||
})
|
||||
|
||||
it('suppresses the owed release when the app popped kitty mode before the keyup', () => {
|
||||
// A TUI that quits on the pressed key pops its negotiation before the
|
||||
// keyup; a CSI-u release would land in the successor shell as junk.
|
||||
let flags = 0b1010
|
||||
const { forwarder, sendInput } = installWithFlags(() => flags)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: 'q', code: 'KeyQ' }))).toBe(true)
|
||||
dispatchInsertText(textarea, 'q')
|
||||
flags = 0
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'q', code: 'KeyQ' }))).toBe(
|
||||
true
|
||||
)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('\x1b[113u')
|
||||
})
|
||||
|
||||
it('encodes the release from the press when an input source rewrote the keyup key', () => {
|
||||
const { forwarder, sendInput } = installWithFlags(() => 0b1010)
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true)
|
||||
dispatchInsertText(textarea, ',')
|
||||
// Chromium reports 'Process' for IME-owned keys after a source switch
|
||||
// mid-hold; `code` still identifies the physical press.
|
||||
expect(
|
||||
forwarder.claimKeyEvent(keyEvent({ type: 'keyup', key: 'Process', code: 'Comma' }))
|
||||
).toBe(true)
|
||||
expect(sendInput.mock.calls.map((call) => call[0])).toEqual(['\x1b[44u', '\x1b[44;1:3u'])
|
||||
})
|
||||
|
||||
it('writes the commit raw when the caller tracks no flags at all', () => {
|
||||
// The preview bridge installs the forwarder with no pane to negotiate with.
|
||||
const sendInput = vi.fn()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import { encodeImeCommitAsKittyReport } from './terminal-ime-kitty-commit-encoding'
|
||||
import {
|
||||
encodeImeCommitForKitty,
|
||||
encodeImeReleaseForKitty,
|
||||
type ImeCommitReleaseObligation,
|
||||
type ImeReleaseKeyEvent
|
||||
} from './terminal-ime-kitty-commit-encoding'
|
||||
|
||||
// Why: a plain printable keydown never produces terminal bytes. Bytes for
|
||||
// printable characters come only from the `input` event, which on macOS *is*
|
||||
@@ -18,6 +23,38 @@ type ClaimedKeyPress = {
|
||||
repeat?: boolean
|
||||
}
|
||||
|
||||
/** A claimed press waiting for its `insertText`, plus an early keyup if one already landed. */
|
||||
type PendingCommit = {
|
||||
press: ClaimedKeyPress
|
||||
/** Non-null when `keyup` preceded `insertText` — an order some macOS IMEs use. */
|
||||
keyup: ImeReleaseKeyEvent | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What a claimed physical key still owes once its commit settled. A non-null
|
||||
* `obligation` is an encoder-owned release report; a null one is a tombstone
|
||||
* that only keeps the matching keyup away from xterm for a press the app
|
||||
* never received.
|
||||
*/
|
||||
type ClaimedKeyRecord = {
|
||||
key: string
|
||||
code?: string
|
||||
obligation: ImeCommitReleaseObligation | null
|
||||
}
|
||||
|
||||
// Bare modifier keydowns produce no terminal bytes and legitimately interleave
|
||||
// between a claimed press and its commit (Shift pressed for the NEXT character
|
||||
// before the text system delivers this one's `insertText`).
|
||||
const MODIFIER_KEYDOWN_KEYS = new Set([
|
||||
'Shift',
|
||||
'Control',
|
||||
'Alt',
|
||||
'Meta',
|
||||
'CapsLock',
|
||||
'AltGraph',
|
||||
'Fn'
|
||||
])
|
||||
|
||||
export type ImeNativeTextKeyEvent = {
|
||||
type: string
|
||||
key: string
|
||||
@@ -76,13 +113,21 @@ function isNativeTextKeydown(event: ImeNativeTextKeyEvent, compositionActive: bo
|
||||
)
|
||||
}
|
||||
|
||||
function matchesClaimedPress(event: ImeNativeTextKeyEvent, claimedPress: ClaimedKeyPress): boolean {
|
||||
function matchesClaimedPress(
|
||||
event: ImeNativeTextKeyEvent,
|
||||
claimedPress: { key: string; code?: string }
|
||||
): boolean {
|
||||
if (event.code && claimedPress.code) {
|
||||
return event.code === claimedPress.code
|
||||
}
|
||||
return event.key === claimedPress.key
|
||||
}
|
||||
|
||||
/** Different held keys coexist, so records are keyed by physical identity. */
|
||||
function claimedKeyId(press: { key: string; code?: string }): string {
|
||||
return press.code ?? press.key
|
||||
}
|
||||
|
||||
export function installTerminalImeNativeTextForwarder(args: {
|
||||
terminalElement: HTMLElement | null | undefined
|
||||
isComposing: () => boolean
|
||||
@@ -102,11 +147,64 @@ export function installTerminalImeNativeTextForwarder(args: {
|
||||
}
|
||||
|
||||
const terminalElement = args.terminalElement
|
||||
let pendingForward = false
|
||||
let compositionTransactionPending = false
|
||||
let claimedPress: ClaimedKeyPress | null = null
|
||||
/** Whether the claimed press actually reached the pty, which decides if its release does. */
|
||||
let forwardedPressBytes = false
|
||||
let pendingCommit: PendingCommit | null = null
|
||||
// Why keyed rather than a single slot: rollover and auto-repeat can leave one
|
||||
// key still held while the next one commits, and each held key owes its own
|
||||
// single release.
|
||||
const claimedKeyRecords = new Map<string, ClaimedKeyRecord>()
|
||||
|
||||
const findClaimedRecordId = (event: ImeNativeTextKeyEvent): string | null => {
|
||||
const direct = claimedKeyId(event)
|
||||
if (claimedKeyRecords.has(direct)) {
|
||||
return direct
|
||||
}
|
||||
for (const [id, record] of claimedKeyRecords) {
|
||||
if (matchesClaimedPress(event, record)) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Emits at most one release for a claimed press; xterm emits none for it. */
|
||||
const settleRelease = (recordId: string, release: ImeReleaseKeyEvent): void => {
|
||||
const record = claimedKeyRecords.get(recordId)
|
||||
claimedKeyRecords.delete(recordId)
|
||||
if (!record?.obligation) {
|
||||
return
|
||||
}
|
||||
const report = encodeImeReleaseForKitty(record.obligation, release, {
|
||||
press: { key: record.key, code: record.code },
|
||||
currentKittyKeyboardFlags: args.getKittyKeyboardFlags?.() ?? 0
|
||||
})
|
||||
if (report) {
|
||||
args.sendInput(report)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a claimed press out of awaiting-commit state. A fresh obligation is
|
||||
* upserted; a repeat or cancellation that produced none must not erase one an
|
||||
* earlier delivered press already owed.
|
||||
*/
|
||||
const settleCommit = (
|
||||
commit: PendingCommit,
|
||||
obligation: ImeCommitReleaseObligation | null
|
||||
): void => {
|
||||
const recordId = claimedKeyId(commit.press)
|
||||
const existing = claimedKeyRecords.get(recordId)
|
||||
if (obligation || !existing) {
|
||||
claimedKeyRecords.set(recordId, {
|
||||
key: commit.press.key,
|
||||
code: commit.press.code,
|
||||
obligation: obligation ?? existing?.obligation ?? null
|
||||
})
|
||||
}
|
||||
if (commit.keyup) {
|
||||
settleRelease(recordId, commit.keyup)
|
||||
}
|
||||
}
|
||||
|
||||
const markCompositionTransactionAccepted = (): void => {
|
||||
compositionTransactionPending = true
|
||||
@@ -118,44 +216,85 @@ export function installTerminalImeNativeTextForwarder(args: {
|
||||
|
||||
const claimKeyEvent = (event: ImeNativeTextKeyEvent): boolean => {
|
||||
if (event.type === 'keydown') {
|
||||
if (pendingCommit && matchesClaimedPress(event, pendingCommit.press)) {
|
||||
// The same key pressed again while its claim still awaits `input`:
|
||||
// settle first so an absorbed early keyup's owed release is emitted
|
||||
// rather than silently overwritten.
|
||||
settleCommit(pendingCommit, null)
|
||||
pendingCommit = null
|
||||
} else if (pendingCommit && !MODIFIER_KEYDOWN_KEYS.has(event.key)) {
|
||||
// Why: retiring here is also what drops a stale claim whose input event
|
||||
// never arrived (the input source swallowed the key) — no timer needed.
|
||||
// It leaves a tombstone so the stale press's keyup still cannot reach
|
||||
// xterm, which never saw its keydown. Bare modifier keydowns are
|
||||
// exempt: they precede a still-inbound commit during fast typing and
|
||||
// must not retire the claim it belongs to.
|
||||
settleCommit(pendingCommit, null)
|
||||
pendingCommit = null
|
||||
}
|
||||
if (event.repeat !== true) {
|
||||
// Why: a fresh same-key press proves the prior press ended; settle its owed release first.
|
||||
const staleRecordId = findClaimedRecordId(event)
|
||||
if (staleRecordId !== null) {
|
||||
settleRelease(staleRecordId, {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
ctrlKey: event.ctrlKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!isNativeTextKeydown(event, args.isComposing())) {
|
||||
return false
|
||||
}
|
||||
// Why: re-arming here is also what drops a stale claim whose input event
|
||||
// never arrived (the input source swallowed the key) — no timer needed.
|
||||
pendingForward = true
|
||||
forwardedPressBytes = false
|
||||
claimedPress = {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
repeat: event.repeat === true
|
||||
pendingCommit = {
|
||||
press: {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
repeat: event.repeat === true
|
||||
},
|
||||
keyup: null
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!claimedPress) {
|
||||
return false
|
||||
}
|
||||
if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing === true) {
|
||||
return false
|
||||
}
|
||||
if (event.type === 'keyup') {
|
||||
if (!matchesClaimedPress(event, claimedPress)) {
|
||||
if (pendingCommit && matchesClaimedPress(event, pendingCommit.press)) {
|
||||
// Copy the release's own fields: the commit has not happened yet, and
|
||||
// the encoder needs the modifier state as of the actual release.
|
||||
pendingCommit.keyup = {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
ctrlKey: event.ctrlKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey
|
||||
}
|
||||
return true
|
||||
}
|
||||
const recordId = findClaimedRecordId(event)
|
||||
if (recordId === null) {
|
||||
return false
|
||||
}
|
||||
const pressReachedThePty = forwardedPressBytes
|
||||
claimedPress = null
|
||||
forwardedPressBytes = false
|
||||
// Why: a release report describes a press the app received. Suppress it only when
|
||||
// this press put nothing on the wire — swallowed by the input source, or owned by a
|
||||
// composition transaction. Suppressing unconditionally would drop the kitty release
|
||||
// for ordinary typing, because the structural claim takes every printable keydown
|
||||
// rather than the short punctuation list the previous design claimed.
|
||||
return !pressReachedThePty
|
||||
// Why the forwarder owns this instead of returning false: xterm's kitty
|
||||
// state is defensively reset while the application tracker stays active,
|
||||
// so delegating the release would either lose it or emit it under flags
|
||||
// the app never negotiated.
|
||||
settleRelease(recordId, {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
ctrlKey: event.ctrlKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey
|
||||
})
|
||||
return true
|
||||
}
|
||||
// Keep the keydown's armed state but still bypass xterm so it does not
|
||||
// double-send printable text before our input forward runs.
|
||||
return event.type === 'keypress'
|
||||
return event.type === 'keypress' && pendingCommit !== null
|
||||
}
|
||||
|
||||
const forwardCommittedText = (event: Event): void => {
|
||||
@@ -165,24 +304,33 @@ export function installTerminalImeNativeTextForwarder(args: {
|
||||
// Why: an accepted composition transaction already owns its commit; letting
|
||||
// it through here would send the text a second time.
|
||||
if (compositionTransactionPending && event.inputType === 'insertText') {
|
||||
pendingForward = false
|
||||
if (pendingCommit) {
|
||||
settleCommit(pendingCommit, null)
|
||||
pendingCommit = null
|
||||
}
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
if (!pendingForward) {
|
||||
const commit = pendingCommit
|
||||
if (!commit) {
|
||||
return
|
||||
}
|
||||
pendingForward = false
|
||||
pendingCommit = null
|
||||
if (event.inputType !== 'insertText') {
|
||||
// A non-text input takes the press over; it delivered nothing, so only the
|
||||
// keyup suppression survives.
|
||||
settleCommit(commit, null)
|
||||
return
|
||||
}
|
||||
if (event.data) {
|
||||
const kittyReport = encodeImeCommitAsKittyReport(
|
||||
claimedPress,
|
||||
args.getKittyKeyboardFlags?.() ?? 0
|
||||
)
|
||||
args.sendInput(kittyReport ?? event.data)
|
||||
forwardedPressBytes = true
|
||||
// Read the mutable flags EXACTLY once, here: kitty state can change
|
||||
// between keydown and commit, and the release must describe the same
|
||||
// negotiation the press was encoded under.
|
||||
const encoding = encodeImeCommitForKitty(commit.press, args.getKittyKeyboardFlags?.() ?? 0)
|
||||
args.sendInput(encoding.report ?? event.data)
|
||||
settleCommit(commit, encoding.release)
|
||||
} else {
|
||||
settleCommit(commit, null)
|
||||
}
|
||||
event.stopImmediatePropagation()
|
||||
// Clear the helper textarea so the committed text doesn't accumulate.
|
||||
@@ -191,11 +339,13 @@ export function installTerminalImeNativeTextForwarder(args: {
|
||||
}
|
||||
}
|
||||
|
||||
// Blur and disposal drop every record without synthesizing bytes: the app
|
||||
// stops receiving this element's keys entirely, so an invented release would
|
||||
// describe a press it can no longer correlate.
|
||||
const cancelPending = (): void => {
|
||||
pendingForward = false
|
||||
forwardedPressBytes = false
|
||||
compositionTransactionPending = false
|
||||
claimedPress = null
|
||||
pendingCommit = null
|
||||
claimedKeyRecords.clear()
|
||||
}
|
||||
|
||||
terminalElement.addEventListener(
|
||||
|
||||
+16
-4
@@ -195,10 +195,8 @@ describe('input-source text substitution reaches the terminal', () => {
|
||||
describe.each([
|
||||
['none', 0],
|
||||
['disambiguate', 1],
|
||||
['event types', 2],
|
||||
['alternate keys', 4],
|
||||
['disambiguate + alternate keys', 5],
|
||||
['disambiguate + event types + alternate keys', 7],
|
||||
// Bit 4 asks for associated text, which only decorates a report bit 3 would already have
|
||||
// produced — on its own it does not turn a printable key into one.
|
||||
['associated text', 16]
|
||||
@@ -208,13 +206,23 @@ describe('input-source text substitution reaches the terminal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Bit 1 (`report_event_types`) is orthogonal to bit 3: the press stays raw substituted text,
|
||||
// but the app asked to be told when the key came back up, and the forwarder owns that keyup.
|
||||
describe.each([
|
||||
['event types', 2],
|
||||
['disambiguate + event types + alternate keys', 7]
|
||||
])('with kitty flags %s (%d) negotiated', (_name, flags) => {
|
||||
it('sends the substituted character raw followed by one CSI-u release', () => {
|
||||
expect(type([COMMA], flags)).toBe(',\x1b[44;1:3u')
|
||||
})
|
||||
})
|
||||
|
||||
// Bit 3 is `report_all_keys_as_escape_codes`: the app asked for every printable key as a CSI-u
|
||||
// report, so committing raw UTF-8 hands it a byte stream it declined. Re-encode the press that
|
||||
// produced the commit. This is not CJK-specific — it is every printable key in such a pane.
|
||||
describe.each([
|
||||
['all keys as escape codes', 8],
|
||||
['all keys + disambiguate', 9],
|
||||
['all keys + disambiguate + event types + alternate keys', 15]
|
||||
['all keys + disambiguate', 9]
|
||||
])('with kitty flags %s (%d) negotiated', (_name, flags) => {
|
||||
it('sends a CSI-u report for the physical key instead of the substituted character', () => {
|
||||
// `,` is the physical Comma key; U+002C is 44. The committed `,` is deliberately absent —
|
||||
@@ -224,6 +232,10 @@ describe('input-source text substitution reaches the terminal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pairs the CSI-u press with exactly one release when event types are also negotiated', () => {
|
||||
expect(type([COMMA], 15)).toBe('\x1b[44u\x1b[44;1:3u')
|
||||
})
|
||||
|
||||
it('reports the physical key as the associated text under bit 3 + bit 4, not the substitution', () => {
|
||||
// Pins the limit named in terminal-ime-kitty-commit-encoding.ts: bit 4 is where the committed
|
||||
// glyph U+FF0C (65292) would ride, and 44 shows up in that slot instead. Closing that needs the
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
parseTerminalSnapshotUnavailableReason,
|
||||
type TerminalSnapshotUnavailableReason
|
||||
} from '../../../shared/terminal-snapshot-unavailability'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../../shared/terminal-kitty-keyboard-flags'
|
||||
import { e2eConfig, e2eDisableRemoteTerminalStallRecovery } from '@/lib/e2e-config'
|
||||
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
|
||||
import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit'
|
||||
@@ -60,7 +61,14 @@ type TerminalMultiplexEvent =
|
||||
|
||||
export type RemoteRuntimeMultiplexedTerminalCallbacks = {
|
||||
onData: (data: string, meta?: { seq?: number; rawLength?: number; transformed?: boolean }) => void
|
||||
onSnapshot: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void
|
||||
onSnapshot: (
|
||||
data: string,
|
||||
meta?: {
|
||||
pendingEscapeTailAnsi?: string
|
||||
seq?: number
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
) => void
|
||||
onSubscribed?: () => void
|
||||
onOutputPauseCapability?: () => void
|
||||
onEnd?: () => void
|
||||
@@ -84,6 +92,10 @@ export type RemoteRuntimeSnapshotImage = {
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Effective kitty flags the HOST proved at this image's own `seq`. Absent
|
||||
* from any host that predates the field — the pane tracker then stays
|
||||
* unproven and commits raw text instead of guessing zero. */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
/** Transient causes the host itself reported: a request reached it and it declined to serialize now. */
|
||||
@@ -197,6 +209,7 @@ type RemoteRuntimeSnapshotInfo = {
|
||||
rows?: number
|
||||
seq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
kittyKeyboardFlags?: number
|
||||
requestId?: number
|
||||
truncated?: boolean
|
||||
unavailable?: TerminalSnapshotUnavailableReason
|
||||
@@ -879,13 +892,16 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
rows: info?.rows ?? 24,
|
||||
seq: info?.seq,
|
||||
source: info?.source,
|
||||
kittyKeyboardFlags: info?.kittyKeyboardFlags,
|
||||
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi
|
||||
}
|
||||
})
|
||||
clearPendingSnapshotRequest(stream)
|
||||
} else if (target === 'initial') {
|
||||
stream.callbacks.onSnapshot(data ?? '', {
|
||||
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi
|
||||
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi,
|
||||
seq: info?.seq,
|
||||
kittyKeyboardFlags: info?.kittyKeyboardFlags
|
||||
})
|
||||
} else if (target === 'recovery') {
|
||||
// Why: a server-pushed recovery snapshot replaces terminal state
|
||||
@@ -893,7 +909,9 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
// An empty snapshot is still applied so stale dropped output does
|
||||
// not linger on a terminal the model says is blank.
|
||||
stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, {
|
||||
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi
|
||||
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi,
|
||||
seq: info?.seq,
|
||||
kittyKeyboardFlags: info?.kittyKeyboardFlags
|
||||
})
|
||||
}
|
||||
} else if (matchesPendingRequest) {
|
||||
@@ -1529,6 +1547,7 @@ function decodeSnapshotInfo(
|
||||
truncated?: unknown
|
||||
unavailable?: unknown
|
||||
pendingEscapeTailAnsi?: unknown
|
||||
kittyKeyboardFlags?: unknown
|
||||
}>(payload)
|
||||
if (!raw) {
|
||||
return null
|
||||
@@ -1538,6 +1557,8 @@ function decodeSnapshotInfo(
|
||||
rows: typeof raw.rows === 'number' ? raw.rows : undefined,
|
||||
seq: typeof raw.seq === 'number' ? raw.seq : undefined,
|
||||
source: raw.source === 'headless' || raw.source === 'renderer' ? raw.source : undefined,
|
||||
// Negative, fractional, and unsafe values are treated as absent, never clamped.
|
||||
kittyKeyboardFlags: parseTerminalKittyKeyboardFlags(raw.kittyKeyboardFlags),
|
||||
requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined,
|
||||
truncated: raw.truncated === true,
|
||||
unavailable: parseTerminalSnapshotUnavailableReason(raw.unavailable),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import {
|
||||
getRemoteRuntimeTerminalMultiplexer,
|
||||
resetRemoteRuntimeTerminalMultiplexersForTests,
|
||||
type RemoteRuntimeMultiplexedTerminal
|
||||
} from './remote-runtime-terminal-multiplexer'
|
||||
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
|
||||
|
||||
// `kittyKeyboardFlags` is an additive optional field on the existing
|
||||
// SnapshotStart frame (Rule 1 of docs/reference/remote-wire-compatibility.md).
|
||||
// An old host omits it, and absence must stay unknown rather than be laundered
|
||||
// into "the host proved kitty is inactive".
|
||||
|
||||
type SubscribeCallbacks = {
|
||||
onResponse: (response: unknown) => void
|
||||
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
}
|
||||
|
||||
type SnapshotStartMeta = { requestId?: number; kittyKeyboardFlags?: unknown }
|
||||
|
||||
class KittyFlagsServer {
|
||||
private streamId = 0
|
||||
/** Meta the host attaches to its next SnapshotStart, initial and requested alike. */
|
||||
nextMeta: SnapshotStartMeta = {}
|
||||
|
||||
constructor(private readonly toClient: (bytes: Uint8Array<ArrayBufferLike>) => void) {}
|
||||
|
||||
receive(bytes: Uint8Array<ArrayBufferLike>): void {
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
if (!frame) {
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Subscribe) {
|
||||
this.streamId = decodeTerminalStreamJson<{ streamId: number }>(frame.payload)?.streamId ?? 0
|
||||
this.sendSnapshot('INITIAL', {})
|
||||
return
|
||||
}
|
||||
if (frame.opcode !== TerminalStreamOpcode.SnapshotRequest) {
|
||||
return
|
||||
}
|
||||
const requestId = decodeTerminalStreamJson<{ requestId?: number }>(frame.payload)?.requestId
|
||||
this.sendSnapshot('REQUESTED', { ...this.nextMeta, requestId })
|
||||
}
|
||||
|
||||
/** A host-pushed recovery snapshot, which arrives with no requestId. */
|
||||
pushRecovery(meta: SnapshotStartMeta): void {
|
||||
this.sendSnapshot('RECOVERED', meta)
|
||||
}
|
||||
|
||||
private sendSnapshot(data: string, meta: SnapshotStartMeta): void {
|
||||
this.send(
|
||||
TerminalStreamOpcode.SnapshotStart,
|
||||
encodeTerminalStreamJson({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
seq: 7,
|
||||
...meta
|
||||
})
|
||||
)
|
||||
this.send(TerminalStreamOpcode.SnapshotChunk, encodeTerminalStreamText(data))
|
||||
this.send(TerminalStreamOpcode.SnapshotEnd, new Uint8Array())
|
||||
}
|
||||
|
||||
private send(opcode: TerminalStreamOpcode, payload: Uint8Array): void {
|
||||
this.toClient(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode,
|
||||
streamId: this.streamId,
|
||||
seq: 0,
|
||||
payload
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('SnapshotStart kitty keyboard flags', () => {
|
||||
let server: KittyFlagsServer
|
||||
const snapshots: {
|
||||
data: string
|
||||
meta?: { seq?: number; kittyKeyboardFlags?: number }
|
||||
}[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
snapshots.length = 0
|
||||
resetRemoteRuntimeTerminalMultiplexersForTests()
|
||||
replaceRuntimeEnvironmentRevisions([])
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
subscribe: vi.fn(async (_args: unknown, callbacks: SubscribeCallbacks) => {
|
||||
server = new KittyFlagsServer((bytes) => callbacks.onBinary?.(bytes))
|
||||
queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } }))
|
||||
return {
|
||||
unsubscribe: vi.fn(),
|
||||
sendBinary: (bytes: Uint8Array<ArrayBufferLike>) => server.receive(bytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function subscribeClient(): Promise<RemoteRuntimeMultiplexedTerminal> {
|
||||
const stream = await getRemoteRuntimeTerminalMultiplexer('env-1').subscribeTerminal({
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
callbacks: {
|
||||
onData: () => {},
|
||||
onSnapshot: (data, meta) => {
|
||||
snapshots.push({ data, meta })
|
||||
}
|
||||
}
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
return stream
|
||||
}
|
||||
|
||||
it.each([
|
||||
['a proven inactive protocol', 0],
|
||||
['proven bit-3 flags', 8]
|
||||
])('decodes %s into the requested snapshot result', async (_name, flags) => {
|
||||
const stream = await subscribeClient()
|
||||
server.nextMeta = { kittyKeyboardFlags: flags }
|
||||
|
||||
const outcome = await stream.serializeBufferOutcome({
|
||||
scrollbackRows: 100
|
||||
})
|
||||
expect(outcome.snapshot).toMatchObject({
|
||||
seq: 7,
|
||||
kittyKeyboardFlags: flags
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the flags and their sequence onto a host-pushed recovery snapshot', async () => {
|
||||
await subscribeClient()
|
||||
expect(snapshots).toHaveLength(1)
|
||||
|
||||
server.pushRecovery({ kittyKeyboardFlags: 8 })
|
||||
expect(snapshots[1]?.meta).toMatchObject({ seq: 7, kittyKeyboardFlags: 8 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['negative', -1],
|
||||
['fractional', 1.5],
|
||||
['beyond safe integers', Number.MAX_SAFE_INTEGER + 2],
|
||||
['a string', '8'],
|
||||
['null', null]
|
||||
])('treats %s as absent rather than clamping it', async (_name, value) => {
|
||||
const stream = await subscribeClient()
|
||||
server.nextMeta = { kittyKeyboardFlags: value }
|
||||
|
||||
const outcome = await stream.serializeBufferOutcome({
|
||||
scrollbackRows: 100
|
||||
})
|
||||
expect(outcome.snapshot?.kittyKeyboardFlags).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts an old host that omits the field without coercing it to zero', async () => {
|
||||
const stream = await subscribeClient()
|
||||
server.nextMeta = {}
|
||||
|
||||
const outcome = await stream.serializeBufferOutcome({
|
||||
scrollbackRows: 100
|
||||
})
|
||||
expect(outcome.availability).toEqual({ kind: 'snapshot' })
|
||||
expect(outcome.snapshot?.data).toBe('REQUESTED')
|
||||
expect(outcome.snapshot?.kittyKeyboardFlags).toBeUndefined()
|
||||
expect(snapshots[0]?.meta?.kittyKeyboardFlags).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Boundary validation for the optional `kittyKeyboardFlags` snapshot field
|
||||
* shared by Electron IPC, remote JSON decoding, and tracker restore.
|
||||
*
|
||||
* Absent means the snapshot owner could not PROVE the state — an old remote
|
||||
* host, an unsequenced renderer serializer, or a source with no mode metadata.
|
||||
* That is not the same fact as a proven inactive protocol, so an unparseable
|
||||
* value returns `undefined` rather than clamping or coercing to `0`; laundering
|
||||
* it into known zero would make Preview commit raw text against a bit-3 TUI.
|
||||
*/
|
||||
export function parseTerminalKittyKeyboardFlags(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
||||
}
|
||||
@@ -147,4 +147,151 @@ describe('TerminalKittyKeyboardModeTracker', () => {
|
||||
ranAndExited.scanReplay('\x1b[>1uoutput\x1b[<u')
|
||||
expect(ranAndExited.flags).toBe(0)
|
||||
})
|
||||
|
||||
// `flags` is the conservative input fallback and `snapshotFlags` is
|
||||
// the provable fact. Collapsing them lets a snapshot that carried no kitty
|
||||
// metadata be republished downstream as "the host proved kitty is inactive",
|
||||
// which is exactly how a Preview on a bit-3 TUI ends up sending legacy text.
|
||||
describe('snapshot provenance', () => {
|
||||
it("separates a fresh PTY's known zero from a snapshot's unproven zero", () => {
|
||||
const fresh = new TerminalKittyKeyboardModeTracker()
|
||||
expect(fresh.flags).toBe(0)
|
||||
expect(fresh.snapshotFlags).toBe(0)
|
||||
|
||||
fresh.resetForSnapshot()
|
||||
expect(fresh.flags).toBe(0)
|
||||
expect(fresh.snapshotFlags).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores known 0 and known 8 onto the active screen', () => {
|
||||
const inactive = new TerminalKittyKeyboardModeTracker()
|
||||
inactive.resetForSnapshot()
|
||||
inactive.restoreSnapshotFlags(0)
|
||||
expect(inactive.snapshotFlags).toBe(0)
|
||||
|
||||
const negotiated = new TerminalKittyKeyboardModeTracker()
|
||||
negotiated.resetForSnapshot()
|
||||
negotiated.restoreSnapshotFlags(8)
|
||||
expect(negotiated.flags).toBe(8)
|
||||
expect(negotiated.snapshotFlags).toBe(8)
|
||||
})
|
||||
|
||||
it('ignores values that are not non-negative safe integers', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.resetForSnapshot()
|
||||
for (const invalid of [-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 2]) {
|
||||
tracker.restoreSnapshotFlags(invalid)
|
||||
expect(tracker.snapshotFlags).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-proves state on an absolute set but not on a relative update', () => {
|
||||
const relative = new TerminalKittyKeyboardModeTracker()
|
||||
relative.resetForSnapshot()
|
||||
relative.scan('\x1b[=8;2u')
|
||||
expect(relative.flags).toBe(8)
|
||||
// An OR against an unproven baseline cannot prove the result.
|
||||
expect(relative.snapshotFlags).toBeUndefined()
|
||||
|
||||
const absolute = new TerminalKittyKeyboardModeTracker()
|
||||
absolute.resetForSnapshot()
|
||||
absolute.scan('\x1b[=8;1u')
|
||||
expect(absolute.snapshotFlags).toBe(8)
|
||||
})
|
||||
|
||||
it('degrades to unknown on a pop that reaches past omitted push history', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.resetForSnapshot()
|
||||
tracker.restoreSnapshotFlags(8)
|
||||
tracker.scan('\x1b[<u')
|
||||
// The frame under the restored one was never captured, so zero is a guess.
|
||||
expect(tracker.flags).toBe(0)
|
||||
expect(tracker.snapshotFlags).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a live push/pop pair proven when its baseline was proven', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.resetForSnapshot()
|
||||
tracker.restoreSnapshotFlags(0)
|
||||
tracker.scan('\x1b[>8u')
|
||||
expect(tracker.snapshotFlags).toBe(8)
|
||||
tracker.scan('\x1b[<u')
|
||||
expect(tracker.snapshotFlags).toBe(0)
|
||||
})
|
||||
|
||||
it('degrades to unknown on a pop that empties the stack over a nonzero frame', () => {
|
||||
// The app's own emulator still holds pre-snapshot frames, so ITS pop
|
||||
// restores the pushed 8 while the mirror's exhausted stack forces 0 —
|
||||
// the forced zero must not be published as proven.
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.resetForSnapshot()
|
||||
tracker.restoreSnapshotFlags(8)
|
||||
tracker.scan('\x1b[>2u\x1b[<u')
|
||||
expect(tracker.flags).toBe(0)
|
||||
expect(tracker.snapshotFlags).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the known bit with the numeric slot across screen switches', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.resetForSnapshot()
|
||||
tracker.restoreSnapshotFlags(8)
|
||||
// The alternate screen's saved slot was never captured.
|
||||
tracker.scan('\x1b[?1049h')
|
||||
expect(tracker.snapshotFlags).toBeUndefined()
|
||||
tracker.scan('\x1b[?1049l')
|
||||
expect(tracker.snapshotFlags).toBe(8)
|
||||
})
|
||||
|
||||
it('re-proves everything on RIS and DECSTR', () => {
|
||||
const ris = new TerminalKittyKeyboardModeTracker()
|
||||
ris.resetForSnapshot()
|
||||
ris.scan('\x1bc')
|
||||
expect(ris.snapshotFlags).toBe(0)
|
||||
|
||||
const softReset = new TerminalKittyKeyboardModeTracker()
|
||||
softReset.resetForSnapshot()
|
||||
softReset.restoreSnapshotFlags(8)
|
||||
softReset.scan('\x1b[!p')
|
||||
expect(softReset.snapshotFlags).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// A constructor-fresh tracker reports known-zero, which is only true for a
|
||||
// PTY it watches from spawn. Grounding lets snapshot appliers refuse to
|
||||
// preserve or carry that default for a pre-existing PTY.
|
||||
describe('baseline grounding', () => {
|
||||
it('starts ungrounded and grounds on an explicit fresh-PTY reset', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
expect(tracker.hasProvenBaseline).toBe(false)
|
||||
tracker.reset()
|
||||
expect(tracker.hasProvenBaseline).toBe(true)
|
||||
})
|
||||
|
||||
it('is not grounded by plain output, only by absolute kitty evidence', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.scan('plain output\x1b[31mcolored\x1b[0m')
|
||||
expect(tracker.hasProvenBaseline).toBe(false)
|
||||
tracker.scan('\x1b[>8u')
|
||||
expect(tracker.hasProvenBaseline).toBe(true)
|
||||
})
|
||||
|
||||
it('grounds on a proven snapshot restore but not on the snapshot reset', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
tracker.reset()
|
||||
tracker.resetForSnapshot()
|
||||
expect(tracker.hasProvenBaseline).toBe(false)
|
||||
tracker.restoreSnapshotFlags(8)
|
||||
expect(tracker.hasProvenBaseline).toBe(true)
|
||||
})
|
||||
|
||||
it('grounds on RIS and DECSTR arriving in the stream', () => {
|
||||
const ris = new TerminalKittyKeyboardModeTracker()
|
||||
ris.scan('\x1bc')
|
||||
expect(ris.hasProvenBaseline).toBe(true)
|
||||
|
||||
const softReset = new TerminalKittyKeyboardModeTracker()
|
||||
softReset.scan('\x1b[!p')
|
||||
expect(softReset.hasProvenBaseline).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseTerminalKittyKeyboardFlags } from './terminal-kitty-keyboard-flags'
|
||||
|
||||
// Why: PTY/SSH chunks can split an escape sequence before its final byte.
|
||||
// Keep parser state far beyond normal sequence lengths while bounding memory.
|
||||
const KITTY_SCAN_TAIL_LIMIT = 4096
|
||||
@@ -6,6 +8,9 @@ const KITTY_SCAN_TAIL_LIMIT = 4096
|
||||
// mirrored stacks unboundedly while the renderer's own stacks stay at 16.
|
||||
const KITTY_STACK_LIMIT = 16
|
||||
|
||||
/** A pushed flags slot plus whether the value it will restore was ever proven. */
|
||||
type KittyStackFrame = { flags: number; known: boolean }
|
||||
|
||||
/**
|
||||
* Mirrors the kitty keyboard protocol flag state (CSI > u push, CSI < u pop,
|
||||
* CSI = u set) by scanning the raw PTY output stream, replicating xterm's
|
||||
@@ -27,16 +32,50 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
private currentFlags = 0
|
||||
private mainFlags = 0
|
||||
private altFlags = 0
|
||||
private mainStack: number[] = []
|
||||
private altStack: number[] = []
|
||||
private currentKnown = true
|
||||
private mainKnown = true
|
||||
private altKnown = true
|
||||
private mainStack: KittyStackFrame[] = []
|
||||
private altStack: KittyStackFrame[] = []
|
||||
/** False once a snapshot replaced state whose push history was never observed. */
|
||||
private mainStackComplete = true
|
||||
private altStackComplete = true
|
||||
private alternateScreenActive = false
|
||||
private alternateScreenSwitchObserved = false
|
||||
// Why: a constructor-fresh tracker reports known-inactive, which is correct
|
||||
// for a PTY it watches from spawn but was never proven for a pre-existing
|
||||
// one. Grounding flips on evidence only: an explicit fresh-PTY reset, a
|
||||
// proven snapshot restore, or scanned bytes that state flags absolutely.
|
||||
private baselineProven = false
|
||||
|
||||
/** Current effective kitty keyboard flags (0 = protocol inactive). */
|
||||
/**
|
||||
* Current effective kitty keyboard flags. `0` doubles as the conservative
|
||||
* input fallback while the state is unknown — read `snapshotFlags` instead
|
||||
* when the caller must distinguish "proven inactive" from "unproven".
|
||||
*/
|
||||
get flags(): number {
|
||||
return this.currentFlags
|
||||
}
|
||||
|
||||
/**
|
||||
* Active screen's proven flags, absent when unknown: after snapshots without
|
||||
* Kitty metadata, or transitions needing uncarried push history. Serializers
|
||||
* publish this, never `flags` — silence must not become a proven zero.
|
||||
*/
|
||||
get snapshotFlags(): number | undefined {
|
||||
return this.currentKnown ? this.currentFlags : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* True once this tracker's knownness is grounded in evidence for the PTY it
|
||||
* mirrors. Consumers use it to refuse to preserve (or carry) a constructor
|
||||
* default across a snapshot that proves nothing — the fresh known-zero must
|
||||
* not be laundered into a host-proven inactive protocol.
|
||||
*/
|
||||
get hasProvenBaseline(): boolean {
|
||||
return this.baselineProven
|
||||
}
|
||||
|
||||
get isAlternateScreen(): boolean {
|
||||
return this.alternateScreenActive
|
||||
}
|
||||
@@ -45,17 +84,57 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
return this.alternateScreenSwitchObserved
|
||||
}
|
||||
|
||||
/** Full reset to a fresh PTY: known-inactive on both screens. */
|
||||
reset(): void {
|
||||
this.baselineProven = true
|
||||
this.scanTail = ''
|
||||
this.currentFlags = 0
|
||||
this.mainFlags = 0
|
||||
this.altFlags = 0
|
||||
this.currentKnown = true
|
||||
this.mainKnown = true
|
||||
this.altKnown = true
|
||||
this.mainStack = []
|
||||
this.altStack = []
|
||||
this.mainStackComplete = true
|
||||
this.altStackComplete = true
|
||||
this.alternateScreenActive = false
|
||||
this.alternateScreenSwitchObserved = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Start applying a replacement or historical snapshot. Production snapshot
|
||||
* ANSI deliberately omits kitty pushes, so scanning it can never recover a
|
||||
* negotiation that predates the capture: keep `0` as the input fallback but
|
||||
* mark both screens and their stacks unproven until `restoreSnapshotFlags`
|
||||
* or explicit application output proves them again.
|
||||
*/
|
||||
resetForSnapshot(): void {
|
||||
this.reset()
|
||||
this.baselineProven = false
|
||||
this.currentKnown = false
|
||||
this.mainKnown = false
|
||||
this.altKnown = false
|
||||
this.mainStackComplete = false
|
||||
this.altStackComplete = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the effective flags the snapshot owner proved at the snapshot's own
|
||||
* sequence boundary. Only the ACTIVE screen becomes known — the inactive
|
||||
* screen's slot and both push stacks stay unproven because current snapshot
|
||||
* authorities expose neither.
|
||||
*/
|
||||
restoreSnapshotFlags(flags: number): void {
|
||||
const parsed = parseTerminalKittyKeyboardFlags(flags)
|
||||
if (parsed === undefined) {
|
||||
return
|
||||
}
|
||||
this.currentFlags = parsed
|
||||
this.currentKnown = true
|
||||
this.baselineProven = true
|
||||
}
|
||||
|
||||
scan(data: string): void {
|
||||
this.scanInternal(data, false)
|
||||
}
|
||||
@@ -106,11 +185,17 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
// Why: xterm's DECSTR (CSI ! p) wipes kitty flags and stacks for both
|
||||
// screens via coreService.reset but does not switch buffers — mirror that
|
||||
// so a soft-resetting TUI stops receiving kitty-encoded Option chords.
|
||||
this.baselineProven = true
|
||||
this.currentFlags = 0
|
||||
this.mainFlags = 0
|
||||
this.altFlags = 0
|
||||
this.currentKnown = true
|
||||
this.mainKnown = true
|
||||
this.altKnown = true
|
||||
this.mainStack = []
|
||||
this.altStack = []
|
||||
this.mainStackComplete = true
|
||||
this.altStackComplete = true
|
||||
}
|
||||
|
||||
private applyScreenSwitch(params: string, enabled: boolean): void {
|
||||
@@ -123,13 +208,19 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
// Why: xterm swaps the current flags with the inactive screen's slot on
|
||||
// every 47/1047/1049 transition, without an already-active guard —
|
||||
// mirror it exactly so this state matches what the renderer encodes.
|
||||
// Why the known bit rides the numeric slot: a screen whose flags were
|
||||
// never proven must not become proven just by being swapped in.
|
||||
if (enabled) {
|
||||
this.mainFlags = this.currentFlags
|
||||
this.mainKnown = this.currentKnown
|
||||
this.currentFlags = this.altFlags
|
||||
this.currentKnown = this.altKnown
|
||||
this.alternateScreenActive = true
|
||||
} else {
|
||||
this.altFlags = this.currentFlags
|
||||
this.altKnown = this.currentKnown
|
||||
this.currentFlags = this.mainFlags
|
||||
this.currentKnown = this.mainKnown
|
||||
this.alternateScreenActive = false
|
||||
}
|
||||
}
|
||||
@@ -143,26 +234,54 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
if (stack.length >= KITTY_STACK_LIMIT) {
|
||||
stack.shift()
|
||||
}
|
||||
stack.push(this.currentFlags)
|
||||
stack.push({ flags: this.currentFlags, known: this.currentKnown })
|
||||
}
|
||||
// A push states the new effective flags absolutely, so they are proven
|
||||
// even when the value it displaced was not.
|
||||
this.currentFlags = parsed[0] || 0
|
||||
this.currentKnown = true
|
||||
this.baselineProven = true
|
||||
return
|
||||
}
|
||||
if (prefix === '<') {
|
||||
const count = Math.max(1, parsed[0] || 1)
|
||||
for (let i = 0; i < count && stack.length > 0; i++) {
|
||||
this.currentFlags = stack.pop() as number
|
||||
let lastPopped: KittyStackFrame | null = null
|
||||
for (let i = 0; i < count; i++) {
|
||||
const frame = stack.pop()
|
||||
if (!frame) {
|
||||
lastPopped = null
|
||||
break
|
||||
}
|
||||
lastPopped = frame
|
||||
this.currentFlags = frame.flags
|
||||
this.currentKnown = frame.known
|
||||
}
|
||||
if (stack.length === 0) {
|
||||
// Why: xterm zeroes an exhausted stack even over a just-popped value.
|
||||
// With complete history that matches the app's emulator exactly, but a
|
||||
// mirror whose stack omits pre-snapshot pushes empties EARLIER than
|
||||
// the app's — its forced zero is only proven when the popped frame
|
||||
// itself proves 0 (the app restores that same value whatever sits
|
||||
// below it); landing at empty on any other frame, or past it, proves
|
||||
// nothing about the older history.
|
||||
this.currentFlags = 0
|
||||
const stackComplete = this.alternateScreenActive
|
||||
? this.altStackComplete
|
||||
: this.mainStackComplete
|
||||
this.currentKnown =
|
||||
stackComplete || (lastPopped !== null && lastPopped.known && lastPopped.flags === 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
const flags = parsed[0] || 0
|
||||
const mode = parsed.length > 1 && parsed[1] ? parsed[1] : 1
|
||||
if (mode === 1) {
|
||||
// An absolute set re-proves the active screen regardless of its baseline.
|
||||
this.currentFlags = flags
|
||||
this.currentKnown = true
|
||||
this.baselineProven = true
|
||||
} else if (mode === 2) {
|
||||
// Relative updates only preserve knowledge; they cannot create it.
|
||||
this.currentFlags |= flags
|
||||
} else if (mode === 3) {
|
||||
this.currentFlags &= ~flags
|
||||
|
||||
@@ -5,12 +5,27 @@ export type TerminalPreviewSnapshot = {
|
||||
seq?: number
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Effective kitty keyboard flags the snapshot owner proved at this same
|
||||
* `seq` boundary. Absent means unknown — Preview then keeps its tracker
|
||||
* unproven and commits raw text rather than guessing zero. */
|
||||
kittyKeyboardFlags?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How the renderer must apply one buffered chunk. `live` is a proven
|
||||
* post-snapshot suffix, so kitty pushes advance the stack; `replay` is
|
||||
* redelivery that may repeat the application's one-time push, so it applies
|
||||
* idempotently (see TerminalKittyKeyboardModeTracker.scanReplay).
|
||||
*/
|
||||
export type TerminalPreviewReplayChunk = {
|
||||
data: string
|
||||
mode: 'live' | 'replay'
|
||||
}
|
||||
|
||||
export type TerminalPreviewConnectResult = {
|
||||
snapshot: TerminalPreviewSnapshot | null
|
||||
/** Live bytes captured while the snapshot was being serialized. */
|
||||
replay: string[]
|
||||
replay: TerminalPreviewReplayChunk[]
|
||||
/** Snapshot acquisition overflowed twice; refresh without blanking the existing view. */
|
||||
resyncRequired?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user