diff --git a/package-lock.json b/package-lock.json index 562b4c1d2..5ddae691c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.14.0", + "version": "0.14.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.14.0", + "version": "0.14.3", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -21,6 +21,12 @@ "@tanstack/react-virtual": "^3.13.13", "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.0.5", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "adm-zip": "^0.5.16", "ansi-to-html": "^0.7.2", "archiver": "^7.0.1", @@ -4707,6 +4713,45 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", + "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", diff --git a/package.json b/package.json index 3eb14278c..78548e793 100644 --- a/package.json +++ b/package.json @@ -216,6 +216,12 @@ "@tanstack/react-virtual": "^3.13.13", "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.0.5", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "adm-zip": "^0.5.16", "ansi-to-html": "^0.7.2", "archiver": "^7.0.1", diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 90c88e2dc..add785dee 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -430,6 +430,60 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void }) ); + // Spawn a terminal PTY for a specific tab (xterm.js integration) + // This creates a persistent PTY shell for terminal tab emulation + ipcMain.handle( + 'process:spawnTerminalTab', + withIpcErrorLogging(handlerOpts('spawnTerminalTab'), async (config: { + sessionId: string; + cwd: string; + shell?: string; + shellArgs?: string; + shellEnvVars?: Record; + cols?: number; + rows?: number; + }) => { + const processManager = requireProcessManager(getProcessManager); + + // If no shell specified, use defaults from settings + let shellToUse = config.shell || settingsStore.get('defaultShell', 'zsh'); + + // Custom shell path overrides the detected/selected shell path + const customShellPath = settingsStore.get('customShellPath', ''); + if (customShellPath && customShellPath.trim()) { + shellToUse = customShellPath.trim(); + } + + // Get shell args and env vars from settings if not provided + const shellArgs = config.shellArgs || settingsStore.get('shellArgs', ''); + const shellEnvVars = config.shellEnvVars || settingsStore.get('shellEnvVars', {}) as Record; + + logger.info('Spawning terminal tab', LOG_CONTEXT, { + sessionId: config.sessionId, + cwd: config.cwd, + shell: shellToUse, + cols: config.cols, + rows: config.rows, + }); + + try { + const result = processManager.spawnTerminalTab({ + sessionId: config.sessionId, + cwd: config.cwd, + shell: shellToUse, + shellArgs, + shellEnvVars, + cols: config.cols, + rows: config.rows, + }); + return result; + } catch (error) { + logger.error('Failed to spawn terminal tab', LOG_CONTEXT, { error: String(error) }); + throw error; + } + }) + ); + // Run a single command and capture only stdout/stderr (no PTY echo/prompts) // Supports SSH remote execution when sessionSshRemoteConfig is provided ipcMain.handle( diff --git a/src/main/preload.ts b/src/main/preload.ts index e8541667f..d3a0774f4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -120,6 +120,18 @@ contextBridge.exposeInMainWorld('maestro', { }; }) => ipcRenderer.invoke('process:runCommand', config), + // Spawn a terminal PTY for a specific tab (xterm.js integration) + // Creates a persistent PTY shell for terminal tab emulation + spawnTerminalTab: (config: { + sessionId: string; + cwd: string; + shell?: string; + shellArgs?: string; + shellEnvVars?: Record; + cols?: number; + rows?: number; + }) => ipcRenderer.invoke('process:spawnTerminalTab', config), + // Get all active processes from ProcessManager getActiveProcesses: () => ipcRenderer.invoke('process:getActiveProcesses'), @@ -1785,6 +1797,15 @@ export interface MaestroAPI { workingDirOverride?: string; }; }) => Promise<{ exitCode: number }>; + spawnTerminalTab: (config: { + sessionId: string; + cwd: string; + shell?: string; + shellArgs?: string; + shellEnvVars?: Record; + cols?: number; + rows?: number; + }) => Promise<{ pid: number; success: boolean }>; getActiveProcesses: () => Promise { - // Strip terminal control sequences and filter prompts/echoes + // For terminal mode with xterm.js, send RAW data - xterm handles all rendering + // This includes control sequences, colors, cursor movement, etc. + if (isTerminal) { + logger.debug('[ProcessManager] PTY onData (raw terminal)', 'ProcessManager', { + sessionId, + pid: ptyProcess.pid, + dataLength: data.length, + }); + this.emit('data', sessionId, data); + return; + } + + // For AI agents using PTY, apply filtering to clean up output const managedProc = this.processes.get(sessionId); const cleanedData = stripControlSequences(data, managedProc?.lastCommand, isTerminal); logger.debug('[ProcessManager] PTY onData', 'ProcessManager', { sessionId, pid: ptyProcess.pid, dataPreview: cleanedData.substring(0, 100) }); @@ -1366,6 +1378,50 @@ export class ProcessManager extends EventEmitter { } } + /** + * Spawn a terminal PTY for a specific terminal tab. + * This is a convenience wrapper around spawn() that enforces terminal mode. + * + * @param config.sessionId - Full session ID in format {sessionId}-terminal-{tabId} + * @param config.cwd - Working directory for the shell + * @param config.shell - Shell to use (e.g., 'zsh', 'bash', '/usr/local/bin/zsh') + * @param config.shellArgs - Additional shell arguments + * @param config.shellEnvVars - Custom environment variables + * @param config.cols - Initial column count (default: 80) + * @param config.rows - Initial row count (default: 24) + */ + spawnTerminalTab(config: { + sessionId: string; + cwd: string; + shell?: string; + shellArgs?: string; + shellEnvVars?: Record; + cols?: number; + rows?: number; + }): { pid: number; success: boolean } { + const { sessionId, cwd, shell, shellArgs, shellEnvVars, cols = 80, rows = 24 } = config; + + logger.debug('[ProcessManager] spawnTerminalTab()', 'ProcessManager', { + sessionId, + cwd, + shell, + cols, + rows, + }); + + // Use the existing spawn logic but force terminal mode + return this.spawn({ + sessionId, + toolType: 'terminal', + cwd, + command: shell || (process.platform === 'win32' ? 'powershell.exe' : 'zsh'), + args: [], + shell, + shellArgs, + shellEnvVars, + }); + } + /** * Write data to a process's stdin */ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d91339376..0039ed558 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -113,6 +113,12 @@ import { THEMES } from './constants/themes'; import { generateId } from './utils/ids'; import { getContextColor } from './utils/theme'; import { setActiveTab, createTab, closeTab, reopenClosedTab, getActiveTab, getWriteModeTab, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab, getInitialRenameValue, hasActiveWizard } from './utils/tabHelpers'; +import { + createTerminalTab, + createClosedTerminalTab, + MAX_CLOSED_TERMINAL_TABS, + // getTerminalSessionId - available for future use +} from './utils/terminalTabHelpers'; import { shouldOpenExternally, flattenTree } from './utils/fileExplorer'; import type { FileNode } from './types/fileTree'; import { substituteTemplateVariables } from './utils/templateVariables'; @@ -576,6 +582,10 @@ function MaestroConsoleInner() { const [outputSearchOpen, setOutputSearchOpen] = useState(false); const [outputSearchQuery, setOutputSearchQuery] = useState(''); + // Terminal Search State (xterm.js search when in terminal mode) + const [terminalSearchOpen, setTerminalSearchOpen] = useState(false); + const [terminalSearchQuery, setTerminalSearchQuery] = useState(''); + // Note: Command History, Tab Completion, and @ Mention states are now in InputContext // See useInputContext() destructuring above for these states @@ -834,6 +844,28 @@ function MaestroConsoleInner() { thinkingStartTime: undefined, })); + // Migrate sessions without terminal tabs (backwards compatibility) + let terminalTabs = correctedSession.terminalTabs; + let activeTerminalTabId = correctedSession.activeTerminalTabId; + if (!terminalTabs || terminalTabs.length === 0) { + const defaultTerminalTab = createTerminalTab( + defaultShell || 'zsh', + correctedSession.cwd, + null + ); + terminalTabs = [defaultTerminalTab]; + activeTerminalTabId = defaultTerminalTab.id; + console.log(`[restoreSession] Migrated session ${correctedSession.id} to terminal tabs`); + } else { + // Reset terminal tab runtime state - PTY processes don't survive app restart + terminalTabs = terminalTabs.map(tab => ({ + ...tab, + pid: 0, + state: 'idle' as const, + exitCode: undefined, + })); + } + // Session restored - no superfluous messages added to AI Terminal or Command Terminal return { ...correctedSession, @@ -861,6 +893,10 @@ function MaestroConsoleInner() { agentError: undefined, agentErrorPaused: false, closedTabHistory: [], // Runtime-only, reset on load + // Terminal tabs - migrated if needed, runtime state reset + terminalTabs, + activeTerminalTabId, + closedTerminalTabHistory: [], // Runtime-only, reset on load }; } else { // Process spawn failed @@ -1188,6 +1224,9 @@ function MaestroConsoleInner() { saveToHistory: true }; + // Create initial terminal tab for worktree session + const initialTerminalTab = createTerminalTab(defaultShell || 'zsh', subdir.path, null); + // Fetch git info (via SSH for remote sessions) let gitBranches: string[] | undefined; let gitTags: string[] | undefined; @@ -1240,6 +1279,10 @@ function MaestroConsoleInner() { aiTabs: [initialTab], activeTabId: initialTabId, closedTabHistory: [], + // Terminal tab management - start with one default terminal + terminalTabs: [initialTerminalTab], + activeTerminalTabId: initialTerminalTab.id, + closedTerminalTabHistory: [], customPath: parentSession.customPath, customArgs: parentSession.customArgs, customEnvVars: parentSession.customEnvVars, @@ -2771,6 +2814,7 @@ function MaestroConsoleInner() { const logsEndRef = useRef(null); const inputRef = useRef(null); const terminalOutputRef = useRef(null); + const terminalViewRef = useRef(null); const sidebarContainerRef = useRef(null); const fileTreeContainerRef = useRef(null); const fileTreeFilterInputRef = useRef(null); @@ -4172,6 +4216,236 @@ You are taking over this conversation. Based on the context above, provide a bri })); }, []); + // ============================================================================ + // TERMINAL TAB HANDLERS (Phase 7) + // These handlers manage terminal tabs within a session, similar to AI tabs. + // Unlike AI tab handlers, these accept sessionId explicitly since TerminalView + // passes the sessionId when calling these callbacks. + // ============================================================================ + + /** + * Select a terminal tab within a session + */ + const handleTerminalTabSelect = useCallback((sessionId: string, tabId: string) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + if (s.activeTerminalTabId === tabId) return s; // Already selected + + // Save scroll position for current tab (if needed in future) + return { + ...s, + activeTerminalTabId: tabId, + }; + })); + }, []); + + /** + * Close a terminal tab, killing its PTY process. + * Adds to closed tab history for undo. + */ + const handleTerminalTabClose = useCallback((sessionId: string, tabId: string) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + + const tabs = s.terminalTabs || []; + const tabIndex = tabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return s; + + // Don't allow closing the last tab + if (tabs.length <= 1) return s; + + const closedTab = tabs[tabIndex]; + + // Add to closed tab history + const closedHistory = s.closedTerminalTabHistory || []; + const newClosedHistory = [ + createClosedTerminalTab(closedTab, tabIndex), + ...closedHistory, + ].slice(0, MAX_CLOSED_TERMINAL_TABS); + + // Remove the tab + const newTabs = tabs.filter(t => t.id !== tabId); + + // Select adjacent tab if closing the active one + let newActiveTabId = s.activeTerminalTabId; + if (s.activeTerminalTabId === tabId) { + // Select the tab to the left, or the first tab if closing leftmost + const newActiveIndex = Math.min(tabIndex, newTabs.length - 1); + newActiveTabId = newTabs[newActiveIndex]?.id || newTabs[0]?.id; + } + + return { + ...s, + terminalTabs: newTabs, + activeTerminalTabId: newActiveTabId, + closedTerminalTabHistory: newClosedHistory, + }; + })); + }, []); + + /** + * Create a new terminal tab + */ + const handleTerminalNewTab = useCallback((sessionId: string) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + + const newTab = createTerminalTab( + defaultShell || 'zsh', + s.cwd, + null // No custom name + ); + + const tabs = s.terminalTabs || []; + return { + ...s, + terminalTabs: [...tabs, newTab], + activeTerminalTabId: newTab.id, // Switch to new tab + }; + })); + }, [defaultShell]); + + /** + * Rename a terminal tab + */ + const handleTerminalTabRename = useCallback((sessionId: string, tabId: string, name: string) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + + const tabs = s.terminalTabs || []; + return { + ...s, + terminalTabs: tabs.map(tab => + tab.id === tabId + ? { ...tab, name: name.trim() || null } // Empty string -> null + : tab + ), + }; + })); + }, []); + + /** + * Reorder terminal tabs (drag and drop) + */ + const handleTerminalTabReorder = useCallback((sessionId: string, fromIndex: number, toIndex: number) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + if (fromIndex === toIndex) return s; + + const tabs = s.terminalTabs || []; + const newTabs = [...tabs]; + const [movedTab] = newTabs.splice(fromIndex, 1); + newTabs.splice(toIndex, 0, movedTab); + + return { + ...s, + terminalTabs: newTabs, + }; + })); + }, []); + + /** + * Update terminal tab state (idle, busy, exited) + */ + const handleTerminalTabStateChange = useCallback(( + sessionId: string, + tabId: string, + state: 'idle' | 'busy' | 'exited', + exitCode?: number + ) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + + const tabs = s.terminalTabs || []; + return { + ...s, + terminalTabs: tabs.map(tab => + tab.id === tabId + ? { ...tab, state, exitCode: state === 'exited' ? exitCode : undefined } + : tab + ), + }; + })); + }, []); + + /** + * Update terminal tab's current working directory. + * Called when shell changes directory (via OSC 7 or similar). + */ + const handleTerminalTabCwdChange = useCallback((sessionId: string, tabId: string, cwd: string) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + + const tabs = s.terminalTabs || []; + return { + ...s, + terminalTabs: tabs.map(tab => + tab.id === tabId ? { ...tab, cwd } : tab + ), + }; + })); + }, []); + + /** + * Update terminal tab's PID after PTY spawn + */ + const handleTerminalTabPidChange = useCallback((sessionId: string, tabId: string, pid: number) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + + const tabs = s.terminalTabs || []; + return { + ...s, + terminalTabs: tabs.map(tab => + tab.id === tabId ? { ...tab, pid } : tab + ), + }; + })); + }, []); + + /** + * Reopen the most recently closed terminal tab. + * Creates a new PTY since the old one is gone. + * Wired to keyboard shortcut Cmd+Shift+T in terminal mode. + */ + const handleReopenTerminalTab = useCallback((sessionId: string) => { + setSessions(prev => prev.map(s => { + if (s.id !== sessionId) return s; + + const closedHistory = s.closedTerminalTabHistory || []; + if (closedHistory.length === 0) return s; + + const [closedEntry, ...remainingHistory] = closedHistory; + + // Create a new tab based on the closed one (but with fresh state) + const reopenedTab = { + ...closedEntry.tab, + id: generateId(), // New ID since old PTY is gone + pid: 0, // Will spawn new PTY + state: 'idle' as const, + exitCode: undefined, + createdAt: Date.now(), + }; + + // Insert at original position or at end + const tabs = s.terminalTabs || []; + const insertIndex = Math.min(closedEntry.index, tabs.length); + const newTabs = [...tabs]; + newTabs.splice(insertIndex, 0, reopenedTab); + + return { + ...s, + terminalTabs: newTabs, + activeTerminalTabId: reopenedTab.id, + closedTerminalTabHistory: remainingHistory, + }; + })); + }, []); + + // ============================================================================ + // END TERMINAL TAB HANDLERS + // ============================================================================ + const handleRemoveQueuedItem = useCallback((itemId: string) => { setSessions(prev => prev.map(s => { if (s.id !== activeSessionIdRef.current) return s; @@ -5252,6 +5526,9 @@ You are taking over this conversation. Based on the context above, provide a bri saveToHistory: defaultSaveToHistory }; + // Create initial terminal tab for worktree session + const initialTerminalTab = createTerminalTab(defaultShell || 'zsh', worktree.path, null); + // Get SSH remote ID for remote git operations const sshRemoteId = parentSession.sshRemoteId || parentSession.sessionSshRemoteConfig?.remoteId || undefined; @@ -5307,6 +5584,10 @@ You are taking over this conversation. Based on the context above, provide a bri aiTabs: [initialTab], activeTabId: initialTabId, closedTabHistory: [], + // Terminal tab management - start with one default terminal + terminalTabs: [initialTerminalTab], + activeTerminalTabId: initialTerminalTab.id, + closedTerminalTabHistory: [], customPath: parentSession.customPath, customArgs: parentSession.customArgs, customEnvVars: parentSession.customEnvVars, @@ -5424,6 +5705,9 @@ You are taking over this conversation. Based on the context above, provide a bri saveToHistory: defaultSaveToHistory }; + // Create initial terminal tab for worktree session + const initialTerminalTab = createTerminalTab(defaultShell || 'zsh', subdir.path, null); + // Fetch git info (with SSH support) let gitBranches: string[] | undefined; let gitTags: string[] | undefined; @@ -5477,6 +5761,10 @@ You are taking over this conversation. Based on the context above, provide a bri aiTabs: [initialTab], activeTabId: initialTabId, closedTabHistory: [], + // Terminal tab management - start with one default terminal + terminalTabs: [initialTerminalTab], + activeTerminalTabId: initialTerminalTab.id, + closedTerminalTabHistory: [], customPath: session.customPath, customArgs: session.customArgs, customEnvVars: session.customEnvVars, @@ -6528,6 +6816,9 @@ You are taking over this conversation. Based on the context above, provide a bri saveToHistory: defaultSaveToHistory }; + // Create initial terminal tab for new sessions + const initialTerminalTab = createTerminalTab(defaultShell || 'zsh', workingDir, null); + const newSession: Session = { id: newId, name, @@ -6565,6 +6856,10 @@ You are taking over this conversation. Based on the context above, provide a bri aiTabs: [initialTab], activeTabId: initialTabId, closedTabHistory: [], + // Terminal tab management - start with one default terminal + terminalTabs: [initialTerminalTab], + activeTerminalTabId: initialTerminalTab.id, + closedTerminalTabHistory: [], // Nudge message - appended to every interactive user message nudgeMessage, // Per-agent config (path, args, env vars, model) @@ -6668,6 +6963,9 @@ You are taking over this conversation. Based on the context above, provide a bri saveToHistory: defaultSaveToHistory }; + // Create initial terminal tab for wizard session + const initialTerminalTab = createTerminalTab(defaultShell || 'zsh', directoryPath, null); + // Build Auto Run folder path const autoRunFolderPath = `${directoryPath}/${AUTO_RUN_FOLDER_NAME}`; const firstDoc = generatedDocuments[0]; @@ -6708,6 +7006,10 @@ You are taking over this conversation. Based on the context above, provide a bri aiTabs: [initialTab], activeTabId: initialTabId, closedTabHistory: [], + // Terminal tab management - start with one default terminal + terminalTabs: [initialTerminalTab], + activeTerminalTabId: initialTerminalTab.id, + closedTerminalTabHistory: [], // Auto Run configuration from wizard autoRunFolderPath, autoRunSelectedFile, @@ -8443,6 +8745,9 @@ You are taking over this conversation. Based on the context above, provide a bri saveToHistory: true }; + // Create initial terminal tab for worktree session + const initialTerminalTab = createTerminalTab(defaultShell || 'zsh', subdir.path, null); + // Fetch git info for this subdirectory (with SSH support) let gitBranches: string[] | undefined; let gitTags: string[] | undefined; @@ -8497,6 +8802,10 @@ You are taking over this conversation. Based on the context above, provide a bri aiTabs: [initialTab], activeTabId: initialTabId, closedTabHistory: [], + // Terminal tab management - start with one default terminal + terminalTabs: [initialTerminalTab], + activeTerminalTabId: initialTerminalTab.id, + closedTerminalTabHistory: [], customPath: activeSession.customPath, customArgs: activeSession.customArgs, customEnvVars: activeSession.customEnvVars, @@ -8586,6 +8895,9 @@ You are taking over this conversation. Based on the context above, provide a bri saveToHistory: defaultSaveToHistory }; + // Create initial terminal tab for worktree session + const initialTerminalTab = createTerminalTab(defaultShell || 'zsh', worktreePath, null); + // Fetch git info for the worktree (pass SSH remote ID for remote sessions) let gitBranches: string[] | undefined; let gitTags: string[] | undefined; @@ -8638,6 +8950,10 @@ You are taking over this conversation. Based on the context above, provide a bri aiTabs: [initialTab], activeTabId: initialTabId, closedTabHistory: [], + // Terminal tab management - start with one default terminal + terminalTabs: [initialTerminalTab], + activeTerminalTabId: initialTerminalTab.id, + closedTerminalTabHistory: [], customPath: activeSession.customPath, customArgs: activeSession.customArgs, customEnvVars: activeSession.customEnvVars, @@ -8720,6 +9036,9 @@ You are taking over this conversation. Based on the context above, provide a bri saveToHistory: defaultSaveToHistory }; + // Create initial terminal tab for worktree session + const initialTerminalTab = createTerminalTab(defaultShell || 'zsh', worktreePath, null); + // Fetch git info for the worktree (pass SSH remote ID for remote sessions) let gitBranches: string[] | undefined; let gitTags: string[] | undefined; @@ -8772,6 +9091,10 @@ You are taking over this conversation. Based on the context above, provide a bri aiTabs: [initialTab], activeTabId: initialTabId, closedTabHistory: [], + // Terminal tab management - start with one default terminal + terminalTabs: [initialTerminalTab], + activeTerminalTabId: initialTerminalTab.id, + closedTerminalTabHistory: [], customPath: createWorktreeSession.customPath, customArgs: createWorktreeSession.customArgs, customEnvVars: createWorktreeSession.customEnvVars, @@ -9194,7 +9517,18 @@ You are taking over this conversation. Based on the context above, provide a bri handleCloseAllTabs, handleCloseOtherTabs, handleCloseTabsLeft, handleCloseTabsRight, // Session bookmark toggle - toggleBookmark + toggleBookmark, + + // Terminal tab handlers (for keyboard shortcuts in terminal mode) + handleTerminalNewTab, + handleTerminalTabSelect, + handleTerminalTabClose, + handleReopenTerminalTab, + terminalViewRef, + setTerminalSearchOpen, + terminalSearchOpen, + terminalSearchQuery, + setTerminalSearchQuery, }; @@ -10828,6 +11162,17 @@ You are taking over this conversation. Based on the context above, provide a bri onExitWizard={endInlineWizard} // Cancel generation and exit wizard onWizardCancelGeneration={endInlineWizard} + // Terminal tab management handlers (Phase 7) + onTerminalTabSelect={handleTerminalTabSelect} + onTerminalTabClose={handleTerminalTabClose} + onTerminalNewTab={handleTerminalNewTab} + onTerminalTabRename={handleTerminalTabRename} + onTerminalTabReorder={handleTerminalTabReorder} + onTerminalTabStateChange={handleTerminalTabStateChange} + onTerminalTabCwdChange={handleTerminalTabCwdChange} + onTerminalTabPidChange={handleTerminalTabPidChange} + // Terminal view ref for keyboard shortcuts (clear, search) + terminalViewRef={terminalViewRef} // Wizard thinking toggle onToggleWizardShowThinking={() => { if (!activeSession) return; diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index 2c0347f60..e45ff6764 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -8,6 +8,7 @@ import { ErrorBoundary } from './ErrorBoundary'; import { GitStatusWidget } from './GitStatusWidget'; import { AgentSessionsBrowser } from './AgentSessionsBrowser'; import { TabBar } from './TabBar'; +import { TerminalView } from './TerminalView'; import { WizardConversationView, DocumentGenerationView } from './InlineWizard'; import { gitService } from '../services/git'; import { useGitStatus } from '../contexts/GitStatusContext'; @@ -166,6 +167,22 @@ interface MainPanelProps { onAtBottomChange?: (isAtBottom: boolean) => void; // Input blur handler for persisting AI input state onInputBlur?: () => void; + + // Terminal tab callbacks (for xterm.js multi-tab terminal) + onTerminalTabSelect?: (sessionId: string, tabId: string) => void; + onTerminalTabClose?: (sessionId: string, tabId: string) => void; + onTerminalNewTab?: (sessionId: string) => void; + onTerminalTabRename?: (sessionId: string, tabId: string, name: string) => void; + onTerminalTabReorder?: (sessionId: string, fromIndex: number, toIndex: number) => void; + onTerminalTabStateChange?: (sessionId: string, tabId: string, state: 'idle' | 'busy' | 'exited', exitCode?: number) => void; + onTerminalTabCwdChange?: (sessionId: string, tabId: string, cwd: string) => void; + onTerminalTabPidChange?: (sessionId: string, tabId: string, pid: number) => void; + onTerminalRequestRename?: (tabId: string) => void; + terminalViewRef?: React.RefObject; + // Shell settings for spawning terminal PTY + defaultShell?: string; + shellArgs?: string; + shellEnvVars?: Record; // Prompt composer modal onOpenPromptComposer?: () => void; // Replay a user message (AI mode) @@ -1177,6 +1194,28 @@ export const MainPanel = React.memo(forwardRef( toolExecutions={activeTab.wizardState.toolExecutions ?? []} hasStartedGenerating={activeTab.wizardState.isGeneratingDocs || (activeTab.wizardState.generatedDocuments?.length ?? 0) > 0} /> + ) : activeSession.inputMode === 'terminal' ? ( + /* Full terminal emulation with xterm.js and tabs */ + props.onTerminalTabSelect?.(activeSession.id, tabId)} + onTabClose={(tabId) => props.onTerminalTabClose?.(activeSession.id, tabId)} + onNewTab={() => props.onTerminalNewTab?.(activeSession.id)} + onTabRename={(tabId, name) => props.onTerminalTabRename?.(activeSession.id, tabId, name)} + onTabReorder={(from, to) => props.onTerminalTabReorder?.(activeSession.id, from, to)} + onTabStateChange={(tabId, state, exitCode) => props.onTerminalTabStateChange?.(activeSession.id, tabId, state, exitCode)} + onTabCwdChange={(tabId, cwd) => props.onTerminalTabCwdChange?.(activeSession.id, tabId, cwd)} + onTabPidChange={(tabId, pid) => props.onTerminalTabPidChange?.(activeSession.id, tabId, pid)} + onRequestRename={props.onTerminalRequestRename} + /> ) : ( ( )} - {/* Input Area (hidden in mobile landscape for focused reading, and during wizard doc generation) */} - {!isMobileLandscape && !(activeTab?.wizardState?.isGeneratingDocs) && ( + {/* Input Area - hidden in terminal mode (xterm handles input), mobile landscape, and during wizard doc generation */} + {!isMobileLandscape && !(activeTab?.wizardState?.isGeneratingDocs) && activeSession.inputMode !== 'terminal' && (
void; + onTabClose: (tabId: string) => void; + onNewTab: () => void; + onRequestRename?: (tabId: string) => void; + onTabReorder?: (fromIndex: number, toIndex: number) => void; +} + +interface TerminalTabProps { + tab: TerminalTab; + index: number; + isActive: boolean; + theme: Theme; + canClose: boolean; + onSelect: () => void; + onClose: () => void; + onMiddleClick: () => void; + onDragStart: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnd: () => void; + onDrop: (e: React.DragEvent) => void; + isDragging: boolean; + isDragOver: boolean; + onRename: () => void; +} + +/** + * Individual terminal tab component. + * Displays terminal state via icon color: green (exited 0), red (exited non-zero), yellow (busy) + */ +const TerminalTabComponent = memo(function TerminalTabComponent({ + tab, + index, + isActive, + theme, + canClose, + onSelect, + onClose, + onMiddleClick, + onDragStart, + onDragOver, + onDragEnd, + onDrop, + isDragging, + isDragOver, + onRename, +}: TerminalTabProps) { + const [isHovered, setIsHovered] = useState(false); + const displayName = getTerminalTabDisplayName(tab, index); + const isExited = tab.state === 'exited'; + const isBusy = tab.state === 'busy'; + + // Determine terminal icon color based on state + const getIconColor = () => { + if (isExited) { + return tab.exitCode === 0 ? theme.colors.success : theme.colors.error; + } + if (isBusy) { + return theme.colors.warning; + } + return isActive ? theme.colors.textMain : theme.colors.textDim; + }; + + const handleMouseDown = (e: React.MouseEvent) => { + // Middle-click to close + if (e.button === 1 && canClose) { + e.preventDefault(); + onMiddleClick(); + } + }; + + const handleCloseClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClose(); + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onDoubleClick={onRename} + className={` + relative flex items-center gap-1.5 px-3 py-1.5 cursor-pointer + transition-all duration-150 select-none + ${isDragging ? 'opacity-50' : ''} + ${isDragOver ? 'ring-2 ring-inset' : ''} + `} + style={{ + // All tabs have rounded top corners + borderTopLeftRadius: '6px', + borderTopRightRadius: '6px', + // Active tab: bright background matching content area + // Inactive tabs: transparent with subtle hover + backgroundColor: isActive + ? theme.colors.bgMain + : (isHovered ? 'rgba(255, 255, 255, 0.08)' : 'transparent'), + // Active tab has visible borders, inactive tabs have no borders + borderTop: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', + borderLeft: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', + borderRight: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', + // Active tab has no bottom border (connects to content) + borderBottom: isActive ? `1px solid ${theme.colors.bgMain}` : '1px solid transparent', + // Active tab sits on top of the tab bar's bottom border + marginBottom: isActive ? '-1px' : '0', + // Slight z-index for active tab to cover border properly + zIndex: isActive ? 1 : 0, + '--tw-ring-color': isDragOver ? theme.colors.accent : 'transparent' + } as React.CSSProperties} + > + {/* Terminal icon with state indicator */} + + + {/* Tab name */} + + {displayName} + + + {/* Exit code indicator */} + {isExited && tab.exitCode !== 0 && ( + + ({tab.exitCode}) + + )} + + {/* Close button - visible on hover or when active */} + {canClose && (isHovered || isActive) && ( + + )} +
+ ); +}); + +/** + * TerminalTabBar component for displaying terminal tabs. + * Shows tabs for each PTY shell session within a Maestro session. + * Appears only in terminal mode (hidden in AI mode). + */ +function TerminalTabBarInner({ + tabs, + activeTabId, + theme, + onTabSelect, + onTabClose, + onNewTab, + onRequestRename, + onTabReorder, +}: TerminalTabBarProps) { + const [draggingIndex, setDraggingIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const tabBarRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + // Can close tabs if there's more than one + const canClose = tabs.length > 1; + + // Center the active tab in the scrollable area when activeTabId changes + useEffect(() => { + requestAnimationFrame(() => { + const container = tabBarRef.current; + const tabElement = container?.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement | null; + if (container && tabElement) { + // Calculate scroll position to center the tab + const scrollLeft = tabElement.offsetLeft - (container.clientWidth / 2) + (tabElement.offsetWidth / 2); + container.scrollTo({ left: scrollLeft, behavior: 'smooth' }); + } + }); + }, [activeTabId]); + + // Check if tabs overflow the container + useEffect(() => { + const checkOverflow = () => { + if (tabBarRef.current) { + setIsOverflowing(tabBarRef.current.scrollWidth > tabBarRef.current.clientWidth); + } + }; + + const timeoutId = setTimeout(checkOverflow, 0); + window.addEventListener('resize', checkOverflow); + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', checkOverflow); + }; + }, [tabs.length]); + + const handleDragStart = useCallback((index: number) => (e: React.DragEvent) => { + setDraggingIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(index)); + }, []); + + const handleDragOver = useCallback((index: number) => (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverIndex(index); + }, []); + + const handleDragEnd = useCallback(() => { + setDraggingIndex(null); + setDragOverIndex(null); + }, []); + + const handleDrop = useCallback((toIndex: number) => (e: React.DragEvent) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); + if (!isNaN(fromIndex) && fromIndex !== toIndex && onTabReorder) { + onTabReorder(fromIndex, toIndex); + } + setDraggingIndex(null); + setDragOverIndex(null); + }, [onTabReorder]); + + const handleRenameRequest = useCallback((tabId: string) => { + if (onRequestRename) { + onRequestRename(tabId); + } + }, [onRequestRename]); + + // Determine the keyboard shortcut hint based on platform + const shortcutKey = typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? 'Ctrl+Shift+`' : 'Ctrl+Shift+`'; + + return ( +
+ {/* Tabs with separators between inactive tabs */} + {tabs.map((tab, index) => { + const isActive = tab.id === activeTabId; + const prevTab = index > 0 ? tabs[index - 1] : null; + const isPrevActive = prevTab?.id === activeTabId; + + // Show separator between inactive tabs (not adjacent to active tab) + const showSeparator = index > 0 && !isActive && !isPrevActive; + + return ( + + {showSeparator && ( +
+ )} +
+ onTabSelect(tab.id)} + onClose={() => onTabClose(tab.id)} + onMiddleClick={() => canClose && onTabClose(tab.id)} + onDragStart={handleDragStart(index)} + onDragOver={handleDragOver(index)} + onDragEnd={handleDragEnd} + onDrop={handleDrop(index)} + isDragging={draggingIndex === index} + isDragOver={dragOverIndex === index} + onRename={() => handleRenameRequest(tab.id)} + /> +
+ + ); + })} + + {/* New tab button - sticky on right when tabs overflow */} +
+ +
+
+ ); +} + +export const TerminalTabBar = memo(TerminalTabBarInner); diff --git a/src/renderer/components/TerminalView.tsx b/src/renderer/components/TerminalView.tsx new file mode 100644 index 000000000..bf8cd8de2 --- /dev/null +++ b/src/renderer/components/TerminalView.tsx @@ -0,0 +1,240 @@ +/** + * TerminalView - Full terminal emulation view with tabs + * + * This component manages: + * - Terminal tab bar (create, close, rename, reorder) + * - XTerminal instance per tab + * - PTY lifecycle (spawn on demand, cleanup on close) + * - Tab switching with proper focus handling + */ + +import React, { useRef, useCallback, useEffect, memo, forwardRef, useImperativeHandle } from 'react'; +import { XTerminal, XTerminalHandle } from './XTerminal'; +import { TerminalTabBar } from './TerminalTabBar'; +import type { Session, TerminalTab } from '../types'; +import type { Theme } from '../../shared/theme-types'; +import { + getActiveTerminalTab, + getTerminalSessionId, +} from '../utils/terminalTabHelpers'; + +/** + * Handle interface for TerminalView to expose methods to parent components + * via ref (used for keyboard shortcuts like Cmd+K clear and Cmd+F search) + */ +export interface TerminalViewHandle { + /** Clear the active terminal buffer */ + clearActiveTerminal: () => void; + /** Focus the active terminal */ + focusActiveTerminal: () => void; + /** Search in the active terminal buffer */ + searchActiveTerminal: (query: string) => boolean; + /** Find the next occurrence of the search query */ + searchNext: (query: string) => boolean; + /** Find the previous occurrence of the search query */ + searchPrevious: (query: string) => boolean; + /** Clear search highlighting in the active terminal */ + clearSearch: () => void; +} + +interface TerminalViewProps { + session: Session; + theme: Theme; + fontFamily: string; + fontSize?: number; + defaultShell: string; + shellArgs?: string; + shellEnvVars?: Record; + // Callbacks to update session state + onTabSelect: (tabId: string) => void; + onTabClose: (tabId: string) => void; + onNewTab: () => void; + onTabRename: (tabId: string, name: string) => void; + onTabReorder: (fromIndex: number, toIndex: number) => void; + onTabStateChange: (tabId: string, state: TerminalTab['state'], exitCode?: number) => void; + onTabCwdChange: (tabId: string, cwd: string) => void; + onTabPidChange: (tabId: string, pid: number) => void; + // Rename modal trigger + onRequestRename?: (tabId: string) => void; +} + +export const TerminalView = memo(forwardRef( + function TerminalView({ + session, + theme, + fontFamily, + fontSize = 14, + defaultShell, + shellArgs, + shellEnvVars, + onTabSelect, + onTabClose, + onNewTab, + onTabReorder, + onTabStateChange, + onTabPidChange, + onRequestRename, + }, ref) { + // Refs for terminal instances (one per tab) + const terminalRefs = useRef>(new Map()); + + // Get terminal tabs with fallback to empty array + const terminalTabs = session.terminalTabs ?? []; + const activeTerminalTabId = session.activeTerminalTabId ?? ''; + + // Get active terminal tab + const activeTab = getActiveTerminalTab(session); + + // Spawn PTY for a tab if not already running + const spawnPtyForTab = useCallback(async (tab: TerminalTab) => { + if (tab.pid > 0) return; // Already spawned + + const terminalSessionId = getTerminalSessionId(session.id, tab.id); + try { + const result = await window.maestro.process.spawnTerminalTab({ + sessionId: terminalSessionId, + cwd: tab.cwd || session.cwd, + shell: defaultShell, + shellArgs, + shellEnvVars, + }); + + if (result.success && result.pid > 0) { + onTabPidChange(tab.id, result.pid); + onTabStateChange(tab.id, 'idle'); + } + } catch (error) { + console.error('[TerminalView] Failed to spawn PTY:', error); + onTabStateChange(tab.id, 'exited', 1); + } + }, [session.id, session.cwd, defaultShell, shellArgs, shellEnvVars, onTabPidChange, onTabStateChange]); + + // Spawn PTY when active tab changes and doesn't have one + useEffect(() => { + if (activeTab && activeTab.pid === 0 && activeTab.state !== 'exited') { + spawnPtyForTab(activeTab); + } + }, [activeTab?.id, activeTab?.pid, activeTab?.state, spawnPtyForTab]); + + // Focus terminal when active tab changes + useEffect(() => { + if (activeTab) { + // Small delay to ensure terminal is mounted + requestAnimationFrame(() => { + const terminalHandle = terminalRefs.current.get(activeTab.id); + terminalHandle?.focus(); + }); + } + }, [activeTab?.id]); + + // Handle PTY exit + useEffect(() => { + if (!activeTab) return; + + const terminalSessionId = getTerminalSessionId(session.id, activeTab.id); + const unsubscribe = window.maestro.process.onExit((sid, code) => { + if (sid === terminalSessionId) { + onTabStateChange(activeTab.id, 'exited', code); + } + }); + + return unsubscribe; + }, [session.id, activeTab?.id, onTabStateChange]); + + // Handle tab close - kill PTY if running + const handleTabClose = useCallback(async (tabId: string) => { + const tab = terminalTabs.find(t => t.id === tabId); + if (tab && tab.pid > 0) { + const terminalSessionId = getTerminalSessionId(session.id, tabId); + await window.maestro.process.kill(terminalSessionId); + } + onTabClose(tabId); + }, [session.id, terminalTabs, onTabClose]); + + // Store terminal ref + const setTerminalRef = useCallback((tabId: string, ref: XTerminalHandle | null) => { + if (ref) { + terminalRefs.current.set(tabId, ref); + } else { + terminalRefs.current.delete(tabId); + } + }, []); + + // Expose methods to parent via ref for keyboard shortcuts + useImperativeHandle(ref, () => ({ + clearActiveTerminal: () => { + const activeTerminal = terminalRefs.current.get(activeTerminalTabId); + activeTerminal?.clear(); + }, + focusActiveTerminal: () => { + const activeTerminal = terminalRefs.current.get(activeTerminalTabId); + activeTerminal?.focus(); + }, + searchActiveTerminal: (query: string) => { + const activeTerminal = terminalRefs.current.get(activeTerminalTabId); + return activeTerminal?.search(query) ?? false; + }, + searchNext: (query: string) => { + const activeTerminal = terminalRefs.current.get(activeTerminalTabId); + return activeTerminal?.searchNext(query) ?? false; + }, + searchPrevious: (query: string) => { + const activeTerminal = terminalRefs.current.get(activeTerminalTabId); + return activeTerminal?.searchPrevious(query) ?? false; + }, + clearSearch: () => { + const activeTerminal = terminalRefs.current.get(activeTerminalTabId); + activeTerminal?.clearSearch(); + }, + }), [activeTerminalTabId]); + + return ( +
+ {/* Terminal Tab Bar */} + + + {/* Terminal Content Area */} +
+ {terminalTabs.map(tab => ( +
+ setTerminalRef(tab.id, ref)} + sessionId={getTerminalSessionId(session.id, tab.id)} + theme={theme} + fontFamily={fontFamily} + fontSize={fontSize} + onTitleChange={(title) => { + // Shell set window title - could update tab name + console.log('[TerminalView] Title change:', title); + }} + /> +
+ ))} + + {/* Show message if no tabs */} + {terminalTabs.length === 0 && ( +
+ No terminal tabs. Click + to create one. +
+ )} +
+
+ ); +})); + +export default TerminalView; diff --git a/src/renderer/components/XTerminal.tsx b/src/renderer/components/XTerminal.tsx new file mode 100644 index 000000000..1f8b8a0d3 --- /dev/null +++ b/src/renderer/components/XTerminal.tsx @@ -0,0 +1,287 @@ +/** + * XTerminal - xterm.js wrapper component for full terminal emulation + * + * This component manages: + * - xterm.js Terminal instance lifecycle + * - Addon loading (fit, webgl, web-links, search, unicode11) + * - IPC communication with main process PTY + * - Resize handling with debouncing + * - Theme synchronization with Maestro themes + */ + +import React, { useEffect, useRef, useCallback, useImperativeHandle, forwardRef } from 'react'; +import { Terminal, ITheme } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebglAddon } from '@xterm/addon-webgl'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { SearchAddon } from '@xterm/addon-search'; +import { Unicode11Addon } from '@xterm/addon-unicode11'; +import '@xterm/xterm/css/xterm.css'; +import type { Theme } from '../../shared/theme-types'; + +interface XTerminalProps { + /** Session ID used for IPC routing (format: {sessionId}-terminal-{tabId}) */ + sessionId: string; + /** Maestro theme for color mapping */ + theme: Theme; + /** User's configured font family */ + fontFamily: string; + /** Font size (default: 14) */ + fontSize?: number; + /** Called when user types (for external handling) */ + onData?: (data: string) => void; + /** Called on terminal resize */ + onResize?: (cols: number, rows: number) => void; + /** Called when shell sets window title */ + onTitleChange?: (title: string) => void; +} + +export interface XTerminalHandle { + /** Write data to the terminal */ + write: (data: string) => void; + /** Focus the terminal */ + focus: () => void; + /** Clear the terminal buffer */ + clear: () => void; + /** Scroll to the bottom of the terminal */ + scrollToBottom: () => void; + /** Search for a query in the terminal buffer */ + search: (query: string) => boolean; + /** Find the next occurrence of the current search */ + searchNext: (term: string) => boolean; + /** Find the previous occurrence of the current search */ + searchPrevious: (term: string) => boolean; + /** Clear the current search highlighting */ + clearSearch: () => void; + /** Get the currently selected text */ + getSelection: () => string; + /** Trigger a resize/fit operation */ + resize: () => void; +} + +/** + * Map Maestro theme colors to xterm.js ITheme format + * Provides sensible default ANSI colors based on theme mode + */ +function mapMaestroThemeToXterm(theme: Theme): ITheme { + // Default ANSI colors - these are typical terminal colors + // We derive them from theme colors where possible + const isDark = theme.mode === 'dark' || theme.mode === 'vibe'; + + return { + background: theme.colors.bgMain, + foreground: theme.colors.textMain, + cursor: theme.colors.accent, + cursorAccent: theme.colors.bgMain, + selectionBackground: theme.colors.accentDim, + selectionForeground: theme.colors.textMain, + // Standard ANSI colors - using theme-aware defaults + black: isDark ? '#000000' : '#2e3436', + red: theme.colors.error || '#e06c75', + green: theme.colors.success || '#98c379', + yellow: theme.colors.warning || '#e5c07b', + blue: theme.colors.accent || '#61afef', + magenta: theme.colors.accentText || '#c678dd', + cyan: isDark ? '#56b6c2' : '#06989a', + white: isDark ? '#abb2bf' : '#d3d7cf', + // Bright variants + brightBlack: theme.colors.textDim || '#5c6370', + brightRed: theme.colors.error || '#e06c75', + brightGreen: theme.colors.success || '#98c379', + brightYellow: theme.colors.warning || '#e5c07b', + brightBlue: theme.colors.accent || '#61afef', + brightMagenta: theme.colors.accentText || '#c678dd', + brightCyan: isDark ? '#56b6c2' : '#34e2e2', + brightWhite: theme.colors.textMain || '#ffffff', + }; +} + +export const XTerminal = forwardRef( + ({ sessionId, theme, fontFamily, fontSize = 14, onData, onResize, onTitleChange }, ref) => { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const searchAddonRef = useRef(null); + const webglAddonRef = useRef(null); + const resizeTimeoutRef = useRef | null>(null); + const currentSearchTermRef = useRef(''); + + // Debounced resize handler + const handleResize = useCallback(() => { + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current); + } + resizeTimeoutRef.current = setTimeout(() => { + if (fitAddonRef.current && terminalRef.current) { + fitAddonRef.current.fit(); + const { cols, rows } = terminalRef.current; + onResize?.(cols, rows); + // Notify main process of resize + window.maestro.process.resize(sessionId, cols, rows); + } + }, 100); // 100ms debounce + }, [sessionId, onResize]); + + // Initialize xterm.js + useEffect(() => { + if (!containerRef.current) return; + + // Create terminal instance + const term = new Terminal({ + cursorBlink: true, + cursorStyle: 'block', + fontFamily: fontFamily || 'Menlo, Monaco, "Courier New", monospace', + fontSize: fontSize, + theme: mapMaestroThemeToXterm(theme), + allowProposedApi: true, // Required for some addons + scrollback: 10000, // 10k lines of scrollback + }); + + // Load addons + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + + const webLinksAddon = new WebLinksAddon(); + term.loadAddon(webLinksAddon); + + const searchAddon = new SearchAddon(); + term.loadAddon(searchAddon); + + const unicode11Addon = new Unicode11Addon(); + term.loadAddon(unicode11Addon); + term.unicode.activeVersion = '11'; + + // WebGL addon (with fallback to canvas renderer) + let webglAddon: WebglAddon | null = null; + try { + webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + console.warn('[XTerminal] WebGL context lost, disposing addon'); + webglAddon?.dispose(); + webglAddonRef.current = null; + }); + term.loadAddon(webglAddon); + webglAddonRef.current = webglAddon; + } catch (e) { + console.warn('[XTerminal] WebGL addon failed to load, using canvas renderer', e); + } + + // Mount to DOM + term.open(containerRef.current); + fitAddon.fit(); + + // Store refs for cleanup and imperative handle + terminalRef.current = term; + fitAddonRef.current = fitAddon; + searchAddonRef.current = searchAddon; + + // Handle title changes from shell (e.g., when SSH changes prompt) + const titleDisposable = term.onTitleChange((title) => { + onTitleChange?.(title); + }); + + return () => { + titleDisposable.dispose(); + webglAddon?.dispose(); + term.dispose(); + terminalRef.current = null; + fitAddonRef.current = null; + searchAddonRef.current = null; + webglAddonRef.current = null; + }; + }, []); // Only run on mount - theme/font updates handled separately + + // Update theme when it changes + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.options.theme = mapMaestroThemeToXterm(theme); + } + }, [theme]); + + // Update font settings when they change + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.options.fontFamily = fontFamily || 'Menlo, Monaco, "Courier New", monospace'; + terminalRef.current.options.fontSize = fontSize; + // Refit after font change + fitAddonRef.current?.fit(); + } + }, [fontFamily, fontSize]); + + // ResizeObserver for container size changes + useEffect(() => { + if (!containerRef.current) return; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(containerRef.current); + + return () => resizeObserver.disconnect(); + }, [handleResize]); + + // Handle data from PTY (main process -> renderer) + useEffect(() => { + const unsubscribe = window.maestro.process.onData((sid, data) => { + if (sid === sessionId && terminalRef.current) { + terminalRef.current.write(data); + } + }); + return unsubscribe; + }, [sessionId]); + + // Handle user input (renderer -> main process) + useEffect(() => { + if (!terminalRef.current) return; + + const disposable = terminalRef.current.onData((data) => { + window.maestro.process.write(sessionId, data); + onData?.(data); + }); + + return () => disposable.dispose(); + }, [sessionId, onData]); + + // Expose imperative handle for parent component control + useImperativeHandle( + ref, + () => ({ + write: (data: string) => terminalRef.current?.write(data), + focus: () => terminalRef.current?.focus(), + clear: () => terminalRef.current?.clear(), + scrollToBottom: () => terminalRef.current?.scrollToBottom(), + search: (query: string) => { + currentSearchTermRef.current = query; + return searchAddonRef.current?.findNext(query) ?? false; + }, + searchNext: (term: string) => { + currentSearchTermRef.current = term; + return searchAddonRef.current?.findNext(term) ?? false; + }, + searchPrevious: (term: string) => { + currentSearchTermRef.current = term; + return searchAddonRef.current?.findPrevious(term) ?? false; + }, + clearSearch: () => { + currentSearchTermRef.current = ''; + searchAddonRef.current?.clearDecorations(); + }, + getSelection: () => terminalRef.current?.getSelection() ?? '', + resize: () => fitAddonRef.current?.fit(), + }), + [] + ); + + return ( +
+ ); + } +); + +XTerminal.displayName = 'XTerminal'; + +export default XTerminal; diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 6171634bd..71ff547e7 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -80,3 +80,10 @@ export const TAB_SHORTCUTS: Record = { goToTab9: { id: 'goToTab9', label: 'Go to Tab 9', keys: ['Meta', '9'] }, goToLastTab: { id: 'goToLastTab', label: 'Go to Last Tab', keys: ['Meta', '0'] }, }; + +// Terminal tab shortcuts (when in terminal mode) +export const TERMINAL_TAB_SHORTCUTS: Record = { + newTerminalTab: { id: 'newTerminalTab', label: 'New Terminal Tab', keys: ['Control', 'Shift', '`'] }, + clearTerminal: { id: 'clearTerminal', label: 'Clear Terminal', keys: ['Meta', 'k'] }, + searchTerminal: { id: 'searchTerminal', label: 'Search Terminal', keys: ['Meta', 'f'] }, +}; diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index dbecd8d10..e66d9fba7 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -194,6 +194,16 @@ interface MaestroAPI { workingDirOverride?: string; }; }) => Promise<{ exitCode: number }>; + // Spawn a terminal PTY for a specific tab (xterm.js integration) + spawnTerminalTab: (config: { + sessionId: string; + cwd: string; + shell?: string; + shellArgs?: string; + shellEnvVars?: Record; + cols?: number; + rows?: number; + }) => Promise<{ pid: number; success: boolean }>; getActiveProcesses: () => Promise t.id === ctx.activeSession.activeTerminalTabId); + if (currentIndex > 0) { + ctx.handleTerminalTabSelect?.(ctx.activeSession.id, terminalTabs[currentIndex - 1].id); + } + trackShortcut('prevTerminalTab'); + return; + } + + // Cmd+Shift+] - Next terminal tab + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === ']') { + e.preventDefault(); + const terminalTabs = ctx.activeSession.terminalTabs ?? []; + const currentIndex = terminalTabs.findIndex((t: { id: string }) => t.id === ctx.activeSession.activeTerminalTabId); + if (currentIndex < terminalTabs.length - 1) { + ctx.handleTerminalTabSelect?.(ctx.activeSession.id, terminalTabs[currentIndex + 1].id); + } + trackShortcut('nextTerminalTab'); + return; + } + + // Cmd+W - Close terminal tab (only if more than one) + if ((e.metaKey || e.ctrlKey) && e.key === 'w' && !e.shiftKey && !e.altKey) { + e.preventDefault(); + const terminalTabs = ctx.activeSession.terminalTabs ?? []; + if (terminalTabs.length > 1) { + ctx.handleTerminalTabClose?.(ctx.activeSession.id, ctx.activeSession.activeTerminalTabId); + } + trackShortcut('closeTerminalTab'); + return; + } + + // Cmd+Shift+T - Reopen closed terminal tab + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 't') { + e.preventDefault(); + ctx.handleReopenTerminalTab?.(ctx.activeSession.id); + trackShortcut('reopenTerminalTab'); + return; + } + + // Cmd+1-9 - Go to terminal tab by number + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { + const num = parseInt(e.key, 10); + if (num >= 1 && num <= 9) { + e.preventDefault(); + const terminalTabs = ctx.activeSession.terminalTabs ?? []; + const targetIndex = num - 1; + if (targetIndex < terminalTabs.length) { + ctx.handleTerminalTabSelect?.(ctx.activeSession.id, terminalTabs[targetIndex].id); + } + trackShortcut(`goToTerminalTab${num}`); + return; + } + } + + // Cmd+0 - Go to last terminal tab + if ((e.metaKey || e.ctrlKey) && e.key === '0' && !e.shiftKey && !e.altKey) { + e.preventDefault(); + const terminalTabs = ctx.activeSession.terminalTabs ?? []; + if (terminalTabs.length > 0) { + ctx.handleTerminalTabSelect?.(ctx.activeSession.id, terminalTabs[terminalTabs.length - 1].id); + } + trackShortcut('goToLastTerminalTab'); + return; + } + } + // Tab shortcuts (AI mode only, requires an explicitly selected session, disabled in group chat view) if (ctx.activeSessionId && ctx.activeSession?.inputMode === 'ai' && ctx.activeSession?.aiTabs && !ctx.activeGroupChatId) { if (ctx.isTabShortcut(e, 'tabSwitcher')) { diff --git a/src/renderer/hooks/utils/useDebouncedPersistence.ts b/src/renderer/hooks/utils/useDebouncedPersistence.ts index 207e55df5..b8bf13d70 100644 --- a/src/renderer/hooks/utils/useDebouncedPersistence.ts +++ b/src/renderer/hooks/utils/useDebouncedPersistence.ts @@ -75,16 +75,34 @@ const prepareSessionForPersistence = (session: Session): Session => { // Return session without runtime-only fields // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { closedTabHistory, agentError, agentErrorPaused, agentErrorTabId, sshConnectionFailed, ...sessionWithoutRuntimeFields } = session; + const { closedTabHistory, closedTerminalTabHistory, agentError, agentErrorPaused, agentErrorTabId, sshConnectionFailed, ...sessionWithoutRuntimeFields } = session; // Ensure activeTabId points to a valid tab (it might have been a wizard tab that got filtered) const activeTabExists = truncatedTabs.some(tab => tab.id === session.activeTabId); const newActiveTabId = activeTabExists ? session.activeTabId : truncatedTabs[0]?.id; + // Clean terminal tabs - reset runtime state (PID, process state, exit code) + // but preserve configuration (shell type, cwd, name, etc.) + const cleanedTerminalTabs = (session.terminalTabs || []).map(tab => ({ + id: tab.id, + name: tab.name, + shellType: tab.shellType, + cwd: tab.cwd, + createdAt: tab.createdAt, + // Reset runtime state - PTY processes don't survive app restart + pid: 0, + state: 'idle' as const, + // Don't persist exitCode - it's runtime-only + })); + return { ...sessionWithoutRuntimeFields, aiTabs: truncatedTabs, activeTabId: newActiveTabId, + // Terminal tabs with runtime state cleaned + terminalTabs: cleanedTerminalTabs, + // Runtime-only, don't persist + closedTerminalTabHistory: undefined, // Reset runtime-only session state - processes don't survive app restart state: 'idle', busySource: undefined, diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 7c2328d3b..8dc017153 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -432,6 +432,34 @@ export interface ClosedTab { closedAt: number; // Timestamp when closed } +/** + * Terminal Tab for multi-tab terminal support within a Maestro session + * Each tab represents a separate PTY shell session with full terminal emulation + */ +export interface TerminalTab { + id: string; // Unique tab ID (generated UUID) + name: string | null; // User-defined name (null = show shell name or "Terminal N") + shellType: string; // Shell being used (e.g., 'zsh', 'bash', 'powershell') + pid: number; // PTY process ID (0 if not spawned yet) + cwd: string; // Current working directory (tracked from shell) + createdAt: number; // Timestamp for ordering + state: 'idle' | 'busy' | 'exited'; // Tab state (busy = command running) + exitCode?: number; // Exit code if shell exited + scrollTop?: number; // Saved scroll position + searchQuery?: string; // Active search query (for Cmd+F persistence) +} + +/** + * Closed terminal tab entry for undo functionality (Cmd+Shift+T) + * Note: Terminal tabs cannot be fully restored since the PTY session is gone, + * but we can recreate a new terminal in the same position with same settings + */ +export interface ClosedTerminalTab { + tab: TerminalTab; // The closed tab data (sans PTY state) + index: number; // Original position in the tab array + closedAt: number; // Timestamp when closed +} + export interface Session { id: string; groupId?: string; @@ -442,6 +470,8 @@ export interface Session { fullPath: string; projectRoot: string; // The initial working directory (never changes, used for Claude session storage) aiLogs: LogEntry[]; + // DEPRECATED: Legacy shell output logs - will be removed after terminal tabs migration + // Terminal tabs use xterm.js with direct PTY streaming, not log entries shellLogs: LogEntry[]; workLog: WorkLogItem[]; contextUsage: number; @@ -451,8 +481,8 @@ export interface Session { // AI process PID (for non-batch agents like Aider) // For Claude batch mode, this is 0 since processes spawn per-message aiPid: number; - // Terminal uses runCommand() which spawns fresh shells per command - // This field is kept for backwards compatibility but is always 0 + // DEPRECATED: Single terminal PID - replaced by terminalTabs[].pid + // Kept for backwards compatibility during migration, always 0 terminalPid: number; port: number; // Live mode - makes session accessible via web interface @@ -554,6 +584,14 @@ export interface Session { // Draft input for terminal mode (persisted across session switches) terminalDraftInput?: string; + // Terminal tab management (multi-tab terminal support with full PTY emulation) + // Each terminal tab represents a separate shell session with xterm.js rendering + terminalTabs?: TerminalTab[]; + // Currently active terminal tab ID + activeTerminalTabId?: string; + // Stack of recently closed terminal tabs for undo (max 10, runtime-only) + closedTerminalTabHistory?: ClosedTerminalTab[]; + // Auto Run panel state (file-based document runner) autoRunFolderPath?: string; // Persisted folder path for Runner Docs autoRunSelectedFile?: string; // Currently selected markdown filename diff --git a/src/renderer/utils/terminalTabHelpers.ts b/src/renderer/utils/terminalTabHelpers.ts new file mode 100644 index 000000000..5b36b8906 --- /dev/null +++ b/src/renderer/utils/terminalTabHelpers.ts @@ -0,0 +1,92 @@ +/** + * Terminal tab helper utilities + * Mirrors the pattern from tabHelpers.ts for AI tabs + */ + +import type { Session, TerminalTab, ClosedTerminalTab } from '../types'; +import { generateId } from './ids'; + +/** + * Get the active terminal tab for a session + */ +export function getActiveTerminalTab(session: Session): TerminalTab | undefined { + return session.terminalTabs?.find(tab => tab.id === session.activeTerminalTabId); +} + +/** + * Create a new terminal tab with default values + */ +export function createTerminalTab( + shellType: string = 'zsh', + cwd: string = '', + name: string | null = null +): TerminalTab { + return { + id: generateId(), + name, + shellType, + pid: 0, + cwd, + createdAt: Date.now(), + state: 'idle', + }; +} + +/** + * Get display name for a terminal tab + * Priority: name > "Terminal N" (by index) + */ +export function getTerminalTabDisplayName(tab: TerminalTab, index: number): string { + if (tab.name) { + return tab.name; + } + return `Terminal ${index + 1}`; +} + +/** + * Generate the PTY session ID for a terminal tab + * Format: {sessionId}-terminal-{tabId} + */ +export function getTerminalSessionId(sessionId: string, tabId: string): string { + return `${sessionId}-terminal-${tabId}`; +} + +/** + * Parse a terminal session ID to extract session ID and tab ID + * Returns null if the format doesn't match + */ +export function parseTerminalSessionId(terminalSessionId: string): { sessionId: string; tabId: string } | null { + const match = terminalSessionId.match(/^(.+)-terminal-(.+)$/); + if (!match) return null; + return { sessionId: match[1], tabId: match[2] }; +} + +/** + * Check if any terminal tab in a session has a running process + */ +export function hasRunningTerminalProcess(session: Session): boolean { + return session.terminalTabs?.some(tab => tab.state === 'busy') ?? false; +} + +/** + * Get the count of active (non-exited) terminal tabs + */ +export function getActiveTerminalTabCount(session: Session): number { + return session.terminalTabs?.filter(tab => tab.state !== 'exited').length ?? 0; +} + +/** + * Create a closed terminal tab entry for undo stack + */ +export function createClosedTerminalTab(tab: TerminalTab, index: number): ClosedTerminalTab { + return { + tab: { ...tab, pid: 0, state: 'idle' }, // Reset runtime state + index, + closedAt: Date.now(), + }; +} + +/** + * Maximum closed terminal tabs to keep in history + */ +export const MAX_CLOSED_TERMINAL_TABS = 10;