From eec2f9137e7da4af266fc18d748edb28dc4be6af Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:20:38 -0700 Subject: [PATCH] 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. --- .../src/components/settings/TerminalPane.tsx | 74 ++++++++++++++++++- .../components/settings/terminal-search.ts | 21 ++++++ .../use-terminal-pane-lifecycle.ts | 23 ++++-- .../src/lib/worktree-activation.test.ts | 65 +++++++++++++++- src/renderer/src/lib/worktree-activation.ts | 36 +++++++-- src/renderer/src/store/slices/terminals.ts | 17 +++-- src/shared/constants.ts | 1 + src/shared/types.ts | 12 +++ 8 files changed, 225 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index 6f485814a39..0135be052ad 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -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) ? ( +
+
+

Workspace Setup Script

+

+ Where the repository setup script runs when a new workspace is created. +

+
+ + + + { + if (!value) { + return + } + updateSettings({ + setupScriptLaunchMode: value as SetupScriptLaunchMode + }) + }} + variant="outline" + size="sm" + className="h-8 flex-wrap" + > + + Split Vertically + + + Split Horizontally + + + New Tab + + +

+ "New Tab" opens the setup command in a background tab titled "Setup" + without stealing focus from your main terminal. +

+
+
+ ) : null, matchesSettingsSearch(searchQuery, TERMINAL_ADVANCED_SEARCH_ENTRIES) ? (
diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index 0d34440a8b9..3ed4fba8747 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -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 ] } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index c8d4ba7e979..65295a90e52 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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 } | 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 } | 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 + 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 } | 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 }) } } diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 9eb6b3be480..bcc8efd2626 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -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 = {}) { return { tabsByWorktree: {} as Record, 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() + }) }) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 2ab8bfc6863..55dbc523aeb 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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 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 } + startup: { command: string; env?: Record; 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 diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 87f44ffae18..8bf57b9dbb9 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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 pendingStartupByTabId: Record }> /** 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 }> + * 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; 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 } | null queueTabSetupSplit: ( tabId: string, - startup: { command: string; env?: Record } + startup: { command: string; env?: Record; direction: SetupSplitDirection } ) => void - consumeTabSetupSplit: (tabId: string) => { command: string; env?: Record } | null + consumeTabSetupSplit: ( + tabId: string + ) => { command: string; env?: Record; direction: SetupSplitDirection } | null queueTabIssueCommandSplit: ( tabId: string, issueCommand: { command: string; env?: Record } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c6c093e6f53..a0d21dc0d96 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 522643c195b..9c4ddac0d6b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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