From f553f99db9c355d8991b27e69a9816db0fc63ee6 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 17 Jan 2026 18:40:54 +0100 Subject: [PATCH 1/9] 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 --- libs/platform/src/editor.ts | 121 ++++++++++++++++++++++++++++++++++++ libs/platform/src/index.ts | 1 + 2 files changed, 122 insertions(+) diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index b6daa0228..02307edb1 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -341,3 +341,124 @@ export async function openInFileManager(targetPath: string): Promise<{ editorNam await execFileAsync(fileManager.command, [targetPath]); return { editorName: fileManager.name }; } + +/** + * Get the platform-specific terminal information + */ +function getTerminalInfo(): { name: string; command: string; args: string[] } { + if (isMac) { + // On macOS, use Terminal.app with AppleScript to open in a specific directory + return { + name: 'Terminal', + command: 'open', + args: ['-a', 'Terminal'], + }; + } else if (isWindows) { + // On Windows, use Windows Terminal if available, otherwise cmd + return { + name: 'Windows Terminal', + command: 'wt', + args: ['-d'], + }; + } else { + // On Linux, try common terminal emulators in order of preference + return { + name: 'Terminal', + command: 'x-terminal-emulator', + args: ['--working-directory'], + }; + } +} + +/** + * Open a terminal in the specified directory + * + * Handles cross-platform differences: + * - On macOS, uses Terminal.app via 'open -a Terminal' or AppleScript for directory + * - On Windows, uses Windows Terminal (wt) or falls back to cmd + * - On Linux, uses x-terminal-emulator or common terminal emulators + * + * @param targetPath - The directory path to open the terminal in + * @returns Promise that resolves with terminal info when launched, rejects on error + */ +export async function openInTerminal(targetPath: string): Promise<{ terminalName: string }> { + if (isMac) { + // Use AppleScript to open Terminal.app in the specified directory + const script = ` + tell application "Terminal" + do script "cd ${targetPath.replace(/"/g, '\\"').replace(/\$/g, '\\$')}" + activate + end tell + `; + await execFileAsync('osascript', ['-e', script]); + return { terminalName: 'Terminal' }; + } else if (isWindows) { + // Try Windows Terminal first + try { + return await new Promise((resolve, reject) => { + const child: ChildProcess = spawn('wt', ['-d', targetPath], { + shell: true, + stdio: 'ignore', + detached: true, + }); + child.unref(); + + child.on('error', () => { + reject(new Error('Windows Terminal not available')); + }); + + setTimeout(() => resolve({ terminalName: 'Windows Terminal' }), 100); + }); + } catch { + // Fall back to cmd + return await new Promise((resolve, reject) => { + const child: ChildProcess = spawn( + 'cmd', + ['/c', 'start', 'cmd', '/k', `cd /d "${targetPath}"`], + { + shell: true, + stdio: 'ignore', + detached: true, + } + ); + child.unref(); + + child.on('error', (err) => { + reject(err); + }); + + setTimeout(() => resolve({ terminalName: 'Command Prompt' }), 100); + }); + } + } else { + // Linux: Try common terminal emulators in order + const terminals = [ + { + name: 'GNOME Terminal', + command: 'gnome-terminal', + args: ['--working-directory', targetPath], + }, + { name: 'Konsole', command: 'konsole', args: ['--workdir', targetPath] }, + { + name: 'xfce4-terminal', + command: 'xfce4-terminal', + args: ['--working-directory', targetPath], + }, + { name: 'xterm', command: 'xterm', args: ['-e', `cd "${targetPath}" && $SHELL`] }, + { + name: 'x-terminal-emulator', + command: 'x-terminal-emulator', + args: ['--working-directory', targetPath], + }, + ]; + + for (const terminal of terminals) { + if (await commandExists(terminal.command)) { + await execFileAsync(terminal.command, terminal.args); + return { terminalName: terminal.name }; + } + } + + throw new Error('No terminal emulator found'); + } +} diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index d51845f99..4883e554d 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -175,4 +175,5 @@ export { findEditorByCommand, openInEditor, openInFileManager, + openInTerminal, } from './editor.js'; From 1e5ed558ec3e23daf6f5d14d067343f4642c6c70 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 20:00:35 +0100 Subject: [PATCH 2/9] 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. --- apps/server/src/routes/worktree/index.ts | 6 +++ .../worktree/routes/open-in-terminal.ts | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 apps/server/src/routes/worktree/routes/open-in-terminal.ts diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 4b54ae9e2..34850f377 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -29,6 +29,7 @@ import { createGetAvailableEditorsHandler, createRefreshEditorsHandler, } from './routes/open-in-editor.js'; +import { createOpenInTerminalHandler } from './routes/open-in-terminal.js'; import { createInitGitHandler } from './routes/init-git.js'; import { createMigrateHandler } from './routes/migrate.js'; import { createStartDevHandler } from './routes/start-dev.js'; @@ -97,6 +98,11 @@ export function createWorktreeRoutes( ); router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler()); router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); + router.post( + '/open-in-terminal', + validatePathParams('worktreePath'), + createOpenInTerminalHandler() + ); router.get('/default-editor', createGetDefaultEditorHandler()); router.get('/available-editors', createGetAvailableEditorsHandler()); router.post('/refresh-editors', createRefreshEditorsHandler()); diff --git a/apps/server/src/routes/worktree/routes/open-in-terminal.ts b/apps/server/src/routes/worktree/routes/open-in-terminal.ts new file mode 100644 index 000000000..2270ed6f8 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -0,0 +1,50 @@ +/** + * POST /open-in-terminal endpoint - Open a terminal in a worktree directory + * + * This module uses @automaker/platform for cross-platform terminal launching. + */ + +import type { Request, Response } from 'express'; +import { isAbsolute } from 'path'; +import { openInTerminal } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createOpenInTerminalHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Security: Validate that worktreePath is an absolute path + if (!isAbsolute(worktreePath)) { + res.status(400).json({ + success: false, + error: 'worktreePath must be an absolute path', + }); + return; + } + + // Use the platform utility to open in terminal + const result = await openInTerminal(worktreePath); + res.json({ + success: true, + result: { + message: `Opened terminal in ${worktreePath}`, + terminalName: result.terminalName, + }, + }); + } catch (error) { + logError(error, 'Open in terminal failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} From ca16d35d14d94b5234c92640d3817a88f8475f9b Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 20:00:44 +0100 Subject: [PATCH 3/9] 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. --- .../components/worktree-actions-dropdown.tsx | 7 +++++++ .../components/worktree-tab.tsx | 3 +++ .../hooks/use-worktree-actions.ts | 19 +++++++++++++++++++ .../worktree-panel/worktree-panel.tsx | 4 ++++ apps/ui/src/lib/http-api-client.ts | 2 ++ 5 files changed, 35 insertions(+) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 459e2ce81..c8f33fc0f 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -26,6 +26,7 @@ import { RefreshCw, Copy, ScrollText, + Terminal, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -51,6 +52,7 @@ interface WorktreeActionsDropdownProps { onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; + onOpenInTerminal: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -81,6 +83,7 @@ export function WorktreeActionsDropdown({ onPull, onPush, onOpenInEditor, + onOpenInTerminal, onCommit, onCreatePR, onAddressPRComments, @@ -303,6 +306,10 @@ export function WorktreeActionsDropdown({ )} + onOpenInTerminal(worktree)} className="text-xs"> + + Open in Terminal + {!worktree.isMain && hasInitScript && ( onRunInitScript(worktree)} className="text-xs"> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 5cb379d3d..2f9e2e19f 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -37,6 +37,7 @@ interface WorktreeTabProps { onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; + onOpenInTerminal: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -81,6 +82,7 @@ export function WorktreeTab({ onPull, onPush, onOpenInEditor, + onOpenInTerminal, onCommit, onCreatePR, onAddressPRComments, @@ -342,6 +344,7 @@ export function WorktreeTab({ onPull={onPull} onPush={onPush} onOpenInEditor={onOpenInEditor} + onOpenInTerminal={onOpenInTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index f1f245dca..d3b1db858 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -143,6 +143,24 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre } }, []); + const handleOpenInTerminal = useCallback(async (worktree: WorktreeInfo) => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.openInTerminal) { + logger.warn('Open in terminal API not available'); + return; + } + const result = await api.worktree.openInTerminal(worktree.path); + if (result.success && result.result) { + toast.success(result.result.message); + } else if (result.error) { + toast.error(result.error); + } + } catch (error) { + logger.error('Open in terminal failed:', error); + } + }, []); + return { isPulling, isPushing, @@ -153,5 +171,6 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre handlePull, handlePush, handleOpenInEditor, + handleOpenInTerminal, }; } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 2cc844f4c..f8b73a259 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -79,6 +79,7 @@ export function WorktreePanel({ handlePull, handlePush, handleOpenInEditor, + handleOpenInTerminal, } = useWorktreeActions({ fetchWorktrees, fetchBranches, @@ -245,6 +246,7 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInTerminal={handleOpenInTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -332,6 +334,7 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInTerminal={handleOpenInTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -390,6 +393,7 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInTerminal={handleOpenInTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index cd0e67391..8eead6d33 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1767,6 +1767,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/switch-branch', { worktreePath, branchName }), openInEditor: (worktreePath: string, editorCommand?: string) => this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), + openInTerminal: (worktreePath: string) => + this.post('/api/worktree/open-in-terminal', { worktreePath }), getDefaultEditor: () => this.get('/api/worktree/default-editor'), getAvailableEditors: () => this.get('/api/worktree/available-editors'), refreshEditors: () => this.post('/api/worktree/refresh-editors', {}), From e56df9156df1e621e656a0f2a74a2497e1c59726 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 20:14:21 +0100 Subject: [PATCH 4/9] 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. --- .../hooks/use-worktree-actions.ts | 31 +++--- .../ui/src/components/views/terminal-view.tsx | 95 +++++++++++++++++++ apps/ui/src/store/app-store.ts | 13 +++ 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index d3b1db858..b666dea96 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -1,7 +1,9 @@ import { useState, useCallback } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { createLogger } from '@automaker/utils/logger'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; import type { WorktreeInfo } from '../types'; const logger = createLogger('WorktreeActions'); @@ -39,6 +41,8 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre const [isPushing, setIsPushing] = useState(false); const [isSwitching, setIsSwitching] = useState(false); const [isActivating, setIsActivating] = useState(false); + const navigate = useNavigate(); + const setPendingTerminalCwd = useAppStore((state) => state.setPendingTerminalCwd); const handleSwitchBranch = useCallback( async (worktree: WorktreeInfo, branchName: string) => { @@ -143,23 +147,16 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre } }, []); - const handleOpenInTerminal = useCallback(async (worktree: WorktreeInfo) => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.openInTerminal) { - logger.warn('Open in terminal API not available'); - return; - } - const result = await api.worktree.openInTerminal(worktree.path); - if (result.success && result.result) { - toast.success(result.result.message); - } else if (result.error) { - toast.error(result.error); - } - } catch (error) { - logger.error('Open in terminal failed:', error); - } - }, []); + const handleOpenInTerminal = useCallback( + (worktree: WorktreeInfo) => { + // Set the pending terminal cwd to the worktree path + setPendingTerminalCwd(worktree.path); + // Navigate to the terminal page + navigate({ to: '/terminal' }); + logger.info('Opening terminal for worktree:', worktree.path); + }, + [navigate, setPendingTerminalCwd] + ); return { isPulling, diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 328afc21b..d9e989403 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -244,6 +244,7 @@ export function TerminalView() { setTerminalScrollbackLines, setTerminalScreenReaderMode, updateTerminalPanelSizes, + setPendingTerminalCwd, } = useAppStore(); const [status, setStatus] = useState(null); @@ -537,6 +538,100 @@ export function TerminalView() { } }, [terminalState.isUnlocked, fetchServerSettings]); + // Handle pending terminal cwd (from "open in terminal" action on worktree menu) + // When pendingTerminalCwd is set and we're ready, create a terminal with that cwd + const pendingTerminalCwdRef = useRef(null); + const pendingTerminalCreatedRef = useRef(false); + useEffect(() => { + const pendingCwd = terminalState.pendingTerminalCwd; + + // Skip if no pending cwd + if (!pendingCwd) { + // Reset the created ref when there's no pending cwd + pendingTerminalCreatedRef.current = false; + pendingTerminalCwdRef.current = null; + return; + } + + // Skip if we already created a terminal for this exact cwd + if (pendingCwd === pendingTerminalCwdRef.current && pendingTerminalCreatedRef.current) { + return; + } + + // Skip if still loading or terminal not enabled + if (loading || !status?.enabled) { + logger.debug('Waiting for terminal to be ready before creating terminal for pending cwd'); + return; + } + + // Skip if password is required but not unlocked yet + if (status.passwordRequired && !terminalState.isUnlocked) { + logger.debug('Waiting for terminal unlock before creating terminal for pending cwd'); + return; + } + + // Track that we're processing this cwd + pendingTerminalCwdRef.current = pendingCwd; + + // Create a terminal with the pending cwd + logger.info('Creating terminal from pending cwd:', pendingCwd); + + // Create terminal with the specified cwd + const createTerminalWithCwd = async () => { + try { + const headers: Record = {}; + const authToken = useAppStore.getState().terminalState.authToken; + if (authToken) { + headers['X-Terminal-Token'] = authToken; + } + + const response = await apiFetch('/api/terminal/sessions', 'POST', { + headers, + body: { cwd: pendingCwd, cols: 80, rows: 24 }, + }); + const data = await response.json(); + + if (data.success) { + // Mark as successfully created + pendingTerminalCreatedRef.current = true; + addTerminalToLayout(data.data.id); + // Mark this session as new for running initial command + if (defaultRunScript) { + setNewSessionIds((prev) => new Set(prev).add(data.data.id)); + } + fetchServerSettings(); + toast.success(`Opened terminal in ${pendingCwd.split('/').pop() || pendingCwd}`); + // Clear the pending cwd after successful creation + setPendingTerminalCwd(null); + } else { + logger.error('Failed to create session from pending cwd:', data.error); + toast.error('Failed to open terminal', { + description: data.error || 'Unknown error', + }); + // Clear pending cwd on failure to prevent infinite retries + setPendingTerminalCwd(null); + } + } catch (err) { + logger.error('Create session error from pending cwd:', err); + toast.error('Failed to open terminal'); + // Clear pending cwd on error to prevent infinite retries + setPendingTerminalCwd(null); + } + }; + + createTerminalWithCwd(); + }, [ + terminalState.pendingTerminalCwd, + terminalState.isUnlocked, + loading, + status?.enabled, + status?.passwordRequired, + setPendingTerminalCwd, + addTerminalToLayout, + defaultRunScript, + fetchServerSettings, + ]); + // Handle project switching - save and restore terminal layouts // Uses terminalState.lastActiveProjectPath (persisted in store) instead of a local ref // This ensures terminals persist when navigating away from terminal route and back diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index a23c17c44..3edeec7ff 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -531,6 +531,7 @@ 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 + pendingTerminalCwd: string | null; // Pending cwd to use when creating next terminal (from "open in terminal" action) } // Persisted terminal layout - now includes sessionIds for reconnection @@ -1229,6 +1230,7 @@ export interface AppActions { setTerminalLineHeight: (lineHeight: number) => void; setTerminalMaxSessions: (maxSessions: number) => void; setTerminalLastActiveProjectPath: (projectPath: string | null) => void; + setPendingTerminalCwd: (cwd: string | null) => void; addTerminalTab: (name?: string) => string; removeTerminalTab: (tabId: string) => void; setActiveTerminalTab: (tabId: string) => void; @@ -1445,6 +1447,7 @@ const initialState: AppState = { lineHeight: 1.0, maxSessions: 100, lastActiveProjectPath: null, + pendingTerminalCwd: null, }, terminalLayoutByProject: {}, specCreatingForProject: null, @@ -2879,6 +2882,9 @@ export const useAppStore = create()((set, get) => ({ maxSessions: current.maxSessions, // Preserve lastActiveProjectPath - it will be updated separately when needed lastActiveProjectPath: current.lastActiveProjectPath, + // Preserve pendingTerminalCwd - this is set by "open in terminal" action and should + // survive the clearTerminalState() call that happens during project switching + pendingTerminalCwd: current.pendingTerminalCwd, }, }); }, @@ -2970,6 +2976,13 @@ export const useAppStore = create()((set, get) => ({ }); }, + setPendingTerminalCwd: (cwd) => { + const current = get().terminalState; + set({ + terminalState: { ...current, pendingTerminalCwd: cwd }, + }); + }, + addTerminalTab: (name) => { const current = get().terminalState; const newTabId = `tab-${Date.now()}`; From b3dcb82c9cd12d998b5975e4788f12b5c75446a8 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 20:22:54 +0100 Subject: [PATCH 5/9] 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. --- .../hooks/use-worktree-actions.ts | 10 +-- .../terminal/terminal-section.tsx | 22 ++++++ .../ui/src/components/views/terminal-view.tsx | 76 ++++++++++++------- apps/ui/src/store/app-store.ts | 26 +++++-- 4 files changed, 94 insertions(+), 40 deletions(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index b666dea96..24f3407e3 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -42,7 +42,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre const [isSwitching, setIsSwitching] = useState(false); const [isActivating, setIsActivating] = useState(false); const navigate = useNavigate(); - const setPendingTerminalCwd = useAppStore((state) => state.setPendingTerminalCwd); + const setPendingTerminal = useAppStore((state) => state.setPendingTerminal); const handleSwitchBranch = useCallback( async (worktree: WorktreeInfo, branchName: string) => { @@ -149,13 +149,13 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre const handleOpenInTerminal = useCallback( (worktree: WorktreeInfo) => { - // Set the pending terminal cwd to the worktree path - setPendingTerminalCwd(worktree.path); + // Set the pending terminal with cwd and branch name + setPendingTerminal({ cwd: worktree.path, branchName: worktree.branch }); // Navigate to the terminal page navigate({ to: '/terminal' }); - logger.info('Opening terminal for worktree:', worktree.path); + logger.info('Opening terminal for worktree:', worktree.path, 'branch:', worktree.branch); }, - [navigate, setPendingTerminalCwd] + [navigate, setPendingTerminal] ); return { diff --git a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx index f1cebb10c..67e4cad1e 100644 --- a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx +++ b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx @@ -25,6 +25,7 @@ export function TerminalSection() { setTerminalScrollbackLines, setTerminalLineHeight, setTerminalDefaultFontSize, + setOpenTerminalMode, } = useAppStore(); const { @@ -34,6 +35,7 @@ export function TerminalSection() { scrollbackLines, lineHeight, defaultFontSize, + openTerminalMode, } = terminalState; return ( @@ -165,6 +167,26 @@ export function TerminalSection() { /> + {/* Open in Terminal Mode */} +
+ +

+ How to open terminals from the "Open in Terminal" action in the worktree menu +

+ +
+ {/* Screen Reader Mode */}
diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index d9e989403..d659ff109 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -244,7 +244,7 @@ export function TerminalView() { setTerminalScrollbackLines, setTerminalScreenReaderMode, updateTerminalPanelSizes, - setPendingTerminalCwd, + setPendingTerminal, } = useAppStore(); const [status, setStatus] = useState(null); @@ -538,23 +538,24 @@ export function TerminalView() { } }, [terminalState.isUnlocked, fetchServerSettings]); - // Handle pending terminal cwd (from "open in terminal" action on worktree menu) - // When pendingTerminalCwd is set and we're ready, create a terminal with that cwd - const pendingTerminalCwdRef = useRef(null); + // Handle pending terminal (from "open in terminal" action on worktree menu) + // When pendingTerminal is set and we're ready, create a terminal based on openTerminalMode setting + const pendingTerminalRef = useRef(null); const pendingTerminalCreatedRef = useRef(false); useEffect(() => { - const pendingCwd = terminalState.pendingTerminalCwd; + const pending = terminalState.pendingTerminal; + const openMode = terminalState.openTerminalMode; - // Skip if no pending cwd - if (!pendingCwd) { - // Reset the created ref when there's no pending cwd + // Skip if no pending terminal + if (!pending) { + // Reset the created ref when there's no pending terminal pendingTerminalCreatedRef.current = false; - pendingTerminalCwdRef.current = null; + pendingTerminalRef.current = null; return; } // Skip if we already created a terminal for this exact cwd - if (pendingCwd === pendingTerminalCwdRef.current && pendingTerminalCreatedRef.current) { + if (pending.cwd === pendingTerminalRef.current && pendingTerminalCreatedRef.current) { return; } @@ -571,13 +572,12 @@ export function TerminalView() { } // Track that we're processing this cwd - pendingTerminalCwdRef.current = pendingCwd; + pendingTerminalRef.current = pending.cwd; // Create a terminal with the pending cwd - logger.info('Creating terminal from pending cwd:', pendingCwd); + logger.info('Creating terminal from pending:', pending, 'mode:', openMode); - // Create terminal with the specified cwd - const createTerminalWithCwd = async () => { + const createTerminalFromPending = async () => { try { const headers: Record = {}; const authToken = useAppStore.getState().terminalState.authToken; @@ -587,46 +587,66 @@ export function TerminalView() { const response = await apiFetch('/api/terminal/sessions', 'POST', { headers, - body: { cwd: pendingCwd, cols: 80, rows: 24 }, + body: { cwd: pending.cwd, cols: 80, rows: 24 }, }); const data = await response.json(); if (data.success) { // Mark as successfully created pendingTerminalCreatedRef.current = true; - addTerminalToLayout(data.data.id); + + if (openMode === 'newTab') { + // Create a new tab named after the branch + const newTabId = addTerminalTab(pending.branchName); + + // Set the tab's layout to the new terminal + useAppStore + .getState() + .setTerminalTabLayout( + newTabId, + { type: 'terminal', sessionId: data.data.id, size: 100 }, + data.data.id + ); + toast.success(`Opened terminal for ${pending.branchName}`); + } else { + // Split mode: add to current tab layout + addTerminalToLayout(data.data.id); + toast.success(`Opened terminal in ${pending.cwd.split('/').pop() || pending.cwd}`); + } + // Mark this session as new for running initial command if (defaultRunScript) { setNewSessionIds((prev) => new Set(prev).add(data.data.id)); } fetchServerSettings(); - toast.success(`Opened terminal in ${pendingCwd.split('/').pop() || pendingCwd}`); - // Clear the pending cwd after successful creation - setPendingTerminalCwd(null); + // Clear the pending terminal after successful creation + setPendingTerminal(null); } else { - logger.error('Failed to create session from pending cwd:', data.error); + logger.error('Failed to create session from pending terminal:', data.error); toast.error('Failed to open terminal', { description: data.error || 'Unknown error', }); - // Clear pending cwd on failure to prevent infinite retries - setPendingTerminalCwd(null); + // Clear pending terminal on failure to prevent infinite retries + setPendingTerminal(null); } } catch (err) { - logger.error('Create session error from pending cwd:', err); + logger.error('Create session error from pending terminal:', err); toast.error('Failed to open terminal'); - // Clear pending cwd on error to prevent infinite retries - setPendingTerminalCwd(null); + // Clear pending terminal on error to prevent infinite retries + setPendingTerminal(null); } }; - createTerminalWithCwd(); + createTerminalFromPending(); }, [ - terminalState.pendingTerminalCwd, + terminalState.pendingTerminal, + terminalState.openTerminalMode, terminalState.isUnlocked, loading, status?.enabled, status?.passwordRequired, - setPendingTerminalCwd, + setPendingTerminal, + addTerminalTab, addTerminalToLayout, defaultRunScript, fetchServerSettings, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 3edeec7ff..bce54390b 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -531,7 +531,8 @@ 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 - pendingTerminalCwd: string | null; // Pending cwd to use when creating next terminal (from "open in terminal" action) + pendingTerminal: { cwd: string; branchName: string } | null; // Pending terminal to create (from "open in terminal" action) + openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action } // Persisted terminal layout - now includes sessionIds for reconnection @@ -1230,7 +1231,8 @@ export interface AppActions { setTerminalLineHeight: (lineHeight: number) => void; setTerminalMaxSessions: (maxSessions: number) => void; setTerminalLastActiveProjectPath: (projectPath: string | null) => void; - setPendingTerminalCwd: (cwd: string | null) => void; + setPendingTerminal: (pending: { cwd: string; branchName: string } | null) => void; + setOpenTerminalMode: (mode: 'newTab' | 'split') => void; addTerminalTab: (name?: string) => string; removeTerminalTab: (tabId: string) => void; setActiveTerminalTab: (tabId: string) => void; @@ -1447,7 +1449,8 @@ const initialState: AppState = { lineHeight: 1.0, maxSessions: 100, lastActiveProjectPath: null, - pendingTerminalCwd: null, + pendingTerminal: null, + openTerminalMode: 'newTab', }, terminalLayoutByProject: {}, specCreatingForProject: null, @@ -2882,9 +2885,11 @@ export const useAppStore = create()((set, get) => ({ maxSessions: current.maxSessions, // Preserve lastActiveProjectPath - it will be updated separately when needed lastActiveProjectPath: current.lastActiveProjectPath, - // Preserve pendingTerminalCwd - this is set by "open in terminal" action and should + // Preserve pendingTerminal - this is set by "open in terminal" action and should // survive the clearTerminalState() call that happens during project switching - pendingTerminalCwd: current.pendingTerminalCwd, + pendingTerminal: current.pendingTerminal, + // Preserve openTerminalMode - user preference + openTerminalMode: current.openTerminalMode, }, }); }, @@ -2976,10 +2981,17 @@ export const useAppStore = create()((set, get) => ({ }); }, - setPendingTerminalCwd: (cwd) => { + setPendingTerminal: (pending) => { const current = get().terminalState; set({ - terminalState: { ...current, pendingTerminalCwd: cwd }, + terminalState: { ...current, pendingTerminal: pending }, + }); + }, + + setOpenTerminalMode: (mode) => { + const current = get().terminalState; + set({ + terminalState: { ...current, openTerminalMode: mode }, }); }, From 5c280ed83aa4cea3097e0462638be35158f6a24e Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 20:43:23 +0100 Subject: [PATCH 6/9] 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 --- .../terminal/terminal-section.tsx | 2 +- .../ui/src/components/views/terminal-view.tsx | 21 ++++++++++++------- .../views/terminal-view/terminal-panel.tsx | 10 +++++++++ apps/ui/src/hooks/use-settings-sync.ts | 18 +++++++++++++--- apps/ui/src/lib/http-api-client.ts | 2 -- apps/ui/src/store/app-store.ts | 14 ++++++++----- libs/types/src/settings.ts | 4 ++++ 7 files changed, 53 insertions(+), 18 deletions(-) diff --git a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx index 67e4cad1e..e70920002 100644 --- a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx +++ b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx @@ -181,7 +181,7 @@ export function TerminalSection() { - New Tab (named after branch) + New Tab Split Current Tab diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index d659ff109..f96d1d5f1 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -596,22 +596,27 @@ export function TerminalView() { pendingTerminalCreatedRef.current = true; if (openMode === 'newTab') { - // Create a new tab named after the branch - const newTabId = addTerminalTab(pending.branchName); + // Create a new tab with default naming + const newTabId = addTerminalTab(); - // Set the tab's layout to the new terminal + // Set the tab's layout to the new terminal with branch name for display in header useAppStore .getState() .setTerminalTabLayout( newTabId, - { type: 'terminal', sessionId: data.data.id, size: 100 }, + { + type: 'terminal', + sessionId: data.data.id, + size: 100, + branchName: pending.branchName, + }, data.data.id ); toast.success(`Opened terminal for ${pending.branchName}`); } else { - // Split mode: add to current tab layout - addTerminalToLayout(data.data.id); - toast.success(`Opened terminal in ${pending.cwd.split('/').pop() || pending.cwd}`); + // Split mode: add to current tab layout with branch name + addTerminalToLayout(data.data.id, 'horizontal', undefined, pending.branchName); + toast.success(`Opened terminal for ${pending.branchName}`); } // Mark this session as new for running initial command @@ -789,6 +794,7 @@ export function TerminalView() { sessionId, size: persisted.size, fontSize: persisted.fontSize, + branchName: persisted.branchName, }; } @@ -1347,6 +1353,7 @@ export function TerminalView() { onCommandRan={() => handleCommandRan(content.sessionId)} isMaximized={terminalState.maximizedSessionId === content.sessionId} onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)} + branchName={content.branchName} /> ); diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 481ee6b41..8a35f4315 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -22,6 +22,7 @@ import { Maximize2, Minimize2, ArrowDown, + GitBranch, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -94,6 +95,7 @@ interface TerminalPanelProps { onCommandRan?: () => void; // Callback when the initial command has been sent isMaximized?: boolean; onToggleMaximize?: () => void; + branchName?: string; // Branch name to display in header (from "Open in Terminal" action) } // Type for xterm Terminal - we'll use any since we're dynamically importing @@ -124,6 +126,7 @@ export function TerminalPanel({ onCommandRan, isMaximized = false, onToggleMaximize, + branchName, }: TerminalPanelProps) { const terminalRef = useRef(null); const containerRef = useRef(null); @@ -1776,6 +1779,13 @@ export function TerminalPanel({
{shellName} + {/* Branch name indicator - show when terminal was opened from worktree */} + {branchName && ( + + + {branchName} + + )} {/* Font size indicator - only show when not default */} {fontSize !== DEFAULT_FONT_SIZE && ( +
+

+ Terminal to use when selecting "Open in Terminal" from the worktree menu +

+ + {terminals.length === 0 && ( +

+ No external terminals detected. Click refresh to re-scan. +

+ )} +
+ + {/* Default Open Mode */} +
+ +

+ How to open the integrated terminal when using "Open in Terminal" from the worktree menu +

+ +
+ {/* Font Family */}
@@ -167,26 +277,6 @@ export function TerminalSection() { />
- {/* Open in Terminal Mode */} -
- -

- How to open terminals from the "Open in Terminal" action in the worktree menu -

- -
- {/* Screen Reader Mode */}
diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index f96d1d5f1..2e692eeb6 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { createLogger } from '@automaker/utils/logger'; import { Terminal as TerminalIcon, @@ -216,7 +217,16 @@ function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) { ); } -export function TerminalView() { +interface TerminalViewProps { + /** Initial working directory to open a terminal in (e.g., from worktree panel) */ + initialCwd?: string; + /** Branch name for display in toast (optional) */ + initialBranch?: string; + /** Mode for opening terminal: 'tab' for new tab, 'split' for split in current tab */ + initialMode?: 'tab' | 'split'; +} + +export function TerminalView({ initialCwd, initialBranch, initialMode }: TerminalViewProps) { const { terminalState, setTerminalUnlocked, @@ -244,9 +254,10 @@ export function TerminalView() { setTerminalScrollbackLines, setTerminalScreenReaderMode, updateTerminalPanelSizes, - setPendingTerminal, } = useAppStore(); + const navigate = useNavigate(); + const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -265,6 +276,7 @@ export function TerminalView() { max: number; } | null>(null); const hasShownHighRamWarningRef = useRef(false); + const initialCwdHandledRef = useRef(null); // Show warning when 20+ terminals are open useEffect(() => { @@ -538,123 +550,99 @@ export function TerminalView() { } }, [terminalState.isUnlocked, fetchServerSettings]); - // Handle pending terminal (from "open in terminal" action on worktree menu) - // When pendingTerminal is set and we're ready, create a terminal based on openTerminalMode setting - const pendingTerminalRef = useRef(null); - const pendingTerminalCreatedRef = useRef(false); + // Handle initialCwd prop - auto-create a terminal with the specified working directory + // This is triggered when navigating from worktree panel's "Open in Integrated Terminal" useEffect(() => { - const pending = terminalState.pendingTerminal; - const openMode = terminalState.openTerminalMode; - - // Skip if no pending terminal - if (!pending) { - // Reset the created ref when there's no pending terminal - pendingTerminalCreatedRef.current = false; - pendingTerminalRef.current = null; - return; - } - - // Skip if we already created a terminal for this exact cwd - if (pending.cwd === pendingTerminalRef.current && pendingTerminalCreatedRef.current) { - return; - } + // Skip if no initialCwd provided + if (!initialCwd) return; - // Skip if still loading or terminal not enabled - if (loading || !status?.enabled) { - logger.debug('Waiting for terminal to be ready before creating terminal for pending cwd'); - return; - } + // Skip if we've already handled this exact cwd (prevents duplicate terminals) + // Include mode in the key to allow opening same cwd with different modes + const cwdKey = `${initialCwd}:${initialMode || 'default'}`; + if (initialCwdHandledRef.current === cwdKey) return; - // Skip if password is required but not unlocked yet - if (status.passwordRequired && !terminalState.isUnlocked) { - logger.debug('Waiting for terminal unlock before creating terminal for pending cwd'); - return; - } + // Skip if terminal is not enabled or not unlocked + if (!status?.enabled) return; + if (status.passwordRequired && !terminalState.isUnlocked) return; - // Track that we're processing this cwd - pendingTerminalRef.current = pending.cwd; + // Skip if still loading + if (loading) return; - // Create a terminal with the pending cwd - logger.info('Creating terminal from pending:', pending, 'mode:', openMode); + // Mark this cwd as being handled + initialCwdHandledRef.current = cwdKey; - const createTerminalFromPending = async () => { + // Create the terminal with the specified cwd + const createTerminalWithCwd = async () => { try { const headers: Record = {}; - const authToken = useAppStore.getState().terminalState.authToken; - if (authToken) { - headers['X-Terminal-Token'] = authToken; + if (terminalState.authToken) { + headers['X-Terminal-Token'] = terminalState.authToken; } const response = await apiFetch('/api/terminal/sessions', 'POST', { headers, - body: { cwd: pending.cwd, cols: 80, rows: 24 }, + body: { cwd: initialCwd, cols: 80, rows: 24 }, }); const data = await response.json(); if (data.success) { - // Mark as successfully created - pendingTerminalCreatedRef.current = true; - - if (openMode === 'newTab') { - // Create a new tab with default naming + // Create in new tab or split based on mode + if (initialMode === 'tab') { + // Create in a new tab (tab name uses default "Terminal N" naming) const newTabId = addTerminalTab(); - - // Set the tab's layout to the new terminal with branch name for display in header - useAppStore - .getState() - .setTerminalTabLayout( - newTabId, - { - type: 'terminal', - sessionId: data.data.id, - size: 100, - branchName: pending.branchName, - }, - data.data.id - ); - toast.success(`Opened terminal for ${pending.branchName}`); + const { addTerminalToTab } = useAppStore.getState(); + // Pass branch name for display in terminal panel header + addTerminalToTab(data.data.id, newTabId, 'horizontal', initialBranch); } else { - // Split mode: add to current tab layout with branch name - addTerminalToLayout(data.data.id, 'horizontal', undefined, pending.branchName); - toast.success(`Opened terminal for ${pending.branchName}`); + // Default: add to current tab (split if there's already a terminal) + // Pass branch name for display in terminal panel header + addTerminalToLayout(data.data.id, undefined, undefined, initialBranch); } // Mark this session as new for running initial command if (defaultRunScript) { setNewSessionIds((prev) => new Set(prev).add(data.data.id)); } + + // Show success toast with branch name if provided + const displayName = initialBranch || initialCwd.split('/').pop() || initialCwd; + toast.success(`Terminal opened at ${displayName}`); + + // Refresh session count fetchServerSettings(); - // Clear the pending terminal after successful creation - setPendingTerminal(null); + + // Clear the cwd from the URL to prevent re-creating on refresh + navigate({ to: '/terminal', search: {}, replace: true }); } else { - logger.error('Failed to create session from pending terminal:', data.error); - toast.error('Failed to open terminal', { + logger.error('Failed to create terminal for cwd:', data.error); + toast.error('Failed to create terminal', { description: data.error || 'Unknown error', }); - // Clear pending terminal on failure to prevent infinite retries - setPendingTerminal(null); } } catch (err) { - logger.error('Create session error from pending terminal:', err); - toast.error('Failed to open terminal'); - // Clear pending terminal on error to prevent infinite retries - setPendingTerminal(null); + logger.error('Create terminal with cwd error:', err); + toast.error('Failed to create terminal', { + description: 'Could not connect to server', + }); } }; - createTerminalFromPending(); + createTerminalWithCwd(); }, [ - terminalState.pendingTerminal, - terminalState.openTerminalMode, - terminalState.isUnlocked, - loading, + initialCwd, + initialBranch, + initialMode, status?.enabled, status?.passwordRequired, - setPendingTerminal, - addTerminalTab, - addTerminalToLayout, + terminalState.isUnlocked, + terminalState.authToken, + terminalState.tabs.length, + loading, defaultRunScript, + addTerminalToLayout, + addTerminalTab, fetchServerSettings, + navigate, ]); // Handle project switching - save and restore terminal layouts @@ -794,7 +782,6 @@ export function TerminalView() { sessionId, size: persisted.size, fontSize: persisted.fontSize, - branchName: persisted.branchName, }; } @@ -949,9 +936,11 @@ export function TerminalView() { // Create a new terminal session // targetSessionId: the terminal to split (if splitting an existing terminal) + // customCwd: optional working directory to use instead of the current project path const createTerminal = async ( direction?: 'horizontal' | 'vertical', - targetSessionId?: string + targetSessionId?: string, + customCwd?: string ) => { if (!canCreateTerminal('[Terminal] Debounced terminal creation')) { return; @@ -965,7 +954,7 @@ export function TerminalView() { const response = await apiFetch('/api/terminal/sessions', 'POST', { headers, - body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 }, + body: { cwd: customCwd || currentProject?.path || undefined, cols: 80, rows: 24 }, }); const data = await response.json(); diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 78815e461..55ffaef9f 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -63,6 +63,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'keyboardShortcuts', 'mcpServers', 'defaultEditorCommand', + 'defaultTerminalId', 'promptCustomization', 'eventHooks', 'projects', @@ -516,6 +517,7 @@ export async function refreshSettingsFromServer(): Promise { }, mcpServers: serverSettings.mcpServers, defaultEditorCommand: serverSettings.defaultEditorCommand ?? null, + defaultTerminalId: serverSettings.defaultTerminalId ?? null, promptCustomization: serverSettings.promptCustomization ?? {}, projects: serverSettings.projects, trashedProjects: serverSettings.trashedProjects, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index f6eb6f2e1..8d4021c20 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1852,6 +1852,56 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, + getAvailableTerminals: async () => { + console.log('[Mock] Getting available terminals'); + return { + success: true, + result: { + terminals: [ + { id: 'iterm2', name: 'iTerm2', command: 'open -a iTerm' }, + { id: 'terminal-macos', name: 'Terminal', command: 'open -a Terminal' }, + ], + }, + }; + }, + + getDefaultTerminal: async () => { + console.log('[Mock] Getting default terminal'); + return { + success: true, + result: { + terminalId: 'iterm2', + terminalName: 'iTerm2', + terminalCommand: 'open -a iTerm', + }, + }; + }, + + refreshTerminals: async () => { + console.log('[Mock] Refreshing available terminals'); + return { + success: true, + result: { + terminals: [ + { id: 'iterm2', name: 'iTerm2', command: 'open -a iTerm' }, + { id: 'terminal-macos', name: 'Terminal', command: 'open -a Terminal' }, + ], + message: 'Found 2 available terminals', + }, + }; + }, + + openInExternalTerminal: async (worktreePath: string, terminalId?: string) => { + console.log('[Mock] Opening in external terminal:', worktreePath, terminalId); + return { + success: true, + result: { + message: `Opened ${worktreePath} in ${terminalId ?? 'default terminal'}`, + terminalName: terminalId ?? 'Terminal', + }, + }; + }, + initGit: async (projectPath: string) => { console.log('[Mock] Initializing git:', projectPath); return { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index cd0e67391..7f01ed80d 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1770,6 +1770,11 @@ export class HttpApiClient implements ElectronAPI { getDefaultEditor: () => this.get('/api/worktree/default-editor'), getAvailableEditors: () => this.get('/api/worktree/available-editors'), refreshEditors: () => this.post('/api/worktree/refresh-editors', {}), + getAvailableTerminals: () => this.get('/api/worktree/available-terminals'), + getDefaultTerminal: () => this.get('/api/worktree/default-terminal'), + refreshTerminals: () => this.post('/api/worktree/refresh-terminals', {}), + openInExternalTerminal: (worktreePath: string, terminalId?: string) => + this.post('/api/worktree/open-in-external-terminal', { worktreePath, terminalId }), initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }), startDevServer: (projectPath: string, worktreePath: string) => this.post('/api/worktree/start-dev', { projectPath, worktreePath }), diff --git a/apps/ui/src/routes/terminal.tsx b/apps/ui/src/routes/terminal.tsx index bbd0ababd..5130d58e9 100644 --- a/apps/ui/src/routes/terminal.tsx +++ b/apps/ui/src/routes/terminal.tsx @@ -1,6 +1,19 @@ import { createFileRoute } from '@tanstack/react-router'; import { TerminalView } from '@/components/views/terminal-view'; +import { z } from 'zod'; + +const terminalSearchSchema = z.object({ + cwd: z.string().optional(), + branch: z.string().optional(), + mode: z.enum(['tab', 'split']).optional(), +}); export const Route = createFileRoute('/terminal')({ - component: TerminalView, + validateSearch: terminalSearchSchema, + component: RouteComponent, }); + +function RouteComponent() { + const { cwd, branch, mode } = Route.useSearch(); + return ; +} diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c2e43cd8e..717811d70 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -731,6 +731,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 @@ -1169,6 +1172,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; @@ -1244,7 +1250,8 @@ export interface AppActions { addTerminalToTab: ( sessionId: string, tabId: string, - direction?: 'horizontal' | 'vertical' + direction?: 'horizontal' | 'vertical', + branchName?: string ) => void; setTerminalTabLayout: ( tabId: string, @@ -1426,6 +1433,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 @@ -2425,6 +2433,8 @@ export const useAppStore = create()((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 }); @@ -3240,7 +3250,7 @@ export const useAppStore = create()((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; @@ -3249,11 +3259,12 @@ export const useAppStore = create()((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', diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 49c1c4ad1..58543c26e 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -946,6 +946,58 @@ export interface WorktreeAPI { }; error?: string; }>; + + // Get available external terminals + getAvailableTerminals: () => Promise<{ + success: boolean; + result?: { + terminals: Array<{ + id: string; + name: string; + command: string; + }>; + }; + error?: string; + }>; + + // Get default external terminal + getDefaultTerminal: () => Promise<{ + success: boolean; + result?: { + terminalId: string; + terminalName: string; + terminalCommand: string; + }; + error?: string; + }>; + + // Refresh terminal cache and re-detect available terminals + refreshTerminals: () => Promise<{ + success: boolean; + result?: { + terminals: Array<{ + id: string; + name: string; + command: string; + }>; + message: string; + }; + error?: string; + }>; + + // Open worktree in an external terminal + openInExternalTerminal: ( + worktreePath: string, + terminalId?: string + ) => Promise<{ + success: boolean; + result?: { + message: string; + terminalName: string; + }; + error?: string; + }>; + // Initialize git repository in a project initGit: (projectPath: string) => Promise<{ success: boolean; diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 4883e554d..5952ba2d8 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -177,3 +177,12 @@ export { openInFileManager, openInTerminal, } from './editor.js'; + +// External terminal detection and launching +export { + clearTerminalCache, + detectAllTerminals, + detectDefaultTerminal, + findTerminalById, + openInExternalTerminal, +} from './terminal.js'; diff --git a/libs/platform/src/terminal.ts b/libs/platform/src/terminal.ts new file mode 100644 index 000000000..17ee5ad60 --- /dev/null +++ b/libs/platform/src/terminal.ts @@ -0,0 +1,602 @@ +/** + * Cross-platform terminal detection and launching utilities + * + * Handles: + * - Detecting available external terminals on the system + * - Cross-platform terminal launching + * - Caching of detected terminals for performance + */ + +import { execFile, spawn, type ChildProcess } from 'child_process'; +import { promisify } from 'util'; +import { homedir } from 'os'; +import { join } from 'path'; +import { access } from 'fs/promises'; +import type { TerminalInfo } from '@automaker/types'; + +const execFileAsync = promisify(execFile); + +// Platform detection +const isWindows = process.platform === 'win32'; +const isMac = process.platform === 'darwin'; +const isLinux = process.platform === 'linux'; + +// Cache with TTL for terminal detection +let cachedTerminals: TerminalInfo[] | null = null; +let cacheTimestamp: number = 0; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Check if the terminal cache is still valid + */ +function isCacheValid(): boolean { + return cachedTerminals !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS; +} + +/** + * Clear the terminal detection cache + * Useful when terminals may have been installed/uninstalled + */ +export function clearTerminalCache(): void { + cachedTerminals = null; + cacheTimestamp = 0; +} + +/** + * Check if a CLI command exists in PATH + * Uses platform-specific command lookup (where on Windows, which on Unix) + */ +async function commandExists(cmd: string): Promise { + try { + const whichCmd = isWindows ? 'where' : 'which'; + await execFileAsync(whichCmd, [cmd]); + return true; + } catch { + return false; + } +} + +/** + * Check if a macOS app bundle exists and return the path if found + * Checks /Applications, /System/Applications (for built-in apps), and ~/Applications + */ +async function findMacApp(appName: string): Promise { + if (!isMac) return null; + + // Check /Applications first (third-party apps) + const appPath = join('/Applications', `${appName}.app`); + try { + await access(appPath); + return appPath; + } catch { + // Not in /Applications + } + + // Check /System/Applications (built-in macOS apps like Terminal on Catalina+) + const systemAppPath = join('/System/Applications', `${appName}.app`); + try { + await access(systemAppPath); + return systemAppPath; + } catch { + // Not in /System/Applications + } + + // Check ~/Applications (used by some installers) + const userAppPath = join(homedir(), 'Applications', `${appName}.app`); + try { + await access(userAppPath); + return userAppPath; + } catch { + return null; + } +} + +/** + * Check if a Windows path exists + */ +async function windowsPathExists(path: string): Promise { + if (!isWindows) return false; + + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** + * Terminal definition with CLI command and platform-specific identifiers + */ +interface TerminalDefinition { + id: string; + name: string; + /** CLI command (cross-platform, checked via which/where) */ + cliCommand?: string; + /** Alternative CLI commands to check */ + cliAliases?: readonly string[]; + /** macOS app bundle name */ + macAppName?: string; + /** Windows executable paths to check */ + windowsPaths?: readonly string[]; + /** Linux binary paths to check */ + linuxPaths?: readonly string[]; + /** Platform restriction */ + platform?: 'darwin' | 'win32' | 'linux'; +} + +/** + * List of supported terminals in priority order + */ +const SUPPORTED_TERMINALS: TerminalDefinition[] = [ + // macOS terminals + { + id: 'iterm2', + name: 'iTerm2', + cliCommand: 'iterm2', + macAppName: 'iTerm', + platform: 'darwin', + }, + { + id: 'warp', + name: 'Warp', + cliCommand: 'warp', + macAppName: 'Warp', + platform: 'darwin', + }, + { + id: 'ghostty', + name: 'Ghostty', + cliCommand: 'ghostty', + macAppName: 'Ghostty', + }, + { + id: 'rio', + name: 'Rio', + cliCommand: 'rio', + macAppName: 'Rio', + }, + { + id: 'alacritty', + name: 'Alacritty', + cliCommand: 'alacritty', + macAppName: 'Alacritty', + }, + { + id: 'wezterm', + name: 'WezTerm', + cliCommand: 'wezterm', + macAppName: 'WezTerm', + }, + { + id: 'kitty', + name: 'Kitty', + cliCommand: 'kitty', + macAppName: 'kitty', + }, + { + id: 'hyper', + name: 'Hyper', + cliCommand: 'hyper', + macAppName: 'Hyper', + }, + { + id: 'tabby', + name: 'Tabby', + cliCommand: 'tabby', + macAppName: 'Tabby', + }, + { + id: 'terminal-macos', + name: 'System Terminal', + macAppName: 'Utilities/Terminal', + platform: 'darwin', + }, + + // Windows terminals + { + id: 'windows-terminal', + name: 'Windows Terminal', + cliCommand: 'wt', + windowsPaths: [join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'wt.exe')], + platform: 'win32', + }, + { + id: 'powershell', + name: 'PowerShell', + cliCommand: 'pwsh', + cliAliases: ['powershell'], + windowsPaths: [ + join( + process.env.SYSTEMROOT || 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ), + ], + platform: 'win32', + }, + { + id: 'cmd', + name: 'Command Prompt', + cliCommand: 'cmd', + windowsPaths: [join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'cmd.exe')], + platform: 'win32', + }, + { + id: 'git-bash', + name: 'Git Bash', + windowsPaths: [ + join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Git', 'git-bash.exe'), + join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Git', 'git-bash.exe'), + ], + platform: 'win32', + }, + + // Linux terminals + { + id: 'gnome-terminal', + name: 'GNOME Terminal', + cliCommand: 'gnome-terminal', + platform: 'linux', + }, + { + id: 'konsole', + name: 'Konsole', + cliCommand: 'konsole', + platform: 'linux', + }, + { + id: 'xfce4-terminal', + name: 'XFCE4 Terminal', + cliCommand: 'xfce4-terminal', + platform: 'linux', + }, + { + id: 'tilix', + name: 'Tilix', + cliCommand: 'tilix', + platform: 'linux', + }, + { + id: 'terminator', + name: 'Terminator', + cliCommand: 'terminator', + platform: 'linux', + }, + { + id: 'foot', + name: 'Foot', + cliCommand: 'foot', + platform: 'linux', + }, + { + id: 'xterm', + name: 'XTerm', + cliCommand: 'xterm', + platform: 'linux', + }, +]; + +/** + * Try to find a terminal - checks CLI, macOS app bundle, or Windows paths + * Returns TerminalInfo if found, null otherwise + */ +async function findTerminal(definition: TerminalDefinition): Promise { + // Skip if terminal is for a different platform + if (definition.platform) { + if (definition.platform === 'darwin' && !isMac) return null; + if (definition.platform === 'win32' && !isWindows) return null; + if (definition.platform === 'linux' && !isLinux) return null; + } + + // Try CLI command first (works on all platforms) + const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])].filter( + Boolean + ) as string[]; + for (const cliCommand of cliCandidates) { + if (await commandExists(cliCommand)) { + return { + id: definition.id, + name: definition.name, + command: cliCommand, + }; + } + } + + // Try macOS app bundle + if (isMac && definition.macAppName) { + const appPath = await findMacApp(definition.macAppName); + if (appPath) { + return { + id: definition.id, + name: definition.name, + command: `open -a "${appPath}"`, + }; + } + } + + // Try Windows paths + if (isWindows && definition.windowsPaths) { + for (const windowsPath of definition.windowsPaths) { + if (await windowsPathExists(windowsPath)) { + return { + id: definition.id, + name: definition.name, + command: windowsPath, + }; + } + } + } + + return null; +} + +/** + * Detect all available external terminals on the system + * Results are cached for 5 minutes for performance + */ +export async function detectAllTerminals(): Promise { + // Return cached result if still valid + if (isCacheValid() && cachedTerminals) { + return cachedTerminals; + } + + // Check all terminals in parallel for better performance + const terminalChecks = SUPPORTED_TERMINALS.map((def) => findTerminal(def)); + const results = await Promise.all(terminalChecks); + + // Filter out null results (terminals not found) + const terminals = results.filter((t): t is TerminalInfo => t !== null); + + // Update cache + cachedTerminals = terminals; + cacheTimestamp = Date.now(); + + return terminals; +} + +/** + * Detect the default (first available) external terminal on the system + * Returns the highest priority terminal that is installed, or null if none found + */ +export async function detectDefaultTerminal(): Promise { + const terminals = await detectAllTerminals(); + return terminals[0] ?? null; +} + +/** + * Find a specific terminal by ID + * Returns the terminal info if available, null otherwise + */ +export async function findTerminalById(id: string): Promise { + const terminals = await detectAllTerminals(); + return terminals.find((t) => t.id === id) ?? null; +} + +/** + * Open a directory in the specified external terminal + * + * Handles cross-platform differences: + * - On macOS, uses 'open -a' for app bundles or direct command with --directory flag + * - On Windows, uses spawn with shell:true + * - On Linux, uses direct execution with working directory + * + * @param targetPath - The directory path to open + * @param terminalId - The terminal ID to use (optional, uses default if not specified) + * @returns Promise that resolves with terminal info when launched, rejects on error + */ +export async function openInExternalTerminal( + targetPath: string, + terminalId?: string +): Promise<{ terminalName: string }> { + // Determine which terminal to use + let terminal: TerminalInfo | null; + + if (terminalId) { + terminal = await findTerminalById(terminalId); + if (!terminal) { + // Fall back to default if specified terminal not found + terminal = await detectDefaultTerminal(); + } + } else { + terminal = await detectDefaultTerminal(); + } + + if (!terminal) { + throw new Error('No external terminal available'); + } + + // Execute the terminal + await executeTerminalCommand(terminal, targetPath); + + return { terminalName: terminal.name }; +} + +/** + * Execute a terminal command to open at a specific path + * Handles platform-specific differences in command execution + */ +async function executeTerminalCommand(terminal: TerminalInfo, targetPath: string): Promise { + const { id, command } = terminal; + + // Handle 'open -a "AppPath"' style commands (macOS app bundles) + if (command.startsWith('open -a ')) { + const appPath = command.replace('open -a ', '').replace(/"/g, ''); + + // Different terminals have different ways to open at a directory + if (id === 'iterm2') { + // iTerm2: Use AppleScript to open a new window at the path + await execFileAsync('osascript', [ + '-e', + `tell application "iTerm" + create window with default profile + tell current session of current window + write text "cd ${escapeShellArg(targetPath)}" + end tell + end tell`, + ]); + } else if (id === 'terminal-macos') { + // macOS Terminal: Use AppleScript + await execFileAsync('osascript', [ + '-e', + `tell application "Terminal" + do script "cd ${escapeShellArg(targetPath)}" + activate + end tell`, + ]); + } else if (id === 'warp') { + // Warp: Open app and use AppleScript to cd + await execFileAsync('open', ['-a', appPath, targetPath]); + } else { + // Generic: Just open the app with the directory as argument + await execFileAsync('open', ['-a', appPath, targetPath]); + } + return; + } + + // Handle different terminals based on their ID + switch (id) { + case 'iterm2': + // iTerm2 CLI mode + await execFileAsync('osascript', [ + '-e', + `tell application "iTerm" + create window with default profile + tell current session of current window + write text "cd ${escapeShellArg(targetPath)}" + end tell + end tell`, + ]); + break; + + case 'ghostty': + // Ghostty: uses --working-directory=PATH format (single arg) + await spawnDetached(command, [`--working-directory=${targetPath}`]); + break; + + case 'alacritty': + // Alacritty: uses --working-directory flag + await spawnDetached(command, ['--working-directory', targetPath]); + break; + + case 'wezterm': + // WezTerm: uses start --cwd flag + await spawnDetached(command, ['start', '--cwd', targetPath]); + break; + + case 'kitty': + // Kitty: uses --directory flag + await spawnDetached(command, ['--directory', targetPath]); + break; + + case 'hyper': + // Hyper: open at directory by setting cwd + await spawnDetached(command, [targetPath]); + break; + + case 'tabby': + // Tabby: open at directory + await spawnDetached(command, ['open', targetPath]); + break; + + case 'rio': + // Rio: uses --working-dir flag + await spawnDetached(command, ['--working-dir', targetPath]); + break; + + case 'windows-terminal': + // Windows Terminal: uses -d flag for directory + await spawnDetached(command, ['-d', targetPath], { shell: true }); + break; + + case 'powershell': + case 'cmd': + // PowerShell/CMD: Start in directory with /K to keep open + await spawnDetached('start', [command, '/K', `cd /d "${targetPath}"`], { + shell: true, + }); + break; + + case 'git-bash': + // Git Bash: uses --cd flag + await spawnDetached(command, ['--cd', targetPath], { shell: true }); + break; + + case 'gnome-terminal': + // GNOME Terminal: uses --working-directory flag + await spawnDetached(command, ['--working-directory', targetPath]); + break; + + case 'konsole': + // Konsole: uses --workdir flag + await spawnDetached(command, ['--workdir', targetPath]); + break; + + case 'xfce4-terminal': + // XFCE4 Terminal: uses --working-directory flag + await spawnDetached(command, ['--working-directory', targetPath]); + break; + + case 'tilix': + // Tilix: uses --working-directory flag + await spawnDetached(command, ['--working-directory', targetPath]); + break; + + case 'terminator': + // Terminator: uses --working-directory flag + await spawnDetached(command, ['--working-directory', targetPath]); + break; + + case 'foot': + // Foot: uses --working-directory flag + await spawnDetached(command, ['--working-directory', targetPath]); + break; + + case 'xterm': + // XTerm: uses -e to run a shell in the directory + await spawnDetached(command, ['-e', `cd "${targetPath}" && $SHELL`]); + break; + + default: + // Generic fallback: try to run the command with the directory as argument + await spawnDetached(command, [targetPath]); + } +} + +/** + * Spawn a detached process that won't block the parent + */ +function spawnDetached( + command: string, + args: string[], + options: { shell?: boolean } = {} +): Promise { + return new Promise((resolve, reject) => { + const child: ChildProcess = spawn(command, args, { + shell: options.shell ?? false, + stdio: 'ignore', + detached: true, + }); + + // Unref to allow the parent process to exit independently + child.unref(); + + child.on('error', (err) => { + reject(err); + }); + + // Resolve after a small delay to catch immediate spawn errors + // Terminals run in background, so we don't wait for them to exit + setTimeout(() => resolve(), 100); + }); +} + +/** + * Escape a string for safe use in shell commands + */ +function escapeShellArg(arg: string): string { + // Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a01457821..35735ec9f 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -292,3 +292,6 @@ export type { EventReplayHookResult, } from './event-history.js'; export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js'; + +// Terminal types +export type { TerminalInfo } from './terminal.js'; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 7c0955ff1..01c1b7672 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -607,6 +607,10 @@ export interface GlobalSettings { /** Default editor command for "Open In" action (null = auto-detect: Cursor > VS Code > first available) */ defaultEditorCommand: string | null; + // Terminal Configuration + /** Default external terminal ID for "Open In Terminal" action (null = integrated terminal) */ + defaultTerminalId: string | null; + // Prompt Customization /** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */ promptCustomization?: PromptCustomization; @@ -896,6 +900,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { codexThreadId: undefined, mcpServers: [], defaultEditorCommand: null, + defaultTerminalId: null, enableSkills: true, skillsSources: ['user', 'project'], enableSubagents: true, diff --git a/libs/types/src/terminal.ts b/libs/types/src/terminal.ts new file mode 100644 index 000000000..34b9b6a49 --- /dev/null +++ b/libs/types/src/terminal.ts @@ -0,0 +1,15 @@ +/** + * Terminal types for the "Open In Terminal" functionality + */ + +/** + * Information about an available external terminal + */ +export interface TerminalInfo { + /** Unique identifier for the terminal (e.g., 'iterm2', 'warp') */ + id: string; + /** Display name of the terminal (e.g., "iTerm2", "Warp") */ + name: string; + /** CLI command or open command to launch the terminal */ + command: string; +} diff --git a/package-lock.json b/package-lock.json index 8fc7b149c..cdcb1d464 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11274,7 +11274,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11296,7 +11295,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11339,7 +11337,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11361,7 +11358,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11383,7 +11379,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11405,7 +11400,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11427,7 +11421,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11449,7 +11442,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11471,7 +11463,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, From 5bb3415086af2219f199c021064b918c2a65d2e7 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 23:09:23 +0100 Subject: [PATCH 8/9] 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 --- apps/server/src/routes/worktree/index.ts | 6 ++- .../worktree/routes/open-in-terminal.ts | 40 +++++-------------- .../hooks/use-worktree-actions.ts | 3 +- .../ui/src/components/views/terminal-view.tsx | 11 +++-- apps/ui/src/routes/terminal.tsx | 5 ++- apps/ui/src/store/app-store.ts | 13 ------ libs/platform/src/editor.ts | 39 +++++------------- 7 files changed, 36 insertions(+), 81 deletions(-) diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 6d905a8f0..7074691d6 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -117,7 +117,11 @@ export function createWorktreeRoutes( router.get('/available-terminals', createGetAvailableTerminalsHandler()); router.get('/default-terminal', createGetDefaultTerminalHandler()); router.post('/refresh-terminals', createRefreshTerminalsHandler()); - router.post('/open-in-external-terminal', createOpenInExternalTerminalHandler()); + router.post( + '/open-in-external-terminal', + validatePathParams('worktreePath'), + createOpenInExternalTerminalHandler() + ); router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); router.post('/migrate', createMigrateHandler()); diff --git a/apps/server/src/routes/worktree/routes/open-in-terminal.ts b/apps/server/src/routes/worktree/routes/open-in-terminal.ts index 87c8d147c..37ba54803 100644 --- a/apps/server/src/routes/worktree/routes/open-in-terminal.ts +++ b/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -144,42 +144,20 @@ export function createRefreshTerminalsHandler() { export function createOpenInExternalTerminalHandler() { return async (req: Request, res: Response): Promise => { try { + // worktreePath is validated by validatePathParams middleware const { worktreePath, terminalId } = req.body as { worktreePath: string; terminalId?: string; }; - if (!worktreePath) { - res.status(400).json({ - success: false, - error: 'worktreePath required', - }); - return; - } - - // Security: Validate that worktreePath is an absolute path - if (!isAbsolute(worktreePath)) { - res.status(400).json({ - success: false, - error: 'worktreePath must be an absolute path', - }); - return; - } - - try { - const result = await openInExternalTerminal(worktreePath, terminalId); - res.json({ - success: true, - result: { - message: `Opened ${worktreePath} in ${result.terminalName}`, - terminalName: result.terminalName, - }, - }); - } catch (terminalError) { - // Terminal failed to open - logger.warn(`Failed to open in terminal: ${getErrorMessage(terminalError)}`); - throw terminalError; - } + const result = await openInExternalTerminal(worktreePath, terminalId); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.terminalName}`, + terminalName: result.terminalName, + }, + }); } catch (error) { logError(error, 'Open in external terminal failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index ea4a835ed..8e7f6e4e9 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -131,9 +131,10 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre (worktree: WorktreeInfo, mode?: 'tab' | 'split') => { // Navigate to the terminal view with the worktree path and branch name // The terminal view will handle creating the terminal with the specified cwd + // Include nonce to allow opening the same worktree multiple times navigate({ to: '/terminal', - search: { cwd: worktree.path, branch: worktree.branch, mode }, + search: { cwd: worktree.path, branch: worktree.branch, mode, nonce: Date.now() }, }); }, [navigate] diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 2e692eeb6..5d99924e5 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -224,9 +224,11 @@ interface TerminalViewProps { initialBranch?: string; /** Mode for opening terminal: 'tab' for new tab, 'split' for split in current tab */ initialMode?: 'tab' | 'split'; + /** Unique nonce to allow opening the same worktree multiple times */ + nonce?: number; } -export function TerminalView({ initialCwd, initialBranch, initialMode }: TerminalViewProps) { +export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) { const { terminalState, setTerminalUnlocked, @@ -556,9 +558,9 @@ export function TerminalView({ initialCwd, initialBranch, initialMode }: Termina // Skip if no initialCwd provided if (!initialCwd) return; - // Skip if we've already handled this exact cwd (prevents duplicate terminals) - // Include mode in the key to allow opening same cwd with different modes - const cwdKey = `${initialCwd}:${initialMode || 'default'}`; + // Skip if we've already handled this exact request (prevents duplicate terminals) + // Include mode and nonce in the key to allow opening same cwd multiple times + const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`; if (initialCwdHandledRef.current === cwdKey) return; // Skip if terminal is not enabled or not unlocked @@ -632,6 +634,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode }: Termina initialCwd, initialBranch, initialMode, + nonce, status?.enabled, status?.passwordRequired, terminalState.isUnlocked, diff --git a/apps/ui/src/routes/terminal.tsx b/apps/ui/src/routes/terminal.tsx index 5130d58e9..f7ced99b2 100644 --- a/apps/ui/src/routes/terminal.tsx +++ b/apps/ui/src/routes/terminal.tsx @@ -6,6 +6,7 @@ const terminalSearchSchema = z.object({ cwd: z.string().optional(), branch: z.string().optional(), mode: z.enum(['tab', 'split']).optional(), + nonce: z.number().optional(), }); export const Route = createFileRoute('/terminal')({ @@ -14,6 +15,6 @@ export const Route = createFileRoute('/terminal')({ }); function RouteComponent() { - const { cwd, branch, mode } = Route.useSearch(); - return ; + const { cwd, branch, mode, nonce } = Route.useSearch(); + return ; } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 717811d70..6dd04084a 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -531,7 +531,6 @@ 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 - pendingTerminal: { cwd: string; branchName: string } | null; // Pending terminal to create (from "open in terminal" action) openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action } @@ -1239,7 +1238,6 @@ export interface AppActions { setTerminalLineHeight: (lineHeight: number) => void; setTerminalMaxSessions: (maxSessions: number) => void; setTerminalLastActiveProjectPath: (projectPath: string | null) => void; - setPendingTerminal: (pending: { cwd: string; branchName: string } | null) => void; setOpenTerminalMode: (mode: 'newTab' | 'split') => void; addTerminalTab: (name?: string) => string; removeTerminalTab: (tabId: string) => void; @@ -1459,7 +1457,6 @@ const initialState: AppState = { lineHeight: 1.0, maxSessions: 100, lastActiveProjectPath: null, - pendingTerminal: null, openTerminalMode: 'newTab', }, terminalLayoutByProject: {}, @@ -2898,9 +2895,6 @@ export const useAppStore = create()((set, get) => ({ maxSessions: current.maxSessions, // Preserve lastActiveProjectPath - it will be updated separately when needed lastActiveProjectPath: current.lastActiveProjectPath, - // Preserve pendingTerminal - this is set by "open in terminal" action and should - // survive the clearTerminalState() call that happens during project switching - pendingTerminal: current.pendingTerminal, // Preserve openTerminalMode - user preference openTerminalMode: current.openTerminalMode, }, @@ -2994,13 +2988,6 @@ export const useAppStore = create()((set, get) => ({ }); }, - setPendingTerminal: (pending) => { - const current = get().terminalState; - set({ - terminalState: { ...current, pendingTerminal: pending }, - }); - }, - setOpenTerminalMode: (mode) => { const current = get().terminalState; set({ diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts index 02307edb1..7f9607471 100644 --- a/libs/platform/src/editor.ts +++ b/libs/platform/src/editor.ts @@ -19,6 +19,15 @@ const execFileAsync = promisify(execFile); const isWindows = process.platform === 'win32'; const isMac = process.platform === 'darwin'; +/** + * Escape a string for safe use in shell commands + * Handles paths with spaces, special characters, etc. + */ +function escapeShellArg(arg: string): string { + // Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string + return `'${arg.replace(/'/g, "'\\''")}'`; +} + // Cache with TTL for editor detection let cachedEditors: EditorInfo[] | null = null; let cacheTimestamp: number = 0; @@ -342,34 +351,6 @@ export async function openInFileManager(targetPath: string): Promise<{ editorNam return { editorName: fileManager.name }; } -/** - * Get the platform-specific terminal information - */ -function getTerminalInfo(): { name: string; command: string; args: string[] } { - if (isMac) { - // On macOS, use Terminal.app with AppleScript to open in a specific directory - return { - name: 'Terminal', - command: 'open', - args: ['-a', 'Terminal'], - }; - } else if (isWindows) { - // On Windows, use Windows Terminal if available, otherwise cmd - return { - name: 'Windows Terminal', - command: 'wt', - args: ['-d'], - }; - } else { - // On Linux, try common terminal emulators in order of preference - return { - name: 'Terminal', - command: 'x-terminal-emulator', - args: ['--working-directory'], - }; - } -} - /** * Open a terminal in the specified directory * @@ -386,7 +367,7 @@ export async function openInTerminal(targetPath: string): Promise<{ terminalName // Use AppleScript to open Terminal.app in the specified directory const script = ` tell application "Terminal" - do script "cd ${targetPath.replace(/"/g, '\\"').replace(/\$/g, '\\$')}" + do script "cd ${escapeShellArg(targetPath)}" activate end tell `; From 6df310aa0b5a48937c34df7b03cd07311bb21245 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 22:12:59 +0100 Subject: [PATCH 9/9] fix: add project name testid to project switcher item Add data-testid with sanitized project name for E2E test assertions. The testid format is 'project-switcher-project-{name}' where name is the lowercased project name with spaces replaced by hyphens. This fixes E2E tests that expect to find projects by name in the project switcher. --- .../project-switcher/components/project-switcher-item.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx index c1a2fa261..36ff80688 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -37,10 +37,14 @@ export function ProjectSwitcherItem({ const IconComponent = getIconComponent(); const hasCustomIcon = !!project.customIconPath; + // Create a sanitized project name for test ID (lowercase, hyphens instead of spaces) + const sanitizedName = project.name.toLowerCase().replace(/\s+/g, '-'); + return (