"use client"; import { BarChart3, BookOpen, Check, ChevronDown, ChevronRight, Compass, Eye, EyeOff, FileEdit, FileOutput, FilePlus, Globe, History, Inbox, LayoutGrid, ListOrdered, Loader2, type LucideIcon, MessageCircle, MessagesSquare, Milestone, MoreHorizontal, Pause, PenLine, Play, SendHorizonal, Settings, SkipForward, SlidersHorizontal, Square, Stethoscope, Terminal, Trash2, Undo2, X, Zap, } from "lucide-react"; import Image from "next/image"; import { type ClipboardEvent, type DragEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { generateImageId, MAX_PENDING_IMAGES, type PendingImage, processImageFile, } from "@/lib/image-utils"; import type { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser"; import { type ActiveToolExecution, buildPromptCommand, type CompletedToolExecution, type PendingUiRequest, useSFWorkspaceActions, useSFWorkspaceState, } from "@/lib/sf-workspace-store"; import { useTerminalFontSize } from "@/lib/use-terminal-font-size"; import { cn } from "@/lib/utils"; import { deriveWorkflowAction } from "@/lib/workflow-actions"; /* ─── ActionPanel types ─── */ // ActionPanelConfig removed — all commands now route through the main bridge. /* ─── SF Action Definitions ─── */ /** * Defines every /sf subcommand available in the chat input bar. * Top 3 are shown as standalone buttons; the rest live in the overflow menu. * All commands dispatch through the main bridge session. */ interface SFActionDef { label: string; command: string; icon: LucideIcon; description: string; category: | "workflow" | "visibility" | "correction" | "knowledge" | "config" | "maintenance"; /** When true, this command is disabled while auto-mode is active (injects competing LLM prompt) */ disabledDuringAuto?: boolean; } const SF_ACTIONS: SFActionDef[] = [ // ── Top 3 (standalone buttons) ── { label: "Discuss", command: "/discuss", icon: MessageCircle, description: "Start guided milestone/slice discussion", category: "workflow", disabledDuringAuto: true, }, { label: "Next", command: "/next", icon: Play, description: "Execute next task, then pause", category: "workflow", }, { label: "Autonomous", command: "/autonomous", icon: Zap, description: "Run all queued product units continuously", category: "workflow", }, // ── Overflow: Workflow ── { label: "Stop", command: "/stop", icon: Square, description: "Stop autonomous mode gracefully", category: "workflow", }, { label: "Pause", command: "/pause", icon: Pause, description: "Pause autonomous mode (preserves state)", category: "workflow", }, // ── Overflow: Visibility ── { label: "Status", command: "/status", icon: BarChart3, description: "Show progress dashboard", category: "visibility", }, { label: "Visualize", command: "/visualize", icon: LayoutGrid, description: "Interactive TUI (progress, deps, metrics, timeline)", category: "visibility", }, { label: "Queue", command: "/queue", icon: ListOrdered, description: "Show queued/dispatched units and execution order", category: "visibility", }, { label: "History", command: "/history", icon: History, description: "View execution history with cost/phase/model details", category: "visibility", }, // ── Overflow: Course correction ── { label: "Steer", command: "/steer", icon: Compass, description: "Apply user override to active work", category: "correction", }, { label: "Capture", command: "/capture", icon: PenLine, description: "Quick-capture a thought to CAPTURES.md", category: "correction", }, { label: "Triage", command: "/triage", icon: Inbox, description: "Classify and route pending captures", category: "correction", disabledDuringAuto: true, }, { label: "Skip", command: "/skip", icon: SkipForward, description: "Prevent a unit from auto-mode dispatch", category: "correction", }, { label: "Undo", command: "/undo", icon: Undo2, description: "Revert last completed unit", category: "correction", }, // ── Overflow: Knowledge ── { label: "Knowledge", command: "/knowledge", icon: BookOpen, description: "Add rule, pattern, or lesson to KNOWLEDGE.md", category: "knowledge", }, // ── Overflow: Configuration ── { label: "Mode", command: "/mode", icon: SlidersHorizontal, description: "Set workflow mode (solo/team)", category: "config", }, { label: "Prefs", command: "/prefs", icon: Settings, description: "Manage preferences (global/project)", category: "config", }, // ── Overflow: Maintenance ── { label: "Doctor", command: "/doctor", icon: Stethoscope, description: "Diagnose and repair .sf/ state", category: "maintenance", }, { label: "Export", command: "/export", icon: FileOutput, description: "Export milestone/slice results (JSON or Markdown)", category: "maintenance", }, { label: "Cleanup", command: "/cleanup", icon: Trash2, description: "Remove merged branches or snapshots", category: "maintenance", }, { label: "Remote", command: "/remote", icon: Globe, description: "Control remote auto-mode (Slack/Discord)", category: "maintenance", }, ]; /** Top 3 shown as standalone buttons next to chat input */ const TOP_ACTIONS = SF_ACTIONS.slice(0, 3); /** Remaining actions in the overflow menu */ const OVERFLOW_ACTIONS = SF_ACTIONS.slice(3); const CATEGORY_LABELS: Record = { workflow: "Workflow", visibility: "Visibility", correction: "Course Correction", knowledge: "Knowledge", config: "Configuration", maintenance: "Maintenance", }; function groupByCategory(actions: SFActionDef[]): Array<{ category: SFActionDef["category"]; label: string; items: SFActionDef[]; }> { const seen = new Map(); for (const a of actions) { let group = seen.get(a.category); if (!group) { group = []; seen.set(a.category, group); } group.push(a); } return Array.from(seen.entries()).map(([cat, items]) => ({ category: cat, label: CATEGORY_LABELS[cat], items, })); } /** * ChatMode — main view for the Chat tab. * * All /sf commands dispatch through the main bridge session. * Commands that inject competing LLM prompts (discuss, triage) * are disabled while auto-mode is active. * * Observability: * - This component mounts only when activeView === "chat" (no hidden pre-init). * - sessionStorage key "sf-active-view:" equals "chat" when this view is active. * - Header toolbar: data-testid="chat-mode-action-bar" confirms toolbar rendered. * - Primary button: data-testid="chat-primary-action" reflects current workflowAction label. * - Secondary buttons: data-testid="chat-secondary-action-{command}". */ export function ChatMode({ className }: { className?: string }) { const state = useSFWorkspaceState(); const { sendCommand } = useSFWorkspaceActions(); const bridge = state.boot?.bridge ?? null; const handleAction = useCallback( (command: string) => { void sendCommand(buildPromptCommand(command, bridge)); }, [sendCommand, bridge], ); return (
{/* ── Header bar ── */} {/* ── Main chat pane ── */} handleAction(action.command)} />
); } /* ─── Header ─── */ interface ChatModeHeaderProps { onPrimaryAction: (command: string) => void; onSecondaryAction: (command: string) => void; } /** * ChatModeHeader — action toolbar for Chat Mode. * * Single-row layout matching the Power User Mode header: title + badge left-aligned, * workflow action buttons immediately to the right (no second row). * * Observability: * - data-testid="chat-mode-action-bar" on the workflow button row * - data-testid="chat-primary-action" on the primary button * - data-testid="chat-secondary-action-{command}" on each secondary button */ function ChatModeHeader({ onPrimaryAction, onSecondaryAction, }: ChatModeHeaderProps) { const state = useSFWorkspaceState(); const boot = state.boot; const workspace = boot?.workspace ?? null; const auto = boot?.auto ?? null; const workflowAction = deriveWorkflowAction({ phase: workspace?.active.phase ?? "pre-planning", autoActive: auto?.active ?? false, autoPaused: auto?.paused ?? false, onboardingLocked: boot?.onboarding.locked ?? false, commandInFlight: state.commandInFlight, bootStatus: state.bootStatus, hasMilestones: (workspace?.milestones.length ?? 0) > 0, projectDetectionKind: boot?.projectDetection?.kind ?? null, }); const handlePrimary = () => { if (!workflowAction.primary) return; onPrimaryAction(workflowAction.primary.command); }; // Derive a short SF state badge label const stateBadge = (() => { if (state.bootStatus !== "ready") return state.bootStatus; const phase = workspace?.active.phase; if (!phase) return "idle"; if (auto?.active && !auto?.paused) return "auto"; if (auto?.paused) return "paused"; return phase; })(); return (
{/* Left: title + state badge */}
Chat Mode {stateBadge}
{/* Right: workflow action buttons */}
{workflowAction.primary && ( )} {workflowAction.secondaries.map((action) => ( ))} {state.commandInFlight && ( )}
); } type ShikiHighlighter = { codeToTokens: ( code: string, options: { lang: string; theme: string }, ) => { tokens: Array< Array<{ color?: string; content: string; fontStyle?: number; offset?: number; }> >; bg?: string; fg?: string; }; }; let chatHighlighterPromise: Promise | null = null; function getChatHighlighter(): Promise { if (!chatHighlighterPromise) { chatHighlighterPromise = import("shiki") .then( (mod) => mod.createHighlighter({ themes: ["github-dark-default", "github-light-default"], langs: [ "typescript", "tsx", "javascript", "jsx", "json", "jsonc", "markdown", "mdx", "css", "scss", "less", "html", "xml", "yaml", "toml", "bash", "python", "ruby", "rust", "go", "java", "kotlin", "swift", "c", "cpp", "csharp", "php", "sql", "graphql", "dockerfile", "makefile", "lua", "diff", "ini", "dotenv", ], }) as Promise, ) .catch((err) => { chatHighlighterPromise = null; throw err; }) as Promise; } return chatHighlighterPromise!; } function HighlightedCode({ code, highlighter, lang, theme, className, }: { code: string; highlighter: ShikiHighlighter; lang: string; theme: string; className?: string; }) { const highlighted = highlighter.codeToTokens(code, { lang, theme }); return (
			
				{highlighted.tokens.map((line, lineNumber) => (
					 token.content).join("")}`}
					>
						{line.map((token, tokenIndex) => (
							
								{token.content}
							
						))}
						{lineNumber < highlighted.tokens.length - 1 ? "\n" : null}
					
				))}
			
		
); } /* ─── Markdown renderer for assistant bubbles ─── */ /** * Renders markdown content using react-markdown + remark-gfm + shiki code blocks. * Dynamic imports keep the main bundle lean. * Falls back to plain text if modules fail to load. * * Observability: * - console.debug("[ChatBubble] markdown modules loaded") fires once on first render */ function MarkdownContent({ content }: { content: string }) { const [rendered, setRendered] = useState(null); const [ready, setReady] = useState(false); const isDark = useIsDark(); useEffect(() => { let cancelled = false; Promise.all([ import("react-markdown"), import("remark-gfm"), getChatHighlighter(), ]) .then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => { if (cancelled) return; console.debug("[ChatBubble] markdown modules loaded"); const ReactMarkdown = ReactMarkdownMod.default; const remarkGfm = remarkGfmMod.default; const shikiTheme = isDark ? "github-dark-default" : "github-light-default"; const buildComponents = (h: typeof highlighter) => ({ code({ className, children, ...props }: React.HTMLAttributes & { children?: React.ReactNode; }) { const match = /language-(\w+)/.exec(className || ""); const codeStr = String(children).replace(/\n$/, ""); if (match) { try { return ( ); } catch { /* unsupported language — fall through */ } } const isInline = !className && !String(children).includes("\n"); if (isInline) { return ( {children} ); } return (
								{children}
							
); }, pre({ children }: { children?: React.ReactNode }) { return <>{children}; }, table({ children }: { children?: React.ReactNode }) { return (
{children}
); }, th({ children }: { children?: React.ReactNode }) { return ( {children} ); }, td({ children }: { children?: React.ReactNode }) { return ( {children} ); }, a({ href, children }: { href?: string; children?: React.ReactNode }) { return ( {children} ); }, h1({ children }: { children?: React.ReactNode }) { return (

{children}

); }, h2({ children }: { children?: React.ReactNode }) { return (

{children}

); }, h3({ children }: { children?: React.ReactNode }) { return (

{children}

); }, ul({ children }: { children?: React.ReactNode }) { return (
    {children}
); }, ol({ children }: { children?: React.ReactNode }) { return (
    {children}
); }, blockquote({ children }: { children?: React.ReactNode }) { return (
{children}
); }, hr() { return
; }, p({ children }: { children?: React.ReactNode }) { return (

{children}

); }, img({ alt, src }: { alt?: string; src?: string }) { return ( 🖼 {alt || src || "image"} ); }, }); setRendered( {content} , ); setReady(true); }) .catch(() => { if (!cancelled) setReady(true); }); return () => { cancelled = true; }; }, [content, isDark]); // re-render when content changes (streaming) or theme toggles if (!ready) { // Plain text fallback while modules load return ( {content} ); } if (!rendered) { return ( {content} ); } return
{rendered}
; } /* ─── TuiSelectPrompt ─── */ /** * Renders a SF arrow-key select prompt as a native clickable list. * * Clicking an option calculates the arrow-key delta from the current * PTY-tracked selection, sends that many \x1b[A/\x1b[B + \r to the PTY, * and transitions to a static post-submission state. * * Observability: * - Logs "[TuiSelectPrompt] mounted kind=select label=%s" on mount * - Logs "[TuiSelectPrompt] submit delta=%d keystrokes=%j" on submit * - data-testid="tui-select-prompt" on container * - data-testid="tui-select-option-{i}" on each option button * - data-testid="tui-prompt-submitted" on post-submission element */ function TuiSelectPrompt({ prompt, onSubmit, }: { prompt: TuiPrompt; onSubmit: (data: string) => void; }) { const [localIndex, setLocalIndex] = useState(prompt.selectedIndex ?? 0); const [submitted, setSubmitted] = useState(false); const containerRef = useRef(null); useEffect(() => { console.log("[TuiSelectPrompt] mounted kind=select label=%s", prompt.label); // Auto-focus the container so keyboard events are captured immediately containerRef.current?.focus(); }, [prompt.label]); const submitIndex = useCallback( (clickedIndex: number) => { const delta = clickedIndex - localIndex; let keystrokes = ""; if (delta > 0) { keystrokes = "\x1b[B".repeat(delta); } else if (delta < 0) { keystrokes = "\x1b[A".repeat(Math.abs(delta)); } keystrokes += "\r"; console.log( "[TuiSelectPrompt] submit delta=%d keystrokes=%j", delta, keystrokes, ); setLocalIndex(clickedIndex); setSubmitted(true); onSubmit(keystrokes); }, [localIndex, onSubmit], ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (submitted) return; if (e.key === "ArrowUp") { e.preventDefault(); setLocalIndex((i) => Math.max(0, i - 1)); } else if (e.key === "ArrowDown") { e.preventDefault(); setLocalIndex((i) => Math.min(prompt.options.length - 1, i + 1)); } else if (e.key === "Enter") { e.preventDefault(); submitIndex(localIndex); } }, [submitted, localIndex, prompt.options.length, submitIndex], ); if (submitted) { const selectedLabel = prompt.options[localIndex] ?? ""; return (
{selectedLabel}
); } return (
{prompt.label && (

{prompt.label}

)} {prompt.options.map((option, i) => { const isSelected = i === localIndex; const description = prompt.descriptions?.[i]; return ( ); })}
); } /* ─── TuiTextPrompt ─── */ /** * Renders a SF text prompt as a native labeled input field. * * Submitting sends the typed value + "\r" to the PTY (carriage return = Enter). * After submission shows a static "✓ Submitted" confirmation (value not echoed). * * Observability: * - Logs "[TuiTextPrompt] mounted kind=text label=%s" on mount * - Logs "[TuiTextPrompt] submitted label=%s" on submit * - data-testid="tui-text-prompt" on container * - data-testid="tui-prompt-submitted" on post-submission element */ function TuiTextPrompt({ prompt, onSubmit, }: { prompt: TuiPrompt; onSubmit: (data: string) => void; }) { const [value, setValue] = useState(""); const [submitted, setSubmitted] = useState(false); const inputRef = useRef(null); useEffect(() => { console.log("[TuiTextPrompt] mounted kind=text label=%s", prompt.label); inputRef.current?.focus(); }, [prompt.label]); const handleSubmit = useCallback(() => { if (submitted) return; console.log("[TuiTextPrompt] submitted label=%s", prompt.label); setSubmitted(true); onSubmit(value + "\r"); }, [submitted, value, prompt.label, onSubmit]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleSubmit(); } }, [handleSubmit], ); if (submitted) { return (
✓ Submitted
); } return (
{prompt.label && (

{prompt.label}

)}
setValue(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type your answer…" className="flex-1 h-8 text-sm" aria-label={prompt.label || "Text input"} />
); } /* ─── TuiPasswordPrompt ─── */ /** * Renders a SF password/API-key prompt as a native masked input field. * * Submitting sends the typed value + "\r" to the PTY. * The entered value is NEVER shown in the DOM, logs, or post-submission text. * After submission shows "{label} — entered ✓" with no value echo. * * Observability: * - Logs "[TuiPasswordPrompt] mounted kind=password label=%s" on mount * - Logs "[TuiPasswordPrompt] submitted label=%s" on submit (value not logged) * - data-testid="tui-password-prompt" on container * - data-testid="tui-prompt-submitted" on post-submission element */ function TuiPasswordPrompt({ prompt, onSubmit, }: { prompt: TuiPrompt; onSubmit: (data: string) => void; }) { const [value, setValue] = useState(""); const [submitted, setSubmitted] = useState(false); const [showPassword, setShowPassword] = useState(false); const inputRef = useRef(null); useEffect(() => { console.log( "[TuiPasswordPrompt] mounted kind=password label=%s", prompt.label, ); inputRef.current?.focus(); }, [prompt.label]); const handleSubmit = useCallback(() => { if (submitted) return; // Value intentionally not logged — redaction constraint console.log("[TuiPasswordPrompt] submitted label=%s", prompt.label); setSubmitted(true); onSubmit(value + "\r"); }, [submitted, value, prompt.label, onSubmit]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleSubmit(); } }, [handleSubmit], ); if (submitted) { const displayLabel = prompt.label || "Value"; return (
{displayLabel} — entered ✓
); } return (
{prompt.label && (

{prompt.label}

)}
setValue(e.target.value)} onKeyDown={handleKeyDown} placeholder="Enter value…" className="h-8 pr-9 text-sm" aria-label={prompt.label || "Password input"} autoComplete="off" />

Value is transmitted securely and not stored in chat history.

); } /* ─── StreamingCursor ─── */ function StreamingCursor() { return (