mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
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:
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
-62
@@ -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>
|
||||
)
|
||||
}
|
||||
+14
-8
@@ -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">
|
||||
|
||||
+64
-4
@@ -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 () => {
|
||||
|
||||
+36
-14
@@ -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
|
||||
|
||||
+42
@@ -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>
|
||||
)
|
||||
}
|
||||
+5
-3
@@ -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(
|
||||
|
||||
+9
-4
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user