@prjct.app/pi-plan 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,8 @@
1
- # Changelog
1
+ ## [0.2.0](https://github.com/prjct-app/pi-plan/compare/v0.1.3...v0.2.0) (2026-09-10)
2
+
3
+ ### Features
4
+
5
+ * richer plan TUI, structured planning, and hardened review flow ([582411f](https://github.com/prjct-app/pi-plan/commit/582411f6ab21e45f2d49858629e066b6efee5853))
2
6
 
3
7
  ## 0.1.3
4
8
 
package/CONTRIBUTING.md CHANGED
@@ -12,3 +12,7 @@
12
12
  ## Package documentation
13
13
 
14
14
  Follow [docs/package.md](docs/package.md) and its versioned official references. Keep README examples consistent with registered commands, distinguish tested behavior from unverified compatibility, and verify `npm run check:package` before release.
15
+
16
+ ## Releases
17
+
18
+ Merging a releasable change into `main` automatically publishes to npm. Use conventional commit messages and read [Automatic releases](docs/releases.md) before merging. The workflow manages versions and authenticates with npm through OIDC.
package/README.md CHANGED
@@ -24,10 +24,19 @@ For project-only installation, add `-l`: `pi install -l npm:@prjct.app/pi-plan`.
24
24
  | --- | --- |
25
25
  | `/plan` | Toggle planning mode |
26
26
  | `Ctrl+Alt+P` | Toggle planning mode from the keyboard |
27
- | `/todos` | Show the current plan's steps |
27
+ | `/todos` | Open the current plan dialog with steps, progress, and verification |
28
28
  | `pi --plan` | Start with planning enabled after the extension is installed |
29
29
 
30
- Run `/plan`, then ask Pi to investigate a concrete task. Numbered steps under a `Plan:` heading become a progress list. Choose whether to execute when prompted. During execution, `[DONE:n]` markers update completed steps.
30
+ Run `/plan`, then ask Pi to investigate a concrete task. The planner answers with a **Goal**, a short **Approach**, numbered steps under a `Plan:` heading that reference real files, a **Verify** section with the exact validation commands, and any **Risks**.
31
+
32
+ When a plan is ready, a review dialog offers four actions:
33
+
34
+ - **Execute the plan** — restore full tools and track step progress.
35
+ - **Refine the plan** — send feedback and keep planning.
36
+ - **Stay in plan mode** — keep read-only exploration.
37
+ - **Discard the plan** — exit plan mode and drop the steps.
38
+
39
+ During execution, a progress widget shows a completion bar, the current step, and the Verify command; `[DONE:n]` markers update completed steps, the footer shows `▸ plan n/total`, and the transcript renders the plan, kickoff, and completion with collapsed summaries expandable via `Ctrl+O`. After the final step, Pi runs the Verify commands and reports the result.
31
40
 
32
41
  Planning disables the managed `edit` and `write` tools and checks Bash calls against a read-only allowlist. Other custom tools and external processes can retain write capabilities. Plan mode is a workflow policy, not a security sandbox.
33
42
 
@@ -46,7 +55,7 @@ pi remove npm:@prjct.app/pi-plan
46
55
 
47
56
  Use `pi config` to enable or disable individual resources. Use `pi config -l` for project settings and add `-l` to removal when you installed locally.
48
57
 
49
- To pin version 0.1.3, use `pi install npm:@prjct.app/pi-plan@0.1.3`. Pi skips pinned npm versions during package updates. For a Git installation, update or remove using the same `git:github.com/prjct-app/pi-plan` source instead of the npm source.
58
+ To pin version 0.2.0, use `pi install npm:@prjct.app/pi-plan@0.2.0`. Pi skips pinned npm versions during package updates. For a Git installation, update or remove using the same `git:github.com/prjct-app/pi-plan` source instead of the npm source.
50
59
 
51
60
  When switching from GitHub to npm, remove the Git installation first, then install the npm package and restart Pi.
52
61
 
@@ -56,7 +65,7 @@ If `/work` is unknown, install Pi Workflows too. If `/todos` is empty, ask for n
56
65
 
57
66
  ## Package and API documentation
58
67
 
59
- Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`, and status/widget APIs.
68
+ Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`, status/widget APIs, custom message and entry renderers, and documented TUI components (`SelectList`, `DynamicBorder`, `Text`, `Container`). Plan progress records are display-only entries that never enter the model context; only the execution kickoff instruction does.
60
69
 
61
70
  See [Package structure and compatibility](docs/package.md) for the manifest, dependency policy, shipped resources, and official references. This package follows the [official Pi package guide](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md) and [extension API guide](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/extensions.md) for the tested version.
62
71
 
@@ -71,7 +80,7 @@ npm test
71
80
  npm run check:package
72
81
  ```
73
82
 
74
- Pi loads the TypeScript entry point directly; no build step is required. To try this checkout for one run, use `pi -e .`. Tests use isolated temporary state and do not call model APIs. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution rules and [CHANGELOG.md](CHANGELOG.md) for release notes.
83
+ Pi loads the TypeScript entry point directly; no build step is required. To try this checkout for one run, use `pi -e .`. Tests use isolated temporary state and do not call model APIs. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution rules, [Automatic releases](docs/releases.md) for publishing, and [CHANGELOG.md](CHANGELOG.md) for release notes.
75
84
 
76
85
  ## License
77
86
 
package/docs/package.md CHANGED
@@ -35,7 +35,7 @@ Third-party runtime dependencies belong in `dependencies`. Companion extensions
35
35
 
36
36
  ## Public interfaces
37
37
 
38
- Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`, and status/widget APIs.
38
+ Uses documented tool selection, `tool_call`, commands, shortcuts, flags, `appendEntry()`, `pi.events`, status/widget APIs, custom message and entry renderers (`registerMessageRenderer()`, `registerEntryRenderer()`), and documented TUI components (`SelectList`, `DynamicBorder`, `Text`, `Container`) from `@earendil-works/pi-tui` and `@earendil-works/pi-coding-agent`. Plan progress records are display-only entries that never enter the model context; only the execution kickoff instruction does.
39
39
 
40
40
  ## Published contents
41
41
 
package/index.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { PLAN_MODE_ENABLE_EVENT } from "./src/events.ts";
2
- export { default } from "./src/index.ts";
3
- export { extractTodoItems, isSafeCommand, markCompletedSteps } from "./src/utils.ts";
2
+ export { default, installPlan } from "./src/index.ts";
3
+ export { extractTodoItems, extractVerification, isSafeCommand, markCompletedSteps } from "./src/utils.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prjct.app/pi-plan",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Plan before editing in PI Agent with read-only tool restrictions, an approval step, and task progress tracking.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -7,31 +7,125 @@
7
7
  * Features:
8
8
  * - /plan command or Ctrl+Alt+P to toggle
9
9
  * - Bash restricted to allowlisted read-only commands
10
- * - Extracts numbered plan steps from "Plan:" sections
10
+ * - Structured plans: Goal, Approach, numbered steps, Verify, Risks
11
+ * - Plan review dialog (execute / refine / stay / discard)
11
12
  * - [DONE:n] markers to complete steps during execution
12
- * - Progress tracking widget during execution
13
+ * - Progress widget with bar, current-step highlight, and footer status
14
+ * - Custom renderers for plan, execution, and completion messages
13
15
  */
14
16
 
15
17
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
16
18
  import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
17
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
- import { Key } from "@earendil-works/pi-tui";
19
+ import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
20
+ import {
21
+ type Component,
22
+ Container,
23
+ Key,
24
+ matchesKey,
25
+ SelectList,
26
+ type SelectItem,
27
+ Text,
28
+ truncateToWidth,
29
+ } from "@earendil-works/pi-tui";
19
30
  import { PLAN_MODE_ENABLE_EVENT, type PlanModeEnableRequest } from "./events.ts";
20
- import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.ts";
31
+ import {
32
+ extractTodoItems,
33
+ extractVerification,
34
+ isSafeCommand,
35
+ markCompletedSteps,
36
+ progressBar,
37
+ type TodoItem,
38
+ } from "./utils.ts";
21
39
 
22
40
  // Tools
23
- const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "ask_user"];
24
- const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
25
41
  const PLAN_MODE_DISABLED_TOOLS = new Set<string>(["edit", "write"]);
26
- const PLAN_MANAGED_TOOLS = new Set<string>([...PLAN_MODE_TOOLS, ...NORMAL_MODE_TOOLS]);
42
+
43
+ // Custom message types that carry instructions. They are only relevant while
44
+ // their mode is active and are filtered out of the model context afterwards.
45
+ const PLAN_INSTRUCTION_TYPE = "plan-mode-context";
27
46
 
28
47
  interface PlanModeState {
29
48
  enabled: boolean;
30
49
  todos?: TodoItem[];
31
50
  executing?: boolean;
51
+ verify?: string;
32
52
  toolsBeforePlanMode?: string[];
33
53
  }
34
54
 
55
+ interface PlanListDetails {
56
+ steps: TodoItem[];
57
+ verify?: string;
58
+ }
59
+
60
+ type ReviewChoice = "execute" | "refine" | "stay" | "discard";
61
+
62
+ const PLAN_MODE_PROMPT = `[PLAN MODE ACTIVE]
63
+ You are in plan mode: read-only exploration before any edit.
64
+
65
+ Restrictions:
66
+ - The edit and write tools are disabled.
67
+ - Bash is limited to an allowlist of read-only commands.
68
+ - Never attempt changes; describe exactly what you would change instead.
69
+
70
+ Process:
71
+ 1. Investigate first: open the real files, symbols, and tests involved. Do not speculate about code you have not read.
72
+ 2. If the request is ambiguous or has multiple viable approaches, ask the user (ask_user when available) before finalizing the plan.
73
+ 3. Produce the plan in exactly this format:
74
+
75
+ **Goal:** one sentence describing the end state
76
+ **Approach:** 2-3 sentences on the strategy and key trade-offs
77
+
78
+ Plan:
79
+ 1. Imperative step naming the concrete file(s)/symbol(s) and the change
80
+ 2. ...
81
+
82
+ **Verify:** the exact command(s) that prove the plan succeeded
83
+ **Risks:** one line per risk, only when real risks exist
84
+
85
+ Rules:
86
+ - 3 to 8 steps, ordered by dependency, each independently checkable.
87
+ - Every step must reference real paths or symbols found during investigation.
88
+ - No filler steps such as "run tests" or "update docs" unless the user asked; the Verify section covers validation.
89
+ - Keep each step to one line; put file lists or notes as indented sub-bullets under its step.`;
90
+
91
+ function executionRules(verify: string | undefined): string {
92
+ const verification = verify
93
+ ? `When every step is done, verify with: ${verify} — and report the actual result.`
94
+ : "When every step is done, run the project's verification commands and report the actual result.";
95
+ return `Rules:
96
+ - If a step is blocked, explain why and continue with the next unblocked step.
97
+ - Include a [DONE:n] tag in your response only after step n is actually applied.
98
+ - ${verification}`;
99
+ }
100
+
101
+ // Kickoff message: the full approved plan, sent once when execution starts.
102
+ function executionKickoffPrompt(todos: TodoItem[], verify: string | undefined): string {
103
+ const stepList = todos.map((t) => `${t.step}. ${t.text}`).join("\n");
104
+ const first = todos.find((t) => !t.completed);
105
+ return `[EXECUTING PLAN - Full tool access restored]
106
+
107
+ Approved steps:
108
+ ${stepList}
109
+
110
+ Complete the steps in order, starting with step ${first?.step ?? 1}${first ? `: ${first.text}` : ""}.
111
+ ${executionRules(verify)}`;
112
+ }
113
+
114
+ // Per-start injection: progress plus only the remaining steps. Completed
115
+ // steps are already visible in the transcript and would be stale noise.
116
+ function executionContextPrompt(todos: TodoItem[], verify: string | undefined): string {
117
+ const doneCount = todos.filter((t) => t.completed).length;
118
+ const remaining = todos.filter((t) => !t.completed);
119
+ const stepList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
120
+ return `[EXECUTING PLAN - Full tool access restored]
121
+
122
+ Progress: ${doneCount}/${todos.length} steps complete.
123
+ Remaining steps:
124
+ ${stepList}
125
+
126
+ ${executionRules(verify)}`;
127
+ }
128
+
35
129
  // Type guard for assistant messages
36
130
  function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
37
131
  return m.role === "assistant" && Array.isArray(m.content);
@@ -45,10 +139,62 @@ function getTextContent(message: AssistantMessage): string {
45
139
  .join("\n");
46
140
  }
47
141
 
48
- export default function planModeExtension(pi: ExtensionAPI): void {
142
+ // Collapsed one-line transcript view (pi-team pattern).
143
+ function collapsed(text: string): Component {
144
+ return {
145
+ invalidate() {},
146
+ render(width: number) {
147
+ return [truncateToWidth(text, width)];
148
+ },
149
+ };
150
+ }
151
+
152
+ function formatStep(item: TodoItem, theme: Theme, current: TodoItem | undefined): string {
153
+ if (item.completed) {
154
+ return theme.fg("success", "✓ ") + theme.fg("muted", theme.strikethrough(item.text));
155
+ }
156
+ if (item === current) {
157
+ return theme.fg("accent", "▸ ") + theme.fg("text", item.text);
158
+ }
159
+ return theme.fg("dim", "○ ") + theme.fg("muted", item.text);
160
+ }
161
+
162
+ function planListView(details: PlanListDetails | undefined, expanded: boolean, theme: Theme): Component {
163
+ const steps = details?.steps ?? [];
164
+ const heading = `▸ Plan · ${steps.length} step${steps.length === 1 ? "" : "s"}`;
165
+ if (!expanded) return collapsed(`${heading} · Ctrl+O details`);
166
+ const lines = [theme.fg("accent", theme.bold(heading))];
167
+ for (const item of steps) lines.push(`${item.step}. ${item.completed ? "✓" : "○"} ${item.text}`);
168
+ if (details?.verify) lines.push(theme.fg("muted", `Verify: ${details.verify}`));
169
+ return new Text(lines.join("\n"), 1, 0);
170
+ }
171
+
172
+ function planCompleteView(details: PlanListDetails | undefined, expanded: boolean, theme: Theme): Component {
173
+ const steps = details?.steps ?? [];
174
+ const heading = `▸ Plan complete ✓ · ${steps.length}/${steps.length}`;
175
+ if (!expanded) return collapsed(heading);
176
+ const lines = [theme.fg("success", theme.bold(heading))];
177
+ for (const item of steps) lines.push(theme.fg("muted", `✓ ${item.text}`));
178
+ if (details?.verify) lines.push(theme.fg("muted", `Verified with: ${details.verify}`));
179
+ return new Text(lines.join("\n"), 1, 0);
180
+ }
181
+
182
+ function planExecuteView(details: PlanListDetails | undefined, expanded: boolean, theme: Theme): Component {
183
+ const steps = details?.steps ?? [];
184
+ const first = steps.find((t) => !t.completed);
185
+ const heading = `▸ Execute plan · start at step ${first?.step ?? 1} of ${steps.length}`;
186
+ if (!expanded) return collapsed(`${heading} · Ctrl+O details`);
187
+ const lines = [theme.fg("accent", theme.bold(heading))];
188
+ for (const item of steps) lines.push(`${item.step}. ${item.text}`);
189
+ if (details?.verify) lines.push(theme.fg("muted", `Verify: ${details.verify}`));
190
+ return new Text(lines.join("\n"), 1, 0);
191
+ }
192
+
193
+ export function installPlan(pi: ExtensionAPI): void {
49
194
  let planModeEnabled = false;
50
195
  let executionMode = false;
51
196
  let todoItems: TodoItem[] = [];
197
+ let planVerify: string | undefined;
52
198
  let toolsBeforePlanMode: string[] | undefined;
53
199
 
54
200
  pi.registerFlag("plan", {
@@ -57,58 +203,60 @@ export default function planModeExtension(pi: ExtensionAPI): void {
57
203
  default: false,
58
204
  });
59
205
 
206
+ function uniqueToolNames(toolNames: string[]): string[] {
207
+ return [...new Set(toolNames)];
208
+ }
209
+
60
210
  function updateStatus(ctx: ExtensionContext): void {
61
211
  // Footer status
62
212
  if (executionMode && todoItems.length > 0) {
63
213
  const completed = todoItems.filter((t) => t.completed).length;
64
- ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
214
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `▸ plan ${completed}/${todoItems.length}`));
65
215
  } else if (planModeEnabled) {
66
- ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
216
+ const drafted = todoItems.length > 0 ? ` · ${todoItems.length} steps ready` : "";
217
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", `⏸ plan${drafted}`));
67
218
  } else {
68
219
  ctx.ui.setStatus("plan-mode", undefined);
69
220
  }
70
221
 
71
- // Widget showing todo list
222
+ // Progress widget below the editor while executing
72
223
  if (executionMode && todoItems.length > 0) {
73
- const lines = todoItems.map((item) => {
74
- if (item.completed) {
75
- return (
76
- ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
77
- );
78
- }
79
- return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
80
- });
81
- ctx.ui.setWidget("plan-todos", lines);
224
+ ctx.ui.setWidget("plan-todos", (_tui, theme) => ({
225
+ invalidate() {},
226
+ render(width: number) {
227
+ const total = todoItems.length;
228
+ const doneCount = todoItems.filter((t) => t.completed).length;
229
+ const pct = Math.round((doneCount / total) * 100);
230
+ const barColor = doneCount === total ? "success" : "accent";
231
+ const current = todoItems.find((t) => !t.completed);
232
+ const lines = [
233
+ theme.fg("accent", theme.bold(`▸ Plan ${doneCount}/${total}`)) +
234
+ " " +
235
+ theme.fg(barColor, progressBar(doneCount, total)) +
236
+ theme.fg("muted", ` ${pct}%`),
237
+ ];
238
+ for (const item of todoItems) lines.push(formatStep(item, theme, current));
239
+ if (planVerify) lines.push(theme.fg("dim", `Verify: ${planVerify}`));
240
+ return lines.map((line) => truncateToWidth(line, width));
241
+ },
242
+ }));
82
243
  } else {
83
244
  ctx.ui.setWidget("plan-todos", undefined);
84
245
  }
85
246
  }
86
247
 
87
- function uniqueToolNames(toolNames: string[]): string[] {
88
- return [...new Set(toolNames)];
89
- }
90
-
91
- function getPlanModeTools(activeToolNames: string[]): string[] {
92
- return uniqueToolNames(activeToolNames.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name)));
93
- }
94
-
95
- function getNormalModeTools(activeToolNames: string[]): string[] {
96
- return uniqueToolNames([
97
- ...NORMAL_MODE_TOOLS,
98
- ...activeToolNames.filter((name) => !PLAN_MANAGED_TOOLS.has(name)),
99
- ]);
100
- }
101
-
102
248
  function enablePlanModeTools(): void {
103
249
  if (toolsBeforePlanMode === undefined) {
104
250
  toolsBeforePlanMode = pi.getActiveTools();
105
251
  }
106
- pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
252
+ pi.setActiveTools(uniqueToolNames(toolsBeforePlanMode.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name))));
107
253
  }
108
254
 
109
255
  function restoreNormalModeTools(): void {
110
- pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools()));
111
- toolsBeforePlanMode = undefined;
256
+ if (toolsBeforePlanMode !== undefined) {
257
+ pi.setActiveTools(toolsBeforePlanMode);
258
+ toolsBeforePlanMode = undefined;
259
+ }
112
260
  }
113
261
 
114
262
  function persistState(): void {
@@ -116,30 +264,34 @@ export default function planModeExtension(pi: ExtensionAPI): void {
116
264
  enabled: planModeEnabled,
117
265
  todos: todoItems,
118
266
  executing: executionMode,
267
+ verify: planVerify,
119
268
  toolsBeforePlanMode,
120
- });
269
+ } satisfies PlanModeState);
121
270
  }
122
271
 
123
272
  function enablePlanMode(ctx: ExtensionContext, source?: string): void {
124
273
  planModeEnabled = true;
125
274
  executionMode = false;
126
275
  todoItems = [];
276
+ planVerify = undefined;
127
277
  enablePlanModeTools();
128
278
  ctx.ui.notify(
129
279
  source
130
- ? `Plan mode enabled by ${source}. Built-in write tools disabled.`
131
- : "Plan mode enabled. Built-in write tools disabled.",
280
+ ? `Plan mode enabled by ${source}. Write tools disabled; bash is read-only.`
281
+ : "Plan mode enabled. Write tools disabled; bash is read-only.",
282
+ "info",
132
283
  );
133
284
  updateStatus(ctx);
134
285
  persistState();
135
286
  }
136
287
 
137
- function disablePlanMode(ctx: ExtensionContext): void {
288
+ function disablePlanMode(ctx: ExtensionContext, reason = "Plan mode disabled. Full access restored."): void {
138
289
  planModeEnabled = false;
139
290
  executionMode = false;
140
291
  todoItems = [];
292
+ planVerify = undefined;
141
293
  restoreNormalModeTools();
142
- ctx.ui.notify("Plan mode disabled. Full access restored.");
294
+ ctx.ui.notify(reason, "info");
143
295
  updateStatus(ctx);
144
296
  persistState();
145
297
  }
@@ -152,6 +304,81 @@ export default function planModeExtension(pi: ExtensionAPI): void {
152
304
  }
153
305
  }
154
306
 
307
+ // Bordered SelectList dialog (documented TUI Pattern 1).
308
+ function planReviewDialog(ctx: ExtensionContext): Promise<ReviewChoice | null> {
309
+ return ctx.ui.custom<ReviewChoice | null>((tui, theme, _kb, done) => {
310
+ const container = new Container();
311
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
312
+ container.addChild(
313
+ new Text(theme.fg("accent", theme.bold(`Plan ready · ${todoItems.length} steps`)), 1, 0),
314
+ );
315
+ container.addChild(
316
+ new Text(todoItems.map((t) => theme.fg("muted", ` ${t.step}. ${t.text}`)).join("\n"), 1, 0),
317
+ );
318
+ if (planVerify) container.addChild(new Text(theme.fg("dim", ` Verify: ${planVerify}`), 1, 0));
319
+
320
+ const items: SelectItem[] = [
321
+ { value: "execute", label: "Execute the plan", description: "Restore full tools and track step progress" },
322
+ { value: "refine", label: "Refine the plan", description: "Send feedback and keep planning" },
323
+ { value: "stay", label: "Stay in plan mode", description: "Keep read-only exploration" },
324
+ { value: "discard", label: "Discard the plan", description: "Exit plan mode and drop these steps" },
325
+ ];
326
+ const list = new SelectList(items, items.length, {
327
+ selectedPrefix: (t) => theme.fg("accent", t),
328
+ selectedText: (t) => theme.fg("accent", t),
329
+ description: (t) => theme.fg("muted", t),
330
+ scrollInfo: (t) => theme.fg("dim", t),
331
+ noMatch: (t) => theme.fg("warning", t),
332
+ });
333
+ list.onSelect = (item) => done(item.value as ReviewChoice);
334
+ list.onCancel = () => done(null);
335
+ container.addChild(list);
336
+
337
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc stay in plan mode"), 1, 0));
338
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
339
+
340
+ return {
341
+ render: (w) => container.render(w),
342
+ invalidate: () => container.invalidate(),
343
+ handleInput: (data) => {
344
+ list.handleInput(data);
345
+ tui.requestRender();
346
+ },
347
+ };
348
+ });
349
+ }
350
+
351
+ function showTodosDialog(ctx: ExtensionContext): Promise<null> {
352
+ const total = todoItems.length;
353
+ const doneCount = todoItems.filter((t) => t.completed).length;
354
+ return ctx.ui.custom<null>((tui, theme, _kb, done) => {
355
+ const container = new Container();
356
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
357
+ const title = executionMode
358
+ ? `Executing plan · ${doneCount}/${total} done`
359
+ : planModeEnabled
360
+ ? `Draft plan · ${total} steps`
361
+ : `Plan · ${doneCount}/${total} done`;
362
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
363
+ const current = todoItems.find((t) => !t.completed);
364
+ container.addChild(
365
+ new Text(todoItems.map((item) => ` ${item.step}. ${formatStep(item, theme, current)}`).join("\n"), 1, 0),
366
+ );
367
+ if (planVerify) container.addChild(new Text(theme.fg("dim", ` Verify: ${planVerify}`), 1, 0));
368
+ container.addChild(new Text(theme.fg("dim", "esc/enter close"), 1, 0));
369
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
370
+
371
+ return {
372
+ render: (w) => container.render(w),
373
+ invalidate: () => container.invalidate(),
374
+ handleInput: (data) => {
375
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) done(null);
376
+ tui.requestRender();
377
+ },
378
+ };
379
+ });
380
+ }
381
+
155
382
  pi.events.on(PLAN_MODE_ENABLE_EVENT, (data) => {
156
383
  const request = data as PlanModeEnableRequest;
157
384
  if (!request?.ctx || typeof request.source !== "string") return;
@@ -166,14 +393,18 @@ export default function planModeExtension(pi: ExtensionAPI): void {
166
393
  });
167
394
 
168
395
  pi.registerCommand("todos", {
169
- description: "Show current plan todo list",
396
+ description: "Show the current plan steps and progress",
170
397
  handler: async (_args, ctx) => {
171
398
  if (todoItems.length === 0) {
172
- ctx.ui.notify("No todos. Create a plan first with /plan", "info");
399
+ ctx.ui.notify("No plan steps. Create a plan first with /plan", "info");
400
+ return;
401
+ }
402
+ if (!ctx.hasUI) {
403
+ const list = todoItems.map((item) => `${item.step}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
404
+ ctx.ui.notify(`Plan:\n${list}`, "info");
173
405
  return;
174
406
  }
175
- const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
176
- ctx.ui.notify(`Plan Progress:\n${list}`, "info");
407
+ await showTodosDialog(ctx);
177
408
  },
178
409
  });
179
410
 
@@ -182,9 +413,29 @@ export default function planModeExtension(pi: ExtensionAPI): void {
182
413
  handler: async (ctx) => togglePlanMode(ctx),
183
414
  });
184
415
 
185
- // Block destructive bash commands in plan mode
416
+ pi.registerEntryRenderer<PlanListDetails>("plan-todo-list", (entry, { expanded }, theme) =>
417
+ planListView(entry.data, expanded, theme),
418
+ );
419
+ pi.registerEntryRenderer<PlanListDetails>("plan-complete", (entry, { expanded }, theme) =>
420
+ planCompleteView(entry.data, expanded, theme),
421
+ );
422
+ pi.registerMessageRenderer<PlanListDetails>("plan-mode-execute", (message, { expanded }, theme) =>
423
+ planExecuteView(message.details, expanded, theme),
424
+ );
425
+
426
+ // In plan mode, block writes defensively (even if another extension
427
+ // re-adds them) and restrict bash to the read-only allowlist.
186
428
  pi.on("tool_call", async (event) => {
187
- if (!planModeEnabled || event.toolName !== "bash") return;
429
+ if (!planModeEnabled) return;
430
+
431
+ if (PLAN_MODE_DISABLED_TOOLS.has(event.toolName)) {
432
+ return {
433
+ block: true,
434
+ reason: `Plan mode: ${event.toolName} is disabled. Present the plan and get approval before editing.`,
435
+ };
436
+ }
437
+
438
+ if (event.toolName !== "bash") return;
188
439
 
189
440
  const command = event.input.command as string;
190
441
  if (!isSafeCommand(command)) {
@@ -195,23 +446,37 @@ export default function planModeExtension(pi: ExtensionAPI): void {
195
446
  }
196
447
  });
197
448
 
198
- // Filter out stale plan mode context when not in plan mode
449
+ // Filter stale instruction messages so old mode prompts never accumulate
450
+ // in the model context. Each mode keeps only its LATEST injected copy;
451
+ // older duplicates are dropped. Display entries (plan list, completion)
452
+ // live outside the LLM context entirely (appendEntry, not sendMessage).
199
453
  pi.on("context", async (event) => {
200
- if (planModeEnabled) return;
454
+ const messages = event.messages;
455
+ let lastPlanInstruction = -1;
456
+ let lastExecutionInstruction = -1;
457
+ messages.forEach((m, i) => {
458
+ const customType = (m as { customType?: string }).customType;
459
+ if (customType === PLAN_INSTRUCTION_TYPE) lastPlanInstruction = i;
460
+ if (customType === "plan-execution-context") lastExecutionInstruction = i;
461
+ });
201
462
 
202
463
  return {
203
- messages: event.messages.filter((m) => {
464
+ messages: messages.filter((m, i) => {
204
465
  const msg = m as AgentMessage & { customType?: string };
205
- if (msg.customType === "plan-mode-context") return false;
466
+ if (msg.customType === PLAN_INSTRUCTION_TYPE) return planModeEnabled && i === lastPlanInstruction;
467
+ if (msg.customType === "plan-execution-context") return executionMode && i === lastExecutionInstruction;
468
+ if (msg.customType === "plan-mode-execute") return executionMode;
206
469
  if (msg.role !== "user") return true;
207
470
 
471
+ // Legacy sessions stored the plan-mode prompt as plain user text.
208
472
  const content = msg.content;
209
473
  if (typeof content === "string") {
210
- return !content.includes("[PLAN MODE ACTIVE]");
474
+ return planModeEnabled || !content.includes("[PLAN MODE ACTIVE]");
211
475
  }
212
476
  if (Array.isArray(content)) {
213
- return !content.some(
214
- (c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
477
+ return (
478
+ planModeEnabled ||
479
+ !content.some((c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"))
215
480
  );
216
481
  }
217
482
  return true;
@@ -224,44 +489,18 @@ export default function planModeExtension(pi: ExtensionAPI): void {
224
489
  if (planModeEnabled) {
225
490
  return {
226
491
  message: {
227
- customType: "plan-mode-context",
228
- content: `[PLAN MODE ACTIVE]
229
- You are in plan mode - a read-only exploration mode for safe code analysis.
230
-
231
- Restrictions:
232
- - Built-in edit and write tools are disabled
233
- - Other currently active tools remain available
234
- - Bash is restricted to an allowlist of read-only commands
235
-
236
- Ask clarifying questions using ask_user when available; otherwise ask the user directly.
237
- Use an available web-search skill via bash for web research when needed.
238
-
239
- Create a detailed numbered plan under a "Plan:" header:
240
-
241
- Plan:
242
- 1. First step description
243
- 2. Second step description
244
- ...
245
-
246
- Do NOT attempt to make changes - just describe what you would do.`,
492
+ customType: PLAN_INSTRUCTION_TYPE,
493
+ content: PLAN_MODE_PROMPT,
247
494
  display: false,
248
495
  },
249
496
  };
250
497
  }
251
498
 
252
499
  if (executionMode && todoItems.length > 0) {
253
- const remaining = todoItems.filter((t) => !t.completed);
254
- const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
255
500
  return {
256
501
  message: {
257
502
  customType: "plan-execution-context",
258
- content: `[EXECUTING PLAN - Full tool access enabled]
259
-
260
- Remaining steps:
261
- ${todoList}
262
-
263
- Execute each step in order.
264
- After completing a step, include a [DONE:n] tag in your response.`,
503
+ content: executionContextPrompt(todoItems, planVerify),
265
504
  display: false,
266
505
  },
267
506
  };
@@ -285,13 +524,14 @@ After completing a step, include a [DONE:n] tag in your response.`,
285
524
  // Check if execution is complete
286
525
  if (executionMode && todoItems.length > 0) {
287
526
  if (todoItems.every((t) => t.completed)) {
288
- const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
289
- pi.sendMessage(
290
- { customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
291
- { triggerTurn: false },
292
- );
527
+ // Display-only record; kept out of the LLM context on purpose.
528
+ pi.appendEntry("plan-complete", {
529
+ steps: todoItems,
530
+ verify: planVerify,
531
+ } satisfies PlanListDetails);
293
532
  executionMode = false;
294
533
  todoItems = [];
534
+ planVerify = undefined;
295
535
  updateStatus(ctx);
296
536
  persistState(); // Save cleared state so resume doesn't restore old execution mode
297
537
  }
@@ -300,35 +540,25 @@ After completing a step, include a [DONE:n] tag in your response.`,
300
540
 
301
541
  if (!planModeEnabled || !ctx.hasUI) return;
302
542
 
303
- // Extract todos from last assistant message
543
+ // Extract the plan from the last assistant message
304
544
  const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
305
545
  if (lastAssistant) {
306
- const extracted = extractTodoItems(getTextContent(lastAssistant));
546
+ const text = getTextContent(lastAssistant);
547
+ const extracted = extractTodoItems(text);
307
548
  if (extracted.length > 0) {
308
549
  todoItems = extracted;
550
+ planVerify = extractVerification(text);
309
551
  }
310
552
  }
311
553
 
312
554
  if (todoItems.length === 0) return;
555
+ updateStatus(ctx);
313
556
  persistState();
314
557
 
315
- // Show plan steps and prompt for next action
316
- const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
317
- const planTodoListMessage = {
318
- customType: "plan-todo-list",
319
- content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
320
- display: true,
321
- };
558
+ const choice = await planReviewDialog(ctx);
322
559
 
323
- const choice = await ctx.ui.select("Plan mode - what next?", [
324
- "Execute the plan (track progress)",
325
- "Stay in plan mode",
326
- "Refine the plan",
327
- ]);
328
-
329
- if (choice?.startsWith("Execute")) {
330
- const firstTodoItem = todoItems[0];
331
- if (!firstTodoItem) return;
560
+ if (choice === "execute") {
561
+ const details: PlanListDetails = { steps: todoItems, verify: planVerify };
332
562
 
333
563
  planModeEnabled = false;
334
564
  executionMode = true;
@@ -336,26 +566,28 @@ After completing a step, include a [DONE:n] tag in your response.`,
336
566
  updateStatus(ctx);
337
567
  persistState();
338
568
 
339
- const remainingList = todoItems.map((t) => `${t.step}. ${t.text}`).join("\n");
340
- const execMessage = `Execute the plan.
341
-
342
- Remaining steps:
343
- ${remainingList}
344
-
345
- Start with: ${firstTodoItem.text}
346
- After completing a step, include a [DONE:n] tag in your response.`;
347
- pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
569
+ // The step list is a display-only entry; only the kickoff instruction
570
+ // enters the LLM context.
571
+ pi.appendEntry("plan-todo-list", details);
348
572
  pi.sendMessage(
349
- { customType: "plan-mode-execute", content: execMessage, display: true },
573
+ {
574
+ customType: "plan-mode-execute",
575
+ content: executionKickoffPrompt(todoItems, planVerify),
576
+ display: true,
577
+ details,
578
+ },
350
579
  { triggerTurn: true, deliverAs: "followUp" },
351
580
  );
352
- } else if (choice === "Refine the plan") {
581
+ } else if (choice === "refine") {
353
582
  const refinement = await ctx.ui.editor("Refine the plan:", "");
354
583
  if (refinement?.trim()) {
355
- pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
584
+ pi.appendEntry("plan-todo-list", { steps: todoItems, verify: planVerify } satisfies PlanListDetails);
356
585
  pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
357
586
  }
587
+ } else if (choice === "discard") {
588
+ disablePlanMode(ctx, "Plan discarded. Full access restored.");
358
589
  }
590
+ // "stay" or dialog cancelled: remain in plan mode.
359
591
  });
360
592
 
361
593
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -369,18 +601,21 @@ After completing a step, include a [DONE:n] tag in your response.`;
369
601
  planModeEnabled = true;
370
602
  }
371
603
 
372
- const entries = ctx.sessionManager.getEntries();
604
+ // getBranch() scopes restore to this branch, never a fork's copy.
605
+ const entries = ctx.sessionManager.getBranch();
373
606
 
374
607
  // Restore persisted state
375
608
  const planModeEntry = entries
376
- .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
377
- .pop() as { data?: PlanModeState } | undefined;
378
-
379
- if (planModeEntry?.data) {
380
- planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
381
- todoItems = planModeEntry.data.todos ?? todoItems;
382
- executionMode = planModeEntry.data.executing ?? executionMode;
383
- toolsBeforePlanMode = planModeEntry.data.toolsBeforePlanMode ?? toolsBeforePlanMode;
609
+ .filter((e) => e.type === "custom" && e.customType === "plan-mode")
610
+ .pop();
611
+
612
+ if (planModeEntry?.type === "custom" && planModeEntry.data) {
613
+ const data = planModeEntry.data as PlanModeState;
614
+ planModeEnabled = data.enabled ?? planModeEnabled;
615
+ todoItems = data.todos ?? todoItems;
616
+ executionMode = data.executing ?? executionMode;
617
+ planVerify = data.verify ?? planVerify;
618
+ toolsBeforePlanMode = data.toolsBeforePlanMode ?? toolsBeforePlanMode;
384
619
  }
385
620
 
386
621
  // On resume: re-scan messages to rebuild completion state
@@ -390,8 +625,8 @@ After completing a step, include a [DONE:n] tag in your response.`;
390
625
  // Find the index of the last plan-mode-execute entry (marks when current execution started)
391
626
  let executeIndex = -1;
392
627
  for (let i = entries.length - 1; i >= 0; i--) {
393
- const entry = entries[i] as { type: string; customType?: string };
394
- if (entry.customType === "plan-mode-execute") {
628
+ const entry = entries[i];
629
+ if (entry.type === "custom_message" && entry.customType === "plan-mode-execute") {
395
630
  executeIndex = i;
396
631
  break;
397
632
  }
@@ -401,7 +636,7 @@ After completing a step, include a [DONE:n] tag in your response.`;
401
636
  const messages: AssistantMessage[] = [];
402
637
  for (let i = executeIndex + 1; i < entries.length; i++) {
403
638
  const entry = entries[i];
404
- if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
639
+ if (entry.type === "message" && isAssistantMessage(entry.message as AgentMessage)) {
405
640
  messages.push(entry.message as AssistantMessage);
406
641
  }
407
642
  }
@@ -415,3 +650,7 @@ After completing a step, include a [DONE:n] tag in your response.`;
415
650
  updateStatus(ctx);
416
651
  });
417
652
  }
653
+
654
+ export default function planModeExtension(pi: ExtensionAPI): void {
655
+ installPlan(pi);
656
+ }
package/src/utils.ts CHANGED
@@ -21,13 +21,20 @@ const DESTRUCTIVE_PATTERNS = [
21
21
  /\bshred\b/i,
22
22
  /(^|[^<])>(?!>)/,
23
23
  />>/,
24
+ // find/awk/sed/perl in-place or exec mutations
25
+ /\bfind\b[^|]*\s(-delete|-exec\b|-ok\b)/i,
26
+ /\bsed\s+-\w*i/i,
27
+ /\bawk\s+-i\s*inplace\b/i,
28
+ /\bperl\s+-\w*i/i,
29
+ /\bxargs\b/i,
30
+ /\bpatch\b/i,
24
31
  /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
25
32
  /\byarn\s+(add|remove|install|publish)/i,
26
33
  /\bpnpm\s+(add|remove|install|publish)/i,
27
34
  /\bpip\s+(install|uninstall)/i,
28
35
  /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
29
36
  /\bbrew\s+(install|uninstall|upgrade)/i,
30
- /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
37
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone|clean|restore|switch|worktree|rm|apply|am\b|reflog\s+expire)/i,
31
38
  /\bsudo\b/i,
32
39
  /\bsu\b/i,
33
40
  /\bkill\b/i,
@@ -77,10 +84,15 @@ const SAFE_PATTERNS = [
77
84
  /^\s*top\b/,
78
85
  /^\s*htop\b/,
79
86
  /^\s*free\b/,
80
- /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
81
- /^\s*git\s+ls-/i,
87
+ /^\s*nl\b/,
88
+ /^\s*readlink\b/,
89
+ /^\s*realpath\b/,
90
+ /^\s*basename\b/,
91
+ /^\s*dirname\b/,
92
+ /^\s*git\s+(status|log|diff|show|branch|remote|blame|grep|describe|rev-parse|ls-|config\s+--get)/i,
82
93
  /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
83
94
  /^\s*yarn\s+(list|info|why|audit)/i,
95
+ /^\s*pnpm\s+(list|ls|why|outdated|audit)/i,
84
96
  /^\s*node\s+--version/i,
85
97
  /^\s*python\s+--version/i,
86
98
  /^\s*curl\s/i,
@@ -106,49 +118,74 @@ export interface TodoItem {
106
118
  completed: boolean;
107
119
  }
108
120
 
121
+ // Clean display text without changing its meaning: keep verbs such as
122
+ // "Delete" or "Remove" because stripping them inverts the step's intent.
109
123
  export function cleanStepText(text: string): string {
110
124
  let cleaned = text
111
125
  .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
112
126
  .replace(/`([^`]+)`/g, "$1") // Remove code
113
- .replace(
114
- /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
115
- "",
116
- )
117
127
  .replace(/\s+/g, " ")
118
128
  .trim();
119
129
 
120
130
  if (cleaned.length > 0) {
121
131
  cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
122
132
  }
123
- if (cleaned.length > 50) {
124
- cleaned = `${cleaned.slice(0, 47)}...`;
133
+ if (cleaned.length > 100) {
134
+ cleaned = `${cleaned.slice(0, 97)}...`;
125
135
  }
126
136
  return cleaned;
127
137
  }
128
138
 
139
+ // Header accepted as "Plan:", "**Plan:**", "## Plan:", and similar variants.
140
+ const PLAN_HEADER = /(?:^|\n)\s*(?:#{1,6}\s+)?\*{0,2}Plan\*{0,2}\s*:\*{0,2}\s*\n/i;
141
+ const NUMBERED_STEP = /^\s*(\d+)[.)]\s+(.+)$/;
142
+ const CONTINUATION = /^\s+\S/;
143
+
129
144
  export function extractTodoItems(message: string): TodoItem[] {
130
145
  const items: TodoItem[] = [];
131
- const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
132
- if (!headerMatch) return items;
133
-
134
- const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
135
- const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
146
+ const headerMatch = message.match(PLAN_HEADER);
147
+ if (!headerMatch || headerMatch.index === undefined) return items;
136
148
 
137
- for (const match of planSection.matchAll(numberedPattern)) {
138
- const text = match[2]
139
- .trim()
140
- .replace(/\*{1,2}$/, "")
141
- .trim();
142
- if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
143
- const cleaned = cleanStepText(text);
149
+ const planSection = message.slice(headerMatch.index + headerMatch[0].length);
150
+ for (const line of planSection.split("\n")) {
151
+ const stepMatch = line.match(NUMBERED_STEP);
152
+ if (stepMatch) {
153
+ const cleaned = cleanStepText(stepMatch[2]);
144
154
  if (cleaned.length > 3) {
145
155
  items.push({ step: items.length + 1, text: cleaned, completed: false });
146
156
  }
157
+ continue;
147
158
  }
159
+ // Indented sub-bullets belong to the previous step; skip them without
160
+ // ending the section. Any other line ends the numbered plan, so later
161
+ // numbered lists (e.g. verification steps) are never captured.
162
+ if (items.length > 0 && line.trim().length > 0 && !CONTINUATION.test(line)) break;
148
163
  }
149
164
  return items;
150
165
  }
151
166
 
167
+ // Extract the "**Verify:**" section: the exact commands that prove the plan.
168
+ export function extractVerification(message: string): string | undefined {
169
+ const match = message.match(/(?:^|\n)\s*(?:#{1,6}\s+)?\*{0,2}Verify\*{0,2}\s*:\*{0,2}\s*/i);
170
+ if (!match || match.index === undefined) return undefined;
171
+
172
+ const lines: string[] = [];
173
+ for (const line of message.slice(match.index + match[0].length).split("\n")) {
174
+ if (line.trim().length === 0) {
175
+ if (lines.length > 0) break;
176
+ continue;
177
+ }
178
+ // Stop at the next section header such as "**Risks:**".
179
+ if (lines.length > 0 && /^\s*(?:#{1,6}\s|\*{0,2}[A-Z][A-Za-z ]*\*{0,2}\s*:)/.test(line)) break;
180
+ lines.push(line.trim().replace(/^[-*]\s+/, ""));
181
+ }
182
+ const text = lines
183
+ .join("; ")
184
+ .replace(/`([^`]+)`/g, "$1")
185
+ .trim();
186
+ return text.length > 0 ? text.slice(0, 200) : undefined;
187
+ }
188
+
152
189
  export function extractDoneSteps(message: string): number[] {
153
190
  const steps: number[] = [];
154
191
  for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
@@ -166,3 +203,9 @@ export function markCompletedSteps(text: string, items: TodoItem[]): number {
166
203
  }
167
204
  return doneSteps.length;
168
205
  }
206
+
207
+ export function progressBar(completed: number, total: number, width = 8): string {
208
+ const ratio = total <= 0 ? 0 : completed / total;
209
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
210
+ return "█".repeat(filled) + "░".repeat(width - filled);
211
+ }