407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Compass, Loader2, OctagonX, Wrench } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
getOnboardingPresentation,
|
|
getSessionLabelFromBridge,
|
|
getStatusPresentation,
|
|
useSFWorkspaceActions,
|
|
useSFWorkspaceState,
|
|
} from "@/lib/sf-workspace-store";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface TerminalProps {
|
|
className?: string;
|
|
}
|
|
|
|
type InputMode = "prompt" | "follow_up" | "steer";
|
|
type WidgetPlacement = "aboveEditor" | "belowEditor";
|
|
|
|
const MAX_VISIBLE_WIDGET_LINES = 6;
|
|
|
|
function getInputMode(
|
|
state: ReturnType<typeof useSFWorkspaceState>,
|
|
): InputMode {
|
|
const session = state.boot?.bridge.sessionState;
|
|
if (!session) return "prompt";
|
|
if (session.isStreaming) return "follow_up";
|
|
return "prompt";
|
|
}
|
|
|
|
function inputModePlaceholder(
|
|
mode: InputMode,
|
|
state: ReturnType<typeof useSFWorkspaceState>,
|
|
): string {
|
|
if (state.bootStatus === "loading") return "Loading workspace…";
|
|
if (state.bootStatus === "error")
|
|
return "Workspace boot failed — check the visible error state";
|
|
if (state.commandInFlight) return `Sending ${state.commandInFlight}…`;
|
|
if (state.boot?.onboarding.locked) {
|
|
return getOnboardingPresentation(state).detail;
|
|
}
|
|
switch (mode) {
|
|
case "steer":
|
|
return "Type a steering message to redirect the agent…";
|
|
case "follow_up":
|
|
return "Agent is active — type a follow-up or /state";
|
|
case "prompt":
|
|
return "Type a prompt, /state, /new, or /clear";
|
|
}
|
|
}
|
|
|
|
function inputModeLabel(mode: InputMode): string {
|
|
switch (mode) {
|
|
case "steer":
|
|
return "steer";
|
|
case "follow_up":
|
|
return "follow-up";
|
|
case "prompt":
|
|
return "$";
|
|
}
|
|
}
|
|
|
|
function getWidgetsForPlacement(
|
|
widgetContents: Record<
|
|
string,
|
|
{ lines: string[] | undefined; placement?: WidgetPlacement }
|
|
>,
|
|
placement: WidgetPlacement,
|
|
): Array<{
|
|
key: string;
|
|
placement: WidgetPlacement;
|
|
visibleLines: string[];
|
|
hiddenLineCount: number;
|
|
fullText: string;
|
|
}> {
|
|
return Object.entries(widgetContents)
|
|
.filter(([, widget]) => {
|
|
const widgetPlacement = widget.placement ?? "aboveEditor";
|
|
return (
|
|
widgetPlacement === placement &&
|
|
Array.isArray(widget.lines) &&
|
|
widget.lines.length > 0
|
|
);
|
|
})
|
|
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
|
.map(([key, widget]) => {
|
|
const lines = widget.lines ?? [];
|
|
return {
|
|
key,
|
|
placement,
|
|
visibleLines: lines.slice(0, MAX_VISIBLE_WIDGET_LINES),
|
|
hiddenLineCount: Math.max(0, lines.length - MAX_VISIBLE_WIDGET_LINES),
|
|
fullText: lines.join("\n"),
|
|
};
|
|
});
|
|
}
|
|
|
|
function TerminalWidgetBand({
|
|
placement,
|
|
widgets,
|
|
}: {
|
|
placement: WidgetPlacement;
|
|
widgets: Array<{
|
|
key: string;
|
|
placement: WidgetPlacement;
|
|
visibleLines: string[];
|
|
hiddenLineCount: number;
|
|
fullText: string;
|
|
}>;
|
|
}) {
|
|
if (widgets.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
className="border-t border-border/50 bg-card/50 px-4 py-2"
|
|
data-testid={
|
|
placement === "aboveEditor"
|
|
? "terminal-widgets-above-editor"
|
|
: "terminal-widgets-below-editor"
|
|
}
|
|
>
|
|
<div className="space-y-2">
|
|
{widgets.map((widget) => (
|
|
<div
|
|
key={`${widget.placement}:${widget.key}`}
|
|
className="rounded-md border border-border bg-background/50 px-3 py-2"
|
|
data-testid="terminal-widget"
|
|
data-widget-key={widget.key}
|
|
data-widget-placement={widget.placement}
|
|
title={widget.fullText}
|
|
>
|
|
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
|
|
<span className="truncate">{widget.key}</span>
|
|
<span>
|
|
{widget.placement === "aboveEditor"
|
|
? "Above editor"
|
|
: "Below editor"}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-1 text-xs text-foreground">
|
|
{widget.visibleLines.map((line, index) => (
|
|
<div
|
|
key={`${widget.key}:${index}`}
|
|
className="whitespace-pre-wrap break-words"
|
|
>
|
|
{line}
|
|
</div>
|
|
))}
|
|
{widget.hiddenLineCount > 0 && (
|
|
<div
|
|
className="text-[11px] text-muted-foreground"
|
|
data-testid="terminal-widget-overflow"
|
|
>
|
|
+{widget.hiddenLineCount} more line
|
|
{widget.hiddenLineCount === 1 ? "" : "s"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Terminal({ className }: TerminalProps) {
|
|
const workspace = useSFWorkspaceState();
|
|
const { submitInput, sendAbort, sendSteer, consumeEditorTextBuffer } =
|
|
useSFWorkspaceActions();
|
|
const [input, setInput] = useState("");
|
|
const [steerMode, setSteerMode] = useState(false);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const autoMode = getInputMode(workspace);
|
|
const isStreaming = Boolean(workspace.boot?.bridge.sessionState?.isStreaming);
|
|
const inputMode: InputMode = steerMode && isStreaming ? "steer" : autoMode;
|
|
const widgetsAboveEditor = getWidgetsForPlacement(
|
|
workspace.widgetContents,
|
|
"aboveEditor",
|
|
);
|
|
const widgetsBelowEditor = getWidgetsForPlacement(
|
|
workspace.widgetContents,
|
|
"belowEditor",
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (workspace.editorTextBuffer === null) return;
|
|
const buffer = workspace.editorTextBuffer;
|
|
const updateTimer = window.setTimeout(() => {
|
|
setInput(buffer);
|
|
consumeEditorTextBuffer();
|
|
inputRef.current?.focus();
|
|
}, 0);
|
|
return () => window.clearTimeout(updateTimer);
|
|
}, [consumeEditorTextBuffer, workspace.editorTextBuffer]);
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, []);
|
|
|
|
const status = getStatusPresentation(workspace);
|
|
const sessionLabel = getSessionLabelFromBridge(workspace.boot?.bridge);
|
|
const isInputDisabled =
|
|
workspace.bootStatus !== "ready" ||
|
|
workspace.commandInFlight === "refresh" ||
|
|
Boolean(workspace.boot?.onboarding.locked);
|
|
|
|
const handleSubmit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return;
|
|
|
|
if (inputMode === "steer") {
|
|
await sendSteer(trimmed);
|
|
setInput("");
|
|
setSteerMode(false);
|
|
return;
|
|
}
|
|
|
|
await submitInput(trimmed);
|
|
setInput("");
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn("flex flex-col bg-terminal font-mono text-sm", className)}
|
|
onClick={() => inputRef.current?.focus()}
|
|
>
|
|
{/* Terminal header */}
|
|
<div className="flex items-center justify-between border-b border-border/50 px-4 py-2 text-[11px] text-muted-foreground">
|
|
<div className="min-w-0 flex items-center gap-2 truncate">
|
|
<span data-testid="terminal-session-banner">
|
|
{sessionLabel || "Waiting for live session…"}
|
|
</span>
|
|
{/* Active tool execution badge */}
|
|
{workspace.activeToolExecution && (
|
|
<span
|
|
className="inline-flex items-center gap-1 rounded-full border border-foreground/15 bg-accent/60 px-2 py-0.5 text-[10px] font-medium text-foreground/80"
|
|
data-testid="terminal-tool-badge"
|
|
>
|
|
<Wrench className="h-3 w-3" />
|
|
{workspace.activeToolExecution.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Abort button */}
|
|
{isStreaming && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 gap-1 px-2 text-[11px] text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void sendAbort();
|
|
}}
|
|
disabled={workspace.commandInFlight === "abort"}
|
|
data-testid="terminal-abort-button"
|
|
>
|
|
<OctagonX className="h-3 w-3" />
|
|
Abort
|
|
</Button>
|
|
)}
|
|
<span
|
|
className={cn(
|
|
"h-2 w-2 rounded-full",
|
|
status.tone === "success"
|
|
? "bg-success"
|
|
: status.tone === "warning"
|
|
? "bg-warning"
|
|
: status.tone === "danger"
|
|
? "bg-destructive"
|
|
: "bg-muted-foreground/60",
|
|
status.tone === "success" && "animate-pulse",
|
|
)}
|
|
/>
|
|
<span>{status.label}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Terminal lines + streaming content */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{workspace.terminalLines.map((line) => (
|
|
<div key={line.id} className="flex" data-testid="terminal-line">
|
|
<span className="mr-2 select-none text-muted-foreground">
|
|
{line.timestamp}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"whitespace-pre-wrap",
|
|
line.type === "input" &&
|
|
"text-foreground before:content-['$_'] before:text-muted-foreground",
|
|
line.type === "output" && "text-terminal-foreground",
|
|
line.type === "system" && "text-muted-foreground",
|
|
line.type === "success" && "text-success",
|
|
line.type === "error" && "text-destructive",
|
|
)}
|
|
>
|
|
{line.content}
|
|
</span>
|
|
</div>
|
|
))}
|
|
|
|
{/* Completed transcript blocks from previous turns */}
|
|
{workspace.liveTranscript.length > 0 && (
|
|
<div className="mt-2 space-y-2" data-testid="terminal-transcript">
|
|
{workspace.liveTranscript.map((block, i) => (
|
|
<div
|
|
key={`transcript-${i}`}
|
|
className="whitespace-pre-wrap rounded border border-border/50 bg-accent/20 px-3 py-2 text-foreground"
|
|
>
|
|
{block}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Live streaming assistant text */}
|
|
{workspace.streamingAssistantText && (
|
|
<div className="mt-2" data-testid="terminal-streaming-text">
|
|
<div className="whitespace-pre-wrap rounded border border-foreground/10 bg-foreground/[0.03] px-3 py-2 text-foreground">
|
|
{workspace.streamingAssistantText}
|
|
<span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-foreground/60" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Streaming indicator when active but no text yet */}
|
|
{isStreaming &&
|
|
!workspace.streamingAssistantText &&
|
|
!workspace.activeToolExecution && (
|
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Agent is thinking…
|
|
</div>
|
|
)}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
<TerminalWidgetBand
|
|
placement="aboveEditor"
|
|
widgets={widgetsAboveEditor}
|
|
/>
|
|
|
|
{/* Input area with steer toggle */}
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="flex items-center gap-2 border-t border-border/50 px-4 py-2"
|
|
>
|
|
{/* Steer toggle — only visible when agent is streaming */}
|
|
{isStreaming && (
|
|
<Button
|
|
type="button"
|
|
variant={steerMode ? "default" : "ghost"}
|
|
size="sm"
|
|
className={cn(
|
|
"h-6 gap-1 px-2 text-[11px]",
|
|
steerMode
|
|
? "bg-foreground text-background"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSteerMode(!steerMode);
|
|
}}
|
|
data-testid="terminal-steer-toggle"
|
|
>
|
|
<Compass className="h-3 w-3" />
|
|
Steer
|
|
</Button>
|
|
)}
|
|
<span
|
|
className={cn(
|
|
"text-muted-foreground",
|
|
inputMode === "steer" && "font-semibold text-foreground",
|
|
)}
|
|
>
|
|
{inputModeLabel(inputMode)}
|
|
</span>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={input}
|
|
onChange={(event) => setInput(event.target.value)}
|
|
className="flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:text-muted-foreground"
|
|
placeholder={inputModePlaceholder(inputMode, workspace)}
|
|
disabled={isInputDisabled}
|
|
data-testid="terminal-command-input"
|
|
/>
|
|
{workspace.commandInFlight && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{workspace.commandInFlight}…
|
|
</span>
|
|
)}
|
|
</form>
|
|
|
|
<TerminalWidgetBand
|
|
placement="belowEditor"
|
|
widgets={widgetsBelowEditor}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|