From ccedcc83b9443de49b2cd3ca8282b96e9b6ec8f2 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Tue, 19 May 2026 13:39:30 -0700 Subject: [PATCH] Add experimental terminal macros Co-authored-by: Orca --- src/main/persistence.ts | 5 + .../components/settings/ExperimentalPane.tsx | 26 ++ .../src/components/settings/Settings.tsx | 2 +- .../settings/TerminalMacrosSection.tsx | 123 ++++++++++ .../settings/experimental-search.ts | 18 +- .../settings/terminal-pane-search-entries.ts | 42 ++++ .../components/settings/terminal-search.ts | 29 +-- .../src/components/tab-bar/TabBar.tsx | 62 ++++- .../terminal-macros/TerminalMacroDialog.tsx | 225 ++++++++++++++++++ .../use-terminal-pane-lifecycle.ts | 14 +- .../src/lib/launch-terminal-macro.test.ts | 105 ++++++++ src/renderer/src/lib/launch-terminal-macro.ts | 66 +++++ src/renderer/src/store/slices/settings.ts | 4 + src/renderer/src/store/slices/terminals.ts | 6 +- src/shared/constants.ts | 2 + src/shared/terminal-macros.test.ts | 62 +++++ src/shared/terminal-macros.ts | 82 +++++++ src/shared/types.ts | 13 + 18 files changed, 847 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/settings/TerminalMacrosSection.tsx create mode 100644 src/renderer/src/components/settings/terminal-pane-search-entries.ts create mode 100644 src/renderer/src/components/terminal-macros/TerminalMacroDialog.tsx create mode 100644 src/renderer/src/lib/launch-terminal-macro.test.ts create mode 100644 src/renderer/src/lib/launch-terminal-macro.ts create mode 100644 src/shared/terminal-macros.test.ts create mode 100644 src/shared/terminal-macros.ts 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 ? ( + +
+ +

+ 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 ( +
+
+
+ +

+ Each macro opens a named tab and can seed one startup split. +

+
+ +
+ +
+ {visibleMacros.length === 0 ? ( +
No terminal macros saved.
+ ) : ( +
+ {visibleMacros.map((macro) => ( +
+
+
+
{macro.name || 'Untitled'}
+ {layoutLabel(macro.layout)} +
+
+ {macro.command || macro.splitCommand || 'Opens an idle shell'} +
+
+ + +
+ ))} +
+ )} +
+ + !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. + + + +
+
+ + + setDraft((current) => ({ ...current, name: event.target.value })) + } + placeholder="Codex review" + /> +
+ +
+ + { + if (value === 'tab' || value === 'split-right' || value === 'split-down') { + setDraft((current) => ({ + ...current, + layout: value as TerminalMacroLayout + })) + } + }} + className="justify-start" + > + Tab + Split Right + Split Down + +
+ +
+ +