From 114619c8c8a01951010a9144c2e7eb3d16f8dee0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 22 May 2026 19:14:39 -0400 Subject: [PATCH] Add Quick Commands surface with tab-bar split-button and settings pane (#2650) Co-authored-by: Orca --- .../components/settings/QuickCommandsPane.tsx | 367 ++++++++++++++++++ .../src/components/settings/Settings.tsx | 27 +- .../src/components/settings/TerminalPane.tsx | 39 -- .../settings/TerminalQuickCommandsSection.tsx | 248 ------------ .../settings/quick-commands-search.ts | 26 ++ .../components/settings/terminal-search.ts | 9 - .../tab-bar/TabBarQuickCommandsButton.tsx | 309 +++++++++++++++ .../components/tab-group/TabGroupPanel.tsx | 15 +- .../TerminalQuickCommandDialog.tsx | 33 +- .../src/lib/run-quick-command-in-new-tab.ts | 65 ++++ src/renderer/src/store/slices/settings.ts | 2 + src/renderer/src/store/slices/tabs.ts | 4 + src/renderer/src/store/slices/terminals.ts | 15 + 13 files changed, 849 insertions(+), 310 deletions(-) create mode 100644 src/renderer/src/components/settings/QuickCommandsPane.tsx delete mode 100644 src/renderer/src/components/settings/TerminalQuickCommandsSection.tsx create mode 100644 src/renderer/src/components/settings/quick-commands-search.ts create mode 100644 src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx create mode 100644 src/renderer/src/lib/run-quick-command-in-new-tab.ts diff --git a/src/renderer/src/components/settings/QuickCommandsPane.tsx b/src/renderer/src/components/settings/QuickCommandsPane.tsx new file mode 100644 index 000000000..d3a6ddc97 --- /dev/null +++ b/src/renderer/src/components/settings/QuickCommandsPane.tsx @@ -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) => void +} + +const GLOBAL_SCOPE_KEY = '__global__' + +type EditorState = + | { + mode: 'add' + command: TerminalQuickCommand + } + | { + mode: 'edit' + command: TerminalQuickCommand + } + | null + +function getRepoLabel(repo: Pick): string { + return repo.displayName || repo.path +} + +function getScopeLabel( + scope: TerminalQuickCommandScope, + repoById: Map> +): 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(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 | null>(null) + const [scopePopoverOpen, setScopePopoverOpen] = useState(false) + + const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) + + const allScopeKeys = useMemo( + () => new Set([GLOBAL_SCOPE_KEY, ...repos.map((r) => r.id)]), + [repos] + ) + const effectiveSelection: ReadonlySet = 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 All commands + } + 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 {parts.join(', ') || 'None'} + } + + 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 => { + 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 ( +
+
+
+ +

+ Run them from the Quick Commands button in the tab bar, or right-click inside any + terminal. +

+
+ +
+ +
+ + + + + + +
+ +
+ + toggleScope(GLOBAL_SCOPE_KEY)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + + Global + + {repos.map((repo) => { + const isSelected = effectiveSelection.has(repo.id) + return ( + toggleScope(repo.id)} + className="items-center gap-2 px-3 py-1.5 text-xs" + > + + + + ) + })} + +
+
+
+
+ +
+ {visibleCommands.length === 0 ? ( +
+ {commands.length === 0 + ? 'No quick commands saved.' + : 'No commands in the selected scopes.'} +
+ ) : ( +
+ {visibleCommands.map((command) => { + const scope = getTerminalQuickCommandScope(command) + return ( +
+
+
+
+ {command.label || 'Untitled'} +
+ + {scope.type === 'repo' ? ( + <> + + {getScopeLabel(scope, repoById)} + + ) : ( + {getScopeLabel(scope, repoById)} + )} + +
+
+ {command.command || 'No command text'} +
+
+
+ {command.appendEnter ? 'Enter' : 'Insert'} +
+ + +
+ ) + })} +
+ )} +
+ + {editor !== null ? ( + !open && setEditor(null)} + onSave={saveCommand} + /> + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 0360dfee0..653948990 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -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 { + + {isSectionMounted('quick-commands') ? ( + + ) : null} + + {showDesktopOnlySettings ? ( 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({ ) : null, - matchesSettingsSearch(searchQuery, TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES) ? ( -
-
-

Quick Commands

-

- Save global and repository-specific terminal snippets for the right-click menu. -

-
- - - updateSettings({ terminalQuickCommands })} - /> - -
- ) : null, matchesSettingsSearch(searchQuery, TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES) ? (
diff --git a/src/renderer/src/components/settings/TerminalQuickCommandsSection.tsx b/src/renderer/src/components/settings/TerminalQuickCommandsSection.tsx deleted file mode 100644 index 4416818d1..000000000 --- a/src/renderer/src/components/settings/TerminalQuickCommandsSection.tsx +++ /dev/null @@ -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[] - 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): string { - return repo.displayName || repo.path -} - -function getScopeLabel( - scope: TerminalQuickCommandScope, - repoById: Map> -): 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(null) - const [scopeFilter, setScopeFilter] = useState('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 ( -
-
-
- -

- Commands are sent as plain terminal input to the active pane. -

-
- -
- -
- { - if (value === 'all' || value === 'global' || value === 'repo') { - setScopeFilter(value) - } - }} - className="justify-start" - > - All - Global - - Repository - - - {scopeFilter === 'repo' && repos.length > 0 ? ( - - ) : null} -
- -
- {visibleCommands.length === 0 ? ( -
- {commands.length === 0 ? 'No quick commands saved.' : 'No commands match this scope.'} -
- ) : ( -
- {visibleCommands.map((command) => { - const scope = getTerminalQuickCommandScope(command) - return ( -
-
-
-
- {command.label || 'Untitled'} -
- - {getScopeLabel(scope, repoById)} - -
-
- {command.command || 'No command text'} -
-
-
- {command.appendEnter ? 'Enter' : 'Insert'} -
- - -
- ) - })} -
- )} -
- - !open && setEditor(null)} - onSave={saveCommand} - /> -
- ) -} diff --git a/src/renderer/src/components/settings/quick-commands-search.ts b/src/renderer/src/components/settings/quick-commands-search.ts new file mode 100644 index 000000000..c2189de00 --- /dev/null +++ b/src/renderer/src/components/settings/quick-commands-search.ts @@ -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' + ] + } +] diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index 49132971d..0a09e00ca 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -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, diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx new file mode 100644 index 000000000..eba28d405 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -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 => { + 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 ( + <> + + + + + + Save a quick command for this repo + + + !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 => ( + 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" + > + + + {command.label} + + {command.command} + + + + + + + + ) + + return ( + <> +
+ + + + + + {mostRecent ? `Run: ${mostRecent.command}` : 'Run quick command'} + + + + + + + + + + {totalVisible === 0 ? ( + No commands + ) : null} + {repoCommands.map(renderItem)} + {repoCommands.length > 0 && globalCommands.length > 0 ? ( + + ) : null} + {globalCommands.map(renderItem)} + +
+ +
+
+
+
+
+ !open && setEditor(null)} + onSave={handleSaveCommand} + /> + + ) +} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 602e826d8..f49cf102d 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -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 ? ( + + ) : null} {isFocused ? ( diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index 2907840e0..d1beaebff 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -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 ( @@ -107,7 +109,18 @@ export function TerminalQuickCommandDialog({ -
+
{ + // 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() + } + }} + >
setDraft((current) => ({ ...current, label: event.target.value })) } - placeholder="Restart server" + placeholder="Start dev server" />
@@ -155,7 +168,7 @@ export function TerminalQuickCommandDialog({ > Global - Repository + Project {selectedScope.type === 'repo' && repos.length > 0 ? ( @@ -168,9 +181,7 @@ export function TerminalQuickCommandDialog({ > @@ -187,7 +198,7 @@ export function TerminalQuickCommandDialog({ {selectedRepoMissing ? (

- Saving keeps the existing repo scope unless you choose another. + Saving keeps the existing project scope unless you choose another.

) : null}
@@ -227,8 +238,14 @@ export function TerminalQuickCommandDialog({ - diff --git a/src/renderer/src/lib/run-quick-command-in-new-tab.ts b/src/renderer/src/lib/run-quick-command-in-new-tab.ts new file mode 100644 index 000000000..0e6b902a8 --- /dev/null +++ b/src/renderer/src/lib/run-quick-command-in-new-tab.ts @@ -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 } +} diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 15cd1412c..4b56f99f3 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -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 { deferredSshReconnectTargets: [], deferredSshSessionIdsByTabId: {}, cacheTimerByKey: {}, + recentQuickCommandIdByGroup: {}, expandedDirs: {}, pendingExplorerReveal: null, openFiles: [], diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 37e5d1c4e..bf211ae1c 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -905,10 +905,14 @@ export const createTabsSlice: StateCreator = (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( { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 46a864c5f..170c580d2 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -183,6 +183,11 @@ export type TerminalSlice = { expandedPaneByTabId: Record canExpandPaneByTabId: Record terminalLayoutsByTabId: Record + /** 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 + setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void pendingStartupByTabId: Record< string, { @@ -376,6 +381,16 @@ export const createTerminalSlice: StateCreator deferredSshReconnectTargets: [], deferredSshSessionIdsByTabId: {}, cacheTimerByKey: {}, + recentQuickCommandIdByGroup: {}, + + setRecentQuickCommandForGroup: (groupId, quickCommandId) => { + set((s) => ({ + recentQuickCommandIdByGroup: { + ...s.recentQuickCommandIdByGroup, + [groupId]: quickCommandId + } + })) + }, setCacheTimerStartedAt: (key, ts) => { set((s) => {