@danypops/pi-packed 0.14.0 → 0.15.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,7 +11,7 @@ The extension connects to Packed's authenticated user daemon and starts the pack
11
11
  ## Commands
12
12
 
13
13
  - `/packed` -- a floating overlay panel: 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
- - `u` / `U` -- update the selected package / update every outdated package, one combined confirmation and one reload. `U`'s progress bar appears inline next to the package currently updating -- 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`.
14
+ - `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`.
15
15
  - `x` -- remove the selected package.
16
16
  - `d` -- disable (or re-enable) the selected package's own extensions.
17
17
  - `c` -- jump to resource config for the selected package (skills, prompts, themes).
@@ -0,0 +1,44 @@
1
+ /**
2
+ * spinner.ts — a tiny, testable indeterminate-progress ticker for a
3
+ * surface with no discrete step count (a single in-flight subprocess
4
+ * call, unlike ProgressBar's own known N-of-M batch position). tick() is
5
+ * the pure, synchronously-testable core; start()/stop() wire it to a real
6
+ * interval for genuine on-screen animation. pi-tui's own Loader component
7
+ * does the identical thing internally but keeps its current frame private
8
+ * (it owns a whole Text line), so it can't be embedded inline in a
9
+ * caller's own row the way this one is -- hence this small local one.
10
+ */
11
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
+ const INTERVAL_MS = 80;
13
+
14
+ export class Spinner {
15
+ private index = 0;
16
+ private timer: ReturnType<typeof setInterval> | undefined;
17
+
18
+ /** Current animation frame -- a single braille glyph. */
19
+ glyph(): string {
20
+ return FRAMES[this.index]!;
21
+ }
22
+
23
+ /** Advances one frame. Pure and synchronous -- the deterministic unit under test; start() is just this wired to a real timer. */
24
+ tick(): void {
25
+ this.index = (this.index + 1) % FRAMES.length;
26
+ }
27
+
28
+ /** Wires tick() to a real interval, calling onTick after each advance so a host can requestRender(). Idempotent -- calling start() again restarts cleanly rather than stacking a second interval. */
29
+ start(onTick: () => void): void {
30
+ this.stop();
31
+ this.timer = setInterval(() => {
32
+ this.tick();
33
+ onTick();
34
+ }, INTERVAL_MS);
35
+ }
36
+
37
+ /** Safe to call even if never started, or more than once. */
38
+ stop(): void {
39
+ if (this.timer !== undefined) {
40
+ clearInterval(this.timer);
41
+ this.timer = undefined;
42
+ }
43
+ }
44
+ }
@@ -12,9 +12,13 @@
12
12
  * own row percentage, not a fixed row count -- scales with the real
13
13
  * terminal, unlike the anchor:"top-center"+offsetY this replaced, which
14
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 update stays on
16
- * this same package list -- progress renders inline next to the row
17
- * currently being updated, not as a separate screen. Rows render through
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
+ * (Spinner, this package's own -- neither Malevich nor pi-tui exposes an
18
+ * embeddable one) renders inline next to the row currently updating,
19
+ * settling into a real ✓/✗ plus a bounded tail of that row's own actual
20
+ * captured stdout/stderr once it finishes, never a determinate bar (a
21
+ * single subprocess call has no knowable percentage). Rows render through
18
22
  * Malevich's Table (real column-aligned Package/Version/status cells,
19
23
  * per-row selection styling baked into each cell since Table's own
20
24
  * cellStyle is column-wide, not row-wide) inside this panel's own
@@ -29,7 +33,7 @@
29
33
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
30
34
  import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
31
35
  import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
32
- import { Envelope, Menu, ProgressBar, Table, type MenuItem, type TableColumn, type TextMeasure } from "malevich-tui-components";
36
+ import { Envelope, Menu, Table, type MenuItem, type TableColumn, type TextMeasure } from "malevich-tui-components";
33
37
  import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
34
38
  import type { Row, ViewMode } from "./model.js";
35
39
  import type { Natives, PackageResources } from "./packed.js";
@@ -39,6 +43,7 @@ import { showResourceConfig, applyResourceToggle } from "./resource-config.js";
39
43
  import { showDiscoverPanel } from "./discover.js";
40
44
  import { menuTheme } from "./menu-theme.js";
41
45
  import { confirmReload } from "./reload.js";
46
+ import { Spinner } from "./spinner.js";
42
47
 
43
48
  interface PanelAction {
44
49
  type: "update" | "remove" | "disable" | "config" | "find" | "refresh" | "settings";
@@ -115,7 +120,13 @@ export async function applyPackageChoice(
115
120
 
116
121
  interface UpdateAllResult { changed: number; failedNames: string[]; }
117
122
 
118
- interface UpdateProgressEvent { row: Row; index: number; total: number; phase: "start" | "done"; }
123
+ /** Present only on phase "done" -- the real captured stdout+stderr from
124
+ * ExecInstaller (ok: true) or the thrown error's message (ok: false). This
125
+ * is the actual execution output, not a synthetic status string, so a host
126
+ * can show a genuine success/failure sign instead of guessing from
127
+ * reloadRequired alone. */
128
+ interface UpdateProgressResult { ok: boolean; output: string; }
129
+ interface UpdateProgressEvent { row: Row; index: number; total: number; phase: "start" | "done"; result?: UpdateProgressResult; }
119
130
 
120
131
  /** The core sequential-update loop, with no UI of its own -- reports each
121
132
  * step via onProgress so any host surface (a floating overlay, or the
@@ -134,14 +145,28 @@ async function performUpdateAll(
134
145
  try {
135
146
  const outcome = await natives.update(`npm:${row.name}`, approved);
136
147
  if (outcome.reloadRequired) changed += 1;
148
+ onProgress?.({ row, index: i, total: outdated.length, phase: "done", result: { ok: true, output: outcome.output } });
137
149
  } catch (e) {
138
- failedNames.push(`${row.name}: ${e instanceof Error ? e.message : e}`);
150
+ const message = e instanceof Error ? e.message : String(e);
151
+ failedNames.push(`${row.name}: ${message}`);
152
+ onProgress?.({ row, index: i, total: outdated.length, phase: "done", result: { ok: false, output: message } });
139
153
  }
140
- onProgress?.({ row, index: i, total: outdated.length, phase: "done" });
141
154
  }
142
155
  return { changed, failedNames };
143
156
  }
144
157
 
158
+ const MAX_LOG_TAIL_CHARS = 60;
159
+
160
+ /** The last non-empty line of real captured output, bounded -- never the
161
+ * full stdout/stderr dump inline (a noisy npm install can produce hundreds
162
+ * of lines). undefined when there's nothing worth showing (the common
163
+ * case: `pi update` often produces no output on success at all). */
164
+ function logTail(output: string): string | undefined {
165
+ const line = output.trim().split("\n").filter(Boolean).at(-1);
166
+ if (!line) return undefined;
167
+ return line.length > MAX_LOG_TAIL_CHARS ? `${line.slice(0, MAX_LOG_TAIL_CHARS - 1)}…` : line;
168
+ }
169
+
145
170
  /** Approves once for the whole batch, runs it via whatever runBatch does
146
171
  * (a floating overlay for applyUpdateAll's own public API, or renderPanel's
147
172
  * embedded progress bar), then reports the combined result -- shared so
@@ -182,11 +207,17 @@ async function approveAndRunUpdateAll(
182
207
  return "changed";
183
208
  }
184
209
 
185
- /** Floats its own progress-bar overlay over the still-open panel -- kept
210
+ const MAX_SETTLED_LOG_LINES = 5;
211
+
212
+ /** Floats its own spinner+log overlay over the still-open panel -- kept
186
213
  * for applyUpdateAll's own public API (and anything calling it directly,
187
214
  * outside the packages panel). renderPanel's own U key does not use this;
188
- * it renders the same progress bar inline on its own already-open overlay
189
- * instead of stacking a second one. */
215
+ * it renders the same spinner+log inline on its own already-open overlay
216
+ * instead of stacking a second one. An indeterminate spinner (not a
217
+ * determinate bar) because a single subprocess call has no knowable
218
+ * percentage -- only "still running" or "settled". Each settled row
219
+ * appends one bounded log line (real captured output, not a synthetic
220
+ * status) with a genuine success/failure glyph, up to a small scrollback. */
190
221
  async function runUpdatesWithProgress(
191
222
  outdated: Row[],
192
223
  natives: Natives,
@@ -195,22 +226,35 @@ async function runUpdatesWithProgress(
195
226
  ): Promise<UpdateAllResult> {
196
227
  return ctx.ui.custom<UpdateAllResult>(
197
228
  (tui, theme, _kb, done) => {
198
- const bar = new ProgressBar({ value: 0, max: outdated.length, label: `${outdated[0]?.name ?? ""} (1/${outdated.length})`, style: (s) => theme.fg("accent", s) });
229
+ const spinner = new Spinner();
230
+ const settledLines: string[] = [];
231
+ let currentLabel = `${outdated[0]?.name ?? ""} (1/${outdated.length})`;
199
232
  const border = () => new DynamicBorder((s) => theme.fg("border", s));
200
233
  const container = new Container();
201
234
  container.addChild(new Spacer(1));
202
235
  container.addChild(border());
203
236
  container.addChild({ invalidate() {}, render: (_width: number) => [theme.bold("Updating packages")] });
204
237
  container.addChild(new Spacer(1));
205
- container.addChild(bar);
238
+ container.addChild({ invalidate() {}, render: (width: number) => [truncateToWidth(`${theme.fg("accent", spinner.glyph())} ${currentLabel}`, width, "")] });
239
+ container.addChild(new Spacer(1));
240
+ container.addChild({
241
+ invalidate() {},
242
+ render: (width: number) => settledLines.slice(-MAX_SETTLED_LOG_LINES).map((line) => truncateToWidth(line, width, "")),
243
+ });
206
244
  container.addChild(new Spacer(1));
207
245
  container.addChild(border());
208
246
 
247
+ spinner.start(() => tui.requestRender());
209
248
  performUpdateAll(outdated, natives, approved, (event) => {
210
- bar.setLabel(`${event.row.name} (${event.index + 1}/${event.total})`);
211
- if (event.phase === "done") bar.setValue(event.index + 1);
249
+ if (event.phase === "start") {
250
+ currentLabel = `${event.row.name} (${event.index + 1}/${event.total})`;
251
+ } else {
252
+ const glyph = event.result?.ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
253
+ const tail = event.result ? logTail(event.result.output) : undefined;
254
+ settledLines.push(`${glyph} ${event.row.name}${tail ? theme.fg("dim", ` -- ${tail}`) : ""}`);
255
+ }
212
256
  tui.requestRender();
213
- }).then(done);
257
+ }).finally(() => spinner.stop()).then(done);
214
258
 
215
259
  return { render: (width: number) => container.render(width), invalidate: () => container.invalidate(), handleInput() {} };
216
260
  },
@@ -372,11 +416,16 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
372
416
  let searchActive = false;
373
417
  let filtered = visibleRows(rows, mode);
374
418
  let selectedIndex = 0;
375
- // Set only while U's batch update is running. The list stays fully
376
- // visible throughout -- this just names which row the shared bar
377
- // (below) is currently sitting next to.
419
+ // Set only while U's batch update is running -- blocks input for the
420
+ // whole batch (the installer owns input until it finishes), same as
421
+ // before. The list stays fully visible throughout. Which specific row
422
+ // currently shows a spinner vs. a settled ✓/✗ is settled's own job
423
+ // (below), not this -- a just-finished row must show its glyph
424
+ // immediately, even for the instant before the next row's "start"
425
+ // event reassigns this to the next name.
378
426
  let updatingRowName: string | undefined;
379
- const updatingBar = new ProgressBar({ value: 0, max: 1, width: 10, style: (s) => theme.fg("accent", s) });
427
+ const spinner = new Spinner();
428
+ const settled = new Map<string, { ok: boolean; tail: string | undefined }>();
380
429
 
381
430
  const maxVisible = 20;
382
431
 
@@ -386,21 +435,23 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
386
435
  }
387
436
 
388
437
  /** U -- runs the whole approve+update+notify+reload flow without ever
389
- * closing this panel or replacing the list: each step's progress
390
- * appears inline next to the row currently being updated, via
391
- * updatingRowName/updatingBar, which list's own render checks. Rows
392
- * refresh in place afterward unless a reload already ended the
393
- * session. */
438
+ * closing this panel or replacing the list: each row's own settled
439
+ * outcome (spinner while in flight, then a real ✓/✗ plus a bounded tail
440
+ * of its actual captured output) appears inline next to that row, via
441
+ * updatingRowName/spinner/settled, which list's own render checks. Rows
442
+ * refresh in place afterward unless a reload already ended the session. */
394
443
  async function runUpdateAllInline(): Promise<void> {
395
444
  const outdated = rows.filter((row) => row.hasUpdate);
445
+ settled.clear();
396
446
  const outcome = await approveAndRunUpdateAll(outdated, natives, ctx, (batch, approved) => {
397
- updatingBar.setValue(0);
398
- updatingBar.setMax(batch.length);
447
+ spinner.start(() => tui.requestRender());
399
448
  return performUpdateAll(batch, natives, approved, (event) => {
400
449
  updatingRowName = event.row.name;
401
- if (event.phase === "done") updatingBar.setValue(event.index + 1);
450
+ if (event.phase === "done" && event.result) {
451
+ settled.set(event.row.name, { ok: event.result.ok, tail: logTail(event.result.output) });
452
+ }
402
453
  tui.requestRender();
403
- });
454
+ }).finally(() => spinner.stop());
404
455
  });
405
456
  updatingRowName = undefined;
406
457
  if (outcome === "changed") {
@@ -410,6 +461,7 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
410
461
  const reloaded = await loadRows(natives);
411
462
  if (reloaded.error) ctx.ui.notify(`refresh failed: ${reloaded.error}`, "error");
412
463
  else rows = reloaded.rows;
464
+ settled.clear(); // fresh state for a subsequent batch
413
465
  applyFilter();
414
466
  tui.requestRender();
415
467
  }
@@ -485,10 +537,16 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
485
537
  const selected = i === selectedIndex;
486
538
  const cursor = selected ? theme.fg("accent", "❯ ") : " ";
487
539
  const name = selected ? theme.bold(row.name) : row.name;
488
- const isUpdating = updatingRowName === row.name;
540
+ // A settled row shows its real outcome even for the instant
541
+ // before the next row's "start" event moves updatingRowName
542
+ // off it -- settled always wins over "still spinning".
543
+ const rowSettled = settled.get(row.name);
544
+ const isUpdating = !rowSettled && updatingRowName === row.name;
489
545
  const status = isUpdating
490
- ? theme.fg("accent", `${updatingBar.format(10)} updating…`)
491
- : row.hasUpdate ? theme.fg("warning", `↑${row.latest}`) : "";
546
+ ? theme.fg("accent", `${spinner.glyph()} updating…`)
547
+ : rowSettled
548
+ ? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
549
+ : row.hasUpdate ? theme.fg("warning", `↑${row.latest}`) : "";
492
550
  return { name: `${cursor}${name}`, version: theme.fg("dim", row.version), status };
493
551
  }),
494
552
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Pi package tools, commands, profiles, and TUI for the Packed daemon",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],