Add agent prompt quick commands (#3011)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
21556f90ac
commit
1cf754580d
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue