@mrclrchtr/supi-prompt-suggestions 1.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.
Files changed (48) hide show
  1. package/README.md +62 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +113 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +79 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/abort-utils.ts +31 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/api.ts +44 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +188 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +207 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/config.ts +12 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +119 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/context.ts +16 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +57 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/index.ts +40 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +211 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
  18. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  19. package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
  20. package/node_modules/@mrclrchtr/supi-core/src/progress-widget.ts +189 -0
  21. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  22. package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
  23. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
  24. package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
  25. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  26. package/node_modules/@mrclrchtr/supi-core/src/session.ts +4 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +15 -0
  28. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +48 -0
  29. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +331 -0
  30. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +6 -0
  31. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +9 -0
  32. package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
  33. package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
  34. package/node_modules/@mrclrchtr/supi-core/src/substrate-types.ts +11 -0
  35. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  36. package/node_modules/@mrclrchtr/supi-core/src/tool-framework.ts +182 -0
  37. package/node_modules/@mrclrchtr/supi-core/src/types.ts +2 -0
  38. package/package.json +39 -0
  39. package/src/api.ts +8 -0
  40. package/src/config/config.ts +16 -0
  41. package/src/config/settings.ts +44 -0
  42. package/src/editor/editor.ts +138 -0
  43. package/src/extension.ts +26 -0
  44. package/src/generation/client.ts +122 -0
  45. package/src/generation/generator.ts +225 -0
  46. package/src/generation/model-resolution.ts +72 -0
  47. package/src/generation/normalize.ts +50 -0
  48. package/src/session.ts +168 -0
@@ -0,0 +1,331 @@
1
+ // Generic settings overlay for SuPi extensions.
2
+ //
3
+ // Uses pi-tui's SettingsList with scope toggle (Tab), extension grouping,
4
+ // and search. Each extension declares its settings via registerSettings().
5
+
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ Container,
10
+ Input,
11
+ Key,
12
+ matchesKey,
13
+ type SelectItem,
14
+ SelectList,
15
+ type SelectListTheme,
16
+ type SettingItem,
17
+ SettingsList,
18
+ Text,
19
+ } from "@earendil-works/pi-tui";
20
+ import { getSelectableModels } from "../model-selection.ts";
21
+ import {
22
+ getRegisteredSettings,
23
+ type SettingsScope,
24
+ type SettingsSection,
25
+ } from "./settings-registry.ts";
26
+
27
+ // ── Input submenu component ──────────────────────────────────
28
+
29
+ /**
30
+ * Creates a pi-tui Input-backed submenu component with enter-to-confirm
31
+ * and escape-to-cancel handling.
32
+ *
33
+ * @param currentValue - Initial value for the text input.
34
+ * @param label - Label text displayed above the input.
35
+ * @param done - Callback invoked with the confirmed value, or undefined on cancel.
36
+ */
37
+ export function createInputSubmenu(
38
+ currentValue: string,
39
+ label: string,
40
+ done: (selectedValue?: string) => void,
41
+ ): {
42
+ render: (width: number) => string[];
43
+ invalidate: () => void;
44
+ handleInput: (data: string) => boolean;
45
+ } {
46
+ const input = new Input();
47
+ input.setValue(currentValue);
48
+
49
+ return {
50
+ render: (_width: number) => {
51
+ const lines = [` ${label}`];
52
+ lines.push(...input.render(_width));
53
+ lines.push(" enter confirm • esc cancel");
54
+ return lines;
55
+ },
56
+ invalidate: () => {
57
+ input.invalidate();
58
+ },
59
+ handleInput: (data: string) => {
60
+ if (matchesKey(data, Key.escape)) {
61
+ done();
62
+ return true;
63
+ }
64
+ if (matchesKey(data, Key.enter)) {
65
+ done(input.getValue());
66
+ return true;
67
+ }
68
+ input.handleInput(data);
69
+ return true;
70
+ },
71
+ };
72
+ }
73
+
74
+ // ── Types ────────────────────────────────────────────────────
75
+
76
+ interface OverlayState {
77
+ scope: SettingsScope;
78
+ cwd: string;
79
+ }
80
+
81
+ // ── Pure helpers ─────────────────────────────────────────────
82
+
83
+ function getScopeLabel(scope: SettingsScope): string {
84
+ return scope === "project" ? "Project" : "Global";
85
+ }
86
+
87
+ function buildFlatItems(
88
+ sections: SettingsSection[],
89
+ scope: SettingsScope,
90
+ cwd: string,
91
+ ctx?: ExtensionContext,
92
+ ): SettingItem[] {
93
+ const items: SettingItem[] = [];
94
+ for (const section of sections) {
95
+ const sectionItems = section.loadValues(scope, cwd, ctx);
96
+ for (const item of sectionItems) {
97
+ items.push({
98
+ ...item,
99
+ id: `${section.id}.${item.id}`,
100
+ label: `${section.label}: ${item.label}`,
101
+ });
102
+ }
103
+ }
104
+ return items;
105
+ }
106
+
107
+ function findSectionAndId(
108
+ sections: SettingsSection[],
109
+ flatId: string,
110
+ ): { section: SettingsSection; itemId: string } | null {
111
+ const dotIndex = flatId.indexOf(".");
112
+ if (dotIndex === -1) return null;
113
+ const sectionId = flatId.slice(0, dotIndex);
114
+ const itemId = flatId.slice(dotIndex + 1);
115
+ const section = sections.find((s) => s.id === sectionId);
116
+ if (!section) return null;
117
+ return { section, itemId };
118
+ }
119
+
120
+ // ── Component ────────────────────────────────────────────────
121
+
122
+ interface SettingsOverlayDeps {
123
+ ctx: ExtensionContext;
124
+ state: OverlayState;
125
+ container: Container;
126
+ settingsList: SettingsList | null;
127
+ tui: Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[0];
128
+ theme: Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[1];
129
+ done: () => void;
130
+ }
131
+
132
+ function createSettingsList(deps: SettingsOverlayDeps): SettingsList {
133
+ const sections = getRegisteredSettings();
134
+ const items = buildFlatItems(sections, deps.state.scope, deps.state.cwd, deps.ctx);
135
+ const onChange = (flatId: string, newValue: string) => {
136
+ const found = findSectionAndId(sections, flatId);
137
+ if (found) {
138
+ found.section.persistChange(
139
+ deps.state.scope,
140
+ deps.state.cwd,
141
+ found.itemId,
142
+ newValue,
143
+ deps.ctx,
144
+ );
145
+ }
146
+ // Re-read all values to reflect persisted changes, but keep the list
147
+ // instance (and its selectedIndex) intact.
148
+ const updatedItems = buildFlatItems(sections, deps.state.scope, deps.state.cwd, deps.ctx);
149
+ for (const updated of updatedItems) {
150
+ const existing = items.find((i) => i.id === updated.id);
151
+ if (existing && existing.currentValue !== updated.currentValue) {
152
+ settingsList.updateValue(updated.id, updated.currentValue);
153
+ }
154
+ }
155
+ deps.tui.requestRender();
156
+ };
157
+ const settingsList = new SettingsList(
158
+ items,
159
+ Math.min(items.length + 4, 20),
160
+ getSettingsListTheme(),
161
+ onChange,
162
+ () => deps.done(),
163
+ { enableSearch: true },
164
+ );
165
+ return settingsList;
166
+ }
167
+
168
+ function rebuildSettingsList(deps: SettingsOverlayDeps): SettingsList {
169
+ const settingsList = createSettingsList(deps);
170
+ deps.settingsList = settingsList;
171
+
172
+ deps.container.clear();
173
+ deps.container.addChild(createHeaderComponent(deps));
174
+ deps.container.addChild(settingsList);
175
+
176
+ return settingsList;
177
+ }
178
+
179
+ function createHeaderComponent(deps: SettingsOverlayDeps): Text {
180
+ const { theme, state } = deps;
181
+ const scopeLabel = getScopeLabel(state.scope);
182
+ const otherScope = state.scope === "project" ? "Global" : "Project";
183
+ const headerText = new Text(
184
+ `${theme.fg("accent", theme.bold("SuPi Settings"))} ${theme.fg("text", `Scope: ${scopeLabel}`)} ${theme.fg("dim", `(tab → ${otherScope})`)}`,
185
+ 0,
186
+ 0,
187
+ );
188
+ return headerText;
189
+ }
190
+
191
+ function handleScopeToggle(deps: SettingsOverlayDeps): void {
192
+ deps.state.scope = deps.state.scope === "project" ? "global" : "project";
193
+ rebuildSettingsList(deps);
194
+ deps.tui.requestRender();
195
+ }
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
+
289
+ // ── Entry point ──────────────────────────────────────────────
290
+
291
+ export function openSettingsOverlay(ctx: ExtensionContext): void {
292
+ const sections = getRegisteredSettings();
293
+ if (sections.length === 0) {
294
+ ctx.ui.notify("No settings registered by SuPi extensions", "info");
295
+ return;
296
+ }
297
+
298
+ void ctx.ui.custom<void>((tui, theme, _kb, done) => {
299
+ const state: OverlayState = { scope: "project", cwd: ctx.cwd };
300
+ const container = new Container();
301
+
302
+ const deps: SettingsOverlayDeps = {
303
+ ctx,
304
+ state,
305
+ container,
306
+ settingsList: null,
307
+ tui,
308
+ theme,
309
+ done,
310
+ };
311
+
312
+ rebuildSettingsList(deps);
313
+
314
+ const component = {
315
+ render: (width: number) => container.render(width),
316
+ invalidate: () => container.invalidate(),
317
+ handleInput: (data: string) => {
318
+ if (matchesKey(data, Key.tab)) {
319
+ handleScopeToggle(deps);
320
+ return true;
321
+ }
322
+ // Delegate input to the settings list (always set after rebuildSettingsList)
323
+ deps.settingsList?.handleInput?.(data);
324
+ deps.tui.requestRender();
325
+ return true;
326
+ },
327
+ };
328
+
329
+ return component;
330
+ });
331
+ }
@@ -0,0 +1,6 @@
1
+ // supi-core settings-ui domain — settings TUI components (imports pi-tui at runtime, heavy).
2
+ export {
3
+ createInputSubmenu,
4
+ createModelPickerSubmenu,
5
+ openSettingsOverlay,
6
+ } from "./settings/settings-ui.ts";
@@ -0,0 +1,9 @@
1
+ // supi-core settings domain — settings registry (lightweight, type-only pi-tui import).
2
+
3
+ export { registerSettingsCommand } from "./settings/settings-command.ts";
4
+ export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
5
+ export {
6
+ clearRegisteredSettings,
7
+ getRegisteredSettings,
8
+ registerSettings,
9
+ } from "./settings/settings-registry.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
+ }
@@ -0,0 +1,11 @@
1
+ /** 0-based position used by LSP and code-intelligence internally. */
2
+ export interface CodePosition {
3
+ line: number;
4
+ character: number;
5
+ }
6
+
7
+ /** Normalized location — flat replacement for LSP's nested Location/range shape. */
8
+ export interface CodeLocation {
9
+ uri: string;
10
+ range: { start: CodePosition; end: CodePosition };
11
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared terminal title formatting and signaling utilities.
3
+ *
4
+ * Centralized place for pi title convention (π prefix), completion (✓)
5
+ * and waiting (●) indicators, and the audible terminal bell.
6
+ */
7
+ import path from "node:path";
8
+
9
+ /** Unicode checkmark shown when the agent finishes a turn. */
10
+ export const DONE_SYMBOL = "\u2713";
11
+ /** Unicode dot shown when waiting for user input. */
12
+ export const WAITING_SYMBOL = "\u25CF";
13
+
14
+ /** Minimal UI surface needed for title operations. */
15
+ export interface TitleTarget {
16
+ ui: {
17
+ setTitle?(title: string): void;
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Format pi's canonical terminal title from session name and cwd.
23
+ * Falls back gracefully when either is missing.
24
+ *
25
+ * @example
26
+ * formatTitle("my-session", "/home/projects/foo") // "π - my-session - foo"
27
+ * formatTitle(undefined, "/home/projects/foo") // "π - foo"
28
+ * formatTitle("my-session") // "π - my-session"
29
+ * formatTitle() // "π"
30
+ */
31
+ export function formatTitle(sessionName?: string, cwd?: string): string {
32
+ const base = cwd ? path.basename(cwd) : undefined;
33
+ if (sessionName && base) return `π - ${sessionName} - ${base}`;
34
+ if (sessionName) return `π - ${sessionName}`;
35
+ if (base) return `π - ${base}`;
36
+ return "π";
37
+ }
38
+
39
+ /** Sound the audible terminal bell (ASCII BEL). */
40
+ export function signalBell(): void {
41
+ process.stdout.write("\x07");
42
+ }
43
+
44
+ /**
45
+ * Set the terminal title to indicate the agent is waiting for user input.
46
+ * Prefixes with ● and sounds the terminal bell.
47
+ */
48
+ export function signalWaiting(ctx: TitleTarget, title: string): void {
49
+ ctx.ui.setTitle?.(`${WAITING_SYMBOL} ${title}`);
50
+ signalBell();
51
+ }
52
+
53
+ /**
54
+ * Set the terminal title to indicate the agent turn has completed.
55
+ * Prefixes with ✓ and sounds the terminal bell.
56
+ */
57
+ export function signalDone(ctx: TitleTarget, title: string): void {
58
+ ctx.ui.setTitle?.(`${DONE_SYMBOL} ${title}`);
59
+ signalBell();
60
+ }
@@ -0,0 +1,182 @@
1
+ // Shared tool framework for SuPi extensions.
2
+ //
3
+ // Provides a standard ToolSpec→PromptSurface→registerTool pipeline so
4
+ // individual packages do not duplicate spec interfaces, guidance derivation,
5
+ // registration loops, or common TypeBox parameter schemas.
6
+
7
+ import type {
8
+ AgentToolResult,
9
+ AgentToolUpdateCallback,
10
+ ExtensionAPI,
11
+ ExtensionCommandContext,
12
+ ExtensionContext,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { type TSchema, Type } from "typebox";
15
+ import { ProgressWidget, type WidgetProgress } from "./progress-widget.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** Minimum contract for a SuPi tool definition. */
22
+ export interface SuiPiToolSpec {
23
+ name: string;
24
+ label: string;
25
+ description: string;
26
+ promptSnippet: string;
27
+ promptGuidelines: string[];
28
+ parameters: TSchema;
29
+ }
30
+
31
+ /** Derived prompt surface — what pi flattens into the system prompt. */
32
+ export interface SuiPiToolPromptSurface {
33
+ description: string;
34
+ promptSnippet: string;
35
+ promptGuidelines: string[];
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Guidance derivation
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Static derivation: copies spec fields into a prompt surface.
44
+ *
45
+ * Packages that need dynamic guidance (e.g. server-coverage injection) should
46
+ * build their own surfaces, optionally starting from the output of this helper.
47
+ */
48
+ export function derivePromptSurface(spec: SuiPiToolSpec): SuiPiToolPromptSurface {
49
+ return {
50
+ description: spec.description,
51
+ promptSnippet: spec.promptSnippet,
52
+ promptGuidelines: [...spec.promptGuidelines],
53
+ };
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Registration
58
+ // ---------------------------------------------------------------------------
59
+
60
+ // biome-ignore lint/complexity/useMaxParams: matches pi ToolDefinition.execute signature
61
+ export type ToolExecuteFn = (
62
+ toolCallId: string,
63
+ params: unknown,
64
+ signal: AbortSignal | undefined,
65
+ onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
66
+ ctx: ExtensionContext,
67
+ ) => Promise<AgentToolResult<Record<string, unknown>>>;
68
+
69
+ /**
70
+ * Register a set of tools from specs + pre-derived surfaces.
71
+ *
72
+ * `createExecute` receives the spec and returns a pi-compatible execute
73
+ * function. This keeps execute-logic package-local while the framework owns
74
+ * the declarative surface and registration boilerplate.
75
+ */
76
+ export function registerSuiPiTools(
77
+ pi: ExtensionAPI,
78
+ specs: readonly SuiPiToolSpec[],
79
+ surfaces: Record<string, SuiPiToolPromptSurface>,
80
+ createExecute: (spec: SuiPiToolSpec) => ToolExecuteFn,
81
+ ): void {
82
+ for (const spec of specs) {
83
+ const surface = surfaces[spec.name];
84
+ pi.registerTool({
85
+ name: spec.name,
86
+ label: spec.label,
87
+ description: surface?.description ?? spec.description,
88
+ promptSnippet: surface?.promptSnippet ?? spec.promptSnippet,
89
+ promptGuidelines: surface?.promptGuidelines ?? [...spec.promptGuidelines],
90
+ parameters: spec.parameters,
91
+ execute: createExecute(spec),
92
+ });
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Shared parameter builders
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /** File path (relative or absolute). */
101
+ export const FileParam = Type.String({ description: "File path (relative or absolute)" });
102
+
103
+ /** 1-based line number. */
104
+ export const LineParam = Type.Number({ description: "1-based line number", minimum: 1 });
105
+
106
+ /** 1-based character column (UTF-16). */
107
+ export const CharacterParam = Type.Number({
108
+ description: "1-based column number (UTF-16)",
109
+ minimum: 1,
110
+ });
111
+
112
+ /** Symbol name for discovery-based resolution. */
113
+ export const SymbolParam = Type.String({
114
+ description: "Symbol name for discovery-based resolution",
115
+ });
116
+
117
+ /** Maximum results to return. */
118
+ export const MaxResultsParam = Type.Number({ description: "Maximum results to return" });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Progress widget runner
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Run an async operation with a live TUI progress widget.
126
+ *
127
+ * Automatically manages:
128
+ * - The {@link ProgressWidget} lifecycle
129
+ * - `supi:working:start` / `supi:working:end` events for tab-spinner integration
130
+ * - Abort signal handling
131
+ * - Error catching (returns `null` on failure)
132
+ *
133
+ * Falls back to running without a widget when `ctx.hasUI` is false.
134
+ *
135
+ * @param pi - The extension API (for event emission).
136
+ * @param ctx - The command context (for UI access and hasUI check).
137
+ * @param title - The progress widget title.
138
+ * @param runner - Async function that receives (signal, onProgress).
139
+ * @returns The runner result, or `null` on cancel/error.
140
+ */
141
+ export async function runWithProgressWidget<T>(
142
+ pi: ExtensionAPI,
143
+ ctx: ExtensionCommandContext,
144
+ title: string,
145
+ runner: (signal: AbortSignal, onProgress: (p: WidgetProgress) => void) => Promise<T>,
146
+ ): Promise<T | null> {
147
+ if (!ctx.hasUI) {
148
+ // No UI — run without progress widget but still emit working events
149
+ pi.events.emit("supi:working:start", { source: "supi-core" });
150
+ try {
151
+ return await runner(new AbortController().signal, () => {});
152
+ } catch {
153
+ return null;
154
+ } finally {
155
+ pi.events.emit("supi:working:end", { source: "supi-core" });
156
+ }
157
+ }
158
+
159
+ return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
160
+ const widget = new ProgressWidget(tui, theme, title);
161
+ let finished = false;
162
+
163
+ const finish = (result: T | null) => {
164
+ if (finished) return;
165
+ finished = true;
166
+ pi.events.emit("supi:working:end", { source: "supi-core" });
167
+ widget.dispose();
168
+ done(result);
169
+ };
170
+
171
+ widget.onAbort = () => {
172
+ // Widget handles abort signal; runner resolves with cancel/error.
173
+ };
174
+
175
+ pi.events.emit("supi:working:start", { source: "supi-core" });
176
+ runner(widget.signal, (progress) => widget.updateProgress(progress))
177
+ .then((result) => finish(result))
178
+ .catch(() => finish(null));
179
+
180
+ return widget;
181
+ });
182
+ }
@@ -0,0 +1,2 @@
1
+ // supi-core types domain — shared substrate types (zero dependencies, pure types).
2
+ export type { CodeLocation, CodePosition } from "./substrate-types.ts";