@danypops/pi-packed 0.20.2 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,14 +11,15 @@ The extension connects to Packed's authenticated user daemon and starts the pack
11
11
  ## Commands
12
12
 
13
13
  - `/packed` -- one floating overlay, four tabs (Packages/Find/Config/Settings), always shown in a persistent tab bar at the top: the focused tab reverses the theme's text color for high contrast in both dark and light modes, unfocused tabs show only a foreground color with their own first letter highlighted as a jump mnemonic (the focused tab needs no mnemonic -- there's nothing to jump to when you're already there). `Tab`/`Shift-Tab` (or `←`/`→`) sweep between them -- recognized under legacy CSI, xterm's modifyOtherKeys, and the Kitty keyboard protocol alike, not just one hardcoded encoding; from any tab other than Packages, `p`/`f`/`c`/`s` also jump straight to the matching one (never stolen while that tab's own filter/query box is capturing text). `Esc` returns to Packages from anywhere else, or closes the panel from Packages itself. Every approval and reload confirmation, on any tab, renders inline on this same panel (`y`/`n` keys), never a separate popup or native dialog. A standing test (`keymap.test.ts`, using Malevich's tree-style `findMnemonicConflicts`) verifies no two actions genuinely reachable at once share a key.
14
- - **Packages** (the default tab) -- every installed Pi package, with update availability. Mnemonics follow lazy.nvim's own convention (uppercase acts on every row, lowercase on the one under the cursor):
14
+ - **Packages** (the default tab) -- every installed Pi package is a bordered Card with full-card selection highlighting and update availability. Mnemonics follow lazy.nvim's own convention (uppercase acts on every row, lowercase on the one under the cursor):
15
15
  - `u` / `U` -- update the selected package / update every outdated package, one combined confirmation and one reload. `U` shows a spinner inline next to the package currently updating, settling into a real ✓ or ✗ plus a short tail of that update's own captured output once it finishes -- the list stays visible throughout, nothing swaps to a separate screen. A reload is confirmed separately from the update itself -- decline it to defer: the update already happened, only picking it up in this Pi session is deferred until `/reload`.
16
16
  - `x` -- remove the selected package.
17
17
  - `d` -- disable (or re-enable) the selected package's own extensions.
18
+ - `i` -- inspect the selected package's registry metadata and README inside the same overlay; `Esc`/`q` returns.
18
19
  - `c` -- jump to the Config tab, scoped to the selected package.
19
20
  - `f` -- jump to the Find tab. `s` -- jump to the Settings tab.
20
21
  - `Enter` opens a smaller floating action menu with all of the above. `r` refreshes, `/` filters, `v` cycles view modes.
21
- - **Find** -- search the npm registry for new Pi packages and install one.
22
+ - **Find** -- search the npm registry for new Pi packages. Results render as Cards; after a search, `i` inspects the selected result, `Enter` installs it, and `/` returns to query editing.
22
23
  - **Config** -- enable or disable individual extensions, skills, prompt templates, and themes declared by installed packages, per package, at global or project scope (`v` switches global/project). `/packed config` opens the panel landing directly on this tab.
23
24
  - **Settings** -- change whether package mutations (install/update/remove/toggle) require confirmation.
24
25
  - `/packed setup plan [path] [--prune]` / `/packed setup apply [path] [--prune]` -- preview or apply a reproducible-environment manifest (`pi-setup.json`) against installed packages and profiles.
@@ -26,7 +26,7 @@ export default async function (pi: ExtensionAPI) {
26
26
 
27
27
  pi.registerCommand("packed", {
28
28
  description:
29
- "Browse and manage installed Pi packages -- u/U update, x remove, d disable, c config, f find, s settings -- or run setup plan/apply or config directly",
29
+ "Browse and manage installed Pi packages -- i inspect, u/U update, x remove, d disable, c config, f find, s settings -- or run setup plan/apply or config directly",
30
30
  handler: async (args, ctx) => {
31
31
  if (await handleSetupCommand(args, ctx, natives)) return;
32
32
  if (await handleResourceConfigCommand(args, ctx, natives, showPackedPanel)) return;
@@ -1,8 +1,17 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import type { DialogTheme, MenuTheme, TabBarTheme } from "malevich-tui-components";
2
+ import type { CardTheme, DialogTheme, MenuTheme, TabBarTheme } from "malevich-tui-components";
3
3
 
4
4
  /** Maps Pi's own Theme onto Malevich's Menu -- the one place this mapping
5
5
  * exists, shared by every ctx.ui.custom overlay menu in this extension. */
6
+ export function cardTheme(theme: Theme): CardTheme {
7
+ return {
8
+ border: (s) => theme.fg("border", s),
9
+ selectedBorder: (s) => theme.fg("accent", s),
10
+ content: (s) => s,
11
+ selectedContent: (s) => (typeof theme.inverse === "function" ? theme.inverse(theme.fg("text", s)) : theme.fg("accent", s)),
12
+ };
13
+ }
14
+
6
15
  export function menuTheme(theme: Theme): MenuTheme {
7
16
  return {
8
17
  border: (s) => theme.fg("border", s),
@@ -0,0 +1,93 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { buildDetailLines, type Component, type TextMeasure } from "malevich-tui-components";
4
+ import type { Natives, PackageInfo } from "./packed.js";
5
+ import { sanitizeTerminalText } from "./terminal-text.js";
6
+
7
+ const README_CHAR_LIMIT = 50_000;
8
+ const DETAIL_LINE_LIMIT = 2_000;
9
+ const VIEWPORT_LINES = 18;
10
+
11
+ export class PackageInspector implements Component {
12
+ private info: PackageInfo | undefined;
13
+ private error: string | undefined;
14
+ private loading = true;
15
+ private scrollOffset = 0;
16
+
17
+ constructor(
18
+ private readonly packageName: string,
19
+ private readonly natives: Natives,
20
+ private readonly theme: Theme,
21
+ private readonly measure: TextMeasure,
22
+ private readonly onClose: () => void,
23
+ private readonly requestRender: () => void,
24
+ ) {}
25
+
26
+ async load(): Promise<void> {
27
+ try {
28
+ this.info = await this.natives.info(this.packageName);
29
+ } catch (error) {
30
+ this.error = error instanceof Error ? error.message : String(error);
31
+ } finally {
32
+ this.loading = false;
33
+ this.requestRender();
34
+ }
35
+ }
36
+
37
+ invalidate(): void {}
38
+
39
+ handleInput(data: string): void {
40
+ if (matchesKey(data, "escape") || data === "q") {
41
+ this.onClose();
42
+ return;
43
+ }
44
+ if (matchesKey(data, "down") || data === "j") this.scrollOffset += 1;
45
+ else if (matchesKey(data, "up") || data === "k") this.scrollOffset = Math.max(0, this.scrollOffset - 1);
46
+ else if (data === "g") this.scrollOffset = 0;
47
+ else if (data === "G") this.scrollOffset = Number.MAX_SAFE_INTEGER;
48
+ this.requestRender();
49
+ }
50
+
51
+ render(width: number): string[] {
52
+ const heading = this.theme.bold(this.theme.fg("accent", `Inspect ${this.packageName}`));
53
+ const help = this.theme.fg("muted", "esc/q close · ↑↓/j/k scroll · g/G top/bottom");
54
+ if (this.loading) return [heading, help, "", this.theme.fg("muted", "Loading registry metadata…")];
55
+ if (this.error) return [heading, help, "", this.theme.fg("error", this.error)];
56
+ if (!this.info) return [heading, help, "", this.theme.fg("muted", "No package metadata")];
57
+
58
+ const info = this.info;
59
+ const rawReadme = sanitizeTerminalText(info.readme?.trim() || "No README published.");
60
+ const readmeTruncated = rawReadme.length > README_CHAR_LIMIT;
61
+ const readme = rawReadme.slice(0, README_CHAR_LIMIT);
62
+ const detailMeasure: TextMeasure = { ...this.measure, wrapTextWithAnsi };
63
+ const details = buildDetailLines(Math.max(1, width), {
64
+ fields: [
65
+ { label: "Version", value: sanitizeTerminalText(info.version) },
66
+ ...(info.license ? [{ label: "License", value: sanitizeTerminalText(info.license) }] : []),
67
+ ...(info.repository ? [{ label: "Repository", value: sanitizeTerminalText(info.repository) }] : []),
68
+ ],
69
+ sections: [
70
+ ...(info.description ? [{ heading: "Description", body: sanitizeTerminalText(info.description) }] : []),
71
+ { heading: "README (registry metadata)", body: readme },
72
+ ],
73
+ theme: {
74
+ field: (s) => this.theme.fg("dim", s),
75
+ heading: (s) => this.theme.bold(this.theme.fg("accent", s)),
76
+ byline: (s) => this.theme.fg("muted", s),
77
+ body: (s) => s,
78
+ },
79
+ measure: detailMeasure,
80
+ }).slice(0, DETAIL_LINE_LIMIT);
81
+ if (readmeTruncated || details.length === DETAIL_LINE_LIMIT) details.push(this.theme.fg("warning", "Output truncated."));
82
+ const maxOffset = Math.max(0, details.length - VIEWPORT_LINES);
83
+ this.scrollOffset = Math.min(this.scrollOffset, maxOffset);
84
+ const visible = details.slice(this.scrollOffset, this.scrollOffset + VIEWPORT_LINES);
85
+ return [
86
+ heading,
87
+ help,
88
+ this.theme.fg("dim", `${this.scrollOffset + 1}-${this.scrollOffset + visible.length}/${details.length}`),
89
+ "",
90
+ ...visible,
91
+ ];
92
+ }
93
+ }
@@ -21,6 +21,8 @@ export interface TabHost {
21
21
  * approve/reload call inside a tab's own mutation flow must go through
22
22
  * this, not ctx directly. */
23
23
  inlineCtx: ExtensionCommandContext;
24
+ /** Opens registry metadata and README for one package inside this overlay. */
25
+ inspectPackage(name: string): void;
24
26
  /** Re-renders the whole overlay. Call after any state change a tab
25
27
  * makes outside of a handleInput call the host already re-renders for. */
26
28
  requestRender(): void;
@@ -9,13 +9,15 @@
9
9
  * Install (behind a quick inline confirm, then the standard approval
10
10
  * every mutation surface requires) does.
11
11
  */
12
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
13
13
  import { rawKeyHint } from "@earendil-works/pi-coding-agent";
14
14
  import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
- import type { Component } from "malevich-tui-components";
15
+ import { Card, type Component } from "malevich-tui-components";
16
+ import { cardTheme } from "../menu-theme.js";
16
17
  import type { Natives, PackageSummary } from "../packed.js";
17
18
  import { InstallServiceError } from "../packed.js";
18
19
  import type { TabHost } from "../tab-host.js";
20
+ import { sanitizeTerminalText } from "../terminal-text.js";
19
21
  import { approvePackageOperation } from "../tools.js";
20
22
  import { shouldSearch } from "./discover-model.js";
21
23
 
@@ -52,11 +54,6 @@ export async function applyInstall(result: PackageSummary, natives: Natives, ctx
52
54
  }
53
55
  }
54
56
 
55
- interface Theme {
56
- fg(color: string, s: string): string;
57
- bold(s: string): string;
58
- }
59
-
60
57
  /** /packed's Find tab -- a real Component. Its query box always captures
61
58
  * free text (isCapturingInput() is unconditionally true), unlike
62
59
  * Packages/Config's toggleable search: there's no "browse mode" here to
@@ -69,13 +66,13 @@ export class FindTab implements Component {
69
66
  private searching = false;
70
67
  private error: string | undefined;
71
68
  private busy = false;
72
- private readonly maxVisible = 15;
69
+ private queryActive = true;
70
+ private readonly maxVisible = 4;
73
71
 
74
72
  constructor(
75
73
  private readonly natives: Natives,
76
74
  private readonly host: TabHost,
77
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: read via `const { theme } = this` in render(), which Biome's usage check doesn't trace
78
- private readonly theme: Theme,
75
+ private readonly _theme: Theme,
79
76
  ) {}
80
77
 
81
78
  /** Find's query box is always in text-edit mode (no separate toggle like
@@ -103,8 +100,10 @@ export class FindTab implements Component {
103
100
  invalidate(): void {}
104
101
 
105
102
  render(width: number): string[] {
106
- const { theme } = this;
107
- const hint = rawKeyHint("enter", "search/install");
103
+ const theme = this._theme;
104
+ const hint = this.queryActive
105
+ ? rawKeyHint("enter", "search")
106
+ : `${rawKeyHint("enter", "install")}${theme.fg("muted", " · ")}${rawKeyHint("i", "inspect")}${theme.fg("muted", " · ")}${rawKeyHint("/", "query")}`;
108
107
  const status = this.searching ? "searching…" : this.error ? theme.fg("error", this.error) : `${this.results.length} result(s)`;
109
108
  const spacing = Math.max(1, width - visibleWidth(hint) - visibleWidth(status));
110
109
  const line1 = truncateToWidth(`${hint}${" ".repeat(spacing)}`, width, "") + status;
@@ -118,11 +117,15 @@ export class FindTab implements Component {
118
117
  for (let i = start; i < end; i++) {
119
118
  const result = this.results[i]!;
120
119
  const selected = i === this.selectedIndex;
121
- const cursor = selected ? theme.fg("accent", "❯") : " ";
122
- const name = selected ? theme.bold(result.name) : result.name;
123
- const ver = theme.fg("dim", `@${result.version}`);
124
- const description = result.description ? theme.fg("muted", ` — ${result.description}`) : "";
125
- lines.push(truncateToWidth(`${cursor} ${name}${ver}${description}`, width, ""));
120
+ const card = new Card({
121
+ title: theme.bold(`${result.name}@${result.version}`),
122
+ content: [sanitizeTerminalText(result.description ?? "No description")],
123
+ selected,
124
+ theme: cardTheme(theme),
125
+ measure: { visibleWidth, truncateToWidth },
126
+ });
127
+ lines.push(...card.render(width));
128
+ if (i < end - 1) lines.push("");
126
129
  }
127
130
  return lines;
128
131
  }
@@ -137,11 +140,21 @@ export class FindTab implements Component {
137
140
  if (this.results.length > 0) this.selectedIndex = (this.selectedIndex + 1) % this.results.length;
138
141
  break;
139
142
  case "\r":
140
- if (shouldSearch(this.queryInput.getValue(), this.lastSearchedQuery, this.results.length > 0)) void this.runSearch();
143
+ if (this.queryActive || shouldSearch(this.queryInput.getValue(), this.lastSearchedQuery, this.results.length > 0))
144
+ void this.runSearch();
141
145
  else void this.installSelected();
142
146
  return;
147
+ case "/":
148
+ this.queryActive = true;
149
+ break;
150
+ case "i": {
151
+ const result = this.results[this.selectedIndex];
152
+ if (!this.queryActive && result) this.host.inspectPackage(result.name);
153
+ else this.queryInput.handleInput(data);
154
+ return;
155
+ }
143
156
  default:
144
- this.queryInput.handleInput(data);
157
+ if (this.queryActive) this.queryInput.handleInput(data);
145
158
  break;
146
159
  }
147
160
  this.host.requestRender();
@@ -158,6 +171,7 @@ export class FindTab implements Component {
158
171
  this.results = response.results;
159
172
  this.lastSearchedQuery = this.queryInput.getValue();
160
173
  this.selectedIndex = 0;
174
+ this.queryActive = false;
161
175
  } catch (e) {
162
176
  this.error = e instanceof Error ? e.message : String(e);
163
177
  this.results = [];
@@ -0,0 +1,6 @@
1
+ const UNSAFE_TERMINAL_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
2
+
3
+ /** Removes terminal control bytes from external registry text while preserving tabs and line breaks. */
4
+ export function sanitizeTerminalText(text: string): string {
5
+ return text.replace(UNSAFE_TERMINAL_CONTROL, "");
6
+ }
@@ -0,0 +1,51 @@
1
+ import type { Component } from "malevich-tui-components";
2
+
3
+ /** Keeps a component failure inside its overlay instead of Pi's event loop. */
4
+ export class TuiErrorBoundary implements Component {
5
+ private failed = false;
6
+
7
+ constructor(
8
+ private readonly component: Component,
9
+ private readonly fallback: readonly string[],
10
+ private readonly onError?: () => void,
11
+ ) {}
12
+
13
+ render(width: number): string[] {
14
+ if (this.failed) return this.renderFallback(width);
15
+ try {
16
+ return this.component.render(width);
17
+ } catch {
18
+ this.fail();
19
+ return this.renderFallback(width);
20
+ }
21
+ }
22
+
23
+ invalidate(): void {
24
+ if (this.failed) return;
25
+ try {
26
+ this.component.invalidate();
27
+ } catch {
28
+ this.fail();
29
+ }
30
+ }
31
+
32
+ handleInput(data: string): void {
33
+ if (this.failed) return;
34
+ try {
35
+ this.component.handleInput?.(data);
36
+ } catch {
37
+ this.fail();
38
+ }
39
+ }
40
+
41
+ private fail(): void {
42
+ if (this.failed) return;
43
+ this.failed = true;
44
+ this.onError?.();
45
+ }
46
+
47
+ private renderFallback(width: number): string[] {
48
+ const boundedWidth = Math.max(0, width);
49
+ return this.fallback.map((line) => line.slice(0, boundedWidth));
50
+ }
51
+ }
@@ -44,10 +44,10 @@
44
44
  * settled map U's batch path uses, not only as a scrollback toast. All
45
45
  * data flows through the packed CLI (thin seam).
46
46
  */
47
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
48
- import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
47
+ import { DynamicBorder, type ExtensionCommandContext, rawKeyHint, type Theme } from "@earendil-works/pi-coding-agent";
49
48
  import { Container, Input, matchesKey, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
50
49
  import {
50
+ Card,
51
51
  type Component,
52
52
  Dialog,
53
53
  Envelope,
@@ -55,19 +55,19 @@ import {
55
55
  type MenuItem,
56
56
  Spinner,
57
57
  TabbedContainer,
58
- Table,
59
58
  type TextMeasure,
60
59
  } from "malevich-tui-components";
61
60
  import { confirmReload } from "./approval/reload.js";
62
- import { dialogTheme, menuTheme, panelFrameStyle, tabBarTheme } from "./menu-theme.js";
63
- import type { Row, ViewMode } from "./model.js";
64
- import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
61
+ import { cardTheme, dialogTheme, menuTheme, panelFrameStyle, tabBarTheme } from "./menu-theme.js";
62
+ import { filterRows, mergeRows, nextMode, type Row, type ViewMode, visibleRows } from "./model.js";
63
+ import { PackageInspector } from "./package-inspector.js";
65
64
  import type { Natives, PackageResources } from "./packed.js";
66
65
  import type { TabHost } from "./tab-host.js";
67
66
  import { createFindTab } from "./tabs/discover.js";
68
67
  import { applyResourceToggle, ConfigTab } from "./tabs/resource-config.js";
69
68
  import { SettingsTab } from "./tabs/security-tui.js";
70
69
  import { approvePackageOperation } from "./tools.js";
70
+ import { TuiErrorBoundary } from "./tui-error-boundary.js";
71
71
 
72
72
  export type PackedTabKey = "packages" | "find" | "config" | "settings";
73
73
 
@@ -407,11 +407,6 @@ async function showActionMenu(ctx: ExtensionCommandContext, row: Row): Promise<"
407
407
  );
408
408
  }
409
409
 
410
- interface PackagesTabTheme {
411
- fg(color: string, s: string): string;
412
- bold(s: string): string;
413
- }
414
-
415
410
  /** Packages -- the panel's default/"home" tab. A real Component (not the
416
411
  * panel's own top-level ctx.ui.custom owner anymore); the shared TabHost
417
412
  * gives it inline approval/reload dialogs and a way to signal the overlay
@@ -432,14 +427,13 @@ export class PackagesTab implements Component {
432
427
  private updatingRowName: string | undefined;
433
428
  private readonly spinner = new Spinner();
434
429
  private readonly settled = new Map<string, { ok: boolean; tail: string | undefined }>();
435
- private readonly table: Table;
436
- private readonly maxVisible = 20;
430
+ private readonly maxVisible = 5;
437
431
 
438
432
  constructor(
439
433
  private readonly natives: Natives,
440
434
  private readonly host: TabHost,
441
- readonly theme: PackagesTabTheme,
442
- measure: TextMeasure,
435
+ readonly theme: Theme,
436
+ private readonly measure: TextMeasure,
443
437
  initialRows: Row[],
444
438
  /** c on a row, or the Enter action menu's "Configure resources" --
445
439
  * switches the shared TabbedContainer to Config, seeded with that
@@ -448,16 +442,6 @@ export class PackagesTab implements Component {
448
442
  ) {
449
443
  this.rows = initialRows;
450
444
  this.filtered = visibleRows(this.rows, this.mode);
451
- this.table = new Table({
452
- columns: [
453
- { header: "Package", key: "name" },
454
- { header: "Version", key: "version" },
455
- { header: "", key: "status" },
456
- ],
457
- rows: [],
458
- measure,
459
- headerStyle: (s) => theme.fg("muted", s),
460
- });
461
445
  }
462
446
 
463
447
  /** An active filter or an in-flight mutation means Escape/Left-Right
@@ -478,9 +462,7 @@ export class PackagesTab implements Component {
478
462
  return this.searchActive || this.updatingRowName !== undefined;
479
463
  }
480
464
 
481
- invalidate(): void {
482
- this.table.invalidate();
483
- }
465
+ invalidate(): void {}
484
466
 
485
467
  private applyFilter(): void {
486
468
  this.filtered = filterRows(visibleRows(this.rows, this.mode), this.searchInput.getValue());
@@ -505,6 +487,8 @@ export class PackagesTab implements Component {
505
487
  theme.fg("muted", " · ") +
506
488
  rawKeyHint("d", "disable") +
507
489
  theme.fg("muted", " · ") +
490
+ rawKeyHint("i", "inspect") +
491
+ theme.fg("muted", " · ") +
508
492
  rawKeyHint("c", "config") +
509
493
  theme.fg("muted", " · ") +
510
494
  rawKeyHint("r", "refresh");
@@ -530,28 +514,29 @@ export class PackagesTab implements Component {
530
514
  // selectedIndex stays this tab's own job.
531
515
  const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible));
532
516
  const end = Math.min(start + this.maxVisible, this.filtered.length);
533
- this.table.setRows(
534
- this.filtered.slice(start, end).map((row, offset) => {
535
- const i = start + offset;
536
- const selected = i === this.selectedIndex;
537
- const cursor = selected ? theme.fg("accent", "❯ ") : " ";
538
- const name = selected ? theme.bold(row.name) : row.name;
539
- // A settled row shows its real outcome even for the instant before
540
- // the next row's "start" event moves updatingRowName off it --
541
- // settled always wins over "still spinning".
542
- const rowSettled = this.settled.get(row.name);
543
- const isUpdating = !rowSettled && this.updatingRowName === row.name;
544
- const status = isUpdating
545
- ? theme.fg("accent", `${this.spinner.glyph()} updating…`)
546
- : rowSettled
547
- ? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
548
- : row.hasUpdate
549
- ? theme.fg("warning", `↑${row.latest}`)
550
- : "";
551
- return { name: `${cursor}${name}`, version: theme.fg("dim", row.version), status };
552
- }),
553
- );
554
- lines.push(...this.table.render(width));
517
+ for (const [offset, row] of this.filtered.slice(start, end).entries()) {
518
+ const selected = start + offset === this.selectedIndex;
519
+ // A settled row shows its real outcome even for the instant before
520
+ // the next row's "start" event moves updatingRowName off it.
521
+ const rowSettled = this.settled.get(row.name);
522
+ const isUpdating = !rowSettled && this.updatingRowName === row.name;
523
+ const status = isUpdating
524
+ ? theme.fg("accent", `${this.spinner.glyph()} updating…`)
525
+ : rowSettled
526
+ ? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
527
+ : row.hasUpdate
528
+ ? theme.fg("warning", `↑${row.latest}`)
529
+ : theme.fg("muted", "installed");
530
+ const card = new Card({
531
+ title: theme.bold(row.name),
532
+ content: [`${theme.fg("dim", row.version)} · ${status}`],
533
+ selected,
534
+ theme: cardTheme(theme),
535
+ measure: this.measure,
536
+ });
537
+ lines.push(...card.render(width));
538
+ if (start + offset < end - 1) lines.push("");
539
+ }
555
540
  const hasScroll = start > 0 || end < this.filtered.length;
556
541
  lines.push(theme.fg("dim", ` ${hasScroll ? `${this.selectedIndex + 1}/${this.filtered.length} ` : ""}${this.mode}`));
557
542
  return lines;
@@ -616,6 +601,11 @@ export class PackagesTab implements Component {
616
601
  if (row) void this.runRowActionInline({ type: "disable", row });
617
602
  return;
618
603
  }
604
+ case "i": {
605
+ const row = this.filtered[this.selectedIndex];
606
+ if (row) this.host.inspectPackage(row.name);
607
+ return;
608
+ }
619
609
  case "c": {
620
610
  const row = this.filtered[this.selectedIndex];
621
611
  if (row) this.switchTab("config", row.name);
@@ -765,6 +755,7 @@ function renderUnifiedPanel(
765
755
  // host.inlineCtx below, exactly like the panel's own approval/reload
766
756
  // dialogs always have been.
767
757
  let pendingDialog: Dialog | undefined;
758
+ let packageInspector: PackageInspector | undefined;
768
759
 
769
760
  function confirmInline(title: string, message: string): Promise<boolean> {
770
761
  return new Promise((resolve) => {
@@ -788,15 +779,29 @@ function renderUnifiedPanel(
788
779
  }
789
780
 
790
781
  const inlineCtx: ExtensionCommandContext = { ...ctx, hasUI: true, ui: { ...ctx.ui, confirm: confirmInline } };
782
+ const measure: TextMeasure = { visibleWidth, truncateToWidth };
791
783
  const host: TabHost = {
792
784
  ctx,
793
785
  inlineCtx,
786
+ inspectPackage: (name) => {
787
+ packageInspector = new PackageInspector(
788
+ name,
789
+ natives,
790
+ theme,
791
+ measure,
792
+ () => {
793
+ packageInspector = undefined;
794
+ tui.requestRender();
795
+ },
796
+ () => tui.requestRender(),
797
+ );
798
+ void packageInspector.load();
799
+ tui.requestRender();
800
+ },
794
801
  requestRender: () => tui.requestRender(),
795
802
  onSessionReplaced: () => done(undefined),
796
803
  };
797
804
 
798
- const measure: TextMeasure = { visibleWidth, truncateToWidth };
799
-
800
805
  const packagesTab = new PackagesTab(natives, host, theme, measure, initialRows, (target, configFilter) => {
801
806
  if (target === "config" && configFilter !== undefined) configTab.setFilter(configFilter);
802
807
  tabbedContainer.setActive(target);
@@ -831,6 +836,7 @@ function renderUnifiedPanel(
831
836
  ],
832
837
  theme: tabBarTheme(theme),
833
838
  initialKey: initialTab,
839
+ measure,
834
840
  // Malevich's own default matcher only recognizes legacy CSI sequences;
835
841
  // pi-tui's real matchesKey also covers the Kitty keyboard protocol and
836
842
  // xterm's modifyOtherKeys encodings for the same keys. Malevich's
@@ -854,9 +860,9 @@ function renderUnifiedPanel(
854
860
  measure,
855
861
  });
856
862
 
857
- return {
863
+ const panel: Component = {
858
864
  render(width: number): string[] {
859
- envelope.setContent(pendingDialog ?? tabbedContainer);
865
+ envelope.setContent(pendingDialog ?? packageInspector ?? tabbedContainer);
860
866
  return envelope.render(width);
861
867
  },
862
868
  invalidate: () => envelope.invalidate(),
@@ -866,6 +872,10 @@ function renderUnifiedPanel(
866
872
  tui.requestRender();
867
873
  return;
868
874
  }
875
+ if (packageInspector) {
876
+ packageInspector.handleInput(data);
877
+ return;
878
+ }
869
879
  const activeKey = tabbedContainer.getActiveKey() as PackedTabKey;
870
880
  const activeTab = tabByKey[activeKey];
871
881
  // Each host-level key is checked against that tab's own capture flag
@@ -920,6 +930,7 @@ function renderUnifiedPanel(
920
930
  tui.requestRender();
921
931
  },
922
932
  };
933
+ return new TuiErrorBoundary(panel, ["Packed could not render.", "Restart Pi and run /packed again."], () => tui.requestRender());
923
934
  // Pinned to the very top (anchor:"top-center"+offsetY:1 -- a fixed row
924
935
  // count regardless of terminal size), not row:"40%". The percentage
925
936
  // positioning was tried instead but reverted: pi-tui's row-percentage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.20.2",
3
+ "version": "0.21.1",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  "@danypops/vehicle-core": "^0.12.3",
35
35
  "@danypops/vehicle-server": "^0.17.1",
36
36
  "jiti": "2.6.1",
37
- "malevich-tui-components": "^0.20.4",
37
+ "malevich-tui-components": "^0.21.1",
38
38
  "publint": "0.3.22",
39
39
  "semver": "^7.8.5"
40
40
  },