@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge behind the unified panel: one row per extension resource, joining
|
|
3
|
+
* the picker's resolved items to the doctor's reports.
|
|
4
|
+
*
|
|
5
|
+
* The join key is the extension directory (`basename(dirname(path))`), not the
|
|
6
|
+
* display name: a subfolder resource is shown as `web-access/index.ts` but its
|
|
7
|
+
* report is named `web-access`. A directory name is only usable when it maps to
|
|
8
|
+
* exactly one unique resource path — two unrelated extensions can share a
|
|
9
|
+
* directory name (a user `plan` beside the package's `plan`) and every flat file
|
|
10
|
+
* has the shared directory `extensions`, so a colliding name claims no report.
|
|
11
|
+
* The report then falls through to the trailing `Not resolved` group rather than
|
|
12
|
+
* lending one resource another's health.
|
|
13
|
+
*
|
|
14
|
+
* Row keys are the unique resource identity (`source:path`, or `report:<name>`
|
|
15
|
+
* for a report with no item) so the cursor survives a rebuild even when two
|
|
16
|
+
* rows share a directory name. Items keep the picker's grouping and order, and
|
|
17
|
+
* a row never invents health: an item without a report carries no glyph and no
|
|
18
|
+
* summary.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { basename, dirname } from "node:path";
|
|
22
|
+
import type { ExtensionReport } from "../../../lib/requirements.ts";
|
|
23
|
+
import { buildEntries } from "../picker/resources.ts";
|
|
24
|
+
import type { ResourceItem } from "../picker/types.ts";
|
|
25
|
+
import type { PanelGroup, PanelModel, PanelRow } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
/** The extension directory name that joins a resolved item to its report. */
|
|
28
|
+
export function extensionName(path: string): string {
|
|
29
|
+
return basename(dirname(path));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Unique identity of a resolved resource, independent of the directory name. */
|
|
33
|
+
export function resourceKey(item: ResourceItem): string {
|
|
34
|
+
return `${item.metadata.source}:${item.path}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Row key for a report that has no resolved extension item. */
|
|
38
|
+
function reportKey(name: string): string {
|
|
39
|
+
return `report:${name}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Group key and label for reports with no resolved extension item. */
|
|
43
|
+
export const REPORT_ONLY_GROUP = "reported-only";
|
|
44
|
+
export const REPORT_ONLY_LABEL = "Not resolved";
|
|
45
|
+
|
|
46
|
+
export interface PanelJoinOptions {
|
|
47
|
+
items: readonly ResourceItem[];
|
|
48
|
+
reports: readonly ExtensionReport[];
|
|
49
|
+
agentDir: string;
|
|
50
|
+
/** Narrow to one extension directory or report name, or `undefined` for the full list. */
|
|
51
|
+
filter?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the merged model for the current items and reports. */
|
|
55
|
+
export function buildPanelModel(options: PanelJoinOptions): PanelModel {
|
|
56
|
+
const { items, reports, agentDir, filter } = options;
|
|
57
|
+
const byName = new Map(reports.map((report) => [report.extension, report]));
|
|
58
|
+
|
|
59
|
+
// A directory name may claim a report only when exactly one unique resource
|
|
60
|
+
// path uses it. A name shared by two resources is ambiguous, so no row claims
|
|
61
|
+
// the report and it stays available to the trailing `Not resolved` group.
|
|
62
|
+
const pathsByName = new Map<string, Set<string>>();
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const name = extensionName(item.path);
|
|
65
|
+
const paths = pathsByName.get(name) ?? new Set<string>();
|
|
66
|
+
paths.add(resourceKey(item));
|
|
67
|
+
pathsByName.set(name, paths);
|
|
68
|
+
}
|
|
69
|
+
const unambiguous = (name: string): boolean => pathsByName.get(name)?.size === 1;
|
|
70
|
+
|
|
71
|
+
const claimed = new Set<string>();
|
|
72
|
+
const groups: PanelGroup[] = [];
|
|
73
|
+
const rows: PanelRow[] = [];
|
|
74
|
+
let current: PanelGroup | undefined;
|
|
75
|
+
|
|
76
|
+
for (const entry of buildEntries([...items], agentDir)) {
|
|
77
|
+
if (entry.kind === "group") {
|
|
78
|
+
current = { key: entry.key, label: entry.label, count: 0 };
|
|
79
|
+
groups.push(current);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const item = entry.item;
|
|
83
|
+
const name = extensionName(item.path);
|
|
84
|
+
const report = unambiguous(name) ? byName.get(name) : undefined;
|
|
85
|
+
if (report) claimed.add(name);
|
|
86
|
+
if (filter !== undefined && name !== filter) continue;
|
|
87
|
+
rows.push({ key: resourceKey(item), item, report, label: entry.label, group: current?.key ?? "" });
|
|
88
|
+
if (current) current.count += 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const extras = reports.filter(
|
|
92
|
+
(report) => !claimed.has(report.extension) && (filter === undefined || report.extension === filter),
|
|
93
|
+
);
|
|
94
|
+
if (extras.length > 0) {
|
|
95
|
+
const group: PanelGroup = { key: REPORT_ONLY_GROUP, label: REPORT_ONLY_LABEL, count: extras.length };
|
|
96
|
+
groups.push(group);
|
|
97
|
+
for (const report of extras) {
|
|
98
|
+
rows.push({ key: reportKey(report.extension), report, label: report.extension, group: group.key });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { groups: groups.filter((group) => group.count > 0), rows };
|
|
103
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unified bytefly panel: one docked screen listing every extension with its
|
|
3
|
+
* enable checkbox and its health.
|
|
4
|
+
*
|
|
5
|
+
* The component is derived: it rebuilds the merged model from the picker
|
|
6
|
+
* runtime and the current reports on every render, keeps the focused row's key
|
|
7
|
+
* across rebuilds, and requests a repaint only after an input changes state.
|
|
8
|
+
* Screen chrome and scroll math come from `lib/tui.ts` and `lib/list-cursor.ts`.
|
|
9
|
+
* A row without a report renders no glyph and no summary, never a false `ok`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
Key,
|
|
15
|
+
matchesKey,
|
|
16
|
+
truncateToWidth,
|
|
17
|
+
visibleWidth,
|
|
18
|
+
type Component,
|
|
19
|
+
type TuiMouseEvent,
|
|
20
|
+
type TuiMouseEventResult,
|
|
21
|
+
} from "@earendil-works/pi-tui";
|
|
22
|
+
import { sanitizeLine } from "../../../lib/format.ts";
|
|
23
|
+
import {
|
|
24
|
+
fitRows,
|
|
25
|
+
formatRange,
|
|
26
|
+
isCloseKey,
|
|
27
|
+
ListCursor,
|
|
28
|
+
navIntent,
|
|
29
|
+
selectionMarker,
|
|
30
|
+
wheelDelta,
|
|
31
|
+
} from "../../../lib/list-cursor.ts";
|
|
32
|
+
import type { ExtensionReport } from "../../../lib/requirements.ts";
|
|
33
|
+
import { dockListFrame, screenHint, type ViewportRowsSource } from "../../../lib/tui.ts";
|
|
34
|
+
import { detailLines as reportDetailLines, reportSeverity, reportSummary, severityGlyph } from "../doctor/format.ts";
|
|
35
|
+
import type { ExtensionsPickerRuntime } from "../picker/runtime.ts";
|
|
36
|
+
import { summarySegments } from "./format.ts";
|
|
37
|
+
import { buildPanelModel } from "./join.ts";
|
|
38
|
+
import type { PanelModel, PanelRow } from "./types.ts";
|
|
39
|
+
|
|
40
|
+
/** Body rows when the terminal height is unknown. */
|
|
41
|
+
const SCREEN_DEFAULT_ROWS = 16;
|
|
42
|
+
/** Fixed rows around the body: header, summary, blank, blank, hint, blank. */
|
|
43
|
+
const FRAME_ROWS = 6;
|
|
44
|
+
/** Detail rows reserved for the focused extension's findings. */
|
|
45
|
+
const DETAIL_ROWS = 4;
|
|
46
|
+
/** Body rows that must survive before the detail pane collapses to its title. */
|
|
47
|
+
const MIN_BODY_ROWS = 3;
|
|
48
|
+
|
|
49
|
+
/** A flat display entry: a group header or a selectable row. */
|
|
50
|
+
type PanelEntry = { kind: "group"; key: string; label: string; count: number } | { kind: "row"; row: PanelRow };
|
|
51
|
+
|
|
52
|
+
/** The next row from `from` in `direction`, or `undefined` when there is none. */
|
|
53
|
+
function nextRow(entries: PanelEntry[], from: number, direction: -1 | 1): number | undefined {
|
|
54
|
+
for (let index = from + direction; index >= 0 && index < entries.length; index += direction) {
|
|
55
|
+
if (entries[index].kind === "row") return index;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Interleave the model's groups and rows into one display list. */
|
|
61
|
+
function entriesOf(model: PanelModel): PanelEntry[] {
|
|
62
|
+
const entries: PanelEntry[] = [];
|
|
63
|
+
for (const group of model.groups) {
|
|
64
|
+
entries.push({ kind: "group", key: group.key, label: group.label, count: group.count });
|
|
65
|
+
for (const row of model.rows) {
|
|
66
|
+
if (row.group === group.key) entries.push({ kind: "row", row });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return entries;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Terminal rows, or `undefined` when the height is unknown. */
|
|
73
|
+
function totalRows(source: ViewportRowsSource | undefined): number | undefined {
|
|
74
|
+
const rows = typeof source === "function" ? source() : source;
|
|
75
|
+
return rows !== undefined && Number.isFinite(rows) && rows > 0 ? rows : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Host seams for the panel screen, injected by the command runner. */
|
|
79
|
+
export interface PanelComponentOptions {
|
|
80
|
+
/** The session's picker runtime: items, scope, toggles, dirty flag. */
|
|
81
|
+
runtime: ExtensionsPickerRuntime;
|
|
82
|
+
/** Current reports; read on every render so a refresh is picked up. */
|
|
83
|
+
reports: () => readonly ExtensionReport[];
|
|
84
|
+
/** Narrow to one extension directory, when the command named one. */
|
|
85
|
+
filter?: string;
|
|
86
|
+
theme: Theme;
|
|
87
|
+
agentDir: string;
|
|
88
|
+
/** Live terminal height, for the row budget. */
|
|
89
|
+
viewportRows?: ViewportRowsSource;
|
|
90
|
+
requestRender: () => void;
|
|
91
|
+
/** Re-probe with refresh; awaited before the busy state clears. */
|
|
92
|
+
refresh: () => void | Promise<void>;
|
|
93
|
+
onClose: () => void;
|
|
94
|
+
onNotice?: (message: string) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The docked bytefly panel. */
|
|
98
|
+
export class PanelComponent implements Component {
|
|
99
|
+
private readonly cursor: ListCursor;
|
|
100
|
+
/** Serialises toggles so a rapid double press toggles twice, not once. */
|
|
101
|
+
private chain: Promise<void> = Promise.resolve();
|
|
102
|
+
/** Key of the focused row, so it survives a rebuild that reorders rows. */
|
|
103
|
+
private focusedKey: string | undefined;
|
|
104
|
+
/** Whether the detail pane shows every check or only problems. */
|
|
105
|
+
private showDetail = false;
|
|
106
|
+
/** True while a re-probe is in flight, so `r` does not stack probes. */
|
|
107
|
+
private refreshing = false;
|
|
108
|
+
|
|
109
|
+
constructor(private readonly options: PanelComponentOptions) {
|
|
110
|
+
this.cursor = new ListCursor(() => this.options.requestRender());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The active theme, read live on every render. */
|
|
114
|
+
private get theme(): Theme {
|
|
115
|
+
return this.options.theme;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
invalidate(): void {
|
|
119
|
+
// Everything is rebuilt from live state on each render.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The merged model for the current items and reports. */
|
|
123
|
+
private model(): PanelModel {
|
|
124
|
+
return buildPanelModel({
|
|
125
|
+
items: this.options.runtime.items(),
|
|
126
|
+
reports: this.options.reports(),
|
|
127
|
+
agentDir: this.options.agentDir,
|
|
128
|
+
filter: this.options.filter,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private entries(): PanelEntry[] {
|
|
133
|
+
return entriesOf(this.model());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Keep the focus on the same row key when the list shape changes. */
|
|
137
|
+
private retainFocus(entries: PanelEntry[]): void {
|
|
138
|
+
const entry = entries[this.cursor.selected];
|
|
139
|
+
if (entry?.kind === "row") {
|
|
140
|
+
this.focusedKey = entry.row.key;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (this.focusedKey === undefined) return;
|
|
144
|
+
const index = entries.findIndex((candidate) => candidate.kind === "row" && candidate.row.key === this.focusedKey);
|
|
145
|
+
if (index >= 0) this.cursor.selected = index;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Move onto the first row when the focus is not already on one. */
|
|
149
|
+
private clampSelection(entries: PanelEntry[]): void {
|
|
150
|
+
if (entries[this.cursor.selected]?.kind === "row") return;
|
|
151
|
+
const first = entries.findIndex((entry) => entry.kind === "row");
|
|
152
|
+
if (first >= 0) this.cursor.selected = first;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private firstRow(): number | undefined {
|
|
156
|
+
const index = this.entries().findIndex((entry) => entry.kind === "row");
|
|
157
|
+
return index >= 0 ? index : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private lastRow(): number | undefined {
|
|
161
|
+
const entries = this.entries();
|
|
162
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
163
|
+
if (entries[index].kind === "row") return index;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private select(index: number | undefined): void {
|
|
169
|
+
if (index === undefined) return;
|
|
170
|
+
this.cursor.set(index, this.entries().length);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Move `delta` rows, skipping group headers. */
|
|
174
|
+
private move(delta: number): void {
|
|
175
|
+
if (delta === 0) return;
|
|
176
|
+
this.clampSelection(this.entries());
|
|
177
|
+
const entries = this.entries();
|
|
178
|
+
const direction = delta < 0 ? -1 : 1;
|
|
179
|
+
let index = this.cursor.selected;
|
|
180
|
+
let remaining = Math.abs(delta);
|
|
181
|
+
while (remaining > 0) {
|
|
182
|
+
const next = nextRow(entries, index, direction);
|
|
183
|
+
if (next === undefined) break;
|
|
184
|
+
index = next;
|
|
185
|
+
remaining -= 1;
|
|
186
|
+
}
|
|
187
|
+
this.select(index);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private switchScope(): void {
|
|
191
|
+
const next = this.options.runtime.scope() === "global" ? "project" : "global";
|
|
192
|
+
if (next === "project" && !this.options.runtime.projectAvailable()) {
|
|
193
|
+
this.options.onNotice?.("Project is not trusted; manage extensions in Global scope only.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.options.runtime.setScope(next);
|
|
197
|
+
this.options.requestRender();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private toggle(): void {
|
|
201
|
+
const entry = this.entries()[this.cursor.selected];
|
|
202
|
+
if (entry?.kind !== "row" || !entry.row.item) return;
|
|
203
|
+
const item = entry.row.item;
|
|
204
|
+
this.chain = this.chain
|
|
205
|
+
.then(() => this.options.runtime.toggle(item))
|
|
206
|
+
.catch(() => undefined)
|
|
207
|
+
.then(() => this.options.requestRender());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Re-probe once; a second request while one is in flight is ignored. */
|
|
211
|
+
async refresh(): Promise<void> {
|
|
212
|
+
if (this.refreshing) return;
|
|
213
|
+
this.refreshing = true;
|
|
214
|
+
this.options.requestRender();
|
|
215
|
+
try {
|
|
216
|
+
await this.options.refresh();
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.options.onNotice?.(`Could not refresh: ${error instanceof Error ? error.message : String(error)}`);
|
|
219
|
+
} finally {
|
|
220
|
+
this.refreshing = false;
|
|
221
|
+
this.options.requestRender();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
handleInput(data: string): void {
|
|
226
|
+
if (isCloseKey(data)) {
|
|
227
|
+
this.options.onClose();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// An input can arrive before the first render; put the focus on a row.
|
|
231
|
+
this.clampSelection(this.entries());
|
|
232
|
+
if (matchesKey(data, Key.space)) {
|
|
233
|
+
this.toggle();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (matchesKey(data, Key.enter)) {
|
|
237
|
+
this.showDetail = !this.showDetail;
|
|
238
|
+
this.options.requestRender();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (matchesKey(data, Key.tab)) {
|
|
242
|
+
this.switchScope();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (matchesKey(data, "r")) {
|
|
246
|
+
void this.refresh();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (data === "g") {
|
|
250
|
+
this.select(this.firstRow());
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (data === "G") {
|
|
254
|
+
this.select(this.lastRow());
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
switch (navIntent(data)) {
|
|
258
|
+
case "up":
|
|
259
|
+
this.move(-1);
|
|
260
|
+
return;
|
|
261
|
+
case "down":
|
|
262
|
+
this.move(1);
|
|
263
|
+
return;
|
|
264
|
+
case "pageUp":
|
|
265
|
+
this.move(-Math.max(1, this.cursor.visible));
|
|
266
|
+
return;
|
|
267
|
+
case "pageDown":
|
|
268
|
+
this.move(Math.max(1, this.cursor.visible));
|
|
269
|
+
return;
|
|
270
|
+
case "home":
|
|
271
|
+
this.select(this.firstRow());
|
|
272
|
+
return;
|
|
273
|
+
case "end":
|
|
274
|
+
this.select(this.lastRow());
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
280
|
+
const delta = wheelDelta(event);
|
|
281
|
+
if (delta === undefined) return undefined;
|
|
282
|
+
this.move(delta);
|
|
283
|
+
return { handled: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private summary(width: number, model: PanelModel): string {
|
|
287
|
+
const segments = summarySegments(model, this.options.runtime.scope(), {
|
|
288
|
+
filter: this.options.filter,
|
|
289
|
+
compact: width < 72,
|
|
290
|
+
});
|
|
291
|
+
const separator = this.theme.fg("dim", " · ");
|
|
292
|
+
const kept: string[] = [];
|
|
293
|
+
for (const segment of segments) {
|
|
294
|
+
const styled = this.theme.fg(segment.color, segment.text);
|
|
295
|
+
if (kept.length > 0 && visibleWidth(` ${[...kept, styled].join(separator)}`) > width) break;
|
|
296
|
+
kept.push(styled);
|
|
297
|
+
}
|
|
298
|
+
return truncateToWidth(` ${kept.join(separator)}`, width, "…");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private groupLine(entry: Extract<PanelEntry, { kind: "group" }>, width: number): string {
|
|
302
|
+
const label = this.theme.fg("muted", sanitizeLine(entry.label));
|
|
303
|
+
const count = this.theme.fg("dim", `(${entry.count})`);
|
|
304
|
+
return truncateToWidth(` ${label} ${count}`, width, "…");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private rowLine(row: PanelRow, selected: boolean, width: number): string {
|
|
308
|
+
const checkbox = row.item
|
|
309
|
+
? row.item.enabled
|
|
310
|
+
? this.theme.fg("success", "[x]")
|
|
311
|
+
: this.theme.fg("dim", "[ ]")
|
|
312
|
+
: this.theme.fg("dim", " ");
|
|
313
|
+
const severity = row.report ? reportSeverity(row.report) : undefined;
|
|
314
|
+
const glyph = severity ? severityGlyph(severity, this.theme) : " ";
|
|
315
|
+
const label = sanitizeLine(row.label);
|
|
316
|
+
const styled = selected
|
|
317
|
+
? this.theme.fg("accent", label)
|
|
318
|
+
: row.item && !row.item.enabled
|
|
319
|
+
? this.theme.fg("dim", label)
|
|
320
|
+
: label;
|
|
321
|
+
const summary = row.report ? reportSummary(row.report) : "";
|
|
322
|
+
const head = `${selectionMarker(this.theme, selected)}${checkbox} ${glyph} `;
|
|
323
|
+
if (!summary) return truncateToWidth(`${head}${styled}`, width, "…");
|
|
324
|
+
|
|
325
|
+
const available = Math.max(1, width - visibleWidth(head) - visibleWidth(summary) - 1);
|
|
326
|
+
const name = truncateToWidth(styled, available, "…");
|
|
327
|
+
const pad = " ".repeat(Math.max(0, available - visibleWidth(name)));
|
|
328
|
+
const tone = severity === "missing" ? "error" : severity === "degraded" ? "warning" : "dim";
|
|
329
|
+
return truncateToWidth(`${head}${name}${pad} ${this.theme.fg(tone, summary)}`, width, "…");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The focused row's label, findings, and the pending-changes note. */
|
|
333
|
+
private detail(row: PanelRow | undefined, width: number, budget: number): string[] {
|
|
334
|
+
const lines: string[] = [];
|
|
335
|
+
if (row?.report) {
|
|
336
|
+
const tail = reportSummary(row.report);
|
|
337
|
+
lines.push(
|
|
338
|
+
truncateToWidth(
|
|
339
|
+
` ${this.theme.fg("muted", sanitizeLine(row.label))} ${this.theme.fg("dim", "—")} ${this.theme.fg(row.report.enabled ? "muted" : "warning", tail)}`,
|
|
340
|
+
width,
|
|
341
|
+
"…",
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
if (budget > 0) lines.push(...reportDetailLines(row.report, this.theme, width, budget, this.showDetail));
|
|
345
|
+
} else if (row?.item) {
|
|
346
|
+
lines.push(truncateToWidth(` ${this.theme.fg("muted", sanitizeLine(row.item.path))}`, width, "…"));
|
|
347
|
+
}
|
|
348
|
+
if (this.options.runtime.isDirty()) {
|
|
349
|
+
lines.push(truncateToWidth(` ${this.theme.fg("warning", "Saved · reload to apply")}`, width, "…"));
|
|
350
|
+
}
|
|
351
|
+
return lines;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
render(width: number): string[] {
|
|
355
|
+
const w = Math.max(1, width);
|
|
356
|
+
const model = this.model();
|
|
357
|
+
const entries = entriesOf(model);
|
|
358
|
+
const summary = this.summary(w, model);
|
|
359
|
+
|
|
360
|
+
if (entries.length === 0) {
|
|
361
|
+
return dockListFrame({
|
|
362
|
+
theme: this.theme,
|
|
363
|
+
width: w,
|
|
364
|
+
title: "bytefly",
|
|
365
|
+
summary,
|
|
366
|
+
body: [truncateToWidth(` ${this.theme.fg("dim", "No extensions found.")}`, w)],
|
|
367
|
+
hint: screenHint(this.theme, w, ["Esc close"]),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.retainFocus(entries);
|
|
372
|
+
this.clampSelection(entries);
|
|
373
|
+
|
|
374
|
+
const focusedEntry = entries[this.cursor.selected];
|
|
375
|
+
const focused = focusedEntry?.kind === "row" ? focusedEntry.row : undefined;
|
|
376
|
+
const total = totalRows(this.options.viewportRows);
|
|
377
|
+
const detailBudget = total !== undefined && total < FRAME_ROWS + 1 + MIN_BODY_ROWS + DETAIL_ROWS ? 0 : DETAIL_ROWS;
|
|
378
|
+
const detail = this.detail(focused, w, detailBudget);
|
|
379
|
+
|
|
380
|
+
this.cursor.visible = fitRows(
|
|
381
|
+
this.options.viewportRows,
|
|
382
|
+
entries.length,
|
|
383
|
+
FRAME_ROWS + detail.length,
|
|
384
|
+
SCREEN_DEFAULT_ROWS,
|
|
385
|
+
);
|
|
386
|
+
this.cursor.sync(entries.length);
|
|
387
|
+
const { start, end } = this.cursor.window(entries.length);
|
|
388
|
+
|
|
389
|
+
const body: string[] = [];
|
|
390
|
+
for (let index = start; index < end; index += 1) {
|
|
391
|
+
const entry = entries[index];
|
|
392
|
+
if (entry.kind === "group") body.push(this.groupLine(entry, w));
|
|
393
|
+
else body.push(this.rowLine(entry.row, index === this.cursor.selected, w));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return dockListFrame({
|
|
397
|
+
theme: this.theme,
|
|
398
|
+
width: w,
|
|
399
|
+
title: "bytefly",
|
|
400
|
+
summary,
|
|
401
|
+
body,
|
|
402
|
+
range: this.cursor.clipped(entries.length)
|
|
403
|
+
? formatRange(this.theme, w, { start, end, total: entries.length })
|
|
404
|
+
: undefined,
|
|
405
|
+
detail: detail.length > 0 ? detail : undefined,
|
|
406
|
+
hint: screenHint(this.theme, w, [
|
|
407
|
+
"Esc close",
|
|
408
|
+
"Space toggle",
|
|
409
|
+
"Enter detail",
|
|
410
|
+
"Tab scope",
|
|
411
|
+
this.refreshing ? "refreshing…" : "r refresh",
|
|
412
|
+
"↑/↓ select",
|
|
413
|
+
]),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the unified bytefly panel: the merged row model the formatter and
|
|
3
|
+
* the screen both read.
|
|
4
|
+
*
|
|
5
|
+
* A row is the union of one picker item and its doctor report, joined on the
|
|
6
|
+
* extension directory name. Either side may be absent: an item without a report
|
|
7
|
+
* renders no health, and a report without an item has no checkbox.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionReport } from "../../../lib/requirements.ts";
|
|
11
|
+
import type { ResourceItem } from "../picker/types.ts";
|
|
12
|
+
|
|
13
|
+
/** One group header in the merged model. */
|
|
14
|
+
export interface PanelGroup {
|
|
15
|
+
/** Group key from the picker's `buildEntries`. */
|
|
16
|
+
key: string;
|
|
17
|
+
/** Group label, e.g. `npm:pkg (user)`. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** Number of rows under this group. */
|
|
20
|
+
count: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One extension row: a picker item, a doctor report, or both. */
|
|
24
|
+
export interface PanelRow {
|
|
25
|
+
/**
|
|
26
|
+
* Unique row identity: `source:path` for an item, `report:<name>` for a
|
|
27
|
+
* report-only row. Never the directory name, which two rows can share.
|
|
28
|
+
*/
|
|
29
|
+
key: string;
|
|
30
|
+
/** The resolved extension item, when the picker has one. */
|
|
31
|
+
item?: ResourceItem;
|
|
32
|
+
/** The doctor report, when one exists for this directory. */
|
|
33
|
+
report?: ExtensionReport;
|
|
34
|
+
/** Picker display name (`folder/file.ts`), or the report name when no item. */
|
|
35
|
+
label: string;
|
|
36
|
+
/** Key of the group this row belongs to. */
|
|
37
|
+
group: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The merged model: group headers and rows, both in display order. */
|
|
41
|
+
export interface PanelModel {
|
|
42
|
+
groups: PanelGroup[];
|
|
43
|
+
rows: PanelRow[];
|
|
44
|
+
}
|
|
@@ -213,14 +213,3 @@ export function firstSelectable(entries: Entry[], from = 0): number | undefined
|
|
|
213
213
|
}
|
|
214
214
|
return undefined;
|
|
215
215
|
}
|
|
216
|
-
|
|
217
|
-
/** Plain-text listing used outside TUI mode. */
|
|
218
|
-
export function formatListing(items: ResourceItem[], agentDir: string): string {
|
|
219
|
-
if (items.length === 0) return "No extensions found.";
|
|
220
|
-
const lines: string[] = [];
|
|
221
|
-
for (const entry of buildEntries(items, agentDir)) {
|
|
222
|
-
if (entry.kind === "group") lines.push(`${entry.label} (${entry.count})`);
|
|
223
|
-
else lines.push(` [${entry.item.enabled ? "x" : " "}] ${entry.label}`);
|
|
224
|
-
}
|
|
225
|
-
return lines.join("\n");
|
|
226
|
-
}
|
|
@@ -8,6 +8,8 @@ highlighted code, per-language icons, and a fuzzy quick-open palette. It is
|
|
|
8
8
|
bound to `127.0.0.1`
|
|
9
9
|
and ships no dependencies — `node:http` plus one small vanilla script.
|
|
10
10
|
|
|
11
|
+
<img src="../../assets/file-browse.png" alt="The loopback file browser, showing the path filter, the file tree, and a rendered Markdown preview" width="100%">
|
|
12
|
+
|
|
11
13
|
```bash
|
|
12
14
|
pi --extension ./extensions/file-browse # load just this extension
|
|
13
15
|
pi -e . # load the whole @gavin-hu/my-pi-agent package
|
|
@@ -31,10 +33,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
|
|
|
31
33
|
Python (`py`, `pyw`, `pyi`), Java, YAML (`yaml`, `yml`), and SQL. Other text
|
|
32
34
|
types stay plain.
|
|
33
35
|
- **Rendered Markdown.** `.md`/`.markdown` files render as headings, lists,
|
|
34
|
-
tables, code, links, and images, with a one-click
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
tables, code, inline and reference links, and images, with a one-click
|
|
37
|
+
**raw** view (`?raw=1`). Tables size their columns to the content and scroll
|
|
38
|
+
when wide, and named and numeric character references are decoded. Fenced code
|
|
39
|
+
blocks are syntax-highlighted when the fence names a supported language. Raw
|
|
40
|
+
HTML in the source is escaped, never executed, and external images are shown
|
|
41
|
+
as links rather than fetched.
|
|
38
42
|
- **Sortable listings.** The Name / Size / Modified headers sort the current
|
|
39
43
|
directory, directories first, and the choice is remembered per browser.
|
|
40
44
|
- **Copy and wrap.** Copy a file's root-relative path or its contents, and
|
|
@@ -201,7 +205,10 @@ document and the project document (project wins):
|
|
|
201
205
|
tag set, escapes every literal character, allowlists URL schemes (dropping
|
|
202
206
|
`javascript:`/`data:`), rewrites local links and images to `/view`/`/raw`, and
|
|
203
207
|
renders an external image as a link instead of loading it (the page CSP is
|
|
204
|
-
`img-src 'self' data:`).
|
|
208
|
+
`img-src 'self' data:`). Link reference definitions (`[id]: url`), including
|
|
209
|
+
the full `[text][id]`, collapsed `[text][]`, and shortcut `[text]` forms, and
|
|
210
|
+
numeric and common named character references are resolved. The Rendered/Raw
|
|
211
|
+
switch is a server-side `?raw=1`
|
|
205
212
|
link, so it works without JavaScript. Set `markdown: false` to fall back to
|
|
206
213
|
the plain-text view. A fenced code block whose info string names a supported
|
|
207
214
|
language is highlighted through `lib/highlight.ts` (either an extension or a
|