@mrclrchtr/supi-settings 4.9.0 → 5.0.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 +64 -24
- package/node_modules/@mrclrchtr/supi-core/README.md +2 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +19 -3
- package/node_modules/@mrclrchtr/supi-core/src/debug-timing.ts +34 -21
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +3 -0
- package/package.json +2 -2
- package/src/ui/scoped-settings-list.ts +29 -21
- package/src/ui/settings-list-layout.ts +19 -0
- package/src/ui/settings-row-model.ts +39 -7
- package/src/ui/settings-ui.ts +12 -3
package/README.md
CHANGED
|
@@ -6,56 +6,96 @@
|
|
|
6
6
|
|
|
7
7
|
# @mrclrchtr/supi-settings
|
|
8
8
|
|
|
9
|
-
SuPi Settings
|
|
9
|
+
SuPi Settings is a Pi extension and a small public UI library. It adds one interactive screen for the project and global settings that loaded SuPi extensions contribute.
|
|
10
|
+
|
|
11
|
+
This package is part of the SuPi release stack.
|
|
10
12
|
|
|
11
13
|
## Install
|
|
12
14
|
|
|
15
|
+
Install the Pi extension:
|
|
16
|
+
|
|
13
17
|
```bash
|
|
14
18
|
pi install npm:@mrclrchtr/supi-settings
|
|
15
19
|
```
|
|
16
20
|
|
|
21
|
+
If Pi is open, run `/reload` after installation.
|
|
22
|
+
|
|
17
23
|
For local development:
|
|
18
24
|
|
|
19
25
|
```bash
|
|
20
26
|
pi install ./packages/supi-settings
|
|
21
27
|
```
|
|
22
28
|
|
|
23
|
-
## What
|
|
29
|
+
## What it adds
|
|
30
|
+
|
|
31
|
+
The extension registers one slash command:
|
|
32
|
+
|
|
33
|
+
- **`/supi-settings`** — open a searchable settings screen
|
|
34
|
+
|
|
35
|
+
The package does not register a tool, a shortcut, or its own setting. The screen contains settings from other loaded SuPi extensions. If no extension contributes settings, Pi shows `No settings registered by SuPi extensions`.
|
|
36
|
+
|
|
37
|
+
The screen is available in Pi interactive TUI mode. Print, JSON, and RPC modes do not provide this custom screen.
|
|
38
|
+
|
|
39
|
+
## Use the settings screen
|
|
40
|
+
|
|
41
|
+
The screen starts in project scope.
|
|
24
42
|
|
|
25
|
-
|
|
43
|
+
| Input | Action |
|
|
44
|
+
|---|---|
|
|
45
|
+
| Type text | Filter by section, setting, key, or displayed value |
|
|
46
|
+
| Up or Down | Move through the settings |
|
|
47
|
+
| Enter | Open the actions for the selected setting |
|
|
48
|
+
| Space | Cycle a boolean, enum, or fixed numeric choice |
|
|
49
|
+
| Tab | Switch between project and global scope |
|
|
50
|
+
| Escape | Close the current menu or the settings screen |
|
|
26
51
|
|
|
27
|
-
|
|
52
|
+
Each value can have a `(project)`, `(global)`, or `(default)` source badge.
|
|
28
53
|
|
|
29
|
-
|
|
54
|
+
In project scope, a project value overrides a global value or the package default. **Inherit from global** and **Use default** delete the project value. In global scope, **Reset to default** deletes the global value.
|
|
30
55
|
|
|
31
|
-
|
|
56
|
+
Free-text, list, model, and custom settings open a matching editor or picker when the contributing module provides one.
|
|
32
57
|
|
|
33
|
-
|
|
58
|
+
## Storage and boundaries
|
|
34
59
|
|
|
35
|
-
|
|
60
|
+
Each contributing settings module owns its reads, writes, validation, refresh behavior, and notices. The screen waits for a write to finish and then reads a new snapshot. A failure in one module does not hide settings from modules that loaded successfully.
|
|
36
61
|
|
|
37
|
-
|
|
62
|
+
Modules that use the standard `@mrclrchtr/supi-core` config adapter store values in:
|
|
38
63
|
|
|
39
|
-
|
|
64
|
+
- global: `~/.pi/agent/supi/config.json`
|
|
65
|
+
- project: `<cwd>/.pi/supi/config.json`
|
|
66
|
+
|
|
67
|
+
A custom module can use a different store. SuPi Settings does not call a model or a remote service itself, but a contributed module controls its own operations. Pi extensions run with the permissions of the Pi process.
|
|
68
|
+
|
|
69
|
+
## Public API
|
|
70
|
+
|
|
71
|
+
Add the package as a dependency when another extension needs the reusable UI helpers:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pnpm add @mrclrchtr/supi-settings
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Import only from the explicit API subpath:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import {
|
|
81
|
+
createInputSubmenu,
|
|
82
|
+
createModelPickerSubmenu,
|
|
83
|
+
openSettingsOverlay,
|
|
84
|
+
} from "@mrclrchtr/supi-settings/api";
|
|
85
|
+
```
|
|
40
86
|
|
|
41
|
-
|
|
87
|
+
The API exports:
|
|
42
88
|
|
|
43
|
-
- `
|
|
44
|
-
- `
|
|
45
|
-
- `
|
|
46
|
-
- `supi-cache` — prompt-cache monitoring and history collection
|
|
47
|
-
- `supi-debug` — debug event capture and retention
|
|
48
|
-
- `supi-insights` — report-generation options
|
|
49
|
-
- `supi-skills` — skill load and model-invocation controls
|
|
89
|
+
- `openSettingsOverlay(pi, ctx)` — collect the current settings modules and open the screen.
|
|
90
|
+
- `createInputSubmenu(currentValue, label, done)` — create a free-text submenu with confirm and cancel handling.
|
|
91
|
+
- `createModelPickerSubmenu(currentValue, done, ctx?, options?)` — create a picker from static choices and models that match Pi's configured `enabledModels` patterns. The picker includes `disabled` by default.
|
|
50
92
|
|
|
51
|
-
|
|
93
|
+
The `@mrclrchtr/supi-settings/extension` subpath exports the Pi extension factory that registers `/supi-settings`.
|
|
52
94
|
|
|
53
|
-
|
|
54
|
-
- `@mrclrchtr/supi-settings/api` — settings UI and submenu helpers
|
|
95
|
+
Settings modules use `registerSettings()` and the `SettingsModule` types from `@mrclrchtr/supi-core/settings`. Their `read()` and `apply()` methods are asynchronous. The screen collects the modules when the command opens.
|
|
55
96
|
|
|
56
97
|
## Source layout
|
|
57
98
|
|
|
58
|
-
- `src/extension.ts` —
|
|
59
|
-
- `src/api.ts` —
|
|
99
|
+
- `src/extension.ts` — Pi extension entrypoint
|
|
100
|
+
- `src/api.ts` — public UI exports
|
|
60
101
|
- `src/ui/` — settings screen, scoped list, action menu, and submenus
|
|
61
|
-
- `@mrclrchtr/supi-core/settings` owns the registry, schema, scope resolution, and persistence
|
|
@@ -28,6 +28,7 @@ pnpm add @mrclrchtr/supi-core
|
|
|
28
28
|
- `loadSupiConfig()` — merged config with resolution order `defaults <- global <- project`
|
|
29
29
|
- `loadSupiConfigForScope()` — load one scope at a time for settings UIs
|
|
30
30
|
- `writeSupiConfig()` — persist values
|
|
31
|
+
- `replaceSupiConfigSection()` — replace one nested section while preserving other sections
|
|
31
32
|
- `removeSupiConfigKey()` — remove a key or override
|
|
32
33
|
|
|
33
34
|
Config file locations:
|
|
@@ -49,6 +50,7 @@ Config file locations:
|
|
|
49
50
|
|
|
50
51
|
- context-provider registry for `/supi-context`
|
|
51
52
|
- debug-event registry and monotonic phase timers for producers that want shared debug capture
|
|
53
|
+
- optional Debug Operation IDs for exact, directly owned public Tool-call correlation; ambient events stay uncorrelated
|
|
52
54
|
- settings registry used by `/supi-settings`
|
|
53
55
|
|
|
54
56
|
### Project and session helpers
|
|
@@ -148,6 +148,37 @@ export function writeSupiConfig(
|
|
|
148
148
|
fs.writeFileSync(configPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Replace one complete config section while preserving other sections.
|
|
153
|
+
*
|
|
154
|
+
* This is useful for nested settings that must remove stale keys as part of
|
|
155
|
+
* one update. An empty section is removed from the config file.
|
|
156
|
+
*/
|
|
157
|
+
export function replaceSupiConfigSection(
|
|
158
|
+
loc: SupiConfigLocation,
|
|
159
|
+
value: Record<string, unknown>,
|
|
160
|
+
options?: SupiConfigOptions,
|
|
161
|
+
): void {
|
|
162
|
+
const configPath = getSupiConfigPath(loc.scope, loc.cwd, options);
|
|
163
|
+
const existing = readJsonFile(configPath) ?? {};
|
|
164
|
+
|
|
165
|
+
if (Object.keys(value).length > 0) existing[loc.section] = value;
|
|
166
|
+
else delete existing[loc.section];
|
|
167
|
+
|
|
168
|
+
const content = Object.keys(existing).length > 0 ? `${JSON.stringify(existing, null, 2)}\n` : "";
|
|
169
|
+
if (content) {
|
|
170
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
171
|
+
fs.writeFileSync(configPath, content, "utf-8");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(configPath);
|
|
177
|
+
} catch {
|
|
178
|
+
// File may not exist.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
/**
|
|
152
183
|
* Remove a key from a config section.
|
|
153
184
|
* Used by `interval default` to remove the project override.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// supi-core config domain — config loading.
|
|
2
2
|
export type { SupiConfigLocation, SupiConfigOptions } from "./config/config.ts";
|
|
3
3
|
export {
|
|
4
|
+
getSupiConfigPath,
|
|
4
5
|
loadSupiConfig,
|
|
5
6
|
loadSupiConfigForScope,
|
|
6
7
|
loadSupiConfigSectionForScope,
|
|
7
8
|
readJsonFile,
|
|
8
9
|
removeSupiConfigKey,
|
|
10
|
+
replaceSupiConfigSection,
|
|
9
11
|
writeSupiConfig,
|
|
10
12
|
} from "./config/config.ts";
|
|
@@ -25,6 +25,8 @@ export const DEBUG_REGISTRY_DEFAULTS: DebugRegistryConfig = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
export interface DebugEventInput {
|
|
28
|
+
/** Opaque identity for events directly owned by one public Tool call. */
|
|
29
|
+
operationId?: string;
|
|
28
30
|
source: string;
|
|
29
31
|
level: DebugLevel;
|
|
30
32
|
category: string;
|
|
@@ -42,6 +44,8 @@ export interface DebugEvent extends DebugEventInput {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface DebugEventQuery {
|
|
47
|
+
/** Match one exact Debug Operation ID. */
|
|
48
|
+
operationId?: string;
|
|
45
49
|
source?: string;
|
|
46
50
|
level?: DebugLevel;
|
|
47
51
|
category?: string;
|
|
@@ -53,6 +57,7 @@ export interface DebugEventQuery {
|
|
|
53
57
|
export interface DebugEventView {
|
|
54
58
|
id: number;
|
|
55
59
|
timestamp: number;
|
|
60
|
+
operationId?: string;
|
|
56
61
|
source: string;
|
|
57
62
|
level: DebugLevel;
|
|
58
63
|
category: string;
|
|
@@ -84,6 +89,7 @@ interface DebugRegistryState {
|
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
const REGISTRY_KEY = Symbol.for("@mrclrchtr/supi-core/debug-registry");
|
|
92
|
+
const DEBUG_OPERATION_ID_RE = /^op-[A-Za-z0-9_-]{21}[AQgw]$/;
|
|
87
93
|
const SECRET_KEY_RE = /(?:token|password|passwd|secret|api[_-]?key|authorization|credential)/i;
|
|
88
94
|
const ENV_SECRET_RE =
|
|
89
95
|
/\b([A-Za-z0-9_]*(?:token|password|passwd|secret|api[_-]?key|authorization|credential)[A-Za-z0-9_]*)=(?:'[^']*'|"[^"]*"|\S+)/gi;
|
|
@@ -135,11 +141,17 @@ export function isDebugLevel(value: unknown): value is DebugLevel {
|
|
|
135
141
|
return value === "debug" || value === "info" || value === "warning" || value === "error";
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
/**
|
|
144
|
+
/** Return whether a value has the exact 16-byte base64url Debug Operation ID form. */
|
|
145
|
+
export function isDebugOperationId(value: unknown): value is string {
|
|
146
|
+
return typeof value === "string" && DEBUG_OPERATION_ID_RE.test(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Match a debug event against the supported exact filters. */
|
|
139
150
|
export function matchesDebugEventQuery(
|
|
140
|
-
event: Pick<DebugEventView, "source" | "level" | "category">,
|
|
141
|
-
query: Pick<DebugEventQuery, "source" | "level" | "category">,
|
|
151
|
+
event: Pick<DebugEventView, "operationId" | "source" | "level" | "category">,
|
|
152
|
+
query: Pick<DebugEventQuery, "operationId" | "source" | "level" | "category">,
|
|
142
153
|
): boolean {
|
|
154
|
+
if (query.operationId && event.operationId !== query.operationId) return false;
|
|
143
155
|
if (query.source && event.source !== query.source) return false;
|
|
144
156
|
if (query.level && event.level !== query.level) return false;
|
|
145
157
|
if (query.category && event.category !== query.category) return false;
|
|
@@ -197,6 +209,7 @@ function toSanitizedView(event: DebugEvent): DebugEventView {
|
|
|
197
209
|
return {
|
|
198
210
|
id: event.id,
|
|
199
211
|
timestamp: event.timestamp,
|
|
212
|
+
operationId: event.operationId,
|
|
200
213
|
source: event.source,
|
|
201
214
|
level: event.level,
|
|
202
215
|
category: event.category,
|
|
@@ -216,6 +229,9 @@ export function subscribeDebugEvents(listener: DebugEventListener): () => void {
|
|
|
216
229
|
/** Record a session-local debug event if debugging is enabled. */
|
|
217
230
|
export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
|
|
218
231
|
const state = getState();
|
|
232
|
+
if (input.operationId !== undefined && !isDebugOperationId(input.operationId)) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
219
235
|
if (!state.config.enabled) {
|
|
220
236
|
return null;
|
|
221
237
|
}
|
|
@@ -46,13 +46,21 @@ export interface DebugTimer {
|
|
|
46
46
|
* names are accumulated. Event data reserves the `timing` field. When Debug is
|
|
47
47
|
* disabled at start, this returns a no-op timer and does not read the clock.
|
|
48
48
|
* Pass a factory to `finish()` to avoid event-data construction when disabled.
|
|
49
|
+
* Clock, event-construction, and registry failures are isolated from the
|
|
50
|
+
* measured operation and make the timer a no-op.
|
|
49
51
|
*/
|
|
50
52
|
export function startDebugTimer(options: DebugTimerOptions = {}): DebugTimer {
|
|
51
53
|
if (!isDebugRegistryEnabled()) return DISABLED_DEBUG_TIMER;
|
|
52
54
|
const now = options.now ?? performance.now.bind(performance);
|
|
53
|
-
|
|
55
|
+
let startedAt: number;
|
|
56
|
+
try {
|
|
57
|
+
startedAt = now();
|
|
58
|
+
} catch {
|
|
59
|
+
return DISABLED_DEBUG_TIMER;
|
|
60
|
+
}
|
|
54
61
|
let previousAt = startedAt;
|
|
55
62
|
let finished = false;
|
|
63
|
+
let failed = false;
|
|
56
64
|
const phases = new Map<string, number>();
|
|
57
65
|
|
|
58
66
|
const markAt = (phase: string, current: number): void => {
|
|
@@ -65,30 +73,35 @@ export function startDebugTimer(options: DebugTimerOptions = {}): DebugTimer {
|
|
|
65
73
|
return {
|
|
66
74
|
enabled: true,
|
|
67
75
|
mark(phase) {
|
|
68
|
-
if (finished) return;
|
|
69
|
-
|
|
76
|
+
if (finished || failed) return;
|
|
77
|
+
try {
|
|
78
|
+
markAt(phase, now());
|
|
79
|
+
} catch {
|
|
80
|
+
failed = true;
|
|
81
|
+
}
|
|
70
82
|
},
|
|
71
83
|
finish(input, finalPhase) {
|
|
72
|
-
if (finished) return null;
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
if (finished || failed) return null;
|
|
85
|
+
finished = true;
|
|
86
|
+
try {
|
|
87
|
+
if (!isDebugRegistryEnabled()) return null;
|
|
88
|
+
const completedAt = now();
|
|
89
|
+
if (finalPhase) markAt(finalPhase, completedAt);
|
|
90
|
+
const phasesMs = Object.fromEntries(
|
|
91
|
+
[...phases.entries()].map(([name, value]) => [name, duration(value)]),
|
|
92
|
+
);
|
|
93
|
+
const timing: DebugTiming = {
|
|
94
|
+
durationMs: duration(completedAt - startedAt),
|
|
95
|
+
phasesMs,
|
|
96
|
+
};
|
|
97
|
+
const eventInput = typeof input === "function" ? input() : input;
|
|
98
|
+
return recordDebugEvent({
|
|
99
|
+
...eventInput,
|
|
100
|
+
data: { ...eventInput.data, timing },
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
75
103
|
return null;
|
|
76
104
|
}
|
|
77
|
-
const completedAt = now();
|
|
78
|
-
if (finalPhase) markAt(finalPhase, completedAt);
|
|
79
|
-
finished = true;
|
|
80
|
-
const phasesMs = Object.fromEntries(
|
|
81
|
-
[...phases.entries()].map(([name, value]) => [name, duration(value)]),
|
|
82
|
-
);
|
|
83
|
-
const timing: DebugTiming = {
|
|
84
|
-
durationMs: duration(completedAt - startedAt),
|
|
85
|
-
phasesMs,
|
|
86
|
-
};
|
|
87
|
-
const eventInput = typeof input === "function" ? input() : input;
|
|
88
|
-
return recordDebugEvent({
|
|
89
|
-
...eventInput,
|
|
90
|
-
data: { ...eventInput.data, timing },
|
|
91
|
-
});
|
|
92
105
|
},
|
|
93
106
|
};
|
|
94
107
|
}
|
|
@@ -41,7 +41,10 @@ export interface SettingsApplyResult {
|
|
|
41
41
|
*/
|
|
42
42
|
export interface SettingsModule {
|
|
43
43
|
id: string;
|
|
44
|
+
/** Human-readable section label shown in the UI. */
|
|
44
45
|
label: string;
|
|
46
|
+
/** Optional label that groups this module within its section. */
|
|
47
|
+
subsection?: string;
|
|
45
48
|
read(context: SettingsContext): Promise<SettingsSnapshot>;
|
|
46
49
|
apply(request: SettingsActionRequest): Promise<SettingsApplyResult>;
|
|
47
50
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-settings",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "One project/global settings UI for SuPi packages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"!__tests__"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@mrclrchtr/supi-core": "
|
|
33
|
+
"@mrclrchtr/supi-core": "5.0.0"
|
|
34
34
|
},
|
|
35
35
|
"bundledDependencies": [
|
|
36
36
|
"@mrclrchtr/supi-core"
|
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
// Source-aware settings list with scope actions and custom submenus.
|
|
2
|
-
|
|
3
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import {
|
|
5
|
-
Input,
|
|
6
|
-
Key,
|
|
7
|
-
matchesKey,
|
|
8
|
-
truncateToWidth,
|
|
9
|
-
visibleWidth,
|
|
10
|
-
wrapTextWithAnsi,
|
|
11
|
-
} from "@earendil-works/pi-tui";
|
|
2
|
+
import { Input, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
3
|
import type { SettingsAction, SettingsModule, SettingsScope } from "@mrclrchtr/supi-core/settings";
|
|
13
4
|
import type { ThemeAccessor } from "./settings-action-menu.ts";
|
|
14
5
|
import {
|
|
@@ -16,8 +7,14 @@ import {
|
|
|
16
7
|
createActionMenuComponent,
|
|
17
8
|
getConcreteChoices,
|
|
18
9
|
} from "./settings-action-menu.ts";
|
|
10
|
+
import { renderDescriptionViewport } from "./settings-list-layout.ts";
|
|
19
11
|
import { type LoadedSettingsModule, readSettingsModules } from "./settings-module-reader.ts";
|
|
20
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
filterSettingsRows,
|
|
14
|
+
maxVisibleHeaderCount,
|
|
15
|
+
rowsFromModules,
|
|
16
|
+
type ScopedRow,
|
|
17
|
+
} from "./settings-row-model.ts";
|
|
21
18
|
import { createInputSubmenu, createModelPickerSubmenu } from "./settings-submenus.ts";
|
|
22
19
|
|
|
23
20
|
interface SubmenuState {
|
|
@@ -27,7 +24,6 @@ interface SubmenuState {
|
|
|
27
24
|
handleInput?: (data: string) => void;
|
|
28
25
|
};
|
|
29
26
|
}
|
|
30
|
-
|
|
31
27
|
export class ScopedSettingsList {
|
|
32
28
|
private modules: SettingsModule[];
|
|
33
29
|
private scope: SettingsScope;
|
|
@@ -68,7 +64,6 @@ export class ScopedSettingsList {
|
|
|
68
64
|
this.onError = onError;
|
|
69
65
|
this.rebuildRows(initial);
|
|
70
66
|
}
|
|
71
|
-
|
|
72
67
|
async reload(scope: SettingsScope, cwd: string, ctx?: ExtensionContext): Promise<void> {
|
|
73
68
|
this.scope = scope;
|
|
74
69
|
this.cwd = cwd;
|
|
@@ -91,12 +86,10 @@ export class ScopedSettingsList {
|
|
|
91
86
|
hasOpenSubmenu(): boolean {
|
|
92
87
|
return this.submenu !== null || this.actionPending;
|
|
93
88
|
}
|
|
94
|
-
|
|
95
89
|
invalidate(): void {
|
|
96
90
|
this.cachedWidth = undefined;
|
|
97
91
|
this.cachedLines = undefined;
|
|
98
92
|
}
|
|
99
|
-
|
|
100
93
|
render(width: number): string[] {
|
|
101
94
|
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
102
95
|
if (this.submenu) {
|
|
@@ -108,7 +101,6 @@ export class ScopedSettingsList {
|
|
|
108
101
|
this.cachedLines = this.renderList(width);
|
|
109
102
|
return this.cachedLines;
|
|
110
103
|
}
|
|
111
|
-
|
|
112
104
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: One pass handles filtering, grouping, selection, scrolling, and descriptions.
|
|
113
105
|
private renderList(width: number): string[] {
|
|
114
106
|
const lines: string[] = [];
|
|
@@ -139,6 +131,8 @@ export class ScopedSettingsList {
|
|
|
139
131
|
Math.max(...displayRows.map((row) => visibleWidth(row.field.field.label))),
|
|
140
132
|
);
|
|
141
133
|
let previousSection: string | undefined;
|
|
134
|
+
let previousSubsection: string | undefined;
|
|
135
|
+
let visibleHeaderCount = 0;
|
|
142
136
|
for (let i = start; i < end; i++) {
|
|
143
137
|
const row = displayRows[i];
|
|
144
138
|
if (!row) continue;
|
|
@@ -146,27 +140,41 @@ export class ScopedSettingsList {
|
|
|
146
140
|
lines.push(
|
|
147
141
|
truncateToWidth(` ${this.theme.fg("muted", this.theme.bold(row.moduleLabel))}`, width),
|
|
148
142
|
);
|
|
143
|
+
visibleHeaderCount++;
|
|
149
144
|
previousSection = row.moduleLabel;
|
|
145
|
+
previousSubsection = undefined;
|
|
146
|
+
}
|
|
147
|
+
if (row.subsectionLabel !== previousSubsection) {
|
|
148
|
+
previousSubsection = row.subsectionLabel;
|
|
149
|
+
if (row.subsectionLabel) {
|
|
150
|
+
lines.push(truncateToWidth(` ${this.theme.fg("dim", row.subsectionLabel)}`, width));
|
|
151
|
+
visibleHeaderCount++;
|
|
152
|
+
}
|
|
150
153
|
}
|
|
151
154
|
const isSelected = i === this.selectedIndex;
|
|
152
|
-
const
|
|
155
|
+
const indent = row.subsectionLabel ? 6 : 4;
|
|
156
|
+
const prefix = isSelected
|
|
157
|
+
? `${" ".repeat(indent - 2)}${this.theme.fg("accent", "→ ")}`
|
|
158
|
+
: " ".repeat(indent);
|
|
153
159
|
const label = row.field.field.label.padEnd(
|
|
154
160
|
row.field.field.label.length + maxLabelWidth - visibleWidth(row.field.field.label),
|
|
155
161
|
);
|
|
156
162
|
const labelText = this.theme.fg(isSelected ? "accent" : "text", label);
|
|
157
|
-
const valueWidth = Math.max(0, width -
|
|
163
|
+
const valueWidth = Math.max(0, width - indent - maxLabelWidth - 2);
|
|
158
164
|
const value = truncateToWidth(row.field.displayValue, valueWidth, "");
|
|
159
165
|
const valueText = this.theme.fg(isSelected ? "accent" : "muted", value);
|
|
160
166
|
lines.push(truncateToWidth(`${prefix}${labelText} ${valueText}`, width));
|
|
161
167
|
}
|
|
168
|
+
const reservedHeaderCount = maxVisibleHeaderCount(displayRows, maxVisible);
|
|
169
|
+
for (let i = visibleHeaderCount; i < reservedHeaderCount; i++) lines.push("");
|
|
162
170
|
if (start > 0 || end < displayRows.length) {
|
|
163
171
|
lines.push(this.theme.fg("dim", ` (${this.selectedIndex + 1}/${displayRows.length})`));
|
|
164
172
|
}
|
|
165
173
|
const description = displayRows[this.selectedIndex]?.field.field.description;
|
|
166
|
-
if (description) {
|
|
174
|
+
if (displayRows.some((row) => row.field.field.description)) {
|
|
167
175
|
lines.push("");
|
|
168
|
-
for (const line of
|
|
169
|
-
lines.push(this.theme.fg("dim", ` ${line}`));
|
|
176
|
+
for (const line of renderDescriptionViewport(description, width)) {
|
|
177
|
+
lines.push(line ? this.theme.fg("dim", ` ${line}`) : "");
|
|
170
178
|
}
|
|
171
179
|
}
|
|
172
180
|
lines.push("");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const DESCRIPTION_VIEWPORT_HEIGHT = 4;
|
|
4
|
+
|
|
5
|
+
/** Render a fixed-height description preview so selection changes do not resize the menu. */
|
|
6
|
+
export function renderDescriptionViewport(
|
|
7
|
+
description: string | undefined,
|
|
8
|
+
width: number,
|
|
9
|
+
): string[] {
|
|
10
|
+
const contentWidth = Math.max(1, width - 4);
|
|
11
|
+
const wrapped = description ? wrapTextWithAnsi(description, contentWidth) : [];
|
|
12
|
+
const visible = wrapped.slice(0, DESCRIPTION_VIEWPORT_HEIGHT);
|
|
13
|
+
if (wrapped.length > DESCRIPTION_VIEWPORT_HEIGHT) {
|
|
14
|
+
const lastIndex = DESCRIPTION_VIEWPORT_HEIGHT - 1;
|
|
15
|
+
visible[lastIndex] = truncateToWidth(`${visible[lastIndex] ?? ""}…`, contentWidth, "…");
|
|
16
|
+
}
|
|
17
|
+
while (visible.length < DESCRIPTION_VIEWPORT_HEIGHT) visible.push("");
|
|
18
|
+
return visible;
|
|
19
|
+
}
|
|
@@ -4,17 +4,48 @@ import type { LoadedSettingsModule } from "./settings-module-reader.ts";
|
|
|
4
4
|
export interface ScopedRow {
|
|
5
5
|
flatId: string;
|
|
6
6
|
moduleLabel: string;
|
|
7
|
+
subsectionLabel?: string;
|
|
7
8
|
field: ScopedFieldValue;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export function rowsFromModules(loaded: LoadedSettingsModule[]): ScopedRow[] {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
field
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
const rowsByModuleLabel = new Map<string, ScopedRow[]>();
|
|
13
|
+
for (const { module, snapshot } of loaded) {
|
|
14
|
+
const rows = rowsByModuleLabel.get(module.label) ?? [];
|
|
15
|
+
rows.push(
|
|
16
|
+
...snapshot.rows.map((field) => ({
|
|
17
|
+
flatId: `${module.id}.${field.field.key}`,
|
|
18
|
+
moduleLabel: module.label,
|
|
19
|
+
subsectionLabel: module.subsection,
|
|
20
|
+
field,
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
rowsByModuleLabel.set(module.label, rows);
|
|
24
|
+
}
|
|
25
|
+
return [...rowsByModuleLabel.values()].flat();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Return the largest group-header count for any row window of the given size. */
|
|
29
|
+
export function maxVisibleHeaderCount(rows: ScopedRow[], windowSize: number): number {
|
|
30
|
+
let maximum = 0;
|
|
31
|
+
for (let start = 0; start <= rows.length - windowSize; start++) {
|
|
32
|
+
let count = 0;
|
|
33
|
+
let previousSection: string | undefined;
|
|
34
|
+
let previousSubsection: string | undefined;
|
|
35
|
+
for (const row of rows.slice(start, start + windowSize)) {
|
|
36
|
+
if (row.moduleLabel !== previousSection) {
|
|
37
|
+
count++;
|
|
38
|
+
previousSection = row.moduleLabel;
|
|
39
|
+
previousSubsection = undefined;
|
|
40
|
+
}
|
|
41
|
+
if (row.subsectionLabel !== previousSubsection) {
|
|
42
|
+
previousSubsection = row.subsectionLabel;
|
|
43
|
+
if (row.subsectionLabel) count++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
maximum = Math.max(maximum, count);
|
|
47
|
+
}
|
|
48
|
+
return maximum;
|
|
18
49
|
}
|
|
19
50
|
|
|
20
51
|
export function filterSettingsRows(rows: ScopedRow[], query: string): ScopedRow[] {
|
|
@@ -23,6 +54,7 @@ export function filterSettingsRows(rows: ScopedRow[], query: string): ScopedRow[
|
|
|
23
54
|
return rows.filter(
|
|
24
55
|
(row) =>
|
|
25
56
|
row.moduleLabel.toLowerCase().includes(normalized) ||
|
|
57
|
+
row.subsectionLabel?.toLowerCase().includes(normalized) ||
|
|
26
58
|
row.field.field.label.toLowerCase().includes(normalized) ||
|
|
27
59
|
row.field.field.key.toLowerCase().includes(normalized) ||
|
|
28
60
|
row.field.displayValue.toLowerCase().includes(normalized),
|
package/src/ui/settings-ui.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { Container, Key, matchesKey, Text } from "@earendil-works/pi-tui";
|
|
|
9
9
|
import {
|
|
10
10
|
createSettingsContributionCollector,
|
|
11
11
|
type SettingsCollectionDiagnostic,
|
|
12
|
+
type SettingsModule,
|
|
12
13
|
type SettingsScope,
|
|
13
14
|
SUPI_SETTINGS_COLLECT_EVENT,
|
|
14
15
|
} from "@mrclrchtr/supi-core/settings";
|
|
@@ -37,14 +38,22 @@ function latestStatus(diagnostics: SettingsCollectionDiagnostic[]): OverlayStatu
|
|
|
37
38
|
return latest ? { kind: latest.kind, message: latest.message } : undefined;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** Keep the skill catalog after the smaller fixed settings sections. */
|
|
42
|
+
function orderSettingsModules(modules: SettingsModule[]): SettingsModule[] {
|
|
43
|
+
const skills = modules.find((module) => module.id === "skills");
|
|
44
|
+
if (!skills) return modules;
|
|
45
|
+
return [...modules.filter((module) => module.id !== skills.id), skills];
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
export async function openSettingsOverlay(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
41
49
|
const collection = collectSettingsModules(pi);
|
|
42
|
-
|
|
50
|
+
const modules = orderSettingsModules(collection.modules);
|
|
51
|
+
if (modules.length === 0) {
|
|
43
52
|
ctx.ui.notify("No settings registered by SuPi extensions", "info");
|
|
44
53
|
return;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
const initial = await readSettingsModules(
|
|
56
|
+
const initial = await readSettingsModules(modules, {
|
|
48
57
|
scope: "project",
|
|
49
58
|
cwd: ctx.cwd,
|
|
50
59
|
ctx,
|
|
@@ -61,7 +70,7 @@ export async function openSettingsOverlay(pi: ExtensionAPI, ctx: ExtensionContex
|
|
|
61
70
|
|
|
62
71
|
const container = new Container();
|
|
63
72
|
const scopedList = new ScopedSettingsList(
|
|
64
|
-
|
|
73
|
+
modules,
|
|
65
74
|
initial.loaded,
|
|
66
75
|
state.scope,
|
|
67
76
|
state.cwd,
|