@gavin-hu/my-pi-agent 0.11.4 → 0.12.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 (81) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +5 -4
  73. package/extensions/wechat/tools.ts +0 -2
  74. package/lib/README.md +2 -1
  75. package/lib/config.ts +43 -1
  76. package/lib/env.ts +12 -0
  77. package/lib/requirements.ts +76 -2
  78. package/lib/todo-state.ts +62 -0
  79. package/lib/tui/glyphs.ts +1 -0
  80. package/package.json +1 -1
  81. package/assets/bytefly.schema.json +0 -785
@@ -28,7 +28,7 @@ export const PLAN_OUTLINE = [
28
28
  "# <Title>",
29
29
  "",
30
30
  "## Goal",
31
- "What this change achieves, in one or two sentences.",
31
+ "What this change achieves, and what it deliberately does not.",
32
32
  "",
33
33
  "## Context",
34
34
  "The relevant code, constraints, and prior art.",
@@ -37,8 +37,8 @@ export const PLAN_OUTLINE = [
37
37
  "The chosen approach and why; alternatives considered.",
38
38
  "",
39
39
  "## Steps",
40
- "- [ ] First concrete step",
41
- "- [ ] Next step",
40
+ "- [ ] Add `parseConfig` in `src/config.ts`, with a test for a missing key",
41
+ "- [ ] Wire it into `load()` and document the failure in the README",
42
42
  "",
43
43
  "## Risks",
44
44
  "What could go wrong and how it is mitigated.",
@@ -61,6 +61,10 @@ export interface PlanSection {
61
61
  title: string;
62
62
  /** The section's markdown, including its heading line. */
63
63
  text: string;
64
+ /** 0-based source line of the section's heading (0 for the preamble). */
65
+ line: number;
66
+ /** Heading level, or 0 for the preamble before the first heading. */
67
+ level: number;
64
68
  }
65
69
 
66
70
  /** A top-level list item located in the source. */
@@ -78,10 +82,19 @@ interface PlanListItem {
78
82
  /** A top-level numbered or bulleted list item, capturing its parts. */
79
83
  const LIST_ITEM = /^([ \t]*)((?:\d+[.)]|[-*+])\s+)(.*\S)\s*$/;
80
84
  const CHECKBOX = /^\[([ xX])\]\s*(.*)$/;
81
- /** A markdown heading outside a fence, capturing its level and text. */
82
- const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
83
- /** A markdown code-fence opener or closer (backticks or tildes). */
84
- const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
85
+ /**
86
+ * A markdown heading outside a fence, capturing its level and text: up to three
87
+ * leading spaces, a space after the hashes, and a closing hash run only when a
88
+ * space separates it. Shared with `blocks.ts`, so the section split and the
89
+ * browser outline read a heading line the same way, and `## C#` keeps its title.
90
+ */
91
+ export const HEADING = /^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/;
92
+ /**
93
+ * A markdown code-fence opener or closer (backticks or tildes). Shared with
94
+ * `blocks.ts`: a tab-indented fence is prose for both parsers, never code for
95
+ * one and prose for the other.
96
+ */
97
+ export const FENCE = /^ {0,3}(`{3,}|~{3,})/;
85
98
  const DONE_MARKER = /\[DONE:\d+\]/gi;
86
99
 
87
100
  /**
@@ -282,13 +295,13 @@ export function setStepStatuses(plan: string, updates: StepUpdate[]): string | u
282
295
  }
283
296
 
284
297
  /**
285
- * Titles (normalized) whose section text differs between two plan revisions, or
286
- * that are new in `current`. Revision snapshots are the caller's to provide;
287
- * strip stored feedback first so a review comment does not look like an edit.
288
- * Duplicate headings are compared by occurrence, and a changed title marks every
289
- * section with that title — plans following the canonical outline have none.
298
+ * Per-section change flags for a second review: `boolean[]` aligned with
299
+ * `extractPlanSections(current)`, so duplicate headings are flagged by
300
+ * occurrence rather than by title. Revision snapshots are the caller's to
301
+ * provide; strip stored feedback first so a review comment does not look like
302
+ * an edit.
290
303
  */
291
- export function changedSections(previous: string, current: string): Set<string> {
304
+ export function changedSections(previous: string, current: string): boolean[] {
292
305
  const before = new Map<string, string[]>();
293
306
  for (const section of extractPlanSections(previous)) {
294
307
  const texts = before.get(section.title);
@@ -297,14 +310,12 @@ export function changedSections(previous: string, current: string): Set<string>
297
310
  }
298
311
 
299
312
  const seen = new Map<string, number>();
300
- const changed = new Set<string>();
301
- for (const section of extractPlanSections(current)) {
313
+ return extractPlanSections(current).map((section) => {
302
314
  const index = seen.get(section.title) ?? 0;
303
315
  seen.set(section.title, index + 1);
304
316
  const prior = before.get(section.title)?.[index];
305
- if (prior === undefined || prior !== section.text.trim()) changed.add(section.title);
306
- }
307
- return changed;
317
+ return prior === undefined || prior !== section.text.trim();
318
+ });
308
319
  }
309
320
 
310
321
  /**
@@ -323,7 +334,7 @@ export function extractPlanSections(plan: string): PlanSection[] {
323
334
  current = undefined;
324
335
  };
325
336
 
326
- for (const line of plan.split("\n")) {
337
+ plan.split("\n").forEach((line, index) => {
327
338
  const fenceMatch = line.match(FENCE);
328
339
  if (fenceMatch) {
329
340
  if (fence === undefined) fence = fenceMatch[1][0];
@@ -332,13 +343,13 @@ export function extractPlanSections(plan: string): PlanSection[] {
332
343
  const heading = line.match(HEADING);
333
344
  if (heading) {
334
345
  flush();
335
- current = { title: plainText(heading[2]), text: line };
336
- continue;
346
+ current = { title: plainText(heading[2]), text: line, line: index, level: heading[1].length };
347
+ return;
337
348
  }
338
349
  }
339
- if (current === undefined) current = { title: "", text: line };
350
+ if (current === undefined) current = { title: "", text: line, line: 0, level: 0 };
340
351
  else current.text += `\n${line}`;
341
- }
352
+ });
342
353
 
343
354
  flush();
344
355
  return sections;
@@ -3,25 +3,15 @@
3
3
  *
4
4
  * `todo` is whole-list replacement, so seeding an approved plan straight over
5
5
  * the list would discard whatever the user was already tracking. This module
6
- * reads the last `todo` result from the session branch — the published state
7
- * channel, replayed the way the todo extension itself reconstructs it, with no
8
- * import from that extension — and merges the plan's steps into it: existing
9
- * items keep their place and status, a step whose text is already present is
10
- * skipped, and the tool's item cap is respected without dropping anything the
11
- * user had.
6
+ * merges the plan's steps into the list `lib/todo-state.ts` replays from the
7
+ * session branch: existing items keep their place and status, a step whose text
8
+ * is already present is skipped, and the tool's item cap is respected without
9
+ * dropping anything the user had.
12
10
  */
13
11
 
14
- import { TODO_TOOL } from "../../lib/tool-names.ts";
12
+ import type { ExistingTodo } from "../../lib/todo-state.ts";
15
13
  import type { PlanStep } from "./steps.ts";
16
14
 
17
- /** One item in the existing todo list, as the todo tool stores it. */
18
- export interface ExistingTodo {
19
- content: string;
20
- status: "pending" | "in_progress" | "completed";
21
- /** Present-continuous label the todo tool requires for an in-progress item. */
22
- activeForm?: string;
23
- }
24
-
25
15
  /** The merged list plus the counts the approval message reports. */
26
16
  export interface TodoMerge {
27
17
  todos: ExistingTodo[];
@@ -35,52 +25,9 @@ export interface TodoMerge {
35
25
  deferred: number;
36
26
  }
37
27
 
38
- /** Minimal shape of a session entry, used for runtime narrowing. */
39
- interface BranchEntryLike {
40
- type?: string;
41
- message?: {
42
- role?: string;
43
- toolName?: string;
44
- details?: unknown;
45
- };
46
- }
47
-
48
28
  /** Fallback list cap when the registered todo tool does not advertise one. */
49
29
  export const DEFAULT_TODO_MAX = 50;
50
30
 
51
- function isStatus(value: unknown): value is ExistingTodo["status"] {
52
- return value === "pending" || value === "in_progress" || value === "completed";
53
- }
54
-
55
- /**
56
- * The last valid todo list written on the branch, or an empty list. A rejected
57
- * call carries the unchanged list and is not a state write, so it is skipped;
58
- * an entry that does not re-validate is ignored rather than wiping a good list.
59
- */
60
- export function lastTodos(entries: Iterable<unknown>): ExistingTodo[] {
61
- let todos: ExistingTodo[] = [];
62
- for (const raw of entries) {
63
- const entry = raw as BranchEntryLike;
64
- if (entry?.type !== "message") continue;
65
- const message = entry.message;
66
- if (message?.role !== "toolResult" || message.toolName !== TODO_TOOL) continue;
67
- const details = message.details as { todos?: unknown; error?: unknown } | undefined;
68
- if (!details || details.error) continue;
69
- if (!Array.isArray(details.todos)) continue;
70
- todos = details.todos.flatMap((item): ExistingTodo[] => {
71
- if (typeof item !== "object" || item === null) return [];
72
- const value = item as { content?: unknown; status?: unknown; activeForm?: unknown };
73
- if (typeof value.content !== "string" || !isStatus(value.status)) return [];
74
- const content = value.content.trim();
75
- if (!content) return [];
76
- const activeForm =
77
- typeof value.activeForm === "string" && value.activeForm.trim() ? value.activeForm.trim() : undefined;
78
- return [activeForm ? { content, status: value.status, activeForm } : { content, status: value.status }];
79
- });
80
- }
81
- return todos;
82
- }
83
-
84
31
  /** Case- and whitespace-insensitive key for recognizing a repeated item. */
85
32
  function contentKey(content: string): string {
86
33
  return content.trim().replace(/\s+/g, " ").toLowerCase();
@@ -17,17 +17,16 @@
17
17
 
18
18
  import { StringEnum } from "@earendil-works/pi-ai";
19
19
  import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
20
- import { basename } from "node:path";
21
20
  import { Type, type Static } from "typebox";
22
21
  import { sanitizeLine } from "../../lib/format.ts";
23
- import { askHuman } from "../../lib/interaction.ts";
24
22
  import { TODO_TOOL } from "../../lib/tool-names.ts";
25
- import { FULL_SCREEN_OVERLAY, reuseText, toolDetails } from "../../lib/tui/chrome.ts";
23
+ import { lastTodos } from "../../lib/todo-state.ts";
24
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
26
25
  import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
27
26
  import { paint } from "../../lib/tui/tokens.ts";
28
27
  import { expandHint, GLYPHS } from "../../lib/tui/glyphs.ts";
29
28
  import { READ_ONLY_SUMMARY } from "./policy.ts";
30
- import { feedbackSummary, type PlanAnnotation } from "./feedback.ts";
29
+ import { feedbackSummary } from "./feedback.ts";
31
30
  import { ENTER_TOOL, EXIT_TOOL, SET_PLAN_STEPS_TOOL, WRITE_PLAN_TOOL, type PlanRuntime } from "./runtime.ts";
32
31
  import {
33
32
  MAX_STEP_LENGTH,
@@ -39,10 +38,11 @@ import {
39
38
  type PlanStepSummary,
40
39
  type StepUpdate,
41
40
  } from "./steps.ts";
42
- import { DEFAULT_TODO_MAX, lastTodos, mergeTodos, type TodoMerge } from "./todos.ts";
43
- import { PlanViewComponent, sanitizePlanText, type PlanViewAction } from "./tui.ts";
41
+ import { DEFAULT_TODO_MAX, mergeTodos, type TodoMerge } from "./todos.ts";
42
+ import { reviewPlan } from "./review.ts";
43
+ import { sanitizePlanText } from "./tui.ts";
44
44
  import type { EnterPlanModeDetails, ExitPlanModeDetails, SetPlanStepsDetails, WritePlanDetails } from "./types.ts";
45
- import { createPlanWebReview, type PlanReviewResult, type PlanReviewSession, type PlanWebReview } from "./web.ts";
45
+ import { createPlanWebReview, type PlanWebReview } from "./web.ts";
46
46
 
47
47
  const ExitPlanModeParams = Type.Object({
48
48
  plan_path: Type.String({
@@ -204,103 +204,6 @@ function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
204
204
  return ` Merged ${counted} into the todo list${suffix}. ${tick}`;
205
205
  }
206
206
 
207
- /** The review choice plus the refinement the user typed in the TUI, if any. */
208
- interface PlanReviewOutcome {
209
- action: PlanViewAction;
210
- refinement?: string;
211
- }
212
-
213
- /** A dialog-only choice: post the full plan text, then ask again. Never returned. */
214
- type ReviewChoice = PlanReviewOutcome | { action: "send" };
215
-
216
- /** Prompt shown by the dialog-based refine editor in non-TUI modes. */
217
- const REFINE_PROMPT = "Refine the plan — what should change? (Enter to save, Esc to cancel)";
218
-
219
- /** How much of one step the remote digest shows. */
220
- const DIGEST_STEP_CHARS = 90;
221
-
222
- /**
223
- * A one-message digest of the plan for a remote review.
224
- *
225
- * The file is the primary artifact, but a phone client may never render or open
226
- * it, and the peer still has to choose approve / refine. The digest carries the
227
- * title, the step count, and the first few steps; the full text follows as a
228
- * file or as the fallback message.
229
- */
230
- function planReviewDigest(content: string): string {
231
- const title = /^#\s+(.+)$/m.exec(content)?.[1]?.trim();
232
- const steps = extractPlanSteps(content);
233
- const count = steps.length;
234
- const head = `Plan${title ? `: ${title}` : ""}${count > 0 ? ` (${count} step${count === 1 ? "" : "s"})` : ""}`;
235
- const preview = steps.slice(0, 3).map((step, index) => {
236
- const text = step.content.replace(/\s+/g, " ").trim();
237
- const shown = text.length > DIGEST_STEP_CHARS ? `${text.slice(0, DIGEST_STEP_CHARS - 1)}…` : text;
238
- return `${index + 1}. ${shown}`;
239
- });
240
- const more = count > preview.length ? `… (${count - preview.length} more)` : "";
241
- return [head, ...preview, more, "The full plan is attached above, or sent as text if the file failed."]
242
- .filter((line) => line !== "")
243
- .join("\n");
244
- }
245
-
246
- /**
247
- * Ask the user to review the plan file. The TUI opens the scrollable review
248
- * screen (with an inline refine editor); dialog-capable modes (RPC) fall back
249
- * to a select plus the refine editor; the caller has already refused when there
250
- * is no UI.
251
- */
252
- async function reviewPlan(
253
- ctx: ExtensionToolContext,
254
- plan: { path: string; relativePath: string; content: string; bytes: number },
255
- previous?: string,
256
- ): Promise<PlanReviewOutcome> {
257
- // The first pass sends the file (or the full text when it fails) and the
258
- // digest; asking for the full plan re-enters with the text only, then shows
259
- // the choices again.
260
- let sentFullText = false;
261
- for (;;) {
262
- const outcome = await askHuman<ReviewChoice>(ctx, {
263
- custom: async () => {
264
- const outcome = await ctx.ui.custom<PlanReviewOutcome | undefined>(
265
- (tui, theme, _keybindings, done) =>
266
- new PlanViewComponent({
267
- plan,
268
- previous,
269
- theme,
270
- onClose: (action, refinement) => done({ action, refinement }),
271
- requestRender: () => tui.requestRender(),
272
- viewportRows: () => tui.terminal?.rows,
273
- tui,
274
- }),
275
- FULL_SCREEN_OVERLAY,
276
- );
277
- return outcome ?? { action: "keep" };
278
- },
279
- dialogs: async () => {
280
- const choice = await ctx.ui.select("Plan mode — what next?", [
281
- "Approve and execute",
282
- "Send the full plan",
283
- "Refine the plan",
284
- "Keep planning",
285
- ]);
286
- if (choice === "Approve and execute") return { action: "approve" };
287
- if (choice === "Send the full plan") return { action: "send" };
288
- if (choice === "Refine the plan") return { action: "refine" };
289
- return { action: "keep" };
290
- },
291
- // A remote turn gets the plan file up front, falling back to the full text;
292
- // a local TUI turn ignores both and renders the component. The digest goes
293
- // out either way so the choices stay answerable when a phone client does not
294
- // render the file bubble.
295
- file: sentFullText ? undefined : { path: plan.path, name: basename(plan.relativePath) },
296
- body: plan.content,
297
- note: sentFullText ? undefined : planReviewDigest(plan.content),
298
- });
299
- if (outcome.action !== "send") return outcome;
300
- sentFullText = true;
301
- }
302
- }
303
-
304
207
  export function registerTools(
305
208
  pi: ExtensionAPI,
306
209
  runtime: PlanRuntime,
@@ -320,7 +223,7 @@ export function registerTools(
320
223
  executionMode: "sequential",
321
224
  // readOnlyHint stays true: entering plan mode changes only the session's
322
225
  // tool set, never files or system state, so it needs no approval.
323
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
226
+ annotations: { readOnlyHint: true, openWorldHint: false },
324
227
 
325
228
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
326
229
  if (runtime.isEnabled()) {
@@ -464,9 +367,9 @@ export function registerTools(
464
367
  executionMode: "sequential",
465
368
  // readOnlyHint stays true: it writes no files, and the review dialog inside
466
369
  // execute is the human gate, so an outer prompt would only double-ask.
467
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
370
+ annotations: { readOnlyHint: true, openWorldHint: false },
468
371
 
469
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
372
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
470
373
  const planPath = (params as ExitPlanModeArgs).plan_path.trim();
471
374
 
472
375
  if (!runtime.isEnabled()) {
@@ -477,7 +380,7 @@ export function registerTools(
477
380
  };
478
381
  }
479
382
 
480
- let file = await runtime.plans.read(ctx.cwd, planPath);
383
+ const file = await runtime.plans.read(ctx.cwd, planPath);
481
384
  if (!file) {
482
385
  return {
483
386
  content: [
@@ -510,117 +413,57 @@ export function registerTools(
510
413
  };
511
414
  }
512
415
 
513
- // What the last review showed this file, so this one can mark the changes.
514
- // Mark before presenting: the next review compares against what is on
515
- // screen now, not against a revision the user never saw.
516
- let presentedPrevious: string | undefined;
517
- const present = (candidate: { path: string; relativePath: string; content: string; bytes: number }) => {
518
- const previous = runtime.reviewed(candidate.path);
519
- runtime.markReviewed(candidate.path, candidate.content);
520
- presentedPrevious = previous;
521
- return reviewPlan(ctx, candidate, previous);
522
- };
523
-
524
- let outcome = await present(file);
525
- // Comments carried by a browser approval, reported alongside the approval.
526
- let browserComments: PlanAnnotation[] = [];
527
- // Review, refine, and browser review all loop back here until the user
528
- // approves, keeps planning, or a refinement is handed to the model. The
529
- // dialog-based refine editor carries no text from the review screen, so an
530
- // empty or cancelled editor returns to the review instead of being reported
531
- // as "not approved".
532
- for (;;) {
533
- if (outcome.action === "browser") {
534
- // The review screen that produced this choice already marked the
535
- // content it showed, so the page compares against that baseline.
536
- const session: PlanReviewSession | undefined = await web
537
- .start(ctx.cwd, file, { canApprove: true, previous: presentedPrevious })
538
- .catch(() => undefined);
539
- if (!session) {
540
- ctx.ui.notify("Plan review: could not start the browser review server.", "error");
541
- outcome = await present(file);
542
- continue;
543
- }
544
- ctx.ui.notify(`Plan review: opened ${session.url}`, "info");
545
- const result: PlanReviewResult = await session.done;
546
- await session.close();
547
- if (result.status === "approved") {
548
- // Approve from the browser is the TUI's approve: keep the comments it
549
- // carried and fall through to the shared approval path below.
550
- file = result.plan;
551
- browserComments = result.annotations;
552
- outcome = { action: "approve" };
553
- continue;
554
- }
555
- if (result.status === "saved") {
556
- // The comments are already in the file; hand them back as a refinement so
557
- // the model revises the same plan and calls exit_plan_mode again.
558
- const refinement = feedbackSummary(result.annotations) || "See the review comments in the plan file.";
559
- return {
560
- content: [
561
- {
562
- type: "text",
563
- text: `The user reviewed the plan in the browser and left feedback:\n${refinement}\nStay in plan mode, revise the plan (write_plan, reusing plan_path), then call exit_plan_mode again.`,
564
- },
565
- ],
566
- details: {
567
- approved: false,
568
- plan: result.plan.content,
569
- planPath: result.plan.path,
570
- relativePath: result.plan.relativePath,
571
- refined: true,
572
- refinement,
573
- } satisfies ExitPlanModeDetails,
574
- };
575
- }
576
- // Cancelled or the tab closed: re-read the file and review again.
577
- file = (await runtime.plans.read(ctx.cwd, file.path)) ?? file;
578
- outcome = await present(file);
579
- continue;
580
- }
581
- if (outcome.action === "refine" && !outcome.refinement) {
582
- const refinement = (await ctx.ui.editor(REFINE_PROMPT, ""))?.trim();
583
- if (refinement) outcome = { action: "refine", refinement };
584
- else outcome = await present(file);
585
- continue;
586
- }
587
- if (outcome.action === "refine") {
588
- const refinement = outcome.refinement ?? "";
589
- return {
590
- content: [
591
- {
592
- type: "text",
593
- text: `The user asked to refine the plan: ${refinement}\nStay in plan mode, revise the plan (write_plan, reusing plan_path), then call exit_plan_mode again.`,
594
- },
595
- ],
596
- details: {
597
- approved: false,
598
- plan: file.content,
599
- planPath: file.path,
600
- relativePath: file.relativePath,
601
- refined: true,
602
- refinement,
603
- } satisfies ExitPlanModeDetails,
604
- };
605
- }
606
- if (outcome.action !== "approve") {
607
- return {
608
- content: [
609
- { type: "text", text: "Plan not approved. Stay in plan mode, ask what to change, and revise the plan." },
610
- ],
611
- details: {
612
- approved: false,
613
- plan: file.content,
614
- planPath: file.path,
615
- relativePath: file.relativePath,
616
- } satisfies ExitPlanModeDetails,
617
- };
618
- }
619
- break;
416
+ // One decision, however the session is attached; `review.ts` owns the loop,
417
+ // the baseline, and closing the browser review when the turn is aborted.
418
+ const decision = await reviewPlan({
419
+ ctx,
420
+ web,
421
+ plans: runtime.plans,
422
+ baselines: runtime,
423
+ plan: file,
424
+ signal,
425
+ });
426
+
427
+ if (decision.action === "refine") {
428
+ // A browser save already wrote its comments into the file; either way the
429
+ // model revises the same plan and presents it again.
430
+ const feedback = decision.fromBrowser
431
+ ? `The user reviewed the plan in the browser and left feedback:\n${decision.refinement}`
432
+ : `The user asked to refine the plan: ${decision.refinement}`;
433
+ return {
434
+ content: [
435
+ {
436
+ type: "text",
437
+ text: `${feedback}\nStay in plan mode, revise the plan (write_plan, reusing plan_path), then call exit_plan_mode again.`,
438
+ },
439
+ ],
440
+ details: {
441
+ approved: false,
442
+ plan: decision.plan.content,
443
+ planPath: decision.plan.path,
444
+ relativePath: decision.plan.relativePath,
445
+ refined: true,
446
+ refinement: decision.refinement,
447
+ annotations: decision.comments.length > 0 ? decision.comments : undefined,
448
+ } satisfies ExitPlanModeDetails,
449
+ };
450
+ }
451
+ if (decision.action === "keep") {
452
+ return {
453
+ content: [
454
+ { type: "text", text: "Plan not approved. Stay in plan mode, ask what to change, and revise the plan." },
455
+ ],
456
+ details: {
457
+ approved: false,
458
+ plan: decision.plan.content,
459
+ planPath: decision.plan.path,
460
+ relativePath: decision.plan.relativePath,
461
+ } satisfies ExitPlanModeDetails,
462
+ };
620
463
  }
621
464
 
622
465
  runtime.disable(ctx);
623
- const { steps, merged, error } = await seedTodos(pi, ctx, file.content);
466
+ const { steps, merged, error } = await seedTodos(pi, ctx, decision.plan.content);
624
467
  const listing =
625
468
  steps.length > 0
626
469
  ? `\n\n${steps
@@ -635,21 +478,22 @@ export function registerTools(
635
478
  : merged
636
479
  ? mergeSummary(merged, steps)
637
480
  : ` Could not read the todo list. Record these steps with the todo tool before you start, and tick them off with ${SET_PLAN_STEPS_TOOL}.`;
638
- const browserNote = browserComments.length
639
- ? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(browserComments)}`
481
+ const browserNote = decision.comments.length
482
+ ? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(decision.comments)}`
640
483
  : "";
641
484
  return {
642
485
  content: [
643
486
  {
644
487
  type: "text",
645
- text: `Plan approved. Plan mode is off and write access is restored. The plan file ${file.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${listing}`,
488
+ text: `Plan approved. Plan mode is off and write access is restored. The plan file ${decision.plan.relativePath} is the source of truth; follow its steps.${browserNote}${tail}${listing}`,
646
489
  },
647
490
  ],
648
491
  details: {
649
492
  approved: true,
650
- plan: file.content,
651
- planPath: file.path,
652
- relativePath: file.relativePath,
493
+ plan: decision.plan.content,
494
+ planPath: decision.plan.path,
495
+ relativePath: decision.plan.relativePath,
496
+ annotations: decision.comments.length > 0 ? decision.comments : undefined,
653
497
  } satisfies ExitPlanModeDetails,
654
498
  };
655
499
  },
@@ -30,7 +30,7 @@ import {
30
30
  type TuiMouseEvent,
31
31
  type TuiMouseEventResult,
32
32
  } from "@earendil-works/pi-tui";
33
- import { stripControlChars } from "../../lib/format.ts";
33
+ import { sanitizeLine, stripControlChars } from "../../lib/format.ts";
34
34
  import {
35
35
  editorTheme,
36
36
  screenHint,
@@ -128,8 +128,11 @@ const TASK_MARKER = /^([-*+]|\d+[.)])(\s+)(\[([ xX])\])(\s*)$/;
128
128
  /**
129
129
  * Wrap the Markdown theme so task checkboxes read at a glance with the shared
130
130
  * `◼`/`◻` vocabulary, the same box the bytefly screen and the question dialog
131
- * draw. The replacement is one column wide instead of the bracket's three, so a
132
- * wrapped list continuation shifts left by two.
131
+ * draw. The box is one column wide instead of the bracket's three, so the two
132
+ * freed columns are kept as padding: pi-tui derives a continuation line's indent
133
+ * from the original marker's width, and shortening the marker without padding
134
+ * hangs every wrapped task line two columns too far right, where the width
135
+ * truncation clips its tail.
133
136
  */
134
137
  function planMarkdownTheme(theme: Theme, base: MarkdownTheme): MarkdownTheme {
135
138
  return {
@@ -138,7 +141,9 @@ function planMarkdownTheme(theme: Theme, base: MarkdownTheme): MarkdownTheme {
138
141
  const match = TASK_MARKER.exec(text);
139
142
  if (!match) return base.listBullet(text);
140
143
  const [, bullet, gap, , mark, trail] = match;
141
- return `${base.listBullet(`${bullet}${gap}`)}${checkbox(theme, mark.toLowerCase() === "x")}${trail}`;
144
+ const themed = `${base.listBullet(`${bullet}${gap}`)}${checkbox(theme, mark.toLowerCase() === "x")}`;
145
+ const pad = Math.max(0, visibleWidth(text) - visibleWidth(themed) - visibleWidth(trail));
146
+ return `${themed}${" ".repeat(pad)}${trail}`;
142
147
  },
143
148
  };
144
149
  }
@@ -205,10 +210,13 @@ export class PlanViewComponent implements Component {
205
210
  const base = this.options.markdownTheme ?? getMarkdownTheme();
206
211
  const theme = planMarkdownTheme(this.theme, base);
207
212
  const current = sanitizePlanText(this.options.plan.content);
213
+ // Both sides of the diff get the same normalization: the baseline is stored
214
+ // raw, so sanitizing only `current` would report a tab as a change on every
215
+ // review.
208
216
  const changed =
209
217
  this.options.previous === undefined
210
- ? new Set<string>()
211
- : changedSections(stripFeedback(this.options.previous), stripFeedback(current));
218
+ ? []
219
+ : changedSections(stripFeedback(sanitizePlanText(this.options.previous)), stripFeedback(current));
212
220
  const chunks = extractPlanSections(current);
213
221
  const lines: string[] = [];
214
222
  const sections: SectionOffset[] = [];
@@ -221,14 +229,14 @@ export class PlanViewComponent implements Component {
221
229
  sections.push({
222
230
  title: chunk.title,
223
231
  offset: lines.length,
224
- ...(changed.has(chunk.title) ? { changed: true } : {}),
232
+ ...(changed[index] ? { changed: true } : {}),
225
233
  });
226
234
  lines.push(...rendered);
227
235
  // Sections render independently, so restore the blank line a heading-only
228
236
  // chunk omits before the next section.
229
237
  if (index < chunks.length - 1 && lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
230
238
  });
231
- this.cache = { width, lines, sections, changedCount: chunks.filter((chunk) => changed.has(chunk.title)).length };
239
+ this.cache = { width, lines, sections, changedCount: changed.filter(Boolean).length };
232
240
  return this.cache;
233
241
  }
234
242
 
@@ -363,7 +371,7 @@ export class PlanViewComponent implements Component {
363
371
  const theme = this.theme;
364
372
  const w = Math.max(1, width);
365
373
  const plan = this.options.plan;
366
- const title = planTitle(basename(plan.path));
374
+ const title = sanitizeLine(planTitle(basename(plan.path)));
367
375
 
368
376
  const previousWidth = this.cache?.width;
369
377
  const built = plan.content.trim() ? this.build(w) : { width: w, lines: [], sections: [], changedCount: 0 };
@@ -2,6 +2,7 @@
2
2
  * Shared types for the plan-mode extension.
3
3
  */
4
4
 
5
+ import type { PlanAnnotation } from "./feedback.ts";
5
6
  import type { StepUpdate } from "./steps.ts";
6
7
 
7
8
  /** Persisted (custom entry) plan-mode state. */
@@ -62,6 +63,8 @@ export interface ExitPlanModeDetails {
62
63
  refined?: boolean;
63
64
  /** The refinement the user typed, when `refined` is true. */
64
65
  refinement?: string;
66
+ /** Comments the review carried when it approved or requested a refinement. */
67
+ annotations?: PlanAnnotation[];
65
68
  /** True when no interactive UI was available to ask for approval. */
66
69
  unavailable?: boolean;
67
70
  }