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.
This commit is contained in:
Jinjing
2026-08-27 21:17:52 -07:00
parent c04e7578c9
commit 8a185731cb
21 changed files with 321 additions and 103 deletions
@@ -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' }
}
}
+11 -4
View File
@@ -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({
@@ -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'
}
])
})
})
@@ -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
})
}
@@ -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()
@@ -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)}
@@ -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}
@@ -294,6 +294,7 @@ function TerminalQuickCommandEditorDialog({
open
mode="add"
command={command}
hostId={hostId}
repos={hostRepos}
onOpenChange={onOpenChange}
onSave={onSave}
@@ -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 (
<label className="flex min-w-0 cursor-pointer items-center gap-2">
<Switch
checked={appendEnter}
aria-label={translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.e4e5fed3b3',
'Toggle append Enter'
)}
onCheckedChange={onToggle}
/>
<span className="truncate text-[11px] text-muted-foreground">
{translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.767e4be3e3',
'Append Enter — run immediately'
)}
</span>
</label>
)
}
return (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.5fa607d807',
'Append Enter'
)}
</div>
<div className="text-xs text-muted-foreground">
{translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.c936c2d6d2',
'Submit immediately instead of only inserting text.'
)}
</div>
</div>
<Switch
checked={appendEnter}
aria-label={translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.e4e5fed3b3',
'Toggle append Enter'
)}
onCheckedChange={onToggle}
/>
</div>
)
}
@@ -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<TerminalQuickCommandDialogDraftMemory>
setDraft: Dispatch<SetStateAction<TerminalQuickCommand>>
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({
/>
<div className="flex items-center justify-between gap-3 border-t border-border bg-muted/50 px-3 py-2">
{!isTerminalAgentQuickCommand(draft) ? (
<TerminalQuickCommandAppendEnterSwitch
appendEnter={draft.appendEnter}
onToggle={toggleAppendEnter}
compact
{!isAgentAction || agentDraftsSupported ? (
<TerminalQuickCommandSubmissionSwitch
action={isAgentAction ? 'agent-prompt' : 'terminal-command'}
checked={
isTerminalAgentQuickCommand(draft)
? draft.submitPrompt !== false
: draft.appendEnter
}
onToggle={toggleImmediateSubmission}
/>
) : (
<span className="text-[11px] text-muted-foreground">
@@ -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<typeof vi.fn<(open: boolean) => void>>
onSave?: ReturnType<typeof vi.fn<(command: TerminalQuickCommand) => 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<HTMLElement>(
'[aria-label="Toggle immediate prompt submission"]'
)
const saveButton = Array.from(document.body.querySelectorAll<HTMLButtonElement>('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 () => {
@@ -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<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
/** 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<TerminalQuickCommand>(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}
/>
<TerminalQuickCommandAdvancedSection
@@ -0,0 +1,42 @@
import { translate } from '@/i18n/i18n'
import { Switch } from '@/components/ui/switch'
type TerminalQuickCommandSubmissionSwitchProps = {
action: 'terminal-command' | 'agent-prompt'
checked: boolean
onToggle: () => 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 (
<label className="flex min-w-0 cursor-pointer items-center gap-2">
<Switch checked={checked} aria-label={ariaLabel} onCheckedChange={onToggle} />
<span className="truncate text-[11px] text-muted-foreground">
{isAgentPrompt
? translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandSubmissionSwitch.prompt_compact',
'Submit prompt — run immediately'
)
: translate(
'auto.components.terminal.quick.commands.TerminalQuickCommandAppendEnterSwitch.767e4be3e3',
'Append Enter — run immediately'
)}
</span>
</label>
)
}
@@ -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(
@@ -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 })
}
}
}
@@ -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' })
@@ -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
})
+5
View File
@@ -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,
@@ -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
+23 -1
View File
@@ -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)])
+9 -3
View File
@@ -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 (