diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 2a1815c5..1814f24f 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -1,6 +1,6 @@ import { ipcMain, dialog, app, shell } from 'electron'; -import { existsSync, writeFileSync, mkdirSync } from 'fs'; -import { execFileSync } from 'child_process'; +import { existsSync, writeFileSync, mkdirSync, statSync } from 'fs'; +import { execFileSync } from 'node:child_process'; import path from 'path'; import { is } from '@electron-toolkit/utils'; import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants'; @@ -345,4 +345,96 @@ export function registerSettingsHandlers( await shell.openExternal(url); } ); + + ipcMain.handle( + IPC_CHANNELS.SHELL_OPEN_TERMINAL, + async (_, dirPath: string): Promise> => { + try { + // Validate dirPath input + if (!dirPath || typeof dirPath !== 'string' || dirPath.trim() === '') { + return { + success: false, + error: 'Directory path is required and must be a non-empty string' + }; + } + + // Resolve to absolute path + const resolvedPath = path.resolve(dirPath); + + // Verify path exists + if (!existsSync(resolvedPath)) { + return { + success: false, + error: `Directory does not exist: ${resolvedPath}` + }; + } + + // Verify it's a directory + try { + if (!statSync(resolvedPath).isDirectory()) { + return { + success: false, + error: `Path is not a directory: ${resolvedPath}` + }; + } + } catch (statError) { + return { + success: false, + error: `Cannot access path: ${resolvedPath}` + }; + } + + const platform = process.platform; + + if (platform === 'darwin') { + // macOS: Use execFileSync with argument array to prevent injection + execFileSync('open', ['-a', 'Terminal', resolvedPath], { stdio: 'ignore' }); + } else if (platform === 'win32') { + // Windows: Use cmd.exe directly with argument array + // /C tells cmd to execute the command and terminate + // /K keeps the window open after executing cd + execFileSync('cmd.exe', ['/K', 'cd', '/d', resolvedPath], { + stdio: 'ignore', + windowsHide: false, + shell: false // Explicitly disable shell to prevent injection + }); + } else { + // Linux: Try common terminal emulators with argument arrays + const terminals: Array<{ cmd: string; args: string[] }> = [ + { cmd: 'gnome-terminal', args: ['--working-directory', resolvedPath] }, + { cmd: 'konsole', args: ['--workdir', resolvedPath] }, + { cmd: 'xfce4-terminal', args: ['--working-directory', resolvedPath] }, + { cmd: 'xterm', args: ['-e', 'bash', '-c', `cd '${resolvedPath.replace(/'/g, "'\\''")}' && exec bash`] } + ]; + + let opened = false; + for (const { cmd, args } of terminals) { + try { + execFileSync(cmd, args, { stdio: 'ignore' }); + opened = true; + break; + } catch { + // Try next terminal + continue; + } + } + + if (!opened) { + return { + success: false, + error: 'No supported terminal emulator found. Please install gnome-terminal, konsole, xfce4-terminal, or xterm.' + }; + } + } + + return { success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + return { + success: false, + error: `Failed to open terminal: ${errorMsg}` + }; + } + } + ); } diff --git a/apps/frontend/src/preload/api/modules/shell-api.ts b/apps/frontend/src/preload/api/modules/shell-api.ts index a5d4b4ea..1a395ffd 100644 --- a/apps/frontend/src/preload/api/modules/shell-api.ts +++ b/apps/frontend/src/preload/api/modules/shell-api.ts @@ -1,11 +1,13 @@ import { IPC_CHANNELS } from '../../../shared/constants'; import { invokeIpc } from './ipc-utils'; +import type { IPCResult } from '../../../shared/types'; /** * Shell Operations API */ export interface ShellAPI { openExternal: (url: string) => Promise; + openTerminal: (dirPath: string) => Promise>; } /** @@ -13,5 +15,7 @@ export interface ShellAPI { */ export const createShellAPI = (): ShellAPI => ({ openExternal: (url: string): Promise => - invokeIpc(IPC_CHANNELS.SHELL_OPEN_EXTERNAL, url) + invokeIpc(IPC_CHANNELS.SHELL_OPEN_EXTERNAL, url), + openTerminal: (dirPath: string): Promise> => + invokeIpc(IPC_CHANNELS.SHELL_OPEN_TERMINAL, dirPath) }); diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 0f070f8b..04a7ae03 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -391,6 +391,29 @@ export function App() { setSelectedTask(null); }; + const handleOpenInbuiltTerminal = (_id: string, cwd: string) => { + // Note: _id parameter is intentionally unused - terminal ID is auto-generated by addTerminal() + // Parameter kept for callback signature consistency with callers + console.log('[App] Opening inbuilt terminal:', { cwd }); + + // Switch to terminals view + setActiveView('terminals'); + + // Close modal + setSelectedTask(null); + + // Add terminal to store - this will trigger Terminal component to mount + // which will then create the backend PTY via usePtyProcess + // Note: TerminalGrid is always mounted (just hidden), so no need to wait + const terminal = useTerminalStore.getState().addTerminal(cwd, selectedProject?.path); + + if (!terminal) { + console.error('[App] Failed to add terminal to store (max terminals reached?)'); + } else { + console.log('[App] Terminal added to store:', terminal.id); + } + }; + const handleAddProject = async () => { try { const path = await window.electronAPI.selectDirectory(); @@ -716,6 +739,8 @@ export function App() { open={!!selectedTask} task={selectedTask} onOpenChange={(open) => !open && handleCloseTaskDetail()} + onSwitchToTerminals={() => setActiveView('terminals')} + onOpenInbuiltTerminal={handleOpenInbuiltTerminal} /> {/* Dialogs */} diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index b70166b3..6113454d 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -44,9 +44,11 @@ interface TaskDetailModalProps { open: boolean; task: Task | null; onOpenChange: (open: boolean) => void; + onSwitchToTerminals?: () => void; + onOpenInbuiltTerminal?: (id: string, cwd: string) => void; } -export function TaskDetailModal({ open, task, onOpenChange }: TaskDetailModalProps) { +export function TaskDetailModal({ open, task, onOpenChange, onSwitchToTerminals, onOpenInbuiltTerminal }: TaskDetailModalProps) { // Don't render anything if no task if (!task) { return null; @@ -57,12 +59,14 @@ export function TaskDetailModal({ open, task, onOpenChange }: TaskDetailModalPro open={open} task={task} onOpenChange={onOpenChange} + onSwitchToTerminals={onSwitchToTerminals} + onOpenInbuiltTerminal={onOpenInbuiltTerminal} /> ); } // Separate component to use hooks only when task exists -function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; task: Task; onOpenChange: (open: boolean) => void }) { +function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, onOpenInbuiltTerminal }: { open: boolean; task: Task; onOpenChange: (open: boolean) => void; onSwitchToTerminals?: () => void; onOpenInbuiltTerminal?: (id: string, cwd: string) => void }) { const state = useTaskDetail({ task }); const progressPercent = calculateProgress(task.subtasks); const completedSubtasks = task.subtasks.filter(s => s.status === 'completed').length; @@ -408,6 +412,8 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t onShowConflictDialog={state.setShowConflictDialog} onLoadMergePreview={state.loadMergePreview} onClose={handleClose} + onSwitchToTerminals={onSwitchToTerminals} + onOpenInbuiltTerminal={onOpenInbuiltTerminal} /> )} diff --git a/apps/frontend/src/renderer/components/task-detail/TaskReview.tsx b/apps/frontend/src/renderer/components/task-detail/TaskReview.tsx index ebfd96d5..77b79cf8 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskReview.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskReview.tsx @@ -40,6 +40,8 @@ interface TaskReviewProps { onShowConflictDialog: (show: boolean) => void; onLoadMergePreview: () => void; onClose?: () => void; + onSwitchToTerminals?: () => void; + onOpenInbuiltTerminal?: (id: string, cwd: string) => void; } /** @@ -79,7 +81,9 @@ export function TaskReview({ onStageOnlyChange, onShowConflictDialog, onLoadMergePreview, - onClose + onClose, + onSwitchToTerminals, + onOpenInbuiltTerminal }: TaskReviewProps) { return (
@@ -115,6 +119,9 @@ export function TaskReview({ onLoadMergePreview={onLoadMergePreview} onStageOnlyChange={onStageOnlyChange} onMerge={onMerge} + onClose={onClose} + onSwitchToTerminals={onSwitchToTerminals} + onOpenInbuiltTerminal={onOpenInbuiltTerminal} /> ) : task.stagedInMainProject && !stagedSuccess ? ( (null); const [isOpening, setIsOpening] = useState(false); + /** + * Open an inbuilt terminal tab + */ const openTerminal = async (id: string, cwd: string) => { setIsOpening(true); setError(null); @@ -28,5 +31,28 @@ export function useTerminalHandler() { } }; - return { openTerminal, error, isOpening }; + /** + * Open the path in the system's default external terminal application + */ + const openExternalTerminal = async (cwd: string) => { + setIsOpening(true); + setError(null); + + try { + const result = await window.electronAPI.openTerminal(cwd); + + if (!result.success) { + setError(result.error || 'Failed to open external terminal'); + console.error('[Terminal] Failed to open external:', result.error); + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Unknown error'; + setError(`Failed to open external terminal: ${errorMsg}`); + console.error('[Terminal] Exception:', err); + } finally { + setIsOpening(false); + } + }; + + return { openTerminal, openExternalTerminal, error, isOpening }; } diff --git a/apps/frontend/src/renderer/components/task-detail/task-review/TerminalDropdown.tsx b/apps/frontend/src/renderer/components/task-detail/task-review/TerminalDropdown.tsx new file mode 100644 index 00000000..a796f6bf --- /dev/null +++ b/apps/frontend/src/renderer/components/task-detail/task-review/TerminalDropdown.tsx @@ -0,0 +1,55 @@ +import { Terminal, ExternalLink, ChevronDown } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '../../ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '../../ui/dropdown-menu'; + +interface TerminalDropdownProps { + onOpenInbuilt: () => void; + onOpenExternal: () => void; + disabled?: boolean; + className?: string; +} + +/** + * Dropdown button for selecting terminal type (inbuilt or external) + */ +export function TerminalDropdown({ + onOpenInbuilt, + onOpenExternal, + disabled = false, + className +}: TerminalDropdownProps) { + const { t } = useTranslation('taskReview'); + + return ( + + + + + + + + {t('terminal.openInbuilt')} + + + + {t('terminal.openExternal')} + + + + ); +} diff --git a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx index 8e48f2e6..7ec59168 100644 --- a/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx +++ b/apps/frontend/src/renderer/components/task-detail/task-review/WorkspaceStatus.tsx @@ -19,6 +19,7 @@ import { Checkbox } from '../../ui/checkbox'; import { cn } from '../../../lib/utils'; import type { Task, WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types'; import { useTerminalHandler } from '../hooks/useTerminalHandler'; +import { TerminalDropdown } from './TerminalDropdown'; interface WorkspaceStatusProps { task: Task; @@ -35,6 +36,9 @@ interface WorkspaceStatusProps { onLoadMergePreview: () => void; onStageOnlyChange: (value: boolean) => void; onMerge: () => void; + onClose?: () => void; + onSwitchToTerminals?: () => void; + onOpenInbuiltTerminal?: (id: string, cwd: string) => void; } /** @@ -54,9 +58,12 @@ export function WorkspaceStatus({ onShowConflictDialog, onLoadMergePreview, onStageOnlyChange, - onMerge + onMerge, + onClose, + onSwitchToTerminals, + onOpenInbuiltTerminal }: WorkspaceStatusProps) { - const { openTerminal, error: terminalError, isOpening } = useTerminalHandler(); + const { openTerminal, openExternalTerminal, error: terminalError, isOpening } = useTerminalHandler(); const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts; const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges; const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0; @@ -98,16 +105,16 @@ export function WorkspaceStatus({ View {worktreeStatus.worktreePath && ( - + className="h-7 px-2" + /> )}
@@ -177,21 +184,22 @@ export function WorkspaceStatus({

Commit or stash them before staging to avoid conflicts.

- + className="text-xs h-6 mt-2" + /> )} diff --git a/apps/frontend/src/renderer/lib/mocks/infrastructure-mock.ts b/apps/frontend/src/renderer/lib/mocks/infrastructure-mock.ts index 85787631..753083ea 100644 --- a/apps/frontend/src/renderer/lib/mocks/infrastructure-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/infrastructure-mock.ts @@ -184,5 +184,10 @@ export const infrastructureMock = { openExternal: async (url: string) => { console.warn('[Browser Mock] openExternal:', url); window.open(url, '_blank'); + }, + + openTerminal: async (dirPath: string) => { + console.warn('[Browser Mock] openTerminal:', dirPath); + return { success: true }; } }; diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 40c5bde2..ba223c6c 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -117,6 +117,7 @@ export const IPC_CHANNELS = { // Shell operations SHELL_OPEN_EXTERNAL: 'shell:openExternal', + SHELL_OPEN_TERMINAL: 'shell:openTerminal', // Roadmap operations ROADMAP_GET: 'roadmap:get', diff --git a/apps/frontend/src/shared/i18n/index.ts b/apps/frontend/src/shared/i18n/index.ts index bd54dd15..407d8d4c 100644 --- a/apps/frontend/src/shared/i18n/index.ts +++ b/apps/frontend/src/shared/i18n/index.ts @@ -9,6 +9,7 @@ import enTasks from './locales/en/tasks.json'; import enWelcome from './locales/en/welcome.json'; import enOnboarding from './locales/en/onboarding.json'; import enDialogs from './locales/en/dialogs.json'; +import enTaskReview from './locales/en/taskReview.json'; // Import French translation resources import frCommon from './locales/fr/common.json'; @@ -18,6 +19,7 @@ import frTasks from './locales/fr/tasks.json'; import frWelcome from './locales/fr/welcome.json'; import frOnboarding from './locales/fr/onboarding.json'; import frDialogs from './locales/fr/dialogs.json'; +import frTaskReview from './locales/fr/taskReview.json'; export const defaultNS = 'common'; @@ -29,7 +31,8 @@ export const resources = { tasks: enTasks, welcome: enWelcome, onboarding: enOnboarding, - dialogs: enDialogs + dialogs: enDialogs, + taskReview: enTaskReview }, fr: { common: frCommon, @@ -38,7 +41,8 @@ export const resources = { tasks: frTasks, welcome: frWelcome, onboarding: frOnboarding, - dialogs: frDialogs + dialogs: frDialogs, + taskReview: frTaskReview } } as const; @@ -49,7 +53,7 @@ i18n lng: 'en', // Default language (will be overridden by settings) fallbackLng: 'en', defaultNS, - ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs'], + ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'taskReview'], interpolation: { escapeValue: false // React already escapes values }, diff --git a/apps/frontend/src/shared/i18n/locales/en/taskReview.json b/apps/frontend/src/shared/i18n/locales/en/taskReview.json new file mode 100644 index 00000000..9a95ebfe --- /dev/null +++ b/apps/frontend/src/shared/i18n/locales/en/taskReview.json @@ -0,0 +1,7 @@ +{ + "terminal": { + "openTerminal": "Open terminal", + "openInbuilt": "Open in Inbuilt Terminal", + "openExternal": "Open in External Terminal" + } +} diff --git a/apps/frontend/src/shared/i18n/locales/fr/taskReview.json b/apps/frontend/src/shared/i18n/locales/fr/taskReview.json new file mode 100644 index 00000000..2dbb2b31 --- /dev/null +++ b/apps/frontend/src/shared/i18n/locales/fr/taskReview.json @@ -0,0 +1,7 @@ +{ + "terminal": { + "openTerminal": "Ouvrir le terminal", + "openInbuilt": "Ouvrir dans le terminal intégré", + "openExternal": "Ouvrir dans le terminal externe" + } +} diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 90ca97bc..cf3e37fd 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -452,6 +452,7 @@ export interface ElectronAPI { // Shell operations openExternal: (url: string) => Promise; + openTerminal: (dirPath: string) => Promise>; // Auto Claude source environment operations getSourceEnv: () => Promise>;