@mrclrchtr/supi-settings 7.2.0 → 7.3.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.
@@ -23,6 +23,7 @@ pnpm add @mrclrchtr/supi-core
23
23
  - `@mrclrchtr/supi-core/api` — reusable helpers for other packages and extensions
24
24
  - `@mrclrchtr/supi-core/llm` — PI-owned direct model requests and JSON helpers
25
25
  - `@mrclrchtr/supi-core/report` — shared text/report rendering helpers for TUI and plain-text summaries
26
+ - `@mrclrchtr/supi-core/tui` — shared terminal UI helpers for list titles, rows, menus, and descriptions
26
27
 
27
28
  ## What you get from the API
28
29
 
@@ -70,6 +71,14 @@ Both completion helpers require a stable `affinityScope`. PI owns auth and endpo
70
71
  - active-branch session helper: `getActiveBranchEntries()`
71
72
  - terminal helpers such as `formatTitle()`, `signalWaiting()`, and `signalDone()`
72
73
 
74
+ ### TUI helpers
75
+
76
+ - `formatTuiTitleLine()` — use the shared title style in terminal lists
77
+ - `renderSelectableRow()` — render a selected row with an aligned value
78
+ - `renderDescriptionViewport()` — show a fixed-height description preview
79
+ - `renderTuiHintLine()` — render short key hints in one line
80
+ - `createSelectListMenu()` — build an action menu with shared key hints
81
+
73
82
  ### Report helpers
74
83
 
75
84
  - `clampReportWidth()` — enforce a minimum readable report width
@@ -125,3 +134,4 @@ export default function myExtension(pi: ExtensionAPI) {
125
134
  - `src/debug-timing.ts` — monotonic total and phase timers for Debug Event Producers
126
135
  - `src/settings/` — settings registry, schema, scope resolution, and persistence
127
136
  - `src/report.ts` — shared text/report rendering helpers
137
+ - `src/tui.ts` — shared terminal UI list and action-menu helpers
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -67,6 +67,7 @@
67
67
  "./settings": "./src/settings.ts",
68
68
  "./spinner-frames": "./src/spinner-frames.ts",
69
69
  "./status-spinner": "./src/status-spinner.ts",
70
- "./terminal": "./src/terminal.ts"
70
+ "./terminal": "./src/terminal.ts",
71
+ "./tui": "./src/tui.ts"
71
72
  }
72
73
  }
@@ -38,3 +38,5 @@ export * from "./spinner-frames.ts";
38
38
  export * from "./status-spinner.ts";
39
39
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
40
40
  export * from "./terminal.ts";
41
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
42
+ export * from "./tui.ts";
@@ -34,3 +34,5 @@ export * from "./settings.ts";
34
34
  export * from "./status-spinner.ts";
35
35
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
36
36
  export * from "./terminal.ts";
37
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
38
+ export * from "./tui.ts";
@@ -0,0 +1,188 @@
1
+ /** Session-only tool and skill visibility state. */
2
+ export interface SessionCapabilityState {
3
+ version: 1;
4
+ eligibleToolNames: string[];
5
+ initiallyInactiveToolNames: string[];
6
+ toolDenylist: string[];
7
+ /** Tools that the user explicitly enabled for this session. */
8
+ toolEnabledNames?: string[];
9
+ hiddenSkillNames: string[];
10
+ }
11
+
12
+ /** A model-visible skill that a provider can offer to the selector. */
13
+ export interface SessionCapabilitySkill {
14
+ name: string;
15
+ description: string;
16
+ }
17
+
18
+ /** Skill inventory provider registered by the skills extension. */
19
+ export interface SessionCapabilitySkillProvider<TContext = unknown> {
20
+ listEligibleSkills(
21
+ context: TContext,
22
+ ): Promise<SessionCapabilitySkill[]> | SessionCapabilitySkill[];
23
+ }
24
+
25
+ /** Custom session-entry type used for hidden capability snapshots. */
26
+ export const SESSION_CAPABILITIES_ENTRY = "supi-capabilities";
27
+
28
+ const REGISTRY_PREFIX = "@mrclrchtr/supi-core/session-capabilities/";
29
+ const STATE_KEY = Symbol.for(`${REGISTRY_PREFIX}state`);
30
+ const PROVIDER_KEY = Symbol.for(`${REGISTRY_PREFIX}skill-providers`);
31
+ const FORK_KEY = Symbol.for(`${REGISTRY_PREFIX}pending-forks`);
32
+
33
+ type GlobalRegistry = typeof globalThis & Record<symbol, unknown>;
34
+
35
+ type SkillProviderMap = Map<string, SessionCapabilitySkillProvider<unknown>>;
36
+ type ForkStateMap = Map<string, SessionCapabilityState>;
37
+
38
+ function getStateMap(): Map<string, SessionCapabilityState> {
39
+ const target = globalThis as GlobalRegistry;
40
+ let map = target[STATE_KEY] as Map<string, SessionCapabilityState> | undefined;
41
+ if (!map) {
42
+ map = new Map();
43
+ target[STATE_KEY] = map;
44
+ }
45
+ return map;
46
+ }
47
+
48
+ function getProviderMap(): SkillProviderMap {
49
+ const target = globalThis as GlobalRegistry;
50
+ let map = target[PROVIDER_KEY] as SkillProviderMap | undefined;
51
+ if (!map) {
52
+ map = new Map();
53
+ target[PROVIDER_KEY] = map;
54
+ }
55
+ return map;
56
+ }
57
+
58
+ function getForkMap(): ForkStateMap {
59
+ const target = globalThis as GlobalRegistry;
60
+ let map = target[FORK_KEY] as ForkStateMap | undefined;
61
+ if (!map) {
62
+ map = new Map();
63
+ target[FORK_KEY] = map;
64
+ }
65
+ return map;
66
+ }
67
+
68
+ function stringList(value: unknown): string[] {
69
+ if (!Array.isArray(value)) return [];
70
+ return Array.from(new Set(value.filter((item): item is string => typeof item === "string")));
71
+ }
72
+
73
+ /** Create an empty state snapshot. */
74
+ export function createEmptySessionCapabilityState(): SessionCapabilityState {
75
+ return {
76
+ version: 1,
77
+ eligibleToolNames: [],
78
+ initiallyInactiveToolNames: [],
79
+ toolDenylist: [],
80
+ toolEnabledNames: [],
81
+ hiddenSkillNames: [],
82
+ };
83
+ }
84
+
85
+ /** Validate and copy one stored snapshot. */
86
+ export function decodeSessionCapabilities(value: unknown): SessionCapabilityState | undefined {
87
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
88
+ const record = value as Record<string, unknown>;
89
+ if (record.version !== 1) return undefined;
90
+
91
+ const initiallyInactiveToolNames = stringList(record.initiallyInactiveToolNames);
92
+ const initiallyInactive = new Set(initiallyInactiveToolNames);
93
+ const eligibleToolNames = stringList(record.eligibleToolNames).filter(
94
+ (name) => !initiallyInactive.has(name),
95
+ );
96
+ const eligible = new Set(eligibleToolNames);
97
+ const toolDenylist = stringList(record.toolDenylist).filter((name) => eligible.has(name));
98
+ const denied = new Set(toolDenylist);
99
+
100
+ return {
101
+ version: 1,
102
+ eligibleToolNames,
103
+ initiallyInactiveToolNames,
104
+ toolDenylist,
105
+ toolEnabledNames: stringList(record.toolEnabledNames).filter(
106
+ (name) => eligible.has(name) && !denied.has(name),
107
+ ),
108
+ hiddenSkillNames: stringList(record.hiddenSkillNames),
109
+ };
110
+ }
111
+
112
+ /** Return a safe copy of the latest valid snapshot in all session entries. */
113
+ export function findSessionCapabilities(
114
+ entries: readonly unknown[],
115
+ ): SessionCapabilityState | undefined {
116
+ for (let index = entries.length - 1; index >= 0; index--) {
117
+ const entry = entries[index];
118
+ if (typeof entry !== "object" || entry === null) continue;
119
+ const record = entry as Record<string, unknown>;
120
+ if (record.type !== "custom" || record.customType !== SESSION_CAPABILITIES_ENTRY) continue;
121
+ const state = decodeSessionCapabilities(record.data);
122
+ if (state) return state;
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /** Shared session state. Session IDs keep sessions in the same cwd separate. */
128
+ export const sessionCapabilityState = {
129
+ get(sessionId: string): SessionCapabilityState | undefined {
130
+ const state = getStateMap().get(sessionId);
131
+ return state ? decodeSessionCapabilities(state) : undefined;
132
+ },
133
+ set(sessionId: string, state: SessionCapabilityState): void {
134
+ const decoded = decodeSessionCapabilities(state);
135
+ if (decoded) getStateMap().set(sessionId, decoded);
136
+ },
137
+ clear(sessionId: string): void {
138
+ getStateMap().delete(sessionId);
139
+ },
140
+ };
141
+
142
+ /** Register the current session's skill inventory provider. */
143
+ export function registerSessionCapabilitySkillProvider<TContext>(
144
+ sessionId: string,
145
+ provider: SessionCapabilitySkillProvider<TContext>,
146
+ ): () => void {
147
+ const providers = getProviderMap();
148
+ providers.set(sessionId, provider as SessionCapabilitySkillProvider<unknown>);
149
+ return () => {
150
+ if (providers.get(sessionId) === provider) providers.delete(sessionId);
151
+ };
152
+ }
153
+
154
+ /** Get the current session's skill inventory provider, if one is loaded. */
155
+ export function getSessionCapabilitySkillProvider<TContext = unknown>(
156
+ sessionId: string,
157
+ ): SessionCapabilitySkillProvider<TContext> | undefined {
158
+ return getProviderMap().get(sessionId) as SessionCapabilitySkillProvider<TContext> | undefined;
159
+ }
160
+
161
+ function forkKey(sourceSessionFile: string | undefined): string {
162
+ return sourceSessionFile ?? "<memory-session>";
163
+ }
164
+
165
+ /** Hold a state copy until PI starts the forked session. */
166
+ export function setPendingSessionCapabilityFork(
167
+ sourceSessionFile: string | undefined,
168
+ state: SessionCapabilityState,
169
+ ): void {
170
+ const decoded = decodeSessionCapabilities(state);
171
+ if (decoded) getForkMap().set(forkKey(sourceSessionFile), decoded);
172
+ }
173
+
174
+ /** Take the state copy for one fork. The copy is used once. */
175
+ export function readPendingSessionCapabilityFork(
176
+ sourceSessionFile: string | undefined,
177
+ ): SessionCapabilityState | undefined {
178
+ const forks = getForkMap();
179
+ const key = forkKey(sourceSessionFile);
180
+ const state = forks.get(key);
181
+ forks.delete(key);
182
+ return state ? decodeSessionCapabilities(state) : undefined;
183
+ }
184
+
185
+ /** Clear pending fork copies after a cancelled or unrelated transition. */
186
+ export function clearPendingSessionCapabilityForks(): void {
187
+ getForkMap().clear();
188
+ }
@@ -1,6 +1,23 @@
1
1
  // supi-core session domain — session utilities and registries.
2
2
 
3
3
  export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
4
+ export type {
5
+ SessionCapabilitySkill,
6
+ SessionCapabilitySkillProvider,
7
+ SessionCapabilityState,
8
+ } from "./session-capabilities.ts";
9
+ export {
10
+ clearPendingSessionCapabilityForks,
11
+ createEmptySessionCapabilityState,
12
+ decodeSessionCapabilities,
13
+ findSessionCapabilities,
14
+ getSessionCapabilitySkillProvider,
15
+ readPendingSessionCapabilityFork,
16
+ registerSessionCapabilitySkillProvider,
17
+ SESSION_CAPABILITIES_ENTRY,
18
+ sessionCapabilityState,
19
+ setPendingSessionCapabilityFork,
20
+ } from "./session-capabilities.ts";
4
21
  export type { SessionNameTrackerHost } from "./session-utils.ts";
5
22
  export {
6
23
  createSessionNameTracker,
@@ -0,0 +1,141 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type SelectItem,
4
+ SelectList,
5
+ type TuiMouseEvent,
6
+ type TuiMouseEventResult,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ wrapTextWithAnsi,
10
+ } from "@earendil-works/pi-tui";
11
+
12
+ /** The theme methods used by the shared TUI list renderers. */
13
+ export type TuiTheme = Pick<Theme, "fg" | "bold">;
14
+
15
+ /** Settings for one selectable row in a themed list. */
16
+ export interface SelectableRowOptions {
17
+ /** The row label. */
18
+ label: string;
19
+ /** The value shown beside the label. */
20
+ value: string;
21
+ /** The row indent in terminal columns. */
22
+ indent: number;
23
+ /** The largest indent used by the list. */
24
+ maxIndent: number;
25
+ /** The label column width. */
26
+ maxLabelWidth: number;
27
+ /** The row width in terminal columns. */
28
+ width: number;
29
+ /** True when this row is selected. */
30
+ selected: boolean;
31
+ /** Theme used for row colors. */
32
+ theme: TuiTheme;
33
+ }
34
+
35
+ /** Settings for a shared action menu. */
36
+ export interface SelectListMenuOptions {
37
+ /** The title shown above the choices. */
38
+ title: string;
39
+ /** The choices shown in the menu. */
40
+ items: SelectItem[];
41
+ /** Theme used for menu colors. */
42
+ theme: TuiTheme;
43
+ /** Called when the user selects a choice. */
44
+ onSelect: (item: SelectItem) => void;
45
+ /** Called when the user closes the menu without a choice. */
46
+ onCancel: () => void;
47
+ }
48
+
49
+ /** Component methods for a shared action menu. */
50
+ export interface SelectListMenuComponent {
51
+ render(width: number): string[];
52
+ invalidate(): void;
53
+ handleInput(data: string): void;
54
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
55
+ }
56
+
57
+ /** Build a title line with optional right-side tabs in the shared TUI style. */
58
+ export function formatTuiTitleLine(title: string, theme: TuiTheme, trailing = ""): string {
59
+ const titleText = theme.fg("accent", theme.bold(title));
60
+ return ` ${titleText}${trailing ? ` ${trailing}` : ""}`;
61
+ }
62
+
63
+ /** Render one row with a selected marker and an aligned value column. */
64
+ export function renderSelectableRow(options: SelectableRowOptions): string {
65
+ const indent = Math.max(0, options.indent);
66
+ const maxIndent = Math.max(indent, options.maxIndent);
67
+ const maxLabelWidth = Math.max(0, options.maxLabelWidth);
68
+ const width = Math.max(0, options.width);
69
+ const label = truncateToWidth(options.label, maxLabelWidth, "");
70
+ const labelWidth = visibleWidth(label);
71
+ const prefix = options.selected
72
+ ? `${" ".repeat(Math.max(0, indent - 2))}${options.theme.fg("accent", "→ ")}`
73
+ : " ".repeat(indent);
74
+ const labelPadding = " ".repeat(maxIndent - indent + maxLabelWidth - labelWidth);
75
+ const valueWidth = Math.max(0, width - maxIndent - maxLabelWidth - 4);
76
+ const value = truncateToWidth(options.value, valueWidth, "");
77
+ const labelText = options.theme.fg(options.selected ? "accent" : "text", label + labelPadding);
78
+ const valueText = options.theme.fg(options.selected ? "accent" : "muted", value);
79
+
80
+ return truncateToWidth(`${prefix}${labelText} ${valueText}`, width);
81
+ }
82
+
83
+ /** Render a fixed-height, four-line description preview for a selected row. */
84
+ export function renderDescriptionViewport(
85
+ description: string | undefined,
86
+ width: number,
87
+ ): string[] {
88
+ const height = 4;
89
+ const indent = " ";
90
+ const contentWidth = Math.max(1, width - 4);
91
+ const wrapped = description ? wrapTextWithAnsi(description, contentWidth) : [];
92
+ const visible = wrapped.slice(0, height);
93
+
94
+ if (wrapped.length > height) {
95
+ const lastIndex = height - 1;
96
+ visible[lastIndex] = truncateToWidth(`${visible[lastIndex] ?? ""}…`, contentWidth, "…");
97
+ }
98
+
99
+ while (visible.length < height) visible.push("");
100
+ return visible.map((line) => (line ? `${indent}${line}` : ""));
101
+ }
102
+
103
+ /** Render a dimmed hint line with the shared separator and truncation rules. */
104
+ export function renderTuiHintLine(hints: string[], theme: TuiTheme, width: number): string {
105
+ return truncateToWidth(theme.fg("dim", hints.join(" · ")), width);
106
+ }
107
+
108
+ /** Build the shared title, choices, and key hints for an action menu. */
109
+ export function createSelectListMenu(options: SelectListMenuOptions): SelectListMenuComponent {
110
+ const { items, onSelect, onCancel, theme, title } = options;
111
+ const selectList = new SelectList(items, Math.min(items.length + 2, 15), {
112
+ selectedPrefix: (text) => theme.fg("accent", text),
113
+ selectedText: (text) => theme.fg("accent", text),
114
+ description: (text) => theme.fg("muted", text),
115
+ scrollInfo: (text) => theme.fg("dim", text),
116
+ noMatch: (text) => theme.fg("warning", text),
117
+ });
118
+ selectList.onSelect = onSelect;
119
+ selectList.onCancel = onCancel;
120
+
121
+ return {
122
+ render(width) {
123
+ return [
124
+ truncateToWidth(theme.fg("accent", ` ${title}`), width),
125
+ ...selectList.render(width),
126
+ "",
127
+ renderTuiHintLine([" ↑↓ navigate", "Enter select", "Esc cancel"], theme, width),
128
+ ];
129
+ },
130
+ invalidate() {
131
+ selectList.invalidate();
132
+ },
133
+ handleInput(data) {
134
+ selectList.handleInput(data);
135
+ },
136
+ handleMouse(event) {
137
+ if (event.y < 1) return undefined;
138
+ return selectList.handleMouse({ ...event, y: event.y - 1 });
139
+ },
140
+ };
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-settings",
3
- "version": "7.2.0",
3
+ "version": "7.3.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": "7.2.0"
33
+ "@mrclrchtr/supi-core": "7.3.0"
34
34
  },
35
35
  "bundledDependencies": [
36
36
  "@mrclrchtr/supi-core"
@@ -1,13 +1,17 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Input, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import type { SettingsAction, SettingsModule, SettingsScope } from "@mrclrchtr/supi-core/settings";
4
+ import {
5
+ renderDescriptionViewport,
6
+ renderSelectableRow,
7
+ renderTuiHintLine,
8
+ } from "@mrclrchtr/supi-core/tui";
4
9
  import type { ThemeAccessor } from "./settings-action-menu.ts";
5
10
  import {
6
11
  buildActionMenu,
7
12
  createActionMenuComponent,
8
13
  getConcreteChoices,
9
14
  } from "./settings-action-menu.ts";
10
- import { renderDescriptionViewport } from "./settings-list-layout.ts";
11
15
  import { type LoadedSettingsModule, readSettingsModules } from "./settings-module-reader.ts";
12
16
  import {
13
17
  filterSettingsRows,
@@ -62,7 +66,7 @@ export class ScopedSettingsList {
62
66
  this.tui = tui;
63
67
  this.onCancel = onCancel;
64
68
  this.onError = onError;
65
- this.rebuildRows(initial);
69
+ this.rows = rowsFromModules(initial);
66
70
  }
67
71
  async reload(scope: SettingsScope, cwd: string, ctx?: ExtensionContext): Promise<void> {
68
72
  this.scope = scope;
@@ -130,6 +134,7 @@ export class ScopedSettingsList {
130
134
  30,
131
135
  Math.max(...displayRows.map((row) => visibleWidth(row.field.field.label))),
132
136
  );
137
+ const maxIndent = Math.max(...displayRows.map((row) => (row.subsectionLabel ? 6 : 4)));
133
138
  let previousSection: string | undefined;
134
139
  let previousSubsection: string | undefined;
135
140
  let visibleHeaderCount = 0;
@@ -151,19 +156,19 @@ export class ScopedSettingsList {
151
156
  visibleHeaderCount++;
152
157
  }
153
158
  }
154
- const isSelected = i === this.selectedIndex;
155
159
  const indent = row.subsectionLabel ? 6 : 4;
156
- const prefix = isSelected
157
- ? `${" ".repeat(indent - 2)}${this.theme.fg("accent", "→ ")}`
158
- : " ".repeat(indent);
159
- const label = row.field.field.label.padEnd(
160
- row.field.field.label.length + maxLabelWidth - visibleWidth(row.field.field.label),
160
+ lines.push(
161
+ renderSelectableRow({
162
+ label: row.field.field.label,
163
+ value: row.field.displayValue,
164
+ indent,
165
+ maxIndent,
166
+ maxLabelWidth,
167
+ width,
168
+ selected: i === this.selectedIndex,
169
+ theme: this.theme,
170
+ }),
161
171
  );
162
- const labelText = this.theme.fg(isSelected ? "accent" : "text", label);
163
- const valueWidth = Math.max(0, width - indent - maxLabelWidth - 2);
164
- const value = truncateToWidth(row.field.displayValue, valueWidth, "");
165
- const valueText = this.theme.fg(isSelected ? "accent" : "muted", value);
166
- lines.push(truncateToWidth(`${prefix}${labelText} ${valueText}`, width));
167
172
  }
168
173
  const reservedHeaderCount = maxVisibleHeaderCount(displayRows, maxVisible);
169
174
  for (let i = visibleHeaderCount; i < reservedHeaderCount; i++) lines.push("");
@@ -174,7 +179,7 @@ export class ScopedSettingsList {
174
179
  if (displayRows.some((row) => row.field.field.description)) {
175
180
  lines.push("");
176
181
  for (const line of renderDescriptionViewport(description, width)) {
177
- lines.push(line ? this.theme.fg("dim", ` ${line}`) : "");
182
+ lines.push(line ? this.theme.fg("dim", line) : "");
178
183
  }
179
184
  }
180
185
  lines.push("");
@@ -183,10 +188,9 @@ export class ScopedSettingsList {
183
188
  }
184
189
 
185
190
  private renderHint(width: number): string {
186
- const hints = [];
187
- if (this.searchInput) hints.push("Type to search");
191
+ const hints = this.searchInput ? ["Type to search"] : [];
188
192
  hints.push("Enter for actions", "Space to cycle", "Tab for scope", "Esc to close");
189
- return truncateToWidth(this.theme.fg("dim", hints.join(" · ")), width);
193
+ return renderTuiHintLine(hints, this.theme, width);
190
194
  }
191
195
 
192
196
  handleInput(data: string): void {
@@ -248,17 +252,13 @@ export class ScopedSettingsList {
248
252
  if (!this.searchInput) this.searchInput = new Input();
249
253
  }
250
254
 
251
- private rebuildRows(loaded: LoadedSettingsModule[]): void {
252
- this.rows = rowsFromModules(loaded);
253
- }
254
-
255
255
  private async refreshRows(): Promise<void> {
256
256
  const result = await readSettingsModules(this.modules, {
257
257
  scope: this.scope,
258
258
  cwd: this.cwd,
259
259
  ctx: this.ctx,
260
260
  });
261
- this.rebuildRows(result.loaded);
261
+ this.rows = rowsFromModules(result.loaded);
262
262
  for (const error of result.errors) this.onError?.(error);
263
263
  }
264
264
 
@@ -4,8 +4,8 @@
4
4
  // value choices, Inherit from global, Use default, and Reset to default.
5
5
 
6
6
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
8
7
  import type { ScopedFieldValue, SettingsField, SettingsScope } from "@mrclrchtr/supi-core/settings";
8
+ import { createSelectListMenu } from "@mrclrchtr/supi-core/tui";
9
9
 
10
10
  /** Theme accessor type matching the TUI custom() theme parameter. */
11
11
  export type ThemeAccessor = Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[1];
@@ -76,26 +76,11 @@ export function createActionMenuComponent(
76
76
  done: (action?: string) => void,
77
77
  theme: ThemeAccessor,
78
78
  ) {
79
- const items: SelectItem[] = menu.map((m) => ({ value: m.value, label: m.label }));
80
- const container = new Container();
81
- container.addChild(new Text(theme.fg("accent", " Actions"), 1, 0));
82
- const selectList = new SelectList(items, Math.min(items.length + 2, 15), {
83
- selectedPrefix: (t) => theme.fg("accent", t),
84
- selectedText: (t) => theme.fg("accent", t),
85
- description: (t) => theme.fg("muted", t),
86
- scrollInfo: (t) => theme.fg("dim", t),
87
- noMatch: (t) => theme.fg("warning", t),
79
+ return createSelectListMenu({
80
+ title: "Actions",
81
+ items: menu.map((item) => ({ value: item.value, label: item.label })),
82
+ theme,
83
+ onSelect: (item) => done(item.value),
84
+ onCancel: () => done(),
88
85
  });
89
- selectList.onSelect = (item) => done(item.value);
90
- selectList.onCancel = () => done();
91
- container.addChild(selectList);
92
- container.addChild(new Text(theme.fg("dim", " ↑↓ navigate • enter select • esc cancel"), 1, 0));
93
- return {
94
- render: (width: number) => container.render(width),
95
- invalidate: () => container.invalidate(),
96
- handleInput: (data: string) => {
97
- selectList.handleInput(data);
98
- return true;
99
- },
100
- };
101
86
  }
@@ -13,6 +13,7 @@ import {
13
13
  type SettingsScope,
14
14
  SUPI_SETTINGS_COLLECT_EVENT,
15
15
  } from "@mrclrchtr/supi-core/settings";
16
+ import { formatTuiTitleLine } from "@mrclrchtr/supi-core/tui";
16
17
  import { ScopedSettingsList } from "./scoped-settings-list.ts";
17
18
  import { readSettingsModules } from "./settings-module-reader.ts";
18
19
 
@@ -95,8 +96,12 @@ export async function openSettingsOverlay(pi: ExtensionAPI, ctx: ExtensionContex
95
96
  container.addChild(new DynamicBorder((text: string) => theme.fg("borderMuted", text)));
96
97
  container.addChild(
97
98
  new Text(
98
- `${theme.fg("accent", theme.bold("SuPi Settings"))} ${theme.fg("dim", "Scope")} ${scope("Project", "project")} ${scope("Global", "global")}`,
99
- 1,
99
+ formatTuiTitleLine(
100
+ "SuPi Settings",
101
+ theme,
102
+ `${theme.fg("dim", "Scope")} ${scope("Project", "project")} ${scope("Global", "global")}`,
103
+ ),
104
+ 0,
100
105
  0,
101
106
  ),
102
107
  );
@@ -1,19 +0,0 @@
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
- }