@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
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-prompt-suggestions",
3
+ "version": "1.16.1",
4
+ "description": "Advisory ghost-text prompt suggestions for the pi coding agent",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "src/**/*.ts",
9
+ "!__tests__"
10
+ ],
11
+ "exports": {
12
+ "./extension": {
13
+ "types": "./src/extension.ts",
14
+ "import": "./src/extension.ts"
15
+ },
16
+ "./api": {
17
+ "types": "./src/api.ts",
18
+ "import": "./src/api.ts"
19
+ }
20
+ },
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/extension.ts"
24
+ ]
25
+ },
26
+ "dependencies": {
27
+ "@earendil-works/pi-ai": "*",
28
+ "@earendil-works/pi-coding-agent": "*",
29
+ "@earendil-works/pi-tui": "*",
30
+ "@mrclrchtr/supi-core": "workspace:*"
31
+ },
32
+ "bundledDependencies": [
33
+ "@mrclrchtr/supi-core"
34
+ ],
35
+ "devDependencies": {
36
+ "@types/node": "25.9.4",
37
+ "vitest": "4.1.9"
38
+ }
39
+ }
package/src/api.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @mrclrchtr/supi-prompt-suggestions
3
+ *
4
+ * Advisory ghost-text prompt suggestions for the pi coding agent.
5
+ * @module
6
+ */
7
+
8
+ export type { PromptSuggestionsConfig } from "./config/config.ts";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Config types and defaults for supi-prompt-suggestions.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ export interface PromptSuggestionsConfig extends Record<string, unknown> {
8
+ /** The suggestion model in `provider/model-id` format, or `"disabled"`. */
9
+ model: string;
10
+ }
11
+
12
+ export const DEFAULTS: PromptSuggestionsConfig = {
13
+ model: "disabled",
14
+ };
15
+
16
+ export const CONFIG_SECTION = "promptSuggestions";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Settings registration for supi-prompt-suggestions.
3
+ *
4
+ * Registers a `promptSuggestions` section with a single `model` picker
5
+ * that shows `disabled` first, then the scoped model set.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { registerConfigSettings } from "@mrclrchtr/supi-core/config";
11
+ import { createModelPickerSubmenu } from "@mrclrchtr/supi-core/settings-ui";
12
+ import { CONFIG_SECTION, DEFAULTS } from "./config.ts";
13
+
14
+ const MODEL_ITEM_ID = "model";
15
+
16
+ /** Register the prompt-suggestions settings section. */
17
+ export function registerPromptSuggestionsSettings(): void {
18
+ registerConfigSettings({
19
+ id: "promptSuggestions",
20
+ label: "Prompt suggestions",
21
+ section: CONFIG_SECTION,
22
+ defaults: DEFAULTS,
23
+ buildItems: (settings, _scope, _cwd, ctx) => [
24
+ {
25
+ id: MODEL_ITEM_ID,
26
+ label: "Suggestion model",
27
+ description: "Model used for ghost-text suggestions. Select 'disabled' to turn off.",
28
+ currentValue: settings.model,
29
+ submenu: (currentValue: string, done: (selectedValue?: string) => void) =>
30
+ createModelPickerSubmenu(currentValue, done, ctx),
31
+ },
32
+ ],
33
+ // biome-ignore lint/complexity/useMaxParams: callback shape mandated by registerConfigSettings API
34
+ persistChange: (_scope, _cwd, settingId, value, helpers) => {
35
+ if (settingId === MODEL_ITEM_ID) {
36
+ if (value === "disabled") {
37
+ helpers.unset(MODEL_ITEM_ID);
38
+ } else {
39
+ helpers.set(MODEL_ITEM_ID, value);
40
+ }
41
+ }
42
+ },
43
+ });
44
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Ghost-text editor component.
3
+ *
4
+ * Extends PI's CustomEditor and renders prompt suggestions as dim ghost text
5
+ * after the cursor in an empty editor.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ CURSOR_MARKER,
13
+ type EditorOptions,
14
+ type EditorTheme,
15
+ matchesKey,
16
+ type TUI,
17
+ truncateToWidth,
18
+ visibleWidth,
19
+ } from "@earendil-works/pi-tui";
20
+
21
+ // ── Types ──────────────────────────────────────────────────────────────────
22
+
23
+ export interface GhostTextCallbacks {
24
+ onAccept: (suggestion: string) => void;
25
+ onDismiss: () => void;
26
+ }
27
+
28
+ export interface GhostTextEditorOptions extends EditorOptions {
29
+ callbacks: GhostTextCallbacks;
30
+ }
31
+
32
+ // ── Component ──────────────────────────────────────────────────────────────
33
+
34
+ export class GhostTextEditor extends CustomEditor {
35
+ private suggestion: string | null = null;
36
+ private callbacks: GhostTextCallbacks;
37
+
38
+ constructor(
39
+ tui: TUI,
40
+ theme: EditorTheme,
41
+ keybindings: KeybindingsManager,
42
+ options: GhostTextEditorOptions,
43
+ ) {
44
+ const { callbacks, ...editorOptions } = options;
45
+ super(tui, theme, keybindings, editorOptions);
46
+ this.callbacks = callbacks;
47
+ }
48
+
49
+ // ── Ghost text API ──────────────────────────────────────────
50
+
51
+ setSuggestion(suggestion: string | null): void {
52
+ this.suggestion = suggestion;
53
+ this.tui.requestRender();
54
+ }
55
+
56
+ clearGhost(): void {
57
+ this.suggestion = null;
58
+ this.tui.requestRender();
59
+ }
60
+
61
+ // ── Input handling ──────────────────────────────────────────
62
+
63
+ override handleInput(data: string): void {
64
+ if (this.suggestion) {
65
+ // Use PI's matchesKey (not raw escape sequences) — it handles
66
+ // CSI (\x1b[C), SS3 (\x1bOC), and Kitty keyboard protocol correctly.
67
+ if (matchesKey(data, "right")) {
68
+ this.insertTextAtCursor(this.suggestion);
69
+ this.callbacks.onAccept(this.suggestion);
70
+ this.clearGhost();
71
+ return;
72
+ }
73
+ this.clearGhost();
74
+ this.callbacks.onDismiss();
75
+ }
76
+ super.handleInput(data);
77
+ }
78
+
79
+ // ── Rendering ───────────────────────────────────────────────
80
+
81
+ override render(width: number): string[] {
82
+ const lines = super.render(width);
83
+ if (!this.suggestion || !this.focused) return lines;
84
+
85
+ const markerIndex = findCursorMarkerLine(lines);
86
+ if (markerIndex === -1) return lines;
87
+
88
+ const markerLine = lines[markerIndex];
89
+ if (!markerLine) return lines;
90
+
91
+ // Cap ghost text to at most half the terminal width.
92
+ const cursorPos = markerLine.indexOf(CURSOR_MARKER);
93
+ const available = width - visibleWidth(markerLine.slice(0, cursorPos));
94
+ const maxGhost = Math.min(available - 1, Math.floor(width / 2));
95
+ if (maxGhost <= 0) return lines;
96
+ const plainSuggestion = truncateToWidth(this.suggestion, maxGhost, "");
97
+ if (!plainSuggestion) return lines;
98
+ const ghost = `\x1b[2m${plainSuggestion}\x1b[0m`;
99
+ const gw = visibleWidth(plainSuggestion);
100
+
101
+ // Shrink the line first so ghost addition stays within terminal width.
102
+ const shrunk = truncateToWidth(markerLine, width - gw, "");
103
+ if (!shrunk.includes(CURSOR_MARKER)) return lines;
104
+
105
+ const insertPos = ghostInsertPosition(shrunk);
106
+
107
+ lines[markerIndex] = shrunk.slice(0, insertPos) + ghost + shrunk.slice(insertPos);
108
+ return lines;
109
+ }
110
+ }
111
+
112
+ // ── Helpers ────────────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Find the character position where ghost text should be inserted on
116
+ * a line containing {@link CURSOR_MARKER}.
117
+ *
118
+ * The ghost text is inserted after the visual cursor block emitted by
119
+ * `CustomEditor`. Since the cursor is rendered as an inverse-video span
120
+ * bounded by `\x1b[7m` … `\x1b[0m`, we search for the reset escape after
121
+ * the cursor marker to skip the entire block.
122
+ *
123
+ * @remarks
124
+ * This depends on `CustomEditor`'s internal ANSI encoding of the cursor.
125
+ * If the upstream cursor rendering changes, this helper must be updated.
126
+ */
127
+ function ghostInsertPosition(line: string): number {
128
+ const cursorIdx = line.indexOf(CURSOR_MARKER);
129
+ if (cursorIdx === -1) return 0;
130
+
131
+ const afterMarker = line.slice(cursorIdx + CURSOR_MARKER.length);
132
+ const inverseEnd = afterMarker.indexOf("\x1b[0m");
133
+ return cursorIdx + CURSOR_MARKER.length + (inverseEnd >= 0 ? inverseEnd + 4 : 0);
134
+ }
135
+
136
+ function findCursorMarkerLine(lines: string[]): number {
137
+ return lines.findIndex((l) => l.includes(CURSOR_MARKER));
138
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * supi-prompt-suggestions PI extension entrypoint.
3
+ *
4
+ * Registers settings and wires session event handlers to a
5
+ * {@link SessionLifecycle} instance that owns the ghost editor,
6
+ * status spinner, and suggestion generator lifecycle.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { registerPromptSuggestionsSettings } from "./config/settings.ts";
13
+ import { SuggestionGenerator } from "./generation/generator.ts";
14
+ import { SessionLifecycle } from "./session.ts";
15
+
16
+ export default function (pi: ExtensionAPI): void {
17
+ registerPromptSuggestionsSettings();
18
+
19
+ const generator = new SuggestionGenerator();
20
+ const session = new SessionLifecycle(generator);
21
+
22
+ pi.on("session_start", (_event, ctx) => session.onStart(ctx));
23
+ pi.on("agent_end", (event, ctx) => session.onAgentEnd(event, ctx));
24
+ pi.on("agent_start", () => session.onAgentStart());
25
+ pi.on("session_shutdown", () => session.onShutdown());
26
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Low-level suggestion model client.
3
+ *
4
+ * Pure functions for calling the suggestion model — no module-level state,
5
+ * no orchestration, no debug logging.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { completeSimple } from "@earendil-works/pi-ai";
11
+
12
+ // ── Constants ──────────────────────────────────────────────────────────
13
+
14
+ export const GENERATION_TIMEOUT_MS = 20_000;
15
+
16
+ /**
17
+ * System prompt instructs the model to suggest a follow-up user message
18
+ * (question, answer, or directive) or return the NO_SUGGESTION sentinel
19
+ * when none is useful.
20
+ *
21
+ * The model receives no PI, SuPi, project, or conversation context —
22
+ * only the last assistant message text is sent.
23
+ */
24
+ const SYSTEM_PROMPT =
25
+ "You suggest follow-up messages for a coding assistant conversation. " +
26
+ "Given the assistant's last message, write a single line the user would type next. " +
27
+ "It must be a direct question, answer, or directive — nothing else. " +
28
+ "Do NOT include greetings, thank-yous, politeness, or conversational filler. " +
29
+ "If there is no useful follow-up, respond with exactly the word NO_SUGGESTION and nothing else. " +
30
+ "Keep suggestions under 240 characters.";
31
+
32
+ // ── Prompt building ────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Format the tail text as a completion prompt.
36
+ *
37
+ * Instructions live in {@link SYSTEM_PROMPT} — the user message
38
+ * only provides the assistant message content to suggest from.
39
+ */
40
+ export function buildPrompt(tail: string): string {
41
+ return `<assistant_message>\n${tail}\n</assistant_message>\n\nSuggestion:`;
42
+ }
43
+
44
+ // ── Types ──────────────────────────────────────────────────────────────
45
+
46
+ export interface SuggestionClientResult {
47
+ ok: true;
48
+ text: string;
49
+ }
50
+
51
+ export interface SuggestionClientError {
52
+ ok: false;
53
+ message: string;
54
+ }
55
+
56
+ export type SuggestionClientOutput = SuggestionClientResult | SuggestionClientError;
57
+
58
+ export interface SuggestionClientOptions {
59
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
60
+ model: any;
61
+ auth: { apiKey: string; headers?: Record<string, string> };
62
+ tail: string;
63
+ signal: AbortSignal;
64
+ }
65
+
66
+ // ── API call ───────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Call the suggestion model with a simple completion prompt.
70
+ *
71
+ * Pure function — all side effects (HTTP, abort) are scoped to the call.
72
+ * Returns a structured result; does not log or interact with the extension
73
+ * context.
74
+ */
75
+ export async function callSuggestionModel(
76
+ opts: SuggestionClientOptions,
77
+ ): Promise<SuggestionClientOutput> {
78
+ const { model, auth, tail, signal } = opts;
79
+
80
+ const response = await completeSimple(
81
+ model,
82
+ {
83
+ systemPrompt: SYSTEM_PROMPT,
84
+ messages: [
85
+ {
86
+ role: "user",
87
+ content: [{ type: "text", text: buildPrompt(tail) }],
88
+ timestamp: Date.now(),
89
+ },
90
+ ],
91
+ },
92
+ {
93
+ apiKey: auth.apiKey,
94
+ headers: auth.headers,
95
+ signal,
96
+ },
97
+ );
98
+
99
+ if (!response?.content) {
100
+ const message = `Suggestion model returned no content (stopReason: ${response?.stopReason ?? "undefined"})`;
101
+ return { ok: false, message };
102
+ }
103
+
104
+ if (response.stopReason === "error") {
105
+ const message = `Suggestion model failed: ${response.errorMessage ?? response.stopReason}`;
106
+ return { ok: false, message };
107
+ }
108
+
109
+ // Prefer text blocks; fall back to thinking blocks for reasoning-only models.
110
+ const textContent = response.content
111
+ .filter((c: { type: string }) => c.type === "text")
112
+ .map((c: { type: string; text?: string }) => c.text)
113
+ .join("");
114
+
115
+ if (!textContent) {
116
+ const contentTypes = response.content.map((c: { type: string }) => c.type);
117
+ const message = `Suggestion model returned no text (content types: [${contentTypes.join(", ")}])`;
118
+ return { ok: false, message };
119
+ }
120
+
121
+ return { ok: true, text: textContent };
122
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Suggestion generator — async orchestration of prompt suggestion
3
+ * generation with concurrency control.
4
+ *
5
+ * Manages the lifecycle: config check, model resolution, model call,
6
+ * normalization, and callback dispatch. Uses an internal abort controller
7
+ * and generation ID to cancel or discard stale results.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { combineAbortSignals } from "@mrclrchtr/supi-core/abort-utils";
14
+ import { loadSectionConfig } from "@mrclrchtr/supi-core/config";
15
+ import { recordDebugEvent } from "@mrclrchtr/supi-core/debug";
16
+ import { CONFIG_SECTION, DEFAULTS } from "../config/config.ts";
17
+ import {
18
+ callSuggestionModel,
19
+ GENERATION_TIMEOUT_MS,
20
+ type SuggestionClientOutput,
21
+ } from "./client.ts";
22
+ import { resolveSuggestionAuth } from "./model-resolution.ts";
23
+ import { normalizeSuggestion } from "./normalize.ts";
24
+
25
+ // ── Types ──────────────────────────────────────────────────────────────────
26
+
27
+ export type GenerationStatus =
28
+ | { kind: "idle" }
29
+ | { kind: "generating" }
30
+ | { kind: "ready"; suggestion: string }
31
+ | { kind: "error"; message: string };
32
+
33
+ export interface SuggestionCallbacks {
34
+ /** Called when generation status changes. */
35
+ onStatus: (status: GenerationStatus) => void;
36
+ }
37
+
38
+ interface RunOptions {
39
+ ctx: ExtensionContext;
40
+ modelId: string;
41
+ tail: string;
42
+ id: number;
43
+ abort: AbortController;
44
+ callbacks: SuggestionCallbacks;
45
+ }
46
+
47
+ // ── Generator ──────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Encapsulates the async suggestion generation lifecycle.
51
+ *
52
+ * Manages concurrency via an internal abort controller and generation ID.
53
+ * Instances are independent — tests can create fresh instances without
54
+ * shared mutable state.
55
+ */
56
+ export class SuggestionGenerator {
57
+ private currentAbort: AbortController | null = null;
58
+ private generationId = 0;
59
+
60
+ /**
61
+ * Start suggestion generation from the last assistant text.
62
+ *
63
+ * Fire-and-forget — the caller does not await the returned promise.
64
+ * Cancels any in-flight generation.
65
+ */
66
+ start(ctx: ExtensionContext, lastAssistantText: string, callbacks: SuggestionCallbacks): void {
67
+ // Cancel any in-flight generation
68
+ this.dismiss();
69
+
70
+ const config = loadSectionConfig(CONFIG_SECTION, ctx.cwd, DEFAULTS);
71
+ if (config.model === "disabled") {
72
+ recordDebugEvent({
73
+ source: "prompt-suggestions",
74
+ level: "debug",
75
+ category: "generation.skipped",
76
+ message: "Prompt suggestion generation skipped: model is disabled",
77
+ cwd: ctx.cwd,
78
+ });
79
+ callbacks.onStatus({ kind: "idle" });
80
+ return;
81
+ }
82
+
83
+ const text = lastAssistantText.trim();
84
+ if (!text) {
85
+ recordDebugEvent({
86
+ source: "prompt-suggestions",
87
+ level: "debug",
88
+ category: "generation.skipped",
89
+ message: "Prompt suggestion generation skipped: no text in last assistant message",
90
+ cwd: ctx.cwd,
91
+ });
92
+ callbacks.onStatus({ kind: "idle" });
93
+ return;
94
+ }
95
+
96
+ const tail = text.slice(-8_000);
97
+ const id = ++this.generationId;
98
+ const abort = new AbortController();
99
+ this.currentAbort = abort;
100
+
101
+ callbacks.onStatus({ kind: "generating" });
102
+
103
+ // Fire-and-forget
104
+ void this.#run({ ctx, modelId: config.model, tail, id, abort, callbacks });
105
+ }
106
+
107
+ /** Cancel in-flight generation and invalidate the current generation ID. */
108
+ dismiss(): void {
109
+ if (this.currentAbort) {
110
+ this.currentAbort.abort();
111
+ this.currentAbort = null;
112
+ }
113
+ this.generationId++;
114
+ }
115
+
116
+ // ── Private ──────────────────────────────────────────────────────────
117
+
118
+ async #run(opts: RunOptions): Promise<void> {
119
+ const { ctx, tail, id, abort, callbacks } = opts;
120
+
121
+ recordDebugEvent({
122
+ source: "prompt-suggestions",
123
+ level: "debug",
124
+ category: "generation.start",
125
+ message: "Prompt suggestion generation started",
126
+ cwd: ctx.cwd,
127
+ data: { tailLength: tail.length },
128
+ });
129
+
130
+ try {
131
+ const authResult = await resolveSuggestionAuth(ctx, opts.modelId);
132
+
133
+ // Discard if generation was cancelled while resolving auth
134
+ if (id !== this.generationId || abort.signal.aborted) return;
135
+
136
+ if (authResult.kind === "error") {
137
+ recordDebugEvent({
138
+ source: "prompt-suggestions",
139
+ level: "warning",
140
+ category: "generation.auth-failure",
141
+ message: authResult.message,
142
+ cwd: ctx.cwd,
143
+ });
144
+ callbacks.onStatus({ kind: "error", message: authResult.message });
145
+ return;
146
+ }
147
+
148
+ const { auth } = authResult;
149
+
150
+ if (id !== this.generationId || abort.signal.aborted) return;
151
+
152
+ const { signal: combinedSignal, cleanup: cleanupTimeout } = combineAbortSignals(
153
+ abort,
154
+ GENERATION_TIMEOUT_MS,
155
+ );
156
+
157
+ try {
158
+ const response = await callSuggestionModel({
159
+ model: auth.model,
160
+ auth: { apiKey: auth.apiKey, headers: auth.headers },
161
+ tail,
162
+ signal: combinedSignal,
163
+ });
164
+
165
+ this.#handleResponse(response, id, ctx.cwd, callbacks);
166
+ } finally {
167
+ cleanupTimeout();
168
+ }
169
+ } catch (err) {
170
+ if (id !== this.generationId) return;
171
+ const message = err instanceof Error ? err.message : String(err);
172
+ recordDebugEvent({
173
+ source: "prompt-suggestions",
174
+ level: "warning",
175
+ category: "generation.error",
176
+ message: `Prompt suggestion generation failed: ${message}`,
177
+ cwd: ctx.cwd,
178
+ data: { error: message },
179
+ });
180
+ callbacks.onStatus({ kind: "error", message });
181
+ } finally {
182
+ if (this.currentAbort === abort) {
183
+ this.currentAbort = null;
184
+ }
185
+ }
186
+ }
187
+
188
+ #handleResponse(
189
+ response: SuggestionClientOutput,
190
+ id: number,
191
+ cwd: string,
192
+ callbacks: SuggestionCallbacks,
193
+ ): void {
194
+ if (id !== this.generationId) return;
195
+
196
+ if (!response.ok) {
197
+ callbacks.onStatus({ kind: "error", message: response.message });
198
+ return;
199
+ }
200
+
201
+ const normalized = normalizeSuggestion(response.text);
202
+ if (!normalized) {
203
+ recordDebugEvent({
204
+ source: "prompt-suggestions",
205
+ level: "debug",
206
+ category: "generation.rejected",
207
+ message: `Prompt suggestion rejected: empty after normalization (${response.text.length} raw chars)`,
208
+ cwd,
209
+ data: { rawLength: response.text.length },
210
+ });
211
+ callbacks.onStatus({ kind: "idle" });
212
+ return;
213
+ }
214
+
215
+ recordDebugEvent({
216
+ source: "prompt-suggestions",
217
+ level: "debug",
218
+ category: "generation.done",
219
+ message: `Prompt suggestion ready: "${normalized}"`,
220
+ cwd,
221
+ data: { suggestion: normalized, length: normalized.length },
222
+ });
223
+ callbacks.onStatus({ kind: "ready", suggestion: normalized });
224
+ }
225
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Suggestion model resolution.
3
+ *
4
+ * Resolves the configured suggestion model from the scoped model set
5
+ * and obtains its API key. Extracted from {@link SuggestionGenerator}
6
+ * for isolated testing.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { getSelectableModels } from "@mrclrchtr/supi-core/model-selection";
13
+
14
+ // ── Types ──────────────────────────────────────────────────────────────────
15
+
16
+ export interface ResolvedAuth {
17
+ // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
18
+ model: any;
19
+ apiKey: string;
20
+ headers?: Record<string, string>;
21
+ }
22
+
23
+ export interface AuthResolutionError {
24
+ kind: "error";
25
+ message: string;
26
+ }
27
+
28
+ export type AuthResolutionResult = { kind: "ok"; auth: ResolvedAuth } | AuthResolutionError;
29
+
30
+ // ── Resolution ─────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Resolve model + API key for the configured suggestion model.
34
+ *
35
+ * Finds the model in the scoped set and obtains an API key via
36
+ * `modelRegistry.getApiKeyAndHeaders`.
37
+ *
38
+ * The caller must check for `"disabled"` before invoking — this function
39
+ * assumes a valid, non-disabled model ID.
40
+ */
41
+ export async function resolveSuggestionAuth(
42
+ ctx: ExtensionContext,
43
+ modelId: string,
44
+ ): Promise<AuthResolutionResult> {
45
+ const match = findSuggestionModel(ctx, modelId);
46
+ if (!match) {
47
+ return {
48
+ kind: "error",
49
+ message: `Suggestion model "${modelId}" not in scoped set`,
50
+ };
51
+ }
52
+
53
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(match.model);
54
+ if (!auth.ok) {
55
+ return {
56
+ kind: "error",
57
+ message: `No API key configured for ${match.canonicalId}`,
58
+ };
59
+ }
60
+
61
+ return {
62
+ kind: "ok",
63
+ auth: { model: match.model, apiKey: auth.apiKey ?? "", headers: auth.headers },
64
+ };
65
+ }
66
+
67
+ // ── Helpers ────────────────────────────────────────────────────────────────
68
+
69
+ function findSuggestionModel(ctx: ExtensionContext, modelId: string) {
70
+ const models = getSelectableModels(ctx);
71
+ return models.find((m) => m.canonicalId === modelId);
72
+ }