@north-light/crouter 0.3.183 → 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 (67) 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/canvas/nav-model.d.ts +1 -8
  47. package/dist/core/canvas/nav-model.js +30 -59
  48. package/dist/core/canvas/nav-render.d.ts +2 -2
  49. package/dist/core/canvas/nav-render.js +8 -6
  50. package/dist/core/command-manifests/registry.d.ts +10 -3
  51. package/dist/core/command-manifests/registry.js +36 -22
  52. package/dist/core/command-plugins/compose.js +1 -1
  53. package/dist/core/command-plugins/discovery.js +15 -7
  54. package/dist/core/keybindings/catalog.d.ts +1 -1
  55. package/dist/core/keybindings/catalog.js +1 -0
  56. package/dist/core/resolver.d.ts +2 -2
  57. package/dist/core/runtime/tmux-bindings.js +2 -0
  58. package/dist/core/tui/page-host.d.ts +25 -0
  59. package/dist/core/tui/page-host.js +65 -0
  60. package/dist/daemon/__tests__/subtree-filter-freshness.test.d.ts +1 -0
  61. package/dist/daemon/__tests__/subtree-filter-freshness.test.js +63 -0
  62. package/dist/pi-extensions/canvas-bash-valve.js +45 -1
  63. package/dist/types.d.ts +4 -1
  64. package/package.json +1 -1
  65. package/runtime.lock.json +2 -2
  66. /package/dist/{commands/sys/panels → core/tui}/panel.d.ts +0 -0
  67. /package/dist/{commands/sys/panels → core/tui}/panel.js +0 -0
@@ -0,0 +1,640 @@
1
+ // The `crtr pkg browse` page: a grouped source rail, plugin list, and dossier.
2
+ import { Input, matchesKey, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
3
+ import { formatGesture, matchesPiTuiInput, } from "../../../core/keybindings/index.js";
4
+ import { runTuiPage } from "../../../core/tui/page-host.js";
5
+ import { PAGE_CHROME_ROWS, columns, framed, padAnsi, pageFrame, theme, visibleRange, } from "../../../core/tui/panel.js";
6
+ import { availableActions, presentAction, runAction } from "./actions.js";
7
+ import { loadCapability, loadCatalog } from "./catalog.js";
8
+ import { PkgDetailPanel } from "./detail-panel.js";
9
+ import { PkgListPanel } from "./list-panel.js";
10
+ import { INSTALLED_SOURCE_ID, marketplaceSourceId, plural, } from "./model.js";
11
+ const RAIL_WIDTH = 22;
12
+ const LIST_WIDTH = 34;
13
+ const RAIL_MIN_TOTAL_WIDTH = 84;
14
+ const THREE_COLUMN_MIN_WIDTH = 108;
15
+ /** Full-screen plugin browser. The data and mutation boundaries stay in the
16
+ * catalog/actions collaborators, leaving this class responsible for movement,
17
+ * disclosure, and the rendered page grammar. */
18
+ export class PkgBrowseShell {
19
+ tui;
20
+ terminalRows;
21
+ bindings;
22
+ catalog;
23
+ sourceIndex = 0;
24
+ zone = "rail";
25
+ list;
26
+ detail = new PkgDetailPanel();
27
+ // `undefined` is an in-flight capability read; `null` is a completed read with
28
+ // no inspectable plugin directory. `Map.has()` distinguishes a request that
29
+ // has started from a row never selected in this browser session.
30
+ capabilities = new Map();
31
+ modal;
32
+ busy;
33
+ notice;
34
+ _focused = false;
35
+ onCancel;
36
+ constructor(tui, terminalRows, bindings) {
37
+ this.tui = tui;
38
+ this.terminalRows = terminalRows;
39
+ this.bindings = bindings;
40
+ this.catalog = loadCatalog();
41
+ this.list = new PkgListPanel(() => this.tui.requestRender());
42
+ this.syncSource();
43
+ }
44
+ /** The page follows the user's own `crtr.setup.*` navigation gestures, the
45
+ * same ones `crtr sys settings` resolves, alongside its literal defaults —
46
+ * a remap must not leave one full-screen page moving and the other stuck. */
47
+ matches(id, data) {
48
+ return matchesPiTuiInput(this.bindings, id, data, (input, gesture) => matchesKey(input, gesture));
49
+ }
50
+ /** The footer names keys, so it names the gestures actually bound. */
51
+ label(id, fallback) {
52
+ const gestures = this.bindings.gestures(id);
53
+ return gestures.length === 0 ? fallback : formatGesture(gestures[0].split(" > ").pop());
54
+ }
55
+ isDown(data) {
56
+ return matchesKey(data, "j") || matchesKey(data, "down") || this.matches("crtr.setup.list-down", data);
57
+ }
58
+ isUp(data) {
59
+ return matchesKey(data, "k") || matchesKey(data, "up") || this.matches("crtr.setup.list-up", data);
60
+ }
61
+ /** Move one zone to the right, or open the focused row. */
62
+ isDescend(data) {
63
+ return (matchesKey(data, "l") ||
64
+ matchesKey(data, "right") ||
65
+ matchesKey(data, "enter") ||
66
+ this.matches("crtr.setup.advance", data) ||
67
+ this.matches("crtr.setup.open", data));
68
+ }
69
+ /** Move one zone to the left. Escape is deliberately separate: in the dossier
70
+ * it pops the whole view stack where `h` folds one branch. */
71
+ isAscend(data) {
72
+ return matchesKey(data, "h") || matchesKey(data, "left");
73
+ }
74
+ /** Tab cycles rail sources from anywhere. The resolved `tab-next` gesture
75
+ * also carries the horizontal arrows, which mean "into the panel" here, so
76
+ * those are excluded exactly as the settings shell excludes them. */
77
+ cyclesSource(data) {
78
+ const horizontal = matchesKey(data, "right") || matchesKey(data, "left");
79
+ if (matchesKey(data, "tab") || (!horizontal && this.matches("crtr.setup.tab-next", data)))
80
+ return 1;
81
+ if (matchesKey(data, "shift+tab") || (!horizontal && this.matches("crtr.setup.tab-previous", data)))
82
+ return -1;
83
+ return 0;
84
+ }
85
+ set focused(value) {
86
+ this._focused = value;
87
+ }
88
+ get focused() {
89
+ return this._focused;
90
+ }
91
+ /** Required by pi-tui's `Component`, and deliberately a no-op: this page
92
+ * holds no render cache to drop. Every panel is recomputed each frame
93
+ * because capability reads and action results land between input events. */
94
+ invalidate() { }
95
+ dispose() {
96
+ if (this.busy !== undefined)
97
+ clearInterval(this.busy.timer);
98
+ }
99
+ // --- model selection -----------------------------------------------------
100
+ sources() {
101
+ if (this.catalog.sources.length > 0)
102
+ return this.catalog.sources;
103
+ // A first run has no registered marketplace, but it still needs a Library
104
+ // row so the empty state has a home and `a` has an obvious invitation.
105
+ return [{ id: INSTALLED_SOURCE_ID, kind: "installed", label: "Installed", entries: [] }];
106
+ }
107
+ get source() {
108
+ const sources = this.sources();
109
+ this.sourceIndex = Math.max(0, Math.min(this.sourceIndex, sources.length - 1));
110
+ return sources[this.sourceIndex];
111
+ }
112
+ syncSource(selectedKey) {
113
+ this.list.setSource(this.source, selectedKey);
114
+ this.syncDetail();
115
+ }
116
+ syncDetail() {
117
+ const entry = this.list.selected;
118
+ let capability = null;
119
+ if (entry !== undefined) {
120
+ if (!this.capabilities.has(entry.key)) {
121
+ this.capabilities.set(entry.key, undefined);
122
+ // Capability validation is deliberately asynchronous: it asks the same
123
+ // command-path authority the install gate uses. Rendering stays cheap;
124
+ // the dossier shows its identity/provenance immediately, then redraws
125
+ // only when this selected row's capability surface arrives.
126
+ // A capability read that fails is a fact about one plugin, never a
127
+ // reason to take the page down with it: the dossier says the surface is
128
+ // unknowable and every other row keeps working.
129
+ void Promise.resolve(loadCapability(entry))
130
+ .catch(() => null)
131
+ .then((resolved) => {
132
+ this.capabilities.set(entry.key, resolved);
133
+ if (this.list.selected?.key !== entry.key)
134
+ return;
135
+ this.syncDetail();
136
+ this.tui.requestRender();
137
+ });
138
+ }
139
+ capability = this.capabilities.get(entry.key);
140
+ }
141
+ // A catalog warning is page-level, not a fact about the focused plugin. The
142
+ // notice above the list carries the first and says how many others there
143
+ // are; the source dossier lists every one of them, so a second breakage is
144
+ // never silently dropped.
145
+ const warnings = this.catalog.warnings;
146
+ const first = warnings[0];
147
+ this.list.setNotice(first === undefined ? undefined : warnings.length > 1 ? `${first} · +${warnings.length - 1} more` : first);
148
+ this.list.setEmptyState(this.sources().some((source) => source.kind === "marketplace"));
149
+ this.detail.setWarnings(warnings);
150
+ this.detail.setEntry(entry, this.source, capability);
151
+ }
152
+ reload(selectedKey) {
153
+ const priorSource = this.source.id;
154
+ this.catalog = loadCatalog();
155
+ this.capabilities.clear();
156
+ const nextSource = this.sources().findIndex((source) => source.id === priorSource);
157
+ this.sourceIndex = nextSource >= 0 ? nextSource : 0;
158
+ this.syncSource(selectedKey);
159
+ }
160
+ goSource(delta) {
161
+ const sources = this.sources();
162
+ this.sourceIndex = (this.sourceIndex + delta + sources.length) % sources.length;
163
+ this.clearActionNotice();
164
+ this.syncSource();
165
+ }
166
+ moveList(delta) {
167
+ this.list.move(delta);
168
+ this.clearActionNotice();
169
+ this.syncDetail();
170
+ }
171
+ clearActionNotice() {
172
+ this.notice = undefined;
173
+ }
174
+ // --- input ---------------------------------------------------------------
175
+ /** A live filter, an open modal, or a running action holds every key — the
176
+ * one predicate both `handleInput` and the footer consult, so the footer can
177
+ * never advertise a key the filter is swallowing. */
178
+ panelOwnsKeyboard() {
179
+ return this.busy !== undefined || this.modal !== undefined || this.list.ownsKeyboard;
180
+ }
181
+ handleInput(data) {
182
+ if (this.busy !== undefined)
183
+ return;
184
+ if (this.modal !== undefined) {
185
+ this.handleModal(data);
186
+ return;
187
+ }
188
+ if (this.list.ownsKeyboard && this.list.handleInput(data)) {
189
+ this.clearActionNotice();
190
+ this.syncDetail();
191
+ return;
192
+ }
193
+ const cycle = this.cyclesSource(data);
194
+ if (cycle !== 0) {
195
+ this.goSource(cycle);
196
+ return;
197
+ }
198
+ if (matchesKey(data, "q") || this.matches("crtr.setup.cancel", data)) {
199
+ this.onCancel?.();
200
+ return;
201
+ }
202
+ if (this.zone === "rail") {
203
+ if (this.isDown(data))
204
+ this.goSource(1);
205
+ else if (this.isUp(data))
206
+ this.goSource(-1);
207
+ else if (this.isDescend(data))
208
+ this.enterList();
209
+ else if (matchesKey(data, "escape") || matchesKey(data, "left"))
210
+ this.onCancel?.();
211
+ else if (matchesKey(data, "a"))
212
+ this.openAdd();
213
+ else if (!this.triggerAction(data, undefined))
214
+ this.jumpToInitial(data);
215
+ return;
216
+ }
217
+ if (this.zone === "list") {
218
+ if (this.list.handleInput(data)) {
219
+ this.clearActionNotice();
220
+ this.syncDetail();
221
+ return;
222
+ }
223
+ if (this.isDown(data))
224
+ this.moveList(1);
225
+ else if (this.isUp(data))
226
+ this.moveList(-1);
227
+ else if (this.isDescend(data))
228
+ this.enterDetail();
229
+ else if (this.isAscend(data) || matchesKey(data, "escape"))
230
+ this.enterRail();
231
+ else
232
+ this.triggerAction(data, this.list.selected);
233
+ return;
234
+ }
235
+ // The dossier zone. Its own stack answers first — a row cursor and an open
236
+ // doc or command view — and only a key it does not use leaves for the list.
237
+ if (this.isDown(data))
238
+ this.detail.moveVertical(1);
239
+ else if (this.isUp(data))
240
+ this.detail.moveVertical(-1);
241
+ else if (matchesKey(data, "space") || this.matches("crtr.setup.toggle", data))
242
+ this.detail.toggleExpand();
243
+ else if (this.isDescend(data))
244
+ this.detail.open();
245
+ else if (this.isAscend(data)) {
246
+ if (!this.detail.collapseOrPop())
247
+ this.enterList();
248
+ }
249
+ else if (matchesKey(data, "escape")) {
250
+ if (!this.detail.escape())
251
+ this.enterList();
252
+ }
253
+ else
254
+ this.triggerAction(data, this.list.selected);
255
+ }
256
+ enterRail() {
257
+ this.zone = "rail";
258
+ this.clearActionNotice();
259
+ }
260
+ enterList() {
261
+ this.zone = "list";
262
+ this.clearActionNotice();
263
+ this.syncDetail();
264
+ }
265
+ enterDetail() {
266
+ if (this.list.selected === undefined && this.source.marketplace === undefined)
267
+ return;
268
+ this.zone = "detail";
269
+ this.clearActionNotice();
270
+ this.syncDetail();
271
+ }
272
+ jumpToInitial(data) {
273
+ if (data.length !== 1 || data.charCodeAt(0) < 32)
274
+ return;
275
+ const needle = data.toLowerCase();
276
+ const found = this.sources().findIndex((source, index) => index !== this.sourceIndex && source.label.toLowerCase().startsWith(needle));
277
+ if (found >= 0) {
278
+ this.sourceIndex = found;
279
+ this.syncSource();
280
+ }
281
+ }
282
+ // --- actions and modals --------------------------------------------------
283
+ /** The focused rail row as an action subject. Carries the marketplace's scope
284
+ * as well as its name: two marketplaces can share a name across the user and
285
+ * project scopes, and without the scope `availableActions` cannot tell which
286
+ * one a remove would destroy, so it offers nothing at all. */
287
+ marketplaceSubject() {
288
+ const marketplace = this.source.marketplace;
289
+ if (marketplace === undefined)
290
+ return {};
291
+ return { marketplace: marketplace.name, scope: marketplace.scope };
292
+ }
293
+ triggerAction(data, entry) {
294
+ const requests = availableActions(entry === undefined ? this.marketplaceSubject() : { entry });
295
+ const key = requests.find((request) => (request.kind === "install" && matchesKey(data, "i")) ||
296
+ ((request.kind === "enable" || request.kind === "disable") && matchesKey(data, "e")) ||
297
+ (request.kind === "remove" || request.kind === "market-remove" ? matchesKey(data, "x") : false) ||
298
+ (request.kind === "update" || request.kind === "market-update" ? matchesKey(data, "u") : false));
299
+ if (key === undefined)
300
+ return false;
301
+ const presentation = presentAction(key);
302
+ if (key.kind === "install" && this.catalog.installScopes.length > 1) {
303
+ this.modal = { kind: "scope", request: key, presentation, index: 0 };
304
+ }
305
+ else if (presentation.confirm) {
306
+ this.modal = { kind: "confirm", request: key, presentation };
307
+ }
308
+ else {
309
+ this.execute(key, presentation);
310
+ }
311
+ return true;
312
+ }
313
+ openAdd() {
314
+ const url = new Input();
315
+ const ref = new Input();
316
+ url.focused = true;
317
+ this.modal = { kind: "add", url, ref, field: "url" };
318
+ }
319
+ handleModal(data) {
320
+ const modal = this.modal;
321
+ if (modal === undefined)
322
+ return;
323
+ if (modal.kind === "scope") {
324
+ if (matchesKey(data, "escape"))
325
+ this.modal = undefined;
326
+ else if (matchesKey(data, "j") || matchesKey(data, "down"))
327
+ modal.index = Math.min(this.catalog.installScopes.length - 1, modal.index + 1);
328
+ else if (matchesKey(data, "k") || matchesKey(data, "up"))
329
+ modal.index = Math.max(0, modal.index - 1);
330
+ else if (matchesKey(data, "enter")) {
331
+ this.modal = undefined;
332
+ this.execute({ ...modal.request, scope: this.catalog.installScopes[modal.index] }, modal.presentation);
333
+ }
334
+ }
335
+ else if (modal.kind === "confirm") {
336
+ if (matchesKey(data, "y") || matchesKey(data, "enter")) {
337
+ this.modal = undefined;
338
+ this.execute(modal.request, modal.presentation);
339
+ }
340
+ else if (matchesKey(data, "n") || matchesKey(data, "escape"))
341
+ this.modal = undefined;
342
+ }
343
+ else {
344
+ if (matchesKey(data, "escape")) {
345
+ this.modal = undefined;
346
+ }
347
+ else if (matchesKey(data, "tab")) {
348
+ modal.field = modal.field === "url" ? "ref" : "url";
349
+ modal.url.focused = modal.field === "url";
350
+ modal.ref.focused = modal.field === "ref";
351
+ }
352
+ else if (matchesKey(data, "enter")) {
353
+ if (modal.field === "url") {
354
+ modal.field = "ref";
355
+ modal.url.focused = false;
356
+ modal.ref.focused = true;
357
+ }
358
+ else
359
+ this.submitAdd(modal);
360
+ }
361
+ else {
362
+ (modal.field === "url" ? modal.url : modal.ref).handleInput(data);
363
+ }
364
+ }
365
+ }
366
+ submitAdd(modal) {
367
+ const url = modal.url.getValue().trim();
368
+ if (url === "")
369
+ return;
370
+ const ref = modal.ref.getValue().trim();
371
+ this.modal = undefined;
372
+ const request = {
373
+ kind: "market-add",
374
+ url,
375
+ ...(ref === "" ? {} : { ref }),
376
+ scope: this.addScope(),
377
+ };
378
+ this.execute(request, presentAction(request));
379
+ }
380
+ /** Where `a` registers a marketplace. Named once so the modal can state
381
+ * exactly what the action will do. */
382
+ addScope() {
383
+ return this.catalog.installScopes[0] ?? "user";
384
+ }
385
+ execute(request, presentation) {
386
+ // A marketplace's identity is its name AND its scope: one name can be
387
+ // registered in both, and a target resolved by name alone renders the
388
+ // project copy's outcome under the user copy's row.
389
+ const target = request.entry !== undefined
390
+ ? { kind: "plugin", key: request.entry.key }
391
+ : request.marketplace === undefined || request.scope === undefined
392
+ ? undefined
393
+ : { kind: "marketplace", sourceId: marketplaceSourceId(request.marketplace, request.scope) };
394
+ this.beginBusy(presentation.progress);
395
+ void runAction(request).then((outcome) => this.finishAction(target, outcome));
396
+ }
397
+ beginBusy(progress) {
398
+ const busy = {
399
+ progress,
400
+ frame: 0,
401
+ timer: setInterval(() => {
402
+ if (this.busy === undefined)
403
+ return;
404
+ this.busy.frame = (this.busy.frame + 1) % 4;
405
+ this.tui.requestRender();
406
+ }, 125),
407
+ };
408
+ this.busy = busy;
409
+ this.tui.requestRender();
410
+ }
411
+ finishAction(target, outcome) {
412
+ if (this.busy !== undefined)
413
+ clearInterval(this.busy.timer);
414
+ this.busy = undefined;
415
+ this.notice = { text: outcome.message, ok: outcome.ok };
416
+ this.reload(target?.kind === "plugin" ? target.key : undefined);
417
+ this.syncDetail();
418
+ if (target?.kind === "plugin") {
419
+ const entry = this.sources().flatMap((source) => source.entries).find((candidate) => candidate.key === target.key);
420
+ this.detail.setPluginAction(entry, outcome);
421
+ }
422
+ else if (target?.kind === "marketplace") {
423
+ this.detail.setMarketplaceAction(target.sourceId, outcome);
424
+ }
425
+ this.tui.requestRender();
426
+ }
427
+ // --- rendering -----------------------------------------------------------
428
+ /** The source rail. It scrolls like the list does: marketplace count is user
429
+ * controlled, so a fixed slice would let the selection sit below the last
430
+ * drawn row, moving invisibly while the badge the user is aiming for never
431
+ * appears. Group headings scroll with their group. */
432
+ renderRail(width, height) {
433
+ const out = [];
434
+ let selectedLine = 0;
435
+ let marketplaceHeading = false;
436
+ for (const [index, source] of this.sources().entries()) {
437
+ if (source.kind === "installed") {
438
+ out.push(theme.fg("dim", " LIBRARY"));
439
+ }
440
+ else if (!marketplaceHeading) {
441
+ if (out.length > 0)
442
+ out.push("");
443
+ out.push(theme.fg("dim", " MARKETPLACES"));
444
+ marketplaceHeading = true;
445
+ }
446
+ const badge = source.kind === "installed"
447
+ ? String(source.entries.length)
448
+ : `${source.marketplace?.installedCount ?? 0}/${source.marketplace?.pluginCount ?? source.entries.length}`;
449
+ const room = Math.max(1, width - visibleWidth(badge) - 3);
450
+ const label = padAnsi(truncateToWidth(source.label, room), room);
451
+ const body = ` ${label} ${badge}`;
452
+ if (index === this.sourceIndex)
453
+ selectedLine = out.length;
454
+ if (index === this.sourceIndex && this.zone === "rail")
455
+ out.push(theme.bg("selectedBg", padAnsi(body, width)));
456
+ else if (index === this.sourceIndex)
457
+ out.push(`${theme.fg("accent", "▌")}${padAnsi(theme.fg("accent", body.slice(1)), width - 1)}`);
458
+ else
459
+ out.push(padAnsi(` ${label} ${theme.fg("dim", badge)}`, width));
460
+ }
461
+ const range = visibleRange(out.length, selectedLine, height);
462
+ const window = out.slice(range.start, range.start + height);
463
+ while (window.length < height)
464
+ window.push("");
465
+ return window;
466
+ }
467
+ renderPanel(width, height, zone) {
468
+ if (this.modal !== undefined)
469
+ return this.renderModal(width, height);
470
+ if (zone === "detail")
471
+ return this.detail.render(width, height, true);
472
+ return this.list.render(width, height, zone === "list");
473
+ }
474
+ renderModal(width, height) {
475
+ const modal = this.modal;
476
+ if (modal === undefined)
477
+ return [];
478
+ let lines;
479
+ // No modal repeats its own keys: the footer is the single place this page
480
+ // names the currently-legal set, and it yields to an open modal.
481
+ if (modal.kind === "scope") {
482
+ lines = [theme.fg("accent", theme.bold("Install into")), "", ...this.catalog.installScopes.map((scope, index) => `${index === modal.index ? theme.fg("accent", "▶") : " "} ${scope}`)];
483
+ }
484
+ else if (modal.kind === "confirm") {
485
+ lines = [theme.fg("warning", theme.bold(modal.presentation.label)), "", ...this.wrapModal(modal.presentation.confirm ?? "Continue?", Math.max(1, width - 4))];
486
+ }
487
+ else {
488
+ const inputWidth = Math.max(1, width - 8);
489
+ // The scope is not offered as a choice here, so it is stated: a user who
490
+ // reads "Add marketplace" and gets a user-scope registration they never
491
+ // chose has been surprised by the page.
492
+ lines = [
493
+ theme.fg("accent", theme.bold("Add marketplace")),
494
+ theme.fg("dim", `Registers into ${this.addScope()} scope`),
495
+ "",
496
+ "Git URL",
497
+ modal.field === "url" ? modal.url.render(inputWidth)[0] ?? "" : modal.url.getValue(),
498
+ "",
499
+ "Ref (optional)",
500
+ modal.field === "ref" ? modal.ref.render(inputWidth)[0] ?? "" : modal.ref.getValue(),
501
+ ];
502
+ }
503
+ const framedLines = framed(lines, width);
504
+ while (framedLines.length < height)
505
+ framedLines.push("");
506
+ return framedLines.slice(0, height);
507
+ }
508
+ wrapModal(text, width) {
509
+ const words = text.trim().split(/\s+/);
510
+ const lines = [];
511
+ let line = "";
512
+ for (const word of words) {
513
+ const next = line === "" ? word : `${line} ${word}`;
514
+ if (visibleWidth(next) <= width)
515
+ line = next;
516
+ else {
517
+ if (line !== "")
518
+ lines.push(line);
519
+ line = word;
520
+ }
521
+ }
522
+ if (line !== "")
523
+ lines.push(line);
524
+ return lines;
525
+ }
526
+ footer(width) {
527
+ if (this.busy !== undefined) {
528
+ const frame = ["◐", "◓", "◑", "◒"][this.busy.frame];
529
+ return theme.fg("warning", `${frame} ${this.busy.progress}`);
530
+ }
531
+ // A modal outranks a lingering notice: while one is open its keys are the
532
+ // only legal ones, and the last action's success line must not sit under a
533
+ // destructive confirm telling the user nothing about how to answer it.
534
+ if (this.modal?.kind === "scope")
535
+ return theme.fg("dim", "j/k choose · enter install · esc cancel");
536
+ if (this.modal?.kind === "confirm")
537
+ return theme.fg("dim", "y/enter confirm · n/esc cancel");
538
+ if (this.modal?.kind === "add")
539
+ return theme.fg("dim", "tab field · enter next/add · esc cancel");
540
+ // The open filter owns every key, `q` and `tab` included — naming them here
541
+ // would teach a shortcut that types a literal character into the filter.
542
+ if (this.list.ownsKeyboard)
543
+ return theme.fg("dim", "type to filter · enter keep · esc clear");
544
+ if (this.notice !== undefined)
545
+ return theme.fg(this.notice.ok ? "success" : "warning", this.notice.text);
546
+ const actionHints = this.availableActionHints();
547
+ const zone = this.zone;
548
+ const move = `${this.label("crtr.setup.list-down", "j")}/${this.label("crtr.setup.list-up", "k")}`;
549
+ const open = this.label("crtr.setup.advance", "enter");
550
+ // Navigation is the half that must survive: a user who cannot read `q close`
551
+ // is stuck, while one who cannot read `x remove` has merely lost a shortcut.
552
+ // So the left side sheds its optional pieces — the letter-jump aside first,
553
+ // then the action hints — before anything truncates.
554
+ const navigation = zone === "rail"
555
+ ? `${move} source · a add`
556
+ : zone === "list"
557
+ ? `${move} row · / filter · ${open} dossier`
558
+ : this.detail.viewing
559
+ ? `${move} scroll · esc back`
560
+ : `${move} row · space expand · ${open} open · esc list`;
561
+ const aside = zone === "rail" ? "type a letter to jump" : "";
562
+ const right = `${this.label("crtr.setup.tab-next", "tab")} source · ${this.label("crtr.setup.cancel", "q")} close`;
563
+ const candidates = [
564
+ [navigation, actionHints, aside],
565
+ [navigation, actionHints],
566
+ [navigation],
567
+ ].map((parts) => parts.filter((part) => part !== "").join(" · "));
568
+ for (const left of candidates) {
569
+ const gap = width - visibleWidth(left) - visibleWidth(right);
570
+ if (gap >= 2)
571
+ return `${theme.fg("dim", left)}${" ".repeat(gap)}${theme.fg("dim", right)}`;
572
+ }
573
+ return theme.fg("dim", truncateToWidth(`${navigation} · ${this.label("crtr.setup.cancel", "q")} close`, width));
574
+ }
575
+ /** The footer names KEYS, not actions. An absent plugin offers a user-scope
576
+ * and a project-scope install — two actions behind one `i`, which the scope
577
+ * modal then chooses between — so the hints are deduped before joining. */
578
+ availableActionHints() {
579
+ const entry = this.zone === "rail" ? undefined : this.list.selected;
580
+ const hints = availableActions(entry === undefined ? this.marketplaceSubject() : { entry })
581
+ .map((request) => {
582
+ switch (request.kind) {
583
+ case "install": return "i install";
584
+ case "enable": return "e enable";
585
+ case "disable": return "e disable";
586
+ case "remove": return "x remove";
587
+ case "update": return "u update";
588
+ case "market-add": return "a add";
589
+ case "market-remove": return "x remove";
590
+ case "market-update": return "u update";
591
+ }
592
+ });
593
+ return [...new Set(hints)].join(" · ");
594
+ }
595
+ render(width) {
596
+ const height = this.terminalRows();
597
+ const bodyHeight = Math.max(1, height - PAGE_CHROME_ROWS);
598
+ const railVisible = width >= RAIL_MIN_TOTAL_WIDTH;
599
+ const threeColumns = width >= THREE_COLUMN_MIN_WIDTH;
600
+ let body;
601
+ if (threeColumns) {
602
+ const detailWidth = Math.max(1, width - RAIL_WIDTH - LIST_WIDTH - 6);
603
+ const middle = columns(this.list.render(LIST_WIDTH, bodyHeight, this.zone === "list"), LIST_WIDTH, this.modal === undefined ? this.detail.render(detailWidth, bodyHeight, this.zone === "detail") : this.renderModal(detailWidth, bodyHeight), detailWidth);
604
+ body = columns(this.renderRail(RAIL_WIDTH, bodyHeight), RAIL_WIDTH, middle, width - RAIL_WIDTH - 3);
605
+ }
606
+ else if (railVisible) {
607
+ const panelWidth = width - RAIL_WIDTH - 3;
608
+ const panelZone = this.zone === "detail" ? "detail" : "list";
609
+ body = columns(this.renderRail(RAIL_WIDTH, bodyHeight), RAIL_WIDTH, this.renderPanel(panelWidth, bodyHeight, panelZone), panelWidth);
610
+ }
611
+ else if (this.modal !== undefined) {
612
+ body = this.renderModal(width, bodyHeight);
613
+ }
614
+ else if (this.zone === "rail") {
615
+ // At 80 columns the rail costs more than it explains beside the list, so
616
+ // it becomes the whole panel when focused rather than disappearing: `a`,
617
+ // `u` and `x` are rail keys, and a hidden rail put every marketplace
618
+ // action out of reach at the narrowest supported width.
619
+ body = this.renderRail(width, bodyHeight);
620
+ }
621
+ else {
622
+ body = this.renderPanel(width, bodyHeight, this.zone === "detail" ? "detail" : "list");
623
+ }
624
+ const meta = railVisible
625
+ ? `${plural(this.sources().filter((source) => source.kind === "marketplace").length, "marketplace", "marketplaces")} · ${plural(this.sources().reduce((count, source) => count + source.entries.length, 0), "plugin", "plugins")}`
626
+ : `${this.source.label} · ${this.sourceIndex + 1}/${this.sources().length}`;
627
+ return pageFrame({ width, height, title: "crouter plugins", meta, body, footer: this.footer(width) });
628
+ }
629
+ }
630
+ export async function runPkgBrowse() {
631
+ if (!process.stdout.isTTY) {
632
+ process.stdout.write("crtr pkg browse needs a TTY. Run it in a terminal, or use `crtr pkg market browse` and `crtr pkg plugin list` for scriptable access.\n");
633
+ return;
634
+ }
635
+ await runTuiPage(({ tui, terminal, bindings }, control) => {
636
+ const shell = new PkgBrowseShell(tui, () => terminal.rows, bindings);
637
+ shell.onCancel = control.cancel;
638
+ return shell;
639
+ });
640
+ }
@@ -0,0 +1,2 @@
1
+ import type { LeafDef } from '../../core/command.js';
2
+ export declare const pkgBrowseLeaf: LeafDef;
@@ -0,0 +1,27 @@
1
+ // The `crtr pkg browse` leaf DEFINITION only. The shell itself lives in
2
+ // browse/shell.ts and is loaded on demand, because it pulls pi's model runtime
3
+ // and TUI components — an expensive module graph. Registering the `pkg`
4
+ // subtree (every `crtr pkg …` invocation, and root `-h`, which builds the full
5
+ // tree) needs the help metadata below and nothing else, so the shell must NOT
6
+ // be reachable by a static import from here.
7
+ import { defineLeaf } from '../../core/command.js';
8
+ export const pkgBrowseLeaf = defineLeaf({
9
+ name: 'browse',
10
+ description: 'open the interactive plugin browser',
11
+ whenToUse: 'a human wants to browse registered marketplaces and inspect or manage plugins interactively. Agents should use pkg market browse or pkg plugin list, which are scriptable.',
12
+ help: {
13
+ name: 'pkg browse',
14
+ summary: 'open the interactive plugin browser',
15
+ inputNote: 'No input parameters.',
16
+ output: [],
17
+ outputKind: 'object',
18
+ effects: [
19
+ 'Opens a pi-tui page for browsing registered marketplaces, inspecting plugin capability surfaces, and managing plugins.',
20
+ 'Requires a TTY. Without one it prints a pointer to pkg market browse and pkg plugin list and exits.',
21
+ ],
22
+ },
23
+ run: async () => {
24
+ await (await import('./browse/shell.js')).runPkgBrowse();
25
+ },
26
+ render: () => '',
27
+ });