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'