Add agent prompt quick commands (#3011)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-29 16:14:50 -04:00 committed by GitHub
parent 21556f90ac
commit 1cf754580d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1178 additions and 540 deletions

View File

@ -78,20 +78,36 @@ function dirEntry(args: { name: string; directory?: boolean; symlink?: boolean }
}
}
function createRuntimeFileCommands() {
function mockLocalPathStats(entries: Record<string, [number, number]>) {
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
const entry = entries[p]
if (entry) {
return mockStats(entry[0], entry[1])
}
throw enoent()
})
}
function createRuntimeFileCommands(options?: {
path?: string
openDiff?: ReturnType<typeof vi.fn>
}) {
const store = {
getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined)
}
const path = options?.path ?? '/repo'
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => store,
resolveWorktreeSelector: vi.fn(async () => ({
id: 'wt-1',
repoId: 'repo-1',
path: '/repo'
path
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
openFile: vi.fn(),
...(options?.openDiff ? { openDiff: options.openDiff } : {})
} as never)
return { commands, store }
}
@ -128,18 +144,7 @@ describe('RuntimeFileCommands', () => {
it('opens source control diffs through the renderer host', async () => {
const openDiff = vi.fn()
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => ({ getRepo: vi.fn(() => undefined) }),
resolveWorktreeSelector: vi.fn(async () => ({
id: 'wt-1',
repoId: 'repo-1',
path: '/repo'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn(),
openDiff
} as never)
const { commands } = createRuntimeFileCommands({ openDiff })
const result = await commands.openMobileDiff('id:wt-1', 'docs/readme.md', true)
@ -180,12 +185,9 @@ describe('RuntimeFileCommands', () => {
it('allows runtime-local case-only rename with IPC parity guard behavior', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
if (p === '/repo/README.md' || p === '/repo/readme.md') {
return mockStats(10, 100)
}
throw enoent()
mockLocalPathStats({
'/repo/README.md': [10, 100],
'/repo/readme.md': [10, 100]
})
await commands.renameFileExplorerPath('id:wt-1', 'README.md', 'readme.md')
@ -195,15 +197,9 @@ describe('RuntimeFileCommands', () => {
it('rejects runtime-local true destination collisions', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
if (p === '/repo/old.ts') {
return mockStats(11, 110)
}
if (p === '/repo/new.ts') {
return mockStats(11, 111)
}
throw enoent()
mockLocalPathStats({
'/repo/old.ts': [11, 110],
'/repo/new.ts': [11, 111]
})
await expect(commands.renameFileExplorerPath('id:wt-1', 'old.ts', 'new.ts')).rejects.toThrow(
@ -215,12 +211,9 @@ describe('RuntimeFileCommands', () => {
it('rejects runtime-local hard-link alias collisions', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
if (p === '/repo/README.md' || p === '/repo/README-hardlink.md') {
return mockStats(12, 120)
}
throw enoent()
mockLocalPathStats({
'/repo/README.md': [12, 120],
'/repo/README-hardlink.md': [12, 120]
})
await expect(
@ -232,12 +225,9 @@ describe('RuntimeFileCommands', () => {
it('rejects runtime-local cross-parent case-only collisions', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockImplementation(async (p: string) => p)
lstatMock.mockImplementation(async (p: string) => {
if (p === '/repo/src/README.md' || p === '/repo/docs/readme.md') {
return mockStats(13, 130)
}
throw enoent()
mockLocalPathStats({
'/repo/src/README.md': [13, 130],
'/repo/docs/readme.md': [13, 130]
})
await expect(
@ -277,7 +267,6 @@ describe('RuntimeFileCommands', () => {
value: 'win32'
})
const store = { getRepo: vi.fn(() => undefined) }
const close = vi.fn()
const on = vi.fn()
let listener: (() => void) | null = null
@ -287,18 +276,7 @@ describe('RuntimeFileCommands', () => {
})
resolveAuthorizedPathMock.mockResolvedValue('C:\\repo')
statMock.mockResolvedValue({ isDirectory: () => true })
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => store,
resolveWorktreeSelector: vi.fn(async () => ({
id: 'wt-1',
repoId: 'repo-1',
path: 'C:\\repo'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
} as never)
const { commands } = createRuntimeFileCommands({ path: 'C:\\repo' })
const onEvents = vi.fn()
const unsubscribe = await commands.watchFileExplorer('id:wt-1', onEvents)
@ -321,7 +299,6 @@ describe('RuntimeFileCommands', () => {
})
it('tracks native Parcel watcher unsubscribe work so shutdown can await it', async () => {
const { commands } = createRuntimeFileCommands()
resolveAuthorizedPathMock.mockResolvedValue('/repo')
statMock.mockResolvedValue({ isDirectory: () => true })
let resolveUnsubscribe: () => void = () => {}
@ -332,6 +309,7 @@ describe('RuntimeFileCommands', () => {
})
)
subscribeParcelWatcherMock.mockResolvedValue({ unsubscribe: unsubscribeMock })
const { commands } = createRuntimeFileCommands()
const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn())
unsubscribe()

View File

@ -6,7 +6,11 @@ import type {
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../../shared/types'
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
import {
getTerminalQuickCommandBody,
getTerminalQuickCommandScope,
isTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
import {
createTerminalQuickCommandDraft,
TerminalQuickCommandDialog
@ -20,6 +24,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import RepoBadgeLabel, { RepoBadgeMark } from '../repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
type QuickCommandsPaneProps = {
settings: GlobalSettings
@ -339,12 +344,30 @@ export function QuickCommandsPane({
)}
</Badge>
</div>
<div className="truncate font-mono text-xs text-foreground/80">
{command.command || 'No command text'}
<div className="flex min-w-0 items-center gap-1.5 text-xs text-foreground/80">
{isTerminalAgentQuickCommand(command) ? (
<span className="shrink-0 text-muted-foreground">
<AgentIcon agent={command.agent} size={12} />
</span>
) : null}
<span
className={cn(
'truncate',
isTerminalAgentQuickCommand(command) ? '' : 'font-mono'
)}
>
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}`
: getTerminalQuickCommandBody(command) || 'No command text'}
</span>
</div>
</div>
<div className="shrink-0 text-[11px] font-medium text-foreground/75">
{command.appendEnter ? 'Enter' : 'Insert'}
{isTerminalAgentQuickCommand(command)
? 'Agent'
: command.appendEnter
? 'Enter'
: 'Insert'}
</div>
<Button
type="button"

View File

@ -18,13 +18,19 @@ import {
createTerminalQuickCommandDraft,
TerminalQuickCommandDialog
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
import {
getTerminalQuickCommandBody,
getTerminalQuickCommandScope,
isTerminalAgentQuickCommand,
isTerminalQuickCommandComplete
} from '../../../../shared/terminal-quick-commands'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { cn } from '@/lib/utils'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
type TabBarQuickCommandsButtonProps = {
worktreeId: string
@ -56,7 +62,7 @@ export function TabBarQuickCommandsButton({
const repoList: TerminalQuickCommand[] = []
const globalList: TerminalQuickCommand[] = []
for (const command of allCommands ?? []) {
if (!command.label.trim() || !command.command.trimEnd()) {
if (!isTerminalQuickCommandComplete(command)) {
continue
}
const scope = getTerminalQuickCommandScope(command)
@ -187,11 +193,23 @@ export function TabBarQuickCommandsButton({
onSelect={() => handleRun(command)}
className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-black/8 dark:data-[selected=true]:bg-white/14"
>
<Play className="size-3 shrink-0 text-muted-foreground" fill="currentColor" strokeWidth={0} />
{isTerminalAgentQuickCommand(command) ? (
<span className="shrink-0 text-muted-foreground">
<AgentIcon agent={command.agent} size={12} />
</span>
) : (
<Play
className="size-3 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{command.label}</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{command.command}
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${command.prompt}`
: command.command}
</span>
</span>
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover/qc:opacity-100 group-data-[selected=true]/qc:opacity-100">
@ -243,7 +261,11 @@ export function TabBarQuickCommandsButton({
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{mostRecent ? `Run: ${mostRecent.command}` : 'Run quick command'}
{mostRecent
? isTerminalAgentQuickCommand(mostRecent)
? `Start ${getAgentLabel(mostRecent.agent)}: ${getTerminalQuickCommandBody(mostRecent)}`
: `Run: ${getTerminalQuickCommandBody(mostRecent)}`
: 'Run quick command'}
</TooltipContent>
</Tooltip>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleOpenChange}>

View File

@ -26,7 +26,9 @@ import {
} from '@/components/ui/dropdown-menu'
import { shouldIgnoreTerminalMenuPointerDownOutside } from './terminal-context-menu-dismiss'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { AgentIcon } from '@/lib/agent-catalog'
type TerminalContextMenuProps = {
open: boolean
@ -86,6 +88,25 @@ export default function TerminalContextMenu({
const closeShortcut = useShortcutLabel('terminal.closePane')
const hasQuickCommands = repoQuickCommands.length > 0 || globalQuickCommands.length > 0
const showEqualizeShortcut = equalizeShortcut !== 'Unassigned'
const renderQuickCommandItem = (command: TerminalQuickCommand): React.JSX.Element => (
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
{isTerminalAgentQuickCommand(command) ? (
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={command.agent} size={14} />
</span>
) : (
<Play
className="size-3.5 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1 truncate">{command.label}</span>
{!isTerminalAgentQuickCommand(command) && !command.appendEnter ? (
<DropdownMenuShortcut className="shrink-0">Insert</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
return (
<DropdownMenu
@ -154,14 +175,7 @@ export default function TerminalContextMenu({
<DropdownMenuLabel className="truncate">
{quickCommandRepoLabel}
</DropdownMenuLabel>
{repoQuickCommands.map((command) => (
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
<span className="truncate">{command.label}</span>
{!command.appendEnter ? (
<DropdownMenuShortcut className="shrink-0">Insert</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
))}
{repoQuickCommands.map(renderQuickCommandItem)}
</>
) : null}
{globalQuickCommands.length > 0 ? (
@ -170,14 +184,7 @@ export default function TerminalContextMenu({
{repoQuickCommands.length > 0 ? (
<DropdownMenuLabel>Global</DropdownMenuLabel>
) : null}
{globalQuickCommands.map((command) => (
<DropdownMenuItem key={command.id} onSelect={() => onQuickCommand(command)}>
<span className="truncate">{command.label}</span>
{!command.appendEnter ? (
<DropdownMenuShortcut className="shrink-0">Insert</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
))}
{globalQuickCommands.map(renderQuickCommandItem)}
</>
) : null}
</>

View File

@ -67,6 +67,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
import {
getTerminalQuickCommandScope,
isTerminalQuickCommandComplete,
terminalQuickCommandMatchesRepo
} from '../../../../shared/terminal-quick-commands'
import {
@ -366,8 +367,8 @@ export default function TerminalPane({
: quickCommandRepoId
? 'This Repo'
: null
const validQuickCommands = (settings?.terminalQuickCommands ?? []).filter(
(command) => command.label.trim() && command.command.trimEnd()
const validQuickCommands = (settings?.terminalQuickCommands ?? []).filter((command) =>
isTerminalQuickCommandComplete(command)
)
const repoQuickCommands = validQuickCommands.filter((command) => {
const scope = getTerminalQuickCommandScope(command)
@ -376,6 +377,15 @@ export default function TerminalPane({
const globalQuickCommands = validQuickCommands.filter(
(command) => getTerminalQuickCommandScope(command).type === 'global'
)
const quickCommandGroupId =
useAppStore(
(s) =>
s.unifiedTabsByWorktree[worktreeId]?.find(
(tab) => tab.entityId === tabId && tab.contentType === 'terminal'
)?.groupId ??
s.activeGroupIdByWorktree[worktreeId] ??
null
) ?? null
const openQuickCommandEditor = useCallback((scope: TerminalQuickCommandScope): void => {
setQuickCommandDraft(createTerminalQuickCommandDraft(scope))
@ -1485,6 +1495,7 @@ export default function TerminalPane({
paneTransportsRef,
paneCwdRef,
worktreeId,
groupId: quickCommandGroupId,
fallbackCwd: cwd ?? '',
toggleExpandPane,
onRequestClosePane: handleRequestClosePane,

View File

@ -91,4 +91,25 @@ describe('sendTerminalQuickCommandToPane', () => {
expect(sendInput).toHaveBeenCalledWith('echo one; echo two')
expect(pane.terminal.focus).toHaveBeenCalledOnce()
})
it('does not write agent prompt quick commands into the current pane', () => {
const sendInput = vi.fn(() => true)
const focus = vi.fn()
const sent = sendTerminalQuickCommandToPane({
command: {
id: 'agent',
label: 'Agent',
action: 'agent-prompt',
agent: 'codex',
prompt: 'Review this'
},
pane: { terminal: { focus } },
transport: { sendInput }
})
expect(sent).toBe(false)
expect(sendInput).not.toHaveBeenCalled()
expect(focus).not.toHaveBeenCalled()
})
})

View File

@ -1,7 +1,8 @@
import type { TerminalQuickCommand } from '../../../../shared/types'
import {
buildTerminalQuickCommandInput,
flattenTerminalQuickCommand
flattenTerminalQuickCommand,
isTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
type QuickCommandPane = {
@ -23,6 +24,9 @@ export function sendTerminalQuickCommandToPane({
pane: QuickCommandPane
transport: QuickCommandTransport | null | undefined
}): boolean {
if (isTerminalAgentQuickCommand(command)) {
return false
}
if (!transport) {
return false
}

View File

@ -4,10 +4,12 @@ import type { PtyTransport } from './pty-transport'
import { getConnectionId } from '@/lib/connection-context'
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch'
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import { pasteTerminalText } from './terminal-bracketed-paste'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
@ -16,6 +18,7 @@ type UseTerminalPaneContextMenuDeps = {
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
paneCwdRef: React.RefObject<PaneCwdMap>
worktreeId: string
groupId: string | null
fallbackCwd: string
toggleExpandPane: (paneId: number) => void
onRequestClosePane: (paneId: number) => void
@ -49,6 +52,7 @@ export function useTerminalPaneContextMenu({
paneTransportsRef,
paneCwdRef,
worktreeId,
groupId,
fallbackCwd,
toggleExpandPane,
onRequestClosePane,
@ -183,6 +187,11 @@ export function useTerminalPaneContextMenu({
}
const onQuickCommand = (command: TerminalQuickCommand): void => {
if (isTerminalAgentQuickCommand(command)) {
runQuickCommandInNewTab({ command, worktreeId, groupId })
return
}
const pane = resolveMenuPane()
if (!pane) {
return

View File

@ -0,0 +1,28 @@
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { TerminalQuickCommandDialogAction } from './terminal-quick-command-dialog-draft'
type TerminalQuickCommandActionToggleProps = {
selectedAction: TerminalQuickCommandDialogAction
onActionChange: (action: TerminalQuickCommandDialogAction) => void
}
export function TerminalQuickCommandActionToggle({
selectedAction,
onActionChange
}: TerminalQuickCommandActionToggleProps): React.JSX.Element {
return (
<ToggleGroup
type="single"
value={selectedAction}
onValueChange={(value) => {
if (value === 'terminal-command' || value === 'agent-prompt') {
onActionChange(value)
}
}}
className="justify-start"
>
<ToggleGroupItem value="terminal-command">Terminal Command</ToggleGroupItem>
<ToggleGroupItem value="agent-prompt">Agent Prompt</ToggleGroupItem>
</ToggleGroup>
)
}

View File

@ -0,0 +1,36 @@
type TerminalQuickCommandAppendEnterSwitchProps = {
appendEnter: boolean
onToggle: () => void
}
export function TerminalQuickCommandAppendEnterSwitch({
appendEnter,
onToggle
}: TerminalQuickCommandAppendEnterSwitchProps): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-4 rounded-md border border-border/50 px-3 py-2">
<div className="space-y-0.5">
<div className="text-sm font-medium">Append Enter</div>
<div className="text-xs text-muted-foreground">
Submit immediately instead of only inserting text.
</div>
</div>
<button
type="button"
role="switch"
aria-checked={appendEnter}
aria-label="Toggle append Enter"
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
appendEnter ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
appendEnter ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
)
}

View File

@ -1,29 +1,23 @@
import { useMemo, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { useRef, useState } from 'react'
import type {
Repo,
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../../shared/types'
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
getTerminalQuickCommandAction,
getTerminalQuickCommandScope,
isTerminalAgentQuickCommand,
supportsTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
@ -32,22 +26,21 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { useAppStore } from '@/store'
import type { TuiAgent } from '../../../../shared/types'
import { TerminalQuickCommandActionToggle } from './TerminalQuickCommandActionToggle'
import { TerminalQuickCommandAppendEnterSwitch } from './TerminalQuickCommandAppendEnterSwitch'
import { TerminalQuickCommandDialogFooter } from './TerminalQuickCommandDialogFooter'
import { TerminalQuickCommandLabelField } from './TerminalQuickCommandLabelField'
import { TerminalQuickCommandScopeField } from './TerminalQuickCommandScopeField'
import {
buildTerminalAgentQuickCommandPreset,
type TerminalAgentQuickCommandPreset
} from './terminal-agent-quick-command-presets'
createTerminalQuickCommandDialogDraftMemory,
switchTerminalQuickCommandDialogAction
} from './terminal-quick-command-dialog-draft'
type TerminalQuickCommandDialogMode = 'add' | 'edit'
const EMPTY_AGENT_CMD_OVERRIDES = {}
type TerminalQuickCommandDialogProps = {
open: boolean
mode: TerminalQuickCommandDialogMode
@ -69,23 +62,6 @@ export function createTerminalQuickCommandDraft(
}
}
function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
return repo.displayName || repo.path
}
function filterAgentPresets(
presets: TerminalAgentQuickCommandPreset[],
rawQuery: string
): TerminalAgentQuickCommandPreset[] {
const query = rawQuery.trim().toLowerCase()
if (!query) {
return presets
}
return presets.filter((preset) => {
return preset.label.toLowerCase().includes(query) || preset.agent.toLowerCase().includes(query)
})
}
export function TerminalQuickCommandDialog({
open,
mode,
@ -94,14 +70,13 @@ export function TerminalQuickCommandDialog({
onOpenChange,
onSave
}: TerminalQuickCommandDialogProps): React.JSX.Element {
const fallbackAgent: TuiAgent =
AGENT_CATALOG.find((entry) => supportsTerminalAgentQuickCommand(entry.id))?.id ?? 'claude'
const [draft, setDraft] = useState<TerminalQuickCommand>(command)
const [agentPresetOpen, setAgentPresetOpen] = useState(false)
const [agentPresetQuery, setAgentPresetQuery] = useState('')
const wasOpenRef = useRef(open)
const syncedCommandRef = useRef(command)
const agentCmdOverrides = useAppStore(
(s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES
)
const draftMemoryRef = useRef(createTerminalQuickCommandDialogDraftMemory(command, fallbackAgent))
const selectedAction = getTerminalQuickCommandAction(draft)
const selectedScope = getTerminalQuickCommandScope(draft)
// Why: repo-scoped commands can outlive the current repo list; only an
// explicit selection should replace the saved repo id.
@ -119,62 +94,75 @@ export function TerminalQuickCommandDialog({
syncedCommandRef.current = command
// Why: opening or retargeting the dialog should render the new command
// draft immediately instead of repairing it in a follow-up Effect.
draftMemoryRef.current = createTerminalQuickCommandDialogDraftMemory(command, fallbackAgent)
setDraft({ ...command })
if (agentPresetOpen) {
setAgentPresetOpen(false)
}
if (agentPresetQuery) {
setAgentPresetQuery('')
}
}
const agentPresets = useMemo(() => {
return AGENT_CATALOG.map((entry, index) => {
const preset = buildTerminalAgentQuickCommandPreset({
agent: entry.id,
label: entry.label,
cmdOverrides: agentCmdOverrides,
platform: CLIENT_PLATFORM
})
return preset ? { preset, index } : null
const selectedAgent =
isTerminalAgentQuickCommand(draft) && supportsTerminalAgentQuickCommand(draft.agent)
? draft.agent
: fallbackAgent
const setAction = (action: 'terminal-command' | 'agent-prompt'): void => {
setDraft((current) => {
const next = switchTerminalQuickCommandDialogAction(current, action, draftMemoryRef.current)
draftMemoryRef.current = next.memory
return next.draft
})
.filter((item): item is { preset: TerminalAgentQuickCommandPreset; index: number } =>
Boolean(item)
)
.sort((a, b) => a.index - b.index)
.map((item) => item.preset)
}, [agentCmdOverrides])
}
const visibleAgentPresets = useMemo(
() => filterAgentPresets(agentPresets, agentPresetQuery),
[agentPresetQuery, agentPresets]
)
const selectAgentPreset = (preset: TerminalAgentQuickCommandPreset): void => {
setDraft((current) => ({
...current,
command: preset.command
}))
setAgentPresetOpen(false)
setAgentPresetQuery('')
const toggleAppendEnter = (): void => {
setDraft((current) =>
isTerminalAgentQuickCommand(current)
? current
: (() => {
const appendEnter = !current.appendEnter
draftMemoryRef.current = {
...draftMemoryRef.current,
terminalAppendEnter: appendEnter
}
return { ...current, appendEnter }
})()
)
}
const saveDraft = (): void => {
const next = {
...draft,
label: draft.label.trim(),
command: draft.command.trimEnd(),
scope: selectedScope
}
if (!next.label || !next.command) {
const next: TerminalQuickCommand = isTerminalAgentQuickCommand(draft)
? {
id: draft.id,
label: draft.label.trim(),
action: 'agent-prompt',
agent: draft.agent,
prompt: draft.prompt.trimEnd(),
scope: selectedScope
}
: {
id: draft.id,
label: draft.label.trim(),
action: 'terminal-command',
command: draft.command.trimEnd(),
appendEnter: draft.appendEnter,
scope: selectedScope
}
if (
!next.label ||
(isTerminalAgentQuickCommand(next)
? !next.prompt.trim() || !supportsTerminalAgentQuickCommand(next.agent)
: !next.command.trim())
) {
return
}
onSave(next)
onOpenChange(false)
}
const canSave = draft.label.trim().length > 0 && draft.command.trimEnd().length > 0
const submitShortcutLabel = isMacUserAgent() ? '⌘↵' : 'Ctrl+Enter'
const canSave =
draft.label.trim().length > 0 &&
(isTerminalAgentQuickCommand(draft)
? draft.prompt.trimEnd().length > 0 && supportsTerminalAgentQuickCommand(draft.agent)
: draft.command.trimEnd().length > 0)
const isMac = isMacUserAgent()
const submitShortcutLabel = isMac ? '⌘↵' : 'Ctrl+Enter'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -184,7 +172,7 @@ export function TerminalQuickCommandDialog({
{mode === 'edit' ? 'Edit Quick Command' : 'Add Quick Command'}
</DialogTitle>
<DialogDescription className="text-xs">
Save terminal input text for the context menu.
Save terminal commands or agent prompts for quick access.
</DialogDescription>
</DialogHeader>
@ -193,196 +181,139 @@ export function TerminalQuickCommandDialog({
onKeyDown={(event) => {
// Why: cross-platform submit shortcut — Cmd+Enter on Mac, Ctrl+Enter
// elsewhere. Falls through to native textarea/Input newline insertion
// when the modifier isn't held.
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && canSave) {
// when the platform modifier isn't held.
const platformSubmit = isMac ? event.metaKey : event.ctrlKey
if (event.key === 'Enter' && platformSubmit && canSave) {
event.preventDefault()
saveDraft()
}
}}
>
<TerminalQuickCommandLabelField label={draft.label} setDraft={setDraft} />
<div className="space-y-2">
<Label>Label</Label>
<Input
value={draft.label}
onChange={(event) =>
setDraft((current) => ({ ...current, label: event.target.value }))
}
placeholder="Start dev server"
<Label>Action</Label>
<TerminalQuickCommandActionToggle
selectedAction={selectedAction}
onActionChange={setAction}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>Command Text</Label>
<Popover open={agentPresetOpen} onOpenChange={setAgentPresetOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 shrink-0 gap-1 px-2 text-xs font-normal"
aria-label="Insert an agent command"
>
Insert agent command
<ChevronDown className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(17rem,calc(100vw-2rem))] p-0">
<Command shouldFilter={false}>
<CommandInput
autoFocus
placeholder="Search agents"
value={agentPresetQuery}
onValueChange={setAgentPresetQuery}
className="h-9 text-xs"
wrapperClassName="px-3"
/>
<CommandList className="max-h-64">
<CommandEmpty>No agents match your search.</CommandEmpty>
{visibleAgentPresets.map((preset) => (
<CommandItem
key={preset.agent}
value={`${preset.agent} ${preset.label}`}
disabled={!preset.startsWithPrompt}
onSelect={() => {
if (preset.startsWithPrompt) {
selectAgentPreset(preset)
}
}}
className="min-h-11 items-start gap-2 px-3 py-2"
>
<span className="mt-0.5">
<AgentIcon agent={preset.agent} size={16} />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{preset.label}</span>
{!preset.startsWithPrompt ? (
<span className="truncate text-xs text-muted-foreground">
Does not support prompt commands
</span>
) : null}
</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<textarea
value={draft.command}
onChange={(event) =>
setDraft((current) => ({ ...current, command: event.target.value }))
}
placeholder="npm run dev"
rows={4}
className="min-h-24 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
<div className="space-y-2">
<Label>Scope</Label>
<div className="flex flex-wrap items-center gap-2">
<ToggleGroup
type="single"
value={selectedScope.type}
onValueChange={(value) => {
if (value === 'global') {
setDraft((current) => ({ ...current, scope: { type: 'global' } }))
}
if (value === 'repo' && repos[0]) {
if (selectedScope.type !== 'repo') {
setDraft((current) => ({
...current,
scope: { type: 'repo', repoId: repos[0].id }
}))
}
}
}}
className="justify-start"
>
<ToggleGroupItem value="global">Global</ToggleGroupItem>
<ToggleGroupItem value="repo" disabled={repos.length === 0}>
Project
</ToggleGroupItem>
</ToggleGroup>
{selectedScope.type === 'repo' && repos.length > 0 ? (
<div className="space-y-1">
<Select
value={selectedRepoId}
onValueChange={(repoId) =>
setDraft((current) => ({ ...current, scope: { type: 'repo', repoId } }))
{isTerminalAgentQuickCommand(draft) ? (
<>
<div className="space-y-2">
<Label>Agent</Label>
<Select
value={selectedAgent}
onValueChange={(agent) => {
const nextAgent = agent as TuiAgent
draftMemoryRef.current = {
...draftMemoryRef.current,
agent: nextAgent
}
setDraft((current) =>
isTerminalAgentQuickCommand(current)
? { ...current, agent: nextAgent }
: current
)
}}
>
<SelectTrigger>
<SelectValue placeholder="Choose agent" />
</SelectTrigger>
<SelectContent
position="popper"
side="bottom"
align="start"
sideOffset={4}
className="max-h-[min(20rem,var(--radix-select-content-available-height))] w-[--radix-select-trigger-width]"
>
<SelectTrigger size="sm" className="min-w-48">
<SelectValue
placeholder={selectedRepoMissing ? 'Project not in list' : 'Choose project'}
/>
</SelectTrigger>
<SelectContent>
{repos.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<RepoBadgeLabel
name={getRepoLabel(repo)}
color={repo.badgeColor}
className="max-w-full"
/>
{AGENT_CATALOG.map((entry) => {
const supported = supportsTerminalAgentQuickCommand(entry.id)
return (
<SelectItem key={entry.id} value={entry.id} disabled={!supported}>
<span className="flex min-w-0 items-center gap-2">
<AgentIcon agent={entry.id} size={16} />
<span className="flex min-w-0 flex-col">
<span className="truncate">{entry.label}</span>
{!supported ? (
<span className="truncate text-xs text-muted-foreground">
Does not support prompt commands
</span>
) : null}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedRepoMissing ? (
<p className="max-w-48 text-xs text-muted-foreground">
Saving keeps the existing project scope unless you choose another.
</p>
) : null}
</div>
) : null}
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border/50 px-3 py-2">
<div className="space-y-0.5">
<div className="text-sm font-medium">Append Enter</div>
<div className="text-xs text-muted-foreground">
Submit immediately instead of only inserting text.
)
})}
</SelectContent>
</Select>
</div>
</div>
<button
type="button"
role="switch"
aria-checked={draft.appendEnter}
aria-label="Toggle append Enter"
onClick={() =>
setDraft((current) => ({ ...current, appendEnter: !current.appendEnter }))
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
draft.appendEnter ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
draft.appendEnter ? 'translate-x-4' : 'translate-x-0.5'
}`}
<div className="space-y-2">
<Label>Prompt</Label>
<textarea
value={draft.prompt}
onChange={(event) => {
const prompt = event.target.value
draftMemoryRef.current = {
...draftMemoryRef.current,
agentPrompt: prompt
}
setDraft((current) =>
isTerminalAgentQuickCommand(current) ? { ...current, prompt } : current
)
}}
placeholder="Ask the agent to investigate this workspace"
rows={4}
className="min-h-24 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
</>
) : (
<div className="space-y-2">
<Label>Command Text</Label>
<textarea
value={draft.command}
onChange={(event) => {
const command = event.target.value
draftMemoryRef.current = {
...draftMemoryRef.current,
terminalCommand: command
}
setDraft((current) =>
isTerminalAgentQuickCommand(current) ? current : { ...current, command }
)
}}
placeholder="npm run dev"
rows={4}
className="min-h-24 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</button>
</div>
</div>
)}
<TerminalQuickCommandScopeField
repos={repos}
selectedScope={selectedScope}
selectedRepoId={selectedRepoId}
selectedRepoMissing={selectedRepoMissing}
setDraft={setDraft}
/>
{!isTerminalAgentQuickCommand(draft) ? (
<TerminalQuickCommandAppendEnterSwitch
appendEnter={draft.appendEnter}
onToggle={toggleAppendEnter}
/>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
onClick={saveDraft}
disabled={!canSave}
title={`Save (${submitShortcutLabel})`}
>
Save
<span className="ml-1 text-[10px] opacity-60">{submitShortcutLabel}</span>
</Button>
</DialogFooter>
<TerminalQuickCommandDialogFooter
canSave={canSave}
submitShortcutLabel={submitShortcutLabel}
onCancel={() => onOpenChange(false)}
onSave={saveDraft}
/>
</DialogContent>
</Dialog>
)

View File

@ -0,0 +1,33 @@
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
type TerminalQuickCommandDialogFooterProps = {
canSave: boolean
submitShortcutLabel: string
onCancel: () => void
onSave: () => void
}
export function TerminalQuickCommandDialogFooter({
canSave,
submitShortcutLabel,
onCancel,
onSave
}: TerminalQuickCommandDialogFooterProps): React.JSX.Element {
return (
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button
type="button"
onClick={onSave}
disabled={!canSave}
title={`Save (${submitShortcutLabel})`}
>
Save
<span className="ml-1 text-[10px] opacity-60">{submitShortcutLabel}</span>
</Button>
</DialogFooter>
)
}

View File

@ -0,0 +1,25 @@
import type { Dispatch, SetStateAction } from 'react'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
type TerminalQuickCommandLabelFieldProps = {
label: string
setDraft: Dispatch<SetStateAction<TerminalQuickCommand>>
}
export function TerminalQuickCommandLabelField({
label,
setDraft
}: TerminalQuickCommandLabelFieldProps): React.JSX.Element {
return (
<div className="space-y-2">
<Label>Label</Label>
<Input
value={label}
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
placeholder="Start dev server"
/>
</div>
)
}

View File

@ -0,0 +1,97 @@
import type { Dispatch, SetStateAction } from 'react'
import type {
Repo,
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../../shared/types'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
type TerminalQuickCommandScopeFieldProps = {
repos: Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
selectedScope: TerminalQuickCommandScope
selectedRepoId: string
selectedRepoMissing: boolean
setDraft: Dispatch<SetStateAction<TerminalQuickCommand>>
}
function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
return repo.displayName || repo.path
}
export function TerminalQuickCommandScopeField({
repos,
selectedScope,
selectedRepoId,
selectedRepoMissing,
setDraft
}: TerminalQuickCommandScopeFieldProps): React.JSX.Element {
return (
<div className="space-y-2">
<Label>Scope</Label>
<div className="flex flex-wrap items-center gap-2">
<ToggleGroup
type="single"
value={selectedScope.type}
onValueChange={(value) => {
if (value === 'global') {
setDraft((current) => ({ ...current, scope: { type: 'global' } }))
}
if (value === 'repo' && repos[0] && selectedScope.type !== 'repo') {
setDraft((current) => ({
...current,
scope: { type: 'repo', repoId: repos[0].id }
}))
}
}}
className="justify-start"
>
<ToggleGroupItem value="global">Global</ToggleGroupItem>
<ToggleGroupItem value="repo" disabled={repos.length === 0}>
Project
</ToggleGroupItem>
</ToggleGroup>
{selectedScope.type === 'repo' && repos.length > 0 ? (
<div className="space-y-1">
<Select
value={selectedRepoId}
onValueChange={(repoId) =>
setDraft((current) => ({ ...current, scope: { type: 'repo', repoId } }))
}
>
<SelectTrigger size="sm" className="min-w-48">
<SelectValue
placeholder={selectedRepoMissing ? 'Project not in list' : 'Choose project'}
/>
</SelectTrigger>
<SelectContent>
{repos.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<RepoBadgeLabel
name={getRepoLabel(repo)}
color={repo.badgeColor}
className="max-w-full"
/>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedRepoMissing ? (
<p className="max-w-48 text-xs text-muted-foreground">
Saving keeps the existing project scope unless you choose another.
</p>
) : null}
</div>
) : null}
</div>
</div>
)
}

View File

@ -1,109 +0,0 @@
import { describe, expect, it } from 'vitest'
import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../../shared/types'
import { buildTerminalAgentQuickCommandPreset } from './terminal-agent-quick-command-presets'
describe('terminal agent quick command presets', () => {
it('matches the supported one-line startup commands for every prompt-starting agent', () => {
const expectedCommands: Partial<Record<TuiAgent, string>> = {
claude: "claude 'your prompt here'",
codex: "codex 'your prompt here'",
copilot: "copilot -i 'your prompt here'",
omp: "omp 'your prompt here'",
opencode: "opencode --prompt 'your prompt here'",
pi: "pi 'your prompt here'",
gemini: "gemini --prompt-interactive 'your prompt here'",
antigravity: "agy --prompt-interactive 'your prompt here'",
cursor: "cursor-agent 'your prompt here'",
'command-code': "command-code --trust 'your prompt here'",
droid: "droid 'your prompt here'"
}
const promptStartingCommands = Object.keys(TUI_AGENT_CONFIG)
.map((agent) =>
buildTerminalAgentQuickCommandPreset({
agent: agent as TuiAgent,
label: agent,
cmdOverrides: {},
platform: 'linux'
})
)
.filter((preset): preset is NonNullable<typeof preset> => preset?.startsWithPrompt === true)
expect(
Object.fromEntries(promptStartingCommands.map((preset) => [preset.agent, preset.command]))
).toEqual(expectedCommands)
})
it('does not expose post-start paste agents as insertable command templates', () => {
const insertableAgents = Object.keys(TUI_AGENT_CONFIG).filter((agent) => {
return (
buildTerminalAgentQuickCommandPreset({
agent: agent as TuiAgent,
label: agent,
cmdOverrides: {},
platform: 'linux'
})?.startsWithPrompt === true
)
})
expect(insertableAgents).not.toContain('aider')
expect(insertableAgents).not.toContain('goose')
expect(insertableAgents).not.toContain('amp')
expect(insertableAgents).not.toContain('qwen-code')
})
it('builds prompt-starting commands for argv agents', () => {
expect(
buildTerminalAgentQuickCommandPreset({
agent: 'claude',
label: 'Claude',
cmdOverrides: {},
platform: 'darwin'
})
).toEqual({
agent: 'claude',
label: 'Claude',
command: "claude 'your prompt here'",
startsWithPrompt: true
})
})
it('uses interactive prompt flags when the agent requires them', () => {
expect(
buildTerminalAgentQuickCommandPreset({
agent: 'gemini',
label: 'Gemini',
cmdOverrides: {},
platform: 'linux'
})?.command
).toBe("gemini --prompt-interactive 'your prompt here'")
})
it('marks post-start paste agents as launch-only', () => {
expect(
buildTerminalAgentQuickCommandPreset({
agent: 'aider',
label: 'Aider',
cmdOverrides: {},
platform: 'linux'
})
).toEqual({
agent: 'aider',
label: 'Aider',
command: 'aider',
startsWithPrompt: false
})
})
it('preserves configured command overrides', () => {
expect(
buildTerminalAgentQuickCommandPreset({
agent: 'codex',
label: 'Codex',
cmdOverrides: { codex: '/opt/bin/codex' },
platform: 'linux'
})?.command
).toBe("/opt/bin/codex 'your prompt here'")
})
})

View File

@ -1,43 +0,0 @@
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import type { AgentStartupShell } from '../../../../shared/tui-agent-startup'
import type { TuiAgent } from '../../../../shared/types'
export const TERMINAL_AGENT_QUICK_COMMAND_PRESET_PROMPT = 'your prompt here'
export type TerminalAgentQuickCommandPreset = {
agent: TuiAgent
label: string
command: string
startsWithPrompt: boolean
}
export function buildTerminalAgentQuickCommandPreset(args: {
agent: TuiAgent
label: string
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
shell?: AgentStartupShell
}): TerminalAgentQuickCommandPreset | null {
const plan = buildAgentStartupPlan({
agent: args.agent,
prompt: TERMINAL_AGENT_QUICK_COMMAND_PRESET_PROMPT,
cmdOverrides: args.cmdOverrides,
platform: args.platform,
...(args.shell ? { shell: args.shell } : {})
})
if (!plan) {
return null
}
// Why: quick commands only store one terminal input string. Agents that need
// a post-start paste can still be launched, but the prompt cannot be encoded
// in the saved quick-command text without runtime readiness handling.
const startsWithPrompt = plan.followupPrompt === null
return {
agent: args.agent,
label: args.label,
command: plan.launchCommand,
startsWithPrompt
}
}

View File

@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import type { TerminalQuickCommand } from '../../../../shared/types'
import {
createTerminalQuickCommandDialogDraftMemory,
switchTerminalQuickCommandDialogAction
} from './terminal-quick-command-dialog-draft'
describe('terminal quick command dialog draft transitions', () => {
it('keeps agent prompt blank when switching from a terminal command for the first time', () => {
const command: TerminalQuickCommand = {
id: 'qc-1',
label: 'Status',
action: 'terminal-command',
command: 'git status',
appendEnter: false,
scope: { type: 'global' }
}
const result = switchTerminalQuickCommandDialogAction(
command,
'agent-prompt',
createTerminalQuickCommandDialogDraftMemory(command, 'claude')
)
expect(result.draft).toEqual({
id: 'qc-1',
label: 'Status',
action: 'agent-prompt',
agent: 'claude',
prompt: '',
scope: { type: 'global' }
})
})
it('keeps terminal command blank when switching from an agent prompt for the first time', () => {
const command: TerminalQuickCommand = {
id: 'qc-1',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: 'Review the diff',
scope: { type: 'repo', repoId: 'repo-1' }
}
const result = switchTerminalQuickCommandDialogAction(
command,
'terminal-command',
createTerminalQuickCommandDialogDraftMemory(command, 'claude')
)
expect(result.draft).toEqual({
id: 'qc-1',
label: 'Review',
action: 'terminal-command',
command: '',
appendEnter: true,
scope: { type: 'repo', repoId: 'repo-1' }
})
})
it('preserves independent text, agent, and append-enter drafts across toggles', () => {
const initial: TerminalQuickCommand = {
id: 'qc-1',
label: 'Work',
action: 'terminal-command',
command: 'pnpm test',
appendEnter: false,
scope: { type: 'global' }
}
const initialMemory = createTerminalQuickCommandDialogDraftMemory(initial, 'claude')
const toAgent = switchTerminalQuickCommandDialogAction(initial, 'agent-prompt', initialMemory)
const editedAgent: TerminalQuickCommand = {
...toAgent.draft,
action: 'agent-prompt',
agent: 'codex',
prompt: 'Investigate failures'
}
const backToTerminal = switchTerminalQuickCommandDialogAction(
editedAgent,
'terminal-command',
toAgent.memory
)
const editedTerminal: TerminalQuickCommand = {
...backToTerminal.draft,
action: 'terminal-command',
command: 'pnpm vitest',
appendEnter: true
}
const backToAgent = switchTerminalQuickCommandDialogAction(
editedTerminal,
'agent-prompt',
backToTerminal.memory
)
expect(backToTerminal.draft).toMatchObject({
action: 'terminal-command',
command: 'pnpm test',
appendEnter: false
})
expect(backToAgent.draft).toMatchObject({
action: 'agent-prompt',
agent: 'codex',
prompt: 'Investigate failures'
})
const finalTerminal = switchTerminalQuickCommandDialogAction(
backToAgent.draft,
'terminal-command',
backToAgent.memory
)
expect(finalTerminal.draft).toMatchObject({
action: 'terminal-command',
command: 'pnpm vitest',
appendEnter: true
})
})
})

View File

@ -0,0 +1,92 @@
import {
getTerminalQuickCommandScope,
isTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
import type { TerminalQuickCommand, TuiAgent } from '../../../../shared/types'
export type TerminalQuickCommandDialogAction = 'terminal-command' | 'agent-prompt'
export type TerminalQuickCommandDialogDraftMemory = {
terminalCommand: string
terminalAppendEnter: boolean
agent: TuiAgent
agentPrompt: string
}
export function createTerminalQuickCommandDialogDraftMemory(
command: TerminalQuickCommand,
fallbackAgent: TuiAgent
): TerminalQuickCommandDialogDraftMemory {
if (isTerminalAgentQuickCommand(command)) {
return {
terminalCommand: '',
terminalAppendEnter: true,
agent: command.agent,
agentPrompt: command.prompt
}
}
return {
terminalCommand: command.command,
terminalAppendEnter: command.appendEnter,
agent: fallbackAgent,
agentPrompt: ''
}
}
export function rememberTerminalQuickCommandDialogDraft(
memory: TerminalQuickCommandDialogDraftMemory,
draft: TerminalQuickCommand
): TerminalQuickCommandDialogDraftMemory {
if (isTerminalAgentQuickCommand(draft)) {
return {
...memory,
agent: draft.agent,
agentPrompt: draft.prompt
}
}
return {
...memory,
terminalCommand: draft.command,
terminalAppendEnter: draft.appendEnter
}
}
export function switchTerminalQuickCommandDialogAction(
draft: TerminalQuickCommand,
action: TerminalQuickCommandDialogAction,
memory: TerminalQuickCommandDialogDraftMemory
): {
draft: TerminalQuickCommand
memory: TerminalQuickCommandDialogDraftMemory
} {
const nextMemory = rememberTerminalQuickCommandDialogDraft(memory, draft)
const base = {
id: draft.id,
label: draft.label,
scope: getTerminalQuickCommandScope(draft)
}
// Why: action modes are independent editors; toggling should not transform
// terminal command text into an agent prompt, or the reverse.
if (action === 'agent-prompt') {
return {
memory: nextMemory,
draft: {
...base,
action: 'agent-prompt',
agent: nextMemory.agent,
prompt: nextMemory.agentPrompt
}
}
}
return {
memory: nextMemory,
draft: {
...base,
action: 'terminal-command',
command: nextMemory.terminalCommand,
appendEnter: nextMemory.terminalAppendEnter
}
}
}

View File

@ -223,6 +223,10 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
}
]
export function getAgentLabel(agent: TuiAgent): string {
return AGENT_CATALOG.find((entry) => entry.id === agent)?.label ?? agent
}
function PiIcon({ size = 14 }: { size?: number }): React.JSX.Element {
// SVG sourced from pi.dev/favicon.svg — the π shape rendered in currentColor.
// Why: className="text-current" opts out of shadcn's Select rule that forces

View File

@ -153,7 +153,36 @@ describe('pasteDraftWhenAgentReady', () => {
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
})
it('can force paste and submit for native-prefill agents', async () => {
it('submits in a separate write after force-pasting native-prefill agents', async () => {
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
agent: 'claude',
submit: true,
forcePaste: true
})
await flushMicrotasks()
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await vi.advanceTimersByTimeAsync(1500)
await flushMicrotasks()
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
{},
'pty-1',
PASTED_ISSUE_URL
)
await vi.advanceTimersByTimeAsync(49)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r')
})
it('does not submit when the verified paste write fails', async () => {
testState.sendRuntimePtyInputVerified.mockResolvedValueOnce(false)
const promise = pasteDraftWhenAgentReady({
tabId: 'tab-1',
content: ISSUE_URL,
@ -166,12 +195,8 @@ describe('pasteDraftWhenAgentReady', () => {
testState.ptyObserver?.(DECSET_BRACKETED_PASTE)
await vi.advanceTimersByTimeAsync(1500)
await expect(promise).resolves.toBe(true)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith(
{},
'pty-1',
`${PASTED_ISSUE_URL}\r`
)
await expect(promise).resolves.toBe(false)
expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1)
})
it('reports false when verified input delivery fails', async () => {

View File

@ -14,6 +14,7 @@ import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-strea
// line-edit shortcuts. Callers choose whether to append Enter after the paste.
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
const POST_PASTE_SUBMIT_DELAY_MS = 50
// Why: every prefill-capable TUI we ship support for (claude / codex / pi /
// opencode / gemini / cursor-agent / copilot) emits `CSI ? 2004 h` (DECSET
@ -94,15 +95,11 @@ export async function pasteDraftWhenAgentReady(args: {
return false
}
try {
return await sendRuntimePtyInputVerified(
useAppStore.getState().settings,
ptyId,
`${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}${submit ? '\r' : ''}`
)
} catch {
return false
}
return await sendBracketedPasteToAgent({
ptyId,
content,
submit: submit === true
})
}
export async function submitPromptToAgentTab(args: {
@ -115,11 +112,34 @@ export async function submitPromptToAgentTab(args: {
if (!ptyId) {
return false
}
return await sendRuntimePtyInputVerified(
useAppStore.getState().settings,
ptyId,
`${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}\r`
)
return await sendBracketedPasteToAgent({ ptyId, content, submit: true })
}
async function sendBracketedPasteToAgent(args: {
ptyId: string
content: string
submit: boolean
}): Promise<boolean> {
const { ptyId, content, submit } = args
const settings = useAppStore.getState().settings
const pastePayload = `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`
try {
const pasted = await sendRuntimePtyInputVerified(settings, ptyId, pastePayload)
if (!pasted) {
return false
}
if (!submit) {
return true
}
// Why: Claude Code can leave a prompt as editable text when paste-end and
// Enter arrive in the same PTY write. Split the submit into the next turn so
// the TUI processes bracketed-paste termination before handling Enter.
await new Promise<void>((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS))
return await sendRuntimePtyInputVerified(settings, ptyId, '\r')
} catch {
return false
}
}
/**

View File

@ -8,11 +8,20 @@ type MockStoreState = {
setTabBarOrder: ReturnType<typeof vi.fn>
setRecentQuickCommandForGroup: ReturnType<typeof vi.fn>
tabsByWorktree: Record<string, { id: string }[]>
unifiedTabsByWorktree: Record<
string,
{ entityId: string; contentType: string; groupId: string }[]
>
activeGroupIdByWorktree: Record<string, string>
openFiles: { id: string; worktreeId: string }[]
browserTabsByWorktree: Record<string, { id: string }[]>
tabBarOrderByWorktree: Record<string, string[]>
}
const mocks = vi.hoisted(() => ({
launchAgentInNewTab: vi.fn()
}))
let mockState: MockStoreState
vi.mock('@/store', () => ({
@ -21,24 +30,32 @@ vi.mock('@/store', () => ({
}
}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({
launchAgentInNewTab: mocks.launchAgentInNewTab
}))
function createStoreState(): MockStoreState {
const state: MockStoreState = {
return {
createTab: vi.fn(() => ({ id: 'tab-new' })),
queueTabStartupCommand: vi.fn(),
setActiveTabType: vi.fn(),
setTabBarOrder: vi.fn(),
setRecentQuickCommandForGroup: vi.fn(),
tabsByWorktree: { 'wt-1': [{ id: 'tab-existing' }, { id: 'tab-new' }] },
unifiedTabsByWorktree: {
'wt-1': [{ entityId: 'tab-new', contentType: 'terminal', groupId: 'group-1' }]
},
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
openFiles: [],
browserTabsByWorktree: {},
tabBarOrderByWorktree: {}
}
return state
}
describe('runQuickCommandInNewTab', () => {
beforeEach(() => {
mockState = createStoreState()
mocks.launchAgentInNewTab.mockReset()
})
it('flattens multiline quick commands before queuing', () => {
@ -46,6 +63,7 @@ describe('runQuickCommandInNewTab', () => {
command: {
id: 'build',
label: 'Build',
action: 'terminal-command',
command: 'cd packages\nbun run build\ncd ..',
appendEnter: true
},
@ -65,6 +83,7 @@ describe('runQuickCommandInNewTab', () => {
command: {
id: 'status',
label: 'Status',
action: 'terminal-command',
command: 'git status',
appendEnter: true
},
@ -76,4 +95,82 @@ describe('runQuickCommandInNewTab', () => {
command: 'git status'
})
})
it('launches agent quick commands through the programmatic agent prompt path', () => {
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',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: 'Review this diff'
},
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',
launchSource: 'quick_command'
})
expect(mockState.queueTabStartupCommand).not.toHaveBeenCalled()
expect(mockState.setRecentQuickCommandForGroup).toHaveBeenCalledWith('group-1', 'agent-review')
})
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' })
const result = runQuickCommandInNewTab({
command: {
id: 'agent-review',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: 'Review this diff'
},
worktreeId: 'repo::worktree',
groupId: null
})
expect(result).toEqual({ tabId: 'tab-agent' })
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({
agent: 'codex',
prompt: 'Review this diff',
worktreeId: 'repo::worktree',
groupId: undefined,
launchSource: 'quick_command'
})
expect(mockState.setRecentQuickCommandForGroup).toHaveBeenCalledWith(
'active-group',
'agent-review'
)
})
it('does not launch post-start-only agent quick commands', () => {
const result = runQuickCommandInNewTab({
command: {
id: 'agent-aider',
label: 'Aider',
action: 'agent-prompt',
agent: 'aider',
prompt: 'Review this diff'
},
worktreeId: 'repo::worktree',
groupId: 'group-1'
})
expect(result).toBeNull()
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
expect(mockState.queueTabStartupCommand).not.toHaveBeenCalled()
})
})

View File

@ -1,14 +1,35 @@
import { useAppStore } from '@/store'
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
import { flattenTerminalQuickCommand } from '../../../shared/terminal-quick-commands'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import {
flattenTerminalQuickCommand,
isTerminalAgentQuickCommand,
supportsTerminalAgentQuickCommand
} from '../../../shared/terminal-quick-commands'
import type { TerminalQuickCommand } from '../../../shared/types'
export type RunQuickCommandInNewTabArgs = {
command: TerminalQuickCommand
worktreeId: string
/** Tab group the user clicked from. Keeps the spawned terminal in the
* pane the user initiated from instead of falling through to the active group. */
groupId: string
* pane the user initiated from when available. */
groupId?: string | null
}
function resolveQuickCommandGroupId(
worktreeId: string,
tabId: string,
fallbackGroupId: string | null | undefined
): string | null {
const state = useAppStore.getState()
return (
state.unifiedTabsByWorktree[worktreeId]?.find(
(tab) => tab.entityId === tabId && tab.contentType === 'terminal'
)?.groupId ??
fallbackGroupId ??
state.activeGroupIdByWorktree[worktreeId] ??
null
)
}
/**
@ -18,21 +39,45 @@ export type RunQuickCommandInNewTabArgs = {
* the command runs (mirrors the agent quick-launch path in
* `launchAgentInNewTab`).
*
* Always appends Enter the split-button is a "run" affordance, distinct
* from the right-click "Insert" mode where `appendEnter: false` is honored.
* 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.
*/
export function runQuickCommandInNewTab({
command,
worktreeId,
groupId
}: RunQuickCommandInNewTabArgs): { tabId: string } | null {
const targetGroupId = groupId ?? undefined
if (isTerminalAgentQuickCommand(command)) {
if (!command.prompt.trim() || !supportsTerminalAgentQuickCommand(command.agent)) {
return null
}
const result = launchAgentInNewTab({
agent: command.agent,
prompt: command.prompt,
worktreeId,
groupId: targetGroupId,
launchSource: 'quick_command'
})
if (result) {
const launchedGroupId = resolveQuickCommandGroupId(worktreeId, result.tabId, groupId)
if (launchedGroupId) {
useAppStore.getState().setRecentQuickCommandForGroup(launchedGroupId, command.id)
}
return { tabId: result.tabId }
}
return null
}
// Why: a whitespace-only command would still spawn a terminal but feed it an
// empty string, leaving the user with an unexplained blank tab. Refuse early.
if (!command.command.trim()) {
return null
}
const store = useAppStore.getState()
const tab = store.createTab(worktreeId, groupId)
const tab = store.createTab(worktreeId, targetGroupId)
store.queueTabStartupCommand(tab.id, {
command: flattenTerminalQuickCommand(command).command
@ -60,7 +105,10 @@ export function runQuickCommandInNewTab({
order.push(tab.id)
fresh.setTabBarOrder(worktreeId, order)
fresh.setRecentQuickCommandForGroup(groupId, command.id)
const launchedGroupId = resolveQuickCommandGroupId(worktreeId, tab.id, groupId)
if (launchedGroupId) {
fresh.setRecentQuickCommandForGroup(launchedGroupId, command.id)
}
return { tabId: tab.id }
}

View File

@ -150,6 +150,7 @@ export type { WorkspaceSource }
export const launchSourceSchema = z.enum([
'command_palette',
'sidebar',
'quick_command',
'tab_bar_quick_launch',
'task_page',
'new_workspace_composer',

View File

@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest'
import {
buildTerminalQuickCommandInput,
flattenTerminalQuickCommand,
getTerminalQuickCommandAction,
getTerminalQuickCommandBody,
getDefaultTerminalQuickCommands,
isTerminalQuickCommandComplete,
normalizeTerminalQuickCommands,
supportsTerminalAgentQuickCommand,
terminalQuickCommandMatchesRepo
} from './terminal-quick-commands'
@ -49,6 +53,7 @@ describe('terminal quick commands', () => {
{
id: 'status',
label: 'Status',
action: 'terminal-command',
command: 'git status',
appendEnter: false,
scope: { type: 'global' }
@ -56,6 +61,7 @@ describe('terminal quick commands', () => {
{
id: 'empty-command',
label: 'Empty',
action: 'terminal-command',
command: '',
appendEnter: true,
scope: { type: 'global' }
@ -63,6 +69,7 @@ describe('terminal quick commands', () => {
{
id: 'status-2',
label: 'Duplicate',
action: 'terminal-command',
command: 'pwd',
appendEnter: true,
scope: { type: 'global' }
@ -70,6 +77,7 @@ describe('terminal quick commands', () => {
{
id: 'quick-command-4',
label: 'No ID',
action: 'terminal-command',
command: 'date',
appendEnter: true,
scope: { type: 'global' }
@ -97,6 +105,7 @@ describe('terminal quick commands', () => {
{
id: 'repo-dev',
label: 'Dev',
action: 'terminal-command',
command: 'pnpm dev',
appendEnter: true,
scope: { type: 'repo', repoId: 'repo-1' }
@ -104,6 +113,7 @@ describe('terminal quick commands', () => {
{
id: 'bad-repo',
label: 'Bad',
action: 'terminal-command',
command: 'echo bad',
appendEnter: true,
scope: { type: 'global' }
@ -111,6 +121,44 @@ describe('terminal quick commands', () => {
])
})
it('normalizes agent prompt commands without storing generated shell text', () => {
expect(
normalizeTerminalQuickCommands([
{
id: 'agent-review',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: ' Review this diff\n',
command: "codex 'old workaround'"
},
{
id: 'unknown-agent',
label: 'Unknown',
action: 'agent-prompt',
agent: 'not-real',
prompt: 'Do work'
},
{
id: 'post-start-agent',
label: 'Aider',
action: 'agent-prompt',
agent: 'aider',
prompt: 'Do work'
}
])
).toEqual([
{
id: 'agent-review',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: ' Review this diff',
scope: { type: 'global' }
}
])
})
it('matches global commands everywhere and repo commands only in their repo', () => {
expect(
terminalQuickCommandMatchesRepo(
@ -168,6 +216,36 @@ describe('terminal quick commands', () => {
})
).toBe('git status')
})
it('classifies quick command actions and body text', () => {
const terminal = {
id: 'status',
label: 'Status',
command: 'git status',
appendEnter: true
}
const agent = {
id: 'agent',
label: 'Agent',
action: 'agent-prompt' as const,
agent: 'claude' as const,
prompt: 'Fix the tests'
}
expect(getTerminalQuickCommandAction(terminal)).toBe('terminal-command')
expect(getTerminalQuickCommandBody(terminal)).toBe('git status')
expect(isTerminalQuickCommandComplete(terminal)).toBe(true)
expect(getTerminalQuickCommandAction(agent)).toBe('agent-prompt')
expect(getTerminalQuickCommandBody(agent)).toBe('Fix the tests')
expect(isTerminalQuickCommandComplete(agent)).toBe(true)
})
it('only allows agent prompt quick commands for launch-time prompt agents', () => {
expect(supportsTerminalAgentQuickCommand('claude')).toBe(true)
expect(supportsTerminalAgentQuickCommand('gemini')).toBe(true)
expect(supportsTerminalAgentQuickCommand('aider')).toBe(false)
expect(supportsTerminalAgentQuickCommand('not-real')).toBe(false)
})
})
describe('flattenTerminalQuickCommand', () => {

View File

@ -1,4 +1,11 @@
import type { TerminalQuickCommand, TerminalQuickCommandScope } from './types'
import { isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config'
import type {
TerminalAgentQuickCommand,
TerminalCommandQuickCommand,
TerminalQuickCommand,
TerminalQuickCommandAction,
TerminalQuickCommandScope
} from './types'
const MAX_QUICK_COMMANDS = 40
const MAX_QUICK_COMMAND_LABEL_LENGTH = 80
@ -41,6 +48,38 @@ export function terminalQuickCommandMatchesRepo(
return scope.type === 'global' || (repoId !== null && scope.repoId === repoId)
}
export function getTerminalQuickCommandAction(
command: TerminalQuickCommand
): TerminalQuickCommandAction {
return command.action === 'agent-prompt' ? 'agent-prompt' : 'terminal-command'
}
export function isTerminalAgentQuickCommand(
command: TerminalQuickCommand
): command is TerminalAgentQuickCommand {
return getTerminalQuickCommandAction(command) === 'agent-prompt'
}
export function isTerminalCommandQuickCommand(
command: TerminalQuickCommand
): command is TerminalCommandQuickCommand {
return getTerminalQuickCommandAction(command) === 'terminal-command'
}
export function supportsTerminalAgentQuickCommand(
agent: unknown
): agent is TerminalAgentQuickCommand['agent'] {
return isTuiAgent(agent) && TUI_AGENT_CONFIG[agent].promptInjectionMode !== 'stdin-after-start'
}
export function getTerminalQuickCommandBody(command: TerminalQuickCommand): string {
return isTerminalAgentQuickCommand(command) ? command.prompt : command.command
}
export function isTerminalQuickCommandComplete(command: TerminalQuickCommand): boolean {
return command.label.trim().length > 0 && getTerminalQuickCommandBody(command).trim().length > 0
}
export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCommand[] {
if (!Array.isArray(input)) {
return getDefaultTerminalQuickCommands()
@ -59,14 +98,20 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom
continue
}
const hasLabel = typeof record.label === 'string'
const action: TerminalQuickCommandAction =
record.action === 'agent-prompt' ? 'agent-prompt' : 'terminal-command'
const hasCommand = typeof record.command === 'string'
const hasPrompt = typeof record.prompt === 'string'
// Why: settings saves on every edit; preserve incomplete rows so a newly
// added command is not deleted before the user fills in the command text.
if (!hasLabel && !hasCommand) {
if (!hasLabel && !hasCommand && !hasPrompt) {
continue
}
const agent = supportsTerminalAgentQuickCommand(record.agent) ? record.agent : null
if (action === 'agent-prompt' && agent === null) {
continue
}
const label = hasLabel ? String(record.label).trim() : ''
const command = hasCommand ? String(record.command).trimEnd() : ''
const idBase = rawId || `quick-command-${normalized.length + 1}`
let id = idBase.slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH)
@ -77,13 +122,35 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom
}
seenIds.add(id)
normalized.push({
const base = {
id,
label: label.slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH),
command: command.slice(0, MAX_QUICK_COMMAND_TEXT_LENGTH),
appendEnter: record.appendEnter !== false,
scope: normalizeTerminalQuickCommandScope(record.scope)
})
}
if (action === 'agent-prompt') {
if (agent === null) {
continue
}
const agentId = agent
normalized.push({
...base,
action: 'agent-prompt',
agent: agentId,
prompt: (hasPrompt ? String(record.prompt).trimEnd() : '').slice(
0,
MAX_QUICK_COMMAND_TEXT_LENGTH
)
})
} else {
const command = hasCommand ? String(record.command).trimEnd() : ''
normalized.push({
...base,
action: 'terminal-command',
command: command.slice(0, MAX_QUICK_COMMAND_TEXT_LENGTH),
appendEnter: record.appendEnter !== false
})
}
if (normalized.length >= MAX_QUICK_COMMANDS) {
break
@ -93,7 +160,7 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom
return normalized
}
export function buildTerminalQuickCommandInput(command: TerminalQuickCommand): string {
export function buildTerminalQuickCommandInput(command: TerminalCommandQuickCommand): string {
return command.appendEnter ? `${command.command}\r` : command.command
}
@ -101,7 +168,9 @@ const LINE_BREAK_RE = /\r\n|\r|\n/
// Why: quick-command lines are independent shell commands; one shell command
// list prevents foreground programs from reading later lines as stdin.
export function flattenTerminalQuickCommand(command: TerminalQuickCommand): TerminalQuickCommand {
export function flattenTerminalQuickCommand(
command: TerminalCommandQuickCommand
): TerminalCommandQuickCommand {
if (!LINE_BREAK_RE.test(command.command)) {
return command
}

View File

@ -1614,14 +1614,28 @@ export type TerminalQuickCommandScope =
repoId: string
}
export type TerminalQuickCommand = {
export type TerminalQuickCommandAction = 'terminal-command' | 'agent-prompt'
export type TerminalQuickCommandBase = {
id: string
label: string
command: string
appendEnter: boolean
scope?: TerminalQuickCommandScope
}
export type TerminalCommandQuickCommand = TerminalQuickCommandBase & {
action?: 'terminal-command'
command: string
appendEnter: boolean
}
export type TerminalAgentQuickCommand = TerminalQuickCommandBase & {
action: 'agent-prompt'
agent: TuiAgent
prompt: string
}
export type TerminalQuickCommand = TerminalCommandQuickCommand | TerminalAgentQuickCommand
export type OpenInApplication = {
id: string
label: string