@@ -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() })}
>
Add Command
- {commands.length === 0 ? (
-
No quick commands saved.
+ {visibleCommands.length === 0 ? (
+
+ {commands.length === 0 ? 'No quick commands saved.' : 'No commands match this scope.'}
+
) : (
- {commands.map((command) => (
-
-
-
{command.label || 'Untitled'}
-
- {command.command || 'No command text'}
+ {visibleCommands.map((command) => {
+ const scope = getTerminalQuickCommandScope(command)
+ return (
+
+
+
+
+ {command.label || 'Untitled'}
+
+
+ {getScopeLabel(scope, repoById)}
+
+
+
+ {command.command || 'No command text'}
+
+
+ {command.appendEnter ? 'Enter' : 'Insert'}
+
+
+
-
- {command.appendEnter ? 'Enter' : 'Insert'}
-
-
-
-
- ))}
+ )
+ })}
)}
-
+
!open && setEditor(null)}
+ onSave={saveCommand}
+ />
)
}
diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
index f4fce7e40..b7207337e 100644
--- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
@@ -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 (
{mod}V
- {quickCommands.length > 0 ? (
-
-
-
- Quick Commands
-
-
- {quickCommands.map((command) => (
- onQuickCommand(command)}>
- {command.label}
- {!command.appendEnter ? (
- Insert
- ) : null}
-
- ))}
-
-
- ) : null}
+
+
+
+ Quick Commands
+
+
+ {hasQuickCommands ? (
+ <>
+ {quickCommandRepoLabel && repoQuickCommands.length > 0 ? (
+ <>
+
+ {quickCommandRepoLabel}
+
+ {repoQuickCommands.map((command) => (
+ onQuickCommand(command)}>
+ {command.label}
+ {!command.appendEnter ? (
+ Insert
+ ) : null}
+
+ ))}
+ >
+ ) : null}
+ {globalQuickCommands.length > 0 ? (
+ <>
+ {repoQuickCommands.length > 0 ? : null}
+ {repoQuickCommands.length > 0 ? (
+ Global
+ ) : null}
+ {globalQuickCommands.map((command) => (
+ onQuickCommand(command)}>
+ {command.label}
+ {!command.appendEnter ? (
+ Insert
+ ) : null}
+
+ ))}
+ >
+ ) : null}
+ >
+ ) : (
+
+ No quick commands
+
+ )}
+
+ {
+ // Why: the dropdown sits above dialogs; force-close before
+ // opening the add modal even during the open-gesture guard.
+ onOpenChange(false)
+ onAddQuickCommand()
+ }}
+ >
+
+ Add Quick Command…
+
+
+
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index 6b813acfc..cb7786922 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -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({ query: '', caseSensitive: false, regex: false })
const [closeConfirmPaneId, setCloseConfirmPaneId] = useState(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(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}
/>
+
{/* 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).
diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx
new file mode 100644
index 000000000..2907840e0
--- /dev/null
+++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx
@@ -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[]
+ 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): string {
+ return repo.displayName || repo.path
+}
+
+export function TerminalQuickCommandDialog({
+ open,
+ mode,
+ command,
+ repos = [],
+ onOpenChange,
+ onSave
+}: TerminalQuickCommandDialogProps): React.JSX.Element {
+ const [draft, setDraft] = useState(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 (
+
+ )
+}
diff --git a/src/shared/terminal-quick-commands.test.ts b/src/shared/terminal-quick-commands.test.ts
index 22192a5ba..2290f799d 100644
--- a/src/shared/terminal-quick-commands.test.ts
+++ b/src/shared/terminal-quick-commands.test.ts
@@ -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({
diff --git a/src/shared/terminal-quick-commands.ts b/src/shared/terminal-quick-commands.ts
index c78dc1e29..f28fa0b25 100644
--- a/src/shared/terminal-quick-commands.ts
+++ b/src/shared/terminal-quick-commands.ts
@@ -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
+ 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) {
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 8741ef2e8..f8c1f9684 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -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 = {