diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index f093f51514d..ca7cdcf390a 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -75,6 +75,7 @@ import { agentHookServer } from './agent-hooks/server'
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id'
+import { normalizeTerminalMacros } from '../shared/terminal-macros'
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
import { normalizeVisibleTaskProviders } from '../shared/task-providers'
import { normalizeOpenInApplications } from '../shared/open-in-applications'
@@ -1238,6 +1239,7 @@ export class Store {
terminalQuickCommands: normalizeTerminalQuickCommands(
parsed.settings?.terminalQuickCommands
),
+ terminalMacros: normalizeTerminalMacros(parsed.settings?.terminalMacros),
visibleTaskProviders: normalizeVisibleTaskProviders(
parsed.settings?.visibleTaskProviders
),
@@ -2116,6 +2118,9 @@ export class Store {
updates.terminalQuickCommands
)
}
+ if ('terminalMacros' in updates) {
+ sanitizedUpdates.terminalMacros = normalizeTerminalMacros(updates.terminalMacros)
+ }
if ('visibleTaskProviders' in updates) {
sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders(
updates.visibleTaskProviders
diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx
index 9422ca3893a..6e8eefaa2ad 100644
--- a/src/renderer/src/components/settings/ExperimentalPane.tsx
+++ b/src/renderer/src/components/settings/ExperimentalPane.tsx
@@ -5,6 +5,7 @@ import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { EXPERIMENTAL_PANE_SEARCH_ENTRIES, EXPERIMENTAL_SEARCH_ENTRY } from './experimental-search'
import { HiddenExperimentalGroup } from './HiddenExperimentalGroup'
+import { TerminalMacrosSection } from './TerminalMacrosSection'
export { EXPERIMENTAL_PANE_SEARCH_ENTRIES }
@@ -27,6 +28,9 @@ export function ExperimentalPane({
const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.symlinks
])
+ const showTerminalMacros = matchesSettingsSearch(searchQuery, [
+ EXPERIMENTAL_SEARCH_ENTRY.terminalMacros
+ ])
return (
@@ -146,6 +150,28 @@ export function ExperimentalPane({
) : null}
+ {showTerminalMacros ? (
+
+
+
Terminal Macros
+
+ Define named terminal launch presets that open a new tab, optionally create a split
+ right or down, and seed each pane with startup text. This remains experimental and
+ currently only appears in the tab-bar new-tab menu.
+
+
+ updateSettings({ terminalMacros })}
+ />
+
+ ) : null}
+
{hiddenExperimentalUnlocked ?
: null}
)
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx
index 6a9830ca9c3..38e01cf4088 100644
--- a/src/renderer/src/components/settings/Settings.tsx
+++ b/src/renderer/src/components/settings/Settings.tsx
@@ -45,7 +45,7 @@ import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { RepositoryPane, getRepositoryPaneSearchEntries } from './RepositoryPane'
-import { getTerminalPaneSearchEntries } from './terminal-search'
+import { getTerminalPaneSearchEntries } from './terminal-pane-search-entries'
import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane'
import { CommitMessageAiPane } from './CommitMessageAiPane'
import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search'
diff --git a/src/renderer/src/components/settings/TerminalMacrosSection.tsx b/src/renderer/src/components/settings/TerminalMacrosSection.tsx
new file mode 100644
index 00000000000..c3b8d63c186
--- /dev/null
+++ b/src/renderer/src/components/settings/TerminalMacrosSection.tsx
@@ -0,0 +1,123 @@
+import { useState } from 'react'
+import { Pencil, Plus, Trash2 } from 'lucide-react'
+import type { TerminalMacro } from '../../../../shared/types'
+import {
+ createTerminalMacroDraft,
+ TerminalMacroDialog
+} from '@/components/terminal-macros/TerminalMacroDialog'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { Label } from '../ui/label'
+
+type TerminalMacrosSectionProps = {
+ macros: TerminalMacro[]
+ onChange: (macros: TerminalMacro[]) => void
+}
+
+type EditorState =
+ | { mode: 'add'; macro: TerminalMacro }
+ | { mode: 'edit'; macro: TerminalMacro }
+ | null
+
+function layoutLabel(layout: TerminalMacro['layout']): string {
+ if (layout === 'split-right') {
+ return 'Split Right'
+ }
+ if (layout === 'split-down') {
+ return 'Split Down'
+ }
+ return 'Tab'
+}
+
+export function TerminalMacrosSection({
+ macros,
+ onChange
+}: TerminalMacrosSectionProps): React.JSX.Element {
+ const [editor, setEditor] = useState(null)
+ const visibleMacros = macros.filter(
+ (macro) => macro.name.trim() || macro.command.trimEnd() || macro.splitCommand?.trimEnd()
+ )
+
+ const saveMacro = (next: TerminalMacro): void => {
+ if (editor?.mode === 'edit') {
+ onChange(macros.map((macro) => (macro.id === next.id ? next : macro)))
+ } else {
+ onChange([...macros, next])
+ }
+ }
+
+ const removeMacro = (id: string): void => {
+ onChange(macros.filter((macro) => macro.id !== id))
+ }
+
+ return (
+
+
+
+
Saved Macros
+
+ Each macro opens a named tab and can seed one startup split.
+
+
+
setEditor({ mode: 'add', macro: createTerminalMacroDraft() })}
+ >
+
+ Add Macro
+
+
+
+
+ {visibleMacros.length === 0 ? (
+
No terminal macros saved.
+ ) : (
+
+ {visibleMacros.map((macro) => (
+
+
+
+
{macro.name || 'Untitled'}
+
{layoutLabel(macro.layout)}
+
+
+ {macro.command || macro.splitCommand || 'Opens an idle shell'}
+
+
+
setEditor({ mode: 'edit', macro })}
+ >
+
+
+
removeMacro(macro.id)}
+ className="text-muted-foreground hover:text-destructive"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
!open && setEditor(null)}
+ onSave={saveMacro}
+ />
+
+ )
+}
diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts
index 102c44c55cc..cac0bafc0fd 100644
--- a/src/renderer/src/components/settings/experimental-search.ts
+++ b/src/renderer/src/components/settings/experimental-search.ts
@@ -47,6 +47,21 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'env',
'node_modules'
]
+ },
+ {
+ title: 'Terminal Macros',
+ description:
+ 'Named terminal launch presets that open a tab and optional startup split from the new-tab menu.',
+ keywords: [
+ 'experimental',
+ 'terminal',
+ 'macro',
+ 'macros',
+ 'launch',
+ 'preset',
+ 'split',
+ 'workflow'
+ ]
}
]
@@ -64,5 +79,6 @@ function findEntry(title: string): SettingsSearchEntry {
export const EXPERIMENTAL_SEARCH_ENTRY = {
pet: findEntry('Pet'),
activity: findEntry('Agents View'),
- symlinks: findEntry('Symlinks on worktrees')
+ symlinks: findEntry('Symlinks on worktrees'),
+ terminalMacros: findEntry('Terminal Macros')
} as const
diff --git a/src/renderer/src/components/settings/terminal-pane-search-entries.ts b/src/renderer/src/components/settings/terminal-pane-search-entries.ts
new file mode 100644
index 00000000000..0cc0cb44e84
--- /dev/null
+++ b/src/renderer/src/components/settings/terminal-pane-search-entries.ts
@@ -0,0 +1,42 @@
+import {
+ MANAGE_SESSIONS_SEARCH_ENTRIES,
+ TERMINAL_ADVANCED_SEARCH_ENTRIES,
+ TERMINAL_CURSOR_SEARCH_ENTRIES,
+ TERMINAL_DARK_THEME_SEARCH_ENTRIES,
+ TERMINAL_FLOATING_SEARCH_ENTRIES,
+ TERMINAL_GHOSTTY_IMPORT_SEARCH_ENTRIES,
+ 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,
+ TERMINAL_WINDOW_SEARCH_ENTRIES
+} from './terminal-search'
+import { TERMINAL_WINDOWS_SEARCH_ENTRIES } from './terminal-windows-search'
+import type { SettingsSearchEntry } from './settings-search'
+
+export function getTerminalPaneSearchEntries(platform: {
+ isWindows: boolean
+ isMac: boolean
+}): SettingsSearchEntry[] {
+ // Why: keep search results aligned with the controls each platform actually renders.
+ return [
+ ...TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES,
+ ...TERMINAL_FLOATING_SEARCH_ENTRIES,
+ ...TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES,
+ ...TERMINAL_RENDERING_SEARCH_ENTRIES,
+ ...TERMINAL_CURSOR_SEARCH_ENTRIES,
+ ...TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
+ ...(platform.isWindows ? TERMINAL_WINDOWS_SEARCH_ENTRIES : []),
+ ...TERMINAL_DARK_THEME_SEARCH_ENTRIES,
+ ...TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
+ ...TERMINAL_WINDOW_SEARCH_ENTRIES,
+ ...TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
+ ...TERMINAL_GHOSTTY_IMPORT_SEARCH_ENTRIES,
+ ...MANAGE_SESSIONS_SEARCH_ENTRIES,
+ ...TERMINAL_ADVANCED_SEARCH_ENTRIES,
+ ...(platform.isMac ? TERMINAL_MAC_OPTION_SEARCH_ENTRIES : [])
+ ]
+}
diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts
index 9c81f7ab6bf..7001738b0da 100644
--- a/src/renderer/src/components/settings/terminal-search.ts
+++ b/src/renderer/src/components/settings/terminal-search.ts
@@ -1,5 +1,6 @@
import type { SettingsSearchEntry } from './settings-search'
-import { TERMINAL_WINDOWS_SEARCH_ENTRIES } from './terminal-windows-search'
+
+export { getTerminalPaneSearchEntries } from './terminal-pane-search-entries'
export const TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
@@ -290,29 +291,3 @@ export const TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
]
}
]
-
-export function getTerminalPaneSearchEntries(platform: {
- isWindows: boolean
- isMac: boolean
-}): SettingsSearchEntry[] {
- // Why: the settings search index must mirror the visible controls. Keeping
- // platform-only controls out of other platforms' search results prevents
- // users from landing on an option the UI intentionally hides.
- return [
- ...TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES,
- ...TERMINAL_FLOATING_SEARCH_ENTRIES,
- ...TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES,
- ...TERMINAL_RENDERING_SEARCH_ENTRIES,
- ...TERMINAL_CURSOR_SEARCH_ENTRIES,
- ...TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
- ...(platform.isWindows ? TERMINAL_WINDOWS_SEARCH_ENTRIES : []),
- ...TERMINAL_DARK_THEME_SEARCH_ENTRIES,
- ...TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
- ...TERMINAL_WINDOW_SEARCH_ENTRIES,
- ...TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
- ...TERMINAL_GHOSTTY_IMPORT_SEARCH_ENTRIES,
- ...MANAGE_SESSIONS_SEARCH_ENTRIES,
- ...TERMINAL_ADVANCED_SEARCH_ENTRIES,
- ...(platform.isMac ? TERMINAL_MAC_OPTION_SEARCH_ENTRIES : [])
- ]
-}
diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx
index 08613a5ae64..33f2cb41264 100644
--- a/src/renderer/src/components/tab-bar/TabBar.tsx
+++ b/src/renderer/src/components/tab-bar/TabBar.tsx
@@ -5,7 +5,7 @@
* more clarity than the ~5 lines of bloat is worth. */
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { SortableContext } from '@dnd-kit/sortable'
-import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react'
+import { FilePlus, Globe, Plus, Settings as SettingsIcon, TerminalSquare } from 'lucide-react'
import type {
BrowserTab as BrowserTabState,
TerminalTab,
@@ -31,9 +31,13 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
+import { launchTerminalMacro } from '@/lib/launch-terminal-macro'
const isMac = navigator.userAgent.includes('Mac')
const isWindows = navigator.userAgent.includes('Windows')
@@ -152,6 +156,9 @@ function TabBarInner({
const defaultWindowsPowerShellImplementation = useAppStore(
(s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto'
)
+ const terminalMacros = useAppStore((s) => s.settings?.terminalMacros ?? [])
+ const openSettingsPage = useAppStore((s) => s.openSettingsPage)
+ const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const [pwshAvailable, setPwshAvailable] = useState(false)
useEffect(() => {
if (!isWindows) {
@@ -162,6 +169,10 @@ function TabBarInner({
void window.api.pwsh.isAvailable().then(setPwshAvailable)
}, [])
const resolvedGroupId = groupId ?? worktreeId
+ const launchableTerminalMacros = useMemo(
+ () => terminalMacros.filter((macro) => macro.name.trim().length > 0),
+ [terminalMacros]
+ )
const statusByRelativePath = useMemo(
() => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []),
@@ -577,6 +588,55 @@ function TabBarInner({
/>
>
) : null}
+
+
+
+
+ Macros
+
+
+ {launchableTerminalMacros.length === 0 ? (
+
+ No macros saved
+
+ ) : (
+ launchableTerminalMacros.map((macro) => (
+ {
+ const launched = launchTerminalMacro({
+ macro,
+ worktreeId,
+ groupId: resolvedGroupId
+ })
+ if (launched) {
+ focusTerminalTabSurface(launched.tabId)
+ }
+ }}
+ className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
+ title={`Run ${macro.name}`}
+ >
+
+ {macro.name}
+
+ ))
+ )}
+
+ {
+ openSettingsTarget({ pane: 'terminal', repoId: null, sectionId: 'macros' })
+ openSettingsPage()
+ }}
+ className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium text-muted-foreground"
+ >
+
+ Manage Macros…
+
+
+
diff --git a/src/renderer/src/components/terminal-macros/TerminalMacroDialog.tsx b/src/renderer/src/components/terminal-macros/TerminalMacroDialog.tsx
new file mode 100644
index 00000000000..0bc73f396c4
--- /dev/null
+++ b/src/renderer/src/components/terminal-macros/TerminalMacroDialog.tsx
@@ -0,0 +1,225 @@
+import { useEffect, useState } from 'react'
+import type { TerminalMacro, TerminalMacroLayout } from '../../../../shared/types'
+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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+
+type TerminalMacroDialogMode = 'add' | 'edit'
+
+type TerminalMacroDialogProps = {
+ open: boolean
+ mode: TerminalMacroDialogMode
+ macro: TerminalMacro
+ onOpenChange: (open: boolean) => void
+ onSave: (macro: TerminalMacro) => void
+}
+
+export function createTerminalMacroDraft(): TerminalMacro {
+ return {
+ id: `terminal-macro-${createBrowserUuid()}`,
+ name: '',
+ layout: 'tab',
+ command: '',
+ appendEnter: true,
+ splitCommand: '',
+ splitAppendEnter: true
+ }
+}
+
+export function TerminalMacroDialog({
+ open,
+ mode,
+ macro,
+ onOpenChange,
+ onSave
+}: TerminalMacroDialogProps): React.JSX.Element {
+ const [draft, setDraft] = useState(macro)
+
+ useEffect(() => {
+ if (open) {
+ setDraft({ ...macro })
+ }
+ }, [macro, open])
+
+ const saveDraft = (): void => {
+ const nextLayout = draft.layout
+ const next: TerminalMacro = {
+ ...draft,
+ name: draft.name.trim(),
+ command: draft.command.trimEnd(),
+ layout: nextLayout,
+ appendEnter: draft.appendEnter !== false,
+ ...(nextLayout === 'tab'
+ ? { splitCommand: '', splitAppendEnter: true }
+ : {
+ splitCommand: draft.splitCommand?.trimEnd() ?? '',
+ splitAppendEnter: draft.splitAppendEnter !== false
+ })
+ }
+ if (!next.name) {
+ return
+ }
+ onSave(next)
+ onOpenChange(false)
+ }
+
+ const canSave = draft.name.trim().length > 0
+ const hasSplitPane = draft.layout !== 'tab'
+
+ return (
+
+
+
+
+ {mode === 'edit' ? 'Edit Macro' : 'Add Macro'}
+
+
+ Macros open a named terminal tab and optionally create one startup split.
+
+
+
+
+
+ Name
+
+ setDraft((current) => ({ ...current, name: event.target.value }))
+ }
+ placeholder="Codex review"
+ />
+
+
+
+ Launch Layout
+ {
+ if (value === 'tab' || value === 'split-right' || value === 'split-down') {
+ setDraft((current) => ({
+ ...current,
+ layout: value as TerminalMacroLayout
+ }))
+ }
+ }}
+ className="justify-start"
+ >
+ Tab
+ Split Right
+ Split Down
+
+
+
+
+ Primary Pane Startup
+
+
+ {hasSplitPane ? (
+
+
+
Split Pane Startup
+
+ Leave blank to open the split as an idle shell.
+
+
+
+ ) : null}
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ Save
+
+
+
+
+ )
+}
+
+function MacroAppendEnterToggle({
+ checked,
+ label,
+ description,
+ onToggle
+}: {
+ checked: boolean
+ label: string
+ description: string
+ onToggle: () => void
+}): React.JSX.Element {
+ return (
+
+
+
{label}
+
{description}
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index c84cded8ef7..15fc2ed5161 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -73,7 +73,7 @@ type UseTerminalPaneLifecycleDeps = {
* (vertical or horizontal per the user setting) to run the setup command —
* keeping the main terminal interactive. */
setupSplit?: {
- command: string
+ command?: string
env?: Record
direction: SetupSplitDirection
} | null
@@ -909,11 +909,13 @@ export function useTerminalPaneLifecycle({
if (setupSplit) {
if (initialPane) {
- const setupPane = splitPaneWithOneShotStartup(
- ptyDeps,
- { command: setupSplit.command, env: setupSplit.env },
- () => manager.splitPane(initialPane.id, setupSplit.direction)
- )
+ const setupPane = setupSplit.command
+ ? splitPaneWithOneShotStartup(
+ ptyDeps,
+ { command: setupSplit.command, env: setupSplit.env },
+ () => manager.splitPane(initialPane.id, setupSplit.direction)
+ )
+ : manager.splitPane(initialPane.id, setupSplit.direction)
issueAutomationAnchorPaneId = setupPane?.id ?? null
// Restore focus to the main pane so the user's terminal receives
// keyboard input — the setup pane runs unattended.
diff --git a/src/renderer/src/lib/launch-terminal-macro.test.ts b/src/renderer/src/lib/launch-terminal-macro.test.ts
new file mode 100644
index 00000000000..82f7a55b839
--- /dev/null
+++ b/src/renderer/src/lib/launch-terminal-macro.test.ts
@@ -0,0 +1,105 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = {
+ createTab: vi.fn(),
+ setTabCustomTitle: vi.fn(),
+ queueTabStartupCommand: vi.fn(),
+ queueTabSetupSplit: vi.fn(),
+ setActiveTabType: vi.fn(),
+ setTabBarOrder: vi.fn(),
+ tabsByWorktree: {
+ 'wt-1': [{ id: 'existing-tab' }, { id: 'tab-1' }]
+ },
+ openFiles: [{ id: 'editor-1', worktreeId: 'wt-1' }],
+ browserTabsByWorktree: {
+ 'wt-1': [{ id: 'browser-1' }]
+ },
+ tabBarOrderByWorktree: {
+ 'wt-1': ['existing-tab', 'editor-1', 'browser-1']
+ }
+}
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => state
+ }
+}))
+
+describe('launchTerminalMacro', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ state.createTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' })
+ })
+
+ it('creates a named tab and queues the primary startup command', async () => {
+ const { launchTerminalMacro } = await import('./launch-terminal-macro')
+
+ const result = launchTerminalMacro({
+ macro: {
+ id: 'macro-1',
+ name: 'Codex review',
+ layout: 'tab',
+ command: 'codex --model gpt-5.5',
+ appendEnter: true
+ },
+ worktreeId: 'wt-1',
+ groupId: 'group-1'
+ })
+
+ expect(state.createTab).toHaveBeenCalledWith('wt-1', 'group-1')
+ expect(state.setTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Codex review')
+ expect(state.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', {
+ command: 'codex --model gpt-5.5\r'
+ })
+ expect(state.queueTabSetupSplit).not.toHaveBeenCalled()
+ expect(state.setActiveTabType).toHaveBeenCalledWith('terminal')
+ expect(state.setTabBarOrder).toHaveBeenCalledWith('wt-1', [
+ 'existing-tab',
+ 'editor-1',
+ 'browser-1',
+ 'tab-1'
+ ])
+ expect(result).toEqual({ tabId: 'tab-1' })
+ })
+
+ it('queues an initial split and preserves idle split shells when blank', async () => {
+ const { launchTerminalMacro } = await import('./launch-terminal-macro')
+
+ launchTerminalMacro({
+ macro: {
+ id: 'macro-2',
+ name: 'Dev stack',
+ layout: 'split-right',
+ command: '',
+ appendEnter: true,
+ splitCommand: '',
+ splitAppendEnter: true
+ },
+ worktreeId: 'wt-1'
+ })
+
+ expect(state.queueTabStartupCommand).not.toHaveBeenCalled()
+ expect(state.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
+ direction: 'vertical'
+ })
+ })
+
+ it('returns null for blank names', async () => {
+ const { launchTerminalMacro } = await import('./launch-terminal-macro')
+
+ expect(
+ launchTerminalMacro({
+ macro: {
+ id: 'macro-3',
+ name: ' ',
+ layout: 'tab',
+ command: '',
+ appendEnter: true
+ },
+ worktreeId: 'wt-1'
+ })
+ ).toBeNull()
+
+ expect(state.createTab).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/lib/launch-terminal-macro.ts b/src/renderer/src/lib/launch-terminal-macro.ts
new file mode 100644
index 00000000000..8d1a03f9f47
--- /dev/null
+++ b/src/renderer/src/lib/launch-terminal-macro.ts
@@ -0,0 +1,66 @@
+import { useAppStore } from '@/store'
+import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
+import { buildTerminalMacroInput } from '../../../shared/terminal-macros'
+import type { TerminalMacro } from '../../../shared/types'
+
+type LaunchTerminalMacroArgs = {
+ macro: TerminalMacro
+ worktreeId: string
+ groupId?: string
+}
+
+export function launchTerminalMacro({
+ macro,
+ worktreeId,
+ groupId
+}: LaunchTerminalMacroArgs): { tabId: string } | null {
+ const store = useAppStore.getState()
+ const name = macro.name.trim()
+ if (!name) {
+ return null
+ }
+
+ const tab = store.createTab(worktreeId, groupId)
+ store.setTabCustomTitle(tab.id, name)
+
+ const command = macro.command.trimEnd()
+ if (command) {
+ store.queueTabStartupCommand(tab.id, {
+ command: buildTerminalMacroInput(command, macro.appendEnter !== false)
+ })
+ }
+
+ if (macro.layout === 'split-right' || macro.layout === 'split-down') {
+ const splitCommand = macro.splitCommand?.trimEnd() ?? ''
+ store.queueTabSetupSplit(tab.id, {
+ direction: macro.layout === 'split-right' ? 'vertical' : 'horizontal',
+ ...(splitCommand
+ ? {
+ command: buildTerminalMacroInput(splitCommand, macro.splitAppendEnter !== false)
+ }
+ : {})
+ })
+ }
+
+ // Why: macro launches should surface the new terminal immediately even when
+ // the user currently has an editor/browser active in the same worktree.
+ store.setActiveTabType('terminal')
+
+ const fresh = useAppStore.getState()
+ const termIds = (fresh.tabsByWorktree[worktreeId] ?? []).map((entry) => entry.id)
+ const editorIds = fresh.openFiles
+ .filter((file) => file.worktreeId === worktreeId)
+ .map((f) => f.id)
+ const browserIds = (fresh.browserTabsByWorktree?.[worktreeId] ?? []).map((entry) => entry.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)
+
+ return { tabId: tab.id }
+}
diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts
index 9f4da4017c5..19015991838 100644
--- a/src/renderer/src/store/slices/settings.ts
+++ b/src/renderer/src/store/slices/settings.ts
@@ -7,6 +7,7 @@ import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
+import { normalizeTerminalMacros } from '../../../../shared/terminal-macros'
import { normalizeTerminalQuickCommands } from '../../../../shared/terminal-quick-commands'
import { normalizeVisibleTaskProviders } from '../../../../shared/task-providers'
import { normalizeOpenInApplications } from '../../../../shared/open-in-applications'
@@ -244,6 +245,9 @@ export const createSettingsSlice: StateCreator
updates.terminalQuickCommands
)
}
+ if ('terminalMacros' in updates) {
+ sanitizedUpdates.terminalMacros = normalizeTerminalMacros(updates.terminalMacros)
+ }
if ('visibleTaskProviders' in updates) {
sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders(
updates.visibleTaskProviders
diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts
index cb78387aad4..4afa75c29e3 100644
--- a/src/renderer/src/store/slices/terminals.ts
+++ b/src/renderer/src/store/slices/terminals.ts
@@ -150,7 +150,7 @@ export type TerminalSlice = {
* immediately interactive. */
pendingSetupSplitByTabId: Record<
string,
- { command: string; env?: Record; direction: SetupSplitDirection }
+ { command?: string; env?: Record; direction: SetupSplitDirection }
>
/** Queued issue-command-split requests — similar to setup splits but triggered
* when an issue is linked during worktree creation and the repo's issue
@@ -260,11 +260,11 @@ export type TerminalSlice = {
) => { command: string; env?: Record; telemetry?: AgentStartedTelemetry } | null
queueTabSetupSplit: (
tabId: string,
- startup: { command: string; env?: Record; direction: SetupSplitDirection }
+ startup: { command?: string; env?: Record; direction: SetupSplitDirection }
) => void
consumeTabSetupSplit: (
tabId: string
- ) => { command: string; env?: Record; direction: SetupSplitDirection } | null
+ ) => { command?: string; env?: Record; direction: SetupSplitDirection } | null
queueTabIssueCommandSplit: (
tabId: string,
issueCommand: { command: string; env?: Record }
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index f7bc13c8a79..b8c16a11b08 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -12,6 +12,7 @@ import type {
import { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts'
import { getDefaultTerminalQuickCommands } from './terminal-quick-commands'
+import { getDefaultTerminalMacros } from './terminal-macros'
import type { VoiceSettings } from './speech-types'
import { cloneDefaultWorkspaceStatuses } from './workspace-statuses'
import { TASK_PROVIDERS } from './task-providers'
@@ -191,6 +192,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalWindowsPowerShellImplementation: 'auto',
terminalMouseHideWhileTyping: false,
terminalQuickCommands: getDefaultTerminalQuickCommands(),
+ terminalMacros: getDefaultTerminalMacros(),
// Default false: opt-in only (matches Ghostty's default). Existing users
// on upgrade inherit this default via persistence.ts's
// { ...defaults.settings, ...parsed.settings } merge, so enabling
diff --git a/src/shared/terminal-macros.test.ts b/src/shared/terminal-macros.test.ts
new file mode 100644
index 00000000000..26f772043c3
--- /dev/null
+++ b/src/shared/terminal-macros.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { buildTerminalMacroInput, normalizeTerminalMacros } from './terminal-macros'
+
+describe('normalizeTerminalMacros', () => {
+ it('keeps incomplete drafts while trimming persisted values', () => {
+ expect(
+ normalizeTerminalMacros([
+ {
+ id: 'macro-1',
+ name: ' Codex review ',
+ layout: 'split-right',
+ command: 'codex\n',
+ appendEnter: false,
+ splitCommand: 'npm run dev\n'
+ },
+ {
+ id: 'draft-row',
+ name: '',
+ command: 'claude'
+ }
+ ])
+ ).toEqual([
+ {
+ id: 'macro-1',
+ name: 'Codex review',
+ layout: 'split-right',
+ command: 'codex',
+ appendEnter: false,
+ splitCommand: 'npm run dev',
+ splitAppendEnter: true
+ },
+ {
+ id: 'draft-row',
+ name: '',
+ layout: 'tab',
+ command: 'claude',
+ appendEnter: true
+ }
+ ])
+ })
+
+ it('drops non-object rows and falls back invalid layouts to tab', () => {
+ expect(
+ normalizeTerminalMacros([null, 'bad', { id: 'macro-2', name: 'Dev', layout: 'weird' }])
+ ).toEqual([
+ {
+ id: 'macro-2',
+ name: 'Dev',
+ layout: 'tab',
+ command: '',
+ appendEnter: true
+ }
+ ])
+ })
+})
+
+describe('buildTerminalMacroInput', () => {
+ it('optionally appends Enter', () => {
+ expect(buildTerminalMacroInput('codex', true)).toBe('codex\r')
+ expect(buildTerminalMacroInput('codex', false)).toBe('codex')
+ })
+})
diff --git a/src/shared/terminal-macros.ts b/src/shared/terminal-macros.ts
new file mode 100644
index 00000000000..6eafbf4565a
--- /dev/null
+++ b/src/shared/terminal-macros.ts
@@ -0,0 +1,82 @@
+import type { TerminalMacro, TerminalMacroLayout } from './types'
+
+const MAX_TERMINAL_MACROS = 40
+const MAX_TERMINAL_MACRO_NAME_LENGTH = 80
+const MAX_TERMINAL_MACRO_TEXT_LENGTH = 4000
+
+export const DEFAULT_TERMINAL_MACROS: TerminalMacro[] = []
+
+export function getDefaultTerminalMacros(): TerminalMacro[] {
+ return DEFAULT_TERMINAL_MACROS.map((macro) => ({ ...macro }))
+}
+
+function normalizeTerminalMacroLayout(input: unknown): TerminalMacroLayout {
+ return input === 'split-right' || input === 'split-down' ? input : 'tab'
+}
+
+function trimMacroText(input: unknown): string {
+ return typeof input === 'string' ? input.trimEnd().slice(0, MAX_TERMINAL_MACRO_TEXT_LENGTH) : ''
+}
+
+export function normalizeTerminalMacros(input: unknown): TerminalMacro[] {
+ if (!Array.isArray(input)) {
+ return getDefaultTerminalMacros()
+ }
+
+ const normalized: TerminalMacro[] = []
+ const seenIds = new Set()
+
+ for (const item of input) {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
+ continue
+ }
+ const record = item as Record
+ const rawId = typeof record.id === 'string' ? record.id.trim() : ''
+ const hasName = typeof record.name === 'string'
+ const hasCommand = typeof record.command === 'string'
+ const hasSplitCommand = typeof record.splitCommand === 'string'
+ // Why: settings saves on each edit; keep in-progress rows instead of
+ // deleting them before the user has finished filling them out.
+ if (!hasName && !hasCommand && !hasSplitCommand) {
+ continue
+ }
+
+ const name = hasName ? String(record.name).trim() : ''
+ const command = trimMacroText(record.command)
+ const layout = normalizeTerminalMacroLayout(record.layout)
+ const splitCommand = trimMacroText(record.splitCommand)
+
+ const idBase = rawId || `terminal-macro-${normalized.length + 1}`
+ let id = idBase.slice(0, MAX_TERMINAL_MACRO_NAME_LENGTH)
+ let suffix = 2
+ while (seenIds.has(id)) {
+ id = `${idBase.slice(0, MAX_TERMINAL_MACRO_NAME_LENGTH - 4)}-${suffix}`
+ suffix += 1
+ }
+ seenIds.add(id)
+
+ normalized.push({
+ id,
+ name: name.slice(0, MAX_TERMINAL_MACRO_NAME_LENGTH),
+ layout,
+ command,
+ appendEnter: record.appendEnter !== false,
+ ...(layout !== 'tab' || splitCommand
+ ? {
+ splitCommand,
+ splitAppendEnter: record.splitAppendEnter !== false
+ }
+ : {})
+ })
+
+ if (normalized.length >= MAX_TERMINAL_MACROS) {
+ break
+ }
+ }
+
+ return normalized
+}
+
+export function buildTerminalMacroInput(command: string, appendEnter: boolean): string {
+ return appendEnter ? `${command}\r` : command
+}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index d82f4794212..7e8cef00ad3 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -1392,6 +1392,18 @@ export type TerminalQuickCommand = {
scope?: TerminalQuickCommandScope
}
+export type TerminalMacroLayout = 'tab' | 'split-right' | 'split-down'
+
+export type TerminalMacro = {
+ id: string
+ name: string
+ layout: TerminalMacroLayout
+ command: string
+ appendEnter: boolean
+ splitCommand?: string
+ splitAppendEnter?: boolean
+}
+
export type OpenInApplication = {
id: string
label: string
@@ -1459,6 +1471,7 @@ export type GlobalSettings = {
terminalWordSeparator?: string
terminalCursorOpacity?: number
terminalQuickCommands?: TerminalQuickCommand[]
+ terminalMacros?: TerminalMacro[]
windowBackgroundBlur?: boolean
/** Why: Windows terminals conventionally use right-click as a paste gesture.
* The setting stays Windows-only so macOS/Linux keep their existing context