@gavin-hu/my-pi-agent 0.7.0 → 0.8.1

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 (34) hide show
  1. package/README.md +10 -10
  2. package/README.zh-CN.md +153 -0
  3. package/extensions/bytefly/README.md +129 -122
  4. package/extensions/bytefly/doctor/format.ts +3 -22
  5. package/extensions/bytefly/doctor/register.ts +10 -8
  6. package/extensions/bytefly/footer/register.ts +17 -91
  7. package/extensions/bytefly/header/component.ts +8 -4
  8. package/extensions/bytefly/header/config.ts +11 -6
  9. package/extensions/bytefly/header/register.ts +7 -27
  10. package/extensions/bytefly/index.ts +15 -14
  11. package/extensions/bytefly/panel/commands.ts +147 -0
  12. package/extensions/bytefly/panel/format.ts +135 -0
  13. package/extensions/bytefly/panel/join.ts +103 -0
  14. package/extensions/bytefly/panel/tui.ts +416 -0
  15. package/extensions/bytefly/panel/types.ts +44 -0
  16. package/extensions/bytefly/picker/resources.ts +0 -11
  17. package/extensions/file-browse/README.md +20 -7
  18. package/extensions/file-browse/html.ts +29 -6
  19. package/extensions/file-browse/markdown.ts +288 -46
  20. package/extensions/plan/README.md +16 -12
  21. package/extensions/plan/plans.ts +16 -17
  22. package/extensions/plan/runtime.ts +1 -1
  23. package/extensions/plan/steps.ts +31 -19
  24. package/extensions/plan/tools.ts +62 -32
  25. package/extensions/plan/types.ts +6 -6
  26. package/lib/README.md +1 -1
  27. package/lib/requirements.ts +3 -3
  28. package/lib/tui.ts +7 -8
  29. package/lib/web/components.ts +11 -0
  30. package/package.json +2 -2
  31. package/extensions/bytefly/doctor/commands.ts +0 -68
  32. package/extensions/bytefly/doctor/tui.ts +0 -140
  33. package/extensions/bytefly/picker/commands.ts +0 -73
  34. package/extensions/bytefly/picker/tui.ts +0 -260
@@ -42,8 +42,9 @@ pi --plan # start in plan mode
42
42
  them into `todo`.
43
43
  - **A navigable review.** The review screen splits the body at its headings,
44
44
  names the section you are in, and jumps between sections with `n`/`p`.
45
- - **Kept current.** After approval, `set_plan_step` flips a step's checkbox in
46
- the file, so the plan mirrors what has actually been done.
45
+ - **Kept current.** After approval, `set_plan_steps` flips the checkboxes of
46
+ the steps you name in the file, so the plan mirrors what has actually been
47
+ done.
47
48
  - **Reviewable plan.** `exit_plan_mode` reads the plan file back and presents
48
49
  it for approval. The user approves, keeps planning, or asks for a refinement.
49
50
  - **Browser review on demand.** Press `w` on the review screen (or run
@@ -68,7 +69,7 @@ pi --plan # start in plan mode
68
69
  | `enter_plan_mode` | normal mode | Ask to enter plan mode; empty schema, confirmed with `ctx.ui.confirm`. |
69
70
  | `write_plan` | plan mode | Save the plan: `title` (file-name slug), `content` (full markdown in the documented outline), optional `plan_path` to overwrite a refinement. Returns the absolute path. |
70
71
  | `exit_plan_mode` | plan mode | Read the plan file (`plan_path`) and present it for approval. |
71
- | `set_plan_step` | normal mode | Keep an approved plan current: set one top-level step (`step`, 1-based, in the order `exit_plan_mode` listed) to `pending` or `completed`, in place. Only the checkbox changes. |
72
+ | `set_plan_steps` | normal mode | Keep an approved plan current: set the checkboxes of the given top-level steps (`updates`, each a 1-based `step` in the order `exit_plan_mode` listed, plus `status`) to `pending` or `completed` in one write. Only the checkboxes change, and a reopened step drops its `[DONE:n]` marker. |
72
73
 
73
74
  ### Commands and flags
74
75
 
@@ -249,6 +250,8 @@ agent file that overrides it.
249
250
 
250
251
  ## Browser review
251
252
 
253
+ <img src="../../assets/plan-review.png" alt="The browser-based plan review, showing the plan's sections, a rendered code block, and an inline comment box" width="100%">
254
+
252
255
  Press `w` on the review screen — or run `/plan review [path]` — to open the plan
253
256
  in the default browser on `127.0.0.1`. The page is a review surface: a sticky
254
257
  header with the plan title, theme toggle, and Save/Close; a sticky **section
@@ -314,7 +317,7 @@ returns them to the model.
314
317
 
315
318
  - **Not a sandbox.** Extensions share Pi's OS permissions.
316
319
  - **No progress tracker of record.** `todo` owns completion state; the plan
317
- file only mirrors it (`set_plan_step`), and plan mode does not read completion
320
+ file only mirrors it (`set_plan_steps`), and plan mode does not read completion
318
321
  back out.
319
322
  - **No auto-execution.** Approval hands control back to the model.
320
323
 
@@ -322,7 +325,7 @@ returns them to the model.
322
325
 
323
326
  | Integration point | How |
324
327
  |---|---|
325
- | Tools | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_step` registered with `executionMode: "sequential"`. `enter_plan_mode` is active normally with `readOnlyHint: true`; `write_plan` (`readOnlyHint: false`) and `exit_plan_mode` (`readOnlyHint: true`) are `defaultActive: false`. `set_plan_step` is active normally (`readOnlyHint: false`, `idempotentHint: true`); the read-only policy default-denies it in plan mode. Each returns typed `details` (`EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepDetails`); no `outputSchema`/`structuredContent`. |
328
+ | Tools | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps` registered with `executionMode: "sequential"`. `enter_plan_mode` is active normally with `readOnlyHint: true`; `write_plan` (`readOnlyHint: false`) and `exit_plan_mode` (`readOnlyHint: true`) are `defaultActive: false`. `set_plan_steps` is active normally (`readOnlyHint: false`, `idempotentHint: true`); the read-only policy default-denies it in plan mode. Each returns typed `details` (`EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepsDetails`); no `outputSchema`/`structuredContent`. |
326
329
  | Commands and flags | `/plan` (toggle, or enter and send a task), `/plan review [path]` (browser review), `--plan` launch flag, `Ctrl+Alt+P` shortcut. |
327
330
  | State | Enabled flag and the tools it hid persisted with `pi.appendEntry("plan-mode", { enabled, removed })` and rebuilt from `ctx.sessionManager.getBranch()`; the last plan path lives in runtime memory for the footer chip. |
328
331
  | Hooks | `tool_call` blocks non-read-only calls and forces `subagent` read-only; `before_agent_start` injects the hidden `[PLAN MODE ACTIVE]` context; `context` keeps only the newest plan context while enabled and drops stale ones otherwise. |
@@ -360,10 +363,11 @@ returns them to the model.
360
363
  source-line map, so `tui.ts` splits the body at its headings and renders each
361
364
  section separately. That gives exact offsets for the current-section label and
362
365
  `n`/`p`; the single-section case is byte-for-byte the old render.
363
- - **`set_plan_step` is a second contained write.** It changes only a step's
364
- checkbox, in an existing markdown plan inside the plans directory
365
- (`isContainedPlan`), and refuses while plan mode is on — so the read-only
366
- guarantee and the path backstop are unchanged.
366
+ - **`set_plan_steps` is a second contained write.** It changes only the named
367
+ steps' checkboxes, in an existing markdown plan inside the plans directory
368
+ (`isContainedPlan`), in a single all-or-nothing write — refusing every entry
369
+ if any step is out of range — and refuses while plan mode is on, so the
370
+ read-only guarantee and the path backstop are unchanged.
367
371
  - **Direction: enforce capabilities at the tool boundary.** (1) A shared
368
372
  default-deny policy classifies every call as read-only or mutating instead of
369
373
  maintaining a mutator list. (2) Read-only delegation is enforced in the child
@@ -394,13 +398,13 @@ returns them to the model.
394
398
  | File | Responsibility |
395
399
  |---|---|
396
400
  | `index.ts` | Wiring: tools, command, flag, shortcut, events, context injection. |
397
- | `types.ts` | `PlanModeEntry`, `EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepDetails`. |
401
+ | `types.ts` | `PlanModeEntry`, `EnterPlanModeDetails`, `WritePlanDetails`, `ExitPlanModeDetails`, `SetPlanStepsDetails`. |
398
402
  | `policy.ts` | Plan mode's read-only policy, the shared prompt summary, and the bash-refusal guidance. |
399
403
  | `git-bash.ts` | `checkReadOnlyGit`: the pure allowlist that restricts `bash` to read-only git commands. |
400
- | `plans.ts` | Plan-file store: slug/stamp naming, directory resolution, containment, write/read/`setStep`/`saveFeedback`. |
404
+ | `plans.ts` | Plan-file store: slug/stamp naming, directory resolution, containment, write/read/`setSteps`/`saveFeedback`. |
401
405
  | `steps.ts` | Plan-document structure (pure): the outline, step extraction, heading sections, and the checkbox edit. |
402
406
  | `runtime.ts` | Enabled state, tool gating, persistence, control-tool identity, footer status. |
403
- | `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_step`. |
407
+ | `tools.ts` | `enter_plan_mode`, `write_plan`, `exit_plan_mode`, and `set_plan_steps`. |
404
408
  | `commands.ts` | `/plan` (enter, optional task) and the `/plan review [path]` browser subcommand. |
405
409
  | `tui.ts` | `PlanViewComponent` (sectioned Markdown body, section jump, scroll gauge, approve/refine/browser/keep). |
406
410
  | `blocks.ts` | Source-line block model (fence-aware headings, paragraphs, list items, code, quotes, blanks). |
@@ -20,7 +20,7 @@ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works
20
20
  import { repoRootFor } from "../../lib/git/index.ts";
21
21
  import { isInside, isInsideReal } from "../../lib/path.ts";
22
22
  import { formatFeedback, type PlanAnnotation } from "./feedback.ts";
23
- import { extractPlanSteps, setStepStatus, type PlanStep } from "./steps.ts";
23
+ import { extractPlanSteps, setStepStatuses, type StepUpdate } from "./steps.ts";
24
24
 
25
25
  /** Largest plan accepted, in UTF-8 bytes. */
26
26
  export const MAX_PLAN_BYTES = 256 * 1024;
@@ -57,8 +57,8 @@ export interface PlanStore {
57
57
  write(cwd: string, input: WritePlanInput): Promise<WrittenPlan>;
58
58
  /** Read a plan inside the plans directory, or undefined when missing/outside. */
59
59
  read(cwd: string, planPath: string): Promise<StoredPlan | undefined>;
60
- /** Set a top-level step's checkbox in an existing plan. Throws when the file or step is invalid. */
61
- setStep(cwd: string, planPath: string, step: number, status: PlanStep["status"]): Promise<StoredPlan>;
60
+ /** Set the given top-level steps' checkboxes in an existing plan. Throws when the file or any step is invalid. */
61
+ setSteps(cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan>;
62
62
  /** Replace the plan's stored browser feedback. Throws when the file is missing or outside the plans directory. */
63
63
  saveFeedback(cwd: string, planPath: string, annotations: PlanAnnotation[]): Promise<StoredPlan>;
64
64
  }
@@ -222,26 +222,25 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
222
222
  }
223
223
  };
224
224
 
225
- const setStep = async (
226
- cwd: string,
227
- planPath: string,
228
- step: number,
229
- status: PlanStep["status"],
230
- ): Promise<StoredPlan> => {
225
+ const setSteps = async (cwd: string, planPath: string, updates: StepUpdate[]): Promise<StoredPlan> => {
231
226
  const plan = await read(cwd, planPath);
232
227
  if (!plan) throw new Error("No plan file there; pass a plan_path written by write_plan.");
233
- const updated = setStepStatus(plan.content, step, status);
234
- if (!updated) {
235
- const total = extractPlanSteps(plan.content).length;
228
+ const total = extractPlanSteps(plan.content).length;
229
+ const outOfRange = updates.find(
230
+ (update) => !Number.isInteger(update.step) || update.step < 1 || update.step > total,
231
+ );
232
+ if (outOfRange) {
236
233
  const range = total === 0 ? "it has no steps" : `valid steps are 1 to ${total}`;
237
- throw new Error(`Step ${step} is out of range; ${range}.`);
234
+ throw new Error(`Step ${outOfRange.step} is out of range; ${range}.`);
238
235
  }
239
- writeFileSync(plan.path, updated.plan, "utf-8");
236
+ const updated = setStepStatuses(plan.content, updates);
237
+ if (!updated) throw new Error("Could not update the plan steps; pass steps from the plan file.");
238
+ writeFileSync(plan.path, updated, "utf-8");
240
239
  return {
241
240
  path: plan.path,
242
241
  relativePath: plan.relativePath,
243
- content: updated.plan,
244
- bytes: Buffer.byteLength(updated.plan, "utf-8"),
242
+ content: updated,
243
+ bytes: Buffer.byteLength(updated, "utf-8"),
245
244
  };
246
245
  };
247
246
 
@@ -263,5 +262,5 @@ export function createPlanStore(pi: ExtensionAPI, options: { now?: () => Date }
263
262
  };
264
263
  };
265
264
 
266
- return { dirFor, write, read, setStep, saveFeedback };
265
+ return { dirFor, write, read, setSteps, saveFeedback };
267
266
  }
@@ -31,7 +31,7 @@ export const ENTER_TOOL = "enter_plan_mode";
31
31
  /** Tool the model calls to save the plan artifact. */
32
32
  export const WRITE_PLAN_TOOL = "write_plan";
33
33
  /** Tool the model calls to keep an approved plan's steps current. */
34
- export const SET_PLAN_STEP_TOOL = "set_plan_step";
34
+ export const SET_PLAN_STEPS_TOOL = "set_plan_steps";
35
35
  /** Custom-entry type used for persistence. */
36
36
  export const STATE_TYPE = "plan-mode";
37
37
 
@@ -166,28 +166,40 @@ export function extractPlanSteps(plan: string): PlanStep[] {
166
166
  return topLevelItems(plan).map((item) => ({ content: elide(item.step.content), status: item.step.status }));
167
167
  }
168
168
 
169
+ /** One checkbox change: a 1-based step number and the state to set. */
170
+ export interface StepUpdate {
171
+ step: number;
172
+ status: PlanStep["status"];
173
+ }
174
+
169
175
  /**
170
- * Set the checkbox of the `step`th top-level step (1-based, matching
171
- * {@link extractPlanSteps}) and return the rewritten plan with the updated step,
172
- * or undefined when the step number is out of range. Only the marker changes: a
173
- * plain `1.`/`-` item gains a checkbox, an existing one is set, and the step
174
- * text is left exactly as written.
176
+ * Apply a batch of checkbox changes to the given top-level steps (1-based,
177
+ * matching {@link extractPlanSteps}) and return the rewritten plan, or
178
+ * undefined when any step number is out of range. All-or-nothing: every step is
179
+ * validated before any edit, so one bad entry changes nothing. Only the marker
180
+ * changes: a plain `1.`/`-` item gains a checkbox, an existing one is set, a
181
+ * `[DONE:n]` marker is dropped when the step is reopened, and the step text is
182
+ * otherwise left exactly as written. When a step appears twice, the last entry
183
+ * wins.
175
184
  */
176
- export function setStepStatus(
177
- plan: string,
178
- step: number,
179
- status: "pending" | "completed",
180
- ): { plan: string; step: PlanStep } | undefined {
181
- if (!Number.isInteger(step) || step < 1) return undefined;
182
- const target = topLevelItems(plan)[step - 1];
183
- if (!target) return undefined;
184
-
185
- const existing = target.raw.match(CHECKBOX);
186
- const text = existing ? existing[2] : target.raw;
187
- const checkbox = status === "completed" ? "[x]" : "[ ]";
185
+ export function setStepStatuses(plan: string, updates: StepUpdate[]): string | undefined {
186
+ const items = topLevelItems(plan);
187
+ for (const update of updates) {
188
+ if (!Number.isInteger(update.step) || update.step < 1 || !items[update.step - 1]) return undefined;
189
+ }
190
+
188
191
  const lines = plan.split("\n");
189
- lines[target.line] = `${" ".repeat(target.indent)}${target.lead}${checkbox} ${text}`;
190
- return { plan: lines.join("\n"), step: parseStep(`${checkbox} ${text}`) };
192
+ for (const update of updates) {
193
+ const target = items[update.step - 1];
194
+ const existing = target.raw.match(CHECKBOX);
195
+ let text = existing ? existing[2] : target.raw;
196
+ // A reopened step must lose its marker too: the marker alone reads as
197
+ // completed, so keeping it would contradict the checkbox.
198
+ if (update.status === "pending") text = text.replace(DONE_MARKER, "").trimEnd();
199
+ const checkbox = update.status === "completed" ? "[x]" : "[ ]";
200
+ lines[target.line] = `${" ".repeat(target.indent)}${target.lead}${checkbox} ${text}`;
201
+ }
202
+ return lines.join("\n");
191
203
  }
192
204
 
193
205
  /**
@@ -7,7 +7,7 @@
7
7
  * approval (a scrollable review screen in the TUI, a select dialog elsewhere),
8
8
  * and on approval seeds the `todo` tool with the plan's steps (through
9
9
  * `ctx.executeTool`, so the todo widget and validation run normally).
10
- * `set_plan_step` then keeps the file current: after approval the model ticks
10
+ * `set_plan_steps` then keeps the file current: after approval the model ticks
11
11
  * each step off in place as it finishes it. The file is the source of truth:
12
12
  * the model executes from it.
13
13
  *
@@ -26,10 +26,10 @@ import { FULL_SCREEN_OVERLAY, reuseText, toolDetails } from "../../lib/tui.ts";
26
26
  import { expandHint } from "../../lib/ui.ts";
27
27
  import { READ_ONLY_SUMMARY } from "./policy.ts";
28
28
  import { feedbackSummary } from "./feedback.ts";
29
- import { ENTER_TOOL, EXIT_TOOL, SET_PLAN_STEP_TOOL, WRITE_PLAN_TOOL, type PlanRuntime } from "./runtime.ts";
30
- import { PLAN_OUTLINE, extractPlanSteps, type PlanStep } from "./steps.ts";
29
+ import { ENTER_TOOL, EXIT_TOOL, SET_PLAN_STEPS_TOOL, WRITE_PLAN_TOOL, type PlanRuntime } from "./runtime.ts";
30
+ import { PLAN_OUTLINE, extractPlanSteps, type PlanStep, type StepUpdate } from "./steps.ts";
31
31
  import { PlanViewComponent, sanitizePlanText, type PlanViewAction } from "./tui.ts";
32
- import type { EnterPlanModeDetails, ExitPlanModeDetails, SetPlanStepDetails, WritePlanDetails } from "./types.ts";
32
+ import type { EnterPlanModeDetails, ExitPlanModeDetails, SetPlanStepsDetails, WritePlanDetails } from "./types.ts";
33
33
  import { createPlanWebReview, type PlanWebReview } from "./web.ts";
34
34
 
35
35
  const ExitPlanModeParams = Type.Object({
@@ -54,10 +54,7 @@ const WritePlanParams = Type.Object({
54
54
  ),
55
55
  });
56
56
 
57
- const SetPlanStepParams = Type.Object({
58
- plan_path: Type.String({
59
- description: "Path to the approved plan file, as returned by write_plan or exit_plan_mode.",
60
- }),
57
+ const StepUpdateParams = Type.Object({
61
58
  step: Type.Integer({
62
59
  minimum: 1,
63
60
  description: "1-based step number, in the order the plan was approved.",
@@ -67,9 +64,19 @@ const SetPlanStepParams = Type.Object({
67
64
  }),
68
65
  });
69
66
 
67
+ const SetPlanStepsParams = Type.Object({
68
+ plan_path: Type.String({
69
+ description: "Path to the approved plan file, as returned by write_plan or exit_plan_mode.",
70
+ }),
71
+ updates: Type.Array(StepUpdateParams, {
72
+ minItems: 1,
73
+ description: "The steps to set; each entry sets one step's checkbox, and the last entry wins for a duplicate step.",
74
+ }),
75
+ });
76
+
70
77
  type ExitPlanModeArgs = Static<typeof ExitPlanModeParams>;
71
78
  type WritePlanArgs = Static<typeof WritePlanParams>;
72
- type SetPlanStepArgs = Static<typeof SetPlanStepParams>;
79
+ type SetPlanStepsArgs = Static<typeof SetPlanStepsParams>;
73
80
 
74
81
  /** Lines of plan shown in an unexpanded result. */
75
82
  const PREVIEW_LINES = 8;
@@ -89,6 +96,21 @@ function preview(plan: string): { body: string; more: number } {
89
96
  return { body: lines.slice(0, PREVIEW_LINES).join("\n"), more: lines.length - PREVIEW_LINES };
90
97
  }
91
98
 
99
+ /** "Step 2" or "Steps 1, 2" for a set of step numbers. */
100
+ function stepLabel(steps: number[]): string {
101
+ return `${steps.length === 1 ? "Step" : "Steps"} ${steps.join(", ")}`;
102
+ }
103
+
104
+ /** "Steps 1, 2 completed; step 3 reopened" for the tool's text result. */
105
+ function describeUpdates(updates: StepUpdate[]): string {
106
+ const completed = updates.filter((update) => update.status === "completed").map((update) => update.step);
107
+ const pending = updates.filter((update) => update.status === "pending").map((update) => update.step);
108
+ const parts: string[] = [];
109
+ if (completed.length > 0) parts.push(`${stepLabel(completed)} completed`);
110
+ if (pending.length > 0) parts.push(`${stepLabel(pending)} reopened`);
111
+ return parts.join("; ");
112
+ }
113
+
92
114
  /** Seed the todo list with the plan's steps; returns what was recorded. */
93
115
  async function seedTodos(
94
116
  ctx: ExtensionToolContext,
@@ -464,9 +486,9 @@ export function registerTools(
464
486
  : "";
465
487
  const tail =
466
488
  recorded > 0
467
- ? ` Recorded ${recorded} step${recorded === 1 ? "" : "s"} in the todo list. As you finish each step, mark it done there and tick it off in the file with ${SET_PLAN_STEP_TOOL}.`
489
+ ? ` Recorded ${recorded} step${recorded === 1 ? "" : "s"} in the todo list. As you finish each step, mark it done there and tick it off in the file with ${SET_PLAN_STEPS_TOOL}.`
468
490
  : steps.length > 0
469
- ? ` Could not record the steps in the todo list${error ? `: ${error}` : ""}. Keep these steps in mind and tick them off with ${SET_PLAN_STEP_TOOL} as you go.`
491
+ ? ` Could not record the steps in the todo list${error ? `: ${error}` : ""}. Keep these steps in mind and tick them off with ${SET_PLAN_STEPS_TOOL} as you go.`
470
492
  : " If the todo tool is available, record the steps with it before you start.";
471
493
  return {
472
494
  content: [
@@ -525,22 +547,23 @@ export function registerTools(
525
547
  });
526
548
 
527
549
  pi.registerTool({
528
- name: SET_PLAN_STEP_TOOL,
529
- label: "Set plan step",
550
+ name: SET_PLAN_STEPS_TOOL,
551
+ label: "Set plan steps",
530
552
  description:
531
- "Keep an approved plan file current by setting one top-level step's checkbox in place. The step number matches " +
532
- "the order the plan was approved in. Only the checkbox changes; the step text is left as written. Call it as you " +
533
- "finish (or reopen) each step so the file mirrors reality.",
534
- promptSnippet: "Tick a step off in the approved plan file.",
553
+ "Keep an approved plan file current by setting the checkboxes of the given top-level steps in place. Each step " +
554
+ "number matches the order the plan was approved in; a step can be marked completed or reopened. Only the " +
555
+ "checkboxes change; the step text is left as written. The file is rewritten once, and nothing changes when any " +
556
+ "step is out of range.",
557
+ promptSnippet: "Tick steps off in the approved plan file.",
535
558
  promptGuidelines: [
536
- "After a plan is approved, call set_plan_step as you complete each step so the plan file stays current.",
559
+ "After a plan is approved, call set_plan_steps as you complete steps so the plan file stays current.",
537
560
  ],
538
- parameters: SetPlanStepParams,
561
+ parameters: SetPlanStepsParams,
539
562
  executionMode: "sequential",
540
563
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
541
564
 
542
565
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
543
- const args = params as SetPlanStepArgs;
566
+ const args = params as SetPlanStepsArgs;
544
567
  if (runtime.isEnabled()) {
545
568
  return {
546
569
  content: [{ type: "text", text: "Not while planning; refine the plan with write_plan instead." }],
@@ -549,24 +572,23 @@ export function registerTools(
549
572
  };
550
573
  }
551
574
  try {
552
- const plan = await runtime.plans.setStep(ctx.cwd, args.plan_path, args.step, args.status);
575
+ const plan = await runtime.plans.setSteps(ctx.cwd, args.plan_path, args.updates);
553
576
  const steps = extractPlanSteps(plan.content);
554
577
  const done = steps.filter((step) => step.status === "completed").length;
555
578
  return {
556
579
  content: [
557
580
  {
558
581
  type: "text",
559
- text: `Step ${args.step} is ${args.status} (${done}/${steps.length} done) in ${plan.relativePath}.`,
582
+ text: `${describeUpdates(args.updates)} (${done}/${steps.length} done) in ${plan.relativePath}.`,
560
583
  },
561
584
  ],
562
585
  details: {
563
586
  path: plan.path,
564
587
  relativePath: plan.relativePath,
565
- step: args.step,
566
- status: args.status,
588
+ updates: args.updates,
567
589
  done,
568
590
  total: steps.length,
569
- } satisfies SetPlanStepDetails,
591
+ } satisfies SetPlanStepsDetails,
570
592
  };
571
593
  } catch (error) {
572
594
  const message = error instanceof Error ? error.message : String(error);
@@ -580,24 +602,32 @@ export function registerTools(
580
602
 
581
603
  renderCall(args, theme, context) {
582
604
  const text = reuseText(context?.lastComponent);
583
- const step = (args as Partial<SetPlanStepArgs>).step;
584
- const suffix = theme.fg("muted", step === undefined ? "a step" : `step ${step}`);
585
- text.setText(theme.fg("toolTitle", theme.bold(`${SET_PLAN_STEP_TOOL} `)) + suffix);
605
+ const updates = (args as Partial<SetPlanStepsArgs>).updates;
606
+ const label =
607
+ updates && updates.length === 1 && updates[0]?.step !== undefined
608
+ ? `step ${updates[0].step}`
609
+ : updates && updates.length > 1
610
+ ? `${updates.length} steps`
611
+ : "steps";
612
+ text.setText(theme.fg("toolTitle", theme.bold(`${SET_PLAN_STEPS_TOOL} `)) + theme.fg("muted", label));
586
613
  return text;
587
614
  },
588
615
 
589
616
  renderResult(result, _options, theme, context) {
590
617
  const text = reuseText(context?.lastComponent);
591
- const details = toolDetails<SetPlanStepDetails>(result.details);
618
+ const details = toolDetails<SetPlanStepsDetails>(result.details);
592
619
  if (!details) {
593
620
  const first = result.content[0];
594
621
  text.setText(first?.type === "text" ? first.text : "");
595
622
  return text;
596
623
  }
624
+ const completed = details.updates.filter((update) => update.status === "completed").map((u) => u.step);
625
+ const pending = details.updates.filter((update) => update.status === "pending").map((u) => u.step);
597
626
  const heading =
598
- details.status === "completed"
599
- ? theme.fg("success", `✓ Step ${details.step} done`)
600
- : theme.fg("muted", `Step ${details.step} reopened`);
627
+ completed.length > 0
628
+ ? theme.fg("success", `✓ ${stepLabel(completed)} done`) +
629
+ (pending.length > 0 ? theme.fg("muted", ` · ${stepLabel(pending)} reopened`) : "")
630
+ : theme.fg("muted", `${stepLabel(pending)} reopened`);
601
631
  text.setText(heading + theme.fg("dim", ` · ${details.done}/${details.total}`));
602
632
  return text;
603
633
  },
@@ -2,6 +2,8 @@
2
2
  * Shared types for the plan-mode extension.
3
3
  */
4
4
 
5
+ import type { StepUpdate } from "./steps.ts";
6
+
5
7
  /** Persisted (custom entry) plan-mode state. */
6
8
  export interface PlanModeEntry {
7
9
  enabled: boolean;
@@ -32,16 +34,14 @@ export interface WritePlanDetails {
32
34
  bytes: number;
33
35
  }
34
36
 
35
- /** Structured result carried in `set_plan_step`'s `details`. */
36
- export interface SetPlanStepDetails {
37
+ /** Structured result carried in `set_plan_steps`'s `details`. */
38
+ export interface SetPlanStepsDetails {
37
39
  /** Absolute path of the plan file that was updated. */
38
40
  path: string;
39
41
  /** Path relative to the working directory, for transcript display. */
40
42
  relativePath: string;
41
- /** The 1-based step number that was set. */
42
- step: number;
43
- /** The step's new status. */
44
- status: "pending" | "completed";
43
+ /** The checkbox changes that were applied, in call order. */
44
+ updates: StepUpdate[];
45
45
  /** Completed steps after the update. */
46
46
  done: number;
47
47
  /** Total top-level steps in the plan. */
package/lib/README.md CHANGED
@@ -24,7 +24,7 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
24
24
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote adapter installs on `ctx.ui` under a string key, the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file/text preface) plus the shared “Other” label. |
25
25
  | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
26
26
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
27
- | [`list-cursor.ts`](./list-cursor.ts) | Scrollable-list behavior for the `/rewind`, `/extensions`, `/doctor`, `/todos`, and `/jobs` screens: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` state. |
27
+ | [`list-cursor.ts`](./list-cursor.ts) | Scrollable-list behavior for the `/rewind`, `/bytefly`, `/todos`, and `/jobs` screens: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` state. |
28
28
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, and doc guards share. |
29
29
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
30
30
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
@@ -194,9 +194,9 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
194
194
  bytefly: {
195
195
  configFiles: [SHARED_CONFIG_FILE],
196
196
  notes: [
197
- "TUI only for the header, footer, and turn rule; `/extensions` prints a listing and `/doctor` prints its report in RPC, JSON, and print modes.",
198
- "The `/extensions` picker writes through Pi's own settings; project scope requires a trusted project.",
199
- "`/doctor`'s list screen and the proactive missing-prerequisite warning need an interactive UI.",
197
+ "TUI only for the header, footer, and turn rule; `/bytefly` (and the `/extensions` and `/doctor` aliases) prints a listing and report in RPC, JSON, and print modes.",
198
+ "The panel writes extension enablement through Pi's own settings; project scope requires a trusted project.",
199
+ "`/bytefly`'s list screen and the proactive missing-prerequisite warning need an interactive UI.",
200
200
  ],
201
201
  },
202
202
  };
package/lib/tui.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  * todo).
4
4
  *
5
5
  * `FULL_SCREEN_OVERLAY` is for the one surface that must own the whole terminal:
6
- * the plan read/review screen. Every list screen (`/todos`, `/jobs`,
7
- * `/rewind`) stays in the dock's editor slot so they match each other and
8
- * leave the transcript visible.
6
+ * the plan read/review screen. Every list screen (`/bytefly`, `/todos`, `/jobs`,
7
+ * `/rewind`) stays in the dock's editor slot so they match each other and leave
8
+ * the transcript visible.
9
9
  *
10
10
  * Screens are full-width, terminal-height-aware components. These helpers build
11
11
  * the top rule, clamp a screen's body to the terminal, and render the key-hint
@@ -128,11 +128,10 @@ export interface DockListFrame {
128
128
  * its blank line, the body, an optional range row, optional detail rows, and
129
129
  * the blank-line-padded key-hint footer.
130
130
  *
131
- * This is the convention `/extensions` shares with `/jobs` and `/todos`.
132
- * `/doctor` (no summary, no trailing blank, range last) and `/rewind` (a blank
133
- * after the header and no blank before the hint) place their blanks
134
- * differently, so they stay hand-rolled rather than changing their output to
135
- * fit this helper.
131
+ * This is the convention the bytefly panel (`/bytefly`, and the `/extensions`
132
+ * and `/doctor` aliases) shares with `/jobs` and `/todos`. `/rewind` (a blank
133
+ * after the header and no blank before the hint) places its blanks differently,
134
+ * so it stays hand-rolled rather than changing its output to fit this helper.
136
135
  */
137
136
  export function dockListFrame(frame: DockListFrame): string[] {
138
137
  const width = Math.max(1, frame.width);
@@ -113,6 +113,17 @@ th[aria-sort="descending"] .bf-sort::after{content:" \\25BC";font-size:9px;color
113
113
  .bf-md pre{border:1px solid var(--border);border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);overflow:auto;background:var(--canvas)}
114
114
  .bf-md pre code{background:none;border:0;padding:0}
115
115
  .bf-md blockquote{margin-left:0;padding:0 1em;border-left:3px solid var(--border);color:var(--muted)}
116
+ .bf-md li.task{list-style:none;margin-left:-1.4em}
117
+ .bf-md .md-check{color:var(--success)}
118
+ .bf-md table{margin:0;border-collapse:collapse;table-layout:auto;width:auto;font-size:var(--fs-sm)}
119
+ .bf-md .md-table-wrap{overflow-x:auto;margin:0 0 1em}
120
+ .bf-md th,.bf-md td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top}
121
+ .bf-md thead th{background:var(--surface);font-weight:var(--fw-medium)}
122
+ .bf-md .md-left{text-align:left}
123
+ .bf-md .md-center{text-align:center}
124
+ .bf-md .md-right{text-align:right}
125
+ .bf-md img{max-width:100%;height:auto;border-radius:var(--radius-sm)}
126
+ .bf-md hr{border:0;border-top:1px solid var(--border);margin:1.4em 0}
116
127
  .bf-comment{padding:var(--space-3) var(--space-4);background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--warn);border-radius:var(--radius-md)}
117
128
  .bf-comment__quote{margin-bottom:var(--space-1);color:var(--muted);font-size:var(--fs-xs);overflow-wrap:anywhere}
118
129
  .bf-comment__body{white-space:pre-wrap;overflow-wrap:anywhere}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -90,7 +90,7 @@
90
90
  "themes": [
91
91
  "./themes/*.json"
92
92
  ],
93
- "image": "./assets/bytefly-chrome.png"
93
+ "image": "https://raw.githubusercontent.com/gavin-hu/my-pi-agent/main/assets/bytefly-chrome.png"
94
94
  },
95
95
  "scripts": {
96
96
  "test": "bun test --parallel=4 --timeout 30000",
@@ -1,68 +0,0 @@
1
- /**
2
- * The `/doctor` command.
3
- *
4
- * One command for the whole surface: `/doctor` reports everything, `/doctor
5
- * document` narrows it, `/doctor refresh` re-probes. The TUI opens a docked
6
- * list screen; every other mode prints the same report as text.
7
- */
8
-
9
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
- import { withRailsSuppressed } from "../../../lib/rails.ts";
11
- import { REQUIREMENTS, type ExtensionReport } from "../../../lib/requirements.ts";
12
- import { formatExtension, formatReport, hasProblems } from "./format.ts";
13
- import type { DoctorRuntime } from "./runtime.ts";
14
- import { DoctorScreen } from "./tui.ts";
15
-
16
- /** The report text for a set of reports, narrowed when one extension was named. */
17
- function reportText(name: string | undefined, reports: readonly ExtensionReport[]): string {
18
- const first = reports[0];
19
- return name && first ? formatExtension(first) : formatReport(reports);
20
- }
21
-
22
- /** Register the `/doctor` command. */
23
- export function registerCommands(pi: ExtensionAPI, runtime: DoctorRuntime): void {
24
- pi.registerCommand("doctor", {
25
- description: "Check that every extension has what it needs: /doctor [extension] [refresh]",
26
- getArgumentCompletions: (prefix: string) => {
27
- const options = [...Object.keys(REQUIREMENTS), "refresh"].filter((option) => option.startsWith(prefix));
28
- return options.length > 0 ? options.map((value) => ({ value, label: value })) : null;
29
- },
30
- handler: async (args, ctx) => {
31
- const parts = args.trim().split(/\s+/).filter(Boolean);
32
- const refresh = parts.includes("refresh");
33
- const name = parts.find((part) => part !== "refresh");
34
-
35
- const reports = await runtime.probeAll({ refresh });
36
- if (name && !reports.some((report) => report.extension === name)) {
37
- ctx.ui.notify(`doctor: no extension named "${name}"`, "warning");
38
- return;
39
- }
40
- const selected = name ? reports.filter((report) => report.extension === name) : reports;
41
-
42
- if (ctx.mode !== "tui") {
43
- ctx.ui.notify(reportText(name, selected), hasProblems(selected) ? "warning" : "info");
44
- return;
45
- }
46
-
47
- // The dock screen owns the editor slot; hide the rails for its lifetime.
48
- let current = selected;
49
- await withRailsSuppressed(pi, () =>
50
- ctx.ui.custom<void>(
51
- (tui, theme, _keybindings, done) =>
52
- new DoctorScreen({
53
- reports: () => current,
54
- rows: () => tui.terminal?.rows,
55
- theme,
56
- requestRender: () => tui.requestRender(),
57
- refresh: async () => {
58
- const next = await runtime.probeAll({ refresh: true });
59
- current = name ? next.filter((report) => report.extension === name) : next;
60
- tui.requestRender();
61
- },
62
- done,
63
- }),
64
- ),
65
- );
66
- },
67
- });
68
- }