) {
+ 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