@mrclrchtr/supi-skills 4.7.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.
Files changed (38) hide show
  1. package/README.md +46 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +112 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +76 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +40 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +201 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +363 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +10 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/context.ts +8 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +287 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +41 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +57 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/index.ts +34 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +201 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +44 -0
  18. package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
  19. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  20. package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
  21. package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
  22. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
  23. package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
  24. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +71 -0
  25. package/node_modules/@mrclrchtr/supi-core/src/session.ts +8 -0
  26. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +102 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +453 -0
  28. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +36 -0
  29. package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
  30. package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
  31. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  32. package/package.json +64 -0
  33. package/src/extension.ts +9 -0
  34. package/src/skill-catalog.ts +153 -0
  35. package/src/skill-load-settings.ts +305 -0
  36. package/src/skill-model-invocation.ts +134 -0
  37. package/src/skill-settings.ts +400 -0
  38. package/src/skill-shortcut.ts +123 -0
@@ -0,0 +1,102 @@
1
+ // Event-backed settings module registration and collection.
2
+
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { ScopedFieldValue, SettingsAction } from "./settings-schema.ts";
5
+
6
+ export const SUPI_SETTINGS_COLLECT_EVENT = "supi:settings:collect";
7
+
8
+ export type SettingsScope = "project" | "global";
9
+
10
+ /** Scope and PI runtime state supplied for each settings read. */
11
+ export interface SettingsContext {
12
+ scope: SettingsScope;
13
+ cwd: string;
14
+ ctx?: ExtensionContext;
15
+ }
16
+
17
+ /** A resolved, source-aware settings view. */
18
+ export interface SettingsSnapshot {
19
+ rows: ScopedFieldValue[];
20
+ }
21
+
22
+ /** One user action routed to its owning settings module. */
23
+ export interface SettingsActionRequest extends SettingsContext {
24
+ fieldKey: string;
25
+ action: SettingsAction;
26
+ }
27
+
28
+ /** Optional user-facing result from a successful settings action. */
29
+ export interface SettingsApplyResult {
30
+ notice?: {
31
+ message: string;
32
+ level: "info" | "warning" | "error";
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Canonical settings interface consumed by `/supi-settings`.
38
+ *
39
+ * Reads are always asynchronous. Apply resolves only after durable writes and
40
+ * module-owned refresh work complete. Implementations throw on failed writes.
41
+ */
42
+ export interface SettingsModule {
43
+ id: string;
44
+ label: string;
45
+ read(context: SettingsContext): Promise<SettingsSnapshot>;
46
+ apply(request: SettingsActionRequest): Promise<SettingsApplyResult>;
47
+ }
48
+
49
+ export interface SettingsContributionCollector {
50
+ add(module: SettingsModule): void;
51
+ }
52
+
53
+ export interface SettingsCollectionDiagnostic {
54
+ kind: "warning";
55
+ message: string;
56
+ }
57
+
58
+ export interface SettingsCollectionResult {
59
+ modules: SettingsModule[];
60
+ diagnostics: SettingsCollectionDiagnostic[];
61
+ }
62
+
63
+ export function isSettingsContributionCollector(
64
+ value: unknown,
65
+ ): value is SettingsContributionCollector {
66
+ return (
67
+ typeof value === "object" &&
68
+ value !== null &&
69
+ typeof (value as { add?: unknown }).add === "function"
70
+ );
71
+ }
72
+
73
+ /** Create a collector with last-wins duplicate handling and warning diagnostics. */
74
+ export function createSettingsContributionCollector(): SettingsContributionCollector & {
75
+ result(): SettingsCollectionResult;
76
+ } {
77
+ const modules = new Map<string, SettingsModule>();
78
+ const diagnostics: SettingsCollectionDiagnostic[] = [];
79
+
80
+ return {
81
+ add(module: SettingsModule): void {
82
+ if (modules.has(module.id)) {
83
+ diagnostics.push({
84
+ kind: "warning",
85
+ message: `Duplicate SuPi settings contribution "${module.id}"; using the last contribution.`,
86
+ });
87
+ }
88
+ modules.set(module.id, module);
89
+ },
90
+ result(): SettingsCollectionResult {
91
+ return { modules: Array.from(modules.values()), diagnostics: [...diagnostics] };
92
+ },
93
+ };
94
+ }
95
+
96
+ /** Register one settings module during extension factory setup. */
97
+ export function registerSettings(pi: ExtensionAPI, module: SettingsModule): void {
98
+ const dispose = pi.events.on(SUPI_SETTINGS_COLLECT_EVENT, (collector) => {
99
+ if (isSettingsContributionCollector(collector)) collector.add(module);
100
+ });
101
+ pi.on("session_shutdown", () => dispose());
102
+ }
@@ -0,0 +1,453 @@
1
+ // Fixed SuPi-config adapter for the canonical settings module interface.
2
+ //
3
+ // Declarative field descriptors let the adapter own scope inheritance,
4
+ // source-state resolution, value rendering, persistence, and Unset actions.
5
+ //
6
+ // Custom fields remain for nested or unusual config; they report the same
7
+ // source state as declarative flat fields.
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { Component } from "@earendil-works/pi-tui";
11
+ import {
12
+ loadSupiConfigSectionForScope,
13
+ removeSupiConfigKey,
14
+ writeSupiConfig,
15
+ } from "../config/config.ts";
16
+ import type { SettingsApplyResult, SettingsModule, SettingsScope } from "./settings-registry.ts";
17
+
18
+ // ── Types ──────────────────────────────────────────────────────────────────
19
+
20
+ /** Where the current effective value comes from. */
21
+ export type ValueSource = "project" | "global" | "default";
22
+
23
+ /** Structured notification fired to afterPersist. */
24
+ export interface SettingsPersistedChange {
25
+ scope: SettingsScope;
26
+ cwd: string;
27
+ /** The config key that was mutated. */
28
+ fieldKey: string;
29
+ /** What happened: set an explicit value or deleted the scoped key. */
30
+ action: "set" | "delete";
31
+ /** The value written (only present for "set"). */
32
+ storedValue?: unknown;
33
+ /** The effective value after the save (merging defaults ← global ← project). */
34
+ effectiveValue: unknown;
35
+ /** Where the effective value now comes from. */
36
+ effectiveSource: ValueSource;
37
+ }
38
+
39
+ /** Helpers passed to custom-field persist handlers. */
40
+ export interface ConfigHelpers {
41
+ set(key: string, value: unknown): void;
42
+ unset(key: string): void;
43
+ }
44
+
45
+ // ── Field actions ─────────────────────────────────────────────────────────
46
+
47
+ /** A user-initiated action on a settings row. */
48
+ export type SettingsAction = { kind: "set"; value: string } | { kind: "unset" };
49
+
50
+ // ── Field descriptors ─────────────────────────────────────────────────────
51
+
52
+ interface BaseField {
53
+ /** Config key in the section (e.g. "enabled", "severity"). */
54
+ key: string;
55
+ /** Display label. */
56
+ label: string;
57
+ /** Optional description shown when the row is selected. */
58
+ description?: string;
59
+ }
60
+
61
+ /** Boolean on/off toggle. */
62
+ export interface BoolField extends BaseField {
63
+ kind: "boolean";
64
+ }
65
+
66
+ /** Enumeration of string choices (cycle via Space). */
67
+ export interface EnumField extends BaseField {
68
+ kind: "enum";
69
+ values: string[];
70
+ }
71
+
72
+ /** Integer field; with discrete values for cycling or absent for free input. */
73
+ export interface NumberField extends BaseField {
74
+ kind: "number";
75
+ /** Discrete choices for Space cycling; absent = free text input. */
76
+ values?: string[];
77
+ }
78
+
79
+ /** One free-form string. */
80
+ export interface StringField extends BaseField {
81
+ kind: "string";
82
+ }
83
+
84
+ /** Comma-separated string list. */
85
+ export interface StringListField extends BaseField {
86
+ kind: "stringList";
87
+ }
88
+
89
+ /** One non-model choice shown before the scoped models in a model picker. */
90
+ export interface ModelPickerStaticOption {
91
+ /** Persisted value for the choice. */
92
+ value: string;
93
+ /** Human-readable picker label. */
94
+ label: string;
95
+ /** Optional explanation shown alongside the label. */
96
+ description?: string;
97
+ }
98
+
99
+ /** Model picker backed by the scoped model set. */
100
+ export interface ModelPickerField extends BaseField {
101
+ kind: "modelPicker";
102
+ /** Additional host-owned choices shown before scoped models. */
103
+ staticOptions?: ModelPickerStaticOption[];
104
+ /** Whether to include the built-in `disabled` choice. Defaults to true. */
105
+ includeDisabled?: boolean;
106
+ }
107
+
108
+ /**
109
+ * Custom / escape-hatch field for nested config or unusual controls.
110
+ *
111
+ * The field must report its display value and source so the settings UI
112
+ * can render consistent source badges and action menus.
113
+ */
114
+ export interface CustomField extends BaseField {
115
+ kind: "custom";
116
+ /**
117
+ * Return the display value and its source for the given scope.
118
+ * Called on every scope toggle and after persistence.
119
+ */
120
+ resolve: (
121
+ scope: SettingsScope,
122
+ cwd: string,
123
+ ctx?: ExtensionContext,
124
+ ) => {
125
+ /** Human-readable value text, without the source badge. */
126
+ displayValue: string;
127
+ /** Value used to prefill editors/pickers; defaults to displayValue when omitted. */
128
+ editValue?: string;
129
+ source: ValueSource;
130
+ /** When scope is "project" and source is "project", the source after deletion. */
131
+ inheritanceSource?: "global" | "default";
132
+ };
133
+ /**
134
+ * Submenu component factory for editing (Enter).
135
+ * Receives the resolved display value and a done callback; return a pi-tui
136
+ * Component-like object. Undefined means Enter opens the action menu only.
137
+ */
138
+ submenu?: (
139
+ currentValue: string,
140
+ done: (selectedValue?: string) => void,
141
+ scope: SettingsScope,
142
+ cwd: string,
143
+ ctx?: ExtensionContext,
144
+ ) => Component;
145
+ /**
146
+ * Persist handler called on set or unset actions.
147
+ * Required for custom fields so they can write their nested config.
148
+ */
149
+ persist: (
150
+ scope: SettingsScope,
151
+ cwd: string,
152
+ action: SettingsAction,
153
+ helpers: ConfigHelpers,
154
+ ) => void | Promise<void>;
155
+ }
156
+
157
+ /** Union of all supported field kinds. */
158
+ export type SettingsField =
159
+ | BoolField
160
+ | EnumField
161
+ | NumberField
162
+ | StringField
163
+ | StringListField
164
+ | ModelPickerField
165
+ | CustomField;
166
+
167
+ // ── Contribution options ──────────────────────────────────────────────────
168
+
169
+ /** Options for the fixed SuPi-config settings adapter. */
170
+ export interface ConfigSettingsOptions {
171
+ /** Stable contribution identifier — e.g. "lsp", "claude-md". */
172
+ id: string;
173
+ /** Human-readable label shown in the UI. */
174
+ label: string;
175
+ /** SuPi config section name — e.g. "lsp", "claude-md". */
176
+ section: string;
177
+ /** Package-default config values (indexable by field key). */
178
+ defaults: Record<string, unknown>;
179
+ /** Declarative field descriptors. */
180
+ fields: SettingsField[];
181
+ /** Optional live runtime sync after successful persistence. */
182
+ afterPersist?: (change: SettingsPersistedChange) => void;
183
+ /** Optional home directory for config resolution (testing). */
184
+ homeDir?: string;
185
+ }
186
+
187
+ // ── Source-aware row interface ───────────────────────────────────────────
188
+
189
+ /** Resolved value for one field in one scope. */
190
+ export interface ScopedFieldValue {
191
+ /** The field descriptor. */
192
+ field: SettingsField;
193
+ /** Display value string shown in the row (with source badge). */
194
+ displayValue: string;
195
+ /** Value used to prefill editors/pickers, without the source badge. */
196
+ editValue: string;
197
+ /** Where the value comes from. */
198
+ source: ValueSource;
199
+ /**
200
+ * When scope is "project" and source is "project", the source that would
201
+ * apply after deleting the project override ("global" or "default").
202
+ * Undefined otherwise.
203
+ */
204
+ inheritanceSource?: "global" | "default";
205
+ }
206
+
207
+ // ── Source resolution ─────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Resolve the effective value and source for a flat key.
211
+ *
212
+ * For project scope: checks project → global → defaults.
213
+ * For global scope: checks global → defaults.
214
+ */
215
+ // biome-ignore lint/complexity/useMaxParams: resolveValue needs all scope/source parameters for honest multi-tier resolution
216
+ export function resolveValue<T extends Record<string, unknown>>(
217
+ key: string,
218
+ defaults: T,
219
+ projectRaw: Record<string, unknown> | null,
220
+ globalRaw: Record<string, unknown> | null,
221
+ scope: SettingsScope,
222
+ ): { value: unknown; source: ValueSource } {
223
+ // Check direct scope first
224
+ const directRaw = scope === "project" ? projectRaw : globalRaw;
225
+ if (directRaw && key in directRaw) {
226
+ return { value: directRaw[key], source: scope };
227
+ }
228
+
229
+ // For project scope, check global
230
+ if (scope === "project" && globalRaw && key in globalRaw) {
231
+ return { value: globalRaw[key], source: "global" };
232
+ }
233
+
234
+ // Fall back to defaults
235
+ return { value: defaults[key], source: "default" };
236
+ }
237
+
238
+ // ── Value formatting ──────────────────────────────────────────────────────
239
+
240
+ /** Format a value for display. */
241
+ export function formatValue(value: unknown, field: SettingsField): string {
242
+ switch (field.kind) {
243
+ case "boolean":
244
+ return value ? "on" : "off";
245
+ case "number":
246
+ return String(value ?? "");
247
+ case "string":
248
+ return typeof value === "string" && value ? value : "none";
249
+ case "stringList": {
250
+ const arr = Array.isArray(value) ? value : [];
251
+ return arr.length > 0 ? arr.map(String).join(", ") : "none";
252
+ }
253
+ default:
254
+ return String(value ?? "");
255
+ }
256
+ }
257
+
258
+ /** Build source-badged display text. */
259
+ export function sourceBadge(displayValue: string, source: ValueSource): string {
260
+ switch (source) {
261
+ case "project":
262
+ return `${displayValue} (project)`;
263
+ case "global":
264
+ return `${displayValue} (global)`;
265
+ case "default":
266
+ return `${displayValue} (default)`;
267
+ }
268
+ }
269
+
270
+ /** Format the value used to prefill editors and compare concrete choices. */
271
+ export function formatEditValue(value: unknown, field: SettingsField): string {
272
+ if (field.kind === "string") return typeof value === "string" ? value : "";
273
+ if (field.kind === "stringList") {
274
+ const arr = Array.isArray(value) ? value : [];
275
+ return arr.map(String).join(", ");
276
+ }
277
+ return formatValue(value, field);
278
+ }
279
+
280
+ // ── Persistence helpers ───────────────────────────────────────────────────
281
+
282
+ function createConfigHelpers(
283
+ section: string,
284
+ scope: SettingsScope,
285
+ cwd: string,
286
+ homeDir?: string,
287
+ ): ConfigHelpers {
288
+ return {
289
+ set: (key: string, val: unknown) => {
290
+ writeSupiConfig({ section, scope, cwd }, { [key]: val }, { homeDir });
291
+ },
292
+ unset: (key: string) => {
293
+ removeSupiConfigKey({ section, scope, cwd }, key, { homeDir });
294
+ },
295
+ };
296
+ }
297
+
298
+ // ── Fixed config adapter ─────────────────────────────────────────────────
299
+
300
+ interface NotifyAfterPersistInput {
301
+ options: ConfigSettingsOptions;
302
+ field: SettingsField;
303
+ scope: SettingsScope;
304
+ cwd: string;
305
+ action: SettingsAction;
306
+ storedValue: unknown;
307
+ ctx?: ExtensionContext;
308
+ }
309
+
310
+ function notifyAfterPersist(input: NotifyAfterPersistInput): void {
311
+ const { options, field, scope, cwd, action, storedValue, ctx } = input;
312
+ if (!options.afterPersist) return;
313
+
314
+ let effectiveValue: unknown;
315
+ let effectiveSource: ValueSource;
316
+
317
+ if (field.kind === "custom") {
318
+ const resolved = field.resolve(scope, cwd, ctx);
319
+ effectiveValue = resolved.editValue ?? resolved.displayValue;
320
+ effectiveSource = resolved.source;
321
+ } else {
322
+ const projectRaw = loadSupiConfigSectionForScope(options.section, cwd, {
323
+ scope: "project",
324
+ homeDir: options.homeDir,
325
+ });
326
+ const globalRaw = loadSupiConfigSectionForScope(options.section, cwd, {
327
+ scope: "global",
328
+ homeDir: options.homeDir,
329
+ });
330
+ const resolved = resolveValue(field.key, options.defaults, projectRaw, globalRaw, scope);
331
+ effectiveValue = resolved.value;
332
+ effectiveSource = resolved.source;
333
+ }
334
+
335
+ const change: SettingsPersistedChange = {
336
+ scope,
337
+ cwd,
338
+ fieldKey: field.key,
339
+ action: action.kind === "set" ? "set" : "delete",
340
+ effectiveValue,
341
+ effectiveSource,
342
+ };
343
+ if (action.kind === "set") change.storedValue = storedValue;
344
+ options.afterPersist(change);
345
+ }
346
+
347
+ function resolveConfigRows(
348
+ options: ConfigSettingsOptions,
349
+ scope: SettingsScope,
350
+ cwd: string,
351
+ ctx?: ExtensionContext,
352
+ ): ScopedFieldValue[] {
353
+ const defaults = options.defaults as Record<string, unknown>;
354
+ const projectRaw = loadSupiConfigSectionForScope(options.section, cwd, {
355
+ scope: "project",
356
+ homeDir: options.homeDir,
357
+ });
358
+ const globalRaw = loadSupiConfigSectionForScope(options.section, cwd, {
359
+ scope: "global",
360
+ homeDir: options.homeDir,
361
+ });
362
+
363
+ return options.fields.map((field) => {
364
+ if (field.kind === "custom") {
365
+ const resolved = field.resolve(scope, cwd, ctx);
366
+ return {
367
+ field,
368
+ displayValue: resolved.displayValue
369
+ ? sourceBadge(resolved.displayValue, resolved.source)
370
+ : "",
371
+ editValue: resolved.editValue ?? resolved.displayValue,
372
+ source: resolved.source,
373
+ inheritanceSource: resolved.inheritanceSource,
374
+ };
375
+ }
376
+
377
+ const { value, source } = resolveValue(field.key, defaults, projectRaw, globalRaw, scope);
378
+ const displayValue = formatValue(value, field);
379
+ const inheritanceSource =
380
+ scope === "project" && source === "project"
381
+ ? globalRaw && field.key in globalRaw
382
+ ? "global"
383
+ : "default"
384
+ : undefined;
385
+
386
+ return {
387
+ field,
388
+ displayValue: sourceBadge(displayValue, source),
389
+ editValue: formatEditValue(value, field),
390
+ source,
391
+ inheritanceSource,
392
+ };
393
+ });
394
+ }
395
+
396
+ async function applyConfigAction(
397
+ options: ConfigSettingsOptions,
398
+ request: Parameters<SettingsModule["apply"]>[0],
399
+ ): Promise<SettingsApplyResult> {
400
+ const { scope, cwd, fieldKey, action, ctx } = request;
401
+ const field = options.fields.find((candidate) => candidate.key === fieldKey);
402
+ if (!field) return {};
403
+
404
+ const helpers = createConfigHelpers(options.section, scope, cwd, options.homeDir);
405
+ let storedValue: unknown;
406
+ if (field.kind === "custom") {
407
+ await field.persist(scope, cwd, action, helpers);
408
+ storedValue = action.kind === "set" ? action.value : undefined;
409
+ } else if (action.kind === "set") {
410
+ storedValue = parseTypedValue(action.value, field);
411
+ helpers.set(field.key, storedValue);
412
+ } else {
413
+ helpers.unset(field.key);
414
+ }
415
+ notifyAfterPersist({ options, field, scope, cwd, action, storedValue, ctx });
416
+ return {};
417
+ }
418
+
419
+ /** Adapt one fixed SuPi config section to the canonical settings interface. */
420
+ export function defineConfigSettings(options: ConfigSettingsOptions): SettingsModule {
421
+ return {
422
+ id: options.id,
423
+ label: options.label,
424
+ read: ({ scope, cwd, ctx }) =>
425
+ Promise.resolve({ rows: resolveConfigRows(options, scope, cwd, ctx) }),
426
+ apply: (request) => applyConfigAction(options, request),
427
+ };
428
+ }
429
+
430
+ // ── Typed value parsing ───────────────────────────────────────────────────
431
+
432
+ /** Parse a user-supplied string value into the typed config value for the field. */
433
+ export function parseTypedValue(value: string, field: SettingsField): unknown {
434
+ switch (field.kind) {
435
+ case "boolean":
436
+ return value === "on";
437
+ case "number": {
438
+ if (!/^[1-9]\d*$/.test(value.trim())) {
439
+ throw new Error(
440
+ `Invalid value for "${field.label}": "${value}". Enter a positive integer.`,
441
+ );
442
+ }
443
+ return Number.parseInt(value, 10);
444
+ }
445
+ case "stringList":
446
+ return value
447
+ .split(",")
448
+ .map((s) => s.trim())
449
+ .filter((s) => s.length > 0);
450
+ default:
451
+ return value;
452
+ }
453
+ }
@@ -0,0 +1,36 @@
1
+ // supi-core settings domain — settings modules and fixed-config adapters.
2
+ export type {
3
+ SettingsActionRequest,
4
+ SettingsApplyResult,
5
+ SettingsCollectionDiagnostic,
6
+ SettingsCollectionResult,
7
+ SettingsContext,
8
+ SettingsContributionCollector,
9
+ SettingsModule,
10
+ SettingsScope,
11
+ SettingsSnapshot,
12
+ } from "./settings/settings-registry.ts";
13
+ export {
14
+ createSettingsContributionCollector,
15
+ isSettingsContributionCollector,
16
+ registerSettings,
17
+ SUPI_SETTINGS_COLLECT_EVENT,
18
+ } from "./settings/settings-registry.ts";
19
+ export type {
20
+ BoolField,
21
+ ConfigHelpers,
22
+ ConfigSettingsOptions,
23
+ CustomField,
24
+ EnumField,
25
+ ModelPickerField,
26
+ ModelPickerStaticOption,
27
+ NumberField,
28
+ ScopedFieldValue,
29
+ SettingsAction,
30
+ SettingsField,
31
+ SettingsPersistedChange,
32
+ StringField,
33
+ StringListField,
34
+ ValueSource,
35
+ } from "./settings/settings-schema.ts";
36
+ export { defineConfigSettings } from "./settings/settings-schema.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
+ }