mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
Add experimental terminal macros toggle
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user