Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions src/renderer/components/ExpandedTerminalModal.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ terminalId, title, onClose, variant }) => {
const containerRef = useRef<HTMLDivElement>(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(
<div
className="fixed inset-0 z-[900] flex flex-col"
role="dialog"
aria-label="Expanded terminal"
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />

{/* Modal content */}
<div
className={cn(
'relative z-10 mx-4 my-4 flex flex-1 flex-col overflow-hidden rounded-lg border shadow-2xl',
isDark ? 'border-zinc-700 bg-zinc-900' : 'border-border bg-background'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b px-4 py-2',
isDark ? 'border-zinc-700' : 'border-border'
)}
>
<span
className={cn(
'text-xs font-medium',
isDark ? 'text-zinc-300' : 'text-muted-foreground'
)}
>
{title || 'Terminal'}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
className={cn(
isDark
? 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
: 'text-muted-foreground hover:text-foreground'
)}
title="Collapse terminal (Esc)"
>
<Minimize2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
className={cn(
isDark
? 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
: 'text-muted-foreground hover:text-foreground'
)}
title="Close"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>

{/* Terminal container */}
<div ref={containerRef} className="flex-1 overflow-hidden" />
</div>
</div>,
document.body
);
};

export default ExpandedTerminalModal;
49 changes: 48 additions & 1 deletion src/renderer/components/TaskTerminalPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,6 +26,7 @@ import {
formatLifecycleLogLine,
} from '@shared/lifecycle';
import { shouldDisablePlay } from '../lib/lifecycleUi';
import ExpandedTerminalModal from './ExpandedTerminalModal';

interface Task {
id: string;
Expand Down Expand Up @@ -78,6 +79,14 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({

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

const [expandedTerminalId, setExpandedTerminalId] = useState<string | null>(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<Map<string, { focus: () => void }>>(new Map());
const setTerminalRef = useCallback((id: string, ref: { focus: () => void } | null) => {
if (ref) {
Expand Down Expand Up @@ -615,6 +624,27 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
</TooltipProvider>
)}

{/* Expand terminal to full-screen modal */}
{selection.activeTerminalId && !selection.selectedLifecycle && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setExpandedTerminalId(selection.activeTerminalId)}
className="text-muted-foreground hover:text-foreground"
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">Expand terminal</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}

{(() => {
const canDelete =
selection.parsed?.mode === 'task'
Expand Down Expand Up @@ -698,6 +728,7 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
)}
>
<TerminalPane
key={`${terminal.id}::${reattachKey}`}
ref={(r) => setTerminalRef(terminal.id, r)}
id={terminal.id}
cwd={terminal.cwd || task.path}
Expand Down Expand Up @@ -729,6 +760,7 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
)}
>
<TerminalPane
key={`${terminal.id}::${reattachKey}`}
ref={(r) => setTerminalRef(terminal.id, r)}
id={terminal.id}
cwd={terminal.cwd || projectPath}
Expand All @@ -750,6 +782,21 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
) : null}
</div>
)}
{/* Expanded terminal modal */}
{expandedTerminalId && (
<ExpandedTerminalModal
terminalId={expandedTerminalId}
title={
selection.parsed?.mode === 'task'
? `${task?.name || 'Task'} — Terminal`
: selection.parsed?.mode === 'global'
? 'Project Terminal'
: 'Terminal'
}
onClose={handleCloseExpandedTerminal}
variant={effectiveTheme === 'dark' || effectiveTheme === 'dark-black' ? 'dark' : 'light'}
/>
)}
</div>
);
};
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/terminal/SessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down