Add experimental terminal macros

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-19 13:46:52 -07:00
co-authored by Orca
parent 9bbfb04a09
commit ccedcc83b9
18 changed files with 847 additions and 39 deletions
+5
View File
@@ -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
@@ -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 (
<div className="space-y-4">
@@ -146,6 +150,28 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showTerminalMacros ? (
<SearchableSetting
title="Terminal Macros"
description="Named terminal launch presets available from the new-tab menu."
keywords={EXPERIMENTAL_SEARCH_ENTRY.terminalMacros.keywords}
className="space-y-3 px-1 py-2"
>
<div className="space-y-1.5">
<Label>Terminal Macros</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<TerminalMacrosSection
macros={settings.terminalMacros ?? []}
onChange={(terminalMacros) => updateSettings({ terminalMacros })}
/>
</SearchableSetting>
) : null}
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
</div>
)
@@ -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'
@@ -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<EditorState>(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 (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<Label>Saved Macros</Label>
<p className="text-xs text-muted-foreground">
Each macro opens a named tab and can seed one startup split.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditor({ mode: 'add', macro: createTerminalMacroDraft() })}
>
<Plus />
Add Macro
</Button>
</div>
<div className="overflow-hidden rounded-lg border border-border/50">
{visibleMacros.length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">No terminal macros saved.</div>
) : (
<div className="divide-y divide-border/50">
{visibleMacros.map((macro) => (
<div key={macro.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">{macro.name || 'Untitled'}</div>
<Badge variant="outline">{layoutLabel(macro.layout)}</Badge>
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{macro.command || macro.splitCommand || 'Opens an idle shell'}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${macro.name || 'macro'}`}
onClick={() => setEditor({ mode: 'edit', macro })}
>
<Pencil />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${macro.name || 'macro'}`}
onClick={() => removeMacro(macro.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</Button>
</div>
))}
</div>
)}
</div>
<TerminalMacroDialog
open={editor !== null}
mode={editor?.mode ?? 'add'}
macro={editor?.macro ?? createTerminalMacroDraft()}
onOpenChange={(open) => !open && setEditor(null)}
onSave={saveMacro}
/>
</div>
)
}
@@ -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
@@ -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 : [])
]
}
@@ -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 : [])
]
}
+61 -1
View File
@@ -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}
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium">
<TerminalSquare className="size-4 text-muted-foreground" />
Macros
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[15rem] rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]">
{launchableTerminalMacros.length === 0 ? (
<DropdownMenuItem
disabled
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 text-muted-foreground"
>
No macros saved
</DropdownMenuItem>
) : (
launchableTerminalMacros.map((macro) => (
<DropdownMenuItem
key={macro.id}
onSelect={() => {
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}`}
>
<TerminalSquare className="size-4 text-muted-foreground" />
<span className="flex-1 truncate">{macro.name}</span>
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
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"
>
<SettingsIcon className="size-4" />
Manage Macros…
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -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<TerminalMacro>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{mode === 'edit' ? 'Edit Macro' : 'Add Macro'}
</DialogTitle>
<DialogDescription className="text-xs">
Macros open a named terminal tab and optionally create one startup split.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={draft.name}
onChange={(event) =>
setDraft((current) => ({ ...current, name: event.target.value }))
}
placeholder="Codex review"
/>
</div>
<div className="space-y-2">
<Label>Launch Layout</Label>
<ToggleGroup
type="single"
value={draft.layout}
onValueChange={(value) => {
if (value === 'tab' || value === 'split-right' || value === 'split-down') {
setDraft((current) => ({
...current,
layout: value as TerminalMacroLayout
}))
}
}}
className="justify-start"
>
<ToggleGroupItem value="tab">Tab</ToggleGroupItem>
<ToggleGroupItem value="split-right">Split Right</ToggleGroupItem>
<ToggleGroupItem value="split-down">Split Down</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-2">
<Label>Primary Pane Startup</Label>
<textarea
value={draft.command}
onChange={(event) =>
setDraft((current) => ({ ...current, command: event.target.value }))
}
placeholder="codex --model gpt-5.5"
rows={5}
className="min-h-28 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<MacroAppendEnterToggle
checked={draft.appendEnter !== false}
label="Submit primary pane"
description="Append Enter after the startup text."
onToggle={() =>
setDraft((current) => ({ ...current, appendEnter: !current.appendEnter }))
}
/>
</div>
{hasSplitPane ? (
<div className="space-y-2 rounded-lg border border-border/50 p-3">
<div className="space-y-1">
<Label>Split Pane Startup</Label>
<p className="text-xs text-muted-foreground">
Leave blank to open the split as an idle shell.
</p>
</div>
<textarea
value={draft.splitCommand ?? ''}
onChange={(event) =>
setDraft((current) => ({ ...current, splitCommand: event.target.value }))
}
placeholder="npm run dev"
rows={4}
className="min-h-24 w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<MacroAppendEnterToggle
checked={draft.splitAppendEnter !== false}
label="Submit split pane"
description="Append Enter after the split pane startup text."
onToggle={() =>
setDraft((current) => ({
...current,
splitAppendEnter: !current.splitAppendEnter
}))
}
/>
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={saveDraft} disabled={!canSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function MacroAppendEnterToggle({
checked,
label,
description,
onToggle
}: {
checked: boolean
label: string
description: string
onToggle: () => void
}): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-4 rounded-md border border-border/50 px-3 py-2">
<div className="space-y-0.5">
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{description}</div>
</div>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
checked ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
)
}
@@ -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<string, string>
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.
@@ -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()
})
})
@@ -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 }
}
@@ -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<AppState, [], [], SettingsSlice>
updates.terminalQuickCommands
)
}
if ('terminalMacros' in updates) {
sanitizedUpdates.terminalMacros = normalizeTerminalMacros(updates.terminalMacros)
}
if ('visibleTaskProviders' in updates) {
sanitizedUpdates.visibleTaskProviders = normalizeVisibleTaskProviders(
updates.visibleTaskProviders
+3 -3
View File
@@ -150,7 +150,7 @@ export type TerminalSlice = {
* immediately interactive. */
pendingSetupSplitByTabId: Record<
string,
{ command: string; env?: Record<string, string>; direction: SetupSplitDirection }
{ command?: string; env?: Record<string, string>; 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<string, string>; telemetry?: AgentStartedTelemetry } | null
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
startup: { command?: string; env?: Record<string, string>; direction: SetupSplitDirection }
) => void
consumeTabSetupSplit: (
tabId: string
) => { command: string; env?: Record<string, string>; direction: SetupSplitDirection } | null
) => { command?: string; env?: Record<string, string>; direction: SetupSplitDirection } | null
queueTabIssueCommandSplit: (
tabId: string,
issueCommand: { command: string; env?: Record<string, string> }
+2
View File
@@ -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
+62
View File
@@ -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')
})
})
+82
View File
@@ -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<string>()
for (const item of input) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
continue
}
const record = item as Record<string, unknown>
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
}
+13
View File
@@ -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