@danypops/pi-packed 0.20.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
+ }
@@ -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,13 +55,12 @@ 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";
@@ -407,11 +406,6 @@ async function showActionMenu(ctx: ExtensionCommandContext, row: Row): Promise<"
407
406
  );
408
407
  }
409
408
 
410
- interface PackagesTabTheme {
411
- fg(color: string, s: string): string;
412
- bold(s: string): string;
413
- }
414
-
415
409
  /** Packages -- the panel's default/"home" tab. A real Component (not the
416
410
  * panel's own top-level ctx.ui.custom owner anymore); the shared TabHost
417
411
  * gives it inline approval/reload dialogs and a way to signal the overlay
@@ -432,14 +426,13 @@ export class PackagesTab implements Component {
432
426
  private updatingRowName: string | undefined;
433
427
  private readonly spinner = new Spinner();
434
428
  private readonly settled = new Map<string, { ok: boolean; tail: string | undefined }>();
435
- private readonly table: Table;
436
- private readonly maxVisible = 20;
429
+ private readonly maxVisible = 5;
437
430
 
438
431
  constructor(
439
432
  private readonly natives: Natives,
440
433
  private readonly host: TabHost,
441
- readonly theme: PackagesTabTheme,
442
- measure: TextMeasure,
434
+ readonly theme: Theme,
435
+ private readonly measure: TextMeasure,
443
436
  initialRows: Row[],
444
437
  /** c on a row, or the Enter action menu's "Configure resources" --
445
438
  * switches the shared TabbedContainer to Config, seeded with that
@@ -448,16 +441,6 @@ export class PackagesTab implements Component {
448
441
  ) {
449
442
  this.rows = initialRows;
450
443
  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
444
  }
462
445
 
463
446
  /** An active filter or an in-flight mutation means Escape/Left-Right
@@ -478,9 +461,7 @@ export class PackagesTab implements Component {
478
461
  return this.searchActive || this.updatingRowName !== undefined;
479
462
  }
480
463
 
481
- invalidate(): void {
482
- this.table.invalidate();
483
- }
464
+ invalidate(): void {}
484
465
 
485
466
  private applyFilter(): void {
486
467
  this.filtered = filterRows(visibleRows(this.rows, this.mode), this.searchInput.getValue());
@@ -505,6 +486,8 @@ export class PackagesTab implements Component {
505
486
  theme.fg("muted", " · ") +
506
487
  rawKeyHint("d", "disable") +
507
488
  theme.fg("muted", " · ") +
489
+ rawKeyHint("i", "inspect") +
490
+ theme.fg("muted", " · ") +
508
491
  rawKeyHint("c", "config") +
509
492
  theme.fg("muted", " · ") +
510
493
  rawKeyHint("r", "refresh");
@@ -530,28 +513,29 @@ export class PackagesTab implements Component {
530
513
  // selectedIndex stays this tab's own job.
531
514
  const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible));
532
515
  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));
516
+ for (const [offset, row] of this.filtered.slice(start, end).entries()) {
517
+ const selected = start + offset === this.selectedIndex;
518
+ // A settled row shows its real outcome even for the instant before
519
+ // the next row's "start" event moves updatingRowName off it.
520
+ const rowSettled = this.settled.get(row.name);
521
+ const isUpdating = !rowSettled && this.updatingRowName === row.name;
522
+ const status = isUpdating
523
+ ? theme.fg("accent", `${this.spinner.glyph()} updating…`)
524
+ : rowSettled
525
+ ? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
526
+ : row.hasUpdate
527
+ ? theme.fg("warning", `↑${row.latest}`)
528
+ : theme.fg("muted", "installed");
529
+ const card = new Card({
530
+ title: theme.bold(row.name),
531
+ content: [`${theme.fg("dim", row.version)} · ${status}`],
532
+ selected,
533
+ theme: cardTheme(theme),
534
+ measure: this.measure,
535
+ });
536
+ lines.push(...card.render(width));
537
+ if (start + offset < end - 1) lines.push("");
538
+ }
555
539
  const hasScroll = start > 0 || end < this.filtered.length;
556
540
  lines.push(theme.fg("dim", ` ${hasScroll ? `${this.selectedIndex + 1}/${this.filtered.length} ` : ""}${this.mode}`));
557
541
  return lines;
@@ -616,6 +600,11 @@ export class PackagesTab implements Component {
616
600
  if (row) void this.runRowActionInline({ type: "disable", row });
617
601
  return;
618
602
  }
603
+ case "i": {
604
+ const row = this.filtered[this.selectedIndex];
605
+ if (row) this.host.inspectPackage(row.name);
606
+ return;
607
+ }
619
608
  case "c": {
620
609
  const row = this.filtered[this.selectedIndex];
621
610
  if (row) this.switchTab("config", row.name);
@@ -765,6 +754,7 @@ function renderUnifiedPanel(
765
754
  // host.inlineCtx below, exactly like the panel's own approval/reload
766
755
  // dialogs always have been.
767
756
  let pendingDialog: Dialog | undefined;
757
+ let packageInspector: PackageInspector | undefined;
768
758
 
769
759
  function confirmInline(title: string, message: string): Promise<boolean> {
770
760
  return new Promise((resolve) => {
@@ -788,15 +778,29 @@ function renderUnifiedPanel(
788
778
  }
789
779
 
790
780
  const inlineCtx: ExtensionCommandContext = { ...ctx, hasUI: true, ui: { ...ctx.ui, confirm: confirmInline } };
781
+ const measure: TextMeasure = { visibleWidth, truncateToWidth };
791
782
  const host: TabHost = {
792
783
  ctx,
793
784
  inlineCtx,
785
+ inspectPackage: (name) => {
786
+ packageInspector = new PackageInspector(
787
+ name,
788
+ natives,
789
+ theme,
790
+ measure,
791
+ () => {
792
+ packageInspector = undefined;
793
+ tui.requestRender();
794
+ },
795
+ () => tui.requestRender(),
796
+ );
797
+ void packageInspector.load();
798
+ tui.requestRender();
799
+ },
794
800
  requestRender: () => tui.requestRender(),
795
801
  onSessionReplaced: () => done(undefined),
796
802
  };
797
803
 
798
- const measure: TextMeasure = { visibleWidth, truncateToWidth };
799
-
800
804
  const packagesTab = new PackagesTab(natives, host, theme, measure, initialRows, (target, configFilter) => {
801
805
  if (target === "config" && configFilter !== undefined) configTab.setFilter(configFilter);
802
806
  tabbedContainer.setActive(target);
@@ -831,6 +835,7 @@ function renderUnifiedPanel(
831
835
  ],
832
836
  theme: tabBarTheme(theme),
833
837
  initialKey: initialTab,
838
+ measure,
834
839
  // Malevich's own default matcher only recognizes legacy CSI sequences;
835
840
  // pi-tui's real matchesKey also covers the Kitty keyboard protocol and
836
841
  // xterm's modifyOtherKeys encodings for the same keys. Malevich's
@@ -856,7 +861,7 @@ function renderUnifiedPanel(
856
861
 
857
862
  return {
858
863
  render(width: number): string[] {
859
- envelope.setContent(pendingDialog ?? tabbedContainer);
864
+ envelope.setContent(pendingDialog ?? packageInspector ?? tabbedContainer);
860
865
  return envelope.render(width);
861
866
  },
862
867
  invalidate: () => envelope.invalidate(),
@@ -866,6 +871,10 @@ function renderUnifiedPanel(
866
871
  tui.requestRender();
867
872
  return;
868
873
  }
874
+ if (packageInspector) {
875
+ packageInspector.handleInput(data);
876
+ return;
877
+ }
869
878
  const activeKey = tabbedContainer.getActiveKey() as PackedTabKey;
870
879
  const activeTab = tabByKey[activeKey];
871
880
  // Each host-level key is checked against that tab's own capture flag
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.0",
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
  },