@north-light/crouter 0.3.184 → 0.3.185

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 (61) hide show
  1. package/dist/build-root.js +10 -3
  2. package/dist/builtin-memory/internal/plugins.md +1 -1
  3. package/dist/clients/attach/input/capabilities.js +3 -0
  4. package/dist/clients/attach/slash/dispatch.js +12 -0
  5. package/dist/clients/attach/viewer.js +191 -191
  6. package/dist/commands/pkg/browse/actions.d.ts +7 -0
  7. package/dist/commands/pkg/browse/actions.js +199 -0
  8. package/dist/commands/pkg/browse/catalog.d.ts +9 -0
  9. package/dist/commands/pkg/browse/catalog.js +466 -0
  10. package/dist/commands/pkg/browse/command-view.d.ts +3 -0
  11. package/dist/commands/pkg/browse/command-view.js +191 -0
  12. package/dist/commands/pkg/browse/detail-panel.d.ts +66 -0
  13. package/dist/commands/pkg/browse/detail-panel.js +402 -0
  14. package/dist/commands/pkg/browse/doc-view.d.ts +3 -0
  15. package/dist/commands/pkg/browse/doc-view.js +81 -0
  16. package/dist/commands/pkg/browse/list-panel.d.ts +28 -0
  17. package/dist/commands/pkg/browse/list-panel.js +168 -0
  18. package/dist/commands/pkg/browse/model.d.ts +230 -0
  19. package/dist/commands/pkg/browse/model.js +36 -0
  20. package/dist/commands/pkg/browse/shell.d.ts +93 -0
  21. package/dist/commands/pkg/browse/shell.js +640 -0
  22. package/dist/commands/pkg/browse.d.ts +2 -0
  23. package/dist/commands/pkg/browse.js +27 -0
  24. package/dist/commands/pkg/market-manage.js +13 -4
  25. package/dist/commands/pkg/plugin-manage.js +8 -3
  26. package/dist/commands/pkg.js +2 -1
  27. package/dist/commands/sys/doctor.js +6 -4
  28. package/dist/commands/sys/panels/broker-limits-panel.d.ts +1 -1
  29. package/dist/commands/sys/panels/broker-limits-panel.js +1 -1
  30. package/dist/commands/sys/panels/dependencies-panel.d.ts +1 -1
  31. package/dist/commands/sys/panels/dependencies-panel.js +1 -1
  32. package/dist/commands/sys/panels/keybindings-panel.js +1 -1
  33. package/dist/commands/sys/panels/models-panel.d.ts +1 -1
  34. package/dist/commands/sys/panels/models-panel.js +1 -1
  35. package/dist/commands/sys/panels/plugins-panel.d.ts +1 -1
  36. package/dist/commands/sys/panels/plugins-panel.js +1 -1
  37. package/dist/commands/sys/panels/prefs-panel.d.ts +1 -1
  38. package/dist/commands/sys/panels/prefs-panel.js +1 -1
  39. package/dist/commands/sys/panels/provider-panel.d.ts +1 -1
  40. package/dist/commands/sys/panels/provider-panel.js +1 -1
  41. package/dist/commands/sys/settings-shell.js +1 -1
  42. package/dist/commands/sys/setup-settings-runner.d.ts +7 -18
  43. package/dist/commands/sys/setup-settings-runner.js +3 -52
  44. package/dist/commands/sys/setup-wizard.js +1 -1
  45. package/dist/core/__tests__/serial/command-plugins.test.js +21 -40
  46. package/dist/core/command-manifests/registry.d.ts +10 -3
  47. package/dist/core/command-manifests/registry.js +36 -22
  48. package/dist/core/command-plugins/compose.js +1 -1
  49. package/dist/core/command-plugins/discovery.js +15 -7
  50. package/dist/core/keybindings/catalog.d.ts +1 -1
  51. package/dist/core/keybindings/catalog.js +1 -0
  52. package/dist/core/resolver.d.ts +2 -2
  53. package/dist/core/runtime/tmux-bindings.js +2 -0
  54. package/dist/core/tui/page-host.d.ts +25 -0
  55. package/dist/core/tui/page-host.js +65 -0
  56. package/dist/pi-extensions/canvas-bash-valve.js +45 -1
  57. package/dist/types.d.ts +4 -1
  58. package/package.json +1 -1
  59. package/runtime.lock.json +2 -2
  60. /package/dist/{commands/sys/panels → core/tui}/panel.d.ts +0 -0
  61. /package/dist/{commands/sys/panels → core/tui}/panel.js +0 -0
@@ -0,0 +1,66 @@
1
+ import type { ActionOutcome, CatalogSource, PluginCapability, PluginEntry } from "./model.js";
2
+ export declare class PkgDetailPanel {
3
+ private entry;
4
+ private source;
5
+ private capability;
6
+ private pluginAction;
7
+ private marketplaceAction;
8
+ /** Page-level catalog problems. The list's notice shows the first; this is
9
+ * where the rest stay readable. */
10
+ private warnings;
11
+ private scroll;
12
+ /** Extra scrolling the user asked for past the last selectable row, so the
13
+ * inert tail of the overview stays reachable. Reset whenever the cursor
14
+ * moves or the view changes. */
15
+ private tailScroll;
16
+ private cursor;
17
+ private view;
18
+ /** Command branches the user has folded shut, by command path. Branches are
19
+ * open by default: the explorer's whole point is that nothing is hidden. */
20
+ private collapsed;
21
+ /** Last render's geometry, so scrolling can clamp against real content
22
+ * instead of running a counter nobody can see. */
23
+ private lastLines;
24
+ private lastHeight;
25
+ /** Where the cursor's row sits in the last built overview, in line numbers. */
26
+ private cursorRange;
27
+ setEntry(entry: PluginEntry | undefined, source: CatalogSource | undefined, capability: PluginCapability | null | undefined): void;
28
+ setPluginAction(entry: PluginEntry | undefined, outcome: ActionOutcome): void;
29
+ setMarketplaceAction(sourceId: string | undefined, outcome: ActionOutcome): void;
30
+ setWarnings(warnings: string[]): void;
31
+ /** True while an opened doc or command owns the pane, so the shell's footer
32
+ * can teach `esc back` instead of `esc list`. */
33
+ get viewing(): boolean;
34
+ /** `j`/`k`: move the cursor between selectable rows on the overview, scroll a
35
+ * line inside an opened view — and scroll the overview too when the plugin
36
+ * has nothing selectable to move between. */
37
+ moveVertical(delta: number): void;
38
+ /** `space`: fold the focused command branch shut, or open it again. */
39
+ toggleExpand(): boolean;
40
+ /** `enter`/`l`: open the focused doc or command as its own view. */
41
+ open(): boolean;
42
+ /** `h`/`left`: pop an open view, else fold the focused branch. False means
43
+ * the key was not used here and the shell should leave for the list. */
44
+ collapseOrPop(): boolean;
45
+ /** `esc`: pop an open view. False at the root, where the shell leaves. */
46
+ escape(): boolean;
47
+ private popView;
48
+ private toggleExpandShut;
49
+ /** Command rows in display order, skipping the children of folded branches. */
50
+ private commandRows;
51
+ /** Every row the cursor can land on, in the order the overview renders them.
52
+ * The renderer walks the same two lists, so index N here is row N there. */
53
+ private targets;
54
+ render(width: number, height: number, focused: boolean): string[];
55
+ private lines;
56
+ private overviewLines;
57
+ private commandRowLines;
58
+ private status;
59
+ private transportLine;
60
+ private fromLines;
61
+ /** The dossier for a source rather than a plugin: what a marketplace is and
62
+ * where it came from, or the library's own shape — plus every catalog
63
+ * problem, which is page-level and has no other home with room for it. */
64
+ private sourceLines;
65
+ private size;
66
+ }
@@ -0,0 +1,402 @@
1
+ // The right-hand dossier, and the explorer rooted in it.
2
+ //
3
+ // The dossier is a view stack: an overview of what the plugin adds, and — one
4
+ // `enter` deeper — a whole memory doc or a whole command contract. The overview
5
+ // is not a teaser: every command node and every memory doc it lists is a real
6
+ // row a cursor can land on and open, and nothing is elided with `+N more`.
7
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
+ import { padAnsi, theme, wrapText } from "../../../core/tui/panel.js";
9
+ import { renderCommandView } from "./command-view.js";
10
+ import { renderDocView } from "./doc-view.js";
11
+ import { commandLabel, displayVersion, plural } from "./model.js";
12
+ /** One labelled row and its explanation. Inline while both fit; otherwise the
13
+ * explanation wraps underneath, hanging under the label rather than resetting
14
+ * to the left margin. Nothing is ever truncated to make it fit. */
15
+ function entryLines(indent, label, explanation, width, hangingIndent) {
16
+ const styled = `${indent}${theme.fg("accent", label)}`;
17
+ if (explanation === undefined || explanation.trim() === "")
18
+ return [styled];
19
+ const inline = `${indent}${label} — ${explanation}`;
20
+ if (visibleWidth(inline) <= width)
21
+ return [`${styled}${theme.fg("dim", ` — ${explanation}`)}`];
22
+ // The hanging indent is blank space, never a re-print of the prefix: an
23
+ // indent carrying a branch's ▾ would stamp that glyph down every wrapped
24
+ // line of its description, as though each were another branch.
25
+ const hanging = hangingIndent ?? " ".repeat(visibleWidth(indent) + 2);
26
+ return [
27
+ styled,
28
+ ...wrapText(explanation, Math.max(1, width - hanging.length)).map((line) => `${hanging}${theme.fg("dim", line)}`),
29
+ ];
30
+ }
31
+ /** `wrapText` trims, so wrapping an already-indented string silently loses its
32
+ * indent on every line. Wrap the text, then indent each line. */
33
+ function indented(indent, text, width, hangingIndent = indent) {
34
+ return wrapText(text, Math.max(1, width - indent.length)).map((line, index) => `${index === 0 ? indent : hangingIndent}${line}`);
35
+ }
36
+ export class PkgDetailPanel {
37
+ entry;
38
+ source;
39
+ capability;
40
+ pluginAction;
41
+ marketplaceAction;
42
+ /** Page-level catalog problems. The list's notice shows the first; this is
43
+ * where the rest stay readable. */
44
+ warnings = [];
45
+ scroll = 0;
46
+ /** Extra scrolling the user asked for past the last selectable row, so the
47
+ * inert tail of the overview stays reachable. Reset whenever the cursor
48
+ * moves or the view changes. */
49
+ tailScroll = 0;
50
+ cursor = 0;
51
+ view = { kind: "overview" };
52
+ /** Command branches the user has folded shut, by command path. Branches are
53
+ * open by default: the explorer's whole point is that nothing is hidden. */
54
+ collapsed = new Set();
55
+ /** Last render's geometry, so scrolling can clamp against real content
56
+ * instead of running a counter nobody can see. */
57
+ lastLines = 0;
58
+ lastHeight = 1;
59
+ /** Where the cursor's row sits in the last built overview, in line numbers. */
60
+ cursorRange;
61
+ setEntry(entry, source, capability) {
62
+ const changedEntry = this.entry?.key !== entry?.key;
63
+ if (changedEntry || this.source?.id !== source?.id) {
64
+ this.scroll = 0;
65
+ this.tailScroll = 0;
66
+ }
67
+ if (changedEntry) {
68
+ this.pluginAction = undefined;
69
+ this.view = { kind: "overview" };
70
+ this.cursor = 0;
71
+ this.collapsed.clear();
72
+ }
73
+ if (this.source?.id !== source?.id)
74
+ this.marketplaceAction = undefined;
75
+ this.entry = entry;
76
+ this.source = source;
77
+ this.capability = capability;
78
+ }
79
+ setPluginAction(entry, outcome) {
80
+ this.pluginAction = entry === undefined ? undefined : { key: entry.key, outcome };
81
+ }
82
+ setMarketplaceAction(sourceId, outcome) {
83
+ this.marketplaceAction = sourceId === undefined ? undefined : { sourceId, outcome };
84
+ }
85
+ setWarnings(warnings) {
86
+ this.warnings = warnings;
87
+ }
88
+ /** True while an opened doc or command owns the pane, so the shell's footer
89
+ * can teach `esc back` instead of `esc list`. */
90
+ get viewing() {
91
+ return this.view.kind !== "overview";
92
+ }
93
+ // --- navigation ----------------------------------------------------------
94
+ /** `j`/`k`: move the cursor between selectable rows on the overview, scroll a
95
+ * line inside an opened view — and scroll the overview too when the plugin
96
+ * has nothing selectable to move between. */
97
+ moveVertical(delta) {
98
+ const targets = this.targets();
99
+ if (this.view.kind !== "overview" || targets.length === 0) {
100
+ this.scroll = Math.max(0, Math.min(this.scroll + delta, Math.max(0, this.lastLines - this.lastHeight)));
101
+ return;
102
+ }
103
+ const next = Math.max(0, Math.min(targets.length - 1, this.cursor + delta));
104
+ if (next !== this.cursor) {
105
+ this.cursor = next;
106
+ this.tailScroll = 0;
107
+ return;
108
+ }
109
+ // The cursor is already on the last row, but ON DISK and FROM sit below it
110
+ // as inert text. Keep scrolling so a plugin with 78 memory docs does not
111
+ // bury its own install path where nothing can reach it.
112
+ this.tailScroll = Math.max(0, this.tailScroll + delta);
113
+ }
114
+ /** `space`: fold the focused command branch shut, or open it again. */
115
+ toggleExpand() {
116
+ const target = this.targets()[this.cursor];
117
+ if (this.view.kind !== "overview" || target?.kind !== "command")
118
+ return false;
119
+ if (target.node.children.length === 0)
120
+ return false;
121
+ const key = target.node.path.join(" ");
122
+ if (this.collapsed.has(key))
123
+ this.collapsed.delete(key);
124
+ else {
125
+ this.collapsed.add(key);
126
+ // Folding removes rows below the cursor; the cursor itself stays on the
127
+ // branch it just folded, which is still row `this.cursor`.
128
+ }
129
+ return true;
130
+ }
131
+ /** `enter`/`l`: open the focused doc or command as its own view. */
132
+ open() {
133
+ if (this.view.kind !== "overview")
134
+ return false;
135
+ const target = this.targets()[this.cursor];
136
+ if (target === undefined)
137
+ return false;
138
+ this.view = target.kind === "command"
139
+ ? { kind: "command", node: target.node }
140
+ : { kind: "doc", doc: target.doc };
141
+ this.scroll = 0;
142
+ return true;
143
+ }
144
+ /** `h`/`left`: pop an open view, else fold the focused branch. False means
145
+ * the key was not used here and the shell should leave for the list. */
146
+ collapseOrPop() {
147
+ if (this.view.kind !== "overview")
148
+ return this.popView();
149
+ return this.toggleExpandShut();
150
+ }
151
+ /** `esc`: pop an open view. False at the root, where the shell leaves. */
152
+ escape() {
153
+ return this.popView();
154
+ }
155
+ popView() {
156
+ if (this.view.kind === "overview")
157
+ return false;
158
+ this.view = { kind: "overview" };
159
+ this.scroll = 0;
160
+ this.tailScroll = 0;
161
+ return true;
162
+ }
163
+ toggleExpandShut() {
164
+ const target = this.targets()[this.cursor];
165
+ if (target?.kind !== "command" || target.node.children.length === 0)
166
+ return false;
167
+ const key = target.node.path.join(" ");
168
+ if (this.collapsed.has(key))
169
+ return false;
170
+ this.collapsed.add(key);
171
+ return true;
172
+ }
173
+ // --- the selectable model ------------------------------------------------
174
+ /** Command rows in display order, skipping the children of folded branches. */
175
+ commandRows() {
176
+ const rows = [];
177
+ const walk = (nodes, depth) => {
178
+ for (const node of nodes) {
179
+ rows.push({ node, depth });
180
+ if (node.children.length > 0 && !this.collapsed.has(node.path.join(" ")))
181
+ walk(node.children, depth + 1);
182
+ }
183
+ };
184
+ walk(this.capability?.commands ?? [], 0);
185
+ return rows;
186
+ }
187
+ /** Every row the cursor can land on, in the order the overview renders them.
188
+ * The renderer walks the same two lists, so index N here is row N there. */
189
+ targets() {
190
+ const capability = this.capability;
191
+ if (capability === undefined || capability === null)
192
+ return [];
193
+ return [
194
+ ...this.commandRows().map((row) => ({ kind: "command", node: row.node })),
195
+ ...capability.docs.map((doc) => ({ kind: "doc", doc })),
196
+ ];
197
+ }
198
+ // --- rendering -----------------------------------------------------------
199
+ render(width, height, focused) {
200
+ const lines = this.lines(width, focused);
201
+ this.lastLines = lines.length;
202
+ this.lastHeight = height;
203
+ let start = 0;
204
+ if (focused) {
205
+ const range = this.view.kind === "overview" ? this.cursorRange : undefined;
206
+ let cursorScroll;
207
+ if (range !== undefined) {
208
+ if (range.start < this.scroll)
209
+ this.scroll = range.start;
210
+ if (range.start + range.count > this.scroll + height)
211
+ this.scroll = range.start + range.count - height;
212
+ cursorScroll = this.scroll;
213
+ this.scroll += this.tailScroll;
214
+ }
215
+ // Clamping here is what keeps the last reachable position a full pane
216
+ // rather than one line of text above an empty one.
217
+ this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, lines.length - height)));
218
+ // Bank only the tail scrolling that actually landed, so one `k` walks
219
+ // back up instead of unwinding every keypress the clamp swallowed.
220
+ if (cursorScroll !== undefined)
221
+ this.tailScroll = Math.max(0, this.scroll - cursorScroll);
222
+ start = this.scroll;
223
+ }
224
+ const window = lines.slice(start, start + height).map((line) => truncateToWidth(line, width));
225
+ // Pad to the full height so the divider drawn beside this column runs the
226
+ // whole page rather than stopping where the dossier's text runs out.
227
+ while (window.length < height)
228
+ window.push("");
229
+ return window;
230
+ }
231
+ lines(width, focused) {
232
+ const entry = this.entry;
233
+ if (entry === undefined)
234
+ return this.sourceLines(this.source, width);
235
+ if (this.view.kind === "doc")
236
+ return renderDocView(this.view.doc, width);
237
+ if (this.view.kind === "command" && this.capability !== undefined && this.capability !== null)
238
+ return renderCommandView(this.view.node, this.capability, width);
239
+ return this.overviewLines(entry, width, focused);
240
+ }
241
+ overviewLines(entry, width, focused) {
242
+ const out = [];
243
+ const ranges = [];
244
+ const push = (lines, selectable = false) => {
245
+ if (selectable)
246
+ ranges.push({ start: out.length, count: lines.length });
247
+ out.push(...lines);
248
+ };
249
+ const version = displayVersion(entry) ?? "";
250
+ const identity = theme.fg("accent", theme.bold(entry.name));
251
+ const gap = width - visibleWidth(identity) - visibleWidth(version);
252
+ push([gap >= 2 ? `${identity}${" ".repeat(gap)}${theme.fg("dim", version)}` : identity]);
253
+ push([this.status(entry)]);
254
+ if (entry.description) {
255
+ push([""]);
256
+ push(wrapText(entry.description, width));
257
+ }
258
+ const capability = this.capability;
259
+ if (capability === undefined) {
260
+ push(["", theme.fg("dim", "ADDS TO CRTR"), theme.fg("dim", " Reading capability surface…")]);
261
+ }
262
+ else if (capability === null) {
263
+ push(["", theme.fg("warning", "Capability details are unavailable — plugin files are not on disk.")]);
264
+ }
265
+ else {
266
+ push(["", theme.fg("dim", "ADDS TO CRTR")]);
267
+ const transport = this.transportLine(capability);
268
+ if (transport !== undefined)
269
+ push([transport]);
270
+ push([
271
+ ` ${plural(this.commandRows().length, "command", "commands")} · ${plural(capability.docs.length, "memory doc", "memory docs")}`,
272
+ ].map((line) => theme.fg("dim", line)));
273
+ const commandRows = this.commandRows();
274
+ if (commandRows.length > 0 || capability.commandIssues.length > 0) {
275
+ push(["", theme.fg("dim", "COMMANDS")]);
276
+ for (const row of commandRows)
277
+ push(this.commandRowLines(row, width), true);
278
+ for (const issue of capability.commandIssues)
279
+ push(indented(" ! ", `${issue.message}${issue.next ? ` — ${issue.next}` : ""}`, width, " ").map((line) => theme.fg("warning", line)));
280
+ }
281
+ if (capability.docs.length > 0) {
282
+ push(["", theme.fg("dim", "MEMORY")]);
283
+ for (const doc of capability.docs)
284
+ // Correction 2: the description beside a doc is its short form —
285
+ // what the doc SAYS. Its routing line is agent read-routing and
286
+ // lives under the doc view's own heading, never here.
287
+ push(entryLines(" ", doc.name, doc.shortForm, width), true);
288
+ }
289
+ if (entry.install.kind === "installed" && capability.sizeBytes !== undefined) {
290
+ push(["", theme.fg("dim", "ON DISK")]);
291
+ push([` ${theme.fg("accent", entry.install.path)}${theme.fg("dim", ` · ${this.size(capability.sizeBytes)}`)}`]);
292
+ }
293
+ }
294
+ push(this.fromLines(entry, width));
295
+ if (this.pluginAction?.key === entry.key && this.pluginAction.outcome.detail?.length) {
296
+ push(["", theme.fg("dim", "LAST ACTION")]);
297
+ for (const line of this.pluginAction.outcome.detail)
298
+ push(indented(" ", line, width));
299
+ }
300
+ this.cursor = ranges.length === 0 ? 0 : Math.min(this.cursor, ranges.length - 1);
301
+ this.cursorRange = ranges[this.cursor];
302
+ const range = this.cursorRange;
303
+ if (focused && range !== undefined)
304
+ for (let line = range.start; line < range.start + range.count; line += 1)
305
+ out[line] = theme.bg("selectedBg", padAnsi(out[line] ?? "", width));
306
+ return out;
307
+ }
308
+ commandRowLines(row, width) {
309
+ const indent = ` ${" ".repeat(row.depth)}`;
310
+ const glyph = row.node.children.length === 0
311
+ ? " "
312
+ : this.collapsed.has(row.node.path.join(" "))
313
+ ? `${theme.fg("dim", "▸")} `
314
+ : `${theme.fg("dim", "▾")} `;
315
+ const prefix = `${indent}${glyph}`;
316
+ // A leaf indents its wrapped description under its label, as a memory doc
317
+ // does. A branch cannot: that column is where its own children's labels
318
+ // start, so its description would read as one more subcommand. It hangs
319
+ // flush with the label instead, where only the glyph column sits.
320
+ const hanging = row.node.children.length === 0 ? undefined : " ".repeat(visibleWidth(prefix));
321
+ return entryLines(prefix, commandLabel(row.node), row.node.description, width, hanging);
322
+ }
323
+ status(entry) {
324
+ if (entry.install.kind === "absent")
325
+ return theme.fg("dim", "Not installed");
326
+ if (entry.install.scope === "builtin")
327
+ return `${theme.fg("muted", "Builtin")} · ships with crtr`;
328
+ // Enablement survives an update offer: a disabled plugin that happens to
329
+ // have a newer version is still disabled, and the status line is the one
330
+ // place that fact is spelled out.
331
+ const enablement = entry.install.enabled ? theme.fg("success", "enabled") : theme.fg("dim", "disabled");
332
+ if (entry.install.updateAvailable)
333
+ return `${theme.fg("warning", `Update available · ${entry.install.version ?? "?"} → ${entry.version ?? "?"}`)} · ${enablement}`;
334
+ return `Installed · ${entry.install.scope} · ${enablement}`;
335
+ }
336
+ transportLine(capability) {
337
+ if (capability.transport === null)
338
+ return undefined;
339
+ const target = capability.transportTarget === undefined ? "" : ` · ${capability.transportTarget}`;
340
+ return capability.transport === "http"
341
+ ? ` ${theme.fg("accent", "Transport")} http${theme.fg("warning", target)}`
342
+ : ` ${theme.fg("accent", "Transport")} exec${theme.fg("dim", target)}`;
343
+ }
344
+ fromLines(entry, width) {
345
+ if (!entry.marketplace && !entry.source && !entry.owner)
346
+ return [];
347
+ const out = ["", theme.fg("dim", "FROM")];
348
+ if (entry.marketplace) {
349
+ const revision = this.source?.marketplace?.revision;
350
+ // A short revision: this line says which clone the plugin came from, and
351
+ // a 40-character object name buys nothing a human reads here.
352
+ out.push(` ${theme.fg("accent", entry.marketplace)}${revision ? theme.fg("dim", ` @ ${revision.slice(0, 12)}`) : ""}`);
353
+ }
354
+ if (entry.source)
355
+ out.push(...indented(" ", entry.source, width));
356
+ if (entry.owner?.name || entry.owner?.email)
357
+ out.push(theme.fg("dim", ` owner · ${entry.owner.name ?? entry.owner.email}`));
358
+ return out;
359
+ }
360
+ /** The dossier for a source rather than a plugin: what a marketplace is and
361
+ * where it came from, or the library's own shape — plus every catalog
362
+ * problem, which is page-level and has no other home with room for it. */
363
+ sourceLines(source, width) {
364
+ const marketplace = source?.marketplace;
365
+ const out = [];
366
+ if (marketplace === undefined) {
367
+ out.push(theme.fg("accent", theme.bold(source?.label ?? "Library")));
368
+ out.push(theme.fg("dim", "Choose a marketplace or plugin to inspect it."));
369
+ }
370
+ else {
371
+ out.push(theme.fg("accent", theme.bold(marketplace.name)));
372
+ out.push(`Installed ${marketplace.installedCount}/${marketplace.pluginCount} plugins${theme.fg("dim", ` · ${plural(marketplace.updatableCount, "update", "updates")}`)}`);
373
+ out.push("", theme.fg("dim", "MARKETPLACE"));
374
+ out.push(` ${theme.fg("accent", marketplace.scope)} scope · ${marketplace.ref}`);
375
+ out.push(...indented(" ", marketplace.url, width));
376
+ out.push(` ${theme.fg("accent", marketplace.path)}`);
377
+ if (marketplace.revision)
378
+ out.push(` revision · ${marketplace.revision}${marketplace.revisionDate ? ` · ${marketplace.revisionDate}` : ""}`);
379
+ if (marketplace.owner?.name || marketplace.owner?.email)
380
+ out.push(` owner · ${marketplace.owner.name ?? marketplace.owner.email}`);
381
+ }
382
+ if (this.warnings.length > 0) {
383
+ out.push("", theme.fg("dim", "PROBLEMS"));
384
+ for (const warning of this.warnings)
385
+ out.push(...indented(" ", warning, width).map((line) => theme.fg("warning", line)));
386
+ }
387
+ const action = this.marketplaceAction;
388
+ if (action !== undefined && action.sourceId === source?.id && action.outcome.detail?.length) {
389
+ out.push("", theme.fg("dim", "LAST ACTION"));
390
+ for (const line of action.outcome.detail)
391
+ out.push(...indented(" ", line, width));
392
+ }
393
+ return out;
394
+ }
395
+ size(bytes) {
396
+ if (bytes < 1024)
397
+ return `${bytes} B`;
398
+ if (bytes < 1024 * 1024)
399
+ return `${Math.round(bytes / 1024)} KB`;
400
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
401
+ }
402
+ }
@@ -0,0 +1,3 @@
1
+ import type { DocDetail } from "./model.js";
2
+ /** Every line of one memory doc's reader view, wrapped to `width`. */
3
+ export declare function renderDocView(doc: DocDetail, width: number): string[];
@@ -0,0 +1,81 @@
1
+ // The plugin browser's memory-doc reader: one doc rendered whole, for a human
2
+ // deciding whether the agent reading it will do the right thing.
3
+ //
4
+ // Pure rendering. It receives a doc already read from disk and returns styled
5
+ // lines already wrapped to `width`; the dossier owns scrolling and never
6
+ // truncates what comes back.
7
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
8
+ import { Markdown, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+ import { theme, wrapText } from "../../../core/tui/panel.js";
10
+ /** Every line of one memory doc's reader view, wrapped to `width`. */
11
+ export function renderDocView(doc, width) {
12
+ const out = [];
13
+ const viewWidth = Math.max(1, width);
14
+ out.push(...wrapped(doc.name, viewWidth).map((line) => theme.fg("accent", theme.bold(line))));
15
+ out.push(theme.fg("dim", doc.kind));
16
+ if (doc.shortForm) {
17
+ out.push("");
18
+ out.push(...paragraph(doc.shortForm, viewWidth));
19
+ }
20
+ out.push("", theme.fg("dim", "READ WHEN"));
21
+ out.push(...paragraph(doc.routingLine || "Not declared.", viewWidth));
22
+ // The two rungs always answer "will an agent ever see this?", so they are
23
+ // always shown. The qualifiers below them are shown only when the doc sets
24
+ // one — a column of `none` says nothing, and a `read when none` row sitting
25
+ // under a filled READ WHEN section reads as a contradiction.
26
+ out.push("", theme.fg("dim", "VISIBILITY"));
27
+ out.push(...field("system prompt", doc.systemPromptVisibility, viewWidth));
28
+ out.push(...field("file read", doc.fileReadVisibility, viewWidth));
29
+ if (doc.slash)
30
+ out.push(...field("slash", `/${doc.name.replaceAll("/", ":")}`, viewWidth));
31
+ if (doc.appliesTo?.length)
32
+ out.push(...field("applies to", doc.appliesTo.join(", "), viewWidth));
33
+ if (doc.gate !== undefined)
34
+ out.push(...field("gate", JSON.stringify(doc.gate), viewWidth));
35
+ if (doc.readWhenPredicate !== undefined)
36
+ out.push(...field("read-when predicate", JSON.stringify(doc.readWhenPredicate), viewWidth));
37
+ if (doc.rationale) {
38
+ out.push("", theme.fg("dim", "RATIONALE"));
39
+ out.push(...paragraph(doc.rationale, viewWidth));
40
+ }
41
+ out.push("", theme.fg("dim", "ON DISK"));
42
+ out.push(...field("path", doc.path, viewWidth));
43
+ out.push(...field("size", size(doc.sizeBytes), viewWidth));
44
+ out.push("", theme.fg("dim", "BODY"), "");
45
+ out.push(...new Markdown(doc.body, 0, 0, getMarkdownTheme()).render(viewWidth));
46
+ return out;
47
+ }
48
+ /** A prose block gets a quiet, stable left edge instead of reflowing raggedly
49
+ * against a section title. */
50
+ function paragraph(text, width) {
51
+ const indent = " ";
52
+ return wrapped(text, Math.max(1, width - indent.length)).map((line) => `${indent}${theme.fg("dim", line)}`);
53
+ }
54
+ /** A field's later rows line up under its value, not under its label. */
55
+ function field(label, value, width) {
56
+ const prefix = ` ${label} `;
57
+ const indent = " ".repeat(visibleWidth(prefix));
58
+ const valueWidth = Math.max(1, width - visibleWidth(prefix));
59
+ return wrapped(value, valueWidth).map((line, index) => index === 0
60
+ ? `${theme.fg("accent", prefix)}${theme.fg("dim", line)}`
61
+ : `${indent}${theme.fg("dim", line)}`);
62
+ }
63
+ /** `wrapText` is the page's ordinary prose wrapper. Its word-oriented contract
64
+ * deliberately leaves a filesystem path or compact JSON predicate intact, so
65
+ * use pi-tui's character-safe wrapper only for those over-wide tokens instead
66
+ * of turning a full value into an ellipsis. */
67
+ function wrapped(text, width) {
68
+ const clean = text.trim();
69
+ return clean.split(/\s+/).some((word) => visibleWidth(word) > width)
70
+ ? wrapTextWithAnsi(clean, width)
71
+ : wrapText(clean, width);
72
+ }
73
+ function size(bytes) {
74
+ if (bytes === undefined)
75
+ return "unknown";
76
+ if (bytes < 1024)
77
+ return `${bytes} B`;
78
+ if (bytes < 1024 * 1024)
79
+ return `${Math.round(bytes / 1024)} KB`;
80
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
81
+ }
@@ -0,0 +1,28 @@
1
+ import type { CatalogSource, PluginEntry } from "./model.js";
2
+ /** The middle column of the plugin browser. It owns filtering and row position;
3
+ * the shell owns horizontal navigation and actions. */
4
+ export declare class PkgListPanel {
5
+ private readonly onChange;
6
+ private source;
7
+ private index;
8
+ private filter;
9
+ private filtering;
10
+ private notice;
11
+ private hasMarketplaces;
12
+ constructor(onChange: () => void);
13
+ get selected(): PluginEntry | undefined;
14
+ get ownsKeyboard(): boolean;
15
+ setSource(source: CatalogSource, selectedKey?: string): void;
16
+ setEmptyState(hasMarketplaces: boolean): void;
17
+ move(delta: number): void;
18
+ /** Returns true only when a live filter consumed the input. */
19
+ handleInput(data: string): boolean;
20
+ setNotice(notice: string | undefined): void;
21
+ private entries;
22
+ render(width: number, height: number, focused: boolean): string[];
23
+ /** Pad rather than trim: this column sets the length of the divider drawn
24
+ * beside it, and a divider that stops where the content runs out makes the
25
+ * page look half-drawn. */
26
+ private fit;
27
+ private stateGlyph;
28
+ }