diff --git a/packages/native/package.json b/packages/native/package.json index 117e000be..2ba8257ba 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -60,9 +60,9 @@ "types": "./dist/diff/index.d.ts", "default": "./dist/diff/index.js" }, - "./gsd-parser": { - "types": "./dist/gsd-parser/index.d.ts", - "default": "./dist/gsd-parser/index.js" + "./forge-parser": { + "types": "./dist/forge-parser/index.d.ts", + "default": "./dist/forge-parser/index.js" }, "./highlight": { "types": "./dist/highlight/index.d.ts", diff --git a/packages/native/src/gsd-parser/index.ts b/packages/native/src/forge-parser/index.ts similarity index 100% rename from packages/native/src/gsd-parser/index.ts rename to packages/native/src/forge-parser/index.ts diff --git a/packages/native/src/gsd-parser/types.ts b/packages/native/src/forge-parser/types.ts similarity index 100% rename from packages/native/src/gsd-parser/types.ts rename to packages/native/src/forge-parser/types.ts diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 70b8aadd7..5e58fdd2d 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -113,7 +113,7 @@ export { extractAllSections, batchParseGsdFiles, parseRoadmapFile, -} from "./gsd-parser/index.js"; +} from "./forge-parser/index.js"; export type { BatchParseResult, FrontmatterResult, @@ -122,7 +122,7 @@ export type { NativeRoadmapSlice, ParsedGsdFile, SectionResult, -} from "./gsd-parser/index.js"; +} from "./forge-parser/index.js"; export { truncateTail, truncateHead, truncateOutput } from "./truncate/index.js"; export type { TruncateResult, TruncateOutputResult } from "./truncate/index.js"; diff --git a/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts b/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts index 169e6dc83..e8b8461c6 100644 --- a/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts +++ b/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -test("shared/mod.ts has no import from "@sf-run/pi-tui", () => { +test('shared/mod.ts has no import from "@sf-run/pi-tui"', () => { const src = readFileSync(join(__dirname, "../../shared/mod.ts"), "utf-8"); - assert.ok(!src.includes("@sf-run/pi-tui"), "mod.ts must not import "@sf-run/pi-tui"); + assert.ok(!src.includes("@sf-run/pi-tui"), 'mod.ts must not import "@sf-run/pi-tui"'); }); diff --git a/web/app/page.tsx b/web/app/page.tsx index 9923adfd9..e15812f96 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -2,8 +2,8 @@ import dynamic from "next/dynamic" -const GSDAppShell = dynamic( - () => import("@/components/gsd/app-shell").then((mod) => mod.GSDAppShell), +const SFAppShell = dynamic( + () => import("@/components/sf/app-shell").then((mod) => mod.SFAppShell), { ssr: false, loading: () => ( @@ -15,5 +15,5 @@ const GSDAppShell = dynamic( ) export default function Page() { - return + return } diff --git a/web/components/gsd/activity-view.tsx b/web/components/gsd/activity-view.tsx deleted file mode 100644 index 20ab206bd..000000000 --- a/web/components/gsd/activity-view.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client" - -import { CheckCircle2, Play, Clock, Terminal, AlertCircle } from "lucide-react" -import { cn } from "@/lib/utils" -import { useGSDWorkspaceState, type TerminalLineType } from "@/lib/gsd-workspace-store" - -function EventIcon({ type }: { type: TerminalLineType }) { - const baseClass = "h-4 w-4" - switch (type) { - case "system": - return - case "success": - return - case "error": - return - case "output": - return - case "input": - return - default: - return - } -} - -export function ActivityView() { - const workspace = useGSDWorkspaceState() - const terminalLines = workspace.terminalLines ?? [] - - // Show most recent events first - const reversedLines = [...terminalLines].reverse() - - return ( -
-
-

Activity Log

-

- Execution history and git operations -

-
- -
- {reversedLines.length === 0 ? ( -
- No activity yet. Events will appear here once the workspace is active. -
- ) : ( -
- {/* Timeline line */} -
- -
- {reversedLines.map((line) => ( -
- {/* Timeline dot */} -
- -
- - {/* Content */} -
-
-
-

{line.content}

-
- - {line.timestamp} - -
-
-
- ))} -
-
- )} -
-
- ) -} diff --git a/web/components/gsd/app-shell.tsx b/web/components/gsd/app-shell.tsx deleted file mode 100644 index 588c26cfd..000000000 --- a/web/components/gsd/app-shell.tsx +++ /dev/null @@ -1,605 +0,0 @@ -"use client" - -import Image from "next/image" -import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react" -import { Menu, X } from "lucide-react" -import { Sidebar, MilestoneExplorer, CollapsedMilestoneSidebar } from "@/components/gsd/sidebar" -import { ShellTerminal } from "@/components/gsd/shell-terminal" -import { Dashboard } from "@/components/gsd/dashboard" -import { Roadmap } from "@/components/gsd/roadmap" -import { FilesView } from "@/components/gsd/files-view" -import { ActivityView } from "@/components/gsd/activity-view" -import { VisualizerView } from "@/components/gsd/visualizer-view" -import { StatusBar } from "@/components/gsd/status-bar" -import { DualTerminal } from "@/components/gsd/dual-terminal" -import { FocusedPanel } from "@/components/gsd/focused-panel" -import { OnboardingGate } from "@/components/gsd/onboarding-gate" -import { CommandSurface } from "@/components/gsd/command-surface" -import { DevOverridesProvider } from "@/lib/dev-overrides" -import { ProjectStoreManagerProvider, useProjectStoreManager } from "@/lib/project-store-manager" -import { Skeleton } from "@/components/ui/skeleton" -import { cn } from "@/lib/utils" -import { toast } from "sonner" -import { - GSDWorkspaceProvider, - getCurrentScopeLabel, - getProjectDisplayName, - getStatusPresentation, - getVisibleWorkspaceError, - useGSDWorkspaceState, - useGSDWorkspaceActions, -} from "@/lib/gsd-workspace-store" -import { ChatMode } from "@/components/gsd/chat-mode" -import { ScopeBadge } from "@/components/gsd/scope-badge" -import { Badge } from "@/components/ui/badge" -import { ProjectsPanel, ProjectSelectionGate } from "@/components/gsd/projects-view" -import { UpdateBanner } from "@/components/gsd/update-banner" -import { getAuthToken } from "@/lib/auth" - -const KNOWN_VIEWS = new Set(["dashboard", "power", "chat", "roadmap", "files", "activity", "visualize"]) - -function viewStorageKey(projectCwd: string): string { - return `gsd-active-view:${projectCwd}` -} - -function WorkspaceChrome() { - const [activeView, setActiveView] = useState("dashboard") - const [isTerminalExpanded, setIsTerminalExpanded] = useState(false) - const [terminalHeight, setTerminalHeight] = useState(300) - const [terminalDragActive, setTerminalDragActive] = useState(false) - const isDraggingTerminal = useRef(false) - const didDragTerminal = useRef(false) - const dragStartY = useRef(0) - const dragStartHeight = useRef(0) - const [sidebarWidth, setSidebarWidth] = useState(256) - const isDraggingSidebar = useRef(false) - const dragStartX = useRef(0) - const dragStartWidth = useRef(0) - const [sidebarCollapsed, setSidebarCollapsed] = useState(false) - const [viewRestored, setViewRestored] = useState(false) - const [projectsPanelOpen, setProjectsPanelOpen] = useState(false) - const [mobileNavOpen, setMobileNavOpen] = useState(false) - const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false) - const workspace = useGSDWorkspaceState() - const { refreshBoot } = useGSDWorkspaceActions() - - const status = getStatusPresentation(workspace) - const projectPath = workspace.boot?.project.cwd - const projectLabel = getProjectDisplayName(projectPath) - const titleOverride = workspace.titleOverride?.trim() || null - const scopeLabel = getCurrentScopeLabel(workspace.boot?.workspace) - const visibleError = getVisibleWorkspaceError(workspace) - - // Restore persisted view once boot provides projectCwd - useEffect(() => { - if (viewRestored || !projectPath) return - const restoreTimer = window.setTimeout(() => { - try { - const stored = sessionStorage.getItem(viewStorageKey(projectPath)) - if (stored && KNOWN_VIEWS.has(stored)) { - setActiveView(stored) - } - } catch { - // sessionStorage may be unavailable (e.g. SSR, iframe sandbox) - } - setViewRestored(true) - }, 0) - return () => window.clearTimeout(restoreTimer) - }, [projectPath, viewRestored]) - - // Reset viewRestored when projectPath changes so the restore effect can - // fire for the newly-selected project (fixes #2711: tab reset on switch). - const prevProjectPath = useRef(projectPath) - useEffect(() => { - if (prevProjectPath.current !== projectPath) { - prevProjectPath.current = projectPath - setViewRestored(false) - } - }, [projectPath]) - - // Persist view changes to sessionStorage - useEffect(() => { - if (!projectPath) return - try { - sessionStorage.setItem(viewStorageKey(projectPath), activeView) - } catch { - // sessionStorage may be unavailable - } - }, [activeView, projectPath]) - - // Restore sidebar collapsed state from localStorage - useEffect(() => { - const restoreTimer = window.setTimeout(() => { - try { - const stored = localStorage.getItem("gsd-sidebar-collapsed") - if (stored === "true") setSidebarCollapsed(true) - } catch { - // localStorage may be unavailable - } - }, 0) - return () => window.clearTimeout(restoreTimer) - }, []) - - // Persist sidebar collapsed state - useEffect(() => { - try { - localStorage.setItem("gsd-sidebar-collapsed", String(sidebarCollapsed)) - } catch { - // localStorage may be unavailable - } - }, [sidebarCollapsed]) - - useEffect(() => { - if (typeof document === "undefined") return - const base = projectLabel ? `GSD - ${projectLabel}` : "GSD" - document.title = titleOverride ? `${titleOverride} · ${base}` : base - }, [titleOverride, projectLabel]) - - // Close mobile nav on view change - const handleViewChange = useCallback((view: string) => { - setActiveView(view) - setMobileNavOpen(false) - }, []) - - // Listen for cross-component file navigation events (e.g. sidebar task clicks) - useEffect(() => { - const handler = () => { - setActiveView("files") - } - window.addEventListener("gsd:open-file", handler) - return () => window.removeEventListener("gsd:open-file", handler) - }, []) - - // Listen for cross-component view navigation events (e.g. /gsd visualize dispatch) - useEffect(() => { - const handler = (e: CustomEvent<{ view: string }>) => { - if (KNOWN_VIEWS.has(e.detail.view)) { - handleViewChange(e.detail.view) - } - } - window.addEventListener("gsd:navigate-view", handler as EventListener) - return () => window.removeEventListener("gsd:navigate-view", handler as EventListener) - }, [handleViewChange]) - - // Listen for projects panel toggle (sidebar icon, or programmatic) - useEffect(() => { - const handler = () => setProjectsPanelOpen(true) - window.addEventListener("gsd:open-projects", handler) - return () => window.removeEventListener("gsd:open-projects", handler) - }, []) - - // Terminal + sidebar panel drag-to-resize - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (isDraggingTerminal.current) { - didDragTerminal.current = true - const delta = dragStartY.current - e.clientY - const newHeight = Math.max(150, Math.min(600, dragStartHeight.current + delta)) - setTerminalHeight(newHeight) - } - if (isDraggingSidebar.current) { - const delta = dragStartX.current - e.clientX - const newWidth = Math.max(180, Math.min(480, dragStartWidth.current + delta)) - setSidebarWidth(newWidth) - } - } - const handleMouseUp = () => { - isDraggingTerminal.current = false - isDraggingSidebar.current = false - setTerminalDragActive(false) - document.body.style.cursor = "" - document.body.style.userSelect = "" - } - document.addEventListener("mousemove", handleMouseMove) - document.addEventListener("mouseup", handleMouseUp) - return () => { - document.removeEventListener("mousemove", handleMouseMove) - document.removeEventListener("mouseup", handleMouseUp) - } - }, []) - - const handleTerminalDragStart = useCallback( - (e: React.MouseEvent) => { - isDraggingTerminal.current = true - setTerminalDragActive(true) - dragStartY.current = e.clientY - dragStartHeight.current = terminalHeight - document.body.style.cursor = "row-resize" - document.body.style.userSelect = "none" - }, - [terminalHeight], - ) - - const handleSidebarDragStart = useCallback( - (e: React.MouseEvent) => { - isDraggingSidebar.current = true - dragStartX.current = e.clientX - dragStartWidth.current = sidebarWidth - document.body.style.cursor = "col-resize" - document.body.style.userSelect = "none" - }, - [sidebarWidth], - ) - - const retryDisabled = !!workspace.commandInFlight || workspace.onboardingRequestState !== "idle" - const isConnecting = workspace.bootStatus === "idle" || workspace.bootStatus === "loading" - - // Persistent loading toast — dismissed the moment boot completes - useEffect(() => { - if (!isConnecting) return - const id = toast.loading("Connecting to workspace…", { - description: "Establishing the live bridge session", - duration: Infinity, - }) - return () => { - toast.dismiss(id) - } - }, [isConnecting]) - - // Detect project welcome state — hide chrome for v1-legacy, brownfield, blank projects - const detection = workspace.boot?.projectDetection - const isWelcomeState = - !isConnecting && - activeView === "dashboard" && - detection != null && - detection.kind !== "active-gsd" && - detection.kind !== "empty-gsd" - - // --- Unauthenticated gate --- - // Render a clear recovery screen before any workspace chrome is mounted so - // users who open a manually-typed URL (no #token= fragment) get actionable - // guidance instead of a cascade of 401 errors. - if (workspace.bootStatus === "unauthenticated") { - return ( -
- GSD - GSD -
-

Authentication Required

-

- This workspace requires an auth token. Copy the full URL from your terminal - (including the{" "} - #token=…{" "} - part) or restart with{" "} - gsd --web. -

-
-
- ) - } - - return ( -
-
-
- {/* Mobile hamburger menu */} - -
- GSD - GSD - - beta - -
- / - - {isConnecting ? ( - - ) : ( - <> - {projectLabel} - {titleOverride && ( - - {titleOverride} - - )} - - )} - -
- -
- {/* Hidden status marker for test instrumentation */} - {status.label} - - {isConnecting ? : } - -
-
- - - - {!isConnecting && visibleError && ( -
- {visibleError} - -
- )} - - {/* Mobile navigation drawer */} - {mobileNavOpen && ( -
setMobileNavOpen(false)} - data-testid="mobile-nav-overlay" - /> - )} -
- {} : handleViewChange} isConnecting={isConnecting} mobile /> -
- - {/* Mobile milestone drawer */} - {mobileMilestoneOpen && ( -
setMobileMilestoneOpen(false)} - data-testid="mobile-milestone-overlay" - /> - )} - {!isWelcomeState && ( -
- setMobileMilestoneOpen(false)} - /> -
- )} - -
- {/* Desktop sidebar — hidden on mobile */} -
- {} : handleViewChange} isConnecting={isConnecting} /> -
- -
-
- {isConnecting ? ( - - ) : ( - <> - {activeView === "dashboard" && ( - setIsTerminalExpanded(true)} - /> - )} - {activeView === "power" && } - {activeView === "roadmap" && } - {activeView === "files" && } - {activeView === "activity" && } - {activeView === "visualize" && } - {activeView === "chat" && } - - )} -
- - {activeView !== "power" && activeView !== "chat" && ( -
- {/* Drag handle + toggle header — entire bar is clickable */} -
{ - if (didDragTerminal.current) { - didDragTerminal.current = false - return - } - if (!isConnecting) setIsTerminalExpanded(!isTerminalExpanded) - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - if (!isConnecting) setIsTerminalExpanded(!isTerminalExpanded) - } - }} - className={cn( - "flex h-8 w-full items-center justify-between bg-card px-3 text-xs select-none transition-colors", - isTerminalExpanded && "cursor-row-resize", - !isTerminalExpanded && !isConnecting && "cursor-pointer hover:bg-muted/50", - isConnecting && "cursor-default", - )} - onMouseDown={(e) => { - if (isTerminalExpanded) handleTerminalDragStart(e) - }} - > -
- Terminal - - {isTerminalExpanded ? "▼" : "▲"} - -
-
- {/* Terminal content */} -
- -
-
- )} -
- - {/* Resizable milestone sidebar — hidden on mobile, hidden during project welcome */} - {!isWelcomeState && !sidebarCollapsed && ( -
- {/* Thin visible border */} -
- {/* Wide invisible grab area overlapping the border */} -
-
- )} -
- {!isWelcomeState && (sidebarCollapsed ? ( - setSidebarCollapsed(false)} /> - ) : ( - setSidebarCollapsed(true)} - /> - ))} -
-
- - {/* Desktop status bar — hidden on mobile */} -
- -
- - {/* Mobile bottom bar — quick access to milestones + status */} - {!isWelcomeState && ( -
-
- {status.label} - - {scopeLabel} -
- -
- )} - - - - - -
- ) -} - -export function GSDAppShell() { - // Extract the auth token from the URL fragment on first render. - // Must happen before any API calls fire. - getAuthToken() - - return ( - - - - ) -} - -function ProjectAwareWorkspace() { - const manager = useProjectStoreManager() - const activeProjectCwd = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot) - const activeStore = activeProjectCwd ? manager.getActiveStore() : null - - // Shut down all projects when the tab actually closes. - // IMPORTANT: pagehide fires both on real page unload AND on mobile/Safari - // tab switches (bfcache entry). When event.persisted is true the page is - // being cached for later reuse — the server must stay alive. Only send - // the shutdown beacon when the page is truly being discarded. - useEffect(() => { - const handlePageHide = (event: PageTransitionEvent) => { - if (event.persisted) { - // Page is entering bfcache (tab switch, app backgrounding) — keep - // the server alive so PTY sessions survive. - return - } - // sendBeacon cannot set custom headers, so pass the auth token as a - // query parameter instead (the proxy accepts `_token` as a fallback). - const token = getAuthToken() - const url = token ? `/api/shutdown?_token=${token}` : "/api/shutdown" - navigator.sendBeacon(url, "") - } - - window.addEventListener("pagehide", handlePageHide) - - return () => { - window.removeEventListener("pagehide", handlePageHide) - } - }, []) - - // No project selected yet — show project selection gate - if (!activeProjectCwd || !activeStore) { - return - } - - return ( - - - - - - ) -} diff --git a/web/components/gsd/chat-mode.tsx b/web/components/gsd/chat-mode.tsx deleted file mode 100644 index f298f2754..000000000 --- a/web/components/gsd/chat-mode.tsx +++ /dev/null @@ -1,2346 +0,0 @@ -"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/gsd-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 ( -