@danypops/pi-packed 0.16.0 → 0.17.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.
@@ -1,58 +1,65 @@
1
1
  /**
2
- * tui.ts — /packed's default panel. Mnemonics follow lazy.nvim's own
3
- * convention (folke/lazy.nvim lua/lazy/view/config.lua): uppercase acts on
4
- * every row, lowercase acts on the row under the cursor -- U/u update,
5
- * X/x was lazy's delete key (clean); here that pairing is single-target
6
- * only (x remove -- there is no safe "remove every installed package"
7
- * bulk analog, so no X is bound). Adds d disable/enable this package's
8
- * extensions, c jump to full resource config, f find/install new
9
- * packages, s settings. The panel itself is a real bordered (rounded)
10
- * floating overlay (`ctx.ui.custom` with overlay:true, Malevich's
11
- * Envelope for the box), positioned at 40% of terminal height (pi-tui's
12
- * own row percentage, not a fixed row count -- scales with the real
13
- * terminal, unlike the anchor:"top-center"+offsetY this replaced, which
14
- * was pinned 1 row below the absolute top on any terminal size). Enter
15
- * opens a second, smaller overlay action menu on top of it. U's batch
16
- * update stays on this same package list -- an indeterminate spinner
17
- * (Malevich's Spinner, extracted upstream from this panel's original need
18
- * once no embeddable one existed anywhere) renders inline next to the row
19
- * currently updating, settling into a real ✓/✗ plus a bounded tail of
20
- * that row's own actual captured stdout/stderr once it finishes, never a
21
- * determinate bar (a single subprocess call has no knowable percentage).
22
- * Rows render through Malevich's Table (real column-aligned
23
- * Package/Version/status cells, per-row selection styling baked into each
24
- * cell since Table's own cellStyle is column-wide, not row-wide) inside
25
- * this panel's own scroll-window slice -- Table deliberately owns no
26
- * pagination of its own, so the visible-window-around-selectedIndex math
27
- * stays here. u/x/d and the Enter action menu all run their whole
28
- * approve+mutate+confirmReload flow inline, without ever closing this
29
- * overlay first -- every confirm() along the way (mutation approval,
30
- * confirmReload's separate reload gate) renders as a real Malevich Dialog
31
- * on this SAME overlay, dispatched by literal y/n keys, instead of
32
- * ctx.ui.confirm's own separate native dialog (arrow-select only, and a
33
- * genuinely different overlay stacked on top -- confirmed live as a real
34
- * user complaint). Declining a reload defers it: the mutation itself
35
- * already happened, and the panel stays open with refreshed rows instead
36
- * of ending the session. All data flows through the packed CLI (thin seam).
2
+ * tui.ts — /packed's one panel, four tabs. Mnemonics follow lazy.nvim's
3
+ * own convention (folke/lazy.nvim lua/lazy/view/config.lua): uppercase
4
+ * acts on every row, lowercase acts on the row under the cursor -- U/u
5
+ * update, X/x was lazy's delete key (clean); here that pairing is
6
+ * single-target only (x remove -- there is no safe "remove every
7
+ * installed package" bulk analog, so no X is bound). Adds d disable/
8
+ * enable this package's extensions, c jump to Config (scoped to the
9
+ * selected row), f jump to Find, s jump to Settings.
10
+ *
11
+ * All four (Packages/Find/Config/Settings) live inside ONE
12
+ * `ctx.ui.custom` floating overlay for the panel's whole lifetime --
13
+ * Malevich's Envelope for the box (rounded border), TabbedContainer for
14
+ * the persistent tab bar + active-tab content swap, and (on top of that)
15
+ * a Dialog swapped in for any y/n decision, the same content-replacement
16
+ * stacking Envelope's own setContent already provides, generalized one
17
+ * level further here. This replaced each tab opening its own separate
18
+ * screen (Find/Config: a second, visually distinct ctx.ui.custom with no
19
+ * Envelope at all; Settings: Pi's own native ctx.ui.select/confirm) --
20
+ * confirmed live as a real user complaint ("Find, Settings, Config all
21
+ * open a different TUI"). Left/Right cycle tabs; Escape returns to
22
+ * Packages from anywhere else, or closes the whole panel from Packages
23
+ * itself -- unless the active tab's own capturesEscape()/
24
+ * capturesHorizontalArrows() says it wants that key for itself right now
25
+ * (an active text filter, an in-flight mutation, or Find's always-on
26
+ * query box), in which case it's delegated straight through instead.
27
+ *
28
+ * Packages itself: an indeterminate spinner (Malevich's Spinner) renders
29
+ * inline next to the row currently updating, settling into a real ✓/✗
30
+ * plus a bounded tail of that row's own actual captured stdout/stderr
31
+ * once it finishes, never a determinate bar (a single subprocess call has
32
+ * no knowable percentage). Rows render through Malevich's Table (real
33
+ * column-aligned Package/Version/status cells, per-row selection styling
34
+ * baked into each cell since Table's own cellStyle is column-wide, not
35
+ * row-wide) inside this tab's own scroll-window slice -- Table
36
+ * deliberately owns no pagination of its own, so the visible-window-
37
+ * around-selectedIndex math stays here. u/x/d and the Enter action menu
38
+ * all run their whole approve+mutate+confirmReload flow inline, via the
39
+ * shared TabHost's inlineCtx -- every confirm() along the way renders as
40
+ * a real Malevich Dialog on this SAME overlay, dispatched by literal y/n
41
+ * keys, instead of ctx.ui.confirm's own separate native dialog. Every
42
+ * non-"changed" settle point (already up to date, pinned, deferred, a
43
+ * genuine failure) shows its own real reason on that row too via the same
44
+ * settled map U's batch path uses, not only as a scrollback toast. All
45
+ * data flows through the packed CLI (thin seam).
37
46
  */
38
47
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
39
48
  import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
40
49
  import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
41
- import { Dialog, Envelope, Menu, Spinner, Table, type MenuItem, type TableColumn, type TextMeasure } from "malevich-tui-components";
50
+ import { Dialog, Envelope, Menu, Spinner, Table, TabbedContainer, type Component, type MenuItem, type TextMeasure } from "malevich-tui-components";
42
51
  import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
43
52
  import type { Row, ViewMode } from "./model.js";
44
53
  import type { Natives, PackageResources } from "./packed.js";
45
54
  import { approvePackageOperation } from "./tools.js";
46
- import { showPackedSettings } from "./security-tui.js";
47
- import { showResourceConfig, applyResourceToggle } from "./resource-config.js";
48
- import { showDiscoverPanel } from "./discover.js";
49
- import { dialogTheme, menuTheme } from "./menu-theme.js";
55
+ import { createSettingsTab, SettingsTab } from "./security-tui.js";
56
+ import { createConfigTab, ConfigTab, applyResourceToggle } from "./resource-config.js";
57
+ import { createFindTab, FindTab } from "./discover.js";
58
+ import { dialogTheme, menuTheme, tabBarTheme } from "./menu-theme.js";
50
59
  import { confirmReload } from "./reload.js";
60
+ import type { TabHost } from "./tab-host.js";
51
61
 
52
- interface PanelAction {
53
- type: "config" | "find" | "refresh" | "settings";
54
- row?: Row;
55
- }
62
+ export type PackedTabKey = "packages" | "find" | "config" | "settings";
56
63
 
57
64
  /** Outcome of a confirmed row action, resolved after any real mutation and
58
65
  * reload decision -- "changed" means the daemon state changed and Pi has
@@ -63,11 +70,19 @@ interface PanelAction {
63
70
  * "unchanged"/"cancelled" mean the panel keeps running as-is. */
64
71
  export type PackageChoiceOutcome = "changed" | "unchanged" | "cancelled" | "deferred";
65
72
 
73
+ /** A non-"changed" settle point's own detail -- the exact reason nothing
74
+ * (or only a partial thing) happened, so a caller with somewhere to show
75
+ * it inline (a row's own status cell) isn't limited to the scrollback
76
+ * toast every outcome already gets via ctx.ui.notify. "changed" never
77
+ * calls this: ctx.reload() already replaces the session by then. */
78
+ export interface PackageChoiceSettled { ok: boolean; message: string; }
79
+
66
80
  export async function applyPackageChoice(
67
81
  choice: string | undefined,
68
82
  row: Row,
69
83
  natives: Natives,
70
84
  ctx: ExtensionCommandContext,
85
+ onSettled?: (settled: PackageChoiceSettled) => void,
71
86
  ): Promise<PackageChoiceOutcome> {
72
87
  if (choice?.startsWith("Update")) {
73
88
  try {
@@ -84,18 +99,22 @@ export async function applyPackageChoice(
84
99
  ? `pinned to ${version ?? "an exact version"} -- pi update intentionally leaves pinned packages unchanged`
85
100
  : `already up to date${version ? ` at ${version}` : ""}`;
86
101
  ctx.ui.notify(`${row.name} is ${reason}.`, "info");
102
+ onSettled?.({ ok: true, message: reason });
87
103
  return "unchanged";
88
104
  }
89
105
  const transition = outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
90
106
  if (!(await confirmReload(ctx))) {
91
107
  ctx.ui.notify(`Updated ${row.name}${transition}; reload pending -- run /reload when ready.`, "warning");
108
+ onSettled?.({ ok: true, message: `updated${transition}; reload pending` });
92
109
  return "deferred";
93
110
  }
94
111
  ctx.ui.notify(`Updated ${row.name}${transition}; reloading Pi resources.`, "info");
95
112
  await ctx.reload();
96
113
  return "changed";
97
114
  } catch (e) {
98
- ctx.ui.notify(`update failed: ${e instanceof Error ? e.message : e}`, "error");
115
+ const message = e instanceof Error ? e.message : String(e);
116
+ ctx.ui.notify(`update failed: ${message}`, "error");
117
+ onSettled?.({ ok: false, message });
99
118
  return "cancelled";
100
119
  }
101
120
  }
@@ -109,13 +128,16 @@ export async function applyPackageChoice(
109
128
  await natives.remove(row.name, approval.approved);
110
129
  if (!(await confirmReload(ctx))) {
111
130
  ctx.ui.notify(`Removed ${row.name}; reload pending -- run /reload when ready.`, "warning");
131
+ onSettled?.({ ok: true, message: "removed; reload pending" });
112
132
  return "deferred";
113
133
  }
114
134
  ctx.ui.notify(`Removed ${row.name}; reloading Pi resources.`, "info");
115
135
  await ctx.reload();
116
136
  return "changed";
117
137
  } catch (e) {
118
- ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
138
+ const message = e instanceof Error ? e.message : String(e);
139
+ ctx.ui.notify(`remove failed: ${message}`, "error");
140
+ onSettled?.({ ok: false, message });
119
141
  return "cancelled";
120
142
  }
121
143
  }
@@ -349,86 +371,327 @@ async function showActionMenu(ctx: ExtensionCommandContext, row: Row): Promise<"
349
371
  );
350
372
  }
351
373
 
352
- export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
353
- if (!ctx.hasUI) {
354
- ctx.ui.notify("/packed requires interactive mode", "warning");
355
- return;
374
+ interface PackagesTabTheme { fg(color: string, s: string): string; bold(s: string): string; }
375
+
376
+ /** Packages -- the panel's default/"home" tab. A real Component (not the
377
+ * panel's own top-level ctx.ui.custom owner anymore); the shared TabHost
378
+ * gives it inline approval/reload dialogs and a way to signal the overlay
379
+ * should close once a mutation actually replaces the session. */
380
+ export class PackagesTab implements Component {
381
+ private rows: Row[];
382
+ private mode: ViewMode = "all";
383
+ private readonly searchInput = new Input();
384
+ private searchActive = false;
385
+ private filtered: Row[];
386
+ private selectedIndex = 0;
387
+ // Set only while U's batch update (or a single row's own u/x/d) is
388
+ // running -- blocks input for the duration (the mutation/installer owns
389
+ // input until it finishes). Which specific row currently shows a spinner
390
+ // vs. a settled ✓/✗ is settled's own job, not this -- a just-finished row
391
+ // must show its glyph immediately, even for the instant before the next
392
+ // row's "start" event reassigns this to the next name.
393
+ private updatingRowName: string | undefined;
394
+ private readonly spinner = new Spinner();
395
+ private readonly settled = new Map<string, { ok: boolean; tail: string | undefined }>();
396
+ private readonly table: Table;
397
+ private readonly maxVisible = 20;
398
+
399
+ constructor(
400
+ private readonly natives: Natives,
401
+ private readonly host: TabHost,
402
+ private readonly theme: PackagesTabTheme,
403
+ measure: TextMeasure,
404
+ initialRows: Row[],
405
+ /** c on a row, or the Enter action menu's "Configure resources" --
406
+ * switches the shared TabbedContainer to Config, seeded with that
407
+ * row's own name as its filter. f/s switch tabs with no filter. */
408
+ private readonly switchTab: (target: "find" | "config" | "settings", configFilter?: string) => void,
409
+ ) {
410
+ this.rows = initialRows;
411
+ this.filtered = visibleRows(this.rows, this.mode);
412
+ this.table = new Table({
413
+ columns: [
414
+ { header: "Package", key: "name" },
415
+ { header: "Version", key: "version" },
416
+ { header: "", key: "status" },
417
+ ],
418
+ rows: [],
419
+ measure,
420
+ headerStyle: (s) => theme.fg("muted", s),
421
+ });
356
422
  }
357
423
 
358
- let { rows, error } = await loadRows(natives);
359
- if (error) {
360
- ctx.ui.notify(`packed unavailable: ${error}`, "error");
361
- return;
424
+ /** An active filter or an in-flight mutation means Escape/Left-Right
425
+ * belong to this tab (clearing the filter; blocking navigation away
426
+ * mid-mutation), not the host's own back/tab-cycle handling. Packages
427
+ * never uses Left/Right for itself otherwise. */
428
+ capturesEscape(): boolean {
429
+ return this.searchActive || this.updatingRowName !== undefined;
362
430
  }
363
431
 
364
- // A deferred reload means the mutation itself genuinely happened but
365
- // ctx.reload() was declined -- the session is still alive, so refresh
366
- // rows (real on-disk versions) and keep the panel open, same as "refresh".
367
- async function refreshRows(): Promise<void> {
368
- ({ rows, error } = await loadRows(natives));
369
- if (error) ctx.ui.notify(`refresh failed: ${error}`, "error");
432
+ invalidate(): void {
433
+ this.table.invalidate();
370
434
  }
371
435
 
372
- // Panel loop: actions resolve the component, run outside it, then reopen.
373
- // Every mutation (u/x/d, U, and whatever the Enter action menu picks) is
374
- // handled entirely inside renderPanel itself -- approval and reload
375
- // confirms render inline on the same already-open overlay -- and never
376
- // reaches here; only non-mutation navigation resolves this loop.
377
- for (;;) {
378
- const action = await renderPanel(ctx, natives, rows);
379
- if (!action) return; // closed
380
-
381
- if (action.type === "refresh") {
382
- await refreshRows();
383
- continue;
436
+ private applyFilter(): void {
437
+ this.filtered = filterRows(visibleRows(this.rows, this.mode), this.searchInput.getValue());
438
+ this.selectedIndex = 0;
439
+ }
440
+
441
+ private statusLine(): string {
442
+ if (this.updatingRowName) return "updating…";
443
+ const outdated = this.rows.filter((r) => r.hasUpdate).length;
444
+ return outdated > 0 ? `${outdated} update(s)` : "up to date";
445
+ }
446
+
447
+ render(width: number): string[] {
448
+ const { theme } = this;
449
+ const hint = this.searchActive
450
+ ? rawKeyHint("esc", "clear")
451
+ : rawKeyHint("enter", "menu") +
452
+ theme.fg("muted", " · ") +
453
+ rawKeyHint("u/U", "update/all") +
454
+ theme.fg("muted", " · ") +
455
+ rawKeyHint("x", "remove") +
456
+ theme.fg("muted", " · ") +
457
+ rawKeyHint("d", "disable") +
458
+ theme.fg("muted", " · ") +
459
+ rawKeyHint("c", "config") +
460
+ theme.fg("muted", " · ") +
461
+ rawKeyHint("r", "refresh");
462
+ const line1 = truncateToWidth(hint, width, "");
463
+ const dot = "·";
464
+ const line2 = truncateToWidth(
465
+ theme.fg("muted", `${this.statusLine()} ${dot} view: ${this.mode} ${dot} / filter ${dot} tab view ${dot} ${this.rows.length} installed`),
466
+ width,
467
+ "",
468
+ );
469
+ const lines: string[] = [line1, line2];
470
+ if (this.searchActive) lines.push(...this.searchInput.render(width));
471
+ lines.push("");
472
+ if (this.filtered.length === 0) {
473
+ lines.push(theme.fg("muted", " No packages"));
474
+ return lines;
384
475
  }
476
+ // No scrolling of its own (Malevich's Table is deliberately
477
+ // unopinionated about pagination) -- the visible window around
478
+ // selectedIndex stays this tab's own job.
479
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible));
480
+ const end = Math.min(start + this.maxVisible, this.filtered.length);
481
+ this.table.setRows(
482
+ this.filtered.slice(start, end).map((row, offset) => {
483
+ const i = start + offset;
484
+ const selected = i === this.selectedIndex;
485
+ const cursor = selected ? theme.fg("accent", "❯ ") : " ";
486
+ const name = selected ? theme.bold(row.name) : row.name;
487
+ // A settled row shows its real outcome even for the instant before
488
+ // the next row's "start" event moves updatingRowName off it --
489
+ // settled always wins over "still spinning".
490
+ const rowSettled = this.settled.get(row.name);
491
+ const isUpdating = !rowSettled && this.updatingRowName === row.name;
492
+ const status = isUpdating
493
+ ? theme.fg("accent", `${this.spinner.glyph()} updating…`)
494
+ : rowSettled
495
+ ? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
496
+ : row.hasUpdate ? theme.fg("warning", `↑${row.latest}`) : "";
497
+ return { name: `${cursor}${name}`, version: theme.fg("dim", row.version), status };
498
+ }),
499
+ );
500
+ lines.push(...this.table.render(width));
501
+ const hasScroll = start > 0 || end < this.filtered.length;
502
+ lines.push(theme.fg("dim", ` ${hasScroll ? `${this.selectedIndex + 1}/${this.filtered.length} ` : ""}${this.mode}`));
503
+ return lines;
504
+ }
385
505
 
386
- if (action.type === "settings") {
387
- await showPackedSettings(ctx, natives);
388
- continue; // settings never changes package rows -- reopen as-is
506
+ handleInput(data: string): void {
507
+ if (this.updatingRowName) return; // the mutation/installer owns input until it finishes
508
+
509
+ if (this.searchActive) {
510
+ if (data === "\x1b") {
511
+ this.searchActive = false;
512
+ this.searchInput.setValue?.("");
513
+ this.applyFilter();
514
+ } else if (data === "\r") {
515
+ this.searchActive = false;
516
+ } else {
517
+ this.searchInput.handleInput(data);
518
+ this.applyFilter();
519
+ }
520
+ this.host.requestRender();
521
+ return;
522
+ }
523
+
524
+ switch (data) {
525
+ case "\x1b[A":
526
+ this.selectedIndex = (this.selectedIndex - 1 + this.filtered.length) % Math.max(this.filtered.length, 1);
527
+ break;
528
+ case "\x1b[B":
529
+ this.selectedIndex = (this.selectedIndex + 1) % Math.max(this.filtered.length, 1);
530
+ break;
531
+ case "\t":
532
+ this.mode = nextMode(this.mode);
533
+ this.applyFilter();
534
+ break;
535
+ case "/":
536
+ this.searchActive = true;
537
+ break;
538
+ case "r":
539
+ void this.refresh();
540
+ return;
541
+ case "s":
542
+ this.switchTab("settings");
543
+ return;
544
+ case "f":
545
+ this.switchTab("find");
546
+ return;
547
+ case "U":
548
+ void this.runUpdateAllInline();
549
+ return;
550
+ case "u": {
551
+ const row = this.filtered[this.selectedIndex];
552
+ if (row?.hasUpdate) void this.runRowActionInline({ type: "update", row });
553
+ return;
554
+ }
555
+ case "x": {
556
+ const row = this.filtered[this.selectedIndex];
557
+ if (row) void this.runRowActionInline({ type: "remove", row });
558
+ return;
559
+ }
560
+ case "d": {
561
+ const row = this.filtered[this.selectedIndex];
562
+ if (row) void this.runRowActionInline({ type: "disable", row });
563
+ return;
564
+ }
565
+ case "c": {
566
+ const row = this.filtered[this.selectedIndex];
567
+ if (row) this.switchTab("config", row.name);
568
+ return;
569
+ }
570
+ case "\r": {
571
+ const row = this.filtered[this.selectedIndex];
572
+ if (!row) return;
573
+ void (async () => {
574
+ const choice = await showActionMenu(this.host.inlineCtx, row);
575
+ if (choice === "update" || choice === "remove" || choice === "disable") void this.runRowActionInline({ type: choice, row });
576
+ else if (choice === "config") this.switchTab("config", row.name);
577
+ })();
578
+ return;
579
+ }
580
+ default:
581
+ return;
389
582
  }
583
+ this.host.requestRender();
584
+ }
390
585
 
391
- if (action.type === "find") {
392
- await showDiscoverPanel(ctx, natives);
393
- continue; // a successful install already reloaded; a no-op returns here
586
+ private async refresh(): Promise<void> {
587
+ const reloaded = await loadRows(this.natives);
588
+ if (reloaded.error) this.host.ctx.ui.notify(`refresh failed: ${reloaded.error}`, "error");
589
+ else this.rows = reloaded.rows;
590
+ this.applyFilter();
591
+ this.host.requestRender();
592
+ }
593
+
594
+ /** u/x/d and the Enter action menu all funnel through here: approval and
595
+ * reload confirms render inline (via host.inlineCtx) on this same
596
+ * overlay, never closing it first. */
597
+ private async runRowActionInline(action: { type: "update" | "remove" | "disable"; row: Row }): Promise<void> {
598
+ this.updatingRowName = action.row.name;
599
+ this.host.requestRender();
600
+ // A non-"changed" settle point (already up to date, pinned, deferred, a
601
+ // genuine failure) shows its own real reason on this row, the same
602
+ // settled map U's batch path already renders -- not just a scrollback
603
+ // toast.
604
+ const onSettled = (result: PackageChoiceSettled) => this.settled.set(action.row.name, { ok: result.ok, tail: result.message });
605
+ const outcome = action.type === "disable"
606
+ ? await applyDisableExtensions(action.row, this.natives, this.host.inlineCtx)
607
+ : await applyPackageChoice(action.type === "update" ? `Update to ${action.row.latest}` : "Remove", action.row, this.natives, this.host.inlineCtx, onSettled);
608
+ this.updatingRowName = undefined;
609
+ if (outcome === "changed") {
610
+ this.host.onSessionReplaced(); // ctx.reload() already replaced the session
611
+ return;
394
612
  }
613
+ const reloaded = await loadRows(this.natives);
614
+ if (reloaded.error) this.host.ctx.ui.notify(`refresh failed: ${reloaded.error}`, "error");
615
+ else this.rows = reloaded.rows;
616
+ this.applyFilter();
617
+ this.host.requestRender();
618
+ }
395
619
 
396
- await showResourceConfig(ctx, natives, action.row?.name);
397
- // showResourceConfig already handles its own reload prompt
620
+ /** U -- runs the whole approve+update+notify+reload flow without ever
621
+ * closing this tab or replacing the list: each row's own settled outcome
622
+ * (spinner while in flight, then a real ✓/✗ plus a bounded tail of its
623
+ * actual captured output) appears inline next to that row. Rows refresh
624
+ * in place afterward unless a reload already ended the session. */
625
+ private async runUpdateAllInline(): Promise<void> {
626
+ const outdated = this.rows.filter((row) => row.hasUpdate);
627
+ this.settled.clear();
628
+ const outcome = await approveAndRunUpdateAll(outdated, this.natives, this.host.inlineCtx, (batch, approved) => {
629
+ this.spinner.start(() => this.host.requestRender());
630
+ return performUpdateAll(batch, this.natives, approved, (event) => {
631
+ this.updatingRowName = event.row.name;
632
+ if (event.phase === "done" && event.result) {
633
+ this.settled.set(event.row.name, { ok: event.result.ok, tail: logTail(event.result.output) });
634
+ }
635
+ this.host.requestRender();
636
+ }).finally(() => this.spinner.stop());
637
+ });
638
+ this.updatingRowName = undefined;
639
+ if (outcome === "changed") {
640
+ this.host.onSessionReplaced(); // ctx.reload() already replaced the session
641
+ return;
642
+ }
643
+ const reloaded = await loadRows(this.natives);
644
+ if (reloaded.error) this.host.ctx.ui.notify(`refresh failed: ${reloaded.error}`, "error");
645
+ else this.rows = reloaded.rows;
646
+ this.settled.clear(); // fresh state for a subsequent batch
647
+ this.applyFilter();
648
+ this.host.requestRender();
398
649
  }
399
650
  }
400
651
 
401
- function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows: Row[]): Promise<PanelAction | undefined> {
402
- return ctx.ui.custom<PanelAction | undefined>((tui, theme, _kb, done) => {
403
- let rows = initialRows;
404
- let mode: ViewMode = "all";
405
- const searchInput = new Input();
406
- let searchActive = false;
407
- let filtered = visibleRows(rows, mode);
408
- let selectedIndex = 0;
409
- // Set only while U's batch update is running -- blocks input for the
410
- // whole batch (the installer owns input until it finishes), same as
411
- // before. The list stays fully visible throughout. Which specific row
412
- // currently shows a spinner vs. a settled ✓/✗ is settled's own job
413
- // (below), not this -- a just-finished row must show its glyph
414
- // immediately, even for the instant before the next row's "start"
415
- // event reassigns this to the next name.
416
- let updatingRowName: string | undefined;
417
- const spinner = new Spinner();
418
- const settled = new Map<string, { ok: boolean; tail: string | undefined }>();
419
-
420
- const maxVisible = 20;
421
-
422
- function applyFilter(): void {
423
- filtered = filterRows(visibleRows(rows, mode), searchInput.getValue());
424
- selectedIndex = 0;
425
- }
652
+ interface TabScope {
653
+ /** True while this tab wants Escape for itself right now (an active
654
+ * filter, an in-flight mutation) instead of the host's own "back to
655
+ * Packages, or close from Packages" handling. Defaults to false. */
656
+ capturesEscape?(): boolean;
657
+ /** True while this tab wants Left/Right for itself right now (Find's
658
+ * always-on query cursor) instead of the host's own tab-cycling.
659
+ * Defaults to false. */
660
+ capturesHorizontalArrows?(): boolean;
661
+ }
426
662
 
663
+ export async function showPackedPanel(
664
+ ctx: ExtensionCommandContext,
665
+ natives: Natives,
666
+ opts?: { initialTab?: PackedTabKey; initialConfigFilter?: string },
667
+ ): Promise<void> {
668
+ if (!ctx.hasUI) {
669
+ ctx.ui.notify("/packed requires interactive mode", "warning");
670
+ return;
671
+ }
672
+
673
+ const { rows, error } = await loadRows(natives);
674
+ if (error) {
675
+ ctx.ui.notify(`packed unavailable: ${error}`, "error");
676
+ return;
677
+ }
678
+
679
+ await renderUnifiedPanel(ctx, natives, rows, opts?.initialTab ?? "packages", opts?.initialConfigFilter);
680
+ }
681
+
682
+ function renderUnifiedPanel(
683
+ ctx: ExtensionCommandContext,
684
+ natives: Natives,
685
+ initialRows: Row[],
686
+ initialTab: PackedTabKey,
687
+ initialConfigFilter: string | undefined,
688
+ ): Promise<void> {
689
+ return ctx.ui.custom<void>((tui, theme, _kb, done) => {
427
690
  // A y/n decision rendered as this SAME overlay's own content (via a real
428
691
  // Malevich Dialog, dispatched by literal key press) instead of
429
- // ctx.ui.confirm's separate native dialog -- confirmed live as a real
430
- // user complaint: ctx.ui.confirm opens as its own distinct overlay on
431
- // top of this one ("a new window"), arrow-select only, no y/n keys.
692
+ // ctx.ui.confirm's separate native dialog -- shared by every tab via
693
+ // host.inlineCtx below, exactly like the panel's own approval/reload
694
+ // dialogs always have been.
432
695
  let pendingDialog: Dialog | undefined;
433
696
 
434
697
  function confirmInline(title: string, message: string): Promise<boolean> {
@@ -446,188 +709,75 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
446
709
  { label: "No", key: "n", action: () => settle(false) },
447
710
  ],
448
711
  theme: dialogTheme(theme),
712
+ framed: false, // this overlay's own Envelope already draws a border; a second rule would double up on it
449
713
  });
450
714
  tui.requestRender();
451
715
  });
452
716
  }
453
717
 
454
- // Every confirm() call inside a mutation flow driven from this open
455
- // overlay (approvePackageOperation's own approval, confirmReload's
456
- // separate reload gate) routes through confirmInline instead of the
457
- // real ctx.ui.confirm -- notify/reload/etc. stay the genuine ctx.
458
718
  const inlineCtx: ExtensionCommandContext = { ...ctx, hasUI: true, ui: { ...ctx.ui, confirm: confirmInline } };
719
+ const host: TabHost = {
720
+ ctx,
721
+ inlineCtx,
722
+ requestRender: () => tui.requestRender(),
723
+ onSessionReplaced: () => done(undefined),
724
+ };
459
725
 
460
- /** u/x/d and the Enter action menu all funnel through here: approval and
461
- * reload confirms render inline (via inlineCtx/confirmInline) on this
462
- * same overlay, never closing it first the way done()-dispatch used to. */
463
- async function runRowActionInline(action: { type: "update" | "remove" | "disable"; row: Row }): Promise<void> {
464
- updatingRowName = action.row.name;
465
- tui.requestRender();
466
- const outcome = action.type === "disable"
467
- ? await applyDisableExtensions(action.row, natives, inlineCtx)
468
- : await applyPackageChoice(action.type === "update" ? `Update to ${action.row.latest}` : "Remove", action.row, natives, inlineCtx);
469
- updatingRowName = undefined;
470
- if (outcome === "changed") {
471
- done(undefined); // ctx.reload() already replaced the session
472
- return;
473
- }
474
- const reloaded = await loadRows(natives);
475
- if (reloaded.error) ctx.ui.notify(`refresh failed: ${reloaded.error}`, "error");
476
- else rows = reloaded.rows;
477
- applyFilter();
478
- tui.requestRender();
479
- }
726
+ const measure: TextMeasure = { visibleWidth, truncateToWidth };
480
727
 
481
- /** U -- runs the whole approve+update+notify+reload flow without ever
482
- * closing this panel or replacing the list: each row's own settled
483
- * outcome (spinner while in flight, then a real ✓/✗ plus a bounded tail
484
- * of its actual captured output) appears inline next to that row, via
485
- * updatingRowName/spinner/settled, which list's own render checks. Rows
486
- * refresh in place afterward unless a reload already ended the session. */
487
- async function runUpdateAllInline(): Promise<void> {
488
- const outdated = rows.filter((row) => row.hasUpdate);
489
- settled.clear();
490
- const outcome = await approveAndRunUpdateAll(outdated, natives, inlineCtx, (batch, approved) => {
491
- spinner.start(() => tui.requestRender());
492
- return performUpdateAll(batch, natives, approved, (event) => {
493
- updatingRowName = event.row.name;
494
- if (event.phase === "done" && event.result) {
495
- settled.set(event.row.name, { ok: event.result.ok, tail: logTail(event.result.output) });
496
- }
497
- tui.requestRender();
498
- }).finally(() => spinner.stop());
499
- });
500
- updatingRowName = undefined;
501
- if (outcome === "changed") {
502
- done(undefined); // ctx.reload() already replaced the session
503
- return;
504
- }
505
- const reloaded = await loadRows(natives);
506
- if (reloaded.error) ctx.ui.notify(`refresh failed: ${reloaded.error}`, "error");
507
- else rows = reloaded.rows;
508
- settled.clear(); // fresh state for a subsequent batch
509
- applyFilter();
510
- tui.requestRender();
511
- }
728
+ const packagesTab = new PackagesTab(natives, host, theme, measure, initialRows, (target, configFilter) => {
729
+ if (target === "config" && configFilter !== undefined) configTab.setFilter(configFilter);
730
+ tabbedContainer.setActive(target);
731
+ });
512
732
 
513
- function panelTitle(): string {
514
- if (updatingRowName) return "Packages · updating…";
515
- const outdated = rows.filter((r) => r.hasUpdate).length;
516
- return outdated > 0 ? `Packages · ${outdated} update(s)` : "Packages";
517
- }
733
+ const findTab = createFindTab(natives, host, theme);
518
734
 
519
- const header = {
520
- invalidate() {},
521
- render(width: number): string[] {
522
- const hint = searchActive
523
- ? rawKeyHint("esc", "clear")
524
- : rawKeyHint("enter", "menu") +
525
- theme.fg("muted", " · ") +
526
- rawKeyHint("u/U", "update/all") +
527
- theme.fg("muted", " · ") +
528
- rawKeyHint("x", "remove") +
529
- theme.fg("muted", " · ") +
530
- rawKeyHint("d", "disable") +
531
- theme.fg("muted", " · ") +
532
- rawKeyHint("c", "config") +
533
- theme.fg("muted", " · ") +
534
- rawKeyHint("f", "find") +
535
- theme.fg("muted", " · ") +
536
- rawKeyHint("s", "settings") +
537
- theme.fg("muted", " · ") +
538
- rawKeyHint("esc", "close");
539
- const line1 = truncateToWidth(hint, width, "");
540
- const dot = "·";
541
- const line2 = truncateToWidth(
542
- theme.fg("muted", `view: ${mode} ${dot} / filter ${dot} tab view ${dot} r refresh ${dot} ${rows.length} installed`),
543
- width,
544
- "",
545
- );
546
- return [line1, line2];
547
- },
548
- };
735
+ // Config/Settings both need theme (only available once this factory
736
+ // runs) so they're constructed here rather than before the overlay
737
+ // opens; both load their own data asynchronously in the background and
738
+ // render their own "Loading…" state until it resolves -- no
739
+ // placeholder-swap machinery needed.
740
+ const configTab = new ConfigTab(natives, host, theme, initialTab === "config" ? initialConfigFilter : undefined);
741
+ void configTab.load().then(() => tui.requestRender());
549
742
 
550
- // pi-tui's own visibleWidth/truncateToWidth are ANSI-aware (they strip
551
- // escape codes for measurement, matching the pair Malevich's TextMeasure
552
- // port expects) -- rows below bake selection/status styling directly
553
- // into each cell's text since Table's own cellStyle is column-wide, not
554
- // per-row.
555
- const measure: TextMeasure = { visibleWidth, truncateToWidth };
556
- const columns: TableColumn[] = [
557
- { header: "Package", key: "name" },
558
- { header: "Version", key: "version" },
559
- { header: "", key: "status" },
560
- ];
561
- const table = new Table({ columns, rows: [], measure, headerStyle: (s) => theme.fg("muted", s) });
562
-
563
- const list = {
564
- invalidate() {},
565
- render(width: number): string[] {
566
- const lines: string[] = [];
567
- if (searchActive) lines.push(...searchInput.render(width));
568
- lines.push("");
569
- if (filtered.length === 0) {
570
- lines.push(theme.fg("muted", " No packages"));
571
- return lines;
572
- }
573
- // No scrolling of its own (Malevich's Table is deliberately
574
- // unopinionated about pagination) -- the visible window around
575
- // selectedIndex stays this panel's own job, same as before.
576
- const start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible));
577
- const end = Math.min(start + maxVisible, filtered.length);
578
- table.setRows(
579
- filtered.slice(start, end).map((row, offset) => {
580
- const i = start + offset;
581
- const selected = i === selectedIndex;
582
- const cursor = selected ? theme.fg("accent", "❯ ") : " ";
583
- const name = selected ? theme.bold(row.name) : row.name;
584
- // A settled row shows its real outcome even for the instant
585
- // before the next row's "start" event moves updatingRowName
586
- // off it -- settled always wins over "still spinning".
587
- const rowSettled = settled.get(row.name);
588
- const isUpdating = !rowSettled && updatingRowName === row.name;
589
- const status = isUpdating
590
- ? theme.fg("accent", `${spinner.glyph()} updating…`)
591
- : rowSettled
592
- ? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
593
- : row.hasUpdate ? theme.fg("warning", `↑${row.latest}`) : "";
594
- return { name: `${cursor}${name}`, version: theme.fg("dim", row.version), status };
595
- }),
596
- );
597
- lines.push(...table.render(width));
598
- const hasScroll = start > 0 || end < filtered.length;
599
- lines.push(theme.fg("dim", ` ${hasScroll ? `${selectedIndex + 1}/${filtered.length} ` : ""}${mode}`));
600
- return lines;
601
- },
743
+ const settingsTab = new SettingsTab(natives, host, theme);
744
+ void settingsTab.load().then(() => tui.requestRender());
745
+
746
+ const tabByKey: Record<PackedTabKey, Component & TabScope> = {
747
+ packages: packagesTab,
748
+ find: findTab,
749
+ config: configTab,
750
+ settings: settingsTab,
602
751
  };
603
752
 
604
- // Real bordered box (rounded corners), not just a horizontal rule --
605
- // title carries the live update badge/status instead of a header line.
753
+ const tabbedContainer = new TabbedContainer({
754
+ tabs: [
755
+ { key: "packages", label: "Packages", content: tabByKey.packages },
756
+ { key: "find", label: "Find", content: tabByKey.find },
757
+ { key: "config", label: "Config", content: tabByKey.config },
758
+ { key: "settings", label: "Settings", content: tabByKey.settings },
759
+ ],
760
+ theme: tabBarTheme(theme),
761
+ initialKey: initialTab,
762
+ });
763
+
606
764
  // measure must be explicit: Envelope's own default is ASCII-only (raw
607
- // .length, blind to ANSI escape codes) and every line below is styled
608
- // through theme.fg/theme.bold -- without this, Envelope pads each line
609
- // against its own escape-code-inflated "length" instead of its real
610
- // visible width, so the right border lands at a different column on
611
- // every line depending on how much styling that particular line has.
765
+ // .length, blind to ANSI escape codes) and every tab's own content is
766
+ // styled through theme.fg/theme.bold -- without this, Envelope pads
767
+ // each line against its own escape-code-inflated "length" instead of
768
+ // its real visible width, so the right border lands at a different
769
+ // column on every line depending on how much styling it carries.
612
770
  const envelope = new Envelope({
613
- title: panelTitle(),
771
+ title: "packed",
614
772
  borderStyle: "rounded",
615
773
  style: (s) => theme.fg("border", s),
616
774
  titleStyle: (s) => theme.bold(theme.fg("accent", s)),
617
775
  measure,
618
776
  });
619
- const body = {
620
- invalidate() { header.invalidate(); list.invalidate(); },
621
- render(width: number): string[] {
622
- return [...header.render(width), "", ...list.render(width)];
623
- },
624
- };
625
- envelope.setContent(body);
626
777
 
627
778
  return {
628
779
  render(width: number): string[] {
629
- envelope.setTitle(panelTitle());
630
- envelope.setContent(pendingDialog ?? body);
780
+ envelope.setContent(pendingDialog ?? tabbedContainer);
631
781
  return envelope.render(width);
632
782
  },
633
783
  invalidate: () => envelope.invalidate(),
@@ -637,85 +787,24 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
637
787
  tui.requestRender();
638
788
  return;
639
789
  }
640
- if (updatingRowName) return; // the mutation/installer owns input until it finishes
641
-
642
- if (searchActive) {
643
- if (data === "\x1b") {
644
- searchActive = false;
645
- searchInput.setValue?.("");
646
- applyFilter();
647
- } else if (data === "\r") {
648
- searchActive = false;
649
- } else {
650
- searchInput.handleInput(data);
651
- applyFilter();
652
- }
790
+ const activeKey = tabbedContainer.getActiveKey() as PackedTabKey;
791
+ const activeTab = tabByKey[activeKey];
792
+ // Each host-level key is checked against that tab's own capture flag
793
+ // independently -- conflating them would, e.g., let Find's always-on
794
+ // capturesHorizontalArrows() also swallow Escape into the query box
795
+ // instead of navigating back.
796
+ if (data === "\x1b" && !(activeTab.capturesEscape?.() ?? false)) {
797
+ if (activeKey !== "packages") tabbedContainer.setActive("packages");
798
+ else { done(undefined); return; }
653
799
  tui.requestRender();
654
800
  return;
655
801
  }
656
-
657
- switch (data) {
658
- case "\x1b[A": // up
659
- selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(filtered.length, 1);
660
- break;
661
- case "\x1b[B": // down
662
- selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
663
- break;
664
- case "\t":
665
- mode = nextMode(mode);
666
- applyFilter();
667
- break;
668
- case "/":
669
- searchActive = true;
670
- break;
671
- case "r":
672
- done({ type: "refresh" });
673
- return;
674
- case "s":
675
- done({ type: "settings" });
676
- return;
677
- case "f":
678
- done({ type: "find" });
679
- return;
680
- case "U":
681
- void runUpdateAllInline();
682
- return;
683
- case "u": {
684
- const row = filtered[selectedIndex];
685
- if (row?.hasUpdate) void runRowActionInline({ type: "update", row });
686
- return;
687
- }
688
- case "x": {
689
- const row = filtered[selectedIndex];
690
- if (row) void runRowActionInline({ type: "remove", row });
691
- return;
692
- }
693
- case "d": {
694
- const row = filtered[selectedIndex];
695
- if (row) void runRowActionInline({ type: "disable", row });
696
- return;
697
- }
698
- case "c": {
699
- const row = filtered[selectedIndex];
700
- if (row) done({ type: "config", row });
701
- return;
702
- }
703
- case "\r": {
704
- const row = filtered[selectedIndex];
705
- if (!row) return;
706
- void (async () => {
707
- const choice = await showActionMenu(ctx, row);
708
- if (choice === "update" || choice === "remove" || choice === "disable") void runRowActionInline({ type: choice, row });
709
- else if (choice === "config") done({ type: "config", row });
710
- })();
711
- return;
712
- }
713
- case "\x1b":
714
- done(undefined);
715
- return;
716
- default:
717
- return;
802
+ if ((data === "\x1b[C" || data === "\x1b[D") && !(activeTab.capturesHorizontalArrows?.() ?? false)) {
803
+ tabbedContainer.handleInput(data); // owns Left/Right cycling itself
804
+ tui.requestRender();
805
+ return;
718
806
  }
807
+ activeTab.handleInput?.(data);
719
808
  tui.requestRender();
720
809
  },
721
810
  };