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')
})
})