@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.
- package/README.md +13 -8
- package/README.zh-CN.md +5 -3
- package/assets/chrome.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +291 -207
- package/extensions/bytefly/doctor/runtime.ts +16 -0
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/segments.ts +3 -2
- package/extensions/bytefly/footer/types.ts +1 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +13 -3
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +201 -38
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +32 -0
- package/extensions/bytefly/update/register.ts +72 -0
- package/extensions/bytefly/update/state.ts +28 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +2 -2
- package/extensions/document/tool.ts +1 -3
- package/extensions/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +39 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/memory/README.md +1 -1
- package/extensions/plan/README.md +24 -8
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +18 -12
- package/extensions/plan/plans.ts +1 -1
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +243 -0
- package/extensions/plan/runtime.ts +4 -0
- package/extensions/plan/steps.ts +34 -23
- package/extensions/plan/todos.ts +5 -58
- package/extensions/plan/tools.ts +66 -222
- package/extensions/plan/tui.ts +17 -9
- package/extensions/plan/types.ts +3 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +5 -0
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +100 -36
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- package/extensions/subagent/index.ts +0 -2
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/fetch/tool.ts +1 -3
- package/extensions/web-access/search/tool.ts +1 -3
- package/extensions/wechat/README.md +5 -4
- package/extensions/wechat/tools.ts +0 -2
- package/lib/README.md +2 -1
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/requirements.ts +76 -2
- package/lib/todo-state.ts +62 -0
- package/lib/tui/glyphs.ts +1 -0
- package/package.json +1 -1
- package/assets/bytefly.schema.json +0 -785
package/extensions/plan/steps.ts
CHANGED
|
@@ -28,7 +28,7 @@ export const PLAN_OUTLINE = [
|
|
|
28
28
|
"# <Title>",
|
|
29
29
|
"",
|
|
30
30
|
"## Goal",
|
|
31
|
-
"What this change achieves,
|
|
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
|
-
"- [ ]
|
|
41
|
-
"- [ ]
|
|
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
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/extensions/plan/todos.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
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();
|
package/extensions/plan/tools.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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,
|
|
43
|
-
import {
|
|
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
|
|
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,
|
|
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,
|
|
370
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
468
371
|
|
|
469
|
-
async execute(_toolCallId, params,
|
|
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
|
-
|
|
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
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
runtime
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
};
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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,
|
|
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 =
|
|
639
|
-
? `\n\nThe user approved from the browser review with these comments:\n${feedbackSummary(
|
|
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 ${
|
|
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:
|
|
651
|
-
planPath:
|
|
652
|
-
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
|
},
|
package/extensions/plan/tui.ts
CHANGED
|
@@ -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
|
|
132
|
-
*
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
|
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:
|
|
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 };
|
package/extensions/plan/types.ts
CHANGED
|
@@ -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
|
}
|