feat: add external terminal support with cross-platform detection (#565)

* feat(platform): add cross-platform openInTerminal utility

Add utility function to open a terminal in a specified directory:
- macOS: Uses Terminal.app via AppleScript
- Windows: Tries Windows Terminal, falls back to cmd
- Linux: Tries common terminal emulators (gnome-terminal,
  konsole, xfce4-terminal, xterm, x-terminal-emulator)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(server): add open-in-terminal endpoint

Add POST /open-in-terminal endpoint to open a system terminal in the
worktree directory using the cross-platform openInTerminal utility.

The endpoint validates that worktreePath is provided and is an
absolute path for security.

Extracted from PR #558.

* feat(ui): add Open in Terminal action to worktree dropdown

Add "Open in Terminal" option to the worktree actions dropdown menu.
This opens the system terminal in the worktree directory.

Changes:
- Add openInTerminal method to http-api-client
- Add Terminal icon and menu item to worktree-actions-dropdown
- Add onOpenInTerminal prop to WorktreeTab component
- Add handleOpenInTerminal handler to use-worktree-actions hook
- Wire up handler in worktree-panel for both mobile and desktop views

Extracted from PR #558.

* fix(ui): open in terminal navigates to Automaker terminal view

Instead of opening the system terminal, the "Open in Terminal" action
now opens Automaker's built-in terminal with the worktree directory:

- Add pendingTerminalCwd state to app store
- Update use-worktree-actions to set pending cwd and navigate to /terminal
- Add effect in terminal-view to create session with pending cwd

This matches the original PR #558 behavior.

* feat(ui): add terminal open mode setting (new tab vs split)

Add a setting to choose how "Open in Terminal" behaves:
- New Tab: Creates a new tab named after the branch (default)
- Split: Adds to current tab as a split view

Changes:
- Add openTerminalMode setting to terminal state ('newTab' | 'split')
- Update terminal-view to respect the setting
- Add UI in Terminal Settings to toggle the behavior
- Rename pendingTerminalCwd to pendingTerminal with branch name

The new tab mode names tabs after the branch for easy identification.
The split mode is useful for comparing terminals side by side.

* feat(ui): display branch name in terminal header with git icon

- Move branch name display from tab name to terminal header
- Show full branch name (no truncation) with GitBranch icon
- Display branch name for both 'new tab' and 'split' modes
- Persist openTerminalMode setting to server and include in import/export
- Update settings dropdown to simplified "New Tab" label

* feat: add external terminal support with cross-platform detection

Add support for opening worktree directories in external terminals
(iTerm2, Warp, Ghostty, System Terminal, etc.) while retaining the
integrated terminal as the default option.

Changes:
- Add terminal detection for macOS, Windows, and Linux
- Add "Open in Terminal" split-button in worktree dropdown
- Add external terminal selection in Settings > Terminal
- Add default open mode setting (new tab vs split)
- Display branch name in terminal panel header
- Support 20+ terminals across platforms

Part of #558, Closes #550

* fix: address PR review comments

- Add nonce parameter to terminal navigation to allow reopening same
  worktree multiple times
- Fix shell path escaping in editor.ts using single-quote wrapper
- Add validatePathParams middleware to open-in-external-terminal route
- Remove redundant validation block from createOpenInExternalTerminalHandler
- Remove unused pendingTerminal state and setPendingTerminal action
- Remove unused getTerminalInfo function from editor.ts

* fix: address PR review security and validation issues

- Add runtime type check for worktreePath in open-in-terminal handler
- Fix Windows Terminal detection using commandExists before spawn
- Fix xterm shell injection by using sh -c with escapeShellArg
- Use loose equality for null/undefined in useEffectiveDefaultTerminal
- Consolidate duplicate imports from open-in-terminal.js

* chore: update package-lock.json

* fix: use response.json() to prevent disposal race condition in E2E test

Replace response.body() with response.json() in open-existing-project.spec.ts
to fix the "Response has been disposed" error. This matches the pattern used
in other test files.

* Revert "fix: use response.json() to prevent disposal race condition in E2E test"

This reverts commit 36bdf8c24a.

* fix: address PR review feedback for terminal feature

- Add explicit validation for worktreePath in createOpenInExternalTerminalHandler
- Add aria-label to refresh button in terminal settings for accessibility
- Only show "no terminals" message when not refreshing
- Reset initialCwdHandledRef on failure to allow retries
- Use z.coerce.number() for nonce URL param to handle string coercion
- Preserve branchName when creating layout for empty tab
- Update getDefaultTerminal return type to allow null result

---------

Co-authored-by: Kacper <kacperlachowiczwp.pl@wp.pl>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Stefan de Vogelaere
2026-01-19 10:22:26 +01:00
committed by GitHub
parent e73c92b031
commit a52c0461e5
24 changed files with 1839 additions and 20 deletions

View File

@@ -500,7 +500,7 @@ export interface ProjectAnalysis {
// Terminal panel layout types (recursive for splits)
export type TerminalPanelContent =
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number }
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
| {
type: 'split';
id: string; // Stable ID for React key stability
@@ -531,12 +531,13 @@ export interface TerminalState {
lineHeight: number; // Line height multiplier for terminal text
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
}
// Persisted terminal layout - now includes sessionIds for reconnection
// Used to restore terminal layout structure when switching projects
export type PersistedTerminalPanel =
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string }
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
| {
type: 'split';
id?: string; // Optional for backwards compatibility with older persisted layouts
@@ -574,6 +575,7 @@ export interface PersistedTerminalSettings {
scrollbackLines: number;
lineHeight: number;
maxSessions: number;
openTerminalMode: 'newTab' | 'split';
}
/** State for worktree init script execution */
@@ -728,6 +730,9 @@ export interface AppState {
// Editor Configuration
defaultEditorCommand: string | null; // Default editor for "Open In" action
// Terminal Configuration
defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated)
// Skills Configuration
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
@@ -1166,6 +1171,9 @@ export interface AppActions {
// Editor Configuration actions
setDefaultEditorCommand: (command: string | null) => void;
// Terminal Configuration actions
setDefaultTerminalId: (terminalId: string | null) => void;
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
@@ -1215,7 +1223,8 @@ export interface AppActions {
addTerminalToLayout: (
sessionId: string,
direction?: 'horizontal' | 'vertical',
targetSessionId?: string
targetSessionId?: string,
branchName?: string
) => void;
removeTerminalFromLayout: (sessionId: string) => void;
swapTerminals: (sessionId1: string, sessionId2: string) => void;
@@ -1229,6 +1238,7 @@ export interface AppActions {
setTerminalLineHeight: (lineHeight: number) => void;
setTerminalMaxSessions: (maxSessions: number) => void;
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
addTerminalTab: (name?: string) => string;
removeTerminalTab: (tabId: string) => void;
setActiveTerminalTab: (tabId: string) => void;
@@ -1238,7 +1248,8 @@ export interface AppActions {
addTerminalToTab: (
sessionId: string,
tabId: string,
direction?: 'horizontal' | 'vertical'
direction?: 'horizontal' | 'vertical',
branchName?: string
) => void;
setTerminalTabLayout: (
tabId: string,
@@ -1420,6 +1431,7 @@ const initialState: AppState = {
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
mcpServers: [], // No MCP servers configured by default
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
defaultTerminalId: null, // Integrated terminal by default
enableSkills: true, // Skills enabled by default
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
enableSubagents: true, // Subagents enabled by default
@@ -1445,6 +1457,7 @@ const initialState: AppState = {
lineHeight: 1.0,
maxSessions: 100,
lastActiveProjectPath: null,
openTerminalMode: 'newTab',
},
terminalLayoutByProject: {},
specCreatingForProject: null,
@@ -2433,6 +2446,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Editor Configuration actions
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
// Terminal Configuration actions
setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }),
// Prompt Customization actions
setPromptCustomization: async (customization) => {
set({ promptCustomization: customization });
@@ -2672,12 +2687,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
});
},
addTerminalToLayout: (sessionId, direction = 'horizontal', targetSessionId) => {
addTerminalToLayout: (sessionId, direction = 'horizontal', targetSessionId, branchName) => {
const current = get().terminalState;
const newTerminal: TerminalPanelContent = {
type: 'terminal',
sessionId,
size: 50,
branchName,
};
// If no tabs, create first tab
@@ -2690,7 +2706,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
{
id: newTabId,
name: 'Terminal 1',
layout: { type: 'terminal', sessionId, size: 100 },
layout: { type: 'terminal', sessionId, size: 100, branchName },
},
],
activeTabId: newTabId,
@@ -2765,7 +2781,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
let newLayout: TerminalPanelContent;
if (!activeTab.layout) {
newLayout = { type: 'terminal', sessionId, size: 100 };
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
} else if (targetSessionId) {
newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction);
} else {
@@ -2895,6 +2911,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
maxSessions: current.maxSessions,
// Preserve lastActiveProjectPath - it will be updated separately when needed
lastActiveProjectPath: current.lastActiveProjectPath,
// Preserve openTerminalMode - user preference
openTerminalMode: current.openTerminalMode,
},
});
},
@@ -2986,6 +3004,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
});
},
setOpenTerminalMode: (mode) => {
const current = get().terminalState;
set({
terminalState: { ...current, openTerminalMode: mode },
});
},
addTerminalTab: (name) => {
const current = get().terminalState;
const newTabId = `tab-${Date.now()}`;
@@ -3228,7 +3253,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
});
},
addTerminalToTab: (sessionId, tabId, direction = 'horizontal') => {
addTerminalToTab: (sessionId, tabId, direction = 'horizontal', branchName) => {
const current = get().terminalState;
const tab = current.tabs.find((t) => t.id === tabId);
if (!tab) return;
@@ -3237,11 +3262,12 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
type: 'terminal',
sessionId,
size: 50,
branchName,
};
let newLayout: TerminalPanelContent;
if (!tab.layout) {
newLayout = { type: 'terminal', sessionId, size: 100 };
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
} else if (tab.layout.type === 'terminal') {
newLayout = {
type: 'split',
@@ -3373,6 +3399,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
size: panel.size,
fontSize: panel.fontSize,
sessionId: panel.sessionId, // Preserve for reconnection
branchName: panel.branchName, // Preserve branch name for display
};
}
return {