@mrclrchtr/supi-settings 4.9.0 → 4.10.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "4.9.0",
3
+ "version": "4.10.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-settings",
3
- "version": "4.9.0",
3
+ "version": "4.10.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": "4.10.0"
34
34
  },
35
35
  "bundledDependencies": [
36
36
  "@mrclrchtr/supi-core"
@@ -1,14 +1,7 @@
1
1
  // Source-aware settings list with scope actions and custom submenus.
2
2
 
3
3
  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";
4
+ import { Input, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
5
  import type { SettingsAction, SettingsModule, SettingsScope } from "@mrclrchtr/supi-core/settings";
13
6
  import type { ThemeAccessor } from "./settings-action-menu.ts";
14
7
  import {
@@ -16,8 +9,14 @@ import {
16
9
  createActionMenuComponent,
17
10
  getConcreteChoices,
18
11
  } from "./settings-action-menu.ts";
12
+ import { renderDescriptionViewport } from "./settings-list-layout.ts";
19
13
  import { type LoadedSettingsModule, readSettingsModules } from "./settings-module-reader.ts";
20
- import { filterSettingsRows, rowsFromModules, type ScopedRow } from "./settings-row-model.ts";
14
+ import {
15
+ filterSettingsRows,
16
+ maxVisibleSectionCount,
17
+ rowsFromModules,
18
+ type ScopedRow,
19
+ } from "./settings-row-model.ts";
21
20
  import { createInputSubmenu, createModelPickerSubmenu } from "./settings-submenus.ts";
22
21
 
23
22
  interface SubmenuState {
@@ -27,7 +26,6 @@ interface SubmenuState {
27
26
  handleInput?: (data: string) => void;
28
27
  };
29
28
  }
30
-
31
29
  export class ScopedSettingsList {
32
30
  private modules: SettingsModule[];
33
31
  private scope: SettingsScope;
@@ -139,6 +137,7 @@ export class ScopedSettingsList {
139
137
  Math.max(...displayRows.map((row) => visibleWidth(row.field.field.label))),
140
138
  );
141
139
  let previousSection: string | undefined;
140
+ let visibleSectionCount = 0;
142
141
  for (let i = start; i < end; i++) {
143
142
  const row = displayRows[i];
144
143
  if (!row) continue;
@@ -146,6 +145,7 @@ export class ScopedSettingsList {
146
145
  lines.push(
147
146
  truncateToWidth(` ${this.theme.fg("muted", this.theme.bold(row.moduleLabel))}`, width),
148
147
  );
148
+ visibleSectionCount++;
149
149
  previousSection = row.moduleLabel;
150
150
  }
151
151
  const isSelected = i === this.selectedIndex;
@@ -159,14 +159,16 @@ export class ScopedSettingsList {
159
159
  const valueText = this.theme.fg(isSelected ? "accent" : "muted", value);
160
160
  lines.push(truncateToWidth(`${prefix}${labelText} ${valueText}`, width));
161
161
  }
162
+ const reservedSectionCount = maxVisibleSectionCount(displayRows, maxVisible);
163
+ for (let i = visibleSectionCount; i < reservedSectionCount; i++) lines.push("");
162
164
  if (start > 0 || end < displayRows.length) {
163
165
  lines.push(this.theme.fg("dim", ` (${this.selectedIndex + 1}/${displayRows.length})`));
164
166
  }
165
167
  const description = displayRows[this.selectedIndex]?.field.field.description;
166
- if (description) {
168
+ if (displayRows.some((row) => row.field.field.description)) {
167
169
  lines.push("");
168
- for (const line of wrapTextWithAnsi(description, Math.max(1, width - 4))) {
169
- lines.push(this.theme.fg("dim", ` ${line}`));
170
+ for (const line of renderDescriptionViewport(description, width)) {
171
+ lines.push(line ? this.theme.fg("dim", ` ${line}`) : "");
170
172
  }
171
173
  }
172
174
  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
+ }
@@ -17,6 +17,22 @@ export function rowsFromModules(loaded: LoadedSettingsModule[]): ScopedRow[] {
17
17
  );
18
18
  }
19
19
 
20
+ /** Return the largest section-header count for any row window of the given size. */
21
+ export function maxVisibleSectionCount(rows: ScopedRow[], windowSize: number): number {
22
+ let maximum = 0;
23
+ for (let start = 0; start <= rows.length - windowSize; start++) {
24
+ let count = 0;
25
+ let previousSection: string | undefined;
26
+ for (const row of rows.slice(start, start + windowSize)) {
27
+ if (row.moduleLabel === previousSection) continue;
28
+ count++;
29
+ previousSection = row.moduleLabel;
30
+ }
31
+ maximum = Math.max(maximum, count);
32
+ }
33
+ return maximum;
34
+ }
35
+
20
36
  export function filterSettingsRows(rows: ScopedRow[], query: string): ScopedRow[] {
21
37
  if (!query) return rows;
22
38
  const normalized = query.toLowerCase();
@@ -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,