@mrclrchtr/supi-insights 1.16.0 → 2.0.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.16.0",
3
+ "version": "2.0.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,6 +23,7 @@
23
23
  "settings",
24
24
  "configuration"
25
25
  ],
26
+ "type": "module",
26
27
  "files": [
27
28
  "src/**/*.ts",
28
29
  "!__tests__"
@@ -49,12 +50,14 @@
49
50
  },
50
51
  "main": "src/api.ts",
51
52
  "exports": {
53
+ "./abort-utils": "./src/abort-utils.ts",
52
54
  "./api": "./src/api.ts",
53
55
  "./config": "./src/config.ts",
54
56
  "./context": "./src/context.ts",
55
57
  "./debug": "./src/debug-registry.ts",
56
58
  "./footer-registry": "./src/footer-registry.ts",
57
59
  "./llm": "./src/llm.ts",
60
+ "./model-selection": "./src/model-selection.ts",
58
61
  "./package.json": "./package.json",
59
62
  "./path": "./src/path.ts",
60
63
  "./report": "./src/report.ts",
@@ -63,6 +66,8 @@
63
66
  "./session": "./src/session.ts",
64
67
  "./settings": "./src/settings.ts",
65
68
  "./settings-ui": "./src/settings-ui.ts",
69
+ "./spinner-frames": "./src/spinner-frames.ts",
70
+ "./status-spinner": "./src/status-spinner.ts",
66
71
  "./terminal": "./src/terminal.ts",
67
72
  "./tool-framework": "./src/tool-framework.ts",
68
73
  "./types": "./src/types.ts"
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Abort controller/signal utility helpers.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /**
8
+ * Combine a caller-provided abort controller with a generation timeout signal.
9
+ *
10
+ * Returns the combined signal and a cleanup function that removes both
11
+ * event listeners. Callers must invoke cleanup in a `finally` block
12
+ * to avoid listener leaks.
13
+ */
14
+ export function combineAbortSignals(
15
+ abort: AbortController,
16
+ timeoutMs: number,
17
+ ): { signal: AbortSignal; cleanup: () => void } {
18
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
19
+ const combinedController = new AbortController();
20
+ const onAbort = () => combinedController.abort();
21
+ abort.signal.addEventListener("abort", onAbort, { once: true });
22
+ timeoutSignal.addEventListener("abort", onAbort, { once: true });
23
+
24
+ return {
25
+ signal: combinedController.signal,
26
+ cleanup: () => {
27
+ abort.signal.removeEventListener("abort", onAbort);
28
+ timeoutSignal.removeEventListener("abort", onAbort);
29
+ },
30
+ };
31
+ }
@@ -6,6 +6,8 @@
6
6
  // For lighter imports, use one of the domain subpaths directly
7
7
  // (e.g. @mrclrchtr/supi-core/config, @mrclrchtr/supi-core/context).
8
8
 
9
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
10
+ export * from "./abort-utils.ts";
9
11
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
10
12
  export * from "./config.ts";
11
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
@@ -17,6 +19,8 @@ export * from "./footer-registry.ts";
17
19
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
20
  export * from "./llm.ts";
19
21
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
22
+ export * from "./model-selection.ts";
23
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
20
24
  export * from "./path.ts";
21
25
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
22
26
  export * from "./project.ts";
@@ -29,6 +33,10 @@ export * from "./settings.ts";
29
33
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
30
34
  export * from "./settings-ui.ts";
31
35
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
36
+ export * from "./spinner-frames.ts";
37
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
38
+ export * from "./status-spinner.ts";
39
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
32
40
  export * from "./terminal.ts";
33
41
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
34
42
  export * from "./tool-framework.ts";
@@ -5,6 +5,7 @@
5
5
  // to enable auto-generated persistChange. When all items have a configType,
6
6
  // the persistChange callback can be omitted.
7
7
 
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
9
  import type { SettingItem } from "@earendil-works/pi-tui";
9
10
  import type { SettingsScope } from "../settings/settings-registry.ts";
10
11
  import { registerSettings } from "../settings/settings-registry.ts";
@@ -60,7 +61,12 @@ export interface ConfigSettingsOptions<T> {
60
61
  * persistChange handling. When ALL items declare a configType,
61
62
  * the `persistChange` callback can be omitted.
62
63
  */
63
- buildItems: (settings: T, scope: SettingsScope, cwd: string) => ConfigSettingItem[];
64
+ buildItems: (
65
+ settings: T,
66
+ scope: SettingsScope,
67
+ cwd: string,
68
+ ctx?: ExtensionContext,
69
+ ) => ConfigSettingItem[];
64
70
  /**
65
71
  * Handle a settings change with scoped persistence helpers.
66
72
  *
@@ -141,12 +147,12 @@ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): vo
141
147
  registerSettings({
142
148
  id: options.id,
143
149
  label: options.label,
144
- loadValues: (scope, cwd) => {
150
+ loadValues: (scope, cwd, ctx) => {
145
151
  const settings = loadSupiConfigForScope(options.section, cwd, options.defaults, {
146
152
  scope,
147
153
  homeDir: options.homeDir,
148
154
  });
149
- const items = options.buildItems(settings, scope, cwd);
155
+ const items = options.buildItems(settings, scope, cwd, ctx);
150
156
  cachedItems = items;
151
157
  return items;
152
158
  },
@@ -173,7 +179,7 @@ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): vo
173
179
  }
174
180
 
175
181
  // Auto-generate when all items are declarative
176
- const items = cachedItems ?? options.buildItems(options.defaults, scope, cwd);
182
+ const items = cachedItems ?? options.buildItems(options.defaults, scope, cwd, undefined);
177
183
  if (areAllItemsDeclarative(items)) {
178
184
  autoPersistChange(settingId, value, helpers, items);
179
185
  }
@@ -1,6 +1,7 @@
1
1
  // supi-core config domain — config loading and config-settings helpers.
2
2
  export type { SupiConfigLocation, SupiConfigOptions } from "./config/config.ts";
3
3
  export {
4
+ loadSectionConfig,
4
5
  loadSupiConfig,
5
6
  loadSupiConfigForScope,
6
7
  readJsonFile,
@@ -8,7 +8,7 @@
8
8
  import { createRegistry } from "./registry-utils.ts";
9
9
 
10
10
  /** Where the contribution should appear in the footer. */
11
- export type FooterPlacement = "stats" | "status";
11
+ export type FooterPlacement = "stats" | "stats-end" | "status";
12
12
 
13
13
  /** A single footer contribution registered by an extension. */
14
14
  export interface FooterContribution {
@@ -6,6 +6,8 @@
6
6
  // For lighter imports, use one of the domain subpaths directly
7
7
  // (e.g. @mrclrchtr/supi-core/config, @mrclrchtr/supi-core/context).
8
8
 
9
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
10
+ export * from "./abort-utils.ts";
9
11
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
10
12
  export * from "./config.ts";
11
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
@@ -15,6 +17,8 @@ export * from "./debug-registry.ts";
15
17
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
18
  export * from "./footer-registry.ts";
17
19
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
20
+ export * from "./model-selection.ts";
21
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
22
  export * from "./path.ts";
19
23
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
20
24
  export * from "./project.ts";
@@ -27,6 +31,8 @@ export * from "./settings.ts";
27
31
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
28
32
  export * from "./settings-ui.ts";
29
33
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
34
+ export * from "./status-spinner.ts";
35
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
30
36
  export * from "./terminal.ts";
31
37
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
32
38
  export * from "./tool-framework.ts";
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared model-selection helpers for SuPi extensions.
3
+ *
4
+ * Provides scoped-model listing using PI's `enabledModels` configuration,
5
+ * matching the same semantics as the `@mrclrchtr/supi-review` model picker.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { Model } from "@earendil-works/pi-ai";
11
+ import { type ExtensionContext, SettingsManager } from "@earendil-works/pi-coding-agent";
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────────
14
+
15
+ /** A selectable model entry with display metadata. */
16
+ export interface ModelSelection {
17
+ /** Canonical `provider/model-id` string. */
18
+ canonicalId: string;
19
+ /** Provider name, e.g. `"anthropic"`. */
20
+ provider: string;
21
+ /** Model id, e.g. `"claude-sonnet-4-5"`. */
22
+ id: string;
23
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
24
+ model: Model<any>;
25
+ /** Human-readable label (model name or canonicalId). */
26
+ label: string;
27
+ /** Optional description (canonicalId when different from label). */
28
+ description?: string;
29
+ /** Whether this model is the current session model. */
30
+ isCurrent: boolean;
31
+ }
32
+
33
+ // ── Helpers ────────────────────────────────────────────────────────────────
34
+
35
+ /** Build the canonical `provider/model-id` string. */
36
+ export function toCanonicalModelId(
37
+ model: Pick<NonNullable<ExtensionContext["model"]>, "provider" | "id">,
38
+ ): string {
39
+ return `${model.provider}/${model.id}`;
40
+ }
41
+
42
+ /**
43
+ * List selectable models from PI's scoped model configuration.
44
+ *
45
+ * Only models that match the configured `enabledModels` patterns are offered.
46
+ * The current session model is included only when it is inside that scoped set.
47
+ * Returns an empty array when no scoped model patterns are configured.
48
+ */
49
+ export function getSelectableModels(
50
+ ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
51
+ enabledModelPatterns = SettingsManager.create(ctx.cwd).getEnabledModels(),
52
+ ): ModelSelection[] {
53
+ if (!enabledModelPatterns || enabledModelPatterns.length === 0) {
54
+ return [];
55
+ }
56
+
57
+ const byCanonicalId = new Map<string, ModelSelection>();
58
+ const availableModels = filterByEnabledModels(
59
+ enabledModelPatterns,
60
+ ctx.modelRegistry.getAvailable(),
61
+ );
62
+
63
+ const addModel = (
64
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
65
+ model: Model<any>,
66
+ isCurrent: boolean,
67
+ ) => {
68
+ const canonicalId = toCanonicalModelId(model);
69
+ const existing = byCanonicalId.get(canonicalId);
70
+ if (existing) {
71
+ if (isCurrent) existing.isCurrent = true;
72
+ return;
73
+ }
74
+
75
+ byCanonicalId.set(canonicalId, {
76
+ canonicalId,
77
+ provider: model.provider,
78
+ id: model.id,
79
+ model,
80
+ label: model.name ?? canonicalId,
81
+ description: canonicalId,
82
+ isCurrent,
83
+ });
84
+ };
85
+
86
+ if (ctx.model && matchModelPatterns(ctx.model, enabledModelPatterns)) {
87
+ addModel(ctx.model, true);
88
+ }
89
+
90
+ for (const model of availableModels) {
91
+ addModel(
92
+ model,
93
+ ctx.model ? toCanonicalModelId(model) === toCanonicalModelId(ctx.model) : false,
94
+ );
95
+ }
96
+
97
+ return Array.from(byCanonicalId.values()).sort((a, b) => {
98
+ if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
99
+ return a.canonicalId.localeCompare(b.canonicalId);
100
+ });
101
+ }
102
+
103
+ // ── Private helpers ────────────────────────────────────────────────────────
104
+
105
+ function filterByEnabledModels<T extends { provider: string; id: string }>(
106
+ patterns: string[],
107
+ models: T[],
108
+ ): T[] {
109
+ return models.filter((model) => matchModelPatterns(model, patterns));
110
+ }
111
+
112
+ function matchModelPatterns(model: { provider: string; id: string }, patterns: string[]): boolean {
113
+ return patterns.some((pattern) => matchModelPattern(model, pattern));
114
+ }
115
+
116
+ function matchModelPattern(model: { provider: string; id: string }, pattern: string): boolean {
117
+ const canonicalId = `${model.provider}/${model.id}`;
118
+ if (pattern.includes("/")) {
119
+ return simpleGlobMatch(canonicalId, pattern);
120
+ }
121
+ return simpleGlobMatch(model.id, pattern) || simpleGlobMatch(canonicalId, pattern);
122
+ }
123
+
124
+ function simpleGlobMatch(text: string, pattern: string): boolean {
125
+ if (!pattern.includes("*") && !pattern.includes("?")) {
126
+ return text.toLowerCase() === pattern.toLowerCase();
127
+ }
128
+
129
+ const regex = pattern
130
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
131
+ .replace(/\*/g, ".*")
132
+ .replace(/\?/g, ".");
133
+ return new RegExp(`^${regex}$`, "i").test(text);
134
+ }
@@ -3,6 +3,7 @@
3
3
  // Extensions declare their settings via `registerSettings()` during their
4
4
  // factory function. The generic settings UI reads them via `getRegisteredSettings()`.
5
5
 
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
7
  import type { SettingItem } from "@earendil-works/pi-tui";
7
8
  import { createRegistry } from "../registry-utils.ts";
8
9
 
@@ -14,9 +15,15 @@ export interface SettingsSection {
14
15
  /** Human-readable label shown in the UI */
15
16
  label: string;
16
17
  /** Load current SettingItem[] for the given scope */
17
- loadValues: (scope: SettingsScope, cwd: string) => SettingItem[];
18
+ loadValues: (scope: SettingsScope, cwd: string, ctx?: ExtensionContext) => SettingItem[];
18
19
  /** Persist a change back to config */
19
- persistChange: (scope: SettingsScope, cwd: string, settingId: string, value: string) => void;
20
+ persistChange: (
21
+ scope: SettingsScope,
22
+ cwd: string,
23
+ settingId: string,
24
+ value: string,
25
+ ctx?: ExtensionContext,
26
+ ) => void;
20
27
  }
21
28
 
22
29
  const registry = createRegistry<SettingsSection>("settings-registry");
@@ -10,10 +10,14 @@ import {
10
10
  Input,
11
11
  Key,
12
12
  matchesKey,
13
+ type SelectItem,
14
+ SelectList,
15
+ type SelectListTheme,
13
16
  type SettingItem,
14
17
  SettingsList,
15
18
  Text,
16
19
  } from "@earendil-works/pi-tui";
20
+ import { getSelectableModels } from "../model-selection.ts";
17
21
  import {
18
22
  getRegisteredSettings,
19
23
  type SettingsScope,
@@ -84,10 +88,11 @@ function buildFlatItems(
84
88
  sections: SettingsSection[],
85
89
  scope: SettingsScope,
86
90
  cwd: string,
91
+ ctx?: ExtensionContext,
87
92
  ): SettingItem[] {
88
93
  const items: SettingItem[] = [];
89
94
  for (const section of sections) {
90
- const sectionItems = section.loadValues(scope, cwd);
95
+ const sectionItems = section.loadValues(scope, cwd, ctx);
91
96
  for (const item of sectionItems) {
92
97
  items.push({
93
98
  ...item,
@@ -115,6 +120,7 @@ function findSectionAndId(
115
120
  // ── Component ────────────────────────────────────────────────
116
121
 
117
122
  interface SettingsOverlayDeps {
123
+ ctx: ExtensionContext;
118
124
  state: OverlayState;
119
125
  container: Container;
120
126
  settingsList: SettingsList | null;
@@ -125,15 +131,21 @@ interface SettingsOverlayDeps {
125
131
 
126
132
  function createSettingsList(deps: SettingsOverlayDeps): SettingsList {
127
133
  const sections = getRegisteredSettings();
128
- const items = buildFlatItems(sections, deps.state.scope, deps.state.cwd);
134
+ const items = buildFlatItems(sections, deps.state.scope, deps.state.cwd, deps.ctx);
129
135
  const onChange = (flatId: string, newValue: string) => {
130
136
  const found = findSectionAndId(sections, flatId);
131
137
  if (found) {
132
- found.section.persistChange(deps.state.scope, deps.state.cwd, found.itemId, newValue);
138
+ found.section.persistChange(
139
+ deps.state.scope,
140
+ deps.state.cwd,
141
+ found.itemId,
142
+ newValue,
143
+ deps.ctx,
144
+ );
133
145
  }
134
146
  // Re-read all values to reflect persisted changes, but keep the list
135
147
  // instance (and its selectedIndex) intact.
136
- const updatedItems = buildFlatItems(sections, deps.state.scope, deps.state.cwd);
148
+ const updatedItems = buildFlatItems(sections, deps.state.scope, deps.state.cwd, deps.ctx);
137
149
  for (const updated of updatedItems) {
138
150
  const existing = items.find((i) => i.id === updated.id);
139
151
  if (existing && existing.currentValue !== updated.currentValue) {
@@ -182,6 +194,98 @@ function handleScopeToggle(deps: SettingsOverlayDeps): void {
182
194
  deps.tui.requestRender();
183
195
  }
184
196
 
197
+ /** Minimal SelectList theme — uses identity so the parent SettingsList provides styling context. */
198
+ const PASSTHROUGH_THEME: SelectListTheme = {
199
+ selectedPrefix: (text) => `› ${text}`,
200
+ selectedText: (text) => text,
201
+ description: (text) => text,
202
+ scrollInfo: (text) => text,
203
+ noMatch: (text) => text,
204
+ };
205
+
206
+ /**
207
+ * Create a model picker submenu for settings.
208
+ *
209
+ * Shows a scrollable list of selectable models from the scoped model set,
210
+ * with the current session model annotated `[current]`. The first entry is
211
+ * always `"disabled"`.
212
+ *
213
+ * @param currentValue - Currently configured canonical model id or `"disabled"`.
214
+ * @param done - Callback invoked with the selected value, or undefined on cancel.
215
+ * @param ctx - Extension context for model listing. When undefined, only
216
+ * `"disabled"` is offered.
217
+ */
218
+ export function createModelPickerSubmenu(
219
+ currentValue: string,
220
+ done: (selectedValue?: string) => void,
221
+ ctx?: ExtensionContext,
222
+ ): {
223
+ render: (width: number) => string[];
224
+ invalidate: () => void;
225
+ handleInput: (data: string) => boolean;
226
+ } {
227
+ const items = buildModelItems(ctx);
228
+
229
+ const initialIndex =
230
+ currentValue === "disabled"
231
+ ? 0
232
+ : Math.max(
233
+ 0,
234
+ items.findIndex((item) => item.value === currentValue),
235
+ );
236
+
237
+ const container = new Container();
238
+ container.addChild(new Text(" Select suggestion model", 1, 0));
239
+ container.addChild(new Text("", 1, 0));
240
+
241
+ const selectList = new SelectList(items, Math.min(items.length, 15), PASSTHROUGH_THEME);
242
+
243
+ if (initialIndex >= 0) {
244
+ selectList.setSelectedIndex(initialIndex);
245
+ }
246
+
247
+ selectList.onSelect = (item) => done(item.value);
248
+ selectList.onCancel = () => done();
249
+
250
+ container.addChild(selectList);
251
+ container.addChild(new Text(" ↑↓ navigate • enter select • esc cancel", 1, 0));
252
+
253
+ return {
254
+ render: (width: number) => container.render(width),
255
+ invalidate: () => container.invalidate(),
256
+ handleInput: (data: string) => {
257
+ selectList.handleInput(data);
258
+ return true;
259
+ },
260
+ };
261
+ }
262
+
263
+ /** Build selectable model items with "disabled" first. */
264
+ function buildModelItems(ctx?: ExtensionContext): SelectItem[] {
265
+ const items: SelectItem[] = [
266
+ {
267
+ value: "disabled",
268
+ label: "disabled",
269
+ description: "No prompt suggestions",
270
+ },
271
+ ];
272
+
273
+ if (!ctx) return items;
274
+
275
+ const models = getSelectableModels(ctx);
276
+
277
+ for (const model of models) {
278
+ const suffix = model.isCurrent ? " [current]" : "";
279
+ items.push({
280
+ value: model.canonicalId,
281
+ label: `${model.canonicalId}${suffix}`,
282
+ description: model.label !== model.canonicalId ? model.label : undefined,
283
+ });
284
+ }
285
+
286
+ return items;
287
+ }
288
+
185
289
  // ── Entry point ──────────────────────────────────────────────
186
290
 
187
291
  export function openSettingsOverlay(ctx: ExtensionContext): void {
@@ -196,6 +300,7 @@ export function openSettingsOverlay(ctx: ExtensionContext): void {
196
300
  const container = new Container();
197
301
 
198
302
  const deps: SettingsOverlayDeps = {
303
+ ctx,
199
304
  state,
200
305
  container,
201
306
  settingsList: null,
@@ -1,2 +1,6 @@
1
1
  // supi-core settings-ui domain — settings TUI components (imports pi-tui at runtime, heavy).
2
- export { createInputSubmenu, openSettingsOverlay } from "./settings/settings-ui.ts";
2
+ export {
3
+ createInputSubmenu,
4
+ createModelPickerSubmenu,
5
+ openSettingsOverlay,
6
+ } from "./settings/settings-ui.ts";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared UI constants for SuPi extensions.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /** Braille spinner frames used across SuPi extensions for animated loaders. */
8
+ export const BRAILLE_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+
10
+ /** Tick interval (ms) shared by all braille-spinner consumers. */
11
+ export const SPINNER_INTERVAL_MS = 80;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Lightweight status-bar spinner for SuPi extensions.
3
+ *
4
+ * Manages a setInterval-based animated spinner that writes to
5
+ * `ctx.ui.setStatus`. Each tick advances the frame and re-renders
6
+ * with the current message.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { BRAILLE_SPINNER_FRAMES, SPINNER_INTERVAL_MS } from "./spinner-frames.ts";
13
+
14
+ /**
15
+ * Manages an animated braille spinner on the status bar.
16
+ *
17
+ * Usage:
18
+ * ```ts
19
+ * const spinner = new StatusSpinner(ctx, "my-package");
20
+ * spinner.start("generating…");
21
+ * // later
22
+ * spinner.stop();
23
+ * ```
24
+ */
25
+ export class StatusSpinner {
26
+ private interval: ReturnType<typeof setInterval> | null = null;
27
+ private frame = 0;
28
+ private currentMessage = "";
29
+
30
+ constructor(
31
+ private ctx: ExtensionContext,
32
+ private source: string,
33
+ private frames: readonly string[] = BRAILLE_SPINNER_FRAMES,
34
+ ) {}
35
+
36
+ /** Start the spinner with the given message. Overwrites any active spinner. */
37
+ start(message: string): void {
38
+ this.stop();
39
+ this.currentMessage = message;
40
+ this.render();
41
+
42
+ this.interval = setInterval(() => {
43
+ this.frame++;
44
+ this.render();
45
+ }, SPINNER_INTERVAL_MS);
46
+ }
47
+
48
+ /** Update the display message without resetting the spinner. */
49
+ update(message: string): void {
50
+ this.currentMessage = message;
51
+ }
52
+
53
+ /** Stop the spinner and clear the status. */
54
+ stop(): void {
55
+ if (this.interval !== null) {
56
+ clearInterval(this.interval);
57
+ this.interval = null;
58
+ }
59
+ this.ctx.ui.setStatus(this.source, "");
60
+ }
61
+
62
+ // ── Private ──────────────────────────────────────────────────────────
63
+
64
+ private render(): void {
65
+ const icon = this.frames[this.frame % this.frames.length];
66
+ this.ctx.ui.setStatus(this.source, `${icon} ${this.currentMessage}`);
67
+ }
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-insights",
3
- "version": "1.16.0",
3
+ "version": "2.0.0",
4
4
  "description": "SuPi Insights extension — generate usage reports analyzing your PI sessions",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,6 +25,7 @@
25
25
  "usage-report",
26
26
  "html-report"
27
27
  ],
28
+ "type": "module",
28
29
  "files": [
29
30
  "src/**/*.ts",
30
31
  "!__tests__/**",
@@ -34,7 +35,7 @@
34
35
  ],
35
36
  "dependencies": {
36
37
  "diff": "^9.0.0",
37
- "@mrclrchtr/supi-core": "1.16.0"
38
+ "@mrclrchtr/supi-core": "2.0.0"
38
39
  },
39
40
  "bundledDependencies": [
40
41
  "@mrclrchtr/supi-core"
package/src/html.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import type { AggregatedData, InsightResults } from "./types.ts";
6
7
  import {
7
8
  emptyHtml,
@@ -15,8 +16,10 @@ import {
15
16
  SATISFACTION_ORDER,
16
17
  } from "./utils.ts";
17
18
 
18
- const REPORT_CSS = readFileSync(join(__dirname, "report.css"), "utf-8");
19
- const REPORT_JS_TEMPLATE = readFileSync(join(__dirname, "report.js"), "utf-8");
19
+ const INSIGHTS_DIR = join(fileURLToPath(import.meta.url), "..");
20
+
21
+ const REPORT_CSS = readFileSync(join(INSIGHTS_DIR, "report.css"), "utf-8");
22
+ const REPORT_JS_TEMPLATE = readFileSync(join(INSIGHTS_DIR, "report.js"), "utf-8");
20
23
 
21
24
  function generateReportJs(hourCountsJson: string): string {
22
25
  return REPORT_JS_TEMPLATE.replace("__HOUR_COUNTS_JSON__", hourCountsJson);