@gavin-hu/my-pi-agent 0.16.0 → 0.17.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.
Files changed (135) hide show
  1. package/README.md +14 -8
  2. package/README.zh-CN.md +14 -9
  3. package/extensions/bytefly/README.md +174 -101
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/panel/commands.ts +46 -25
  17. package/extensions/bytefly/panel/format.ts +49 -14
  18. package/extensions/bytefly/panel/join.ts +35 -9
  19. package/extensions/bytefly/panel/settings.ts +10 -27
  20. package/extensions/bytefly/panel/skills.ts +51 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  22. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  23. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  24. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  25. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  26. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  27. package/extensions/bytefly/panel/tui.ts +223 -331
  28. package/extensions/bytefly/panel/types.ts +26 -8
  29. package/extensions/bytefly/picker/discovery.ts +32 -27
  30. package/extensions/bytefly/picker/resources.ts +16 -8
  31. package/extensions/bytefly/picker/runtime.ts +62 -34
  32. package/extensions/bytefly/picker/toggle.ts +16 -10
  33. package/extensions/bytefly/picker/types.ts +31 -13
  34. package/extensions/bytefly/update/format.ts +66 -8
  35. package/extensions/bytefly/update/patch.ts +66 -0
  36. package/extensions/bytefly/update/register.ts +4 -11
  37. package/extensions/bytefly/update/release.ts +65 -0
  38. package/extensions/bytefly/update/state.ts +14 -0
  39. package/extensions/bytefly/update/suppress.ts +47 -29
  40. package/extensions/bytefly/update/types.ts +15 -5
  41. package/extensions/check/tui.ts +2 -0
  42. package/extensions/context/README.md +21 -11
  43. package/extensions/context/format.ts +10 -8
  44. package/extensions/context/report.ts +21 -8
  45. package/extensions/context/tui.ts +26 -10
  46. package/extensions/context/types.ts +5 -3
  47. package/extensions/file-browse/README.md +29 -1
  48. package/extensions/file-browse/client.ts +16 -463
  49. package/extensions/file-browse/html.ts +7 -5
  50. package/extensions/file-browse/palette.ts +224 -0
  51. package/extensions/file-browse/router.ts +2 -2
  52. package/extensions/file-browse/styles.ts +8 -3
  53. package/extensions/file-browse/tree.ts +381 -0
  54. package/extensions/file-read/README.md +4 -1
  55. package/extensions/file-read/extract/odt.ts +7 -5
  56. package/extensions/goal/tools.ts +6 -11
  57. package/extensions/job/index.ts +20 -45
  58. package/extensions/job/tools.ts +51 -77
  59. package/extensions/memory/tools.ts +4 -6
  60. package/extensions/memory/tui.ts +5 -1
  61. package/extensions/plan/README.md +88 -33
  62. package/extensions/plan/commands.ts +19 -4
  63. package/extensions/plan/config.ts +27 -0
  64. package/extensions/plan/plan-index.ts +3 -1
  65. package/extensions/plan/plans.ts +31 -13
  66. package/extensions/plan/review.ts +34 -14
  67. package/extensions/plan/runtime.ts +3 -1
  68. package/extensions/plan/steps.ts +43 -14
  69. package/extensions/plan/tools/cancel.ts +79 -0
  70. package/extensions/plan/tools/enter.ts +10 -10
  71. package/extensions/plan/tools/exit.ts +54 -43
  72. package/extensions/plan/tools/steps.ts +7 -19
  73. package/extensions/plan/tools/write.ts +22 -29
  74. package/extensions/plan/tools.ts +5 -2
  75. package/extensions/plan/tui.ts +9 -7
  76. package/extensions/plan/types.ts +8 -0
  77. package/extensions/plan/web-chrome.ts +101 -0
  78. package/extensions/plan/web-client.ts +98 -311
  79. package/extensions/plan/web-markdown.ts +222 -0
  80. package/extensions/plan/web-page.ts +28 -2
  81. package/extensions/plan/web-router.ts +2 -2
  82. package/extensions/rewind/checklist.ts +4 -0
  83. package/extensions/rewind/review-tui.ts +3 -1
  84. package/extensions/rewind/rewind-tui.ts +4 -0
  85. package/extensions/subagent/README.md +25 -12
  86. package/extensions/subagent/format.ts +72 -25
  87. package/extensions/subagent/index.ts +21 -47
  88. package/extensions/subagent/orchestrate.ts +18 -41
  89. package/extensions/subagent/render.ts +29 -42
  90. package/extensions/subagent/stream.ts +24 -18
  91. package/extensions/subagent/types.ts +7 -0
  92. package/extensions/todo/tools.ts +5 -6
  93. package/extensions/todo/tui.ts +5 -1
  94. package/extensions/watch/index.ts +10 -35
  95. package/extensions/watch/tools.ts +31 -59
  96. package/extensions/wechat/README.md +40 -18
  97. package/extensions/wechat/bridge.ts +38 -40
  98. package/extensions/wechat/client.ts +1 -4
  99. package/extensions/wechat/commands.ts +1 -1
  100. package/extensions/wechat/interaction.ts +25 -4
  101. package/extensions/wechat/login-screen.ts +2 -3
  102. package/extensions/wechat/notices.ts +7 -5
  103. package/extensions/wechat/prompt.ts +19 -4
  104. package/extensions/wechat/qrcode.ts +9 -9
  105. package/extensions/wechat/slash.ts +14 -0
  106. package/extensions/wechat/status-screen.ts +2 -2
  107. package/extensions/wechat/tools.ts +3 -11
  108. package/extensions/worktree/README.md +48 -12
  109. package/extensions/worktree/commands.ts +109 -65
  110. package/extensions/worktree/enter.ts +1 -1
  111. package/extensions/worktree/exit.ts +11 -1
  112. package/extensions/worktree/lifecycle.ts +9 -1
  113. package/extensions/worktree/screen.ts +263 -0
  114. package/extensions/worktree/status.ts +84 -38
  115. package/lib/README.md +7 -6
  116. package/lib/config.ts +12 -0
  117. package/lib/format.ts +6 -2
  118. package/lib/interaction.ts +18 -1
  119. package/lib/report.ts +75 -2
  120. package/lib/tool-result.ts +54 -4
  121. package/lib/tui/README.md +16 -8
  122. package/lib/tui/chrome.ts +27 -6
  123. package/lib/tui/dock-screen.ts +6 -2
  124. package/lib/tui/gauge.ts +9 -5
  125. package/lib/usage.ts +26 -0
  126. package/lib/web/README.md +131 -0
  127. package/lib/web/base.ts +19 -2
  128. package/lib/web/components.ts +10 -6
  129. package/lib/web/layout.ts +2 -0
  130. package/lib/web/markup.ts +2 -1
  131. package/lib/web/runtime.ts +103 -25
  132. package/lib/web/tokens.ts +23 -7
  133. package/package.json +3 -1
  134. package/skills/changelog/SKILL.md +57 -45
  135. package/skills/next/SKILL.md +184 -0
@@ -22,6 +22,7 @@ const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
22
22
  const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
23
23
  const REVIEW_PREFIX = "review";
24
24
  const LIST_PREFIX = "list";
25
+ const CANCEL_PREFIX = "cancel";
25
26
 
26
27
  /** Notice text for the current mode; shared by `/plan` and the `Ctrl+Alt+P` shortcut. */
27
28
  export function planModeNotice(enabled: boolean): string {
@@ -49,6 +50,11 @@ function isListSubcommand(prompt: string): boolean {
49
50
  return prompt.toLowerCase() === LIST_PREFIX;
50
51
  }
51
52
 
53
+ /** Exact `cancel` leaves plan mode without approving; a longer sentence stays a task. */
54
+ function isCancelSubcommand(prompt: string): boolean {
55
+ return prompt.toLowerCase() === CANCEL_PREFIX;
56
+ }
57
+
52
58
  /**
53
59
  * Whether a command line is the reserved `review` subcommand. Bare `review`
54
60
  * always is; a path after it is, but `review the auth flow` stays a task, so a
@@ -167,18 +173,27 @@ export function registerCommands(
167
173
  ): void {
168
174
  pi.registerCommand("plan", {
169
175
  description:
170
- "Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, or review one in the browser",
176
+ "Toggle plan mode (read-only exploration), enter it with an optional task, list saved plans, review one in the browser, or cancel planning without approving",
171
177
  getArgumentCompletions: (prefix) => {
172
178
  const text = prefix.trimStart();
173
179
  if (text.includes(" ")) return null;
174
- const subcommands = [LIST_PREFIX, REVIEW_PREFIX].filter((name) => name.startsWith(text));
180
+ const subcommands = [LIST_PREFIX, REVIEW_PREFIX, CANCEL_PREFIX].filter((name) => name.startsWith(text));
175
181
  return subcommands.length > 0 ? subcommands.map((value) => ({ value, label: value })) : null;
176
182
  },
177
183
  handler: async (args, ctx) => {
178
184
  const prompt = args.trim();
179
185
 
180
- // `/plan list` and `/plan review [path]` are reserved subcommands;
181
- // everything else is a task.
186
+ // `/plan list`, `/plan review [path]`, and `/plan cancel` are reserved
187
+ // subcommands; everything else is a task.
188
+ if (isCancelSubcommand(prompt)) {
189
+ if (!runtime.isEnabled()) {
190
+ ctx.ui.notify("Plan mode is already off.", "info");
191
+ return;
192
+ }
193
+ runtime.disable(ctx);
194
+ ctx.ui.notify("Plan mode off — full access restored. The plan was not approved.", "info");
195
+ return;
196
+ }
182
197
  if (isListSubcommand(prompt)) {
183
198
  await openPlanIndex(pi, runtime, web, ctx);
184
199
  return;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Config-file support for plan mode.
3
+ *
4
+ * The `plan` section of the shared `bytefly.json` (global then project, project
5
+ * wins). Only the approval offer is configurable; plan mode itself never depends
6
+ * on it.
7
+ */
8
+
9
+ import { loadConfigSection } from "../../lib/config.ts";
10
+
11
+ export interface PlanConfig {
12
+ /** Ask before recording an approved plan's objective as the session goal. */
13
+ goalPrompt: boolean;
14
+ }
15
+
16
+ export const DEFAULT_CONFIG: PlanConfig = { goalPrompt: true };
17
+
18
+ /** Normalize one merged config object over the running base. */
19
+ export function normalizeConfig(raw: Record<string, unknown> | undefined, base: PlanConfig): PlanConfig {
20
+ if (!raw) return base;
21
+ return { goalPrompt: typeof raw.goalPrompt === "boolean" ? raw.goalPrompt : base.goalPrompt };
22
+ }
23
+
24
+ /** Effective plan config from the shared `plan` section (defaults, global, project). */
25
+ export function loadConfig(cwd: string): PlanConfig {
26
+ return loadConfigSection(cwd, "plan", DEFAULT_CONFIG, normalizeConfig);
27
+ }
@@ -25,7 +25,7 @@ import type { PlanSummary } from "./plans.ts";
25
25
 
26
26
  /** Rows the screen shows when the terminal height is unknown. */
27
27
  const DEFAULT_ROWS = 14;
28
- /** Fixed rows around the list: header, summary, blank, range, detail, hint, blank. */
28
+ /** Fixed rows around the list: header, summary, blank, range, detail, hint, closing rule. */
29
29
  const CHROME = 7;
30
30
  /** Longest title a row draws before the trailing meta wins the column. */
31
31
  const TITLE_WIDTH = 56;
@@ -221,6 +221,8 @@ export class PlanIndexComponent implements Component {
221
221
  : screenHint(theme, w, ["Esc close", { text: "Enter review", color: "accent" }, "r reload", "↑/↓", "g/G"]);
222
222
 
223
223
  return dockListFrame({
224
+ theme,
225
+ width: w,
224
226
  header: titleRule(theme, w, "Plan index"),
225
227
  summary: this.summaryLine(w),
226
228
  body,
@@ -6,7 +6,9 @@
6
6
  * read the same artifact the model is working from and the plan survives
7
7
  * outside the conversation without a partial write ever being visible. Files
8
8
  * live under `<repo-root>/.pi/plans` (the worktree root inside a worktree), or
9
- * under `<agent-dir>/plans` outside a repository.
9
+ * under `<agent-dir>/plans` outside a repository. An extension tool sees the
10
+ * session cwd rather than the worktree the user entered, so the active worktree
11
+ * is applied before any path is resolved.
10
12
  *
11
13
  * The directory is self-ignoring: the first write drops a `.gitignore` with `*`
12
14
  * so plans never show up as untracked files or inside rewind snapshots,
@@ -29,6 +31,7 @@ import {
29
31
  } from "node:fs";
30
32
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
31
33
  import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
+ import { resolveEffectiveCwd } from "../../lib/env.ts";
32
35
  import { repoRootFor } from "../../lib/git/index.ts";
33
36
  import { sanitizeLine } from "../../lib/format.ts";
34
37
  import { isInside, isInsideReal } from "../../lib/path.ts";
@@ -219,19 +222,32 @@ async function repoRoot(pi: ExtensionAPI, cwd: string): Promise<string | undefin
219
222
 
220
223
  export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date } = {}): PlanStore {
221
224
  const now = options.now ?? (() => new Date());
222
- // One `git rev-parse` per working directory; a worktree switch is a new cwd.
225
+ // One `git rev-parse` per effective root; a worktree switch changes that root.
223
226
  const dirCache = new Map<string, string>();
224
227
 
228
+ /**
229
+ * The root every path here is resolved against.
230
+ *
231
+ * A tool registered by an extension receives the session's cwd, not the
232
+ * worktree the user entered: only the built-ins are re-rooted. The worktree
233
+ * extension publishes its root in the environment, so this is where the two
234
+ * meet, and it is applied before the git lookup, the relative path, and the
235
+ * plan path resolution.
236
+ */
237
+ const baseOf = (cwd: string): string => resolveEffectiveCwd(cwd);
238
+
225
239
  const dirFor = async (cwd: string): Promise<string> => {
226
- const cached = dirCache.get(cwd);
240
+ const base = baseOf(cwd);
241
+ const cached = dirCache.get(base);
227
242
  if (cached) return cached;
228
- const root = await repoRoot(pi, cwd);
243
+ const root = await repoRoot(pi, base);
229
244
  const dir = root ? join(root, CONFIG_DIR_NAME, "plans") : join(getAgentDir(), "plans");
230
- dirCache.set(cwd, dir);
245
+ dirCache.set(base, dir);
231
246
  return dir;
232
247
  };
233
248
 
234
249
  const write = async (cwd: string, input: WritePlanInput): Promise<WrittenPlan> => {
250
+ const base = baseOf(cwd);
235
251
  const title = input.title.trim();
236
252
  if (!title) throw new Error("A plan title is required.");
237
253
  const bytes = Buffer.byteLength(input.content, "utf-8");
@@ -239,13 +255,13 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
239
255
  throw new Error(`The plan is too large (${bytes} bytes; the limit is ${MAX_PLAN_BYTES}).`);
240
256
  }
241
257
 
242
- const dir = await dirFor(cwd);
258
+ const dir = await dirFor(base);
243
259
  mkdirSync(dir, { recursive: true });
244
260
  ensureIgnored(dir);
245
261
 
246
262
  let path: string;
247
263
  if (input.planPath) {
248
- const resolved = resolve(cwd, input.planPath);
264
+ const resolved = resolve(base, input.planPath);
249
265
  if (!isContainedPlan(dir, resolved)) {
250
266
  throw new Error("plan_path must be a markdown plan file inside the plans directory.");
251
267
  }
@@ -260,18 +276,19 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
260
276
  }
261
277
 
262
278
  writeAtomic(path, input.content);
263
- return { path, relativePath: relativeTo(cwd, path), title, content: input.content, bytes };
279
+ return { path, relativePath: relativeTo(base, path), title, content: input.content, bytes };
264
280
  };
265
281
 
266
282
  const read = async (cwd: string, planPath: string): Promise<StoredPlan | undefined> => {
267
- const dir = await dirFor(cwd);
268
- const resolved = resolve(cwd, planPath);
283
+ const base = baseOf(cwd);
284
+ const dir = await dirFor(base);
285
+ const resolved = resolve(base, planPath);
269
286
  if (!isContainedPlan(dir, resolved) || !existsSync(resolved)) return undefined;
270
287
  try {
271
288
  const content = readFileSync(resolved, "utf-8");
272
289
  return {
273
290
  path: resolved,
274
- relativePath: relativeTo(cwd, resolved),
291
+ relativePath: relativeTo(base, resolved),
275
292
  content,
276
293
  bytes: Buffer.byteLength(content, "utf-8"),
277
294
  };
@@ -375,7 +392,8 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
375
392
  * dotfile, or a file that vanishes mid-walk is simply not a plan.
376
393
  */
377
394
  const index = async (cwd: string): Promise<PlanSummary[]> => {
378
- const dir = await dirFor(cwd);
395
+ const base = baseOf(cwd);
396
+ const dir = await dirFor(base);
379
397
  let names: string[];
380
398
  try {
381
399
  names = readdirSync(dir);
@@ -399,7 +417,7 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
399
417
 
400
418
  const summary: PlanSummary = {
401
419
  path,
402
- relativePath: relativeTo(cwd, path),
420
+ relativePath: relativeTo(base, path),
403
421
  title: planTitle(name),
404
422
  modified: info.mtimeMs,
405
423
  bytes: info.size,
@@ -44,7 +44,9 @@ export type ReviewDecision =
44
44
  /** True when the request came from the browser, which is reported differently. */
45
45
  fromBrowser: boolean;
46
46
  }
47
- | { action: "keep"; plan: StoredPlan };
47
+ | { action: "keep"; plan: StoredPlan }
48
+ /** Leave plan mode without approving; the plan file stays for a later review. */
49
+ | { action: "cancel"; plan: StoredPlan };
48
50
 
49
51
  /** What the previous review showed, so this one can mark what changed. */
50
52
  export interface ReviewBaselines {
@@ -69,12 +71,16 @@ export interface ReviewPlanOptions {
69
71
  /** Prompt shown by the dialog-based refine editor in non-TUI modes. */
70
72
  const REFINE_PROMPT = "Refine the plan — what should change? (Enter to save, Esc to cancel)";
71
73
 
74
+ /**
75
+ * How long a remote review waits for its answer. Much longer than a plain dialog's
76
+ * bound, because the human has to read the plan first and a phone is a slow place
77
+ * to do it; an expiry here also ends plan mode, so it must not be easy to hit.
78
+ */
79
+ export const PLAN_REVIEW_TIMEOUT_MS = 600_000;
80
+
72
81
  /** How much of one step the remote digest shows. */
73
82
  const DIGEST_STEP_CHARS = 90;
74
83
 
75
- /** The refinement request used when a browser save carried no comments. */
76
- const NO_BROWSER_COMMENTS = "See the review comments in the plan file.";
77
-
78
84
  /**
79
85
  * A one-message digest of the plan for a remote review.
80
86
  *
@@ -94,7 +100,13 @@ function planReviewDigest(content: string): string {
94
100
  return `${index + 1}. ${shown}`;
95
101
  });
96
102
  const more = count > preview.length ? `… (${count - preview.length} more)` : "";
97
- return [head, ...preview, more, "The full plan is attached above, or sent as text if the file failed."]
103
+ return [
104
+ head,
105
+ ...preview,
106
+ more,
107
+ "Plan mode is on — write access is off. Choose “Exit plan mode” to leave without approving.",
108
+ "The full plan is attached above, or sent as text if the file failed.",
109
+ ]
98
110
  .filter((line) => line !== "")
99
111
  .join("\n");
100
112
  }
@@ -135,12 +147,16 @@ async function askPlanReview(
135
147
  "Approve and execute",
136
148
  "Send the full plan",
137
149
  "Refine the plan",
138
- "Keep planning",
150
+ "Keep planning (stay in plan mode)",
151
+ "Exit plan mode (discard)",
139
152
  ]);
140
153
  if (choice === "Approve and execute") return { action: "approve" };
141
154
  if (choice === "Send the full plan") return { action: "send" };
142
155
  if (choice === "Refine the plan") return { action: "refine" };
143
- return { action: "keep" };
156
+ if (choice === "Keep planning (stay in plan mode)") return { action: "keep" };
157
+ // Dismissing the plan gate means leaving plan mode, not silently staying
158
+ // in it. The explicit Keep choice above is the only way to stay.
159
+ return { action: "cancel" };
144
160
  },
145
161
  // A remote turn gets the plan file up front, falling back to the full text;
146
162
  // a local TUI turn ignores both and renders the component. The digest goes
@@ -149,6 +165,7 @@ async function askPlanReview(
149
165
  file: sentFullText ? undefined : { path: plan.path, name: basename(plan.relativePath) },
150
166
  body: plan.content,
151
167
  note: sentFullText ? undefined : planReviewDigest(plan.content),
168
+ timeoutMs: PLAN_REVIEW_TIMEOUT_MS,
152
169
  });
153
170
  if (outcome.action !== "send") return outcome;
154
171
  sentFullText = true;
@@ -209,6 +226,7 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
209
226
 
210
227
  if (choice.action === "approve") return { action: "approve", plan, comments: [] };
211
228
  if (choice.action === "keep") return { action: "keep", plan };
229
+ if (choice.action === "cancel") return { action: "cancel", plan };
212
230
  if (choice.action === "refine") {
213
231
  const refinement = choice.refinement ?? (await ctx.ui.editor(REFINE_PROMPT, ""))?.trim();
214
232
  // An empty or cancelled editor returns to the review screen instead of
@@ -231,13 +249,15 @@ export async function reviewPlan(options: ReviewPlanOptions): Promise<ReviewDeci
231
249
  }
232
250
  if (result.status === "approved") return { action: "approve", plan: result.plan, comments: result.annotations };
233
251
  if (result.status === "saved") {
234
- return {
235
- action: "refine",
236
- plan: result.plan,
237
- refinement: feedbackSummary(result.annotations) || NO_BROWSER_COMMENTS,
238
- comments: result.annotations,
239
- fromBrowser: true,
240
- };
252
+ // A save with no comments asks for nothing, so it returns to the screen the
253
+ // way an empty refine editor does, instead of reporting a refinement nobody
254
+ // wrote.
255
+ const refinement = feedbackSummary(result.annotations);
256
+ if (!refinement) {
257
+ plan = result.plan;
258
+ continue;
259
+ }
260
+ return { action: "refine", plan: result.plan, refinement, comments: result.annotations, fromBrowser: true };
241
261
  }
242
262
  // Cancelled or the tab closed: the file may have been rewritten underneath,
243
263
  // so re-read it before showing the screen again.
@@ -32,6 +32,8 @@ import type { PlanModeEntry, ReviewBaselineEntry } from "./types.ts";
32
32
 
33
33
  /** Tool the model calls to leave plan mode after approval. */
34
34
  export const EXIT_TOOL = "exit_plan_mode";
35
+ /** Tool the model calls to leave plan mode without approving. */
36
+ export const CANCEL_TOOL = "cancel_plan_mode";
35
37
  /** Tool the model calls to ask to enter plan mode. */
36
38
  export const ENTER_TOOL = PLAN_ENTER_TOOL;
37
39
  /** Tool the model calls to save the plan artifact. */
@@ -47,7 +49,7 @@ export const BASELINE_TYPE = "plan-baseline";
47
49
  const STATUS_NAME_LENGTH = 28;
48
50
 
49
51
  /** Tools plan mode activates itself; the guard permits only this extension's own. */
50
- const CONTROL_TOOLS = [EXIT_TOOL, WRITE_PLAN_TOOL];
52
+ const CONTROL_TOOLS = [EXIT_TOOL, CANCEL_TOOL, WRITE_PLAN_TOOL];
51
53
 
52
54
  export interface PlanRuntime {
53
55
  /** Whether plan mode is currently on. */
@@ -23,34 +23,59 @@ export const MAX_STEPS = 50;
23
23
  export const MAX_STEP_LENGTH = 200;
24
24
 
25
25
  /**
26
- * The outline a plan is guided to follow. Guidance only: `write_plan` never
27
- * rejects or rewrites a plan that omits a section.
26
+ * The outline a plan is guided to follow: one job per section, and every example
27
+ * is the shape wanted rather than a description of it. `Visual` is the one
28
+ * section that carries the end state — the same surface before and after when the
29
+ * change alters one that already exists, one frame when it is new — in a fence
30
+ * that stays untagged unless every line is code, so the reviewer sees the result
31
+ * before the steps that build it. Guidance only: `write_plan` never rejects or
32
+ * rewrites a plan that omits a section, and a change with no visible surface drops
33
+ * `Visual`.
28
34
  */
29
35
  export const PLAN_OUTLINE = [
30
36
  "# <Title>",
31
37
  "",
32
38
  "## Goal",
33
- "What this change achieves, and what it deliberately does not.",
39
+ "One sentence: the outcome this change delivers, and what it deliberately does not do.",
34
40
  "",
35
41
  "## Context",
36
- "The relevant code, constraints, and prior art.",
42
+ "- `src/load.ts:42` reads the config once at startup; that is the only caller.",
43
+ "- The file is user-editable, so a bad key must name the file and line.",
37
44
  "",
38
45
  "## Approach",
39
- "The chosen approach and why; alternatives considered.",
46
+ "- Parse into a typed record in `parseConfig`; `load()` keeps the file read.",
47
+ "- Rejected: validating inside `load()`, because the test would need a filesystem.",
48
+ "",
49
+ "## Visual",
50
+ "A bad key at startup, before and after:",
51
+ "```",
52
+ "$ bun start",
53
+ 'config.json:12: ignoring unknown key "timeout_ms"',
54
+ "```",
55
+ "",
56
+ "```",
57
+ "$ bun start",
58
+ 'config.json:12: unknown key "timeout_ms" — did you mean "timeoutMs"?',
59
+ "```",
40
60
  "",
41
61
  "## Steps",
42
62
  "- [ ] Add `parseConfig` in `src/config.ts`, with a test for a missing key",
43
- "- [ ] Wire it into `load()` and document the failure in the README",
63
+ " - `load()` is the only caller today; it keeps the file read.",
64
+ "- [ ] Report a bad key with its file and line, and note the rule in the README",
44
65
  "",
45
66
  "## Risks",
46
- "What could go wrong and how it is mitigated.",
67
+ "- A missing key now fails at startup rather than on first use: name that in the README.",
68
+ "- Mitigated by the test in the first step.",
47
69
  "",
48
70
  "## Verification",
49
- "How to prove it works: commands and checks.",
71
+ "```bash",
72
+ "bun test src/config.test.ts",
73
+ "bun run check",
74
+ "```",
50
75
  ].join("\n");
51
76
 
52
77
  /** The outline's section titles, in order, for documentation and tests. */
53
- export const PLAN_SECTIONS = ["Goal", "Context", "Approach", "Steps", "Risks", "Verification"] as const;
78
+ export const PLAN_SECTIONS = ["Goal", "Context", "Approach", "Visual", "Steps", "Risks", "Verification"] as const;
54
79
 
55
80
  export interface PlanStep {
56
81
  content: string;
@@ -233,11 +258,12 @@ const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?/;
233
258
  * The plan's `Verification` section as one trailing checklist item, or
234
259
  * `undefined` when the plan has none.
235
260
  *
236
- * The section is prose ("commands and checks"), and a todo item is one line, so
237
- * the lines are stripped of list markers and inline emphasis and joined with
238
- * `; `, prefixed with `Verify:` and elided like a long step. The item is
239
- * deliberately *not* a plan step: it is not in the `Steps` section, so it must
240
- * not count against the step cap or appear in `set_plan_steps` numbering.
261
+ * The section is prose ("commands and checks") or a fenced block of them, and a
262
+ * todo item is one line, so the fence lines are dropped, the rest is stripped of
263
+ * list markers and inline emphasis, and the lines are joined with `; `, prefixed
264
+ * with `Verify:` and elided like a long step. The item is deliberately *not* a
265
+ * plan step: it is not in the `Steps` section, so it must not count against the
266
+ * step cap or appear in `set_plan_steps` numbering.
241
267
  *
242
268
  * A plan with no `Steps` heading feeds its shallowest list as steps (the older
243
269
  * rule), so that list already covers the verification bullets; appending a
@@ -252,6 +278,9 @@ export function verificationItem(plan: string): PlanStep | undefined {
252
278
  const items = section.text
253
279
  .split("\n")
254
280
  .slice(1)
281
+ // A fenced block is how the outline asks for commands; its fence lines are
282
+ // punctuation, not a check, so only the commands inside it are carried.
283
+ .filter((line) => !FENCE.test(line))
255
284
  .map((line) => plainText(line.replace(LIST_PREFIX, "")))
256
285
  .filter(Boolean);
257
286
  if (items.length === 0) return undefined;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The `cancel_plan_mode` tool registration.
3
+ *
4
+ * This is the escape hatch that does not need a plan file or a review dialog:
5
+ * when the user asks to leave planning, the model can restore write access
6
+ * without approving anything. The plan store is untouched; a later `/plan` can
7
+ * still review the saved artifact.
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { Type } from "typebox";
12
+ import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
13
+ import { paint } from "../../../lib/tui/tokens.ts";
14
+ import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
15
+ import { failedResult } from "../../../lib/tool-result.ts";
16
+ import { CANCEL_TOOL, type PlanRuntime } from "../runtime.ts";
17
+ import type { CancelPlanModeDetails } from "../types.ts";
18
+
19
+ const CANCELLED_TEXT =
20
+ "Plan mode is off and write access is restored. The plan was not approved; no todo items or goal were recorded. " +
21
+ "Any saved plan file remains for a later review.";
22
+
23
+ export function registerCancelTool(pi: ExtensionAPI, runtime: PlanRuntime): void {
24
+ pi.registerTool({
25
+ name: CANCEL_TOOL,
26
+ label: "Cancel plan mode",
27
+ description:
28
+ "Leave plan mode without approving a plan. Restores write access; no todo items or goal are recorded. " +
29
+ "Use when the user asks to exit, cancel, or abandon planning.",
30
+ promptSnippet: "Leave plan mode without approving the plan.",
31
+ promptGuidelines: [
32
+ "Call cancel_plan_mode when the user asks to leave or abandon planning without approving a plan.",
33
+ ],
34
+ parameters: Type.Object({}),
35
+ defaultActive: false,
36
+ executionMode: "sequential",
37
+ // The mode change touches no files; like enter_plan_mode, it needs no
38
+ // destructive annotation.
39
+ annotations: { readOnlyHint: true, openWorldHint: false },
40
+
41
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
42
+ if (!runtime.isEnabled()) {
43
+ return failedResult("Not in plan mode; there is nothing to cancel.", {
44
+ cancelled: false,
45
+ } satisfies CancelPlanModeDetails);
46
+ }
47
+ runtime.disable(ctx);
48
+ ctx.ui.notify("Plan mode off — full access restored. The plan was not approved.", "info");
49
+ return {
50
+ content: [{ type: "text", text: CANCELLED_TEXT }],
51
+ details: { cancelled: true } satisfies CancelPlanModeDetails,
52
+ };
53
+ },
54
+
55
+ renderCall(_args, theme, context) {
56
+ return callLine(theme, context?.lastComponent, CANCEL_TOOL, "left plan mode");
57
+ },
58
+
59
+ renderResult(result, options, theme, context) {
60
+ const text = reuseText(context?.lastComponent);
61
+ const outcome = detailsOrLine(
62
+ theme,
63
+ result,
64
+ options.expanded,
65
+ toolDetails<CancelPlanModeDetails>(result.details),
66
+ );
67
+ if ("line" in outcome) {
68
+ text.setText(outcome.line);
69
+ return text;
70
+ }
71
+ text.setText(
72
+ outcome.details.cancelled
73
+ ? paint(theme, "warn", "Plan mode off — plan not approved")
74
+ : paint(theme, "meta", "Not in plan mode"),
75
+ );
76
+ return text;
77
+ },
78
+ });
79
+ }
@@ -11,6 +11,8 @@ import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
11
11
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
12
12
  import { paint } from "../../../lib/tui/tokens.ts";
13
13
  import { callLine, detailsOrLine } from "../../../lib/tui/tool.ts";
14
+ import { failedResult } from "../../../lib/tool-result.ts";
15
+ import { isRemoteTurn } from "../../../lib/interaction.ts";
14
16
  import { PLAN_CONSENT, READ_ONLY_SUMMARY } from "../../../lib/plan-entry.ts";
15
17
  import { ENTER_TOOL, type PlanRuntime } from "../runtime.ts";
16
18
  import type { EnterPlanModeDetails } from "../types.ts";
@@ -45,16 +47,10 @@ export function registerEnterTool(pi: ExtensionAPI, runtime: PlanRuntime): void
45
47
  };
46
48
  }
47
49
  if (!ctx.hasUI) {
48
- return {
49
- content: [
50
- {
51
- type: "text",
52
- text: "No interactive UI is available to confirm entering plan mode. Ask the user whether they want a plan.",
53
- },
54
- ],
55
- details: { entered: false, unavailable: true } satisfies EnterPlanModeDetails,
56
- isError: true,
57
- };
50
+ return failedResult(
51
+ "No interactive UI is available to confirm entering plan mode. Ask the user whether they want a plan.",
52
+ { entered: false, unavailable: true } satisfies EnterPlanModeDetails,
53
+ );
58
54
  }
59
55
 
60
56
  const approved = await ctx.ui.confirm("Enter plan mode?", PLAN_CONSENT);
@@ -65,6 +61,10 @@ export function registerEnterTool(pi: ExtensionAPI, runtime: PlanRuntime): void
65
61
  };
66
62
  }
67
63
  runtime.enable(ctx);
64
+ if (isRemoteTurn(ctx)) {
65
+ // The peer UI has no footer chip; say the mode and its exit in the chat.
66
+ ctx.ui.notify("Plan mode on — write access is off. Say “exit plan mode” to leave without approving.", "info");
67
+ }
68
68
  return {
69
69
  content: [{ type: "text", text: ENTERED_TEXT }],
70
70
  details: { entered: true } satisfies EnterPlanModeDetails,