From ffdd9c13f5d2fc62534b9cc973a325f6c29a0815 Mon Sep 17 00:00:00 2001 From: Shreyas Papinwar Date: Thu, 12 Mar 2026 00:39:11 +0530 Subject: [PATCH 1/4] feat(terminal): add expandable mini terminal modal --- .../components/ExpandedTerminalModal.tsx | 136 ++++++++++++++++++ src/renderer/components/TaskTerminalPanel.tsx | 49 ++++++- src/renderer/terminal/SessionRegistry.ts | 12 ++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/renderer/components/ExpandedTerminalModal.tsx diff --git a/src/renderer/components/ExpandedTerminalModal.tsx b/src/renderer/components/ExpandedTerminalModal.tsx new file mode 100644 index 000000000..128d6509b --- /dev/null +++ b/src/renderer/components/ExpandedTerminalModal.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { Minimize2, X } from 'lucide-react'; +import { Button } from './ui/button'; +import { cn } from '@/lib/utils'; +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; + + // Detach from sidebar, attach to this modal's container + terminalSessionRegistry.detach(terminalId); + const session = terminalSessionRegistry.reattach(terminalId, container); + + // Focus the terminal after a short delay for DOM to settle + 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 */} +
+ {/* Header */} +
+ + {title || 'Terminal'} + +
+ + +
+
+ + {/* Terminal container */} +
+
+
, + document.body + ); +}; + +export default ExpandedTerminalModal; diff --git a/src/renderer/components/TaskTerminalPanel.tsx b/src/renderer/components/TaskTerminalPanel.tsx index 3a0cf0410..a7ac29b87 100644 --- a/src/renderer/components/TaskTerminalPanel.tsx +++ b/src/renderer/components/TaskTerminalPanel.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { TerminalPane } from './TerminalPane'; -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'; @@ -26,6 +26,7 @@ import { formatLifecycleLogLine, } from '@shared/lifecycle'; import { shouldDisablePlay } from '../lib/lifecycleUi'; +import ExpandedTerminalModal from './ExpandedTerminalModal'; interface Task { id: string; @@ -78,6 +79,14 @@ const TaskTerminalPanelComponent: React.FC = ({ const selection = useTerminalSelection({ task, taskTerminals, globalTerminals }); + const [expandedTerminalId, setExpandedTerminalId] = useState(null); + // Bumped when the expanded modal closes to force the sidebar TerminalPane to re-attach + const [reattachKey, setReattachKey] = useState(0); + const handleCloseExpandedTerminal = useCallback(() => { + setExpandedTerminalId(null); + setReattachKey((k) => k + 1); + }, []); + const terminalRefs = useRef void }>>(new Map()); const setTerminalRef = useCallback((id: string, ref: { focus: () => void } | null) => { if (ref) { @@ -615,6 +624,27 @@ const TaskTerminalPanelComponent: React.FC = ({ )} + {/* Expand terminal to full-screen modal */} + {selection.activeTerminalId && !selection.selectedLifecycle && ( + + + + + + +

Expand terminal

+
+
+
+ )} + {(() => { const canDelete = selection.parsed?.mode === 'task' @@ -698,6 +728,7 @@ const TaskTerminalPanelComponent: React.FC = ({ )} > setTerminalRef(terminal.id, r)} id={terminal.id} cwd={terminal.cwd || task.path} @@ -729,6 +760,7 @@ const TaskTerminalPanelComponent: React.FC = ({ )} > setTerminalRef(terminal.id, r)} id={terminal.id} cwd={terminal.cwd || projectPath} @@ -750,6 +782,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); } From 31e2a6150313ed00b52a6eed727d140a9ccbf6ce Mon Sep 17 00:00:00 2001 From: Shreyas Papinwar Date: Thu, 12 Mar 2026 00:49:31 +0530 Subject: [PATCH 2/4] fix(terminal): allow keyboard input in expanded terminal modal --- .../components/ExpandedTerminalModal.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/ExpandedTerminalModal.tsx b/src/renderer/components/ExpandedTerminalModal.tsx index 128d6509b..736340fce 100644 --- a/src/renderer/components/ExpandedTerminalModal.tsx +++ b/src/renderer/components/ExpandedTerminalModal.tsx @@ -29,9 +29,12 @@ const ExpandedTerminalModal: React.FC = ({ terminalId, title, onClose, va terminalSessionRegistry.detach(terminalId); const session = terminalSessionRegistry.reattach(terminalId, container); - // Focus the terminal after a short delay for DOM to settle + // Focus the terminal after DOM settles — double rAF ensures xterm has + // opened and fitted before we try to grab focus. requestAnimationFrame(() => { - session?.focus(); + requestAnimationFrame(() => { + session?.focus(); + }); }); return () => { @@ -67,7 +70,7 @@ const ExpandedTerminalModal: React.FC = ({ terminalId, title, onClose, va return createPortal(
{/* Backdrop */} @@ -125,8 +128,15 @@ const ExpandedTerminalModal: React.FC = ({ terminalId, title, onClose, va
- {/* Terminal container */} -
+ {/* Terminal container — click to focus */} +
{ + const session = terminalSessionRegistry.getSession(terminalId); + session?.focus(); + }} + />
, document.body From 17372ad1599493925fe97ea3c2d9fbef4225addb Mon Sep 17 00:00:00 2001 From: Shreyas Papinwar Date: Thu, 12 Mar 2026 00:52:38 +0530 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?remove=20duplicate=20button,=20scope=20reattach=20key,=20remove?= =?UTF-8?q?=20redundant=20detach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ExpandedTerminalModal.tsx | 46 ++++++------------- src/renderer/components/TaskTerminalPanel.tsx | 14 +++--- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/src/renderer/components/ExpandedTerminalModal.tsx b/src/renderer/components/ExpandedTerminalModal.tsx index 736340fce..d98769d6e 100644 --- a/src/renderer/components/ExpandedTerminalModal.tsx +++ b/src/renderer/components/ExpandedTerminalModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Minimize2, X } from 'lucide-react'; +import { Minimize2 } from 'lucide-react'; import { Button } from './ui/button'; import { cn } from '@/lib/utils'; import { terminalSessionRegistry } from '../terminal/SessionRegistry'; @@ -25,8 +25,7 @@ const ExpandedTerminalModal: React.FC = ({ terminalId, title, onClose, va const container = containerRef.current; if (!container || !terminalId) return; - // Detach from sidebar, attach to this modal's container - terminalSessionRegistry.detach(terminalId); + // 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 @@ -98,34 +97,19 @@ const ExpandedTerminalModal: React.FC = ({ terminalId, title, onClose, va > {title || 'Terminal'} -
- - -
+ {/* Terminal container — click to focus */} diff --git a/src/renderer/components/TaskTerminalPanel.tsx b/src/renderer/components/TaskTerminalPanel.tsx index a7ac29b87..3e3be835c 100644 --- a/src/renderer/components/TaskTerminalPanel.tsx +++ b/src/renderer/components/TaskTerminalPanel.tsx @@ -80,12 +80,14 @@ const TaskTerminalPanelComponent: React.FC = ({ const selection = useTerminalSelection({ task, taskTerminals, globalTerminals }); const [expandedTerminalId, setExpandedTerminalId] = useState(null); - // Bumped when the expanded modal closes to force the sidebar TerminalPane to re-attach - const [reattachKey, setReattachKey] = useState(0); + // 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); - setReattachKey((k) => k + 1); - }, []); + }, [expandedTerminalId]); const terminalRefs = useRef void }>>(new Map()); const setTerminalRef = useCallback((id: string, ref: { focus: () => void } | null) => { @@ -728,7 +730,7 @@ const TaskTerminalPanelComponent: React.FC = ({ )} > setTerminalRef(terminal.id, r)} id={terminal.id} cwd={terminal.cwd || task.path} @@ -760,7 +762,7 @@ const TaskTerminalPanelComponent: React.FC = ({ )} > setTerminalRef(terminal.id, r)} id={terminal.id} cwd={terminal.cwd || projectPath} From 5cf6773c7bd7fadac5e3c09f1071eed8fb97804b Mon Sep 17 00:00:00 2001 From: Shreyas Papinwar Date: Thu, 12 Mar 2026 01:02:05 +0530 Subject: [PATCH 4/4] fix(terminal): prevent expanded modal from overlapping titlebar --- src/renderer/components/ExpandedTerminalModal.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/ExpandedTerminalModal.tsx b/src/renderer/components/ExpandedTerminalModal.tsx index d98769d6e..4488cec2a 100644 --- a/src/renderer/components/ExpandedTerminalModal.tsx +++ b/src/renderer/components/ExpandedTerminalModal.tsx @@ -3,6 +3,7 @@ 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 { @@ -75,12 +76,13 @@ const ExpandedTerminalModal: React.FC = ({ terminalId, title, onClose, va {/* Backdrop */}
- {/* Modal content */} + {/* Modal content — top margin clears the titlebar / traffic lights */}
{/* Header */}