@mrclrchtr/supi-settings 4.6.0 → 4.8.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.
Files changed (27) hide show
  1. package/README.md +8 -6
  2. package/node_modules/@mrclrchtr/supi-core/README.md +26 -34
  3. package/node_modules/@mrclrchtr/supi-core/package.json +4 -7
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +4 -6
  5. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +0 -20
  6. package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +7 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +0 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/context.ts +1 -9
  9. package/node_modules/@mrclrchtr/supi-core/src/index.ts +4 -6
  10. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +54 -28
  11. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +91 -125
  12. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +10 -7
  13. package/package.json +2 -2
  14. package/src/api.ts +2 -1
  15. package/src/extension.ts +5 -2
  16. package/src/index.ts +1 -1
  17. package/{node_modules/@mrclrchtr/supi-core/src/settings → src/ui}/scoped-settings-list.ts +112 -93
  18. package/{node_modules/@mrclrchtr/supi-core/src/settings → src/ui}/settings-action-menu.ts +1 -2
  19. package/src/ui/settings-module-reader.ts +36 -0
  20. package/src/ui/settings-row-model.ts +30 -0
  21. package/{node_modules/@mrclrchtr/supi-core/src/settings → src/ui}/settings-submenus.ts +2 -2
  22. package/{node_modules/@mrclrchtr/supi-core/src/settings → src/ui}/settings-ui.ts +43 -34
  23. package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +0 -119
  24. package/node_modules/@mrclrchtr/supi-core/src/progress-widget.ts +0 -189
  25. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +0 -15
  26. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +0 -3
  27. package/node_modules/@mrclrchtr/supi-core/src/tool-framework.ts +0 -192
@@ -1,42 +1,35 @@
1
- // Scoped settings list component for SuPi settings overlay.
2
- //
3
- // Replaces pi-tui's generic SettingsList with a source-aware list that
4
- // renders source badges, dispatches Enter / Space / action-menu semantics,
5
- // and delegates custom-field submenus.
1
+ // Source-aware settings list with scope actions and custom submenus.
6
2
 
7
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { Input, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ Input,
6
+ Key,
7
+ matchesKey,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ wrapTextWithAnsi,
11
+ } from "@earendil-works/pi-tui";
12
+ import type { SettingsAction, SettingsModule, SettingsScope } from "@mrclrchtr/supi-core/settings";
9
13
  import type { ThemeAccessor } from "./settings-action-menu.ts";
10
14
  import {
11
15
  buildActionMenu,
12
16
  createActionMenuComponent,
13
17
  getConcreteChoices,
14
18
  } from "./settings-action-menu.ts";
15
- import type { SettingsScope, SettingsSection } from "./settings-registry.ts";
16
- import type { ScopedFieldValue, SettingsFieldAction } from "./settings-schema.ts";
19
+ import { type LoadedSettingsModule, readSettingsModules } from "./settings-module-reader.ts";
20
+ import { filterSettingsRows, rowsFromModules, type ScopedRow } from "./settings-row-model.ts";
17
21
  import { createInputSubmenu, createModelPickerSubmenu } from "./settings-submenus.ts";
18
22
 
19
- // ═══════════════════════════════════════════════════════════════════════════
20
- // Scoped settings list
21
- // ═══════════════════════════════════════════════════════════════════════════
22
-
23
- interface ScopedRow {
24
- flatId: string;
25
- sectionLabel: string;
26
- field: ScopedFieldValue;
27
- }
28
-
29
23
  interface SubmenuState {
30
24
  component: {
31
25
  render: (w: number) => string[];
32
26
  invalidate?: () => void;
33
27
  handleInput?: (data: string) => void;
34
28
  };
35
- onDone: () => void;
36
29
  }
37
30
 
38
31
  export class ScopedSettingsList {
39
- private sections: SettingsSection[];
32
+ private modules: SettingsModule[];
40
33
  private scope: SettingsScope;
41
34
  private cwd: string;
42
35
  private ctx: ExtensionContext | undefined;
@@ -49,12 +42,14 @@ export class ScopedSettingsList {
49
42
  private submenu: SubmenuState | null = null;
50
43
  private searchInput?: Input;
51
44
  private searchQuery = "";
45
+ private actionPending = false;
52
46
  private cachedWidth?: number;
53
47
  private cachedLines?: string[];
54
48
 
55
49
  // biome-ignore lint/complexity/useMaxParams: component constructor needs all dependencies upfront for immutable wiring
56
50
  constructor(
57
- sections: SettingsSection[],
51
+ modules: SettingsModule[],
52
+ initial: LoadedSettingsModule[],
58
53
  scope: SettingsScope,
59
54
  cwd: string,
60
55
  ctx: ExtensionContext | undefined,
@@ -63,7 +58,7 @@ export class ScopedSettingsList {
63
58
  onCancel: () => void,
64
59
  onError?: (message: string) => void,
65
60
  ) {
66
- this.sections = sections;
61
+ this.modules = modules;
67
62
  this.scope = scope;
68
63
  this.cwd = cwd;
69
64
  this.ctx = ctx;
@@ -71,16 +66,30 @@ export class ScopedSettingsList {
71
66
  this.tui = tui;
72
67
  this.onCancel = onCancel;
73
68
  this.onError = onError;
74
- this.rebuildRows();
69
+ this.rebuildRows(initial);
75
70
  }
76
71
 
77
- reload(scope: SettingsScope, cwd: string, ctx?: ExtensionContext): void {
72
+ async reload(scope: SettingsScope, cwd: string, ctx?: ExtensionContext): Promise<void> {
78
73
  this.scope = scope;
79
74
  this.cwd = cwd;
80
75
  this.ctx = ctx;
81
- this.rebuildRows();
82
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
83
- this.invalidate();
76
+ this.actionPending = true;
77
+ try {
78
+ await this.refreshRows();
79
+ this.selectedIndex = Math.min(
80
+ this.selectedIndex,
81
+ Math.max(0, this.filteredRows().length - 1),
82
+ );
83
+ } finally {
84
+ this.actionPending = false;
85
+ this.invalidate();
86
+ this.tui.requestRender();
87
+ }
88
+ }
89
+
90
+ /** Return true while a setting editor, action menu, or persistence action owns input. */
91
+ hasOpenSubmenu(): boolean {
92
+ return this.submenu !== null || this.actionPending;
84
93
  }
85
94
 
86
95
  invalidate(): void {
@@ -100,6 +109,7 @@ export class ScopedSettingsList {
100
109
  return this.cachedLines;
101
110
  }
102
111
 
112
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: One pass handles filtering, grouping, selection, scrolling, and descriptions.
103
113
  private renderList(width: number): string[] {
104
114
  const lines: string[] = [];
105
115
  if (this.searchInput) {
@@ -118,39 +128,61 @@ export class ScopedSettingsList {
118
128
  lines.push(this.renderHint(width));
119
129
  return lines;
120
130
  }
121
- const maxVisible = Math.min(displayRows.length + 2, 20);
131
+ const maxVisible = Math.min(displayRows.length, 10);
122
132
  const start = Math.max(
123
133
  0,
124
134
  Math.min(this.selectedIndex - Math.floor(maxVisible / 2), displayRows.length - maxVisible),
125
135
  );
126
136
  const end = Math.min(start + maxVisible, displayRows.length);
137
+ const maxLabelWidth = Math.min(
138
+ 30,
139
+ Math.max(...displayRows.map((row) => visibleWidth(row.field.field.label))),
140
+ );
141
+ let previousSection: string | undefined;
127
142
  for (let i = start; i < end; i++) {
128
143
  const row = displayRows[i];
129
144
  if (!row) continue;
145
+ if (row.moduleLabel !== previousSection) {
146
+ lines.push(
147
+ truncateToWidth(` ${this.theme.fg("muted", this.theme.bold(row.moduleLabel))}`, width),
148
+ );
149
+ previousSection = row.moduleLabel;
150
+ }
130
151
  const isSelected = i === this.selectedIndex;
131
- const prefix = isSelected ? " " : " ";
132
- const section = this.theme.fg("dim", `${row.sectionLabel}: `);
133
- const label = isSelected
134
- ? this.theme.fg("accent", row.field.field.label)
135
- : this.theme.fg("text", row.field.field.label);
136
- lines.push(truncateToWidth(`${prefix}${section}${label} ${row.field.displayValue}`, width));
152
+ const prefix = isSelected ? ` ${this.theme.fg("accent", " ")}` : " ";
153
+ const label = row.field.field.label.padEnd(
154
+ row.field.field.label.length + maxLabelWidth - visibleWidth(row.field.field.label),
155
+ );
156
+ const labelText = this.theme.fg(isSelected ? "accent" : "text", label);
157
+ const valueWidth = Math.max(0, width - 4 - maxLabelWidth - 2);
158
+ const value = truncateToWidth(row.field.displayValue, valueWidth, "");
159
+ const valueText = this.theme.fg(isSelected ? "accent" : "muted", value);
160
+ lines.push(truncateToWidth(`${prefix}${labelText} ${valueText}`, width));
137
161
  }
138
162
  if (start > 0 || end < displayRows.length) {
139
163
  lines.push(this.theme.fg("dim", ` (${this.selectedIndex + 1}/${displayRows.length})`));
140
164
  }
165
+ const description = displayRows[this.selectedIndex]?.field.field.description;
166
+ if (description) {
167
+ lines.push("");
168
+ for (const line of wrapTextWithAnsi(description, Math.max(1, width - 4))) {
169
+ lines.push(this.theme.fg("dim", ` ${line}`));
170
+ }
171
+ }
141
172
  lines.push("");
142
173
  lines.push(this.renderHint(width));
143
174
  return lines;
144
175
  }
145
176
 
146
- private renderHint(_width: number): string {
177
+ private renderHint(width: number): string {
147
178
  const hints = [];
148
179
  if (this.searchInput) hints.push("Type to search");
149
- hints.push("Enter actions", "Space cycle", "Esc close");
150
- return this.theme.fg("dim", hints.join(" · "));
180
+ hints.push("Enter for actions", "Space to cycle", "Tab for scope", "Esc to close");
181
+ return truncateToWidth(this.theme.fg("dim", hints.join(" · ")), width);
151
182
  }
152
183
 
153
184
  handleInput(data: string): void {
185
+ if (this.actionPending) return;
154
186
  if (this.submenu) {
155
187
  this.submenu.component.handleInput?.(data);
156
188
  this.invalidate();
@@ -208,29 +240,22 @@ export class ScopedSettingsList {
208
240
  if (!this.searchInput) this.searchInput = new Input();
209
241
  }
210
242
 
211
- private rebuildRows(): void {
212
- const rows: ScopedRow[] = [];
213
- for (const section of this.sections) {
214
- for (const value of section.loadValues(this.scope, this.cwd, this.ctx)) {
215
- rows.push({
216
- flatId: `${section.id}.${value.field.key}`,
217
- sectionLabel: section.label,
218
- field: value,
219
- });
220
- }
221
- }
222
- this.rows = rows;
243
+ private rebuildRows(loaded: LoadedSettingsModule[]): void {
244
+ this.rows = rowsFromModules(loaded);
245
+ }
246
+
247
+ private async refreshRows(): Promise<void> {
248
+ const result = await readSettingsModules(this.modules, {
249
+ scope: this.scope,
250
+ cwd: this.cwd,
251
+ ctx: this.ctx,
252
+ });
253
+ this.rebuildRows(result.loaded);
254
+ for (const error of result.errors) this.onError?.(error);
223
255
  }
224
256
 
225
257
  private filteredRows(): ScopedRow[] {
226
- if (!this.searchQuery) return this.rows;
227
- const q = this.searchQuery.toLowerCase();
228
- return this.rows.filter(
229
- (r) =>
230
- r.field.field.label.toLowerCase().includes(q) ||
231
- r.field.field.key.toLowerCase().includes(q) ||
232
- r.field.displayValue.toLowerCase().includes(q),
233
- );
258
+ return filterSettingsRows(this.rows, this.searchQuery);
234
259
  }
235
260
 
236
261
  private activateSelected(): void {
@@ -242,11 +267,6 @@ export class ScopedSettingsList {
242
267
  if (menu.length === 0) return;
243
268
  this.submenu = {
244
269
  component: createActionMenuComponent(menu, this.doneAction(row), this.theme),
245
- onDone: () => {
246
- this.submenu = null;
247
- this.invalidate();
248
- this.tui.requestRender();
249
- },
250
270
  };
251
271
  this.invalidate();
252
272
  this.tui.requestRender();
@@ -260,10 +280,8 @@ export class ScopedSettingsList {
260
280
  this.tui.requestRender();
261
281
  return;
262
282
  }
263
- if (action === "inherit") {
264
- this.dispatchAction(row.flatId, { kind: "inherit" });
265
- } else if (action === "resetToDefault") {
266
- this.dispatchAction(row.flatId, { kind: "resetToDefault" });
283
+ if (action === "inherit" || action === "resetToDefault") {
284
+ this.dispatchAction(row.flatId, { kind: "unset" });
267
285
  } else if (action === "edit") {
268
286
  this.openFreeInputSubmenu(row);
269
287
  } else if (action.startsWith("set:")) {
@@ -295,7 +313,7 @@ export class ScopedSettingsList {
295
313
  if (selectedValue !== undefined) {
296
314
  this.dispatchAction(row.flatId, { kind: "set", value: selectedValue });
297
315
  } else {
298
- this.reload(this.scope, this.cwd, this.ctx);
316
+ void this.reload(this.scope, this.cwd, this.ctx);
299
317
  }
300
318
  this.invalidate();
301
319
  this.tui.requestRender();
@@ -304,15 +322,7 @@ export class ScopedSettingsList {
304
322
  this.cwd,
305
323
  this.ctx,
306
324
  );
307
- this.submenu = {
308
- component: comp,
309
- onDone: () => {
310
- this.submenu = null;
311
- this.reload(this.scope, this.cwd, this.ctx);
312
- this.invalidate();
313
- this.tui.requestRender();
314
- },
315
- };
325
+ this.submenu = { component: comp };
316
326
  } else if (row.field.field.kind === "modelPicker") {
317
327
  this.submenu = {
318
328
  component: createModelPickerSubmenu(
@@ -326,11 +336,6 @@ export class ScopedSettingsList {
326
336
  this.ctx,
327
337
  row.field.field,
328
338
  ),
329
- onDone: () => {
330
- this.submenu = null;
331
- this.invalidate();
332
- this.tui.requestRender();
333
- },
334
339
  };
335
340
  } else {
336
341
  const label =
@@ -344,30 +349,44 @@ export class ScopedSettingsList {
344
349
  this.invalidate();
345
350
  this.tui.requestRender();
346
351
  }),
347
- onDone: () => {
348
- this.submenu = null;
349
- this.invalidate();
350
- this.tui.requestRender();
351
- },
352
352
  };
353
353
  }
354
354
  this.invalidate();
355
355
  this.tui.requestRender();
356
356
  }
357
357
 
358
- private dispatchAction(flatId: string, action: SettingsFieldAction): void {
358
+ private dispatchAction(flatId: string, action: SettingsAction): void {
359
+ void this.runAction(flatId, action);
360
+ }
361
+
362
+ private async runAction(flatId: string, action: SettingsAction): Promise<void> {
359
363
  const dotIndex = flatId.indexOf(".");
360
364
  if (dotIndex === -1) return;
361
- const sectionId = flatId.slice(0, dotIndex);
365
+ const moduleId = flatId.slice(0, dotIndex);
362
366
  const fieldKey = flatId.slice(dotIndex + 1);
363
- const section = this.sections.find((s) => s.id === sectionId);
364
- if (!section) return;
367
+ const module = this.modules.find((candidate) => candidate.id === moduleId);
368
+ if (!module) return;
369
+
370
+ this.actionPending = true;
365
371
  try {
366
- section.handleAction(this.scope, this.cwd, fieldKey, action, this.ctx);
372
+ const result = await module.apply({
373
+ scope: this.scope,
374
+ cwd: this.cwd,
375
+ ctx: this.ctx,
376
+ fieldKey,
377
+ action,
378
+ });
379
+ if (result?.notice) {
380
+ this.ctx?.ui.notify(result.notice.message, result.notice.level);
381
+ }
382
+ await this.refreshRows();
367
383
  } catch (err) {
368
384
  this.onError?.(err instanceof Error ? err.message : String(err));
385
+ await this.refreshRows();
386
+ } finally {
387
+ this.actionPending = false;
388
+ this.invalidate();
389
+ this.tui.requestRender();
369
390
  }
370
- this.reload(this.scope, this.cwd, this.ctx);
371
- this.tui.requestRender();
372
391
  }
373
392
  }
@@ -5,8 +5,7 @@
5
5
 
6
6
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
8
- import type { SettingsScope } from "./settings-registry.ts";
9
- import type { ScopedFieldValue, SettingsField } from "./settings-schema.ts";
8
+ import type { ScopedFieldValue, SettingsField, SettingsScope } from "@mrclrchtr/supi-core/settings";
10
9
 
11
10
  /** Theme accessor type matching the TUI custom() theme parameter. */
12
11
  export type ThemeAccessor = Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[1];
@@ -0,0 +1,36 @@
1
+ import type {
2
+ SettingsContext,
3
+ SettingsModule,
4
+ SettingsSnapshot,
5
+ } from "@mrclrchtr/supi-core/settings";
6
+
7
+ export interface LoadedSettingsModule {
8
+ module: SettingsModule;
9
+ snapshot: SettingsSnapshot;
10
+ }
11
+
12
+ export interface SettingsReadResult {
13
+ loaded: LoadedSettingsModule[];
14
+ errors: string[];
15
+ }
16
+
17
+ /** Read independent settings modules without hiding successful modules when one fails. */
18
+ export async function readSettingsModules(
19
+ modules: SettingsModule[],
20
+ context: SettingsContext,
21
+ ): Promise<SettingsReadResult> {
22
+ const results = await Promise.all(
23
+ modules.map(async (module) => {
24
+ try {
25
+ return { loaded: { module, snapshot: await module.read(context) } };
26
+ } catch (error) {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ return { error: `${module.label}: ${message}` };
29
+ }
30
+ }),
31
+ );
32
+ return {
33
+ loaded: results.flatMap((result) => (result.loaded ? [result.loaded] : [])),
34
+ errors: results.flatMap((result) => (result.error ? [result.error] : [])),
35
+ };
36
+ }
@@ -0,0 +1,30 @@
1
+ import type { ScopedFieldValue } from "@mrclrchtr/supi-core/settings";
2
+ import type { LoadedSettingsModule } from "./settings-module-reader.ts";
3
+
4
+ export interface ScopedRow {
5
+ flatId: string;
6
+ moduleLabel: string;
7
+ field: ScopedFieldValue;
8
+ }
9
+
10
+ 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
+ );
18
+ }
19
+
20
+ export function filterSettingsRows(rows: ScopedRow[], query: string): ScopedRow[] {
21
+ if (!query) return rows;
22
+ const normalized = query.toLowerCase();
23
+ return rows.filter(
24
+ (row) =>
25
+ row.moduleLabel.toLowerCase().includes(normalized) ||
26
+ row.field.field.label.toLowerCase().includes(normalized) ||
27
+ row.field.field.key.toLowerCase().includes(normalized) ||
28
+ row.field.displayValue.toLowerCase().includes(normalized),
29
+ );
30
+ }
@@ -13,8 +13,8 @@ import {
13
13
  SelectList,
14
14
  Text,
15
15
  } from "@earendil-works/pi-tui";
16
- import { getSelectableModels } from "../model-selection.ts";
17
- import type { ModelPickerField } from "./settings-schema.ts";
16
+ import { getSelectableModels } from "@mrclrchtr/supi-core/model-selection";
17
+ import type { ModelPickerField } from "@mrclrchtr/supi-core/settings";
18
18
 
19
19
  /**
20
20
  * Creates a pi-tui Input-backed submenu component with enter-to-confirm
@@ -1,21 +1,19 @@
1
- // Declarative settings overlay for SuPi extensions.
2
- //
3
- // Thin orchestration layer that collects settings contributions and opens the
4
- // scoped settings list inside a pi-tui custom component overlay.
1
+ // Declarative settings screen for SuPi extensions.
5
2
 
6
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ DynamicBorder,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
7
8
  import { Container, Key, matchesKey, Text } from "@earendil-works/pi-tui";
8
- import { ScopedSettingsList } from "./scoped-settings-list.ts";
9
- import type { SettingsCollectionDiagnostic, SettingsScope } from "./settings-registry.ts";
10
9
  import {
11
10
  createSettingsContributionCollector,
11
+ type SettingsCollectionDiagnostic,
12
+ type SettingsScope,
12
13
  SUPI_SETTINGS_COLLECT_EVENT,
13
- } from "./settings-registry.ts";
14
-
15
- // Re-export submenu helpers
16
- export { createInputSubmenu, createModelPickerSubmenu } from "./settings-submenus.ts";
17
-
18
- // ── Overlay ────────────────────────────────────────────────────────────────
14
+ } from "@mrclrchtr/supi-core/settings";
15
+ import { ScopedSettingsList } from "./scoped-settings-list.ts";
16
+ import { readSettingsModules } from "./settings-module-reader.ts";
19
17
 
20
18
  interface OverlayStatus {
21
19
  kind: "warning" | "error";
@@ -28,11 +26,7 @@ interface OverlayState {
28
26
  status?: OverlayStatus;
29
27
  }
30
28
 
31
- function getScopeLabel(scope: SettingsScope): string {
32
- return scope === "project" ? "Project" : "Global";
33
- }
34
-
35
- function collectSettingsSections(pi: ExtensionAPI) {
29
+ function collectSettingsModules(pi: ExtensionAPI) {
36
30
  const collector = createSettingsContributionCollector();
37
31
  pi.events.emit(SUPI_SETTINGS_COLLECT_EVENT, collector);
38
32
  return collector.result();
@@ -43,23 +37,32 @@ function latestStatus(diagnostics: SettingsCollectionDiagnostic[]): OverlayStatu
43
37
  return latest ? { kind: latest.kind, message: latest.message } : undefined;
44
38
  }
45
39
 
46
- export function openSettingsOverlay(pi: ExtensionAPI, ctx: ExtensionContext): void {
47
- const collection = collectSettingsSections(pi);
48
- if (collection.sections.length === 0) {
40
+ export async function openSettingsOverlay(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
41
+ const collection = collectSettingsModules(pi);
42
+ if (collection.modules.length === 0) {
49
43
  ctx.ui.notify("No settings registered by SuPi extensions", "info");
50
44
  return;
51
45
  }
52
46
 
47
+ const initial = await readSettingsModules(collection.modules, {
48
+ scope: "project",
49
+ cwd: ctx.cwd,
50
+ ctx,
51
+ });
52
+ const initialError = initial.errors.at(-1);
53
53
  void ctx.ui.custom<void>((tui, theme, _kb, done) => {
54
54
  const state: OverlayState = {
55
55
  scope: "project",
56
56
  cwd: ctx.cwd,
57
- status: latestStatus(collection.diagnostics),
57
+ status: initialError
58
+ ? { kind: "error", message: initialError }
59
+ : latestStatus(collection.diagnostics),
58
60
  };
59
61
 
60
62
  const container = new Container();
61
63
  const scopedList = new ScopedSettingsList(
62
- collection.sections,
64
+ collection.modules,
65
+ initial.loaded,
63
66
  state.scope,
64
67
  state.cwd,
65
68
  ctx,
@@ -76,35 +79,41 @@ export function openSettingsOverlay(pi: ExtensionAPI, ctx: ExtensionContext): vo
76
79
 
77
80
  const rebuildOverlay = () => {
78
81
  container.clear();
79
- const scopeLabel = getScopeLabel(state.scope);
80
- const otherScope = state.scope === "project" ? "Global" : "Project";
82
+ const scope = (label: string, value: SettingsScope) =>
83
+ value === state.scope
84
+ ? theme.fg("accent", theme.bold(`[${label}]`))
85
+ : theme.fg("dim", label);
86
+ container.addChild(new DynamicBorder((text: string) => theme.fg("borderMuted", text)));
81
87
  container.addChild(
82
88
  new Text(
83
- `${theme.fg("accent", theme.bold("SuPi Settings"))} ${theme.fg("text", `Scope: ${scopeLabel}`)} ${theme.fg("dim", `(tab → ${otherScope})`)}`,
84
- 0,
89
+ `${theme.fg("accent", theme.bold("SuPi Settings"))} ${theme.fg("dim", "Scope")} ${scope("Project", "project")} ${scope("Global", "global")}`,
90
+ 1,
85
91
  0,
86
92
  ),
87
93
  );
88
94
  if (state.status) {
89
- container.addChild(new Text(theme.fg(state.status.kind, state.status.message), 0, 0));
95
+ container.addChild(new Text(theme.fg(state.status.kind, state.status.message), 1, 0));
90
96
  }
97
+ container.addChild(scopedList);
98
+ container.addChild(new DynamicBorder((text: string) => theme.fg("borderMuted", text)));
91
99
  };
92
100
 
93
101
  rebuildOverlay();
94
102
 
95
103
  const component = {
96
- render: (width: number) => [...container.render(width), ...scopedList.render(width)],
104
+ render: (width: number) => container.render(width),
97
105
  invalidate: () => {
106
+ rebuildOverlay();
98
107
  container.invalidate();
99
- scopedList.invalidate();
100
108
  },
101
109
  handleInput: (data: string) => {
102
- if (matchesKey(data, Key.tab)) {
110
+ if (matchesKey(data, Key.tab) && !scopedList.hasOpenSubmenu()) {
103
111
  state.scope = state.scope === "project" ? "global" : "project";
104
112
  state.status = undefined;
105
- scopedList.reload(state.scope, state.cwd, ctx);
106
- rebuildOverlay();
107
- tui.requestRender();
113
+ void scopedList.reload(state.scope, state.cwd, ctx).then(() => {
114
+ rebuildOverlay();
115
+ tui.requestRender();
116
+ });
108
117
  return true;
109
118
  }
110
119
  scopedList.handleInput(data);