From 111eb24856680b4d98042c4569e59de50e3f785b Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 22:57:11 +0100 Subject: [PATCH] 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 --- apps/server/src/routes/worktree/index.ts | 13 + .../worktree/routes/open-in-terminal.ts | 144 ++++- .../src/components/icons/terminal-icons.tsx | 213 +++++++ .../components/worktree-actions-dropdown.tsx | 103 ++- .../components/worktree-tab.tsx | 9 +- .../hooks/use-available-terminals.ts | 99 +++ .../hooks/use-worktree-actions.ts | 45 +- .../worktree-panel/worktree-panel.tsx | 12 +- .../terminal/terminal-section.tsx | 132 +++- .../ui/src/components/views/terminal-view.tsx | 157 +++-- apps/ui/src/hooks/use-settings-sync.ts | 2 + apps/ui/src/lib/electron.ts | 50 ++ apps/ui/src/lib/http-api-client.ts | 5 + apps/ui/src/routes/terminal.tsx | 15 +- apps/ui/src/store/app-store.ts | 17 +- apps/ui/src/types/electron.d.ts | 52 ++ libs/platform/src/index.ts | 9 + libs/platform/src/terminal.ts | 602 ++++++++++++++++++ libs/types/src/index.ts | 3 + libs/types/src/settings.ts | 5 + libs/types/src/terminal.ts | 15 + package-lock.json | 9 - 22 files changed, 1565 insertions(+), 146 deletions(-) create mode 100644 apps/ui/src/components/icons/terminal-icons.tsx create mode 100644 apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts create mode 100644 libs/platform/src/terminal.ts create mode 100644 libs/types/src/terminal.ts diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 34850f37..6d905a8f 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -30,6 +30,12 @@ import { createRefreshEditorsHandler, } from './routes/open-in-editor.js'; import { createOpenInTerminalHandler } from './routes/open-in-terminal.js'; +import { + createGetAvailableTerminalsHandler, + createGetDefaultTerminalHandler, + createRefreshTerminalsHandler, + createOpenInExternalTerminalHandler, +} 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'; @@ -106,6 +112,13 @@ export function createWorktreeRoutes( router.get('/default-editor', createGetDefaultEditorHandler()); router.get('/available-editors', createGetAvailableEditorsHandler()); router.post('/refresh-editors', createRefreshEditorsHandler()); + + // External terminal routes + router.get('/available-terminals', createGetAvailableTerminalsHandler()); + router.get('/default-terminal', createGetDefaultTerminalHandler()); + router.post('/refresh-terminals', createRefreshTerminalsHandler()); + router.post('/open-in-external-terminal', createOpenInExternalTerminalHandler()); + router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); router.post('/migrate', createMigrateHandler()); router.post( 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 2270ed6f..87c8d147 100644 --- a/apps/server/src/routes/worktree/routes/open-in-terminal.ts +++ b/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -1,14 +1,30 @@ /** - * POST /open-in-terminal endpoint - Open a terminal in a worktree directory + * Terminal endpoints for opening worktree directories in terminals * - * This module uses @automaker/platform for cross-platform terminal launching. + * POST /open-in-terminal - Open in system default terminal (integrated) + * GET /available-terminals - List all available external terminals + * GET /default-terminal - Get the default external terminal + * POST /refresh-terminals - Clear terminal cache and re-detect + * POST /open-in-external-terminal - Open a directory in an external terminal */ import type { Request, Response } from 'express'; import { isAbsolute } from 'path'; -import { openInTerminal } from '@automaker/platform'; +import { + openInTerminal, + clearTerminalCache, + detectAllTerminals, + detectDefaultTerminal, + openInExternalTerminal, +} from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; +const logger = createLogger('open-in-terminal'); + +/** + * Handler to open in system default terminal (integrated terminal behavior) + */ export function createOpenInTerminalHandler() { return async (req: Request, res: Response): Promise => { try { @@ -48,3 +64,125 @@ export function createOpenInTerminalHandler() { } }; } + +/** + * Handler to get all available external terminals + */ +export function createGetAvailableTerminalsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const terminals = await detectAllTerminals(); + res.json({ + success: true, + result: { + terminals, + }, + }); + } catch (error) { + logError(error, 'Get available terminals failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to get the default external terminal + */ +export function createGetDefaultTerminalHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const terminal = await detectDefaultTerminal(); + res.json({ + success: true, + result: terminal + ? { + terminalId: terminal.id, + terminalName: terminal.name, + terminalCommand: terminal.command, + } + : null, + }); + } catch (error) { + logError(error, 'Get default terminal failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to refresh the terminal cache and re-detect available terminals + * Useful when the user has installed/uninstalled terminals + */ +export function createRefreshTerminalsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Clear the cache + clearTerminalCache(); + + // Re-detect terminals (this will repopulate the cache) + const terminals = await detectAllTerminals(); + + logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`); + + res.json({ + success: true, + result: { + terminals, + message: `Found ${terminals.length} available external terminals`, + }, + }); + } catch (error) { + logError(error, 'Refresh terminals failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to open a directory in an external terminal + */ +export function createOpenInExternalTerminalHandler() { + return async (req: Request, res: Response): Promise => { + try { + 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; + } + } 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/icons/terminal-icons.tsx b/apps/ui/src/components/icons/terminal-icons.tsx new file mode 100644 index 00000000..38e8a47d --- /dev/null +++ b/apps/ui/src/components/icons/terminal-icons.tsx @@ -0,0 +1,213 @@ +import type { ComponentType, ComponentProps } from 'react'; +import { Terminal } from 'lucide-react'; + +type IconProps = ComponentProps<'svg'>; +type IconComponent = ComponentType; + +/** + * iTerm2 logo icon + */ +export function ITerm2Icon(props: IconProps) { + return ( + + + + ); +} + +/** + * Warp terminal logo icon + */ +export function WarpIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Ghostty terminal logo icon + */ +export function GhosttyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Alacritty terminal logo icon + */ +export function AlacrittyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * WezTerm terminal logo icon + */ +export function WezTermIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Kitty terminal logo icon + */ +export function KittyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Hyper terminal logo icon + */ +export function HyperIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Tabby terminal logo icon + */ +export function TabbyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Rio terminal logo icon + */ +export function RioIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Windows Terminal logo icon + */ +export function WindowsTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * PowerShell logo icon + */ +export function PowerShellIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Command Prompt (cmd) logo icon + */ +export function CmdIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Git Bash logo icon + */ +export function GitBashIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * GNOME Terminal logo icon + */ +export function GnomeTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Konsole logo icon + */ +export function KonsoleIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * macOS Terminal logo icon + */ +export function MacOSTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Get the appropriate icon component for a terminal ID + */ +export function getTerminalIcon(terminalId: string): IconComponent { + const terminalIcons: Record = { + iterm2: ITerm2Icon, + warp: WarpIcon, + ghostty: GhosttyIcon, + alacritty: AlacrittyIcon, + wezterm: WezTermIcon, + kitty: KittyIcon, + hyper: HyperIcon, + tabby: TabbyIcon, + rio: RioIcon, + 'windows-terminal': WindowsTerminalIcon, + powershell: PowerShellIcon, + cmd: CmdIcon, + 'git-bash': GitBashIcon, + 'gnome-terminal': GnomeTerminalIcon, + konsole: KonsoleIcon, + 'terminal-macos': MacOSTerminalIcon, + // Linux terminals - use generic terminal icon + 'xfce4-terminal': Terminal, + tilix: Terminal, + terminator: Terminal, + foot: Terminal, + xterm: Terminal, + }; + + return terminalIcons[terminalId] ?? Terminal; +} 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 c8f33fc0..41041315 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 @@ -27,13 +27,21 @@ import { Copy, ScrollText, Terminal, + SquarePlus, + SplitSquareHorizontal, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; import { TooltipWrapper } from './tooltip-wrapper'; import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors'; +import { + useAvailableTerminals, + useEffectiveDefaultTerminal, +} from '../hooks/use-available-terminals'; import { getEditorIcon } from '@/components/icons/editor-icons'; +import { getTerminalIcon } from '@/components/icons/terminal-icons'; +import { useAppStore } from '@/store/app-store'; interface WorktreeActionsDropdownProps { worktree: WorktreeInfo; @@ -52,7 +60,8 @@ interface WorktreeActionsDropdownProps { onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; - onOpenInTerminal: (worktree: WorktreeInfo) => void; + onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; + onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -83,7 +92,8 @@ export function WorktreeActionsDropdown({ onPull, onPush, onOpenInEditor, - onOpenInTerminal, + onOpenInIntegratedTerminal, + onOpenInExternalTerminal, onCommit, onCreatePR, onAddressPRComments, @@ -111,6 +121,20 @@ export function WorktreeActionsDropdown({ ? getEditorIcon(effectiveDefaultEditor.command) : null; + // Get available terminals for the "Open In Terminal" submenu + const { terminals, hasExternalTerminals } = useAvailableTerminals(); + + // Use shared hook for effective default terminal (null = integrated terminal) + const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals); + + // Get the user's preferred mode for opening terminals (new tab vs split) + const openTerminalMode = useAppStore((s) => s.terminalState.openTerminalMode); + + // Get icon component for the effective terminal + const DefaultTerminalIcon = effectiveDefaultTerminal + ? getTerminalIcon(effectiveDefaultTerminal.id) + : Terminal; + // Check if there's a PR associated with this worktree from stored metadata const hasPR = !!worktree.pr; @@ -306,10 +330,77 @@ export function WorktreeActionsDropdown({ )} - onOpenInTerminal(worktree)} className="text-xs"> - - Open in Terminal - + {/* Open in terminal - always show with integrated + external options */} + +
+ {/* Main clickable area - opens in default terminal (integrated or external) */} + { + if (effectiveDefaultTerminal) { + // External terminal is the default + onOpenInExternalTerminal(worktree, effectiveDefaultTerminal.id); + } else { + // Integrated terminal is the default - use user's preferred mode + const mode = openTerminalMode === 'newTab' ? 'tab' : 'split'; + onOpenInIntegratedTerminal(worktree, mode); + } + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Open in {effectiveDefaultTerminal?.name ?? 'Terminal'} + + {/* Chevron trigger for submenu with all terminals */} + +
+ + {/* Automaker Terminal - with submenu for new tab vs split */} + + + + Terminal + {!effectiveDefaultTerminal && ( + (default) + )} + + + onOpenInIntegratedTerminal(worktree, 'tab')} + className="text-xs" + > + + New Tab + + onOpenInIntegratedTerminal(worktree, 'split')} + className="text-xs" + > + + Split + + + + {/* External terminals */} + {terminals.length > 0 && } + {terminals.map((terminal) => { + const TerminalIcon = getTerminalIcon(terminal.id); + const isDefault = terminal.id === effectiveDefaultTerminal?.id; + return ( + onOpenInExternalTerminal(worktree, terminal.id)} + className="text-xs" + > + + {terminal.name} + {isDefault && ( + (default) + )} + + ); + })} + +
{!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 359a8f99..56478385 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 @@ -38,7 +38,8 @@ interface WorktreeTabProps { onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; - onOpenInTerminal: (worktree: WorktreeInfo) => void; + onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; + onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -83,7 +84,8 @@ export function WorktreeTab({ onPull, onPush, onOpenInEditor, - onOpenInTerminal, + onOpenInIntegratedTerminal, + onOpenInExternalTerminal, onCommit, onCreatePR, onAddressPRComments, @@ -345,7 +347,8 @@ export function WorktreeTab({ onPull={onPull} onPush={onPush} onOpenInEditor={onOpenInEditor} - onOpenInTerminal={onOpenInTerminal} + onOpenInIntegratedTerminal={onOpenInIntegratedTerminal} + onOpenInExternalTerminal={onOpenInExternalTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts new file mode 100644 index 00000000..c89573e1 --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts @@ -0,0 +1,99 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import type { TerminalInfo } from '@automaker/types'; + +const logger = createLogger('AvailableTerminals'); + +// Re-export TerminalInfo for convenience +export type { TerminalInfo }; + +export function useAvailableTerminals() { + const [terminals, setTerminals] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + + const fetchAvailableTerminals = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.getAvailableTerminals) { + setIsLoading(false); + return; + } + const result = await api.worktree.getAvailableTerminals(); + if (result.success && result.result?.terminals) { + setTerminals(result.result.terminals); + } + } catch (error) { + logger.error('Failed to fetch available terminals:', error); + } finally { + setIsLoading(false); + } + }, []); + + /** + * Refresh terminals by clearing the server cache and re-detecting + * Use this when the user has installed/uninstalled terminals + */ + const refresh = useCallback(async () => { + setIsRefreshing(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.refreshTerminals) { + // Fallback to regular fetch if refresh not available + await fetchAvailableTerminals(); + return; + } + const result = await api.worktree.refreshTerminals(); + if (result.success && result.result?.terminals) { + setTerminals(result.result.terminals); + logger.info(`Terminal cache refreshed, found ${result.result.terminals.length} terminals`); + } + } catch (error) { + logger.error('Failed to refresh terminals:', error); + } finally { + setIsRefreshing(false); + } + }, [fetchAvailableTerminals]); + + useEffect(() => { + fetchAvailableTerminals(); + }, [fetchAvailableTerminals]); + + return { + terminals, + isLoading, + isRefreshing, + refresh, + // Convenience property: has external terminals available + hasExternalTerminals: terminals.length > 0, + // The first terminal is the "default" one (highest priority) + defaultTerminal: terminals[0] ?? null, + }; +} + +/** + * Hook to get the effective default terminal based on user settings + * Returns null if user prefers integrated terminal (defaultTerminalId is null) + * Falls back to: user preference > first available external terminal + */ +export function useEffectiveDefaultTerminal(terminals: TerminalInfo[]): TerminalInfo | null { + const defaultTerminalId = useAppStore((s) => s.defaultTerminalId); + + return useMemo(() => { + // If user hasn't set a preference (null), they prefer integrated terminal + if (defaultTerminalId === null) { + return null; + } + + // If user has set a preference, find it in available terminals + if (defaultTerminalId) { + const found = terminals.find((t) => t.id === defaultTerminalId); + if (found) return found; + } + + // If the saved preference doesn't exist anymore, fall back to first available + return terminals[0] ?? null; + }, [terminals, defaultTerminalId]); +} 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 24f3407e..ea4a835e 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 @@ -3,7 +3,6 @@ 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'); @@ -37,12 +36,11 @@ interface UseWorktreeActionsOptions { } export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) { + const navigate = useNavigate(); const [isPulling, setIsPulling] = useState(false); const [isPushing, setIsPushing] = useState(false); const [isSwitching, setIsSwitching] = useState(false); const [isActivating, setIsActivating] = useState(false); - const navigate = useNavigate(); - const setPendingTerminal = useAppStore((state) => state.setPendingTerminal); const handleSwitchBranch = useCallback( async (worktree: WorktreeInfo, branchName: string) => { @@ -129,6 +127,18 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre [isPushing, fetchBranches, fetchWorktrees] ); + const handleOpenInIntegratedTerminal = useCallback( + (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 + navigate({ + to: '/terminal', + search: { cwd: worktree.path, branch: worktree.branch, mode }, + }); + }, + [navigate] + ); + const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => { try { const api = getElectronAPI(); @@ -147,15 +157,25 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre } }, []); - const handleOpenInTerminal = useCallback( - (worktree: WorktreeInfo) => { - // 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, 'branch:', worktree.branch); + const handleOpenInExternalTerminal = useCallback( + async (worktree: WorktreeInfo, terminalId?: string) => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.openInExternalTerminal) { + logger.warn('Open in external terminal API not available'); + return; + } + const result = await api.worktree.openInExternalTerminal(worktree.path, terminalId); + if (result.success && result.result) { + toast.success(result.result.message); + } else if (result.error) { + toast.error(result.error); + } + } catch (error) { + logger.error('Open in external terminal failed:', error); + } }, - [navigate, setPendingTerminal] + [] ); return { @@ -167,7 +187,8 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre handleSwitchBranch, handlePull, handlePush, + handleOpenInIntegratedTerminal, handleOpenInEditor, - handleOpenInTerminal, + handleOpenInExternalTerminal, }; } 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 dd8c9376..1c05eb7b 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,8 +79,9 @@ export function WorktreePanel({ handleSwitchBranch, handlePull, handlePush, + handleOpenInIntegratedTerminal, handleOpenInEditor, - handleOpenInTerminal, + handleOpenInExternalTerminal, } = useWorktreeActions({ fetchWorktrees, fetchBranches, @@ -247,7 +248,8 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} - onOpenInTerminal={handleOpenInTerminal} + onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} + onOpenInExternalTerminal={handleOpenInExternalTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -335,7 +337,8 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} - onOpenInTerminal={handleOpenInTerminal} + onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} + onOpenInExternalTerminal={handleOpenInExternalTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -394,7 +397,8 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} - onOpenInTerminal={handleOpenInTerminal} + onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} + onOpenInExternalTerminal={handleOpenInExternalTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} 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 e7092000..f7e80e19 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 @@ -2,6 +2,7 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Slider } from '@/components/ui/slider'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { Select, SelectContent, @@ -9,12 +10,20 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { SquareTerminal } from 'lucide-react'; +import { + SquareTerminal, + RefreshCw, + Terminal, + SquarePlus, + SplitSquareHorizontal, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes'; import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options'; +import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals'; +import { getTerminalIcon } from '@/components/icons/terminal-icons'; export function TerminalSection() { const { @@ -25,6 +34,8 @@ export function TerminalSection() { setTerminalScrollbackLines, setTerminalLineHeight, setTerminalDefaultFontSize, + defaultTerminalId, + setDefaultTerminalId, setOpenTerminalMode, } = useAppStore(); @@ -38,6 +49,9 @@ export function TerminalSection() { openTerminalMode, } = terminalState; + // Get available external terminals + const { terminals, isRefreshing, refresh } = useAvailableTerminals(); + return (
+ {/* Default External Terminal */} +
+
+ + +
+

+ 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 88075747..46e049d5 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 initialCwd provided + if (!initialCwd) return; - // 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'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 we already created a terminal for this exact cwd - if (pending.cwd === pendingTerminalRef.current && pendingTerminalCreatedRef.current) { - return; - } + // Skip if terminal is not enabled or not unlocked + if (!status?.enabled) return; + if (status.passwordRequired && !terminalState.isUnlocked) 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 still loading + if (loading) 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; - } + // Mark this cwd as being handled + initialCwdHandledRef.current = cwdKey; - // Track that we're processing this cwd - pendingTerminalRef.current = pending.cwd; - - // Create a terminal with the pending cwd - logger.info('Creating terminal from pending:', pending, 'mode:', openMode); - - 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 70db19e9..2b8c98a7 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', @@ -568,6 +569,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 f6eb6f2e..8d4021c2 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 2943f3e2..ba2b8dd3 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1808,6 +1808,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 bbd0abab..5130d58e 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 e94cb9b6..29d5b70e 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 @@ -2439,6 +2447,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 }); @@ -3254,7 +3264,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; @@ -3263,11 +3273,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 49c1c4ad..58543c26 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 4883e554..5952ba2d 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 00000000..17ee5ad6 --- /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 4a8c6af1..7f812e51 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -296,3 +296,6 @@ export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-hist // Worktree and PR types export type { PRState, WorktreePRInfo } from './worktree.js'; export { PR_STATES, validatePRState } from './worktree.js'; + +// Terminal types +export type { TerminalInfo } from './terminal.js'; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 7c0955ff..01c1b767 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 00000000..34b9b6a4 --- /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 97a2c4fe..dc69ae79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11275,7 +11275,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11297,7 +11296,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11341,7 +11339,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11363,7 +11360,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11385,7 +11381,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11407,7 +11402,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11429,7 +11423,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11451,7 +11444,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11473,7 +11465,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" },