@mrclrchtr/supi-settings 4.10.0 → 6.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.
@@ -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.10.0",
3
+ "version": "6.0.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  "./api": "./src/api.ts",
54
54
  "./config": "./src/config.ts",
55
55
  "./context": "./src/context.ts",
56
- "./debug": "./src/debug-registry.ts",
56
+ "./debug": "./src/debug.ts",
57
57
  "./evidence-badge": "./src/evidence-badge.ts",
58
58
  "./footer-registry": "./src/footer-registry.ts",
59
59
  "./llm": "./src/llm.ts",
@@ -11,7 +11,7 @@ export * from "./config.ts";
11
11
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
12
12
  export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
- export * from "./debug-registry.ts";
14
+ export * from "./debug.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
16
  export * from "./evidence-badge.ts";
17
17
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
@@ -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";
@@ -4,9 +4,6 @@
4
4
  // supi-debug extension owns policy/configuration and exposes events through a
5
5
  // command/tool while this module stays dependency-free for producers.
6
6
 
7
- // biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
8
- export * from "./debug-timing.ts";
9
-
10
7
  export type DebugLevel = "debug" | "info" | "warning" | "error";
11
8
  export type DebugAgentAccess = "off" | "sanitized" | "raw";
12
9
  export interface DebugRegistryConfig {
@@ -25,6 +22,8 @@ export const DEBUG_REGISTRY_DEFAULTS: DebugRegistryConfig = {
25
22
  };
26
23
 
27
24
  export interface DebugEventInput {
25
+ /** Opaque identity for events directly owned by one public Tool call. */
26
+ operationId?: string;
28
27
  source: string;
29
28
  level: DebugLevel;
30
29
  category: string;
@@ -42,6 +41,8 @@ export interface DebugEvent extends DebugEventInput {
42
41
  }
43
42
 
44
43
  export interface DebugEventQuery {
44
+ /** Match one exact Debug Operation ID. */
45
+ operationId?: string;
45
46
  source?: string;
46
47
  level?: DebugLevel;
47
48
  category?: string;
@@ -53,6 +54,7 @@ export interface DebugEventQuery {
53
54
  export interface DebugEventView {
54
55
  id: number;
55
56
  timestamp: number;
57
+ operationId?: string;
56
58
  source: string;
57
59
  level: DebugLevel;
58
60
  category: string;
@@ -84,6 +86,7 @@ interface DebugRegistryState {
84
86
  }
85
87
 
86
88
  const REGISTRY_KEY = Symbol.for("@mrclrchtr/supi-core/debug-registry");
89
+ const DEBUG_OPERATION_ID_RE = /^op-[A-Za-z0-9_-]{21}[AQgw]$/;
87
90
  const SECRET_KEY_RE = /(?:token|password|passwd|secret|api[_-]?key|authorization|credential)/i;
88
91
  const ENV_SECRET_RE =
89
92
  /\b([A-Za-z0-9_]*(?:token|password|passwd|secret|api[_-]?key|authorization|credential)[A-Za-z0-9_]*)=(?:'[^']*'|"[^"]*"|\S+)/gi;
@@ -135,11 +138,17 @@ export function isDebugLevel(value: unknown): value is DebugLevel {
135
138
  return value === "debug" || value === "info" || value === "warning" || value === "error";
136
139
  }
137
140
 
138
- /** Match a debug event against the supported source, level, and category filters. */
141
+ /** Return whether a value has the exact 16-byte base64url Debug Operation ID form. */
142
+ export function isDebugOperationId(value: unknown): value is string {
143
+ return typeof value === "string" && DEBUG_OPERATION_ID_RE.test(value);
144
+ }
145
+
146
+ /** Match a debug event against the supported exact filters. */
139
147
  export function matchesDebugEventQuery(
140
- event: Pick<DebugEventView, "source" | "level" | "category">,
141
- query: Pick<DebugEventQuery, "source" | "level" | "category">,
148
+ event: Pick<DebugEventView, "operationId" | "source" | "level" | "category">,
149
+ query: Pick<DebugEventQuery, "operationId" | "source" | "level" | "category">,
142
150
  ): boolean {
151
+ if (query.operationId && event.operationId !== query.operationId) return false;
143
152
  if (query.source && event.source !== query.source) return false;
144
153
  if (query.level && event.level !== query.level) return false;
145
154
  if (query.category && event.category !== query.category) return false;
@@ -197,6 +206,7 @@ function toSanitizedView(event: DebugEvent): DebugEventView {
197
206
  return {
198
207
  id: event.id,
199
208
  timestamp: event.timestamp,
209
+ operationId: event.operationId,
200
210
  source: event.source,
201
211
  level: event.level,
202
212
  category: event.category,
@@ -216,6 +226,9 @@ export function subscribeDebugEvents(listener: DebugEventListener): () => void {
216
226
  /** Record a session-local debug event if debugging is enabled. */
217
227
  export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
218
228
  const state = getState();
229
+ if (input.operationId !== undefined && !isDebugOperationId(input.operationId)) {
230
+ return null;
231
+ }
219
232
  if (!state.config.enabled) {
220
233
  return null;
221
234
  }
@@ -0,0 +1,9 @@
1
+ // Debug domain entry for `@mrclrchtr/supi-core/debug`.
2
+ //
3
+ // Kept separate from debug-registry.ts so debug-timing.ts can import the
4
+ // registry without creating an import cycle through the barrel re-export.
5
+
6
+ // biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
7
+ export * from "./debug-registry.ts";
8
+ // biome-ignore lint/performance/noReExportAll: preserve the stable debug domain entry point
9
+ export * from "./debug-timing.ts";
@@ -11,7 +11,7 @@ export * from "./config.ts";
11
11
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
12
12
  export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
- export * from "./debug-registry.ts";
14
+ export * from "./debug.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
16
  export * from "./footer-registry.ts";
17
17
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
@@ -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.10.0",
3
+ "version": "6.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.10.0"
33
+ "@mrclrchtr/supi-core": "6.0.0"
34
34
  },
35
35
  "bundledDependencies": [
36
36
  "@mrclrchtr/supi-core"
@@ -1,5 +1,3 @@
1
- // Source-aware settings list with scope actions and custom submenus.
2
-
3
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
2
  import { Input, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
3
  import type { SettingsAction, SettingsModule, SettingsScope } from "@mrclrchtr/supi-core/settings";
@@ -13,7 +11,7 @@ import { renderDescriptionViewport } from "./settings-list-layout.ts";
13
11
  import { type LoadedSettingsModule, readSettingsModules } from "./settings-module-reader.ts";
14
12
  import {
15
13
  filterSettingsRows,
16
- maxVisibleSectionCount,
14
+ maxVisibleHeaderCount,
17
15
  rowsFromModules,
18
16
  type ScopedRow,
19
17
  } from "./settings-row-model.ts";
@@ -66,7 +64,6 @@ export class ScopedSettingsList {
66
64
  this.onError = onError;
67
65
  this.rebuildRows(initial);
68
66
  }
69
-
70
67
  async reload(scope: SettingsScope, cwd: string, ctx?: ExtensionContext): Promise<void> {
71
68
  this.scope = scope;
72
69
  this.cwd = cwd;
@@ -89,12 +86,10 @@ export class ScopedSettingsList {
89
86
  hasOpenSubmenu(): boolean {
90
87
  return this.submenu !== null || this.actionPending;
91
88
  }
92
-
93
89
  invalidate(): void {
94
90
  this.cachedWidth = undefined;
95
91
  this.cachedLines = undefined;
96
92
  }
97
-
98
93
  render(width: number): string[] {
99
94
  if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
100
95
  if (this.submenu) {
@@ -106,7 +101,6 @@ export class ScopedSettingsList {
106
101
  this.cachedLines = this.renderList(width);
107
102
  return this.cachedLines;
108
103
  }
109
-
110
104
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: One pass handles filtering, grouping, selection, scrolling, and descriptions.
111
105
  private renderList(width: number): string[] {
112
106
  const lines: string[] = [];
@@ -137,7 +131,8 @@ export class ScopedSettingsList {
137
131
  Math.max(...displayRows.map((row) => visibleWidth(row.field.field.label))),
138
132
  );
139
133
  let previousSection: string | undefined;
140
- let visibleSectionCount = 0;
134
+ let previousSubsection: string | undefined;
135
+ let visibleHeaderCount = 0;
141
136
  for (let i = start; i < end; i++) {
142
137
  const row = displayRows[i];
143
138
  if (!row) continue;
@@ -145,22 +140,33 @@ export class ScopedSettingsList {
145
140
  lines.push(
146
141
  truncateToWidth(` ${this.theme.fg("muted", this.theme.bold(row.moduleLabel))}`, width),
147
142
  );
148
- visibleSectionCount++;
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
  }
162
- const reservedSectionCount = maxVisibleSectionCount(displayRows, maxVisible);
163
- for (let i = visibleSectionCount; i < reservedSectionCount; i++) lines.push("");
168
+ const reservedHeaderCount = maxVisibleHeaderCount(displayRows, maxVisible);
169
+ for (let i = visibleHeaderCount; i < reservedHeaderCount; i++) lines.push("");
164
170
  if (start > 0 || end < displayRows.length) {
165
171
  lines.push(this.theme.fg("dim", ` (${this.selectedIndex + 1}/${displayRows.length})`));
166
172
  }
@@ -4,29 +4,44 @@ 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();
18
26
  }
19
27
 
20
- /** Return the largest section-header count for any row window of the given size. */
21
- export function maxVisibleSectionCount(rows: ScopedRow[], windowSize: number): number {
28
+ /** Return the largest group-header count for any row window of the given size. */
29
+ export function maxVisibleHeaderCount(rows: ScopedRow[], windowSize: number): number {
22
30
  let maximum = 0;
23
31
  for (let start = 0; start <= rows.length - windowSize; start++) {
24
32
  let count = 0;
25
33
  let previousSection: string | undefined;
34
+ let previousSubsection: string | undefined;
26
35
  for (const row of rows.slice(start, start + windowSize)) {
27
- if (row.moduleLabel === previousSection) continue;
28
- count++;
29
- previousSection = row.moduleLabel;
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
+ }
30
45
  }
31
46
  maximum = Math.max(maximum, count);
32
47
  }
@@ -39,6 +54,7 @@ export function filterSettingsRows(rows: ScopedRow[], query: string): ScopedRow[
39
54
  return rows.filter(
40
55
  (row) =>
41
56
  row.moduleLabel.toLowerCase().includes(normalized) ||
57
+ row.subsectionLabel?.toLowerCase().includes(normalized) ||
42
58
  row.field.field.label.toLowerCase().includes(normalized) ||
43
59
  row.field.field.key.toLowerCase().includes(normalized) ||
44
60
  row.field.displayValue.toLowerCase().includes(normalized),