Skip to content

Commit de8d1da

Browse files
Merge pull request #1424 from shreyaspapi/feat/expandable-mini-terminal
feat(terminal): expandable mini terminal — full-screen modal view
2 parents 5c51961 + f244298 commit de8d1da

3 files changed

Lines changed: 194 additions & 1 deletion

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import React, { useEffect, useRef, useCallback } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import { Minimize2 } from 'lucide-react';
4+
import { Button } from './ui/button';
5+
import { cn } from '@/lib/utils';
6+
import { TITLEBAR_HEIGHT } from '../constants/layout';
7+
import { terminalSessionRegistry } from '../terminal/SessionRegistry';
8+
9+
interface Props {
10+
terminalId: string;
11+
title?: string;
12+
onClose: () => void;
13+
variant?: 'dark' | 'light';
14+
}
15+
16+
/**
17+
* Full-screen modal overlay that re-attaches an existing terminal session.
18+
* The session is detached from the mini terminal in the sidebar and attached
19+
* to this modal's container. On close, the session returns to the sidebar.
20+
*/
21+
const ExpandedTerminalModal: React.FC<Props> = ({ terminalId, title, onClose, variant }) => {
22+
const containerRef = useRef<HTMLDivElement>(null);
23+
24+
// Attach terminal session to the modal container on mount
25+
useEffect(() => {
26+
const container = containerRef.current;
27+
if (!container || !terminalId) return;
28+
29+
// Attach to this modal's container — attach() internally detaches first
30+
const session = terminalSessionRegistry.reattach(terminalId, container);
31+
32+
// Focus the terminal after DOM settles — double rAF ensures xterm has
33+
// opened and fitted before we try to grab focus.
34+
requestAnimationFrame(() => {
35+
requestAnimationFrame(() => {
36+
session?.focus();
37+
});
38+
});
39+
40+
return () => {
41+
// On unmount, detach from modal — TerminalPane in sidebar will re-attach
42+
terminalSessionRegistry.detach(terminalId);
43+
};
44+
}, [terminalId]);
45+
46+
// Handle Escape key to close
47+
const handleKeyDown = useCallback(
48+
(e: KeyboardEvent) => {
49+
// Only close on Escape when not inside the terminal textarea
50+
// (xterm captures Escape for its own use only when not at a prompt)
51+
if (
52+
e.key === 'Escape' &&
53+
!(e.target as HTMLElement)?.classList?.contains('xterm-helper-textarea')
54+
) {
55+
e.preventDefault();
56+
e.stopPropagation();
57+
onClose();
58+
}
59+
},
60+
[onClose]
61+
);
62+
63+
useEffect(() => {
64+
window.addEventListener('keydown', handleKeyDown, true);
65+
return () => window.removeEventListener('keydown', handleKeyDown, true);
66+
}, [handleKeyDown]);
67+
68+
const isDark = variant === 'dark';
69+
70+
return createPortal(
71+
<div
72+
className="fixed inset-0 z-[900] flex flex-col"
73+
data-expanded-terminal="true"
74+
aria-label="Expanded terminal"
75+
>
76+
{/* Backdrop */}
77+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
78+
79+
{/* Modal content — top margin clears the titlebar / traffic lights */}
80+
<div
81+
className={cn(
82+
'relative z-10 mx-4 mb-4 flex flex-1 flex-col overflow-hidden rounded-lg border shadow-2xl',
83+
isDark ? 'border-zinc-700 bg-zinc-900' : 'border-border bg-background'
84+
)}
85+
style={{ marginTop: `calc(${TITLEBAR_HEIGHT} + 8px)` }}
86+
>
87+
{/* Header */}
88+
<div
89+
className={cn(
90+
'flex items-center justify-between border-b px-4 py-2',
91+
isDark ? 'border-zinc-700' : 'border-border'
92+
)}
93+
>
94+
<span
95+
className={cn(
96+
'text-xs font-medium',
97+
isDark ? 'text-zinc-300' : 'text-muted-foreground'
98+
)}
99+
>
100+
{title || 'Terminal'}
101+
</span>
102+
<Button
103+
variant="ghost"
104+
size="icon-sm"
105+
onClick={onClose}
106+
className={cn(
107+
isDark
108+
? 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
109+
: 'text-muted-foreground hover:text-foreground'
110+
)}
111+
title="Collapse terminal (Esc)"
112+
>
113+
<Minimize2 className="h-3.5 w-3.5" />
114+
</Button>
115+
</div>
116+
117+
{/* Terminal container — click to focus */}
118+
<div
119+
ref={containerRef}
120+
className="flex-1 overflow-hidden"
121+
onClick={() => {
122+
const session = terminalSessionRegistry.getSession(terminalId);
123+
session?.focus();
124+
}}
125+
/>
126+
</div>
127+
</div>,
128+
document.body
129+
);
130+
};
131+
132+
export default ExpandedTerminalModal;

src/renderer/components/TaskTerminalPanel.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
22
import { TerminalPane } from './TerminalPane';
33
import { LifecycleTerminalView } from './LifecycleTerminalView';
4-
import { Plus, Play, RotateCw, Square, X } from 'lucide-react';
4+
import { Plus, Play, RotateCw, Square, X, Maximize2 } from 'lucide-react';
55
import { useTheme } from '../hooks/useTheme';
66
import { useTaskTerminals } from '@/lib/taskTerminalsStore';
77
import { useTerminalSelection } from '../hooks/useTerminalSelection';
@@ -29,6 +29,7 @@ import {
2929
formatLifecycleLogLine,
3030
} from '@shared/lifecycle';
3131
import { shouldDisablePlay } from '../lib/lifecycleUi';
32+
import ExpandedTerminalModal from './ExpandedTerminalModal';
3233

3334
interface Task {
3435
id: string;
@@ -81,6 +82,16 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
8182

8283
const selection = useTerminalSelection({ task, taskTerminals, globalTerminals });
8384

85+
const [expandedTerminalId, setExpandedTerminalId] = useState<string | null>(null);
86+
// Tracks which terminal needs a key bump to force re-attach after modal close
87+
const [reattachId, setReattachId] = useState<string | null>(null);
88+
const reattachCounter = useRef(0);
89+
const handleCloseExpandedTerminal = useCallback(() => {
90+
setReattachId(expandedTerminalId);
91+
reattachCounter.current += 1;
92+
setExpandedTerminalId(null);
93+
}, [expandedTerminalId]);
94+
8495
const panelRef = useRef<HTMLDivElement | null>(null);
8596
const terminalRefs = useRef<Map<string, { focus: () => void }>>(new Map());
8697
const setTerminalRef = useCallback((id: string, ref: { focus: () => void } | null) => {
@@ -645,6 +656,27 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
645656
</TooltipProvider>
646657
)}
647658

659+
{/* Expand terminal to full-screen modal */}
660+
{selection.activeTerminalId && !selection.selectedLifecycle && (
661+
<TooltipProvider delayDuration={200}>
662+
<Tooltip>
663+
<TooltipTrigger asChild>
664+
<Button
665+
variant="ghost"
666+
size="icon-sm"
667+
onClick={() => setExpandedTerminalId(selection.activeTerminalId)}
668+
className="text-muted-foreground hover:text-foreground"
669+
>
670+
<Maximize2 className="h-3.5 w-3.5" />
671+
</Button>
672+
</TooltipTrigger>
673+
<TooltipContent side="bottom">
674+
<p className="text-xs">Expand terminal</p>
675+
</TooltipContent>
676+
</Tooltip>
677+
</TooltipProvider>
678+
)}
679+
648680
{(() => {
649681
const canDelete =
650682
selection.parsed?.mode === 'task'
@@ -743,6 +775,7 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
743775
)}
744776
>
745777
<TerminalPane
778+
key={`${terminal.id}${reattachId === terminal.id ? `::${reattachCounter.current}` : ''}`}
746779
ref={(r) => setTerminalRef(terminal.id, r)}
747780
id={terminal.id}
748781
cwd={terminal.cwd || task.path}
@@ -774,6 +807,7 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
774807
)}
775808
>
776809
<TerminalPane
810+
key={`${terminal.id}${reattachId === terminal.id ? `::${reattachCounter.current}` : ''}`}
777811
ref={(r) => setTerminalRef(terminal.id, r)}
778812
id={terminal.id}
779813
cwd={terminal.cwd || projectPath}
@@ -795,6 +829,21 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
795829
) : null}
796830
</div>
797831
)}
832+
{/* Expanded terminal modal */}
833+
{expandedTerminalId && (
834+
<ExpandedTerminalModal
835+
terminalId={expandedTerminalId}
836+
title={
837+
selection.parsed?.mode === 'task'
838+
? `${task?.name || 'Task'} — Terminal`
839+
: selection.parsed?.mode === 'global'
840+
? 'Project Terminal'
841+
: 'Terminal'
842+
}
843+
onClose={handleCloseExpandedTerminal}
844+
variant={effectiveTheme === 'dark' || effectiveTheme === 'dark-black' ? 'dark' : 'light'}
845+
/>
846+
)}
798847
</div>
799848
);
800849
};

src/renderer/terminal/SessionRegistry.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ class SessionRegistry {
4747
this.sessions.delete(taskId);
4848
}
4949

50+
/**
51+
* Re-attach an existing session to a new container without creating a new one.
52+
* Used by the expanded terminal modal to move a session from the sidebar
53+
* into a full-screen overlay while preserving the PTY and scrollback.
54+
*/
55+
reattach(taskId: string, container: HTMLElement): TerminalSessionManager | null {
56+
const session = this.sessions.get(taskId);
57+
if (!session) return null;
58+
session.attach(container);
59+
return session;
60+
}
61+
5062
getSession(taskId: string): TerminalSessionManager | undefined {
5163
return this.sessions.get(taskId);
5264
}

0 commit comments

Comments
 (0)