Add Quick Commands surface with tab-bar split-button and settings pane (#2650)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-22 19:14:39 -04:00 committed by GitHub
parent d3d63c888b
commit 114619c8c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 849 additions and 310 deletions

View File

@ -0,0 +1,367 @@
import { useMemo, useState } from 'react'
import { Check, ChevronsUpDown, Pencil, Plus, Trash2 } from 'lucide-react'
import type {
GlobalSettings,
Repo,
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../../shared/types'
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
import {
createTerminalQuickCommandDraft,
TerminalQuickCommandDialog
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
import { useAppStore } from '../../store'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Command, CommandItem, CommandList } from '../ui/command'
import { Label } from '../ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import RepoDotLabel from '../repo/RepoDotLabel'
import { cn } from '@/lib/utils'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
type QuickCommandsPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
const GLOBAL_SCOPE_KEY = '__global__'
type EditorState =
| {
mode: 'add'
command: TerminalQuickCommand
}
| {
mode: 'edit'
command: TerminalQuickCommand
}
| null
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 project'
}
export function QuickCommandsPane({
settings,
updateSettings
}: QuickCommandsPaneProps): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const commands = settings.terminalQuickCommands ?? []
const confirm = useConfirmationDialog()
const [editor, setEditor] = useState<EditorState>(null)
// Why: `null` means "show all" (sticky-all), independent of the current repo
// list — mirrors the tasks-page repo combobox so newly added repos appear
// automatically rather than being silently excluded.
const [scopeSelection, setScopeSelection] = useState<ReadonlySet<string> | null>(null)
const [scopePopoverOpen, setScopePopoverOpen] = useState(false)
const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
const allScopeKeys = useMemo(
() => new Set<string>([GLOBAL_SCOPE_KEY, ...repos.map((r) => r.id)]),
[repos]
)
const effectiveSelection: ReadonlySet<string> = scopeSelection ?? allScopeKeys
const showAll = scopeSelection === null
const visibleCommands = commands.filter((command) => {
const scope = getTerminalQuickCommandScope(command)
if (showAll) {
return true
}
if (scope.type === 'global') {
return effectiveSelection.has(GLOBAL_SCOPE_KEY)
}
return effectiveSelection.has(scope.repoId)
})
const createDraftForCurrentFilter = (): TerminalQuickCommand => {
// Why: when the user has narrowed to a single repo scope, the natural
// intent for "Add Command" is to create one in that repo. When the filter
// is narrowed to Global-only, honor that. Otherwise prefer the active
// workspace repo; fall back to global when there's no active repo.
if (!showAll) {
const selectedRepoIds = [...effectiveSelection].filter((key) => key !== GLOBAL_SCOPE_KEY)
if (selectedRepoIds.length === 1 && !effectiveSelection.has(GLOBAL_SCOPE_KEY)) {
return createTerminalQuickCommandDraft({ type: 'repo', repoId: selectedRepoIds[0] })
}
if (selectedRepoIds.length === 0 && effectiveSelection.has(GLOBAL_SCOPE_KEY)) {
return createTerminalQuickCommandDraft({ type: 'global' })
}
}
if (activeRepoId && repoById.has(activeRepoId)) {
return createTerminalQuickCommandDraft({ type: 'repo', repoId: activeRepoId })
}
return createTerminalQuickCommandDraft({ type: 'global' })
}
const toggleScope = (key: string): void => {
const current = new Set(effectiveSelection)
if (current.has(key)) {
// Why: forbid the empty selection — every command would disappear and
// there'd be no signal that the filter caused it.
if (current.size <= 1) {
return
}
current.delete(key)
} else {
current.add(key)
}
setScopeSelection(current.size === allScopeKeys.size ? null : current)
}
const handleSelectAll = (): void => {
if (showAll) {
// Why: tasks-page parity — clicking "All" while everything is selected
// collapses to a single scope rather than emitting an empty set.
setScopeSelection(new Set([GLOBAL_SCOPE_KEY]))
return
}
setScopeSelection(null)
}
const renderTriggerLabel = (): React.JSX.Element => {
if (showAll) {
return <span>All commands</span>
}
const includesGlobal = effectiveSelection.has(GLOBAL_SCOPE_KEY)
const selectedRepos = repos.filter((r) => effectiveSelection.has(r.id))
const parts: string[] = []
if (includesGlobal) {
parts.push('Global')
}
if (selectedRepos.length > 0) {
const [first, ...rest] = selectedRepos
parts.push(rest.length > 0 ? `${first.displayName} +${rest.length}` : first.displayName)
}
return <span className="truncate">{parts.join(', ') || 'None'}</span>
}
const saveCommand = (next: TerminalQuickCommand): void => {
// Why: re-read from the store so save lands on the latest list when
// multiple edit dialogs fire in quick succession.
const latest = useAppStore.getState().settings?.terminalQuickCommands ?? []
const isEdit = latest.some((command) => command.id === next.id)
const nextList = isEdit
? latest.map((command) => (command.id === next.id ? next : command))
: [...latest, next]
updateSettings({ terminalQuickCommands: nextList })
}
const removeCommand = async (command: TerminalQuickCommand): Promise<void> => {
const confirmed = await confirm({
title: `Delete "${command.label || 'Untitled'}"?`,
description: 'This quick command will be removed from your saved list.',
confirmLabel: 'Delete',
confirmVariant: 'destructive'
})
if (!confirmed) {
return
}
// Why: re-read latest list from the store at delete time — the await above
// can span other settings changes, and a stale closure would resurrect
// commands that were removed concurrently.
const latest = useAppStore.getState().settings?.terminalQuickCommands ?? []
updateSettings({
terminalQuickCommands: latest.filter((c) => c.id !== command.id)
})
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<Label>Saved Commands</Label>
<p className="text-xs text-muted-foreground">
Run them from the Quick Commands button in the tab bar, or right-click inside any
terminal.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditor({ mode: 'add', command: createDraftForCurrentFilter() })}
>
<Plus />
Add Command
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Popover open={scopePopoverOpen} onOpenChange={setScopePopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={scopePopoverOpen}
className="h-8 min-w-52 justify-between px-3 text-xs font-normal"
>
{renderTriggerLabel()}
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(320px,calc(100vw-1rem))] min-w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<div className="border-b border-border">
<button
type="button"
onClick={handleSelectAll}
onMouseDown={(event) => event.preventDefault()}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
showAll && 'opacity-80'
)}
>
<Check
className={cn(
'size-3 text-muted-foreground',
showAll ? 'opacity-70' : 'opacity-0'
)}
/>
<span>All commands</span>
</button>
</div>
<CommandList>
<CommandItem
value={GLOBAL_SCOPE_KEY}
onSelect={() => toggleScope(GLOBAL_SCOPE_KEY)}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
effectiveSelection.has(GLOBAL_SCOPE_KEY) ? 'opacity-70' : 'opacity-0'
)}
/>
<span>Global</span>
</CommandItem>
{repos.map((repo) => {
const isSelected = effectiveSelection.has(repo.id)
return (
<CommandItem
key={repo.id}
value={repo.id}
onSelect={() => toggleScope(repo.id)}
className="items-center gap-2 px-3 py-1.5 text-xs"
>
<Check
className={cn(
'size-3 text-muted-foreground',
isSelected ? 'opacity-70' : 'opacity-0'
)}
/>
<RepoDotLabel
name={getRepoLabel(repo)}
color={repo.badgeColor}
className="max-w-full"
/>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="overflow-hidden rounded-lg border border-border/50">
{visibleCommands.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
{commands.length === 0
? 'No quick commands saved.'
: 'No commands in the selected scopes.'}
</div>
) : (
<div className="max-h-[60vh] divide-y divide-border/50 overflow-y-auto scrollbar-sleek">
{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 gap-1.5">
{scope.type === 'repo' ? (
<>
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full"
style={{
backgroundColor: repoById.get(scope.repoId)?.badgeColor
}}
/>
<span className="truncate">{getScopeLabel(scope, repoById)}</span>
</>
) : (
<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={() => void removeCommand(command)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</Button>
</div>
)
})}
</div>
)}
</div>
{editor !== null ? (
<TerminalQuickCommandDialog
open
mode={editor.mode}
command={editor.command}
repos={repos}
onOpenChange={(open) => !open && setEditor(null)}
onSave={saveCommand}
/>
) : null}
</div>
)
}

View File

@ -15,6 +15,7 @@ import {
MousePointerClick,
Network,
PanelsTopLeft,
Play,
ShieldCheck,
Palette,
Server,
@ -65,6 +66,8 @@ import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane'
import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane'
import { TasksPane } from './TasksPane'
import { TASKS_PANE_SEARCH_ENTRIES } from './tasks-search'
import { QuickCommandsPane } from './QuickCommandsPane'
import { QUICK_COMMANDS_PANE_SEARCH_ENTRIES } from './quick-commands-search'
import {
DeveloperPermissionsPane,
DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES
@ -103,6 +106,7 @@ type SettingsNavTarget =
| 'input'
| 'floating-workspace'
| 'terminal'
| 'quick-commands'
| 'notifications'
| 'computer-use'
| 'developer-permissions'
@ -520,11 +524,19 @@ function Settings(): React.JSX.Element {
{
id: 'terminal',
title: 'Terminal',
description: 'Shells, terminal appearance, quick commands, and pane behavior.',
description: 'Shells, terminal appearance, and pane behavior.',
icon: SquareTerminal,
searchEntries: terminalPaneSearchEntries,
group: 'workflows'
},
{
id: 'quick-commands',
title: 'Quick Commands',
description: 'Saved terminal commands, scoped globally or per project.',
icon: Play,
searchEntries: QUICK_COMMANDS_PANE_SEARCH_ENTRIES,
group: 'workflows'
},
...(showDesktopOnlySettings
? [
{
@ -1075,7 +1087,7 @@ function Settings(): React.JSX.Element {
<SettingsSection
id="terminal"
title="Terminal"
description="Shells, terminal appearance, quick commands, and pane behavior."
description="Shells, terminal appearance, and pane behavior."
searchEntries={terminalPaneSearchEntries}
headerAction={
<Button
@ -1106,6 +1118,17 @@ function Settings(): React.JSX.Element {
) : null}
</SettingsSection>
<SettingsSection
id="quick-commands"
title="Quick Commands"
description="Saved terminal commands, scoped globally or per project."
searchEntries={QUICK_COMMANDS_PANE_SEARCH_ENTRIES}
>
{isSectionMounted('quick-commands') ? (
<QuickCommandsPane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
{showDesktopOnlySettings ? (
<SettingsSection
id="browser"

View File

@ -39,7 +39,6 @@ import {
TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
TERMINAL_MAC_OPTION_SEARCH_ENTRIES,
TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES,
TERMINAL_RENDERING_SEARCH_ENTRIES,
TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES,
@ -57,8 +56,6 @@ import { TerminalWindowSection } from './TerminalWindowSection'
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
@ -89,9 +86,6 @@ 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('')
@ -167,39 +161,6 @@ export function TerminalPane({
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES) ? (
<section key="quick-commands" className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Quick Commands</h3>
<p className="text-xs text-muted-foreground">
Save global and repository-specific terminal snippets for the right-click menu.
</p>
</div>
<SearchableSetting
title="Quick Commands"
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>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES) ? (
<section key="typography" className="space-y-4">
<div className="space-y-1">

View File

@ -1,248 +0,0 @@
import { useEffect, useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import type {
Repo,
TerminalQuickCommand,
TerminalQuickCommandScope
} from '../../../../shared/types'
import { getTerminalQuickCommandScope } from '../../../../shared/terminal-quick-commands'
import {
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'
command: TerminalQuickCommand
}
| {
mode: 'edit'
command: TerminalQuickCommand
}
| null
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 [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 (!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])
}
}
const removeCommand = (id: string): void => {
onChange(commands.filter((command) => command.id !== id))
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<Label>Saved Commands</Label>
<p className="text-xs text-muted-foreground">
Commands are sent as plain terminal input to the active pane.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
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">
{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">
{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>
)}
</div>
<TerminalQuickCommandDialog
open={editor !== null}
mode={editor?.mode ?? 'add'}
command={editor?.command ?? createTerminalQuickCommandDraft()}
repos={repos}
onOpenChange={(open) => !open && setEditor(null)}
onSave={saveCommand}
/>
</div>
)
}

View File

@ -0,0 +1,26 @@
import type { SettingsSearchEntry } from './settings-search'
export const QUICK_COMMANDS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Quick Commands',
description:
'Saved terminal commands that can be launched from any terminal, scoped globally or to a specific project.',
keywords: [
'quick',
'command',
'commands',
'terminal',
'shortcut',
'snippet',
'global',
'project',
'repo',
'repository',
'run',
'launch',
'pnpm',
'npm',
'yarn'
]
}
]

View File

@ -78,14 +78,6 @@ export const TERMINAL_CURSOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Quick Commands',
description: 'Saved terminal command snippets available from the terminal right-click menu.',
keywords: ['terminal', 'command', 'snippet', 'quick command', 'send', 'context menu']
}
]
export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Inactive Pane Opacity',
@ -283,7 +275,6 @@ export function getTerminalPaneSearchEntries(platform: {
// users from landing on an option the UI intentionally hides.
return [
...TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES,
...TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES,
...TERMINAL_RENDERING_SEARCH_ENTRIES,
...TERMINAL_CURSOR_SEARCH_ENTRIES,
...TERMINAL_PANE_STYLE_SEARCH_ENTRIES,

View File

@ -0,0 +1,309 @@
import { useMemo, useState } from 'react'
import { ChevronDown, Pencil, Play, Plus, Trash2 } from 'lucide-react'
import { useAppStore } from '@/store'
import {
Command,
CommandEmpty,
CommandItem,
CommandList,
CommandSeparator
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
createTerminalQuickCommandDraft,
TerminalQuickCommandDialog
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
import { getTerminalQuickCommandScope } 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'
type TabBarQuickCommandsButtonProps = {
worktreeId: string
groupId: string
}
export function TabBarQuickCommandsButton({
worktreeId,
groupId
}: TabBarQuickCommandsButtonProps): React.JSX.Element | null {
const allCommands = useAppStore((s) => s.settings?.terminalQuickCommands)
const recentByGroup = useAppStore((s) => s.recentQuickCommandIdByGroup)
const updateSettings = useAppStore((s) => s.updateSettings)
const repos = useAppStore((s) => s.repos)
const confirm = useConfirmationDialog()
// Why: floating terminals share a synthetic worktree id (`global-floating-terminal`)
// that has no separator, so naive `getRepoIdFromWorktreeId` would return that
// sentinel as a "repo id" and the button would point at a repo that doesn't
// exist. Resolve to a real repo from the workspace; otherwise hide the button.
const repoId = useMemo(() => {
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return null
}
const candidate = getRepoIdFromWorktreeId(worktreeId)
return repos.some((r) => r.id === candidate) ? candidate : null
}, [worktreeId, repos])
const { repoCommands, globalCommands } = useMemo(() => {
const repoList: TerminalQuickCommand[] = []
const globalList: TerminalQuickCommand[] = []
for (const command of allCommands ?? []) {
if (!command.label.trim() || !command.command.trimEnd()) {
continue
}
const scope = getTerminalQuickCommandScope(command)
if (scope.type === 'global') {
globalList.push(command)
} else if (scope.type === 'repo' && repoId !== null && scope.repoId === repoId) {
repoList.push(command)
}
}
return { repoCommands: repoList, globalCommands: globalList }
}, [allCommands, repoId])
const recentId = recentByGroup[groupId] ?? null
// Why: split-button label prefers the most recently used command for this
// group regardless of scope, then falls back to the first repo command (so
// repo-scoped is preferred over global on first run), then to the first
// global one if no repo commands exist.
const mostRecent = useMemo(() => {
if (recentId) {
const match =
repoCommands.find((c) => c.id === recentId) ?? globalCommands.find((c) => c.id === recentId)
if (match) {
return match
}
}
return repoCommands[0] ?? globalCommands[0] ?? null
}, [repoCommands, globalCommands, recentId])
const [menuOpen, setMenuOpen] = useState(false)
const [commandValue, setCommandValue] = useState('')
const [editor, setEditor] = useState<
| { mode: 'add'; command: TerminalQuickCommand }
| { mode: 'edit'; command: TerminalQuickCommand }
| null
>(null)
const totalVisible = repoCommands.length + globalCommands.length
const hasAnyCommands = totalVisible > 0
const handleOpenChange = (next: boolean): void => {
setMenuOpen(next)
if (!next) {
setCommandValue('')
}
}
const handleRun = (command: TerminalQuickCommand): void => {
setMenuOpen(false)
runQuickCommandInNewTab({ command, worktreeId, groupId })
}
const handleSaveCommand = (next: TerminalQuickCommand): void => {
const current = useAppStore.getState().settings?.terminalQuickCommands ?? []
const isEdit = current.some((c) => c.id === next.id)
const nextList = isEdit ? current.map((c) => (c.id === next.id ? next : c)) : [...current, next]
void updateSettings({ terminalQuickCommands: nextList })
}
const handleDeleteCommand = async (command: TerminalQuickCommand): Promise<void> => {
setMenuOpen(false)
const confirmed = await confirm({
title: `Delete "${command.label}"?`,
description: 'This quick command will be removed from your saved list.',
confirmLabel: 'Delete',
confirmVariant: 'destructive'
})
if (!confirmed) {
return
}
const current = useAppStore.getState().settings?.terminalQuickCommands ?? []
void updateSettings({ terminalQuickCommands: current.filter((c) => c.id !== command.id) })
}
// Why: hidden in folder-mode worktrees (no repoId) and floating terminals.
// Without a repoId the button can't represent a repo-scoped run target, and
// global-only mode would be confusing in a context that doesn't belong to a
// repo at all.
if (!repoId) {
return null
}
// Empty state: single "Add command" button that opens the dialog directly.
if (!hasAnyCommands) {
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setEditor({
mode: 'add',
command: createTerminalQuickCommandDraft({ type: 'repo', repoId })
})
}
className="my-auto flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
aria-label="Add quick command"
>
<Plus className="size-3.5" />
<span className="text-[12px] font-medium">Add command</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Save a quick command for this repo
</TooltipContent>
</Tooltip>
<TerminalQuickCommandDialog
open={editor !== null}
mode={editor?.mode ?? 'add'}
command={editor?.command ?? createTerminalQuickCommandDraft({ type: 'repo', repoId })}
repos={repos}
onOpenChange={(open) => !open && setEditor(null)}
onSave={handleSaveCommand}
/>
</>
)
}
const splitButtonClass =
'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground'
const innerButtonBase =
'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
const renderItem = (command: TerminalQuickCommand): React.JSX.Element => (
<CommandItem
key={command.id}
value={command.id}
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} />
<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}
</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">
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setMenuOpen(false)
setEditor({ mode: 'edit', command })
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={`Edit ${command.label}`}
>
<Pencil className="size-3" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
void handleDeleteCommand(command)
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
aria-label={`Remove ${command.label}`}
>
<Trash2 className="size-3" />
</button>
</span>
</CommandItem>
)
return (
<>
<div className={splitButtonClass}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => mostRecent && handleRun(mostRecent)}
disabled={!mostRecent}
className={cn(innerButtonBase, 'gap-1.5 rounded-l-md rounded-r-none px-1.5')}
aria-label={
mostRecent ? `Run quick command: ${mostRecent.label}` : 'Run quick command'
}
>
<Play className="size-3 shrink-0" fill="currentColor" strokeWidth={0} />
<span className="max-w-[160px] truncate text-[12px] font-medium">
{mostRecent?.label ?? 'Run'}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{mostRecent ? `Run: ${mostRecent.command}` : 'Run quick command'}
</TooltipContent>
</Tooltip>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
innerButtonBase,
'justify-center rounded-l-none rounded-r-md border-l border-border/60 px-1'
)}
aria-label="More quick commands"
>
<ChevronDown className="size-3" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={6} className="w-72 p-0">
<Command
shouldFilter={false}
value={commandValue}
onValueChange={setCommandValue}
className="bg-transparent"
>
<CommandList className="max-h-72 py-1">
{totalVisible === 0 ? (
<CommandEmpty className="py-4 text-center text-[11px]">No commands</CommandEmpty>
) : null}
{repoCommands.map(renderItem)}
{repoCommands.length > 0 && globalCommands.length > 0 ? (
<CommandSeparator className="my-1" />
) : null}
{globalCommands.map(renderItem)}
</CommandList>
<div className="border-t border-border/50 p-1">
<button
type="button"
onClick={() => {
setMenuOpen(false)
setEditor({
mode: 'add',
command: createTerminalQuickCommandDraft({ type: 'repo', repoId })
})
}}
className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Plus className="size-3.5" />
Add command
</button>
</div>
</Command>
</DropdownMenuContent>
</DropdownMenu>
</div>
<TerminalQuickCommandDialog
open={editor !== null}
mode={editor?.mode ?? 'add'}
command={editor?.command ?? createTerminalQuickCommandDraft({ type: 'repo', repoId })}
repos={repos}
onOpenChange={(open) => !open && setEditor(null)}
onSave={handleSaveCommand}
/>
</>
)
}

View File

@ -10,6 +10,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import TabBar from '../tab-bar/TabBar'
import { TabBarQuickCommandsButton } from '../tab-bar/TabBarQuickCommandsButton'
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
import TabGroupDropOverlay from './TabGroupDropOverlay'
import { resolveGroupTabFromVisibleId } from './tab-group-visible-id'
@ -162,10 +163,13 @@ export default function TabGroupPanel({
const menuButtonClassName =
'my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
const actionChromeClassName = `flex shrink-0 items-center overflow-hidden transition-[width,margin,opacity] duration-150 ${
isFocused
? 'ml-1.5 w-7 pointer-events-auto opacity-100'
: 'ml-1.5 w-7 pointer-events-none opacity-0'
// Why: focused-only — the QC split-button and Pane Actions ellipsis both
// appear together so the action cluster never reflows when focus shifts
// between groups. Unfocused groups collapse the cluster fully (no
// reserved width) since the surrounding tab strip already absorbs the
// freed space.
const actionChromeClassName = `flex shrink-0 items-center gap-0.5 overflow-hidden transition-[opacity] duration-150 ${
isFocused ? 'ml-1.5 pointer-events-auto opacity-100' : 'pointer-events-none opacity-0 w-0'
}`
return (
@ -238,6 +242,9 @@ export default function TabGroupPanel({
className={actionChromeClassName}
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{isFocused ? (
<TabBarQuickCommandsButton worktreeId={worktreeId} groupId={groupId} />
) : null}
{isFocused ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>

View File

@ -26,6 +26,7 @@ import {
} from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers'
type TerminalQuickCommandDialogMode = 'add' | 'edit'
@ -94,6 +95,7 @@ export function TerminalQuickCommandDialog({
}
const canSave = draft.label.trim().length > 0 && draft.command.trimEnd().length > 0
const submitShortcutLabel = isMacUserAgent() ? '⌘↵' : 'Ctrl+Enter'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -107,7 +109,18 @@ export function TerminalQuickCommandDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div
className="space-y-4"
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) {
event.preventDefault()
saveDraft()
}
}}
>
<div className="space-y-2">
<Label>Label</Label>
<Input
@ -115,7 +128,7 @@ export function TerminalQuickCommandDialog({
onChange={(event) =>
setDraft((current) => ({ ...current, label: event.target.value }))
}
placeholder="Restart server"
placeholder="Start dev server"
/>
</div>
@ -155,7 +168,7 @@ export function TerminalQuickCommandDialog({
>
<ToggleGroupItem value="global">Global</ToggleGroupItem>
<ToggleGroupItem value="repo" disabled={repos.length === 0}>
Repository
Project
</ToggleGroupItem>
</ToggleGroup>
{selectedScope.type === 'repo' && repos.length > 0 ? (
@ -168,9 +181,7 @@ export function TerminalQuickCommandDialog({
>
<SelectTrigger size="sm" className="min-w-48">
<SelectValue
placeholder={
selectedRepoMissing ? 'Repository not in list' : 'Choose repository'
}
placeholder={selectedRepoMissing ? 'Project not in list' : 'Choose project'}
/>
</SelectTrigger>
<SelectContent>
@ -187,7 +198,7 @@ export function TerminalQuickCommandDialog({
</Select>
{selectedRepoMissing ? (
<p className="max-w-48 text-xs text-muted-foreground">
Saving keeps the existing repo scope unless you choose another.
Saving keeps the existing project scope unless you choose another.
</p>
) : null}
</div>
@ -227,8 +238,14 @@ export function TerminalQuickCommandDialog({
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={saveDraft} disabled={!canSave}>
<Button
type="button"
onClick={saveDraft}
disabled={!canSave}
title={`Save (${submitShortcutLabel})`}
>
Save
<span className="ml-1 text-[10px] opacity-60">{submitShortcutLabel}</span>
</Button>
</DialogFooter>
</DialogContent>

View File

@ -0,0 +1,65 @@
import { useAppStore } from '@/store'
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
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
}
/**
* Spawn a fresh terminal tab in the given group and queue the quick-command
* text as the startup command. The PTY connection layer writes the command
* once the shell is ready, so the user always sees their first prompt before
* 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.
*/
export function runQuickCommandInNewTab({
command,
worktreeId,
groupId
}: RunQuickCommandInNewTabArgs): { tabId: string } | 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)
store.queueTabStartupCommand(tab.id, {
command: command.command
})
// Why: match `+` button's createNewTerminalTab — without this, a worktree
// currently showing an editor file keeps rendering the editor and the new
// terminal tab stays invisible.
store.setActiveTabType('terminal')
// Why: persist tab-bar order with the new terminal appended. Without this,
// reconcileTabOrder falls back to terminals-first when the stored order is
// unset, jumping the new tab to index 0.
const fresh = useAppStore.getState()
const termIds = (fresh.tabsByWorktree[worktreeId] ?? []).map((t) => t.id)
const editorIds = fresh.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id)
const browserIds = (fresh.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id)
const base = reconcileTabOrder(
fresh.tabBarOrderByWorktree[worktreeId],
termIds,
editorIds,
browserIds
)
const order = base.filter((id) => id !== tab.id)
order.push(tab.id)
fresh.setTabBarOrder(worktreeId, order)
fresh.setRecentQuickCommandForGroup(groupId, command.id)
return { tabId: tab.id }
}

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { GlobalSettings } from '../../../../shared/types'
@ -78,6 +79,7 @@ function runtimeScopedStateReset(): Partial<AppState> {
deferredSshReconnectTargets: [],
deferredSshSessionIdsByTabId: {},
cacheTimerByKey: {},
recentQuickCommandIdByGroup: {},
expandedDirs: {},
pendingExplorerReveal: null,
openFiles: [],

View File

@ -905,10 +905,14 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
groupId,
remainingGroups[0]?.id ?? null
)
// Why: drop the dead group's recent-quick-command entry so the in-memory
// map can't grow unbounded as users open/close groups.
const { [groupId]: _droppedRecent, ...remainingRecent } = current.recentQuickCommandIdByGroup
return {
groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: remainingGroups },
layoutByWorktree: collapsedState.layoutByWorktree,
activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree,
recentQuickCommandIdByGroup: remainingRecent,
...(current.activeWorktreeId === worktreeId
? buildActiveSurfacePatch(
{

View File

@ -183,6 +183,11 @@ export type TerminalSlice = {
expandedPaneByTabId: Record<string, boolean>
canExpandPaneByTabId: Record<string, boolean>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
/** Most recently run quick-command id per tab group. In-memory only; resets
* on app restart so a stale id from a deleted command can't surface as the
* split-button label across sessions. */
recentQuickCommandIdByGroup: Record<string, string>
setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void
pendingStartupByTabId: Record<
string,
{
@ -376,6 +381,16 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
deferredSshReconnectTargets: [],
deferredSshSessionIdsByTabId: {},
cacheTimerByKey: {},
recentQuickCommandIdByGroup: {},
setRecentQuickCommandForGroup: (groupId, quickCommandId) => {
set((s) => ({
recentQuickCommandIdByGroup: {
...s.recentQuickCommandIdByGroup,
[groupId]: quickCommandId
}
}))
},
setCacheTimerStartedAt: (key, ts) => {
set((s) => {