From 8a185731cbc83b47caa77e72d3d76d12d786561c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:44:54 -0700 Subject: [PATCH] Support editable draft mode for agent quick commands Agent prompts can now defer execution by setting submitPrompt: false, allowing them to insert as editable drafts like terminal commands do. Older clients don't receive this field via capability negotiation, ensuring backward compatibility with mixed-version paired hosts. --- .../runtime/rpc/methods/client-ui.test.ts | 2 + src/main/runtime/rpc/methods/client-ui.ts | 15 ++-- ...al-quick-command-client-projection.test.ts | 38 +++++++++++ ...erminal-quick-command-client-projection.ts | 24 +++++++ .../terminal-quick-command-rpc-schema.ts | 1 + .../components/settings/QuickCommandsPane.tsx | 1 + .../tab-bar/TabBarQuickCommandsButton.tsx | 2 + .../components/terminal-pane/TerminalPane.tsx | 1 + .../TerminalQuickCommandAppendEnterSwitch.tsx | 62 ----------------- .../TerminalQuickCommandContentSection.tsx | 22 +++--- .../TerminalQuickCommandDialog.test.tsx | 68 +++++++++++++++++-- .../TerminalQuickCommandDialog.tsx | 50 ++++++++++---- .../TerminalQuickCommandSubmissionSwitch.tsx | 42 ++++++++++++ ...erminal-quick-command-dialog-draft.test.ts | 8 ++- .../terminal-quick-command-dialog-draft.ts | 13 ++-- .../lib/run-quick-command-in-new-tab.test.ts | 31 +++++++++ .../src/lib/run-quick-command-in-new-tab.ts | 1 + src/shared/protocol-version.ts | 5 ++ src/shared/terminal-quick-command-types.ts | 2 + src/shared/terminal-quick-commands.test.ts | 24 ++++++- src/shared/terminal-quick-commands.ts | 12 +++- 21 files changed, 321 insertions(+), 103 deletions(-) create mode 100644 src/main/runtime/rpc/methods/terminal-quick-command-client-projection.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal-quick-command-client-projection.ts delete mode 100644 src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx create mode 100644 src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandSubmissionSwitch.tsx diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3bfb7303bd0..5d30ceac500 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -184,6 +184,7 @@ describe('client UI RPC methods', () => { action: 'agent-prompt' as const, agent: 'codex' as const, prompt: 'Review this diff', + submitPrompt: false as const, scope: { type: 'global' as const } } ] @@ -205,6 +206,7 @@ describe('client UI RPC methods', () => { action: 'agent-prompt', agent: 'codex', prompt: 'Review this diff\n', + submitPrompt: false, scope: { type: 'global' } } } diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 4161064be01..6bd8f803b71 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -7,6 +7,7 @@ import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas' // the typecheck graph so drift fails the build instead of a paired client. import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema' +import { projectTerminalQuickCommandsForClient } from './terminal-quick-command-client-projection' export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ @@ -26,15 +27,21 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ params: null, // Why: command bodies can total ~240 KB, so keep unrelated settings reads // from carrying them over every paired/relay connection. - handler: (_params, { runtime }) => ({ - terminalQuickCommands: runtime.getClientTerminalQuickCommands() + handler: (_params, { runtime, clientCapabilities }) => ({ + terminalQuickCommands: projectTerminalQuickCommandsForClient( + runtime.getClientTerminalQuickCommands(), + clientCapabilities + ) }) }), defineMethod({ name: 'settings.updateTerminalQuickCommands', params: TerminalQuickCommandsUpdate, - handler: (params, { runtime }) => ({ - terminalQuickCommands: runtime.updateClientTerminalQuickCommands(params.mutation) + handler: (params, { runtime, clientCapabilities }) => ({ + terminalQuickCommands: projectTerminalQuickCommandsForClient( + runtime.updateClientTerminalQuickCommands(params.mutation), + clientCapabilities + ) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal-quick-command-client-projection.test.ts b/src/main/runtime/rpc/methods/terminal-quick-command-client-projection.test.ts new file mode 100644 index 00000000000..ab78c9e9215 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-quick-command-client-projection.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' +import { projectTerminalQuickCommandsForClient } from './terminal-quick-command-client-projection' + +const commands: TerminalQuickCommand[] = [ + { + id: 'review', + label: 'Review', + action: 'agent-prompt', + agent: 'codex', + prompt: 'Review this diff', + submitPrompt: false + } +] + +describe('projectTerminalQuickCommandsForClient', () => { + it('preserves Agent draft state for current and capable clients', () => { + expect(projectTerminalQuickCommandsForClient(commands, undefined)).toEqual(commands) + expect( + projectTerminalQuickCommandsForClient(commands, [ + TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY + ]) + ).toEqual(commands) + }) + + it('omits Agent draft state for older exact-shape clients', () => { + expect(projectTerminalQuickCommandsForClient(commands, [])).toEqual([ + { + id: 'review', + label: 'Review', + action: 'agent-prompt', + agent: 'codex', + prompt: 'Review this diff' + } + ]) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-quick-command-client-projection.ts b/src/main/runtime/rpc/methods/terminal-quick-command-client-projection.ts new file mode 100644 index 00000000000..6f6efa897b9 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-quick-command-client-projection.ts @@ -0,0 +1,24 @@ +import { TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeCapability } from '../../../../shared/protocol-version' +import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' +import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' + +export function projectTerminalQuickCommandsForClient( + commands: readonly TerminalQuickCommand[], + clientCapabilities: readonly RuntimeCapability[] | undefined +): TerminalQuickCommand[] { + if ( + clientCapabilities === undefined || + clientCapabilities.includes(TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY) + ) { + return [...commands] + } + return commands.map((command) => { + if (!isTerminalAgentQuickCommand(command)) { + return command + } + const { submitPrompt: _submitPrompt, ...legacyCommand } = command + void _submitPrompt + return legacyCommand + }) +} diff --git a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts index 18661f10b57..311cba94cad 100644 --- a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts +++ b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts @@ -40,6 +40,7 @@ const TerminalQuickCommandUpdateItem = z.union([ message: 'Agent does not support prompt commands' }), prompt: z.string().max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH), + submitPrompt: z.boolean().optional(), scope: TerminalQuickCommandScopeUpdate.optional() }) .strict() diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx index 2305a034227..351c70c11dc 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.tsx +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -394,6 +394,7 @@ export function QuickCommandsPane({ open mode={editor.mode} command={editor.command} + hostId={editor.hostId} repos={hostRepos} defaultAdvancedOpen onOpenChange={(open) => !open && setEditor(null)} diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index ae831a418f9..378a0dd7066 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -203,6 +203,7 @@ export function TabBarQuickCommandsButton({ open={editor !== null} mode={editor?.mode ?? 'add'} command={editor?.command ?? createTerminalQuickCommandDraft({ type: 'repo', repoId })} + hostId={editor?.hostId ?? defaultHostId} repos={editorRepos} onOpenChange={(open) => !open && setEditor(null)} onSave={handleSaveCommand} @@ -232,6 +233,7 @@ export function TabBarQuickCommandsButton({ open={editor !== null} mode={editor?.mode ?? 'add'} command={editor?.command ?? createTerminalQuickCommandDraft({ type: 'repo', repoId })} + hostId={editor?.hostId ?? defaultHostId} repos={editorRepos} onOpenChange={(open) => !open && setEditor(null)} onSave={handleSaveCommand} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2b58732f0ea..acaf592fd35 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -294,6 +294,7 @@ function TerminalQuickCommandEditorDialog({ open mode="add" command={command} + hostId={hostId} repos={hostRepos} onOpenChange={onOpenChange} onSave={onSave} diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx deleted file mode 100644 index 187fa402207..00000000000 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAppendEnterSwitch.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { translate } from '@/i18n/i18n' -import { Switch } from '@/components/ui/switch' - -type TerminalQuickCommandAppendEnterSwitchProps = { - appendEnter: boolean - onToggle: () => void - compact?: boolean -} - -export function TerminalQuickCommandAppendEnterSwitch({ - appendEnter, - onToggle, - compact = false -}: TerminalQuickCommandAppendEnterSwitchProps): React.JSX.Element { - if (compact) { - return ( - - ) - } - - return ( -
-
-
- {translate( - 'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.5fa607d807', - 'Append Enter' - )} -
-
- {translate( - 'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.c936c2d6d2', - 'Submit immediately instead of only inserting text.' - )} -
-
- -
- ) -} diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx index c56a77a4ca4..44f5751c7f6 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandContentSection.tsx @@ -18,7 +18,7 @@ import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { getTerminalQuickCommandAgentOptions } from './terminal-quick-command-agent-options' import type { TerminalQuickCommandDialogDraftMemory } from './terminal-quick-command-dialog-draft' -import { TerminalQuickCommandAppendEnterSwitch } from './TerminalQuickCommandAppendEnterSwitch' +import { TerminalQuickCommandSubmissionSwitch } from './TerminalQuickCommandSubmissionSwitch' const QUICK_COMMAND_AGENT_OPTIONS = getTerminalQuickCommandAgentOptions() @@ -26,18 +26,20 @@ type TerminalQuickCommandContentSectionProps = { draft: TerminalQuickCommand isAgentAction: boolean selectedAgent: TuiAgent + agentDraftsSupported: boolean draftMemoryRef: MutableRefObject setDraft: Dispatch> - toggleAppendEnter: () => void + toggleImmediateSubmission: () => void } export function TerminalQuickCommandContentSection({ draft, isAgentAction, selectedAgent, + agentDraftsSupported, draftMemoryRef, setDraft, - toggleAppendEnter + toggleImmediateSubmission }: TerminalQuickCommandContentSectionProps): React.JSX.Element { const commandText = isTerminalAgentQuickCommand(draft) ? draft.prompt : draft.command // Why: the frame header is a plain span, so the textarea carries the accessible name itself. @@ -189,11 +191,15 @@ export function TerminalQuickCommandContentSection({ />
- {!isTerminalAgentQuickCommand(draft) ? ( - ) : ( 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 7a155d11bfb..334b5104060 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -3,7 +3,11 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' const mountedRoots: Root[] = [] @@ -12,6 +16,7 @@ async function renderDialog( command: TerminalQuickCommand, props: { defaultAdvancedOpen?: boolean + hostId?: ExecutionHostId onOpenChange?: ReturnType void>> onSave?: ReturnType void>> } = {} @@ -32,6 +37,7 @@ async function renderDialog( open={true} mode="add" command={command} + hostId={props.hostId ?? 'local'} repos={[]} defaultAdvancedOpen={props.defaultAdvancedOpen} onOpenChange={onOpenChange} @@ -67,6 +73,7 @@ function findAnimatedRowContaining(text: string): HTMLElement { describe('TerminalQuickCommandDialog animation structure', () => { beforeEach(() => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) }) afterEach(async () => { @@ -186,18 +193,71 @@ describe('TerminalQuickCommandDialog animation structure', () => { expect(onOpenChange).toHaveBeenCalledWith(false) }) - it('hides append enter and shows agent toolbar hint in agent mode', async () => { - await renderDialog({ + it('saves an agent prompt as an editable draft when immediate submission is toggled off', async () => { + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + [ + 'env-new', + { + checkedAt: 1, + status: { + capabilities: [TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY] + } + } + ] + ]) as AppState['runtimeStatusByEnvironmentId'] + }) + const { onOpenChange, onSave } = await renderDialog( + { + id: 'qc-3', + label: 'Investigate', + action: 'agent-prompt', + agent: 'claude', + prompt: 'Look into the build', + scope: { type: 'global' } + }, + { hostId: 'runtime:env-new' } + ) + const submitPromptSwitch = document.body.querySelector( + '[aria-label="Toggle immediate prompt submission"]' + ) + const saveButton = Array.from(document.body.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Save') + ) + + expect(document.body.textContent).toContain('Supports /goal, skills, paths') + expect(document.body.textContent).toContain('Submit prompt — run immediately') + expect(submitPromptSwitch?.getAttribute('aria-checked')).toBe('true') + + await click(submitPromptSwitch!) + await click(saveButton!) + + expect(onSave).toHaveBeenCalledWith({ id: 'qc-3', label: 'Investigate', action: 'agent-prompt', agent: 'claude', prompt: 'Look into the build', + submitPrompt: false, scope: { type: 'global' } }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) - expect(document.body.textContent).toContain('Supports /goal, skills, paths') - expect(document.body.textContent).not.toContain('Append Enter — run immediately') + it('hides agent draft submission controls for older remote hosts', async () => { + await renderDialog( + { + id: 'qc-legacy-agent', + label: 'Investigate', + action: 'agent-prompt', + agent: 'claude', + prompt: 'Look into the build' + }, + { hostId: 'runtime:env-old' } + ) + + expect(document.body.textContent).not.toContain('Submit prompt — run immediately') + expect(document.body.textContent).toContain('Multi-line prompts are fine — keep them focused.') }) it('shows scope summary on the collapsed advanced toggle', async () => { diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index c0d5a26c2e1..7f959c08dd4 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -5,6 +5,8 @@ import type { } from '../../../../shared/terminal-quick-command-types' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { getTerminalQuickCommandAction, getTerminalQuickCommandScope, @@ -33,6 +35,7 @@ import { switchTerminalQuickCommandDialogAction } from './terminal-quick-command-dialog-draft' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' type TerminalQuickCommandDialogMode = 'add' | 'edit' @@ -40,6 +43,7 @@ type TerminalQuickCommandDialogProps = { open: boolean mode: TerminalQuickCommandDialogMode command: TerminalQuickCommand + hostId: ExecutionHostId repos?: readonly Pick[] /** Settings has no ambient workspace to imply scope from, so it opens the * Advanced section up front. In-workspace entry points leave it collapsed. */ @@ -66,11 +70,21 @@ export function TerminalQuickCommandDialog({ open, mode, command, + hostId, repos = EMPTY_REPOS, defaultAdvancedOpen = false, onOpenChange, onSave }: TerminalQuickCommandDialogProps): React.JSX.Element { + const host = parseExecutionHostId(hostId) + const agentDraftsSupported = useAppStore( + (state) => + host?.kind !== 'runtime' || + state.runtimeStatusByEnvironmentId + .get(host.environmentId) + ?.status?.capabilities?.includes(TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY) === + true + ) const fallbackAgent: TuiAgent = getAgentCatalog().find((entry) => supportsTerminalAgentQuickCommand(entry.id))?.id ?? 'claude' const [draft, setDraft] = useState(command) @@ -115,19 +129,25 @@ export function TerminalQuickCommandDialog({ }) } - const toggleAppendEnter = (): void => { - setDraft((current) => - isTerminalAgentQuickCommand(current) - ? current - : (() => { - const appendEnter = !current.appendEnter - draftMemoryRef.current = { - ...draftMemoryRef.current, - terminalAppendEnter: appendEnter - } - return { ...current, appendEnter } - })() - ) + const toggleImmediateSubmission = (): void => { + if (isTerminalAgentQuickCommand(draft)) { + const submitPrompt = draft.submitPrompt === false + draftMemoryRef.current = { ...draftMemoryRef.current, agentSubmitPrompt: submitPrompt } + if (!submitPrompt) { + setDraft({ ...draft, submitPrompt: false }) + return + } + const { submitPrompt: _submitPrompt, ...submitted } = draft + void _submitPrompt + setDraft(submitted) + return + } + const appendEnter = !draft.appendEnter + draftMemoryRef.current = { + ...draftMemoryRef.current, + terminalAppendEnter: appendEnter + } + setDraft({ ...draft, appendEnter }) } const saveDraft = (): void => { @@ -138,6 +158,7 @@ export function TerminalQuickCommandDialog({ action: 'agent-prompt', agent: draft.agent, prompt: draft.prompt.trimEnd(), + ...(draft.submitPrompt === false ? { submitPrompt: false as const } : {}), scope: selectedScope } : { @@ -233,9 +254,10 @@ export function TerminalQuickCommandDialog({ draft={draft} isAgentAction={isAgentAction} selectedAgent={selectedAgent} + agentDraftsSupported={agentDraftsSupported} draftMemoryRef={draftMemoryRef} setDraft={setDraft} - toggleAppendEnter={toggleAppendEnter} + toggleImmediateSubmission={toggleImmediateSubmission} /> void +} + +export function TerminalQuickCommandSubmissionSwitch({ + action, + checked, + onToggle +}: TerminalQuickCommandSubmissionSwitchProps): React.JSX.Element { + const isAgentPrompt = action === 'agent-prompt' + const ariaLabel = isAgentPrompt + ? translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandSubmissionSwitch.toggle_prompt', + 'Toggle immediate prompt submission' + ) + : translate( + 'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.e4e5fed3b3', + 'Toggle append Enter' + ) + + return ( + + ) +} diff --git a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts index becc121fb15..e6188d60ccd 100644 --- a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts +++ b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts @@ -59,7 +59,7 @@ describe('terminal quick command dialog draft transitions', () => { }) }) - it('preserves independent text, agent, and append-enter drafts across toggles', () => { + it('preserves independent text and submission drafts across toggles', () => { const initial: TerminalQuickCommand = { id: 'qc-1', label: 'Work', @@ -74,7 +74,8 @@ describe('terminal quick command dialog draft transitions', () => { ...toAgent.draft, action: 'agent-prompt', agent: 'codex', - prompt: 'Investigate failures' + prompt: 'Investigate failures', + submitPrompt: false } const backToTerminal = switchTerminalQuickCommandDialogAction( editedAgent, @@ -101,7 +102,8 @@ describe('terminal quick command dialog draft transitions', () => { expect(backToAgent.draft).toMatchObject({ action: 'agent-prompt', agent: 'codex', - prompt: 'Investigate failures' + prompt: 'Investigate failures', + submitPrompt: false }) const finalTerminal = switchTerminalQuickCommandDialogAction( diff --git a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.ts b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.ts index 01dc86367c7..49191c7ef8c 100644 --- a/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.ts +++ b/src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.ts @@ -12,6 +12,7 @@ export type TerminalQuickCommandDialogDraftMemory = { terminalAppendEnter: boolean agent: TuiAgent agentPrompt: string + agentSubmitPrompt: boolean } export function createTerminalQuickCommandDialogDraftMemory( @@ -23,14 +24,16 @@ export function createTerminalQuickCommandDialogDraftMemory( terminalCommand: '', terminalAppendEnter: true, agent: command.agent, - agentPrompt: command.prompt + agentPrompt: command.prompt, + agentSubmitPrompt: command.submitPrompt !== false } } return { terminalCommand: command.command, terminalAppendEnter: command.appendEnter, agent: fallbackAgent, - agentPrompt: '' + agentPrompt: '', + agentSubmitPrompt: true } } @@ -42,7 +45,8 @@ export function rememberTerminalQuickCommandDialogDraft( return { ...memory, agent: draft.agent, - agentPrompt: draft.prompt + agentPrompt: draft.prompt, + agentSubmitPrompt: draft.submitPrompt !== false } } return { @@ -76,7 +80,8 @@ export function switchTerminalQuickCommandDialogAction( ...base, action: 'agent-prompt', agent: nextMemory.agent, - prompt: nextMemory.agentPrompt + prompt: nextMemory.agentPrompt, + ...(nextMemory.agentSubmitPrompt ? {} : { submitPrompt: false as const }) } } } 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 6237537af39..13df5c0401d 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 @@ -170,6 +170,37 @@ describe('runQuickCommandInNewTab', () => { expect(mockState.setRecentQuickCommandForGroup).toHaveBeenCalledWith('group-1', 'agent-review') }) + it('launches insertion-only agent prompts as editable drafts', () => { + mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-agent' }) + mockState.unifiedTabsByWorktree['repo::worktree'] = [ + { entityId: 'tab-agent', contentType: 'terminal', groupId: 'group-1' } + ] + + const result = runQuickCommandInNewTab({ + command: { + id: 'agent-review-draft', + label: 'Review draft', + action: 'agent-prompt', + agent: 'codex', + prompt: 'Review this diff', + submitPrompt: false + }, + worktreeId: 'repo::worktree', + groupId: 'group-1' + }) + + expect(result).toEqual({ tabId: 'tab-agent' }) + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({ + agent: 'codex', + prompt: 'Review this diff', + worktreeId: 'repo::worktree', + groupId: 'group-1', + promptDelivery: 'draft', + launchSource: 'quick_command', + quickCommandLabel: 'Review draft' + }) + }) + it('falls back to the active group when context-menu group resolution is missing', () => { mockState.activeGroupIdByWorktree['repo::worktree'] = 'active-group' mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-agent' }) 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 fd88a3f5bbf..64be2d5040a 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 @@ -60,6 +60,7 @@ export function runQuickCommandInNewTab({ prompt: command.prompt, worktreeId, groupId: targetGroupId, + ...(command.submitPrompt === false ? { promptDelivery: 'draft' as const } : {}), launchSource: 'quick_command', quickCommandLabel: command.label }) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 9a262cafd7e..fb15700df65 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -96,6 +96,9 @@ export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parki // Why: older hosts lack the targeted settings RPCs and strip agentPrompt from // terminal creation, so mobile must hide Quick Commands unless both are present. export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-commands.v1' as const +// Why: older clients reject unknown Quick Command keys, so hosts must omit Agent draft state unless negotiated. +export const TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY = + 'terminal.quick-commands.agent-drafts.v1' as const // Why: older hosts strip worktree.create's clientMutationId, so mobile must only // replay ambiguous cutovers when the host advertises idempotent create support; // status.worktreeCreateIdempotency carries the optional host retention policy. @@ -155,6 +158,7 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY ] as const @@ -192,6 +196,7 @@ export const RUNTIME_CAPABILITIES = [ TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY, + TERMINAL_QUICK_COMMAND_AGENT_DRAFTS_RUNTIME_CAPABILITY, WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, diff --git a/src/shared/terminal-quick-command-types.ts b/src/shared/terminal-quick-command-types.ts index a887f47b84d..4e38c7b3f7d 100644 --- a/src/shared/terminal-quick-command-types.ts +++ b/src/shared/terminal-quick-command-types.ts @@ -27,6 +27,8 @@ export type TerminalAgentQuickCommand = TerminalQuickCommandBase & { action: 'agent-prompt' agent: TuiAgent prompt: string + /** Omitted means submit immediately, preserving commands saved by older Orca versions. */ + submitPrompt?: false } export type TerminalQuickCommand = TerminalCommandQuickCommand | TerminalAgentQuickCommand diff --git a/src/shared/terminal-quick-commands.test.ts b/src/shared/terminal-quick-commands.test.ts index 3b07e72aef6..df4abe6a777 100644 --- a/src/shared/terminal-quick-commands.test.ts +++ b/src/shared/terminal-quick-commands.test.ts @@ -132,6 +132,7 @@ describe('terminal quick commands', () => { action: 'agent-prompt', agent: 'codex', prompt: ' Review this diff\n', + submitPrompt: false, command: "codex 'old workaround'" }, { @@ -156,6 +157,7 @@ describe('terminal quick commands', () => { action: 'agent-prompt', agent: 'codex', prompt: ' Review this diff', + submitPrompt: false, scope: { type: 'global' } } ]) @@ -218,10 +220,30 @@ describe('terminal quick commands', () => { it('accepts only complete canonical command lists at protocol boundaries', () => { const canonical = normalizeTerminalQuickCommands([ - { id: 'status', label: 'Status', command: 'git status', appendEnter: true } + { id: 'status', label: 'Status', command: 'git status', appendEnter: true }, + { + id: 'review', + label: 'Review', + action: 'agent-prompt', + agent: 'codex', + prompt: 'Review this diff', + submitPrompt: false + } ]) expect(parseNormalizedTerminalQuickCommands(canonical)).toEqual(canonical) + expect( + parseNormalizedTerminalQuickCommands([ + { + id: 'legacy-review', + label: 'Legacy review', + action: 'agent-prompt', + agent: 'codex', + prompt: 'Review this diff', + scope: { type: 'global' } + } + ]) + ).not.toBeNull() expect(parseNormalizedTerminalQuickCommands([{ ...canonical[0], command: 42 }])).toBeNull() expect( parseNormalizedTerminalQuickCommands([...canonical, ...canonical.slice(0, 1)]) diff --git a/src/shared/terminal-quick-commands.ts b/src/shared/terminal-quick-commands.ts index 28d51bc0323..3472f63e8c2 100644 --- a/src/shared/terminal-quick-commands.ts +++ b/src/shared/terminal-quick-commands.ts @@ -142,7 +142,8 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom prompt: (hasPrompt ? String(record.prompt).trimEnd() : '').slice( 0, MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH - ) + ), + ...(record.submitPrompt === false ? { submitPrompt: false as const } : {}) }) } else { const command = hasCommand ? String(record.command).trimEnd() : '' @@ -198,11 +199,16 @@ function isNormalizedTerminalQuickCommand(value: unknown, expected: TerminalQuic return false } if (isTerminalAgentQuickCommand(expected)) { + const expectedKeys = + expected.submitPrompt === false + ? ['id', 'label', 'action', 'agent', 'prompt', 'submitPrompt', 'scope'] + : ['id', 'label', 'action', 'agent', 'prompt', 'scope'] return ( - hasExactKeys(command, ['id', 'label', 'action', 'agent', 'prompt', 'scope']) && + hasExactKeys(command, expectedKeys) && command.action === 'agent-prompt' && command.agent === expected.agent && - command.prompt === expected.prompt + command.prompt === expected.prompt && + command.submitPrompt === expected.submitPrompt ) } return (