diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts index f640483a39f..0bfb6cd44c9 100644 --- a/src/main/daemon/daemon-pty-buffer-snapshots.ts +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -184,6 +184,11 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { this.markSessionDirty(id) } + async resetInputModes(id: string): Promise { + await this.client.request('resetInputModes', { sessionId: id }) + this.markSessionDirty(id) + } + acknowledgeDataEvent(_id: string, _charCount: number): void { // No flow control for daemon-backed terminals } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2fbfd17a039..c3330e61826 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -162,6 +162,10 @@ export class DaemonPtyRouter implements IPtyProvider { await this.adapterFor(id).clearBuffer(id) } + async resetInputModes(id: string): Promise { + await this.adapterFor(id).resetInputModes(id) + } + async closeStartupQueryAuthority(id: string): Promise { return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0 } diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index ae007366dd0..9bd0d7963b6 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -129,6 +129,9 @@ export class DaemonRequestRouter { case 'clearScrollback': this.options.host.clearScrollback(request.payload.sessionId) return {} + case 'resetInputModes': + this.options.host.resetInputModes(request.payload.sessionId) + return {} case 'listSessions': return { sessions: this.options.host.listSessions() } case 'shutdownIfIdle': diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index f2e86abab28..363e9125b26 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -50,6 +50,7 @@ function createProvider( getCwd: vi.fn(async () => ''), getInitialCwd: vi.fn(async () => ''), clearBuffer: vi.fn(async () => {}), + resetInputModes: vi.fn(async () => {}), acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 2b50424ae83..6db8c80ac87 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -168,6 +168,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } clearBuffer = (id: string): Promise => this.providerFor(id).clearBuffer(id) + resetInputModes = (id: string): Promise => this.providerFor(id).resetInputModes(id) async closeStartupQueryAuthority(id: string): Promise { return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0 diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index 507ca01a927..04b3f773bda 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -125,6 +125,16 @@ export class SessionOutputPlane { }) } + /** Grounds the emulator and the cold-restore records without a client + * broadcast; attached renderers ground themselves (Reset Terminal). */ + applyInputModeGround(ground: string): void { + if (this.disposed) { + return + } + this.emulator.write(ground) + this.record({ kind: 'output', data: ground }) + } + isCursorOnEmptyPromptLine(): boolean { return this.emulator.isCursorOnEmptyPromptLine() } diff --git a/src/main/daemon/session-reset-input-modes.test.ts b/src/main/daemon/session-reset-input-modes.test.ts new file mode 100644 index 00000000000..ea1aa7e7351 --- /dev/null +++ b/src/main/daemon/session-reset-input-modes.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { Session } from './session' +import type { SubprocessHandle } from './session-subprocess-handle' + +function createSession() { + let onData: ((data: string) => void) | null = null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Session reads only these members. + const handle = { + pid: 999, + getForegroundProcess: () => null, + confirmShellForeground: vi.fn(async () => false), + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + terminateOwnedTree: () => 'unavailable' as const, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit() {}, + dispose: () => {} + } as unknown as SubprocessHandle + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the omitted options are optional. + const session = new Session({ + sessionId: 'reset', + cols: 80, + rows: 24, + subprocess: handle, + shellReadySupported: false + } as never) + const received: string[] = [] + session.attachClient({ onData: (data: string) => received.push(data), onExit: () => {} }) + return { session, received, emit: (data: string) => onData?.(data) } +} + +async function readModes(session: Session) { + await session.settleShellOwnershipConfirmation() + return session.getSnapshot()!.modes +} + +describe('Session.resetInputModes', () => { + it('grounds modes an app left armed without a command end, and the next snapshot carries it', async () => { + const { session, received, emit } = createSession() + // A crashed app's arming with no OSC 133;D the barrier could ground at. + emit('prompt$ app\r\n\x1b[>31u\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?1h') + const armed = await readModes(session) + expect(armed).toMatchObject({ + kittyKeyboardFlags: 31, + mouseTracking: true, + bracketedPaste: true + }) + session.takePendingOutput(false) + const broadcast = received.length + + session.resetInputModes() + + expect(await readModes(session)).toMatchObject({ + kittyKeyboardFlags: 0, + mouseTracking: false, + sgrMouseMode: false, + bracketedPaste: false, + applicationCursor: false, + alternateScreen: false + }) + // Clients ground themselves; a zero-raw span here would be dropped or duplicated. + expect(received).toHaveLength(broadcast) + const records = session.takePendingOutput(false)!.records + expect(records).toEqual([{ kind: 'output', data: expect.stringContaining('\x1b[<99u') }]) + session.dispose() + }) + + it('keeps focus reporting the terminal host armed for the pane', async () => { + const { session, emit } = createSession() + // ConPTY arms ?1004h before any shell marker. + emit('\x1b[?1004hprompt$ ') + await readModes(session) + session.takePendingOutput(false) + + session.resetInputModes() + + const [record] = session.takePendingOutput(false)!.records + expect(record).toMatchObject({ kind: 'output' }) + expect(record?.kind === 'output' && record.data).not.toContain('\x1b[?1004l') + session.dispose() + }) +}) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index e69bec7c5b1..7d113665292 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -146,13 +146,9 @@ export class Session { // Daemon POSIX PTYs need the local provider's cooked-echo containment (#13137). // DA1/CPR stay immediate unless an echo-risk reply is already held (#13892, #15559). - if (this.startupIngress.answerLiveQueryReply(data)) { - return - } - - // Why: keep queuing during the post-ready flush-gate window ('ready' but not yet flushed); a - // direct write would race fresh input ahead of the buffered startup command. - if (this.shellReady.tryEnqueue(data)) { + // Why the queue: keep queuing during the post-ready flush-gate window ('ready' but not yet + // flushed); a direct write would race fresh input ahead of the buffered startup command. + if (this.startupIngress.answerLiveQueryReply(data) || this.shellReady.tryEnqueue(data)) { return } @@ -277,6 +273,10 @@ export class Session { this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites) } + resetInputModes(): void { + this.output.applyInputModeGround(this.recoveryBarrier.groundInputModes()) + } + prepareForFinalSnapshot(): string { const held = this.shellReady.releaseHeldBytes() this.startupIngress.snapshotBarrier() diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 6e8d5ceee81..d5669618c7a 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -277,6 +277,10 @@ export class TerminalHost { getAliveTerminalHostSession(this.sessions, sessionId).clearScrollback() } + resetInputModes(sessionId: string): void { + getAliveTerminalHostSession(this.sessions, sessionId).resetInputModes() + } + // Why: null-not-throw — checkpoint is best-effort against a session that may have just exited. getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { return getTerminalHostSnapshot(this.sessions.get(sessionId), opts) diff --git a/src/main/daemon/terminal-shell-recovery-barrier.ts b/src/main/daemon/terminal-shell-recovery-barrier.ts index 6fe5832dcf1..d8a29d4b4b4 100644 --- a/src/main/daemon/terminal-shell-recovery-barrier.ts +++ b/src/main/daemon/terminal-shell-recovery-barrier.ts @@ -86,6 +86,13 @@ export class TerminalShellRecoveryBarrier { return this.scanner.owner } + /** Reset Terminal: grounds the lifecycle model now and returns the bytes for + * the host's other models. Not released downstream: a zero-raw span is dropped + * by credit-windowed delivery and snapshot-seq dedup, so each client grounds itself. */ + groundInputModes(): string { + return this.scanner.groundProcessBoundary() + } + /** Answers a paired runtime's ownership question from the barrier's settled * state. The barrier scans bytes before any consumer receives them, so its * verdict is never behind the caller's parse position — a fresh process diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index d8d88fab033..2ef4dfeb117 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -206,9 +206,10 @@ export type GetCwdRequest = { } } -export type ClearScrollbackRequest = { +// Why resetInputModes is a type, not a clear flag: an older daemon rejects it instead of clearing. +export type TerminalBufferActionRequest = { id: string - type: 'clearScrollback' + type: 'clearScrollback' | 'resetInputModes' payload: { sessionId: string } @@ -321,7 +322,7 @@ export type DaemonRequest = | InspectProcessRequest | ConfirmForegroundProcessRequest | ConfirmShellForegroundRequest - | ClearScrollbackRequest + | TerminalBufferActionRequest | ShutdownRequest | PingRequest | SystemResolverHealthRequest diff --git a/src/main/ipc/pty-reset-input-modes.test.ts b/src/main/ipc/pty-reset-input-modes.test.ts new file mode 100644 index 00000000000..55fa628078d --- /dev/null +++ b/src/main/ipc/pty-reset-input-modes.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { onMock } from './pty-ipc-mock-registry' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('Reset Terminal main-side entry points', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + function setup() { + const resetInputModes = vi.fn(async () => {}) + installDaemonTestProvider({ resetInputModes }) + let controller: { resetInputModes: (ptyId: string) => Promise } | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + resetHeadlessTerminalInputModes: vi.fn(async () => {}) + } + handlers.clear() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only these members. + registerPtyHandlers(mainWindow as never, runtime as never) + return { resetInputModes, runtime, controller: controller! } + } + + it('grounds the provider and the headless model from the pane IPC', () => { + const { resetInputModes, runtime } = setup() + const listener = onMock.mock.calls.findLast( + (entry: unknown[]) => entry[0] === 'pty:resetInputModes' + )?.[1] + if (typeof listener !== 'function') { + throw new Error('missing pty:resetInputModes listener') + } + + listener(null, { id: 'pty-1' }) + + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + expect(runtime.resetHeadlessTerminalInputModes).toHaveBeenCalledWith('pty-1') + // The pane grounded itself before sending; echoing back would ground it twice. + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:resetInputModes:request', + expect.anything() + ) + }) + + it("grounds the host window's pane and the provider for a runtime-initiated reset", async () => { + const { resetInputModes, controller } = setup() + + await controller.resetInputModes('pty-1') + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:resetInputModes:request', { + ptyId: 'pty-1' + }) + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + }) + + it('swallows an older host rejecting the request', async () => { + const { resetInputModes, controller } = setup() + resetInputModes.mockRejectedValueOnce(new Error('Unknown request type: resetInputModes')) + + await expect(controller.resetInputModes('pty-1')).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts index c9f3e92c620..62e74738b72 100644 --- a/src/main/ipc/pty/ipc/resize-visibility.ts +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -322,4 +322,13 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { .catch(() => {}) runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) }) + + ipcMain.removeAllListeners('pty:resetInputModes') + ipcMain.on('pty:resetInputModes', (_event, args: { id: string }) => { + // Why: an older daemon or relay rejects the request; its model keeps the modes until reattach. + tryGetProviderForPty(args.id) + ?.resetInputModes(args.id) + .catch(() => {}) + runtime?.resetHeadlessTerminalInputModes(args.id).catch(() => {}) + }) } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 75ff168a646..4013e06e37f 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -10,6 +10,7 @@ import { import { attachPtyFromRuntimeController, clearBufferFromRuntimeController, + resetInputModesFromRuntimeController, confirmForegroundProcessFromRuntimeController, confirmShellForegroundFromRuntimeController, getCwdFromRuntimeController, @@ -70,6 +71,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), + resetInputModes: (ptyId) => resetInputModesFromRuntimeController(deps, ptyId), hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId), listProcesses: (connectionId, opts) => listProcessesFromRuntimeController(deps, connectionId, opts), diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 5fec2c25e26..c555c0b3102 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -193,6 +193,21 @@ export async function clearBufferFromRuntimeController( } } +export async function resetInputModesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + // Why: a remote client's reset must also ground this host window's view of the pane. + if (deps.mainWindow && !deps.mainWindow.isDestroyed()) { + deps.mainWindow.webContents.send('pty:resetInputModes:request', { ptyId }) + } + try { + await getProviderForPty(ptyId).resetInputModes(ptyId) + } catch { + /* best effort: an older daemon or relay rejects the request */ + } +} + const settledLocalPtyProviderStartups = new WeakSet>() const watchedLocalPtyProviderStartups = new WeakSet>() diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index a8e2f9ce609..728e8a1273a 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -118,6 +118,8 @@ export class LocalPtyProvider implements IPtyProvider { clearBuffer(id: string): Promise { return clearLocalPtyBuffer(id) } + // A direct PTY keeps no terminal model of its own. + async resetInputModes(_id: string): Promise {} closeStartupQueryAuthority(id: string): number { return closeLocalPtyStartupQueryAuthority(id) } diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index cc63cfb9d09..c6f00c98354 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -109,6 +109,7 @@ describe('PTY provider dispatch', () => { getCwd: vi.fn(), getInitialCwd: vi.fn(), clearBuffer: vi.fn(), + resetInputModes: vi.fn(), acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 4b31fbbb2c8..d2b38bd4b2f 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -224,6 +224,8 @@ export type IPtyProvider = { getCwd(id: string): Promise getInitialCwd(id: string): Promise clearBuffer(id: string): Promise + /** Grounds the host's own terminal models (Reset Terminal); renderers ground themselves. */ + resetInputModes(id: string): Promise /** Ordered handoff from startup source authority to the live/hidden view authority. */ closeStartupQueryAuthority?: (id: string) => Promise | number acknowledgeDataEvent(id: string, charCount: number): void diff --git a/src/main/providers/ssh-pty-provider-rpc-operations.ts b/src/main/providers/ssh-pty-provider-rpc-operations.ts index bcd847de27b..361bdc15715 100644 --- a/src/main/providers/ssh-pty-provider-rpc-operations.ts +++ b/src/main/providers/ssh-pty-provider-rpc-operations.ts @@ -34,6 +34,9 @@ export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyP clearBuffer: async (id: string): Promise => { await mux.request('pty.clearBuffer', { id: toRelayPtyId(id) }) }, + resetInputModes: async (id: string): Promise => { + await mux.request('pty.resetInputModes', { id: toRelayPtyId(id) }) + }, closeStartupQueryAuthority: async (id: string): Promise => { const result = (await mux.request('pty.closeStartupQueryAuthority', { id: toRelayPtyId(id) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 76b5d9f85e4..854e076cec5 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -55,6 +55,7 @@ export class SshPtyProvider implements IPtyProvider { getCwd = (id: string): Promise => this.rpcOperations.getCwd(id) getInitialCwd = (id: string): Promise => this.rpcOperations.getInitialCwd(id) clearBuffer = (id: string): Promise => this.rpcOperations.clearBuffer(id) + resetInputModes = (id: string): Promise => this.rpcOperations.resetInputModes(id) closeStartupQueryAuthority = (id: string): Promise => this.rpcOperations.closeStartupQueryAuthority(id) acknowledgeDataEvent = (id: string, charCount: number): void => diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index dbdf4f1c37a..4ebeed55f90 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -6,6 +6,7 @@ import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-que import { isNativeWindowsConptyPty } from './terminal-model-query-authority' import { getTerminalViewAttributes } from './terminal-view-attribute-store' import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' +import { PROCESS_BOUNDARY_GROUND } from '../../shared/terminal-mode-reset-profiles' export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer { /** Shared factory for the per-PTY runtime emulators (seed, hydration, and @@ -195,4 +196,21 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback()) await state.writeChain } + + // Public: Reset Terminal must ground this model too; park/reveal and mobile restore from it. + async resetHeadlessTerminalInputModes(ptyId: string): Promise { + // Why now, not on the chain: onPtyData scans live bytes into these on arrival. + // Focus is outside their model, so the plain ground is exact. + this.scanProviderModeTrackers(ptyId, PROCESS_BOUNDARY_GROUND) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why on the chain: the ground must land after every PTY chunk already queued. + const completion = state.writeChain.then(async () => { + await state.emulator.write(state.ownership.groundInputModes()) + }) + state.writeChain = completion.catch(() => {}) + await completion + } } diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 160c99f2071..57b094165da 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -16,6 +16,14 @@ import { import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecutionContext { + /** Arrival-order mode scan: the settled tracker plus any in-flight snapshot capture's. */ + protected scanProviderModeTrackers(ptyId: string, data: string): void { + this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) + for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { + tracker.scan(data) + } + } + onPtyData( ptyId: string, data: string, @@ -27,10 +35,7 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution ): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) - this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) - for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { - tracker.scan(data) - } + this.scanProviderModeTrackers(ptyId, data) const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd const cwdChanged = osc7Metadata.cwdChanged diff --git a/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts b/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts index 7994f7d8857..53a9b4ffc59 100644 --- a/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts +++ b/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts @@ -80,6 +80,16 @@ export class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRuntimeWithA return { handle, cleared: true } } + async resetTerminalInputModes(handle: string): Promise<{ handle: string; reset: boolean }> { + const leaf = this.resolveLeafForHandle(handle) + if (!leaf?.ptyId) { + throw new Error('terminal_not_found') + } + await this.ptyController?.resetInputModes?.(leaf.ptyId) + await this.resetHeadlessTerminalInputModes(leaf.ptyId) + return { handle, reset: true } + } + getTerminalSize(ptyId: string): { cols: number; rows: number } | null { return this.ptyController?.getSize?.(ptyId) ?? null } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts index ef0a1a96de5..ef895661c10 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import type { PtyProviderBufferSnapshot } from '../../providers/pty-provider-contract' +import { TerminalKittyKeyboardModeTracker } from '../../../shared/terminal-kitty-keyboard-mode-tracker' import { HEADLESS_LEAF_ID, TEST_WORKTREE_ID, @@ -199,6 +201,79 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.data).not.toContain('line-0') }) + it('resets input modes through the PTY controller and the headless model', async () => { + const resetInputModes = vi.fn().mockResolvedValue(undefined) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resetInputModes + }) + syncSinglePty(runtime, 'pty-1') + // An app armed these and crashed with no command end. + runtime.onPtyData('pty-1', 'prompt$ app\r\n\x1b[>31u\x1b[?1000h\x1b[?2004h', 123) + const [terminal] = (await runtime.listTerminals()).terminals + const armed = await runtime.serializeTerminalBuffer('pty-1') + expect(armed?.kittyKeyboardFlags).toBe(31) + + await expect(runtime.resetTerminalInputModes(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + reset: true + }) + + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + const snapshot = await runtime.serializeTerminalBuffer('pty-1') + expect(snapshot?.kittyKeyboardFlags).toBe(0) + expect(snapshot?.data).not.toContain('\x1b[?1000h') + expect(snapshot?.data).not.toContain('\x1b[?2004h') + }) + + it('grounds the provider mode tracker before a later chunk, in the emulator order', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime, 'pty-1') + runtime.onPtyData('pty-1', 'prompt$ ', 123) + const tracker = new TerminalKittyKeyboardModeTracker() + runtime['providerModeTrackersByPtyId'].set('pty-1', tracker) + + // A TUI starts while the reset still waits on the headless write chain. + const reset = runtime.resetHeadlessTerminalInputModes('pty-1') + runtime.onPtyData('pty-1', '\x1b[?1049h', 124) + await reset + await runtime['headlessTerminals'].get('pty-1')?.writeChain + + expect(runtime['headlessTerminals'].get('pty-1')?.emulator.isAlternateScreen).toBe(true) + expect(tracker.isAlternateScreen).toBe(true) + }) + + it('grounds an in-flight provider snapshot capture so it cannot publish the pre-reset screen', async () => { + let resolveSnapshot: (snapshot: PtyProviderBufferSnapshot) => void = () => {} + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer: () => new Promise((resolve) => (resolveSnapshot = resolve)) + }) + syncSinglePty(runtime, 'pty-1') + const generation = runtime['getPtyLifecycleGeneration']('pty-1') + + // The capture's daemon request left before the reset; its answer predates the ground. + const capture = runtime['captureProviderTerminalBuffer']('pty-1', {}, generation) + await runtime.resetHeadlessTerminalInputModes('pty-1') + resolveSnapshot({ + data: '', + cols: 80, + rows: 24, + seq: 1, + source: 'headless', + alternateScreen: true + }) + await capture + + expect(runtime['providerModeTrackersByPtyId'].get('pty-1')?.isAlternateScreen).toBe(false) + }) + it('waits for terminal exit and resolves with the exit status', async () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/pty-shell-ownership-mirror.ts b/src/main/runtime/pty-shell-ownership-mirror.ts index 5c54465be75..45a7d8e8422 100644 --- a/src/main/runtime/pty-shell-ownership-mirror.ts +++ b/src/main/runtime/pty-shell-ownership-mirror.ts @@ -42,6 +42,11 @@ export class PtyShellOwnershipMirror { } } + /** Reset Terminal: the ground for this mirror's view of mode ownership, already scanned. */ + groundInputModes(): string { + return this.scanner.groundProcessBoundary() + } + get owner(): TerminalOwner | undefined { return this.scanner.owner } diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts index e26788d5d46..59370934057 100644 --- a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -21,6 +21,7 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ ['terminal.agentStatus', { terminal: 'term' }, false], ['terminal.rename', { terminal: 'term', title: null }, false], ['terminal.clearBuffer', { terminal: 'term' }, false], + ['terminal.resetInputModes', { terminal: 'term' }, false], ['terminal.send', { terminal: 'term', text: 'x' }, false], ['terminal.wait', { terminal: 'term', for: 'exit' }, false], ['terminal.create', {}, false], @@ -67,11 +68,11 @@ async function invoke(name: string, params: unknown, runtime: Partial { it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => { - expect(TERMINAL_METHODS).toHaveLength(35) + expect(TERMINAL_METHODS).toHaveLength(36) expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual( METHOD_CASES.map(([name, _params, stream]) => [name, stream]) ) - expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(35) + expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(36) for (const [name, params] of METHOD_CASES) { expect(() => schemaFor(name).parse(params), name).not.toThrow() } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index 52c1063b0cc..68afc1e8d19 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -121,5 +121,12 @@ export const TERMINAL_QUERY_METHODS = [ handler: async (params, { runtime }) => ({ clear: await runtime.clearTerminalBuffer(params.terminal) }) + }), + defineMethod({ + name: 'terminal.resetInputModes', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + reset: await runtime.resetTerminalInputModes(params.terminal) + }) }) ] diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 7b540a05e9a..60c1e18cf10 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -124,6 +124,7 @@ export type RuntimePtyController = { confirmShellForeground?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise + resetInputModes?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 55937134bb1..eb45c4dba68 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -84,6 +84,7 @@ export type PtyApi = { reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void clearBuffer: (id: string) => void + resetInputModes: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number, processedChars?: number) => void @@ -229,6 +230,7 @@ export type PtyApi = { }) => void ) => () => void onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void + onResetInputModesRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, snapshot: { diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 225aec9618a..1f8639c58be 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -98,6 +98,9 @@ export const ptySessionControlApi = { clearBuffer: (id: string): void => { ipcRenderer.send('pty:clearBuffer', { id }) }, + resetInputModes: (id: string): void => { + ipcRenderer.send('pty:resetInputModes', { id }) + }, ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 49e2189fd53..557570c8eab 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -120,6 +120,11 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.on('pty:clearBuffer:request', listener) return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) }, + onResetInputModesRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('pty:resetInputModes:request', listener) + return () => ipcRenderer.removeListener('pty:resetInputModes:request', listener) + }, sendSerializedBuffer: ( requestId: string, snapshot: { diff --git a/src/relay/pty-handler-shell-recovery.test.ts b/src/relay/pty-handler-shell-recovery.test.ts index 0d34aa80ff9..54497cd1763 100644 --- a/src/relay/pty-handler-shell-recovery.test.ts +++ b/src/relay/pty-handler-shell-recovery.test.ts @@ -193,4 +193,17 @@ describe.each([ expect(published()).toBe(`${DYING_COMMAND}${COMMAND_DONE}${PROMPT}`) }) + + it('grounds replay, not the live stream, on Reset Terminal after an unhooked crash', async () => { + // Armed with no command end: nothing the host barrier could ground at. + const unhookedCrash = '\x1b[>1u\x1b[?1000h' + await stream(unhookedCrash, PROMPT) + + dispatcher.feed(requestFrame(4, 'pty.resetInputModes', { id: ptyId })) + await vi.advanceTimersByTimeAsync(50) + + // The client grounds its own view; a zero-raw span would not cross the credit window. + expect(published()).toBe(`${unhookedCrash}${PROMPT}`) + expect(await replay()).toBe(`${unhookedCrash}${PROMPT}${PROCESS_BOUNDARY_GROUND}`) + }) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 6f17479784f..286bfdb89a7 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1118,6 +1118,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) + this.dispatcher.onRequest('pty.resetInputModes', (p) => this.resetInputModes(p)) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p)) @@ -2686,6 +2687,15 @@ export class PtyHandler { } } + // Why the replay buffer and not the stream: a zero-raw span never crosses the + // credit window, and the client grounds its own view; reattach replays this. + private async resetInputModes(params: Record): Promise { + const managed = this.ptys.get(params.id as string) + if (managed?.recoveryBarrier && !managed.disposed) { + this.appendReplayBuffer(managed, managed.recoveryBarrier.groundInputModes()) + } + } + private async hasChildProcesses(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index bd25abcce74..16f87ae24a9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -78,6 +78,7 @@ function renderMenu(overrides: Record = {}): string { onEqualizePaneSizes: vi.fn(), onClosePane: vi.fn(), onClearScreen: vi.fn(), + onResetTerminal: vi.fn(), canContinueAgentSessionInNewSession: false, onContinueAgentSessionInNewSession: vi.fn(), onForkAgentSession: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 5236d61d1a6..e09969753e9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -12,6 +12,7 @@ import { PanelsTopLeft, PanelRightClose, Pencil, + RotateCcw, SquareTerminal, TextSelect, X @@ -53,6 +54,7 @@ type TerminalContextMenuProps = { onEqualizePaneSizes: () => void onClosePane: () => void onClearScreen: () => void + onResetTerminal: () => void canContinueAgentSessionInNewSession: boolean onContinueAgentSessionInNewSession: () => void onForkAgentSession: () => void @@ -144,6 +146,7 @@ function TerminalContextMenuItems({ onEqualizePaneSizes, onClosePane, onClearScreen, + onResetTerminal, canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onForkAgentSession, @@ -350,6 +353,13 @@ function TerminalContextMenuItems({ {translate('auto.components.terminal.pane.TerminalContextMenu.b4cdd9314e', 'Clear Screen')} + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.resetTerminal', + 'Reset Terminal' + )} + ) } diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 08613fb6c3a..0b5da641c95 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -242,6 +242,7 @@ export function TerminalPaneSurface({ onEqualizePaneSizes={contextMenu.onEqualizePaneSizes} onClosePane={contextMenu.onClosePane} onClearScreen={contextMenu.onClearScreen} + onResetTerminal={contextMenu.onResetTerminal} canContinueAgentSessionInNewSession={contextMenuCanContinueInNewSession} onContinueAgentSessionInNewSession={contextMenu.onContinueAgentSessionInNewSession} onForkAgentSession={() => void contextMenu.onForkAgentSession()} diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts index 4adda77cc20..f72596187fb 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts @@ -6,6 +6,7 @@ describe('pty buffer serializer registry', () => { beforeEach(() => { vi.resetModules() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only these pty members. ;(globalThis as { window: typeof window }).window = { ...originalWindow, api: { @@ -13,6 +14,7 @@ describe('pty buffer serializer registry', () => { pty: { ...originalWindow?.api?.pty, onClearBufferRequest: vi.fn(() => () => {}), + onResetInputModesRequest: vi.fn(() => () => {}), onSerializeBufferRequest: vi.fn(() => () => {}), sendSerializedBuffer: vi.fn() } @@ -120,4 +122,16 @@ describe('pty buffer serializer registry', () => { expect.objectContaining({ pendingEscapeTailAnsi: '\x1b[38;5;' }) ) }) + + it("routes a host's Reset Terminal request to the pane that owns the PTY", async () => { + const { registerPtySerializer } = await import('./pty-buffer-serializer') + const resetInputModes = vi.fn() + registerPtySerializer('pty-1', () => null, { resetInputModes }) + const [[onRequest]] = vi.mocked(window.api.pty.onResetInputModesRequest).mock.calls + + onRequest({ ptyId: 'pty-2' }) + expect(resetInputModes).not.toHaveBeenCalled() + onRequest({ ptyId: 'pty-1' }) + expect(resetInputModes).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index 35d6a833050..ada99d6cbe8 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -34,6 +34,7 @@ export type SerializeFn = ( type SerializerEntry = { fn: SerializeFn clear?: () => void + resetInputModes?: () => void owner: symbol } @@ -50,10 +51,10 @@ let listenerAttached = false export function registerPtySerializer( ptyId: string, serialize: SerializeFn, - clear?: () => void + actions: Pick = {} ): () => void { const owner = Symbol(ptyId) - serializersByPtyId.set(ptyId, { fn: serialize, clear, owner }) + serializersByPtyId.set(ptyId, { fn: serialize, ...actions, owner }) ensureSerializerListener() return () => { const current = serializersByPtyId.get(ptyId) @@ -116,6 +117,11 @@ export function registerPtyTitleSource( } } +/** Grounds the pane's own records only; the host grounds its models on its own request. */ +export function resetPtyRendererInputModes(ptyId: string): void { + serializersByPtyId.get(ptyId)?.resetInputModes?.() +} + export function hasPtySerializer(ptyId: string): boolean { return serializersByPtyId.has(ptyId) } @@ -133,6 +139,8 @@ function ensureSerializerListener(): void { serializersByPtyId.get(request.ptyId)?.clear?.() }) + window.api.pty.onResetInputModesRequest((request) => resetPtyRendererInputModes(request.ptyId)) + window.api.pty.onSerializeBufferRequest((request) => { const entry = serializersByPtyId.get(request.ptyId) void Promise.resolve(entry?.fn(request.opts) ?? null) 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 index 4817dc8c17d..9faaaf10eb3 100644 --- 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 @@ -215,4 +215,19 @@ describe('connectPanePty kitty keyboard restore', () => { expect(mirror.snapshotFlags).toBe(0) expect(mirror.isAlternateScreen).toBe(false) }) + + it('grounds xterm and the mirror together on Reset Terminal and asks the host to ground', async () => { + const { pane, mirror } = await reattachWithSnapshotFlags(true) + expect(mirror?.flags).toBe(31) + const { resetTerminalInputModes } = await import('./terminal-input-mode-reset') + + resetTerminalInputModes('tab-pty') + + expect(pane.terminal.write).toHaveBeenLastCalledWith( + PROCESS_BOUNDARY_GROUND, + expect.any(Function) + ) + expect(mirror?.flags).toBe(0) + expect(window.api.pty.resetInputModes).toHaveBeenCalledWith('tab-pty') + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts index eb3a40b6803..d8a9333c359 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts @@ -54,6 +54,8 @@ export function installTerminalTestGlobals(): void { setPtyDeliveryInterest: vi.fn(), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), + onResetInputModesRequest: vi.fn(() => vi.fn()), + resetInputModes: vi.fn(), onSerializeBufferRequest: vi.fn(() => vi.fn()), sendSerializedBuffer: vi.fn(), declarePendingPaneSerializer: vi.fn().mockResolvedValue(1), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts index b4a953ef5b5..2b75548ec39 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts @@ -1,3 +1,4 @@ +import { buildProcessBoundaryGround } from '../../../../../shared/terminal-mode-reset-profiles' import { serializeWithAbsoluteCursor } from '../../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health' import { registerPtySerializer, registerPtyTitleSource } from '../pty-buffer-serializer' @@ -72,10 +73,16 @@ export function bindRegisterPaneSerializer(session: ConnectPanePtySession): void return null } }, - () => { - session.clearHiddenOutputRestoreState() - discardTerminalOutput(session.pane.terminal) - clearTerminalScrollbackAndFollowOutput(session.pane.terminal) + { + clear: () => { + session.clearHiddenOutputRestoreState() + discardTerminalOutput(session.pane.terminal) + clearTerminalScrollbackAndFollowOutput(session.pane.terminal) + }, + resetInputModes: () => + session.writeInputModeGround( + buildProcessBoundaryGround({ keepFocusReporting: session.isNativeWindowsConpty }) + ) } ) const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => diff --git a/src/renderer/src/components/terminal-pane/terminal-input-mode-reset.ts b/src/renderer/src/components/terminal-pane/terminal-input-mode-reset.ts new file mode 100644 index 00000000000..479da70f65d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-input-mode-reset.ts @@ -0,0 +1,18 @@ +import { resetWebRuntimeTerminalInputModes } from '@/runtime/web-runtime-session' +import { resetPtyRendererInputModes } from './pty-buffer-serializer' + +/** + * Reset Terminal: grounds the input modes an app left armed (Kitty keyboard, + * mouse, bracketed paste, focus, alt screen, cursor keys) in the pane and in the + * host's models, so a reattach does not re-arm them. Each side grounds its own + * records; an older host rejects the request and only the pane is grounded. + */ +export function resetTerminalInputModes(ptyId: string | null): void { + if (!ptyId) { + return + } + resetPtyRendererInputModes(ptyId) + if (!resetWebRuntimeTerminalInputModes(ptyId)) { + window.api.pty.resetInputModes(ptyId) + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 155482f2163..463a3a7f415 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -26,6 +26,7 @@ import { useTerminalContextMenuTrigger } from './use-terminal-context-menu-trigg import { useAppStore } from '@/store' import { makePaneKey } from '../../../../shared/stable-pane-id' import { resolvePaneAgentSessionId } from './pane-agent-session-id' +import { resetTerminalInputModes } from './terminal-input-mode-reset' type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject @@ -68,6 +69,7 @@ type TerminalMenuState = { onEqualizePaneSizes: () => void onClosePane: () => void onClearScreen: () => void + onResetTerminal: () => void onForkAgentSession: () => Promise onContinueAgentSessionInNewSession: () => void onCopyAgentSessionContext: () => Promise @@ -209,6 +211,13 @@ export function useTerminalPaneContextMenu({ } } + const onResetTerminal = (): void => { + const pane = resolveMenuPane() + if (pane) { + resetTerminalInputModes(paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null) + } + } + const onForkAgentSession = async (): Promise => forkAgentSessionFromMenuPane(agentSessionContext, resolveMenuPane()) @@ -295,6 +304,7 @@ export function useTerminalPaneContextMenu({ onEqualizePaneSizes, onClosePane, onClearScreen, + onResetTerminal, onForkAgentSession, onContinueAgentSessionInNewSession, onCopyAgentSessionContext, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b7aac035aef..ffdcd6031c2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3193,6 +3193,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "Clear Screen", + "resetTerminal": "Reset Terminal", "8c17d6786d": "Close Pane", "copyTerminalId": "Copy Terminal ID", "2cf85a6a55": "Copy Pane ID", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 835eb4ab440..adbe527574a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2751,6 +2751,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "Borrar pantalla", + "resetTerminal": "Restablecer terminal", "8c17d6786d": "Cerrar panel", "copyTerminalId": "Copiar ID de terminal", "2cf85a6a55": "Copiar ID del panel", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d2a04423ace..047564c4e61 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3136,6 +3136,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "Effacer l'écran", + "resetTerminal": "Réinitialiser le terminal", "8c17d6786d": "Fermer le volet", "copyTerminalId": "Copier l'ID du terminal", "2cf85a6a55": "Copier l'ID du volet", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 30bbb81cab1..97ff2a6ffda 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3033,6 +3033,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "クリアスクリーン", + "resetTerminal": "ターミナルをリセット", "8c17d6786d": "ペインを閉じる", "copyTerminalId": "ターミナル ID をコピー", "2cf85a6a55": "ペインIDのコピー", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2c2318895a6..511ee010e70 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3033,6 +3033,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "화면 지우기", + "resetTerminal": "터미널 초기화", "8c17d6786d": "창 닫기", "copyTerminalId": "터미널 ID 복사", "2cf85a6a55": "창 ID 복사", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3c0f5d7aa3d..e4fa1521899 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3033,6 +3033,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "清屏", + "resetTerminal": "重置终端", "8c17d6786d": "关闭窗格", "copyTerminalId": "复制终端 ID", "2cf85a6a55": "复制窗格 ID", diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index d62b48527b3..1ef7cfa5d6d 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -25,7 +25,10 @@ export { consumePendingWebRuntimeSplitMirrorTelemetry, closeWebRuntimeTerminal, updateWebRuntimePaneLayout, - setWebRuntimeTabProps, - clearWebRuntimeTerminalBuffer + setWebRuntimeTabProps } from './web-runtime-terminal-actions' +export { + clearWebRuntimeTerminalBuffer, + resetWebRuntimeTerminalInputModes +} from './web-runtime-terminal-buffer-actions' export type { WebRuntimeSplitSource } from './web-runtime-split-focus' diff --git a/src/renderer/src/runtime/web-runtime-terminal-actions.ts b/src/renderer/src/runtime/web-runtime-terminal-actions.ts index e12e9941492..0ae704640f0 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-actions.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-actions.ts @@ -279,32 +279,3 @@ export function setWebRuntimeTabProps(args: { }) return true } - -// Why: local pane.terminal.clear() is undone by the next host snapshot replay; clear the host buffer so it sticks. -export function clearWebRuntimeTerminalBuffer(ptyId: string | null | undefined): boolean { - if (!ptyId) { - return false - } - const remote = parseRemoteRuntimePtyId(ptyId) - const environmentId = remote?.environmentId?.trim() - if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) { - return false - } - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'terminal.clearBuffer', - params: { terminal: remote.handle }, - timeoutMs: 15_000 - }) - .then((response) => { - unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ clear: unknown }>) - }) - .catch((error) => { - console.warn( - '[web-runtime-session] failed to clear terminal buffer:', - error instanceof Error ? error.message : String(error) - ) - }) - return true -} diff --git a/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts b/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts new file mode 100644 index 00000000000..8759f2feaeb --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts @@ -0,0 +1,49 @@ +import { unwrapRuntimeRpcResult } from './runtime-rpc-client' +import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { isWebRuntimeSessionActive } from './web-runtime-session-environment' + +// Why: local pane.terminal.clear() is undone by the next host snapshot replay; clear the host buffer so it sticks. +export function clearWebRuntimeTerminalBuffer(ptyId: string | null | undefined): boolean { + return callWebRuntimeTerminalAction(ptyId, 'terminal.clearBuffer', 'clear terminal buffer') +} + +// Why: same as clear; the host's snapshot would re-arm the modes on the next replay. +export function resetWebRuntimeTerminalInputModes(ptyId: string | null | undefined): boolean { + return callWebRuntimeTerminalAction( + ptyId, + 'terminal.resetInputModes', + 'reset terminal input modes' + ) +} + +function callWebRuntimeTerminalAction( + ptyId: string | null | undefined, + method: 'terminal.clearBuffer' | 'terminal.resetInputModes', + action: string +): boolean { + if (!ptyId) { + return false + } + const remote = parseRemoteRuntimePtyId(ptyId) + const environmentId = remote?.environmentId?.trim() + if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) { + return false + } + void window.api.runtimeEnvironments + .call({ + selector: environmentId, + method, + params: { terminal: remote.handle }, + timeoutMs: 15_000 + }) + .then((response) => { + unwrapRuntimeRpcResult(response) + }) + .catch((error) => { + console.warn( + `[web-runtime-session] failed to ${action}:`, + error instanceof Error ? error.message : String(error) + ) + }) + return true +} diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index 535c3454431..be6dc77cd7d 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -17,6 +17,8 @@ export function createPtyApi(): NonNullable['pty']> { signal: () => {}, // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. clearBuffer: () => {}, + // Likewise terminal.resetInputModes. + resetInputModes: () => {}, kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, @@ -84,6 +86,7 @@ export function createPtyApi(): NonNullable['pty']> { onSpawned: () => noopUnsubscribe, onSerializeBufferRequest: () => noopUnsubscribe, onClearBufferRequest: () => noopUnsubscribe, + onResetInputModesRequest: () => noopUnsubscribe, sendSerializedBuffer: () => {}, declarePendingPaneSerializer: () => Promise.resolve(0), settlePaneSerializer: () => Promise.resolve(), diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index df05a234a88..326962eae58 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1137,6 +1137,7 @@ export const RPC_PARAMS_BY_METHOD = { 'terminal.read': TerminalRead, 'terminal.recoverPane': TerminalRecoverPane, 'terminal.rename': TerminalRename, + 'terminal.resetInputModes': TerminalHandle, 'terminal.resizeForClient': TerminalResizeForClient, 'terminal.resolveActive': TerminalResolveActive, 'terminal.resolveIdentity': TerminalHandle, diff --git a/tests/e2e/terminal-reset-input-modes.spec.ts b/tests/e2e/terminal-reset-input-modes.spec.ts new file mode 100644 index 00000000000..f10797666af --- /dev/null +++ b/tests/e2e/terminal-reset-input-modes.spec.ts @@ -0,0 +1,131 @@ +/** + * Reset Terminal grounds the input modes an app left armed where the host saw + * no command end, and the ground survives park and reveal. The reveal builds a + * fresh xterm from the host's model, so a renderer-only reset would come back + * armed. + */ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { openTerminalContextMenu } from './helpers/terminal-pane-title-actions' +import { + clearTerminalPtyWriteLog, + installTerminalPtyWriteSpy, + readTerminalPtyWrites +} from './helpers/terminal-pty-write-spy' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 +const KITTY_SHIFT_ENTER = '\x1b[13;2u' + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +type XtermInputModes = { kittyFlags: number | null; mouse: string | null } + +async function readXtermInputModes(page: Page): Promise { + 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 + | { + modes?: { mouseTrackingMode?: string } + _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + } + | undefined + return { + kittyFlags: terminal?._core?.coreService?.kittyKeyboard?.flags ?? null, + mouse: terminal?.modes?.mouseTrackingMode ?? null + } + }) +} + +// Why Shift+Enter: the policy emits CSI-u only when the pane mirror reports kitty flags. +async function readShiftEnterWrite(page: Page, app: ElectronApplication): Promise { + await clearTerminalPtyWriteLog(app) + await focusActiveTerminalInput(page) + await page.keyboard.press('Shift+Enter') + let written = '' + await expect + .poll( + async () => { + written = (await readTerminalPtyWrites(app)).join('') + return written.length + }, + { timeout: 5_000, message: 'Shift+Enter wrote nothing' } + ) + .toBeGreaterThan(0) + return written +} + +async function expectGrounded(page: Page, app: ElectronApplication, when: string): Promise { + await expect + .poll(() => readXtermInputModes(page), { timeout: 10_000, message: `xterm armed ${when}` }) + .toEqual({ kittyFlags: 0, mouse: 'none' }) + expect(await readShiftEnterWrite(page, app)).not.toContain(KITTY_SHIFT_ENTER) +} + +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('Reset Terminal grounds modes an unhooked crash left armed, through park and reveal', 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) + + // `exec` drops the shell hooks, so no OSC 133;D ever lets the host ground the + // modes the printf arms and leaves behind. + await sendToTerminal(orcaPage, ptyId, 'exec bash --norc --noprofile\r') + await sendToTerminal(orcaPage, ptyId, "printf '\\033[>5u\\033[?1000h\\033[?1006h'\r") + await expect + .poll(() => readXtermInputModes(orcaPage), { timeout: 10_000 }) + .toEqual({ kittyFlags: 5, mouse: 'vt200' }) + expect(await readShiftEnterWrite(orcaPage, electronApp)).toContain(KITTY_SHIFT_ENTER) + + await openTerminalContextMenu(orcaPage) + await orcaPage.getByRole('menuitem', { name: 'Reset Terminal', exact: true }).click() + await expectGrounded(orcaPage, electronApp, 'after Reset Terminal') + + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS }) + await activateTerminalTab(orcaPage, tabId) + await waitForActivePanePtyId(orcaPage) + await expectGrounded(orcaPage, electronApp, 'after park and reveal') +})