From c53ed030b235c9da3835dbd1e0ce24c90cbff1fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:13:24 -0700 Subject: [PATCH] fix(terminal): one intentional-stop register, and per-run spawn and input facts (#22989) * fix(terminal): one intentional-stop register and per-run spawn and input facts Main now keeps one register of PTY stops it made on purpose, with an owner count and a kind: reversible (sleep, hibernation) or replaced (a restart handing the pane to a new process). It replaces four separate markers, and every exit path reads it, so a hibernated or restarted pane keeps its tab and binding through the exit instead of being retired and grafted back. Main also records two facts per process run, keyed by incarnation: whether the run was a fresh spawn (from the spawn-commit origin, which now counts a cold restore as a reattach) and when a client first sent it input a person produced. Renderer keystroke, paste, drop and quick-command writes carry a userInput flag; terminal.send, stream input and dispatched agent prompts are recorded by the runtime, excluding terminal query replies. * test(terminal): a stopped process's synthetic and late provider exits keep its pane One process can reach the runtime's exit handler twice: main's synthetic exit when the kill reply overtakes the stream, then the provider's own exit, which certifies the death. Both must read the same stop, a later process on the same id must not, and the stop is forgotten when the duplicate-exit window closes. * test(terminal): a runtime worktree sleep keeps its tabs and wake bindings * fix(terminal): keep every overlapping stop kind on one exit A sleep and a restart that stop the same process now each keep their label: the exit carries both renderer flags, and a runtime kill during the sleep still records its SSH stop as reversible. A later stop of an already-stopped process joins its entry, so a failed repeat cannot erase the stop that landed. The cold-restore rule moves into the run facts, so the binding span keeps labelling a cold restore as a spawn. * test(terminal): IME and Hangul commits reach the PTY as user input Drives a real xterm through the pane's input handling: a macOS input-source substitution, an iPadOS Hangul syllable, and a composition the route delivers after a pane switch each reach onData flagged as user input, which is what tags the write main records. * test(terminal): state why the IME provenance test's canvas stub is safe * fix(terminal): a new process ends an unpinned stop, and focus reports are not input - A landed stop that no exit pinned to an incarnation now ends when a new process commits on the same id, so that process's exit is not read as the stop. Both spawn-commit funnels report through one runtime method. - A spawn commit with no incarnation starts its run facts clean, since it cannot be told from a new process. - Stream input that is only focus reports no longer counts as user input; the desktop renderer already excludes them through xterm's own signal. - The IME provenance test uses typed fakes: the pane input and composition route installers now take only the fields they read. * chore: restore pnpm-lock.yaml to the base revision * fix(terminal): typing in the dashboard preview is a run's user input The dashboard popout's terminal preview writes to the PTY through its own runtime path, which recorded no input, so a pane typed into only from the preview read as untyped. It now records before the write, after the mobile-driver check. One classifier, shared with terminal.send, decides which provenance-free bytes nobody typed: a whole terminal reply or only focus reports, which also covers the focus-in a desktop renderer sends when it reattaches a remote pane. * test(terminal): the IME provenance test removes the navigator stubs it adds happy-dom serves navigator.userAgent from the prototype, so the test found no own descriptor to restore and left the iPad user agent in place. The pane-switch case then ran as a Mac pane only because it followed the Hangul case. Deleting the own-property stub restores the default platform for every case. * fix(terminal): every PTY write names its input kind, and one record point reads it A run's first input was recorded by opposite defaults: the renderer's pty:write recorded nothing unless a writer opted in, terminal.send recorded everything that was not a reply, and main's own controller writes recorded nothing. Each unclassified producer silently took its transport's default, so the worktree-create draft counted from the renderer and not from main, and mailbox pointers never counted. Every host write entry point now takes a required kind: driving, launch or query-reply. That covers the runtime controller's write and settled write, the terminal writer, sendTerminal, sendTerminalAgentPrompt, pty:write and pty:writeAccepted, the renderer transport and the runtime input helpers. The fact is recorded once, just before the provider write, in the controller funnel and the pty:write funnel: only driving bytes count, and a payload that is only a reply or focus reports never does. The per-producer records are gone. Launch writes (create-time drafts and follow-ups, the agent launch prompt, restored and cold-restore startup commands, the SSH background launch) do not count. Mailbox pointers, dispatch, plugins and agent-team sends do. The runtime mixins are unchecked by the compiler, and the pane session is an any bag, so two source scans fail on any write there that leaves out its kind. * fix(terminal): type the pane session's transport so the compiler checks every write's kind The pane-connection session is an `any` bag, so a transport write there that left out its input kind compiled, and only a text scan over the renderer caught it. Declaring `transport: PtyTransport` on the session puts those writes under the compiler, so the scan is gone. One hidden-delivery guard now narrows a null PTY id itself instead of relying on an untyped predicate. The main-process scan over `@ts-nocheck` runtime files missed five files whose directive sits on the second line, below a lint directive, and missed a write through a local alias of the PTY controller. It now finds both. * fix(terminal): record a runtime spawn's run facts only after its binding save succeeds The runtime spawn funnel reported the commit before its host-session binding save, so a spawn discarded for a failed save still recorded run facts and cleared a landed stop. Report it after the save, and separately on the adopted return, which makes no save. Also align tests and fixtures with main: the removed stop-owner map, required write input kinds, SQLite-backed stores and async binding saves. * test(terminal): pin where the renderer spawn funnel records its commit The merge moved the renderer funnel's spawn-commit report into the publish step, after the binding save, but no test covered it: deleting the call or moving it back before the save left every suite green. Cover both: a committed spawn records its run facts and supersedes an unpinned landed stop, and a spawn discarded for a failed binding save records nothing and leaves the stop. * fix(terminal): record a runtime spawn's commit only once its registration succeeds The runtime funnel reported each commit before registering the process, so a spawn that exited during start still recorded run facts and could end a landed stop, while the renderer funnel reports only after registration. Report after registration on both the ordinary and the adopted branch, so only a committed, registered run has facts and an unknown run keeps reading as not fresh. * test(terminal): pin that a runtime adoption supersedes a landed stop no exit pinned * fix(terminal): read an unconfirmed explicit stop's reversibility from the intentional-stop registry --- .../pty-daemon-ssh-lease-lifecycle.test.ts | 9 +- src/main/ipc/pty-pane-restart-replace.test.ts | 4 +- src/main/ipc/pty-ssh-undelivered-kill.test.ts | 45 ++- src/main/ipc/pty/delivery/exit.ts | 57 +--- src/main/ipc/pty/ipc/renderer-kill.ts | 59 ++-- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 2 + .../pty/ipc/spawn-commit-run-facts.test.ts | 79 +++++ .../pty/ipc/write-input-chunk-yield.test.ts | 3 +- .../pty/ipc/write-input-user-input.test.ts | 63 ++++ src/main/ipc/pty/ipc/write-input.ts | 17 +- src/main/ipc/pty/register-handlers.ts | 5 +- .../ipc/pty/register-without-renderer.test.ts | 6 +- src/main/ipc/pty/runtime/controller-deps.ts | 1 - src/main/ipc/pty/runtime/controller.ts | 10 +- src/main/ipc/pty/runtime/kill.ts | 30 +- src/main/ipc/pty/runtime/operations.ts | 16 +- .../runtime/spawn-commit-run-facts.test.ts | 147 +++++++++ src/main/ipc/pty/runtime/spawn-commit.ts | 11 + .../spawn-preflight-requested-shell.test.ts | 1 - src/main/ipc/pty/session.ts | 5 - .../loading-store/pty-binding-span.ts | 4 +- .../pty-spawn-commit-dependencies-fixture.ts | 1 - src/main/plugins/plugin-host-methods.test.ts | 12 +- .../plugins/plugin-host-service-bindings.ts | 6 +- .../runtime/agent-prompt-line-settle.test.ts | 2 +- .../agent-prompt-receipt-correlation.test.ts | 2 + ...ission-runtime-hook-and-generation.test.ts | 46 ++- .../agent-prompt-submission-runtime.test.ts | 85 ++++-- ...pt-submission-windows-submit-delay.test.ts | 47 ++- .../claude-agent-teams-tmux-dispatcher.ts | 2 +- src/main/runtime/claude-agent-teams-types.ts | 4 +- ...h-remote-terminal-source-range-consumer.ts | 1 + ...ca-runtime-controller-knows-pty-is-live.ts | 6 +- ...time-create-pty-headless-terminal-state.ts | 2 +- .../orca-runtime-deliver-pending-messages.ts | 4 +- ...get-worktree-terminal-provisioning-host.ts | 2 +- src/main/runtime/orca-runtime-on-pty-exit.ts | 9 +- ...refresh-floating-workspace-pty-liveness.ts | 1 + ...-authoritative-terminal-wait-permission.ts | 4 +- ...resolve-terminal-split-source-authority.ts | 2 +- ...renderer-session-owned-mobile-terminals.ts | 4 +- src/main/runtime/orca-runtime-runtime-id.ts | 17 +- ...ntime-sleep-resolved-worktree-terminals.ts | 27 +- ...ntime-stop-exact-terminals-for-worktree.ts | 24 +- ...untime-terminal-handle-incarnation.test.ts | 12 +- .../agent-status-and-waits-part-03.spec.ts | 2 +- .../agent-status-and-waits.spec.ts | 12 +- .../mobile-session-tabs-part-13.spec.ts | 2 +- ...nal-creation-and-readiness-part-07.spec.ts | 10 +- ...nal-creation-and-readiness-part-08.spec.ts | 32 +- .../terminal-handles-and-agent-status.spec.ts | 8 +- ...output-and-worker-recovery-part-02.spec.ts | 4 +- ...output-and-worker-recovery-part-07.spec.ts | 6 +- ...worktree-setup-and-startup-part-03.spec.ts | 12 +- ...worktree-setup-and-startup-part-04.spec.ts | 8 +- ...rca-runtime-write-terminal-agent-prompt.ts | 6 +- .../coordinator-runtime-contract.ts | 2 +- .../mailbox-pointer-pty-write.ts | 3 +- src/main/runtime/orchestration/preamble.ts | 3 +- .../methods/agent-launch-terminal-prompt.ts | 1 + .../worker-release-mobile-report.test.ts | 2 +- .../worker-start-prompt-contract.test.ts | 1 + .../terminal/terminal-input-delivery.ts | 3 +- .../methods/terminal/terminal-send-method.ts | 3 + .../rpc/terminal-agent-prompt-send.test.ts | 3 +- ...rminal-multiplex-ack-output-budget.test.ts | 28 +- ...al-multiplex-ack-overflow-recovery.test.ts | 14 +- ...l-multiplex-desktop-resize-routing.test.ts | 28 +- ...al-multiplex-input-write-rejection.test.ts | 28 +- ...ultiplex-output-pause-and-viewport.test.ts | 6 +- .../rpc/terminal-output-batching.test.ts | 6 +- src/main/runtime/rpc/terminal-send.test.ts | 7 +- .../runtime-pty-controller-contract.ts | 10 +- .../runtime/runtime-terminal-contracts.ts | 5 +- src/main/runtime/runtime-terminal-writer.ts | 14 +- .../runtime-worktree-startup-readiness.ts | 7 +- ...rminal-close-observed-exit-test-fixture.ts | 3 +- ...al-input-kind-unchecked-call-sites.test.ts | 80 +++++ .../terminal-intentional-stop-exit.test.ts | 288 ++++++++++++++++++ .../terminal-intentional-stops.test.ts | 112 +++++++ .../runtime/terminal-intentional-stops.ts | 122 ++++++++ ...rminal-interactive-wait-visibility.test.ts | 6 +- .../runtime/terminal-query-responder.test.ts | 11 +- .../runtime/terminal-run-facts-input.test.ts | 244 +++++++++++++++ src/main/runtime/terminal-run-facts.test.ts | 65 ++++ src/main/runtime/terminal-run-facts.ts | 90 ++++++ .../terminal-send-stale-leaf-liveness.test.ts | 66 ++-- src/preload/api/pty-api.ts | 5 +- src/preload/api/pty-bridge-session-control.ts | 9 +- .../claude-model-switch-confirmation.ts | 2 +- .../native-chat-runtime-image-send.ts | 8 +- .../native-chat-runtime-send.test.ts | 65 +++- .../native-chat/native-chat-runtime-send.ts | 30 +- .../use-native-chat-composer-interrupt.ts | 2 +- .../use-native-chat-interactive-send.test.tsx | 3 +- .../use-native-chat-interactive-send.ts | 7 +- .../deferred-split-pane-handoff.test.ts | 110 +++++-- .../deferred-split-pane-handoff.ts | 2 +- ...keyboard-handlers-ime-enter-keyup.test.tsx | 6 +- ...y-connection-foreground-write-path.test.ts | 4 +- .../pty-connection-fresh-spawn-guards.test.ts | 10 +- .../pty-connection-hibernation-wake.test.ts | 2 +- ...n-hook-completion-bell-arbitration.test.ts | 2 +- ...ction-hook-completion-side-effects.test.ts | 4 +- ...pty-connection-interrupt-inference.test.ts | 6 +- ...pty-connection-reattach-mode-reset.test.ts | 8 +- ...connection-replay-payload-handling.test.ts | 2 +- .../pty-connection-setup-split-spawn.test.ts | 4 +- ...nection-ssh-startup-draft-delivery.test.ts | 11 +- ...onnection-startup-command-delivery.test.ts | 2 +- ...y-connection-terminal-input-gating.test.ts | 20 +- ...ty-connection-typed-agent-identity.test.ts | 12 +- ...-connection-windows-keyboard-reset.test.ts | 22 +- .../connect-pane-pty-session.ts | 3 + .../deferred-cold-restore-and-snapshot.ts | 4 +- .../foreground-output-refresh.ts | 1 + .../pty-connection/pane-serializer-settle.ts | 24 +- .../pty-connection/pty-input-forward.ts | 8 +- .../pty-connection/replay-data-drain.ts | 2 +- .../pty-input-write-queue-contract.ts | 11 +- .../pty-input-write-queue.test.ts | 88 +++--- .../terminal-pane/pty-input-write-queue.ts | 43 ++- .../pty-preconnect-input-buffer.test.ts | 42 +-- .../pty-preconnect-input-buffer.ts | 29 +- .../pty-transport-input-write.test.ts | 170 ++++++----- .../pty-transport-reattach-admission.test.ts | 4 +- .../terminal-pane/pty-transport-types.ts | 5 +- .../pty-transport-user-input.test.ts | 89 ++++++ .../components/terminal-pane/pty-transport.ts | 31 +- ...te-runtime-error-surface-dismissal.test.ts | 14 +- ...-runtime-pty-query-reply-immediate.test.ts | 6 +- ...-pty-transport-host-session-launch.test.ts | 2 +- ...transport-host-surface-replacement.test.ts | 4 +- ...ime-pty-transport-input-coalescing.test.ts | 43 +-- ...ntime-pty-transport-input-fallback.test.ts | 10 +- ...ty-transport-stale-handle-recovery.test.ts | 6 +- ...ransport-sticky-replacement-policy.test.ts | 7 +- ...ime-pty-transport-stream-reconnect.test.ts | 8 +- .../remote-runtime-pty-transport.ts | 2 + .../terminal-captured-input-dispatch.test.ts | 2 +- .../terminal-captured-input-dispatch.ts | 2 +- .../terminal-drop-handler.test.ts | 30 +- .../terminal-drop-internal-handler.test.ts | 17 +- .../terminal-drop-path-writer.test.ts | 37 ++- .../terminal-drop-path-writer.ts | 2 +- .../terminal-ime-composition-route.ts | 4 +- ...terminal-ime-user-input-provenance.test.ts | 174 +++++++++++ ...nal-ios-hangul-preedit-coexistence.test.ts | 2 +- .../terminal-pane/terminal-pane-menu-paste.ts | 2 +- .../terminal-pane/terminal-pane-pane-input.ts | 2 +- .../terminal-pane-paste-execution.ts | 2 +- .../terminal-paste-operation-order.test.ts | 9 +- .../terminal-programmatic-text-paste.ts | 2 +- .../terminal-pty-paste-writer.test.ts | 10 +- .../terminal-pty-paste-writer.ts | 6 +- .../terminal-quick-command-dispatch.test.ts | 8 +- .../terminal-quick-command-dispatch.ts | 8 +- .../terminal-startup-command-paste.ts | 2 +- .../use-terminal-pane-mobile-actions.ts | 2 +- .../src/lib/agent-draft-paste-content.ts | 20 +- .../src/lib/agent-followup-delivery.test.ts | 4 +- .../src/lib/agent-followup-delivery.ts | 2 +- .../agent-paste-draft-submit-retry.test.ts | 10 +- .../src/lib/agent-paste-draft.test.ts | 15 +- src/renderer/src/lib/agent-paste-draft.ts | 22 +- ...ch-agent-background-session-remote.test.ts | 21 +- .../lib/launch-agent-background-session.ts | 2 +- src/renderer/src/lib/new-workspace.test.ts | 7 +- .../src/lib/startup-draft-input-kind.test.ts | 113 +++++++ .../runtime-terminal-inspection.test.ts | 90 ++++-- .../runtime/runtime-terminal-inspection.ts | 22 +- src/shared/source-scan/call-argument-scan.ts | 83 +++++ src/shared/terminal-input-kind.ts | 8 + 173 files changed, 3139 insertions(+), 876 deletions(-) create mode 100644 src/main/ipc/pty/ipc/spawn-commit-run-facts.test.ts create mode 100644 src/main/ipc/pty/ipc/write-input-user-input.test.ts create mode 100644 src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts create mode 100644 src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts create mode 100644 src/main/runtime/terminal-intentional-stop-exit.test.ts create mode 100644 src/main/runtime/terminal-intentional-stops.test.ts create mode 100644 src/main/runtime/terminal-intentional-stops.ts create mode 100644 src/main/runtime/terminal-run-facts-input.test.ts create mode 100644 src/main/runtime/terminal-run-facts.test.ts create mode 100644 src/main/runtime/terminal-run-facts.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts create mode 100644 src/renderer/src/lib/startup-draft-input-kind.test.ts create mode 100644 src/shared/source-scan/call-argument-scan.ts create mode 100644 src/shared/terminal-input-kind.ts diff --git a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts index 128f163c5b3..5ae2adb49d7 100644 --- a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts +++ b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_PTY_IDENTITY_MISMATCH_ERROR, SSH_SESSION_EXPIRED_ERROR @@ -359,7 +360,8 @@ describe('registerPtyHandlers', () => { const exitListeners = new Set<(payload: { id: string; code: number }) => void>() const runtime = { setPtyController: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } setLocalPtyProvider({ spawn: vi.fn(), @@ -393,15 +395,14 @@ describe('registerPtyHandlers', () => { handlers.clear() registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { - markReversibleStops: (ptyIds: readonly string[]) => () => void stopAndWait: (ptyId: string) => Promise } - const release = controller.markReversibleStops(['local-pty']) + const settleStop = runtime.intentionalPtyStops.mark('local-pty', 'reversible', null) const stopPromise = controller.stopAndWait('local-pty') await vi.advanceTimersByTimeAsync(1_200) await expect(stopPromise).resolves.toBe(true) - release() + settleStop(true) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index a1ced7904f8..50558a0f870 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -3,6 +3,7 @@ import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harne import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -161,7 +162,8 @@ function installRestartHarness( seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), - onPtyData: vi.fn() + onPtyData: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } return { providerSpawn, shutdown, store, runtime, control } } diff --git a/src/main/ipc/pty-ssh-undelivered-kill.test.ts b/src/main/ipc/pty-ssh-undelivered-kill.test.ts index bf231fdc235..32f4da8356f 100644 --- a/src/main/ipc/pty-ssh-undelivered-kill.test.ts +++ b/src/main/ipc/pty-ssh-undelivered-kill.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors' import { registerPtyHandlers, @@ -79,7 +80,8 @@ function installController(handlers: Map) { setPtyController: vi.fn(), markPtyStopRequested: vi.fn(), markPtyLivenessUnverifiable: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } handlers.clear() return { runtime } @@ -91,7 +93,6 @@ describe('undelivered SSH stops', () => { function install(store: ReturnType): { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void recordUnconfirmedStop: (ptyId: string) => boolean runtime: ReturnType['runtime'] } { @@ -108,13 +109,11 @@ describe('undelivered SSH stops', () => { const controller = runtime.setPtyController.mock.calls[0]?.[0] as { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void recordUnconfirmedStop: (ptyId: string) => boolean } return { kill: controller.kill, stopAndWait: controller.stopAndWait, - markReversibleStops: controller.markReversibleStops, recordUnconfirmedStop: controller.recordUnconfirmedStop, runtime } @@ -302,15 +301,41 @@ describe('undelivered SSH stops', () => { ) setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') - const { kill, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { kill, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { kill(SCOPED_PTY_ID) await new Promise((resolve) => setTimeout(resolve, 0)) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) + unregisterSshPtyProvider('ssh-1') + deletePtyOwnership(SCOPED_PTY_ID) + } + }) + + it('records nothing while a reversible stop owns the PTY and a restart stop joins it', async () => { + const store = createKillStore() + registerSshPtyProvider( + 'ssh-1', + sshProviderStub(async () => { + throw new Error('socket closed') + }) + ) + setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') + restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') + const { kill, runtime } = install(store) + const settleSleep = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) + const settleRestart = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'replaced', null) + + try { + kill(SCOPED_PTY_ID) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() + } finally { + settleRestart(false) + settleSleep(false) unregisterSshPtyProvider('ssh-1') deletePtyOwnership(SCOPED_PTY_ID) } @@ -379,15 +404,15 @@ describe('undelivered SSH stops', () => { restorePtyIncarnation('local-pty', 'inc-h') setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-i') - const { recordUnconfirmedStop, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { recordUnconfirmedStop, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { expect(recordUnconfirmedStop('local-pty')).toBe(false) expect(recordUnconfirmedStop(SCOPED_PTY_ID)).toBe(false) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) deletePtyOwnership('local-pty') deletePtyOwnership(SCOPED_PTY_ID) } diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index a7575f7c2e6..ad144b4d587 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -7,57 +7,8 @@ import { allocatePtyLifecycleSequence } from '../host-env/types' import { makePtyDataPayload, sendPtyDataToRenderer } from './payload' import { getRendererInFlightCharsForPty } from './accounting' import { clearFlushTimerIfIdle } from './flush' -import { ptyIncarnationById } from '../provider/ownership-state' import type { PtyIpcSession } from '../session' -export type ReplacedPtyStop = { - incarnationId: string | undefined - expiryTimer?: NodeJS.Timeout -} - -/** Labels the exit of a PTY that main stops so a new process can take its pane. Settle with - * whether the stop succeeded; a failed stop leaves no label behind. */ -export function markReplacedPtyStop( - session: PtyIpcSession, - id: string -): (stopped: boolean) => void { - clearTimeout(session.replacedPtyStopsById.get(id)?.expiryTimer) - const mark: ReplacedPtyStop = { incarnationId: ptyIncarnationById.get(id) } - session.replacedPtyStopsById.set(id, mark) - return (stopped) => { - if (session.replacedPtyStopsById.get(id) !== mark) { - return - } - if (!stopped) { - session.replacedPtyStopsById.delete(id) - return - } - // Why a window: an SSH exit can reach the renderer after the stop settles; bound it like a synthetic kill. - mark.expiryTimer = setTimeout(() => { - if (session.replacedPtyStopsById.get(id) === mark) { - session.replacedPtyStopsById.delete(id) - } - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - mark.expiryTimer.unref?.() - } -} - -function consumeReplacedPtyStop( - session: PtyIpcSession, - payload: { id: string; incarnationId?: string } -): boolean { - const mark = session.replacedPtyStopsById.get(payload.id) - if ( - !mark || - (mark.incarnationId && payload.incarnationId && mark.incarnationId !== payload.incarnationId) - ) { - return false - } - clearTimeout(mark.expiryTimer) - session.replacedPtyStopsById.delete(payload.id) - return true -} - export function rememberSyntheticKillExit( session: PtyIpcSession, id: string, @@ -202,12 +153,12 @@ export function finalizePtyExitForRenderer( session.schedulePendingDataAfterCreditReport(true) } } + const intentionalStops = + session.runtime?.intentionalPtyStops?.claimExit(payload.id, payload.incarnationId) ?? [] session.mainWindow.webContents.send('pty:exit', { ...payload, - ...(session.reversibleStopOwnersByPtyId.has(payload.id) - ? { preserveRendererBinding: true } - : {}), - ...(consumeReplacedPtyStop(session, payload) ? { replacedByRestart: true } : {}) + ...(intentionalStops.includes('reversible') ? { preserveRendererBinding: true } : {}), + ...(intentionalStops.includes('replaced') ? { replacedByRestart: true } : {}) }) } diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index 8f73e72aa59..10a0ffa476e 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -1,10 +1,11 @@ import { getPtyIpc } from '../../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { TerminalIntentionalStopKind } from '../../../runtime/terminal-intentional-stops' import type { IPtyProvider } from '../../../providers/types' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' -import { ptyOwnership } from '../provider/ownership-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, sshProviders, tryGetProviderForPty } from '../provider/registry' import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill' @@ -31,27 +32,49 @@ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void { ) } -/** Stops a pane's PTY for the spawn replacing it. `markReplaced` labels the exit so the renderer - * reads it as a handoff, not the pane dying; a failed stop removes the label with nothing sent. */ -export async function stopReplacedPanePty( - deps: PtyKillIpcDeps, - id: string, - markReplaced: (id: string) => (stopped: boolean) => void -): Promise { - const settle = markReplaced(id) - try { - await stopRendererOwnedPty(deps, { id }) - } catch (err) { - settle(false) - throw err - } - settle(true) +/** Stops a pane's PTY for the spawn replacing it. The stop register labels the exit so the + * renderer reads it as a handoff, not the pane dying; a failed stop leaves no label. */ +export async function stopReplacedPanePty(deps: PtyKillIpcDeps, id: string): Promise { + await stopRendererOwnedPtyAs(deps, { id }, 'replaced') } /** Stops a renderer-owned PTY and settles only once its shutdown has been observed or synthesized. */ export async function stopRendererOwnedPty( deps: PtyKillIpcDeps, args: { id: string; keepHistory?: boolean } +): Promise { + // Why: only hibernation passes keepHistory, and its exit must keep the pane's wake binding. + await stopRendererOwnedPtyAs(deps, args, args?.keepHistory === true ? 'reversible' : null) +} + +async function stopRendererOwnedPtyAs( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean }, + intentionalStop: TerminalIntentionalStopKind | null +): Promise { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + const settleStop = intentionalStop + ? deps.runtime?.intentionalPtyStops?.mark( + args.id, + intentionalStop, + ptyIncarnationById.get(args.id) ?? null + ) + : undefined + let stopped = false + try { + await stopRendererOwnedPtyProcess(deps, args) + stopped = true + } finally { + settleStop?.(stopped) + } +} + +async function stopRendererOwnedPtyProcess( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean } ): Promise { const { store, @@ -61,10 +84,6 @@ export async function stopRendererOwnedPty( rememberSyntheticKillExit, sendPtyExitToRenderer } = deps - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } runtime?.markPtyStopRequested?.(args.id) const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 5d9ad086920..3740e1489a0 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -83,6 +83,8 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

) { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops: stops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { + runtime, + store: { persistPtyBinding }, + options: {}, + sendPtySpawnedToRenderer: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above, and the store only for its binding save. + const deps = ports as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1', + cols: 120, + rows: 40 + }) + ctx.validatedLeafId = 'leaf-1' + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID } + const outcome = await commitPtyIpcSpawn(ctx).then( + () => 'committed', + () => 'discarded' + ) + return { outcome, facts, stops } +} + +describe('renderer spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a committed spawn and lets it supersede a landed stop no exit pinned', async () => { + const { outcome, facts, stops } = await commit(vi.fn().mockResolvedValue(true)) + + expect(outcome).toBe('committed') + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(true) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + const { outcome, facts, stops } = await commit(persistPtyBinding) + + expect(outcome).toBe('discarded') + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts index ceeb5127b7d..4afd2b008f1 100644 --- a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts +++ b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts @@ -68,7 +68,7 @@ describe('chunked pty write yield', () => { }) as typeof setImmediate) const outcome = await Promise.race([ - createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }), + createWriteInput()({ inputKind: 'driving', id: PTY_ID, data: THREE_CHUNK_INPUT }), afterImmediateTurns(50) ]) @@ -88,6 +88,7 @@ describe('chunked pty write yield', () => { const immediate = vi.spyOn(globalThis, 'setImmediate') const outcome = createWriteInput()({ + inputKind: 'driving', id: PTY_ID, data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) }) diff --git a/src/main/ipc/pty/ipc/write-input-user-input.test.ts b/src/main/ipc/pty/ipc/write-input-user-input.test.ts new file mode 100644 index 00000000000..d4f20f00a68 --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input-user-input.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptyOwnership } from '../provider/ownership-state' +import { createPtyWriteInput } from './write-input' + +const PTY_ID = 'pty-user-input' + +const { provider } = vi.hoisted(() => ({ + provider: { write: vi.fn(), hasPty: vi.fn(() => true) } +})) + +vi.mock('../provider/registry', () => ({ + tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined) +})) + +function createWriteInput(facts: TerminalRunFactsRegister) { + const runtime = { getDriver: () => ({ kind: 'desktop' }), terminalRunFacts: facts } + const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: write input reads only getDriver and terminalRunFacts from the runtime, and isDestroyed/webContents from the window. + return createPtyWriteInput({ mainWindow: mainWindow as never, runtime: runtime as never }) +} + +beforeEach(() => { + ptyOwnership.set(PTY_ID, null) + provider.write.mockReset() +}) + +afterEach(() => { + ptyOwnership.delete(PTY_ID) +}) + +describe('renderer PTY writes: input kind', () => { + it.each(['writePtyInput', 'writePtyInputAccepted'] as const)( + '%s records driving input before the provider write', + async (writer) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + const recordedAtWrite: (number | null)[] = [] + provider.write.mockImplementation(() => { + recordedAtWrite.push(facts.read(PTY_ID, 'inc-1').firstUserInputAt) + }) + + await createWriteInput(facts)[writer]({ id: PTY_ID, data: 'exit\r', inputKind: 'driving' }) + + expect(recordedAtWrite).toEqual([expect.any(Number)]) + } + ) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[3;4R'], + ['a driving write that is only a reply', 'driving', '\x1b[3;4R'], + ['a driving write that is only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', async (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + + await createWriteInput(facts).writePtyInput({ id: PTY_ID, data, inputKind }) + + expect(provider.write).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 77b27ef16f0..2f8ea72cd9b 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -9,6 +9,7 @@ import { } from '../../../../shared/terminal-input' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state' export function isMainWindowPtyIpcEvent( @@ -25,7 +26,7 @@ export function isMainWindowPtyIpcEvent( ) } -export type PtyWritePayload = { id: string; data: string } +export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind } export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } export function createPtyWriteInput(deps: { @@ -148,6 +149,12 @@ export function createPtyWriteInput(deps: { const isPtyWriteEventFromMainWindow = (event: IpcMainEvent | IpcMainInvokeEvent): boolean => isMainWindowPtyIpcEvent(event, mainWindow) + const noteRendererPtyInput = (args: PtyWritePayload): void => { + lastInputAtByPty.set(args.id, performance.now()) + interactiveOutputCharsByPty.set(args.id, 0) + runtime?.terminalRunFacts?.recordInput(args.id, args.inputKind, args.data) + } + const writePtyInput = (args: PtyWritePayload): boolean | Promise => { // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. if (runtime?.getDriver(args.id).kind === 'mobile') { @@ -158,9 +165,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false @@ -180,9 +185,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index aaace9fac37..5c7c897474a 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -17,7 +17,6 @@ import { stopReplacedPanePty, type PtyKillIpcDeps } from './ipc/renderer-kill' -import { markReplacedPtyStop } from './delivery/exit' import { installPtyWriteIpcHandlers } from './ipc/write' import { installPtySpawnIpcHandler } from './ipc/spawn' import { installPtyRuntimeController } from './runtime/controller' @@ -237,7 +236,6 @@ export function registerPtyHandlers( options, trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, retiredRejectedPtyIds: session.retiredRejectedPtyIds, - reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, mainWindow, transitionSpawnHiddenRendererPtyDeliveryState: session.transitionSpawnHiddenRendererPtyDeliveryState, @@ -275,8 +273,7 @@ export function registerPtyHandlers( trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery, - stopReplacedPty: (id) => - stopReplacedPanePty(killDeps, id, (ptyId) => markReplacedPtyStop(session, ptyId)) + stopReplacedPty: (id) => stopReplacedPanePty(killDeps, id) }) installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) diff --git a/src/main/ipc/pty/register-without-renderer.test.ts b/src/main/ipc/pty/register-without-renderer.test.ts index 513d969f110..a5d19f020fc 100644 --- a/src/main/ipc/pty/register-without-renderer.test.ts +++ b/src/main/ipc/pty/register-without-renderer.test.ts @@ -128,8 +128,8 @@ describe('PTY registration without renderer delivery', () => { throw new Error('missing runtime PTY controller') } - expect(controller.write('daemon-pty', 'local input')).toBe(true) - expect(controller.write(remoteId, 'remote input')).toBe(true) + expect(controller.write('daemon-pty', 'local input', 'driving')).toBe(true) + expect(controller.write(remoteId, 'remote input', 'driving')).toBe(true) await controller.clearBuffer?.('daemon-pty') await controller.clearBuffer?.(remoteId) await expect(controller.attach?.('daemon-pty')).resolves.toBe(true) @@ -147,7 +147,7 @@ describe('PTY registration without renderer delivery', () => { expect(attach).toHaveBeenCalledExactlyOnceWith('daemon-pty') unregisterSshPtyProvider('ssh-a') - expect(controller.write(remoteId, 'disconnected input')).toBe(false) + expect(controller.write(remoteId, 'disconnected input', 'driving')).toBe(false) await expect(controller.probePtyLiveness?.(remoteId)).resolves.toBeNull() expect(local.write).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 8f20245505c..e1bfd5d7f10 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -82,7 +82,6 @@ export type PtyRuntimeControllerDeps = { } trustedTerminalHandleEnv: Set retiredRejectedPtyIds: Map - reversibleStopOwnersByPtyId: Map mainWindow?: PtyRendererDelivery transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void syncPtyBackgroundedDelivery?: (id: string, caller: string) => void diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 06ce1891b69..75ff168a646 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -4,7 +4,6 @@ import type { PtyRuntimeControllerDeps } from './controller-deps' import { spawnPtyFromRuntimeController } from './spawn' import { killPtyFromRuntimeController, - markReversibleStopsFromRuntimeController, retireRejectedPtyFromRuntimeController, stopAndWaitPtyFromRuntimeController } from './kill' @@ -45,9 +44,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi }, adoptStablePane, spawn: async (args) => spawnPtyFromRuntimeController(deps, args), - write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), - writeWithSettlement: (ptyId, data) => - writePtyFromRuntimeController(ptyId, data, { waitForSettlement: true }), + write: (ptyId, data, inputKind) => writePtyFromRuntimeController(deps, ptyId, data, inputKind), + writeWithSettlement: (ptyId, data, inputKind) => + writePtyFromRuntimeController(deps, ptyId, data, inputKind, { waitForSettlement: true }), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own @@ -57,13 +56,12 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), retireRejectedPty: (ptyId, stopConfirmed) => retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), - markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), recordUnconfirmedStop: (ptyId) => recordUnconfirmedExplicitSshStop({ store: deps.store, ptyId, - reversible: deps.reversibleStopOwnersByPtyId.has(ptyId) + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false }), getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options), diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 98bdc1e97ae..edbd12b0b18 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -19,8 +19,7 @@ export function killPtyFromRuntimeController( rememberSyntheticKillExit, sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds, - reversibleStopOwnersByPtyId + retiredRejectedPtyIds } = deps runtime?.markPtyStopRequested?.(ptyId) let connectionId: string | null | undefined = ptyOwnership.get(ptyId) @@ -31,7 +30,7 @@ export function killPtyFromRuntimeController( store, ptyId, connectionId, - reversible: reversibleStopOwnersByPtyId.has(ptyId), + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false, incarnationId }) } @@ -184,31 +183,6 @@ export function retireRejectedPtyFromRuntimeController( }) } -export function markReversibleStopsFromRuntimeController( - deps: PtyRuntimeControllerDeps, - ptyIds: readonly string[] -): () => void { - const { reversibleStopOwnersByPtyId } = deps - for (const ptyId of ptyIds) { - reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const ptyId of ptyIds) { - const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 - if (owners > 0) { - reversibleStopOwnersByPtyId.set(ptyId, owners) - } else { - reversibleStopOwnersByPtyId.delete(ptyId) - } - } - } -} - /** * Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`. * diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index f03892a6e31..5fec2c25e26 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -12,16 +12,28 @@ import { writeUnverifiable, type WriteSettlement } from '../../../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' + +type RuntimeWriteDeps = Pick -export function writePtyFromRuntimeController(ptyId: string, data: string): boolean export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind +): boolean +export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, + ptyId: string, + data: string, + inputKind: TerminalInputKind, options: { waitForSettlement: true } ): WriteSettlement | Promise export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind, options?: { waitForSettlement: true } ): boolean | WriteSettlement | Promise { let provider: IPtyProvider @@ -36,6 +48,7 @@ export function writePtyFromRuntimeController( if (!provider.writeWithSettlement) { return writeRefused('provider_cannot_settle') } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.writeWithSettlement(ptyId, data) } catch { @@ -43,6 +56,7 @@ export function writePtyFromRuntimeController( return writeUnverifiable('provider_threw_after_handoff', true) } } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.write(ptyId, data) !== false } catch { diff --git a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts new file mode 100644 index 00000000000..5701b9f82c9 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeWithRuntimeId } from '../../../runtime/orca-runtime-runtime-id' +import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptySizes } from '../delivery/visibility-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +const PTY_ID = 'orca-pty-run-facts' +const INCARNATION_ID = 'inc-run-facts' + +const ADOPTED = { + disposition: 'adopted', + owner: { + claim: { kind: 'terminal' }, + generation: 'g1', + phase: 'live', + ptyId: PTY_ID, + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' } + } +} + +async function commit( + result: Record, + facts = new TerminalRunFactsRegister(), + intentionalPtyStops = new TerminalIntentionalStops(), + prepare?: (ctx: ReturnType) => void +) { + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { runtime, store: undefined, options: {}, sendPtySpawnedToRenderer: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above; store-less deps skip every persistence branch. + const deps = ports as unknown as PtyRuntimeControllerDeps + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worktree-less spawn needs only its grid. + const args = { cols: 120, rows: 40 } as unknown as RuntimePtySpawnArgs + const ctx = createRuntimePtySpawnState(deps, args) + const spawned = { id: PTY_ID, incarnationId: INCARNATION_ID, ...result } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each case sets the spawn-result fields the commit reads. + ctx.result = spawned as unknown as typeof ctx.result + prepare?.(ctx) + await commitRuntimePtySpawn(ctx) + return facts.read(PTY_ID, INCARNATION_ID) +} + +describe('runtime spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a new process as a fresh spawn', async () => { + expect((await commit({})).freshSpawn).toBe(true) + }) + + it('never reads an SSH adoption that omits isReattach as fresh', async () => { + expect((await commit({ agentSessionEnsure: ADOPTED })).freshSpawn).toBe(false) + }) + + it('never reads a cold restore as fresh', async () => { + const coldRestore = { scrollback: 'prior output', cwd: '/tmp' } + + expect((await commit({ coldRestore })).freshSpawn).toBe(false) + }) + + it('keeps a process run facts when the same incarnation commits again', async () => { + const facts = new TerminalRunFactsRegister() + await commit({}, facts) + facts.recordInput(PTY_ID, 'driving', 'ls\r', 100) + + expect(await commit({ isReattach: true }, facts)).toEqual({ + freshSpawn: true, + firstUserInputAt: 100 + }) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('lets a committed $spawn supersede a landed stop that no exit pinned', async ({ result }) => { + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await commit(result, new TerminalRunFactsRegister(), stops) + + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + + await expect( + commit({}, facts, stops, (ctx) => { + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.hostSessionBinding = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit calls only persistPtyBinding on this store. + store: { persistPtyBinding } as unknown as NonNullable< + typeof ctx.hostSessionBinding + >['store'], + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1' + } + }) + ).rejects.toThrow() + + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('records nothing for a $spawn that exited during start', async ({ result }) => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await expect( + commit(result, facts, stops, (ctx) => { + ctx.args.worktreeId = 'wt-1' + ctx.deps.runtime!.registerPty = vi.fn(() => { + throw new Error('agent_session_exited_during_start') + }) + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 856039e7d5a..95994e0effc 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -92,6 +92,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (rejectedRegistration) { await rejectedRegistration } + // Why here: an adoption returns before the commit site below. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) if (ctx.result.incarnationId) { @@ -187,6 +192,12 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) } + // Why after registration: a spawn discarded for a failed save or rejected for exiting during + // start must not record facts or end a stop. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 46f16081efa..a299327fc3f 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -43,7 +43,6 @@ function makeDeps(): PtyRuntimeControllerDeps { finishPtyShutdown, trustedTerminalHandleEnv: new Set(), retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest. mainWindow: {} as BrowserWindow } diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index e5c59d9b81a..be666fc6248 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -10,7 +10,6 @@ import type { PtyRendererDeliveryStateReport } from '../../../shared/pty-renderer-delivery-health' import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug' -import type { ReplacedPtyStop } from './delivery/exit' import { PtyProducerFlowController } from '../pty-producer-flow-control' import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue' import type { SshPtyOutputIntake } from '../ssh-pty-output-intake' @@ -108,8 +107,6 @@ export type PtyIpcSession = { string, { cleanupTimer: NodeJS.Timeout; incarnationId: string | undefined } > - reversibleStopOwnersByPtyId: Map - replacedPtyStopsById: Map retiredRejectedPtyIds: Map pendingSerializeRequests: Map< string, @@ -238,8 +235,6 @@ export function createPtyIpcSession(args: { sourceCreditPendingPtys: new Set(), backgroundedDeliverySyncByPty: new Map(), syntheticKillExitPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), - replacedPtyStopsById: new Map(), retiredRejectedPtyIds: new Map(), pendingSerializeRequests: new Map(), canSendPtyDataToRenderer: unsetSessionFn, diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index 58bed424a51..903d93c4967 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -10,11 +10,13 @@ export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw */ export type PtyBindingOrigin = 'reattach' | 'spawn' | 'relay_reattach' | 'split' | 'unknown' +export type PtySpawnCommitOrigin = Extract + /** The spawn-commit paths share one rule: a split outranks a reattach, a reattach outranks a spawn. */ export function spawnCommitBindingOrigin( commit: { isReattach?: boolean; agentSessionEnsure?: { disposition: string } }, expectedSourceBinding?: unknown -): PtyBindingOrigin { +): PtySpawnCommitOrigin { if (expectedSourceBinding !== undefined) { return 'split' } diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts index b1d185c38b1..2777fdfde4b 100644 --- a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -37,7 +37,6 @@ export function createPtySpawnCommitDependencies( sendPtyExitToRenderer: unexpectedPreflight, finishPtyShutdown: unexpectedPreflight, retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), get mainWindow() { return unexpectedPreflight() } diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts index ee6e7a5b9d0..7f196c9eb81 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -194,10 +194,14 @@ describe('terminal.sendText explicit worktree routing', () => { { includeVisualLayouts: false } ) expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) - expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, { - text: 'echo hi', - enter: true - }) + expect(delegate.sendTerminal).toHaveBeenCalledWith( + terminalId, + { + text: 'echo hi', + enter: true + }, + { inputKind: 'driving' } + ) expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan( vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]! ) diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts index 266b819dbae..731934d5595 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -3,6 +3,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos import type { PluginHostServices } from './plugin-host-methods' import { PluginSecretsStore } from './plugin-secrets-store' import { PluginKvStore } from './plugin-storage-store' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' /** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ export type PluginRuntimeDelegate = { @@ -19,7 +20,8 @@ export type PluginRuntimeDelegate = { ): Promise<{ terminals: { handle: string; title: string | null }[] }> sendTerminal( handle: string, - action: { text?: string; enter?: boolean } + action: { text?: string; enter?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise<{ accepted: boolean }> dispatchPluginNotification(input: { pluginId: string @@ -59,7 +61,7 @@ export function bindPluginHostServices(input: { .map((terminal) => ({ id: terminal.handle })) }, sendTerminalText: async (terminalId, action) => { - const result = await delegate.sendTerminal(terminalId, action) + const result = await delegate.sendTerminal(terminalId, action, { inputKind: 'driving' }) return { accepted: result.accepted } }, dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), diff --git a/src/main/runtime/agent-prompt-line-settle.test.ts b/src/main/runtime/agent-prompt-line-settle.test.ts index 45d8bca53a6..4ac0d9648ee 100644 --- a/src/main/runtime/agent-prompt-line-settle.test.ts +++ b/src/main/runtime/agent-prompt-line-settle.test.ts @@ -37,7 +37,7 @@ describe('agent prompt line-settle scheduling', () => { () => undefined, 'antigravity' ) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(submitDelayMs - 1) diff --git a/src/main/runtime/agent-prompt-receipt-correlation.test.ts b/src/main/runtime/agent-prompt-receipt-correlation.test.ts index e6b6d7f2793..77e8967b6d3 100644 --- a/src/main/runtime/agent-prompt-receipt-correlation.test.ts +++ b/src/main/runtime/agent-prompt-receipt-correlation.test.ts @@ -34,6 +34,7 @@ describe('agent prompt receipt correlation', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-first', observationTimeoutMs: 0 @@ -41,6 +42,7 @@ describe('agent prompt receipt correlation', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-second', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts index c893623a18a..3d0c317a31b 100644 --- a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts @@ -102,7 +102,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -130,6 +132,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'antigravity-pre-invocation', observationTimeoutMs: 20_000 @@ -156,7 +159,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { stateStartedAt: 1_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -171,6 +176,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex') const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-first', observationTimeoutMs: 0 @@ -193,6 +199,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-second', observationTimeoutMs: 500 @@ -219,7 +226,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { it('does not write Enter after the PTY generation changes during settlement', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('terminal_handle_stale') await vi.advanceTimersByTimeAsync(0) @@ -256,6 +265,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -291,7 +301,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -318,9 +330,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -331,7 +343,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -351,8 +365,10 @@ describe('agent prompt submission runtime hook and generation cases', () => { } }) - const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt') - const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt') + const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { inputKind: 'driving' }) + const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await Promise.all([first, second]) @@ -373,6 +389,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-first', observationTimeoutMs: 0 @@ -380,6 +397,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-second', observationTimeoutMs: 0 @@ -418,6 +436,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', { + inputKind: 'driving', beforeWrite: async () => { firstWriteReached() await firstGate @@ -430,7 +449,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { 0 ) - const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt') + const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(replacement).resolves.toMatchObject({ accepted: true }) expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) @@ -444,6 +465,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -466,6 +488,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -483,6 +506,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 873863ada75..700445483a9 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -43,7 +43,9 @@ describe('agent prompt submission runtime', () => { } ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -59,7 +61,9 @@ describe('agent prompt submission runtime', () => { } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -75,7 +79,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -93,7 +99,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -109,7 +117,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -122,9 +132,9 @@ describe('agent prompt submission runtime', () => { const { runtime, handle, writes } = await createPromptRuntime(() => undefined) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -140,9 +150,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -155,9 +165,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -175,7 +185,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -197,9 +209,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -214,7 +226,9 @@ describe('agent prompt submission runtime', () => { text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n' }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -235,7 +249,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -257,7 +273,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', output, Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -271,6 +289,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -291,6 +310,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -319,9 +339,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -369,7 +389,9 @@ describe('agent prompt submission runtime', () => { ) vi.setSystemTime(2_000) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -389,7 +411,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -408,7 +432,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -432,7 +458,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -453,6 +481,7 @@ describe('agent prompt submission runtime', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-output-only', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts index f6298df8b17..3bc936c78a5 100644 --- a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -111,7 +111,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor('review this', 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -131,7 +133,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { // Measured ConPTY ingest for 8 KB is 60-89 ms; the old constant charged 1_500 ms. const delayMs = submitDelayFor(prompt, 'win32') expect(delayMs).toBeLessThan(700) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs) @@ -146,7 +148,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() const prompt = 'y'.repeat(320_000) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Every byte is already handed to node-pty here -- the hazard is that the *host* is @@ -167,6 +169,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime() const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -186,7 +189,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -215,7 +220,9 @@ describe('agent prompt submit delay follows the execution host', () => { patchPtyRecord(runtime, { isWsl: true, wslDistro: 'Ubuntu' }) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') expect(delayMs).toBeGreaterThan(submitDelayFor(HOST_PROBE_PROMPT, 'linux')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -235,7 +242,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('win32') const clientDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') const hostDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(clientDelayMs) @@ -255,7 +264,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('linux') const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'linux') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -277,7 +288,9 @@ describe('agent prompt submit delay follows the execution host', () => { }) registerSshRemotePlatform(undefined) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -341,7 +354,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000)) + const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000), { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Marker at 100 ms + a 1_500 ms quiet window would have submitted at ~1_600 ms, while @@ -370,7 +385,7 @@ describe('agent prompt render gate on a ConPTY host', () => { markerDelayMs: ingestMs - 1_000, noiseUntilMs: ingestMs + 20_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(ingestMs + 8_000 - 1) @@ -387,7 +402,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -409,7 +426,11 @@ describe('plain terminal send suffix delay', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() - const send = runtime.sendTerminal(handle, { text: 'z'.repeat(320_000), enter: true }) + const send = runtime.sendTerminal( + handle, + { text: 'z'.repeat(320_000), enter: true }, + { inputKind: 'driving' } + ) // Same hazard as the agent-prompt path: a flat 500 ms wrote Enter mid-paste here. await vi.advanceTimersByTimeAsync(3_342) @@ -429,7 +450,7 @@ describe('plain terminal send suffix delay', () => { const send = runtime.sendTerminal( handle, { text: 'z'.repeat(320_000), enter: true }, - { signal: controller.signal } + { inputKind: 'driving', signal: controller.signal } ) const rejected = expect(send).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts index a9a3b4484a8..eb4acb14d5e 100644 --- a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -226,7 +226,7 @@ export class ClaudeAgentTeamsTmuxDispatcher { const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l')) if (text) { - await api.sendTerminal(pane.handle, { text }) + await api.sendTerminal(pane.handle, { text }, { inputKind: 'driving' }) } return '' } diff --git a/src/main/runtime/claude-agent-teams-types.ts b/src/main/runtime/claude-agent-teams-types.ts index 10234f5226d..bddfad17d17 100644 --- a/src/main/runtime/claude-agent-teams-types.ts +++ b/src/main/runtime/claude-agent-teams-types.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalShow, RuntimeTerminalSplit } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type AgentTeamsTmuxCompatRequest = { teamId: string @@ -43,7 +44,8 @@ export type AgentTeamsTerminalApi = { readTerminal(handle: string, opts?: { limit?: number }): Promise sendTerminal( handle: string, - action: { text?: string; enter?: boolean; interrupt?: boolean } + action: { text?: string; enter?: boolean; interrupt?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise focusTerminal(handle: string): Promise closeTerminal(handle: string): Promise diff --git a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts index f433c75793d..7cb440b4bac 100644 --- a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts +++ b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts @@ -182,6 +182,7 @@ export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends Orca try { await assertTerminalInputWithinLimitWithYield(data) await this.writeTerminalInputChunks(ptyId, data, { + inputKind: 'driving', // Why: a phone can claim the floor while a paste yields between chunks. beforeWrite: () => { if (this.getDriver(ptyId).kind === 'mobile') { diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 391b55ac5b0..3c9c8b7eab9 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-termi import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core' import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' import type { RuntimeTerminalSend } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts' import { assertTerminalInputWithinLimitWithYield, @@ -103,7 +104,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string - } = {} + inputKind: TerminalInputKind + } ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -152,7 +154,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso async sendTerminalAgentPrompt( handle: string, prompt: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise { // Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup. const payloadFor = (ptyId: string): string => { 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 b9f35478505..dbdf4f1c37a 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 @@ -45,7 +45,7 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi // pending and flushes at the ready marker or the 15s // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query // reply is delayed at most that bound, not lost. - this.ptyController?.write(ptyId, reply) + this.ptyController?.write(ptyId, reply, 'query-reply') } } }) diff --git a/src/main/runtime/orca-runtime-deliver-pending-messages.ts b/src/main/runtime/orca-runtime-deliver-pending-messages.ts index 55c6a8dad5e..2cd1cc69c00 100644 --- a/src/main/runtime/orca-runtime-deliver-pending-messages.ts +++ b/src/main/runtime/orca-runtime-deliver-pending-messages.ts @@ -137,7 +137,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv let settlesInEnterCallback = false try { const payload = formatMessagePointer(unread.length, mailboxHandle) - const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false + const wrote = this.ptyController?.write(deliveryPtyId, payload, 'driving') ?? false if (!wrote) { return } @@ -171,7 +171,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - this.ptyController?.write(deliveryPtyId, '\r') + this.ptyController?.write(deliveryPtyId, '\r', 'driving') } catch { // Terminal may have closed during the delay; mail remains queued for check. } finally { diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index d9d6b2acfb9..7f42304eb8b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -36,7 +36,7 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt this.ptyController!.hasChildProcesses?.(ptyId) ?? Promise.resolve(false), subscribeToData: (ptyId, listener) => this.subscribeToTerminalData(ptyId, listener), readRecentOutput: (ptyId) => this.recentPtyOutputById.get(ptyId)?.read(), - write: (ptyId, data) => this.ptyController?.write(ptyId, data) + write: (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) } } diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 99fd88373fa..52b7024b818 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -117,10 +117,9 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null ) } - const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) - const preservesIntentionalHandlelessSurface = - this.intentionalHandlelessPtyStops.has(ptyId) && - (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) + // Why both kinds: a sleep keeps its wake hint, and a restart's replacement takes the pane. + const preservesIntentionallyStoppedSurface = + this.intentionalPtyStops.claimExit(ptyId, exitIncarnationId ?? pty?.incarnationId).length > 0 advertisedUrlWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) @@ -217,7 +216,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.pruneDisconnectedPtyTranscript(pty) } let retirement: Promise | undefined - if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { + if (preservesIntentionallyStoppedSurface || preservesAbnormalSshSurface) { // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts index de98831cdd5..b09ca3b7b8d 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts @@ -146,6 +146,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.terminalRunFacts.delete(ptyId) this.disposePtyTitleTracker(ptyId) this.invalidatePtyIncarnationHandle(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index ad7c81f2e87..04cb91e0281 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -61,7 +61,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeAction(ptyId, action, payload, options) } @@ -69,7 +69,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O protected writeTerminalInputChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeChunks(ptyId, text, options) } diff --git a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts index 49d55d96c17..6acf8ba325f 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts @@ -100,7 +100,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt return await this.claudeAgentTeams.handleTmuxCompat(request, { splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), readTerminal: (handle, opts) => this.readTerminal(handle, opts), - sendTerminal: (handle, action) => this.sendTerminal(handle, action), + sendTerminal: (handle, action, options) => this.sendTerminal(handle, action, options), focusTerminal: (handle) => this.focusTerminal(handle), closeTerminal: (handle) => this.closeTerminal(handle), showTerminal: (handle) => this.showTerminal(handle) diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index bfab8f0ba9a..8e9ad65dc32 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -123,9 +123,9 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { return } - if (this.ptyController?.write(pty.ptyId, command)) { + if (this.ptyController?.write(pty.ptyId, command, 'launch')) { // Why: Enter rides its own write so a long command cannot swallow it. - this.ptyController.write(pty.ptyId, '\r') + this.ptyController.write(pty.ptyId, '\r', 'launch') this.noteTerminalSpawnCommand(pty.ptyId, command) } } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index e4f38e058d3..4b252d57fef 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -45,6 +45,8 @@ import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoin import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' import { RuntimeTerminalWriter } from './runtime-terminal-writer' import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' +import { TerminalIntentionalStops } from './terminal-intentional-stops' +import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts' import type { TuiIdleEvidenceSource } from './tui-idle-evidence' import { TUI_IDLE_DEFAULT_TIMEOUT_MS, @@ -233,9 +235,16 @@ export class OrcaRuntimeWithRuntimeId { protected pendingPtyRegistrationIncarnations = new Map() - // Why: exact-stop is the current sleep transaction boundary; its exit must - // leave the renderer's intentional sleeping surface available for wake. - protected intentionalHandlelessPtyStops = new Map() + // Why public: the PTY IPC layer's stop paths write it and its exit delivery reads it. + readonly intentionalPtyStops = new TerminalIntentionalStops() + + readonly terminalRunFacts = new TerminalRunFactsRegister() + + /** Both spawn-commit funnels report each committed process here, once. */ + noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) + this.intentionalPtyStops.noteSpawnCommit(commit.id) + } // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a @@ -322,7 +331,7 @@ export class OrcaRuntimeWithRuntimeId { protected readonly terminalWaiters = new RuntimeTerminalWaiterRegistry() protected readonly terminalWriter = new RuntimeTerminalWriter( - (ptyId, data) => this.ptyController?.write(ptyId, data) ?? false, + (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false, (ptyId) => this.getPtyWriteHostPlatform(ptyId), (ptyId) => this.getPtyAgent(ptyId) ) diff --git a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts index 7d3645473bc..0f0a856bda2 100644 --- a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts +++ b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts @@ -64,7 +64,7 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const pendingPtyIds = new Set() let generation = 0 let fullyCommitted = false - let releaseReversibleRendererStops = (): void => {} + const settleReversibleStops = new Map void>() try { const resolvedWorktrees = includeTargetResolvedWorktree( [...(await this.getResolvedWorktreeMap()).values()], @@ -148,16 +148,25 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const stopAndWait = ptyController.stopAndWait.bind(ptyController) const orderedLivePtyIds = [...livePtyIds].sort() - releaseReversibleRendererStops = - ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) - const stopResults = await Promise.allSettled( - orderedLivePtyIds.map(async (ptyId) => ({ + for (const ptyId of orderedLivePtyIds) { + settleReversibleStops.set( ptyId, - stopped: await stopAndWait(ptyId, { + this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + ) + } + const stopResults = await Promise.allSettled( + orderedLivePtyIds.map(async (ptyId) => { + const stopped = await stopAndWait(ptyId, { keepHistory: true, deadlineMs: teardownRpcDeadline(sleepDeadline) }) - })) + settleReversibleStops.get(ptyId)?.(stopped) + return { ptyId, stopped } + }) ) const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => { const result = stopResults[index] @@ -251,7 +260,9 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi postStopVerified: true } } finally { - releaseReversibleRendererStops() + for (const settleStop of settleReversibleStops.values()) { + settleStop(false) + } if (!fullyCommitted && generation > 0) { const cancelledPtyIds = [...pendingPtyIds].sort() if (cancelledPtyIds.length > 0) { diff --git a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts index a96ae64e596..d854be6ec68 100644 --- a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts @@ -47,18 +47,22 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit const stoppedPtyIds: string[] = [] for (const ptyId of [...expected].sort()) { - if (opts.keepHistory) { - this.intentionalHandlelessPtyStops.set( - ptyId, - this.ptysById.get(ptyId)?.incarnationId ?? null - ) - } + // Why: exact-stop is the sleep transaction boundary; its exit must leave the sleeping surface for wake. + const settleStop = opts.keepHistory + ? this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + : null + let stopped = false try { - if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { - throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) - } + stopped = await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }) } finally { - this.intentionalHandlelessPtyStops.delete(ptyId) + settleStop?.(stopped) + } + if (!stopped) { + throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) } stoppedPtyIds.push(ptyId) } diff --git a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts index bac59e614b2..dd98d82083f 100644 --- a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts +++ b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts @@ -159,12 +159,16 @@ describe('runtime terminal handle incarnation fencing', () => { }) expect(replacement?.handle).not.toBe(staleHandle) await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale') - await expect(runtime.sendTerminal(staleHandle, { text: 'stale input' })).rejects.toThrow( - 'terminal_handle_stale' - ) + await expect( + runtime.sendTerminal(staleHandle, { text: 'stale input' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_handle_stale') await expect( - runtime.sendTerminal(replacement!.handle, { text: 'replacement input' }) + runtime.sendTerminal( + replacement!.handle, + { text: 'replacement input' }, + { inputKind: 'driving' } + ) ).resolves.toMatchObject({ accepted: true, handle: replacement!.handle diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts index 2501d0141dd..a1bcdf4b287 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts @@ -85,7 +85,7 @@ describe('OrcaRuntimeService', () => { runtime.sendTerminal( terminal.handle, { text: 'notes', enter: true }, - { beforeWrite, afterWrite } + { inputKind: 'driving', beforeWrite, afterWrite } ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 23312f49420..310a1e12c81 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -150,10 +150,14 @@ describe('OrcaRuntimeService', () => { nextCursor: expect.any(String) }) - const send = await runtime.sendTerminal(terminal.handle, { - text: 'continue', - enter: true - }) + const send = await runtime.sendTerminal( + terminal.handle, + { + text: 'continue', + enter: true + }, + { inputKind: 'driving' } + ) expect(send).toMatchObject({ handle: terminal.handle, accepted: true diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts index 27421489aba..a47f037d40e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts @@ -602,7 +602,7 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledTimes(2) expect(write.mock.calls[0][0]).toBe('pty-bare') expect(String(write.mock.calls[0][1])).toMatch(/codex/) - expect(write.mock.calls[1]).toEqual(['pty-bare', '\r']) + expect(write.mock.calls[1]).toEqual(['pty-bare', '\r', 'launch']) } finally { vi.useRealTimers() } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 9145bea46f6..d90dc63031b 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -467,7 +467,7 @@ describe('OrcaRuntimeService', () => { }) const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) - await runtime.sendTerminal(handle, { text: 'continue', enter: true }) + await runtime.sendTerminal(handle, { text: 'continue', enter: true }, { inputKind: 'driving' }) expect(writes).toEqual(['continue', '\r']) }) @@ -490,7 +490,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const prompt = 'line one\nline two\x1b[201~' - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -535,6 +535,7 @@ describe('OrcaRuntimeService', () => { ) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'the brief', { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE }) await vi.runAllTimersAsync() @@ -601,6 +602,7 @@ describe('OrcaRuntimeService', () => { const assertAuthority = vi.fn() const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving', beforeWrite: assertAuthority }) await vi.advanceTimersByTimeAsync(500) @@ -659,7 +661,9 @@ describe('OrcaRuntimeService', () => { 'review this change', agent ) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) if (agent === 'omp') { await sendPromise expect(writes).toEqual([`${buildAgentPromptPasteBytes('review this change')}\r`]) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts index 8b27ce9bb5d..fe3687aab0a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts @@ -44,7 +44,9 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect(runtime.isTerminalRunningSettledPromptAgent(handle)).resolves.toBe(true) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(1_199) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_500) @@ -78,7 +80,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(renderGateCapMs('review this change') - 1) expect(writes).not.toContain('\r') @@ -117,7 +121,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'codex' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(8_000) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_599) @@ -159,7 +165,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) // The marker at 100 ms re-arms the cap, but the ingest term is absolute: a prompt this // small is already ingested by then, so the fallback is one flat render timeout later. await vi.advanceTimersByTimeAsync(100 + 8_000 - 1) @@ -193,7 +201,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail` - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -230,7 +238,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const sendRejection = expect(sendPromise).rejects.toThrow('terminal_not_writable') await vi.runAllTimersAsync() @@ -258,7 +266,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('') - const result = await runtime.sendTerminal(handle, { text }) + const result = await runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(result).toMatchObject({ handle, @@ -285,7 +293,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\nline two\nline three` - await runtime.sendTerminal(handle, { text, enter: true }) + await runtime.sendTerminal(handle, { text, enter: true }, { inputKind: 'driving' }) expect(writes.at(-1)).toBe('\r') expect(writes.slice(0, -1).join('')).toBe(text) @@ -312,7 +320,7 @@ describe('OrcaRuntimeService', () => { vi.useFakeTimers() try { - const sendPromise = runtime.sendTerminal(handle, { text }) + const sendPromise = runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(writes).toEqual([]) @@ -346,7 +354,11 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect( - runtime.sendTerminal(handle, { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }) + runtime.sendTerminal( + handle, + { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }, + { inputKind: 'driving' } + ) ).rejects.toThrow(TERMINAL_INPUT_TOO_LARGE_ERROR) expect(writes).toEqual([]) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts index 65757797f70..e122effd17d 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts @@ -230,7 +230,7 @@ describe('OrcaRuntimeService', () => { expect(new Set(handles).size).toBe(handles.length) await expect( - runtime.sendTerminal('term_victim', { text: 'for victim' }) + runtime.sendTerminal('term_victim', { text: 'for victim' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writesByPty.get('pty-victim')).toEqual(['for victim']) expect(writesByPty.has('pty-imposter')).toBe(false) @@ -260,7 +260,7 @@ describe('OrcaRuntimeService', () => { const listed = await runtime.listTerminals() expect(listed.terminals[0]?.handle).toBe('term_already_bound') await expect( - runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + runtime.sendTerminal('term_already_bound', { text: 'still routed' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual(['still routed']) // the reported-but-not-adopted handle must not resolve to the live pty @@ -347,7 +347,9 @@ describe('OrcaRuntimeService', () => { handle, tail: ['after unavailable'] }) - await expect(runtime.sendTerminal(handle, { text: 'still writable' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'still writable' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index 1ebf15cbc24..9f2e747ba9a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -54,7 +54,7 @@ describe('OrcaRuntimeService', () => { tail: ['after restart'] }) await expect( - runtime.sendTerminal('term_exported', { text: 'still writable' }) + runtime.sendTerminal('term_exported', { text: 'still writable' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ handle: 'term_exported', accepted: true @@ -179,7 +179,7 @@ describe('OrcaRuntimeService', () => { ]) expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1) - await runtime.sendTerminal('term_agent', { text: 'input' }) + await runtime.sendTerminal('term_agent', { text: 'input' }, { inputKind: 'driving' }) await runtime.updateRemoteDesktopViewer('pty-agent', 'viewer', 'client', 132, 41) expect(writes).toEqual([['pty-agent', 'input']]) expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index 8980dc53f85..4f89b097cbb 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -65,7 +65,7 @@ describe('OrcaRuntimeService', () => { const afterRestart = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) const listed = await restarted.listTerminals(`id:${TEST_WORKTREE_ID}`) restarted.onPtyData('persisted-pty', 'after restart\n', 1) - await restarted.sendTerminal('term_current', { text: 'input' }) + await restarted.sendTerminal('term_current', { text: 'input' }, { inputKind: 'driving' }) await restarted.updateRemoteDesktopViewer('persisted-pty', 'viewer', 'client', 132, 41) expect(beforeRestart.tabs[0]).toMatchObject({ @@ -183,7 +183,9 @@ describe('OrcaRuntimeService', () => { const shown = await runtime.showTerminal(entry!.handle) expect(shown.writable).toBe(true) - await expect(runtime.sendTerminal(entry!.handle, { text: 'hi' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(entry!.handle, { text: 'hi' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual([['pty-orphan', 'hi']]) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index cc73ffa2718..5a5c9521403 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -418,7 +418,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-startup-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('keeps the 8s main-runtime startup readiness budget for agents without an override', async () => { @@ -554,7 +558,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-opencode-draft-budget', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-opencode-draft-budget', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('rejects explicit startup commands for disabled selected agents', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index 2ec8e73f800..cc2c2cae2ba 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -83,7 +83,7 @@ describe('OrcaRuntimeService', () => { }) ) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r') + expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', 'launch') }) }) @@ -526,7 +526,11 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-explicit-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) }) diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 5e90602755c..1597084eee1 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -27,7 +27,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ptyId: string, generation: number, pastePayload: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise<{ submits: number; prompt?: RuntimeTerminalPromptDelivery }> { assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -62,7 +62,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // beginning when a large frame is split into independently processed chunks. renderGate?.arm() const initialWrite = submitWithPaste ? pastePayload + AGENT_PROMPT_SUBMIT : pastePayload - if (!this.ptyController?.write(ptyId, initialWrite)) { + if (!this.ptyController?.write(ptyId, initialWrite, options.inputKind)) { throw new Error('terminal_not_writable') } } catch (error) { @@ -103,7 +103,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso const baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) if (!submitWithPaste) { - if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { + if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } diff --git a/src/main/runtime/orchestration/coordinator-runtime-contract.ts b/src/main/runtime/orchestration/coordinator-runtime-contract.ts index 2f4f5bf9b5a..3c98fa30200 100644 --- a/src/main/runtime/orchestration/coordinator-runtime-contract.ts +++ b/src/main/runtime/orchestration/coordinator-runtime-contract.ts @@ -11,7 +11,7 @@ export type CoordinatorRuntime = { sendTerminalAgentPrompt( handle: string, prompt: string, - options?: DispatchPreambleSendOptions + options: DispatchPreambleSendOptions ): Promise listTerminals( worktreeSelector?: string, diff --git a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts index 8dbedc9b33c..52016aa1465 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts @@ -24,7 +24,8 @@ export function writeOrchestrationPointerWithSettlement( return writeRefused('provider_cannot_settle') } try { - return settledWrite.call(args.controller, args.ptyId, args.data) + // Why driving: a pointer is input that tells a running agent to read its mail. + return settledWrite.call(args.controller, args.ptyId, args.data, 'driving') } catch { // A partial write that then threw cannot prove the transport took nothing. return writeUnverifiable('provider_threw_after_handoff', true) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 208868ce45b..a9fbb528939 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -147,12 +147,13 @@ ${params.taskSpec}` export type DispatchPreambleSendOptions = Pick< RuntimeAgentPromptWriteOptions, - 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' + 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' | 'inputKind' > export function dispatchPreambleSendOptions(requestId: string): DispatchPreambleSendOptions { // Why: a delayed provider hook must not revoke an accepted Dispatch. return { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE, acceptQueued: true, observationTimeoutMs: 0, diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts index 41081daeb99..ac66912e747 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -66,6 +66,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: { return false } const sent = await args.runtime.sendTerminalAgentPrompt(args.handle, args.text, { + inputKind: 'launch', // Paired: together these take the queued path, which settles an unobserved turn start into // an `input_accepted` receipt rather than raising it. Without the id the write is verified // strictly and a slow first turn throws. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts index dd4ce2522ea..d9798a47372 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts @@ -100,7 +100,7 @@ it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work' method.handler(method.params!.parse(params) as never, { runtime } as never) ).resolves.toMatchObject({ send: { accepted: true } }) } - expect(write).toHaveBeenCalledWith('pty-worker', 'x') + expect(write).toHaveBeenCalledWith('pty-worker', 'x', 'driving') expect(commit).toHaveBeenCalledTimes(1) expect(dbAccess).not.toHaveBeenCalled() expect(takeover).not.toHaveBeenCalled() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index 593d097580f..526b09a6320 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -381,6 +381,7 @@ describe('orchestration worker-start prompt contract', () => { const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex') runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const pending = runtime.sendTerminalAgentPrompt(handle, 'queued prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'busy-swallowed', observationTimeoutMs: 0 diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index b28fed95098..59fd827f0ce 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -66,10 +66,11 @@ export async function sendTerminalStreamInput( const floorClaim: MobileInputFloorClaimHolder = { current: null } try { if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) + const result = await runtime.sendTerminal(args.terminal, action, { inputKind: 'driving' }) return result.accepted ? 'delivered' : 'rejected' } const result = await runtime.sendTerminal(args.terminal, action, { + inputKind: 'driving', reserveWrite: (writePtyId) => { const claim = runtime.beginMobileInputFloor(writePtyId, clientId) if (!claim) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 99c01d902a4..ca7ac0aeb75 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -210,6 +210,7 @@ export const TERMINAL_SEND_METHODS = [ try { result = useSettledAgentPrompt ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + inputKind: 'driving', beforeWrite, signal, ...(orchestrationMutation @@ -234,6 +235,8 @@ export const TERMINAL_SEND_METHODS = [ { beforeWrite, signal, + // Why: a wire write carries no provenance beyond a client's own query reply. + inputKind: params.inputKind === 'query-reply' ? 'query-reply' : 'driving', ...(reserveWrite ? { reserveWrite } : {}), ...(params.inputKind !== 'query-reply' && mobileFloorClientId ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index 29254fbcf3a..824513dd999 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -45,6 +45,7 @@ describe('terminal agent prompt send RPC', () => { expect(response.ok).toBe(true) expect(runtime.isTerminalRunningSettledPromptAgent).toHaveBeenCalledWith('terminal-1') expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('terminal-1', 'review this change', { + inputKind: 'driving', beforeWrite: undefined, signal: undefined }) @@ -81,7 +82,7 @@ describe('terminal agent prompt send RPC', () => { expect(sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'echo x', enter: true, interrupt: false }, - { beforeWrite: undefined, signal: undefined } + { inputKind: 'driving', beforeWrite: undefined, signal: undefined } ) expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts index 3dc1f6b2918..f93b772994e 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts @@ -241,11 +241,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) handlers.get(16)?.( @@ -434,11 +438,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-8', { - text: 'remote-still-interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-8', + { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const frameCountBeforeAck = binaryFrames.length diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts index 03327859c62..57abea846ae 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts @@ -413,11 +413,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) binaryFrames.splice(0) diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 45ff2d77b8c..562181b2752 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -206,11 +206,15 @@ describe('terminal multiplex RPC', () => { ) ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'ls\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false) @@ -248,11 +252,15 @@ describe('terminal multiplex RPC', () => { ) } await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', { - text: 'retry', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenLastCalledWith( + 'terminal-1', + { + text: 'retry', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) dataListenerRef.current?.('a') diff --git a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts index b7fb20b2f73..ae2298a20dc 100644 --- a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts @@ -327,11 +327,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'echo one\necho two\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'echo one\necho two\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving') @@ -403,11 +407,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'printf a\nprintf b\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'printf a\nprintf b\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-1:desktop-1') diff --git a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts index ef6087923bb..eecd7b03859 100644 --- a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts @@ -309,7 +309,11 @@ describe('terminal multiplex RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'x', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index e597f5e5b13..214a5a2814e 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -347,7 +347,11 @@ describe('terminal output batching', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'ls\r', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index e9fb481aae0..9aa55eb823e 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -232,6 +232,7 @@ describe('terminal send RPC', () => { interrupt: false }, { + inputKind: 'driving', beforeWrite: undefined, reserveWrite: expect.any(Function), afterWrite: expect.any(Function) @@ -370,7 +371,7 @@ describe('terminal send RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: '\x1b[3;4R', enter: false, interrupt: false }, - { beforeWrite: undefined } + { beforeWrite: undefined, inputKind: 'query-reply' } ) expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) @@ -570,7 +571,7 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { inputKind: 'driving', beforeWrite: undefined } ) }) @@ -653,7 +654,7 @@ describe('terminal send RPC', () => { enter: true, interrupt: false }, - { beforeWrite: expect.any(Function) } + { inputKind: 'driving', beforeWrite: expect.any(Function) } ) }) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 54aae2321ad..7b540a05e9a 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -12,6 +12,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimePtyController = { claimStablePaneCreate?(args: { @@ -93,9 +94,13 @@ export type RuntimePtyController = { stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> - write(ptyId: string, data: string): boolean + write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean /** Three-valued settlement; local providers settle synchronously. */ - writeWithSettlement?(ptyId: string, data: string): WriteSettlement | Promise + writeWithSettlement?( + ptyId: string, + data: string, + inputKind: TerminalInputKind + ): WriteSettlement | Promise /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ @@ -106,7 +111,6 @@ export type RuntimePtyController = { ptyId: string, opts?: { keepHistory?: boolean; deadlineMs?: number } ): Promise - markReversibleStops?(ptyIds: readonly string[]): () => void /** Durably records a kill order for an explicit close's unconfirmed stop, replayed when its SSH * host reconnects. True only when an order was written; local PTYs have no later host to ask. */ recordUnconfirmedStop?(ptyId: string): boolean diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 0dcf355bfe0..fa0e7a0afa0 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -16,6 +16,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contracts' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' @@ -203,7 +204,9 @@ export type RuntimeProviderSnapshotReadOptions = { } /** Agent-prompt writes add the correlation inputs a queued-acceptance receipt needs. */ -export type RuntimeAgentPromptWriteOptions = RuntimeTerminalWriteOptions & { +export type RuntimeAgentPromptWriteOptions = Omit & { + /** `launch` for the prompt an agent starts with; `driving` for any prompt sent to a running one. */ + inputKind: Exclude /** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */ promptForSchedule?: string /** See buildAgentPromptPasteBytes. */ diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 7ff6ba4a7f3..10e94f3747f 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,8 +1,10 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TuiAgent } from '../../shared/tui-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimeTerminalWriteOptions = { + inputKind: TerminalInputKind signal?: AbortSignal beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void @@ -12,7 +14,7 @@ export type RuntimeTerminalWriteOptions = { export class RuntimeTerminalWriter { constructor( - private readonly write: (ptyId: string, data: string) => boolean, + private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean, private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () => process.platform, private readonly getAgent: (ptyId: string) => TuiAgent | null = () => null @@ -22,7 +24,7 @@ export class RuntimeTerminalWriter { ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. @@ -54,7 +56,7 @@ export class RuntimeTerminalWriter { throw error } options.reserveWrite?.(ptyId) - if (!this.write(ptyId, suffix)) { + if (!this.write(ptyId, suffix, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -65,7 +67,7 @@ export class RuntimeTerminalWriter { } await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, payload)) { + if (!this.write(ptyId, payload, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -74,14 +76,14 @@ export class RuntimeTerminalWriter { async writeChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, chunk.value)) { + if (!this.write(ptyId, chunk.value, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 7208e0d3bde..779f16ec6a4 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -4,6 +4,7 @@ import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-sca import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { WorktreeStartupDraftPaste, WorktreeStartupFollowup @@ -19,7 +20,7 @@ export type WorktreeStartupReadinessHost = { hasChildProcesses?: (ptyId: string) => Promise subscribeToData: (ptyId: string, listener: (data: string) => void) => () => void readRecentOutput: (ptyId: string) => string | undefined - write: (ptyId: string, data: string) => void + write: (ptyId: string, data: string, inputKind: TerminalInputKind) => void } export function pasteWorktreeStartupDraftWhenReady( @@ -33,7 +34,7 @@ export function pasteWorktreeStartupDraftWhenReady( console.warn('[worktree-create] agent did not become ready for draft paste') return } - host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`) + host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to paste startup draft:', error)) } @@ -49,7 +50,7 @@ export function sendWorktreeStartupFollowupWhenReady( console.warn('[worktree-create] agent did not become ready for follow-up prompt') return } - host.write(ptyId, `${followup.prompt}\r`) + host.write(ptyId, `${followup.prompt}\r`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to send startup follow-up prompt:', error) diff --git a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts index 628139923b0..81287bafc59 100644 --- a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts +++ b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts @@ -37,8 +37,7 @@ export async function runObservedExitSocketScenario(scenario: ObservedExitSocket rememberSyntheticKillExit: harness.session.rememberSyntheticKillExit, sendPtyExitToRenderer: harness.session.sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map() + retiredRejectedPtyIds: new Map() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop/kill read only these controller ports and optional store; spawn ports are unused. const deps = ports as unknown as PtyRuntimeControllerDeps diff --git a/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts new file mode 100644 index 00000000000..e806e88320c --- /dev/null +++ b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from '../../shared/source-scan/source-tree-scan' +import { + findCallsMissingArgument, + type RequiredCallArgument +} from '../../shared/source-scan/call-argument-scan' + +/** + * Every PTY write names its input kind, and the compiler enforces that everywhere except the + * runtime files split out with `@ts-nocheck`. There a missing kind would compile and silently + * record nothing, so this scan is the ratchet for them. + */ +const MAIN_ROOT = resolve(__dirname, '..') + +const namesInputKind = (literal: string): boolean => /\binputKind\b|\.\.\./.test(literal) +const kindAt = (index: number, receiver?: RegExp): RequiredCallArgument => ({ + index, + ...(receiver ? { receiver } : {}), + acceptsObjectLiteral: namesInputKind +}) +const CONTROLLER = /[Cc]ontroller\??\s*$/ + +const KIND_ARGUMENT_BY_METHOD: Record = { + write: kindAt(2, CONTROLLER), + writeWithSettlement: kindAt(2, CONTROLLER), + sendTerminal: kindAt(2), + sendTerminalAgentPrompt: kindAt(2), + writeTerminalAction: kindAt(3), + writeTerminalInputChunks: kindAt(2), + writeTerminalAgentPrompt: kindAt(4), + writeAction: kindAt(3), + writeChunks: kindAt(2) +} + +// Why multiline: some unchecked files open with a lint directive before `@ts-nocheck`. +const uncheckedSources = scanSourceTree(MAIN_ROOT).filter((file) => + /^\/\/ @ts-nocheck\b/m.test(file.source) +) + +describe('PTY write call sites the compiler cannot check', () => { + it('scans the unchecked runtime files that write to a PTY', () => { + expect(uncheckedSources.map((file) => file.relativePath)).toEqual( + expect.arrayContaining([ + 'runtime/orca-runtime-deliver-pending-messages.ts', + 'runtime/orca-runtime-create-pty-headless-terminal-state.ts', + 'runtime/orca-runtime-write-terminal-agent-prompt.ts', + 'runtime/orca-runtime-sync-window-graph.ts' + ]) + ) + }) + + it('finds a write, prompt or send that leaves out its kind', () => { + const planted = [ + '', + 'this.ptyController?.write(ptyId, reply)', + "this.ptyController.write(ptyId, '\\r', 'launch')", + "await this.sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + 'await this.sendTerminalAgentPrompt(handle, prompt, { ...options })', + 'other.write(ptyId, data)', + 'const controller = this.ptyController; controller.write(ptyId, data)' + ].join('\n') + + expect(findCallsMissingArgument(planted, KIND_ARGUMENT_BY_METHOD)).toEqual([ + '2: .write(ptyId, reply)', + "4: .sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + '7: .write(ptyId, data)' + ]) + }) + + it('passes an input kind at every write', () => { + const missing = uncheckedSources.flatMap((file) => + findCallsMissingArgument(file.source, KIND_ARGUMENT_BY_METHOD).map( + (site) => `${file.relativePath}:${site}` + ) + ) + + expect(missing).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts new file mode 100644 index 00000000000..0c3f996153f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { Store } from '../persistence/loading-store/store' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { wirePtyIpcSession } from '../ipc/pty/delivery/wire-session' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { bindProviderListeners } from '../ipc/pty/provider/bind-listeners' +import { + stopRendererOwnedPty, + stopReplacedPanePty, + type PtyKillIpcDeps +} from '../ipc/pty/ipc/renderer-kill' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import { createPtyIpcSession } from '../ipc/pty/session' +import type { IPtyProvider } from '../providers/types' +import { OrcaRuntimeService } from './orca-runtime' +import { + INCARNATION_ID, + LEAF_ID, + PTY_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' + +const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement' +const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777' +const LATER_INCARNATION_ID = '88888888-8888-4888-8888-888888888888' + +const directories: string[] = [] +const stores: Store[] = [] +const priorProvider = getLocalPtyProvider() +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + for (const store of stores.splice(0)) { + store.freezeWrites() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** A real store and runtime with one bound pane, and main's exit delivery to a renderer stub. + * `lateProviderExit`: the kill's reply overtakes the exit, so main synthesizes one and the + * provider's own exit arrives later through its listener. */ +function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-intentional-stop-')) + directories.push(directory) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority( + join(directory, 'profile-state.db'), + 'intentional-stop' + ) + }) + stores.push(store) + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Fixture', + badgeColor: 'gray', + addedAt: 1, + // Why: a folder workspace resolves without git, which the sleep transaction needs. + ...(opts.folder ? { kind: 'folder' as const } : {}) + }) + store.setWorkspaceSession(advanceTerminalTopologyRevision(makeSession(), WORKTREE_ID)) + store.flushOrThrow() + const runtime = new OrcaRuntimeService(store) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID + }) + ptyOwnership.set(PTY_ID, null) + ptyIncarnationById.set(PTY_ID, INCARNATION_ID) + let emitProviderExit: + | ((payload: { id: string; code: number; incarnationId?: string }) => void) + | undefined + const provider = { + onData: () => () => {}, + onExit: (listener: typeof emitProviderExit) => { + emitProviderExit = listener + return () => {} + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners bind only onData and onExit, and the renderer kill hands the provider to the shutdown port below. + setLocalPtyProvider(provider as unknown as IPtyProvider) + const rendererSend = vi.fn() + const window = { isDestroyed: () => false, webContents: { send: rendererSend } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exit delivery reads only isDestroyed and webContents.send. + const session = createPtyIpcSession({ mainWindow: window as unknown as BrowserWindow, runtime }) + wirePtyIpcSession(session) + bindProviderListeners(session) + const deps: PtyKillIpcDeps = { + store, + runtime, + getLocalPtyProviderStartupPromise: () => undefined, + // The provider's own exit, delivered the way its listener delivers it. + shutdownProviderAndDetectExit: async (_provider, id) => { + if (opts.lateProviderExit) { + return false + } + runtime.onPtyExit(id, 0, INCARNATION_ID, { providerExitObserved: true }) + session.sendPtyExitToRenderer({ id, code: 0, incarnationId: INCARNATION_ID }) + return true + }, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + sendPtyExitToRenderer: session.sendPtyExitToRenderer + } + return { + store, + runtime, + deps, + emitProviderExit: (incarnationId: string) => + emitProviderExit?.({ id: PTY_ID, code: 0, incarnationId }), + boundPtyId: () => + store.getWorkspaceSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID] ?? null, + tabIds: () => (store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID] ?? []).map((t) => t.id), + rendererExits: () => + rendererSend.mock.calls.filter(([channel]) => channel === 'pty:exit').map(([, p]) => p) + } +} + +describe('intentional stops keep the pane through the exit', () => { + it('retires the pane when an ordinary close ends the process', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID }) + + expect(harness.boundPtyId()).toBeNull() + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID } + ]) + }) + + it('keeps the tab and its wake binding when the renderer hibernates the pane', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('keeps a typed pane that a restart replaces, and binds the replacement', async () => { + const harness = createHarness() + harness.runtime.terminalRunFacts.recordSpawnCommit({ + id: PTY_ID, + incarnationId: INCARNATION_ID + }) + harness.runtime.terminalRunFacts.recordInput(PTY_ID, 'driving', 'ls\r') + + await stopReplacedPanePty(harness.deps, PTY_ID) + expect(harness.boundPtyId()).toBe(PTY_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: REPLACEMENT_PTY_ID, + incarnationId: REPLACEMENT_INCARNATION_ID, + origin: 'spawn' + }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(REPLACEMENT_PTY_ID) + expect( + harness.store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[ + makePaneKey(TAB_ID, LEAF_ID) + ] + ).toBe(REPLACEMENT_INCARNATION_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, replacedByRestart: true } + ]) + }) + + it('labels the exit for both a sleep and a restart that stop the same process', async () => { + const harness = createHarness() + const settleSleep = harness.runtime.intentionalPtyStops.mark( + PTY_ID, + 'reversible', + INCARNATION_ID + ) + + await stopReplacedPanePty(harness.deps, PTY_ID) + settleSleep(true) + + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { + id: PTY_ID, + code: 0, + incarnationId: INCARNATION_ID, + preserveRendererBinding: true, + replacedByRestart: true + } + ]) + }) + + it('keeps the pane through the synthetic exit and the provider exit that follows it', async () => { + const harness = createHarness({ lateProviderExit: true }) + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.emitProviderExit(INCARNATION_ID) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: -1, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('never reads the exit of a later process on the same id as the stop', async () => { + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: LATER_INCARNATION_ID + }) + ptyIncarnationById.set(PTY_ID, LATER_INCARNATION_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + incarnationId: LATER_INCARNATION_ID, + origin: 'reattach' + }) + + harness.emitProviderExit(LATER_INCARNATION_ID) + + // Why wait: an unstopped exit retires the pane through an async durable save. + await vi.waitFor(() => expect(harness.boundPtyId()).toBeNull()) + expect(harness.rendererExits().at(-1)).toEqual({ + id: PTY_ID, + code: 0, + incarnationId: LATER_INCARNATION_ID + }) + }) + + it('forgets the stop once the duplicate-exit window after it closes', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([ + 'reversible' + ]) + vi.advanceTimersByTime(1) + + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('keeps the tab and its wake binding when the runtime puts the worktree to sleep', async () => { + const harness = createHarness({ folder: true }) + const inventories = [[{ id: PTY_ID, worktreeId: WORKTREE_ID, cwd: WORKTREE_PATH, title: 'a' }]] + harness.runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait: async (ptyId) => { + harness.runtime.onPtyExit(ptyId, -1, INCARNATION_ID, { providerExitObserved: true }) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => inventories.shift() ?? [] + }) + + await harness.runtime.sleepTerminalsForWorktree(`id:${WORKTREE_ID}`) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.test.ts b/src/main/runtime/terminal-intentional-stops.test.ts new file mode 100644 index 00000000000..d37fc4147e8 --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { TerminalIntentionalStops } from './terminal-intentional-stops' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('terminal intentional stops', () => { + it('keeps the mark while a second overlapping owner still holds it', () => { + const stops = new TerminalIntentionalStops() + const settleFirst = stops.mark('pty-1', 'reversible', 'inc-1') + const settleSecond = stops.mark('pty-1', 'reversible', 'inc-1') + + settleFirst(false) + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + settleSecond(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('still reads an exit that lands after the stop settled, until the window closes', () => { + vi.useFakeTimers() + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-ssh', 'reversible', 'inc-1') + + settle(true) + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + + expect(stops.isReversibleStopInFlight('pty-ssh')).toBe(false) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual(['reversible']) + vi.advanceTimersByTime(1) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual([]) + }) + + it('drops the mark at once when the stop fails', () => { + const stops = new TerminalIntentionalStops() + + stops.mark('pty-1', 'replaced', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('reads the synthetic exit and the provider exit of the same process alike', () => { + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-1', 'replaced', null) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + settle(true) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('never marks the exit of another process that reuses the id', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1') + + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('starts a new stop of the same id fresh once the prior one settled', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + const settle = stops.mark('pty-1', 'replaced', 'inc-2') + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual(['replaced']) + settle(false) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('labels one exit with every kind of stop that overlapped on it', () => { + const stops = new TerminalIntentionalStops() + const settleSleep = stops.mark('pty-1', 'reversible', 'inc-1') + const settleRestart = stops.mark('pty-1', 'replaced', 'inc-1') + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible', 'replaced']) + + settleSleep(true) + expect(stops.isReversibleStopInFlight('pty-1')).toBe(false) + settleRestart(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('keeps a landed stop when a later stop of the same process fails', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + stops.mark('pty-1', 'reversible', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('lets a new process on the id supersede a landed stop no exit pinned', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-unpinned', 'reversible', null)(true) + stops.mark('pty-pinned', 'reversible', 'inc-1')(true) + stops.mark('pty-in-flight', 'replaced', null) + + for (const ptyId of ['pty-unpinned', 'pty-pinned', 'pty-in-flight']) { + stops.noteSpawnCommit(ptyId) + } + + expect(stops.claimExit('pty-unpinned', null)).toEqual([]) + expect(stops.claimExit('pty-pinned', 'inc-1')).toEqual(['reversible']) + expect(stops.claimExit('pty-in-flight', null)).toEqual(['replaced']) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts new file mode 100644 index 00000000000..b89bc709e5f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -0,0 +1,122 @@ +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' + +/** + * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: + * - `reversible`: sleep or hibernation; the binding is the wake hint. + * - `replaced`: a restart stops the old process so a new one can take the pane. + */ +export type TerminalIntentionalStopKind = 'reversible' | 'replaced' + +type IntentionalStopOwners = { inFlight: number; stopped: boolean } + +type IntentionalStop = { + /** Null until known; the first exit that claims the stop pins it to that process. */ + incarnationId: string | null + /** Why per kind: overlapping stops of different kinds each hold their own label on the exit. */ + ownersByKind: Map + expiryTimer?: ReturnType +} + +const NO_INTENTIONAL_STOP: readonly TerminalIntentionalStopKind[] = [] + +function hasInFlightOwners(stop: IntentionalStop): boolean { + return [...stop.ownersByKind.values()].some((owners) => owners.inFlight > 0) +} + +/** The one register of PTY stops main made on purpose, read by every exit path. */ +export class TerminalIntentionalStops { + private readonly stopsByPtyId = new Map() + + /** Registers one owner's stop; call the result once with whether the stop landed. */ + mark( + ptyId: string, + kind: TerminalIntentionalStopKind, + incarnationId: string | null + ): (stopped: boolean) => void { + let stop = this.stopsByPtyId.get(ptyId) + if (stop && this.joins(stop, incarnationId)) { + clearTimeout(stop.expiryTimer) + stop.expiryTimer = undefined + stop.incarnationId ??= incarnationId + } else { + clearTimeout(stop?.expiryTimer) + stop = { incarnationId, ownersByKind: new Map() } + this.stopsByPtyId.set(ptyId, stop) + } + const owners = stop.ownersByKind.get(kind) ?? { inFlight: 0, stopped: false } + owners.inFlight += 1 + stop.ownersByKind.set(kind, owners) + const owned = stop + let settled = false + return (stopped) => { + if (settled || this.stopsByPtyId.get(ptyId) !== owned) { + return + } + settled = true + owners.stopped ||= stopped + owners.inFlight -= 1 + if (owners.inFlight === 0 && !owners.stopped) { + owned.ownersByKind.delete(kind) + } + this.settleIfIdle(ptyId, owned) + } + } + + /** The kinds of stop this exit ends; empty when the process was not stopped on purpose. */ + claimExit( + ptyId: string, + exitIncarnationId: string | null | undefined + ): readonly TerminalIntentionalStopKind[] { + const stop = this.stopsByPtyId.get(ptyId) + if (!stop) { + return NO_INTENTIONAL_STOP + } + if (stop.incarnationId && exitIncarnationId && stop.incarnationId !== exitIncarnationId) { + return NO_INTENTIONAL_STOP + } + stop.incarnationId ??= exitIncarnationId ?? null + return [...stop.ownersByKind.keys()] + } + + /** Whether a stop of this PTY that may still be undone is in flight. */ + isReversibleStopInFlight(ptyId: string): boolean { + return (this.stopsByPtyId.get(ptyId)?.ownersByKind.get('reversible')?.inFlight ?? 0) > 0 + } + + /** A process committed on this id. A landed stop's process is dead, so an entry no exit ever + * pinned could otherwise claim the new process's exit as the stop. */ + noteSpawnCommit(ptyId: string): void { + const stop = this.stopsByPtyId.get(ptyId) + if (stop && stop.incarnationId === null && !hasInFlightOwners(stop)) { + clearTimeout(stop.expiryTimer) + this.stopsByPtyId.delete(ptyId) + } + } + + // Why: a settled entry joins only its own known process, so an id reused by a process whose + // incarnation is not yet known never inherits the old stop. + private joins(stop: IntentionalStop, incarnationId: string | null): boolean { + if (stop.incarnationId !== null && stop.incarnationId === incarnationId) { + return true + } + return hasInFlightOwners(stop) && (stop.incarnationId === null || incarnationId === null) + } + + private settleIfIdle(ptyId: string, stop: IntentionalStop): void { + if (hasInFlightOwners(stop)) { + return + } + if (stop.ownersByKind.size === 0) { + this.stopsByPtyId.delete(ptyId) + return + } + // Why a window: an SSH exit can arrive after the stop settles, and a synthetic exit can be + // followed by the provider's own; both describe the same stopped process. + stop.expiryTimer = setTimeout(() => { + if (this.stopsByPtyId.get(ptyId) === stop) { + this.stopsByPtyId.delete(ptyId) + } + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + stop.expiryTimer.unref?.() + } +} diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 3a6470b41f9..b79ea7a3270 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -92,9 +92,9 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { await expect( assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) ).rejects.toThrow('terminal_guard_permission') - await expect(runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') }) it('lets a dispatch preamble through once the same lane is working', async () => { diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index 9db883529a0..f479be9e47a 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -21,6 +21,7 @@ import { setTerminalViewAttributes } from './terminal-view-attribute-store' import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' const settingsState = { terminalMainSideEffectAuthority: true as boolean, @@ -58,9 +59,11 @@ type RendererBufferStub = { data: string; cols: number; rows: number } function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { const runtime = new OrcaRuntimeService(store) const replies: { ptyId: string; data: string }[] = [] + const inputKinds: TerminalInputKind[] = [] runtime.setPtyController({ - write: (ptyId, data) => { + write: (ptyId, data, inputKind) => { replies.push({ ptyId, data }) + inputKinds.push(inputKind) return true }, kill: () => true, @@ -74,7 +77,7 @@ function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = } : {}) }) - return { runtime, replies } + return { runtime, replies, inputKinds } } /** Awaits the per-PTY emulator writeChain so queued chunk links (and the @@ -146,7 +149,7 @@ describe('reply parity for hidden-dropped chunks', () => { ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] ])('%s', async (_label, chunk, expectedReplies) => { - const { runtime, replies } = createResponderRuntime() + const { runtime, replies, inputKinds } = createResponderRuntime() markHiddenRendererPty('pty-q') runtime.onPtyData('pty-q', chunk, Date.now()) @@ -154,6 +157,8 @@ describe('reply parity for hidden-dropped chunks', () => { expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + // Why: a reply written as driving input would read the untouched run as typed. + expect(inputKinds).toEqual(expectedReplies.map(() => 'query-reply')) }) it.each([ diff --git a/src/main/runtime/terminal-run-facts-input.test.ts b/src/main/runtime/terminal-run-facts-input.test.ts new file mode 100644 index 00000000000..a1f26d9b686 --- /dev/null +++ b/src/main/runtime/terminal-run-facts-input.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import { writePtyFromRuntimeController } from '../ipc/pty/runtime/operations' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import type { IPtyProvider } from '../providers/types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write' +import { sendTerminalStreamInput } from './rpc/methods/terminal/terminal-input-delivery' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' +import { + pasteWorktreeStartupDraftWhenReady, + sendWorktreeStartupFollowupWhenReady, + type WorktreeStartupReadinessHost +} from './runtime-worktree-startup-readiness' + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/run-facts', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const PTY_ID = 'pty-run-facts-input' +const priorProvider = getLocalPtyProvider() + +type FreshRun = { + runtime: OrcaRuntimeService + controller: RuntimePtyController + handle: string + /** The kind each provider write was sent with, and whether input was recorded by then. */ + writes: { data: string; inputRecorded: boolean }[] + kinds: TerminalInputKind[] + firstUserInputAt: () => number | null +} + +/** A fresh shell whose controller writes go through main's real write funnel to a fake provider. */ +async function createFreshRun(): Promise { + const runtime = new OrcaRuntimeService(makeStore() as never) + const firstUserInputAt = (): number | null => + runtime.terminalRunFacts.read(PTY_ID, null).firstUserInputAt + const writes: FreshRun['writes'] = [] + const kinds: TerminalInputKind[] = [] + const write = (_id: string, data: string): boolean => { + writes.push({ data, inputRecorded: firstUserInputAt() !== null }) + if (data.endsWith('\r')) { + runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', Date.now()) + } + return true + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write funnel calls only write and writeWithSettlement on the provider. + setLocalPtyProvider({ + write, + writeWithSettlement: settledWriteStub(write) + } as unknown as IPtyProvider) + const controller: RuntimePtyController = { + spawn: async () => ({ id: PTY_ID }), + write: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind) + }, + writeWithSettlement: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind, { + waitForSettlement: true + }) + }, + kill: () => true, + getForegroundProcess: async () => null + } + runtime.setPtyController(controller) + const terminal = await runtime.createTerminal('path:/tmp/worktree-a', { launchAgent: 'aider' }) + runtime.terminalRunFacts.recordSpawnCommit({ id: PTY_ID }) + return { runtime, controller, handle: terminal.handle, writes, kinds, firstUserInputAt } +} + +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) +}) + +describe('run facts: the controller write funnel', () => { + it('records terminal.send input before the write that could end the process', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal( + run.handle, + { text: 'exit', enter: true }, + { inputKind: 'driving' } + ) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true, true]) + }) + + it('records stream input from a client', async () => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text: 'l', + client: undefined, + isMobile: false + }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a dispatched agent prompt before its first write', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'review this', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes[0]?.inputRecorded).toBe(true) + }) + + it('reads a run launched with a prompt as untyped until someone drives it', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'start here', { + inputKind: 'launch' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.writes.length).toBeGreaterThan(0) + expect(run.firstUserInputAt()).toBeNull() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'driving' }) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a mailbox pointer, which drives the running agent', async () => { + const run = await createFreshRun() + + await writeOrchestrationPointerWithSettlement({ + ptyId: PTY_ID, + data: 'You have 1 unread message.', + controller: run.controller + }) + + expect(run.kinds).toEqual(['driving']) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records nothing for a client query reply', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'query-reply' }) + + expect(run.firstUserInputAt()).toBeNull() + }) + + it.each([ + ['a terminal reply', '\x1b[3;4R'], + ['focus reports', '\x1b[I\x1b[O'], + ['the focus-in a desktop sends on reattaching a remote pane', '\x1b[I'] + ])('records nothing for stream input that is only %s', async (_label, text) => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text, + client: undefined, + isMobile: false + }) + + expect(run.writes).toHaveLength(1) + expect(run.firstUserInputAt()).toBeNull() + }) + + it('records a reply mixed with a keystroke', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: '\x1b[3;4Rx' }, { inputKind: 'driving' }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records dashboard preview typing before the write that could end the process', async () => { + const run = await createFreshRun() + + await expect(run.runtime.writeTerminalPreviewInput(PTY_ID, 'exit\r')).resolves.toBe(true) + + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true]) + }) + + it('records nothing for dashboard preview bytes that are only a reply or focus reports', async () => { + const run = await createFreshRun() + + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[3;4R') + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[O\x1b[I') + + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) + +describe('run facts: a created worktree’s startup writes', () => { + function readinessHost(run: FreshRun): WorktreeStartupReadinessHost { + return { + getPtyId: () => PTY_ID, + getForegroundProcess: async () => 'aider', + subscribeToData: () => () => {}, + // Why: bracketed paste enabled, then quiet, is the default draft-ready signal. + readRecentOutput: () => '\x1b[?2004h', + write: (ptyId, data, inputKind) => run.controller.write(ptyId, data, inputKind) + } + } + + it('reads a run whose only input was its create-time draft and follow-up as untyped', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + const host = readinessHost(run) + + pasteWorktreeStartupDraftWhenReady(host, run.handle, { agent: 'aider', content: 'plan it' }) + sendWorktreeStartupFollowupWhenReady(host, run.handle, { + expectedProcess: 'aider', + prompt: 'and ship it' + }) + await vi.runAllTimersAsync() + + expect(run.kinds).toEqual(['launch', 'launch']) + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts new file mode 100644 index 00000000000..e1982ebc968 --- /dev/null +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { TerminalRunFactsRegister } from './terminal-run-facts' + +describe('terminal run facts', () => { + it('reads a run main never saw committed as not fresh', () => { + expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ + freshSpawn: false, + firstUserInputAt: null + }) + }) + + it('keeps the first user input across later input and a re-registration of the same process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + facts.recordInput('pty-1', 'driving', 'ls\r', 200) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: true, firstUserInputAt: 100 }) + }) + + it('starts a new process clean', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2' }, { tabId: 'source-tab' }) + + expect(facts.read('pty-1', 'inc-2')).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it('never reads a reattached process as fresh', () => { + const facts = new TerminalRunFactsRegister() + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false) + }) + + it('starts clean when a commit carries no incarnation to tell it from a new process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', isReattach: true }) + + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[1;1R'], + ['driving bytes that are only a terminal reply', 'driving', '\x1b[1;1R'], + ['driving bytes that are only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + + facts.recordInput('pty-1', inputKind, data, 100) + + expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts new file mode 100644 index 00000000000..1fb3e411b6c --- /dev/null +++ b/src/main/runtime/terminal-run-facts.ts @@ -0,0 +1,90 @@ +import { + spawnCommitBindingOrigin, + type PtySpawnCommitOrigin +} from '../persistence/loading-store/pty-binding-span' +import { isTerminalQueryReply } from '../../shared/terminal-query-reply' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' + +export type TerminalRunFacts = { + /** This process was started for its pane, not reattached, adopted or cold-restored. */ + freshSpawn: boolean + /** When input first drove this process, from any client or driver; null if none has. */ + firstUserInputAt: number | null +} + +// Why: a paired client's xterm answers focus changes (CSI I / CSI O) through input that carries no +// provenance; the desktop renderer already excludes them via xterm's user-input signal. +// oxlint-disable-next-line no-control-regex -- focus reports are ESC-framed sequences by definition. +const TERMINAL_FOCUS_REPORTS_ONLY_RE = new RegExp('^(?:\\u001b\\[[IO])+$') + +/** Input with no provenance that no person typed: a whole terminal reply or only focus reports. */ +function isUntypedTerminalInput(payload: string): boolean { + return isTerminalQueryReply(payload) || TERMINAL_FOCUS_REPORTS_ONLY_RE.test(payload) +} + +export type TerminalSpawnCommit = Parameters[0] & { + id: string + incarnationId?: string + coldRestore?: object +} + +/** A cold restore starts a new process for a pane that had one, so it is never fresh. */ +type TerminalRunSpawnOrigin = PtySpawnCommitOrigin | 'cold-restore' + +type TerminalRunRecord = { + incarnationId: string | null + spawnOrigin: TerminalRunSpawnOrigin + firstUserInputAt: number | null +} + +/** Main's per-process facts about one PTY run, keyed by the incarnation they describe. */ +export class TerminalRunFactsRegister { + private readonly runsByPtyId = new Map() + + /** Once per process: a re-registration of the same incarnation keeps its facts. Without an + * incarnation a commit cannot be told from a new process, so it starts clean. */ + recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + const incarnationId = commit.incarnationId ?? null + if ( + incarnationId !== null && + this.runsByPtyId.get(commit.id)?.incarnationId === incarnationId + ) { + return + } + const origin = spawnCommitBindingOrigin(commit, expectedSourceBinding) + this.runsByPtyId.set(commit.id, { + incarnationId, + spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, + firstUserInputAt: null + }) + } + + /** The one record point both write funnels call just before the provider write, because input + * such as `exit` can end the process before the write returns. The payload check backs up a + * writer that labels a reply or focus report as driving. */ + recordInput(ptyId: string, inputKind: TerminalInputKind, data: string, now = Date.now()): void { + if (inputKind !== 'driving') { + return + } + const run = this.runsByPtyId.get(ptyId) + if (run && run.firstUserInputAt === null && !isUntypedTerminalInput(data)) { + run.firstUserInputAt = now + } + } + + /** A run main never saw committed reads as not fresh, which keeps today's close-on-exit. */ + read(ptyId: string, incarnationId: string | null | undefined): TerminalRunFacts { + const run = this.runsByPtyId.get(ptyId) + if (!run || (run.incarnationId && incarnationId && run.incarnationId !== incarnationId)) { + return { freshSpawn: false, firstUserInputAt: null } + } + return { + freshSpawn: run.spawnOrigin === 'spawn' || run.spawnOrigin === 'split', + firstUserInputAt: run.firstUserInputAt + } + } + + delete(ptyId: string): void { + this.runsByPtyId.delete(ptyId) + } +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index b7c5ed95068..a25ed9817fb 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -96,9 +96,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledWith(STALE_PTY_ID) expect(write).not.toHaveBeenCalled() @@ -108,9 +108,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'do the thing', { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(write).not.toHaveBeenCalled() }) @@ -120,12 +120,14 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => null }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('treats a throwing probe as unknown and proceeds', async () => { @@ -135,11 +137,13 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { } }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds when the probe answers live (restored session before its pane remounts)', async () => { @@ -147,21 +151,25 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => true }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds unchanged when the controller exposes no probe', async () => { const { runtime, handle, write } = await makeRuntimeWithLeafHandle({}) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('never probes when the provider synchronously knows the id (live pty)', async () => { @@ -171,24 +179,26 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => ptyId === STALE_PTY_ID }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).not.toHaveBeenCalled() - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('reuses a proven-absent verdict across repeated sends instead of re-probing', async () => { const probe = vi.fn(async () => false) const { runtime, handle } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) - await expect(runtime.sendTerminal(handle, { text: 'b' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledTimes(1) }) @@ -201,17 +211,19 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => livePtyIds.has(ptyId) }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') // Same id recreated by a fresh spawn: provider knowledge must beat the verdict. livePtyIds.add(STALE_PTY_ID) - await expect(runtime.sendTerminal(handle, { text: 'b' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).toHaveBeenCalledTimes(1) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b', 'driving') }) }) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index ca273aee07d..55937134bb1 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -3,6 +3,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -75,8 +76,8 @@ export type PtyApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> - write: (id: string, data: string) => void - writeAccepted: (id: string, data: string) => Promise + write: (id: string, data: string, inputKind: TerminalInputKind) => void + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void claimViewport: (id: string, cols: number, rows: number) => void diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index e9b19dbe43a..225aec9618a 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -71,11 +72,11 @@ export const ptySessionControlApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) + write: (id: string, data: string, inputKind: TerminalInputKind): void => { + ipcRenderer.send('pty:write', { id, data, inputKind }) }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }), onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => callback(payload) diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index eedf8506e1c..334e4639add 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -147,7 +147,7 @@ export function createClaudeModelSwitchConfirmationObserver(args: { // exact Claude warning defaults to “Yes” and needs only one Enter. const accepted = args.submitConfirmation ? args.submitConfirmation() !== false - : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT) + : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT, 'driving') if (!accepted) { finish('unknown') return diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index f77ba77e274..aba65acf8db 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -56,20 +56,20 @@ export function sendNativeChatMessageWithImageAttachments( ), trimmedText.length > 0 )) { - sendRuntimePtyInput(settings, ptyId, payload) + sendRuntimePtyInput(settings, ptyId, payload, 'driving') } if (trimmedText.length > 0) { delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) return } delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index d8cf90167ec..0071b73b21d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -138,7 +138,12 @@ describe('sendNativeChatMessage', () => { ]) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) }) @@ -194,13 +199,19 @@ describe('sendNativeChatMessageVerified', () => { expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( SETTINGS, PTY, - buildNativeChatPasteBytes('/model sonnet') + buildNativeChatPasteBytes('/model sonnet'), + 'driving' ) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) expect(await result).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect( sendRuntimePtyInputVerified.mock.calls.some( (call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT @@ -234,7 +245,12 @@ describe('sendNativeChatMessageVerified', () => { const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT) // Only the verified path's Enter — chat's delayed Enter was cancelled. expect(submits).toHaveLength(0) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('returns false when the delayed Enter wait is aborted', async () => { @@ -347,9 +363,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => { '\x1b[200~@"C:\\Images\\screen shot.png"\x1b[201~ ' ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('clears the line, then bracket-pastes image paths before prompt text', () => { @@ -372,10 +393,20 @@ describe('sendNativeChatMessageWithImageAttachments', () => { ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'what do you see?') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + 'what do you see?', + 'driving' + ) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4) }) @@ -396,7 +427,12 @@ describe('sendNativeChatMessageWithImageAttachments', () => { vi.advanceTimersByTime(1) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('treats whitespace-only prompt input as attachment-only', () => { @@ -450,7 +486,7 @@ describe('empty prompt submit', () => { it('submits an empty prompt with a bare Enter', () => { submitNativeChatPrompt(SETTINGS, PTY) expect(sendRuntimePtyInput).toHaveBeenCalledOnce() - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT, 'driving') }) }) @@ -483,16 +519,17 @@ describe('sendNativeChatAskAnswer', () => { ) vi.advanceTimersByTime(0) - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( SETTINGS, PTY, - buildNativeChatPasteBytes('custom answer') + buildNativeChatPasteBytes('custom answer'), + 'driving' ) }) @@ -533,7 +570,7 @@ describe('sendNativeChatAskAnswer', () => { const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled) await vi.advanceTimersByTimeAsync(handle.settleAfterMs) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving') expect(onSettled).not.toHaveBeenCalled() resolveAccepted(true) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a845fe10d16..1eb7a676103 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -72,7 +72,12 @@ export function clearUnsubmittedAgentInput( ptyId: string, options?: NativeChatSendOptions ): void { - sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + sendRuntimePtyInput( + settings, + ptyId, + options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + 'driving' + ) } /** @@ -104,7 +109,7 @@ export function clearThenWrite( // An unreadable terminal is unconfirmed; the maximal clear remains safe. } if (!cleared) { - sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX) + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') } writeBody() }) @@ -140,11 +145,11 @@ export function sendNativeChatMessage( if (isCancelled()) { return } - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') // Schedule from the actual body write: an overdue clear-confirm callback // must not collapse the required body-to-Enter gap after a renderer stall. delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) @@ -203,12 +208,13 @@ export async function sendNativeChatMessageVerified( const bodyAccepted = await sendRuntimePtyInputVerified( settings, ptyId, - buildNativeChatPasteBytes(text) + buildNativeChatPasteBytes(text), + 'driving' ) if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { return false } - return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) + return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** Types a slash command as individual keys so Codex opens its command palette. */ @@ -224,7 +230,7 @@ export async function typeNativeChatCommand( command, signal, write: async (key) => - (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) ? 'accepted' : 'rejected' }) return outcome === 'accepted' } @@ -253,7 +259,9 @@ export function sendNativeChatTypedCommand( if (isCancelled()) { return 'rejected' } - return (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + return (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) + ? 'accepted' + : 'rejected' } }).then(finish, () => finish('rejected')) }, @@ -269,7 +277,7 @@ export function sendNativeChatTypedCommand( /** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the * composer is empty. */ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** @@ -297,10 +305,10 @@ export function sendNativeChatAskAnswer( // Why: inference must use the remote host's acceptance result, not // the fire-and-forget renderer dispatch result. verifiedWrites.push( - sendRuntimePtyInputVerified(settings, ptyId, bytes).catch(() => false) + sendRuntimePtyInputVerified(settings, ptyId, bytes, 'driving').catch(() => false) ) } else { - sendRuntimePtyInput(settings, ptyId, bytes) + sendRuntimePtyInput(settings, ptyId, bytes, 'driving') } }, index * NATIVE_CHAT_QUESTION_STEP_MS) ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts index 71a654c0927..f59d86303f1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts @@ -25,7 +25,7 @@ export function useNativeChatComposerInterrupt(args: { } const target = resolveTarget() if (target) { - sendRuntimePtyInput(target.settings, target.ptyId, ESC) + sendRuntimePtyInput(target.settings, target.ptyId, ESC, 'driving') } }, [cancelPendingSends, isWorking, onStop, resolveTarget]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 864c62f6f91..f2cbd8df93c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -195,7 +195,8 @@ describe('useNativeChatInteractiveSend', () => { expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith( { terminalTabId: 'tab-1' }, 'pty-1', - '\x1b' + '\x1b', + 'driving' ) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 43adabae8f3..3d270cde49b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -76,7 +76,12 @@ export function useNativeChatInteractiveSend( if (!targetPtyId) { return } - sendRuntimePtyInput(getSettingsForAgentTabRuntimeOwner(terminalTabId), targetPtyId, raw) + sendRuntimePtyInput( + getSettingsForAgentTabRuntimeOwner(terminalTabId), + targetPtyId, + raw, + 'driving' + ) }, [terminalTabId, targetPtyId] ) diff --git a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts index cba19b43c2f..c77339bfe2e 100644 --- a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts +++ b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts @@ -30,39 +30,52 @@ describe('deferred split pane handoff', () => { const cwdPromise = Promise.resolve('/source/cwd') const initial = beginDeferredSplitPaneHandoff(key, cwdPromise) - appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary' }) - appendDeferredSplitPaneInput(initial, { data: '\x1b[0n', kind: 'immediate' }) - appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted' }) + appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary', inputKind: 'driving' }) + appendDeferredSplitPaneInput(initial, { + data: '\x1b[0n', + kind: 'immediate', + inputKind: 'query-reply' + }) + appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted', inputKind: 'driving' }) const remounted = claimDeferredSplitPaneHandoff(key) expect(remounted?.cwdPromise).toBe(cwdPromise) expect(remounted?.preconnectInput).toEqual([ - { data: 'typed', kind: 'ordinary' }, - { data: '\x1b[0n', kind: 'immediate' }, - { data: '\x03', kind: 'accepted' } + { data: 'typed', kind: 'ordinary', inputKind: 'driving' }, + { data: '\x1b[0n', kind: 'immediate', inputKind: 'query-reply' }, + { data: '\x03', kind: 'accepted', inputKind: 'driving' } ]) }) it('keeps input across repeated remounts and fences stale owners', () => { const key = makePaneKey('tab-1', LEAF_1) const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(initial, { data: 'before-first-remount', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'before-first-remount', + kind: 'ordinary', + inputKind: 'driving' + }) const firstRemount = claimDeferredSplitPaneHandoff(key) expect(firstRemount).not.toBeNull() - appendDeferredSplitPaneInput(initial, { data: 'stale-input', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'stale-input', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(initial) clearDeferredSplitPaneHandoff(initial) appendDeferredSplitPaneInput(firstRemount!.handle, { data: 'before-second-remount', - kind: 'ordinary' + kind: 'ordinary', + inputKind: 'driving' }) const secondRemount = claimDeferredSplitPaneHandoff(key) expect(secondRemount?.preconnectInput).toEqual([ - { data: 'before-first-remount', kind: 'ordinary' }, - { data: 'before-second-remount', kind: 'ordinary' } + { data: 'before-first-remount', kind: 'ordinary', inputKind: 'driving' }, + { data: 'before-second-remount', kind: 'ordinary', inputKind: 'driving' } ]) clearDeferredSplitPaneHandoff(firstRemount!.handle) @@ -74,21 +87,33 @@ describe('deferred split pane handoff', () => { it('releases an unmounted owner without dropping its pending handoff', () => { const key = makePaneKey('tab-1', LEAF_1) const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(initial, { data: 'before-unmount', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'before-unmount', + kind: 'ordinary', + inputKind: 'driving' + }) releaseDeferredSplitPaneHandoff(initial) - appendDeferredSplitPaneInput(initial, { data: 'late-stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'late-stale', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(initial) expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([ - { data: 'before-unmount', kind: 'ordinary' } + { data: 'before-unmount', kind: 'ordinary', inputKind: 'driving' } ]) }) it('lets a late close discard a released handoff by its stable pane key', () => { const key = makePaneKey('tab-1', LEAF_1) const owner = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(owner, { data: 'must-not-replay', kind: 'ordinary' }) + appendDeferredSplitPaneInput(owner, { + data: 'must-not-replay', + kind: 'ordinary', + inputKind: 'driving' + }) // Whole-tab cleanup releases the mount-local handle before a stale close callback can run. releaseDeferredSplitPaneHandoff(owner) @@ -112,7 +137,11 @@ describe('deferred split pane handoff', () => { it('drops a stale record when an authoritative restored PTY wins the key', () => { const key = makePaneKey('tab-authoritative', LEAF_1) const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale')) - appendDeferredSplitPaneInput(stale, { data: 'must-not-replay', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { + data: 'must-not-replay', + kind: 'ordinary', + inputKind: 'driving' + }) discardDeferredSplitPaneHandoffForKey(key) @@ -123,26 +152,44 @@ describe('deferred split pane handoff', () => { it('replaces an older handoff for the same stable pane key', () => { const key = makePaneKey('tab-1', LEAF_1) const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale')) - appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary', inputKind: 'driving' }) const currentPromise = Promise.resolve('/current') const current = beginDeferredSplitPaneHandoff(key, currentPromise) - appendDeferredSplitPaneInput(stale, { data: 'late-stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { + data: 'late-stale', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(stale) - appendDeferredSplitPaneInput(current, { data: 'current', kind: 'ordinary' }) + appendDeferredSplitPaneInput(current, { + data: 'current', + kind: 'ordinary', + inputKind: 'driving' + }) const claimed = claimDeferredSplitPaneHandoff(key) expect(claimed?.cwdPromise).toBe(currentPromise) - expect(claimed?.preconnectInput).toEqual([{ data: 'current', kind: 'ordinary' }]) + expect(claimed?.preconnectInput).toEqual([ + { data: 'current', kind: 'ordinary', inputKind: 'driving' } + ]) }) it('retains input within the shared preconnect entry and code-unit caps', () => { const entryKey = makePaneKey('tab-entries', LEAF_1) const entryHandle = beginDeferredSplitPaneHandoff(entryKey, Promise.resolve('/entries')) for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - appendDeferredSplitPaneInput(entryHandle, { data: '', kind: 'ordinary' }) + appendDeferredSplitPaneInput(entryHandle, { + data: '', + kind: 'ordinary', + inputKind: 'driving' + }) } - appendDeferredSplitPaneInput(entryHandle, { data: 'overflow', kind: 'ordinary' }) + appendDeferredSplitPaneInput(entryHandle, { + data: 'overflow', + kind: 'ordinary', + inputKind: 'driving' + }) expect(claimDeferredSplitPaneHandoff(entryKey)?.preconnectInput).toHaveLength( PTY_PRECONNECT_INPUT_MAX_ENTRIES ) @@ -154,11 +201,20 @@ describe('deferred split pane handoff', () => { ) appendDeferredSplitPaneInput(codeUnitHandle, { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), - kind: 'ordinary' + kind: 'ordinary', + inputKind: 'driving' + }) + appendDeferredSplitPaneInput(codeUnitHandle, { + data: 'overflow', + kind: 'ordinary', + inputKind: 'driving' }) - appendDeferredSplitPaneInput(codeUnitHandle, { data: 'overflow', kind: 'ordinary' }) expect(claimDeferredSplitPaneHandoff(codeUnitKey)?.preconnectInput).toEqual([ - { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' } + { + data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + kind: 'ordinary', + inputKind: 'driving' + } ]) }) @@ -209,12 +265,12 @@ describe('deferred split pane handoff', () => { it('does not expose the registry input array by reference', () => { const key = makePaneKey('tab-1', LEAF_1) const handle = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary' }) + appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary', inputKind: 'driving' }) const firstClaim = claimDeferredSplitPaneHandoff(key) firstClaim?.preconnectInput.splice(0) expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([ - { data: 'kept', kind: 'ordinary' } + { data: 'kept', kind: 'ordinary', inputKind: 'driving' } ]) }) }) diff --git a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts index 6dfa1f30866..046b43daa9c 100644 --- a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts +++ b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts @@ -135,7 +135,7 @@ export function appendDeferredSplitPaneInput( ) { return } - owned.record.preconnectInput.push({ data: input.data, kind: input.kind }) + owned.record.preconnectInput.push({ ...input }) owned.record.inputCodeUnits += input.data.length } diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx index 7e6f9a02549..8a84eddbcef 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx @@ -328,7 +328,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { vi.runAllTimers() expect(harness.sendInput).toHaveBeenCalledTimes(1) - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) @@ -351,7 +351,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { vi.runAllTimers() expect(harness.sendInput).toHaveBeenCalledTimes(1) - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) @@ -374,7 +374,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { expect(harness.setActivePane).toHaveBeenCalledWith(2, { focus: false }) expect(harness.sendInput).toHaveBeenCalledOnce() - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts index 743f06f6f42..f2dfe0bd637 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts @@ -240,7 +240,7 @@ describe('connectPanePty', () => { const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void forward('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('forwards terminal input directly when the host supplies no deferPtyInput', async () => { @@ -253,7 +253,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() sendTerminalInputThroughPane(pane, 'a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps large ANSI redraws after captured shortcut input on the immediate path', 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 17511577376..02c99e2aa4b 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 @@ -490,7 +490,7 @@ describe('connectPanePty', () => { // Released (via the guard's fallback or parse completion): input flows again. deps.replayingPanesRef.current.delete(pane.id) sendTerminalInputThroughPane(pane, 'echo hi\r') - expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r') + expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r', 'query-reply') }) it('preserves classified user input during replay while suppressing synthetic replies', async () => { @@ -533,7 +533,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r') + expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') // A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them. pane.terminal.buffer.active.type = 'alternate' @@ -544,7 +544,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r') + expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') // The same bytes on the normal buffer can only be a keyboard arrow, which survives replay. pane.terminal.buffer.active.type = 'normal' @@ -556,7 +556,7 @@ describe('connectPanePty', () => { forward() } expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B', 'driving') // Once the guard releases, the same mouse report is ordinary input again. deps.replayingPanesRef.current.delete(pane.id) @@ -567,7 +567,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M', 'driving') }) it('settles a queued startup only after the pane binds its spawned PTY', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts index fd279a38f9d..7855e756226 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts @@ -738,7 +738,7 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') expect(mockStoreState.recordTerminalInput).not.toHaveBeenCalled() // The reply still reaches the shell; only the activity recording is gated. - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') // Real user input fires the core signal and records activity. for (const listener of userInputListeners) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts index e315563f0c6..ef49288f3eb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts @@ -528,7 +528,7 @@ describe('connectPanePty', () => { 'terminal-bell' ) expect(deps.clearTerminalPaneUnread).not.toHaveBeenCalled() - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) // Why: xterm auto-replies during replay must not count as user interaction, or a BELed pane would self-dismiss unseen. 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 e61460ee490..c3221a7753b 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 @@ -463,8 +463,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[I') sendTerminalInputThroughPane(pane, '\x7f') expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I') - expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I', 'query-reply') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f', 'query-reply') } finally { restoreUserAgent() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts index 4b1f376b773..7e9034c1d61 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts @@ -364,7 +364,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() expect(deps.updateTabTitle).not.toHaveBeenCalled() @@ -478,7 +478,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u', 'query-reply') expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ paneKey, baselineUpdatedAt: 1_000, @@ -652,7 +652,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) 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 99c54dad2eb..1e8ad402de7 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 @@ -275,8 +275,8 @@ describe('connectPanePty', () => { transport.sendInputImmediate.mockClear() sendTerminalInputThroughPane(pane, 'yes') sendTerminalInputThroughPane(pane, '\x1b[A') // arrow-key auto-repeat stays batched - expect(transport.sendInput).toHaveBeenCalledWith('yes') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A') + expect(transport.sendInput).toHaveBeenCalledWith('yes', 'query-reply') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A', 'query-reply') expect(transport.sendInputImmediate).not.toHaveBeenCalled() // terminal-query-reply.test proves real xterm emits this as one framed onData reply; this pins it to the immediate path. @@ -291,7 +291,7 @@ describe('connectPanePty', () => { const printableInputs = [']10;hello', '>|xterm.js(6.1.0-beta.287)', ']|literal-text'] for (const data of printableInputs) { sendTerminalInputThroughPane(pane, data) - expect(transport.sendInput).toHaveBeenCalledWith(data) + expect(transport.sendInput).toHaveBeenCalledWith(data, 'query-reply') } expect(transport.sendInputImmediate).not.toHaveBeenCalled() }) @@ -368,7 +368,7 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + 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}`, 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 559060108ee..a83236b5378 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 @@ -400,7 +400,7 @@ describe('connectPanePty', () => { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, expect.any(Function) ) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') return connection }) disposable.dispose() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts index c61fb188caf..92438968984 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts @@ -600,7 +600,7 @@ describe('connectPanePty', () => { writes.join('').indexOf('NEWER-LIVE-SSH-OUTPUT') ) expect(transport.sendInput).not.toHaveBeenCalledWith('codex resume provider-session\r') - expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r') + expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r', 'query-reply') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, stablePtyId) }) @@ -642,7 +642,7 @@ describe('connectPanePty', () => { setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) transport.claimViewport.mockClear() ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') expect(transport.claimViewport).toHaveBeenCalledTimes(1) ;(onDataHandler as (data: string) => void)('b') // The renderer stays parked until runtime convergence is acknowledged, so a second keystroke can retry a transient failed resize. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts index 2538c9e0cec..59fdc6decd7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts @@ -222,7 +222,10 @@ describe('connectPanePty', () => { expect(createdTransportOptions[0]?.commandDelivery).toBe('provider') expect(transport.sendInput).not.toHaveBeenCalledWith('droid\r') - expect(transport.sendInputAccepted).toHaveBeenCalledWith(`\x1b[200~${prompt}\x1b[201~`) + expect(transport.sendInputAccepted).toHaveBeenCalledWith( + `\x1b[200~${prompt}\x1b[201~`, + 'launch' + ) }) it('waits past 8s for a cold Codex composer and preserves input ordering', async () => { @@ -279,7 +282,8 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(transport.sendInputAccepted).toHaveBeenCalledWith( - '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' + '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~', + 'launch' ) expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ '\x1b[I', @@ -354,7 +358,8 @@ describe('connectPanePty', () => { expect(transport.sendInputAccepted).toHaveBeenCalledTimes(1) expect(transport.sendInputAccepted).toHaveBeenCalledWith( - '\x1b[200~Linked Linear issue: STA-905\x1b[201~' + '\x1b[200~Linked Linear issue: STA-905\x1b[201~', + 'launch' ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts index 969bf836594..f85917e28c6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts @@ -525,7 +525,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(pane.terminal.paste).toHaveBeenCalledWith(command) - expect(transport.sendInput).toHaveBeenCalledWith('\r') + expect(transport.sendInput).toHaveBeenCalledWith('\r', 'launch') expect(transport.sendInput).not.toHaveBeenCalledWith(`${command}\r`) } finally { globalThis.setTimeout = originalSetTimeout diff --git a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts index 4659fccb5c5..4a2d2ee1206 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts @@ -191,7 +191,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('uses the current worktree tab for Codex stale fallback without enumerating all worktrees', async () => { @@ -234,7 +234,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('blocks stale Codex fallback input from the current worktree tab without enumerating all worktrees', async () => { @@ -313,7 +313,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('restores input through the tab fallback when the dismissed pane has no live PTY binding', async () => { @@ -347,7 +347,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps a dismissed split pane typing while a sibling still holds the prompt', async () => { @@ -389,7 +389,7 @@ describe('connectPanePty', () => { ;(onDataHandler as (data: string) => void)('a') expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-dismissed') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps blocking a pane with its own unanswered notice next to a dismissed sibling', async () => { @@ -466,7 +466,7 @@ describe('connectPanePty', () => { ;(onDataHandler as (data: string) => void)('a') expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-plain-shell') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps blocking input on a pane whose restart is requested but not yet run', async () => { @@ -782,7 +782,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() vi.advanceTimersByTime(500) - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') expect(transport.sendInput).not.toHaveBeenCalled() expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) @@ -843,7 +843,7 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\r') // The terminator disarmed it, so the next real command reaches the shell. sendTerminalInputThroughPane(pane, 'ls\r') - expect(transport.sendInput).toHaveBeenCalledWith('ls\r') + expect(transport.sendInput).toHaveBeenCalledWith('ls\r', 'query-reply') _resetTerminalPaneRecoveryForTests() }) @@ -867,7 +867,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('x') + expect(transport.sendInput).toHaveBeenCalledWith('x', 'query-reply') expect(pane.terminal.write).toHaveBeenCalledWith('', expect.any(Function)) expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) binding.dispose() @@ -892,7 +892,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) await flushAsyncTicks() - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function)) binding.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts index 119e61e121e..73105c0248a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts @@ -182,7 +182,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ @@ -436,7 +436,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -474,7 +474,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('pi\r') + expect(transport.sendInput).toHaveBeenCalledWith('pi\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ @@ -525,7 +525,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -564,7 +564,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -650,7 +650,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r') + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r', 'query-reply') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', agentType: 'omp', 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 471e237a200..552f62c54bb 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 @@ -254,7 +254,7 @@ describe('connectPanePty', () => { ) transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -311,7 +311,7 @@ describe('connectPanePty', () => { ) transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -568,7 +568,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -621,8 +621,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -667,8 +667,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x7f') sendTerminalInputThroughPane(pane, 'x') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) mockStoreState.agentStatusByPaneKey[paneKey] = { @@ -679,7 +679,7 @@ describe('connectPanePty', () => { transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) mockStoreState.agentStatusByPaneKey[paneKey] = { @@ -689,7 +689,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[O') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') } finally { restoreUserAgent() } @@ -725,8 +725,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) } finally { restoreUserAgent() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts index c6a25e0d461..417ccd812da 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts @@ -1,5 +1,6 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyConnectionDeps } from '../pty-connection-types' +import type { PtyTransport } from '../pty-transport-types' // Why: connectPanePty is a single closure factory. The bag lets extracted // installers share the same mutable bindings without changing call order. @@ -7,6 +8,8 @@ export type ConnectPanePtySession = { pane: ManagedPane manager: PaneManager deps: PtyConnectionDeps + // Why typed: every PTY write must name its input kind, and the bag's `any` would hide a missing one. + transport: PtyTransport // oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract [key: string]: any } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 6c3a0628f79..f2a91feb98c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -91,7 +91,7 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { return false } - return session.transport.sendInput('\r') + return session.transport.sendInput('\r', 'launch') } session.schedulePendingStartupCommandDelivery = (): void => { const startup = session.pendingStartupCommand @@ -116,7 +116,7 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio const command = startup.command const submitted = session.shouldDeliverStartupViaTerminalPaste ? await runTerminalPasteStartupCommand(command) - : session.transport.sendInput(`${command}\r`) + : session.transport.sendInput(`${command}\r`, 'launch') if (submitted) { session.armStartupDraftReadinessObservation() } else { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts index dff163f81d8..9c1b45051be 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts @@ -151,6 +151,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi session.hiddenDeliverySyncedPtyId = null } if ( + !ptyId || !session.isHiddenDeliveryGateManagedPty(ptyId) || !session.canUseHiddenOutputSnapshot(ptyId) ) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index 9ca1202e9b1..8015e49dd11 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -122,16 +122,22 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { ) // Why: xterm focus reports share this transport queue. Bypassing it can // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. - void sendAgentDraftPasteContent(settings, ptyId, session.startupDraftPrompt, async (data) => { - const accepted = await writeTerminalPastePtyInput(session.transport, data) - if (accepted && !startupDraftInputRecorded) { - // Why: this transport write bypasses xterm's user-input signal; keep - // the composed draft from being discarded by later hibernation. - startupDraftInputRecorded = true - session.recordTerminalInputForHibernation() + void sendAgentDraftPasteContent( + settings, + ptyId, + session.startupDraftPrompt, + 'launch', + async (data) => { + const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch') + if (accepted && !startupDraftInputRecorded) { + // Why: this transport write bypasses xterm's user-input signal; keep + // the composed draft from being discarded by later hibernation. + startupDraftInputRecorded = true + session.recordTerminalInputForHibernation() + } + return accepted } - return accepted - }) + ) .catch(() => false) .finally(() => { startupDraftPasteInFlight = false diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts index 6ff7c244443..aad2384bb27 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -100,6 +100,8 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { session.clearPendingTerminalInputIntent() return } + // Why xterm's provenance: its own focus reports reach onData too, and no person typed them. + const inputKind = wasUserInput ? 'driving' : 'query-reply' const intent = session.pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to @@ -123,7 +125,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { } session.clearPendingTerminalInputIntent() const writePromise = session.transport - .sendInputAccepted(data) + .sendInputAccepted(data, inputKind) .then((accepted): boolean | Promise | null => { if (accepted) { // Why: rejected writes use transport recovery and must not arm a parser probe. @@ -152,7 +154,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { } if (intent) { session.claimViewportForUserActivity() - if (session.transport.sendInput(data)) { + if (session.transport.sendInput(data, inputKind)) { session.markAcceptedTerminalInputSent() session.observeAcceptedShellCommandInput(data) session.observeAcceptedTerminalInput(data, intent) @@ -163,7 +165,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { return } session.claimViewportForUserActivity() - if (session.transport.sendInput(data)) { + if (session.transport.sendInput(data, inputKind)) { session.markAcceptedTerminalInputSent() session.observeAcceptedShellCommandInput(data) session.observeAcceptedTerminalInput(data) 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 81ac85ed752..bd3d7284ac9 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 @@ -63,7 +63,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { if (!session.shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { return } - session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) + session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE, 'query-reply') }) } diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts index 6ac6068f068..b3a2236fe1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts @@ -1,3 +1,5 @@ +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' + export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES = 64 export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS = @@ -8,6 +10,7 @@ export type PendingPtyInputWrite = { id: string text: string replyOnly: boolean + inputKind: TerminalInputKind resolveAccepted: ((accepted: boolean) => void) | undefined tooLarge: boolean | Promise chunks?: Iterator @@ -15,17 +18,17 @@ export type PendingPtyInputWrite = { } export type PtyInputWriteQueue = { - enqueue: (id: string, data: string) => boolean + enqueue: (id: string, data: string, inputKind: TerminalInputKind) => boolean enqueueQueryReply: (id: string, data: string) => boolean - enqueueAccepted: (id: string, data: string) => Promise + enqueueAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise waitForDrain: () => Promise clear: () => void } export type PtyInputWriteQueueDeps = { isWritable: (id: string) => boolean - write: (id: string, data: string) => void - writeAccepted?: (id: string, data: string) => Promise + write: (id: string, data: string, inputKind: TerminalInputKind) => void + writeAccepted?: (id: string, data: string, inputKind: TerminalInputKind) => Promise yieldBetweenWrites?: () => Promise onDrainFailure?: (id: string) => void } diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index 556ba19f8a6..9b3099d0326 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -81,7 +81,7 @@ describe('pty input write queue', () => { // Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports // enqueued while the drain cannot run between events. for (let i = 0; i < 240; i += 1) { - expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true) + expect(queue.enqueue('pty-1', WHEEL_UP_REPORT, 'driving')).toBe(true) } await queue.waitForDrain() @@ -98,7 +98,7 @@ describe('pty input write queue', () => { const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd'] for (const input of inputs) { - queue.enqueue('pty-1', input) + queue.enqueue('pty-1', input, 'driving') } await queue.waitForDrain() @@ -112,10 +112,10 @@ describe('pty input write queue', () => { write: (id, data) => writes.push({ id, data }) }) - queue.enqueue('pty-1', 'a') - queue.enqueue('pty-1', 'b') - queue.enqueue('pty-2', 'c') - queue.enqueue('pty-1', 'd') + queue.enqueue('pty-1', 'a', 'driving') + queue.enqueue('pty-1', 'b', 'driving') + queue.enqueue('pty-2', 'c', 'driving') + queue.enqueue('pty-1', 'd', 'driving') await queue.waitForDrain() expect(writes).toEqual([ @@ -131,7 +131,7 @@ describe('pty input write queue', () => { const piece = 'x'.repeat(1000) for (let i = 0; i < 12; i += 1) { - queue.enqueue('pty-1', piece) + queue.enqueue('pty-1', piece, 'driving') } await queue.waitForDrain() @@ -146,9 +146,9 @@ describe('pty input write queue', () => { const { writes, queue } = createRecordingQueue() const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) - queue.enqueue('pty-1', 'before') - queue.enqueue('pty-1', large) - queue.enqueue('pty-1', 'after') + queue.enqueue('pty-1', 'before', 'driving') + queue.enqueue('pty-1', large, 'driving') + queue.enqueue('pty-1', 'after', 'driving') await queue.waitForDrain() expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`) @@ -161,7 +161,7 @@ describe('pty input write queue', () => { it('rejects input over the terminal input byte limit without writing', async () => { const { writes, queue } = createRecordingQueue() - expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving')).toBe(false) await queue.waitForDrain() expect(writes).toEqual([]) @@ -171,9 +171,9 @@ describe('pty input write queue', () => { let writable = true const { writes, queue } = createRecordingQueue({ writable: () => writable }) - queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'a', 'driving') writable = false - queue.enqueue('pty-1', 'b') + queue.enqueue('pty-1', 'b', 'driving') await queue.waitForDrain() expect(writes).toEqual([{ id: 'pty-1', data: 'a' }]) @@ -210,7 +210,7 @@ describe('pty input write queue', () => { const reply = mode2031SequenceFor('dark') queue.enqueueQueryReply('pty-1', reply) - queue.enqueue('pty-1', 'y') + queue.enqueue('pty-1', 'y', 'driving') await queue.waitForDrain() expect(writes).toEqual([ @@ -231,7 +231,7 @@ describe('pty input write queue', () => { allAccepted = queue.enqueueQueryReply('pty-1', reply) && allAccepted } expect(allAccepted).toBe(true) - expect(queue.enqueue('pty-1', 'k')).toBe(true) + expect(queue.enqueue('pty-1', 'k', 'driving')).toBe(true) await Promise.resolve() expect(pendingYields).toHaveLength(1) @@ -256,7 +256,7 @@ describe('pty input write queue', () => { for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) } - expect(queue.enqueue('pty-1', 'k')).toBe(true) + expect(queue.enqueue('pty-1', 'k', 'driving')).toBe(true) await Promise.resolve() for (let turn = 0; turn < PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES; turn += 1) { @@ -286,10 +286,10 @@ describe('pty input write queue', () => { expect(second.length * 3).toBeGreaterThan(PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS) queue.enqueueQueryReply('pty-1', first) queue.enqueueQueryReply('pty-1', dropped) - queue.enqueue('pty-1', 'x') + queue.enqueue('pty-1', 'x', 'driving') queue.enqueueQueryReply('pty-1', second) queue.enqueueQueryReply('pty-1', third) - queue.enqueue('pty-1', 'k') + queue.enqueue('pty-1', 'k', 'driving') await Promise.resolve() expect(pendingYields).toHaveLength(1) @@ -365,7 +365,7 @@ describe('pty input write queue', () => { const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) for (const input of ordinary) { - expect(queue.enqueue('pty-1', input)).toBe(true) + expect(queue.enqueue('pty-1', input, 'driving')).toBe(true) } for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) @@ -386,7 +386,7 @@ describe('pty input write queue', () => { const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) expect(queue.enqueueQueryReply('pty-1', '\x1b[?10000;1n')).toBe(true) - expect(queue.enqueue('pty-1', ordinary)).toBe(true) + expect(queue.enqueue('pty-1', ordinary, 'driving')).toBe(true) for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) } @@ -413,14 +413,14 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'stale')).toBe(true) - expect(queue.enqueue('pty-1', 'also-stale')).toBe(false) + expect(queue.enqueue('pty-1', 'stale', 'driving')).toBe(true) + expect(queue.enqueue('pty-1', 'also-stale', 'driving')).toBe(false) await expect(queue.waitForDrain()).resolves.toBeUndefined() shouldThrow = false - expect(queue.enqueue('pty-1', 'still-stale')).toBe(false) + expect(queue.enqueue('pty-1', 'still-stale', 'driving')).toBe(false) queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await queue.waitForDrain() expect(writes).toEqual([{ id: 'pty-1', data: 'fresh' }]) @@ -443,9 +443,9 @@ describe('pty input write queue', () => { } }) - expect(queue.enqueue('pty-1', 'first')).toBe(true) - const accepted = queue.enqueueAccepted('pty-1', 'second') - expect(queue.enqueue('pty-1', 'third')).toBe(true) + expect(queue.enqueue('pty-1', 'first', 'driving')).toBe(true) + const accepted = queue.enqueueAccepted('pty-1', 'second', 'driving') + expect(queue.enqueue('pty-1', 'third', 'driving')).toBe(true) expect(queue.enqueueQueryReply('pty-1', 'fourth')).toBe(true) await flushAsyncTicks() @@ -475,13 +475,13 @@ describe('pty input write queue', () => { delivered.push(`accepted:${data}`) if (!reentered) { reentered = true - queue.enqueue('pty-1', 'later') + queue.enqueue('pty-1', 'later', 'driving') } return acceptedWrite.promise } }) - const accepted = queue.enqueueAccepted('pty-1', 'first') + const accepted = queue.enqueueAccepted('pty-1', 'first', 'driving') await flushAsyncTicks() expect(delivered).toEqual(['accepted:first']) @@ -503,12 +503,12 @@ describe('pty input write queue', () => { writeAccepted: (_id, data) => { delivered.push(`accepted:${data}`) queue.clear() - queue.enqueue('pty-1', 'fresh') + queue.enqueue('pty-1', 'fresh', 'driving') return acceptedWrite.promise } }) - const accepted = queue.enqueueAccepted('pty-1', 'stale') + const accepted = queue.enqueueAccepted('pty-1', 'stale', 'driving') await expect(accepted).resolves.toBe(false) await queue.waitForDrain() @@ -533,12 +533,12 @@ describe('pty input write queue', () => { return acceptedWrite.promise } }) - const active = queue.enqueueAccepted('pty-1', 'stale-active') - const pending = queue.enqueueAccepted('pty-1', 'stale-pending') + const active = queue.enqueueAccepted('pty-1', 'stale-active', 'driving') + const pending = queue.enqueueAccepted('pty-1', 'stale-pending', 'driving') await acceptedStarted.promise queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await expect(active).resolves.toBe(false) await expect(pending).resolves.toBe(false) @@ -567,7 +567,9 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true) + expect( + queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2), 'driving') + ).toBe(true) expect(rejectYield).toBeDefined() rejectYield?.(failure) await queue.waitForDrain() @@ -607,11 +609,13 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true) + expect( + queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2), 'driving') + ).toBe(true) expect(rejectYield).toBeDefined() queue.clear() rejectNextYield = false - expect(queue.enqueue('pty-2', 'fresh')).toBe(true) + expect(queue.enqueue('pty-2', 'fresh', 'driving')).toBe(true) rejectYield?.(failure) await queue.waitForDrain() @@ -697,8 +701,8 @@ describe('pty input write queue', () => { }) const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) - queue.enqueue('pty-1', large) - queue.enqueue('pty-1', 'tail') + queue.enqueue('pty-1', large, 'driving') + queue.enqueue('pty-1', 'tail', 'driving') // First chunk is written synchronously, then the drain parks on the yield. expect(writes.length).toBe(1) @@ -716,9 +720,9 @@ describe('pty input write queue', () => { const stale = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) try { - expect(queue.enqueue('pty-1', stale)).toBe(true) + expect(queue.enqueue('pty-1', stale, 'driving')).toBe(true) queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await vi.runAllTimersAsync() await queue.waitForDrain() @@ -755,7 +759,7 @@ describe('pty input write queue', () => { globalThis.gc?.() const heapBefore = process.memoryUsage().heapUsed for (let index = 0; index < 20_000; index += 1) { - await queue.enqueueAccepted('pty-1', 'x') + await queue.enqueueAccepted('pty-1', 'x', 'driving') } await queue.waitForDrain() globalThis.gc?.() diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index 31b37ed6af5..7f646354b27 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -12,6 +12,7 @@ import { type PtyInputWriteQueue, type PtyInputWriteQueueDeps } from './pty-input-write-queue-contract' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { createHeadQueue, peekHeadQueue, @@ -111,7 +112,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput // Why: one cancel per in-flight write rather than `.then()` on a queue-lifetime // promise — those reactions are retained until that promise settles, so a // long-lived pane accumulated one record per acknowledged write (Esc, Ctrl+C). - async function writeAcceptedChunk(id: string, data: string): Promise { + async function writeAcceptedChunk(item: PendingPtyInputWrite, data: string): Promise { let cancel = (): void => undefined const cancelled = new Promise((resolve) => { cancel = () => resolve(false) @@ -122,7 +123,9 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput try { return await Promise.race([ cancelled, - Promise.resolve(deps.writeAccepted?.(id, data) ?? false).catch(() => false) + Promise.resolve(deps.writeAccepted?.(item.id, data, item.inputKind) ?? false).catch( + () => false + ) ]) } finally { pendingAcceptedCancels.delete(cancel) @@ -173,6 +176,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput peek.tooLarge !== false || peek.chunks !== undefined || !isCoalesciblePtyInput(peek) || + peek.inputKind !== next.inputKind || payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS ) { break @@ -180,7 +184,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput payload += peek.text removePending(peek) } - deps.write(next.id, payload) + deps.write(next.id, payload, next.inputKind) if (firstPending()) { await yieldBetweenWrites() } @@ -196,8 +200,8 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } const writeGeneration = generation const accepted = next.resolveAccepted - ? await writeAcceptedChunk(next.id, chunk.value) - : (deps.write(next.id, chunk.value), true) + ? await writeAcceptedChunk(next, chunk.value) + : (deps.write(next.id, chunk.value, next.inputKind), true) if (generation !== writeGeneration || firstPending() !== next) { continue } @@ -249,8 +253,9 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput function enqueueInput( id: string, - data: string, + text: string, queryReply: boolean, + inputKind: TerminalInputKind, resolveAccepted?: PendingPtyInputWrite['resolveAccepted'] ): boolean { try { @@ -260,20 +265,28 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } // Every query reply stays atomic so host-side ordering can classify it (#13892). const replyOnly = queryReply - if (replyOnly && !admitReply(data)) { + if (replyOnly && !admitReply(text)) { return false } - const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(data) + const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(text) if (tooLarge === true) { resolveAccepted?.(false) return false } - const item = { sequence: nextSequence, id, text: data, replyOnly, tooLarge, resolveAccepted } + const item = { + sequence: nextSequence, + id, + text, + replyOnly, + inputKind, + tooLarge, + resolveAccepted + } nextSequence += 1 if (replyOnly) { pendingReplies.items.push(item) pendingReplyCount += 1 - pendingReplyCodeUnits += data.length + pendingReplyCodeUnits += text.length } else { pendingOrdinary.items.push(item) } @@ -286,17 +299,17 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } return { - enqueue(id: string, data: string): boolean { - return enqueueInput(id, data, false) + enqueue(id: string, data: string, inputKind: TerminalInputKind): boolean { + return enqueueInput(id, data, false, inputKind) }, enqueueQueryReply(id: string, data: string): boolean { - return enqueueInput(id, data, true) + return enqueueInput(id, data, true, 'query-reply') }, - enqueueAccepted: (id, data) => + enqueueAccepted: (id, data, inputKind) => new Promise((resolve) => { - enqueueInput(id, data, false, resolve) + enqueueInput(id, data, false, inputKind, resolve) }), async waitForDrain(): Promise { diff --git a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts index d36c3a274ed..2595863cb3d 100644 --- a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts @@ -11,9 +11,9 @@ describe('createPtyPreconnectInputBuffer', () => { const acceptedWrite = createDeferred() const buffer = createPtyPreconnectInputBuffer() const delivered: string[] = [] - const accepted = buffer.enqueueAccepted('first') - expect(buffer.enqueue('second', 'ordinary')).toBe(true) - expect(buffer.enqueue('third', 'immediate')).toBe(true) + const accepted = buffer.enqueueAccepted('first', 'driving') + expect(buffer.enqueue('second', 'ordinary', 'driving')).toBe(true) + expect(buffer.enqueue('third', 'immediate', 'driving')).toBe(true) const writer = { isCurrent: () => true, sendInput: (data: string) => { @@ -52,9 +52,9 @@ describe('createPtyPreconnectInputBuffer', () => { const writeStarted = createDeferred() const buffer = createPtyPreconnectInputBuffer() const sendInput = vi.fn(() => true) - const accepted = buffer.enqueueAccepted('first') - const laterAccepted = buffer.enqueueAccepted('third') - expect(buffer.enqueue('second', 'ordinary')).toBe(true) + const accepted = buffer.enqueueAccepted('first', 'driving') + const laterAccepted = buffer.enqueueAccepted('third', 'driving') + expect(buffer.enqueue('second', 'ordinary', 'driving')).toBe(true) const flushing = buffer.flush({ isCurrent: () => true, sendInput, @@ -72,7 +72,7 @@ describe('createPtyPreconnectInputBuffer', () => { await expect(laterAccepted).resolves.toBe(false) await expect(flushing).resolves.toBeUndefined() expect(sendInput).not.toHaveBeenCalled() - expect(buffer.enqueue('after-clear', 'ordinary')).toBe(false) + expect(buffer.enqueue('after-clear', 'ordinary', 'driving')).toBe(false) if (lateOutcome === 'resolve') { acceptedWrite.resolve(true) @@ -91,7 +91,8 @@ describe('createPtyPreconnectInputBuffer', () => { const codeUnitWriteStarted = createDeferred() const codeUnitBuffer = createPtyPreconnectInputBuffer() const codeUnitAccepted = codeUnitBuffer.enqueueAccepted( - 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS) + 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + 'driving' ) const codeUnitFlush = codeUnitBuffer.flush({ isCurrent: () => true, @@ -104,7 +105,7 @@ describe('createPtyPreconnectInputBuffer', () => { }) await codeUnitWriteStarted.promise - expect(codeUnitBuffer.enqueue('overflow', 'ordinary')).toBe(false) + expect(codeUnitBuffer.enqueue('overflow', 'ordinary', 'driving')).toBe(false) codeUnitBuffer.clear() codeUnitWrite.resolve(true) @@ -114,9 +115,9 @@ describe('createPtyPreconnectInputBuffer', () => { const entryWrite = createDeferred() const entryWriteStarted = createDeferred() const entryBuffer = createPtyPreconnectInputBuffer() - const entryAccepted = entryBuffer.enqueueAccepted('first') + const entryAccepted = entryBuffer.enqueueAccepted('first', 'driving') for (let index = 1; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - expect(entryBuffer.enqueue('', 'ordinary')).toBe(true) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(true) } const entryFlush = entryBuffer.flush({ isCurrent: () => true, @@ -129,7 +130,7 @@ describe('createPtyPreconnectInputBuffer', () => { }) await entryWriteStarted.promise - expect(entryBuffer.enqueue('', 'ordinary')).toBe(false) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(false) entryBuffer.clear() entryWrite.resolve(true) @@ -141,12 +142,13 @@ describe('createPtyPreconnectInputBuffer', () => { const entryBuffer = createPtyPreconnectInputBuffer( Array.from({ length: PTY_PRECONNECT_INPUT_MAX_ENTRIES + 1 }, () => ({ data: '', - kind: 'ordinary' as const + kind: 'ordinary' as const, + inputKind: 'driving' as const })) ) let entryWrites = 0 - expect(entryBuffer.enqueue('', 'ordinary')).toBe(false) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(false) await entryBuffer.flush({ isCurrent: () => true, sendInput: () => { @@ -158,12 +160,16 @@ describe('createPtyPreconnectInputBuffer', () => { expect(entryWrites).toBe(PTY_PRECONNECT_INPUT_MAX_ENTRIES) const codeUnitBuffer = createPtyPreconnectInputBuffer([ - { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' }, - { data: 'overflow', kind: 'ordinary' } + { + data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + kind: 'ordinary', + inputKind: 'driving' + }, + { data: 'overflow', kind: 'ordinary', inputKind: 'driving' } ]) const codeUnitWrites: number[] = [] - expect(codeUnitBuffer.enqueue('new', 'ordinary')).toBe(false) + expect(codeUnitBuffer.enqueue('new', 'ordinary', 'driving')).toBe(false) await codeUnitBuffer.flush({ isCurrent: () => true, sendInput: (data) => { @@ -179,7 +185,7 @@ describe('createPtyPreconnectInputBuffer', () => { // Regression: an abandoned deferred spawn never reaches connect(), so nothing // drained the buffer and a paste awaiting sendInputAccepted hung forever. const buffer = createPtyPreconnectInputBuffer() - const pastePending = buffer.enqueueAccepted('pasted text') + const pastePending = buffer.enqueueAccepted('pasted text', 'driving') let settled = false void pastePending.then(() => { settled = true diff --git a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts index 3dce1962bb9..cb8dec2d6d0 100644 --- a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts @@ -1,4 +1,5 @@ import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' export const PTY_PRECONNECT_INPUT_MAX_ENTRIES = 1024 // Why: a retention budget, not the 16MB single-write ceiling. The deferral lasts @@ -12,6 +13,7 @@ export type PtyPreconnectInputKind = 'ordinary' | 'immediate' | 'accepted' export type PtyPreconnectInputEntry = { data: string kind: PtyPreconnectInputKind + inputKind: TerminalInputKind } type BufferedInput = PtyPreconnectInputEntry & { @@ -20,9 +22,9 @@ type BufferedInput = PtyPreconnectInputEntry & { type PreconnectInputWriter = { isCurrent: () => boolean - sendInput: (data: string) => boolean + sendInput: (data: string, inputKind: TerminalInputKind) => boolean sendInputImmediate: (data: string) => boolean - sendInputAccepted?: (data: string) => Promise + sendInputAccepted?: (data: string, inputKind: TerminalInputKind) => Promise } export type PtyPreconnectInputBuffer = { @@ -30,10 +32,12 @@ export type PtyPreconnectInputBuffer = { enqueue: ( data: string, kind: 'ordinary' | 'immediate', + inputKind: TerminalInputKind, onRetained?: (entry: PtyPreconnectInputEntry) => void ) => boolean enqueueAccepted: ( data: string, + inputKind: TerminalInputKind, onRetained?: (entry: PtyPreconnectInputEntry) => void ) => Promise flush: (writer: PreconnectInputWriter) => Promise @@ -70,14 +74,15 @@ export function createPtyPreconnectInputBuffer( const createInput = ( data: string, kind: PtyPreconnectInputKind, + inputKind: TerminalInputKind, resolve?: BufferedInput['resolve'] - ): BufferedInput => ({ data, kind, ...(resolve ? { resolve } : {}) }) + ): BufferedInput => ({ data, kind, inputKind, ...(resolve ? { resolve } : {}) }) const notifyRetained = ( onRetained: ((entry: PtyPreconnectInputEntry) => void) | undefined, input: BufferedInput ): void => { try { - onRetained?.({ data: input.data, kind: input.kind }) + onRetained?.({ data: input.data, kind: input.kind, inputKind: input.inputKind }) } catch { // Handoff capture is advisory; a callback failure must not reject input admission. } @@ -86,7 +91,7 @@ export function createPtyPreconnectInputBuffer( // Seeded entries came from a predecessor transport and must not be reported // back to that predecessor's handoff owner as newly typed input. for (const entry of initialEntries) { - retain(createInput(entry.data, entry.kind)) + retain(createInput(entry.data, entry.kind, entry.inputKind)) } const clear = (): void => { const dropped = pending @@ -124,8 +129,8 @@ export function createPtyPreconnectInputBuffer( accepted = await Promise.race([ Promise.resolve( writer.sendInputAccepted - ? writer.sendInputAccepted(input.data) - : writer.sendInput(input.data) + ? writer.sendInputAccepted(input.data, input.inputKind) + : writer.sendInput(input.data, input.inputKind) ), flushStopped.then(() => null) ]) @@ -152,7 +157,7 @@ export function createPtyPreconnectInputBuffer( const accepted = input.kind === 'immediate' ? writer.sendInputImmediate(input.data) - : writer.sendInput(input.data) + : writer.sendInput(input.data, input.inputKind) if (!accepted) { clear() return @@ -186,17 +191,17 @@ export function createPtyPreconnectInputBuffer( return { isBuffering: () => buffering, - enqueue(data, kind, onRetained) { - const input = createInput(data, kind) + enqueue(data, kind, inputKind, onRetained) { + const input = createInput(data, kind, inputKind) const retained = retain(input) if (retained) { notifyRetained(onRetained, input) } return retained }, - enqueueAccepted(data, onRetained) { + enqueueAccepted(data, inputKind, onRetained) { return new Promise((resolve) => { - const input = createInput(data, 'accepted', resolve) + const input = createInput(data, 'accepted', inputKind, resolve) if (!retain(input)) { resolve(false) return diff --git a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts index 1060d617dd2..df2c11e08d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts @@ -65,8 +65,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: { onWriteUnavailable: recovery } }) - expect(transport.sendInput('input')).toBe(true) - expect(transport.sendInput('later-input')).toBe(false) + expect(transport.sendInput('input', 'driving')).toBe(true) + expect(transport.sendInput('later-input', 'driving')).toBe(false) expect(recovery).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure) @@ -80,8 +80,8 @@ describe('createIpcPtyTransport', () => { const localTransport = createIpcPtyTransport({}) await localTransport.connect({ url: '', callbacks: {} }) - await expect(localTransport.sendInputAccepted?.('\x03')).resolves.toBe(true) - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03') + await expect(localTransport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03', 'driving') const sshTransport = createIpcPtyTransport({ connectionId: 'ssh-1' }) await sshTransport.connect({ url: '', callbacks: {} }) @@ -103,8 +103,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first-')).toBe(true) - const accepted = transport.sendInputAccepted?.('second-') + expect(transport.sendInput('first-', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second-', 'driving') expect(transport.sendInputImmediate('third')).toBe(true) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -133,21 +133,21 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true, preconnectInput: [ - { data: 'before-remount-', kind: 'ordinary' }, - { data: '\x1b[0n', kind: 'immediate' }, - { data: '\x03', kind: 'accepted' } + { data: 'before-remount-', kind: 'ordinary', inputKind: 'driving' }, + { data: '\x1b[0n', kind: 'immediate', inputKind: 'query-reply' }, + { data: '\x03', kind: 'accepted', inputKind: 'driving' } ], onPreconnectInput }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('new-ordinary-')).toBe(true) + expect(transport.sendInput('new-ordinary-', 'driving')).toBe(true) expect(transport.sendInputImmediate('new-immediate-')).toBe(true) - const accepted = transport.sendInputAccepted?.('new-accepted') + const accepted = transport.sendInputAccepted?.('new-accepted', 'driving') expect(onPreconnectInput.mock.calls).toEqual([ - [{ data: 'new-ordinary-', kind: 'ordinary' }], - [{ data: 'new-immediate-', kind: 'immediate' }], - [{ data: 'new-accepted', kind: 'accepted' }] + [{ data: 'new-ordinary-', kind: 'ordinary', inputKind: 'driving' }], + [{ data: 'new-immediate-', kind: 'immediate', inputKind: 'query-reply' }], + [{ data: 'new-accepted', kind: 'accepted', inputKind: 'driving' }] ]) spawn.resolve({ id: 'pty-1' }) @@ -180,8 +180,8 @@ describe('createIpcPtyTransport', () => { onPreconnectInput: (input) => captured.push(input) }) - const predecessorAccepted = predecessor.sendInputAccepted?.('\x03') - expect(captured).toEqual([{ data: '\x03', kind: 'accepted' }]) + const predecessorAccepted = predecessor.sendInputAccepted?.('\x03', 'driving') + expect(captured).toEqual([{ data: '\x03', kind: 'accepted', inputKind: 'driving' }]) const successor = createIpcPtyTransport({ preconnectInput: captured }) const connecting = successor.connect({ url: '', callbacks: {} }) @@ -205,17 +205,17 @@ describe('createIpcPtyTransport', () => { onPreconnectInput }) - expect(transport.sendInput('ordinary')).toBe(true) + expect(transport.sendInput('ordinary', 'driving')).toBe(true) expect(transport.sendInputImmediate('immediate')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + const accepted = transport.sendInputAccepted?.('accepted', 'driving') expect(onPreconnectInput).toHaveBeenCalledTimes(3) await transport.connect({ url: '', callbacks: {} }) await expect(accepted).resolves.toBe(true) - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'ordinary') - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'immediate') - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'accepted') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'ordinary', 'driving') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'immediate', 'query-reply') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'accepted', 'driving') }) it('keeps live acknowledged input ahead of later ordinary and immediate writes', async () => { @@ -235,9 +235,9 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') - expect(transport.sendInput('third')).toBe(true) + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') + expect(transport.sendInput('third', 'driving')).toBe(true) expect(transport.sendInputImmediate('fourth')).toBe(true) await flushAsyncTicks() @@ -275,11 +275,11 @@ describe('createIpcPtyTransport', () => { const slowInput = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(slowInput)).toBe(true) + expect(transport.sendInput(slowInput, 'driving')).toBe(true) await connecting - const accepted = transport.sendInputAccepted?.('accepted') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) expect(transport.sendInputImmediate('reply')).toBe(true) expect(delivered).toEqual([]) @@ -318,14 +318,14 @@ describe('createIpcPtyTransport', () => { const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - expect(transport.sendInput('ordinary')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + expect(transport.sendInput('ordinary', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') await transport.destroy?.() await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-destroy')).toBe(false) + expect(transport.sendInput('after-destroy', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-destroy')).toBe(false) - await expect(transport.sendInputAccepted?.('after-destroy')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-destroy', 'driving')).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) @@ -341,11 +341,11 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.('first') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('first', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) spawn.resolve({ id: 'pty-1' }) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first', 'driving') if (teardown === 'disconnect') { transport.disconnect() @@ -358,7 +358,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) await expect(connecting).resolves.toBe('pty-1') expect(window.api.pty.write).not.toHaveBeenCalled() - expect(transport.sendInput('after-teardown')).toBe(false) + expect(transport.sendInput('after-teardown', 'driving')).toBe(false) acceptedWrite.resolve(true) await flushAsyncTicks() @@ -377,10 +377,10 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.('first') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('first', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first', 'driving') if (teardown === 'disconnect') { transport.disconnect() @@ -392,7 +392,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() - expect(transport.sendInput('after-teardown')).toBe(false) + expect(transport.sendInput('after-teardown', 'driving')).toBe(false) acceptedWrite.resolve(true) await flushAsyncTicks() @@ -412,7 +412,7 @@ describe('createIpcPtyTransport', () => { const onConnect = vi.fn() const connecting = transport.connect({ url: '', callbacks: { onConnect } }) - const accepted = transport.sendInputAccepted?.('pending') + const accepted = transport.sendInputAccepted?.('pending', 'driving') if (teardown === 'disconnect') { transport.disconnect() } else { @@ -551,18 +551,18 @@ describe('createIpcPtyTransport', () => { const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(`${chunk}${chunk}`)).toBe(true) - expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(transport.sendInput(`${chunk}${chunk}`, 'driving')).toBe(true) + expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') onExit?.({ id: 'pty-1', code: 0 }) transport.attach({ existingPtyId: 'pty-1', callbacks: {} }) - expect(transport.sendInput('fresh')).toBe(true) + expect(transport.sendInput('fresh', 'driving')).toBe(true) await vi.runAllTimersAsync() expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ - ['pty-1', chunk], - ['pty-1', 'fresh'] + ['pty-1', chunk, 'driving'], + ['pty-1', 'fresh', 'driving'] ]) } finally { vi.useRealTimers() @@ -577,22 +577,22 @@ describe('createIpcPtyTransport', () => { const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(`${chunk}stale-tail`) + const accepted = transport.sendInputAccepted?.(`${chunk}stale-tail`, 'driving') await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') onExit?.({ id: 'pty-1', code: 0 }) transport.attach({ existingPtyId: 'pty-1', callbacks: {} }) - expect(transport.sendInput('fresh')).toBe(true) + expect(transport.sendInput('fresh', 'driving')).toBe(true) await expect(accepted).resolves.toBe(false) await flushAsyncTicks() - expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', 'fresh') + expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', 'fresh', 'driving') acceptedWrite.resolve(true) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') await expect(accepted).resolves.toBe(false) }) @@ -601,13 +601,13 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) expect(transport.sendInputImmediate('immediate')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + const accepted = transport.sendInputAccepted?.('accepted', 'driving') transport.disconnect() await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-disconnect')).toBe(false) + expect(transport.sendInput('after-disconnect', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-disconnect')).toBe(false) - await expect(transport.sendInputAccepted?.('after-disconnect')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-disconnect', 'driving')).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) @@ -619,13 +619,13 @@ describe('createIpcPtyTransport', () => { bufferPreHandlerPtyExit(ptyId, 0) const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - const accepted = transport.sendInputAccepted?.('input') + const accepted = transport.sendInputAccepted?.('input', 'driving') transport.attach({ existingPtyId: ptyId, callbacks: {} }) await expect(accepted).resolves.toBe(false) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('after-exit')).toBe(false) + expect(transport.sendInput('after-exit', 'driving')).toBe(false) clearPreHandlerPtyState(ptyId) }) @@ -635,7 +635,7 @@ describe('createIpcPtyTransport', () => { }) const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - const accepted = transport.sendInputAccepted?.('input') + const accepted = transport.sendInputAccepted?.('input', 'driving') expect(() => transport.attach({ existingPtyId: 'pty-attach-failure', callbacks: {} })).toThrow( 'dispatcher attach failed' @@ -643,7 +643,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('after-failure')).toBe(false) + expect(transport.sendInput('after-failure', 'driving')).toBe(false) }) it('settles and drops preconnect input when the split spawn fails', async () => { @@ -652,14 +652,14 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const onError = vi.fn() - expect(transport.sendInput('ordinary')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + expect(transport.sendInput('ordinary', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') await transport.connect({ url: '', callbacks: { onError } }) await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-failure')).toBe(false) + expect(transport.sendInput('after-failure', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-failure')).toBe(false) - await expect(transport.sendInputAccepted?.('after-failure')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-failure', 'driving')).resolves.toBe(false) expect(onError).toHaveBeenCalledWith('spawn failed') expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -673,16 +673,16 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') - expect(transport.sendInput('third')).toBe(true) + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') + expect(transport.sendInput('third', 'driving')).toBe(true) spawn.resolve({ id: 'pty-1' }) await connecting await expect(accepted).resolves.toBe(false) expect(window.api.pty.write).toHaveBeenCalledOnce() - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'first', 'driving') }) it('drops acknowledged preconnect input when an earlier ordinary write fails', async () => { @@ -695,8 +695,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) try { - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') await transport.connect({ url: '', callbacks: {} }) await expect(accepted).resolves.toBe(false) @@ -712,12 +712,14 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) } - expect(transport.sendInput('overflow')).toBe(false) + expect(transport.sendInput('overflow', 'driving')).toBe(false) const oversized = createIpcPtyTransport({ bufferInputUntilConnect: true }) - expect(oversized.sendInput('x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS + 1))).toBe(false) + expect( + oversized.sendInput('x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS + 1), 'driving') + ).toBe(false) await transport.destroy?.() await oversized.destroy?.() }) @@ -731,14 +733,14 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(`${chunk}tail`)).toBe(true) + expect(transport.sendInput(`${chunk}tail`, 'driving')).toBe(true) expect(window.api.pty.write).toHaveBeenCalledTimes(1) - expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk) + expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk, 'driving') await vi.runOnlyPendingTimersAsync() expect(window.api.pty.write).toHaveBeenCalledTimes(2) - expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail') + expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail', 'driving') } finally { vi.useRealTimers() } @@ -755,7 +757,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) expect(transport.sendInputImmediate(first)).toBe(true) - expect(transport.sendInput(ordinary)).toBe(true) + expect(transport.sendInput(ordinary, 'driving')).toBe(true) for (const reply of replies) { expect(transport.sendInputImmediate(reply)).toBe(true) } @@ -763,11 +765,11 @@ describe('createIpcPtyTransport', () => { await vi.runAllTimersAsync() expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ - ['pty-1', first], - ['pty-1', ordinary], + ['pty-1', first, 'query-reply'], + ['pty-1', ordinary, 'driving'], ...replies .slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES) - .map((reply) => ['pty-1', reply]) + .map((reply) => ['pty-1', reply, 'query-reply']) ]) } finally { vi.useRealTimers() @@ -783,7 +785,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(text)).toBe(true) + expect(transport.sendInput(text, 'driving')).toBe(true) expect(window.api.pty.write).not.toHaveBeenCalled() await vi.runAllTimersAsync() @@ -805,7 +807,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + expect(transport.sendInput('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving')).toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() }) @@ -818,16 +820,16 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(`${chunk}tail`) + const accepted = transport.sendInputAccepted?.(`${chunk}tail`, 'driving') await Promise.resolve() expect(window.api.pty.writeAccepted).toHaveBeenCalledTimes(1) - expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(1, 'pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(1, 'pty-1', chunk, 'driving') await vi.runOnlyPendingTimersAsync() await expect(accepted).resolves.toBe(true) expect(window.api.pty.writeAccepted).toHaveBeenCalledTimes(2) - expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(2, 'pty-1', 'tail') + expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(2, 'pty-1', 'tail', 'driving') } finally { vi.useRealTimers() } @@ -842,7 +844,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(text) + const accepted = transport.sendInputAccepted?.(text, 'driving') await Promise.resolve() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -867,7 +869,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) await expect( - transport.sendInputAccepted?.('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)) + transport.sendInputAccepted?.('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving') ).resolves.toBe(false) expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts index 7eeba23ec8d..b10fd3fe1ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts @@ -92,7 +92,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: { onError } }) expect(onError).toHaveBeenCalled() expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('echo hello\r')).toBe(false) + expect(transport.sendInput('echo hello\r', 'driving')).toBe(false) await flushPtySideEffects() expect(write).not.toHaveBeenCalled() @@ -102,7 +102,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: { onError: onErrorKilled } }) expect(onErrorKilled).not.toHaveBeenCalled() expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('echo hello\r')).toBe(false) + expect(transport.sendInput('echo hello\r', 'driving')).toBe(false) await flushPtySideEffects() expect(write).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 3b835b9d0a2..7fd0de6952e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -10,6 +10,7 @@ import type { import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { EventProps } from '../../../../shared/telemetry-events' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' import type { TuiAgent } from '../../../../shared/tui-agent' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -182,7 +183,7 @@ export type PtyTransport = { callbacks: PtyCallbacks }) => void disconnect: () => void - sendInput: (data: string) => boolean + sendInput: (data: string, inputKind: TerminalInputKind) => boolean // Why: latency-critical terminal query replies (CPR/DSR/DA/OSC color/pixel // size) must skip input coalescing — a querying program reads them in raw // mode with a short timeout, so a debounced reply lands on the shell prompt @@ -190,7 +191,7 @@ export type PtyTransport = { // this is `sendInput` for them; the remote transport flushes pending input // (preserving order) and sends the reply immediately. sendInputImmediate: (data: string) => boolean - sendInputAccepted?: (data: string) => Promise + sendInputAccepted?: (data: string, inputKind: TerminalInputKind) => Promise /** Settles retained pre-connect input when a deferred spawn is abandoned before connect. */ abandonPreconnectInput?: () => void claimViewport?: (cols: number, rows: number) => boolean diff --git a/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts new file mode 100644 index 00000000000..34a71b81b8c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDeferred, flushAsyncTicks } from './pty-connection-test-async' +import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' + +describe('createIpcPtyTransport: input kind', () => { + const originalWindow: typeof window | undefined = globalThis.window + + beforeEach(() => { + vi.resetModules() + installIpcPtyWindow(originalWindow, {}) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + it('sends every write with the kind its writer gave it', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + + transport.sendInput('typed', 'driving') + await flushAsyncTicks() + transport.sendInput('\x1b[I', 'query-reply') + await flushAsyncTicks() + transport.sendInputImmediate('\x1b[1;1R') + await flushAsyncTicks() + await transport.sendInputAccepted?.('echo startup\r', 'launch') + + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', 'typed', 'driving'], + ['pty-1', '\x1b[I', 'query-reply'], + ['pty-1', '\x1b[1;1R', 'query-reply'] + ]) + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'echo startup\r', 'launch') + transport.disconnect() + }) + + it('never merges bytes of different kinds', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + + // The first write drains at once; what queues behind it would otherwise coalesce. + transport.sendInput('\x1b[O', 'query-reply') + transport.sendInput('\x1b[I', 'query-reply') + transport.sendInput('a', 'driving') + transport.sendInput('b', 'driving') + + await vi.waitFor(() => + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', '\x1b[O', 'query-reply'], + ['pty-1', '\x1b[I', 'query-reply'], + ['pty-1', 'ab', 'driving'] + ]) + ) + transport.disconnect() + }) + + it('keeps the kind of input written before the spawn connects and across a remount', async () => { + const spawn = createDeferred>>() + vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise) + const { createIpcPtyTransport } = await import('./pty-transport') + const captured: PtyPreconnectInputEntry[] = [] + const predecessor = createIpcPtyTransport({ + bufferInputUntilConnect: true, + onPreconnectInput: (entry) => captured.push(entry) + }) + void predecessor.connect({ url: '', callbacks: {} }) + predecessor.sendInput('typed-early', 'driving') + predecessor.sendInput('startup', 'launch') + predecessor.destroy?.() + + const successor = createIpcPtyTransport({ preconnectInput: captured }) + spawn.resolve({ id: 'pty-1' }) + await successor.connect({ url: '', callbacks: {} }) + await flushAsyncTicks() + + expect(captured).toEqual([ + { data: 'typed-early', kind: 'ordinary', inputKind: 'driving' }, + { data: 'startup', kind: 'ordinary', inputKind: 'launch' } + ]) + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', 'typed-early', 'driving'], + ['pty-1', 'startup', 'launch'] + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index a8adfccb7ac..fd26a7324b5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -5,6 +5,7 @@ import { createPtyInputWriteQueue } from './pty-input-write-queue' import { createPtyOutputProcessor } from './pty-output-processor' import { createPtyPreconnectInputBuffer } from './pty-preconnect-input-buffer' import type { IpcPtyTransportOptions, PtyTransport } from './pty-transport-types' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' export { ensurePtyDispatcher, @@ -57,8 +58,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const inputWriteQueue = createPtyInputWriteQueue({ isWritable: (id) => !destroyed && connected && ptyId === id, - write: (id, data) => window.api.pty.write(id, data), - writeAccepted: (id, data) => window.api.pty.writeAccepted(id, data), + write: (id, data, inputKind) => window.api.pty.write(id, data, inputKind), + writeAccepted: (id, data, inputKind) => window.api.pty.writeAccepted(id, data, inputKind), onDrainFailure: (id) => { if (ptyId === id) { storedCallbacks.onWriteUnavailable?.() @@ -115,12 +116,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } await preconnectInputBuffer.flush({ isCurrent: () => !destroyed && connected && ptyId === id, - sendInput: (data) => inputWriteQueue.enqueue(id, data), + sendInput: (data, inputKind) => inputWriteQueue.enqueue(id, data, inputKind), sendInputImmediate: (data) => inputWriteQueue.enqueueQueryReply(id, data), ...(connectionId ? {} : { - sendInputAccepted: (data: string) => inputWriteQueue.enqueueAccepted(id, data) + sendInputAccepted: (data: string, inputKind: TerminalInputKind) => + inputWriteQueue.enqueueAccepted(id, data, inputKind) }) }) } @@ -213,16 +215,23 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks = {} }, - sendInput(data) { + sendInput(data, inputKind) { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueue(data, 'ordinary', opts.onPreconnectInput) + return preconnectInputBuffer.enqueue(data, 'ordinary', inputKind, opts.onPreconnectInput) } - return !destroyed && connected && ptyId ? inputWriteQueue.enqueue(ptyId, data) : false + return !destroyed && connected && ptyId + ? inputWriteQueue.enqueue(ptyId, data, inputKind) + : false }, sendInputImmediate(data) { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueue(data, 'immediate', opts.onPreconnectInput) + return preconnectInputBuffer.enqueue( + data, + 'immediate', + 'query-reply', + opts.onPreconnectInput + ) } return !destroyed && connected && ptyId ? inputWriteQueue.enqueueQueryReply(ptyId, data) @@ -232,14 +241,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...(connectionId ? {} : { - async sendInputAccepted(data: string): Promise { + async sendInputAccepted(data: string, inputKind: TerminalInputKind): Promise { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueueAccepted(data, opts.onPreconnectInput) + return preconnectInputBuffer.enqueueAccepted(data, inputKind, opts.onPreconnectInput) } if (destroyed || !connected || !ptyId) { return false } - return inputWriteQueue.enqueueAccepted(ptyId, data) + return inputWriteQueue.enqueueAccepted(ptyId, data, inputKind) } }), diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts index 1df9a355a05..b07afbb20d3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts @@ -48,18 +48,18 @@ describe('remote runtime error surface dismissal', () => { const onError = vi.fn() const transport = await attachTransportWithFatalSends(onError) - expect(await transport.sendInputAccepted?.('a')).toBe(false) - expect(await transport.sendInputAccepted?.('b')).toBe(false) + expect(await transport.sendInputAccepted?.('a', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('b', 'driving')).toBe(false) // Contract preserved: one continuous outage still surfaces the repeated error once. expect(onError.mock.calls).toEqual([[FATAL_ERROR]]) transport.notifyErrorSurfaceDismissed?.() - expect(await transport.sendInputAccepted?.('c')).toBe(false) + expect(await transport.sendInputAccepted?.('c', 'driving')).toBe(false) expect(onError.mock.calls).toEqual([[FATAL_ERROR], [FATAL_ERROR]]) // The memory re-arms: repeats after the re-surfaced error are suppressed again until the next dismissal. - expect(await transport.sendInputAccepted?.('d')).toBe(false) + expect(await transport.sendInputAccepted?.('d', 'driving')).toBe(false) expect(onError).toHaveBeenCalledTimes(2) transport.destroy?.() }) @@ -68,9 +68,9 @@ describe('remote runtime error surface dismissal', () => { const onError = vi.fn() const transport = await attachTransportWithFatalSends(onError) - expect(await transport.sendInputAccepted?.('a')).toBe(false) - expect(await transport.sendInputAccepted?.('b')).toBe(false) - expect(await transport.sendInputAccepted?.('c')).toBe(false) + expect(await transport.sendInputAccepted?.('a', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('b', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('c', 'driving')).toBe(false) expect(onError).toHaveBeenCalledTimes(1) transport.destroy?.() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts index ec46891e9d5..6d8616a1baa 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts @@ -71,7 +71,7 @@ describe('remote transport sendInputImmediate (#7329)', () => { await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) // Typed input: debounced — nothing sent before the 8ms flush. - expect(transport.sendInput('yes')).toBe(true) + expect(transport.sendInput('yes', 'driving')).toBe(true) expect(terminalSendCalls()).toEqual([]) // Query reply (OSC 11 background color): sent immediately, no timer. @@ -139,10 +139,10 @@ describe('remote transport sendInputImmediate (#7329)', () => { // A paste above CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS forces the async // validation path, so its bytes are captured in validationTail, not pending. const paste = 'p'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - expect(transport.sendInput(paste)).toBe(true) + expect(transport.sendInput(paste, 'driving')).toBe(true) // Immediately (validation still pending) a TUI emits a CPR reply. expect(transport.sendInputImmediate('\x1b[3;1R')).toBe(true) - expect(transport.sendInput('z')).toBe(true) + expect(transport.sendInput('z', 'driving')).toBe(true) // Wait until the reply is actually flushed instead of sleeping a fixed // interval: the reply legitimately trails the paste's async validation plus diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 6185198d59e..f5861cbc810 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -523,7 +523,7 @@ describe('createRemoteRuntimePtyTransport', () => { } send.mockClear() - expect(transport.sendInput('ls\r')).toBe(true) + expect(transport.sendInput('ls\r', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() const inputFrame = decodeTerminalStreamFrame(send.mock.calls[0][0]) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts index 398efce980a..58034193be8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts @@ -249,7 +249,9 @@ describe('createRemoteRuntimePtyTransport', () => { expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) const listCallsAfterBound = hostListCalls - await expect(transport.sendInputAccepted?.('retry while reconnecting')).resolves.toBe(false) + await expect( + transport.sendInputAccepted?.('retry while reconnecting', 'driving') + ).resolves.toBe(false) await vi.advanceTimersByTimeAsync(16_000) // The accepted-snapshot listener already owns recovery. User input must diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts index 999716aa43d..b77f0b26e82 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts @@ -143,9 +143,9 @@ describe('createRemoteRuntimePtyTransport', () => { }) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) expect(transport.claimViewport?.(101, 33)).toBe(true) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) expect(transport.sendInputImmediate('\x1b[?1;2c')).toBe(true) - expect(transport.sendInput('z')).toBe(true) + expect(transport.sendInput('z', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) expect(runtimeCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) @@ -193,8 +193,8 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - expect(transport.sendInput('b')).toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + expect(transport.sendInput('b', 'driving')).toBe(true) expect(runtimeCall).not.toHaveBeenCalled() await vi.runOnlyPendingTimersAsync() @@ -225,8 +225,8 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - expect(transport.sendInput('b')).toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + expect(transport.sendInput('b', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -256,14 +256,14 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) - expect(transport.sendInput(chunk)).toBe(true) + expect(transport.sendInput(chunk, 'driving')).toBe(true) expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) let frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) expect(frame?.opcode).toBe(TerminalStreamOpcode.Input) expect(frame?.streamId).toBe(streamId) expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe(chunk) - expect(transport.sendInput('tail')).toBe(true) + expect(transport.sendInput('tail', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -299,7 +299,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'terminal.send', @@ -339,8 +339,8 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).toHaveBeenCalledWith({ @@ -383,7 +383,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(false) }) it('splits large acknowledged remote input before terminal.send RPCs', async () => { @@ -415,7 +415,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) const chunk = '😀'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES / 4) - await expect(transport.sendInputAccepted?.(`${chunk}tail`)).resolves.toBe(true) + await expect(transport.sendInputAccepted?.(`${chunk}tail`, 'driving')).resolves.toBe(true) const sendCalls = runtimeCall.mock.calls.filter((call) => call[0].method === 'terminal.send') expect(sendCalls).toHaveLength(2) @@ -455,7 +455,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) runtimeCall.mockClear() - const accepted = transport.sendInputAccepted?.(text) + const accepted = transport.sendInputAccepted?.(text, 'driving') await Promise.resolve() expect(runtimeCall).not.toHaveBeenCalled() @@ -502,9 +502,9 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.(`${firstChunk}${rejectedChunk}after`)).resolves.toBe( - false - ) + await expect( + transport.sendInputAccepted?.(`${firstChunk}${rejectedChunk}after`, 'driving') + ).resolves.toBe(false) const sendTexts = runtimeCall.mock.calls .filter((call) => call[0].method === 'terminal.send') @@ -530,7 +530,10 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() await expect( - transport.sendInputAccepted?.('😀'.repeat(Math.floor(TERMINAL_INPUT_MAX_BYTES / 4) + 1)) + transport.sendInputAccepted?.( + '😀'.repeat(Math.floor(TERMINAL_INPUT_MAX_BYTES / 4) + 1), + 'driving' + ) ).resolves.toBe(false) expect(runtimeCall).not.toHaveBeenCalled() }) @@ -550,7 +553,7 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('echo one\necho two\r\n')).toBe(true) + expect(transport.sendInput('echo one\necho two\r\n', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -614,7 +617,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() expect(transport.claimViewport?.(101, 33)).toBe(true) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() const frames = subscriptionSendBinary.mock.calls.map((call) => diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts index 6230bd00d8f..30c03745abe 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts @@ -60,7 +60,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(onWriteUnavailable).toHaveBeenCalledOnce()) @@ -103,7 +103,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable, onError } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(onWriteUnavailable).toHaveBeenCalledOnce()) @@ -148,7 +148,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable: oldWriteUnavailable } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('old')).toBe(true) + expect(transport.sendInput('old', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( @@ -205,7 +205,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: {} }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-old')) - expect(transport.sendInput('queued-for-old')).toBe(true) + expect(transport.sendInput('queued-for-old', 'driving')).toBe(true) resolvedPaneHandle = 'terminal-new' transport.attach({ @@ -322,7 +322,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() // Why: replacement input stays disabled until terminal.resolvePane proves the new handle belongs to this pane. - expect(transport.sendInput('x')).toBe(false) + expect(transport.sendInput('x', 'driving')).toBe(false) vi.advanceTimersByTime(8) const inputFrames = subscriptionSendBinary.mock.calls diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts index 53c1921fe74..cec52958852 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts @@ -226,7 +226,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-rebind') + const pendingSend = sendInputAccepted('sent-before-rebind', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' })) ) @@ -293,7 +293,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-close') + const pendingSend = sendInputAccepted('sent-before-close', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' })) ) @@ -390,7 +390,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-stream-end') + const pendingSend = sendInputAccepted('sent-before-stream-end', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts index 5a61a5587e6..ce689695bc0 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts @@ -76,7 +76,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-stream-end') + const pendingSend = sendInputAccepted('sent-before-stream-end', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) @@ -361,7 +361,10 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const sends = Promise.all([sendInputAccepted('first'), sendInputAccepted('second')]) + const sends = Promise.all([ + sendInputAccepted('first', 'driving'), + sendInputAccepted('second', 'driving') + ]) await vi.waitFor(() => expect(hostListCalls).toBe(1)) await expect(sends).resolves.toEqual([false, false]) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts index e0b85414e3f..c3d61a8b3d3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts @@ -466,7 +466,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput(`detached-${cycle}`)).toBe(false) + expect(transport.sendInput(`detached-${cycle}`, 'driving')).toBe(false) expect(unsubscribeByEpoch[cycle]).toHaveBeenCalledTimes(1) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(cycle + 2)) await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) @@ -542,7 +542,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(disconnectedState?.phase).toBe('disconnected') expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('must not reach a stale socket')).toBe(false) + expect(transport.sendInput('must not reach a stale socket', 'driving')).toBe(false) expect(onError).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(5 * 60_000) expect(runtimeSubscribe).toHaveBeenCalledTimes(callsAtCutoff) @@ -591,7 +591,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) expect(transport.claimViewport?.(101, 33)).toBe(true) - const accepted = transport.sendInputAccepted?.('\x03') + const accepted = transport.sendInputAccepted?.('\x03', 'driving') await Promise.resolve() rejectReconnect(new Error('reconnect failed')) @@ -610,7 +610,7 @@ describe('createRemoteRuntimePtyTransport', () => { const { streamId } = latestSubscribePayload() expect(transport.claimViewport?.(101, 33)).toBe(true) - const accepted = transport.sendInputAccepted?.('x') + const accepted = transport.sendInputAccepted?.('x', 'driving') subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'end', streamId } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 17d78934e53..a8e8a727ad8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -2583,6 +2583,8 @@ export function createRemoteRuntimePtyTransport( storedCallbacks = {} }, + // Why no kind: terminal.send has no launch kind, and its query-reply kind is for mobile + // clients, so the host classifies a desktop's bytes itself. sendInput(data: string): boolean { if (!connected || !handle || recoveryBlocksIo()) { return false diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts index df997a87afc..05513b53c66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts @@ -27,7 +27,7 @@ describe('sendCapturedTerminalInput', () => { data: '\r' }) ).toBe(true) - expect(transport.sendInput).toHaveBeenCalledWith('\r') + expect(transport.sendInput).toHaveBeenCalledWith('\r', 'driving') } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts index 7a2780a5d32..5409d5420de 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts @@ -32,7 +32,7 @@ export function sendCapturedTerminalInput({ ) { return false } - const sent = capturedTransport.sendInput(data) + const sent = capturedTransport.sendInput(data, 'driving') if (sent) { onAccepted?.() } diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index ee6a492f642..26e8f21d3fe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -156,7 +156,8 @@ describe('handleTerminalFileDrop', () => { { assertCurrent: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png') + wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png'), + 'driving' ) expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -252,7 +253,8 @@ describe('handleTerminalFileDrop', () => { { assertCurrent: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png') + wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png'), + 'driving' ) }) @@ -308,7 +310,7 @@ describe('handleTerminalFileDrop', () => { '/remote/repo/.orca/drops', { assertCurrent: expect.any(Function) } ) - expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ') + expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ', 'driving') }) it('keeps explicit local worktree drops local while a runtime is focused', async () => { @@ -335,7 +337,7 @@ describe('handleTerminalFileDrop', () => { }) expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() - expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(focus).toHaveBeenCalled() }) @@ -382,8 +384,12 @@ describe('handleTerminalFileDrop', () => { }) expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() - expect(sendInput).toHaveBeenNthCalledWith(1, "'/mnt/c/Users/alice/Desktop/notes one.txt' ") - expect(sendInput).toHaveBeenNthCalledWith(2, '/home/alice/repo/README.md ') + expect(sendInput).toHaveBeenNthCalledWith( + 1, + "'/mnt/c/Users/alice/Desktop/notes one.txt' ", + 'driving' + ) + expect(sendInput).toHaveBeenNthCalledWith(2, '/home/alice/repo/README.md ', 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -413,7 +419,7 @@ describe('handleTerminalFileDrop', () => { data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' } }) - expect(sendInputAccepted).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(sendInputAccepted).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -452,7 +458,7 @@ describe('handleTerminalFileDrop', () => { expect(activeSendInput).not.toHaveBeenCalled() expect(activeFocus).not.toHaveBeenCalled() - expect(targetSendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(targetSendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(targetFocus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-target') }) @@ -508,8 +514,8 @@ describe('handleTerminalFileDrop', () => { worktreePath: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo' }) expect(sendInput.mock.calls).toEqual([ - ["'/mnt/c/Users/Name/My Project/file.txt' "], - ['/home/user/repo/README.md '] + ["'/mnt/c/Users/Name/My Project/file.txt' ", 'driving'], + ['/home/user/repo/README.md ', 'driving'] ]) expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -608,7 +614,7 @@ describe('handleTerminalFileDrop', () => { expectedSshTargetId: 'ssh-win', expectedSshConnectionGeneration: 4 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ', 'driving') expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -684,6 +690,6 @@ describe('handleTerminalFileDrop', () => { data: { paths: ["/Users/me/it's here.txt"], target: 'terminal' } }) - expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ") + expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ", 'driving') }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts index 2c1c2652e87..cd42929c248 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts @@ -116,7 +116,10 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 2 }) - expect(sendInput.mock.calls).toEqual([['/repo/a.ts '], ["'/repo/my file.ts' "]]) + expect(sendInput.mock.calls).toEqual([ + ['/repo/a.ts ', 'driving'], + ["'/repo/my file.ts' ", 'driving'] + ]) expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -155,7 +158,7 @@ describe('handleInternalTerminalFileDrop', () => { expect(result).toEqual({ status: 'cancelled', reason: 'target-stale', pathCount: 1 }) expect(sendInputAccepted).toHaveBeenCalledTimes(1) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(replacementSendInput).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -187,7 +190,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() @@ -254,7 +257,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\repo\\a&b.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\repo\\a&b.txt" ', 'driving') }) it('uses SSH remote platform metadata for Windows internal file drops', async () => { @@ -293,7 +296,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ', 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -334,7 +337,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ") + expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ", 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -387,7 +390,7 @@ describe('handleInternalTerminalFileDrop', () => { expect(result).toEqual({ status: 'pasted', pathCount: 1 }) expect(activeSendInput).not.toHaveBeenCalled() expect(activeFocus).not.toHaveBeenCalled() - expect(targetSendInput).toHaveBeenCalledWith('/repo/drop-target.ts ') + expect(targetSendInput).toHaveBeenCalledWith('/repo/drop-target.ts ', 'driving') expect(targetFocus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-target') }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts index 7fcb898d7eb..20fe46c1fda 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts @@ -48,7 +48,7 @@ describe('terminal drop path writer', () => { failureReason: 'write-rejected' }) expect(sendInputAccepted).toHaveBeenCalledTimes(1) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() }) @@ -70,7 +70,8 @@ describe('terminal drop path writer', () => { // Why: image attachment detection in terminal TUIs keys off bracketed paste // of the literal path — no shell-escaping, no trailing space. expect(sendInputAccepted).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('/repo/My Screenshot.png') + wrapTerminalBracketedPasteText('/repo/My Screenshot.png'), + 'driving' ) }) @@ -88,10 +89,11 @@ describe('terminal drop path writer', () => { targetShell: 'posix' }) - expect(sendInputAccepted).toHaveBeenNthCalledWith(1, '/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenNthCalledWith(1, '/repo/a.ts ', 'driving') expect(sendInputAccepted).toHaveBeenNthCalledWith( 2, - wrapTerminalBracketedPasteText('/repo/shot.png') + wrapTerminalBracketedPasteText('/repo/shot.png'), + 'driving' ) }) @@ -113,9 +115,10 @@ describe('terminal drop path writer', () => { // non-image path would collide with it without an explicit separator. expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - `${wrapTerminalBracketedPasteText('/repo/shot.png')} ` + `${wrapTerminalBracketedPasteText('/repo/shot.png')} `, + 'driving' ) - expect(sendInputAccepted).toHaveBeenNthCalledWith(2, '/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenNthCalledWith(2, '/repo/a.ts ', 'driving') }) it('does not insert a separator between back-to-back image pastes', async () => { @@ -136,11 +139,13 @@ describe('terminal drop path writer', () => { // TUI input between the two attachments. expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - wrapTerminalBracketedPasteText('/repo/one.png') + wrapTerminalBracketedPasteText('/repo/one.png'), + 'driving' ) expect(sendInputAccepted).toHaveBeenNthCalledWith( 2, - wrapTerminalBracketedPasteText('/repo/two.png') + wrapTerminalBracketedPasteText('/repo/two.png'), + 'driving' ) }) @@ -158,7 +163,10 @@ describe('terminal drop path writer', () => { targetShell: 'posix' }) - expect(sendInputAccepted).toHaveBeenCalledWith("'/repo/a.png; touch /tmp/pwned #.png' ") + expect(sendInputAccepted).toHaveBeenCalledWith( + "'/repo/a.png; touch /tmp/pwned #.png' ", + 'driving' + ) }) it('falls back to shell escaping for image paths with Windows shell metacharacters', async () => { @@ -175,7 +183,7 @@ describe('terminal drop path writer', () => { targetShell: 'windows' }) - expect(sendInputAccepted).toHaveBeenCalledWith('"C:\\Users\\me\\Pictures\\a&b.png" ') + expect(sendInputAccepted).toHaveBeenCalledWith('"C:\\Users\\me\\Pictures\\a&b.png" ', 'driving') }) it('separates an image paste from a following image path that must be shell escaped', async () => { @@ -194,9 +202,14 @@ describe('terminal drop path writer', () => { expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - `${wrapTerminalBracketedPasteText('/repo/shot.png')} ` + `${wrapTerminalBracketedPasteText('/repo/shot.png')} `, + 'driving' + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 2, + "'/repo/a.png; touch /tmp/pwned #.png' ", + 'driving' ) - expect(sendInputAccepted).toHaveBeenNthCalledWith(2, "'/repo/a.png; touch /tmp/pwned #.png' ") }) it('times out dropped path writes that never receive PTY acknowledgement', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts index f8ed3766251..824a812ddb7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts @@ -72,7 +72,7 @@ export async function writeTerminalDropPathsToCapturedTarget({ ) : `${shellEscapePath(path, targetShell)} ` const writeResult = await runTerminalPasteOperationWithTimeout( - () => writeTerminalPastePtyInput(liveTransport, payload), + () => writeTerminalPastePtyInput(liveTransport, payload, 'driving'), operationTimeoutMs ) if (writeResult.timedOut) { diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts index 7b7ed78989f..45fe1ee23ab 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts @@ -99,8 +99,8 @@ export function readTerminalImeCompositionSessionDetail( export function installTerminalImeCompositionRoute(args: { terminalElement: HTMLElement | null | undefined terminal: Pick - capturedTransport: PtyTransport - getCurrentTransport: () => PtyTransport | undefined + capturedTransport: Pick + getCurrentTransport: () => Pick | undefined }): IDisposable { const terminalElement = args.terminalElement const sessions = new Map() diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts new file mode 100644 index 00000000000..5b0accad22c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment happy-dom +// IME and Hangul commits bypass xterm's key path and enter through `terminal.input`. Main records +// a run's first user input from xterm's user-input signal, so every commit route must carry it. +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { IDisposable } from '@xterm/xterm' +import type { PtyTransport } from './pty-transport' +import { + installTerminalImeCompositionRoute, + XTERM_COMPOSITION_SESSION_END_EVENT, + XTERM_COMPOSITION_SESSION_START_EVENT +} from './terminal-ime-composition-route' +import { IPAD_DESKTOP_MODE_UA, pretendIosWeb } from './terminal-ios-hangul-preedit-fixture' +import { installTerminalPaneInputHandling } from './terminal-pane-pane-input' +import { subscribeToTerminalInputData } from './terminal-user-input-signal' + +const MAC_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' + +type ForwardedInput = { data: string; wasUserInput: boolean } + +const disposables: IDisposable[] = [] + +function openPane(): { + terminal: Terminal + textarea: HTMLTextAreaElement + forwarded: ForwardedInput[] +} { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal({ cols: 40, rows: 8 }) + disposables.push(terminal) + terminal.open(container) + installTerminalPaneInputHandling({ + pane: { id: 1, terminal }, + managerRef: { current: null }, + paneKittyKeyboardModesRef: { current: new Map() }, + settingsRef: { current: {} }, + imeCompositionDisposablesRef: { current: new Map() }, + imeNativeTextForwarderDisposablesRef: { current: new Map() } + }) + const forwarded: ForwardedInput[] = [] + disposables.push( + subscribeToTerminalInputData(terminal, (data, wasUserInput) => + forwarded.push({ data, wasUserInput }) + ) + ) + return { terminal, textarea: terminal.textarea!, forwarded } +} + +function key( + textarea: HTMLTextAreaElement, + type: 'keydown' | 'keypress' | 'keyup', + init: { key: string; code: string; keyCode: number } +): KeyboardEvent { + const event = new KeyboardEvent(type, { ...init, bubbles: true, cancelable: true }) + // happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm's key paths read them. + Object.defineProperty(event, 'keyCode', { value: init.keyCode }) + Object.defineProperty(event, 'charCode', { value: type === 'keypress' ? init.keyCode : 0 }) + textarea.dispatchEvent(event) + return event +} + +function insertText(textarea: HTMLTextAreaElement, inputType: string, data: string | null): void { + const event = new InputEvent('input', { bubbles: true }) + Object.defineProperty(event, 'inputType', { value: inputType }) + Object.defineProperty(event, 'data', { value: data }) + Object.defineProperty(event, 'composed', { value: true }) + textarea.dispatchEvent(event) +} + +describe('IME and Hangul commits reach the PTY as user input', () => { + let originalUserAgent: PropertyDescriptor | undefined + let originalMaxTouchPoints: PropertyDescriptor | undefined + let originalGetContext: PropertyDescriptor | undefined + + beforeEach(() => { + originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent') + originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints') + // happy-dom has no 2d context, which the DOM renderer's WidthCache requires. + originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'getContext') + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: () => ({ measureText: () => ({ width: 10 }) }) + }) + }) + + afterEach(() => { + for (const disposable of disposables.splice(0)) { + disposable.dispose() + } + for (const [property, descriptor] of [ + ['userAgent', originalUserAgent], + ['maxTouchPoints', originalMaxTouchPoints] + ] as const) { + // Why delete: with no own descriptor the real value lives on the prototype, which the stub shadows. + if (descriptor) { + Object.defineProperty(navigator, property, descriptor) + } else { + Reflect.deleteProperty(navigator, property) + } + } + if (originalGetContext) { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalGetContext) + } else { + Reflect.deleteProperty(HTMLCanvasElement.prototype, 'getContext') + } + document.body.replaceChildren() + }) + + it('marks a macOS input-source substitution as user input', () => { + pretendIosWeb(0, MAC_UA) + const { textarea, forwarded } = openPane() + + const keydown = key(textarea, 'keydown', { key: ',', code: 'Comma', keyCode: 188 }) + expect(keydown.defaultPrevented).toBe(false) + textarea.value = ',' + insertText(textarea, 'insertText', ',') + key(textarea, 'keyup', { key: ',', code: 'Comma', keyCode: 188 }) + + expect(forwarded).toEqual([{ data: ',', wasUserInput: true }]) + }) + + it('marks an iPadOS Hangul syllable commit as user input', async () => { + pretendIosWeb(5, IPAD_DESKTOP_MODE_UA) + const { textarea, forwarded } = openPane() + const typeJamo = async (jamo: string, written: string, replaces: boolean) => { + if ( + !key(textarea, 'keydown', { key: jamo, code: 'KeyQ', keyCode: jamo.charCodeAt(0) }) + .defaultPrevented + ) { + key(textarea, 'keypress', { key: jamo, code: 'KeyQ', keyCode: jamo.charCodeAt(0) }) + } + if (replaces) { + textarea.value = textarea.value.slice(0, -1) + insertText(textarea, 'deleteContentBackward', null) + } + textarea.value += written + insertText(textarea, 'insertText', written) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + } + + await typeJamo('ㅎ', 'ㅎ', false) + await typeJamo('ㅏ', '하', true) + await typeJamo('ㄴ', '한', true) + await typeJamo('ㄱ', 'ㄱ', false) + + expect(forwarded).toEqual([{ data: '한', wasUserInput: true }]) + }) + + it('marks a composition the route delivers after a pane switch as user input', () => { + const { terminal, forwarded } = openPane() + const transport: Pick = { getPtyId: () => 'pty-1' } + disposables.push( + installTerminalImeCompositionRoute({ + terminalElement: terminal.element!, + terminal, + capturedTransport: transport, + getCurrentTransport: () => transport + }) + ) + const session = (type: string, data?: string): CustomEvent => + new CustomEvent(type, { + bubbles: true, + cancelable: true, + detail: { id: 1, data, dataPendingReconciliation: false } + }) + + terminal.element!.dispatchEvent(session(XTERM_COMPOSITION_SESSION_START_EVENT)) + terminal.element!.dispatchEvent(session(XTERM_COMPOSITION_SESSION_END_EVENT, '한')) + + expect(forwarded).toEqual([{ data: '한', wasUserInput: true }]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts index 5f65a429922..42f032b17f4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts @@ -210,7 +210,7 @@ describe('the iPadOS Hangul path alongside everything else', () => { pretendIosWeb() const rig = openIosTerminal() const sent: string[] = [] - writeTerminalPastePtyInput({ sendInput: (data) => (sent.push(data), true) }, 'echo ') + writeTerminalPastePtyInput({ sendInput: (data) => (sent.push(data), true) }, 'echo ', 'driving') expect(sent).toEqual(['echo ']) await typeHangeul(rig) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts index 295b7e35c53..b27900d2097 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts @@ -89,7 +89,7 @@ export const executeTerminalPaneMenuPasteText = async ( const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(pane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => isPanePasteTargetMounted(context, pane, transport, ptyId), canContinue: () => isPanePasteTargetMounted(context, pane, transport, ptyId) }) 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 b639d3b6ef8..6ecab6f332b 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 @@ -33,7 +33,7 @@ import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll import { resetTerminalKeyboardProtocolAfterInterrupt } from './terminal-pane-lifecycle-primitives' type PaneInputContext = { - pane: ManagedPane + pane: Pick managerRef: React.RefObject paneKittyKeyboardModesRef: UseTerminalPaneLifecycleDeps['paneKittyKeyboardModesRef'] settingsRef: React.RefObject | null | undefined> diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts b/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts index e436bd2cc6a..309c42dc18d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts @@ -93,7 +93,7 @@ export function createTerminalPanePasteExecution( const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(pane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => { if (!isPanePasteTargetMounted(pane, transport, ptyId)) { return false diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts index b36f0d06f65..f08926b2c81 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts @@ -54,7 +54,13 @@ describe('terminal paste operation ordering', () => { pasteText: () => {}, writePty, yieldToEventLoop: async () => { - startupDraft ??= sendAgentDraftPasteContent(null, 'pty-1', 'GENERATED_CONTEXT', writePty) + startupDraft ??= sendAgentDraftPasteContent( + null, + 'pty-1', + 'GENERATED_CONTEXT', + 'launch', + writePty + ) } }) await startupDraft @@ -80,6 +86,7 @@ describe('terminal paste operation ordering', () => { null, 'pty-1', 'G'.repeat(64 * 1024 + 1), + 'launch', async (data) => { writes.push({ owner: 'startup', data }) if (data === BRACKETED_PASTE_START) { diff --git a/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts b/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts index f92427e4729..4f3549a15ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts @@ -66,7 +66,7 @@ export function handleTerminalProgrammaticTextPaste({ .then((plan) => executeTerminalPastePlan(plan, { pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => isTerminalPanePasteTargetCurrent({ manager: getManager(), diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts index e501c50402d..5a587c6acc9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts @@ -8,21 +8,21 @@ describe('terminal PTY paste writer', () => { const sendInputAccepted = vi.fn().mockResolvedValue(true) await expect( - writeTerminalPastePtyInput({ sendInput, sendInputAccepted }, 'payload') + writeTerminalPastePtyInput({ sendInput, sendInputAccepted }, 'payload', 'driving') ).resolves.toBe(true) - expect(sendInputAccepted).toHaveBeenCalledWith('payload') + expect(sendInputAccepted).toHaveBeenCalledWith('payload', 'driving') expect(sendInput).not.toHaveBeenCalled() }) it('falls back to queued PTY writes when acknowledged writes are unavailable', () => { const sendInput = vi.fn().mockReturnValue(true) - expect(writeTerminalPastePtyInput({ sendInput }, 'payload')).toBe(true) - expect(sendInput).toHaveBeenCalledWith('payload') + expect(writeTerminalPastePtyInput({ sendInput }, 'payload', 'driving')).toBe(true) + expect(sendInput).toHaveBeenCalledWith('payload', 'driving') }) it('rejects writes without a transport', () => { - expect(writeTerminalPastePtyInput(undefined, 'payload')).toBe(false) + expect(writeTerminalPastePtyInput(undefined, 'payload', 'driving')).toBe(false) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts index dd7e257615a..d1fffbc76fb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts @@ -1,15 +1,17 @@ import type { PtyTransport } from './pty-transport' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' type TerminalPastePtyWriter = Pick export function writeTerminalPastePtyInput( transport: TerminalPastePtyWriter | undefined, - data: string + data: string, + inputKind: TerminalInputKind ): boolean | Promise { if (!transport) { return false } // Why: paste chunking must respect PTY backpressure. sendInput only queues // local writes, while sendInputAccepted resolves after the PTY accepts them. - return transport.sendInputAccepted?.(data) ?? transport.sendInput(data) + return transport.sendInputAccepted?.(data, inputKind) ?? transport.sendInput(data, inputKind) } diff --git a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts index 7759e8802bf..11c0449bd96 100644 --- a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts @@ -40,7 +40,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('git status\r') + expect(sendInput).toHaveBeenCalledWith('git status\r', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -62,7 +62,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(false) - expect(sendInput).toHaveBeenCalledWith('npm test') + expect(sendInput).toHaveBeenCalledWith('npm test', 'driving') expect(pane.terminal.focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).not.toHaveBeenCalled() }) @@ -85,7 +85,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('cd packages; bun run build; cd ..\r') + expect(sendInput).toHaveBeenCalledWith('cd packages; bun run build; cd ..\r', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() }) @@ -107,7 +107,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('echo one; echo two') + expect(sendInput).toHaveBeenCalledWith('echo one; echo two', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts index 39d49d02c86..5c8d3622b00 100644 --- a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts @@ -5,6 +5,7 @@ import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' +import type { PtyTransport } from './pty-transport-types' type QuickCommandPane = { leafId: string @@ -13,9 +14,7 @@ type QuickCommandPane = { } } -type QuickCommandTransport = { - sendInput: (data: string) => boolean -} +type QuickCommandTransport = Pick export function sendTerminalQuickCommandToPane({ command, @@ -36,7 +35,8 @@ export function sendTerminalQuickCommandToPane({ } const sent = transport.sendInput( - buildTerminalQuickCommandInput(flattenTerminalQuickCommand(command)) + buildTerminalQuickCommandInput(flattenTerminalQuickCommand(command)), + 'driving' ) if (sent) { recordTerminalUserInputForLeaf(tabId, pane.leafId) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts index 3a2307cd87d..913c8b6cfdc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts @@ -47,7 +47,7 @@ export async function executeTerminalStartupCommandPaste({ return executeTerminalPastePlan(plan, { pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'launch'), isTargetCurrent: isCurrent, canContinue: isCurrent }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts index 5ce2bfc438e..009ab6b800a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts @@ -196,7 +196,7 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(clickedPane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: targetStillMounted, canContinue: targetStillMounted }) diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index a76086b6790..19d5a30d275 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -12,6 +12,7 @@ import { } from '@/components/terminal-pane/terminal-bracketed-paste' import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' // Why: bracketed paste markers let supported TUIs treat generated prompt text // as one paste instead of echoing character-by-character or triggering edits. @@ -29,10 +30,11 @@ export async function sendAgentDraftPasteContent( settings: Pick | null | undefined, ptyId: string, content: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { return await runTerminalPtyInputTransaction(ptyId, () => - sendAgentDraftPasteContentNow(settings, ptyId, content, writePty) + sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind, writePty) ) } @@ -42,6 +44,7 @@ export async function sendAgentDraftPasteContentNow( settings: Pick | null | undefined, ptyId: string, content: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) { @@ -57,6 +60,7 @@ export async function sendAgentDraftPasteContentNow( settings, ptyId, wrapTerminalBracketedPasteText(terminalContent), + inputKind, writePty ) } @@ -71,16 +75,16 @@ export async function sendAgentDraftPasteContentNow( for (const chunk of iterateAgentDraftPasteContentChunks(terminalContent)) { let accepted = false try { - accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, writePty) + accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, inputKind, writePty) } catch { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId, writePty) + await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty) } return false } if (!accepted) { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId, writePty) + await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty) } return false } @@ -197,20 +201,24 @@ async function writeAgentDraftPtyInput( settings: Pick | null | undefined, ptyId: string, data: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { - return writePty ? await writePty(data) : await sendRuntimePtyInputVerified(settings, ptyId, data) + return writePty + ? await writePty(data) + : await sendRuntimePtyInputVerified(settings, ptyId, data, inputKind) } async function closeAgentDraftBracketedPaste( settings: Pick | null | undefined, ptyId: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { try { // Why: once the opener reached the PTY, a failed content chunk should not // leave the target TUI in bracketed-paste mode. - await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, writePty) + await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, inputKind, writePty) } catch { // The original write already failed; callers only need the paste to fail closed. } diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts index 207c70ada36..48534cf8b06 100644 --- a/src/renderer/src/lib/agent-followup-delivery.test.ts +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -49,7 +49,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = }) expect(delivered).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') }) it(`still refuses to type into a bare ${agent} shell foreground`, async () => { @@ -105,6 +105,6 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = }) expect(delivered).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') }) }) diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index bdcf0b4a92f..e520cb6df1f 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -22,7 +22,7 @@ export async function sendFollowupPromptWhenAgentReady(args: { return false } try { - return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`) + return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`, 'launch') } catch { return false } diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts index 5b763aa5c6d..84ad14770d1 100644 --- a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -105,7 +105,12 @@ describe('post-paste submit retry Enter', () => { await expect(promise).resolves.toBe(true) expect(enterWrites()).toHaveLength(2) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( + {}, + 'pty-1', + '\r', + 'launch' + ) expect(vi.getTimerCount()).toBe(0) }) @@ -143,7 +148,8 @@ describe('post-paste submit retry Enter', () => { const competing = sendAgentDraftPasteContent( {}, 'pty-1', - 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1), + 'driving' ) await flushMicrotasks(10) expect(writes).toEqual([PASTED_ISSUE_URL, '\r']) diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index fdcb76cf2ed..bb07fb58504 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -57,7 +57,9 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, - sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, + // Why drop the kind: this suite pins write shapes; startup-draft-input-kind.test.ts pins kinds. + sendRuntimePtyInputVerified: (settings: unknown, ptyId: string, data: string) => + testState.sendRuntimePtyInputVerified(settings, ptyId, data), inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess })) @@ -726,7 +728,8 @@ describe('pasteDraftWhenAgentReady', () => { const competing = sendAgentDraftPasteContent( {}, 'pty-1', - 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1), + 'driving' ) await flushMicrotasks(10) expect(writes).toEqual([PASTED_ISSUE_URL]) @@ -902,7 +905,7 @@ describe('pasteDraftWhenAgentReady', () => { it('yields during large accepted-size preflight before writing agent draft chunks', async () => { const content = 'x'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 300 * 1024) - const promise = sendAgentDraftPasteContent({}, 'pty-1', content) + const promise = sendAgentDraftPasteContent({}, 'pty-1', content, 'driving') await flushMicrotasks(5) expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() @@ -915,9 +918,9 @@ describe('pasteDraftWhenAgentReady', () => { }) it('rejects oversized agent drafts before any PTY write', async () => { - await expect( - sendAgentDraftPasteContent({}, 'pty-1', 'x'.repeat(AGENT_DRAFT_PASTE_MAX_BYTES + 1)) - ).resolves.toBe(false) + const oversized = 'x'.repeat(AGENT_DRAFT_PASTE_MAX_BYTES + 1) + const result = sendAgentDraftPasteContent({}, 'pty-1', oversized, 'driving') + await expect(result).resolves.toBe(false) expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index a260d6bee18..feca855338a 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TuiAgent } from '../../../shared/tui-agent' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { useAppStore } from '@/store' @@ -136,7 +137,9 @@ export async function pasteDraftWhenAgentReady(args: { ptyId, content, submit: submit === true, - agent + agent, + // Why launch: this delivers the prompt or draft an agent is started with. + inputKind: 'launch' }) } @@ -188,7 +191,9 @@ export async function pasteDraftToAgentPtyWhenReady(args: { ptyId, content, submit: submit === true, - agent + agent, + // Why launch: this delivers the prompt or draft an agent is started with. + inputKind: 'launch' }) } @@ -201,7 +206,8 @@ export async function submitPromptToAgentPty(args: { settings: getSettingsForAgentTabRuntimeOwner(args.tabId), ptyId: args.ptyId, content: args.content, - submit: true + submit: true, + inputKind: 'driving' }) } @@ -209,7 +215,7 @@ export async function sendBracketedPasteToRunningAgent(args: { ptyId: string content: string }): Promise { - return await sendBracketedPasteToAgent({ ptyId: args.ptyId, content: args.content, submit: true }) + return await sendBracketedPasteToAgent({ ...args, submit: true, inputKind: 'driving' }) } async function sendBracketedPasteToAgent(args: { @@ -218,14 +224,16 @@ async function sendBracketedPasteToAgent(args: { content: string submit: boolean agent?: TuiAgent + inputKind: TerminalInputKind }): Promise { const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args + const { inputKind } = args const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined try { // Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent // paste on this PTY can slip between them and submit a half-written prompt. return await runTerminalPtyInputTransaction(ptyId, async () => { - const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content) + const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind) if (!pasted || !submit) { return pasted } @@ -234,14 +242,14 @@ async function sendBracketedPasteToAgent(args: { // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r') + const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) if (submitRetryDelayMs !== undefined) { // Why: agents that render their composer before Enter is live silently eat // the first Enter; the retry is best-effort and never downgrades `submitted`. await new Promise((resolve) => window.setTimeout(resolve, submitRetryDelayMs)) try { - await sendRuntimePtyInputVerified(settings, ptyId, '\r') + await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) } catch { // Why: a rejected retry leaves the first Enter's verdict untouched. } diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index 11ade27d58a..ed62c67f356 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -178,7 +178,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "claude '--dangerously-skip-permissions' 'run the automation'\r" + "claude '--dangerously-skip-permissions' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -214,7 +215,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -241,7 +243,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r" + "codex '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -262,7 +265,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r" + "codex '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -288,7 +292,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -312,7 +317,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -353,7 +359,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 9ef4bcfae4a..07c39dd082a 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -118,7 +118,7 @@ export async function launchAgentBackgroundSession( command: sshConnectionId ? startupPlan.launchCommand : null, waitForShellReady: Boolean(sshConnectionId) && sshBackgroundLaunchWaitsForShellReady(startupPlan), - write: (ptyId, data) => window.api.pty.write(ptyId, data) + write: (ptyId, data) => window.api.pty.write(ptyId, data, 'launch') }) // Route by the worktree's owner host, not the focused runtime. const runtimeTarget = getActiveRuntimeTarget( diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 78d59f781f2..d3ca4fa01e6 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -293,7 +293,12 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith({}, 'pty-1', 'fix the spinner\r') + expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + 'fix the spinner\r', + 'launch' + ) expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) diff --git a/src/renderer/src/lib/startup-draft-input-kind.test.ts b/src/renderer/src/lib/startup-draft-input-kind.test.ts new file mode 100644 index 00000000000..34c96e3073f --- /dev/null +++ b/src/renderer/src/lib/startup-draft-input-kind.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +// A created worktree's draft reaches its agent by one of two renderer routes: the pane's own +// startup paste, or the draft helper that waits for the agent. Both must write it as launch input. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' +import { bindSettlePaneSerializer } from '@/components/terminal-pane/pty-connection/pane-serializer-settle' +import type { ConnectPanePtySession } from '@/components/terminal-pane/pty-connection/connect-pane-pty-session' +import { STARTUP_DRAFT_PASTE_QUIET_MS } from '@/components/terminal-pane/pty-connection/pty-connect-limits' +import { pasteDraftToAgentPtyWhenReady, submitPromptToAgentPty } from './agent-paste-draft' + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ settings: {}, terminalLayoutsByTabId: {} }), + subscribe: () => () => {} + } +})) + +vi.mock('./worktree-runtime-owner', () => ({ + getSettingsForWorktreeRuntimeOwner: () => ({}), + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('./agent-draft-readiness', () => ({ + waitForAgentDraftInputReady: async () => true +})) + +const DRAFT = 'plan the migration' +const originalApi = window.api + +function stubPtyApi(): { kinds: TerminalInputKind[] } { + const kinds: TerminalInputKind[] = [] + const writeAccepted = vi.fn(async (_id: string, _data: string, inputKind: TerminalInputKind) => { + kinds.push(inputKind) + return true + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both routes reach only pty.writeAccepted. + window.api = { pty: { writeAccepted, write: vi.fn() } } as unknown as typeof window.api + return { kinds } +} + +/** The pane route: the pane's transport pastes the draft once the agent's composer is ready. */ +async function paneRouteKinds(): Promise { + const { kinds } = stubPtyApi() + const transport = { + getPtyId: () => 'pty-1', + sendInput: () => true, + sendInputAccepted: (data: string, inputKind: TerminalInputKind) => + window.api.pty.writeAccepted('pty-1', data, inputKind) + } + const pane = { id: 1 } + const bag: Record = { + pane, + transport, + disposed: false, + connectionId: null, + ownsStartupDraftPaste: true, + shouldDeliverStartupViaTerminalPaste: false, + startupDraftPrompt: DRAFT, + recordTerminalInputForHibernation: () => {}, + deps: { worktreeId: 'wt-1', paneTransportsRef: { current: new Map([[pane.id, transport]]) } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session is an any-typed bag; the draft path reads only the fields above. + const session = bag as unknown as ConnectPanePtySession + bindSettlePaneSerializer(session) + session.observeStartupDraftPasteReadiness('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(STARTUP_DRAFT_PASTE_QUIET_MS) + await vi.runAllTimersAsync() + return kinds +} + +/** The helper route: the draft helper waits for the agent, then writes through the runtime. */ +async function helperRouteKinds(): Promise { + const { kinds } = stubPtyApi() + const pasted = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: DRAFT, + agent: 'aider' + }) + await vi.runAllTimersAsync() + await expect(pasted).resolves.toBe(true) + return kinds +} + +describe('a created worktree’s startup draft', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + window.api = originalApi + }) + + it('writes as launch input on both renderer routes', async () => { + const paneKinds = await paneRouteKinds() + const helperKinds = await helperRouteKinds() + + expect(paneKinds.length).toBeGreaterThan(0) + expect(new Set(paneKinds)).toEqual(new Set(['launch'])) + expect(new Set(helperKinds)).toEqual(new Set(paneKinds)) + }) + + it('differs from a prompt submitted to an agent that is already running', async () => { + const { kinds } = stubPtyApi() + + const submitted = submitPromptToAgentPty({ tabId: 'tab-1', ptyId: 'pty-1', content: DRAFT }) + await vi.runAllTimersAsync() + + await expect(submitted).resolves.toBe(true) + expect(new Set(kinds)).toEqual(new Set(['driving'])) + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index 7f54f5873bc..ded589e26b1 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -110,7 +110,12 @@ describe('runtime terminal owner routing', () => { it('sends input through the PTY owning environment instead of the active one', async () => { expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -341,7 +346,12 @@ describe('runtime terminal owner routing', () => { }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -372,7 +382,12 @@ describe('runtime terminal owner routing', () => { useAppStore.setState({ lastTerminalInputAtByPaneKey: {} }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) const nextLeafId = '22222222-2222-4222-8222-222222222222' useAppStore.setState({ @@ -417,7 +432,12 @@ describe('runtime terminal owner routing', () => { }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -436,7 +456,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-stale', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) }) @@ -452,7 +473,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) @@ -472,17 +494,19 @@ describe('runtime terminal owner routing', () => { localWriteAccepted.mockResolvedValue(true) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x') + sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving') ).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x') + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving') expect(localWrite).not.toHaveBeenCalled() }) it('rejects oversized fire-and-forget local input before IPC writes', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe(false) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(false) expect(localWrite).not.toHaveBeenCalled() expect(localWriteAccepted).not.toHaveBeenCalled() @@ -492,7 +516,12 @@ describe('runtime terminal owner routing', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', text) + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + text, + 'driving' + ) ).toBe(false) expect(runtimeTransportCall).not.toHaveBeenCalled() @@ -504,14 +533,14 @@ describe('runtime terminal owner routing', () => { try { const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe( - true - ) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(true) expect(localWrite).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(0) - expect(localWrite).toHaveBeenCalledWith('local-pty', text) + expect(localWrite).toHaveBeenCalledWith('local-pty', text, 'driving') expect(localWriteAccepted).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -523,9 +552,9 @@ describe('runtime terminal owner routing', () => { try { const text = makeByteOversizedTerminalInput() - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe( - true - ) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(true) await vi.runAllTimersAsync() @@ -540,7 +569,12 @@ describe('runtime terminal owner routing', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', text) + sendRuntimePtyInputVerified( + { activeRuntimeEnvironmentId: null }, + 'local-pty', + text, + 'driving' + ) ).resolves.toBe(false) expect(localWriteAccepted).not.toHaveBeenCalled() @@ -555,7 +589,8 @@ describe('runtime terminal owner routing', () => { const accepted = sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: null }, 'local-pty', - text + text, + 'driving' ) expect(localWriteAccepted).not.toHaveBeenCalled() @@ -563,7 +598,7 @@ describe('runtime terminal owner routing', () => { await vi.advanceTimersByTimeAsync(0) await expect(accepted).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', text) + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', text, 'driving') expect(localWrite).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -577,7 +612,8 @@ describe('runtime terminal owner routing', () => { const accepted = sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: null }, 'local-pty', - text + text, + 'driving' ) await vi.runAllTimersAsync() @@ -612,7 +648,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(true) @@ -643,7 +680,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) @@ -829,10 +867,10 @@ describe('runtime terminal owner routing', () => { localWriteAccepted.mockResolvedValue(false) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x') + sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving') ).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x') - expect(localWrite).toHaveBeenCalledWith('local-pty', 'x') + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving') + expect(localWrite).toHaveBeenCalledWith('local-pty', 'x', 'driving') }) }) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index b34d75f4555..e235bb5adef 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -20,6 +20,7 @@ export type { ClientOnlyUnverifiableInspection, ClientOnlyUnverifiableReason } from '../../../shared/terminal-process-inspection' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' export type RuntimeTerminalProcessInspection = TerminalProcessInspection @@ -216,7 +217,8 @@ export async function confirmRuntimeTerminalForegroundProcess( export function sendRuntimePtyInput( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): boolean { const tooLarge = isRuntimePtyInputTooLarge(data) if (tooLarge === true) { @@ -228,19 +230,22 @@ export function sendRuntimePtyInput( void tooLarge .then((resolvedTooLarge) => { if (!resolvedTooLarge) { - sendRuntimePtyInputWithinLimit(settings, ptyId, data) + sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind) } }) .catch(() => {}) return true } - return sendRuntimePtyInputWithinLimit(settings, ptyId, data) + return sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind) } +// Why the kind reaches only the local write: terminal.send has no launch kind, and its query-reply +// kind is for mobile clients, so the host classifies a desktop's environment write by its bytes. function sendRuntimePtyInputWithinLimit( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): boolean { const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) const target = ownerEnvironmentId @@ -248,7 +253,7 @@ function sendRuntimePtyInputWithinLimit( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - window.api.pty.write(ptyId, data) + window.api.pty.write(ptyId, data, inputKind) recordRuntimeTerminalInputForPtyId(ptyId) return true } @@ -274,7 +279,8 @@ function sendRuntimePtyInputWithinLimit( export async function sendRuntimePtyInputVerified( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): Promise { const tooLarge = isRuntimePtyInputTooLarge(data) if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) { @@ -286,9 +292,9 @@ export async function sendRuntimePtyInputVerified( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - const accepted = await window.api.pty.writeAccepted(ptyId, data) + const accepted = await window.api.pty.writeAccepted(ptyId, data, inputKind) if (!accepted) { - window.api.pty.write(ptyId, data) + window.api.pty.write(ptyId, data, inputKind) // Why: SSH/local fallback writes are fire-and-forget. Callers use this // boolean to continue UX flow, while hook telemetry confirms real turns. recordRuntimeTerminalInputForPtyId(ptyId) diff --git a/src/shared/source-scan/call-argument-scan.ts b/src/shared/source-scan/call-argument-scan.ts new file mode 100644 index 00000000000..59a411aa2f0 --- /dev/null +++ b/src/shared/source-scan/call-argument-scan.ts @@ -0,0 +1,83 @@ +import { stripComments } from './source-tree-scan' + +const CLOSER_BY_OPENER: Record = { '(': ')', '[': ']', '{': '}' } + +/** Top-level argument texts of the call whose `(` ends at `start`; quote-aware, not a parser. */ +export function splitCallArguments(source: string, start: number): string[] { + const args: string[] = [] + const closers: string[] = [')'] + let current = '' + let quote: string | null = null + for (let index = start; index < source.length; index += 1) { + const char = source[index]! + if (quote) { + current += char + if (char === '\\') { + current += source[index + 1] ?? '' + index += 1 + } else if (char === quote) { + quote = null + } + continue + } + const closer = CLOSER_BY_OPENER[char] + if (char === "'" || char === '"' || char === '`') { + quote = char + } else if (closer) { + closers.push(closer) + } else if (char === closers.at(-1)) { + closers.pop() + if (closers.length === 0) { + break + } + } else if (char === ',' && closers.length === 1) { + args.push(current.trim()) + current = '' + continue + } + current += char + } + if (current.trim()) { + args.push(current.trim()) + } + return args +} + +export type RequiredCallArgument = { + /** Which positional argument must be present. */ + index: number + /** The call counts only when the text before `.method(` matches, e.g. its receiver. */ + receiver?: RegExp + /** When the argument is an object literal, whether it carries the required field. */ + acceptsObjectLiteral?: (literal: string) => boolean +} + +/** + * `line: .method(args)` for each call that leaves out a required argument. For code the compiler + * cannot check, such as `@ts-nocheck` files and `any`-typed receivers. + */ +export function findCallsMissingArgument( + source: string, + requiredByMethod: Record +): string[] { + const code = stripComments(source) + const callRe = new RegExp(`\\.\\s*(${Object.keys(requiredByMethod).join('|')})\\s*\\(`, 'g') + const missing: string[] = [] + for (const match of code.matchAll(callRe)) { + const method = match[1]! + const required = requiredByMethod[method]! + const before = code.slice(0, match.index) + if (required.receiver && !required.receiver.test(before)) { + continue + } + const args = splitCallArguments(code, match.index + match[0].length) + const argument = args[required.index] + const present = + argument !== undefined && + (!argument.startsWith('{') || (required.acceptsObjectLiteral?.(argument) ?? true)) + if (!present) { + missing.push(`${before.split('\n').length}: .${method}(${args.join(', ')})`) + } + } + return missing +} diff --git a/src/shared/terminal-input-kind.ts b/src/shared/terminal-input-kind.ts new file mode 100644 index 00000000000..f9cef0e2a18 --- /dev/null +++ b/src/shared/terminal-input-kind.ts @@ -0,0 +1,8 @@ +/** + * Why bytes are written to a PTY. Every host write entry point takes one, so no producer inherits + * a transport's default. + * - `driving`: input that drives the run, from a person or any client, driver or agent acting on it. + * - `launch`: bytes Orca writes to start the run: startup commands, a create-time draft or prompt. + * - `query-reply`: the terminal's automatic answer to a query the running program made. + */ +export type TerminalInputKind = 'driving' | 'launch' | 'query-reply'