From 3eac4d93d3c31c018c08e48c849fe5d4ffc4cace Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:21:53 -0400
Subject: [PATCH] fix(terminal): stop guessing that apps died and wiping their
keyboard modes (#23584)
* fix(terminal): stop guessing that apps died and wiping their keyboard modes
The renderer wiped xterm's Kitty keyboard flags on every Ctrl+C, every live
reattach, and every Windows agent turn end, though the app usually survives.
xterm then encoded keys in legacy form while the pane mirror Orca's shortcut
policy reads still held the negotiated flags, so Cmd+C, Shift+Enter,
Option/Alt and IME commits disagreed with each other and with the app.
- Delete the Ctrl+C wipe, the ConPTY agent-idle wipe, and the mirror reset on
every PTY exit (it also ran on unverified host-loss exits).
- Live reattach profiles no longer reset Kitty; every replay epilogue instead
re-asserts the mirror's flags (pop-all, then the host-proven set; a bare pop
while unproven), so a revealed xterm gets the live app's flags back.
- Route every renderer-originated mode write through one scanning writer so
xterm and the mirror always parse the same bytes: confirmed-shell reset,
hibernate, cold restore, and a full process-boundary ground at fresh spawn.
- Read Kitty flags as 0 where the protocol is withheld (ConPTY), since xterm
ignores CSI u there but the mirror still scans it.
- The dashboard popout restores snapshot flags as bytes so its xterm agrees.
* style(terminal): separate the kitty restore builder from the pen reset
* fix(terminal): let the kitty mirror own the withheld-protocol rule
Review follow-ups for the stop-guessing change:
- The mirror takes a `kittyKeyboard` option from the xterm's advertisement
and ignores CSI u when withheld, as xterm does, replacing a per-reader
helper that any new reader could skip. Daemon/headless users keep the
default.
- Replay epilogues are writers (`writeReplayEpilogue`,
`writeReattachReplayReset`) that take the sync or async xterm writer, so
nothing that looks like a builder mutates the mirror.
- An abandoned hidden restore re-asserts the mirror's kitty flags after the
byte-gap reset: its discarded chunks were already scanned.
- Tests pin a non-zero host restore (epilogue ends `=31u`, mirror 31), a
withheld pane staying at 0, and the restart-in-place ground landing after
the mirror reset; the epilogue test helper is now an exact builder.
* refactor(terminal): one epilogue writer and one scanned ground per boundary
- Reattach callers write `chooseReattachReplayReset(...)` through
`writeReplayEpilogue`, dropping the second writer from the session.
- Fresh spawn and cold restore rely on their scanned ground alone: it leaves
the mirror known at 0 with a proven baseline, so the extra reset() was dead.
---
src/main/daemon/daemon-pty-spawn-result.ts | 2 +-
.../terminal-mode-rehydrate-sequences.ts | 8 +-
src/main/providers/pty-spawn-result.ts | 4 +-
.../AgentTerminalPreview.test.tsx | 8 +-
.../dashboard-popout/AgentTerminalPreview.tsx | 11 +-
.../preview-terminal-options.ts | 14 +-
.../preview-terminal-snapshot-replay.test.ts | 4 +-
.../preview-terminal-snapshot-replay.ts | 8 +-
.../layout-serialization.test.ts | 3 +-
...-connection-daemon-snapshot-replay.test.ts | 9 +-
.../pty-connection-fresh-spawn-guards.test.ts | 22 --
...ty-connection-hidden-codex-queries.test.ts | 25 +-
...ction-hook-completion-side-effects.test.ts | 7 +-
...-connection-kitty-keyboard-restore.test.ts | 218 ++++++++++++++++++
...pty-connection-parked-ssh-snapshot.test.ts | 3 +-
...pty-connection-reattach-mode-reset.test.ts | 45 ++--
...connection-replay-payload-handling.test.ts | 10 +-
...-connection-stalled-hidden-restore.test.ts | 22 +-
.../pty-connection-test-pane-fixtures.ts | 4 +-
.../pty-connection-test-replay-epilogue.ts | 6 +
...-connection-windows-keyboard-reset.test.ts | 21 +-
.../pty-connection/apply-reattach-payload.ts | 16 +-
.../pty-connection/connect-pane-pty.ts | 18 +-
.../pty-connection/direct-ssh-retry-status.ts | 9 -
.../fresh-spawn-follow-reset.ts | 27 ++-
.../pty-connection/fresh-spawn-start.ts | 9 +-
.../hidden-output-restore-abandon.ts | 3 +-
.../hidden-output-restore-drain.ts | 3 +-
.../hidden-output-restore-snapshot.ts | 16 +-
.../pty-connection/pane-agent-identity.ts | 17 +-
.../pty-connection/pty-exit-hibernate.ts | 13 +-
.../reattach-payload-context.test.ts | 5 +-
.../reattach-payload-session.ts | 4 +-
...-payload-ssh-reconnect-model-paint.test.ts | 4 +-
.../pty-connection/replay-data-drain.ts | 5 +-
.../pty-connection/ssh-snapshot-prepaint.ts | 2 +-
.../write-pty-output-to-xterm.ts | 3 +-
.../terminal-pane-lifecycle-primitives.ts | 10 -
.../terminal-pane/terminal-pane-pane-input.ts | 2 -
.../terminal-replay-cursor-state.test.ts | 35 +--
.../use-terminal-pane-lifecycle.test.ts | 18 --
.../use-terminal-pane-lifecycle.ts | 1 -
...rminal-kitty-keyboard-mode-tracker.test.ts | 16 +-
.../terminal-kitty-keyboard-mode-tracker.ts | 24 +-
.../terminal-mode-reset-profiles.test.ts | 65 +++++-
src/shared/terminal-mode-reset-profiles.ts | 24 +-
.../terminal-kitty-reattach-parity.spec.ts | 119 ++++++++++
tests/e2e/terminal-shortcuts.spec.ts | 120 ++++++----
48 files changed, 728 insertions(+), 314 deletions(-)
create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-test-replay-epilogue.ts
create mode 100644 tests/e2e/terminal-kitty-reattach-parity.spec.ts
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')
})
})