diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 4b54ae9e2..854e5c60c 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -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( diff --git a/apps/server/src/routes/worktree/routes/open-in-terminal.ts b/apps/server/src/routes/worktree/routes/open-in-terminal.ts new file mode 100644 index 000000000..9b13101e3 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -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 => { + 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 => { + try { + const terminals = await detectAllTerminals(); + res.json({ + success: true, + result: { + terminals, + }, + }); + } catch (error) { + logError(error, 'Get available terminals failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to get the default external terminal + */ +export function createGetDefaultTerminalHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const terminal = await detectDefaultTerminal(); + res.json({ + success: true, + result: terminal + ? { + terminalId: terminal.id, + terminalName: terminal.name, + terminalCommand: terminal.command, + } + : null, + }); + } catch (error) { + logError(error, 'Get default terminal failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to refresh the terminal cache and re-detect available terminals + * Useful when the user has installed/uninstalled terminals + */ +export function createRefreshTerminalsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Clear the cache + clearTerminalCache(); + + // Re-detect terminals (this will repopulate the cache) + const terminals = await detectAllTerminals(); + + logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`); + + res.json({ + success: true, + result: { + terminals, + message: `Found ${terminals.length} available external terminals`, + }, + }); + } catch (error) { + logError(error, 'Refresh terminals failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to open a directory in an external terminal + */ +export function createOpenInExternalTerminalHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, terminalId } = req.body as { + worktreePath: string; + terminalId?: string; + }; + + if (!worktreePath || 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) }); + } + }; +} diff --git a/apps/ui/src/components/icons/terminal-icons.tsx b/apps/ui/src/components/icons/terminal-icons.tsx new file mode 100644 index 000000000..38e8a47d9 --- /dev/null +++ b/apps/ui/src/components/icons/terminal-icons.tsx @@ -0,0 +1,213 @@ +import type { ComponentType, ComponentProps } from 'react'; +import { Terminal } from 'lucide-react'; + +type IconProps = ComponentProps<'svg'>; +type IconComponent = ComponentType; + +/** + * iTerm2 logo icon + */ +export function ITerm2Icon(props: IconProps) { + return ( + + + + ); +} + +/** + * Warp terminal logo icon + */ +export function WarpIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Ghostty terminal logo icon + */ +export function GhosttyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Alacritty terminal logo icon + */ +export function AlacrittyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * WezTerm terminal logo icon + */ +export function WezTermIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Kitty terminal logo icon + */ +export function KittyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Hyper terminal logo icon + */ +export function HyperIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Tabby terminal logo icon + */ +export function TabbyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Rio terminal logo icon + */ +export function RioIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Windows Terminal logo icon + */ +export function WindowsTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * PowerShell logo icon + */ +export function PowerShellIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Command Prompt (cmd) logo icon + */ +export function CmdIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Git Bash logo icon + */ +export function GitBashIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * GNOME Terminal logo icon + */ +export function GnomeTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Konsole logo icon + */ +export function KonsoleIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * macOS Terminal logo icon + */ +export function MacOSTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Get the appropriate icon component for a terminal ID + */ +export function getTerminalIcon(terminalId: string): IconComponent { + const terminalIcons: Record = { + iterm2: ITerm2Icon, + warp: WarpIcon, + ghostty: GhosttyIcon, + alacritty: AlacrittyIcon, + wezterm: WezTermIcon, + kitty: KittyIcon, + hyper: HyperIcon, + tabby: TabbyIcon, + rio: RioIcon, + 'windows-terminal': WindowsTerminalIcon, + powershell: PowerShellIcon, + cmd: CmdIcon, + 'git-bash': GitBashIcon, + 'gnome-terminal': GnomeTerminalIcon, + konsole: KonsoleIcon, + 'terminal-macos': MacOSTerminalIcon, + // Linux terminals - use generic terminal icon + 'xfce4-terminal': Terminal, + tilix: Terminal, + terminator: Terminal, + foot: Terminal, + xterm: Terminal, + }; + + return terminalIcons[terminalId] ?? Terminal; +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 459e2ce81..41041315b 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -26,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({ )} + {/* Open in terminal - always show with integrated + external options */} + +
+ {/* Main clickable area - opens in default terminal (integrated or external) */} + { + if (effectiveDefaultTerminal) { + // External terminal is the default + onOpenInExternalTerminal(worktree, effectiveDefaultTerminal.id); + } else { + // Integrated terminal is the default - use user's preferred mode + const mode = openTerminalMode === 'newTab' ? 'tab' : 'split'; + onOpenInIntegratedTerminal(worktree, mode); + } + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Open in {effectiveDefaultTerminal?.name ?? 'Terminal'} + + {/* Chevron trigger for submenu with all terminals */} + +
+ + {/* Automaker Terminal - with submenu for new tab vs split */} + + + + Terminal + {!effectiveDefaultTerminal && ( + (default) + )} + + + onOpenInIntegratedTerminal(worktree, 'tab')} + className="text-xs" + > + + New Tab + + onOpenInIntegratedTerminal(worktree, 'split')} + className="text-xs" + > + + Split + + + + {/* External terminals */} + {terminals.length > 0 && } + {terminals.map((terminal) => { + const TerminalIcon = getTerminalIcon(terminal.id); + const isDefault = terminal.id === effectiveDefaultTerminal?.id; + return ( + onOpenInExternalTerminal(worktree, terminal.id)} + className="text-xs" + > + + {terminal.name} + {isDefault && ( + (default) + )} + + ); + })} + +
{!worktree.isMain && hasInitScript && ( onRunInitScript(worktree)} className="text-xs"> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 212e6d89f..564783853 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -38,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} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts new file mode 100644 index 000000000..b719183d9 --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts @@ -0,0 +1,99 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import type { TerminalInfo } from '@automaker/types'; + +const logger = createLogger('AvailableTerminals'); + +// Re-export TerminalInfo for convenience +export type { TerminalInfo }; + +export function useAvailableTerminals() { + const [terminals, setTerminals] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + + const fetchAvailableTerminals = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.getAvailableTerminals) { + setIsLoading(false); + return; + } + const result = await api.worktree.getAvailableTerminals(); + if (result.success && result.result?.terminals) { + setTerminals(result.result.terminals); + } + } catch (error) { + logger.error('Failed to fetch available terminals:', error); + } finally { + setIsLoading(false); + } + }, []); + + /** + * Refresh terminals by clearing the server cache and re-detecting + * Use this when the user has installed/uninstalled terminals + */ + const refresh = useCallback(async () => { + setIsRefreshing(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.refreshTerminals) { + // Fallback to regular fetch if refresh not available + await fetchAvailableTerminals(); + return; + } + const result = await api.worktree.refreshTerminals(); + if (result.success && result.result?.terminals) { + setTerminals(result.result.terminals); + logger.info(`Terminal cache refreshed, found ${result.result.terminals.length} terminals`); + } + } catch (error) { + logger.error('Failed to refresh terminals:', error); + } finally { + setIsRefreshing(false); + } + }, [fetchAvailableTerminals]); + + useEffect(() => { + fetchAvailableTerminals(); + }, [fetchAvailableTerminals]); + + return { + terminals, + isLoading, + isRefreshing, + refresh, + // Convenience property: has external terminals available + hasExternalTerminals: terminals.length > 0, + // The first terminal is the "default" one (highest priority) + defaultTerminal: terminals[0] ?? null, + }; +} + +/** + * Hook to get the effective default terminal based on user settings + * Returns null if user prefers integrated terminal (defaultTerminalId is null) + * Falls back to: user preference > first available external terminal + */ +export function useEffectiveDefaultTerminal(terminals: TerminalInfo[]): TerminalInfo | null { + const defaultTerminalId = useAppStore((s) => s.defaultTerminalId); + + return useMemo(() => { + // If user hasn't set a preference (null/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]); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index f1f245dca..8e7f6e4e9 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -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, }; } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index fbd54d737..1c05eb7b4 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -79,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} diff --git a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx index f1cebb10c..eb81e8476 100644 --- a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx +++ b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx @@ -2,6 +2,7 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Slider } from '@/components/ui/slider'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { Select, SelectContent, @@ -9,12 +10,20 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { SquareTerminal } from 'lucide-react'; +import { + SquareTerminal, + RefreshCw, + Terminal, + SquarePlus, + SplitSquareHorizontal, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes'; import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options'; +import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals'; +import { getTerminalIcon } from '@/components/icons/terminal-icons'; export function TerminalSection() { const { @@ -25,6 +34,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 (
+ {/* Default External Terminal */} +
+
+ + +
+

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

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

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

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

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

+ +
+ {/* Font Family */}
diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 0287ca682..df01e59f1 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { createLogger } from '@automaker/utils/logger'; import { Terminal as TerminalIcon, @@ -216,7 +217,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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -264,6 +278,7 @@ export function TerminalView() { max: number; } | null>(null); const hasShownHighRamWarningRef = useRef(false); + const initialCwdHandledRef = useRef(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 = {}; + 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} /> ); diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 88f02591e..ce6359c88 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -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(null); const containerRef = useRef(null); @@ -1776,6 +1779,13 @@ export function TerminalPanel({
{shellName} + {/* Branch name indicator - show when terminal was opened from worktree */} + {branchName && ( + + + {branchName} + + )} {/* Font size indicator - only show when not default */} {fontSize !== DEFAULT_FONT_SIZE && (