diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index 5e799230fa1..afc7315abcd 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -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( diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts index ac4e34897b6..8537de08961 100644 --- a/src/main/daemon/terminal-mode-rehydrate-sequences.ts +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -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) { diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 90b41d9656a..2886a00e8b6 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -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. */ diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 4a63a289551..a40251967c4 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -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() 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(() => { diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 59ed54f68d2..875f13a96f4 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -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 | 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, diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts index c27f085029d..3bf324e385d 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts @@ -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(), diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts index 3e288626259..f3b3ed4f800 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts @@ -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']) }) }) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts index 43d4333cc17..9e684353eb7 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts @@ -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') } diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 8980c86ee8d..5835246ebaf 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -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 ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts index 2a20be95575..088c2c54905 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts @@ -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 () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts index 02c99e2aa4b..408ce503b7e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts index fe18699c20d..e9443188b95 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts index c3221a7753b..2845be5a64d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts new file mode 100644 index 00000000000..4817dc8c17d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts @@ -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[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + 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>()) +}) + +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() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + 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) => { + 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>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +function createDeps(overrides: Record = {}) { + 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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts index 39191d5d36a..84043771908 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts @@ -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') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index 1e8ad402de7..6c22586b59f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -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).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) ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts index a83236b5378..dbcc67fa47e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts index d4c2ffd6745..a0f06268e97 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts @@ -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() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index c91c51f789e..302efe743bb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-replay-epilogue.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-replay-epilogue.ts new file mode 100644 index 00000000000..56b4c515249 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-replay-epilogue.ts @@ -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)}` +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts index 552f62c54bb..22ef4f6618a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts @@ -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)' ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index 1eabdf99032..e8f8e7c1a08 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -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)) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 1c43f40b0b4..8c560ce30bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -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 })() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts index 67fd09bcc59..a494d08fc80 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts index 83ab2e3f478..cb553ebc7de 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -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 = ( + 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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 29bc438a01c..b260adcccb8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts index 3d08e5d495b..3e30289c2f6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts index 1705fbe2cdf..ea4c48f43f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts @@ -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 } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 450fdd799db..e895128b280 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 97ce140bd05..2c86932c499 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts index 3ea62593ed3..98315f2ca6f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts @@ -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 && diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index de3ee9011a8..46d2d41982d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -10,11 +10,14 @@ import type { ReattachPayloadContext } from './reattach-payload-context' import type { ReattachPayloadSession } from './reattach-payload-session' function createSession(overrides: Record = {}): 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(() => ''), + writeInputModeGround: vi.fn(), + chooseReattachReplayReset: vi.fn(() => ''), + writeReplayEpilogue: vi.fn(), sendFocusedReattachFocusInAfterReplay: vi.fn(), kittyKeyboardModes: { hasProvenBaseline: true, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index c9d53a56b54..b4c963d29f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -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' > diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts index 68782e666f6..bf14e261b2b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts @@ -36,11 +36,13 @@ function createSession( fireLog: FireLog, overrides: Record = {} ): 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(() => ''), + chooseReattachReplayReset: vi.fn(() => ''), + writeReplayEpilogue: vi.fn((profile: string) => fireLog.push(`write:${profile}`)), sendFocusedReattachFocusInAfterReplay: vi.fn(), applySnapshotKittyKeyboardModes: vi.fn(() => fireLog.push('kitty:snapshot-baseline')), setRestoredSnapshotBaseline: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index bd3d7284ac9..087326d0f40 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts b/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts index 8dfe10e8a6e..fd2a4a097a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts @@ -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) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts index 3335776a118..34fdd14bf70 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts @@ -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) ) } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts index d569e265098..385c07cf7c0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts @@ -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: { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts index 6ecab6f332b..32ee2639029 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts @@ -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 @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index ce6a3db257c..70a3c4d2668 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -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 { } 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, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index a86341860fa..7e9e1e42b93 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index d5f39d6fa8a..944a2e6bbe0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -25,7 +25,6 @@ export { mapRestoredPaneTitlesByPaneId, paneOwnsQueuedStartup, replayLayoutWithOneShotParkIntent, - resetTerminalKeyboardProtocolAfterInterrupt, resolvePaneLinkCwd, resolvePaneSeedCwd, resolveQueuedInitialCwd, diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts index 91fab874a78..7dbf7b94e1a 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts @@ -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) + }) }) diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.ts index 880d1fb5cf7..6b2b0e26f83 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.ts @@ -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) + } } } diff --git a/src/shared/terminal-mode-reset-profiles.test.ts b/src/shared/terminal-mode-reset-profiles.test.ts index d3de25ea4fe..203d836a3f8 100644 --- a/src/shared/terminal-mode-reset-profiles.test.ts +++ b/src/shared/terminal-mode-reset-profiles.test.ts @@ -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 => + new Promise((resolve) => term.write(data, resolve)) + const xtermFlags = async (): Promise => { + 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[ { + 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 { + 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 { + 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) + } +}) diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index c70ab00567b..a187df5b83b 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -227,27 +227,6 @@ async function getActiveTerminalViewport( }) } -async function enableKittyKeyboardReporting(page: Page, flags: number): Promise { - 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((resolve) => { - pane.terminal.write(`\x1b[=${flags}u`, resolve) - }) - }, flags) -} - async function getKittyKeyboardFlags(page: Page): Promise { return page.evaluate(() => { const state = window.__store?.getState() @@ -274,6 +253,40 @@ async function getKittyKeyboardFlags(page: Page): Promise { }) } +// 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 { + 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 { + 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') }) })