From 7b2a86dbfc7264056bc7c95a3d755b146df10d5b Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Mon, 30 Mar 2026 20:51:29 -0400 Subject: [PATCH 01/23] feat: auto-detect Claude Code plans and surface inline commenting UI Watch plan directories (~/.claude/plans/ and project-local .claude/plans/) for markdown plan files. Surface detected plans in the sidebar with a "Plans" section. Clicking a plan opens a split pane alongside the terminal that renders the plan as structured sections with inline feedback commenting. Feedback is written back to the plan file as blockquotes that Claude Code can read on its next pass. Closes #243 --- client/src/App.tsx | 45 +++- client/src/EmptyState.tsx | 11 +- client/src/PlanPane.tsx | 304 ++++++++++++++++++++++++++ client/src/PlanSidebar.tsx | 61 ++++++ client/src/Sidebar.tsx | 15 +- client/src/terminalDisplay.ts | 9 +- client/src/tips.ts | 4 + client/src/usePlans.ts | 107 +++++++++ client/src/useTerminalLifecycle.ts | 30 ++- client/src/useTerminalMetadata.ts | 21 +- common/src/contract.ts | 8 + common/src/index.ts | 32 +++ server/src/git.ts | 5 +- server/src/meta/claude.ts | 8 +- server/src/meta/github.ts | 8 +- server/src/meta/index.ts | 10 +- server/src/meta/plans.ts | 190 ++++++++++++++++ server/src/plans.ts | 64 ++++++ server/src/publisher.ts | 16 +- server/src/router.ts | 25 ++- server/src/session.ts | 4 +- server/src/terminals.ts | 28 ++- tests/features/plans.feature | 56 +++++ tests/step_definitions/plans_steps.ts | 232 ++++++++++++++++++++ 24 files changed, 1242 insertions(+), 51 deletions(-) create mode 100644 client/src/PlanPane.tsx create mode 100644 client/src/PlanSidebar.tsx create mode 100644 client/src/usePlans.ts create mode 100644 server/src/meta/plans.ts create mode 100644 server/src/plans.ts create mode 100644 tests/features/plans.feature create mode 100644 tests/step_definitions/plans_steps.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 3506a4f8e..463fd8d2b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,6 +22,7 @@ import MissionControl, { type MCMode } from "./MissionControl"; import ModalDialog, { refocusTerminal } from "./ModalDialog"; import Dialog from "@corvu/dialog"; import EmptyState from "./EmptyState"; +import PlanPane from "./PlanPane"; import { createCommands } from "./commands"; import { wsStatus, serverRestarted } from "./rpc"; @@ -34,6 +35,7 @@ import { useSubPanel } from "./useSubPanel"; import { useColorScheme } from "./useColorScheme"; import { useTips } from "./useTips"; import { useRecentRepos } from "./useRecentRepos"; +import { usePlans } from "./usePlans"; const App: Component = () => { const { @@ -118,6 +120,16 @@ const App: Component = () => { const [searchOpen, setSearchOpen] = createSignal(false); createEffect(on(activeId, () => setSearchOpen(false), { defer: true })); + const { + plans, + activePlanPath, + planContent, + isPlanContentLoading, + openPlan, + closePlan, + addFeedback, + } = usePlans({ activeMeta }); + const { initTipTriggers, startupTips, setStartupTips } = useTips(); initTipTriggers({ terminalIds }); @@ -336,11 +348,24 @@ const App: Component = () => { onReorder={reorderTerminals} open={sidebarOpen()} onClose={closeSidebar} + plans={plans()} + activePlanPath={activePlanPath()} + onSelectPlan={(path) => { + // Toggle: clicking the active plan closes it + if (activePlanPath() === path) closePlan(); + else openPlan(path); + }} /> {/* min-w-0: override flex min-width:auto so terminal area shrinks below canvas intrinsic size */} -
+
+ {/* Terminal area — always rendered, flex-grows into available space */}
{
+ {/* Plan pane — shown as right split when a plan is open */} + +
+
+ p.path === activePlanPath())?.name ?? + "Plan" + } + onClose={closePlan} + onAddFeedback={addFeedback} + /> +
+
diff --git a/client/src/EmptyState.tsx b/client/src/EmptyState.tsx index f423dc21a..24213806d 100644 --- a/client/src/EmptyState.tsx +++ b/client/src/EmptyState.tsx @@ -27,13 +27,14 @@ const EmptyState: Component = (props) => (
{(session) => { - const topLevel = () => - session().terminals.filter((t) => !t.parentId); + const topLevel = () => session().terminals.filter((t) => !t.parentId); const subCount = () => - session().terminals.filter((t) => t.parentId) - .length; + session().terminals.filter((t) => t.parentId).length; return ( -
+

Restore previous session

diff --git a/client/src/PlanPane.tsx b/client/src/PlanPane.tsx new file mode 100644 index 000000000..76fb06a53 --- /dev/null +++ b/client/src/PlanPane.tsx @@ -0,0 +1,304 @@ +/** PlanPane — renders a plan file as structured sections with inline commenting. */ + +import { + type Component, + Show, + For, + createSignal, + createMemo, +} from "solid-js"; +import type { PlanContent } from "kolu-common"; + +/** A parsed section of a plan file (heading + content until next heading). */ +interface PlanSection { + heading: string; + level: number; + /** Line number of the heading (1-based). */ + lineStart: number; + /** Line number of the last content line before the next heading (1-based). */ + lineEnd: number; + /** Raw content lines below the heading (excludes the heading itself). */ + content: string; + /** Existing feedback blocks in this section. */ + feedbacks: string[]; +} + +/** Parse plan markdown into sections by headings. */ +function parseSections(content: string): PlanSection[] { + const lines = content.split("\n"); + const sections: PlanSection[] = []; + let current: PlanSection | null = null; + const contentLines: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const headingMatch = line.match(/^(#{1,6})\s+(.+)/); + + if (headingMatch) { + // Flush previous section + if (current) { + current.lineEnd = i; // Previous line (0-based → 1-based handled below) + current.content = contentLines.join("\n").trim(); + current.feedbacks = extractFeedbacks(contentLines); + sections.push(current); + contentLines.length = 0; + } + + current = { + heading: headingMatch[2]!, + level: headingMatch[1]!.length, + lineStart: i + 1, // 1-based + lineEnd: i + 1, + content: "", + feedbacks: [], + }; + } else if (current) { + contentLines.push(line); + } + } + + // Flush last section + if (current) { + current.lineEnd = lines.length; + current.content = contentLines.join("\n").trim(); + current.feedbacks = extractFeedbacks(contentLines); + sections.push(current); + } + + // If no headings found, treat entire content as one section + if (sections.length === 0 && content.trim()) { + sections.push({ + heading: "Plan", + level: 1, + lineStart: 1, + lineEnd: lines.length, + content: content.trim(), + feedbacks: [], + }); + } + + return sections; +} + +/** Extract existing feedback blockquotes from content lines. */ +function extractFeedbacks(lines: string[]): string[] { + const feedbacks: string[] = []; + let current: string[] = []; + let inFeedback = false; + + for (const line of lines) { + if (line.startsWith("> [FEEDBACK]:")) { + inFeedback = true; + current.push(line.replace(/^> \[FEEDBACK\]:\s*/, "")); + } else if (inFeedback && line.startsWith("> ")) { + current.push(line.replace(/^> /, "")); + } else { + if (inFeedback && current.length > 0) { + feedbacks.push(current.join("\n")); + current = []; + } + inFeedback = false; + } + } + if (current.length > 0) feedbacks.push(current.join("\n")); + + return feedbacks; +} + +/** Strip feedback blockquotes from content for display. + * Only removes lines that are part of a feedback block (starting with `> [FEEDBACK]:`) + * and their continuation lines. Normal blockquotes are preserved. */ +function stripFeedback(content: string): string { + const lines = content.split("\n"); + const result: string[] = []; + let inFeedback = false; + + for (const line of lines) { + if (line.startsWith("> [FEEDBACK]:")) { + inFeedback = true; + } else if (inFeedback && line.startsWith("> ")) { + // Continuation of feedback block — skip + } else { + inFeedback = false; + result.push(line); + } + } + + return result.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +const SectionBlock: Component<{ + section: PlanSection; + onAddFeedback: (afterLine: number, text: string) => void; +}> = (props) => { + const [commenting, setCommenting] = createSignal(false); + const [feedbackText, setFeedbackText] = createSignal(""); + + function handleSubmit() { + const text = feedbackText().trim(); + if (!text) return; + props.onAddFeedback(props.section.lineStart, text); + setFeedbackText(""); + setCommenting(false); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + if (e.key === "Escape") { + setCommenting(false); + setFeedbackText(""); + } + } + + const headingTag = () => { + const l = props.section.level; + return l <= 2 ? "text-base font-semibold" : "text-sm font-medium"; + }; + + const displayContent = createMemo(() => stripFeedback(props.section.content)); + + return ( +
+ {/* Heading + add-feedback button */} +
+ + {props.section.heading} + + +
+ + {/* Content */} + +
+          {displayContent()}
+        
+
+ + {/* Existing feedbacks */} + + {(fb) => ( +
+ {fb} +
+ )} +
+ + {/* Feedback input */} + +
+