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
This commit is contained in:
Jinjing
2026-08-27 21:17:52 -07:00
parent c72afda498
commit c28041da6f
24 changed files with 569 additions and 74 deletions
+63 -1
View File
@@ -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')
+42 -8
View File
@@ -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<boolean> => {
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<string> = { done: false, value: firstChunk }
let nextChunk: IteratorResult<string> = { 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
}
@@ -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
}
@@ -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
@@ -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<boolean> => {
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 = []
@@ -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
@@ -7,6 +7,7 @@ import type { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
export type PendingStartupCommand = {
command: string
submit?: boolean
env?: Record<string, string>
}
@@ -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
@@ -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 () => {
@@ -179,6 +179,8 @@ export type PtyTransport = {
// (preserving order) and sends the reply immediately.
sendInputImmediate: (data: string) => boolean
sendInputAccepted?: (data: string) => Promise<boolean>
/** Reserved for durable one-shot delivery that must observe remote relay settlement. */
sendInputSettled?: (data: string) => Promise<boolean>
claimViewport?: (cols: number, rows: number) => boolean
/** Capability-negotiated paired-runtime delivery gate; false preserves legacy delivery. */
setOutputPaused?: (paused: boolean) => boolean
@@ -152,7 +152,19 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
},
...(connectionId
? {}
? {
async sendInputSettled(data: string): Promise<boolean> {
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<boolean> {
if (!connected || !ptyId) {
@@ -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()
@@ -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<void>
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<TerminalPasteExecutionResult> {
@@ -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' }
}
@@ -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)
@@ -1,6 +1,9 @@
import type { PtyTransport } from './pty-transport'
type TerminalPastePtyWriter = Pick<PtyTransport, 'sendInput' | 'sendInputAccepted'>
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)
)
}
@@ -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<PtyTransport, 'sendInput'>
transport: Pick<PtyTransport, 'sendInput' | 'sendInputAccepted' | 'sendInputSettled'>
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' })
})
}
@@ -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
@@ -10,12 +10,21 @@ const mountedRoots: Root[] = []
async function renderDialog(
command: TerminalQuickCommand,
props: { defaultAdvancedOpen?: boolean } = {}
): Promise<void> {
props: {
defaultAdvancedOpen?: boolean
onOpenChange?: ReturnType<typeof vi.fn<(open: boolean) => void>>
onSave?: ReturnType<typeof vi.fn<(command: TerminalQuickCommand) => void>>
} = {}
): Promise<{
onOpenChange: ReturnType<typeof vi.fn<(open: boolean) => void>>
onSave: ReturnType<typeof vi.fn<(command: TerminalQuickCommand) => 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<void> {
await act(async () => element.click())
}
async function replaceTextareaValue(textarea: HTMLTextAreaElement, value: string): Promise<void> {
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<HTMLElement>(
'[aria-label="Toggle append Enter"]'
)
const textarea = document.body.querySelector<HTMLTextAreaElement>(
'textarea[aria-label="Command"]'
)
const saveButton = Array.from(document.body.querySelectorAll<HTMLButtonElement>('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',
@@ -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'
})
})
@@ -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
@@ -187,6 +187,7 @@ export type TerminalActions = {
tabId: string,
startup: {
command: string
submit?: boolean
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
@@ -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<string, string>
@@ -51,6 +51,7 @@ export type TerminalState = {
string,
{
command: string
submit?: boolean
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
@@ -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 })
}
})
})
@@ -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())