mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+47
-10
@@ -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
|
||||
|
||||
+99
-27
@@ -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
|
||||
|
||||
+65
-4
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user