@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.
- package/README.md +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- 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
|
|
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
|
|
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
|
-
*
|
|
56
|
-
* session with
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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,
|
|
223
|
-
const goal = await finalizeGoal(ctx,
|
|
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 ${
|
|
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:
|
|
260
|
-
planPath:
|
|
261
|
-
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
|
|
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
|
|
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
|
|
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,
|
package/extensions/plan/tools.ts
CHANGED
|
@@ -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
|
|
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),
|
package/extensions/plan/tui.ts
CHANGED
|
@@ -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.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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,
|
|
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 =
|
|
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")
|
|
334
|
-
|
|
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(
|
|
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;
|
package/extensions/plan/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|