diff --git a/src/renderer/components/ExpandedTerminalModal.tsx b/src/renderer/components/ExpandedTerminalModal.tsx new file mode 100644 index 000000000..4488cec2a --- /dev/null +++ b/src/renderer/components/ExpandedTerminalModal.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { Minimize2 } from 'lucide-react'; +import { Button } from './ui/button'; +import { cn } from '@/lib/utils'; +import { TITLEBAR_HEIGHT } from '../constants/layout'; +import { terminalSessionRegistry } from '../terminal/SessionRegistry'; + +interface Props { + terminalId: string; + title?: string; + onClose: () => void; + variant?: 'dark' | 'light'; +} + +/** + * Full-screen modal overlay that re-attaches an existing terminal session. + * The session is detached from the mini terminal in the sidebar and attached + * to this modal's container. On close, the session returns to the sidebar. + */ +const ExpandedTerminalModal: React.FC = ({ terminalId, title, onClose, variant }) => { + const containerRef = useRef(null); + + // Attach terminal session to the modal container on mount + useEffect(() => { + const container = containerRef.current; + if (!container || !terminalId) return; + + // Attach to this modal's container — attach() internally detaches first + const session = terminalSessionRegistry.reattach(terminalId, container); + + // Focus the terminal after DOM settles — double rAF ensures xterm has + // opened and fitted before we try to grab focus. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + session?.focus(); + }); + }); + + return () => { + // On unmount, detach from modal — TerminalPane in sidebar will re-attach + terminalSessionRegistry.detach(terminalId); + }; + }, [terminalId]); + + // Handle Escape key to close + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Only close on Escape when not inside the terminal textarea + // (xterm captures Escape for its own use only when not at a prompt) + if ( + e.key === 'Escape' && + !(e.target as HTMLElement)?.classList?.contains('xterm-helper-textarea') + ) { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }, + [onClose] + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [handleKeyDown]); + + const isDark = variant === 'dark'; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Modal content — top margin clears the titlebar / traffic lights */} +
+ {/* Header */} +
+ + {title || 'Terminal'} + + +
+ + {/* Terminal container — click to focus */} +
{ + const session = terminalSessionRegistry.getSession(terminalId); + session?.focus(); + }} + /> +
+
, + document.body + ); +}; + +export default ExpandedTerminalModal; diff --git a/src/renderer/components/TaskTerminalPanel.tsx b/src/renderer/components/TaskTerminalPanel.tsx index 8cb44c01d..91e783dac 100644 --- a/src/renderer/components/TaskTerminalPanel.tsx +++ b/src/renderer/components/TaskTerminalPanel.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { TerminalPane } from './TerminalPane'; import { LifecycleTerminalView } from './LifecycleTerminalView'; -import { Plus, Play, RotateCw, Square, X } from 'lucide-react'; +import { Plus, Play, RotateCw, Square, X, Maximize2 } from 'lucide-react'; import { useTheme } from '../hooks/useTheme'; import { useTaskTerminals } from '@/lib/taskTerminalsStore'; import { useTerminalSelection } from '../hooks/useTerminalSelection'; @@ -29,6 +29,7 @@ import { formatLifecycleLogLine, } from '@shared/lifecycle'; import { shouldDisablePlay } from '../lib/lifecycleUi'; +import ExpandedTerminalModal from './ExpandedTerminalModal'; interface Task { id: string; @@ -81,6 +82,16 @@ const TaskTerminalPanelComponent: React.FC = ({ const selection = useTerminalSelection({ task, taskTerminals, globalTerminals }); + const [expandedTerminalId, setExpandedTerminalId] = useState(null); + // Tracks which terminal needs a key bump to force re-attach after modal close + const [reattachId, setReattachId] = useState(null); + const reattachCounter = useRef(0); + const handleCloseExpandedTerminal = useCallback(() => { + setReattachId(expandedTerminalId); + reattachCounter.current += 1; + setExpandedTerminalId(null); + }, [expandedTerminalId]); + const panelRef = useRef(null); const terminalRefs = useRef void }>>(new Map()); const setTerminalRef = useCallback((id: string, ref: { focus: () => void } | null) => { @@ -645,6 +656,27 @@ const TaskTerminalPanelComponent: React.FC = ({ )} + {/* Expand terminal to full-screen modal */} + {selection.activeTerminalId && !selection.selectedLifecycle && ( + + + + + + +

Expand terminal

+
+
+
+ )} + {(() => { const canDelete = selection.parsed?.mode === 'task' @@ -743,6 +775,7 @@ const TaskTerminalPanelComponent: React.FC = ({ )} > setTerminalRef(terminal.id, r)} id={terminal.id} cwd={terminal.cwd || task.path} @@ -774,6 +807,7 @@ const TaskTerminalPanelComponent: React.FC = ({ )} > setTerminalRef(terminal.id, r)} id={terminal.id} cwd={terminal.cwd || projectPath} @@ -795,6 +829,21 @@ const TaskTerminalPanelComponent: React.FC = ({ ) : null}
)} + {/* Expanded terminal modal */} + {expandedTerminalId && ( + + )}
); }; diff --git a/src/renderer/terminal/SessionRegistry.ts b/src/renderer/terminal/SessionRegistry.ts index 0bb4dfe89..a56650577 100644 --- a/src/renderer/terminal/SessionRegistry.ts +++ b/src/renderer/terminal/SessionRegistry.ts @@ -47,6 +47,18 @@ class SessionRegistry { this.sessions.delete(taskId); } + /** + * Re-attach an existing session to a new container without creating a new one. + * Used by the expanded terminal modal to move a session from the sidebar + * into a full-screen overlay while preserving the PTY and scrollback. + */ + reattach(taskId: string, container: HTMLElement): TerminalSessionManager | null { + const session = this.sessions.get(taskId); + if (!session) return null; + session.attach(container); + return session; + } + getSession(taskId: string): TerminalSessionManager | undefined { return this.sessions.get(taskId); }