Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
132 changes: 132 additions & 0 deletions src/renderer/components/ExpandedTerminalModal.tsx
Original file line number Diff line number Diff line change
@@ -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<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;

// 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(
<div
className="fixed inset-0 z-[900] flex flex-col"
data-expanded-terminal="true"
aria-label="Expanded terminal"
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />

{/* Modal content — top margin clears the titlebar / traffic lights */}
<div
className={cn(
'relative z-10 mx-4 mb-4 flex flex-1 flex-col overflow-hidden rounded-lg border shadow-2xl',
isDark ? 'border-zinc-700 bg-zinc-900' : 'border-border bg-background'
)}
style={{ marginTop: `calc(${TITLEBAR_HEIGHT} + 8px)` }}
>
{/* 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>
<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>
</div>

{/* Terminal container — click to focus */}
<div
ref={containerRef}
className="flex-1 overflow-hidden"
onClick={() => {
const session = terminalSessionRegistry.getSession(terminalId);
session?.focus();
}}
/>
</div>
</div>,
document.body
);
};

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

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

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

const [expandedTerminalId, setExpandedTerminalId] = useState<string | null>(null);
// Tracks which terminal needs a key bump to force re-attach after modal close
const [reattachId, setReattachId] = useState<string | null>(null);
const reattachCounter = useRef(0);
const handleCloseExpandedTerminal = useCallback(() => {
setReattachId(expandedTerminalId);
reattachCounter.current += 1;
setExpandedTerminalId(null);
}, [expandedTerminalId]);

const panelRef = useRef<HTMLDivElement | null>(null);
const terminalRefs = useRef<Map<string, { focus: () => void }>>(new Map());
const setTerminalRef = useCallback((id: string, ref: { focus: () => void } | null) => {
Expand Down Expand Up @@ -645,6 +656,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 @@ -743,6 +775,7 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
)}
>
<TerminalPane
key={`${terminal.id}${reattachId === terminal.id ? `::${reattachCounter.current}` : ''}`}
ref={(r) => setTerminalRef(terminal.id, r)}
id={terminal.id}
cwd={terminal.cwd || task.path}
Expand Down Expand Up @@ -774,6 +807,7 @@ const TaskTerminalPanelComponent: React.FC<Props> = ({
)}
>
<TerminalPane
key={`${terminal.id}${reattachId === terminal.id ? `::${reattachCounter.current}` : ''}`}
ref={(r) => setTerminalRef(terminal.id, r)}
id={terminal.id}
cwd={terminal.cwd || projectPath}
Expand All @@ -795,6 +829,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
Loading