@nklisch/pi-enhanced 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -0
  3. package/node_modules/@nklisch/pi-astral-pocket/LICENSE +3 -0
  4. package/node_modules/@nklisch/pi-astral-pocket/README.md +82 -0
  5. package/node_modules/@nklisch/pi-astral-pocket/package.json +52 -0
  6. package/node_modules/@nklisch/pi-astral-pocket/src/activation.ts +44 -0
  7. package/node_modules/@nklisch/pi-astral-pocket/src/config.ts +78 -0
  8. package/node_modules/@nklisch/pi-astral-pocket/src/distiller.ts +162 -0
  9. package/node_modules/@nklisch/pi-astral-pocket/src/guidance.ts +59 -0
  10. package/node_modules/@nklisch/pi-astral-pocket/src/index.ts +127 -0
  11. package/node_modules/@nklisch/pi-astral-pocket/src/sessions.ts +225 -0
  12. package/node_modules/@nklisch/pi-astral-pocket/src/store.ts +227 -0
  13. package/node_modules/@nklisch/pi-astral-pocket/src/tools.ts +112 -0
  14. package/node_modules/@nklisch/pi-clearance/native/clearance-core.darwin-arm64.node +0 -0
  15. package/node_modules/@nklisch/pi-clearance/native/clearance-core.darwin-x64.node +0 -0
  16. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-arm64-gnu.node +0 -0
  17. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  18. package/node_modules/@nklisch/pi-conveniences/extensions/agents-context.ts +4 -6
  19. package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +38 -22
  20. package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
  21. package/node_modules/@nklisch/pi-plugins/README.md +12 -6
  22. package/node_modules/@nklisch/pi-plugins/dist/catalog.js +11 -0
  23. package/node_modules/@nklisch/pi-plugins/dist/catalog.js.map +1 -1
  24. package/node_modules/@nklisch/pi-plugins/dist/host.js +85 -29
  25. package/node_modules/@nklisch/pi-plugins/dist/host.js.map +1 -1
  26. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.d.ts +4 -0
  27. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.js +24 -0
  28. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.js.map +1 -0
  29. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +6 -0
  30. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +130 -47
  31. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  32. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.d.ts +9 -0
  33. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.js +37 -0
  34. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.js.map +1 -0
  35. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +2 -0
  36. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  37. package/node_modules/@nklisch/pi-plugins/dist/types.d.ts +4 -1
  38. package/node_modules/@nklisch/pi-plugins/dist/types.js.map +1 -1
  39. package/node_modules/@nklisch/pi-plugins/package.json +1 -1
  40. package/package.json +4 -1
@@ -46,6 +46,8 @@ export declare class PluginManager implements Component, Focusable {
46
46
  private checking;
47
47
  private updatesChecked;
48
48
  private checkedMarketplaces;
49
+ private checkTotal;
50
+ private checkErrors;
49
51
  private checkingMarketplaces;
50
52
  private checkController;
51
53
  private checkPromise;
@@ -62,6 +64,10 @@ export declare class PluginManager implements Component, Focusable {
62
64
  private batchItemActive;
63
65
  private destroyed;
64
66
  private completed;
67
+ private bodyScroll;
68
+ private bodyFocusLine;
69
+ private bodyHeight;
70
+ private followFocus;
65
71
  constructor(options: PluginManagerOptions);
66
72
  get focused(): boolean;
67
73
  set focused(value: boolean);
@@ -1,5 +1,7 @@
1
1
  import { Input, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { DEFAULT_REFRESH_TIMEOUT_MS } from "../host.js";
3
+ import { installedPluginVersion } from "../plugin-metadata.js";
4
+ import { framePluginManager, padManagerLine } from "./plugin-manager-layout.js";
3
5
  import { filterPluginRows, pluginIdentity, pluginManagerKeyAction, pluginManagerMarketplaceRows, projectInstalledPluginDetail, prunePluginSelection, restorePluginCursor, } from "./plugin-manager-model.js";
4
6
  const TAB_ORDER = ["installed", "discover", "marketplaces", "issues"];
5
7
  const BATCH_ACTIONS = ["install", "update", "enable", "disable", "remove"];
@@ -27,10 +29,6 @@ function wrapText(text, width) {
27
29
  function catalogEntry(catalog, name) {
28
30
  return catalog?.plugins.find((item) => item.name === name);
29
31
  }
30
- function receiptVersion(plugin) {
31
- const value = plugin?.receipt?.version;
32
- return typeof value === "string" && value.length > 0 ? value : undefined;
33
- }
34
32
  function identityOf(plugin) {
35
33
  return pluginIdentity(plugin.name, plugin.marketplace);
36
34
  }
@@ -76,6 +74,8 @@ export class PluginManager {
76
74
  checking = false;
77
75
  updatesChecked = false;
78
76
  checkedMarketplaces = 0;
77
+ checkTotal = 0;
78
+ checkErrors = new Map();
79
79
  checkingMarketplaces = new Set();
80
80
  checkController;
81
81
  checkPromise;
@@ -92,6 +92,10 @@ export class PluginManager {
92
92
  batchItemActive = false;
93
93
  destroyed = false;
94
94
  completed = false;
95
+ bodyScroll = 0;
96
+ bodyFocusLine = 0;
97
+ bodyHeight = 20;
98
+ followFocus = true;
95
99
  constructor(options) {
96
100
  this.host = options.host;
97
101
  this.tui = options.tui;
@@ -115,35 +119,61 @@ export class PluginManager {
115
119
  this.dialogInput.focused = value && this.dialog?.kind === "input";
116
120
  }
117
121
  render(width) {
118
- const renderWidth = Math.max(1, width);
119
- const lines = [];
120
- lines.push(this.renderTabs(renderWidth));
121
- lines.push(this.theme.fg("border", "─".repeat(renderWidth)));
122
+ const renderWidth = Math.max(1, width < 4 ? width : width - 4);
123
+ // Reserve shell, tabs, help/status and Pi's own footer. Keep the selected
124
+ // row reachable rather than letting long catalogs push it off screen.
125
+ const terminalRows = this.tui.terminal?.rows ?? 40;
126
+ this.bodyHeight = Math.max(1, terminalRows - 9);
127
+ this.bodyFocusLine = 0;
128
+ let body;
122
129
  if (this.dialog !== undefined)
123
- lines.push(...this.renderDialog(renderWidth));
130
+ body = this.renderDialog(renderWidth);
124
131
  else if (this.view === "detail")
125
- lines.push(...this.renderDetail(renderWidth));
132
+ body = this.renderDetail(renderWidth);
126
133
  else if (this.view === "confirm")
127
- lines.push(...this.renderConfirmation(renderWidth));
134
+ body = this.renderConfirmation(renderWidth);
128
135
  else if (this.view === "batch")
129
- lines.push(...this.renderBatch(renderWidth));
136
+ body = this.renderBatch(renderWidth);
130
137
  else if (this.tab === "installed")
131
- lines.push(...this.renderInstalled(renderWidth));
138
+ body = this.renderInstalled(renderWidth);
132
139
  else if (this.tab === "discover")
133
- lines.push(...this.renderDiscover(renderWidth));
140
+ body = this.renderDiscover(renderWidth);
134
141
  else if (this.tab === "marketplaces")
135
- lines.push(...this.renderMarketplaces(renderWidth));
142
+ body = this.renderMarketplaces(renderWidth);
136
143
  else
137
- lines.push(...this.renderIssues(renderWidth));
138
- lines.push("");
139
- lines.push(this.renderFooter(renderWidth));
144
+ body = this.renderIssues(renderWidth);
145
+ if (this.searchFocused)
146
+ this.bodyFocusLine = 1;
147
+ if (this.followFocus) {
148
+ if (this.bodyFocusLine < this.bodyScroll)
149
+ this.bodyScroll = this.bodyFocusLine;
150
+ if (this.bodyFocusLine >= this.bodyScroll + this.bodyHeight)
151
+ this.bodyScroll = this.bodyFocusLine - this.bodyHeight + 1;
152
+ }
153
+ this.bodyScroll = Math.max(0, Math.min(this.bodyScroll, body.length - this.bodyHeight));
154
+ const hiddenBelow = Math.max(0, body.length - this.bodyScroll - this.bodyHeight);
155
+ const scrollHint = body.length > this.bodyHeight ? `↑ ${this.bodyScroll} above · ↓ ${hiddenBelow} below · PgUp/PgDn scroll` : "";
156
+ const lines = [
157
+ this.renderTabs(renderWidth),
158
+ this.theme.fg("borderMuted", "─".repeat(renderWidth)),
159
+ ...body.slice(this.bodyScroll, this.bodyScroll + this.bodyHeight),
160
+ this.theme.fg("muted", scrollHint),
161
+ this.renderFooter(renderWidth),
162
+ ];
140
163
  if (this.toast !== undefined)
141
- lines.push(this.theme.fg("accent", truncateToWidth(` ${this.toast}`, renderWidth, "")));
142
- return lines.map((line) => truncateToWidth(line, renderWidth, ""));
164
+ lines.push(this.theme.fg("accent", this.toast));
165
+ return framePluginManager(lines, width, this.theme);
143
166
  }
144
167
  handleInput(data) {
145
168
  if (this.destroyed)
146
169
  return;
170
+ if (!this.searchFocused && this.dialog?.kind !== "input" && (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown))) {
171
+ this.bodyScroll += (matchesKey(data, Key.pageUp) ? -1 : 1) * this.bodyHeight;
172
+ this.followFocus = false;
173
+ this.requestRender();
174
+ return;
175
+ }
176
+ this.followFocus = true;
147
177
  if (this.dialog !== undefined) {
148
178
  this.handleDialogInput(data);
149
179
  return;
@@ -203,6 +233,7 @@ export class PluginManager {
203
233
  }
204
234
  invalidate() {
205
235
  this.searchInput.invalidate();
236
+ this.dialogInput.invalidate();
206
237
  }
207
238
  dispose() {
208
239
  this.destroyed = true;
@@ -233,8 +264,10 @@ export class PluginManager {
233
264
  return tab === this.tab ? this.theme.bg("selectedBg", this.theme.fg("text", text)) : this.theme.fg("muted", text);
234
265
  }).join(" ");
235
266
  const status = this.statusText(width);
267
+ if (visibleWidth(rendered) > width)
268
+ return this.theme.fg("accent", truncateToWidth(`‹ ${labels[this.tab]} › ${TAB_ORDER.indexOf(this.tab) + 1}/4`, width, "…"));
236
269
  if (width < 80)
237
- return truncateToWidth(rendered, width, "");
270
+ return rendered;
238
271
  const gap = Math.max(1, width - visibleWidth(rendered) - visibleWidth(status));
239
272
  return truncateToWidth(rendered + " ".repeat(gap) + status, width, "");
240
273
  }
@@ -242,11 +275,13 @@ export class PluginManager {
242
275
  if (width < 80)
243
276
  return "";
244
277
  if (this.checking)
245
- return this.theme.fg("accent", `◌ Checking ${this.checkedMarketplaces}/${this.checkingMarketplaces.size} marketplaces · manager remains available`);
278
+ return this.theme.fg("accent", `◌ Checking ${this.checkedMarketplaces}/${this.checkTotal} marketplaces · manager remains available`);
246
279
  if (this.reloadNeeded)
247
280
  return this.theme.fg("warning", "● Reload needed");
281
+ if (this.checkErrors.size > 0)
282
+ return this.theme.fg("warning", `Local data · ${this.checkErrors.size} check${this.checkErrors.size === 1 ? "" : "s"} failed`);
248
283
  if (this.updatesChecked)
249
- return this.theme.fg("muted", "Local data · updates checked just now");
284
+ return this.theme.fg("muted", "Local data · checked this session");
250
285
  return this.theme.fg("muted", "Local data · updates not checked");
251
286
  }
252
287
  renderInstalled(width) {
@@ -273,6 +308,8 @@ export class PluginManager {
273
308
  const lines = [this.heading("Manage marketplaces")];
274
309
  const rows = pluginManagerMarketplaceRows(this.marketplaces, this.checkOnOpen);
275
310
  for (const [index, row] of rows.entries()) {
311
+ if (this.marketplaceCursor === index)
312
+ this.bodyFocusLine = lines.length;
276
313
  if (row.kind === "marketplace") {
277
314
  const marketplace = row.marketplace;
278
315
  const status = this.marketplaceStatus(marketplace.name);
@@ -303,6 +340,8 @@ export class PluginManager {
303
340
  const issue = this.issues[index];
304
341
  const selected = index === this.cursor;
305
342
  const color = issue.severity === "error" ? "error" : "warning";
343
+ if (selected)
344
+ this.bodyFocusLine = lines.length;
306
345
  lines.push(this.renderCursorLine(selected, this.theme.fg(color, `${issue.severity === "error" ? "×" : "△"} ${issue.title}`)));
307
346
  lines.push(this.indent(this.theme.fg("muted", issue.message), width));
308
347
  }
@@ -321,8 +360,8 @@ export class PluginManager {
321
360
  this.theme.fg("accent", `${this.tab === "discover" ? "Discover" : "Installed"} / Plugin details`),
322
361
  this.heading(`${row.name} @ ${row.marketplace}`),
323
362
  this.fact("Version", row.availableVersion === undefined
324
- ? receiptVersion(installed) ?? row.version ?? "not declared"
325
- : `${receiptVersion(installed) ?? "unversioned"} → ${row.availableVersion}`),
363
+ ? installedPluginVersion(installed) ?? row.version ?? "unavailable"
364
+ : `${installedPluginVersion(installed) ?? "version unavailable"} → ${row.availableVersion}`),
326
365
  this.fact("Status", installed === undefined ? "not installed" : this.statusLabel(installed, entry)),
327
366
  this.fact("Source", entry?.source.kind === "local"
328
367
  ? entry.source.path
@@ -348,7 +387,7 @@ export class PluginManager {
348
387
  lines.push("");
349
388
  const marker = installed.autoUpdate ? this.theme.fg("success", "[on]") : this.theme.fg("muted", "[off]");
350
389
  lines.push(`${marker} ${this.theme.bold("Update automatically on Pi startup")}`);
351
- lines.push(this.indent(this.theme.fg("muted", "Enabling grants standing authorization to replace executable plugin content when its declared catalog version changes."), width));
390
+ lines.push(this.indent(this.theme.fg("muted", "Enabling grants standing authorization to replace executable plugin content when its declared release version changes."), width));
352
391
  lines.push("");
353
392
  }
354
393
  lines.push(this.theme.fg("accent", "Actions"));
@@ -356,6 +395,8 @@ export class PluginManager {
356
395
  for (let index = 0; index < actions.length; index++) {
357
396
  const action = actions[index];
358
397
  const text = action.label;
398
+ if (index === this.detailActionCursor)
399
+ this.bodyFocusLine = lines.length;
359
400
  lines.push(this.renderCursorLine(index === this.detailActionCursor, action.destructive ? this.theme.fg("error", text) : index === 0 ? this.theme.fg("accent", text) : this.theme.fg("text", text)));
360
401
  }
361
402
  return lines;
@@ -368,6 +409,7 @@ export class PluginManager {
368
409
  for (const line of wrapText(dialog.message, Math.max(8, width - 4)))
369
410
  lines.push(this.indent(line, width));
370
411
  lines.push("");
412
+ this.bodyFocusLine = lines.length + this.dialogCursor;
371
413
  lines.push(this.renderCursorLine(this.dialogCursor === 0, dialog.destructive
372
414
  ? this.theme.fg("error", dialog.actionLabel)
373
415
  : this.theme.fg("accent", dialog.actionLabel)));
@@ -376,13 +418,13 @@ export class PluginManager {
376
418
  else {
377
419
  lines.push("");
378
420
  this.dialogInput.focused = this._focused;
379
- const text = this.dialogInput.getValue().length === 0
380
- ? this.theme.fg("muted", `› ${dialog.placeholder}`)
381
- : `› ${this.dialogInput.render(Math.max(1, width - 4))[0] ?? ""}`;
382
- lines.push(this.indent(this.theme.fg("border", "[") + text + this.theme.fg("border", "]"), width));
421
+ if (this.dialogInput.getValue().length === 0)
422
+ lines.push(this.indent(this.theme.fg("muted", dialog.placeholder), width));
423
+ this.bodyFocusLine = lines.length;
424
+ // Render the actual input even when empty so Pi can locate its IME cursor.
425
+ const input = this.dialogInput.render(Math.max(1, width - 4))[0] ?? "";
426
+ lines.push(this.indent(this.theme.fg("border", "[") + input + this.theme.fg("border", "]"), width));
383
427
  }
384
- lines.push("");
385
- lines.push(this.theme.fg("dim", dialog.kind === "input" ? "enter submit · esc cancel" : "↑↓ or y/n · enter run · esc cancel"));
386
428
  return lines.map((line) => truncateToWidth(line, width, ""));
387
429
  }
388
430
  handleDialogInput(data) {
@@ -474,8 +516,6 @@ export class PluginManager {
474
516
  this.heading(`${actionVerb(batch.action)} ${rows.length} plugin${rows.length === 1 ? "" : "s"}?`),
475
517
  this.theme.fg("text", `This batch affects ${rows.length} selected plugin${rows.length === 1 ? "" : "s"}.`),
476
518
  ];
477
- for (const row of rows)
478
- lines.push(this.indent(`• ${row.name} @ ${row.marketplace} · ${row.version ?? "unversioned"}`, width));
479
519
  lines.push("");
480
520
  if (executable) {
481
521
  lines.push(this.theme.bg("toolPendingBg", this.theme.fg("warning", " Executable content")));
@@ -490,8 +530,13 @@ export class PluginManager {
490
530
  lines.push(this.indent(this.theme.fg("muted", "Each item runs sequentially and Pi reloads once when the manager closes."), width));
491
531
  }
492
532
  lines.push("");
533
+ this.bodyFocusLine = lines.length + this.detailActionCursor;
493
534
  lines.push(this.renderCursorLine(this.detailActionCursor === 0, this.theme.fg("accent", `${actionVerb(batch.action)} ${rows.length} plugin${rows.length === 1 ? "" : "s"}`)));
494
535
  lines.push(this.renderCursorLine(this.detailActionCursor === 1, this.theme.fg("text", "Back to selection")));
536
+ // Keep the effect and confirmation controls ahead of potentially long lists.
537
+ lines.push("", this.heading("Selected plugins"));
538
+ for (const row of rows)
539
+ lines.push(this.indent(`• ${row.name} @ ${row.marketplace} · ${row.version ?? "version unavailable"}`, width));
495
540
  return lines;
496
541
  }
497
542
  renderBatch(width) {
@@ -511,6 +556,8 @@ export class PluginManager {
511
556
  const result = this.batchResults[index];
512
557
  const prefix = result === undefined ? this.theme.fg("dim", "○") : result.ok ? this.theme.fg("success", "✓") : this.theme.fg("error", "×");
513
558
  const note = result === undefined ? (this.batchRunning && index === this.batchResults.length ? "working…" : "waiting") : result.ok ? "complete" : result.error ?? "failed";
559
+ if (this.batchRunning && index === this.batchResults.length)
560
+ this.bodyFocusLine = lines.length;
514
561
  lines.push(this.indent(`${prefix} ${identity.plugin} @ ${identity.marketplace} ${this.theme.fg("muted", note)}`, width));
515
562
  }
516
563
  if (!this.batchRunning) {
@@ -519,6 +566,7 @@ export class PluginManager {
519
566
  if (succeeded > 0)
520
567
  lines.push(this.theme.fg("warning", "Reload will run once when this manager closes."));
521
568
  lines.push("");
569
+ this.bodyFocusLine = lines.length + this.detailActionCursor;
522
570
  lines.push(this.renderCursorLine(this.detailActionCursor === 0, this.theme.fg("accent", "View installed plugins")));
523
571
  lines.push(this.renderCursorLine(this.detailActionCursor === 1, this.theme.fg("text", "Close manager")));
524
572
  }
@@ -533,12 +581,14 @@ export class PluginManager {
533
581
  for (const [index, row] of rows.entries()) {
534
582
  const status = this.rowStatus(row);
535
583
  const selected = this.selected.has(row.id);
536
- const main = `${selected ? "◉" : "○"} ${row.name} · ${row.marketplace} · ${row.version ?? "unversioned"}`;
584
+ const main = `${selected ? "◉" : "○"} ${row.name} · ${row.marketplace} · ${row.version ?? "version unavailable"}`;
537
585
  const suffix = row.installed && this.tab === "discover" ? ` · ${this.theme.fg("success", "installed")}` : status;
538
586
  const title = this.theme.fg("accent", main);
539
587
  const cursor = index === this.cursor ? this.theme.fg("accent", "›") : " ";
540
588
  const content = `${cursor} ${title}${suffix.length > 0 ? ` ${suffix}` : ""}`;
541
- lines.push(this.theme.bg(index === this.cursor ? "selectedBg" : "toolPendingBg", truncateToWidth(content, width, "")));
589
+ if (index === this.cursor)
590
+ this.bodyFocusLine = lines.length + 2;
591
+ lines.push(this.theme.bg(index === this.cursor ? "selectedBg" : "toolPendingBg", padManagerLine(content, width)));
542
592
  lines.push(this.indent(this.theme.fg("muted", row.description ?? "No description declared."), width));
543
593
  }
544
594
  return lines;
@@ -554,7 +604,11 @@ export class PluginManager {
554
604
  }
555
605
  renderFooter(width) {
556
606
  let footer;
557
- if (this.view === "detail" || this.view === "confirm")
607
+ if (this.dialog !== undefined)
608
+ footer = this.dialog.kind === "input" ? "enter submit · esc cancel" : "↑↓ or y/n · enter run · esc cancel";
609
+ else if (this.searchFocused)
610
+ footer = "Type to search · Enter or Esc return to list";
611
+ else if (this.view === "detail" || this.view === "confirm")
558
612
  footer = "↑↓ navigate · Enter run · Esc back";
559
613
  else if (this.view === "batch")
560
614
  footer = this.batchRunning ? "Esc stop after current · manager stays open" : "Enter view installed · Esc close";
@@ -562,7 +616,7 @@ export class PluginManager {
562
616
  footer = "Esc cancel checks · navigation remains available";
563
617
  else
564
618
  footer = "Ctrl+←/→ tabs · Ctrl+F search · ↑↓ navigate · Space select · a all · Enter details · r check · Esc close";
565
- if (width < 80) {
619
+ if (width < 80 && this.dialog === undefined && !this.searchFocused) {
566
620
  const compact = this.checking ? "◌ checking · Esc cancel" : this.reloadNeeded ? "● reload needed" : "";
567
621
  footer = compact.length > 0 ? compact : footer;
568
622
  }
@@ -575,7 +629,7 @@ export class PluginManager {
575
629
  return `${this.theme.fg("muted", label.padEnd(10))} ${value}`;
576
630
  }
577
631
  indent(text, width) {
578
- return truncateToWidth(` ${text}`, width, "");
632
+ return truncateToWidth(` ${text}`, width, "");
579
633
  }
580
634
  renderCursorLine(cursor, text) {
581
635
  return `${cursor ? this.theme.fg("accent", "›") : " "} ${text}`;
@@ -784,7 +838,7 @@ export class PluginManager {
784
838
  }
785
839
  if (action === "toggle-auto" && installed !== undefined) {
786
840
  if (!installed.autoUpdate) {
787
- const approved = await this.promptConfirm("Enable automatic plugin updates", "This grants standing authorization to replace executable hooks and MCP servers when Pi startup sees a declared catalog version change. Enable it?", { actionLabel: "Enable" });
841
+ const approved = await this.promptConfirm("Enable automatic plugin updates", "This grants standing authorization to replace executable hooks and MCP servers when Pi startup sees a declared release version change. Enable it?", { actionLabel: "Enable" });
788
842
  if (!approved)
789
843
  return;
790
844
  }
@@ -843,6 +897,8 @@ export class PluginManager {
843
897
  if (explicit === undefined)
844
898
  this.selected = new Set(identities.map(identityToString));
845
899
  this.batch = Object.freeze({ action, identities: Object.freeze(identities.map((identity) => Object.freeze({ ...identity }))) });
900
+ this.bodyScroll = 0;
901
+ this.followFocus = false;
846
902
  this.detailActionCursor = 0;
847
903
  this.view = "confirm";
848
904
  this.requestRender();
@@ -923,6 +979,8 @@ export class PluginManager {
923
979
  });
924
980
  }
925
981
  openDetail(id) {
982
+ this.bodyScroll = 0;
983
+ this.followFocus = false;
926
984
  this.detailId = id;
927
985
  this.detailActionCursor = 0;
928
986
  this.runtime = undefined;
@@ -937,6 +995,7 @@ export class PluginManager {
937
995
  this.done({ reloadNeeded: this.reloadNeeded });
938
996
  }
939
997
  setTab(tab) {
998
+ this.bodyScroll = 0;
940
999
  this.tab = tab;
941
1000
  this.view = "list";
942
1001
  this.detailId = undefined;
@@ -1050,13 +1109,13 @@ export class PluginManager {
1050
1109
  installedRows() {
1051
1110
  return this.installed.map((plugin) => {
1052
1111
  const entry = catalogEntry(this.catalogs.get(plugin.marketplace), plugin.name);
1053
- const installedVersion = receiptVersion(plugin);
1112
+ const installedVersion = installedPluginVersion(plugin);
1054
1113
  const availableVersion = entry?.version !== undefined && entry.version !== installedVersion ? entry.version : undefined;
1055
1114
  return {
1056
1115
  id: identityOf(plugin),
1057
1116
  name: plugin.name,
1058
1117
  marketplace: plugin.marketplace,
1059
- description: typeof entry?.description === "string" ? entry.description : undefined,
1118
+ description: plugin.description ?? (typeof plugin.receipt?.description === "string" ? plugin.receipt.description : entry?.description),
1060
1119
  version: installedVersion,
1061
1120
  availableVersion,
1062
1121
  installed: true,
@@ -1080,7 +1139,7 @@ export class PluginManager {
1080
1139
  marketplace: marketplace.name,
1081
1140
  description: entry.description,
1082
1141
  version: entry.version,
1083
- availableVersion: installed !== undefined && entry.version !== receiptVersion(installed) ? entry.version : undefined,
1142
+ availableVersion: installed !== undefined && entry.version !== installedPluginVersion(installed) ? entry.version : undefined,
1084
1143
  installed: installed !== undefined,
1085
1144
  enabled: installed?.enabled,
1086
1145
  autoUpdate: installed?.autoUpdate,
@@ -1102,7 +1161,7 @@ export class PluginManager {
1102
1161
  statusLabel(installed, entry) {
1103
1162
  if (!installed.enabled)
1104
1163
  return "disabled";
1105
- if (entry?.version !== undefined && entry.version !== receiptVersion(installed))
1164
+ if (entry?.version !== undefined && entry.version !== installedPluginVersion(installed))
1106
1165
  return "update available";
1107
1166
  return "enabled";
1108
1167
  }
@@ -1123,6 +1182,8 @@ export class PluginManager {
1123
1182
  marketplaceStatus(name) {
1124
1183
  if (this.checkingMarketplaces.has(name))
1125
1184
  return this.theme.fg("accent", "checking…");
1185
+ if (this.checkErrors.has(name))
1186
+ return this.theme.fg("warning", "check failed · cached catalog");
1126
1187
  const catalog = this.catalogs.get(name);
1127
1188
  return catalog === undefined ? this.theme.fg("warning", "catalog unavailable") : this.theme.fg("muted", "local checkout ready");
1128
1189
  }
@@ -1166,6 +1227,8 @@ export class PluginManager {
1166
1227
  this.checkController = controller;
1167
1228
  this.checking = true;
1168
1229
  this.checkedMarketplaces = 0;
1230
+ this.checkTotal = new Set(names).size;
1231
+ this.updatesChecked = false;
1169
1232
  this.checkingMarketplaces = new Set(names);
1170
1233
  this.localEpoch++;
1171
1234
  this.requestRender();
@@ -1173,6 +1236,11 @@ export class PluginManager {
1173
1236
  if (this.destroyed || run !== this.checkRun)
1174
1237
  return;
1175
1238
  this.checkedMarketplaces++;
1239
+ this.checkingMarketplaces.delete(result.marketplace);
1240
+ if (result.ok)
1241
+ this.checkErrors.delete(result.marketplace);
1242
+ else
1243
+ this.checkErrors.set(result.marketplace, result.error ?? "marketplace check failed");
1176
1244
  if (result.ok && result.catalog !== undefined) {
1177
1245
  const cursorId = this.currentCursorId();
1178
1246
  this.catalogs.set(result.marketplace, result.catalog);
@@ -1197,8 +1265,11 @@ export class PluginManager {
1197
1265
  return results;
1198
1266
  }
1199
1267
  catch (error) {
1200
- if (!this.destroyed && run === this.checkRun)
1268
+ if (!this.destroyed && run === this.checkRun) {
1269
+ for (const name of this.checkingMarketplaces)
1270
+ this.checkErrors.set(name, errorMessage(error));
1201
1271
  this.showToast(`Marketplace check failed: ${errorMessage(error)}`, "warning");
1272
+ }
1202
1273
  return [];
1203
1274
  }
1204
1275
  finally {
@@ -1242,6 +1313,10 @@ export class PluginManager {
1242
1313
  if (!marketplaceNames.has(name))
1243
1314
  this.catalogs.delete(name);
1244
1315
  }
1316
+ for (const name of this.checkErrors.keys()) {
1317
+ if (!marketplaceNames.has(name))
1318
+ this.checkErrors.delete(name);
1319
+ }
1245
1320
  this.restoreCursor(cursorId);
1246
1321
  this.requestRender();
1247
1322
  this.maybeStartOpenCheck();
@@ -1306,7 +1381,12 @@ export class PluginManager {
1306
1381
  });
1307
1382
  }
1308
1383
  }
1384
+ // Runtime diagnostics also aggregate each plugin's diagnostics. Display
1385
+ // each once with its plugin identity, rather than duplicating the warning.
1386
+ const pluginDiagnostics = new Set(runtime.plugins.flatMap((plugin) => plugin.diagnostics));
1309
1387
  for (const diagnostic of runtime.diagnostics) {
1388
+ if (pluginDiagnostics.has(diagnostic))
1389
+ continue;
1310
1390
  issues.push({ id: `runtime:${diagnostic.scope}`, title: diagnostic.scope, message: diagnostic.message, severity: "error" });
1311
1391
  }
1312
1392
  for (const plugin of this.installed) {
@@ -1314,10 +1394,13 @@ export class PluginManager {
1314
1394
  if (entry === undefined && this.catalogs.has(plugin.marketplace)) {
1315
1395
  issues.push({ id: `${identityOf(plugin)}:catalog`, title: identityOf(plugin), message: "Installed bundle is not declared in the current marketplace catalog; the installed copy remains available.", severity: "error", pluginId: identityOf(plugin) });
1316
1396
  }
1317
- else if (plugin.autoUpdate && entry?.version === undefined) {
1318
- issues.push({ id: `${identityOf(plugin)}:version`, title: identityOf(plugin), message: "Automatic updates are marked, but this catalog does not declare a version. Use /plugins update-marked for an explicit force update.", severity: "warning", pluginId: identityOf(plugin) });
1397
+ else if (plugin.autoUpdate && entry !== undefined && entry.version === undefined && installedPluginVersion(plugin) === undefined) {
1398
+ issues.push({ id: `${identityOf(plugin)}:version`, title: identityOf(plugin), message: "No release version is available locally. Marked checks also inspect source manifests; /plugins update-marked forces replacement when no version is declared.", severity: "warning", pluginId: identityOf(plugin) });
1319
1399
  }
1320
1400
  }
1401
+ for (const [marketplace, message] of this.checkErrors) {
1402
+ issues.push({ id: `${marketplace}:check`, title: marketplace, message, severity: "warning" });
1403
+ }
1321
1404
  for (const [marketplace, catalog] of this.catalogs) {
1322
1405
  for (const diagnostic of catalog.diagnostics ?? [])
1323
1406
  issues.push({ id: `${marketplace}:${diagnostic.scope}`, title: marketplace, message: diagnostic.message, severity: "warning" });