mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
@@ -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 : [])
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user