@mrclrchtr/supi-review 2.0.5 → 2.1.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/node_modules/@mrclrchtr/supi-core/README.md +29 -21
- package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
- package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +88 -69
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +22 -8
- package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +357 -0
- package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +51 -29
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +64 -18
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +11 -5
- package/node_modules/@mrclrchtr/supi-core/src/tool-framework.ts +10 -0
- package/package.json +2 -2
|
@@ -39,10 +39,9 @@ Config file locations:
|
|
|
39
39
|
|
|
40
40
|
### Settings helpers
|
|
41
41
|
|
|
42
|
-
- `
|
|
43
|
-
- `
|
|
44
|
-
- `
|
|
45
|
-
- `openSettingsOverlay()` — open the shared settings UI directly
|
|
42
|
+
- `registerConfigSettings(pi, options)` — contribute a config-backed settings section with scoped persistence helpers
|
|
43
|
+
- `registerSettingsCommand(pi)` — register `/supi-settings` (used by `@mrclrchtr/supi-settings`)
|
|
44
|
+
- `openSettingsOverlay(pi, ctx)` — open the shared settings UI directly
|
|
46
45
|
- `createInputSubmenu()` — helper for simple text-entry submenus
|
|
47
46
|
|
|
48
47
|
The built-in settings UI supports:
|
|
@@ -83,25 +82,34 @@ The built-in settings UI supports:
|
|
|
83
82
|
## Example
|
|
84
83
|
|
|
85
84
|
```ts
|
|
85
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
86
86
|
import { loadSupiConfig, registerConfigSettings, wrapExtensionContext } from "@mrclrchtr/supi-core/api";
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
enabled: true
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
registerConfigSettings({
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
88
|
+
export default function myExtension(pi: ExtensionAPI) {
|
|
89
|
+
const defaults = { enabled: true };
|
|
90
|
+
const config = loadSupiConfig("my-extension", process.cwd(), defaults);
|
|
91
|
+
|
|
92
|
+
registerConfigSettings(pi, {
|
|
93
|
+
id: "my-extension",
|
|
94
|
+
label: "My Extension",
|
|
95
|
+
section: "my-extension",
|
|
96
|
+
defaults,
|
|
97
|
+
buildItems: (settings) => [
|
|
98
|
+
{
|
|
99
|
+
id: "enabled",
|
|
100
|
+
label: "Enabled",
|
|
101
|
+
currentValue: settings.enabled ? "on" : "off",
|
|
102
|
+
values: ["on", "off"],
|
|
103
|
+
configType: "boolean" as const,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const message = wrapExtensionContext("my-extension", "hello", {
|
|
109
|
+
enabled: config.enabled,
|
|
110
|
+
});
|
|
111
|
+
void message;
|
|
112
|
+
}
|
|
105
113
|
```
|
|
106
114
|
|
|
107
115
|
## Source
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-core",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"./status-spinner": "./src/status-spinner.ts",
|
|
71
71
|
"./terminal": "./src/terminal.ts",
|
|
72
72
|
"./tool-framework": "./src/tool-framework.ts",
|
|
73
|
+
"./prompt-surface": "./src/prompt-surface.ts",
|
|
73
74
|
"./types": "./src/types.ts"
|
|
74
75
|
}
|
|
75
76
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
// Config-aware settings helper for SuPi
|
|
2
|
-
// Wraps registerSettings() and centralizes selected-scope loading + scoped persistence.
|
|
1
|
+
// Config-aware settings contribution helper for SuPi packages.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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.
|
|
7
6
|
|
|
8
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
8
|
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
10
|
-
import
|
|
11
|
-
|
|
9
|
+
import {
|
|
10
|
+
isSettingsContributionCollector,
|
|
11
|
+
type SettingsScope,
|
|
12
|
+
type SettingsSection,
|
|
13
|
+
SUPI_SETTINGS_COLLECT_EVENT,
|
|
14
|
+
} from "../settings/settings-registry.ts";
|
|
12
15
|
import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./config.ts";
|
|
13
16
|
|
|
14
17
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
@@ -22,22 +25,13 @@ import { loadSupiConfigForScope, removeSupiConfigKey, writeSupiConfig } from "./
|
|
|
22
25
|
*/
|
|
23
26
|
export type ConfigSettingType = "boolean" | "number" | "stringList";
|
|
24
27
|
|
|
25
|
-
/**
|
|
26
|
-
* Extended setting item that can declare its config type for auto-generated
|
|
27
|
-
* persistence handling.
|
|
28
|
-
*/
|
|
28
|
+
/** Extended setting item that can declare its config type for persistence. */
|
|
29
29
|
export interface ConfigSettingItem extends SettingItem {
|
|
30
|
-
/**
|
|
31
|
-
* When set, persistChange for this item is auto-generated.
|
|
32
|
-
* All items must declare a configType for auto-generation to activate.
|
|
33
|
-
*/
|
|
30
|
+
/** Config value type used for auto-generated persistence. */
|
|
34
31
|
configType?: ConfigSettingType;
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
/**
|
|
38
|
-
* Helpers provided to the persistChange callback for writing or removing
|
|
39
|
-
* scoped config values.
|
|
40
|
-
*/
|
|
34
|
+
/** Helpers provided to persistChange for scoped SuPi config writes. */
|
|
41
35
|
export interface ConfigSettingsHelpers {
|
|
42
36
|
/** Write a key to the selected scope's config section. */
|
|
43
37
|
set(key: string, value: unknown): void;
|
|
@@ -45,22 +39,23 @@ export interface ConfigSettingsHelpers {
|
|
|
45
39
|
unset(key: string): void;
|
|
46
40
|
}
|
|
47
41
|
|
|
42
|
+
export interface ConfigSettingsPersistedChange {
|
|
43
|
+
scope: SettingsScope;
|
|
44
|
+
cwd: string;
|
|
45
|
+
settingId: string;
|
|
46
|
+
value: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
48
49
|
export interface ConfigSettingsOptions<T> {
|
|
49
|
-
/**
|
|
50
|
+
/** Settings contribution identifier — e.g. "lsp", "claude-md". */
|
|
50
51
|
id: string;
|
|
51
|
-
/** Human-readable label shown in the UI */
|
|
52
|
+
/** Human-readable label shown in the UI. */
|
|
52
53
|
label: string;
|
|
53
|
-
/** SuPi config section name — e.g. "lsp", "claude-md" */
|
|
54
|
+
/** SuPi config section name — e.g. "lsp", "claude-md". */
|
|
54
55
|
section: string;
|
|
55
|
-
/** Default config values */
|
|
56
|
+
/** Default config values. */
|
|
56
57
|
defaults: T;
|
|
57
|
-
/**
|
|
58
|
-
* Build SettingItem[] from scoped config. Called by loadValues.
|
|
59
|
-
*
|
|
60
|
-
* Items can include a `configType` property for auto-generated
|
|
61
|
-
* persistChange handling. When ALL items declare a configType,
|
|
62
|
-
* the `persistChange` callback can be omitted.
|
|
63
|
-
*/
|
|
58
|
+
/** Build SettingItem[] from scoped config. */
|
|
64
59
|
buildItems: (
|
|
65
60
|
settings: T,
|
|
66
61
|
scope: SettingsScope,
|
|
@@ -68,10 +63,10 @@ export interface ConfigSettingsOptions<T> {
|
|
|
68
63
|
ctx?: ExtensionContext,
|
|
69
64
|
) => ConfigSettingItem[];
|
|
70
65
|
/**
|
|
71
|
-
*
|
|
66
|
+
* Convert a UI value into scoped SuPi config writes.
|
|
72
67
|
*
|
|
73
|
-
* Optional when
|
|
74
|
-
* Required when any item lacks
|
|
68
|
+
* Optional when every item returned by `buildItems` declares `configType`.
|
|
69
|
+
* Required when any item lacks `configType`.
|
|
75
70
|
*/
|
|
76
71
|
persistChange?: (
|
|
77
72
|
scope: SettingsScope,
|
|
@@ -80,6 +75,8 @@ export interface ConfigSettingsOptions<T> {
|
|
|
80
75
|
value: string,
|
|
81
76
|
helpers: ConfigSettingsHelpers,
|
|
82
77
|
) => void;
|
|
78
|
+
/** Optional live runtime sync after successful persistence. */
|
|
79
|
+
afterPersist?: (change: ConfigSettingsPersistedChange) => void;
|
|
83
80
|
/** Optional home directory for config resolution (testing). */
|
|
84
81
|
homeDir?: string;
|
|
85
82
|
}
|
|
@@ -128,23 +125,27 @@ function areAllItemsDeclarative(items: ConfigSettingItem[]): boolean {
|
|
|
128
125
|
return items.length > 0 && items.every((i) => i.configType !== undefined);
|
|
129
126
|
}
|
|
130
127
|
|
|
131
|
-
|
|
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
|
+
}
|
|
132
144
|
|
|
133
|
-
|
|
134
|
-
* Register a config-backed settings section.
|
|
135
|
-
*
|
|
136
|
-
* Loads display values from the selected scope only (`defaults <- selected scope`)
|
|
137
|
-
* instead of merged effective runtime config. Provides scoped `set` / `unset`
|
|
138
|
-
* persistence helpers so extensions don't need to wire `writeSupiConfig` /
|
|
139
|
-
* `removeSupiConfigKey` by hand.
|
|
140
|
-
*
|
|
141
|
-
* When every item returned by `buildItems` declares a `configType`, the
|
|
142
|
-
* `persistChange` callback is optional and will be auto-generated.
|
|
143
|
-
*/
|
|
144
|
-
export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): void {
|
|
145
|
+
function toSettingsSection<T>(options: ConfigSettingsOptions<T>): SettingsSection {
|
|
145
146
|
let cachedItems: ConfigSettingItem[] | undefined;
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
return {
|
|
148
149
|
id: options.id,
|
|
149
150
|
label: options.label,
|
|
150
151
|
loadValues: (scope, cwd, ctx) => {
|
|
@@ -157,32 +158,50 @@ export function registerConfigSettings<T>(options: ConfigSettingsOptions<T>): vo
|
|
|
157
158
|
return items;
|
|
158
159
|
},
|
|
159
160
|
persistChange: (scope, cwd, settingId, value) => {
|
|
160
|
-
const helpers
|
|
161
|
-
|
|
162
|
-
writeSupiConfig(
|
|
163
|
-
{ section: options.section, scope, cwd },
|
|
164
|
-
{ [key]: val },
|
|
165
|
-
{ homeDir: options.homeDir },
|
|
166
|
-
);
|
|
167
|
-
},
|
|
168
|
-
unset: (key) => {
|
|
169
|
-
removeSupiConfigKey({ section: options.section, scope, cwd }, key, {
|
|
170
|
-
homeDir: options.homeDir,
|
|
171
|
-
});
|
|
172
|
-
},
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
// Use manual persistChange when provided
|
|
161
|
+
const helpers = createHelpers(options, scope, cwd);
|
|
162
|
+
|
|
176
163
|
if (options.persistChange) {
|
|
177
164
|
options.persistChange(scope, cwd, settingId, value, helpers);
|
|
178
|
-
|
|
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);
|
|
179
173
|
}
|
|
180
174
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 });
|
|
185
180
|
}
|
|
186
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();
|
|
187
206
|
});
|
|
188
207
|
}
|
|
@@ -21,6 +21,15 @@ function getProjectConfigPath(cwd: string): string {
|
|
|
21
21
|
return path.join(cwd, PROJECT_CONFIG_DIR, CONFIG_FILE);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Return the SuPi config file path for one scope. */
|
|
25
|
+
export function getSupiConfigPath(
|
|
26
|
+
scope: "global" | "project",
|
|
27
|
+
cwd: string,
|
|
28
|
+
options?: SupiConfigOptions,
|
|
29
|
+
): string {
|
|
30
|
+
return scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(cwd);
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
export function readJsonFile(filePath: string): Record<string, unknown> | null {
|
|
25
34
|
let content: string;
|
|
26
35
|
try {
|
|
@@ -95,15 +104,22 @@ export function loadSupiConfigForScope<T>(
|
|
|
95
104
|
defaults: T,
|
|
96
105
|
options: { scope: "global" | "project" } & SupiConfigOptions,
|
|
97
106
|
): T {
|
|
98
|
-
const config =
|
|
99
|
-
options.scope === "global"
|
|
100
|
-
? readJsonFile(getGlobalConfigPath(options.homeDir))
|
|
101
|
-
: readJsonFile(getProjectConfigPath(cwd));
|
|
107
|
+
const config = readJsonFile(getSupiConfigPath(options.scope, cwd, { homeDir: options.homeDir }));
|
|
102
108
|
|
|
103
109
|
const scopedSection = extractSection(config, section);
|
|
104
110
|
return shallowMerge(defaults, scopedSection);
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
/** Load the raw object for one config section and one scope. */
|
|
114
|
+
export function loadSupiConfigSectionForScope(
|
|
115
|
+
section: string,
|
|
116
|
+
cwd: string,
|
|
117
|
+
options: { scope: "global" | "project" } & SupiConfigOptions,
|
|
118
|
+
): Record<string, unknown> | null {
|
|
119
|
+
const config = readJsonFile(getSupiConfigPath(options.scope, cwd, { homeDir: options.homeDir }));
|
|
120
|
+
return extractSection(config, section);
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
export interface SupiConfigLocation {
|
|
108
124
|
section: string;
|
|
109
125
|
scope: "global" | "project";
|
|
@@ -118,8 +134,7 @@ export function writeSupiConfig(
|
|
|
118
134
|
value: Record<string, unknown>,
|
|
119
135
|
options?: SupiConfigOptions,
|
|
120
136
|
): void {
|
|
121
|
-
const configPath =
|
|
122
|
-
loc.scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(loc.cwd);
|
|
137
|
+
const configPath = getSupiConfigPath(loc.scope, loc.cwd, options);
|
|
123
138
|
|
|
124
139
|
const dir = path.dirname(configPath);
|
|
125
140
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -142,8 +157,7 @@ export function removeSupiConfigKey(
|
|
|
142
157
|
key: string,
|
|
143
158
|
options?: SupiConfigOptions,
|
|
144
159
|
): void {
|
|
145
|
-
const configPath =
|
|
146
|
-
loc.scope === "global" ? getGlobalConfigPath(options?.homeDir) : getProjectConfigPath(loc.cwd);
|
|
160
|
+
const configPath = getSupiConfigPath(loc.scope, loc.cwd, options);
|
|
147
161
|
|
|
148
162
|
const existing = readJsonFile(configPath);
|
|
149
163
|
if (!existing) return;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Configurable tool prompt-surface overrides.
|
|
2
|
+
//
|
|
3
|
+
// Resolution order: package defaults ← global SuPi config ← trusted project SuPi config.
|
|
4
|
+
// Project overrides are trust-gated; they require PI project trust and a PI-recognized
|
|
5
|
+
// trust-requiring resource (e.g. .pi/settings.json).
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type ExtensionContext,
|
|
9
|
+
hasTrustRequiringProjectResources,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { SuiPiToolPromptSurface } from "../tool-framework.ts";
|
|
12
|
+
import { loadSupiConfigSectionForScope, type SupiConfigOptions } from "./config.ts";
|
|
13
|
+
|
|
14
|
+
// ── Public types ───────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export type ToolPromptSurfaceDiagnosticCode =
|
|
17
|
+
| "invalidPromptSurfaceConfig"
|
|
18
|
+
| "invalidPromptSurfaceField"
|
|
19
|
+
| "projectPromptSurfaceIgnored";
|
|
20
|
+
|
|
21
|
+
export interface ToolPromptSurfaceDiagnostic {
|
|
22
|
+
code: ToolPromptSurfaceDiagnosticCode;
|
|
23
|
+
scope: "global" | "project";
|
|
24
|
+
section: string;
|
|
25
|
+
toolName: string;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolveToolPromptSurfaceOptions extends SupiConfigOptions {
|
|
30
|
+
section: string;
|
|
31
|
+
toolName: string;
|
|
32
|
+
defaults: SuiPiToolPromptSurface;
|
|
33
|
+
ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResolveToolPromptSurfaceResult {
|
|
37
|
+
surface: SuiPiToolPromptSurface;
|
|
38
|
+
diagnostics: ToolPromptSurfaceDiagnostic[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Private types ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
type PromptSurfaceField = keyof SuiPiToolPromptSurface;
|
|
44
|
+
type PromptSurfaceScope = ToolPromptSurfaceDiagnostic["scope"];
|
|
45
|
+
|
|
46
|
+
const PROMPT_SURFACE_FIELDS = new Set<string>(["description", "promptSnippet", "promptGuidelines"]);
|
|
47
|
+
|
|
48
|
+
const PROMPT_SURFACE_DIAGNOSTICS_KEY = Symbol.for(
|
|
49
|
+
"@mrclrchtr/supi-core/tool-prompt-surface/notified-diagnostics",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// ── Resolution ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Resolve a tool's model-facing prompt surface from defaults + SuPi config overrides. */
|
|
55
|
+
export function resolveToolPromptSurface(
|
|
56
|
+
options: ResolveToolPromptSurfaceOptions,
|
|
57
|
+
): ResolveToolPromptSurfaceResult {
|
|
58
|
+
const diagnostics: ToolPromptSurfaceDiagnostic[] = [];
|
|
59
|
+
let surface = clonePromptSurface(options.defaults);
|
|
60
|
+
|
|
61
|
+
const globalSection = loadSupiConfigSectionForScope(options.section, options.ctx.cwd, {
|
|
62
|
+
scope: "global",
|
|
63
|
+
homeDir: options.homeDir,
|
|
64
|
+
});
|
|
65
|
+
surface = applyPromptSurfaceScope(surface, options, "global", globalSection, diagnostics);
|
|
66
|
+
|
|
67
|
+
const projectSection = loadSupiConfigSectionForScope(options.section, options.ctx.cwd, {
|
|
68
|
+
scope: "project",
|
|
69
|
+
homeDir: options.homeDir,
|
|
70
|
+
});
|
|
71
|
+
const projectPromptSurface = getPromptSurfaceConfig(projectSection, options.toolName, {
|
|
72
|
+
diagnostics,
|
|
73
|
+
options,
|
|
74
|
+
scope: "project",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (projectPromptSurface) {
|
|
78
|
+
const hasTrustMarker = hasTrustRequiringProjectResources(options.ctx.cwd);
|
|
79
|
+
const projectTrusted = options.ctx.isProjectTrusted();
|
|
80
|
+
if (hasTrustMarker && projectTrusted) {
|
|
81
|
+
surface = applyPromptSurfaceConfig(
|
|
82
|
+
surface,
|
|
83
|
+
options.defaults,
|
|
84
|
+
projectPromptSurface,
|
|
85
|
+
options,
|
|
86
|
+
"project",
|
|
87
|
+
diagnostics,
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
diagnostics.push({
|
|
91
|
+
code: "projectPromptSurfaceIgnored",
|
|
92
|
+
scope: "project",
|
|
93
|
+
section: options.section,
|
|
94
|
+
toolName: options.toolName,
|
|
95
|
+
message: hasTrustMarker
|
|
96
|
+
? `Project prompt-surface overrides for ${options.toolName} were ignored because the project is not trusted in PI.`
|
|
97
|
+
: `Project prompt-surface overrides for ${options.toolName} were ignored because ${options.ctx.cwd}/.pi/supi/config.json is not PI trust-gated. Add .pi/settings.json and trust the project to enable them.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { surface, diagnostics };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Notify prompt-surface diagnostics once per session/tool/diagnostic code. */
|
|
106
|
+
export function notifyToolPromptSurfaceDiagnostics(
|
|
107
|
+
ctx: Pick<ExtensionContext, "sessionManager" | "ui">,
|
|
108
|
+
diagnostics: readonly ToolPromptSurfaceDiagnostic[],
|
|
109
|
+
): void {
|
|
110
|
+
const globalRecord = globalThis as Record<symbol, Set<string> | undefined>;
|
|
111
|
+
const notified = globalRecord[PROMPT_SURFACE_DIAGNOSTICS_KEY] ?? new Set<string>();
|
|
112
|
+
globalRecord[PROMPT_SURFACE_DIAGNOSTICS_KEY] = notified;
|
|
113
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
114
|
+
|
|
115
|
+
for (const diagnostic of diagnostics) {
|
|
116
|
+
const key = `${sessionId}:${diagnostic.section}:${diagnostic.toolName}:${diagnostic.code}`;
|
|
117
|
+
if (notified.has(key)) continue;
|
|
118
|
+
notified.add(key);
|
|
119
|
+
ctx.ui.notify(diagnostic.message, "warning");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Scope helpers ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
// biome-ignore lint/complexity/useMaxParams: resolver dispatch with diagnostics
|
|
126
|
+
function applyPromptSurfaceScope(
|
|
127
|
+
current: SuiPiToolPromptSurface,
|
|
128
|
+
options: ResolveToolPromptSurfaceOptions,
|
|
129
|
+
scope: PromptSurfaceScope,
|
|
130
|
+
sectionConfig: Record<string, unknown> | null,
|
|
131
|
+
diagnostics: ToolPromptSurfaceDiagnostic[],
|
|
132
|
+
): SuiPiToolPromptSurface {
|
|
133
|
+
const promptSurface = getPromptSurfaceConfig(sectionConfig, options.toolName, {
|
|
134
|
+
diagnostics,
|
|
135
|
+
options,
|
|
136
|
+
scope,
|
|
137
|
+
});
|
|
138
|
+
if (!promptSurface) return current;
|
|
139
|
+
return applyPromptSurfaceConfig(
|
|
140
|
+
current,
|
|
141
|
+
options.defaults,
|
|
142
|
+
promptSurface,
|
|
143
|
+
options,
|
|
144
|
+
scope,
|
|
145
|
+
diagnostics,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// biome-ignore lint/complexity/useMaxParams: per-scope config merger with diagnostics
|
|
150
|
+
function applyPromptSurfaceConfig(
|
|
151
|
+
current: SuiPiToolPromptSurface,
|
|
152
|
+
defaults: SuiPiToolPromptSurface,
|
|
153
|
+
config: Record<string, unknown>,
|
|
154
|
+
options: ResolveToolPromptSurfaceOptions,
|
|
155
|
+
scope: PromptSurfaceScope,
|
|
156
|
+
diagnostics: ToolPromptSurfaceDiagnostic[],
|
|
157
|
+
): SuiPiToolPromptSurface {
|
|
158
|
+
let next = clonePromptSurface(current);
|
|
159
|
+
|
|
160
|
+
for (const field of getResetFields(config.$reset, options, scope, diagnostics)) {
|
|
161
|
+
next = { ...next, [field]: clonePromptSurfaceField(defaults[field]) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const description = getOptionalNonEmptyString(
|
|
165
|
+
config.description,
|
|
166
|
+
"description",
|
|
167
|
+
options,
|
|
168
|
+
scope,
|
|
169
|
+
diagnostics,
|
|
170
|
+
);
|
|
171
|
+
if (description !== undefined) next.description = description;
|
|
172
|
+
|
|
173
|
+
const promptSnippet = getOptionalNonEmptyString(
|
|
174
|
+
config.promptSnippet,
|
|
175
|
+
"promptSnippet",
|
|
176
|
+
options,
|
|
177
|
+
scope,
|
|
178
|
+
diagnostics,
|
|
179
|
+
);
|
|
180
|
+
if (promptSnippet !== undefined) next.promptSnippet = promptSnippet;
|
|
181
|
+
|
|
182
|
+
const promptGuidelines = getOptionalStringArray(
|
|
183
|
+
config.promptGuidelines,
|
|
184
|
+
"promptGuidelines",
|
|
185
|
+
options,
|
|
186
|
+
scope,
|
|
187
|
+
diagnostics,
|
|
188
|
+
);
|
|
189
|
+
if (promptGuidelines !== undefined) next.promptGuidelines = promptGuidelines;
|
|
190
|
+
|
|
191
|
+
const prepend = getOptionalStringArray(
|
|
192
|
+
config.prependPromptGuidelines,
|
|
193
|
+
"prependPromptGuidelines",
|
|
194
|
+
options,
|
|
195
|
+
scope,
|
|
196
|
+
diagnostics,
|
|
197
|
+
);
|
|
198
|
+
if (prepend !== undefined) next.promptGuidelines = [...prepend, ...next.promptGuidelines];
|
|
199
|
+
|
|
200
|
+
const append = getOptionalStringArray(
|
|
201
|
+
config.appendPromptGuidelines,
|
|
202
|
+
"appendPromptGuidelines",
|
|
203
|
+
options,
|
|
204
|
+
scope,
|
|
205
|
+
diagnostics,
|
|
206
|
+
);
|
|
207
|
+
if (append !== undefined) next.promptGuidelines = [...next.promptGuidelines, ...append];
|
|
208
|
+
|
|
209
|
+
return next;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Config extraction ──────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
function getPromptSurfaceConfig(
|
|
215
|
+
sectionConfig: Record<string, unknown> | null,
|
|
216
|
+
toolName: string,
|
|
217
|
+
deps: {
|
|
218
|
+
diagnostics: ToolPromptSurfaceDiagnostic[];
|
|
219
|
+
options: ResolveToolPromptSurfaceOptions;
|
|
220
|
+
scope: PromptSurfaceScope;
|
|
221
|
+
},
|
|
222
|
+
): Record<string, unknown> | null {
|
|
223
|
+
if (!sectionConfig) return null;
|
|
224
|
+
if (sectionConfig.tools === undefined) return null;
|
|
225
|
+
if (!isRecord(sectionConfig.tools)) {
|
|
226
|
+
pushInvalidConfig(deps, "tools must be an object.");
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const toolConfig = sectionConfig.tools[toolName];
|
|
230
|
+
if (toolConfig === undefined) return null;
|
|
231
|
+
if (!isRecord(toolConfig)) {
|
|
232
|
+
pushInvalidConfig(deps, `tools.${toolName} must be an object.`);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
if (toolConfig.promptSurface === undefined) return null;
|
|
236
|
+
if (!isRecord(toolConfig.promptSurface)) {
|
|
237
|
+
pushInvalidConfig(deps, `tools.${toolName}.promptSurface must be an object.`);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
return toolConfig.promptSurface;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Field validation ───────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
function getResetFields(
|
|
246
|
+
value: unknown,
|
|
247
|
+
options: ResolveToolPromptSurfaceOptions,
|
|
248
|
+
scope: PromptSurfaceScope,
|
|
249
|
+
diagnostics: ToolPromptSurfaceDiagnostic[],
|
|
250
|
+
): PromptSurfaceField[] {
|
|
251
|
+
if (value === undefined) return [];
|
|
252
|
+
if (!Array.isArray(value)) {
|
|
253
|
+
pushInvalidField(options, scope, diagnostics, "$reset", "must be an array.");
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
const fields: PromptSurfaceField[] = [];
|
|
257
|
+
for (const item of value) {
|
|
258
|
+
if (typeof item === "string" && PROMPT_SURFACE_FIELDS.has(item)) {
|
|
259
|
+
fields.push(item as PromptSurfaceField);
|
|
260
|
+
} else {
|
|
261
|
+
pushInvalidField(
|
|
262
|
+
options,
|
|
263
|
+
scope,
|
|
264
|
+
diagnostics,
|
|
265
|
+
"$reset",
|
|
266
|
+
`contains unsupported field ${JSON.stringify(item)}.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return fields;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// biome-ignore lint/complexity/useMaxParams: validation helper with diagnostics
|
|
274
|
+
function getOptionalNonEmptyString(
|
|
275
|
+
value: unknown,
|
|
276
|
+
field: string,
|
|
277
|
+
options: ResolveToolPromptSurfaceOptions,
|
|
278
|
+
scope: PromptSurfaceScope,
|
|
279
|
+
diagnostics: ToolPromptSurfaceDiagnostic[],
|
|
280
|
+
): string | undefined {
|
|
281
|
+
if (value === undefined) return undefined;
|
|
282
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
283
|
+
pushInvalidField(options, scope, diagnostics, field, "must be a non-empty string.");
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// biome-ignore lint/complexity/useMaxParams: validation helper with diagnostics
|
|
288
|
+
function getOptionalStringArray(
|
|
289
|
+
value: unknown,
|
|
290
|
+
field: string,
|
|
291
|
+
options: ResolveToolPromptSurfaceOptions,
|
|
292
|
+
scope: PromptSurfaceScope,
|
|
293
|
+
diagnostics: ToolPromptSurfaceDiagnostic[],
|
|
294
|
+
): string[] | undefined {
|
|
295
|
+
if (value === undefined) return undefined;
|
|
296
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
297
|
+
return [...value];
|
|
298
|
+
}
|
|
299
|
+
pushInvalidField(options, scope, diagnostics, field, "must be an array of strings.");
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Diagnostics ────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function pushInvalidConfig(
|
|
306
|
+
deps: {
|
|
307
|
+
diagnostics: ToolPromptSurfaceDiagnostic[];
|
|
308
|
+
options: ResolveToolPromptSurfaceOptions;
|
|
309
|
+
scope: PromptSurfaceScope;
|
|
310
|
+
},
|
|
311
|
+
detail: string,
|
|
312
|
+
): void {
|
|
313
|
+
deps.diagnostics.push({
|
|
314
|
+
// biome-ignore lint/security/noSecrets: false positive on string constant
|
|
315
|
+
code: "invalidPromptSurfaceConfig",
|
|
316
|
+
scope: deps.scope,
|
|
317
|
+
section: deps.options.section,
|
|
318
|
+
toolName: deps.options.toolName,
|
|
319
|
+
message: `Invalid prompt-surface config for ${deps.options.section}.${deps.options.toolName}: ${detail}`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// biome-ignore lint/complexity/useMaxParams: diagnostics utility with many fields
|
|
324
|
+
function pushInvalidField(
|
|
325
|
+
options: ResolveToolPromptSurfaceOptions,
|
|
326
|
+
scope: PromptSurfaceScope,
|
|
327
|
+
diagnostics: ToolPromptSurfaceDiagnostic[],
|
|
328
|
+
field: string,
|
|
329
|
+
detail: string,
|
|
330
|
+
): void {
|
|
331
|
+
diagnostics.push({
|
|
332
|
+
// biome-ignore lint/security/noSecrets: false positive on string constant
|
|
333
|
+
code: "invalidPromptSurfaceField",
|
|
334
|
+
scope,
|
|
335
|
+
section: options.section,
|
|
336
|
+
toolName: options.toolName,
|
|
337
|
+
message: `Invalid prompt-surface field ${field} for ${options.section}.${options.toolName}: ${detail}`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Cloning ────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
function clonePromptSurface(surface: SuiPiToolPromptSurface): SuiPiToolPromptSurface {
|
|
344
|
+
return {
|
|
345
|
+
description: surface.description,
|
|
346
|
+
promptSnippet: surface.promptSnippet,
|
|
347
|
+
promptGuidelines: [...surface.promptGuidelines],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function clonePromptSurfaceField<T extends string | string[]>(value: T): T {
|
|
352
|
+
return (Array.isArray(value) ? [...value] : value) as T;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
356
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
357
|
+
}
|
|
@@ -9,7 +9,7 @@ export function registerSettingsCommand(pi: ExtensionAPI): void {
|
|
|
9
9
|
pi.registerCommand("supi-settings", {
|
|
10
10
|
description: "Manage SuPi extension settings",
|
|
11
11
|
handler: async (_args, ctx) => {
|
|
12
|
-
openSettingsOverlay(ctx);
|
|
12
|
+
openSettingsOverlay(pi, ctx);
|
|
13
13
|
},
|
|
14
14
|
});
|
|
15
15
|
}
|
|
@@ -1,48 +1,70 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Event-backed settings contribution types for SuPi extensions.
|
|
2
2
|
//
|
|
3
|
-
// Extensions
|
|
4
|
-
//
|
|
3
|
+
// Extensions contribute config-backed settings sections through PI's shared
|
|
4
|
+
// event bus. The public helper is registerConfigSettings(pi, ...); this module
|
|
5
|
+
// owns the internal collector protocol used by /supi-settings.
|
|
5
6
|
|
|
6
7
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
8
|
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
8
|
-
|
|
9
|
+
|
|
10
|
+
export const SUPI_SETTINGS_COLLECT_EVENT = "supi:settings:collect";
|
|
9
11
|
|
|
10
12
|
export type SettingsScope = "project" | "global";
|
|
11
13
|
|
|
12
14
|
export interface SettingsSection {
|
|
13
|
-
/**
|
|
15
|
+
/** Stable contribution identifier — e.g. "lsp", "claude-md". */
|
|
14
16
|
id: string;
|
|
15
|
-
/** Human-readable label shown in the UI */
|
|
17
|
+
/** Human-readable label shown in the UI. */
|
|
16
18
|
label: string;
|
|
17
|
-
/** Load current SettingItem[] for the given scope */
|
|
19
|
+
/** Load current SettingItem[] for the given scope. */
|
|
18
20
|
loadValues: (scope: SettingsScope, cwd: string, ctx?: ExtensionContext) => SettingItem[];
|
|
19
|
-
/** Persist a
|
|
20
|
-
persistChange: (
|
|
21
|
-
scope: SettingsScope,
|
|
22
|
-
cwd: string,
|
|
23
|
-
settingId: string,
|
|
24
|
-
value: string,
|
|
25
|
-
ctx?: ExtensionContext,
|
|
26
|
-
) => void;
|
|
21
|
+
/** Persist a UI value back to SuPi config. */
|
|
22
|
+
persistChange: (scope: SettingsScope, cwd: string, settingId: string, value: string) => void;
|
|
27
23
|
}
|
|
28
24
|
|
|
29
|
-
|
|
25
|
+
export interface SettingsContributionCollector {
|
|
26
|
+
add(section: SettingsSection): void;
|
|
27
|
+
}
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
* Duplicate ids replace the previous registration.
|
|
35
|
-
*/
|
|
36
|
-
export function registerSettings(section: SettingsSection): void {
|
|
37
|
-
registry.register(section.id, section);
|
|
29
|
+
export interface SettingsCollectionDiagnostic {
|
|
30
|
+
kind: "warning";
|
|
31
|
+
message: string;
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
export interface SettingsCollectionResult {
|
|
35
|
+
sections: SettingsSection[];
|
|
36
|
+
diagnostics: SettingsCollectionDiagnostic[];
|
|
43
37
|
}
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
export function isSettingsContributionCollector(
|
|
40
|
+
value: unknown,
|
|
41
|
+
): value is SettingsContributionCollector {
|
|
42
|
+
return (
|
|
43
|
+
typeof value === "object" &&
|
|
44
|
+
value !== null &&
|
|
45
|
+
typeof (value as { add?: unknown }).add === "function"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Create a collector with last-wins duplicate handling and warning diagnostics. */
|
|
50
|
+
export function createSettingsContributionCollector(): SettingsContributionCollector & {
|
|
51
|
+
result(): SettingsCollectionResult;
|
|
52
|
+
} {
|
|
53
|
+
const sections = new Map<string, SettingsSection>();
|
|
54
|
+
const diagnostics: SettingsCollectionDiagnostic[] = [];
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
add(section: SettingsSection): void {
|
|
58
|
+
if (sections.has(section.id)) {
|
|
59
|
+
diagnostics.push({
|
|
60
|
+
kind: "warning",
|
|
61
|
+
message: `Duplicate SuPi settings contribution "${section.id}"; using the last contribution.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
sections.set(section.id, section);
|
|
65
|
+
},
|
|
66
|
+
result(): SettingsCollectionResult {
|
|
67
|
+
return { sections: Array.from(sections.values()), diagnostics: [...diagnostics] };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
48
70
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Uses pi-tui's SettingsList with scope toggle (Tab), extension grouping,
|
|
4
4
|
// and search. Each extension declares its settings via registerSettings().
|
|
5
5
|
|
|
6
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import {
|
|
9
9
|
Container,
|
|
@@ -19,9 +19,11 @@ import {
|
|
|
19
19
|
} from "@earendil-works/pi-tui";
|
|
20
20
|
import { getSelectableModels } from "../model-selection.ts";
|
|
21
21
|
import {
|
|
22
|
-
|
|
22
|
+
createSettingsContributionCollector,
|
|
23
|
+
type SettingsCollectionDiagnostic,
|
|
23
24
|
type SettingsScope,
|
|
24
25
|
type SettingsSection,
|
|
26
|
+
SUPI_SETTINGS_COLLECT_EVENT,
|
|
25
27
|
} from "./settings-registry.ts";
|
|
26
28
|
|
|
27
29
|
// ── Input submenu component ──────────────────────────────────
|
|
@@ -73,9 +75,15 @@ export function createInputSubmenu(
|
|
|
73
75
|
|
|
74
76
|
// ── Types ────────────────────────────────────────────────────
|
|
75
77
|
|
|
78
|
+
interface OverlayStatus {
|
|
79
|
+
kind: "warning" | "error";
|
|
80
|
+
message: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
interface OverlayState {
|
|
77
84
|
scope: SettingsScope;
|
|
78
85
|
cwd: string;
|
|
86
|
+
status?: OverlayStatus;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
// ── Pure helpers ─────────────────────────────────────────────
|
|
@@ -117,10 +125,27 @@ function findSectionAndId(
|
|
|
117
125
|
return { section, itemId };
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
function collectSettingsSections(pi: ExtensionAPI) {
|
|
129
|
+
const collector = createSettingsContributionCollector();
|
|
130
|
+
pi.events.emit(SUPI_SETTINGS_COLLECT_EVENT, collector);
|
|
131
|
+
return collector.result();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function latestStatus(diagnostics: SettingsCollectionDiagnostic[]): OverlayStatus | undefined {
|
|
135
|
+
const latest = diagnostics.at(-1);
|
|
136
|
+
return latest ? { kind: latest.kind, message: latest.message } : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatSettingsError(settingId: string, error: unknown): string {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
return `Could not save SuPi setting "${settingId}": ${message}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
120
144
|
// ── Component ────────────────────────────────────────────────
|
|
121
145
|
|
|
122
146
|
interface SettingsOverlayDeps {
|
|
123
147
|
ctx: ExtensionContext;
|
|
148
|
+
sections: SettingsSection[];
|
|
124
149
|
state: OverlayState;
|
|
125
150
|
container: Container;
|
|
126
151
|
settingsList: SettingsList | null;
|
|
@@ -130,22 +155,23 @@ interface SettingsOverlayDeps {
|
|
|
130
155
|
}
|
|
131
156
|
|
|
132
157
|
function createSettingsList(deps: SettingsOverlayDeps): SettingsList {
|
|
133
|
-
const
|
|
134
|
-
const items = buildFlatItems(sections, deps.state.scope, deps.state.cwd, deps.ctx);
|
|
158
|
+
const items = buildFlatItems(deps.sections, deps.state.scope, deps.state.cwd, deps.ctx);
|
|
135
159
|
const onChange = (flatId: string, newValue: string) => {
|
|
136
|
-
const found = findSectionAndId(sections, flatId);
|
|
137
|
-
|
|
138
|
-
found
|
|
139
|
-
deps.state.scope,
|
|
140
|
-
deps.state.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
const found = findSectionAndId(deps.sections, flatId);
|
|
161
|
+
try {
|
|
162
|
+
if (found) {
|
|
163
|
+
found.section.persistChange(deps.state.scope, deps.state.cwd, found.itemId, newValue);
|
|
164
|
+
deps.state.status = undefined;
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
deps.state.status = {
|
|
168
|
+
kind: "error",
|
|
169
|
+
message: formatSettingsError(found?.itemId ?? flatId, error),
|
|
170
|
+
};
|
|
145
171
|
}
|
|
146
172
|
// Re-read all values to reflect persisted changes, but keep the list
|
|
147
173
|
// instance (and its selectedIndex) intact.
|
|
148
|
-
const updatedItems = buildFlatItems(sections, deps.state.scope, deps.state.cwd, deps.ctx);
|
|
174
|
+
const updatedItems = buildFlatItems(deps.sections, deps.state.scope, deps.state.cwd, deps.ctx);
|
|
149
175
|
for (const updated of updatedItems) {
|
|
150
176
|
const existing = items.find((i) => i.id === updated.id);
|
|
151
177
|
if (existing && existing.currentValue !== updated.currentValue) {
|
|
@@ -171,6 +197,7 @@ function rebuildSettingsList(deps: SettingsOverlayDeps): SettingsList {
|
|
|
171
197
|
|
|
172
198
|
deps.container.clear();
|
|
173
199
|
deps.container.addChild(createHeaderComponent(deps));
|
|
200
|
+
deps.container.addChild(createStatusComponent(deps));
|
|
174
201
|
deps.container.addChild(settingsList);
|
|
175
202
|
|
|
176
203
|
return settingsList;
|
|
@@ -188,6 +215,20 @@ function createHeaderComponent(deps: SettingsOverlayDeps): Text {
|
|
|
188
215
|
return headerText;
|
|
189
216
|
}
|
|
190
217
|
|
|
218
|
+
function createStatusComponent(deps: SettingsOverlayDeps): {
|
|
219
|
+
render: () => string[];
|
|
220
|
+
invalidate: () => void;
|
|
221
|
+
} {
|
|
222
|
+
return {
|
|
223
|
+
render: () => {
|
|
224
|
+
const status = deps.state.status;
|
|
225
|
+
if (!status) return [];
|
|
226
|
+
return [deps.theme.fg(status.kind, status.message)];
|
|
227
|
+
},
|
|
228
|
+
invalidate: () => {},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
191
232
|
function handleScopeToggle(deps: SettingsOverlayDeps): void {
|
|
192
233
|
deps.state.scope = deps.state.scope === "project" ? "global" : "project";
|
|
193
234
|
rebuildSettingsList(deps);
|
|
@@ -288,19 +329,24 @@ function buildModelItems(ctx?: ExtensionContext): SelectItem[] {
|
|
|
288
329
|
|
|
289
330
|
// ── Entry point ──────────────────────────────────────────────
|
|
290
331
|
|
|
291
|
-
export function openSettingsOverlay(ctx: ExtensionContext): void {
|
|
292
|
-
const
|
|
293
|
-
if (sections.length === 0) {
|
|
332
|
+
export function openSettingsOverlay(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
333
|
+
const collection = collectSettingsSections(pi);
|
|
334
|
+
if (collection.sections.length === 0) {
|
|
294
335
|
ctx.ui.notify("No settings registered by SuPi extensions", "info");
|
|
295
336
|
return;
|
|
296
337
|
}
|
|
297
338
|
|
|
298
339
|
void ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
299
|
-
const state: OverlayState = {
|
|
340
|
+
const state: OverlayState = {
|
|
341
|
+
scope: "project",
|
|
342
|
+
cwd: ctx.cwd,
|
|
343
|
+
status: latestStatus(collection.diagnostics),
|
|
344
|
+
};
|
|
300
345
|
const container = new Container();
|
|
301
346
|
|
|
302
347
|
const deps: SettingsOverlayDeps = {
|
|
303
348
|
ctx,
|
|
349
|
+
sections: collection.sections,
|
|
304
350
|
state,
|
|
305
351
|
container,
|
|
306
352
|
settingsList: null,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
// supi-core settings domain — settings
|
|
1
|
+
// supi-core settings domain — event-backed settings contribution types and command wiring.
|
|
2
2
|
|
|
3
3
|
export { registerSettingsCommand } from "./settings/settings-command.ts";
|
|
4
|
-
export type {
|
|
4
|
+
export type {
|
|
5
|
+
SettingsCollectionDiagnostic,
|
|
6
|
+
SettingsCollectionResult,
|
|
7
|
+
SettingsContributionCollector,
|
|
8
|
+
SettingsScope,
|
|
9
|
+
SettingsSection,
|
|
10
|
+
} from "./settings/settings-registry.ts";
|
|
5
11
|
export {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
createSettingsContributionCollector,
|
|
13
|
+
isSettingsContributionCollector,
|
|
14
|
+
SUPI_SETTINGS_COLLECT_EVENT,
|
|
9
15
|
} from "./settings/settings-registry.ts";
|
|
@@ -53,6 +53,16 @@ export function derivePromptSurface(spec: SuiPiToolSpec): SuiPiToolPromptSurface
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Re-export prompt-surface types (implemented in config/prompt-surface.ts)
|
|
57
|
+
export {
|
|
58
|
+
notifyToolPromptSurfaceDiagnostics,
|
|
59
|
+
type ResolveToolPromptSurfaceOptions,
|
|
60
|
+
type ResolveToolPromptSurfaceResult,
|
|
61
|
+
resolveToolPromptSurface,
|
|
62
|
+
type ToolPromptSurfaceDiagnostic,
|
|
63
|
+
type ToolPromptSurfaceDiagnosticCode,
|
|
64
|
+
} from "./config/prompt-surface.ts";
|
|
65
|
+
|
|
56
66
|
// ---------------------------------------------------------------------------
|
|
57
67
|
// Registration
|
|
58
68
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-review",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "SuPi Review extension — structured code review via /supi-review command",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@mrclrchtr/supi-core": "2.0
|
|
34
|
+
"@mrclrchtr/supi-core": "2.1.0"
|
|
35
35
|
},
|
|
36
36
|
"bundledDependencies": [
|
|
37
37
|
"@mrclrchtr/supi-core"
|