@gavin-hu/my-pi-agent 0.7.0 → 0.8.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.
@@ -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.0",
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",
@@ -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
- }
@@ -1,140 +0,0 @@
1
- /**
2
- * The `/doctor` TUI screen.
3
- *
4
- * One row per extension with a severity glyph, plus a detail pane for the
5
- * focused row. It stays in the dock's editor slot like `/jobs` and `/rewind`,
6
- * so the transcript stays visible; `Esc` closes it.
7
- *
8
- * The screen holds no themed strings: `render` reads the live report each time
9
- * and `invalidate` has nothing to rebuild. Report text comes from `format.ts`,
10
- * which sanitizes everything that originated in a probe.
11
- */
12
-
13
- import type { Theme } from "@earendil-works/pi-coding-agent";
14
- import {
15
- Key,
16
- matchesKey,
17
- truncateToWidth,
18
- type Component,
19
- type TuiMouseEvent,
20
- type TuiMouseEventResult,
21
- } from "@earendil-works/pi-tui";
22
- import { fitRows, formatRange, ListCursor, navigateList, scrollList } from "../../../lib/list-cursor.ts";
23
- import { screenHeader, screenHint } from "../../../lib/tui.ts";
24
- import type { ExtensionReport } from "../../../lib/requirements.ts";
25
- import { countFindings, detailLines, extensionRow } from "./format.ts";
26
-
27
- /** Body rows when the terminal height is unknown. */
28
- const SCREEN_DEFAULT_ROWS = 12;
29
- /** Rows reserved for the focused extension's detail pane. */
30
- const DETAIL_ROWS = 4;
31
- /** Fixed rows around the list: header, blank, detail label, hint. */
32
- const CHROME = 4;
33
-
34
- export interface DoctorScreenOptions {
35
- /** Current reports; read on every render so a refresh is picked up. */
36
- reports: () => ExtensionReport[];
37
- /** Live terminal height, for the row budget. */
38
- rows: () => number | undefined;
39
- theme: Theme;
40
- requestRender: () => void;
41
- /** Re-probe with `refresh` and update `reports`. May be async; awaited before the busy state clears. */
42
- refresh: () => void | Promise<void>;
43
- /** Close the screen. */
44
- done: () => void;
45
- }
46
-
47
- /** The docked `/doctor` list screen. */
48
- export class DoctorScreen implements Component {
49
- private readonly cursor: ListCursor;
50
- private showAll = false;
51
- /** True while a re-probe is in flight, so `r` does not stack probes. */
52
- private refreshing = false;
53
-
54
- constructor(private readonly options: DoctorScreenOptions) {
55
- this.cursor = new ListCursor(() => this.options.requestRender());
56
- }
57
-
58
- invalidate(): void {}
59
-
60
- /** Re-probe once; a second request while one is in flight is ignored. */
61
- async refresh(): Promise<void> {
62
- if (this.refreshing) return;
63
- this.refreshing = true;
64
- this.showAll = false;
65
- this.options.requestRender();
66
- try {
67
- await this.options.refresh();
68
- } finally {
69
- this.refreshing = false;
70
- this.options.requestRender();
71
- }
72
- }
73
-
74
- private count(): number {
75
- return this.options.reports().length;
76
- }
77
-
78
- handleInput(data: string): void {
79
- if (matchesKey(data, Key.escape)) {
80
- this.options.done();
81
- return;
82
- }
83
- if (matchesKey(data, "r")) {
84
- void this.refresh();
85
- return;
86
- }
87
- if (matchesKey(data, Key.enter)) {
88
- this.showAll = !this.showAll;
89
- this.options.requestRender();
90
- return;
91
- }
92
-
93
- navigateList(data, this.cursor, this.count());
94
- }
95
-
96
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
97
- return scrollList(event, this.cursor, this.count());
98
- }
99
-
100
- // Kept off `dockListFrame`: `/doctor` has no summary, prints the range after
101
- // the detail, and has no trailing blank, so the shared frame would change its
102
- // output. Its blank-line convention is deliberately local.
103
- render(width: number): string[] {
104
- const w = Math.max(1, width);
105
- const reports = this.options.reports();
106
- const count = reports.length;
107
-
108
- this.cursor.visible = Math.max(1, fitRows(this.options.rows(), count, CHROME + DETAIL_ROWS, SCREEN_DEFAULT_ROWS));
109
- this.cursor.sync(count);
110
- const window = this.cursor.window(count);
111
-
112
- const lines: string[] = [screenHeader(this.options.theme, w, "doctor")];
113
- for (let index = window.start; index < window.end; index += 1) {
114
- const report = reports[index];
115
- if (report) lines.push(extensionRow(report, this.options.theme, w, index === this.cursor.selected));
116
- }
117
- lines.push("");
118
-
119
- const focused = reports[this.cursor.selected];
120
- const counts = countFindings(focused ? [focused] : []);
121
- const label = focused
122
- ? ` ${focused.extension} — ${counts.missing} missing, ${counts.degraded} degraded`
123
- : " no extensions to report";
124
- lines.push(truncateToWidth(this.options.theme.fg("dim", label), w));
125
- lines.push(...detailLines(focused, this.options.theme, w, DETAIL_ROWS, this.showAll));
126
-
127
- if (this.cursor.clipped(count)) {
128
- lines.push(formatRange(this.options.theme, w, { start: window.start, end: window.end, total: count }));
129
- }
130
- lines.push(
131
- screenHint(this.options.theme, w, [
132
- "Esc close",
133
- "↑/↓ select",
134
- this.showAll ? "Enter problems" : "Enter all checks",
135
- this.refreshing ? "refreshing…" : "r refresh",
136
- ]),
137
- );
138
- return lines;
139
- }
140
- }
@@ -1,73 +0,0 @@
1
- /**
2
- * The `/extensions` command.
3
- *
4
- * In a terminal session it opens the dock list, where Space toggles the focused
5
- * extension and Tab switches between Global and Project settings. Everywhere
6
- * else it prints the same list as text. After the screen closes with pending
7
- * changes it offers to reload so the new selection takes effect.
8
- */
9
-
10
- import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
11
- import { withRailsSuppressed } from "../../../lib/rails.ts";
12
- import { discoverExtensions, type DiscoveryDeps } from "./discovery.ts";
13
- import { formatListing } from "./resources.ts";
14
- import { createExtensionsPickerRuntime } from "./runtime.ts";
15
- import { ExtensionsListComponent } from "./tui.ts";
16
- import type { Discovery } from "./types.ts";
17
-
18
- /** Host seams, injected by tests. */
19
- export interface CommandDeps {
20
- discover?: (deps: DiscoveryDeps) => Promise<Awaited<ReturnType<typeof discoverExtensions>>>;
21
- }
22
-
23
- export function registerExtensionsCommand(pi: ExtensionAPI, deps: CommandDeps = {}): void {
24
- const discover = deps.discover ?? discoverExtensions;
25
-
26
- pi.registerCommand("extensions", {
27
- description: "List and enable/disable extensions",
28
- handler: async (_args, ctx) => {
29
- const cwd = ctx.cwd ?? process.cwd();
30
- const agentDir = getAgentDir();
31
- let discovery: Discovery;
32
- try {
33
- discovery = await discover({ cwd, agentDir, trusted: ctx.isProjectTrusted() });
34
- } catch (error) {
35
- ctx.ui.notify(`Could not list extensions: ${error instanceof Error ? error.message : String(error)}`, "error");
36
- return;
37
- }
38
- const runtime = createExtensionsPickerRuntime({
39
- discovery,
40
- cwd,
41
- agentDir,
42
- onError: (message) => ctx.ui.notify(message, "error"),
43
- });
44
-
45
- if (ctx.mode !== "tui") {
46
- ctx.ui.notify(formatListing(runtime.items(), agentDir), "info");
47
- return;
48
- }
49
-
50
- await withRailsSuppressed(pi, () =>
51
- ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
52
- const unsubscribe = runtime.onChange(() => tui.requestRender());
53
- return new ExtensionsListComponent(
54
- runtime,
55
- theme,
56
- agentDir,
57
- () => {
58
- unsubscribe();
59
- done();
60
- },
61
- () => tui.requestRender(),
62
- () => tui.terminal?.rows,
63
- (message) => ctx.ui.notify(message, "warning"),
64
- );
65
- }),
66
- );
67
-
68
- if (!runtime.isDirty()) return;
69
- const reload = await ctx.ui.confirm("Reload to apply changes?", "Extension changes take effect after a reload.");
70
- if (reload) await ctx.reload();
71
- },
72
- });
73
- }