From a3daa9a30c4976702f6b6c738a84b1d00e21af27 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 28 May 2026 21:58:52 +0000 Subject: [PATCH 1/2] loop: paste-plan-dialog completed after 5 iterations --- pnpm-workspace.yaml | 4 ++ src/tui.tsx | 77 +++++++++++++++++++++++----- src/utils/marked-plan-parser.ts | 20 ++++++++ test/plan-capture.test.ts | 91 +++++++++++++++++++++++++++++++++ vitest.config.ts | 2 + 5 files changed, 181 insertions(+), 13 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40e53b1fda..1357dd6ab1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1 +1,5 @@ +allowBuilds: + better-sqlite3: true + esbuild: true + msgpackr-extract: true onlyBuiltDependencies: '["better-sqlite3"]' diff --git a/src/tui.tsx b/src/tui.tsx index 0c8321b6ee..4e9f4eb83e 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -10,6 +10,7 @@ import { connectForgeProject, type ForgeProjectClient } from './utils/tui-client import { ExecutePlanPanel } from './tui/execute-plan-panel' import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/session-follow' import { fetchLatestPlanForSession } from './utils/plan-from-messages' +import { normalizePastedPlanText } from './utils/marked-plan-parser' type TuiKeybinds = { executePlan: string @@ -264,6 +265,48 @@ const tui: TuiPlugin = async (api) => { return startClientConnection() } + const openExecutionDialog = (currentClient: ForgeProjectClient, sessionID: string, planContent: string) => { + api.ui.dialog.setSize('xlarge') + api.ui.dialog.replace(() => ( + + )) + } + + const openPastePlanDialog = (currentClient: ForgeProjectClient, sessionID: string) => { + api.ui.dialog.setSize('large') + api.ui.dialog.replace(() => ( + { + const normalized = normalizePastedPlanText(value) + if (!normalized.ok) { + api.ui.toast({ + message: normalized.reason === 'empty' + ? 'Paste a plan before executing' + : `Invalid plan markers: ${normalized.reason}`, + variant: 'error', + duration: 4000, + }) + openPastePlanDialog(currentClient, sessionID) + return + } + + openExecutionDialog(currentClient, sessionID, normalized.planText) + }} + onCancel={() => api.ui.dialog.clear()} + /> + )) + } + const runExecutePlan = async () => { const sessionID = getCurrentRouteSessionId(api) if (!sessionID) { @@ -276,24 +319,15 @@ const tui: TuiPlugin = async (api) => { const planText = await fetchLatestPlanForSession(api.client, sessionID, directory) if (!planText) { api.ui.toast({ - message: 'No plan in current session — have the architect emit one first', + message: 'No plan in current session — paste one to execute', variant: 'info', duration: 4000, }) + openPastePlanDialog(currentClient, sessionID) return } - api.ui.dialog.setSize('xlarge') - api.ui.dialog.replace(() => ( - - )) + openExecutionDialog(currentClient, sessionID, planText) } api.keymap.registerLayer({ @@ -301,11 +335,28 @@ const tui: TuiPlugin = async (api) => { { name: 'forge.plan.execute', title: 'Forge: Execute plan', - desc: 'Open the execution dialog for the current session\'s plan', + desc: 'Open the execution dialog for the current session plan, or paste one if none is found', category: 'Forge', namespace: 'palette', run: () => { void runExecutePlan() }, }, + { + name: 'forge.plan.executePasted', + title: 'Forge: Execute pasted plan', + desc: 'Paste a marked or unmarked plan and open the execution dialog', + category: 'Forge', + namespace: 'palette', + run: () => { + const sessionID = getCurrentRouteSessionId(api) + if (!sessionID) { + api.ui.toast({ message: 'Open a session first', variant: 'info', duration: 3000 }) + return + } + void ensureClient().then((currentClient) => { + if (currentClient) openPastePlanDialog(currentClient, sessionID) + }) + }, + }, ], bindings: opts.keybinds.executePlan ? [{ key: opts.keybinds.executePlan, cmd: 'forge.plan.execute' }] diff --git a/src/utils/marked-plan-parser.ts b/src/utils/marked-plan-parser.ts index e8926fcb5f..bc3655d114 100644 --- a/src/utils/marked-plan-parser.ts +++ b/src/utils/marked-plan-parser.ts @@ -15,6 +15,10 @@ export type LatestMarkedPlanInspection = | { status: 'invalid'; reason: Exclude['reason']; messageId?: string } | { status: 'missing' } +export type PastedPlanNormalization = + | { ok: true; planText: string; source: 'marked' | 'unmarked' } + | { ok: false; reason: 'empty' | 'multiple' | 'unterminated' } + function countPlanMarkers(text: string): { startCount: number; endCount: number } { let startCount = 0 let endCount = 0 @@ -82,6 +86,22 @@ export function extractMarkedPlan(text: string): MarkedPlanExtraction { return { ok: true, planText } } +export function normalizePastedPlanText(text: string): PastedPlanNormalization { + const trimmed = text.trim() + if (!trimmed) return { ok: false, reason: 'empty' } + + const extraction = extractMarkedPlan(trimmed) + if (extraction.ok) { + return { ok: true, planText: extraction.planText, source: 'marked' } + } + + if (extraction.reason === 'missing') { + return { ok: true, planText: trimmed, source: 'unmarked' } + } + + return { ok: false, reason: extraction.reason } +} + export function sanitizePlanPaths(planText: string, projectDir: string | undefined): string { if (!projectDir) return planText const trimmed = projectDir.replace(/\/+$/, '') diff --git a/test/plan-capture.test.ts b/test/plan-capture.test.ts index 01e0865249..e8cb1887af 100644 --- a/test/plan-capture.test.ts +++ b/test/plan-capture.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'bun:test' import { extractMarkedPlan, + normalizePastedPlanText, messageText, inspectLatestMarkedPlan, PLAN_START_MARKER, @@ -148,6 +149,96 @@ ${PLAN_END_MARKER}` }) }) +describe('normalizePastedPlanText', () => { + test('marked paste extracts plan body and excludes surrounding text', () => { + const text = `Some intro text + +${PLAN_START_MARKER} +# Implementation Plan + +## Phase 1 +- Do thing one + +## Phase 2 +- Do thing two +${PLAN_END_MARKER} + +Some outro text` + + const result = normalizePastedPlanText(text) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.source).toBe('marked') + expect(result.planText).toContain('# Implementation Plan') + expect(result.planText).not.toContain(PLAN_START_MARKER) + expect(result.planText).not.toContain(PLAN_END_MARKER) + expect(result.planText).not.toContain('Some intro text') + expect(result.planText).not.toContain('Some outro text') + } + }) + + test('unmarked paste returns trimmed text unchanged', () => { + const text = ` + # My Plan + + A simple plan without markers. + + - Step one + - Step two + ` + + const result = normalizePastedPlanText(text) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.source).toBe('unmarked') + expect(result.planText).toBe(text.trim()) + } + }) + + test('empty string returns empty', () => { + const result = normalizePastedPlanText('') + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('empty') + } + }) + + test('whitespace-only string returns empty', () => { + const result = normalizePastedPlanText(' \n \n ') + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('empty') + } + }) + + test('malformed marked paste with only start marker returns unterminated', () => { + const text = `${PLAN_START_MARKER} +Plan content without end` + + const result = normalizePastedPlanText(text) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('unterminated') + } + }) + + test('malformed marked paste with multiple marked plans returns multiple', () => { + const text = `${PLAN_START_MARKER} +Plan A +${PLAN_END_MARKER} + +${PLAN_START_MARKER} +Plan B +${PLAN_END_MARKER}` + + const result = normalizePastedPlanText(text) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('multiple') + } + }) +}) + describe('messageText', () => { test('joins text parts with newlines', () => { const message: PlanCaptureMessage = { diff --git a/vitest.config.ts b/vitest.config.ts index b8b39dd495..a2581d3142 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -60,6 +60,8 @@ export default defineConfig({ 'test/tui/session-follow.test.ts', 'test/hooks/plan-approval-dedupe.test.ts', 'test/hooks/plan-approval-worktree-timing.test.ts', + 'test/plan-capture.test.ts', + 'test/utils/plan-from-messages.test.ts', 'test/services/select-initial-worktree-session.test.ts', 'test/plan-approval.test.ts', 'test/api-model-preferences.test.ts', From 7c1aa326d072828d00fd59e3773f45e8370df406 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 30 May 2026 14:45:50 -0400 Subject: [PATCH 2/2] chore: bump version to 0.4.13 --- package.json | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 68b9ee7993..cd7213f02e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-forge", - "version": "0.4.12", + "version": "0.4.13", "type": "module", "oc-plugin": [ "server", diff --git a/src/version.ts b/src/version.ts index 79e8c35205..274ad5ddb2 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.4.11' +export const VERSION = '0.4.13'