Add experimental terminal macros toggle

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-19 14:01:38 -07:00
co-authored by Orca
parent ccedcc83b9
commit 7dfda9b91d
14 changed files with 132 additions and 139 deletions
@@ -110,6 +110,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalPet: false,
experimentalActivity: true,
experimentalWorktreeSymlinks: false,
terminalMacrosEnabled: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
+1
View File
@@ -103,6 +103,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalPet: false,
experimentalActivity: true,
experimentalWorktreeSymlinks: false,
terminalMacrosEnabled: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
+1
View File
@@ -224,6 +224,7 @@ describe('Store', () => {
expect(settings.openInApplications).toEqual([])
expect(settings.experimentalActivity).toBe(false)
expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true)
expect(settings.terminalMacrosEnabled).toBe(false)
expect(settings.floatingTerminalEnabled).toBe(true)
expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true)
expect(settings.notifications.customSoundPath).toBeNull()
@@ -156,19 +156,43 @@ export function ExperimentalPane({
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"
id="terminal-macros"
>
<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 className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink 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 the new terminal with startup text. This remains
experimental and appears in the tab-bar new-tab menu only when enabled.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.terminalMacrosEnabled}
onClick={() =>
updateSettings({
terminalMacrosEnabled: !settings.terminalMacrosEnabled
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalMacrosEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.terminalMacrosEnabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<TerminalMacrosSection
macros={settings.terminalMacros ?? []}
onChange={(terminalMacros) => updateSettings({ terminalMacros })}
/>
{settings.terminalMacrosEnabled ? (
<TerminalMacrosSection
macros={settings.terminalMacros ?? []}
onChange={(terminalMacros) => updateSettings({ terminalMacros })}
/>
) : null}
</SearchableSetting>
) : null}
@@ -34,9 +34,7 @@ export function TerminalMacrosSection({
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 visibleMacros = macros.filter((macro) => macro.name.trim() || macro.command.trimEnd())
const saveMacro = (next: TerminalMacro): void => {
if (editor?.mode === 'edit') {
@@ -83,7 +81,7 @@ export function TerminalMacrosSection({
<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'}
{macro.command || 'Opens an idle shell'}
</div>
</div>
<Button
@@ -51,12 +51,14 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Terminal Macros',
description:
'Named terminal launch presets that open a tab and optional startup split from the new-tab menu.',
'Experimental toggle for named terminal launch presets that open a tab and optional startup split from the new-tab menu.',
keywords: [
'experimental',
'terminal',
'macro',
'macros',
'toggle',
'enable',
'launch',
'preset',
'split',
+51 -50
View File
@@ -156,6 +156,7 @@ function TabBarInner({
const defaultWindowsPowerShellImplementation = useAppStore(
(s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto'
)
const terminalMacrosEnabled = useAppStore((s) => s.settings?.terminalMacrosEnabled === true)
const terminalMacros = useAppStore((s) => s.settings?.terminalMacros ?? [])
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
@@ -170,8 +171,9 @@ function TabBarInner({
}, [])
const resolvedGroupId = groupId ?? worktreeId
const launchableTerminalMacros = useMemo(
() => terminalMacros.filter((macro) => macro.name.trim().length > 0),
[terminalMacros]
() =>
terminalMacrosEnabled ? terminalMacros.filter((macro) => macro.name.trim().length > 0) : [],
[terminalMacros, terminalMacrosEnabled]
)
const statusByRelativePath = useMemo(
@@ -588,55 +590,54 @@ 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>
))
)}
{launchableTerminalMacros.length > 0 ? (
<>
<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>
<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.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: 'experimental',
repoId: null,
sectionId: 'terminal-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>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -30,9 +30,7 @@ export function createTerminalMacroDraft(): TerminalMacro {
name: '',
layout: 'tab',
command: '',
appendEnter: true,
splitCommand: '',
splitAppendEnter: true
appendEnter: true
}
}
@@ -58,13 +56,7 @@ export function TerminalMacroDialog({
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
})
appendEnter: draft.appendEnter !== false
}
if (!next.name) {
return
@@ -74,7 +66,7 @@ export function TerminalMacroDialog({
}
const canSave = draft.name.trim().length > 0
const hasSplitPane = draft.layout !== 'tab'
const targetsSplitPane = draft.layout !== 'tab'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -122,56 +114,39 @@ export function TerminalMacroDialog({
</div>
<div className="space-y-2">
<Label>Primary Pane Startup</Label>
<Label>{targetsSplitPane ? 'New Pane Startup' : 'Tab Startup'}</Label>
<textarea
value={draft.command}
onChange={(event) =>
setDraft((current) => ({ ...current, command: event.target.value }))
}
placeholder="codex --model gpt-5.5"
placeholder={targetsSplitPane ? 'npm run dev' : '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"
/>
{targetsSplitPane ? (
<p className="text-xs text-muted-foreground">
This startup text runs only in the newly created split pane. The original pane is
left alone.
</p>
) : (
<p className="text-xs text-muted-foreground">
Optional startup text for the new tab.
</p>
)}
<MacroAppendEnterToggle
checked={draft.appendEnter !== false}
label="Submit primary pane"
description="Append Enter after the startup text."
label={targetsSplitPane ? 'Run new pane startup text' : 'Run tab startup text'}
description={
targetsSplitPane
? 'Press Enter after sending the new split pane text.'
: 'Press Enter after sending the tab 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>
@@ -31,7 +31,7 @@ describe('launchTerminalMacro', () => {
state.createTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' })
})
it('creates a named tab and queues the primary startup command', async () => {
it('creates a named tab and queues startup for tab macros', async () => {
const { launchTerminalMacro } = await import('./launch-terminal-macro')
const result = launchTerminalMacro({
@@ -62,7 +62,7 @@ describe('launchTerminalMacro', () => {
expect(result).toEqual({ tabId: 'tab-1' })
})
it('queues an initial split and preserves idle split shells when blank', async () => {
it('queues an initial split and sends startup only to the new split pane', async () => {
const { launchTerminalMacro } = await import('./launch-terminal-macro')
launchTerminalMacro({
@@ -70,17 +70,16 @@ describe('launchTerminalMacro', () => {
id: 'macro-2',
name: 'Dev stack',
layout: 'split-right',
command: '',
appendEnter: true,
splitCommand: '',
splitAppendEnter: true
command: 'npm run dev',
appendEnter: true
},
worktreeId: 'wt-1'
})
expect(state.queueTabStartupCommand).not.toHaveBeenCalled()
expect(state.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
direction: 'vertical'
direction: 'vertical',
command: 'npm run dev\r'
})
})
@@ -24,19 +24,18 @@ export function launchTerminalMacro({
store.setTabCustomTitle(tab.id, name)
const command = macro.command.trimEnd()
if (command) {
if (command && macro.layout === 'tab') {
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
? {
command: buildTerminalMacroInput(splitCommand, macro.splitAppendEnter !== false)
command: buildTerminalMacroInput(command, macro.appendEnter !== false)
}
: {})
})
+3
View File
@@ -192,6 +192,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalWindowsPowerShellImplementation: 'auto',
terminalMouseHideWhileTyping: false,
terminalQuickCommands: getDefaultTerminalQuickCommands(),
// Why: terminal macros can auto-type into newly spawned terminals, so keep
// the experimental launcher hidden until the user explicitly opts in.
terminalMacrosEnabled: false,
terminalMacros: getDefaultTerminalMacros(),
// Default false: opt-in only (matches Ghostty's default). Existing users
// on upgrade inherit this default via persistence.ts's
+1 -3
View File
@@ -25,9 +25,7 @@ describe('normalizeTerminalMacros', () => {
name: 'Codex review',
layout: 'split-right',
command: 'codex',
appendEnter: false,
splitCommand: 'npm run dev',
splitAppendEnter: true
appendEnter: false
},
{
id: 'draft-row',
+3 -11
View File
@@ -33,18 +33,16 @@ export function normalizeTerminalMacros(input: unknown): TerminalMacro[] {
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'
const hasAnyCommand = typeof record.command === '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) {
if (!hasName && !hasAnyCommand) {
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)
@@ -60,13 +58,7 @@ export function normalizeTerminalMacros(input: unknown): TerminalMacro[] {
name: name.slice(0, MAX_TERMINAL_MACRO_NAME_LENGTH),
layout,
command,
appendEnter: record.appendEnter !== false,
...(layout !== 'tab' || splitCommand
? {
splitCommand,
splitAppendEnter: record.splitAppendEnter !== false
}
: {})
appendEnter: record.appendEnter !== false
})
if (normalized.length >= MAX_TERMINAL_MACROS) {
+1 -2
View File
@@ -1400,8 +1400,6 @@ export type TerminalMacro = {
layout: TerminalMacroLayout
command: string
appendEnter: boolean
splitCommand?: string
splitAppendEnter?: boolean
}
export type OpenInApplication = {
@@ -1471,6 +1469,7 @@ export type GlobalSettings = {
terminalWordSeparator?: string
terminalCursorOpacity?: number
terminalQuickCommands?: TerminalQuickCommand[]
terminalMacrosEnabled: boolean
terminalMacros?: TerminalMacro[]
windowBackgroundBlur?: boolean
/** Why: Windows terminals conventionally use right-click as a paste gesture.