Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-forge",
"version": "0.4.12",
"version": "0.4.13",
"type": "module",
"oc-plugin": [
"server",
Expand Down
4 changes: 4 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
allowBuilds:
better-sqlite3: true
esbuild: true
msgpackr-extract: true
onlyBuiltDependencies: '["better-sqlite3"]'
77 changes: 64 additions & 13 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(() => (
<ExecutionDialog
api={api}
client={currentClient}
cache={executionContextCache()}
pluginConfig={pluginConfig}
planContent={planContent}
sessionId={sessionID}
/>
))
}

const openPastePlanDialog = (currentClient: ForgeProjectClient, sessionID: string) => {
api.ui.dialog.setSize('large')
api.ui.dialog.replace(() => (
<api.ui.DialogPrompt
title="Paste plan"
placeholder="Paste a marked or unmarked implementation plan"
value=""
onConfirm={(value) => {
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) {
Expand All @@ -276,36 +319,44 @@ 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(() => (
<ExecutionDialog
api={api}
client={currentClient}
cache={executionContextCache()}
pluginConfig={pluginConfig}
planContent={planText}
sessionId={sessionID}
/>
))
openExecutionDialog(currentClient, sessionID, planText)
}

api.keymap.registerLayer({
commands: [
{
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' }]
Expand Down
20 changes: 20 additions & 0 deletions src/utils/marked-plan-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export type LatestMarkedPlanInspection =
| { status: 'invalid'; reason: Exclude<MarkedPlanExtraction, { ok: true }>['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
Expand Down Expand Up @@ -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(/\/+$/, '')
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = '0.4.11'
export const VERSION = '0.4.13'
91 changes: 91 additions & 0 deletions test/plan-capture.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, test, expect } from 'bun:test'
import {
extractMarkedPlan,
normalizePastedPlanText,
messageText,
inspectLatestMarkedPlan,
PLAN_START_MARKER,
Expand Down Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down