Add scoped terminal quick commands (#2277)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-18 19:03:46 -04:00 committed by GitHub
parent 9c67557a0c
commit 87a83ae4f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 689 additions and 173 deletions

View File

@ -63,6 +63,7 @@ import { GhosttyImportModal } from './GhosttyImportModal'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import { ManageSessionsSection } from './ManageSessionsSection'
import { TerminalQuickCommandsSection } from './TerminalQuickCommandsSection'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
type TerminalPaneProps = {
settings: GlobalSettings
@ -93,6 +94,9 @@ export function TerminalPane({
pwshAvailable
}: TerminalPaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const repos = useAppStore((state) => state.repos)
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
const activeRepoId = activeWorktreeId ? getRepoIdFromWorktreeId(activeWorktreeId) : null
const isWindows = isWindowsUserAgent()
const isMac = isMacUserAgent()
const [themeSearchDark, setThemeSearchDark] = useState('')
@ -276,18 +280,29 @@ export function TerminalPane({
<div className="space-y-1">
<h3 className="text-sm font-semibold">Quick Commands</h3>
<p className="text-xs text-muted-foreground">
Save terminal input snippets for the terminal right-click menu.
Save global and repository-specific terminal snippets for the right-click menu.
</p>
</div>
<SearchableSetting
title="Quick Commands"
description="Create, edit, and remove terminal command snippets for the right-click menu."
keywords={['terminal', 'command', 'snippet', 'quick command', 'send', 'context menu']}
description="Create, edit, and remove scoped terminal command snippets for the right-click menu."
keywords={[
'terminal',
'command',
'snippet',
'quick command',
'send',
'context menu',
'repo',
'repository'
]}
className="space-y-3"
>
<TerminalQuickCommandsSection
commands={settings.terminalQuickCommands ?? []}
repos={repos}
activeRepoId={activeRepoId}
onChange={(terminalQuickCommands) => updateSettings({ terminalQuickCommands })}
/>
</SearchableSetting>

View File

@ -1,24 +1,31 @@
import { useEffect, useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { Button } from '../ui/button'
import type {
Repo,
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../../shared/types'
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { Input } from '../ui/input'
createTerminalQuickCommandDraft,
TerminalQuickCommandDialog
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import RepoDotLabel from '../repo/RepoDotLabel'
type TerminalQuickCommandsSectionProps = {
commands: TerminalQuickCommand[]
repos: Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
activeRepoId: string | null
onChange: (commands: TerminalQuickCommand[]) => void
}
type ScopeFilter = 'all' | 'global' | 'repo'
type EditorState =
| {
mode: 'add'
@ -30,51 +37,94 @@ type EditorState =
}
| null
function createQuickCommand(): TerminalQuickCommand {
return {
id: `quick-command-${createBrowserUuid()}`,
label: '',
command: '',
appendEnter: true
function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
return repo.displayName || repo.path
}
function getScopeLabel(
scope: TerminalQuickCommandScope,
repoById: Map<string, Pick<Repo, 'displayName' | 'path' | 'badgeColor'>>
): string {
if (scope.type === 'global') {
return 'Global'
}
const repo = repoById.get(scope.repoId)
return repo ? getRepoLabel(repo) : 'Missing repo'
}
export function TerminalQuickCommandsSection({
commands,
repos,
activeRepoId,
onChange
}: TerminalQuickCommandsSectionProps): React.JSX.Element {
const [editor, setEditor] = useState<EditorState>(null)
const [draft, setDraft] = useState<TerminalQuickCommand>(createQuickCommand)
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>('all')
const [repoFilterId, setRepoFilterId] = useState(activeRepoId ?? '')
const [repoFilterManuallyChanged, setRepoFilterManuallyChanged] = useState(false)
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
const activeRepoFilterId = activeRepoId && repoById.has(activeRepoId) ? activeRepoId : ''
const repoFilterIsValid = repoFilterId !== '' && repoById.has(repoFilterId)
const selectedRepoId =
activeRepoFilterId && (!repoFilterManuallyChanged || !repoFilterIsValid)
? activeRepoFilterId
: repoFilterIsValid
? repoFilterId
: (repos[0]?.id ?? '')
const visibleCommands = commands.filter((command) => {
const scope = getTerminalQuickCommandScope(command)
if (scopeFilter === 'global') {
return scope.type === 'global'
}
if (scopeFilter === 'repo') {
return scope.type === 'repo' && (!selectedRepoId || scope.repoId === selectedRepoId)
}
return true
})
const createDraftForCurrentFilter = (): TerminalQuickCommand => {
if (scopeFilter === 'repo' && selectedRepoId) {
return createTerminalQuickCommandDraft({ type: 'repo', repoId: selectedRepoId })
}
return createTerminalQuickCommandDraft({ type: 'global' })
}
// Follow the active worktree until the user picks a repo; resume if that repo disappears.
useEffect(() => {
if (editor) {
setDraft({ ...editor.command })
}
}, [editor])
const saveDraft = (): void => {
const next = {
...draft,
label: draft.label.trim(),
command: draft.command.trimEnd()
}
if (!next.label || !next.command) {
if (!activeRepoFilterId) {
return
}
if (!repoFilterManuallyChanged) {
if (repoFilterId !== activeRepoFilterId) {
setRepoFilterId(activeRepoFilterId)
}
return
}
if (!repoFilterIsValid) {
setRepoFilterId(activeRepoFilterId)
setRepoFilterManuallyChanged(false)
}
}, [activeRepoFilterId, repoFilterId, repoFilterIsValid, repoFilterManuallyChanged])
const changeRepoFilter = (nextRepoId: string): void => {
setRepoFilterManuallyChanged(true)
setRepoFilterId(nextRepoId)
}
const saveCommand = (next: TerminalQuickCommand): void => {
if (editor?.mode === 'edit') {
onChange(commands.map((command) => (command.id === next.id ? next : command)))
} else {
onChange([...commands, next])
}
setEditor(null)
}
const removeCommand = (id: string): void => {
onChange(commands.filter((command) => command.id !== id))
}
const canSave = draft.label.trim().length > 0 && draft.command.trimEnd().length > 0
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
@ -88,128 +138,111 @@ export function TerminalQuickCommandsSection({
type="button"
variant="outline"
size="sm"
onClick={() => setEditor({ mode: 'add', command: createQuickCommand() })}
onClick={() => setEditor({ mode: 'add', command: createDraftForCurrentFilter() })}
>
<Plus />
Add Command
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<ToggleGroup
type="single"
value={scopeFilter}
onValueChange={(value) => {
if (value === 'all' || value === 'global' || value === 'repo') {
setScopeFilter(value)
}
}}
className="justify-start"
>
<ToggleGroupItem value="all">All</ToggleGroupItem>
<ToggleGroupItem value="global">Global</ToggleGroupItem>
<ToggleGroupItem value="repo" disabled={repos.length === 0}>
Repository
</ToggleGroupItem>
</ToggleGroup>
{scopeFilter === 'repo' && repos.length > 0 ? (
<Select value={selectedRepoId} onValueChange={changeRepoFilter}>
<SelectTrigger size="sm" className="min-w-52">
<SelectValue placeholder="Choose repository" />
</SelectTrigger>
<SelectContent>
{repos.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<RepoDotLabel
name={getRepoLabel(repo)}
color={repo.badgeColor}
className="max-w-full"
/>
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
<div className="overflow-hidden rounded-lg border border-border/50">
{commands.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">No quick commands saved.</div>
{visibleCommands.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{commands.length === 0 ? 'No quick commands saved.' : 'No commands match this scope.'}
</div>
) : (
<div className="divide-y divide-border/50">
{commands.map((command) => (
<div key={command.id} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{command.label || 'Untitled'}</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{command.command || 'No command text'}
{visibleCommands.map((command) => {
const scope = getTerminalQuickCommandScope(command)
return (
<div key={command.id} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{command.label || 'Untitled'}
</div>
<Badge variant="outline" className="max-w-44">
<span className="truncate">{getScopeLabel(scope, repoById)}</span>
</Badge>
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{command.command || 'No command text'}
</div>
</div>
<div className="shrink-0 text-[11px] text-muted-foreground">
{command.appendEnter ? 'Enter' : 'Insert'}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${command.label || 'quick command'}`}
onClick={() => setEditor({ mode: 'edit', command })}
>
<Pencil />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${command.label || 'quick command'}`}
onClick={() => removeCommand(command.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</Button>
</div>
<div className="shrink-0 text-[11px] text-muted-foreground">
{command.appendEnter ? 'Enter' : 'Insert'}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${command.label || 'quick command'}`}
onClick={() => setEditor({ mode: 'edit', command })}
>
<Pencil />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${command.label || 'quick command'}`}
onClick={() => removeCommand(command.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</Button>
</div>
))}
)
})}
</div>
)}
</div>
<Dialog open={editor !== null} onOpenChange={(open) => !open && setEditor(null)}>
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{editor?.mode === 'edit' ? 'Edit Quick Command' : 'Add Quick Command'}
</DialogTitle>
<DialogDescription className="text-xs">
Save terminal input text for the context menu.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Label</Label>
<Input
value={draft.label}
onChange={(event) =>
setDraft((current) => ({ ...current, label: event.target.value }))
}
placeholder="Restart server"
/>
</div>
<div className="space-y-2">
<Label>Command Text</Label>
<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="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={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'
}`}
/>
</button>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditor(null)}>
Cancel
</Button>
<Button type="button" onClick={saveDraft} disabled={!canSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TerminalQuickCommandDialog
open={editor !== null}
mode={editor?.mode ?? 'add'}
command={editor?.command ?? createTerminalQuickCommandDraft()}
repos={repos}
onOpenChange={(open) => !open && setEditor(null)}
onSave={saveCommand}
/>
</div>
)
}

View File

@ -8,6 +8,7 @@ import {
PanelsTopLeft,
PanelRightClose,
Pencil,
Plus,
SquareTerminal,
X
} from 'lucide-react'
@ -15,6 +16,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
@ -41,8 +43,11 @@ type TerminalContextMenuProps = {
onEqualizePaneSizes: () => void
onClosePane: () => void
onClearScreen: () => void
quickCommands: TerminalQuickCommand[]
repoQuickCommands: TerminalQuickCommand[]
globalQuickCommands: TerminalQuickCommand[]
quickCommandRepoLabel: string | null
onQuickCommand: (command: TerminalQuickCommand) => void
onAddQuickCommand: () => void
onToggleExpand: () => void
onSetTitle: () => void
}
@ -63,14 +68,18 @@ export default function TerminalContextMenu({
onEqualizePaneSizes,
onClosePane,
onClearScreen,
quickCommands,
repoQuickCommands,
globalQuickCommands,
quickCommandRepoLabel,
onQuickCommand,
onAddQuickCommand,
onToggleExpand,
onSetTitle
}: TerminalContextMenuProps): React.JSX.Element {
const isMac = navigator.userAgent.includes('Mac')
const mod = isMac ? '⌘' : 'Ctrl+'
const shift = isMac ? '⇧' : 'Shift+'
const hasQuickCommands = repoQuickCommands.length > 0 || globalQuickCommands.length > 0
return (
<DropdownMenu
@ -126,24 +135,65 @@ export default function TerminalContextMenu({
Paste
<DropdownMenuShortcut>{mod}V</DropdownMenuShortcut>
</DropdownMenuItem>
{quickCommands.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SquareTerminal />
Quick Commands
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-60">
{quickCommands.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>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SquareTerminal />
Quick Commands
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-60">
{hasQuickCommands ? (
<>
{quickCommandRepoLabel && repoQuickCommands.length > 0 ? (
<>
<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>
))}
</>
) : null}
{globalQuickCommands.length > 0 ? (
<>
{repoQuickCommands.length > 0 ? <DropdownMenuSeparator /> : null}
{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>
))}
</>
) : null}
</>
) : (
<DropdownMenuItem disabled className="text-muted-foreground">
No quick commands
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
// Why: the dropdown sits above dialogs; force-close before
// opening the add modal even during the open-gesture guard.
onOpenChange(false)
onAddQuickCommand()
}}
>
<Plus />
Add Quick Command
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onSplitRight}>
<PanelRightClose />

View File

@ -60,6 +60,17 @@ import {
isSyntheticSinglePaneTitle,
sanitizeTerminalLayoutPaneTitles
} from '@/lib/terminal-pane-title-sanitization'
import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
import {
getTerminalQuickCommandScope,
terminalQuickCommandMatchesRepo
} from '../../../../shared/terminal-quick-commands'
import {
createTerminalQuickCommandDraft,
TerminalQuickCommandDialog
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
// Why: registry lives in a leaf module so the store slice can import it
// without re-entering the `slice → TerminalPane → store → slice` cycle
@ -148,6 +159,10 @@ export default function TerminalPane({
searchOpenRef.current = searchOpen
const searchStateRef = useRef<SearchState>({ query: '', caseSensitive: false, regex: false })
const [closeConfirmPaneId, setCloseConfirmPaneId] = useState<number | null>(null)
const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false)
// Why: the terminal menu can be the first quick-command entry point, so each
// Add action starts with a fresh draft instead of reusing cancelled text.
const [quickCommandDraft, setQuickCommandDraft] = useState(createTerminalQuickCommandDraft)
const [terminalError, setTerminalError] = useState<string | null>(null)
const [sessionStateSaveFailureOpen, setSessionStateSaveFailureOpen] = useState(false)
// Why: override state lives in a plain Map for perf (safeFit reads it on
@ -291,6 +306,8 @@ export default function TerminalPane({
const openSpacePage = useAppStore((store) => store.openSpacePage)
const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace)
const settings = useAppStore((store) => store.settings)
const repos = useAppStore((store) => store.repos)
const updateSettings = useAppStore((store) => store.updateSettings)
// Why: Windows is the only platform where bare right-click is repurposed as
// a paste gesture; on macOS/Linux the terminal still owns right-click for the
// context menu. The settings default keeps the Windows shortcut feeling native
@ -319,6 +336,38 @@ export default function TerminalPane({
})
}, [openSpacePage, refreshWorkspaceSpace])
const quickCommandRepoId =
worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? null : getRepoIdFromWorktreeId(worktreeId)
const quickCommandRepo = repos.find((repo) => repo.id === quickCommandRepoId) ?? null
const quickCommandRepoLabel = quickCommandRepo
? quickCommandRepo.displayName || quickCommandRepo.path
: quickCommandRepoId
? 'This Repo'
: null
const validQuickCommands = (settings?.terminalQuickCommands ?? []).filter(
(command) => command.label.trim() && command.command.trimEnd()
)
const repoQuickCommands = validQuickCommands.filter((command) => {
const scope = getTerminalQuickCommandScope(command)
return scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId)
})
const globalQuickCommands = validQuickCommands.filter(
(command) => getTerminalQuickCommandScope(command).type === 'global'
)
const openQuickCommandEditor = useCallback((scope: TerminalQuickCommandScope): void => {
setQuickCommandDraft(createTerminalQuickCommandDraft(scope))
setQuickCommandEditorOpen(true)
}, [])
const saveQuickCommand = useCallback(
(command: TerminalQuickCommand): void => {
const currentCommands = useAppStore.getState().settings?.terminalQuickCommands ?? []
void updateSettings({ terminalQuickCommands: [...currentCommands, command] })
},
[updateSettings]
)
useEffect(() => {
if (setupSplit) {
consumeTabSetupSplit(tabId)
@ -1383,13 +1432,26 @@ export default function TerminalPane({
onEqualizePaneSizes={contextMenu.onEqualizePaneSizes}
onClosePane={contextMenu.onClosePane}
onClearScreen={contextMenu.onClearScreen}
quickCommands={(settings?.terminalQuickCommands ?? []).filter(
(command) => command.label.trim() && command.command.trimEnd()
)}
repoQuickCommands={repoQuickCommands}
globalQuickCommands={globalQuickCommands}
quickCommandRepoLabel={quickCommandRepoLabel}
onQuickCommand={contextMenu.onQuickCommand}
onAddQuickCommand={
quickCommandRepoId
? () => openQuickCommandEditor({ type: 'repo', repoId: quickCommandRepoId })
: () => openQuickCommandEditor({ type: 'global' })
}
onToggleExpand={contextMenu.onToggleExpand}
onSetTitle={contextMenu.onSetTitle}
/>
<TerminalQuickCommandDialog
open={quickCommandEditorOpen}
mode="add"
command={quickCommandDraft}
repos={repos}
onOpenChange={setQuickCommandEditorOpen}
onSave={saveQuickCommand}
/>
{/* Title bar overlays portaled into each pane container that has a title
or is currently being renamed (so the inline input appears even for
untitled panes when "Set Title..." is triggered).

View File

@ -0,0 +1,237 @@
import { useEffect, 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
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 RepoDotLabel from '@/components/repo/RepoDotLabel'
type TerminalQuickCommandDialogMode = 'add' | 'edit'
type TerminalQuickCommandDialogProps = {
open: boolean
mode: TerminalQuickCommandDialogMode
command: TerminalQuickCommand
repos?: Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
onOpenChange: (open: boolean) => void
onSave: (command: TerminalQuickCommand) => void
}
export function createTerminalQuickCommandDraft(
scope: TerminalQuickCommandScope = { type: 'global' }
): TerminalQuickCommand {
return {
id: `quick-command-${createBrowserUuid()}`,
label: '',
command: '',
appendEnter: true,
scope
}
}
function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
return repo.displayName || repo.path
}
export function TerminalQuickCommandDialog({
open,
mode,
command,
repos = [],
onOpenChange,
onSave
}: TerminalQuickCommandDialogProps): React.JSX.Element {
const [draft, setDraft] = useState<TerminalQuickCommand>(command)
const selectedScope = getTerminalQuickCommandScope(draft)
// Why: repo-scoped commands can outlive the current repo list; only an
// explicit selection should replace the saved repo id.
const selectedRepo =
selectedScope.type === 'repo'
? (repos.find((repo) => repo.id === selectedScope.repoId) ?? null)
: null
const selectedRepoId = selectedRepo?.id ?? ''
const selectedRepoMissing = selectedScope.type === 'repo' && selectedRepo === null
useEffect(() => {
if (open) {
setDraft({ ...command })
}
}, [command, open])
const saveDraft = (): void => {
const next = {
...draft,
label: draft.label.trim(),
command: draft.command.trimEnd(),
scope: selectedScope
}
if (!next.label || !next.command) {
return
}
onSave(next)
onOpenChange(false)
}
const canSave = draft.label.trim().length > 0 && draft.command.trimEnd().length > 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{mode === 'edit' ? 'Edit Quick Command' : 'Add Quick Command'}
</DialogTitle>
<DialogDescription className="text-xs">
Save terminal input text for the context menu.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Label</Label>
<Input
value={draft.label}
onChange={(event) =>
setDraft((current) => ({ ...current, label: event.target.value }))
}
placeholder="Restart server"
/>
</div>
<div className="space-y-2">
<Label>Command Text</Label>
<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}>
Repository
</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 ? 'Repository not in list' : 'Choose repository'
}
/>
</SelectTrigger>
<SelectContent>
{repos.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<RepoDotLabel
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 repo 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.
</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'
}`}
/>
</button>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={saveDraft} disabled={!canSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
buildTerminalQuickCommandInput,
getDefaultTerminalQuickCommands,
normalizeTerminalQuickCommands
normalizeTerminalQuickCommands,
terminalQuickCommandMatchesRepo
} from './terminal-quick-commands'
describe('terminal quick commands', () => {
@ -48,29 +49,106 @@ describe('terminal quick commands', () => {
id: 'status',
label: 'Status',
command: 'git status',
appendEnter: false
appendEnter: false,
scope: { type: 'global' }
},
{
id: 'empty-command',
label: 'Empty',
command: '',
appendEnter: true
appendEnter: true,
scope: { type: 'global' }
},
{
id: 'status-2',
label: 'Duplicate',
command: 'pwd',
appendEnter: true
appendEnter: true,
scope: { type: 'global' }
},
{
id: 'quick-command-4',
label: 'No ID',
command: 'date',
appendEnter: true
appendEnter: true,
scope: { type: 'global' }
}
])
})
it('normalizes repository scoped commands and falls back to global for invalid scopes', () => {
expect(
normalizeTerminalQuickCommands([
{
id: 'repo-dev',
label: 'Dev',
command: 'pnpm dev',
scope: { type: 'repo', repoId: ' repo-1 ' }
},
{
id: 'bad-repo',
label: 'Bad',
command: 'echo bad',
scope: { type: 'repo', repoId: ' ' }
}
])
).toEqual([
{
id: 'repo-dev',
label: 'Dev',
command: 'pnpm dev',
appendEnter: true,
scope: { type: 'repo', repoId: 'repo-1' }
},
{
id: 'bad-repo',
label: 'Bad',
command: 'echo bad',
appendEnter: true,
scope: { type: 'global' }
}
])
})
it('matches global commands everywhere and repo commands only in their repo', () => {
expect(
terminalQuickCommandMatchesRepo(
{
id: 'global',
label: 'Global',
command: 'date',
appendEnter: true,
scope: { type: 'global' }
},
null
)
).toBe(true)
expect(
terminalQuickCommandMatchesRepo(
{
id: 'repo',
label: 'Repo',
command: 'pnpm dev',
appendEnter: true,
scope: { type: 'repo', repoId: 'repo-1' }
},
'repo-1'
)
).toBe(true)
expect(
terminalQuickCommandMatchesRepo(
{
id: 'repo',
label: 'Repo',
command: 'pnpm dev',
appendEnter: true,
scope: { type: 'repo', repoId: 'repo-1' }
},
'repo-2'
)
).toBe(false)
})
it('formats terminal input without assuming shell semantics', () => {
expect(
buildTerminalQuickCommandInput({

View File

@ -1,7 +1,8 @@
import type { TerminalQuickCommand } from './types'
import type { TerminalQuickCommand, TerminalQuickCommandScope } from './types'
const MAX_QUICK_COMMANDS = 40
const MAX_QUICK_COMMAND_LABEL_LENGTH = 80
const MAX_QUICK_COMMAND_REPO_ID_LENGTH = 200
const MAX_QUICK_COMMAND_TEXT_LENGTH = 4000
const REMOVED_PRESET_IDS = new Set(['default-pwd', 'default-git-status'])
@ -11,6 +12,35 @@ export function getDefaultTerminalQuickCommands(): TerminalQuickCommand[] {
return DEFAULT_TERMINAL_QUICK_COMMANDS.map((command) => ({ ...command }))
}
function normalizeTerminalQuickCommandScope(input: unknown): TerminalQuickCommandScope {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return { type: 'global' }
}
const record = input as Record<string, unknown>
if (record.type !== 'repo') {
return { type: 'global' }
}
const repoId = typeof record.repoId === 'string' ? record.repoId.trim() : ''
if (!repoId) {
return { type: 'global' }
}
return { type: 'repo', repoId: repoId.slice(0, MAX_QUICK_COMMAND_REPO_ID_LENGTH) }
}
export function getTerminalQuickCommandScope(
command: TerminalQuickCommand
): TerminalQuickCommandScope {
return normalizeTerminalQuickCommandScope(command.scope)
}
export function terminalQuickCommandMatchesRepo(
command: TerminalQuickCommand,
repoId: string | null
): boolean {
const scope = getTerminalQuickCommandScope(command)
return scope.type === 'global' || (repoId !== null && scope.repoId === repoId)
}
export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCommand[] {
if (!Array.isArray(input)) {
return getDefaultTerminalQuickCommands()
@ -51,7 +81,8 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom
id,
label: label.slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH),
command: command.slice(0, MAX_QUICK_COMMAND_TEXT_LENGTH),
appendEnter: record.appendEnter !== false
appendEnter: record.appendEnter !== false,
scope: normalizeTerminalQuickCommandScope(record.scope)
})
if (normalized.length >= MAX_QUICK_COMMANDS) {

View File

@ -1333,11 +1333,21 @@ export type TerminalColorOverrides = {
bold?: string
}
export type TerminalQuickCommandScope =
| {
type: 'global'
}
| {
type: 'repo'
repoId: string
}
export type TerminalQuickCommand = {
id: string
label: string
command: string
appendEnter: boolean
scope?: TerminalQuickCommandScope
}
export type OpenInApplication = {