mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: add external terminal support with cross-platform detection (#565)
* feat(platform): add cross-platform openInTerminal utility
Add utility function to open a terminal in a specified directory:
- macOS: Uses Terminal.app via AppleScript
- Windows: Tries Windows Terminal, falls back to cmd
- Linux: Tries common terminal emulators (gnome-terminal,
konsole, xfce4-terminal, xterm, x-terminal-emulator)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(server): add open-in-terminal endpoint
Add POST /open-in-terminal endpoint to open a system terminal in the
worktree directory using the cross-platform openInTerminal utility.
The endpoint validates that worktreePath is provided and is an
absolute path for security.
Extracted from PR #558.
* feat(ui): add Open in Terminal action to worktree dropdown
Add "Open in Terminal" option to the worktree actions dropdown menu.
This opens the system terminal in the worktree directory.
Changes:
- Add openInTerminal method to http-api-client
- Add Terminal icon and menu item to worktree-actions-dropdown
- Add onOpenInTerminal prop to WorktreeTab component
- Add handleOpenInTerminal handler to use-worktree-actions hook
- Wire up handler in worktree-panel for both mobile and desktop views
Extracted from PR #558.
* fix(ui): open in terminal navigates to Automaker terminal view
Instead of opening the system terminal, the "Open in Terminal" action
now opens Automaker's built-in terminal with the worktree directory:
- Add pendingTerminalCwd state to app store
- Update use-worktree-actions to set pending cwd and navigate to /terminal
- Add effect in terminal-view to create session with pending cwd
This matches the original PR #558 behavior.
* feat(ui): add terminal open mode setting (new tab vs split)
Add a setting to choose how "Open in Terminal" behaves:
- New Tab: Creates a new tab named after the branch (default)
- Split: Adds to current tab as a split view
Changes:
- Add openTerminalMode setting to terminal state ('newTab' | 'split')
- Update terminal-view to respect the setting
- Add UI in Terminal Settings to toggle the behavior
- Rename pendingTerminalCwd to pendingTerminal with branch name
The new tab mode names tabs after the branch for easy identification.
The split mode is useful for comparing terminals side by side.
* feat(ui): display branch name in terminal header with git icon
- Move branch name display from tab name to terminal header
- Show full branch name (no truncation) with GitBranch icon
- Display branch name for both 'new tab' and 'split' modes
- Persist openTerminalMode setting to server and include in import/export
- Update settings dropdown to simplified "New Tab" label
* feat: add external terminal support with cross-platform detection
Add support for opening worktree directories in external terminals
(iTerm2, Warp, Ghostty, System Terminal, etc.) while retaining the
integrated terminal as the default option.
Changes:
- Add terminal detection for macOS, Windows, and Linux
- Add "Open in Terminal" split-button in worktree dropdown
- Add external terminal selection in Settings > Terminal
- Add default open mode setting (new tab vs split)
- Display branch name in terminal panel header
- Support 20+ terminals across platforms
Part of #558, Closes #550
* fix: address PR review comments
- Add nonce parameter to terminal navigation to allow reopening same
worktree multiple times
- Fix shell path escaping in editor.ts using single-quote wrapper
- Add validatePathParams middleware to open-in-external-terminal route
- Remove redundant validation block from createOpenInExternalTerminalHandler
- Remove unused pendingTerminal state and setPendingTerminal action
- Remove unused getTerminalInfo function from editor.ts
* fix: address PR review security and validation issues
- Add runtime type check for worktreePath in open-in-terminal handler
- Fix Windows Terminal detection using commandExists before spawn
- Fix xterm shell injection by using sh -c with escapeShellArg
- Use loose equality for null/undefined in useEffectiveDefaultTerminal
- Consolidate duplicate imports from open-in-terminal.js
* chore: update package-lock.json
* fix: use response.json() to prevent disposal race condition in E2E test
Replace response.body() with response.json() in open-existing-project.spec.ts
to fix the "Response has been disposed" error. This matches the pattern used
in other test files.
* Revert "fix: use response.json() to prevent disposal race condition in E2E test"
This reverts commit 36bdf8c24a.
* fix: address PR review feedback for terminal feature
- Add explicit validation for worktreePath in createOpenInExternalTerminalHandler
- Add aria-label to refresh button in terminal settings for accessibility
- Only show "no terminals" message when not refreshing
- Reset initialCwdHandledRef on failure to allow retries
- Use z.coerce.number() for nonce URL param to handle string coercion
- Preserve branchName when creating layout for empty tab
- Update getDefaultTerminal return type to allow null result
---------
Co-authored-by: Kacper <kacperlachowiczwp.pl@wp.pl>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
e73c92b031
commit
a52c0461e5
@@ -29,6 +29,13 @@ import {
|
||||
createGetAvailableEditorsHandler,
|
||||
createRefreshEditorsHandler,
|
||||
} from './routes/open-in-editor.js';
|
||||
import {
|
||||
createOpenInTerminalHandler,
|
||||
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';
|
||||
@@ -97,9 +104,25 @@ export function createWorktreeRoutes(
|
||||
);
|
||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||
router.post(
|
||||
'/open-in-terminal',
|
||||
validatePathParams('worktreePath'),
|
||||
createOpenInTerminalHandler()
|
||||
);
|
||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||
router.get('/available-editors', createGetAvailableEditorsHandler());
|
||||
router.post('/refresh-editors', createRefreshEditorsHandler());
|
||||
|
||||
// External terminal routes
|
||||
router.get('/available-terminals', createGetAvailableTerminalsHandler());
|
||||
router.get('/default-terminal', createGetDefaultTerminalHandler());
|
||||
router.post('/refresh-terminals', createRefreshTerminalsHandler());
|
||||
router.post(
|
||||
'/open-in-external-terminal',
|
||||
validatePathParams('worktreePath'),
|
||||
createOpenInExternalTerminalHandler()
|
||||
);
|
||||
|
||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||
router.post('/migrate', createMigrateHandler());
|
||||
router.post(
|
||||
|
||||
181
apps/server/src/routes/worktree/routes/open-in-terminal.ts
Normal file
181
apps/server/src/routes/worktree/routes/open-in-terminal.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Terminal endpoints for opening worktree directories in terminals
|
||||
*
|
||||
* 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,
|
||||
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 {
|
||||
const { worktreePath } = req.body as {
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!worktreePath || typeof worktreePath !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: Validate that worktreePath is an absolute path
|
||||
if (!isAbsolute(worktreePath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath must be an absolute path',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the platform utility to open in terminal
|
||||
const result = await openInTerminal(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened terminal in ${worktreePath}`,
|
||||
terminalName: result.terminalName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Open in terminal failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || typeof worktreePath !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAbsolute(worktreePath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath must be an absolute path',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await openInExternalTerminal(worktreePath, terminalId);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${result.terminalName}`,
|
||||
terminalName: result.terminalName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Open in external terminal failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
213
apps/ui/src/components/icons/terminal-icons.tsx
Normal file
213
apps/ui/src/components/icons/terminal-icons.tsx
Normal 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;
|
||||
}
|
||||
@@ -26,13 +26,22 @@ import {
|
||||
RefreshCw,
|
||||
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;
|
||||
@@ -51,6 +60,8 @@ interface WorktreeActionsDropdownProps {
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => 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;
|
||||
@@ -81,6 +92,8 @@ export function WorktreeActionsDropdown({
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -108,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;
|
||||
|
||||
@@ -303,6 +330,77 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{/* 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" />
|
||||
|
||||
@@ -38,6 +38,8 @@ interface WorktreeTabProps {
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => 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;
|
||||
@@ -82,6 +84,8 @@ export function WorktreeTab({
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -343,6 +347,8 @@ export function WorktreeTab({
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
|
||||
@@ -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/undefined), 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]);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
@@ -35,6 +36,7 @@ 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);
|
||||
@@ -125,6 +127,19 @@ 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
|
||||
// Include nonce to allow opening the same worktree multiple times
|
||||
navigate({
|
||||
to: '/terminal',
|
||||
search: { cwd: worktree.path, branch: worktree.branch, mode, nonce: Date.now() },
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -143,6 +158,27 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
isPulling,
|
||||
isPushing,
|
||||
@@ -152,6 +188,8 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +79,9 @@ export function WorktreePanel({
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
} = useWorktreeActions({
|
||||
fetchWorktrees,
|
||||
fetchBranches,
|
||||
@@ -246,6 +248,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -333,6 +337,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -391,6 +397,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
|
||||
@@ -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,9 @@ export function TerminalSection() {
|
||||
setTerminalScrollbackLines,
|
||||
setTerminalLineHeight,
|
||||
setTerminalDefaultFontSize,
|
||||
defaultTerminalId,
|
||||
setDefaultTerminalId,
|
||||
setOpenTerminalMode,
|
||||
} = useAppStore();
|
||||
|
||||
const {
|
||||
@@ -34,8 +46,12 @@ export function TerminalSection() {
|
||||
scrollbackLines,
|
||||
lineHeight,
|
||||
defaultFontSize,
|
||||
openTerminalMode,
|
||||
} = terminalState;
|
||||
|
||||
// Get available external terminals
|
||||
const { terminals, isRefreshing, refresh } = useAvailableTerminals();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -58,6 +74,103 @@ 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"
|
||||
aria-label="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 && !isRefreshing && (
|
||||
<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>
|
||||
|
||||
@@ -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,18 @@ 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';
|
||||
/** Unique nonce to allow opening the same worktree multiple times */
|
||||
nonce?: number;
|
||||
}
|
||||
|
||||
export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) {
|
||||
const {
|
||||
terminalState,
|
||||
setTerminalUnlocked,
|
||||
@@ -246,6 +258,8 @@ export function TerminalView() {
|
||||
updateTerminalPanelSizes,
|
||||
} = useAppStore();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState<TerminalStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -264,6 +278,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(() => {
|
||||
@@ -537,6 +552,106 @@ export function TerminalView() {
|
||||
}
|
||||
}, [terminalState.isUnlocked, fetchServerSettings]);
|
||||
|
||||
// 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(() => {
|
||||
// Skip if no initialCwd provided
|
||||
if (!initialCwd) return;
|
||||
|
||||
// Skip if we've already handled this exact request (prevents duplicate terminals)
|
||||
// Include mode and nonce in the key to allow opening same cwd multiple times
|
||||
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`;
|
||||
if (initialCwdHandledRef.current === cwdKey) return;
|
||||
|
||||
// Skip if terminal is not enabled or not unlocked
|
||||
if (!status?.enabled) return;
|
||||
if (status.passwordRequired && !terminalState.isUnlocked) return;
|
||||
|
||||
// Skip if still loading
|
||||
if (loading) return;
|
||||
|
||||
// Mark this cwd as being handled
|
||||
initialCwdHandledRef.current = cwdKey;
|
||||
|
||||
// Create the terminal with the specified cwd
|
||||
const createTerminalWithCwd = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await apiFetch('/api/terminal/sessions', 'POST', {
|
||||
headers,
|
||||
body: { cwd: initialCwd, cols: 80, rows: 24 },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// 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();
|
||||
const { addTerminalToTab } = useAppStore.getState();
|
||||
// Pass branch name for display in terminal panel header
|
||||
addTerminalToTab(data.data.id, newTabId, 'horizontal', initialBranch);
|
||||
} else {
|
||||
// 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 cwd from the URL to prevent re-creating on refresh
|
||||
navigate({ to: '/terminal', search: {}, replace: true });
|
||||
} else {
|
||||
logger.error('Failed to create terminal for cwd:', data.error);
|
||||
toast.error('Failed to create terminal', {
|
||||
description: data.error || 'Unknown error',
|
||||
});
|
||||
// Reset the handled ref so the same cwd can be retried
|
||||
initialCwdHandledRef.current = undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Create terminal with cwd error:', err);
|
||||
toast.error('Failed to create terminal', {
|
||||
description: 'Could not connect to server',
|
||||
});
|
||||
// Reset the handled ref so the same cwd can be retried
|
||||
initialCwdHandledRef.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
createTerminalWithCwd();
|
||||
}, [
|
||||
initialCwd,
|
||||
initialBranch,
|
||||
initialMode,
|
||||
nonce,
|
||||
status?.enabled,
|
||||
status?.passwordRequired,
|
||||
terminalState.isUnlocked,
|
||||
terminalState.authToken,
|
||||
terminalState.tabs.length,
|
||||
loading,
|
||||
defaultRunScript,
|
||||
addTerminalToLayout,
|
||||
addTerminalTab,
|
||||
fetchServerSettings,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Handle project switching - save and restore terminal layouts
|
||||
// Uses terminalState.lastActiveProjectPath (persisted in store) instead of a local ref
|
||||
// This ensures terminals persist when navigating away from terminal route and back
|
||||
@@ -828,9 +943,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;
|
||||
@@ -844,7 +961,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();
|
||||
|
||||
@@ -1232,6 +1349,7 @@ export function TerminalView() {
|
||||
onCommandRan={() => handleCommandRan(content.sessionId)}
|
||||
isMaximized={terminalState.maximizedSessionId === content.sessionId}
|
||||
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
|
||||
branchName={content.branchName}
|
||||
/>
|
||||
</TerminalErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ArrowDown,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
@@ -94,6 +95,7 @@ interface TerminalPanelProps {
|
||||
onCommandRan?: () => void; // Callback when the initial command has been sent
|
||||
isMaximized?: boolean;
|
||||
onToggleMaximize?: () => void;
|
||||
branchName?: string; // Branch name to display in header (from "Open in Terminal" action)
|
||||
}
|
||||
|
||||
// Type for xterm Terminal - we'll use any since we're dynamically importing
|
||||
@@ -124,6 +126,7 @@ export function TerminalPanel({
|
||||
onCommandRan,
|
||||
isMaximized = false,
|
||||
onToggleMaximize,
|
||||
branchName,
|
||||
}: TerminalPanelProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -1776,6 +1779,13 @@ export function TerminalPanel({
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<Terminal className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs truncate text-foreground">{shellName}</span>
|
||||
{/* Branch name indicator - show when terminal was opened from worktree */}
|
||||
{branchName && (
|
||||
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-brand-500/10 text-brand-500 shrink-0">
|
||||
<GitBranch className="h-2.5 w-2.5 shrink-0" />
|
||||
<span>{branchName}</span>
|
||||
</span>
|
||||
)}
|
||||
{/* Font size indicator - only show when not default */}
|
||||
{fontSize !== DEFAULT_FONT_SIZE && (
|
||||
<button
|
||||
|
||||
@@ -42,6 +42,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'fontFamilySans',
|
||||
'fontFamilyMono',
|
||||
'terminalFontFamily', // Maps to terminalState.fontFamily
|
||||
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
||||
'sidebarOpen',
|
||||
'chatHistoryOpen',
|
||||
'maxConcurrency',
|
||||
@@ -68,6 +69,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'keyboardShortcuts',
|
||||
'mcpServers',
|
||||
'defaultEditorCommand',
|
||||
'defaultTerminalId',
|
||||
'promptCustomization',
|
||||
'eventHooks',
|
||||
'projects',
|
||||
@@ -107,6 +109,9 @@ function getSettingsFieldValue(
|
||||
if (field === 'terminalFontFamily') {
|
||||
return appState.terminalState.fontFamily;
|
||||
}
|
||||
if (field === 'openTerminalMode') {
|
||||
return appState.terminalState.openTerminalMode;
|
||||
}
|
||||
return appState[field as keyof typeof appState];
|
||||
}
|
||||
|
||||
@@ -134,6 +139,9 @@ function hasSettingsFieldChanged(
|
||||
if (field === 'terminalFontFamily') {
|
||||
return newState.terminalState.fontFamily !== prevState.terminalState.fontFamily;
|
||||
}
|
||||
if (field === 'openTerminalMode') {
|
||||
return newState.terminalState.openTerminalMode !== prevState.terminalState.openTerminalMode;
|
||||
}
|
||||
const key = field as keyof typeof newState;
|
||||
return newState[key] !== prevState[key];
|
||||
}
|
||||
@@ -618,6 +626,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,
|
||||
@@ -628,11 +637,16 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
||||
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
||||
recentFolders: serverSettings.recentFolders ?? [],
|
||||
// Terminal font (nested in terminalState)
|
||||
...(serverSettings.terminalFontFamily && {
|
||||
// Terminal settings (nested in terminalState)
|
||||
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
|
||||
terminalState: {
|
||||
...currentAppState.terminalState,
|
||||
fontFamily: serverSettings.terminalFontFamily,
|
||||
...(serverSettings.terminalFontFamily && {
|
||||
fontFamily: serverSettings.terminalFontFamily,
|
||||
}),
|
||||
...(serverSettings.openTerminalMode && {
|
||||
openTerminalMode: serverSettings.openTerminalMode,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1854,6 +1854,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 {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
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(),
|
||||
nonce: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/terminal')({
|
||||
component: TerminalView,
|
||||
validateSearch: terminalSearchSchema,
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { cwd, branch, mode, nonce } = Route.useSearch();
|
||||
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
|
||||
}
|
||||
|
||||
@@ -500,7 +500,7 @@ export interface ProjectAnalysis {
|
||||
|
||||
// Terminal panel layout types (recursive for splits)
|
||||
export type TerminalPanelContent =
|
||||
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number }
|
||||
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
|
||||
| {
|
||||
type: 'split';
|
||||
id: string; // Stable ID for React key stability
|
||||
@@ -531,12 +531,13 @@ export interface TerminalState {
|
||||
lineHeight: number; // Line height multiplier for terminal text
|
||||
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
|
||||
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
|
||||
openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
|
||||
}
|
||||
|
||||
// Persisted terminal layout - now includes sessionIds for reconnection
|
||||
// Used to restore terminal layout structure when switching projects
|
||||
export type PersistedTerminalPanel =
|
||||
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string }
|
||||
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
|
||||
| {
|
||||
type: 'split';
|
||||
id?: string; // Optional for backwards compatibility with older persisted layouts
|
||||
@@ -574,6 +575,7 @@ export interface PersistedTerminalSettings {
|
||||
scrollbackLines: number;
|
||||
lineHeight: number;
|
||||
maxSessions: number;
|
||||
openTerminalMode: 'newTab' | 'split';
|
||||
}
|
||||
|
||||
/** State for worktree init script execution */
|
||||
@@ -728,6 +730,9 @@ export interface AppState {
|
||||
// Editor Configuration
|
||||
defaultEditorCommand: string | null; // Default editor for "Open In" action
|
||||
|
||||
// Terminal Configuration
|
||||
defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated)
|
||||
|
||||
// Skills Configuration
|
||||
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
||||
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
|
||||
@@ -1166,6 +1171,9 @@ export interface AppActions {
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command: string | null) => void;
|
||||
|
||||
// Terminal Configuration actions
|
||||
setDefaultTerminalId: (terminalId: string | null) => void;
|
||||
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
||||
|
||||
@@ -1215,7 +1223,8 @@ export interface AppActions {
|
||||
addTerminalToLayout: (
|
||||
sessionId: string,
|
||||
direction?: 'horizontal' | 'vertical',
|
||||
targetSessionId?: string
|
||||
targetSessionId?: string,
|
||||
branchName?: string
|
||||
) => void;
|
||||
removeTerminalFromLayout: (sessionId: string) => void;
|
||||
swapTerminals: (sessionId1: string, sessionId2: string) => void;
|
||||
@@ -1229,6 +1238,7 @@ export interface AppActions {
|
||||
setTerminalLineHeight: (lineHeight: number) => void;
|
||||
setTerminalMaxSessions: (maxSessions: number) => void;
|
||||
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
|
||||
setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
|
||||
addTerminalTab: (name?: string) => string;
|
||||
removeTerminalTab: (tabId: string) => void;
|
||||
setActiveTerminalTab: (tabId: string) => void;
|
||||
@@ -1238,7 +1248,8 @@ export interface AppActions {
|
||||
addTerminalToTab: (
|
||||
sessionId: string,
|
||||
tabId: string,
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
direction?: 'horizontal' | 'vertical',
|
||||
branchName?: string
|
||||
) => void;
|
||||
setTerminalTabLayout: (
|
||||
tabId: string,
|
||||
@@ -1420,6 +1431,7 @@ const initialState: AppState = {
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
|
||||
defaultTerminalId: null, // Integrated terminal by default
|
||||
enableSkills: true, // Skills enabled by default
|
||||
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
||||
enableSubagents: true, // Subagents enabled by default
|
||||
@@ -1445,6 +1457,7 @@ const initialState: AppState = {
|
||||
lineHeight: 1.0,
|
||||
maxSessions: 100,
|
||||
lastActiveProjectPath: null,
|
||||
openTerminalMode: 'newTab',
|
||||
},
|
||||
terminalLayoutByProject: {},
|
||||
specCreatingForProject: null,
|
||||
@@ -2433,6 +2446,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
|
||||
// Terminal Configuration actions
|
||||
setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }),
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: async (customization) => {
|
||||
set({ promptCustomization: customization });
|
||||
@@ -2672,12 +2687,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
addTerminalToLayout: (sessionId, direction = 'horizontal', targetSessionId) => {
|
||||
addTerminalToLayout: (sessionId, direction = 'horizontal', targetSessionId, branchName) => {
|
||||
const current = get().terminalState;
|
||||
const newTerminal: TerminalPanelContent = {
|
||||
type: 'terminal',
|
||||
sessionId,
|
||||
size: 50,
|
||||
branchName,
|
||||
};
|
||||
|
||||
// If no tabs, create first tab
|
||||
@@ -2690,7 +2706,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
{
|
||||
id: newTabId,
|
||||
name: 'Terminal 1',
|
||||
layout: { type: 'terminal', sessionId, size: 100 },
|
||||
layout: { type: 'terminal', sessionId, size: 100, branchName },
|
||||
},
|
||||
],
|
||||
activeTabId: newTabId,
|
||||
@@ -2765,7 +2781,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
let newLayout: TerminalPanelContent;
|
||||
if (!activeTab.layout) {
|
||||
newLayout = { type: 'terminal', sessionId, size: 100 };
|
||||
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
|
||||
} else if (targetSessionId) {
|
||||
newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction);
|
||||
} else {
|
||||
@@ -2895,6 +2911,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
maxSessions: current.maxSessions,
|
||||
// Preserve lastActiveProjectPath - it will be updated separately when needed
|
||||
lastActiveProjectPath: current.lastActiveProjectPath,
|
||||
// Preserve openTerminalMode - user preference
|
||||
openTerminalMode: current.openTerminalMode,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -2986,6 +3004,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
setOpenTerminalMode: (mode) => {
|
||||
const current = get().terminalState;
|
||||
set({
|
||||
terminalState: { ...current, openTerminalMode: mode },
|
||||
});
|
||||
},
|
||||
|
||||
addTerminalTab: (name) => {
|
||||
const current = get().terminalState;
|
||||
const newTabId = `tab-${Date.now()}`;
|
||||
@@ -3228,7 +3253,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
addTerminalToTab: (sessionId, tabId, direction = 'horizontal') => {
|
||||
addTerminalToTab: (sessionId, tabId, direction = 'horizontal', branchName) => {
|
||||
const current = get().terminalState;
|
||||
const tab = current.tabs.find((t) => t.id === tabId);
|
||||
if (!tab) return;
|
||||
@@ -3237,11 +3262,12 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
type: 'terminal',
|
||||
sessionId,
|
||||
size: 50,
|
||||
branchName,
|
||||
};
|
||||
let newLayout: TerminalPanelContent;
|
||||
|
||||
if (!tab.layout) {
|
||||
newLayout = { type: 'terminal', sessionId, size: 100 };
|
||||
newLayout = { type: 'terminal', sessionId, size: 100, branchName };
|
||||
} else if (tab.layout.type === 'terminal') {
|
||||
newLayout = {
|
||||
type: 'split',
|
||||
@@ -3373,6 +3399,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
size: panel.size,
|
||||
fontSize: panel.fontSize,
|
||||
sessionId: panel.sessionId, // Preserve for reconnection
|
||||
branchName: panel.branchName, // Preserve branch name for display
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
52
apps/ui/src/types/electron.d.ts
vendored
52
apps/ui/src/types/electron.d.ts
vendored
@@ -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;
|
||||
} | null;
|
||||
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;
|
||||
|
||||
@@ -19,6 +19,15 @@ const execFileAsync = promisify(execFile);
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
/**
|
||||
* Escape a string for safe use in shell commands
|
||||
* Handles paths with spaces, special characters, etc.
|
||||
*/
|
||||
function escapeShellArg(arg: string): string {
|
||||
// Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string
|
||||
return `'${arg.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
// Cache with TTL for editor detection
|
||||
let cachedEditors: EditorInfo[] | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
@@ -341,3 +350,100 @@ export async function openInFileManager(targetPath: string): Promise<{ editorNam
|
||||
await execFileAsync(fileManager.command, [targetPath]);
|
||||
return { editorName: fileManager.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a terminal in the specified directory
|
||||
*
|
||||
* Handles cross-platform differences:
|
||||
* - On macOS, uses Terminal.app via 'open -a Terminal' or AppleScript for directory
|
||||
* - On Windows, uses Windows Terminal (wt) or falls back to cmd
|
||||
* - On Linux, uses x-terminal-emulator or common terminal emulators
|
||||
*
|
||||
* @param targetPath - The directory path to open the terminal in
|
||||
* @returns Promise that resolves with terminal info when launched, rejects on error
|
||||
*/
|
||||
export async function openInTerminal(targetPath: string): Promise<{ terminalName: string }> {
|
||||
if (isMac) {
|
||||
// Use AppleScript to open Terminal.app in the specified directory
|
||||
const script = `
|
||||
tell application "Terminal"
|
||||
do script "cd ${escapeShellArg(targetPath)}"
|
||||
activate
|
||||
end tell
|
||||
`;
|
||||
await execFileAsync('osascript', ['-e', script]);
|
||||
return { terminalName: 'Terminal' };
|
||||
} else if (isWindows) {
|
||||
// Try Windows Terminal first - check if it exists before trying to spawn
|
||||
const hasWindowsTerminal = await commandExists('wt');
|
||||
if (hasWindowsTerminal) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child: ChildProcess = spawn('wt', ['-d', targetPath], {
|
||||
shell: true,
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
child.unref();
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
setTimeout(() => resolve({ terminalName: 'Windows Terminal' }), 100);
|
||||
});
|
||||
}
|
||||
// Fall back to cmd
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child: ChildProcess = spawn(
|
||||
'cmd',
|
||||
['/c', 'start', 'cmd', '/k', `cd /d "${targetPath}"`],
|
||||
{
|
||||
shell: true,
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
child.unref();
|
||||
|
||||
child.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
setTimeout(() => resolve({ terminalName: 'Command Prompt' }), 100);
|
||||
});
|
||||
} else {
|
||||
// Linux: Try common terminal emulators in order
|
||||
const terminals = [
|
||||
{
|
||||
name: 'GNOME Terminal',
|
||||
command: 'gnome-terminal',
|
||||
args: ['--working-directory', targetPath],
|
||||
},
|
||||
{ name: 'Konsole', command: 'konsole', args: ['--workdir', targetPath] },
|
||||
{
|
||||
name: 'xfce4-terminal',
|
||||
command: 'xfce4-terminal',
|
||||
args: ['--working-directory', targetPath],
|
||||
},
|
||||
{
|
||||
name: 'xterm',
|
||||
command: 'xterm',
|
||||
args: ['-e', 'sh', '-c', `cd ${escapeShellArg(targetPath)} && $SHELL`],
|
||||
},
|
||||
{
|
||||
name: 'x-terminal-emulator',
|
||||
command: 'x-terminal-emulator',
|
||||
args: ['--working-directory', targetPath],
|
||||
},
|
||||
];
|
||||
|
||||
for (const terminal of terminals) {
|
||||
if (await commandExists(terminal.command)) {
|
||||
await execFileAsync(terminal.command, terminal.args);
|
||||
return { terminalName: terminal.name };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No terminal emulator found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,4 +175,14 @@ export {
|
||||
findEditorByCommand,
|
||||
openInEditor,
|
||||
openInFileManager,
|
||||
openInTerminal,
|
||||
} from './editor.js';
|
||||
|
||||
// External terminal detection and launching
|
||||
export {
|
||||
clearTerminalCache,
|
||||
detectAllTerminals,
|
||||
detectDefaultTerminal,
|
||||
findTerminalById,
|
||||
openInExternalTerminal,
|
||||
} from './terminal.js';
|
||||
|
||||
607
libs/platform/src/terminal.ts
Normal file
607
libs/platform/src/terminal.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* 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',
|
||||
'sh',
|
||||
'-c',
|
||||
`cd ${escapeShellArg(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, "'\\''")}'`;
|
||||
}
|
||||
@@ -311,3 +311,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';
|
||||
|
||||
@@ -475,6 +475,10 @@ export interface GlobalSettings {
|
||||
/** Terminal font family (undefined = use default Menlo/Monaco) */
|
||||
terminalFontFamily?: string;
|
||||
|
||||
// Terminal Configuration
|
||||
/** How to open terminals from "Open in Terminal" worktree action */
|
||||
openTerminalMode?: 'newTab' | 'split';
|
||||
|
||||
// UI State Preferences
|
||||
/** Whether sidebar is currently open */
|
||||
sidebarOpen: boolean;
|
||||
@@ -603,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;
|
||||
@@ -900,6 +908,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
codexThreadId: undefined,
|
||||
mcpServers: [],
|
||||
defaultEditorCommand: null,
|
||||
defaultTerminalId: null,
|
||||
enableSkills: true,
|
||||
skillsSources: ['user', 'project'],
|
||||
enableSubagents: true,
|
||||
|
||||
15
libs/types/src/terminal.ts
Normal file
15
libs/types/src/terminal.ts
Normal 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;
|
||||
}
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -6190,6 +6190,7 @@
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -6199,7 +6200,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -8410,6 +8411,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
@@ -11303,7 +11305,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11367,7 +11368,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user