@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.
- 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 +20 -7
- package/extensions/file-browse/html.ts +29 -6
- package/extensions/file-browse/markdown.ts +288 -46
- 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 +2 -2
- 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
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The footer surface: the two-line status bar
|
|
3
|
-
*
|
|
2
|
+
* The footer surface: the two-line status bar and the session lifecycle that
|
|
3
|
+
* installs and restores it.
|
|
4
4
|
*
|
|
5
5
|
* The footer is installed only in interactive (`tui`) sessions, and the price
|
|
6
|
-
* configuration is read once per install, so
|
|
7
|
-
*
|
|
8
|
-
* the file.
|
|
9
|
-
*
|
|
10
|
-
* `/bytefly` also drives the header surface, injected by the composition root
|
|
11
|
-
* so this folder never imports `header/`.
|
|
6
|
+
* configuration is read once per install, so starting a new session or changing
|
|
7
|
+
* the session tree is how an edited `bytefly.json` is picked up. Nothing watches
|
|
8
|
+
* the file. The surface is always on; `pi config` is the way to turn bytefly's
|
|
9
|
+
* chrome off.
|
|
12
10
|
*/
|
|
13
11
|
|
|
14
12
|
import { homedir } from "node:os";
|
|
@@ -17,96 +15,24 @@ import { createSnapshotReader } from "./collect/snapshot.ts";
|
|
|
17
15
|
import { loadByteflyConfig } from "./price/config.ts";
|
|
18
16
|
import { createFooter } from "./render/footer.ts";
|
|
19
17
|
|
|
20
|
-
/**
|
|
21
|
-
export
|
|
22
|
-
/** Install the surface, or restore Pi's built-in one, from the current state. */
|
|
23
|
-
apply(ctx: ExtensionContext): void;
|
|
24
|
-
/** Whether the surface is currently on. */
|
|
25
|
-
isEnabled(): boolean;
|
|
26
|
-
/** Set the enabled flag without installing. */
|
|
27
|
-
setEnabled(enabled: boolean): void;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Host seams for the footer surface. */
|
|
31
|
-
export interface FooterDeps {
|
|
32
|
-
/** The header surface, toggled by `/bytefly header` and the master switch. */
|
|
33
|
-
header?: ChromeSurface;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Register `/bytefly [header|footer|status]` and the install/restore lifecycle. */
|
|
37
|
-
export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
|
|
38
|
-
let enabled = true;
|
|
39
|
-
|
|
18
|
+
/** Register the footer install/restore lifecycle. */
|
|
19
|
+
export function registerFooter(pi: ExtensionAPI): void {
|
|
40
20
|
const install = (ctx: ExtensionContext): void => {
|
|
41
21
|
if (ctx.mode !== "tui") return;
|
|
42
22
|
const config = loadByteflyConfig(ctx.cwd);
|
|
43
23
|
const read = createSnapshotReader(config);
|
|
44
24
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
55
|
-
} else {
|
|
56
|
-
ctx.ui.setFooter(undefined);
|
|
57
|
-
}
|
|
25
|
+
ctx.ui.setFooter((tui, _theme, footerData) =>
|
|
26
|
+
createFooter(
|
|
27
|
+
tui,
|
|
28
|
+
footerData,
|
|
29
|
+
() => read(ctx, footerData),
|
|
30
|
+
() => ctx.ui.theme,
|
|
31
|
+
homedir(),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
58
34
|
};
|
|
59
35
|
|
|
60
|
-
const header = deps.header;
|
|
61
|
-
const headerState = (): string => (header?.isEnabled() ? "on" : "off");
|
|
62
|
-
|
|
63
|
-
pi.registerCommand("bytefly", {
|
|
64
|
-
description: "Toggle the TUI bytefly chrome: /bytefly [header|footer|status]",
|
|
65
|
-
getArgumentCompletions: (prefix: string) => {
|
|
66
|
-
const action = prefix.trim();
|
|
67
|
-
const options = ["footer", "header", "status"].filter((option) => option.startsWith(action));
|
|
68
|
-
return options.length > 0 ? options.map((value) => ({ value, label: value })) : null;
|
|
69
|
-
},
|
|
70
|
-
handler: async (args, ctx) => {
|
|
71
|
-
if (ctx.mode !== "tui") return;
|
|
72
|
-
const sub = args.trim().toLowerCase();
|
|
73
|
-
|
|
74
|
-
if (sub === "status") {
|
|
75
|
-
ctx.ui.notify(`bytefly: header ${headerState()}, footer ${enabled ? "on" : "off"}`, "info");
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
if (sub === "header") {
|
|
79
|
-
if (!header) {
|
|
80
|
-
ctx.ui.notify("bytefly: header surface is not available", "warning");
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
header.setEnabled(!header.isEnabled());
|
|
84
|
-
header.apply(ctx);
|
|
85
|
-
ctx.ui.notify(`bytefly header ${headerState()}`, "info");
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if (sub === "footer") {
|
|
89
|
-
enabled = !enabled;
|
|
90
|
-
install(ctx);
|
|
91
|
-
ctx.ui.notify(`bytefly footer ${enabled ? "on" : "off"}`, "info");
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (sub === "") {
|
|
95
|
-
// The master switch brings both surfaces to one state.
|
|
96
|
-
const next = !(enabled && (header?.isEnabled() ?? true));
|
|
97
|
-
enabled = next;
|
|
98
|
-
install(ctx);
|
|
99
|
-
if (header) {
|
|
100
|
-
header.setEnabled(next);
|
|
101
|
-
header.apply(ctx);
|
|
102
|
-
}
|
|
103
|
-
ctx.ui.notify(`bytefly ${next ? "on" : "off"}`, "info");
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
ctx.ui.notify("usage: /bytefly [header|footer|status]", "warning");
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
|
|
110
36
|
pi.on("session_start", (_event, ctx) => install(ctx));
|
|
111
37
|
pi.on("session_tree", (_event, ctx) => install(ctx));
|
|
112
38
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
@@ -65,10 +65,14 @@ export class ByteflyHeader implements Component {
|
|
|
65
65
|
const pad = " ".repeat(Math.min(padX, Math.max(0, w - 1)));
|
|
66
66
|
const [markTop, markBottom] = markLines(theme, view.halfBlocks);
|
|
67
67
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
const version = view.showVersion && this.version ? theme.fg(versionColor, `v${this.version}`) : "";
|
|
69
|
+
// The wordmark mark spells the app name, so the agent cell carries only the
|
|
70
|
+
// version there; the half-block mark does not, so the name stays beside it.
|
|
71
|
+
const agent = view.halfBlocks
|
|
72
|
+
? version
|
|
73
|
+
? `${theme.fg(nameColor, HEADER_CONFIG.appName)} ${version}`
|
|
74
|
+
: theme.fg(nameColor, HEADER_CONFIG.appName)
|
|
75
|
+
: version;
|
|
72
76
|
const chrome = `${theme.fg(versionColor, chromeTagline)} ${theme.fg(nameColor, chromeName)}`;
|
|
73
77
|
const chromeVersioned = chromeVersion ? `${chrome} ${theme.fg(versionColor, `v${chromeVersion}`)}` : chrome;
|
|
74
78
|
|
|
@@ -58,20 +58,25 @@ export const HEADER_CONFIG: HeaderConfig = {
|
|
|
58
58
|
[
|
|
59
59
|
{ binding: "app.interrupt", description: "to interrupt" },
|
|
60
60
|
{ binding: "app.clear", description: "to clear" },
|
|
61
|
-
{ binding: "app.exit", description: "to exit" },
|
|
61
|
+
{ binding: "app.exit", description: "to exit (empty)" },
|
|
62
62
|
{ binding: "app.suspend", description: "to suspend" },
|
|
63
63
|
],
|
|
64
64
|
[
|
|
65
|
-
{ binding: EXPAND_KEYBINDING, description: "
|
|
66
|
-
{ binding: "app.thinking.toggle", description: "to
|
|
67
|
-
{ binding: "app.model.select", description: "
|
|
68
|
-
{ binding: "app.
|
|
65
|
+
{ binding: EXPAND_KEYBINDING, description: "to expand tools" },
|
|
66
|
+
{ binding: "app.thinking.toggle", description: "to expand thinking" },
|
|
67
|
+
{ binding: "app.model.select", description: "to select model" },
|
|
68
|
+
{ binding: "app.model.cycleForward", description: "to cycle models" },
|
|
69
|
+
{ binding: "app.thinking.cycle", description: "to cycle thinking" },
|
|
70
|
+
{ binding: "app.editor.external", description: "for external editor" },
|
|
69
71
|
],
|
|
70
72
|
[
|
|
71
73
|
{ raw: "/", description: "commands" },
|
|
72
74
|
{ raw: "!", description: "bash" },
|
|
73
|
-
{ raw: "
|
|
75
|
+
{ raw: "!!", description: "bash (no context)" },
|
|
76
|
+
{ raw: "/bytefly", description: "extensions" },
|
|
74
77
|
{ raw: "/doctor", description: "checks" },
|
|
78
|
+
{ binding: "app.message.followUp", description: "to queue follow-up" },
|
|
79
|
+
{ binding: "app.clipboard.pasteImage", description: "paste files" },
|
|
75
80
|
],
|
|
76
81
|
],
|
|
77
82
|
};
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The header surface: installs bytefly's custom header on interactive
|
|
3
|
-
*
|
|
2
|
+
* The header surface: installs bytefly's custom header on interactive sessions
|
|
3
|
+
* and restores Pi's built-in header on shutdown.
|
|
4
4
|
*
|
|
5
|
-
* The surface is
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* other mode leaves Pi's built-in header in place, which does not exist there.
|
|
5
|
+
* The surface is always on; `pi config` is the way to turn bytefly's chrome off.
|
|
6
|
+
* It is installed only in interactive (`tui`) sessions; every other mode leaves
|
|
7
|
+
* Pi's built-in header in place, which does not exist there.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -13,27 +12,16 @@ import { isAppleTerminalSession } from "@earendil-works/pi-tui";
|
|
|
13
12
|
import { createHeader, type HeaderView } from "./component.ts";
|
|
14
13
|
import { HEADER_CONFIG } from "./config.ts";
|
|
15
14
|
|
|
16
|
-
/** The header surface, as `/bytefly` drives it. */
|
|
17
|
-
export interface HeaderSurface {
|
|
18
|
-
/** Install the header, or restore Pi's built-in one, from the current state. */
|
|
19
|
-
apply(ctx: ExtensionContext): void;
|
|
20
|
-
/** Whether the header is currently on. */
|
|
21
|
-
isEnabled(): boolean;
|
|
22
|
-
/** Set the enabled flag without installing. */
|
|
23
|
-
setEnabled(enabled: boolean): void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
15
|
/** Register the install/restore lifecycle for the custom header. */
|
|
27
|
-
export function registerHeader(pi: ExtensionAPI):
|
|
16
|
+
export function registerHeader(pi: ExtensionAPI): void {
|
|
28
17
|
// Half blocks misalign in Apple Terminal, so the mark falls back to a wordmark
|
|
29
18
|
// there, mirroring Pi's own built-in header.
|
|
30
19
|
const halfBlocks = !isAppleTerminalSession();
|
|
31
20
|
const view = (): HeaderView => ({ showVersion: HEADER_CONFIG.showVersion, halfBlocks });
|
|
32
|
-
let enabled = true;
|
|
33
21
|
|
|
34
22
|
const apply = (ctx: ExtensionContext): void => {
|
|
35
23
|
if (ctx.mode !== "tui") return;
|
|
36
|
-
ctx.ui.setHeader(
|
|
24
|
+
ctx.ui.setHeader(createHeader(() => ctx.ui.theme, view));
|
|
37
25
|
};
|
|
38
26
|
|
|
39
27
|
pi.on("session_start", (_event, ctx) => apply(ctx));
|
|
@@ -42,12 +30,4 @@ export function registerHeader(pi: ExtensionAPI): HeaderSurface {
|
|
|
42
30
|
if (ctx.mode !== "tui") return;
|
|
43
31
|
ctx.ui.setHeader(undefined);
|
|
44
32
|
});
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
apply,
|
|
48
|
-
isEnabled: () => enabled,
|
|
49
|
-
setEnabled: (value: boolean) => {
|
|
50
|
-
enabled = value;
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
33
|
}
|
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
* bytefly — the package's terminal chrome and diagnostics, composed from six
|
|
3
3
|
* feature folders: `header/` (the themed startup mark and version), `footer/`
|
|
4
4
|
* (the two-line, width-adaptive status bar with its price calculation),
|
|
5
|
-
* `separator/` (the transcript turn rule), `
|
|
6
|
-
*
|
|
5
|
+
* `separator/` (the transcript turn rule), `panel/` (the unified `/bytefly`
|
|
6
|
+
* screen, also reachable as `/extensions` and `/doctor`), `doctor/` (the
|
|
7
|
+
* `run_doctor` tool and its session-scoped probe runtime), and `notify/` (OS
|
|
7
8
|
* notifications for unattended runs).
|
|
8
9
|
*
|
|
9
10
|
* The header, footer, and turn rule are installed only in interactive (`tui`)
|
|
10
|
-
* sessions
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
11
|
+
* sessions and are always on; `pi config` turns bytefly's chrome off. The panel
|
|
12
|
+
* and doctor work in every mode, and notify works wherever the environment can
|
|
13
|
+
* deliver it. The footer is priced from DeepSeek's published rates by default
|
|
14
|
+
* and from `bytefly.json` otherwise, the same file whose `notify` section
|
|
15
|
+
* configures notifications.
|
|
15
16
|
*
|
|
16
17
|
* Load with: pi --extension ./extensions/bytefly
|
|
17
18
|
*/
|
|
@@ -22,13 +23,13 @@ import { type DoctorDeps, registerDoctor } from "./doctor/register.ts";
|
|
|
22
23
|
import { registerFooter } from "./footer/register.ts";
|
|
23
24
|
import { registerHeader } from "./header/register.ts";
|
|
24
25
|
import { type NotifyDeps, registerNotify } from "./notify/register.ts";
|
|
25
|
-
import { type
|
|
26
|
+
import { type PanelCommandDeps, registerPanel } from "./panel/commands.ts";
|
|
26
27
|
import { registerTurnSeparator } from "./separator/register.ts";
|
|
27
28
|
|
|
28
29
|
/** Host seams, injected by tests. */
|
|
29
30
|
export interface ByteflyDeps {
|
|
30
|
-
/** Seams for the
|
|
31
|
-
extensions?:
|
|
31
|
+
/** Seams for the picker side of the unified panel. */
|
|
32
|
+
extensions?: Pick<PanelCommandDeps, "discover">;
|
|
32
33
|
/** Seams for the doctor surface. */
|
|
33
34
|
doctor?: DoctorDeps;
|
|
34
35
|
/** Seams for the notify capability. */
|
|
@@ -39,10 +40,10 @@ export interface ByteflyDeps {
|
|
|
39
40
|
export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void {
|
|
40
41
|
if (!isExtensionEnabled("bytefly")) return;
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
registerFooter(pi
|
|
43
|
+
registerHeader(pi);
|
|
44
|
+
registerFooter(pi);
|
|
44
45
|
registerTurnSeparator(pi);
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const doctor = registerDoctor(pi, deps.doctor);
|
|
47
|
+
registerPanel(pi, { doctor, discover: deps.extensions?.discover });
|
|
47
48
|
registerNotify(pi, deps.notify);
|
|
48
49
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/bytefly` surface: one runner that opens the unified panel, with
|
|
3
|
+
* `/extensions` and `/doctor` registered against the same handler.
|
|
4
|
+
*
|
|
5
|
+
* In `tui` mode it mounts the dock screen; every other mode prints the same
|
|
6
|
+
* information as text. Discovery, the doctor probe, and the dirty-to-reload
|
|
7
|
+
* confirm all live here, so the three command names cannot drift. The doctor
|
|
8
|
+
* runtime is injected by the composition root, so `run_doctor` and the screen
|
|
9
|
+
* keep reading one session cache.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { sanitizeLine } from "../../../lib/format.ts";
|
|
15
|
+
import { withRailsSuppressed } from "../../../lib/rails.ts";
|
|
16
|
+
import { REQUIREMENTS, type ExtensionReport } from "../../../lib/requirements.ts";
|
|
17
|
+
import { formatExtension, hasProblems } from "../doctor/format.ts";
|
|
18
|
+
import type { DoctorRuntime } from "../doctor/runtime.ts";
|
|
19
|
+
import { discoverExtensions, type DiscoveryDeps } from "../picker/discovery.ts";
|
|
20
|
+
import { createExtensionsPickerRuntime } from "../picker/runtime.ts";
|
|
21
|
+
import type { Discovery } from "../picker/types.ts";
|
|
22
|
+
import { formatListing } from "./format.ts";
|
|
23
|
+
import { buildPanelModel, extensionName } from "./join.ts";
|
|
24
|
+
import { PanelComponent } from "./tui.ts";
|
|
25
|
+
|
|
26
|
+
/** Host seams for the panel command, injected by tests and the composition root. */
|
|
27
|
+
export interface PanelCommandDeps {
|
|
28
|
+
/** The doctor runtime created by `registerDoctor`; shared with `run_doctor`. */
|
|
29
|
+
doctor: DoctorRuntime;
|
|
30
|
+
/** The picker's discovery seam. */
|
|
31
|
+
discover?: (deps: DiscoveryDeps) => Promise<Discovery>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Names `/bytefly <name>` accepts: every declared extension, plus the directory
|
|
36
|
+
* of any resource in either discovery view. Reading both views keeps a
|
|
37
|
+
* project-only name valid before the screen has switched to Project scope.
|
|
38
|
+
*/
|
|
39
|
+
function selectableNames(discovery: Discovery): Set<string> {
|
|
40
|
+
const names = new Set<string>(Object.keys(REQUIREMENTS));
|
|
41
|
+
for (const item of [...discovery.global, ...discovery.effective]) names.add(extensionName(item.path));
|
|
42
|
+
return names;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The shared completion list: every declared extension, then `refresh`. */
|
|
46
|
+
function completions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
47
|
+
const options = [...Object.keys(REQUIREMENTS), "refresh"].filter((option) => option.startsWith(prefix));
|
|
48
|
+
return options.length > 0 ? options.map((value) => ({ value, label: value })) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Register `/bytefly`, `/extensions`, and `/doctor` against one runner. */
|
|
52
|
+
export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
|
|
53
|
+
const discover = deps.discover ?? discoverExtensions;
|
|
54
|
+
|
|
55
|
+
const handler = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
56
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
57
|
+
const refresh = parts.includes("refresh");
|
|
58
|
+
const name = parts.find((part) => part !== "refresh");
|
|
59
|
+
|
|
60
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
61
|
+
const agentDir = getAgentDir();
|
|
62
|
+
let discovery: Discovery;
|
|
63
|
+
try {
|
|
64
|
+
discovery = await discover({ cwd, agentDir, trusted: ctx.isProjectTrusted() });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
ctx.ui.notify(`Could not list extensions: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const runtime = createExtensionsPickerRuntime({
|
|
71
|
+
discovery,
|
|
72
|
+
cwd,
|
|
73
|
+
agentDir,
|
|
74
|
+
onError: (message) => ctx.ui.notify(message, "error"),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
let reports: ExtensionReport[];
|
|
78
|
+
try {
|
|
79
|
+
reports = await deps.doctor.probeAll({ refresh });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
ctx.ui.notify(`Could not probe extensions: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (name !== undefined && !selectableNames(discovery).has(name)) {
|
|
85
|
+
ctx.ui.notify(`bytefly: no extension named "${sanitizeLine(name)}"`, "warning");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (ctx.mode !== "tui") {
|
|
90
|
+
if (name !== undefined) {
|
|
91
|
+
const report = reports.find((entry) => entry.extension === name);
|
|
92
|
+
if (report) {
|
|
93
|
+
ctx.ui.notify(formatExtension(report), hasProblems([report]) ? "warning" : "info");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const item = runtime.items().find((entry) => extensionName(entry.path) === name);
|
|
97
|
+
ctx.ui.notify(
|
|
98
|
+
item
|
|
99
|
+
? `bytefly: ${sanitizeLine(item.path)} — no doctor report`
|
|
100
|
+
: `bytefly: no extension named "${sanitizeLine(name)}"`,
|
|
101
|
+
"info",
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const model = buildPanelModel({ items: runtime.items(), reports, agentDir });
|
|
106
|
+
ctx.ui.notify(formatListing(model, runtime.scope()), "info");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The dock screen owns the editor slot; hide the rails for its lifetime.
|
|
111
|
+
await withRailsSuppressed(pi, () =>
|
|
112
|
+
ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
113
|
+
const unsubscribe = runtime.onChange(() => tui.requestRender());
|
|
114
|
+
return new PanelComponent({
|
|
115
|
+
runtime,
|
|
116
|
+
reports: () => reports,
|
|
117
|
+
filter: name,
|
|
118
|
+
theme,
|
|
119
|
+
agentDir,
|
|
120
|
+
viewportRows: () => tui.terminal?.rows,
|
|
121
|
+
requestRender: () => tui.requestRender(),
|
|
122
|
+
refresh: async () => {
|
|
123
|
+
reports = await deps.doctor.probeAll({ refresh: true });
|
|
124
|
+
},
|
|
125
|
+
onClose: () => {
|
|
126
|
+
unsubscribe();
|
|
127
|
+
done();
|
|
128
|
+
},
|
|
129
|
+
onNotice: (message) => ctx.ui.notify(message, "warning"),
|
|
130
|
+
});
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (!runtime.isDirty()) return;
|
|
135
|
+
const reload = await ctx.ui.confirm("Reload to apply changes?", "Extension changes take effect after a reload.");
|
|
136
|
+
if (reload) await ctx.reload();
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const descriptions: Array<[string, string]> = [
|
|
140
|
+
["bytefly", "Open the bytefly extension screen: /bytefly [extension] [refresh]"],
|
|
141
|
+
["extensions", "List and enable/disable extensions"],
|
|
142
|
+
["doctor", "Check extensions and enable or disable one: /doctor [extension] [refresh]"],
|
|
143
|
+
];
|
|
144
|
+
for (const [command, description] of descriptions) {
|
|
145
|
+
pi.registerCommand(command, { description, getArgumentCompletions: completions, handler });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-TUI text for the unified panel.
|
|
3
|
+
*
|
|
4
|
+
* Every mode other than `tui` gets the same facts the screen shows, as plain
|
|
5
|
+
* text: a headline with the scope and the counts, then the picker's grouped
|
|
6
|
+
* listing with a health suffix on each row. A single named extension prints its
|
|
7
|
+
* full findings instead. Nothing here touches the host, and every string that
|
|
8
|
+
* came from a probe is sanitized by `doctor/format.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { GLYPHS } from "../../../lib/ui.ts";
|
|
13
|
+
import { reportSeverity, reportSummary } from "../doctor/format.ts";
|
|
14
|
+
import type { PanelModel, PanelRow } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/** The settings scope the screen shows. */
|
|
17
|
+
export type PanelScope = "global" | "project";
|
|
18
|
+
|
|
19
|
+
/** One segment of the headline, with its semantic color for the TUI. */
|
|
20
|
+
export interface SummarySegment {
|
|
21
|
+
text: string;
|
|
22
|
+
color: ThemeColor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Row and problem counts over a model. */
|
|
26
|
+
export interface PanelCounts {
|
|
27
|
+
extensions: number;
|
|
28
|
+
disabled: number;
|
|
29
|
+
missing: number;
|
|
30
|
+
degraded: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Options for the plain-text headline and listing. */
|
|
34
|
+
export interface PanelTextOptions {
|
|
35
|
+
/** Abbreviate counts (`15 ext · 3 off`) for a narrow terminal. */
|
|
36
|
+
compact?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Options for the TUI headline, which can also name a narrowed extension. */
|
|
40
|
+
export interface SummaryOptions extends PanelTextOptions {
|
|
41
|
+
/** Name the narrowed extension instead of counting the list. */
|
|
42
|
+
filter?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Count the rows and their problems, once per unique report. */
|
|
46
|
+
export function panelCounts(model: PanelModel): PanelCounts {
|
|
47
|
+
let disabled = 0;
|
|
48
|
+
let missing = 0;
|
|
49
|
+
let degraded = 0;
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
for (const row of model.rows) {
|
|
52
|
+
if (row.item && !row.item.enabled) disabled += 1;
|
|
53
|
+
const report = row.report;
|
|
54
|
+
if (!report || seen.has(report.extension)) continue;
|
|
55
|
+
seen.add(report.extension);
|
|
56
|
+
missing += report.findings.filter((finding) => finding.severity === "missing").length;
|
|
57
|
+
degraded += report.findings.filter((finding) => finding.severity === "degraded").length;
|
|
58
|
+
}
|
|
59
|
+
return { extensions: model.rows.length, disabled, missing, degraded };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The headline segments, e.g. `Global · 15 extensions · 3 disabled · 1 missing`.
|
|
64
|
+
* A narrowed model names the extension instead of counting.
|
|
65
|
+
*/
|
|
66
|
+
export function summarySegments(model: PanelModel, scope: PanelScope, options: SummaryOptions = {}): SummarySegment[] {
|
|
67
|
+
const counts = panelCounts(model);
|
|
68
|
+
const segments: SummarySegment[] = [{ text: scope === "global" ? "Global" : "Project", color: "accent" }];
|
|
69
|
+
if (options.filter !== undefined) {
|
|
70
|
+
segments.push({ text: `${options.filter} only`, color: "muted" });
|
|
71
|
+
} else {
|
|
72
|
+
const noun = options.compact ? "ext" : counts.extensions === 1 ? "extension" : "extensions";
|
|
73
|
+
segments.push({ text: `${counts.extensions} ${noun}`, color: "muted" });
|
|
74
|
+
if (counts.disabled > 0) {
|
|
75
|
+
segments.push({ text: `${counts.disabled} ${options.compact ? "off" : "disabled"}`, color: "muted" });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const problems: string[] = [];
|
|
79
|
+
if (counts.missing > 0) problems.push(`${counts.missing} missing`);
|
|
80
|
+
if (counts.degraded > 0) problems.push(`${counts.degraded} degraded`);
|
|
81
|
+
if (problems.length > 0) {
|
|
82
|
+
segments.push({ text: problems.join(", "), color: counts.missing > 0 ? "error" : "warning" });
|
|
83
|
+
}
|
|
84
|
+
return segments;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The headline as one plain line, e.g. `bytefly: Global · 15 extensions · 1 missing`. */
|
|
88
|
+
export function formatHeadline(model: PanelModel, scope: PanelScope, options: PanelTextOptions = {}): string {
|
|
89
|
+
return `bytefly: ${summarySegments(model, scope, options)
|
|
90
|
+
.map((segment) => segment.text)
|
|
91
|
+
.join(" · ")}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Plain severity glyph for a row, or `undefined` when it has no report. */
|
|
95
|
+
function rowGlyph(row: PanelRow): string | undefined {
|
|
96
|
+
if (!row.report) return undefined;
|
|
97
|
+
switch (reportSeverity(row.report)) {
|
|
98
|
+
case "missing":
|
|
99
|
+
return GLYPHS.cross;
|
|
100
|
+
case "degraded":
|
|
101
|
+
return GLYPHS.warn;
|
|
102
|
+
case "info":
|
|
103
|
+
return GLYPHS.disabled;
|
|
104
|
+
default:
|
|
105
|
+
return GLYPHS.check;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One listing row: `[x] ✓ label — summary`, with the suffix only when reported. */
|
|
110
|
+
export function formatRow(row: PanelRow): string {
|
|
111
|
+
const glyph = rowGlyph(row);
|
|
112
|
+
const summary = row.report ? ` — ${reportSummary(row.report)}` : "";
|
|
113
|
+
if (!row.item) {
|
|
114
|
+
// Reported-only: there is no enable pattern to show, so no checkbox column.
|
|
115
|
+
return ` ${glyph ? `${glyph} ` : ""}${row.label}${summary}`;
|
|
116
|
+
}
|
|
117
|
+
const checkbox = row.item.enabled ? "[x]" : "[ ]";
|
|
118
|
+
return ` ${checkbox} ${glyph ? `${glyph} ` : ""}${row.label}${summary}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The grouped text listing, headline first, for RPC/JSON/print modes. */
|
|
122
|
+
export function formatListing(model: PanelModel, scope: PanelScope, options: PanelTextOptions = {}): string {
|
|
123
|
+
const lines = [formatHeadline(model, scope, options)];
|
|
124
|
+
if (model.rows.length === 0) {
|
|
125
|
+
lines.push("", "No extensions found.");
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
for (const group of model.groups) {
|
|
129
|
+
lines.push("", `${group.label} (${group.count})`);
|
|
130
|
+
for (const row of model.rows) {
|
|
131
|
+
if (row.group === group.key) lines.push(formatRow(row));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|