@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.
- package/README.md +10 -10
- package/README.zh-CN.md +153 -0
- package/extensions/bytefly/README.md +129 -122
- package/extensions/bytefly/doctor/format.ts +3 -22
- package/extensions/bytefly/doctor/register.ts +10 -8
- package/extensions/bytefly/footer/register.ts +17 -91
- package/extensions/bytefly/header/component.ts +8 -4
- package/extensions/bytefly/header/config.ts +11 -6
- package/extensions/bytefly/header/register.ts +7 -27
- package/extensions/bytefly/index.ts +15 -14
- package/extensions/bytefly/panel/commands.ts +147 -0
- package/extensions/bytefly/panel/format.ts +135 -0
- package/extensions/bytefly/panel/join.ts +103 -0
- package/extensions/bytefly/panel/tui.ts +416 -0
- package/extensions/bytefly/panel/types.ts +44 -0
- package/extensions/bytefly/picker/resources.ts +0 -11
- package/extensions/file-browse/README.md +12 -5
- package/extensions/file-browse/markdown.ts +227 -45
- package/extensions/plan/README.md +16 -12
- package/extensions/plan/plans.ts +16 -17
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/steps.ts +31 -19
- package/extensions/plan/tools.ts +62 -32
- package/extensions/plan/types.ts +6 -6
- package/lib/README.md +1 -1
- package/lib/requirements.ts +3 -3
- package/lib/tui.ts +7 -8
- package/lib/web/components.ts +11 -0
- package/package.json +1 -1
- package/extensions/bytefly/doctor/commands.ts +0 -68
- package/extensions/bytefly/doctor/tui.ts +0 -140
- package/extensions/bytefly/picker/commands.ts +0 -73
- package/extensions/bytefly/picker/tui.ts +0 -260
package/extensions/plan/steps.ts
CHANGED
|
@@ -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
|
-
*
|
|
171
|
-
* {@link extractPlanSteps}) and return the rewritten plan
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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
|
|
177
|
-
plan
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
/**
|
package/extensions/plan/tools.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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 ${
|
|
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 ${
|
|
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:
|
|
529
|
-
label: "Set plan
|
|
550
|
+
name: SET_PLAN_STEPS_TOOL,
|
|
551
|
+
label: "Set plan steps",
|
|
530
552
|
description:
|
|
531
|
-
"Keep an approved plan file current by setting
|
|
532
|
-
"the order the plan was approved in
|
|
533
|
-
"
|
|
534
|
-
|
|
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
|
|
559
|
+
"After a plan is approved, call set_plan_steps as you complete steps so the plan file stays current.",
|
|
537
560
|
],
|
|
538
|
-
parameters:
|
|
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
|
|
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.
|
|
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:
|
|
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
|
-
|
|
566
|
-
status: args.status,
|
|
588
|
+
updates: args.updates,
|
|
567
589
|
done,
|
|
568
590
|
total: steps.length,
|
|
569
|
-
} satisfies
|
|
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
|
|
584
|
-
const
|
|
585
|
-
|
|
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<
|
|
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
|
-
|
|
599
|
-
? theme.fg("success", `✓
|
|
600
|
-
|
|
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
|
},
|
package/extensions/plan/types.ts
CHANGED
|
@@ -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 `
|
|
36
|
-
export interface
|
|
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
|
|
42
|
-
|
|
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`, `/
|
|
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. |
|
package/lib/requirements.ts
CHANGED
|
@@ -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; `/
|
|
198
|
-
"The
|
|
199
|
-
"`/
|
|
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
|
-
*
|
|
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
|
|
132
|
-
* `/doctor`
|
|
133
|
-
* after the header and no blank before the hint)
|
|
134
|
-
*
|
|
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);
|
package/lib/web/components.ts
CHANGED
|
@@ -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.
|
|
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
|
-
}
|