"use client" import Image from "next/image" import { useEffect, useRef, useCallback, useState, useMemo, KeyboardEvent, DragEvent, ClipboardEvent } from "react" import { MessagesSquare, SendHorizonal, Check, Eye, EyeOff, Play, Loader2, Milestone, X, MessageCircle, FileEdit, FilePlus, Terminal, ChevronDown, ChevronRight, MoreHorizontal, Zap, Square, Pause, BarChart3, LayoutGrid, ListOrdered, History, Compass, PenLine, Inbox, SkipForward, Undo2, BookOpen, Settings, SlidersHorizontal, Stethoscope, FileOutput, Trash2, Globe, type LucideIcon } from "lucide-react" import { cn } from "@/lib/utils" import { Input } from "@/components/ui/input" import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip" import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" import { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser" import { PendingImage, processImageFile, generateImageId, MAX_PENDING_IMAGES } from "@/lib/image-utils" import { useGSDWorkspaceState, useGSDWorkspaceActions, buildPromptCommand, type CompletedToolExecution, type ActiveToolExecution, type PendingUiRequest, type TurnSegment, } from "@/lib/sf-workspace-store" import { deriveWorkflowAction } from "@/lib/workflow-actions" import { useTerminalFontSize } from "@/lib/use-terminal-font-size" /* ─── ActionPanel types ─── */ // ActionPanelConfig removed — all commands now route through the main bridge. /* ─── GSD Action Definitions ─── */ /** * Defines every /gsd 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 GSDActionDef { 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 GSD_ACTIONS: GSDActionDef[] = [ // ── Top 3 (standalone buttons) ── { label: "Discuss", command: "/gsd discuss", icon: MessageCircle, description: "Start guided milestone/slice discussion", category: "workflow", disabledDuringAuto: true }, { label: "Next", command: "/gsd next", icon: Play, description: "Execute next task, then pause", category: "workflow" }, { label: "Auto", command: "/gsd auto", icon: Zap, description: "Run all queued units continuously", category: "workflow" }, // ── Overflow: Workflow ── { label: "Stop", command: "/gsd stop", icon: Square, description: "Stop auto-mode gracefully", category: "workflow" }, { label: "Pause", command: "/gsd pause", icon: Pause, description: "Pause auto-mode (preserves state)", category: "workflow" }, // ── Overflow: Visibility ── { label: "Status", command: "/gsd status", icon: BarChart3, description: "Show progress dashboard", category: "visibility" }, { label: "Visualize", command: "/gsd visualize", icon: LayoutGrid, description: "Interactive TUI (progress, deps, metrics, timeline)", category: "visibility" }, { label: "Queue", command: "/gsd queue", icon: ListOrdered, description: "Show queued/dispatched units and execution order", category: "visibility" }, { label: "History", command: "/gsd history", icon: History, description: "View execution history with cost/phase/model details", category: "visibility" }, // ── Overflow: Course correction ── { label: "Steer", command: "/gsd steer", icon: Compass, description: "Apply user override to active work", category: "correction" }, { label: "Capture", command: "/gsd capture", icon: PenLine, description: "Quick-capture a thought to CAPTURES.md", category: "correction" }, { label: "Triage", command: "/gsd triage", icon: Inbox, description: "Classify and route pending captures", category: "correction", disabledDuringAuto: true }, { label: "Skip", command: "/gsd skip", icon: SkipForward, description: "Prevent a unit from auto-mode dispatch", category: "correction" }, { label: "Undo", command: "/gsd undo", icon: Undo2, description: "Revert last completed unit", category: "correction" }, // ── Overflow: Knowledge ── { label: "Knowledge", command: "/gsd knowledge", icon: BookOpen, description: "Add rule, pattern, or lesson to KNOWLEDGE.md", category: "knowledge" }, // ── Overflow: Configuration ── { label: "Mode", command: "/gsd mode", icon: SlidersHorizontal, description: "Set workflow mode (solo/team)", category: "config" }, { label: "Prefs", command: "/gsd prefs", icon: Settings, description: "Manage preferences (global/project)", category: "config" }, // ── Overflow: Maintenance ── { label: "Doctor", command: "/gsd doctor", icon: Stethoscope, description: "Diagnose and repair .gsd/ state", category: "maintenance" }, { label: "Export", command: "/gsd export", icon: FileOutput, description: "Export milestone/slice results (JSON or Markdown)", category: "maintenance" }, { label: "Cleanup", command: "/gsd cleanup", icon: Trash2, description: "Remove merged branches or snapshots", category: "maintenance" }, { label: "Remote", command: "/gsd 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 = GSD_ACTIONS.slice(0, 3) /** Remaining actions in the overflow menu */ const OVERFLOW_ACTIONS = GSD_ACTIONS.slice(3) const CATEGORY_LABELS: Record = { workflow: "Workflow", visibility: "Visibility", correction: "Course Correction", knowledge: "Knowledge", config: "Configuration", maintenance: "Maintenance", } function groupByCategory(actions: GSDActionDef[]): Array<{ category: GSDActionDef["category"]; label: string; items: GSDActionDef[] }> { 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 /gsd 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 "gsd-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 = useGSDWorkspaceState() const { sendCommand } = useGSDWorkspaceActions() 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 = useGSDWorkspaceState() 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 GSD 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 = { codeToHtml: (code: string, options: { lang: string; theme: string }) => 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", ], }), ) .catch((err) => { chatHighlighterPromise = null throw err }) } return chatHighlighterPromise } /* ─── 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 { const highlighted = h.codeToHtml(codeStr, { lang: match[1], theme: shikiTheme, }) 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 GSD 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 GSD 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 GSD 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 (