singularity-forge/web/components/sf/dual-terminal.tsx
2026-05-05 14:46:18 +02:00

136 lines
4.5 KiB
TypeScript

"use client";
import { GripVertical, Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { MainSessionTerminal } from "@/components/sf/main-session-terminal";
import { ShellTerminal } from "@/components/sf/shell-terminal";
import { useSFWorkspaceState } from "@/lib/sf-workspace-store";
import { useTerminalFontSize } from "@/lib/use-terminal-font-size";
import { derivePendingWorkflowCommandLabel } from "@/lib/workflow-action-execution";
export function DualTerminal() {
const [splitPosition, setSplitPosition] = useState(50);
const containerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const [terminalFontSize] = useTerminalFontSize();
const workspace = useSFWorkspaceState();
const projectCwd = workspace.boot?.project.cwd;
const pendingCommandLabel = derivePendingWorkflowCommandLabel({
commandInFlight: workspace.commandInFlight,
terminalLines: workspace.terminalLines,
});
const handleMouseDown = () => {
isDragging.current = true;
};
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = (x / rect.width) * 100;
setSplitPosition(Math.max(20, Math.min(80, percent)));
}, []);
const handleMouseUp = useCallback(() => {
isDragging.current = false;
}, []);
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseUp, handleMouseMove]);
// Prevent browser default file-open on drag/drop anywhere in the dual terminal.
// Uses native DOM listeners so xterm's internal DOM can't swallow the events first.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const preventDragDefault = (e: DragEvent) => {
e.preventDefault();
};
// Capture phase ensures we fire before any child element can consume the event
el.addEventListener("dragover", preventDragDefault, true);
el.addEventListener("drop", preventDragDefault, true);
return () => {
el.removeEventListener("dragover", preventDragDefault, true);
el.removeEventListener("drop", preventDragDefault, true);
};
}, []);
return (
<div ref={rootRef} className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
<span className="font-medium">Power User Mode</span>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{pendingCommandLabel && (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-primary"
data-testid="power-mode-pending-command"
title={pendingCommandLabel}
>
<Loader2 className="h-3 w-3 animate-spin" />
Sending {pendingCommandLabel}
</span>
)}
<span>Left: Main Session TUI</span>
<span className="text-border">|</span>
<span>Right: Interactive SF</span>
</div>
</div>
{/* Split terminals */}
<div ref={containerRef} className="flex flex-1 overflow-hidden">
{/* Left terminal - Main bridge native TUI */}
<div
style={{ width: `${splitPosition}%` }}
className="flex h-full min-w-0 flex-col overflow-hidden bg-terminal"
>
<MainSessionTerminal
className="min-h-0 flex-1"
fontSize={terminalFontSize}
projectCwd={projectCwd}
/>
</div>
{/* Divider */}
<div
role="separator"
aria-label="Resize terminal panes"
aria-orientation="vertical"
aria-valuemin={20}
aria-valuemax={80}
aria-valuenow={Math.round(splitPosition)}
tabIndex={0}
className="flex w-1 cursor-col-resize items-center justify-center bg-border hover:bg-muted-foreground/30 transition-colors"
onMouseDown={handleMouseDown}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
{/* Right terminal - Interactive SF instance */}
<div
style={{ width: `${100 - splitPosition}%` }}
className="h-full min-w-0 overflow-hidden bg-terminal"
>
<ShellTerminal
className="h-full"
command="sf"
sessionPrefix="sf-interactive"
fontSize={terminalFontSize}
hideInitialSfHeader
projectCwd={projectCwd}
/>
</div>
</div>
</div>
);
}