@danypops/pi-packed 0.15.0 → 0.16.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 +1 -1
- package/extension/src/menu-theme.ts +14 -1
- package/extension/src/tui.ts +123 -49
- package/package.json +2 -2
- package/extension/src/spinner.ts +0 -44
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` 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`.
|
|
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`. Every confirm in this flow -- approval and reload alike -- renders inline on this same panel (`y`/`n` keys), never a separate popup.
|
|
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).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { MenuTheme } from "malevich-tui-components";
|
|
2
|
+
import type { DialogTheme, MenuTheme } 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. */
|
|
@@ -12,3 +12,16 @@ export function menuTheme(theme: Theme): MenuTheme {
|
|
|
12
12
|
title: (s) => theme.fg("accent", s),
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/** Maps Pi's own Theme onto Malevich's Dialog -- used for an inline y/n
|
|
17
|
+
* decision (e.g. confirmReload) rendered as part of an already-open
|
|
18
|
+
* ctx.ui.custom overlay's own content, instead of ctx.ui.confirm's separate,
|
|
19
|
+
* arrow-select-only native dialog. */
|
|
20
|
+
export function dialogTheme(theme: Theme): DialogTheme {
|
|
21
|
+
return {
|
|
22
|
+
border: (s) => theme.fg("border", s),
|
|
23
|
+
title: (s) => theme.bold(theme.fg("accent", s)),
|
|
24
|
+
body: (s) => s,
|
|
25
|
+
dim: (s) => theme.fg("muted", s),
|
|
26
|
+
};
|
|
27
|
+
}
|
package/extension/src/tui.ts
CHANGED
|
@@ -14,26 +14,36 @@
|
|
|
14
14
|
* was pinned 1 row below the absolute top on any terminal size). Enter
|
|
15
15
|
* opens a second, smaller overlay action menu on top of it. U's batch
|
|
16
16
|
* update stays on this same package list -- an indeterminate spinner
|
|
17
|
-
* (
|
|
18
|
-
* embeddable one) renders inline next to the row
|
|
19
|
-
* settling into a real ✓/✗ plus a bounded tail of
|
|
20
|
-
* captured stdout/stderr once it finishes, never a
|
|
21
|
-
* single subprocess call has no knowable percentage).
|
|
22
|
-
* Malevich's Table (real column-aligned
|
|
23
|
-
* per-row selection styling baked into each
|
|
24
|
-
* cellStyle is column-wide, not row-wide) inside
|
|
25
|
-
* scroll-window slice -- Table deliberately owns no
|
|
26
|
-
* own, so the visible-window-around-selectedIndex math
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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. Every non-"changed" settle point (already up to
|
|
37
|
+
* date, pinned, deferred, a genuine failure) shows its own real reason on
|
|
38
|
+
* that row too via the same settled map U's batch path uses, not only as
|
|
39
|
+
* a scrollback toast -- confirmed live as a real bug: a single-row update
|
|
40
|
+
* that turned out to be a no-op gave zero acknowledgment on the panel
|
|
41
|
+
* itself. All data flows through the packed CLI (thin seam).
|
|
32
42
|
*/
|
|
33
43
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
34
44
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
35
45
|
import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
36
|
-
import { Envelope, Menu, Table, type MenuItem, type TableColumn, type TextMeasure } from "malevich-tui-components";
|
|
46
|
+
import { Dialog, Envelope, Menu, Spinner, Table, type MenuItem, type TableColumn, type TextMeasure } from "malevich-tui-components";
|
|
37
47
|
import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
|
|
38
48
|
import type { Row, ViewMode } from "./model.js";
|
|
39
49
|
import type { Natives, PackageResources } from "./packed.js";
|
|
@@ -41,12 +51,11 @@ import { approvePackageOperation } from "./tools.js";
|
|
|
41
51
|
import { showPackedSettings } from "./security-tui.js";
|
|
42
52
|
import { showResourceConfig, applyResourceToggle } from "./resource-config.js";
|
|
43
53
|
import { showDiscoverPanel } from "./discover.js";
|
|
44
|
-
import { menuTheme } from "./menu-theme.js";
|
|
54
|
+
import { dialogTheme, menuTheme } from "./menu-theme.js";
|
|
45
55
|
import { confirmReload } from "./reload.js";
|
|
46
|
-
import { Spinner } from "./spinner.js";
|
|
47
56
|
|
|
48
57
|
interface PanelAction {
|
|
49
|
-
type: "
|
|
58
|
+
type: "config" | "find" | "refresh" | "settings";
|
|
50
59
|
row?: Row;
|
|
51
60
|
}
|
|
52
61
|
|
|
@@ -59,11 +68,19 @@ interface PanelAction {
|
|
|
59
68
|
* "unchanged"/"cancelled" mean the panel keeps running as-is. */
|
|
60
69
|
export type PackageChoiceOutcome = "changed" | "unchanged" | "cancelled" | "deferred";
|
|
61
70
|
|
|
71
|
+
/** A non-"changed" settle point's own detail -- the exact reason nothing
|
|
72
|
+
* (or only a partial thing) happened, so a caller with somewhere to show
|
|
73
|
+
* it inline (a row's own status cell) isn't limited to the scrollback
|
|
74
|
+
* toast every outcome already gets via ctx.ui.notify. "changed" never
|
|
75
|
+
* calls this: ctx.reload() already replaces the session by then. */
|
|
76
|
+
export interface PackageChoiceSettled { ok: boolean; message: string; }
|
|
77
|
+
|
|
62
78
|
export async function applyPackageChoice(
|
|
63
79
|
choice: string | undefined,
|
|
64
80
|
row: Row,
|
|
65
81
|
natives: Natives,
|
|
66
82
|
ctx: ExtensionCommandContext,
|
|
83
|
+
onSettled?: (settled: PackageChoiceSettled) => void,
|
|
67
84
|
): Promise<PackageChoiceOutcome> {
|
|
68
85
|
if (choice?.startsWith("Update")) {
|
|
69
86
|
try {
|
|
@@ -80,18 +97,22 @@ export async function applyPackageChoice(
|
|
|
80
97
|
? `pinned to ${version ?? "an exact version"} -- pi update intentionally leaves pinned packages unchanged`
|
|
81
98
|
: `already up to date${version ? ` at ${version}` : ""}`;
|
|
82
99
|
ctx.ui.notify(`${row.name} is ${reason}.`, "info");
|
|
100
|
+
onSettled?.({ ok: true, message: reason });
|
|
83
101
|
return "unchanged";
|
|
84
102
|
}
|
|
85
103
|
const transition = outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
|
|
86
104
|
if (!(await confirmReload(ctx))) {
|
|
87
105
|
ctx.ui.notify(`Updated ${row.name}${transition}; reload pending -- run /reload when ready.`, "warning");
|
|
106
|
+
onSettled?.({ ok: true, message: `updated${transition}; reload pending` });
|
|
88
107
|
return "deferred";
|
|
89
108
|
}
|
|
90
109
|
ctx.ui.notify(`Updated ${row.name}${transition}; reloading Pi resources.`, "info");
|
|
91
110
|
await ctx.reload();
|
|
92
111
|
return "changed";
|
|
93
112
|
} catch (e) {
|
|
94
|
-
|
|
113
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
114
|
+
ctx.ui.notify(`update failed: ${message}`, "error");
|
|
115
|
+
onSettled?.({ ok: false, message });
|
|
95
116
|
return "cancelled";
|
|
96
117
|
}
|
|
97
118
|
}
|
|
@@ -105,13 +126,16 @@ export async function applyPackageChoice(
|
|
|
105
126
|
await natives.remove(row.name, approval.approved);
|
|
106
127
|
if (!(await confirmReload(ctx))) {
|
|
107
128
|
ctx.ui.notify(`Removed ${row.name}; reload pending -- run /reload when ready.`, "warning");
|
|
129
|
+
onSettled?.({ ok: true, message: "removed; reload pending" });
|
|
108
130
|
return "deferred";
|
|
109
131
|
}
|
|
110
132
|
ctx.ui.notify(`Removed ${row.name}; reloading Pi resources.`, "info");
|
|
111
133
|
await ctx.reload();
|
|
112
134
|
return "changed";
|
|
113
135
|
} catch (e) {
|
|
114
|
-
|
|
136
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
137
|
+
ctx.ui.notify(`remove failed: ${message}`, "error");
|
|
138
|
+
onSettled?.({ ok: false, message });
|
|
115
139
|
return "cancelled";
|
|
116
140
|
}
|
|
117
141
|
}
|
|
@@ -366,8 +390,10 @@ export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Nat
|
|
|
366
390
|
}
|
|
367
391
|
|
|
368
392
|
// Panel loop: actions resolve the component, run outside it, then reopen.
|
|
369
|
-
//
|
|
370
|
-
//
|
|
393
|
+
// Every mutation (u/x/d, U, and whatever the Enter action menu picks) is
|
|
394
|
+
// handled entirely inside renderPanel itself -- approval and reload
|
|
395
|
+
// confirms render inline on the same already-open overlay -- and never
|
|
396
|
+
// reaches here; only non-mutation navigation resolves this loop.
|
|
371
397
|
for (;;) {
|
|
372
398
|
const action = await renderPanel(ctx, natives, rows);
|
|
373
399
|
if (!action) return; // closed
|
|
@@ -387,24 +413,8 @@ export async function showPackedPanel(ctx: ExtensionCommandContext, natives: Nat
|
|
|
387
413
|
continue; // a successful install already reloaded; a no-op returns here
|
|
388
414
|
}
|
|
389
415
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
continue; // showResourceConfig already handles its own reload prompt
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const row = action.row;
|
|
396
|
-
if (!row) continue;
|
|
397
|
-
|
|
398
|
-
if (action.type === "disable") {
|
|
399
|
-
const outcome = await applyDisableExtensions(row, natives, ctx);
|
|
400
|
-
if (outcome === "changed") return;
|
|
401
|
-
if (outcome === "deferred") await refreshRows();
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const outcome = await applyPackageChoice(action.type === "update" ? `Update to ${row.latest}` : "Remove", row, natives, ctx);
|
|
406
|
-
if (outcome === "changed") return; // ctx.reload() already replaced the session
|
|
407
|
-
if (outcome === "deferred") await refreshRows();
|
|
416
|
+
await showResourceConfig(ctx, natives, action.row?.name);
|
|
417
|
+
// showResourceConfig already handles its own reload prompt
|
|
408
418
|
}
|
|
409
419
|
}
|
|
410
420
|
|
|
@@ -434,6 +444,66 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
434
444
|
selectedIndex = 0;
|
|
435
445
|
}
|
|
436
446
|
|
|
447
|
+
// A y/n decision rendered as this SAME overlay's own content (via a real
|
|
448
|
+
// Malevich Dialog, dispatched by literal key press) instead of
|
|
449
|
+
// ctx.ui.confirm's separate native dialog -- confirmed live as a real
|
|
450
|
+
// user complaint: ctx.ui.confirm opens as its own distinct overlay on
|
|
451
|
+
// top of this one ("a new window"), arrow-select only, no y/n keys.
|
|
452
|
+
let pendingDialog: Dialog | undefined;
|
|
453
|
+
|
|
454
|
+
function confirmInline(title: string, message: string): Promise<boolean> {
|
|
455
|
+
return new Promise((resolve) => {
|
|
456
|
+
const settle = (value: boolean) => {
|
|
457
|
+
pendingDialog = undefined;
|
|
458
|
+
tui.requestRender();
|
|
459
|
+
resolve(value);
|
|
460
|
+
};
|
|
461
|
+
pendingDialog = new Dialog({
|
|
462
|
+
title,
|
|
463
|
+
body: message,
|
|
464
|
+
actions: [
|
|
465
|
+
{ label: "Yes", key: "y", action: () => settle(true) },
|
|
466
|
+
{ label: "No", key: "n", action: () => settle(false) },
|
|
467
|
+
],
|
|
468
|
+
theme: dialogTheme(theme),
|
|
469
|
+
});
|
|
470
|
+
tui.requestRender();
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Every confirm() call inside a mutation flow driven from this open
|
|
475
|
+
// overlay (approvePackageOperation's own approval, confirmReload's
|
|
476
|
+
// separate reload gate) routes through confirmInline instead of the
|
|
477
|
+
// real ctx.ui.confirm -- notify/reload/etc. stay the genuine ctx.
|
|
478
|
+
const inlineCtx: ExtensionCommandContext = { ...ctx, hasUI: true, ui: { ...ctx.ui, confirm: confirmInline } };
|
|
479
|
+
|
|
480
|
+
/** u/x/d and the Enter action menu all funnel through here: approval and
|
|
481
|
+
* reload confirms render inline (via inlineCtx/confirmInline) on this
|
|
482
|
+
* same overlay, never closing it first the way done()-dispatch used to. */
|
|
483
|
+
async function runRowActionInline(action: { type: "update" | "remove" | "disable"; row: Row }): Promise<void> {
|
|
484
|
+
updatingRowName = action.row.name;
|
|
485
|
+
tui.requestRender();
|
|
486
|
+
// A non-"changed" settle point (already up to date, pinned, deferred,
|
|
487
|
+
// a genuine failure) shows its own real reason on this row, the same
|
|
488
|
+
// settled map U's batch path already renders -- not just a scrollback
|
|
489
|
+
// toast, which was the whole bug: nothing on the panel itself ever
|
|
490
|
+
// acknowledged that a single-row action had even run.
|
|
491
|
+
const onSettled = (result: PackageChoiceSettled) => settled.set(action.row.name, { ok: result.ok, tail: result.message });
|
|
492
|
+
const outcome = action.type === "disable"
|
|
493
|
+
? await applyDisableExtensions(action.row, natives, inlineCtx)
|
|
494
|
+
: await applyPackageChoice(action.type === "update" ? `Update to ${action.row.latest}` : "Remove", action.row, natives, inlineCtx, onSettled);
|
|
495
|
+
updatingRowName = undefined;
|
|
496
|
+
if (outcome === "changed") {
|
|
497
|
+
done(undefined); // ctx.reload() already replaced the session
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const reloaded = await loadRows(natives);
|
|
501
|
+
if (reloaded.error) ctx.ui.notify(`refresh failed: ${reloaded.error}`, "error");
|
|
502
|
+
else rows = reloaded.rows;
|
|
503
|
+
applyFilter();
|
|
504
|
+
tui.requestRender();
|
|
505
|
+
}
|
|
506
|
+
|
|
437
507
|
/** U -- runs the whole approve+update+notify+reload flow without ever
|
|
438
508
|
* closing this panel or replacing the list: each row's own settled
|
|
439
509
|
* outcome (spinner while in flight, then a real ✓/✗ plus a bounded tail
|
|
@@ -443,7 +513,7 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
443
513
|
async function runUpdateAllInline(): Promise<void> {
|
|
444
514
|
const outdated = rows.filter((row) => row.hasUpdate);
|
|
445
515
|
settled.clear();
|
|
446
|
-
const outcome = await approveAndRunUpdateAll(outdated, natives,
|
|
516
|
+
const outcome = await approveAndRunUpdateAll(outdated, natives, inlineCtx, (batch, approved) => {
|
|
447
517
|
spinner.start(() => tui.requestRender());
|
|
448
518
|
return performUpdateAll(batch, natives, approved, (event) => {
|
|
449
519
|
updatingRowName = event.row.name;
|
|
@@ -583,11 +653,17 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
583
653
|
return {
|
|
584
654
|
render(width: number): string[] {
|
|
585
655
|
envelope.setTitle(panelTitle());
|
|
656
|
+
envelope.setContent(pendingDialog ?? body);
|
|
586
657
|
return envelope.render(width);
|
|
587
658
|
},
|
|
588
659
|
invalidate: () => envelope.invalidate(),
|
|
589
660
|
handleInput(data: string) {
|
|
590
|
-
if (
|
|
661
|
+
if (pendingDialog) {
|
|
662
|
+
pendingDialog.handleInput(data);
|
|
663
|
+
tui.requestRender();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (updatingRowName) return; // the mutation/installer owns input until it finishes
|
|
591
667
|
|
|
592
668
|
if (searchActive) {
|
|
593
669
|
if (data === "\x1b") {
|
|
@@ -632,17 +708,17 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
632
708
|
return;
|
|
633
709
|
case "u": {
|
|
634
710
|
const row = filtered[selectedIndex];
|
|
635
|
-
if (row?.hasUpdate)
|
|
711
|
+
if (row?.hasUpdate) void runRowActionInline({ type: "update", row });
|
|
636
712
|
return;
|
|
637
713
|
}
|
|
638
714
|
case "x": {
|
|
639
715
|
const row = filtered[selectedIndex];
|
|
640
|
-
if (row)
|
|
716
|
+
if (row) void runRowActionInline({ type: "remove", row });
|
|
641
717
|
return;
|
|
642
718
|
}
|
|
643
719
|
case "d": {
|
|
644
720
|
const row = filtered[selectedIndex];
|
|
645
|
-
if (row)
|
|
721
|
+
if (row) void runRowActionInline({ type: "disable", row });
|
|
646
722
|
return;
|
|
647
723
|
}
|
|
648
724
|
case "c": {
|
|
@@ -655,9 +731,7 @@ function renderPanel(ctx: ExtensionCommandContext, natives: Natives, initialRows
|
|
|
655
731
|
if (!row) return;
|
|
656
732
|
void (async () => {
|
|
657
733
|
const choice = await showActionMenu(ctx, row);
|
|
658
|
-
if (choice === "update")
|
|
659
|
-
else if (choice === "remove") done({ type: "remove", row });
|
|
660
|
-
else if (choice === "disable") done({ type: "disable", row });
|
|
734
|
+
if (choice === "update" || choice === "remove" || choice === "disable") void runRowActionInline({ type: choice, row });
|
|
661
735
|
else if (choice === "config") done({ type: "config", row });
|
|
662
736
|
})();
|
|
663
737
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Pi package tools, commands, profiles, and TUI for the Packed daemon",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package"],
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"@danypops/packed": "^0.2.0",
|
|
16
16
|
"@danypops/vehicle-client": "^0.1.1",
|
|
17
17
|
"@danypops/vehicle-client-pi": "^0.1.5",
|
|
18
|
-
"malevich-tui-components": "^0.
|
|
18
|
+
"malevich-tui-components": "^0.11.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"@earendil-works/pi-coding-agent": "*",
|
package/extension/src/spinner.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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
|
-
}
|