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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import { useEffect, useRef } from 'react';
import type { Socket } from 'socket.io-client';
import type { Terminal as XtermTerminalInstance } from '@xterm/xterm';
import { useEditingStore } from '@/stores/useEditingStore';
import { useXtermTheme } from '@/hooks/useXtermTheme';
import { getCssVar } from '@/lib/theme/css-color-resolution';

const FALLBACK_FONT_FAMILY = "ui-monospace, SFMono-Regular, Menlo, Monaco, 'Courier New', monospace";

export interface AgentTerminalConnectPayload {
terminalId: string;
Expand All @@ -23,6 +28,21 @@ interface XtermTerminalProps {

export default function XtermTerminal({ socket, sessionId, connectPayload, onReady, onError }: XtermTerminalProps) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XtermTerminalInstance | null>(null);
const theme = useXtermTheme();
// Read at creation time only — the connect effect below is intentionally
// NOT keyed on `theme` (see its own comment), so later theme changes are
// pushed live via the effect further down instead of through this ref.
const themeRef = useRef(theme);
themeRef.current = theme;

// Live theme updates without tearing down the [socket, sessionId]-keyed
// connection effect — xterm supports assigning `options.theme` directly.
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = theme;
}
}, [theme]);

useEffect(() => {
if (!containerRef.current) return;
Expand All @@ -46,7 +66,17 @@ export default function XtermTerminal({ socket, sessionId, connectPayload, onRea

if (cancelled || !containerRef.current) return;

const terminal = new Terminal({ cursorBlink: true });
const fontFamily = getCssVar('--font-mono') || FALLBACK_FONT_FAMILY;
const terminal = new Terminal({
cursorBlink: true,
theme: themeRef.current,
fontFamily,
fontSize: 13,
cursorStyle: 'bar',
letterSpacing: 0,
lineHeight: 1.35,
});
terminalRef.current = terminal;
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(containerRef.current);
Expand Down Expand Up @@ -101,6 +131,9 @@ export default function XtermTerminal({ socket, sessionId, connectPayload, onRea
// timeout) correctly reflects "this one pane closed".
socket.emit('agent-terminal:disconnect', { connectionId });
terminal.dispose();
if (terminalRef.current === terminal) {
terminalRef.current = null;
}
useEditingStore.getState().endEditing(sessionId);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Fragment, useCallback, useState } from 'react';
import dynamic from 'next/dynamic';
import type { Socket } from 'socket.io-client';
import { SquareSplitHorizontal, X } from 'lucide-react';
import { SquareSplitHorizontal, SquareSplitVertical, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { useTerminalWorkspaceStore, selectWorkspace, type OpenTerminalScope, type TerminalPaneState } from '@/stores/terminal-workspace/useTerminalWorkspaceStore';
Expand All @@ -18,53 +18,51 @@ interface TerminalPanesProps {
socket: Socket | null | undefined;
}

function scopeLabel(scope: OpenTerminalScope): string {
return [scope.projectName, scope.branchName, scope.name].filter(Boolean).join('/');
}

function paneSessionId(terminalId: string, scope: OpenTerminalScope): string {
return `agent-terminal:${terminalId}:${scope.projectName ?? ''}:${scope.branchName ?? ''}:${scope.name}`;
}

export default function TerminalPanes({ terminalId, socket }: TerminalPanesProps) {
const workspace = useTerminalWorkspaceStore(selectWorkspace(terminalId));
const split = useTerminalWorkspaceStore((state) => state.split);
const splitRight = useTerminalWorkspaceStore((state) => state.splitRight);
const splitDown = useTerminalWorkspaceStore((state) => state.splitDown);
const closePane = useTerminalWorkspaceStore((state) => state.closePane);
const selectPane = useTerminalWorkspaceStore((state) => state.selectPane);

// Briefly undefined between this component's first render and the
// mounting TerminalWorkspace's ensureWorkspace effect committing.
if (!workspace) return null;

const { panes, activePaneId } = workspace;
const { columns, activePaneId } = workspace;
const canClose = columns.reduce((sum, column) => sum + column.panes.length, 0) > 1;

return (
<div className="flex h-full flex-col bg-black">
<div className="flex items-center justify-end gap-1 border-b border-white/10 px-2 py-1">
<Button
variant="ghost"
size="sm"
onClick={() => split(terminalId)}
className="h-6 gap-1 px-2 text-xs text-white/70 hover:text-white"
>
<SquareSplitHorizontal className="size-3.5" />
Split
</Button>
</div>
<ResizablePanelGroup orientation="horizontal" className="min-h-0 flex-1">
{panes.map((pane, i) => (
<Fragment key={pane.id}>
{i > 0 && <ResizableHandle />}
<ResizablePanel defaultSize={100 / panes.length} minSize={20}>
<TerminalPane
socket={socket}
terminalId={terminalId}
pane={pane}
isActive={pane.id === activePaneId}
canClose={panes.length > 1}
onSelect={() => selectPane(terminalId, pane.id)}
onClose={() => closePane(terminalId, pane.id)}
/>
<div className="h-full bg-background">
<ResizablePanelGroup orientation="horizontal" className="h-full">
{columns.map((column, columnIndex) => (
<Fragment key={column.id}>
{columnIndex > 0 && <ResizableHandle variant="chrome-free" />}
<ResizablePanel defaultSize={100 / columns.length} minSize={15}>
<ResizablePanelGroup orientation="vertical" className="h-full">
{column.panes.map((pane, paneIndex) => (
<Fragment key={pane.id}>
{paneIndex > 0 && <ResizableHandle variant="chrome-free" />}
<ResizablePanel defaultSize={100 / column.panes.length} minSize={15}>
<TerminalPane
socket={socket}
terminalId={terminalId}
pane={pane}
isActive={pane.id === activePaneId}
canClose={canClose}
onSelect={() => selectPane(terminalId, pane.id)}
onSplitRight={() => splitRight(terminalId, pane.id)}
onSplitDown={() => splitDown(terminalId, pane.id)}
onClose={() => closePane(terminalId, pane.id)}
/>
</ResizablePanel>
</Fragment>
))}
</ResizablePanelGroup>
</ResizablePanel>
</Fragment>
))}
Expand All @@ -73,13 +71,23 @@ export default function TerminalPanes({ terminalId, socket }: TerminalPanesProps
);
}

/**
* Chrome-free by design (no per-pane header, ever, verified against
* PurePoint's real chrome-free pane design) — a top accent bar shows focus
* and hover-revealed controls handle splitting/closing without permanent
* chrome. Known tradeoff: with 2+ panes open the Navigator sidebar does not
* yet indicate which open terminal is showing in which pane — that's
* sidebar/Navigator work, out of scope for this theming-foundation round.
*/
function TerminalPane({
socket,
terminalId,
pane,
isActive,
canClose,
onSelect,
onSplitRight,
onSplitDown,
onClose,
}: {
socket: Socket | null | undefined;
Expand All @@ -88,19 +96,40 @@ function TerminalPane({
isActive: boolean;
canClose: boolean;
onSelect(): void;
onSplitRight(): void;
onSplitDown(): void;
onClose(): void;
}) {
const sessionId = pane.scope ? paneSessionId(terminalId, pane.scope) : null;

return (
<div
className={`flex h-full flex-col ${isActive ? 'ring-1 ring-inset ring-emerald-500/50' : ''}`}
onClick={onSelect}
>
<div className="flex items-center gap-1 border-b border-white/10 bg-white/5 px-1.5 py-1">
<span className="flex-1 truncate px-1 text-xs text-white/80">
{pane.scope ? scopeLabel(pane.scope) : 'No terminal open — click to make this pane active, then pick a terminal in the navigator'}
</span>
<div className="group/pane relative flex h-full flex-col" onClick={onSelect}>
<div className={`absolute inset-x-0 top-0 z-10 h-0.5 ${isActive ? 'bg-primary' : 'bg-transparent'}`} />
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-0.5 rounded-md border border-border bg-card/90 p-0.5 opacity-0 shadow-sm backdrop-blur-sm transition-opacity group-hover/pane:opacity-100 focus-within:opacity-100">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
onSplitRight();
}}
className="size-6 text-muted-foreground hover:text-foreground"
title="Split right"
>
<SquareSplitHorizontal className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
onSplitDown();
}}
className="size-6 text-muted-foreground hover:text-foreground"
title="Split down"
>
<SquareSplitVertical className="size-3.5" />
</Button>
{canClose && (
<Button
variant="ghost"
Expand All @@ -109,7 +138,8 @@ function TerminalPane({
e.stopPropagation();
onClose();
}}
className="size-5 text-white/60 hover:text-white"
className="size-6 text-muted-foreground hover:text-destructive"
title="Close pane"
>
<X className="size-3.5" />
</Button>
Expand Down Expand Up @@ -162,13 +192,13 @@ function TerminalPaneStream({
/>
)}
{!connected && (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<div className="absolute inset-0 flex items-center justify-center bg-background">
{error ? (
<span className="text-sm text-red-400">{error}</span>
<span className="text-sm text-destructive">{error}</span>
) : (
<div className="flex items-center gap-2">
<div className="size-4 animate-spin rounded-full border-2 border-green-400 border-t-transparent" />
<span className="text-sm text-green-400">Connecting…</span>
<div className="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">Connecting…</span>
</div>
)}
</div>
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/components/ui/resizable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {

function ResizableHandle({
className,
variant = "default",
...props
}: ResizablePrimitive.SeparatorProps) {
}: ResizablePrimitive.SeparatorProps & { variant?: "default" | "chrome-free" }) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
data-variant={variant}
className={cn(
"relative flex w-1.5 flex-shrink-0 cursor-col-resize items-center justify-center",
"bg-transparent transition-colors duration-150",
Expand All @@ -40,7 +42,21 @@ function ResizableHandle({
)}
{...props}
>
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-sidebar-border opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[separator=active]:opacity-100 group-data-[separator=active]:bg-primary" />
{/* chrome-free panes (e.g. Terminal) strip every other seam cue, so
their handle must stay faintly visible at rest — everywhere else
the resting opacity-0 default is correct because sibling
borders/cards already show the seam.
Sized (not absolute-positioned) so the parent's flex centering
handles both orientations — a fixed absolute/translate line was
hardcoded vertical and broke for orientation="vertical" groups. */}
<div
className={cn(
"h-full w-px bg-sidebar-border transition-opacity duration-150",
"group-aria-[orientation=horizontal]:h-px group-aria-[orientation=horizontal]:w-full",
"group-hover:opacity-100 group-data-[separator=active]:opacity-100 group-data-[separator=active]:bg-primary",
variant === "chrome-free" ? "opacity-60" : "opacity-0"
)}
/>
</ResizablePrimitive.Separator>
)
}
Expand Down
Loading