mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
fix(terminal): stop guessing that apps died and wiping their keyboard modes (#23584)
* fix(terminal): stop guessing that apps died and wiping their keyboard modes The renderer wiped xterm's Kitty keyboard flags on every Ctrl+C, every live reattach, and every Windows agent turn end, though the app usually survives. xterm then encoded keys in legacy form while the pane mirror Orca's shortcut policy reads still held the negotiated flags, so Cmd+C, Shift+Enter, Option/Alt and IME commits disagreed with each other and with the app. - Delete the Ctrl+C wipe, the ConPTY agent-idle wipe, and the mirror reset on every PTY exit (it also ran on unverified host-loss exits). - Live reattach profiles no longer reset Kitty; every replay epilogue instead re-asserts the mirror's flags (pop-all, then the host-proven set; a bare pop while unproven), so a revealed xterm gets the live app's flags back. - Route every renderer-originated mode write through one scanning writer so xterm and the mirror always parse the same bytes: confirmed-shell reset, hibernate, cold restore, and a full process-boundary ground at fresh spawn. - Read Kitty flags as 0 where the protocol is withheld (ConPTY), since xterm ignores CSI u there but the mirror still scans it. - The dashboard popout restores snapshot flags as bytes so its xterm agrees. * style(terminal): separate the kitty restore builder from the pen reset * fix(terminal): let the kitty mirror own the withheld-protocol rule Review follow-ups for the stop-guessing change: - The mirror takes a `kittyKeyboard` option from the xterm's advertisement and ignores CSI u when withheld, as xterm does, replacing a per-reader helper that any new reader could skip. Daemon/headless users keep the default. - Replay epilogues are writers (`writeReplayEpilogue`, `writeReattachReplayReset`) that take the sync or async xterm writer, so nothing that looks like a builder mutates the mirror. - An abandoned hidden restore re-asserts the mirror's kitty flags after the byte-gap reset: its discarded chunks were already scanned. - Tests pin a non-zero host restore (epilogue ends `=31u`, mirror 31), a withheld pane staying at 0, and the restart-in-place ground landing after the mirror reset; the epilogue test helper is now an exact builder. * refactor(terminal): one epilogue writer and one scanned ground per boundary - Reattach callers write `chooseReattachReplayReset(...)` through `writeReplayEpilogue`, dropping the second writer from the session. - Fresh spawn and cold restore rely on their scanned ground alone: it leaves the mirror known at 0 with a proven baseline, so the extra reset() was dead.
This commit is contained in:
@@ -289,7 +289,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest {
|
||||
const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences
|
||||
const snapshotFrame = reattachSnapshot.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).
|
||||
// Why kitty flags ride beside the payload, not inside it: renderers re-assert them in the replay epilogue, after the payload's screen switches (terminal-query-authority.md §kitty).
|
||||
// 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(
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { TerminalModes } from './types'
|
||||
import { RESET_GRAPHIC_RENDITION } from '../../shared/terminal-mode-reset-profiles'
|
||||
|
||||
// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and
|
||||
// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C
|
||||
// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for
|
||||
// emulator re-seed parity only; a re-seeded emulator answers ?0u and
|
||||
// protocol-conformant programs re-push.
|
||||
// Why no kitty flags here: renderers re-assert the snapshot's kitty flags
|
||||
// (carried beside the payload) in their replay epilogue, after these screen
|
||||
// switches. A re-seeded emulator uses modes.kittyKeyboardFlags directly.
|
||||
export function buildRehydrateSequences(modes: TerminalModes): string {
|
||||
const seqs: string[] = []
|
||||
if (modes.alternateScreen) {
|
||||
|
||||
@@ -52,8 +52,8 @@ export type PtySpawnResult = {
|
||||
}
|
||||
/** Kitty keyboard flags persisted in the daemon snapshot, threaded so the
|
||||
* re-seeded runtime emulator answers hidden `CSI ? u` with the real flags
|
||||
* (terminal-query-authority.md §kitty). Never replayed into a renderer
|
||||
* xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */
|
||||
* (terminal-query-authority.md §kitty). Renderers re-assert them in their
|
||||
* replay epilogue, after the payload's screen switches. */
|
||||
snapshotKittyKeyboardFlags?: number
|
||||
/** Renderer-domain sequence main reconciled for the attach boundary those
|
||||
* flags describe. Set by main, not the provider. */
|
||||
|
||||
@@ -213,7 +213,8 @@ describe('AgentTerminalPreview', () => {
|
||||
expect(input).toHaveBeenCalledTimes(1)
|
||||
expect(input).toHaveBeenCalledWith('pty-1', 'k')
|
||||
|
||||
act(() => terminal.writeCallbacks.shift()?.())
|
||||
// Why drain all: the connection's kitty restore write queues ahead of the live chunk.
|
||||
act(() => terminal.writeCallbacks.splice(0).forEach((callback) => callback()))
|
||||
expect(ack).toHaveBeenCalledWith('pty-1', 4)
|
||||
})
|
||||
|
||||
@@ -271,6 +272,11 @@ describe('AgentTerminalPreview', () => {
|
||||
render(<AgentTerminalPreview ptyId="pty-1" />)
|
||||
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
|
||||
await waitFor(() => expect(imeHarness.forwarders[0]!.getKittyKeyboardFlags()).toBe(8))
|
||||
// The popout xterm gets the same flags, so its encoder agrees with the mirror.
|
||||
expect(terminalHarness.instances[0]!.write).toHaveBeenCalledWith(
|
||||
'\x1b[<99u\x1b[=8u',
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
// Live output keeps advancing the same mirror the forwarder reads.
|
||||
act(() => {
|
||||
|
||||
@@ -11,7 +11,8 @@ import { replayPreviewConnectionSnapshot } from './preview-terminal-snapshot-rep
|
||||
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
|
||||
import {
|
||||
buildPreviewAppearanceOptions,
|
||||
buildPreviewTerminalOptions
|
||||
buildPreviewTerminalOptions,
|
||||
previewAdvertisesKittyKeyboard
|
||||
} from './preview-terminal-options'
|
||||
import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
|
||||
import { installPreviewTerminalCompatibility } from './preview-terminal-compatibility'
|
||||
@@ -116,9 +117,13 @@ export function AgentTerminalPreview({
|
||||
let disposeKeyHandler: (() => void) | null = null
|
||||
let disposeNativeCopyGutterTrim: (() => void) | null = null
|
||||
let disposeTerminalCompatibility: (() => void) | null = null
|
||||
// Why one read: the xterm's advertisement and its mirror must never disagree.
|
||||
const mountTerminalInput = terminalInputRef.current
|
||||
// Why: mirrors the pane's tracker — the policy needs the flags the TUI
|
||||
// negotiated, and this preview parses the same output stream the pane does.
|
||||
const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker()
|
||||
const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker({
|
||||
kittyKeyboard: previewAdvertisesKittyKeyboard(mountTerminalInput)
|
||||
})
|
||||
let refreshInFlight = false
|
||||
let refreshAgain = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -254,7 +259,7 @@ export function AgentTerminalPreview({
|
||||
terminal = new Terminal(
|
||||
buildPreviewTerminalOptions({
|
||||
settings: settingsRef.current,
|
||||
terminalInput: terminalInputRef.current,
|
||||
terminalInput: mountTerminalInput,
|
||||
macOptionIsMeta: macOptionAsAltRef.current === 'true',
|
||||
theme: terminalTheme,
|
||||
themeMode: terminalMode,
|
||||
|
||||
@@ -45,6 +45,13 @@ export function buildPreviewAppearanceOptions(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: local ConPTY CLIs read the advertisement but can't decode CSI-u (#2434); mirror the pane's withhold.
|
||||
export function previewAdvertisesKittyKeyboard(
|
||||
terminalInput: DashboardCardTerminalInput | null
|
||||
): boolean {
|
||||
return !terminalInput || terminalInput.kittyKeyboardAdvertised
|
||||
}
|
||||
|
||||
/**
|
||||
* Full option set for the preview's xterm: the same defaults, user appearance,
|
||||
* and host compatibility flags a pane resolves, so the agent's TUI negotiates
|
||||
@@ -65,10 +72,9 @@ export function buildPreviewTerminalOptions(args: {
|
||||
...(args.terminalInput?.localWindowsConpty
|
||||
? buildLocalConptyTerminalOptions(args.terminalInput.osRelease)
|
||||
: {}),
|
||||
// Why: local ConPTY CLIs read the advertisement but can't decode CSI-u (#2434); mirror the pane's withhold.
|
||||
...(args.terminalInput && !args.terminalInput.kittyKeyboardAdvertised
|
||||
? { vtExtensions: { kittyKeyboard: false } }
|
||||
: {})
|
||||
...(previewAdvertisesKittyKeyboard(args.terminalInput)
|
||||
? {}
|
||||
: { vtExtensions: { kittyKeyboard: false } })
|
||||
}
|
||||
return {
|
||||
...buildDefaultTerminalOptions(),
|
||||
|
||||
+2
-2
@@ -110,7 +110,7 @@ describe('replayPreviewConnectionSnapshot', () => {
|
||||
expect(modes.snapshotFlags).toBe(0)
|
||||
})
|
||||
|
||||
it('writes scrollback, frame, escape tail, then replay, in that order', () => {
|
||||
it('writes scrollback, frame, kitty restore, escape tail, then replay, in that order', () => {
|
||||
const { written } = apply(
|
||||
{
|
||||
scrollbackAnsi: 'history',
|
||||
@@ -119,6 +119,6 @@ describe('replayPreviewConnectionSnapshot', () => {
|
||||
},
|
||||
[{ data: 'tail', mode: 'live' }]
|
||||
)
|
||||
expect(written).toEqual(['history', 'frame', '\x1b[', 'tail'])
|
||||
expect(written).toEqual(['history', 'frame', '\x1b[<99u', '\x1b[', 'tail'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags'
|
||||
import { buildKittyKeyboardRestore } from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import type {
|
||||
TerminalPreviewReplayChunk,
|
||||
TerminalPreviewSnapshot
|
||||
@@ -7,7 +8,7 @@ import type {
|
||||
|
||||
/**
|
||||
* Apply snapshot + buffered replay, restoring proven kitty flags after the
|
||||
* snapshot scan (snapshot ANSI omits kitty pushes). Synchronous so no browser
|
||||
* snapshot bytes (snapshot ANSI omits kitty pushes). Synchronous so no browser
|
||||
* event observes the temporary reset.
|
||||
*/
|
||||
export function replayPreviewConnectionSnapshot(args: {
|
||||
@@ -29,12 +30,11 @@ export function replayPreviewConnectionSnapshot(args: {
|
||||
if (snapshot.data) {
|
||||
args.write(snapshot.data, false)
|
||||
}
|
||||
// Why as bytes: the popout xterm must parse the same restore its mirror scans.
|
||||
args.write(buildKittyKeyboardRestore(provenFlags), 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')
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
POST_REPLAY_MODE_RESET,
|
||||
replayPayloadEndsWithCursorHidden,
|
||||
RESET_GRAPHIC_RENDITION,
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import {
|
||||
@@ -462,7 +461,7 @@ describe('replayPayloadEndsWithCursorHidden', () => {
|
||||
describe('buildPostReplayLiveAgentReattachReset', () => {
|
||||
it('preserves an intentionally hidden cursor', () => {
|
||||
expect(buildPostReplayLiveAgentReattachReset('agent frame\x1b[?25l')).toBe(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+5
-4
@@ -6,6 +6,7 @@ import {
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
RESET_GRAPHIC_RENDITION
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { replayEpilogue } from './pty-connection-test-replay-epilogue'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { flushAsyncTicks, createDeferred, writeHeadlessTerminal } from './pty-connection-test-async'
|
||||
import { createRect } from './pty-connection-test-dom'
|
||||
@@ -192,11 +193,11 @@ describe('connectPanePty', () => {
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_MODE_RESET,
|
||||
replayEpilogue(POST_REPLAY_MODE_RESET, 0),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
@@ -252,7 +253,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
replayEpilogue(POST_REPLAY_DEAD_TUI_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
@@ -461,7 +462,7 @@ describe('connectPanePty', () => {
|
||||
expect(writes.join('')).toContain('RESTORE-LIVE-STATE')
|
||||
expect(writes.join('')).not.toContain('ALT-FRAME-BODY')
|
||||
expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}PREFIX-SCROLLBACKRESTORE-LIVE-STATE`)
|
||||
expect(writes).toContain(POST_REPLAY_MODE_RESET)
|
||||
expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0))
|
||||
})
|
||||
|
||||
it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => {
|
||||
|
||||
@@ -177,28 +177,6 @@ describe('connectPanePty', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: a late exit from a replaced PTY skips onExit's kitty reset, so a fresh spawn must reset the reused per-pane tracker itself or restart-in-place leaks old kitty flags.
|
||||
it('resets a stale kitty keyboard mirror when spawning a fresh PTY', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { TerminalKittyKeyboardModeTracker } =
|
||||
await import('../../../../shared/terminal-kitty-keyboard-mode-tracker')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
const staleTracker = new TerminalKittyKeyboardModeTracker()
|
||||
staleTracker.scan('\x1b[>1u')
|
||||
expect(staleTracker.flags).toBe(1)
|
||||
// Why: a unique tab id keeps this pane's key clear of other tests' pendingSpawnByPaneKey entries so the connect deterministically fresh-spawns.
|
||||
const deps = createDeps({
|
||||
tabId: 'tab-kitty-fresh-spawn',
|
||||
paneKittyKeyboardModesRef: { current: new Map([[91, staleTracker]]) }
|
||||
})
|
||||
|
||||
connectPanePty(createPane(91) as never, createManager(91) as never, deps as never)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(staleTracker.flags).toBe(0)
|
||||
})
|
||||
|
||||
// Why: deleting a worktree kills its PTYs for the filesystem teardown; the
|
||||
// renderer must not race a doomed respawn into a directory main is deleting
|
||||
// (main fences it with TerminalRemovalInProgressError and the pane is about to
|
||||
|
||||
+13
-12
@@ -6,6 +6,7 @@ import {
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
POST_REPLAY_REATTACH_RESET
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { replayEpilogue } from './pty-connection-test-replay-epilogue'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
@@ -520,15 +521,15 @@ describe('connectPanePty', () => {
|
||||
|
||||
// A live agent owns ?1004h (focus reporting); the plain reset's ?1004l would silence focus events until restart, since agents only enable it at startup.
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
replayEpilogue(POST_REPLAY_DEAD_TUI_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
} finally {
|
||||
@@ -583,11 +584,11 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
replayEpilogue(POST_REPLAY_DEAD_TUI_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
} finally {
|
||||
@@ -634,15 +635,15 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
replayEpilogue(POST_REPLAY_DEAD_TUI_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
} finally {
|
||||
@@ -689,11 +690,11 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
} finally {
|
||||
@@ -739,11 +740,11 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
replayEpilogue(POST_REPLAY_DEAD_TUI_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(window.api.pty.inspectProcess).not.toHaveBeenCalled()
|
||||
|
||||
+2
-5
@@ -1,9 +1,6 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants'
|
||||
@@ -456,7 +453,7 @@ describe('connectPanePty', () => {
|
||||
expect(deps.dispatchNotification).toHaveBeenCalledWith(expectedNotification)
|
||||
}
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
|
||||
RESET_TERMINAL_CURSOR_STYLE,
|
||||
expect.any(Function)
|
||||
)
|
||||
transport.sendInput.mockClear()
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
PROCESS_BOUNDARY_GROUND
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import { replayEpilogue } from './pty-connection-test-replay-epilogue'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
LEAF_1,
|
||||
createMockTransport,
|
||||
createPane,
|
||||
createManager,
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
|
||||
const {
|
||||
resetAndRefreshAllTerminalWebglAtlases,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
let transportFactoryQueue: MockTransport[] = []
|
||||
let createdTransportOptions: Record<string, unknown>[] = []
|
||||
let storeSubscribers: ((state: StoreState) => void)[] = []
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({
|
||||
scheduleRuntimeGraphSync
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resetAndRefreshAllTerminalWebglAtlases
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState,
|
||||
subscribe: (listener: (state: StoreState) => void) => {
|
||||
storeSubscribers.push(listener)
|
||||
return () => {
|
||||
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
|
||||
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
|
||||
})
|
||||
|
||||
vi.mock('./cache-timer-seeding', () => ({
|
||||
shouldSeedCacheTimerOnInitialTitle
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
info: toastInfo
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/codex-stale-pane-sweep', () => ({
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
}))
|
||||
|
||||
// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React).
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof React>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./pty-transport', () => ({
|
||||
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./remote-runtime-pty-transport', () => ({
|
||||
createRemoteRuntimePtyTransport: vi.fn(
|
||||
(_environmentId: string, options: Record<string, unknown>) => {
|
||||
createdTransportOptions.push(options)
|
||||
const nextTransport = transportFactoryQueue.shift()
|
||||
if (!nextTransport) {
|
||||
throw new Error('No mock transport queued')
|
||||
}
|
||||
return nextTransport
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher.
|
||||
vi.mock('./pty-dispatcher', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
getEagerPtyBufferHandle: vi.fn(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
function createDeps(overrides: Record<string, unknown> = {}) {
|
||||
return buildPaneConnectionDeps(() => mockStoreState, overrides)
|
||||
}
|
||||
|
||||
// Why: xterm and the pane mirror must end every restore on the same kitty flags.
|
||||
describe('connectPanePty kitty keyboard restore', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
transportFactoryQueue = []
|
||||
createdTransportOptions = []
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
async function reattachWithSnapshotFlags(kittyKeyboard: boolean) {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('tab-pty')
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) =>
|
||||
sessionId
|
||||
? {
|
||||
id: sessionId,
|
||||
snapshot: 'live app frame',
|
||||
snapshotKittyKeyboardFlags: 31,
|
||||
snapshotSeq: 7
|
||||
}
|
||||
: null
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
pane.terminal.options.vtExtensions.kittyKeyboard = kittyKeyboard
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane-connection mocks cover the members connectPanePty reads.
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
return { pane, mirror: deps.paneKittyKeyboardModesRef.current.get(pane.id) }
|
||||
}
|
||||
|
||||
it("restores a live app's host-proven flags into xterm and the mirror on reattach", async () => {
|
||||
const { pane, mirror } = await reattachWithSnapshotFlags(true)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
`${POST_REPLAY_REATTACH_RESET}\x1b[<99u\x1b[=31u`,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mirror?.flags).toBe(31)
|
||||
})
|
||||
|
||||
// Why: xterm ignores CSI u while the protocol is withheld (ConPTY), so its mirror must too.
|
||||
it('keeps a withheld-protocol pane at 0 in both records despite host flags', async () => {
|
||||
const { pane, mirror } = await reattachWithSnapshotFlags(false)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mirror?.flags).toBe(0)
|
||||
})
|
||||
|
||||
it('grounds xterm and the mirror with one scanned write before a restart-in-place spawns', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
const pane = createPane(91)
|
||||
// Why: a unique tab id keeps this pane's key clear of other tests' pendingSpawnByPaneKey entries so the connect deterministically fresh-spawns.
|
||||
const deps = createDeps({ tabId: 'tab-kitty-fresh-spawn' })
|
||||
// The pane's previous occupant left the alternate screen and kitty flags on.
|
||||
const mirror = new TerminalKittyKeyboardModeTracker()
|
||||
mirror.scan('\x1b[?1049h\x1b[>5u')
|
||||
deps.paneKittyKeyboardModesRef.current.set(pane.id, mirror)
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane-connection mocks cover the members connectPanePty reads.
|
||||
connectPanePty(pane as never, createManager(91) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(PROCESS_BOUNDARY_GROUND, expect.any(Function))
|
||||
expect(mirror.snapshotFlags).toBe(0)
|
||||
expect(mirror.isAlternateScreen).toBe(false)
|
||||
})
|
||||
})
|
||||
+2
-1
@@ -4,6 +4,7 @@ import {
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
RESET_GRAPHIC_RENDITION
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { replayEpilogue } from './pty-connection-test-replay-epilogue'
|
||||
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
|
||||
@@ -209,7 +210,7 @@ describe('connectPanePty', () => {
|
||||
// Why: the relay's replay buffer holds full history, so clear xterm before writing to avoid duplicating prior-session content.
|
||||
expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`)
|
||||
expect(writes).toContain('restored-ssh-output')
|
||||
expect(writes).toContain(POST_REPLAY_REATTACH_RESET)
|
||||
expect(writes).toContain(replayEpilogue(POST_REPLAY_REATTACH_RESET))
|
||||
expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH')
|
||||
})
|
||||
|
||||
|
||||
+23
-22
@@ -7,9 +7,9 @@ import {
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
|
||||
RESET_GRAPHIC_RENDITION,
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { replayEpilogue } from './pty-connection-test-replay-epilogue'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
withMockedDocumentActiveElement,
|
||||
@@ -371,7 +371,7 @@ describe('connectPanePty', () => {
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply')
|
||||
// Snapshot ends with ?25l (Cursor Agent parks/hides the cursor); the reset must preserve it, not force ?25h, or a stray block paints.
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
|
||||
replayEpilogue(RESET_TERMINAL_CURSOR_STYLE),
|
||||
expect.any(Function)
|
||||
)
|
||||
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
@@ -411,11 +411,11 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
replayEpilogue(POST_REPLAY_DEAD_TUI_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
@@ -459,8 +459,8 @@ describe('connectPanePty', () => {
|
||||
expect(writes).toContain(
|
||||
`${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ `
|
||||
)
|
||||
expect(writes).toContain(POST_REPLAY_MODE_RESET)
|
||||
expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)
|
||||
expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0))
|
||||
expect(writes).not.toContain(replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -512,11 +512,11 @@ describe('connectPanePty', () => {
|
||||
)
|
||||
const output = writes.join('')
|
||||
const snapshotIndex = output.indexOf('\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ')
|
||||
const resetIndex = output.indexOf(POST_REPLAY_MODE_RESET)
|
||||
const resetIndex = output.indexOf(replayEpilogue(POST_REPLAY_MODE_RESET, 0))
|
||||
expect(snapshotIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(resetIndex).toBeGreaterThan(snapshotIndex)
|
||||
expect(writes).toContain(POST_REPLAY_MODE_RESET)
|
||||
expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)
|
||||
expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0))
|
||||
expect(writes).not.toContain(replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -547,7 +547,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
@@ -581,7 +581,7 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I')
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
|
||||
replayEpilogue(RESET_TERMINAL_CURSOR_STYLE),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
@@ -614,11 +614,11 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I')
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
@@ -650,7 +650,8 @@ describe('connectPanePty', () => {
|
||||
.map((call) => String(call[0]))
|
||||
.find(
|
||||
(data) =>
|
||||
data === POST_REPLAY_REATTACH_RESET || data === POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
|
||||
data === replayEpilogue(POST_REPLAY_REATTACH_RESET) ||
|
||||
data === replayEpilogue(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -658,12 +659,12 @@ describe('connectPanePty', () => {
|
||||
it('keeps mouse reporting when a reattach snapshot restores a live alternate-screen TUI', async () => {
|
||||
await expect(
|
||||
reattachSnapshotResetFor('\x1b[?1049h\x1b[?1002h\x1b[?1006hthird-party tui session')
|
||||
).resolves.toBe(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE)
|
||||
).resolves.toBe(replayEpilogue(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE))
|
||||
})
|
||||
|
||||
it('still disarms mouse reporting when a reattach snapshot ends on the normal buffer', async () => {
|
||||
await expect(reattachSnapshotResetFor('\x1b[?1003h\x1b[?1006hdead tui residue')).resolves.toBe(
|
||||
POST_REPLAY_REATTACH_RESET
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -703,11 +704,11 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I')
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
@@ -741,11 +742,11 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I')
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
@@ -782,11 +783,11 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I')
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
+5
-5
@@ -3,9 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { replayEpilogue } from './pty-connection-test-replay-epilogue'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import {
|
||||
NORMAL_BUFFER_PROLOGUE,
|
||||
@@ -397,7 +397,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply')
|
||||
@@ -437,7 +437,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET),
|
||||
expect.any(Function)
|
||||
)
|
||||
return connection
|
||||
@@ -514,7 +514,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
|
||||
replayEpilogue(RESET_TERMINAL_CURSOR_STYLE),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?25h\x1b[?1004l', expect.any(Function))
|
||||
@@ -673,7 +673,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(8)
|
||||
|
||||
const replayIndex = writes.indexOf('authoritative replay')
|
||||
const resetIndex = writes.indexOf(POST_REPLAY_REATTACH_RESET)
|
||||
const resetIndex = writes.indexOf(replayEpilogue(POST_REPLAY_REATTACH_RESET))
|
||||
const liveIndex = writes.indexOf('NEWER-LIVE\r\n')
|
||||
expect(replayIndex).toBeGreaterThan(0)
|
||||
expect(resetIndex).toBeGreaterThan(replayIndex)
|
||||
|
||||
+14
-8
@@ -1,6 +1,9 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import {
|
||||
buildKittyKeyboardRestore,
|
||||
RESET_AFTER_BYTE_GAP
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
|
||||
import { NORMAL_BUFFER_PROLOGUE } from './pty-connection-test-constants'
|
||||
import {
|
||||
@@ -18,6 +21,9 @@ import {
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
|
||||
// An abandon re-asserts the (known-zero) mirror's kitty flags after grounding the gap.
|
||||
const ABANDONED_RESTORE_GAP = `${RESET_AFTER_BYTE_GAP}${buildKittyKeyboardRestore(0)}`
|
||||
|
||||
const {
|
||||
resetAndRefreshAllTerminalWebglAtlases,
|
||||
scheduleTerminalWebglAtlasRecovery,
|
||||
@@ -206,10 +212,10 @@ describe('connectPanePty', () => {
|
||||
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
|
||||
const combinedLiveIndex = written.indexOf(firstLive + secondLive)
|
||||
expect(warningIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(written[warningIndex - 1]).toBe(RESET_AFTER_BYTE_GAP)
|
||||
expect(written[warningIndex - 1]).toBe(ABANDONED_RESTORE_GAP)
|
||||
// Exactly one: writeRestoreUnavailableWarning already grounds the gap, so a
|
||||
// second unconditional write here was pure duplication.
|
||||
expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1)
|
||||
expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1)
|
||||
expect(combinedLiveIndex).toBeGreaterThan(warningIndex)
|
||||
|
||||
snapshot.resolve({
|
||||
@@ -276,7 +282,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
|
||||
const resetIndex = written.indexOf(RESET_AFTER_BYTE_GAP)
|
||||
const resetIndex = written.indexOf(ABANDONED_RESTORE_GAP)
|
||||
const liveIndex = written.findIndex((data) => data.includes('live-after-reveal'))
|
||||
expect(resetIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(liveIndex).toBeGreaterThanOrEqual(0)
|
||||
@@ -319,13 +325,13 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
|
||||
const resetIndex = written.indexOf(RESET_AFTER_BYTE_GAP)
|
||||
const resetIndex = written.indexOf(ABANDONED_RESTORE_GAP)
|
||||
const liveIndex = written.indexOf(live)
|
||||
expect(resetIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(liveIndex).toBeGreaterThan(resetIndex)
|
||||
// The re-arm arm grounds in rearmRemoteHiddenOutputRestoreInsteadOfWarning,
|
||||
// so the abandon body must not ground a second time.
|
||||
expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1)
|
||||
expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1)
|
||||
expect(written.join('')).not.toContain('main recovery was unavailable')
|
||||
|
||||
disposable.dispose()
|
||||
@@ -383,8 +389,8 @@ describe('connectPanePty', () => {
|
||||
expect(liveIndex).toBeGreaterThan(warningIndex)
|
||||
// This arm gives up on recovery too, so the gap is grounded exactly once
|
||||
// before the blocked foreground is drained under it.
|
||||
expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1)
|
||||
expect(written.indexOf(RESET_AFTER_BYTE_GAP)).toBeLessThan(liveIndex)
|
||||
expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1)
|
||||
expect(written.indexOf(ABANDONED_RESTORE_GAP)).toBeLessThan(liveIndex)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ export type MockPaneTerminal = {
|
||||
scrollback: number
|
||||
ignoreBracketedPasteMode: boolean
|
||||
theme: { foreground: string; background: string }
|
||||
vtExtensions: { kittyKeyboard: boolean }
|
||||
}
|
||||
write: Mock<(data: string, callback?: () => void) => void>
|
||||
resize: Mock<(cols: number, rows: number) => void>
|
||||
@@ -162,7 +163,8 @@ export function createPane(paneId: number): MockPane {
|
||||
theme: {
|
||||
foreground: '#eeeeee',
|
||||
background: '#111111'
|
||||
}
|
||||
},
|
||||
vtExtensions: { kittyKeyboard: true }
|
||||
},
|
||||
write: vi.fn<(data: string, callback?: () => void) => void>(function write(...args): void {
|
||||
const [data, callback] = args
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buildKittyKeyboardRestore } from '../../../../shared/terminal-mode-reset-profiles'
|
||||
|
||||
/** A post-replay reset: the profile, then the mirror's kitty restore (a bare pop while unproven). */
|
||||
export function replayEpilogue(profile: string, provenFlags?: number): string {
|
||||
return `${profile}${buildKittyKeyboardRestore(provenFlags)}`
|
||||
}
|
||||
+13
-8
@@ -182,7 +182,7 @@ describe('connectPanePty', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('resets stale keyboard state when a native Windows agent becomes idle', async () => {
|
||||
it('keeps kitty keyboard state when a native Windows agent becomes idle', async () => {
|
||||
const restoreUserAgent = temporarilySetNavigatorUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
)
|
||||
@@ -206,7 +206,12 @@ describe('connectPanePty', () => {
|
||||
|
||||
idleHandler('* Codex done')
|
||||
|
||||
// Why: a finished turn is not a dead app; its kitty flags stay until the host sees it exit.
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
RESET_TERMINAL_CURSOR_STYLE,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
|
||||
expect.any(Function)
|
||||
)
|
||||
@@ -318,7 +323,7 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('resets stale keyboard state when native Windows hook status reaches done', async () => {
|
||||
it('resets cursor style when native Windows hook status reaches done', async () => {
|
||||
const restoreUserAgent = temporarilySetNavigatorUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
)
|
||||
@@ -359,7 +364,7 @@ describe('connectPanePty', () => {
|
||||
notifyStoreSubscribers()
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
|
||||
RESET_TERMINAL_CURSOR_STYLE,
|
||||
expect.any(Function)
|
||||
)
|
||||
} finally {
|
||||
@@ -367,7 +372,7 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resets stale keyboard state when a batched done→working→done burst lands as one publication', async () => {
|
||||
it('resets cursor style when a batched done→working→done burst lands as one publication', async () => {
|
||||
const restoreUserAgent = temporarilySetNavigatorUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
)
|
||||
@@ -413,7 +418,7 @@ describe('connectPanePty', () => {
|
||||
notifyStoreSubscribers()
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
|
||||
RESET_TERMINAL_CURSOR_STYLE,
|
||||
expect.any(Function)
|
||||
)
|
||||
} finally {
|
||||
@@ -421,7 +426,7 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resets stale keyboard state when a batched burst ends on working after a completed turn', async () => {
|
||||
it('resets cursor style when a batched burst ends on working after a completed turn', async () => {
|
||||
const restoreUserAgent = temporarilySetNavigatorUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
)
|
||||
@@ -464,7 +469,7 @@ describe('connectPanePty', () => {
|
||||
notifyStoreSubscribers()
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
`${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`,
|
||||
RESET_TERMINAL_CURSOR_STYLE,
|
||||
expect.any(Function)
|
||||
)
|
||||
} finally {
|
||||
@@ -472,7 +477,7 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native Windows same-turn done repaints from re-resetting keyboard state', async () => {
|
||||
it('keeps native Windows same-turn done repaints from re-resetting cursor style', async () => {
|
||||
const restoreUserAgent = temporarilySetNavigatorUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
)
|
||||
|
||||
@@ -91,8 +91,8 @@ export function createReattachPayloadHandlers(
|
||||
: daemonSnapshotReplay
|
||||
}`
|
||||
)
|
||||
session.writeReplayData(
|
||||
session.reattachReplayResetSequence(
|
||||
session.writeReplayEpilogue(
|
||||
session.chooseReattachReplayReset(
|
||||
daemonSnapshotReplay,
|
||||
Boolean(ctx.connectResult.coldRestore),
|
||||
ctx.connectResult.isAlternateScreen,
|
||||
@@ -198,8 +198,8 @@ export function createReattachPayloadHandlers(
|
||||
})) {
|
||||
session.writeReplayData(replayChunk)
|
||||
}
|
||||
session.writeReplayData(
|
||||
session.reattachReplayResetSequence(
|
||||
session.writeReplayEpilogue(
|
||||
session.chooseReattachReplayReset(
|
||||
modelData,
|
||||
Boolean(ctx.connectResult?.coldRestore),
|
||||
modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen,
|
||||
@@ -237,8 +237,8 @@ export function createReattachPayloadHandlers(
|
||||
session.writeReplayData(
|
||||
`${ctx.connectResult.coldRestore ? RESET_GRAPHIC_RENDITION : ''}${ctx.connectResult.replay}`
|
||||
)
|
||||
session.writeReplayData(
|
||||
session.reattachReplayResetSequence(
|
||||
session.writeReplayEpilogue(
|
||||
session.chooseReattachReplayReset(
|
||||
ctx.connectResult.replay,
|
||||
Boolean(ctx.connectResult.coldRestore),
|
||||
ctx.connectResult.isAlternateScreen
|
||||
@@ -305,9 +305,7 @@ export function createReattachPayloadHandlers(
|
||||
session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup)
|
||||
}
|
||||
// Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes.
|
||||
session.writeReplayData(POST_REPLAY_MODE_RESET)
|
||||
// Why: the dead run's kitty flags died with it and its scrollback was never scanned — the fresh shell starts at zero.
|
||||
session.kittyKeyboardModes.reset()
|
||||
session.writeInputModeGround(POST_REPLAY_MODE_RESET)
|
||||
session.consumeRestoredViewportBlankingMarker()
|
||||
// Why: a taller destination fit must not pull recovered rows back into the fresh shell's viewport after source-grid replay.
|
||||
session.writeFreshShellViewportBlanking(Math.max(destinationRows, session.pane.terminal.rows))
|
||||
|
||||
@@ -114,10 +114,6 @@ export function connectPanePty(
|
||||
session.remoteOutputGatedPtyId = null
|
||||
session.remoteOutputFactConsumerPtyId = null
|
||||
session.suppressViewportClaimTerminalResize = false
|
||||
// Why: idle callbacks are registered before the deferred PTY output plumbing
|
||||
// exists. Start with the shared scheduler, then switch to the PTY writer
|
||||
// below so hidden-tab resets keep backlog-recovery callbacks and byte order.
|
||||
session.idleAgentTerminalModeReset = RESET_TERMINAL_CURSOR_STYLE
|
||||
session.suppressNativeWindowsIdleCodexFocusReports = false
|
||||
session.setFocusReportSuppressionForAgentCompletion = (
|
||||
title: string | undefined,
|
||||
@@ -127,11 +123,14 @@ export function connectPanePty(
|
||||
session.suppressNativeWindowsIdleCodexFocusReports =
|
||||
agentType && agentType !== 'unknown' ? agentType === 'codex' : titleAgentType === 'codex'
|
||||
}
|
||||
// Why: idle callbacks are registered before the deferred PTY output plumbing
|
||||
// exists. Start with the shared scheduler, then switch to the PTY writer
|
||||
// so hidden-tab resets keep backlog-recovery callbacks and byte order.
|
||||
session.queueAgentIdleTerminalModeReset = (): void => {
|
||||
if (session.disposed) {
|
||||
return
|
||||
}
|
||||
writeTerminalOutput(session.pane.terminal, session.idleAgentTerminalModeReset, {
|
||||
writeTerminalOutput(session.pane.terminal, RESET_TERMINAL_CURSOR_STYLE, {
|
||||
foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
})
|
||||
}
|
||||
@@ -162,15 +161,16 @@ export function connectPanePty(
|
||||
// Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay.
|
||||
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
|
||||
session.cacheKey = makePaneKey(session.deps.tabId, session.pane.leafId)
|
||||
// Why: mirrors the kitty keyboard flags the pane's application negotiates,
|
||||
// so it still reflects what a live application expects after the renderer's
|
||||
// defensive kitty wipes; see TerminalKittyKeyboardModeTracker for its feeds.
|
||||
// Why: xterm exposes no kitty read, so this mirror tracks the flags xterm's
|
||||
// encoder applies; see TerminalKittyKeyboardModeTracker for its feeds.
|
||||
session.kittyKeyboardModes = (() => {
|
||||
const existing = session.deps.paneKittyKeyboardModesRef.current.get(session.pane.id)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = new TerminalKittyKeyboardModeTracker()
|
||||
const created = new TerminalKittyKeyboardModeTracker({
|
||||
kittyKeyboard: session.pane.terminal.options.vtExtensions?.kittyKeyboard === true
|
||||
})
|
||||
session.deps.paneKittyKeyboardModesRef.current.set(session.pane.id, created)
|
||||
return created
|
||||
})()
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
hasCachedWindowsTerminalCapabilities
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { requestTerminalWritePipelineProbe } from '@/lib/pane-manager/terminal-write-pipeline-health'
|
||||
import {
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
} from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { subscribeToTerminalUserInput } from '../terminal-user-input-signal'
|
||||
import {
|
||||
isLocalNativeWindowsConpty,
|
||||
@@ -86,11 +82,6 @@ export function installDirectSshRetryStatus(session: ConnectPanePtySession): voi
|
||||
),
|
||||
executionHostId: session.executionHostId
|
||||
})
|
||||
if (session.isNativeWindowsConpty) {
|
||||
// Why: Windows ConPTY agent turns can leave renderer keyboard modes armed
|
||||
// after completion, corrupting plain input with encoded bytes.
|
||||
session.idleAgentTerminalModeReset = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`
|
||||
}
|
||||
session.shouldApplyNativeWindowsRewriteRefresh = session.isNativeWindowsConpty
|
||||
session.shouldApplyWindowsRendererUnicodeRefresh = CLIENT_PLATFORM === 'win32'
|
||||
session.shouldProtectNativeWindowsSynchronizedOutput = session.isNativeWindowsConpty
|
||||
|
||||
+25
-2
@@ -5,7 +5,8 @@ import {
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
POST_REPLAY_MODE_RESET,
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
|
||||
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
|
||||
buildKittyKeyboardRestore
|
||||
} from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { buildFreshShellViewportBlankingSequence } from '../terminal-restored-viewport'
|
||||
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
@@ -129,7 +130,29 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void
|
||||
})
|
||||
}
|
||||
|
||||
session.reattachReplayResetSequence = (
|
||||
// Why one writer: the kitty mirror and xterm must parse every renderer-originated mode byte alike.
|
||||
session.writeInputModeGround = (data: string): void => {
|
||||
session.kittyKeyboardModes.scan(data)
|
||||
session.writeReplayData(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a post-replay reset ending in the mirror's Kitty flags, through the
|
||||
* mirror first. The restore is built after scanning the profile because its
|
||||
* `?1049l` moves both records to the other screen's slot.
|
||||
*/
|
||||
session.writeReplayEpilogue = <T>(
|
||||
profile: string,
|
||||
write: (data: string) => T = session.writeReplayData
|
||||
): T => {
|
||||
session.kittyKeyboardModes.scan(profile)
|
||||
const kitty = buildKittyKeyboardRestore(session.kittyKeyboardModes.snapshotFlags)
|
||||
session.kittyKeyboardModes.scan(kitty)
|
||||
return write(`${profile}${kitty}`)
|
||||
}
|
||||
|
||||
/** Picks the post-replay reset profile for a reattach; the caller writes it via writeReplayEpilogue. */
|
||||
session.chooseReattachReplayReset = (
|
||||
payload: string,
|
||||
ownerProcessEnded = false,
|
||||
isAlternateScreen?: boolean,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { PROCESS_BOUNDARY_GROUND } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { hasPtySerializer } from '../pty-buffer-serializer'
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
|
||||
@@ -52,11 +53,9 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
// Why: a canceled old replay clear can preserve xterm's native
|
||||
// isUserScrolling flag. A replacement shell must start in follow mode.
|
||||
session.resetFreshSpawnFollowOutput()
|
||||
// Why: a fresh spawn is a new process with kitty keyboard flags at
|
||||
// zero. The exit-handler reset alone is not enough: a late exit from a
|
||||
// replaced PTY takes the stale-transport early return and skips it, so
|
||||
// a restart-in-place would leak the old TUI's flags into a fresh shell.
|
||||
session.kittyKeyboardModes.reset()
|
||||
// Why: a fresh spawn is a new process, so a restart-in-place must not
|
||||
// inherit the old TUI's screen, mouse or kitty modes in xterm or the mirror.
|
||||
session.writeInputModeGround(PROCESS_BOUNDARY_GROUND)
|
||||
session.prepareFreshShellViewportForSpawn(options)
|
||||
const coldRestoreOverride =
|
||||
startupOverride && 'launchConfig' in startupOverride
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
@@ -62,7 +61,7 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession):
|
||||
// session.rearmRemoteHiddenOutputRestoreInsteadOfWarning. Only the quiet
|
||||
// flood-abandon reaches neither, and it still drains chunks below.
|
||||
else if (!rearmedRemoteRestore) {
|
||||
session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
|
||||
session.writeAbandonedRestoreGap()
|
||||
}
|
||||
if (hadPendingOverflow) {
|
||||
return
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs'
|
||||
import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics'
|
||||
import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler'
|
||||
import { isRemoteExecutionHostPtyId } from '../remote-execution-host-pty'
|
||||
|
||||
@@ -173,7 +172,7 @@ export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): vo
|
||||
cycle: session.hiddenOutputRestoreRemoteAbandonCycles
|
||||
})
|
||||
session.noteHiddenOutputRestoreFloodBackpressure()
|
||||
session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
|
||||
session.writeAbandonedRestoreGap()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -6,7 +6,8 @@ import {
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
RESET_AFTER_BYTE_GAP
|
||||
RESET_AFTER_BYTE_GAP,
|
||||
buildKittyKeyboardRestore
|
||||
} from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import {
|
||||
discardTerminalOutput,
|
||||
@@ -30,9 +31,18 @@ import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): void {
|
||||
// Why the kitty restore: an abandoned restore discards queued chunks the
|
||||
// mirror already scanned, so xterm re-adopts the mirror's flags.
|
||||
session.writeAbandonedRestoreGap = function (): void {
|
||||
session.writePtyOutputToXterm(
|
||||
`${RESET_AFTER_BYTE_GAP}${buildKittyKeyboardRestore(session.kittyKeyboardModes.snapshotFlags)}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
session.writeRestoreUnavailableWarning = function (): void {
|
||||
// The reset must parse before both the warning and any foreground drain.
|
||||
session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
|
||||
session.writeAbandonedRestoreGap()
|
||||
if (!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) {
|
||||
return
|
||||
}
|
||||
@@ -156,7 +166,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
|
||||
: hasLiveAgent
|
||||
? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET
|
||||
: POST_REPLAY_LIVE_SNAPSHOT_RESET
|
||||
session.writeReplayData(postReplayReset)
|
||||
session.writeReplayEpilogue(postReplayReset)
|
||||
if (snapshot.pendingEscapeTailAnsi) {
|
||||
// Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329).
|
||||
session.writeReplayData(snapshot.pendingEscapeTailAnsi)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { agentTypeToIconAgent } from '@/lib/agent-status'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { replayIntoTerminal } from '../replay-guard'
|
||||
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { POST_REPLAY_REATTACH_RESET } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { CONFIRMED_SHELL_MODE_RESET } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import {
|
||||
isLocalNativeWindowsConpty,
|
||||
resolveWindowsShellOverride
|
||||
@@ -182,18 +180,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
// Why: a hard-killed agent leaves mouse/focus/kitty modes armed, and the
|
||||
// surviving shell then receives pointer moves as typed SGR reports; the
|
||||
// replay guard keeps xterm's auto-replies from leaking to the shell.
|
||||
// The input mirror scans the same bytes so shortcuts stop encoding for the dead app;
|
||||
// draining queued output first keeps both in stream order.
|
||||
flushTerminalOutput(session.pane.terminal)
|
||||
session.kittyKeyboardModes.scan(POST_REPLAY_REATTACH_RESET)
|
||||
replayIntoTerminal(session.pane, session.deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, {
|
||||
breadcrumbIdentity: {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
ptyId: session.transport.getPtyId()
|
||||
},
|
||||
shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously
|
||||
})
|
||||
session.writeInputModeGround(CONFIRMED_SHELL_MODE_RESET)
|
||||
if (reason === 'visible-pty') {
|
||||
state.clearAgentLaunchConfig(session.cacheKey)
|
||||
return
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
deferPtyShutdownExit,
|
||||
isHostPtySleepPending
|
||||
} from '../pty-shutdown-exit-deferral'
|
||||
import { replayIntoTerminal } from '../replay-guard'
|
||||
import { POST_REPLAY_MODE_RESET } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { isProvenProcessExit } from '../../../../../shared/terminal-exit-cause'
|
||||
import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership'
|
||||
@@ -225,9 +224,6 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void {
|
||||
if (!isUnverifiedExit) {
|
||||
session.clearPanePtyFitBinding()
|
||||
}
|
||||
// Why: the negotiating application died with its PTY; any replacement
|
||||
// session starts with kitty keyboard flags at zero.
|
||||
session.kittyKeyboardModes.reset()
|
||||
const isSuppressedExit =
|
||||
session.deps.consumeSuppressedPtyExit(ptyId) ||
|
||||
preserveRendererBinding ||
|
||||
@@ -272,14 +268,7 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void {
|
||||
// frame still has mouse-tracking/bracketed-paste armed, which silently
|
||||
// eats every click and keystroke against a dead transport — disarm the
|
||||
// modes now and arm the reveal-time wake.
|
||||
replayIntoTerminal(session.pane, session.deps.replayingPanesRef, POST_REPLAY_MODE_RESET, {
|
||||
breadcrumbIdentity: {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
ptyId
|
||||
},
|
||||
shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously
|
||||
})
|
||||
session.writeInputModeGround(POST_REPLAY_MODE_RESET)
|
||||
session.hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record }
|
||||
const pendingWakeMatches =
|
||||
session.pendingHibernatedWakeTarget?.ptyId === ptyId &&
|
||||
|
||||
+4
-1
@@ -10,11 +10,14 @@ import type { ReattachPayloadContext } from './reattach-payload-context'
|
||||
import type { ReattachPayloadSession } from './reattach-payload-session'
|
||||
|
||||
function createSession(overrides: Record<string, unknown> = {}): ReattachPayloadSession {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test read only these session members.
|
||||
return {
|
||||
pane: createPane(1),
|
||||
rememberReattachPayloadAgentSignal: vi.fn(),
|
||||
writeReplayData: vi.fn(),
|
||||
reattachReplayResetSequence: vi.fn(() => '<reset>'),
|
||||
writeInputModeGround: vi.fn(),
|
||||
chooseReattachReplayReset: vi.fn(() => '<reset>'),
|
||||
writeReplayEpilogue: vi.fn(),
|
||||
sendFocusedReattachFocusInAfterReplay: vi.fn(),
|
||||
kittyKeyboardModes: {
|
||||
hasProvenBaseline: true,
|
||||
|
||||
+3
-1
@@ -5,6 +5,7 @@ export type ReattachPayloadSession = Pick<
|
||||
| 'applyColdRestoreAgentResumeStartup'
|
||||
| 'applySnapshotKittyKeyboardModes'
|
||||
| 'buildColdRestoreAgentResumeStartup'
|
||||
| 'chooseReattachReplayReset'
|
||||
| 'clearSleepingRecordAfterColdRestoreSpawn'
|
||||
| 'consumeRestoredViewportBlankingMarker'
|
||||
| 'createReattachGridPush'
|
||||
@@ -13,7 +14,6 @@ export type ReattachPayloadSession = Pick<
|
||||
| 'markHiddenOutputRestoreNeeded'
|
||||
| 'pane'
|
||||
| 'pendingReattachFit'
|
||||
| 'reattachReplayResetSequence'
|
||||
| 'recordRendererOrderedSeq'
|
||||
| 'rememberReattachPayloadAgentSignal'
|
||||
| 'schedulePendingStartupCommandDelivery'
|
||||
@@ -24,5 +24,7 @@ export type ReattachPayloadSession = Pick<
|
||||
| 'suppressStructuralReplayPtyResize'
|
||||
| 'transport'
|
||||
| 'writeFreshShellViewportBlanking'
|
||||
| 'writeInputModeGround'
|
||||
| 'writeReplayEpilogue'
|
||||
| 'writeReplayData'
|
||||
>
|
||||
|
||||
+3
-1
@@ -36,11 +36,13 @@ function createSession(
|
||||
fireLog: FireLog,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): ReattachPayloadSession {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test read only these session members.
|
||||
return {
|
||||
pane: createPane(1),
|
||||
rememberReattachPayloadAgentSignal: vi.fn(),
|
||||
writeReplayData: vi.fn((data: string) => fireLog.push(`write:${data}`)),
|
||||
reattachReplayResetSequence: vi.fn(() => '<reset>'),
|
||||
chooseReattachReplayReset: vi.fn(() => '<reset>'),
|
||||
writeReplayEpilogue: vi.fn((profile: string) => fireLog.push(`write:${profile}`)),
|
||||
sendFocusedReattachFocusInAfterReplay: vi.fn(),
|
||||
applySnapshotKittyKeyboardModes: vi.fn(() => fireLog.push('kitty:snapshot-baseline')),
|
||||
setRestoredSnapshotBaseline: vi.fn(),
|
||||
|
||||
@@ -158,8 +158,9 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void {
|
||||
continue
|
||||
}
|
||||
if (clearBeforeReplay || data.length > 0) {
|
||||
await session.writeReplayDataAsync(
|
||||
session.reattachReplayResetSequence(data, false, alternateScreen, terminalOwner)
|
||||
await session.writeReplayEpilogue(
|
||||
session.chooseReattachReplayReset(data, false, alternateScreen, terminalOwner),
|
||||
session.writeReplayDataAsync
|
||||
)
|
||||
if (!isCurrentPayload()) {
|
||||
continue
|
||||
|
||||
@@ -60,7 +60,7 @@ export function bindPrepaintParkedSshSnapshot(session: ConnectPanePtySession): v
|
||||
})) {
|
||||
session.writeReplayData(replayChunk)
|
||||
}
|
||||
session.writeReplayData(session.reattachReplayResetSequence(modelData))
|
||||
session.writeReplayEpilogue(session.chooseReattachReplayReset(modelData))
|
||||
if (snapshot.pendingEscapeTailAnsi) {
|
||||
session.writeReplayData(snapshot.pendingEscapeTailAnsi)
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-delivery-credit'
|
||||
import { nativeWindowsRewriteNeedsFollowupRenderRefresh } from '@/lib/pane-manager/terminal-complex-script'
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../../shared/terminal-mode-reset-profiles'
|
||||
import { forceFullViewportPresent } from '@/lib/pane-manager/terminal-render-pause-release'
|
||||
|
||||
import { FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS } from './foreground-output-budgets'
|
||||
@@ -126,7 +127,7 @@ export function bindWritePtyOutputToXterm(session: ConnectPanePtySession): void
|
||||
return
|
||||
}
|
||||
session.writePtyOutputToXterm(
|
||||
session.idleAgentTerminalModeReset,
|
||||
RESET_TERMINAL_CURSOR_STYLE,
|
||||
shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,22 +3,12 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter
|
||||
import type { PtyPaneStartup } from './pty-connection-types'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import type { PaneCwdMap } from './resolve-split-cwd'
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { RESET_KITTY_KEYBOARD_PROTOCOL } from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
|
||||
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { resolveLocalhostHttpLinkDisplayUrl } from '@/lib/http-link-routing'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
import { PRIMARY_SELECTION_MAX_LENGTH } from '@/lib/primary-selection'
|
||||
|
||||
/** Writes a transport-agnostic interrupt reset without running xterm work inline. */
|
||||
export function resetTerminalKeyboardProtocolAfterInterrupt(terminal: Terminal): void {
|
||||
writeTerminalOutput(terminal, RESET_KITTY_KEYBOARD_PROTOCOL, {
|
||||
foreground: true,
|
||||
latencySensitive: false
|
||||
})
|
||||
}
|
||||
|
||||
export function recordRuntimeCreatedTerminalPaneSplit(
|
||||
createdPane: unknown,
|
||||
args: {
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from './xterm-bypass-policy'
|
||||
import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle'
|
||||
import { resetTerminalKeyboardProtocolAfterInterrupt } from './terminal-pane-lifecycle-primitives'
|
||||
|
||||
type PaneInputContext = {
|
||||
pane: Pick<ManagedPane, 'id' | 'terminal'>
|
||||
@@ -141,7 +140,6 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi
|
||||
if (event.type === 'keydown') {
|
||||
pendingTerminalInterruptKeyup = true
|
||||
pane.terminal.input(TERMINAL_INTERRUPT_INPUT)
|
||||
resetTerminalKeyboardProtocolAfterInterrupt(pane.terminal)
|
||||
} else {
|
||||
pendingTerminalInterruptKeyup = false
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import {
|
||||
CONFIRMED_SHELL_MODE_RESET,
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
POST_REPLAY_MODE_RESET,
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
|
||||
const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l'
|
||||
@@ -74,10 +74,11 @@ function writeTerminal(term: Terminal, data: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe('terminal replay state reset', () => {
|
||||
it('includes Kitty keyboard protocol reset in replay reset bundles', () => {
|
||||
it('resets Kitty keyboard state only in the process-ended bundles', () => {
|
||||
expect(RESET_KITTY_KEYBOARD_PROTOCOL).toBe('\x1b[<99u\x1b[=0u')
|
||||
expect(POST_REPLAY_MODE_RESET).toContain(RESET_KITTY_KEYBOARD_PROTOCOL)
|
||||
expect(POST_REPLAY_REATTACH_RESET).toContain(RESET_KITTY_KEYBOARD_PROTOCOL)
|
||||
expect(CONFIRMED_SHELL_MODE_RESET).toContain(RESET_KITTY_KEYBOARD_PROTOCOL)
|
||||
expect(POST_REPLAY_REATTACH_RESET).not.toContain(RESET_KITTY_KEYBOARD_PROTOCOL)
|
||||
expect(POST_REPLAY_LIVE_SNAPSHOT_RESET).not.toContain(RESET_KITTY_KEYBOARD_PROTOCOL)
|
||||
})
|
||||
|
||||
@@ -137,7 +138,7 @@ describe('terminal replay state reset', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('clears active-buffer Kitty keyboard state after live reattach replay', async () => {
|
||||
it("keeps a live app's Kitty keyboard state through the live reattach reset", async () => {
|
||||
const term = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -147,25 +148,14 @@ describe('terminal replay state reset', () => {
|
||||
|
||||
try {
|
||||
await writeTerminal(term, '\x1b[=31u\x1b[>15u')
|
||||
expect(readKittyKeyboardState(term)).toMatchObject({
|
||||
flags: 15,
|
||||
mainStack: [31]
|
||||
})
|
||||
|
||||
await writeTerminal(term, POST_REPLAY_REATTACH_RESET)
|
||||
// Why: after renderer reattach, the next Ctrl+C must not inherit a stale
|
||||
// Kitty CSI-u encoder state from the replayed TUI snapshot.
|
||||
expect(readKittyKeyboardState(term)).toMatchObject({
|
||||
flags: 0,
|
||||
mainFlags: 0,
|
||||
mainStack: []
|
||||
})
|
||||
expect(readKittyKeyboardState(term)).toMatchObject({ flags: 15, mainStack: [31] })
|
||||
} finally {
|
||||
term.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears active-buffer Kitty keyboard state with the idle-agent reset sequence', async () => {
|
||||
it('clears active-buffer Kitty keyboard state with the confirmed-shell reset', async () => {
|
||||
const term = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -175,14 +165,7 @@ describe('terminal replay state reset', () => {
|
||||
|
||||
try {
|
||||
await writeTerminal(term, '\x1b[=31u\x1b[>15u')
|
||||
expect(readKittyKeyboardState(term)).toMatchObject({
|
||||
flags: 15,
|
||||
mainStack: [31]
|
||||
})
|
||||
|
||||
await writeTerminal(term, `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`)
|
||||
// Why: this is the exact reset emitted when a native Windows agent turn
|
||||
// completes, so the next Backspace/Enter must not inherit CSI-u encoding.
|
||||
await writeTerminal(term, CONFIRMED_SHELL_MODE_RESET)
|
||||
expect(readKittyKeyboardState(term)).toMatchObject({
|
||||
flags: 0,
|
||||
mainFlags: 0,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
resolvePaneSeedCwd,
|
||||
resolveQueuedInitialCwd,
|
||||
replayLayoutWithOneShotParkIntent,
|
||||
resetTerminalKeyboardProtocolAfterInterrupt,
|
||||
retireMountedTerminalPaneSurface,
|
||||
shouldDetachPaneTransportOnUnmount,
|
||||
splitPaneWithOneShotStartup,
|
||||
@@ -168,23 +167,6 @@ describe('applyTerminalPaneCloseRequest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetTerminalKeyboardProtocolAfterInterrupt', () => {
|
||||
it('does not write to an xterm whose pipeline is certified dead', async () => {
|
||||
const { _resetWritePipelineHealthForTests, notifyUndeliverableWrite } =
|
||||
await import('@/lib/pane-manager/terminal-write-pipeline-health')
|
||||
const terminal = { write: vi.fn() }
|
||||
try {
|
||||
notifyUndeliverableWrite(terminal, 'replay-wedged')
|
||||
|
||||
resetTerminalKeyboardProtocolAfterInterrupt(terminal as never)
|
||||
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
_resetWritePipelineHealthForTests(terminal)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Why: onPaneCreated uses paneOwnsQueuedStartup to decide whether a pane may spend the tab's queued
|
||||
// startup command. Setup/issue splits borrow the same deps.startup field for their own one-shot
|
||||
// payload, so a looser test would let a split pane spend a command it never runs — re-breaking
|
||||
|
||||
@@ -25,7 +25,6 @@ export {
|
||||
mapRestoredPaneTitlesByPaneId,
|
||||
paneOwnsQueuedStartup,
|
||||
replayLayoutWithOneShotParkIntent,
|
||||
resetTerminalKeyboardProtocolAfterInterrupt,
|
||||
resolvePaneLinkCwd,
|
||||
resolvePaneSeedCwd,
|
||||
resolveQueuedInitialCwd,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker'
|
||||
import { POST_REPLAY_REATTACH_RESET } from './terminal-mode-reset-profiles'
|
||||
import { CONFIRMED_SHELL_MODE_RESET } from './terminal-mode-reset-profiles'
|
||||
|
||||
describe('TerminalKittyKeyboardModeTracker', () => {
|
||||
it('starts inactive and ignores non-kitty sequences', () => {
|
||||
@@ -71,7 +71,7 @@ describe('TerminalKittyKeyboardModeTracker', () => {
|
||||
})
|
||||
|
||||
// Why: a confirmed-shell reset lands between live chunks in both records, so they must agree.
|
||||
it('agrees with xterm when the reattach reset interrupts a split live sequence', async () => {
|
||||
it('agrees with xterm when the confirmed-shell reset interrupts a split live sequence', async () => {
|
||||
const term = new Terminal({ allowProposedApi: true, vtExtensions: { kittyKeyboard: true } })
|
||||
const tracker = new TerminalKittyKeyboardModeTracker()
|
||||
const replies: string[] = []
|
||||
@@ -87,7 +87,7 @@ describe('TerminalKittyKeyboardModeTracker', () => {
|
||||
}
|
||||
|
||||
await feed('\x1b[>5u\x1b[?1049h\x1b[>3u\x1b[>')
|
||||
await feed(POST_REPLAY_REATTACH_RESET)
|
||||
await feed(CONFIRMED_SHELL_MODE_RESET)
|
||||
await feed('1u')
|
||||
expect(tracker.flags).toBe(0)
|
||||
expect(await xtermReport()).toBe('\x1b[?0u')
|
||||
@@ -344,4 +344,14 @@ describe('TerminalKittyKeyboardModeTracker', () => {
|
||||
expect(softReset.hasProvenBaseline).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Why: xterm ignores CSI u while the protocol is withheld, so the mirror must stay at 0 too.
|
||||
it('ignores kitty sequences and snapshot flags when the protocol is withheld', () => {
|
||||
const tracker = new TerminalKittyKeyboardModeTracker({ kittyKeyboard: false })
|
||||
tracker.scan('\x1b[>5u\x1b[=31u')
|
||||
expect(tracker.flags).toBe(0)
|
||||
tracker.resetForSnapshot()
|
||||
tracker.restoreSnapshotFlags(31)
|
||||
expect(tracker.flags).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,13 +19,11 @@ type KittyStackFrame = { flags: number; known: boolean }
|
||||
* DECSET/DECRST 47/1047/1049, the full reset on RIS, and the soft reset on
|
||||
* DECSTR (CSI ! p).
|
||||
*
|
||||
* Why a mirror instead of reading xterm's internal state: Orca defensively
|
||||
* wipes the renderer terminal's kitty flags at moments when the TUI may have
|
||||
* died (Ctrl+C interrupts, reattach resets) while the TUI is usually still
|
||||
* alive and expecting protocol-encoded input. This tracker is fed by
|
||||
* application output, snapshot restores, and the Orca resets that also reach
|
||||
* xterm (ConPTY agent-idle, confirmed shell), never by the renderer's other
|
||||
* defensive xterm writes, so it reflects what the live application negotiated.
|
||||
* Why a mirror instead of reading xterm's internal state: xterm's public API
|
||||
* exposes no kitty flags. The renderer scans application output as it queues
|
||||
* it and every mode write of its own; wherever xterm may skip or discard
|
||||
* scanned bytes (snapshot replays, abandoned restores) the renderer re-asserts
|
||||
* the mirror's flags into xterm, so their active-screen flags converge.
|
||||
* The daemon reuses it to carry flags into snapshots (xterm's SerializeAddon does
|
||||
* not serialize kitty state).
|
||||
*/
|
||||
@@ -49,6 +47,12 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
// 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
|
||||
private readonly kittyKeyboard: boolean
|
||||
|
||||
/** `kittyKeyboard: false` mirrors an xterm with the protocol withheld, which ignores `CSI u`. */
|
||||
constructor(options: { kittyKeyboard?: boolean } = {}) {
|
||||
this.kittyKeyboard = options.kittyKeyboard ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* Current effective kitty keyboard flags. `0` doubles as the conservative
|
||||
@@ -129,7 +133,7 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
*/
|
||||
restoreSnapshotFlags(flags: number): void {
|
||||
const parsed = parseTerminalKittyKeyboardFlags(flags)
|
||||
if (parsed === undefined) {
|
||||
if (parsed === undefined || !this.kittyKeyboard) {
|
||||
return
|
||||
}
|
||||
this.currentFlags = parsed
|
||||
@@ -184,7 +188,9 @@ export class TerminalKittyKeyboardModeTracker {
|
||||
this.applyScreenSwitch(match[1], match[2] === 'h')
|
||||
continue
|
||||
}
|
||||
this.applyKittySequence(match[3], match[4] ?? '', replay)
|
||||
if (this.kittyKeyboard) {
|
||||
this.applyKittySequence(match[3], match[4] ?? '', replay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
PROCESS_BOUNDARY_GROUND,
|
||||
RESET_GRAPHIC_RENDITION,
|
||||
RESET_MOUSE_REPORTING,
|
||||
buildKittyKeyboardRestore,
|
||||
buildPostReplayLiveAgentReattachReset,
|
||||
CONFIRMED_SHELL_MODE_RESET,
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
replayPayloadEndsWithCursorHidden
|
||||
} from './terminal-mode-reset-profiles'
|
||||
import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker'
|
||||
|
||||
// Why literal expectations: consumers import these constants, so only a byte-level
|
||||
// assertion here can catch a profile silently losing a mode it is meant to clear.
|
||||
@@ -31,7 +35,7 @@ describe('terminal mode reset profiles', () => {
|
||||
|
||||
it('pins the daemon-reattach profile, which keeps bracketed paste', () => {
|
||||
expect(POST_REPLAY_REATTACH_RESET).toBe(
|
||||
'\x1b[0m\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b7'
|
||||
'\x1b[0m\x1b[0 q\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b7'
|
||||
)
|
||||
expect(POST_REPLAY_REATTACH_RESET).toContain(RESET_GRAPHIC_RENDITION)
|
||||
expect(POST_REPLAY_REATTACH_RESET).not.toContain('\x1b[?2004l')
|
||||
@@ -41,9 +45,7 @@ describe('terminal mode reset profiles', () => {
|
||||
// so this profile can reach a plain shell, where armed focus reporting rings BEL on every pane
|
||||
// switch. Dropping it would also make this byte-identical to the live-agent profile.
|
||||
it('pins the live alternate-screen profile, which keeps mouse reporting but not focus', () => {
|
||||
expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).toBe(
|
||||
'\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?1004l'
|
||||
)
|
||||
expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).toBe('\x1b[0 q\x1b[?25h\x1b[?1004l')
|
||||
expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).not.toContain(RESET_MOUSE_REPORTING)
|
||||
expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).not.toBe(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)
|
||||
})
|
||||
@@ -87,7 +89,7 @@ describe('terminal mode reset profiles', () => {
|
||||
// coverage asserts they were passed through unchanged — need a literal here.
|
||||
it('pins the live-snapshot and live-agent profiles', () => {
|
||||
expect(POST_REPLAY_LIVE_SNAPSHOT_RESET).toBe('\x1b[0 q\x1b[?25h\x1b[?1004l')
|
||||
expect(POST_REPLAY_LIVE_AGENT_REATTACH_RESET).toBe('\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h')
|
||||
expect(POST_REPLAY_LIVE_AGENT_REATTACH_RESET).toBe('\x1b[0 q\x1b[?25h')
|
||||
expect(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET).toBe('\x1b[0 q')
|
||||
})
|
||||
|
||||
@@ -116,4 +118,57 @@ describe('terminal mode reset profiles', () => {
|
||||
expect(buildPostReplayLiveAgentReattachReset('x\x1b[?25h')).toContain('\x1b[?25h')
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a live reattach must keep the app's negotiated flags; the epilogue restores them instead.
|
||||
it('keeps Kitty resets out of every live reattach profile', () => {
|
||||
for (const profile of [
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
|
||||
POST_REPLAY_DEAD_TUI_RESET,
|
||||
POST_REPLAY_LIVE_AGENT_REATTACH_RESET,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET,
|
||||
buildPostReplayLiveAgentReattachReset('x\x1b[?25l')
|
||||
]) {
|
||||
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
|
||||
expect(profile).not.toMatch(/\x1b\[[<=>][0-9;]*u/)
|
||||
}
|
||||
expect(CONFIRMED_SHELL_MODE_RESET).toBe(`${POST_REPLAY_REATTACH_RESET}\x1b[<99u\x1b[=0u`)
|
||||
})
|
||||
|
||||
it('restores proven Kitty flags and only pops unproven ones', () => {
|
||||
expect(buildKittyKeyboardRestore(5)).toBe('\x1b[<99u\x1b[=5u')
|
||||
expect(buildKittyKeyboardRestore(undefined)).toBe('\x1b[<99u')
|
||||
})
|
||||
|
||||
// Why: xterm pushes a relay-redelivered push again while the mirror applies it as a set, so
|
||||
// without the pop-all the app's single pop would leave xterm on the stale frame.
|
||||
it("brings xterm and the mirror to 0 on the app's pop after a redelivered push", async () => {
|
||||
const term = new Terminal({ allowProposedApi: true, vtExtensions: { kittyKeyboard: true } })
|
||||
const mirror = new TerminalKittyKeyboardModeTracker()
|
||||
const replies: string[] = []
|
||||
term.onData((data) => replies.push(data))
|
||||
const write = (data: string): Promise<void> =>
|
||||
new Promise((resolve) => term.write(data, resolve))
|
||||
const xtermFlags = async (): Promise<string> => {
|
||||
replies.length = 0
|
||||
await write('\x1b[?u')
|
||||
return replies.join('')
|
||||
}
|
||||
for (const redelivery of ['\x1b[>1u', '\x1b[>1u']) {
|
||||
mirror.scanReplay(redelivery)
|
||||
await write(redelivery)
|
||||
}
|
||||
const restore = buildKittyKeyboardRestore(mirror.snapshotFlags)
|
||||
mirror.scan(restore)
|
||||
await write(restore)
|
||||
expect(mirror.flags).toBe(1)
|
||||
expect(await xtermFlags()).toBe('\x1b[?1u')
|
||||
|
||||
mirror.scan('\x1b[<u')
|
||||
await write('\x1b[<u')
|
||||
expect(mirror.flags).toBe(0)
|
||||
expect(await xtermFlags()).toBe('\x1b[?0u')
|
||||
term.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
// Why: SerializeAddon replays mode bits assuming reattach to a live TUI, but Orca restores against a fresh shell with none, so stale bits (e.g. focus reporting rings the bell on click) must be reset.
|
||||
export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q'
|
||||
export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u'
|
||||
|
||||
/**
|
||||
* Re-asserts replayed Kitty flags. Pop-all first, so a push a replay
|
||||
* redelivered into xterm's stack cannot resurface on the app's next pop.
|
||||
* Unproven flags only pop, which lands on 0 without proving it.
|
||||
*/
|
||||
export function buildKittyKeyboardRestore(provenFlags: number | undefined): string {
|
||||
return provenFlags === undefined ? '\x1b[<99u' : `\x1b[<99u\x1b[=${provenFlags}u`
|
||||
}
|
||||
|
||||
// Why: abandoned byte-gap replay drains live chunks, so a dropped intensity reset must not style them (STA-4042).
|
||||
export const RESET_GRAPHIC_RENDITION = '\x1b[0m'
|
||||
// Last so a dead process cannot leave stale attributes in the DECSC register.
|
||||
@@ -17,11 +27,15 @@ export const RESET_MOUSE_REPORTING =
|
||||
// Why: serialized panes can end with a live pen, but the following shell assumes default attributes.
|
||||
export const POST_REPLAY_MODE_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l\x1b[?2004l${SAVE_GROUNDED_CURSOR}`
|
||||
|
||||
// Why: same-session live replay; keep cursor/focus cleanup but preserve Kitty flags the running TUI relies on.
|
||||
// Why: same-session live replay; keep cursor/focus cleanup (the replay epilogue re-asserts Kitty flags).
|
||||
export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l`
|
||||
|
||||
// Why: the normal-buffer fallback can follow a dead TUI, so its stale pen and saved pen must not reach the surviving shell.
|
||||
export const POST_REPLAY_REATTACH_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l${SAVE_GROUNDED_CURSOR}`
|
||||
// No Kitty reset in any reattach profile: the replay epilogue restores the host's flags instead.
|
||||
export const POST_REPLAY_REATTACH_RESET = `${RESET_GRAPHIC_RENDITION}${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l${SAVE_GROUNDED_CURSOR}`
|
||||
|
||||
// Why: a foreground proof says the app is gone, so its Kitty flags go with its other modes.
|
||||
export const CONFIRMED_SHELL_MODE_RESET = `${POST_REPLAY_REATTACH_RESET}${RESET_KITTY_KEYBOARD_PROTOCOL}`
|
||||
|
||||
// Why: a foreground shell proves an alternate-screen owner died without its
|
||||
// ?1049l cleanup; leave the renderer on the shell's normal buffer as well.
|
||||
@@ -30,10 +44,10 @@ export const POST_REPLAY_DEAD_TUI_RESET = `\x1b[?1049l${POST_REPLAY_REATTACH_RES
|
||||
// Why: an alt-screen reattach replays the daemon's rehydrateSequences, which re-arm the live TUI's
|
||||
// mouse modes; wiping them one write later hands drags back to xterm's row selection (#8291).
|
||||
// Normal-buffer panes keep RESET_MOUSE_REPORTING so a dead TUI's stale modes never reach a shell (#7893).
|
||||
export const POST_REPLAY_REATTACH_RESET_KEEP_MOUSE = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l`
|
||||
export const POST_REPLAY_REATTACH_RESET_KEEP_MOUSE = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l`
|
||||
|
||||
// Why: a live agent owns focus reporting; resetting ?1004h suppresses the focus-in it needs to re-anchor its cursor (IME).
|
||||
export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h`
|
||||
export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h`
|
||||
|
||||
// Why: a live agent owns cursor/focus here; forcing ?25h/?1004l breaks a parked agent that only arms ?1004h at startup.
|
||||
export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE
|
||||
@@ -134,6 +148,6 @@ export function replayPayloadEndsWithCursorHidden(payload: string): boolean {
|
||||
// Why: some agents hide the real cursor and draw their own, so preserve the payload's final visibility (pty-connection re-shows it if the agent was actually a dead TUI).
|
||||
export function buildPostReplayLiveAgentReattachReset(payload: string): string {
|
||||
return replayPayloadEndsWithCursorHidden(payload)
|
||||
? `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`
|
||||
? RESET_TERMINAL_CURSOR_STYLE
|
||||
: POST_REPLAY_LIVE_AGENT_REATTACH_RESET
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* A live app's kitty keyboard flags survive park and reveal in both records:
|
||||
* xterm (which encodes keys) and the pane mirror (which Orca's shortcut policy
|
||||
* reads). The reveal builds a fresh xterm, so without the replay epilogue's
|
||||
* restore xterm would sit at 0 while the mirror adopted the host's flags.
|
||||
*/
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getActiveTabId,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
clearTerminalPtyWriteLog,
|
||||
installTerminalPtyWriteSpy,
|
||||
readTerminalPtyWrites
|
||||
} from './helpers/terminal-pty-write-spy'
|
||||
import { waitForPtyShellEcho } from './terminal-pty-readiness'
|
||||
|
||||
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
|
||||
const APP_FLAGS = 5
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
|
||||
})
|
||||
|
||||
async function readXtermKittyFlags(page: Page): Promise<number | null> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const tabId = state?.activeTabId ?? null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm exposes kitty flags only on its private core; null when absent.
|
||||
const terminal = pane?.terminal as
|
||||
| { _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } }
|
||||
| undefined
|
||||
return terminal?._core?.coreService?.kittyKeyboard?.flags ?? null
|
||||
})
|
||||
}
|
||||
|
||||
// Why Shift+Enter: the policy emits CSI-u only when the pane mirror reports kitty flags.
|
||||
async function expectShiftEnterWrite(
|
||||
page: Page,
|
||||
app: ElectronApplication,
|
||||
expected: string
|
||||
): Promise<void> {
|
||||
await clearTerminalPtyWriteLog(app)
|
||||
await focusActiveTerminalInput(page)
|
||||
await page.keyboard.press('Shift+Enter')
|
||||
await expect
|
||||
.poll(async () => (await readTerminalPtyWrites(app)).includes(expected), {
|
||||
timeout: 5_000,
|
||||
message: `Shift+Enter did not write ${JSON.stringify(expected)}`
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((tabId) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('Orca store unavailable')
|
||||
}
|
||||
state.setActiveTabType('terminal', window.__store?.getState().activeWorktreeId ?? null)
|
||||
state.setActiveTab(tabId)
|
||||
}, tabId)
|
||||
await expect.poll(() => getActiveTabId(page)).toBe(tabId)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
}
|
||||
|
||||
test("park and reveal keep a live app's kitty flags in xterm and the mirror", async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}) => {
|
||||
test.skip(process.platform === 'win32', 'ConPTY panes withhold the kitty protocol')
|
||||
await installTerminalPtyWriteSpy(electronApp)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const tabId = await getActiveTabId(orcaPage)
|
||||
if (!tabId) {
|
||||
throw new Error('no active terminal tab')
|
||||
}
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
|
||||
|
||||
try {
|
||||
// The app stays alive through the park, so nothing grounds its flags.
|
||||
await sendToTerminal(orcaPage, ptyId, `printf '\\033[=${APP_FLAGS}u'; sleep 120\r`)
|
||||
await expect.poll(() => readXtermKittyFlags(orcaPage)).toBe(APP_FLAGS)
|
||||
await expectShiftEnterWrite(orcaPage, electronApp, '\x1b[13;2u')
|
||||
|
||||
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, {
|
||||
parkDelayMs: PARKING_DELAY_MS
|
||||
})
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await waitForActivePanePtyId(orcaPage)
|
||||
|
||||
await expect
|
||||
.poll(() => readXtermKittyFlags(orcaPage), {
|
||||
timeout: 10_000,
|
||||
message: 'the revealed xterm did not get the live app kitty flags back'
|
||||
})
|
||||
.toBe(APP_FLAGS)
|
||||
await expectShiftEnterWrite(orcaPage, electronApp, '\x1b[13;2u')
|
||||
} finally {
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
})
|
||||
@@ -227,27 +227,6 @@ async function getActiveTerminalViewport(
|
||||
})
|
||||
}
|
||||
|
||||
async function enableKittyKeyboardReporting(page: Page, flags: number): Promise<void> {
|
||||
await page.evaluate(async (flags) => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('No active terminal pane for kitty keyboard setup')
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
pane.terminal.write(`\x1b[=${flags}u`, resolve)
|
||||
})
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async function getKittyKeyboardFlags(page: Page): Promise<number | null> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
@@ -274,6 +253,40 @@ async function getKittyKeyboardFlags(page: Page): Promise<number | null> {
|
||||
})
|
||||
}
|
||||
|
||||
// Why arm from the PTY: the mirror the shortcut policy reads only sees application output.
|
||||
async function armKittyKeyboardFromPty(page: Page, ptyId: string, command: string): Promise<void> {
|
||||
await execInTerminal(page, ptyId, command)
|
||||
await expect
|
||||
.poll(async () => await getKittyKeyboardFlags(page), {
|
||||
timeout: 15_000,
|
||||
message: 'the application never armed kitty keyboard reporting'
|
||||
})
|
||||
.toBe(31)
|
||||
}
|
||||
|
||||
async function interruptAndExpectEtx(page: Page, app: ElectronApplication): Promise<void> {
|
||||
await clearPtyWriteLog(app)
|
||||
await focusActiveTerminalInput(page)
|
||||
await page.keyboard.down('Control')
|
||||
await page.keyboard.up('Control')
|
||||
expect((await getPtyWrites(app)).join('')).toBe('')
|
||||
await clearPtyWriteLog(app)
|
||||
|
||||
expect(await dispatchCtrlCToActiveTerminalTextarea(page, { keyupCtrlKey: false })).toEqual({
|
||||
keydownDefaultPrevented: false,
|
||||
keyupDefaultPrevented: false
|
||||
})
|
||||
await expect
|
||||
.poll(async () => (await getPtyWrites(app)).some((write) => write.includes('\x03')), {
|
||||
timeout: 5_000,
|
||||
message: 'Ctrl+C did not reach the PTY as ETX'
|
||||
})
|
||||
.toBe(true)
|
||||
const writes = (await getPtyWrites(app)).join('')
|
||||
expect(writes).not.toContain('\x1b[99;5u')
|
||||
expect(writes).not.toContain('\x1b[99')
|
||||
}
|
||||
|
||||
async function pressShiftedRussianLayoutKey(page: Page): Promise<{
|
||||
keydownDefaultPrevented: boolean
|
||||
keypressSent: boolean
|
||||
@@ -550,45 +563,55 @@ test.describe('Terminal Shortcuts', () => {
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u')
|
||||
})
|
||||
|
||||
test('plain Ctrl+C sends ETX under kitty keyboard reporting', async ({
|
||||
test('plain Ctrl+C sends ETX and leaves a surviving app its kitty flags', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}) => {
|
||||
test.skip(process.platform === 'win32', 'POSIX trap and printf fixture')
|
||||
await installMainProcessPtyWriteSpy(electronApp)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
// SIGINT during shell startup kills the shell, so interrupt only a ready prompt.
|
||||
await execInTerminal(orcaPage, ptyId, 'echo "CTRL_C_""READY"')
|
||||
await waitForTerminalOutput(orcaPage, 'CTRL_C_READY')
|
||||
await enableKittyKeyboardReporting(orcaPage, 31)
|
||||
await clearPtyWriteLog(electronApp)
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
await orcaPage.keyboard.down('Control')
|
||||
await orcaPage.keyboard.up('Control')
|
||||
expect((await getPtyWrites(electronApp)).join('')).toBe('')
|
||||
await clearPtyWriteLog(electronApp)
|
||||
// An app that survives SIGINT, like an agent TUI, and disarms its own flags on exit.
|
||||
await armKittyKeyboardFromPty(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
`bash -c 'trap "" INT; printf "\\033[=31u"; read -r _; printf "\\033[=0u"'`
|
||||
)
|
||||
|
||||
expect(await dispatchCtrlCToActiveTerminalTextarea(orcaPage, { keyupCtrlKey: false })).toEqual({
|
||||
keydownDefaultPrevented: false,
|
||||
keyupDefaultPrevented: false
|
||||
})
|
||||
await interruptAndExpectEtx(orcaPage, electronApp)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getPtyWrites(electronApp)).some((write) => write.includes('\x03')), {
|
||||
timeout: 5_000,
|
||||
message: 'Ctrl+C did not reach the PTY as ETX'
|
||||
})
|
||||
.toBe(true)
|
||||
const writes = (await getPtyWrites(electronApp)).join('')
|
||||
expect(writes).not.toContain('\x1b[99;5u')
|
||||
expect(writes).not.toContain('\x1b[99')
|
||||
// Orca no longer guesses the app died: xterm keeps encoding for it, and the
|
||||
// mirror the shortcut policy reads agrees (Shift+Enter stays CSI-u).
|
||||
expect(await getKittyKeyboardFlags(orcaPage)).toBe(31)
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u')
|
||||
|
||||
await sendToTerminal(orcaPage, ptyId, '\r')
|
||||
await expect.poll(async () => await getKittyKeyboardFlags(orcaPage)).toBe(0)
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r')
|
||||
})
|
||||
|
||||
test('the host grounds kitty flags an app killed by Ctrl+C left armed', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}) => {
|
||||
test.skip(process.platform === 'win32', 'POSIX printf fixture')
|
||||
await installMainProcessPtyWriteSpy(electronApp)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await execInTerminal(orcaPage, ptyId, 'echo "CTRL_C_""READY"')
|
||||
await waitForTerminalOutput(orcaPage, 'CTRL_C_READY')
|
||||
// `cat` dies on SIGINT with the flags still armed, like a crashed TUI.
|
||||
await armKittyKeyboardFromPty(orcaPage, ptyId, `printf '\\033[=31u'; cat`)
|
||||
|
||||
await interruptAndExpectEtx(orcaPage, electronApp)
|
||||
|
||||
await expect
|
||||
.poll(async () => await getKittyKeyboardFlags(orcaPage), {
|
||||
timeout: 5_000,
|
||||
message: 'Ctrl+C did not clear stale Kitty keyboard flags'
|
||||
message: 'the host did not ground the dead app kitty flags at command end'
|
||||
})
|
||||
.toBe(0)
|
||||
|
||||
await clearPtyWriteLog(electronApp)
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
await orcaPage.keyboard.type('x')
|
||||
@@ -598,8 +621,7 @@ test.describe('Terminal Shortcuts', () => {
|
||||
message: 'Post-interrupt keyboard input stayed in Kitty CSI-u mode'
|
||||
})
|
||||
.toBe(true)
|
||||
const postInterruptWrites = (await getPtyWrites(electronApp)).join('')
|
||||
expect(postInterruptWrites).not.toContain('\x1b[')
|
||||
expect((await getPtyWrites(electronApp)).join('')).not.toContain('\x1b[')
|
||||
await orcaPage.keyboard.press('Backspace')
|
||||
})
|
||||
|
||||
@@ -910,12 +932,13 @@ test.describe('Terminal Shortcuts', () => {
|
||||
orcaPage,
|
||||
electronApp
|
||||
}) => {
|
||||
test.skip(process.platform === 'win32', 'ConPTY panes withhold the kitty protocol')
|
||||
await installMainProcessPtyWriteSpy(electronApp)
|
||||
// Why: CI can mount the xterm surface before the pane transport has a
|
||||
// live PTY. Probe first so xterm onData cannot race a disconnected
|
||||
// sendInput path, then clear the probe writes before the layout assertion.
|
||||
await waitForActivePanePtyId(orcaPage)
|
||||
await enableKittyKeyboardReporting(orcaPage, 31)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await armKittyKeyboardFromPty(orcaPage, ptyId, `printf '\\033[=31u'; cat`)
|
||||
await clearPtyWriteLog(electronApp)
|
||||
|
||||
const dispatch = await pressShiftedRussianLayoutKey(orcaPage)
|
||||
@@ -937,5 +960,6 @@ test.describe('Terminal Shortcuts', () => {
|
||||
const joinedWrites = writes.join('')
|
||||
expect(joinedWrites).not.toContain('\x1b[97:1060;2;1060u')
|
||||
expect(joinedWrites).not.toContain('\x1b[97:1060;2:3u')
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user