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
This commit is contained in:
Stefan de Vogelaere
2026-01-17 22:57:11 +01:00
parent 502361fc7c
commit 111eb24856
22 changed files with 1565 additions and 146 deletions

View File

@@ -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(

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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) });
}
};
}

View File

@@ -0,0 +1,213 @@
import type { ComponentType, ComponentProps } from 'react';
import { Terminal } from 'lucide-react';
type IconProps = ComponentProps<'svg'>;
type IconComponent = ComponentType<IconProps>;
/**
* iTerm2 logo icon
*/
export function ITerm2Icon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.586 0a2.56 2.56 0 00-2.56 2.56v18.88A2.56 2.56 0 002.586 24h18.88a2.56 2.56 0 002.56-2.56V2.56A2.56 2.56 0 0021.466 0H2.586zm8.143 4.027h2.543v15.946h-2.543V4.027zm-3.816 0h2.544v15.946H6.913V4.027zm7.633 0h2.543v15.946h-2.543V4.027z" />
</svg>
);
}
/**
* Warp terminal logo icon
*/
export function WarpIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M9.2 4.8L7.6 7.6 4.8 5.6l1.6-2.8L9.2 4.8zm5.6 0l1.6 2.8 2.8-2-1.6-2.8-2.8 2zM2.4 12l2.8 1.6L3.6 16 .8 14.4 2.4 12zm19.2 0l1.6 2.4-2.8 1.6-1.6-2.4 2.8-1.6zM7.6 16.4l1.6 2.8-2.8 2-1.6-2.8 2.8-2zm8.8 0l2.8 2-1.6 2.8-2.8-2 1.6-2.8zM12 0L8.4 2 12 4l3.6-2L12 0zm0 20l-3.6 2 3.6 2 3.6-2-3.6-2z" />
</svg>
);
}
/**
* Ghostty terminal logo icon
*/
export function GhosttyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2C6.48 2 2 6.48 2 12v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-8c0-5.52-4.48-10-10-10zm-3.5 12a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm7 0a1.5 1.5 0 110-3 1.5 1.5 0 010 3zM12 19c-1.5 0-3-.5-4-1.5v-1c2 1 6 1 8 0v1c-1 1-2.5 1.5-4 1.5z" />
</svg>
);
}
/**
* Alacritty terminal logo icon
*/
export function AlacrittyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 0L1.608 21.6h3.186l1.46-3.032h11.489l1.46 3.032h3.189L12 0zm0 7.29l3.796 7.882H8.204L12 7.29z" />
</svg>
);
}
/**
* WezTerm terminal logo icon
*/
export function WezTermIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2 2h12v2H6V8zm0 4h8v2H6v-2z" />
</svg>
);
}
/**
* Kitty terminal logo icon
*/
export function KittyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3.5 7.5L1 5V2.5L3.5 5V7.5zM20.5 7.5L23 5V2.5L20.5 5V7.5zM12 4L6 8v8l6 4 6-4V8l-6-4zm0 2l4 2.67v5.33L12 16.67 8 14V8.67L12 6z" />
</svg>
);
}
/**
* Hyper terminal logo icon
*/
export function HyperIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M11.857 23.995v-7.125H6.486l5.765-10.856-.363-1.072L7.803.001 0 12.191h5.75L0 23.995h11.857zm.286 0h5.753l5.679-11.804h-5.679l5.679-11.804L17.896.388l-5.753 11.803h5.753L12.143 24z" />
</svg>
);
}
/**
* Tabby terminal logo icon
*/
export function TabbyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2L4 6v12l8 4 8-4V6l-8-4zm0 2l6 3v10l-6 3-6-3V7l6-3z" />
</svg>
);
}
/**
* Rio terminal logo icon
*/
export function RioIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" />
</svg>
);
}
/**
* Windows Terminal logo icon
*/
export function WindowsTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8.165 6L0 9.497v5.006L8.165 18l.413-.206v-4.025L3.197 12l5.381-1.769V6.206L8.165 6zm7.67 0l-.413.206v4.025L20.803 12l-5.381 1.769v4.025l.413.206L24 14.503V9.497L15.835 6z" />
</svg>
);
}
/**
* PowerShell logo icon
*/
export function PowerShellIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.181 2.974c.568 0 .923.463.792 1.035l-3.659 15.982c-.13.572-.697 1.035-1.265 1.035H.819c-.568 0-.923-.463-.792-1.035L3.686 4.009c.13-.572.697-1.035 1.265-1.035h18.23zM8.958 16.677c0 .334.276.611.611.611h3.673a.615.615 0 00.611-.611.615.615 0 00-.611-.611h-3.673a.615.615 0 00-.611.611zm5.126-7.016L9.025 14.72c-.241.241-.241.63 0 .872.241.241.63.241.872 0l5.059-5.059c.241-.241.241-.63 0-.872l-5.059-5.059c-.241-.241-.63-.241-.872 0-.241.241-.241.63 0 .872l5.059 5.059c-.334.334-.334.334 0 0z" />
</svg>
);
}
/**
* Command Prompt (cmd) logo icon
*/
export function CmdIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2.5 1.5l3 3-3 3L5 12l3-3zm5.5 5h6v1.5h-6V12z" />
</svg>
);
}
/**
* Git Bash logo icon
*/
export function GitBashIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.889.721-2.609 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187" />
</svg>
);
}
/**
* GNOME Terminal logo icon
*/
export function GnomeTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4a2 2 0 00-2 2v12a2 2 0 002 2h20a2 2 0 002-2V6a2 2 0 00-2-2H2zm0 2h20v12H2V6zm2 2v2h2V8H4zm4 0v2h12V8H8zm-4 4v2h2v-2H4zm4 0v2h8v-2H8z" />
</svg>
);
}
/**
* Konsole logo icon
*/
export function KonsoleIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 3h18a2 2 0 012 2v14a2 2 0 01-2 2H3a2 2 0 01-2-2V5a2 2 0 012-2zm0 2v14h18V5H3zm2 2l4 4-4 4V7zm6 6h8v2h-8v-2z" />
</svg>
);
}
/**
* macOS Terminal logo icon
*/
export function MacOSTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 4a2 2 0 00-2 2v12a2 2 0 002 2h18a2 2 0 002-2V6a2 2 0 00-2-2H3zm0 2h18v12H3V6zm2 2l5 4-5 4V8zm7 6h7v2h-7v-2z" />
</svg>
);
}
/**
* Get the appropriate icon component for a terminal ID
*/
export function getTerminalIcon(terminalId: string): IconComponent {
const terminalIcons: Record<string, IconComponent> = {
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;
}

View File

@@ -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({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem onClick={() => onOpenInTerminal(worktree)} className="text-xs">
<Terminal className="w-3.5 h-3.5 mr-2" />
Open in Terminal
</DropdownMenuItem>
{/* Open in terminal - always show with integrated + external options */}
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - opens in default terminal (integrated or external) */}
<DropdownMenuItem
onClick={() => {
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"
>
<DefaultTerminalIcon className="w-3.5 h-3.5 mr-2" />
Open in {effectiveDefaultTerminal?.name ?? 'Terminal'}
</DropdownMenuItem>
{/* Chevron trigger for submenu with all terminals */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{/* Automaker Terminal - with submenu for new tab vs split */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<Terminal className="w-3.5 h-3.5 mr-2" />
Terminal
{!effectiveDefaultTerminal && (
<span className="ml-auto mr-2 text-[10px] text-muted-foreground">(default)</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => onOpenInIntegratedTerminal(worktree, 'tab')}
className="text-xs"
>
<SquarePlus className="w-3.5 h-3.5 mr-2" />
New Tab
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onOpenInIntegratedTerminal(worktree, 'split')}
className="text-xs"
>
<SplitSquareHorizontal className="w-3.5 h-3.5 mr-2" />
Split
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* External terminals */}
{terminals.length > 0 && <DropdownMenuSeparator />}
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
const isDefault = terminal.id === effectiveDefaultTerminal?.id;
return (
<DropdownMenuItem
key={terminal.id}
onClick={() => onOpenInExternalTerminal(worktree, terminal.id)}
className="text-xs"
>
<TerminalIcon className="w-3.5 h-3.5 mr-2" />
{terminal.name}
{isDefault && (
<span className="ml-auto text-[10px] text-muted-foreground">(default)</span>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />

View File

@@ -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}

View File

@@ -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<TerminalInfo[]>([]);
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]);
}

View File

@@ -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,
};
}

View File

@@ -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}

View File

@@ -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 (
<div
className={cn(
@@ -60,6 +74,102 @@ export function TerminalSection() {
</p>
</div>
<div className="p-6 space-y-6">
{/* Default External Terminal */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default External Terminal</Label>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={refresh}
disabled={isRefreshing}
title="Refresh available terminals"
>
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Terminal to use when selecting "Open in Terminal" from the worktree menu
</p>
<Select
value={defaultTerminalId ?? 'integrated'}
onValueChange={(value) => {
setDefaultTerminalId(value === 'integrated' ? null : value);
toast.success(
value === 'integrated'
? 'Integrated terminal set as default'
: 'Default terminal changed'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a terminal" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integrated">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Integrated Terminal
</span>
</SelectItem>
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
return (
<SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
<TerminalIcon className="w-4 h-4" />
{terminal.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{terminals.length === 0 && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</p>
)}
</div>
{/* Default Open Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Open Mode</Label>
<p className="text-xs text-muted-foreground">
How to open the integrated terminal when using "Open in Terminal" from the worktree menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => {
setOpenTerminalMode(value);
toast.success(
value === 'newTab'
? 'New terminals will open in new tabs'
: 'New terminals will split the current tab'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
@@ -167,26 +277,6 @@ export function TerminalSection() {
/>
</div>
{/* Open in Terminal Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Open in Terminal Mode</Label>
<p className="text-xs text-muted-foreground">
How to open terminals from the "Open in Terminal" action in the worktree menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => setOpenTerminalMode(value)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">New Tab</SelectItem>
<SelectItem value="split">Split Current Tab</SelectItem>
</SelectContent>
</Select>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">

View File

@@ -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<TerminalStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -265,6 +276,7 @@ export function TerminalView() {
max: number;
} | null>(null);
const hasShownHighRamWarningRef = useRef<boolean>(false);
const initialCwdHandledRef = useRef<string | null>(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<string | null>(null);
const pendingTerminalCreatedRef = useRef<boolean>(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<string, string> = {};
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();

View File

@@ -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<boolean> {
},
mcpServers: serverSettings.mcpServers,
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
defaultTerminalId: serverSettings.defaultTerminalId ?? null,
promptCustomization: serverSettings.promptCustomization ?? {},
projects: serverSettings.projects,
trashedProjects: serverSettings.trashedProjects,

View File

@@ -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 {

View File

@@ -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 }),

View File

@@ -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 <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} />;
}

View File

@@ -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<void>;
@@ -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<AppState & AppActions>()((set, get) => ({
// Editor Configuration actions
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
// Terminal Configuration actions
setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }),
// Prompt Customization actions
setPromptCustomization: async (customization) => {
set({ promptCustomization: customization });
@@ -3254,7 +3264,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
});
},
addTerminalToTab: (sessionId, tabId, direction = 'horizontal') => {
addTerminalToTab: (sessionId, tabId, direction = 'horizontal', branchName) => {
const current = get().terminalState;
const tab = current.tabs.find((t) => t.id === tabId);
if (!tab) return;
@@ -3263,11 +3273,12 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
type: 'terminal',
sessionId,
size: 50,
branchName,
};
let newLayout: TerminalPanelContent;
if (!tab.layout) {
newLayout = { type: 'terminal', sessionId, size: 100 };
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
} else if (tab.layout.type === 'terminal') {
newLayout = {
type: 'split',

View File

@@ -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;

View File

@@ -177,3 +177,12 @@ export {
openInFileManager,
openInTerminal,
} from './editor.js';
// External terminal detection and launching
export {
clearTerminalCache,
detectAllTerminals,
detectDefaultTerminal,
findTerminalById,
openInExternalTerminal,
} from './terminal.js';

View File

@@ -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<boolean> {
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<string | null> {
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<boolean> {
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<TerminalInfo | null> {
// 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<TerminalInfo[]> {
// 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<TerminalInfo | null> {
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<TerminalInfo | null> {
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<void> {
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<void> {
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, "'\\''")}'`;
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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;
}

9
package-lock.json generated
View File

@@ -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"
},