@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
@@ -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
- }
@@ -1,260 +0,0 @@
1
- /**
2
- * The `/extensions` dock screen: a scrollable, keyboard-driven list of every
3
- * resolved extension with its group header.
4
- *
5
- * The component is derived: it reads `runtime.items()` on each render and
6
- * requests a repaint after a toggle or a scope switch through the injected
7
- * callbacks. It reuses the shared screen chrome (`lib/tui.ts`) and scroll math
8
- * (`lib/list-cursor.ts`) rather than re-deriving either.
9
- */
10
-
11
- import type { Theme } from "@earendil-works/pi-coding-agent";
12
- import {
13
- Key,
14
- matchesKey,
15
- truncateToWidth,
16
- type Component,
17
- type TuiMouseEvent,
18
- type TuiMouseEventResult,
19
- } from "@earendil-works/pi-tui";
20
- import { sanitizeLine } from "../../../lib/format.ts";
21
- import {
22
- fitRows,
23
- formatRange,
24
- isCloseKey,
25
- ListCursor,
26
- navIntent,
27
- selectionMarker,
28
- wheelDelta,
29
- } from "../../../lib/list-cursor.ts";
30
- import { dockListFrame, screenHint, type ViewportRowsSource } from "../../../lib/tui.ts";
31
- import { buildEntries, firstSelectable, nextSelectable } from "./resources.ts";
32
- import type { ExtensionsPickerRuntime as Runtime } from "./runtime.ts";
33
- import type { Entry } from "./types.ts";
34
-
35
- /** Rows the screen shows when the terminal height is unknown. */
36
- const SCREEN_DEFAULT_ROWS = 16;
37
-
38
- /**
39
- * Fixed rows around the list: header, summary, blank, blank, hint, blank. The
40
- * focused-detail and pending-changes rows are added on top when present.
41
- */
42
- const CHROME = 6;
43
-
44
- export class ExtensionsListComponent implements Component {
45
- private readonly cursor: ListCursor;
46
- /** Serialises toggles so a rapid double press toggles twice, not once. */
47
- private chain: Promise<void> = Promise.resolve();
48
-
49
- constructor(
50
- private readonly runtime: Runtime,
51
- private readonly theme: Theme,
52
- private readonly agentDir: string,
53
- private readonly onClose: () => void,
54
- private readonly requestRender: () => void,
55
- private readonly viewportRowsSource?: ViewportRowsSource,
56
- private readonly onNotice?: (message: string) => void,
57
- ) {
58
- this.cursor = new ListCursor(() => this.requestRender());
59
- }
60
-
61
- private entries(): Entry[] {
62
- return buildEntries(this.runtime.items(), this.agentDir);
63
- }
64
-
65
- private currentItem() {
66
- const entry = this.entries()[this.cursor.selected];
67
- return entry?.kind === "item" ? entry.item : undefined;
68
- }
69
-
70
- private select(index: number | undefined): void {
71
- if (index === undefined) return;
72
- this.cursor.set(index, this.entries().length);
73
- }
74
-
75
- /** Keep the cursor on an item row when the list shape changes. */
76
- private ensureSelectable(): void {
77
- const entries = this.entries();
78
- if (entries[this.cursor.selected]?.kind === "item") return;
79
- this.select(firstSelectable(entries));
80
- }
81
-
82
- /** Move the cursor onto an item row when the list shape changed. No repaint. */
83
- private clampSelection(entries: Entry[]): void {
84
- if (entries[this.cursor.selected]?.kind === "item") return;
85
- const first = firstSelectable(entries);
86
- if (first !== undefined) this.cursor.selected = first;
87
- }
88
-
89
- /** Move `delta` item rows, skipping group headers. */
90
- private move(delta: number): void {
91
- if (delta === 0) return;
92
- this.ensureSelectable();
93
- const entries = this.entries();
94
- const direction = delta < 0 ? -1 : 1;
95
- let index = this.cursor.selected;
96
- let remaining = Math.abs(delta);
97
- while (remaining > 0) {
98
- const next = nextSelectable(entries, index, direction);
99
- if (next === undefined) break;
100
- index = next;
101
- remaining -= 1;
102
- }
103
- this.cursor.set(index, entries.length);
104
- }
105
-
106
- private switchScope(): void {
107
- const next = this.runtime.scope() === "global" ? "project" : "global";
108
- if (next === "project" && !this.runtime.projectAvailable()) {
109
- this.onNotice?.("Project is not trusted; manage extensions in Global scope only.");
110
- return;
111
- }
112
- this.runtime.setScope(next);
113
- this.clampSelection(this.entries());
114
- this.requestRender();
115
- }
116
-
117
- private toggle(): void {
118
- const item = this.currentItem();
119
- if (!item) return;
120
- this.chain = this.chain
121
- .then(() => this.runtime.toggle(item))
122
- .catch(() => undefined)
123
- .then(() => this.requestRender());
124
- }
125
-
126
- handleInput(data: string): void {
127
- if (isCloseKey(data)) {
128
- this.onClose();
129
- return;
130
- }
131
- this.ensureSelectable();
132
- if (matchesKey(data, Key.space) || matchesKey(data, Key.enter)) {
133
- this.toggle();
134
- return;
135
- }
136
- if (matchesKey(data, Key.tab)) {
137
- this.switchScope();
138
- return;
139
- }
140
- if (data === "g") {
141
- this.select(firstSelectable(this.entries()));
142
- return;
143
- }
144
- if (data === "G") {
145
- this.select(nextSelectable(this.entries(), this.entries().length, -1));
146
- return;
147
- }
148
- switch (navIntent(data)) {
149
- case "up":
150
- this.move(-1);
151
- return;
152
- case "down":
153
- this.move(1);
154
- return;
155
- case "pageUp":
156
- this.move(-Math.max(1, this.cursor.visible));
157
- return;
158
- case "pageDown":
159
- this.move(Math.max(1, this.cursor.visible));
160
- return;
161
- case "home":
162
- this.select(firstSelectable(this.entries()));
163
- return;
164
- case "end":
165
- this.select(nextSelectable(this.entries(), this.entries().length, -1));
166
- return;
167
- }
168
- }
169
-
170
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
171
- const delta = wheelDelta(event);
172
- if (delta === undefined) return undefined;
173
- this.move(delta);
174
- return { handled: true };
175
- }
176
-
177
- invalidate(): void {
178
- // Everything is rebuilt from live state on each render.
179
- }
180
-
181
- private summary(width: number): string {
182
- const items = this.runtime.items();
183
- const scope = this.runtime.scope() === "global" ? "Global" : "Project";
184
- const untrusted = this.runtime.scope() === "global" && !this.runtime.projectAvailable();
185
- const counts = `${items.length} extension${items.length === 1 ? "" : "s"} · ${this.runtime.disabledCount()} disabled`;
186
- return truncateToWidth(
187
- ` ${this.theme.fg("accent", scope)}${untrusted ? this.theme.fg("warning", " (project untrusted)") : ""}${this.theme.fg("muted", ` · ${counts}`)}`,
188
- width,
189
- "…",
190
- );
191
- }
192
-
193
- private itemRow(entry: Extract<Entry, { kind: "item" }>, selected: boolean, width: number): string {
194
- const checkbox = entry.item.enabled ? this.theme.fg("success", "[x]") : this.theme.fg("dim", "[ ]");
195
- const label = sanitizeLine(entry.label);
196
- const styled = selected ? this.theme.fg("accent", label) : entry.item.enabled ? label : this.theme.fg("dim", label);
197
- return truncateToWidth(`${selectionMarker(this.theme, selected)}${checkbox} ${styled}`, width, "…");
198
- }
199
-
200
- render(width: number): string[] {
201
- const w = Math.max(1, width);
202
- const entries = this.entries();
203
- const summary = this.summary(w);
204
-
205
- if (entries.length === 0) {
206
- return dockListFrame({
207
- theme: this.theme,
208
- width: w,
209
- title: "Extensions",
210
- summary,
211
- body: [truncateToWidth(` ${this.theme.fg("dim", "No extensions found.")}`, w)],
212
- hint: screenHint(this.theme, w, ["Esc close"]),
213
- });
214
- }
215
-
216
- this.clampSelection(entries);
217
-
218
- const focused = entries[this.cursor.selected];
219
- const detail = focused?.kind === "item" ? sanitizeLine(focused.item.path) : "";
220
- const chrome = CHROME + (detail ? 1 : 0) + (this.runtime.isDirty() ? 1 : 0);
221
- this.cursor.visible = fitRows(this.viewportRowsSource, entries.length, chrome, SCREEN_DEFAULT_ROWS);
222
- this.cursor.sync(entries.length);
223
-
224
- const { start, end } = this.cursor.window(entries.length);
225
- const body: string[] = [];
226
- for (let index = start; index < end; index++) {
227
- const entry = entries[index];
228
- if (entry.kind === "group") {
229
- body.push(
230
- truncateToWidth(
231
- ` ${this.theme.fg("muted", sanitizeLine(entry.label))} ${this.theme.fg("dim", `(${entry.count})`)}`,
232
- w,
233
- "…",
234
- ),
235
- );
236
- } else {
237
- body.push(this.itemRow(entry, index === this.cursor.selected, w));
238
- }
239
- }
240
-
241
- const footnotes: string[] = [];
242
- if (detail) footnotes.push(truncateToWidth(` ${this.theme.fg("muted", detail)}`, w, "…"));
243
- if (this.runtime.isDirty()) {
244
- footnotes.push(truncateToWidth(` ${this.theme.fg("warning", "Saved · reload to apply")}`, w, "…"));
245
- }
246
-
247
- return dockListFrame({
248
- theme: this.theme,
249
- width: w,
250
- title: "Extensions",
251
- summary,
252
- body,
253
- range: this.cursor.clipped(entries.length)
254
- ? formatRange(this.theme, w, { start, end, total: entries.length })
255
- : undefined,
256
- detail: footnotes.length > 0 ? footnotes : undefined,
257
- hint: screenHint(this.theme, w, ["Esc close", "Space/Enter toggle", "Tab scope", "↑/↓ select", "g/G"]),
258
- });
259
- }
260
- }