From c28041da6fc22caf14580c72eccc72eb928bb4d5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:34:39 -0700 Subject: [PATCH] Add insertion-only quick commands that don't immediately submit Enter - Quick commands with appendEnter: false skip the automatic Enter submit, keeping the pasted command editable before the user presses Enter - Add retry logic with exponential backoff for startup delivery failures, with max 5 attempts per PTY to handle transient write rejections - Implement sendInputSettled for durable one-shot SSH delivery that waits for relay settlement before acknowledging the write - Track delivery completion with onQueuedStartupDelivered callback so callers know when startup paste is done --- src/main/ipc/pty-write-ipc-validation.test.ts | 64 ++++++++- src/main/ipc/pty/ipc/write-input.ts | 50 +++++-- ...onnection-startup-command-delivery.test.ts | 57 ++++++-- .../terminal-pane/pty-connection-types.ts | 4 + .../deferred-cold-restore-and-snapshot.ts | 126 ++++++++++++++---- .../pty-connection/fresh-spawn-start.ts | 11 +- .../pty-connection/fresh-spawn-types.ts | 1 + .../pty-connection/pane-serializer-settle.ts | 5 +- .../pty-transport-input-write.test.ts | 2 + .../terminal-pane/pty-transport-types.ts | 2 + .../components/terminal-pane/pty-transport.ts | 14 +- .../terminal-paste-coordinator.test.ts | 19 +++ .../terminal-pane/terminal-paste-executor.ts | 38 ++++++ .../terminal-pty-paste-writer.test.ts | 14 ++ .../terminal-pty-paste-writer.ts | 15 ++- .../terminal-startup-command-paste.ts | 16 ++- .../use-terminal-pane-lifecycle.ts | 7 +- .../TerminalQuickCommandDialog.test.tsx | 69 +++++++++- .../lib/run-quick-command-in-new-tab.test.ts | 28 +++- .../src/lib/run-quick-command-in-new-tab.ts | 11 +- .../src/store/terminals/terminal-actions.ts | 2 + .../src/store/terminals/terminal-state.ts | 1 + ...minal-quick-command-insertion-only.spec.ts | 82 ++++++++++++ ...al-quick-command-pre-bind-recovery.spec.ts | 5 +- 24 files changed, 569 insertions(+), 74 deletions(-) create mode 100644 tests/e2e/terminal-quick-command-insertion-only.spec.ts diff --git a/src/main/ipc/pty-write-ipc-validation.test.ts b/src/main/ipc/pty-write-ipc-validation.test.ts index bf6082a2c07..b313dc3f725 100644 --- a/src/main/ipc/pty-write-ipc-validation.test.ts +++ b/src/main/ipc/pty-write-ipc-validation.test.ts @@ -9,7 +9,14 @@ import { import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { registerPtyHandlers, deletePtyOwnership, setLocalPtyProvider } from './pty' +import { + registerPtyHandlers, + deletePtyOwnership, + registerSshPtyProvider, + setLocalPtyProvider, + setPtyOwnership, + unregisterSshPtyProvider +} from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -90,6 +97,61 @@ describe('registerPtyHandlers', () => { ).toBe(false) expect(mockProc.proc.write).toHaveBeenCalledTimes(1) }) + it('propagates an explicit local provider write rejection', async () => { + const id = 'local-rejected-write' + const write = vi.fn(() => false) + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ hasPty: vi.fn(() => true), write } as never) + setPtyOwnership(id, null) + + try { + expect(handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { id, data: 'draft' })).toBe( + false + ) + expect(write).toHaveBeenCalledWith(id, 'draft') + } finally { + deletePtyOwnership(id) + } + }) + it('waits for local daemon settlement when the provider supports it', async () => { + const id = 'local-daemon-settlement' + const write = vi.fn(() => true) + const writeWithSettlement = vi.fn().mockResolvedValue(false) + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ hasPty: vi.fn(() => true), write, writeWithSettlement } as never) + setPtyOwnership(id, null) + + try { + await expect( + handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { id, data: 'draft' }) + ).resolves.toBe(false) + expect(writeWithSettlement).toHaveBeenCalledWith(id, 'draft') + expect(write).not.toHaveBeenCalled() + } finally { + deletePtyOwnership(id) + } + }) + it('waits for SSH relay settlement before acknowledging a durable write', async () => { + const id = 'ssh:ssh-settlement@@pty-1' + const writeWithSettlement = vi.fn().mockResolvedValue(false) + registerSshPtyProvider('ssh-settlement', { + hasPty: vi.fn(() => true), + write: vi.fn(), + writeWithSettlement + } as never) + setPtyOwnership(id, 'ssh-settlement') + registerPtyHandlers(mainWindow as never) + + try { + await expect( + handlers.get('pty:writeAccepted')!(mainWindowIpcEvent, { id, data: 'draft' }) + ).resolves.toBe(false) + expect(writeWithSettlement).toHaveBeenCalledWith(id, 'draft') + } finally { + deletePtyOwnership(id) + unregisterSshPtyProvider('ssh-settlement') + } + }) it('asks the renderer to remount when the provider rejects a stale daemon write', async () => { const write = vi.fn(() => { throw new PtyWriteUnavailableError('daemon generation lost') diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index fbfc09d1d4d..fbdad7ad376 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -65,13 +65,11 @@ export function createPtyWriteInput(deps: { const chunks = iterateTerminalInputChunks(data) const first = chunks.next() if (first.done) { - provider.write(id, data) - return true + return provider.write(id, data) !== false } const second = chunks.next() if (second.done) { - provider.write(id, first.value) - return true + return provider.write(id, first.value) !== false } return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) } @@ -98,6 +96,37 @@ export function createPtyWriteInput(deps: { } } + const writePtyProviderInputWithSettlement = async ( + provider: IPtyProvider, + id: string, + data: string + ): Promise => { + if (!provider.writeWithSettlement) { + return false + } + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) { + return false + } + const chunks = iterateTerminalInputChunks(data) + let chunk = chunks.next() + while (!chunk.done) { + if (!(await provider.writeWithSettlement(id, chunk.value))) { + return false + } + chunk = chunks.next() + if (!chunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + return true + } catch (error) { + reportUnavailablePtyWrite(id, error) + return false + } + } + const writePtyProviderInputChunks = async ( provider: IPtyProvider, id: string, @@ -109,7 +138,9 @@ export function createPtyWriteInput(deps: { let chunk: IteratorResult = { done: false, value: firstChunk } let nextChunk: IteratorResult = { done: false, value: secondChunk } while (!chunk.done) { - provider.write(id, chunk.value) + if (provider.write(id, chunk.value) === false) { + return false + } if (!nextChunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) } @@ -173,8 +204,8 @@ export function createPtyWriteInput(deps: { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } - // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. - if (ptyOwnership.get(args.id) !== null) { + const owner = ptyOwnership.get(args.id) + if (owner === undefined) { return false } const provider = tryGetProviderForPty(args.id) @@ -188,7 +219,10 @@ export function createPtyWriteInput(deps: { if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } - return writePtyProviderInput(provider, args.id, args.data) + if (provider.writeWithSettlement) { + return writePtyProviderInputWithSettlement(provider, args.id, args.data) + } + return owner === null ? writePtyProviderInput(provider, args.id, args.data) : false } catch { return false } 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..2ce2ab519aa 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 @@ -1,6 +1,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' +import { wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' import { makePaneKey } from '../../../../shared/stable-pane-id' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import { flushAsyncTicks, drainPendingTimeouts } from './pty-connection-test-async' @@ -478,7 +479,7 @@ describe('connectPanePty', () => { ) }) - it('keeps SSH terminal-paste startup commands renderer-owned', async () => { + it('retries insertion-only local startup paste after a silent-prompt rejection', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -491,10 +492,12 @@ describe('connectPanePty', () => { const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport('pty-id') + let acceptInput = false + transport.sendInput.mockImplementation(() => acceptInput) transport.connect.mockImplementation( async ({ callbacks }: { callbacks: ConnectCallbacks }) => { capturedDataCallback.current = callbacks.onData ?? null - return 'pty-ssh-paste' + return 'pty-local-paste' } ) transportFactoryQueue.push(transport) @@ -502,8 +505,7 @@ describe('connectPanePty', () => { mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - repos: [{ id: 'repo1', connectionId: 'ssh-conn-1' }], - sshConnectionStates: new Map([['ssh-conn-1', { status: 'connected' }]]) + repos: [{ id: 'repo1', connectionId: null }] } const pane = createPane(1) @@ -513,20 +515,37 @@ describe('connectPanePty', () => { }) const manager = createManager(1) const command = 'cd packages\nbun run build\ncd ..' - const deps = createDeps({ startup: { command, delivery: 'terminal-paste' } }) + const onQueuedStartupDelivered = vi.fn() + const deps = createDeps({ + startup: { command, submit: false, delivery: 'terminal-paste' }, + onQueuedStartupDelivered + }) connectPanePty(pane as never, manager as never, deps as never) expect(createdTransportOptions[0]?.command).toBeUndefined() expect(createdTransportOptions[0]?.commandDelivery).toBeUndefined() expect(capturedDataCallback.current).not.toBeNull() + expect(onQueuedStartupDelivered).not.toHaveBeenCalled() + await flushAsyncTicks(20) + await drainPendingTimeouts(pendingTimeouts) + await flushAsyncTicks() + + expect(pane.terminal.paste).not.toHaveBeenCalled() + expect(transport.sendInput).toHaveBeenCalled() + expect(transport.sendInput).not.toHaveBeenCalledWith('\r') + expect(transport.sendInput).not.toHaveBeenCalledWith(`${command}\r`) + expect(onQueuedStartupDelivered).not.toHaveBeenCalled() + + const retryStart = transport.sendInput.mock.calls.length + acceptInput = true capturedDataCallback.current?.('user@host $ ') await drainPendingTimeouts(pendingTimeouts) await flushAsyncTicks() - expect(pane.terminal.paste).toHaveBeenCalledWith(command) - expect(transport.sendInput).toHaveBeenCalledWith('\r') - expect(transport.sendInput).not.toHaveBeenCalledWith(`${command}\r`) + const retryInput = transport.sendInput.mock.calls.slice(retryStart).map((call) => call[0]) + expect(retryInput.join('')).toBe(wrapTerminalBracketedPasteText(command)) + expect(onQueuedStartupDelivered).toHaveBeenCalledTimes(1) } finally { globalThis.setTimeout = originalSetTimeout } @@ -600,9 +619,11 @@ describe('connectPanePty', () => { const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport('pty-id') let livePtyId = 'pty-local-paste' + let replaceOnNextDataWrite = true transport.getPtyId.mockImplementation(() => livePtyId) transport.sendInput.mockImplementation((data: string) => { - if (data !== '\r') { + if (data !== '\r' && replaceOnNextDataWrite) { + replaceOnNextDataWrite = false livePtyId = 'pty-replaced' } return true @@ -627,7 +648,11 @@ describe('connectPanePty', () => { }) const manager = createManager(1) const command = `${'x'.repeat(TERMINAL_PASTE_DIRECT_MAX_BYTES)}tail` - const deps = createDeps({ startup: { command, delivery: 'terminal-paste' } }) + const onQueuedStartupDelivered = vi.fn() + const deps = createDeps({ + startup: { command, delivery: 'terminal-paste' }, + onQueuedStartupDelivered + }) connectPanePty(pane as never, manager as never, deps as never) expect(capturedDataCallback.current).not.toBeNull() @@ -639,6 +664,18 @@ describe('connectPanePty', () => { expect(pane.terminal.paste).not.toHaveBeenCalled() expect(transport.sendInput).not.toHaveBeenCalledWith('\r') expect(transport.sendInput.mock.calls.map((call) => call[0]).join('')).not.toBe(command) + expect(onQueuedStartupDelivered).not.toHaveBeenCalled() + + const retryStart = transport.sendInput.mock.calls.length + livePtyId = 'pty-local-paste' + capturedDataCallback.current?.('user@host $ ') + await drainPendingTimeouts(pendingTimeouts) + await flushAsyncTicks() + + const retryInput = transport.sendInput.mock.calls.slice(retryStart).map((call) => call[0]) + expect(retryInput.at(-1)).toBe('\r') + expect(retryInput.slice(0, -1).join('')).toBe(command) + expect(onQueuedStartupDelivered).toHaveBeenCalledTimes(1) } finally { globalThis.setTimeout = originalSetTimeout } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 1fc9411cdfe..b0bfd1fc6d9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -19,6 +19,8 @@ import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-term export type PtyPaneStartup = { command: string + /** Keep renderer-delivered startup input editable instead of submitting Enter. */ + submit?: boolean /** Renderer-delivered startup input for callers that need xterm paste * semantics before the submit Enter. */ delivery?: 'terminal-paste' @@ -85,6 +87,8 @@ export type PtyConnectionDeps = { * delivery: Windows runs an argv-embedded command before this, and a POSIX shell can die * before the shell-ready write. */ onQueuedStartupSpawned?: () => void + /** Consumes renderer-owned startup only after its terminal paste completes. */ + onQueuedStartupDelivered?: () => void consumeSuppressedPtyExit: (ptyId: string) => boolean isPtyShutdownPending: (ptyId: string) => boolean updateTabTitle: (tabId: string, title: string) => void 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 28e0f95b9f7..4574301d276 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 @@ -19,6 +19,10 @@ import { bindStartFreshSpawn } from './fresh-spawn-start' import { bindFreshSpawnFollowReset } from './fresh-spawn-follow-reset' +const STARTUP_DELIVERY_MAX_ATTEMPTS_PER_PTY = 5 +const STARTUP_DELIVERY_RETRY_BASE_MS = 50 +const STARTUP_DELIVERY_RETRY_MAX_MS = 2_000 + export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySession): void { session.applyColdRestoreAgentResumeStartup = ( startup: ColdRestoreAgentResumeStartup | null @@ -71,10 +75,14 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio !session.disposed && session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && session.transport.getPtyId() === ptyId - const runTerminalPasteStartupCommand = async (command: string): Promise => { + const runTerminalPasteStartupCommand = async ( + command: string, + submit: boolean | undefined + ): Promise<{ delivered: boolean; partial: boolean }> => { const ptyId = session.transport.getPtyId() const result = await executeTerminalStartupCommandPaste({ command, + submit, pane: session.pane, ptyId, runtime: resolveTerminalPasteRuntime({ @@ -89,42 +97,106 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio isTargetCurrent: isStartupPasteTargetCurrent }) if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { - return false + return { delivered: false, partial: result.chunksWritten > 0 } + } + return { + delivered: true, + partial: false } - return session.transport.sendInput('\r') } + let startupDeliveryInFlight = false + let startupDeliveryRetryRequested = false + let startupDeliveryAttemptPtyId: string | null = null + let startupDeliveryAttempts = 0 session.schedulePendingStartupCommandDelivery = (): void => { const startup = session.pendingStartupCommand if (!startup) { return } + const currentPtyId = session.transport.getPtyId() + if (currentPtyId !== startupDeliveryAttemptPtyId) { + startupDeliveryAttemptPtyId = currentPtyId + startupDeliveryAttempts = 0 + } + if (startupDeliveryAttempts >= STARTUP_DELIVERY_MAX_ATTEMPTS_PER_PTY) { + return + } + if (startupDeliveryInFlight) { + startupDeliveryRetryRequested = true + return + } if (session.startupInjectTimer !== null) { clearTimeout(session.startupInjectTimer) } - session.startupInjectTimer = setTimeout(() => { - session.startupInjectTimer = null - void (async () => { - if (session.pendingStartupCommand !== startup || session.disposed) { - return - } - if (session.shouldDeliverStartupViaTerminalPaste) { - await waitForTerminalOutputParsed(session.pane.terminal) - } - if (session.pendingStartupCommand !== startup || session.disposed) { - return - } - const command = startup.command - const submitted = session.shouldDeliverStartupViaTerminalPaste - ? await runTerminalPasteStartupCommand(command) - : session.transport.sendInput(`${command}\r`) - if (submitted) { - session.armStartupDraftReadinessObservation() - } else { - session.releaseUnattemptedStartupDraftPasteDelivery() - } - session.pendingStartupCommand = null - })() - }, 50) + session.startupInjectTimer = setTimeout( + () => { + session.startupInjectTimer = null + void (async () => { + if (startupDeliveryInFlight) { + startupDeliveryRetryRequested = true + return + } + startupDeliveryInFlight = true + startupDeliveryRetryRequested = false + startupDeliveryAttempts += 1 + try { + if (session.pendingStartupCommand !== startup || session.disposed) { + return + } + if (session.shouldDeliverStartupViaTerminalPaste) { + await waitForTerminalOutputParsed(session.pane.terminal) + } + if (session.pendingStartupCommand !== startup || session.disposed) { + return + } + const command = startup.command + let outcome = { delivered: false, partial: false } + try { + outcome = session.shouldDeliverStartupViaTerminalPaste + ? await runTerminalPasteStartupCommand(command, startup.submit) + : { + delivered: + startup.submit === false + ? session.transport.sendInput(command) + : session.transport.sendInput(`${command}\r`), + partial: false + } + } catch { + outcome = { delivered: false, partial: false } + } + if (outcome.delivered) { + session.armStartupDraftReadinessObservation() + try { + session.deps.onQueuedStartupDelivered?.() + } catch { + // Do not invalidate a completed paste because queue cleanup failed. + } + if (session.pendingStartupCommand === startup) { + session.pendingStartupCommand = null + } + } else { + if (outcome.partial) { + startupDeliveryAttempts = STARTUP_DELIVERY_MAX_ATTEMPTS_PER_PTY + } + session.releaseUnattemptedStartupDraftPasteDelivery() + } + } finally { + startupDeliveryInFlight = false + if ( + startupDeliveryRetryRequested && + session.pendingStartupCommand === startup && + !session.disposed + ) { + session.schedulePendingStartupCommandDelivery() + } + } + })() + }, + Math.min( + STARTUP_DELIVERY_RETRY_BASE_MS * 2 ** startupDeliveryAttempts, + STARTUP_DELIVERY_RETRY_MAX_MS + ) + ) } session.freshSpawnFollowResetDisposables = [] diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index d60e94a160f..9b193ed16c0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -174,7 +174,6 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { ? spawnedPtyId : null if (connectResult?.isReattach) { - session.pendingStartupCommand = null const accepted = await session.handleReattachResult( connectResult, null, @@ -182,6 +181,9 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { outputCallbacks.generation ) session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) + if (accepted) { + session.schedulePendingStartupCommandDelivery() + } const gen = await preSignalPromise if (accepted && resolvedPtyId && typeof gen === 'number') { void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {}) @@ -261,14 +263,17 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {}) } } - if (resolvedPtyId && session.connectionId) { + if (resolvedPtyId) { if ( + session.connectionId && session.shouldUseProviderSshStartupDelivery && (startupOverride?.command || session.paneStartup?.command) ) { session.armStartupDraftReadinessObservation() } - session.schedulePendingStartupCommandDelivery() + if (session.pendingStartupCommand) { + session.schedulePendingStartupCommandDelivery() + } } session.finishReattachLiveDataDeferral(Boolean(resolvedPtyId), outputCallbacks.generation) return resolvedPtyId diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts index 4bb06f8dbde..ed82d65ae92 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts @@ -7,6 +7,7 @@ import type { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' export type PendingStartupCommand = { command: string + submit?: boolean env?: Record } 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..1826196e8b1 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 @@ -62,7 +62,10 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { // survives renderer replacement. Only explicit terminal-paste stays renderer-owned. session.pendingStartupCommand = session.shouldDeliverStartupViaTerminalPaste ? session.paneStartup?.command - ? { command: session.paneStartup.command } + ? { + command: session.paneStartup.command, + ...(session.paneStartup.submit === false ? { submit: false } : {}) + } : null : null const startupDraftReadyScanner = session.ownsStartupDraftPaste 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 b6d0f589ba3..914053ce192 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 @@ -71,6 +71,8 @@ describe('createIpcPtyTransport', () => { const sshTransport = createIpcPtyTransport({ connectionId: 'ssh-1' }) await sshTransport.connect({ url: '', callbacks: {} }) expect(sshTransport.sendInputAccepted).toBeUndefined() + await expect(sshTransport.sendInputSettled?.('startup draft')).resolves.toBe(true) + expect(window.api.pty.writeAccepted).toHaveBeenLastCalledWith('pty-1', 'startup draft') }) it('chunks large local IPC terminal input before renderer-to-main writes', async () => { 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 7db2a6e01f0..ad96758e502 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -179,6 +179,8 @@ export type PtyTransport = { // (preserving order) and sends the reply immediately. sendInputImmediate: (data: string) => boolean sendInputAccepted?: (data: string) => Promise + /** Reserved for durable one-shot delivery that must observe remote relay settlement. */ + sendInputSettled?: (data: string) => Promise claimViewport?: (cols: number, rows: number) => boolean /** Capability-negotiated paired-runtime delivery gate; false preserves legacy delivery. */ setOutputPaused?: (paused: boolean) => boolean diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 79f8e0d1e6d..37817c45ebe 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -152,7 +152,19 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra }, ...(connectionId - ? {} + ? { + async sendInputSettled(data: string): Promise { + if (!connected || !ptyId) { + return false + } + const id = ptyId + await inputWriteQueue.waitForDrain() + if (!connected || ptyId !== id) { + return false + } + return writeAcceptedIpcPtyInput(id, data, () => connected && ptyId === id) + } + } : { async sendInputAccepted(data: string): Promise { if (!connected || !ptyId) { diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts index e79099caec7..0709c11e63b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts @@ -500,6 +500,25 @@ describe('terminal paste coordinator', () => { expect(codePointAt.mock.calls.length).toBeLessThan(text.length) }) + it('combines a small bracketed chunked plan into one acknowledged write', async () => { + const plan = bracketedChunkedPlan() + const writePty = vi.fn().mockResolvedValue(true) + + const result = await executeTerminalPastePlan(plan, { + pasteText: vi.fn(), + writePty, + isTargetCurrent: () => true, + combineChunkedWritesUpToBytes: 64, + appendToChunkedWrite: '\r' + }) + + expect(result).toMatchObject({ status: 'pasted', chunksWritten: 1 }) + expect(writePty).toHaveBeenCalledOnce() + expect(writePty).toHaveBeenCalledWith( + `${BRACKETED_PASTE_START}0123456789abcdef${BRACKETED_PASTE_END}\r` + ) + }) + it('cancels before writing when the target changed during async clipboard read', async () => { const pasteText = vi.fn() const writePty = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts index 39a05f4dd24..226df841dce 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts @@ -7,6 +7,7 @@ import { } from './terminal-paste-limits' import { runTerminalPasteOperationWithTimeout } from './terminal-paste-operation-timeout' import { runTerminalPtyInputTransaction } from './terminal-pty-input-transaction' +import { getTerminalInputByteLength } from '../../../../shared/terminal-input' import type { TerminalPasteExecutionReason, TerminalPasteExecutionResult, @@ -21,6 +22,8 @@ type ExecuteTerminalPastePlanArgs = { canContinue?: () => boolean yieldToEventLoop?: () => Promise operationTimeoutMs?: number + combineChunkedWritesUpToBytes?: number + appendToChunkedWrite?: string now?: () => number } @@ -42,6 +45,8 @@ async function executeTerminalPastePlanNow( canContinue, yieldToEventLoop = defaultYieldToEventLoop, operationTimeoutMs = getTerminalPasteOperationTimeoutMs(plan), + combineChunkedWritesUpToBytes = 0, + appendToChunkedWrite = '', now = defaultNow }: ExecuteTerminalPastePlanArgs ): Promise { @@ -77,6 +82,20 @@ async function executeTerminalPastePlanNow( return finish('rejected', 0, 'pty-writer-unavailable') } + if (plan.payload.byteLength <= combineChunkedWritesUpToBytes) { + const combined = `${[...iterateTerminalPastePlanChunks(plan)].join('')}${appendToChunkedWrite}` + if (getTerminalInputByteLength(combined) <= combineChunkedWritesUpToBytes) { + const writeResult = await runTerminalPasteOperationWithTimeout( + () => writePty(combined), + operationTimeoutMs + ) + if (writeResult.timedOut) { + return finish('cancelled', 0, 'operation-timeout') + } + return writeResult.value ? finish('pasted', 1) : finish('cancelled', 0, 'target-disconnected') + } + } + let chunksWritten = 0 let bracketedPasteOpen = false // Why: best-effort — a close that hangs or throws must not mask the real exit reason. @@ -134,6 +153,25 @@ async function executeTerminalPastePlanNow( } await yieldToEventLoop() } + if (appendToChunkedWrite) { + if (isTargetCurrent && !isTargetCurrent()) { + return { status: 'cancelled', reason: 'stale-target' } + } + if (canContinue && !canContinue()) { + return { status: 'cancelled', reason: 'target-disconnected' } + } + const appendResult = await runTerminalPasteOperationWithTimeout( + () => writePty(appendToChunkedWrite), + operationTimeoutMs + ) + if (appendResult.timedOut) { + return { status: 'cancelled', reason: 'operation-timeout' } + } + if (!appendResult.value) { + return { status: 'cancelled', reason: 'target-disconnected' } + } + chunksWritten += 1 + } return { status: 'pasted' } } 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..af6150f48c1 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 @@ -3,6 +3,20 @@ import { describe, expect, it, vi } from 'vitest' import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer' describe('terminal PTY paste writer', () => { + it('prefers remotely settled writes over renderer acknowledgements', async () => { + const sendInput = vi.fn().mockReturnValue(true) + const sendInputAccepted = vi.fn().mockResolvedValue(true) + const sendInputSettled = vi.fn().mockResolvedValue(false) + + await expect( + writeTerminalPastePtyInput({ sendInput, sendInputAccepted, sendInputSettled }, 'payload') + ).resolves.toBe(false) + + expect(sendInputSettled).toHaveBeenCalledWith('payload') + expect(sendInputAccepted).not.toHaveBeenCalled() + expect(sendInput).not.toHaveBeenCalled() + }) + it('prefers acknowledged PTY writes when available', async () => { const sendInput = vi.fn().mockReturnValue(true) const sendInputAccepted = vi.fn().mockResolvedValue(true) 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..ab916232bff 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,6 +1,9 @@ import type { PtyTransport } from './pty-transport' -type TerminalPastePtyWriter = Pick +type TerminalPastePtyWriter = Pick< + PtyTransport, + 'sendInput' | 'sendInputAccepted' | 'sendInputSettled' +> export function writeTerminalPastePtyInput( transport: TerminalPastePtyWriter | undefined, @@ -9,7 +12,11 @@ export function writeTerminalPastePtyInput( 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) + // Why: durable SSH delivery needs relay settlement; ordinary accepted writes + // intentionally remain unavailable there to preserve interactive latency. + return ( + transport.sendInputSettled?.(data) ?? + transport.sendInputAccepted?.(data) ?? + transport.sendInput(data) + ) } 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..1c8bfa23951 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 @@ -7,6 +7,7 @@ import { type TerminalPasteRuntime } from './terminal-paste-coordinator' import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../../shared/terminal-input' type StartupCommandPane = { id: number @@ -16,15 +17,17 @@ type StartupCommandPane = { type ExecuteTerminalStartupCommandPasteArgs = { command: string + submit?: boolean pane: StartupCommandPane ptyId: string | null runtime: TerminalPasteRuntime - transport: Pick + transport: Pick isTargetCurrent?: (ptyId: string | null) => boolean } export async function executeTerminalStartupCommandPaste({ command, + submit, pane, ptyId, runtime, @@ -42,13 +45,20 @@ export async function executeTerminalStartupCommandPaste({ ptyId, runtime }, - terminalBracketedPasteMode: pane.terminal.modes?.bracketedPasteMode === true + terminalBracketedPasteMode: pane.terminal.modes?.bracketedPasteMode === true, + // Startup ownership may be released only after an acknowledged PTY write; + // xterm's direct paste path cannot report transport rejection. + maxDirectBytes: 0 }) return executeTerminalPastePlan(plan, { pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), writePty: (data) => writeTerminalPastePtyInput(transport, data), isTargetCurrent: isCurrent, - canContinue: isCurrent + canContinue: isCurrent, + // One settled write avoids replaying a half-open bracketed frame after a + // transient disconnect. Larger payloads retain the bounded chunked path. + combineChunkedWritesUpToBytes: TERMINAL_INPUT_CHUNK_MAX_BYTES, + ...(submit === false ? {} : { appendToChunkedWrite: '\r' }) }) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 0f033c1c3c8..36b635a98ed 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -238,6 +238,7 @@ type UseTerminalPaneLifecycleDeps = { cwd?: string startup?: { command: string + submit?: boolean /** Startup input needing xterm paste semantics before the submit Enter. */ delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery @@ -1374,9 +1375,13 @@ export function useTerminalPaneLifecycle({ // synchronous step, so nothing can queue in between. () => useAppStore.getState().pendingStartupByTabId[tabId] === startup ) + const startupConsumeCallback = + startupWithSetupSplitWait?.delivery === 'terminal-paste' + ? { onQueuedStartupDelivered: onQueuedStartupSpawned } + : { onQueuedStartupSpawned } const panePtyBinding = connectPanePty(pane, manager, { ...ptyDeps, - ...(onQueuedStartupSpawned ? { onQueuedStartupSpawned } : {}), + ...(onQueuedStartupSpawned ? startupConsumeCallback : {}), // Why: spread order matters — spawnHints.cwd (source pane) must override ptyDeps.cwd (worktree root) so splits boot in the live cwd. ...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}), restoredPtyIdByLeafId: spawnHints?.ptyId diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index a23a3367b12..7a155d11bfb 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -10,12 +10,21 @@ const mountedRoots: Root[] = [] async function renderDialog( command: TerminalQuickCommand, - props: { defaultAdvancedOpen?: boolean } = {} -): Promise { + props: { + defaultAdvancedOpen?: boolean + onOpenChange?: ReturnType void>> + onSave?: ReturnType void>> + } = {} +): Promise<{ + onOpenChange: ReturnType void>> + onSave: ReturnType void>> +}> { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) mountedRoots.push(root) + const onOpenChange = props.onOpenChange ?? vi.fn<(open: boolean) => void>() + const onSave = props.onSave ?? vi.fn<(command: TerminalQuickCommand) => void>() await act(async () => { root.render( @@ -25,11 +34,24 @@ async function renderDialog( command={command} repos={[]} defaultAdvancedOpen={props.defaultAdvancedOpen} - onOpenChange={vi.fn()} - onSave={vi.fn()} + onOpenChange={onOpenChange} + onSave={onSave} /> ) }) + return { onOpenChange, onSave } +} + +async function click(element: HTMLElement): Promise { + await act(async () => element.click()) +} + +async function replaceTextareaValue(textarea: HTMLTextAreaElement, value: string): Promise { + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + valueSetter?.call(textarea, value) + textarea.dispatchEvent(new Event('input', { bubbles: true })) + }) } function findAnimatedRowContaining(text: string): HTMLElement { @@ -125,6 +147,45 @@ describe('TerminalQuickCommandDialog animation structure', () => { expect(document.body.textContent).not.toContain('Supports /goal, skills, paths') }) + it('keeps the command editable and saves insertion-only mode after toggling Enter off', async () => { + const { onOpenChange, onSave } = await renderDialog({ + id: 'qc-editable', + label: 'Start dev server', + action: 'terminal-command', + command: 'npm run dev', + appendEnter: true, + scope: { type: 'global' } + }) + const appendEnterSwitch = document.body.querySelector( + '[aria-label="Toggle append Enter"]' + ) + const textarea = document.body.querySelector( + 'textarea[aria-label="Command"]' + ) + const saveButton = Array.from(document.body.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Save') + ) + expect(appendEnterSwitch).not.toBeNull() + expect(textarea).not.toBeNull() + expect(saveButton).not.toBeUndefined() + + await click(appendEnterSwitch!) + expect(appendEnterSwitch?.getAttribute('aria-checked')).toBe('false') + await replaceTextareaValue(textarea!, 'npm run dev -- --watch') + expect(textarea?.value).toBe('npm run dev -- --watch') + await click(saveButton!) + + expect(onSave).toHaveBeenCalledWith({ + id: 'qc-editable', + label: 'Start dev server', + action: 'terminal-command', + command: 'npm run dev -- --watch', + appendEnter: false, + scope: { type: 'global' } + }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + it('hides append enter and shows agent toolbar hint in agent mode', async () => { await renderDialog({ id: 'qc-3', diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts index 0f7284a1cf6..9afc63f85ed 100644 --- a/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.test.ts @@ -58,7 +58,7 @@ describe('runQuickCommandInNewTab', () => { mocks.launchAgentInNewTab.mockReset() }) - it('flattens multiline quick commands before queuing', () => { + it('flattens multiline quick commands and appends Enter before queuing', () => { const result = runQuickCommandInNewTab({ command: { id: 'build', @@ -76,7 +76,7 @@ describe('runQuickCommandInNewTab', () => { quickCommandLabel: 'Build' }) expect(mockState.queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { - command: 'cd packages; bun run build; cd ..' + command: 'cd packages; bun run build; cd ..\r' }) expect(mockState.setRecentQuickCommandForGroup).toHaveBeenCalledWith('group-1', 'build') }) @@ -101,7 +101,7 @@ describe('runQuickCommandInNewTab', () => { ) }) - it('keeps single-line quick commands unchanged', () => { + it('keeps single-line quick commands and their submit setting', () => { runQuickCommandInNewTab({ command: { id: 'status', @@ -115,7 +115,27 @@ describe('runQuickCommandInNewTab', () => { }) expect(mockState.queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { - command: 'git status' + command: 'git status\r' + }) + }) + + it('does not append Enter when the quick command is insertion-only', () => { + runQuickCommandInNewTab({ + command: { + id: 'status', + label: 'Status', + action: 'terminal-command', + command: 'git status', + appendEnter: false + }, + worktreeId: 'wt-1', + groupId: 'group-1' + }) + + expect(mockState.queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { + command: 'git status', + submit: false, + delivery: 'terminal-paste' }) }) diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.ts index 1d822ad8fc2..9fb9a6cd35d 100644 --- a/src/renderer/src/lib/run-quick-command-in-new-tab.ts +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.ts @@ -2,6 +2,7 @@ import { useAppStore } from '@/store' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { + buildTerminalQuickCommandInput, flattenTerminalQuickCommand, isTerminalAgentQuickCommand, supportsTerminalAgentQuickCommand @@ -40,10 +41,9 @@ function resolveQuickCommandGroupId( * the command runs (mirrors the agent quick-launch path in * `launchAgentInNewTab`). * - * Terminal-command quick commands always append Enter — the split-button is - * a "run" affordance, distinct from the right-click "Insert" mode where - * `appendEnter: false` is honored. Agent-prompt quick commands use the - * agent's normal prompt launch command instead of post-launch TUI paste. + * Terminal-command quick commands preserve their append-Enter setting. Agent- + * prompt quick commands use the agent's normal prompt launch command instead + * of post-launch TUI paste. */ export function runQuickCommandInNewTab({ command, @@ -88,7 +88,8 @@ export function runQuickCommandInNewTab({ }) store.queueTabStartupCommand(tab.id, { - command: flattenTerminalQuickCommand(command).command + command: buildTerminalQuickCommandInput(flattenTerminalQuickCommand(command)), + ...(command.appendEnter === false ? { submit: false, delivery: 'terminal-paste' as const } : {}) }) // Why: match `+` button's createNewTerminalTab — without this, a worktree diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 3779ea88cc0..b7af168503f 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -187,6 +187,7 @@ export type TerminalActions = { tabId: string, startup: { command: string + submit?: boolean delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record @@ -210,6 +211,7 @@ export type TerminalActions = { consumeTabInitialCwd: (tabId: string) => string | null consumeTabStartupCommand: (tabId: string) => { command: string + submit?: boolean delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 4d5cbea8256..aed939a76ee 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -51,6 +51,7 @@ export type TerminalState = { string, { command: string + submit?: boolean delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record diff --git a/tests/e2e/terminal-quick-command-insertion-only.spec.ts b/tests/e2e/terminal-quick-command-insertion-only.spec.ts new file mode 100644 index 00000000000..f7e8dd2015d --- /dev/null +++ b/tests/e2e/terminal-quick-command-insertion-only.spec.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { stageNodeScriptForTerminal } from './helpers/run-node-script-in-terminal' +import { registerTerminalPaneMountReadiness } from './helpers/terminal-pane-mount-readiness' +import { focusActiveTerminalInput } from './helpers/terminal' + +test.describe('Quick Command insertion-only mode', () => { + registerTerminalPaneMountReadiness() + + test('leaves the command editable without submitting Enter', async ({ orcaPage }) => { + const resultPath = path.join(os.tmpdir(), `orca-quick-command-edit-${randomUUID()}.txt`) + const label = `Editable command ${randomUUID()}` + const staged = stageNodeScriptForTerminal( + `require('node:fs').writeFileSync(${JSON.stringify(resultPath)}, process.argv[2] || 'missing')` + ) + + try { + await orcaPage.evaluate( + async ({ command, label }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + await store.getState().updateSettings({ + terminalQuickCommands: [ + { + id: 'e2e-insertion-only', + label, + scope: { type: 'global' }, + action: 'terminal-command', + command, + appendEnter: false + } + ] + }) + }, + { command: staged.command, label } + ) + + const originalTabId = await orcaPage.evaluate( + () => window.__store?.getState().activeTabId ?? null + ) + const quickCommandButton = orcaPage.getByRole('button', { + name: `Run quick command: ${label}` + }) + await expect(quickCommandButton).toBeVisible() + await quickCommandButton.click() + + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeTabId ?? null)) + .not.toBe(originalTabId) + const quickCommandTabId = await orcaPage.evaluate( + () => window.__store?.getState().activeTabId ?? null + ) + expect(quickCommandTabId).not.toBeNull() + await expect + .poll( + () => + orcaPage.evaluate((tabId) => { + const pane = tabId ? window.__paneManagers?.get(tabId)?.getPanes()[0] : undefined + return pane?.serializeAddon.serialize() ?? '' + }, quickCommandTabId), + { message: 'Insertion-only Quick Command text never reached the visible terminal' } + ) + .toContain(staged.command) + + expect(existsSync(resultPath)).toBe(false) + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.type(' edited') + await orcaPage.keyboard.press('Enter') + + await expect.poll(() => existsSync(resultPath)).toBe(true) + expect(readFileSync(resultPath, 'utf8')).toBe('edited') + } finally { + staged.cleanup() + rmSync(resultPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts index c734301d33f..45bf922ddcd 100644 --- a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts +++ b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts @@ -77,6 +77,7 @@ writeFileSync(${JSON.stringify(identityPath)}, JSON.stringify(identity), { flag: process.stdout.write(${JSON.stringify(`${marker}\n`)}) `.trim() ) + const queuedCommand = `${staged.command}\r` try { await orcaPage.evaluate( @@ -138,7 +139,7 @@ process.stdout.write(${JSON.stringify(`${marker}\n`)}) tabId } }) - expect(blocked.pending).toBe(staged.command) + expect(blocked.pending).toBe(queuedCommand) expect(blocked.status).toBe('blocked') await orcaPage.evaluate((tabId) => { @@ -197,7 +198,7 @@ process.stdout.write(${JSON.stringify(`${marker}\n`)}) .toEqual({ generation: blocked.generation + 1, leafReady: true, - pending: staged.command, + pending: queuedCommand, expectedGeneration: blocked.generation + 1 }) await orcaPage.evaluate(() => window.__terminalPtyPreSpawnE2EBarrier?.release())