@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 CHANGED
@@ -6,56 +6,96 @@
6
6
 
7
7
  # @mrclrchtr/supi-settings
8
8
 
9
- SuPi Settings adds a unified `/supi-settings` command to the [pi coding agent](https://github.com/earendil-works/pi). It gives SuPi extensions one shared TUI for project and global configuration.
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 you get
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
- After install, pi gets one new slash command:
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
- - **`/supi-settings`** open a searchable settings screen for registered SuPi extension settings
52
+ Each value can have a `(project)`, `(global)`, or `(default)` source badge.
28
53
 
29
- The screen matches Pi's `/settings` layout and groups settings by extension. It shows current values with source badges like `(project)`, `(global)`, and `(default)`. Use `Tab` to switch between **project** and **global** scopes. Row actions can set a scoped value or delete it with **Inherit** / **Reset to default**.
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
- ## How it works
56
+ Free-text, list, model, and custom settings open a matching editor or picker when the contributing module provides one.
32
57
 
33
- `supi-settings` is the command package for the shared settings registry in `@mrclrchtr/supi-core`.
58
+ ## Storage and boundaries
34
59
 
35
- Other SuPi extensions register asynchronous Settings Modules during extension startup. This package reads their source-aware snapshots, routes `set` and `unset` actions, and shows module-reported failures or reload notices. Fixed SuPi config sections use the shared config adapter; dynamic modules can own other stores without exposing them to this UI.
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
- If no installed SuPi extension has registered settings, `/supi-settings` reports that there are no settings to edit.
62
+ Modules that use the standard `@mrclrchtr/supi-core` config adapter store values in:
38
63
 
39
- ## Typical settings sections
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
- Depending on which SuPi packages are installed, the overlay may include settings for:
87
+ The API exports:
42
88
 
43
- - `supi-lsp` — language-server enablement and diagnostics behavior
44
- - `supi-claude-md` — subdirectory `CLAUDE.md` / `AGENTS.md` discovery
45
- - `supi-bash-timeout` — default bash timeout injection
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
- ## Package surfaces
93
+ The `@mrclrchtr/supi-settings/extension` subpath exports the Pi extension factory that registers `/supi-settings`.
52
94
 
53
- - `@mrclrchtr/supi-settings/extension` pi extension entrypoint, registers `/supi-settings`
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` — pi extension entrypoint
59
- - `src/api.ts` — reusable settings UI surface
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "4.9.0",
3
+ "version": "5.0.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- /** Match a debug event against the supported source, level, and category filters. */
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
- const startedAt = now();
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
- markAt(phase, now());
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
- if (!isDebugRegistryEnabled()) {
74
- finished = true;
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": "4.9.0",
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": "4.9.0"
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 { filterSettingsRows, rowsFromModules, type ScopedRow } from "./settings-row-model.ts";
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 prefix = isSelected ? ` ${this.theme.fg("accent", "→ ")}` : " ";
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 - 4 - maxLabelWidth - 2);
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 wrapTextWithAnsi(description, Math.max(1, width - 4))) {
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
- return loaded.flatMap(({ module, snapshot }) =>
12
- snapshot.rows.map((field) => ({
13
- flatId: `${module.id}.${field.field.key}`,
14
- moduleLabel: module.label,
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),
@@ -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
- if (collection.modules.length === 0) {
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(collection.modules, {
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
- collection.modules,
73
+ modules,
65
74
  initial.loaded,
66
75
  state.scope,
67
76
  state.cwd,