From 6d2f9874b5d6ed2a6da0dabb2a58970c4cd39c44 Mon Sep 17 00:00:00 2001 From: KibbeWater <35224538+KibbeWater@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:40:34 +0100 Subject: [PATCH 01/18] feat: implement iterative plan mode with review workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive plan mode improvements including: - Plan state management with session-scoped storage at ~/.local/share/opencode/plan/ - ExitPlanMode tool that AI calls when plan is ready for review - Plan review TUI component with approve/reject keybindings - Session-scoped permissions using {sessionID} placeholder in patterns - evaluateWithSession() for permission evaluation with session context - Plan→build agent transition on approval with synthetic continuation - Post-compaction reminder pointing AI to plan file location - Server endpoints for plan approve/reject operations --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 45 +++ .../src/cli/cmd/tui/routes/session/index.tsx | 12 +- .../cmd/tui/routes/session/plan-review.tsx | 225 ++++++++++++ packages/opencode/src/id/id.ts | 2 + packages/opencode/src/permission/next.ts | 28 +- .../opencode/src/server/routes/plan-review.ts | 130 +++++++ packages/opencode/src/server/server.ts | 2 + packages/opencode/src/session/compaction.ts | 10 +- packages/opencode/src/session/plan-review.ts | 179 ++++++++++ packages/opencode/src/session/plan.ts | 187 ++++++++++ packages/opencode/src/session/prompt.ts | 27 ++ packages/opencode/src/tool/exit-plan-mode.ts | 76 +++++ packages/opencode/src/tool/exit-plan-mode.txt | 12 + packages/opencode/src/tool/registry.ts | 6 + .../opencode/test/permission-task.test.ts | 319 ------------------ 15 files changed, 936 insertions(+), 324 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/plan-review.tsx create mode 100644 packages/opencode/src/server/routes/plan-review.ts create mode 100644 packages/opencode/src/session/plan-review.ts create mode 100644 packages/opencode/src/session/plan.ts create mode 100644 packages/opencode/src/tool/exit-plan-mode.ts create mode 100644 packages/opencode/src/tool/exit-plan-mode.txt diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0edc911344c..272014685aa 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -23,6 +23,9 @@ import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" +import type { PlanReview } from "@/session/plan-review" + +type PlanReviewRequest = PlanReview.Request import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" @@ -46,6 +49,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ question: { [sessionID: string]: QuestionRequest[] } + plan_review: { + [sessionID: string]: PlanReviewRequest[] + } config: Config session: Session[] session_status: { @@ -85,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: [], permission: {}, question: {}, + plan_review: {}, command: [], provider: [], provider_default: {}, @@ -185,6 +192,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "plan_review.approved": + case "plan_review.rejected": { + const requests = store.plan_review[event.properties.sessionID] + if (!requests) break + const match = Binary.search(requests, event.properties.requestID, (r) => r.id) + if (!match.found) break + setStore( + "plan_review", + event.properties.sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + break + } + + case "plan_review.requested": { + const request = event.properties + const requests = store.plan_review[request.sessionID] + if (!requests) { + setStore("plan_review", request.sessionID, [request]) + break + } + const match = Binary.search(requests, request.id, (r) => r.id) + if (match.found) { + setStore("plan_review", request.sessionID, match.index, reconcile(request)) + break + } + setStore( + "plan_review", + request.sessionID, + produce((draft) => { + draft.splice(match.index, 0, request) + }), + ) + break + } + case "todo.updated": setStore("todo", event.properties.sessionID, event.properties.todos) break diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d91363954a1..f5ce0f1506a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -72,6 +72,7 @@ import { Filesystem } from "@/util/filesystem" import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" +import { PlanReviewPrompt } from "./plan-review" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" @@ -127,6 +128,10 @@ export function Session() { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.question[x.id] ?? []) }) + const planReviews = createMemo(() => { + if (session()?.parentID) return [] + return children().flatMap((x) => sync.data.plan_review[x.id] ?? []) + }) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -1029,8 +1034,11 @@ export function Session() { 0}> + 0}> + + { prompt = r promptRef.set(r) @@ -1039,7 +1047,7 @@ export function Session() { r.set(route.initialPrompt) } }} - disabled={permissions().length > 0 || questions().length > 0} + disabled={permissions().length > 0 || questions().length > 0 || planReviews().length > 0} onSubmit={() => { toBottom() }} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/plan-review.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/plan-review.tsx new file mode 100644 index 00000000000..d9f1ec63790 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/plan-review.tsx @@ -0,0 +1,225 @@ +import { createSignal, createEffect, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" +import { useKeybind } from "../../context/keybind" +import { useTheme } from "../../context/theme" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../component/border" +import { useTextareaKeybindings } from "../../component/textarea-keybindings" +import type { PlanReview } from "@/session/plan-review" + +type PlanReviewRequest = PlanReview.Request + +export function PlanReviewPrompt(props: { request: PlanReviewRequest }) { + const sdk = useSDK() + const { theme } = useTheme() + const keybind = useKeybind() + const bindings = useTextareaKeybindings() + + const [content, setContent] = createSignal(null) + const [loading, setLoading] = createSignal(true) + const [rejectMode, setRejectMode] = createSignal(false) + const [scrollOffset, setScrollOffset] = createSignal(0) + + let textarea: TextareaRenderable | undefined + + // Load plan content on mount + createEffect(async () => { + try { + const result = await Bun.file(props.request.filePath).text() + setContent(result) + } catch { + setContent("(Unable to read plan file)") + } finally { + setLoading(false) + } + }) + + function approve() { + sdk.client.planReview.approve({ + requestID: props.request.id, + }) + } + + function reject(feedback?: string) { + sdk.client.planReview.reject({ + requestID: props.request.id, + feedback, + }) + } + + const contentLines = () => { + const c = content() + if (!c) return [] + return c.split("\n") + } + + const visibleLines = () => { + const lines = contentLines() + const offset = scrollOffset() + const maxVisible = 20 // Show max 20 lines at a time + return lines.slice(offset, offset + maxVisible) + } + + const canScrollUp = () => scrollOffset() > 0 + const canScrollDown = () => scrollOffset() + 20 < contentLines().length + + useKeyboard((evt) => { + // When in reject mode (typing feedback) + if (rejectMode()) { + if (evt.name === "escape") { + evt.preventDefault() + setRejectMode(false) + return + } + if (evt.name === "return") { + evt.preventDefault() + const feedback = textarea?.plainText?.trim() ?? "" + reject(feedback || undefined) + return + } + // Let textarea handle all other keys + return + } + + // Normal mode + if (evt.name === "return") { + evt.preventDefault() + approve() + return + } + + if (evt.name === "r") { + evt.preventDefault() + setRejectMode(true) + return + } + + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + return + } + + // Scroll navigation + if (evt.name === "up" || evt.name === "k") { + evt.preventDefault() + if (canScrollUp()) { + setScrollOffset((o) => o - 1) + } + return + } + + if (evt.name === "down" || evt.name === "j") { + evt.preventDefault() + if (canScrollDown()) { + setScrollOffset((o) => o + 1) + } + return + } + + if (evt.name === "pageup") { + evt.preventDefault() + setScrollOffset((o) => Math.max(0, o - 10)) + return + } + + if (evt.name === "pagedown") { + evt.preventDefault() + setScrollOffset((o) => Math.min(contentLines().length - 20, o + 10)) + return + } + }) + + return ( + + + {/* Header */} + + + Plan Review + + + + + {props.request.filePath} + + + {/* Content */} + + + Loading plan... + + + + + + {visibleLines().map((line, _i) => ( + {line || " "} + ))} + + ... ({contentLines().length - scrollOffset() - 20} more lines) + + + + + {/* Reject feedback input */} + + + Feedback (optional): +