@aliou/pi-utils-settings 0.1.0 → 0.2.1
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/components/sectioned-settings.ts +32 -9
- package/config-loader.ts +152 -40
- package/index.ts +1 -0
- package/package.json +1 -1
- package/settings-command.ts +107 -55
|
@@ -328,6 +328,9 @@ export class SectionedSettings implements Component {
|
|
|
328
328
|
/**
|
|
329
329
|
* Apply search filter to entries without resetting the cursor.
|
|
330
330
|
* Used by updateSections() to preserve selection.
|
|
331
|
+
*
|
|
332
|
+
* Matches on both item labels and section labels. When a section label
|
|
333
|
+
* matches, all items in that section are included.
|
|
331
334
|
*/
|
|
332
335
|
private filterEntries(query: string): void {
|
|
333
336
|
if (!query) {
|
|
@@ -335,29 +338,49 @@ export class SectionedSettings implements Component {
|
|
|
335
338
|
return;
|
|
336
339
|
}
|
|
337
340
|
|
|
341
|
+
const q = query.toLowerCase();
|
|
338
342
|
const filtered: FlatEntry[] = [];
|
|
339
343
|
let currentSection: FlatEntry | null = null;
|
|
344
|
+
let sectionLabelMatches = false;
|
|
340
345
|
let sectionHasMatch = false;
|
|
346
|
+
let sectionItems: FlatEntry[] = [];
|
|
347
|
+
|
|
348
|
+
const flushSection = () => {
|
|
349
|
+
if (sectionLabelMatches && currentSection) {
|
|
350
|
+
// Section label matched: include header + all items
|
|
351
|
+
filtered.push(currentSection);
|
|
352
|
+
filtered.push(...sectionItems);
|
|
353
|
+
} else if (sectionHasMatch && currentSection) {
|
|
354
|
+
// Only some items matched: include header + matched items
|
|
355
|
+
filtered.push(currentSection);
|
|
356
|
+
for (const item of sectionItems) {
|
|
357
|
+
if (item.item && item.item.label.toLowerCase().includes(q)) {
|
|
358
|
+
filtered.push(item);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
341
363
|
|
|
342
364
|
for (const entry of this.flatEntries) {
|
|
343
365
|
if (entry.type === "section") {
|
|
366
|
+
flushSection();
|
|
344
367
|
currentSection = entry;
|
|
345
|
-
|
|
368
|
+
sectionLabelMatches = (entry.sectionLabel ?? "")
|
|
369
|
+
.toLowerCase()
|
|
370
|
+
.includes(q);
|
|
371
|
+
sectionHasMatch = sectionLabelMatches;
|
|
372
|
+
sectionItems = [];
|
|
346
373
|
continue;
|
|
347
374
|
}
|
|
348
375
|
|
|
349
376
|
if (entry.item) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (currentSection && !sectionHasMatch) {
|
|
354
|
-
filtered.push(currentSection);
|
|
355
|
-
sectionHasMatch = true;
|
|
356
|
-
}
|
|
357
|
-
filtered.push(entry);
|
|
377
|
+
sectionItems.push(entry);
|
|
378
|
+
if (entry.item.label.toLowerCase().includes(q)) {
|
|
379
|
+
sectionHasMatch = true;
|
|
358
380
|
}
|
|
359
381
|
}
|
|
360
382
|
}
|
|
383
|
+
flushSection();
|
|
361
384
|
|
|
362
385
|
this.filteredEntries = filtered;
|
|
363
386
|
}
|
package/config-loader.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generic JSON config loader for pi extensions.
|
|
3
3
|
*
|
|
4
|
-
* Loads config from
|
|
5
|
-
* and optionally applies versioned migrations.
|
|
4
|
+
* Loads config from configurable scopes (global, local, memory),
|
|
5
|
+
* deep-merges with defaults, and optionally applies versioned migrations.
|
|
6
6
|
*
|
|
7
7
|
* Global: ~/.pi/agent/extensions/{name}.json
|
|
8
|
-
*
|
|
8
|
+
* Local: {project}/.pi/extensions/{name}.json (walks up to find .pi)
|
|
9
|
+
* Memory: In-memory only, not persisted, resets on reload
|
|
10
|
+
*
|
|
11
|
+
* Merge priority (lowest to highest): defaults -> global -> local -> memory
|
|
9
12
|
*/
|
|
10
13
|
|
|
14
|
+
import { existsSync, statSync } from "node:fs";
|
|
11
15
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
12
17
|
import { dirname, resolve } from "node:path";
|
|
13
18
|
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
14
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Available configuration scopes.
|
|
22
|
+
* - global: User-wide settings in ~/.pi/agent/extensions/
|
|
23
|
+
* - local: Project-specific settings in {project}/.pi/extensions/
|
|
24
|
+
* - memory: Ephemeral settings, not persisted, reset on reload
|
|
25
|
+
*/
|
|
26
|
+
export type Scope = "global" | "local" | "memory";
|
|
27
|
+
|
|
15
28
|
/**
|
|
16
29
|
* A migration that transforms a config from one version to another.
|
|
17
30
|
* Migrations are applied in order during load(). If any migration
|
|
@@ -36,76 +49,127 @@ export interface Migration<TConfig> {
|
|
|
36
49
|
*/
|
|
37
50
|
export interface ConfigStore<TConfig extends object, TResolved extends object> {
|
|
38
51
|
getConfig(): TResolved;
|
|
39
|
-
getRawConfig(scope:
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
getRawConfig(scope: Scope): TConfig | null;
|
|
53
|
+
hasScope(scope: Scope): boolean;
|
|
54
|
+
hasConfig(scope: Scope): boolean;
|
|
55
|
+
getEnabledScopes(): Scope[];
|
|
56
|
+
save(scope: Scope, config: TConfig): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Walk up from cwd to find the project root (.pi directory).
|
|
61
|
+
* Stops at home directory.
|
|
62
|
+
* Returns the path to .pi/extensions/{name}.json, or null if no .pi found.
|
|
63
|
+
*/
|
|
64
|
+
function findLocalConfigPath(extensionName: string): string | null {
|
|
65
|
+
let dir = process.cwd();
|
|
66
|
+
const home = homedir();
|
|
67
|
+
|
|
68
|
+
while (true) {
|
|
69
|
+
const piDir = resolve(dir, ".pi");
|
|
70
|
+
if (existsSync(piDir) && statSync(piDir).isDirectory()) {
|
|
71
|
+
return resolve(piDir, `extensions/${extensionName}.json`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Stop at home directory
|
|
75
|
+
if (dir === home) break;
|
|
76
|
+
|
|
77
|
+
const parent = resolve(dir, "..");
|
|
78
|
+
// Stop if we can't go higher
|
|
79
|
+
if (parent === dir) break;
|
|
80
|
+
dir = parent;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
42
84
|
}
|
|
43
85
|
|
|
44
86
|
export class ConfigLoader<TConfig extends object, TResolved extends object>
|
|
45
87
|
implements ConfigStore<TConfig, TResolved>
|
|
46
88
|
{
|
|
47
89
|
private globalConfig: TConfig | null = null;
|
|
48
|
-
private
|
|
90
|
+
private localConfig: TConfig | null = null;
|
|
91
|
+
private memoryConfig: TConfig | null = null;
|
|
49
92
|
private resolved: TResolved | null = null;
|
|
50
93
|
|
|
51
|
-
private readonly
|
|
52
|
-
private readonly
|
|
94
|
+
private readonly scopes: Scope[];
|
|
95
|
+
private readonly globalPath: string | null;
|
|
96
|
+
private readonly localPath: string | null;
|
|
53
97
|
private readonly defaults: TResolved;
|
|
54
98
|
private readonly migrations: Migration<TConfig>[];
|
|
55
99
|
private readonly afterMerge?: (
|
|
56
100
|
resolved: TResolved,
|
|
57
101
|
global: TConfig | null,
|
|
58
|
-
|
|
102
|
+
local: TConfig | null,
|
|
103
|
+
memory: TConfig | null,
|
|
59
104
|
) => TResolved;
|
|
60
105
|
|
|
61
106
|
constructor(
|
|
62
107
|
extensionName: string,
|
|
63
108
|
defaults: TResolved,
|
|
64
109
|
options?: {
|
|
110
|
+
/**
|
|
111
|
+
* Enabled scopes. Default: ["global", "local"]
|
|
112
|
+
* Merge priority (lowest to highest): defaults -> global -> local -> memory
|
|
113
|
+
*/
|
|
114
|
+
scopes?: Scope[];
|
|
65
115
|
migrations?: Migration<TConfig>[];
|
|
66
116
|
/**
|
|
67
|
-
* Post-merge hook. Called after deep merge with
|
|
117
|
+
* Post-merge hook. Called after deep merge with all raw configs.
|
|
68
118
|
* Use for logic that can't be expressed as a simple merge
|
|
69
119
|
* (e.g., one field replacing another).
|
|
70
120
|
*/
|
|
71
121
|
afterMerge?: (
|
|
72
122
|
resolved: TResolved,
|
|
73
123
|
global: TConfig | null,
|
|
74
|
-
|
|
124
|
+
local: TConfig | null,
|
|
125
|
+
memory: TConfig | null,
|
|
75
126
|
) => TResolved;
|
|
76
127
|
},
|
|
77
128
|
) {
|
|
78
|
-
this.
|
|
79
|
-
getAgentDir(),
|
|
80
|
-
`extensions/${extensionName}.json`,
|
|
81
|
-
);
|
|
82
|
-
this.projectPath = resolve(
|
|
83
|
-
process.cwd(),
|
|
84
|
-
`.pi/extensions/${extensionName}.json`,
|
|
85
|
-
);
|
|
129
|
+
this.scopes = options?.scopes ?? ["global", "local"];
|
|
86
130
|
this.defaults = defaults;
|
|
87
131
|
this.migrations = options?.migrations ?? [];
|
|
88
132
|
this.afterMerge = options?.afterMerge;
|
|
133
|
+
|
|
134
|
+
// Set up paths based on enabled scopes
|
|
135
|
+
this.globalPath = this.scopes.includes("global")
|
|
136
|
+
? resolve(getAgentDir(), `extensions/${extensionName}.json`)
|
|
137
|
+
: null;
|
|
138
|
+
|
|
139
|
+
this.localPath = this.scopes.includes("local")
|
|
140
|
+
? findLocalConfigPath(extensionName)
|
|
141
|
+
: null;
|
|
89
142
|
}
|
|
90
143
|
|
|
91
144
|
/**
|
|
92
145
|
* Load (or reload) config from disk. Applies migrations if needed.
|
|
93
146
|
* Must be called before getConfig() or getRawConfig().
|
|
147
|
+
*
|
|
148
|
+
* Note: Memory config is reset to null on reload (ephemeral).
|
|
94
149
|
*/
|
|
95
150
|
async load(): Promise<void> {
|
|
96
|
-
|
|
97
|
-
this.
|
|
151
|
+
// Load from disk
|
|
152
|
+
this.globalConfig = this.globalPath
|
|
153
|
+
? await this.readFile(this.globalPath)
|
|
154
|
+
: null;
|
|
155
|
+
this.localConfig = this.localPath
|
|
156
|
+
? await this.readFile(this.localPath)
|
|
157
|
+
: null;
|
|
98
158
|
|
|
99
|
-
|
|
159
|
+
// Reset memory on reload (ephemeral)
|
|
160
|
+
this.memoryConfig = null;
|
|
161
|
+
|
|
162
|
+
// Apply migrations to disk configs
|
|
163
|
+
if (this.globalConfig && this.globalPath) {
|
|
100
164
|
this.globalConfig = await this.applyMigrations(
|
|
101
165
|
this.globalConfig,
|
|
102
166
|
this.globalPath,
|
|
103
167
|
);
|
|
104
168
|
}
|
|
105
|
-
if (this.
|
|
106
|
-
this.
|
|
107
|
-
this.
|
|
108
|
-
this.
|
|
169
|
+
if (this.localConfig && this.localPath) {
|
|
170
|
+
this.localConfig = await this.applyMigrations(
|
|
171
|
+
this.localConfig,
|
|
172
|
+
this.localPath,
|
|
109
173
|
);
|
|
110
174
|
}
|
|
111
175
|
|
|
@@ -119,21 +183,60 @@ export class ConfigLoader<TConfig extends object, TResolved extends object>
|
|
|
119
183
|
return this.resolved;
|
|
120
184
|
}
|
|
121
185
|
|
|
122
|
-
getRawConfig(scope:
|
|
123
|
-
|
|
186
|
+
getRawConfig(scope: Scope): TConfig | null {
|
|
187
|
+
switch (scope) {
|
|
188
|
+
case "global":
|
|
189
|
+
return this.globalConfig;
|
|
190
|
+
case "local":
|
|
191
|
+
return this.localConfig;
|
|
192
|
+
case "memory":
|
|
193
|
+
return this.memoryConfig;
|
|
194
|
+
}
|
|
124
195
|
}
|
|
125
196
|
|
|
126
|
-
|
|
127
|
-
return scope
|
|
128
|
-
? this.globalConfig !== null
|
|
129
|
-
: this.projectConfig !== null;
|
|
197
|
+
hasScope(scope: Scope): boolean {
|
|
198
|
+
return this.scopes.includes(scope);
|
|
130
199
|
}
|
|
131
200
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
201
|
+
hasConfig(scope: Scope): boolean {
|
|
202
|
+
if (!this.hasScope(scope)) return false;
|
|
203
|
+
return this.getRawConfig(scope) !== null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
getEnabledScopes(): Scope[] {
|
|
207
|
+
return [...this.scopes];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Save config and reload state (except memory which just updates in place). */
|
|
211
|
+
async save(scope: Scope, config: TConfig): Promise<void> {
|
|
212
|
+
if (!this.hasScope(scope)) {
|
|
213
|
+
throw new Error(`Scope "${scope}" is not enabled`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (scope === "memory") {
|
|
217
|
+
// Memory is ephemeral, just store in place and re-merge
|
|
218
|
+
this.memoryConfig = config;
|
|
219
|
+
this.resolved = this.merge();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const path = scope === "global" ? this.globalPath : this.localPath;
|
|
224
|
+
if (!path) {
|
|
225
|
+
throw new Error(`No path configured for scope "${scope}"`);
|
|
226
|
+
}
|
|
227
|
+
|
|
135
228
|
await this.writeFile(path, config);
|
|
136
|
-
|
|
229
|
+
|
|
230
|
+
// Reload disk configs but preserve memory
|
|
231
|
+
const savedMemory = this.memoryConfig;
|
|
232
|
+
this.globalConfig = this.globalPath
|
|
233
|
+
? await this.readFile(this.globalPath)
|
|
234
|
+
: null;
|
|
235
|
+
this.localConfig = this.localPath
|
|
236
|
+
? await this.readFile(this.localPath)
|
|
237
|
+
: null;
|
|
238
|
+
this.memoryConfig = savedMemory;
|
|
239
|
+
this.resolved = this.merge();
|
|
137
240
|
}
|
|
138
241
|
|
|
139
242
|
// --- Internal ---
|
|
@@ -161,7 +264,7 @@ export class ConfigLoader<TConfig extends object, TResolved extends object>
|
|
|
161
264
|
try {
|
|
162
265
|
await this.writeFile(filePath, current);
|
|
163
266
|
} catch {
|
|
164
|
-
// Save failed
|
|
267
|
+
// Save failed - use migrated version in memory only.
|
|
165
268
|
}
|
|
166
269
|
}
|
|
167
270
|
|
|
@@ -170,10 +273,19 @@ export class ConfigLoader<TConfig extends object, TResolved extends object>
|
|
|
170
273
|
|
|
171
274
|
private merge(): TResolved {
|
|
172
275
|
const merged = structuredClone(this.defaults);
|
|
276
|
+
|
|
277
|
+
// Merge in priority order: global -> local -> memory
|
|
173
278
|
if (this.globalConfig) this.deepMerge(merged, this.globalConfig);
|
|
174
|
-
if (this.
|
|
279
|
+
if (this.localConfig) this.deepMerge(merged, this.localConfig);
|
|
280
|
+
if (this.memoryConfig) this.deepMerge(merged, this.memoryConfig);
|
|
281
|
+
|
|
175
282
|
if (this.afterMerge) {
|
|
176
|
-
return this.afterMerge(
|
|
283
|
+
return this.afterMerge(
|
|
284
|
+
merged,
|
|
285
|
+
this.globalConfig,
|
|
286
|
+
this.localConfig,
|
|
287
|
+
this.memoryConfig,
|
|
288
|
+
);
|
|
177
289
|
}
|
|
178
290
|
return merged;
|
|
179
291
|
}
|
package/index.ts
CHANGED
package/package.json
CHANGED
package/settings-command.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Settings command registration helper.
|
|
3
3
|
*
|
|
4
|
-
* Creates a /{name}:settings command with
|
|
4
|
+
* Creates a /{name}:settings command with tabs for each enabled scope.
|
|
5
5
|
* Changes are tracked in memory. Ctrl+S saves, Esc exits without saving.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -12,10 +12,19 @@ import {
|
|
|
12
12
|
SectionedSettings,
|
|
13
13
|
type SettingsSection,
|
|
14
14
|
} from "./components/sectioned-settings";
|
|
15
|
-
import type { ConfigStore } from "./config-loader";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
import type { ConfigStore, Scope } from "./config-loader";
|
|
16
|
+
import {
|
|
17
|
+
displayToStorageValue,
|
|
18
|
+
getNestedValue,
|
|
19
|
+
setNestedValue,
|
|
20
|
+
} from "./helpers";
|
|
21
|
+
|
|
22
|
+
/** Display labels for each scope */
|
|
23
|
+
const SCOPE_LABELS: Record<Scope, string> = {
|
|
24
|
+
global: "Global",
|
|
25
|
+
local: "Local",
|
|
26
|
+
memory: "Memory",
|
|
27
|
+
};
|
|
19
28
|
|
|
20
29
|
export interface SettingsCommandOptions<
|
|
21
30
|
TConfig extends object,
|
|
@@ -36,11 +45,18 @@ export interface SettingsCommandOptions<
|
|
|
36
45
|
* Use ctx.setDraft in submenu onSave callbacks to store changes
|
|
37
46
|
* in the draft. All changes (toggles, enums, submenus) are only
|
|
38
47
|
* persisted to disk on Ctrl+S.
|
|
48
|
+
*
|
|
49
|
+
* For memory scope, tabConfig is null when no overrides exist yet.
|
|
50
|
+
* Use resolved values as display values in that case.
|
|
39
51
|
*/
|
|
40
52
|
buildSections: (
|
|
41
53
|
tabConfig: TConfig | null,
|
|
42
54
|
resolved: TResolved,
|
|
43
|
-
ctx: {
|
|
55
|
+
ctx: {
|
|
56
|
+
setDraft: (config: TConfig) => void;
|
|
57
|
+
scope: Scope;
|
|
58
|
+
isInherited: (path: string) => boolean;
|
|
59
|
+
},
|
|
44
60
|
) => SettingsSection[];
|
|
45
61
|
/**
|
|
46
62
|
* Custom change handler. Receives the setting ID, new display value,
|
|
@@ -100,7 +116,7 @@ export function registerSettingsCommand<
|
|
|
100
116
|
} = options;
|
|
101
117
|
const description =
|
|
102
118
|
options.commandDescription ??
|
|
103
|
-
`Configure ${commandName.split(":")[0]}
|
|
119
|
+
`Configure ${commandName.split(":")[0]} settings`;
|
|
104
120
|
const extensionLabel = commandName.split(":")[0] ?? title;
|
|
105
121
|
|
|
106
122
|
pi.registerCommand(commandName, {
|
|
@@ -108,34 +124,50 @@ export function registerSettingsCommand<
|
|
|
108
124
|
handler: async (_args, ctx) => {
|
|
109
125
|
if (!ctx.hasUI) return;
|
|
110
126
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
const enabledScopes = configStore.getEnabledScopes();
|
|
128
|
+
if (enabledScopes.length === 0) {
|
|
129
|
+
ctx.ui.notify("No scopes configured", "error");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Default to first scope with existing config, else first enabled scope
|
|
134
|
+
// Safe: we check enabledScopes.length > 0 above
|
|
135
|
+
let activeScope: Scope =
|
|
136
|
+
enabledScopes.find((s) => configStore.hasConfig(s)) ??
|
|
137
|
+
(enabledScopes[0] as Scope);
|
|
114
138
|
|
|
115
139
|
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
116
140
|
let settings: SectionedSettings | null = null;
|
|
117
141
|
let currentSections: SettingsSection[] = [];
|
|
118
142
|
const settingsTheme = getSettingsListTheme();
|
|
119
143
|
|
|
120
|
-
// Per-
|
|
121
|
-
const drafts: Record<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
144
|
+
// Per-scope draft configs. null = no changes from disk/memory.
|
|
145
|
+
const drafts: Partial<Record<Scope, TConfig | null>> = {};
|
|
146
|
+
for (const scope of enabledScopes) {
|
|
147
|
+
drafts[scope] = null;
|
|
148
|
+
}
|
|
125
149
|
|
|
126
150
|
// --- Helpers ---
|
|
127
151
|
|
|
128
|
-
|
|
129
|
-
|
|
152
|
+
/** Get the effective config for the active scope (draft or stored). */
|
|
153
|
+
function getTabConfig(): TConfig | null {
|
|
154
|
+
return drafts[activeScope] ?? configStore.getRawConfig(activeScope);
|
|
130
155
|
}
|
|
131
156
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
/**
|
|
158
|
+
* For memory scope: check if a path has a value in memory config.
|
|
159
|
+
* If not, it's inherited from lower-priority scopes.
|
|
160
|
+
*/
|
|
161
|
+
function isInherited(path: string): boolean {
|
|
162
|
+
if (activeScope !== "memory") return false;
|
|
163
|
+
const memoryConfig =
|
|
164
|
+
drafts.memory ?? configStore.getRawConfig("memory");
|
|
165
|
+
if (!memoryConfig) return true; // No memory config = all inherited
|
|
166
|
+
return getNestedValue(memoryConfig, path) === undefined;
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
function isDirty(): boolean {
|
|
138
|
-
return
|
|
170
|
+
return enabledScopes.some((scope) => drafts[scope] !== null);
|
|
139
171
|
}
|
|
140
172
|
|
|
141
173
|
function getSections(): SettingsSection[] {
|
|
@@ -143,8 +175,10 @@ export function registerSettingsCommand<
|
|
|
143
175
|
const resolved = configStore.getConfig();
|
|
144
176
|
currentSections = buildSections(tabConfig, resolved, {
|
|
145
177
|
setDraft: (config) => {
|
|
146
|
-
drafts[
|
|
178
|
+
drafts[activeScope] = config;
|
|
147
179
|
},
|
|
180
|
+
scope: activeScope,
|
|
181
|
+
isInherited,
|
|
148
182
|
});
|
|
149
183
|
return currentSections;
|
|
150
184
|
}
|
|
@@ -154,13 +188,13 @@ export function registerSettingsCommand<
|
|
|
154
188
|
tui.requestRender();
|
|
155
189
|
}
|
|
156
190
|
|
|
157
|
-
function buildSettingsComponent(
|
|
191
|
+
function buildSettingsComponent(scope: Scope): SectionedSettings {
|
|
158
192
|
return new SectionedSettings(
|
|
159
193
|
getSections(),
|
|
160
194
|
15,
|
|
161
195
|
settingsTheme,
|
|
162
196
|
(id, newValue) => {
|
|
163
|
-
handleChange(
|
|
197
|
+
handleChange(scope, id, newValue);
|
|
164
198
|
},
|
|
165
199
|
() => done(undefined),
|
|
166
200
|
{ enableSearch: true, hintSuffix: "Ctrl+S to save" },
|
|
@@ -169,14 +203,23 @@ export function registerSettingsCommand<
|
|
|
169
203
|
|
|
170
204
|
// --- Change handler (in-memory only) ---
|
|
171
205
|
|
|
172
|
-
function handleChange(
|
|
206
|
+
function handleChange(
|
|
207
|
+
scope: Scope,
|
|
208
|
+
id: string,
|
|
209
|
+
newValue: string,
|
|
210
|
+
): void {
|
|
173
211
|
// Submenu items handle their own saving.
|
|
174
212
|
if (isSubmenuItem(currentSections, id)) {
|
|
175
213
|
refresh();
|
|
176
214
|
return;
|
|
177
215
|
}
|
|
178
216
|
|
|
179
|
-
|
|
217
|
+
// For memory scope with no existing config, start from merged config
|
|
218
|
+
let current = getTabConfig();
|
|
219
|
+
if (scope === "memory" && current === null) {
|
|
220
|
+
current = configStore.getConfig() as unknown as TConfig;
|
|
221
|
+
}
|
|
222
|
+
|
|
180
223
|
const handler = onSettingChange ?? defaultChangeHandler;
|
|
181
224
|
const updated = handler(
|
|
182
225
|
id,
|
|
@@ -186,7 +229,7 @@ export function registerSettingsCommand<
|
|
|
186
229
|
if (!updated) return;
|
|
187
230
|
|
|
188
231
|
// Store in draft, don't write to disk yet.
|
|
189
|
-
drafts[
|
|
232
|
+
drafts[scope] = updated;
|
|
190
233
|
tui.requestRender();
|
|
191
234
|
}
|
|
192
235
|
|
|
@@ -195,25 +238,27 @@ export function registerSettingsCommand<
|
|
|
195
238
|
async function save(): Promise<void> {
|
|
196
239
|
let saved = false;
|
|
197
240
|
|
|
198
|
-
for (const
|
|
199
|
-
const draft = drafts[
|
|
241
|
+
for (const scope of enabledScopes) {
|
|
242
|
+
const draft = drafts[scope];
|
|
200
243
|
if (!draft) continue;
|
|
201
244
|
|
|
202
|
-
const scope = tab === "local" ? "project" : "global";
|
|
203
245
|
try {
|
|
204
246
|
await configStore.save(scope, draft);
|
|
205
|
-
drafts[
|
|
247
|
+
drafts[scope] = null;
|
|
206
248
|
saved = true;
|
|
207
249
|
} catch (error) {
|
|
208
|
-
ctx.ui.notify(
|
|
250
|
+
ctx.ui.notify(
|
|
251
|
+
`Failed to save ${SCOPE_LABELS[scope]}: ${error}`,
|
|
252
|
+
"error",
|
|
253
|
+
);
|
|
209
254
|
}
|
|
210
255
|
}
|
|
211
256
|
|
|
212
257
|
if (saved) {
|
|
213
258
|
ctx.ui.notify(`${extensionLabel}: saved`, "info");
|
|
214
259
|
if (onSave) await onSave();
|
|
215
|
-
// Rebuild with fresh
|
|
216
|
-
settings = buildSettingsComponent(
|
|
260
|
+
// Rebuild with fresh data.
|
|
261
|
+
settings = buildSettingsComponent(activeScope);
|
|
217
262
|
}
|
|
218
263
|
|
|
219
264
|
tui.requestRender();
|
|
@@ -222,30 +267,37 @@ export function registerSettingsCommand<
|
|
|
222
267
|
// --- Tab rendering ---
|
|
223
268
|
|
|
224
269
|
function renderTabs(): string[] {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return ["", ` ${localLabel} ${globalLabel}`, ""];
|
|
270
|
+
// Single scope = no tabs needed
|
|
271
|
+
if (enabledScopes.length === 1) {
|
|
272
|
+
return [""];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const tabLabels = enabledScopes.map((scope) => {
|
|
276
|
+
const label = SCOPE_LABELS[scope];
|
|
277
|
+
const dirtyMark = drafts[scope] ? " *" : "";
|
|
278
|
+
const fullLabel = ` ${label}${dirtyMark} `;
|
|
279
|
+
|
|
280
|
+
if (scope === activeScope) {
|
|
281
|
+
return theme.bg("selectedBg", theme.fg("accent", fullLabel));
|
|
282
|
+
}
|
|
283
|
+
return theme.fg("dim", fullLabel);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return ["", ` ${tabLabels.join(" ")}`, ""];
|
|
243
287
|
}
|
|
244
288
|
|
|
245
289
|
function handleTabSwitch(data: string): boolean {
|
|
290
|
+
// Single scope = no tab switching
|
|
291
|
+
if (enabledScopes.length <= 1) return false;
|
|
292
|
+
|
|
246
293
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) {
|
|
247
|
-
|
|
248
|
-
|
|
294
|
+
const currentIndex = enabledScopes.indexOf(activeScope);
|
|
295
|
+
const direction = matchesKey(data, Key.shift("tab")) ? -1 : 1;
|
|
296
|
+
const nextIndex =
|
|
297
|
+
(currentIndex + direction + enabledScopes.length) %
|
|
298
|
+
enabledScopes.length;
|
|
299
|
+
activeScope = enabledScopes[nextIndex] as Scope;
|
|
300
|
+
settings = buildSettingsComponent(activeScope);
|
|
249
301
|
tui.requestRender();
|
|
250
302
|
return true;
|
|
251
303
|
}
|
|
@@ -254,7 +306,7 @@ export function registerSettingsCommand<
|
|
|
254
306
|
|
|
255
307
|
// --- Init ---
|
|
256
308
|
|
|
257
|
-
settings = buildSettingsComponent(
|
|
309
|
+
settings = buildSettingsComponent(activeScope);
|
|
258
310
|
|
|
259
311
|
return {
|
|
260
312
|
render(width: number) {
|