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