@mrclrchtr/supi-prompt-suggestions 2.1.0 → 2.2.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.
- package/README.md +3 -1
- package/node_modules/@mrclrchtr/supi-core/README.md +9 -8
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -3
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -5
- package/node_modules/@mrclrchtr/supi-core/src/settings/scoped-settings-list.ts +372 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-action-menu.ts +101 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +13 -7
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +464 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-submenus.ts +124 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +50 -309
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +2 -5
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +17 -1
- package/package.json +2 -2
- package/src/config/settings.ts +5 -20
- package/src/editor/editor.ts +26 -1
- package/src/generation/client.ts +7 -6
- package/src/generation/generator.ts +74 -24
- package/src/generation/model-resolution.ts +13 -2
- package/src/generation/normalize.ts +49 -13
- package/src/session.ts +30 -15
- package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +0 -207
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
GENERATION_TIMEOUT_MS,
|
|
20
20
|
type SuggestionClientOutput,
|
|
21
21
|
} from "./client.ts";
|
|
22
|
-
import { resolveSuggestionAuth } from "./model-resolution.ts";
|
|
23
|
-
import {
|
|
22
|
+
import { type ResolvedAuth, resolveSuggestionAuth } from "./model-resolution.ts";
|
|
23
|
+
import { normalizeSuggestionDetailed } from "./normalize.ts";
|
|
24
24
|
|
|
25
25
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
26
26
|
|
|
@@ -141,7 +141,7 @@ export class SuggestionGenerator {
|
|
|
141
141
|
message: authResult.message,
|
|
142
142
|
cwd: ctx.cwd,
|
|
143
143
|
});
|
|
144
|
-
callbacks.onStatus({ kind: "
|
|
144
|
+
callbacks.onStatus({ kind: "idle" });
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
147
147
|
|
|
@@ -149,23 +149,10 @@ export class SuggestionGenerator {
|
|
|
149
149
|
|
|
150
150
|
if (id !== this.generationId || abort.signal.aborted) return;
|
|
151
151
|
|
|
152
|
-
const
|
|
153
|
-
|
|
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
|
-
});
|
|
152
|
+
const response = await this.#callModelWithTimeout(opts, auth);
|
|
153
|
+
if (!response) return;
|
|
164
154
|
|
|
165
|
-
|
|
166
|
-
} finally {
|
|
167
|
-
cleanupTimeout();
|
|
168
|
-
}
|
|
155
|
+
this.#handleResponse(response, id, ctx.cwd, callbacks, opts.modelId);
|
|
169
156
|
} catch (err) {
|
|
170
157
|
if (id !== this.generationId) return;
|
|
171
158
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -185,6 +172,62 @@ export class SuggestionGenerator {
|
|
|
185
172
|
}
|
|
186
173
|
}
|
|
187
174
|
|
|
175
|
+
async #callModelWithTimeout(
|
|
176
|
+
opts: RunOptions,
|
|
177
|
+
auth: ResolvedAuth,
|
|
178
|
+
): Promise<SuggestionClientOutput | null> {
|
|
179
|
+
const { signal: combinedSignal, cleanup: cleanupTimeout } = combineAbortSignals(
|
|
180
|
+
opts.abort,
|
|
181
|
+
GENERATION_TIMEOUT_MS,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
return await callSuggestionModel({
|
|
186
|
+
model: auth.model,
|
|
187
|
+
auth: { apiKey: auth.apiKey, headers: auth.headers, env: auth.env },
|
|
188
|
+
tail: opts.tail,
|
|
189
|
+
signal: combinedSignal,
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (opts.id !== this.generationId) return null;
|
|
193
|
+
if (opts.abort.signal.aborted) {
|
|
194
|
+
this.#recordGenerationAbort(opts);
|
|
195
|
+
opts.callbacks.onStatus({ kind: "idle" });
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
if (combinedSignal.aborted) {
|
|
199
|
+
this.#recordGenerationTimeout(opts);
|
|
200
|
+
opts.callbacks.onStatus({ kind: "idle" });
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
} finally {
|
|
205
|
+
cleanupTimeout();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#recordGenerationAbort(opts: RunOptions): void {
|
|
210
|
+
recordDebugEvent({
|
|
211
|
+
source: "prompt-suggestions",
|
|
212
|
+
level: "debug",
|
|
213
|
+
category: "generation.aborted",
|
|
214
|
+
message: "Prompt suggestion generation aborted",
|
|
215
|
+
cwd: opts.ctx.cwd,
|
|
216
|
+
data: { modelId: opts.modelId },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#recordGenerationTimeout(opts: RunOptions): void {
|
|
221
|
+
recordDebugEvent({
|
|
222
|
+
source: "prompt-suggestions",
|
|
223
|
+
level: "debug",
|
|
224
|
+
category: "generation.timeout",
|
|
225
|
+
message: "Prompt suggestion generation timed out",
|
|
226
|
+
cwd: opts.ctx.cwd,
|
|
227
|
+
data: { modelId: opts.modelId, timeoutMs: GENERATION_TIMEOUT_MS },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
188
231
|
// biome-ignore lint/complexity/useMaxParams: private method, params are orthogonal
|
|
189
232
|
#handleResponse(
|
|
190
233
|
response: SuggestionClientOutput,
|
|
@@ -208,13 +251,13 @@ export class SuggestionGenerator {
|
|
|
208
251
|
return;
|
|
209
252
|
}
|
|
210
253
|
|
|
211
|
-
const normalized =
|
|
254
|
+
const normalized = normalizeSuggestionDetailed(response.text);
|
|
212
255
|
if (!normalized) {
|
|
213
256
|
recordDebugEvent({
|
|
214
257
|
source: "prompt-suggestions",
|
|
215
258
|
level: "debug",
|
|
216
259
|
category: "generation.rejected",
|
|
217
|
-
message:
|
|
260
|
+
message: "Prompt suggestion rejected after normalization",
|
|
218
261
|
cwd,
|
|
219
262
|
data: { rawLength: response.text.length },
|
|
220
263
|
});
|
|
@@ -226,10 +269,17 @@ export class SuggestionGenerator {
|
|
|
226
269
|
source: "prompt-suggestions",
|
|
227
270
|
level: "debug",
|
|
228
271
|
category: "generation.done",
|
|
229
|
-
message:
|
|
272
|
+
message: "Prompt suggestion ready",
|
|
230
273
|
cwd,
|
|
231
|
-
data: {
|
|
274
|
+
data: {
|
|
275
|
+
modelId,
|
|
276
|
+
rawLength: response.text.length,
|
|
277
|
+
length: normalized.text.length,
|
|
278
|
+
graphemeCount: normalized.graphemeCount,
|
|
279
|
+
originalGraphemeCount: normalized.originalGraphemeCount,
|
|
280
|
+
wasSafetyCapped: normalized.wasSafetyCapped,
|
|
281
|
+
},
|
|
232
282
|
});
|
|
233
|
-
callbacks.onStatus({ kind: "ready", suggestion: normalized });
|
|
283
|
+
callbacks.onStatus({ kind: "ready", suggestion: normalized.text });
|
|
234
284
|
}
|
|
235
285
|
}
|
|
@@ -18,6 +18,7 @@ export interface ResolvedAuth {
|
|
|
18
18
|
model: any;
|
|
19
19
|
apiKey: string;
|
|
20
20
|
headers?: Record<string, string>;
|
|
21
|
+
env?: Record<string, string>;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface AuthResolutionError {
|
|
@@ -51,7 +52,7 @@ export async function resolveSuggestionAuth(
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(match.model);
|
|
54
|
-
if (!auth.ok) {
|
|
55
|
+
if (!auth.ok || !hasConfiguredAuth(ctx, match.model, auth.apiKey)) {
|
|
55
56
|
return {
|
|
56
57
|
kind: "error",
|
|
57
58
|
message: `No API key configured for ${match.canonicalId}`,
|
|
@@ -60,7 +61,7 @@ export async function resolveSuggestionAuth(
|
|
|
60
61
|
|
|
61
62
|
return {
|
|
62
63
|
kind: "ok",
|
|
63
|
-
auth: { model: match.model, apiKey: auth.apiKey ?? "", headers: auth.headers },
|
|
64
|
+
auth: { model: match.model, apiKey: auth.apiKey ?? "", headers: auth.headers, env: auth.env },
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -70,3 +71,13 @@ function findSuggestionModel(ctx: ExtensionContext, modelId: string) {
|
|
|
70
71
|
const models = getSelectableModels(ctx);
|
|
71
72
|
return models.find((m) => m.canonicalId === modelId);
|
|
72
73
|
}
|
|
74
|
+
|
|
75
|
+
function hasConfiguredAuth(
|
|
76
|
+
ctx: ExtensionContext,
|
|
77
|
+
// biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
|
|
78
|
+
model: any,
|
|
79
|
+
apiKey?: string,
|
|
80
|
+
): boolean {
|
|
81
|
+
if (apiKey) return true;
|
|
82
|
+
return ctx.modelRegistry.hasConfiguredAuth(model);
|
|
83
|
+
}
|
|
@@ -2,23 +2,33 @@
|
|
|
2
2
|
* Prompt suggestion normalization.
|
|
3
3
|
*
|
|
4
4
|
* Trims, strips matching outer quotes, detects the NO_SUGGESTION sentinel,
|
|
5
|
-
* collapses whitespace, rejects empty results,
|
|
5
|
+
* collapses whitespace, rejects empty results, and applies a high grapheme-safe
|
|
6
|
+
* safety cap to guard against runaway model output.
|
|
6
7
|
*
|
|
7
8
|
* @module
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const MAX_SUGGESTION_GRAPHEMES = 2_000;
|
|
11
12
|
|
|
12
13
|
/** Sentinels that indicate the model found no useful suggestion. */
|
|
13
14
|
const NO_SUGGESTION_SENTINELS = ["NO_SUGGESTION", "NO SUGGESTION"];
|
|
14
15
|
|
|
16
|
+
export interface NormalizedSuggestion {
|
|
17
|
+
/** Full normalized suggestion text, possibly safety-capped. */
|
|
18
|
+
text: string;
|
|
19
|
+
/** Whether the high safety cap was applied. */
|
|
20
|
+
wasSafetyCapped: boolean;
|
|
21
|
+
/** Grapheme count after normalization and before safety capping. */
|
|
22
|
+
originalGraphemeCount: number;
|
|
23
|
+
/** Grapheme count of the returned text. */
|
|
24
|
+
graphemeCount: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
/**
|
|
16
|
-
* Normalize a raw suggestion string
|
|
17
|
-
*
|
|
18
|
-
* truncate at 240 chars.
|
|
19
|
-
* Exported for testing.
|
|
28
|
+
* Normalize a raw suggestion string and return metadata about safety capping.
|
|
29
|
+
* Exported for orchestration and testing.
|
|
20
30
|
*/
|
|
21
|
-
export function
|
|
31
|
+
export function normalizeSuggestionDetailed(text: string): NormalizedSuggestion | null {
|
|
22
32
|
let normalized = text.trim();
|
|
23
33
|
|
|
24
34
|
// Strip matching outer quotes
|
|
@@ -35,16 +45,42 @@ export function normalizeSuggestion(text: string): string | null {
|
|
|
35
45
|
return null;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
// Collapse
|
|
48
|
+
// Collapse all whitespace to spaces.
|
|
39
49
|
normalized = normalized.replace(/\s+/g, " ").trim();
|
|
40
50
|
|
|
41
|
-
// Reject empty (also catches pure-sentinel after whitespace collapse)
|
|
42
51
|
if (!normalized) return null;
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
const graphemes = splitGraphemes(normalized);
|
|
54
|
+
const wasSafetyCapped = graphemes.length > MAX_SUGGESTION_GRAPHEMES;
|
|
55
|
+
if (!wasSafetyCapped) {
|
|
56
|
+
return {
|
|
57
|
+
text: normalized,
|
|
58
|
+
wasSafetyCapped: false,
|
|
59
|
+
originalGraphemeCount: graphemes.length,
|
|
60
|
+
graphemeCount: graphemes.length,
|
|
61
|
+
};
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
|
|
64
|
+
const cappedText = graphemes.slice(0, MAX_SUGGESTION_GRAPHEMES).join("").trimEnd();
|
|
65
|
+
const cappedGraphemeCount = splitGraphemes(cappedText).length;
|
|
66
|
+
return {
|
|
67
|
+
text: cappedText,
|
|
68
|
+
wasSafetyCapped: true,
|
|
69
|
+
originalGraphemeCount: graphemes.length,
|
|
70
|
+
graphemeCount: cappedGraphemeCount,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize a raw suggestion string: trim, strip outer quotes, detect
|
|
76
|
+
* NO_SUGGESTION, collapse whitespace, reject empty, and apply the high safety
|
|
77
|
+
* cap. Exported for existing callers and tests that only need the text.
|
|
78
|
+
*/
|
|
79
|
+
export function normalizeSuggestion(text: string): string | null {
|
|
80
|
+
return normalizeSuggestionDetailed(text)?.text ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function splitGraphemes(text: string): string[] {
|
|
84
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
85
|
+
return Array.from(segmenter.segment(text), (segment) => segment.segment);
|
|
50
86
|
}
|
package/src/session.ts
CHANGED
|
@@ -14,10 +14,16 @@ import type { GenerationStatus, SuggestionGenerator } from "./generation/generat
|
|
|
14
14
|
|
|
15
15
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
+
type SessionTextContent = string | { type: string; text?: string }[] | undefined;
|
|
18
|
+
|
|
17
19
|
/** Narrow shape for session message entries used in history seeding. */
|
|
18
20
|
interface SessionMessageEntry {
|
|
19
21
|
type: "message";
|
|
20
|
-
message?: {
|
|
22
|
+
message?: {
|
|
23
|
+
role?: string;
|
|
24
|
+
stopReason?: string;
|
|
25
|
+
content?: SessionTextContent;
|
|
26
|
+
};
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
// ── SessionLifecycle ───────────────────────────────────────────────────────
|
|
@@ -41,12 +47,18 @@ export class SessionLifecycle {
|
|
|
41
47
|
/** Install the ghost editor wrapper, seed UP-arrow history, and recreate the spinner. */
|
|
42
48
|
onStart(ctx: ExtensionContext): void {
|
|
43
49
|
this.statusSpinner?.stop();
|
|
50
|
+
this.statusSpinner = null;
|
|
51
|
+
this.ghostEditor = null;
|
|
52
|
+
if (ctx.mode !== "tui") return;
|
|
53
|
+
|
|
44
54
|
this.statusSpinner = new StatusSpinner(ctx, "supi-prompt-suggestions");
|
|
45
55
|
this.installEditor(ctx);
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
/** Extract the last assistant message and fire suggestion generation. */
|
|
49
59
|
onAgentEnd(event: AgentEndEvent, ctx: ExtensionContext): void {
|
|
60
|
+
if (ctx.mode !== "tui") return;
|
|
61
|
+
|
|
50
62
|
const lastAssistant = extractLastAssistantText(event);
|
|
51
63
|
if (!lastAssistant) {
|
|
52
64
|
this.statusSpinner?.stop();
|
|
@@ -98,7 +110,6 @@ export class SessionLifecycle {
|
|
|
98
110
|
case "error":
|
|
99
111
|
this.generationInFlight = false;
|
|
100
112
|
this.statusSpinner?.stop();
|
|
101
|
-
ctx.ui.setStatus("supi-prompt-suggestions", `suggestion error: ${status.message}`);
|
|
102
113
|
break;
|
|
103
114
|
case "idle":
|
|
104
115
|
this.generationInFlight = false;
|
|
@@ -136,8 +147,9 @@ export class SessionLifecycle {
|
|
|
136
147
|
* The Editor base class stores history in-memory via `addToHistory()` when
|
|
137
148
|
* messages are submitted. Since `setEditorComponent` replaces the editor
|
|
138
149
|
* with a fresh instance on every `session_start` (including `/reload`), the
|
|
139
|
-
* in-memory history is lost. Reading from `sessionManager.
|
|
140
|
-
* repopulates it from the
|
|
150
|
+
* in-memory history is lost. Reading from `sessionManager.getBranch()`
|
|
151
|
+
* repopulates it from the current conversation path so history survives
|
|
152
|
+
* reloads without pulling prompts from abandoned branches.
|
|
141
153
|
*
|
|
142
154
|
* Cross-module save/restore (capturing the old editor's history in
|
|
143
155
|
* `session_shutdown` and restoring in `session_start`) does not work here
|
|
@@ -145,16 +157,12 @@ export class SessionLifecycle {
|
|
|
145
157
|
* closure variables are destroyed before the new module can read them.
|
|
146
158
|
*/
|
|
147
159
|
private seedHistoryFromSession(editor: GhostTextEditor, ctx: ExtensionContext): void {
|
|
148
|
-
const entries = ctx.sessionManager.
|
|
160
|
+
const entries = ctx.sessionManager.getBranch();
|
|
149
161
|
for (const entry of entries) {
|
|
150
162
|
if (entry.type !== "message") continue;
|
|
151
163
|
const msg = (entry as SessionMessageEntry).message;
|
|
152
164
|
if (msg?.role !== "user") continue;
|
|
153
|
-
const text = msg.content
|
|
154
|
-
?.filter((c) => c.type === "text")
|
|
155
|
-
.map((c) => c.text ?? "")
|
|
156
|
-
.join("")
|
|
157
|
-
.trim();
|
|
165
|
+
const text = extractTextContent(msg.content).trim();
|
|
158
166
|
if (text) editor.addToHistory(text);
|
|
159
167
|
}
|
|
160
168
|
}
|
|
@@ -178,13 +186,20 @@ function extractLastAssistantText(event: AgentEndEvent): string | null {
|
|
|
178
186
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
179
187
|
const msg = messages[i];
|
|
180
188
|
if (msg?.role === "assistant") {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
.map((c: { type: string; text?: string }) => c.text)
|
|
184
|
-
.join("")
|
|
185
|
-
.trim();
|
|
189
|
+
if (msg.stopReason !== "stop") return null;
|
|
190
|
+
const textContent = extractTextContent(msg.content).trim();
|
|
186
191
|
return textContent || null;
|
|
187
192
|
}
|
|
188
193
|
}
|
|
189
194
|
return null;
|
|
190
195
|
}
|
|
196
|
+
|
|
197
|
+
function extractTextContent(content: SessionTextContent): string {
|
|
198
|
+
if (typeof content === "string") return content;
|
|
199
|
+
if (!Array.isArray(content)) return "";
|
|
200
|
+
|
|
201
|
+
return content
|
|
202
|
+
.filter((c) => c.type === "text")
|
|
203
|
+
.map((c) => c.text ?? "")
|
|
204
|
+
.join("\n");
|
|
205
|
+
}
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
// Config-aware settings contribution helper for SuPi packages.
|
|
2
|
-
//
|
|
3
|
-
// Registers config-backed settings sections through PI's shared event bus so
|
|
4
|
-
// /supi-settings can collect contributions from all loaded extensions without
|
|
5
|
-
// relying on a shared supi-core module instance.
|
|
6
|
-
|
|
7
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
9
|
-
import {
|
|
10
|
-
isSettingsContributionCollector,
|
|
11
|
-
type SettingsScope,
|
|
12
|
-
type SettingsSection,
|
|
13
|
-
SUPI_SETTINGS_COLLECT_EVENT,
|
|
14
|
-
} from "../settings/settings-registry.ts";
|
|
15
|
-
import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
|
|
16
|
-
|
|
17
|
-
// ── Types ──────────────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Supported config value types for declarative persistChange.
|
|
21
|
-
*
|
|
22
|
-
* - `"boolean"`: maps "on" → true, "off" → false
|
|
23
|
-
* - `"number"`: parses integer via Number.parseInt, falls back to unset on invalid
|
|
24
|
-
* - `"stringList"`: splits on comma, trims whitespace, unsets on empty
|
|
25
|
-
*/
|
|
26
|
-
export type ConfigSettingType = "boolean" | "number" | "stringList";
|
|
27
|
-
|
|
28
|
-
/** Extended setting item that can declare its config type for persistence. */
|
|
29
|
-
export interface ConfigSettingItem extends SettingItem {
|
|
30
|
-
/** Config value type used for auto-generated persistence. */
|
|
31
|
-
configType?: ConfigSettingType;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Helpers provided to persistChange for scoped SuPi config writes. */
|
|
35
|
-
export interface ConfigSettingsHelpers {
|
|
36
|
-
/** Write a key to the selected scope's config section. */
|
|
37
|
-
set(key: string, value: unknown): void;
|
|
38
|
-
/** Remove a key from the selected scope's config section. */
|
|
39
|
-
unset(key: string): void;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface ConfigSettingsPersistedChange {
|
|
43
|
-
scope: SettingsScope;
|
|
44
|
-
cwd: string;
|
|
45
|
-
settingId: string;
|
|
46
|
-
value: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface ConfigSettingsOptions<T> {
|
|
50
|
-
/** Settings contribution identifier — e.g. "lsp", "claude-md". */
|
|
51
|
-
id: string;
|
|
52
|
-
/** Human-readable label shown in the UI. */
|
|
53
|
-
label: string;
|
|
54
|
-
/** SuPi config section name — e.g. "lsp", "claude-md". */
|
|
55
|
-
section: string;
|
|
56
|
-
/** Default config values. */
|
|
57
|
-
defaults: T;
|
|
58
|
-
/** Build SettingItem[] from scoped config. */
|
|
59
|
-
buildItems: (
|
|
60
|
-
settings: T,
|
|
61
|
-
scope: SettingsScope,
|
|
62
|
-
cwd: string,
|
|
63
|
-
ctx?: ExtensionContext,
|
|
64
|
-
) => ConfigSettingItem[];
|
|
65
|
-
/**
|
|
66
|
-
* Convert a UI value into scoped SuPi config writes.
|
|
67
|
-
*
|
|
68
|
-
* Optional when every item returned by `buildItems` declares `configType`.
|
|
69
|
-
* Required when any item lacks `configType`.
|
|
70
|
-
*/
|
|
71
|
-
persistChange?: (
|
|
72
|
-
scope: SettingsScope,
|
|
73
|
-
cwd: string,
|
|
74
|
-
settingId: string,
|
|
75
|
-
value: string,
|
|
76
|
-
helpers: ConfigSettingsHelpers,
|
|
77
|
-
) => void;
|
|
78
|
-
/** Optional live runtime sync after successful persistence. */
|
|
79
|
-
afterPersist?: (change: ConfigSettingsPersistedChange) => void;
|
|
80
|
-
/** Optional home directory for config resolution (testing). */
|
|
81
|
-
homeDir?: string;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ── Auto-generated persistChange ───────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
function autoPersistChange(
|
|
87
|
-
settingId: string,
|
|
88
|
-
value: string,
|
|
89
|
-
helpers: ConfigSettingsHelpers,
|
|
90
|
-
items: ConfigSettingItem[],
|
|
91
|
-
): void {
|
|
92
|
-
const item = items.find((i) => i.id === settingId);
|
|
93
|
-
if (!item?.configType) return;
|
|
94
|
-
|
|
95
|
-
switch (item.configType) {
|
|
96
|
-
case "boolean": {
|
|
97
|
-
helpers.set(settingId, value === "on");
|
|
98
|
-
break;
|
|
99
|
-
}
|
|
100
|
-
case "number": {
|
|
101
|
-
const num = Number.parseInt(value, 10);
|
|
102
|
-
if (Number.isFinite(num) && num > 0) {
|
|
103
|
-
helpers.set(settingId, num);
|
|
104
|
-
} else {
|
|
105
|
-
helpers.unset(settingId);
|
|
106
|
-
}
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
case "stringList": {
|
|
110
|
-
const names = value
|
|
111
|
-
.split(",")
|
|
112
|
-
.map((s) => s.trim())
|
|
113
|
-
.filter((s) => s.length > 0);
|
|
114
|
-
if (names.length > 0) {
|
|
115
|
-
helpers.set(settingId, names);
|
|
116
|
-
} else {
|
|
117
|
-
helpers.unset(settingId);
|
|
118
|
-
}
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function areAllItemsDeclarative(items: ConfigSettingItem[]): boolean {
|
|
125
|
-
return items.length > 0 && items.every((i) => i.configType !== undefined);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function createHelpers<T>(options: ConfigSettingsOptions<T>, scope: SettingsScope, cwd: string) {
|
|
129
|
-
return {
|
|
130
|
-
set: (key: string, val: unknown) => {
|
|
131
|
-
writeSupiConfig(
|
|
132
|
-
{ section: options.section, scope, cwd },
|
|
133
|
-
{ [key]: val },
|
|
134
|
-
{ homeDir: options.homeDir },
|
|
135
|
-
);
|
|
136
|
-
},
|
|
137
|
-
unset: (key: string) => {
|
|
138
|
-
removeSupiConfigKey({ section: options.section, scope, cwd }, key, {
|
|
139
|
-
homeDir: options.homeDir,
|
|
140
|
-
});
|
|
141
|
-
},
|
|
142
|
-
} satisfies ConfigSettingsHelpers;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function toSettingsSection<T>(options: ConfigSettingsOptions<T>): SettingsSection {
|
|
146
|
-
let cachedItems: ConfigSettingItem[] | undefined;
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
id: options.id,
|
|
150
|
-
label: options.label,
|
|
151
|
-
loadValues: (scope, cwd, ctx) => {
|
|
152
|
-
const settings = loadSupiConfigForScope(options.section, cwd, options.defaults, {
|
|
153
|
-
scope,
|
|
154
|
-
homeDir: options.homeDir,
|
|
155
|
-
});
|
|
156
|
-
const items = options.buildItems(settings, scope, cwd, ctx);
|
|
157
|
-
cachedItems = items;
|
|
158
|
-
return items;
|
|
159
|
-
},
|
|
160
|
-
persistChange: (scope, cwd, settingId, value) => {
|
|
161
|
-
const helpers = createHelpers(options, scope, cwd);
|
|
162
|
-
|
|
163
|
-
if (options.persistChange) {
|
|
164
|
-
options.persistChange(scope, cwd, settingId, value, helpers);
|
|
165
|
-
} else {
|
|
166
|
-
const items = cachedItems ?? options.buildItems(options.defaults, scope, cwd, undefined);
|
|
167
|
-
if (!areAllItemsDeclarative(items)) {
|
|
168
|
-
throw new Error(
|
|
169
|
-
`Settings contribution "${options.id}" needs persistChange or configType on every item.`,
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
autoPersistChange(settingId, value, helpers, items);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
try {
|
|
176
|
-
options.afterPersist?.({ scope, cwd, settingId, value });
|
|
177
|
-
} catch (error) {
|
|
178
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
179
|
-
throw new Error(`Saved setting, but live sync failed: ${message}`, { cause: error });
|
|
180
|
-
}
|
|
181
|
-
},
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ── Registration ───────────────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Register a config-backed settings contribution for `/supi-settings`.
|
|
189
|
-
*
|
|
190
|
-
* Contributions are collected through PI's process-local event bus. Call this
|
|
191
|
-
* during the extension factory function, not in async session handlers.
|
|
192
|
-
*/
|
|
193
|
-
export function registerConfigSettings<T>(
|
|
194
|
-
pi: ExtensionAPI,
|
|
195
|
-
options: ConfigSettingsOptions<T>,
|
|
196
|
-
): void {
|
|
197
|
-
const section = toSettingsSection(options);
|
|
198
|
-
const dispose = pi.events.on(SUPI_SETTINGS_COLLECT_EVENT, (collector) => {
|
|
199
|
-
if (isSettingsContributionCollector(collector)) {
|
|
200
|
-
collector.add(section);
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
pi.on("session_shutdown", () => {
|
|
205
|
-
dispose();
|
|
206
|
-
});
|
|
207
|
-
}
|