@pi-unipi/core 2.16.0 → 2.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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @pi-unipi/core — shared Fusion display status
3
+ *
4
+ * The fusion package owns the active lead/sidekick selection; the footer
5
+ * package owns the input-box frame that should display it. Same pattern as
6
+ * background-tasks' shared registry: a Symbol.for global published at
7
+ * extension init / session start and cleared on shutdown.
8
+ */
9
+
10
+ export interface SharedFusionStatus {
11
+ /** Display name of the lead (the session model). */
12
+ leadName: string;
13
+ /** Lead thinking level, e.g. "medium". */
14
+ leadEffort: string;
15
+ /** Display name of the sidekick. */
16
+ sidekickName: string;
17
+ /** Sidekick thinking level. */
18
+ sidekickEffort: string;
19
+ /** Estimated savings compared with pricing all sidekick usage at lead rates. */
20
+ savedUsd?: number;
21
+ }
22
+
23
+ const KEY = Symbol.for("unipi.fusion.status");
24
+
25
+ type Holder = { status?: SharedFusionStatus | undefined };
26
+
27
+ function holder(): Holder {
28
+ const g = globalThis as { [KEY]?: Holder };
29
+ g[KEY] ??= {};
30
+ return g[KEY] as Holder;
31
+ }
32
+
33
+ export function setSharedFusionStatus(status: SharedFusionStatus | undefined): void {
34
+ holder().status = status;
35
+ }
36
+
37
+ export function getSharedFusionStatus(): SharedFusionStatus | undefined {
38
+ return holder().status;
39
+ }
package/index.ts CHANGED
@@ -12,3 +12,5 @@ export * from "./model-cache.js";
12
12
  export * from "./tui-width.js";
13
13
  export * from "./tui-overlay.js";
14
14
  export * from "./bounded-output.js";
15
+ export * from "./spinner-line.js";
16
+ export * from "./fusion-status.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @pi-unipi/core — self-animating spinner line widget
3
+ *
4
+ * A one-line `ctx.ui.setWidget()` component that owns its own animation
5
+ * timer and calls `tui.requestRender()` on every frame — the same pattern
6
+ * as pi's built-in `Loader`. Use it for "still working" indicators whose
7
+ * data refreshes slowly (1 s task polls, agent trackers) but whose spinner
8
+ * must still look alive at ~12 fps.
9
+ *
10
+ * The text callback runs on every frame so elapsed times stay fresh; return
11
+ * `undefined` to render nothing (the line collapses) without disposing.
12
+ */
13
+
14
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
15
+ export const SPINNER_INTERVAL_MS = 80;
16
+
17
+ export interface SpinnerLineOptions {
18
+ /** Produce the line body (without the spinner glyph). `undefined` hides the line. */
19
+ text: () => string | undefined;
20
+ /** Colour the spinner glyph. Defaults to identity. */
21
+ colorSpinner?: ((glyph: string) => string) | undefined;
22
+ /** Animation frames. Defaults to braille dots. */
23
+ frames?: readonly string[] | undefined;
24
+ intervalMs?: number | undefined;
25
+ /** Left padding columns. Defaults to 1. */
26
+ padLeft?: number | undefined;
27
+ }
28
+
29
+ export interface SpinnerLineComponent {
30
+ render(width: number): string[];
31
+ invalidate(): void;
32
+ dispose(): void;
33
+ }
34
+
35
+ type RenderRequester = { requestRender(): void };
36
+
37
+ /**
38
+ * Create the widget factory to pass to `ctx.ui.setWidget(key, factory, opts)`.
39
+ * The returned component starts animating when constructed and stops on
40
+ * `dispose()` (pi calls `dispose` when the widget is replaced or cleared).
41
+ */
42
+ export function createSpinnerLine(
43
+ options: SpinnerLineOptions,
44
+ ): (tui: RenderRequester, theme: unknown) => SpinnerLineComponent {
45
+ const frames = options.frames ?? SPINNER_FRAMES;
46
+ const intervalMs = options.intervalMs ?? SPINNER_INTERVAL_MS;
47
+ const pad = " ".repeat(Math.max(0, options.padLeft ?? 1));
48
+ const color = options.colorSpinner ?? ((g: string) => g);
49
+
50
+ return (tui) => {
51
+ let frame = 0;
52
+ let timer: ReturnType<typeof setInterval> | undefined =
53
+ frames.length > 1
54
+ ? setInterval(() => {
55
+ frame = (frame + 1) % frames.length;
56
+ tui.requestRender();
57
+ }, intervalMs)
58
+ : undefined;
59
+
60
+ return {
61
+ render(width: number): string[] {
62
+ const body = options.text();
63
+ if (body === undefined) return [];
64
+ const glyph = frames[frame] ?? "";
65
+ const line = `${pad}${glyph.length > 0 ? `${color(glyph)} ` : ""}${body}`;
66
+ return [width > 0 ? truncateVisible(line, Math.max(1, width - 1)) : line];
67
+ },
68
+ invalidate() {
69
+ /* stateless between renders; frame lives in closure */
70
+ },
71
+ dispose() {
72
+ if (timer !== undefined) {
73
+ clearInterval(timer);
74
+ timer = undefined;
75
+ }
76
+ },
77
+ };
78
+ };
79
+ }
80
+
81
+ /** Cheap width-safe truncation that ignores ANSI escapes when counting. */
82
+ function truncateVisible(line: string, max: number): string {
83
+ let visible = 0;
84
+ let out = "";
85
+ for (let i = 0; i < line.length; i++) {
86
+ const ch = line[i] ?? "";
87
+ if (ch === "\x1b") {
88
+ const end = line.indexOf("m", i);
89
+ if (end === -1) break;
90
+ out += line.slice(i, end + 1);
91
+ i = end;
92
+ continue;
93
+ }
94
+ if (visible >= max) break;
95
+ out += ch;
96
+ visible++;
97
+ }
98
+ return out;
99
+ }
package/tui-overlay.ts CHANGED
@@ -87,3 +87,35 @@ export class OverlayTheme {
87
87
  return this.fg("borderMuted", `${left}${safeRepeat("─", innerWidth)}${right}`);
88
88
  }
89
89
  }
90
+
91
+ /**
92
+ * Wrap already-rendered body lines in a solid, opaque frame so an overlay
93
+ * never lets the transcript bleed through. Every row is padded to the full
94
+ * inner width and tinted with `bgFn` (defaults to a dark neutral), which is
95
+ * what makes it opaque — pi's overlay compositor only paints the cells a
96
+ * component returns.
97
+ */
98
+ export function frameOverlay(
99
+ body: readonly string[],
100
+ width: number,
101
+ options: {
102
+ title?: string | undefined;
103
+ borderFg?: ((text: string) => string) | undefined;
104
+ bgFn?: ((text: string) => string) | undefined;
105
+ } = {},
106
+ ): string[] {
107
+ const inner = Math.max(1, width - 2);
108
+ const border = options.borderFg ?? ((t: string) => `\x1b[38;2;83;160;215m${t}\x1b[0m`);
109
+ const bg = options.bgFn ?? ((t: string) => `\x1b[48;2;24;26;32m${t}\x1b[49m`);
110
+ const row = (content: string): string => {
111
+ const cut = truncateToWidth(content, inner, "");
112
+ const padded = cut + safeRepeat(" ", Math.max(0, inner - visibleWidth(cut)));
113
+ return `${border("│")}${bg(padded)}${border("│")}`;
114
+ };
115
+ const titleText = options.title ? ` ${options.title} ` : "";
116
+ const topFill = Math.max(0, inner - visibleWidth(titleText));
117
+ const lines = [border(`╭${titleText}${safeRepeat("─", topFill)}╮`)];
118
+ for (const line of body) lines.push(row(line));
119
+ lines.push(border(`╰${safeRepeat("─", inner)}╯`));
120
+ return lines;
121
+ }