feat: configurable setup script launch location (#745)

Adds a Terminal settings control for where the repo setup script runs
on workspace create: vertical split (default, preserves prior behavior),
horizontal split, or a separate "Setup" tab that does not steal focus
from the primary terminal.
This commit is contained in:
Neil 2026-04-16 21:20:38 -07:00 committed by GitHub
parent ad472f89d4
commit eec2f9137e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 225 additions and 24 deletions

View File

@ -2,7 +2,7 @@
splitting individual settings into separate files would scatter related controls without a
meaningful abstraction boundary. Mirrors the same decision made for GeneralPane.tsx. */
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import type { GlobalSettings, SetupScriptLaunchMode } from '../../../../shared/types'
import {
DEFAULT_TERMINAL_FONT_WEIGHT,
TERMINAL_FONT_WEIGHT_MAX,
@ -34,6 +34,7 @@ import {
TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
TERMINAL_RIGHT_CLICK_TO_PASTE_SEARCH_ENTRY,
TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES
} from './terminal-search'
import { DarkTerminalThemeSection, LightTerminalThemeSection } from './TerminalThemeSections'
@ -390,6 +391,77 @@ export function TerminalPane({
lightPreviewAppearance={lightPreviewAppearance}
/>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES) ? (
<section key="setup-script" className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Workspace Setup Script</h3>
<p className="text-xs text-muted-foreground">
Where the repository setup script runs when a new workspace is created.
</p>
</div>
<SearchableSetting
title="Setup Script Location"
description="Where the repository setup script runs when a new workspace is created."
keywords={[
'setup',
'script',
'workspace',
'split',
'horizontal',
'vertical',
'tab',
'new',
'location',
'launch'
]}
className="space-y-2"
>
<Label>Setup Script Location</Label>
<ToggleGroup
type="single"
value={settings.setupScriptLaunchMode}
onValueChange={(value) => {
if (!value) {
return
}
updateSettings({
setupScriptLaunchMode: value as SetupScriptLaunchMode
})
}}
variant="outline"
size="sm"
className="h-8 flex-wrap"
>
<ToggleGroupItem
value="split-vertical"
className="h-8 px-3 text-xs"
aria-label="Split vertically"
>
Split Vertically
</ToggleGroupItem>
<ToggleGroupItem
value="split-horizontal"
className="h-8 px-3 text-xs"
aria-label="Split horizontally"
>
Split Horizontally
</ToggleGroupItem>
<ToggleGroupItem
value="new-tab"
className="h-8 px-3 text-xs"
aria-label="Run in a new tab"
>
New Tab
</ToggleGroupItem>
</ToggleGroup>
<p className="text-xs text-muted-foreground">
&quot;New Tab&quot; opens the setup command in a background tab titled &quot;Setup&quot;
without stealing focus from your main terminal.
</p>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_ADVANCED_SEARCH_ENTRIES) ? (
<section key="advanced" className="space-y-4">
<div className="space-y-1">

View File

@ -89,6 +89,26 @@ export const TERMINAL_ADVANCED_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Setup Script Location',
description:
"Where the repository setup script runs when a new workspace is created: a vertical split (default), a horizontal split, or a background tab titled 'Setup'.",
keywords: [
'setup',
'script',
'workspace',
'split',
'horizontal',
'vertical',
'tab',
'new',
'location',
'launch'
]
}
]
export const TERMINAL_WINDOWS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Right-click to paste',
@ -111,6 +131,7 @@ export function getTerminalPaneSearchEntries(isWindows: boolean): SettingsSearch
...(isWindows ? TERMINAL_WINDOWS_SEARCH_ENTRIES : []),
...TERMINAL_DARK_THEME_SEARCH_ENTRIES,
...TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
...TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
...TERMINAL_ADVANCED_SEARCH_ENTRIES
]
}

View File

@ -10,7 +10,11 @@ import {
handleOscLink
} from './terminal-link-handlers'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import type { GlobalSettings, TerminalLayoutSnapshot } from '../../../../shared/types'
import type {
GlobalSettings,
SetupSplitDirection,
TerminalLayoutSnapshot
} from '../../../../shared/types'
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
import {
buildFontFamily,
@ -30,9 +34,14 @@ type UseTerminalPaneLifecycleDeps = {
worktreeId: string
cwd?: string
startup?: { command: string; env?: Record<string, string> } | null
/** When present, the initial pane boots clean and a right-side split pane is
* created to run the setup command keeping the main terminal interactive. */
setupSplit?: { command: string; env?: Record<string, string> } | null
/** When present, the initial pane boots clean and a split pane is created
* (vertical or horizontal per the user setting) to run the setup command
* keeping the main terminal interactive. */
setupSplit?: {
command: string
env?: Record<string, string>
direction: SetupSplitDirection
} | null
/** When present, a split pane is created to run the repo's configured
* issue-automation command with the linked issue number interpolated. */
issueCommandSplit?: { command: string; env?: Record<string, string> } | null
@ -466,11 +475,11 @@ export function useTerminalPaneLifecycle({
const setupPane = splitPaneWithOneShotStartup(
ptyDeps,
{ command: setupSplit.command, env: setupSplit.env },
() => manager.splitPane(initialPane.id, 'vertical')
() => manager.splitPane(initialPane.id, setupSplit.direction)
)
issueAutomationAnchorPaneId = setupPane?.id ?? null
// Restore focus to the main (left) pane so the user's terminal
// receives keyboard input — the setup pane runs unattended.
// Restore focus to the main pane so the user's terminal receives
// keyboard input — the setup pane runs unattended.
manager.setActivePane(initialPane.id, { focus: isActive })
}
}

View File

@ -1,11 +1,28 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SetupScriptLaunchMode } from '../../../shared/types'
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
import { useAppStore } from '@/store'
function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void {
useAppStore.setState((state) => ({
settings: state.settings
? { ...state.settings, setupScriptLaunchMode: mode ?? 'split-vertical' }
: mode !== null
? ({ setupScriptLaunchMode: mode } as unknown as typeof state.settings)
: state.settings
}))
}
afterEach(() => {
setSetupScriptLaunchMode('split-vertical')
})
function createMockStore(overrides: Record<string, unknown> = {}) {
return {
tabsByWorktree: {} as Record<string, { id: string }[]>,
createTab: vi.fn(() => ({ id: 'tab-1' })),
setActiveTab: vi.fn(),
setTabCustomTitle: vi.fn(),
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
queueTabStartupCommand: vi.fn(),
queueTabSetupSplit: vi.fn(),
@ -34,7 +51,8 @@ describe('ensureWorktreeHasInitialTerminal', () => {
env: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1'
}
},
direction: 'vertical'
})
})
@ -141,7 +159,8 @@ describe('ensureWorktreeHasInitialTerminal', () => {
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
env: { ORCA_ROOT_PATH: '/tmp/repo' }
env: { ORCA_ROOT_PATH: '/tmp/repo' },
direction: 'vertical'
})
expect(store.queueTabIssueCommandSplit).toHaveBeenCalledWith('tab-1', {
command: 'bash /tmp/repo/.git/orca/issue-command-runner.sh',
@ -157,4 +176,44 @@ describe('ensureWorktreeHasInitialTerminal', () => {
expect(store.queueTabStartupCommand).not.toHaveBeenCalled()
expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled()
})
it('queues a horizontal setup split when setupScriptLaunchMode is split-horizontal', () => {
setSetupScriptLaunchMode('split-horizontal')
const store = createMockStore()
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
})
expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
env: { ORCA_ROOT_PATH: '/tmp/repo' },
direction: 'horizontal'
})
})
it('creates a background Setup tab when setupScriptLaunchMode is new-tab', () => {
setSetupScriptLaunchMode('new-tab')
let createdIndex = 0
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
const store = createMockStore({ createTab })
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
})
expect(createTab).toHaveBeenCalledTimes(2)
// Main tab is activated first (new terminal), then setup tab is created,
// and the helper re-activates the main tab so focus stays on tab-1.
expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1')
expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1')
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup')
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', {
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
env: { ORCA_ROOT_PATH: '/tmp/repo' }
})
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
})
})

View File

@ -1,4 +1,4 @@
import type { WorktreeSetupLaunch } from '../../../shared/types'
import type { SetupSplitDirection, WorktreeSetupLaunch } from '../../../shared/types'
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { buildSetupRunnerCommand } from './setup-runner'
import { useAppStore } from '@/store'
@ -17,6 +17,7 @@ type WorktreeActivationStore = {
tabsByWorktree: Record<string, { id: string }[]>
createTab: (worktreeId: string) => { id: string }
setActiveTab: (tabId: string) => void
setTabCustomTitle: (tabId: string, title: string | null) => void
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
queueTabStartupCommand: (
tabId: string,
@ -24,7 +25,7 @@ type WorktreeActivationStore = {
) => void
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
) => void
queueTabIssueCommandSplit: (
tabId: string,
@ -122,15 +123,34 @@ export function ensureWorktreeHasInitialTerminal(
store.queueTabStartupCommand(terminalTab.id, startup)
}
// Why: run the setup script in a split pane to the right so the main
// terminal stays immediately interactive. The TerminalPane reads this
// signal on mount, creates the initial pane clean, then splits right
// and injects the setup command into the new pane's PTY.
// Why: the setup script launch location is user-configurable. The default
// 'split-vertical' preserves the historical behavior (right-side split so
// the main terminal stays immediately interactive); 'split-horizontal'
// swaps the split orientation; 'new-tab' creates a separate background
// tab titled "Setup" without stealing focus from the main terminal.
if (setup) {
store.queueTabSetupSplit(terminalTab.id, {
const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'split-vertical'
const setupCommand = {
command: buildSetupRunnerCommand(setup.runnerScriptPath),
env: setup.envVars
})
}
if (mode === 'new-tab') {
const setupTab = store.createTab(worktreeId)
// Why: createTab auto-activates the new tab. Revert activation so the
// user's focus stays on the primary terminal — per the design, the
// Setup tab runs unattended in the background.
store.setActiveTab(terminalTab.id)
// Why: customTitle wins over the auto-generated "Terminal N" label
// everywhere the tab is rendered (tab bar, switcher, session snapshots),
// so labeling via customTitle is the single authoritative source.
store.setTabCustomTitle(setupTab.id, 'Setup')
store.queueTabStartupCommand(setupTab.id, setupCommand)
} else {
store.queueTabSetupSplit(terminalTab.id, {
...setupCommand,
direction: mode === 'split-horizontal' ? 'horizontal' : 'vertical'
})
}
}
// Why: when the user links a GitHub issue and opts into that repo's

View File

@ -2,6 +2,7 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type {
SetupSplitDirection,
TerminalLayoutSnapshot,
TerminalTab,
WorkspaceSessionState
@ -63,9 +64,13 @@ export type TerminalSlice = {
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
pendingStartupByTabId: Record<string, { command: string; env?: Record<string, string> }>
/** Queued setup-split requests when present, TerminalPane creates the
* initial pane clean, then splits right and runs the command in the new pane
* so the main terminal stays immediately interactive. */
pendingSetupSplitByTabId: Record<string, { command: string; env?: Record<string, string> }>
* initial pane clean, then splits (vertical or horizontal per user setting)
* and runs the command in the new pane so the main terminal stays
* immediately interactive. */
pendingSetupSplitByTabId: Record<
string,
{ 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
* automation command is enabled. */
@ -107,9 +112,11 @@ export type TerminalSlice = {
) => { command: string; env?: Record<string, string> } | null
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
) => void
consumeTabSetupSplit: (tabId: string) => { command: string; env?: Record<string, string> } | null
consumeTabSetupSplit: (
tabId: string
) => { command: string; env?: Record<string, string>; direction: SetupSplitDirection } | null
queueTabIssueCommandSplit: (
tabId: string,
issueCommand: { command: string; env?: Record<string, string> }

View File

@ -107,6 +107,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// { ...defaults.settings, ...parsed.settings } merge, so enabling
// focus-follows-mouse never happens unexpectedly.
terminalFocusFollowsMouse: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
openLinksInApp: true,
rightSidebarOpenByDefault: true,

View File

@ -546,6 +546,15 @@ export type TuiAgent =
export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs'
/** Where the repo setup script runs when a worktree is created.
* - 'split-vertical': split the initial terminal pane with a vertical divider (default).
* - 'split-horizontal': split the initial terminal pane with a horizontal divider.
* - 'new-tab': open a background tab titled "Setup" and leave focus on the first tab. */
export type SetupScriptLaunchMode = 'split-vertical' | 'split-horizontal' | 'new-tab'
/** Direction used when the setup script launch mode is a split. */
export type SetupSplitDirection = 'vertical' | 'horizontal'
export type GlobalSettings = {
workspaceDir: string
nestWorkspaces: boolean
@ -574,6 +583,9 @@ export type GlobalSettings = {
* menu behavior and users can still reach the menu with Ctrl+right-click. */
terminalRightClickToPaste: boolean
terminalFocusFollowsMouse: boolean
/** Where the repo setup script runs on workspace create. Defaults to a
* vertical split so the user's main terminal stays immediately usable. */
setupScriptLaunchMode: SetupScriptLaunchMode
terminalScrollbackBytes: number
/** Why: opening arbitrary links inside Orca uses an isolated guest browser surface.
* The setting stays opt-in so existing workflows continue to use the system browser