@gavin-hu/my-pi-agent 0.17.0 → 0.18.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 (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. package/extensions/wechat/qrcode.ts +0 -46
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * It presents the saved plan for approval, then seeds the approved steps into
5
5
  * the todo list and finalizes the session goal: a `planning` goal becomes the
6
- * plan's own `## Goal`, and a session with no goal is offered the same. The
6
+ * plan's own `## Goal`, and a session with no goal is given the same. The
7
7
  * review loop and the browser lifecycle belong to `review.ts`; the nested writes
8
8
  * belong to `todo-seed.ts` and the goal tool.
9
9
  */
@@ -12,6 +12,7 @@ import { basename } from "node:path";
12
12
  import type { ExtensionAPI, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
13
13
  import { Type, type Static } from "typebox";
14
14
  import { sanitizeLine } from "../../../lib/format.ts";
15
+ import { messageOf } from "../../../lib/errors.ts";
15
16
  import { lastGoal } from "../../../lib/goal-state.ts";
16
17
  import { GOAL_TOOL } from "../../../lib/tool-names.ts";
17
18
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
@@ -19,12 +20,13 @@ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
19
20
  import { paint } from "../../../lib/tui/tokens.ts";
20
21
  import { callLine, detailsOrLine, moreHint } from "../../../lib/tui/tool.ts";
21
22
  import { failedResult } from "../../../lib/tool-result.ts";
22
- import { loadConfig } from "../config.ts";
23
23
  import { feedbackSummary } from "../feedback.ts";
24
24
  import { mergeSummary, overCap, preview, verificationNote } from "../format.ts";
25
25
  import { sanitizePlanText } from "../plan-text.ts";
26
26
  import { planTitle, type StoredPlan } from "../plans.ts";
27
27
  import { reviewPlan } from "../review.ts";
28
+ import { decideSplit, rosterCard, SPLIT_OFF_NOTE } from "../split.ts";
29
+ import { writeTeam } from "../../../lib/plans/team.ts";
28
30
  import { EXIT_TOOL, SET_PLAN_STEPS_TOOL, type PlanRuntime } from "../runtime.ts";
29
31
  import { goalSection, MAX_STEPS, planStepSummary } from "../steps.ts";
30
32
  import type { ExitPlanModeDetails } from "../types.ts";
@@ -32,11 +34,27 @@ import type { PlanWebReview } from "../web.ts";
32
34
  import { firstTextLine } from "./nested.ts";
33
35
  import { seedTodos } from "./todo-seed.ts";
34
36
 
37
+ const PlanUnitParams = Type.Object({
38
+ name: Type.String({ description: "Lowercase slug; the owner session takes it as its label." }),
39
+ paths: Type.Array(Type.String(), { description: "Repository-relative path prefixes this unit owns." }),
40
+ test: Type.String({ description: "The command that proves the unit is done." }),
41
+ dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Units that must land first." })),
42
+ lead: Type.Optional(Type.Boolean({ description: "True for the one unit this session keeps." })),
43
+ });
44
+
35
45
  const ExitPlanModeParams = Type.Object({
36
46
  plan_path: Type.String({
37
47
  description:
38
- "Path to the plan file written by write_plan (absolute, or relative to the working directory). The user reviews this file.",
48
+ "Path to the plan file written by write_plan: the absolute path it returned, or the `~/…` form it displayed. A bare name resolves inside the plans directory. The user reviews this file.",
39
49
  }),
50
+ units: Type.Optional(
51
+ Type.Array(PlanUnitParams, {
52
+ description:
53
+ "The plan's slices, when it has two or more independent ones the user will run in separate sessions. " +
54
+ "Only used when the plan.split setting is on; validation refuses an overlapping, looping, or oversized " +
55
+ "split before the review opens. Omit it to approve as one flow.",
56
+ }),
57
+ ),
40
58
  });
41
59
 
42
60
  type ExitPlanModeArgs = Static<typeof ExitPlanModeParams>;
@@ -52,24 +70,16 @@ interface GoalOutcome {
52
70
  /**
53
71
  * Finalise the session goal from the approved plan.
54
72
  *
55
- * A `planning` goal needs no dialog — approving the plan *is* the decision — and a
56
- * session with no goal at all gets one confirm, unless `plan.goalPrompt` is off,
57
- * which records the objective without asking. A final goal (`active` or
58
- * `achieved`) is the user's own: approving ordinary work never rewrites it.
59
- * Recording runs through the goal tool, so validation and the widget stay goal's,
60
- * and no model turn is spent.
73
+ * Approving the plan *is* the decision, so neither a `planning` goal nor a
74
+ * session with none is asked again. A final goal (`active` or `achieved`) is the
75
+ * user's own: approving ordinary work never rewrites it. Recording runs through
76
+ * the goal tool, so validation and the widget stay goal's, and no model turn is
77
+ * spent.
61
78
  */
62
79
  async function finalizeGoal(ctx: ExtensionToolContext, plan: StoredPlan): Promise<GoalOutcome> {
63
80
  const objective = planObjective(plan);
64
81
  const current = lastGoal(ctx.sessionManager.getBranch());
65
82
  if (current !== null && current.status !== "planning") return { objective };
66
- if (current === null) {
67
- if (!ctx.hasUI) return { objective };
68
- if (loadConfig(ctx.cwd).goalPrompt) {
69
- const approved = await ctx.ui.confirm("Record this plan as the session goal?", objective);
70
- if (!approved) return { objective };
71
- }
72
- }
73
83
  const outcome = await ctx.executeTool(GOAL_TOOL, { objective, status: "active" });
74
84
  const action = current === null ? "recorded" : "promoted";
75
85
  return outcome.isError ? { objective, action, error: firstTextLine(outcome) } : { objective, action };
@@ -101,7 +111,8 @@ export function registerExitTool(pi: ExtensionAPI, runtime: PlanRuntime, web: Pl
101
111
  annotations: { readOnlyHint: true, openWorldHint: false },
102
112
 
103
113
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
104
- const planPath = (params as ExitPlanModeArgs).plan_path.trim();
114
+ const args = params as ExitPlanModeArgs;
115
+ const planPath = args.plan_path.trim();
105
116
 
106
117
  if (!runtime.isEnabled()) {
107
118
  return failedResult("Not in plan mode; there is no plan to approve.", {
@@ -134,6 +145,26 @@ export function registerExitTool(pi: ExtensionAPI, runtime: PlanRuntime, web: Pl
134
145
  );
135
146
  }
136
147
 
148
+ // The split is decided before the review opens, so a refused or rejected split
149
+ // never spends the user's review — and the screen can show what it approves.
150
+ const units = args.units?.map((unit) => ({
151
+ name: unit.name,
152
+ paths: [...unit.paths],
153
+ test: unit.test,
154
+ dependsOn: [...(unit.dependsOn ?? [])],
155
+ ...(unit.lead ? { lead: true } : {}),
156
+ }));
157
+ const split = decideSplit(ctx.cwd, await runtime.plans.rootFor(ctx.cwd), units);
158
+ if (split.kind === "refused") {
159
+ return failedResult(`Refused: ${split.reason}`, {
160
+ approved: false,
161
+ plan: file.content,
162
+ planPath: file.path,
163
+ relativePath: file.relativePath,
164
+ } satisfies ExitPlanModeDetails);
165
+ }
166
+ const pending = split.kind === "team" ? split : undefined;
167
+
137
168
  if (!ctx.hasUI) {
138
169
  return failedResult(
139
170
  `No interactive UI is available to approve the plan. It is saved at ${file.relativePath}; present it and ask the user to approve it in their reply.`,
@@ -156,6 +187,7 @@ export function registerExitTool(pi: ExtensionAPI, runtime: PlanRuntime, web: Pl
156
187
  baselines: runtime,
157
188
  plan: file,
158
189
  signal,
190
+ ...(pending === undefined ? {} : { split: { ...pending.team, verdict: pending.verdict } }),
159
191
  });
160
192
 
161
193
  if (decision.action === "refine") {
@@ -218,9 +250,33 @@ export function registerExitTool(pi: ExtensionAPI, runtime: PlanRuntime, web: Pl
218
250
  };
219
251
  }
220
252
 
253
+ // The team becomes real here, never before: the section is what a joining
254
+ // session reads, so it appears only after the user approved the split. The
255
+ // write happens while plan mode is still on, so a failure leaves the session
256
+ // able to retry instead of approved-but-empty.
257
+ let approved = decision.plan;
258
+ if (pending !== undefined) {
259
+ try {
260
+ approved = await runtime.plans.write(ctx.cwd, {
261
+ title: planTitle(basename(decision.plan.path)),
262
+ content: writeTeam(decision.plan.content, { lead: pending.team.lead, units: pending.team.units }),
263
+ planPath: decision.plan.path,
264
+ });
265
+ } catch (writeError) {
266
+ return failedResult(
267
+ `The plan was approved but its team could not be written: ${messageOf(writeError)}. Plan mode is still on and no steps were seeded; fix the plan file and call exit_plan_mode again.`,
268
+ {
269
+ approved: false,
270
+ plan: decision.plan.content,
271
+ planPath: decision.plan.path,
272
+ relativePath: decision.plan.relativePath,
273
+ } satisfies ExitPlanModeDetails,
274
+ );
275
+ }
276
+ }
221
277
  runtime.disable(ctx);
222
- const { steps, merged, error } = await seedTodos(pi, ctx, decision.plan.content);
223
- const goal = await finalizeGoal(ctx, decision.plan);
278
+ const { steps, merged, error } = await seedTodos(pi, ctx, approved.content);
279
+ const goal = await finalizeGoal(ctx, approved);
224
280
  const listing =
225
281
  steps.length > 0
226
282
  ? `\n\n${steps
@@ -247,19 +303,41 @@ export function registerExitTool(pi: ExtensionAPI, runtime: PlanRuntime, web: Pl
247
303
  const browserNote = decision.comments.length
248
304
  ? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(decision.comments)}`
249
305
  : "";
306
+ const splitNote =
307
+ pending === undefined
308
+ ? split.kind === "off"
309
+ ? `\n\n${SPLIT_OFF_NOTE}`
310
+ : ""
311
+ : `\n\n${rosterCard(pending.team, approved.path, pi.getSessionName())}\n\nWork only your own unit (${pending.team.lead}); the other units belong to the sessions the user starts.`;
250
312
  return {
251
313
  content: [
252
314
  {
253
315
  type: "text",
254
- 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}${verification}${goalNote}${listing}`,
316
+ text: `Plan approved. Plan mode is off and write access is restored. The plan file ${approved.relativePath} is the source of truth; follow its steps.${browserNote}${splitNote}${tail}${verification}${goalNote}${listing}`,
255
317
  },
256
318
  ],
257
319
  details: {
258
320
  approved: true,
259
- plan: decision.plan.content,
260
- planPath: decision.plan.path,
261
- relativePath: decision.plan.relativePath,
321
+ plan: approved.content,
322
+ planPath: approved.path,
323
+ relativePath: approved.relativePath,
262
324
  annotations: decision.comments.length > 0 ? decision.comments : undefined,
325
+ ...(pending === undefined
326
+ ? split.kind === "off"
327
+ ? { splitOff: true }
328
+ : {}
329
+ : {
330
+ team: {
331
+ lead: pending.team.lead,
332
+ verdict: pending.verdict,
333
+ units: pending.team.units.map((unit) => ({
334
+ name: unit.name,
335
+ paths: unit.paths,
336
+ test: unit.test,
337
+ dependsOn: unit.dependsOn,
338
+ })),
339
+ },
340
+ }),
263
341
  } satisfies ExitPlanModeDetails,
264
342
  };
265
343
  },
@@ -40,7 +40,7 @@ export function registerWriteTool(pi: ExtensionAPI, runtime: PlanRuntime): void
40
40
  name: WRITE_PLAN_TOOL,
41
41
  label: "Write plan",
42
42
  description:
43
- "Save the plan you designed as a markdown file under .pi/plans (project root, or the agent directory outside a " +
43
+ "Save the plan you designed as a markdown file in the plans directory (the agent directory, keyed by " +
44
44
  "repository). Write the complete plan, not a summary, in this outline so it is consistent and scannable:\n\n" +
45
45
  `${PLAN_OUTLINE}\n\n` +
46
46
  "Each section has one job: lead it with its outcome, keep a prose section to a few bullets or two or three " +
@@ -51,10 +51,11 @@ export function registerWriteTool(pi: ExtensionAPI, runtime: PlanRuntime): void
51
51
  "The outline is a guide, not a form: drop Context, Approach, Visual, or Risks when there is nothing to say. " +
52
52
  "Keep Steps and Verification — the steps are read from that section (without it, the shallowest list " +
53
53
  "anywhere becomes the steps), and Verification becomes the last todo item. " +
54
- "In Steps, each top-level bullet is one outcome in the order you will do them, and deeper bullets are notes. " +
54
+ "In Steps, each top-level bullet is one outcome in the order you will do them; " +
55
+ "an indented line continues that outcome, and a deeper bullet is a note. " +
55
56
  "Returns the path to pass to exit_plan_mode; plan_path overwrites an existing file when refining. " +
56
57
  `A plan with more than ${MAX_STEPS} top-level steps in its Steps section is refused, so approval never drops steps it cannot track.`,
57
- promptSnippet: "Save the designed plan as a markdown file under .pi/plans.",
58
+ promptSnippet: "Save the designed plan as a markdown file in the plans directory.",
58
59
  promptGuidelines: [
59
60
  "While plan mode is active, write the full plan with write_plan, then call exit_plan_mode with the returned path.",
60
61
  "When refining with plan_path, keep every section heading as it was: the review marks changed sections by heading, " +
@@ -88,6 +89,10 @@ export function registerWriteTool(pi: ExtensionAPI, runtime: PlanRuntime): void
88
89
  planPath: args.plan_path,
89
90
  });
90
91
  runtime.setLastPlan(ctx, file.path);
92
+ // A store an older version kept in the checkout was moved in by this
93
+ // write; say so once, where the user is looking.
94
+ const moved = runtime.plans.takeNotice();
95
+ if (moved) ctx.ui.notify(moved, "info");
91
96
  return textResult(
92
97
  `Wrote the plan to ${file.relativePath}. Call exit_plan_mode with plan_path: ${file.path}${stepElisionNote(summary.elided)}`,
93
98
  { path: file.path, relativePath: file.relativePath, bytes: file.bytes } satisfies WritePlanDetails,
@@ -2,7 +2,7 @@
2
2
  * Model-facing registration for the plan-mode tools.
3
3
  *
4
4
  * `enter_plan_mode` lets the model ask to plan before touching code; it needs
5
- * user confirmation. `write_plan` saves the plan artifact under `.pi/plans` and
5
+ * user confirmation. `write_plan` saves the plan artifact in the plans directory and
6
6
  * returns its path. `cancel_plan_mode` leaves planning without approving, so a
7
7
  * phone user is never trapped in read-only mode. `exit_plan_mode` reads that file
8
8
  * back, presents it for approval (a scrollable review screen in the TUI, a select dialog elsewhere),
@@ -6,9 +6,11 @@
6
6
  * the active Markdown theme, so headings, lists, code, links, and quotes match
7
7
  * the rest of the application; plan-authored control characters are stripped
8
8
  * first so the file cannot drive the terminal. Task checkboxes are recolored
9
- * and the footer colors the primary actions. It returns the chosen action
10
- * through `onClose` so the caller can act
11
- * only after the screen is gone and input focus is free again.
9
+ * and the footer colors the primary actions. Approving leaves plan mode and
10
+ * starts the work, so it asks `y/N` first through the shared `ConfirmState`;
11
+ * the plan stays mounted, and `Esc` backs out of the question before it backs
12
+ * out of the review. It returns the chosen action through `onClose` so the
13
+ * caller can act only after the screen is gone and input focus is free again.
12
14
  */
13
15
 
14
16
  import { basename } from "node:path";
@@ -27,6 +29,7 @@ import {
27
29
  type TuiMouseEventResult,
28
30
  } from "@earendil-works/pi-tui";
29
31
  import { sanitizeLine } from "../../lib/format.ts";
32
+ import { renderTeam, type PlanUnit } from "../../lib/plans/team.ts";
30
33
  import {
31
34
  editorTheme,
32
35
  screenHint,
@@ -35,13 +38,14 @@ import {
35
38
  type ViewportRowsSource,
36
39
  } from "../../lib/tui/chrome.ts";
37
40
  import { isCloseKey } from "../../lib/tui/cursor.ts";
41
+ import { ConfirmState } from "../../lib/tui/confirm.ts";
38
42
  import { gaugeBar } from "../../lib/tui/gauge.ts";
39
43
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
40
44
  import { plainRule, titleRule } from "../../lib/tui/rules.ts";
41
45
  import { emptyState, checkbox } from "../../lib/tui/rows.ts";
42
46
  import { paint } from "../../lib/tui/tokens.ts";
43
47
  import { planTitle, type StoredPlan } from "./plans.ts";
44
- import { changedSections, sanitizePlanText, type SectionDigest } from "./plan-text.ts";
48
+ import { changedSections, planDisplayText, type SectionDigest } from "./plan-text.ts";
45
49
  import { extractPlanSections } from "./steps.ts";
46
50
 
47
51
  /** What the user chose from the read/review screen. */
@@ -83,6 +87,11 @@ export interface PlanViewOptions {
83
87
  tui?: TUI;
84
88
  /** Markdown theme for the body; defaults to Pi's active theme. */
85
89
  markdownTheme?: MarkdownTheme;
90
+ /**
91
+ * The split approval would write. Rendered as a read-only section after the
92
+ * plan, because the `## Team` section itself only exists after approval.
93
+ */
94
+ split?: { lead: string; units: readonly PlanUnit[]; verdict: "pair" | "team" };
86
95
  }
87
96
 
88
97
  /** Rows the screen shows when the terminal height is unknown. */
@@ -175,6 +184,8 @@ export class PlanViewComponent implements Component {
175
184
  private cache: BodyCache | null = null;
176
185
  /** True while the inline refine editor owns the keyboard. */
177
186
  private editing = false;
187
+ /** The y/N approval question, shared with the other destructive screens. */
188
+ private readonly confirm = new ConfirmState();
178
189
  /** The inline refine editor, built lazily once a TUI handle is available. */
179
190
  private editor: Editor | null = null;
180
191
 
@@ -195,7 +206,7 @@ export class PlanViewComponent implements Component {
195
206
  const padding = width >= MIN_PADDED_WIDTH ? BODY_PADDING : 0;
196
207
  const base = this.options.markdownTheme ?? getMarkdownTheme();
197
208
  const theme = planMarkdownTheme(this.theme, base);
198
- const current = sanitizePlanText(this.options.plan.content);
209
+ const current = planDisplayText(this.options.plan.content);
199
210
  const changed = changedSections(this.options.baseline, current);
200
211
  const chunks = extractPlanSections(current);
201
212
  const lines: string[] = [];
@@ -216,10 +227,31 @@ export class PlanViewComponent implements Component {
216
227
  // chunk omits before the next section.
217
228
  if (index < chunks.length - 1 && lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
218
229
  });
230
+ const split = this.splitSection();
231
+ if (split !== undefined) {
232
+ if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
233
+ const rendered = new Markdown(split, padding, 0, theme)
234
+ .render(width)
235
+ .map((line) => truncateToWidth(line.trimEnd(), width));
236
+ sections.push({ title: "Split", offset: lines.length });
237
+ lines.push(...rendered);
238
+ }
219
239
  this.cache = { width, lines, sections, changedCount: changed.filter(Boolean).length };
220
240
  return this.cache;
221
241
  }
222
242
 
243
+ /**
244
+ * The split as a read-only section: the same table approval writes, so what the
245
+ * user approves is what the plan file will carry.
246
+ */
247
+ private splitSection(): string | undefined {
248
+ const split = this.options.split;
249
+ if (split === undefined) return undefined;
250
+ const owners = split.units.filter((unit) => !unit.lead).length;
251
+ const summary = `${split.verdict} · lead + ${owners} owner${owners === 1 ? "" : "s"} · written on approval`;
252
+ return ["## Split", "", summary, "", renderTeam({ lead: split.lead, units: [...split.units] })].join("\n");
253
+ }
254
+
223
255
  /** The section whose heading is at or above the viewport top. */
224
256
  private currentSection(sections: SectionOffset[]): SectionOffset | undefined {
225
257
  let current: SectionOffset | undefined;
@@ -314,6 +346,12 @@ export class PlanViewComponent implements Component {
314
346
  this.options.requestRender();
315
347
  return;
316
348
  }
349
+ // A pending question owns the keyboard, so a scroll key cannot slip past it
350
+ // and `Esc` backs out of the question rather than the review.
351
+ if (this.confirm.pending) {
352
+ if (this.confirm.resolve(data) !== undefined) this.options.requestRender();
353
+ return;
354
+ }
317
355
  if (isCloseKey(data)) {
318
356
  this.options.onClose("keep");
319
357
  return;
@@ -330,8 +368,10 @@ export class PlanViewComponent implements Component {
330
368
  else if (matchesKey(data, Key.end) || data === "G") this.scrollTo(this.maxOffset);
331
369
  else if (data === "n") this.jumpSection(1);
332
370
  else if (data === "p") this.jumpSection(-1);
333
- else if (data === "a") this.options.onClose("approve");
334
- else if (data === "r") this.startRefine();
371
+ else if (data === "a") {
372
+ this.confirm.ask("Approve and run the plan? y/N", () => this.options.onClose("approve"));
373
+ this.options.requestRender();
374
+ } else if (data === "r") this.startRefine();
335
375
  else if (data === "w") this.options.onClose("browser");
336
376
  else if (data === "x") this.options.onClose("cancel");
337
377
  }
@@ -400,10 +440,13 @@ export class PlanViewComponent implements Component {
400
440
  } else {
401
441
  if (this.maxOffset > 0) lines.push(scrollRow(theme, w, this.offset, end, body.length, sectionLabel));
402
442
  else lines.push("");
443
+ const pending = this.confirm.pending;
403
444
  const sectionHints = sections.length > 1 ? SECTION_HINTS : [];
404
445
  const hints: ScreenHint[] =
405
446
  this.maxOffset > 0 ? [...PRIMARY_HINTS, ...SCROLL_HINTS, ...sectionHints] : PRIMARY_HINTS;
406
- lines.push(screenHint(theme, w, hints));
447
+ lines.push(
448
+ pending ? truncateToWidth(` ${paint(theme, "warn", pending.prompt)}`, w) : screenHint(theme, w, hints),
449
+ );
407
450
  }
408
451
  lines.push(plainRule(theme, w));
409
452
  return lines;
@@ -6,6 +6,32 @@ import type { PlanAnnotation } from "./feedback.ts";
6
6
  import type { SectionDigest } from "./plan-text.ts";
7
7
  import type { StepUpdate } from "./steps.ts";
8
8
 
9
+ /**
10
+ * Who is looking at a running review: the session that opened it (`owner`, the
11
+ * only role that can approve), or someone the link was shared with (`reviewer`,
12
+ * who may comment).
13
+ */
14
+ export type ReviewRole = "owner" | "reviewer";
15
+
16
+ /**
17
+ * What a review token names: its role, and — for a reviewer — the name it posts
18
+ * under, bound the first time that link writes.
19
+ */
20
+ export interface ReviewCaller {
21
+ role: ReviewRole;
22
+ /** The reviewer's name, once this link has written; absent for the owner and
23
+ * for a reviewer that has not commented yet. */
24
+ name?: string;
25
+ }
26
+
27
+ /**
28
+ * One frame on the review's event stream. `comment`, `plan`, and `share` say a
29
+ * page should re-read the island; `closed` ends the review for everyone, and
30
+ * `revoked` tells a reviewer the owner stopped sharing. The frame carries no
31
+ * state of its own, so the island stays the one contract.
32
+ */
33
+ export type ReviewFrame = "comment" | "plan" | "share" | "closed" | "revoked";
34
+
9
35
  /** Persisted (custom entry) plan-mode state. */
10
36
  export interface PlanModeEntry {
11
37
  enabled: boolean;
@@ -38,7 +64,7 @@ export interface EnterPlanModeDetails {
38
64
  export interface WritePlanDetails {
39
65
  /** Absolute path of the written plan file. */
40
66
  path: string;
41
- /** Path relative to the working directory, when inside it. */
67
+ /** How the path is shown: `~`-shortened, since plans live outside the project. */
42
68
  relativePath: string;
43
69
  /** Size of the written plan in bytes. */
44
70
  bytes: number;
@@ -48,7 +74,7 @@ export interface WritePlanDetails {
48
74
  export interface SetPlanStepsDetails {
49
75
  /** Absolute path of the plan file that was updated. */
50
76
  path: string;
51
- /** Path relative to the working directory, for transcript display. */
77
+ /** How the path is shown: `~`-shortened. */
52
78
  relativePath: string;
53
79
  /** The checkbox changes that were applied, in call order. */
54
80
  updates: StepUpdate[];
@@ -66,7 +92,7 @@ export interface ExitPlanModeDetails {
66
92
  plan: string;
67
93
  /** Absolute path of the reviewed plan file. */
68
94
  planPath?: string;
69
- /** Path relative to the working directory, for transcript display. */
95
+ /** How the path is shown: `~`-shortened. */
70
96
  relativePath?: string;
71
97
  /** True when the user asked to refine the plan instead of approving. */
72
98
  refined?: boolean;
@@ -78,6 +104,14 @@ export interface ExitPlanModeDetails {
78
104
  cancelled?: boolean;
79
105
  /** True when no interactive UI was available to ask for approval. */
80
106
  unavailable?: boolean;
107
+ /** The team approval wrote into the plan, when the plan was split. */
108
+ team?: {
109
+ lead: string;
110
+ units: Array<{ name: string; paths: string[]; test: string; dependsOn: string[] }>;
111
+ verdict: "pair" | "team";
112
+ };
113
+ /** True when the model proposed a split while `plan.split` was off. */
114
+ splitOff?: boolean;
81
115
  }
82
116
 
83
117
  /** Structured result carried in `cancel_plan_mode`'s `details`. */
@@ -43,7 +43,7 @@ export const WEB_CHROME_JS = String.raw` function setStatus(text, kind) {
43
43
  var count = annotations.length;
44
44
  label += " \u00b7 " + count + (count === 1 ? " comment" : " comments");
45
45
  if (dirty && !saved && !approved && !closed) {
46
- label += " \u00b7 unsaved";
46
+ label += " \u00b7 " + (role === "reviewer" ? "unposted" : "unsaved");
47
47
  if (!tone) tone = "warn";
48
48
  }
49
49
  stateEl.textContent = label;
@@ -76,6 +76,7 @@ export const WEB_CHROME_JS = String.raw` function setStatus(text, kind) {
76
76
  function lockActions() {
77
77
  if (approveButton) approveButton.disabled = true;
78
78
  if (saveButton) saveButton.disabled = true;
79
+ if (postButton) postButton.disabled = true;
79
80
  if (cancelButton) cancelButton.disabled = true;
80
81
  }
81
82