pi-archimedes 2.1.0 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-archimedes",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,15 +11,15 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "2.1.0",
15
- "@pi-archimedes/diff": "2.1.0",
16
- "@pi-archimedes/ask": "2.1.0",
17
- "@pi-archimedes/subagent": "2.1.0",
18
- "@pi-archimedes/footer": "2.1.0",
19
- "@pi-archimedes/todo": "2.1.0",
20
- "@pi-archimedes/notify": "2.1.0",
21
- "@pi-archimedes/image-paste": "2.1.0",
22
- "@pi-archimedes/session-name": "2.1.0"
14
+ "@pi-archimedes/core": "2.2.0",
15
+ "@pi-archimedes/ask": "2.2.0",
16
+ "@pi-archimedes/image-paste": "2.2.0",
17
+ "@pi-archimedes/todo": "2.2.0",
18
+ "@pi-archimedes/subagent": "2.2.0",
19
+ "@pi-archimedes/session-name": "2.2.0",
20
+ "@pi-archimedes/notify": "2.2.0",
21
+ "@pi-archimedes/diff": "2.2.0",
22
+ "@pi-archimedes/footer": "2.2.0"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Settings Manager TUI component.
3
+ * Center-screen overlay with 2 modes: List and Prompt (free-input field edit).
4
+ * Mirrors the chrome of the /agents manager (see agent-manager.ts).
5
+ */
6
+
7
+ import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import type { SettingItem } from "@earendil-works/pi-tui";
9
+ import { matchesKey, Key, CURSOR_MARKER, truncateToWidth } from "@earendil-works/pi-tui";
10
+ import {
11
+ visibleWidth,
12
+ padEnd,
13
+ wrapText,
14
+ renderHeader,
15
+ renderFooter,
16
+ wrapWithBorder,
17
+ borderContentWidth,
18
+ } from "@pi-archimedes/core/overlay";
19
+
20
+ // ── Public types ────────────────────────────────────────────────────────────
21
+
22
+ export interface PromptDescriptor {
23
+ kind: "text" | "number";
24
+ label: string;
25
+ min?: number;
26
+ }
27
+
28
+ export interface SettingsManagerOptions {
29
+ items: SettingItem[];
30
+ /** Free-input fields keyed by item.id — Enter on these opens prompt mode. */
31
+ prompts: Record<string, PromptDescriptor>;
32
+ theme: Theme;
33
+ onChange: (id: string, newValue: string) => void;
34
+ onSave: () => void;
35
+ onClose: () => void;
36
+ }
37
+
38
+ // ── Constants ───────────────────────────────────────────────────────────────
39
+
40
+ const VISIBLE_ROWS = 20;
41
+
42
+ // ── State ───────────────────────────────────────────────────────────────────
43
+
44
+ interface SettingsManagerState {
45
+ mode: "list" | "prompt";
46
+ /** Index into filteredItems. */
47
+ selectedIndex: number;
48
+ /** "" = no search. */
49
+ searchQuery: string;
50
+ /** Search input armed (toggled by /, cleared by esc). */
51
+ filterActive: boolean;
52
+ /** Recomputed when searchQuery changes. */
53
+ filteredItems: SettingItem[];
54
+ /** item.id while in prompt mode. */
55
+ promptField: string | null;
56
+ /** Seeded from item.currentValue on prompt entry. */
57
+ promptValue: string;
58
+ promptError: string | null;
59
+ }
60
+
61
+ // ── Helpers ─────────────────────────────────────────────────────────────────
62
+
63
+ function filterItemsByLabel(items: SettingItem[], query: string): SettingItem[] {
64
+ if (!query) return items;
65
+ const q = query.toLowerCase();
66
+ return items.filter((item) => item.label.toLowerCase().includes(q));
67
+ }
68
+
69
+ /** ASCII printable — list-mode SEARCH input (matches the /agents agent-manager pattern). */
70
+ function isPrintableChar(data: string): boolean {
71
+ return data.length === 1 && data >= " " && data <= "~";
72
+ }
73
+
74
+ /** Single non-control code unit — free-text PROMPT fields accept accented/Cyrillic/CJK chars. */
75
+ function isPromptTextChar(data: string): boolean {
76
+ if (data.length !== 1) return false;
77
+ const cp = data.codePointAt(0);
78
+ if (cp === undefined) return false;
79
+ // Accept single code points >= 0x20, excluding DEL (0x7f) and the C1 range (0x80..0x9f).
80
+ if (cp < 0x20 || cp === 0x7f) return false;
81
+ if (cp >= 0x80 && cp <= 0x9f) return false;
82
+ return true;
83
+ }
84
+
85
+ // ── Component ───────────────────────────────────────────────────────────────
86
+
87
+ export function createSettingsManager(opts: SettingsManagerOptions): {
88
+ render(width: number): string[];
89
+ handleInput(data: string): void;
90
+ invalidate(): void;
91
+ dispose(): void;
92
+ } {
93
+ const state: SettingsManagerState = {
94
+ mode: "list",
95
+ selectedIndex: 0,
96
+ searchQuery: "",
97
+ filterActive: false,
98
+ filteredItems: opts.items,
99
+ promptField: null,
100
+ promptValue: "",
101
+ promptError: null,
102
+ };
103
+
104
+ const theme = opts.theme;
105
+
106
+ // ── Render (list mode) ──────────────────────────────────────────────────
107
+
108
+ function renderList(contentWidth: number): string[] {
109
+ const lines: string[] = [];
110
+
111
+ lines.push(renderHeader(" Settings ", contentWidth, theme));
112
+ lines.push(padEnd("", contentWidth));
113
+
114
+ if (state.filterActive) {
115
+ lines.push(padEnd(`Search: ${state.searchQuery}${CURSOR_MARKER}`, contentWidth));
116
+ }
117
+
118
+ lines.push(padEnd("", contentWidth));
119
+
120
+ // Label column width: widest label across all items, capped at 30.
121
+ const maxLabelWidth = Math.min(
122
+ 30,
123
+ opts.items.reduce((max, item) => Math.max(max, visibleWidth(item.label)), 0),
124
+ );
125
+
126
+ const start = Math.max(
127
+ 0,
128
+ Math.min(state.selectedIndex - 10, state.filteredItems.length - VISIBLE_ROWS),
129
+ );
130
+ const end = Math.min(start + VISIBLE_ROWS, state.filteredItems.length);
131
+
132
+ for (let i = start; i < end; i++) {
133
+ const item = state.filteredItems[i];
134
+ if (!item) continue;
135
+ const isCursor = i === state.selectedIndex;
136
+ const prefix = isCursor ? "> " : " ";
137
+
138
+ // Truncate before padding so an over-long label can never overflow the row.
139
+ const label = truncateToWidth(item.label, maxLabelWidth, "");
140
+ const labelCol = isCursor
141
+ ? theme.fg("accent", padEnd(label, maxLabelWidth))
142
+ : padEnd(label, maxLabelWidth);
143
+
144
+ // Compose the full value string (with edit marker) BEFORE truncation so
145
+ // the marker is never clipped.
146
+ const descriptor = opts.prompts[item.id];
147
+ const valueString = descriptor
148
+ ? `${item.currentValue} ${theme.fg("dim", "edit…")}`
149
+ : item.currentValue;
150
+ const remainingWidth = Math.max(1, contentWidth - 2 - maxLabelWidth - 2);
151
+ const valueCol = isCursor
152
+ ? truncateToWidth(valueString, remainingWidth, "")
153
+ : theme.fg("dim", truncateToWidth(valueString, remainingWidth, ""));
154
+
155
+ lines.push(padEnd(`${prefix}${labelCol} ${valueCol}`, contentWidth));
156
+ }
157
+
158
+ // Feedback when search filters to zero items.
159
+ if (state.filteredItems.length === 0) {
160
+ lines.push(padEnd(theme.fg("dim", "No matching settings"), contentWidth));
161
+ }
162
+
163
+ // Scroll indicator
164
+ if (state.filteredItems.length > VISIBLE_ROWS) {
165
+ lines.push(theme.fg("dim", ` (${state.selectedIndex + 1}/${state.filteredItems.length})`));
166
+ }
167
+
168
+ // Description of the selected item
169
+ const selected = state.filteredItems[state.selectedIndex];
170
+ if (selected && selected.description) {
171
+ lines.push(padEnd("", contentWidth));
172
+ for (const line of wrapText(selected.description, contentWidth - 2)) {
173
+ lines.push(theme.fg("dim", ` ${line}`));
174
+ }
175
+ }
176
+
177
+ lines.push(padEnd("", contentWidth));
178
+ lines.push(
179
+ renderFooter(
180
+ " [↑↓] move [←→] value [enter] edit [/] search [s] save [esc] close ",
181
+ contentWidth,
182
+ theme,
183
+ ),
184
+ );
185
+
186
+ return lines;
187
+ }
188
+
189
+ // ── Render (prompt mode) ────────────────────────────────────────────────
190
+
191
+ function renderPrompt(width: number): string[] {
192
+ const contentWidth = borderContentWidth(width);
193
+ const descriptor = state.promptField ? opts.prompts[state.promptField] : undefined;
194
+ // Guard the impossible state — render must be total and the overlay frame
195
+ // must never collapse, even without a descriptor.
196
+ if (!descriptor) return wrapWithBorder([], width, theme);
197
+
198
+ const lines: string[] = [];
199
+
200
+ lines.push(renderHeader(" Settings ", contentWidth, theme));
201
+ lines.push(padEnd("", contentWidth));
202
+ lines.push(padEnd("", contentWidth));
203
+ lines.push(theme.fg("dim", ` ${descriptor.label}`));
204
+ lines.push(padEnd(` ${state.promptValue}${CURSOR_MARKER}`, contentWidth));
205
+ lines.push(padEnd("", contentWidth));
206
+ if (state.promptError) {
207
+ lines.push(theme.fg("error", ` ${state.promptError}`));
208
+ }
209
+ lines.push(padEnd("", contentWidth));
210
+ lines.push(renderFooter(" [enter] confirm [esc] cancel ", contentWidth, theme));
211
+
212
+ return wrapWithBorder(lines, width, theme);
213
+ }
214
+
215
+ function render(width: number): string[] {
216
+ if (state.mode === "prompt") return renderPrompt(width);
217
+ return wrapWithBorder(renderList(borderContentWidth(width)), width, theme);
218
+ }
219
+
220
+ // ── Input (list mode) ───────────────────────────────────────────────────
221
+
222
+ function handleListInput(data: string): void {
223
+ if (matchesKey(data, Key.up)) {
224
+ if (state.filteredItems.length === 0) return;
225
+ state.selectedIndex =
226
+ (state.selectedIndex - 1 + state.filteredItems.length) % state.filteredItems.length;
227
+ } else if (matchesKey(data, Key.down)) {
228
+ if (state.filteredItems.length === 0) return;
229
+ state.selectedIndex = (state.selectedIndex + 1) % state.filteredItems.length;
230
+ } else if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
231
+ const item = state.filteredItems[state.selectedIndex];
232
+ if (!item || !item.values || item.values.length <= 1) return;
233
+ const values = item.values;
234
+ const currentIdx = Math.max(0, values.indexOf(item.currentValue));
235
+ const nextIndex = matchesKey(data, Key.left)
236
+ ? (currentIdx - 1 + values.length) % values.length
237
+ : (currentIdx + 1) % values.length;
238
+ const next = values[nextIndex];
239
+ if (next === undefined) return;
240
+ item.currentValue = next;
241
+ opts.onChange(item.id, next);
242
+ } else if (matchesKey(data, Key.enter)) {
243
+ const item = state.filteredItems[state.selectedIndex];
244
+ if (!item) return;
245
+ if (opts.prompts[item.id]) {
246
+ state.mode = "prompt";
247
+ state.promptField = item.id;
248
+ state.promptValue = item.currentValue;
249
+ state.promptError = null;
250
+ }
251
+ } else if (matchesKey(data, "/")) {
252
+ // Re-pressing while active is a no-op.
253
+ state.filterActive = true;
254
+ } else if (matchesKey(data, Key.backspace)) {
255
+ if (state.filterActive && state.searchQuery.length > 0) {
256
+ state.searchQuery = state.searchQuery.slice(0, -1);
257
+ state.filteredItems = filterItemsByLabel(opts.items, state.searchQuery);
258
+ state.selectedIndex = 0;
259
+ }
260
+ } else if (matchesKey(data, Key.escape)) {
261
+ if (state.filterActive) {
262
+ state.searchQuery = "";
263
+ state.filterActive = false;
264
+ state.filteredItems = opts.items;
265
+ state.selectedIndex = 0;
266
+ } else {
267
+ opts.onClose();
268
+ }
269
+ } else if (isPrintableChar(data) && state.filterActive) {
270
+ // Must come before the "s" branch so typing "s" in search doesn't save.
271
+ state.searchQuery += data;
272
+ state.filteredItems = filterItemsByLabel(opts.items, state.searchQuery);
273
+ state.selectedIndex = 0;
274
+ } else if (matchesKey(data, "s")) {
275
+ if (!state.filterActive) {
276
+ opts.onSave();
277
+ }
278
+ }
279
+ }
280
+
281
+ // ── Input (prompt mode) ─────────────────────────────────────────────────
282
+
283
+ function handlePromptInput(data: string): void {
284
+ if (matchesKey(data, Key.escape)) {
285
+ // Discard typed value — item.currentValue untouched.
286
+ state.mode = "list";
287
+ state.promptField = null;
288
+ state.promptError = null;
289
+ } else if (matchesKey(data, Key.enter)) {
290
+ const field = state.promptField;
291
+ const descriptor = field ? opts.prompts[field] : undefined;
292
+ if (!field || !descriptor) {
293
+ state.mode = "list";
294
+ state.promptField = null;
295
+ state.promptError = null;
296
+ return;
297
+ }
298
+
299
+ // Resolve the item FIRST — never mutate config if the target is gone.
300
+ const item = opts.items.find((i) => i.id === field);
301
+ if (!item) {
302
+ state.mode = "list";
303
+ state.promptField = null;
304
+ state.promptError = null;
305
+ return;
306
+ }
307
+
308
+ let normalized: string;
309
+ if (descriptor.kind === "number") {
310
+ const n = parseInt(state.promptValue, 10);
311
+ if (!Number.isFinite(n) || n < (descriptor.min ?? 0)) {
312
+ state.promptError = "must be >= " + (descriptor.min ?? 0);
313
+ return;
314
+ }
315
+ normalized = String(n);
316
+ } else {
317
+ normalized = state.promptValue;
318
+ }
319
+
320
+ opts.onChange(field, normalized);
321
+ item.currentValue = normalized;
322
+ state.mode = "list";
323
+ state.promptField = null;
324
+ state.promptError = null;
325
+ // selectedIndex stays on the field.
326
+ } else if (matchesKey(data, Key.backspace)) {
327
+ if (state.promptValue.length > 0) {
328
+ state.promptValue = state.promptValue.slice(0, -1);
329
+ state.promptError = null;
330
+ }
331
+ } else if (isPromptTextChar(data)) {
332
+ const field = state.promptField;
333
+ const descriptor = field ? opts.prompts[field] : undefined;
334
+ if (!descriptor) return;
335
+ // Number fields accept digits only (matches the old submenu behavior).
336
+ if (descriptor.kind === "number" && !/^\d$/.test(data)) return;
337
+ state.promptValue += data;
338
+ state.promptError = null;
339
+ }
340
+ // Unmatched control/navigation input is swallowed in prompt mode;
341
+ // printable chars (including s, /) are appended as text.
342
+ }
343
+
344
+ function handleInput(data: string): void {
345
+ if (state.mode === "prompt") {
346
+ handlePromptInput(data);
347
+ } else {
348
+ handleListInput(data);
349
+ }
350
+ }
351
+
352
+ return {
353
+ render,
354
+ handleInput,
355
+
356
+ invalidate(): void {
357
+ // No-op — the component owns all of its state.
358
+ },
359
+
360
+ dispose(): void {
361
+ // No timers or subscriptions to clean up.
362
+ },
363
+ };
364
+ }
package/src/settings.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
- import type { Theme } from "@earendil-works/pi-coding-agent";
4
- import { SettingsList, type SettingItem, TUI } from "@earendil-works/pi-tui";
2
+ import type { SettingItem } from "@earendil-works/pi-tui";
5
3
 
6
4
  import { getCoreSettingsItems } from "@pi-archimedes/core";
5
+ import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
7
6
  import { getFooterSettingsItems } from "@pi-archimedes/footer/config";
8
7
  // diff (shiki) is lazy-loaded below to keep shiki out of the startup import chain
9
8
  import { getNotifySettingsItems } from "@pi-archimedes/notify";
@@ -15,87 +14,24 @@ import {
15
14
  saveDiffConfig,
16
15
  saveNotifyConfig,
17
16
  saveSessionNameConfig,
18
- ANIMATION_STYLES,
19
17
  type CoreConfig,
20
- type FooterConfig,
21
- type DiffConfig,
22
18
  type NotifyConfig,
23
- type SessionNameSettings,
24
19
  } from "./config.js";
20
+ import { createSettingsManager, type PromptDescriptor } from "./settings-manager.js";
25
21
 
26
- // ── Factory: text submenu ───────────────────────────────────────────────
22
+ // ── Free-input prompt descriptors (keyed by item.id) ───────────────────────
27
23
 
28
- function createTextSubmenu(opts: {
29
- label: string;
30
- cancelHint?: string;
31
- confirmHint?: string;
32
- }): (currentValue: string, done: (selectedValue?: string) => void) => import("@earendil-works/pi-tui").Component {
33
- return (currentValue: string, done: (selectedValue?: string) => void) => {
34
- const state = { value: currentValue };
35
- return {
36
- invalidate(): void { /* no-op */ },
37
- render(): string[] {
38
- const hints: string[] = [];
39
- if (opts.cancelHint) hints.push(opts.cancelHint);
40
- if (opts.confirmHint) hints.push(opts.confirmHint);
41
- return [
42
- opts.label,
43
- "",
44
- ` ${state.value}`,
45
- "",
46
- hints.join(" | "),
47
- ];
48
- },
49
- handleInput(data: string): void {
50
- if (data === "\x1b") { done(); return; }
51
- if (data === "\r" || data === "\n") { done(state.value); return; }
52
- if (data === "\x7f" || data === "\x08") { state.value = state.value.slice(0, -1); }
53
- else if (data.length === 1) { state.value += data; }
54
- },
55
- };
56
- };
57
- }
24
+ const PROMPTS: Record<string, PromptDescriptor> = {
25
+ labelText: { kind: "text", label: "Label text" },
26
+ labelColor: { kind: "text", label: "RGB color (e.g. 255,215,0)" },
27
+ diffTheme: { kind: "text", label: "Shiki theme" },
28
+ diffSplitMinWidth: { kind: "number", label: "Diff split min width", min: 100 },
29
+ diffSplitMinCodeWidth: { kind: "number", label: "Diff split min code width", min: 30 },
30
+ splitThreshold: { kind: "number", label: "Footer split threshold", min: 80 },
31
+ delayMs: { kind: "number", label: "Notify delay (seconds)", min: 1 },
32
+ };
58
33
 
59
- // ── Factory: number submenu ─────────────────────────────────────────────
60
-
61
- function createNumberSubmenu(opts: {
62
- label: string;
63
- cancelHint?: string;
64
- confirmHint?: string;
65
- min?: number;
66
- }): (currentValue: string, done: (selectedValue?: string) => void) => import("@earendil-works/pi-tui").Component {
67
- return (currentValue: string, done: (selectedValue?: string) => void) => {
68
- const state = { value: currentValue };
69
- return {
70
- invalidate(): void { /* no-op */ },
71
- render(): string[] {
72
- const hints: string[] = [];
73
- if (opts.cancelHint) hints.push(opts.cancelHint);
74
- if (opts.confirmHint) hints.push(opts.confirmHint);
75
- return [
76
- opts.label,
77
- "",
78
- ` ${state.value}`,
79
- "",
80
- hints.join(" | "),
81
- ];
82
- },
83
- handleInput(data: string): void {
84
- if (data === "\x1b") { done(); return; }
85
- if (data === "\r" || data === "\n") {
86
- const n = parseInt(state.value, 10);
87
- if (Number.isFinite(n) && (!opts.min || n >= opts.min)) done(String(n));
88
- else done();
89
- return;
90
- }
91
- if (data === "\x7f" || data === "\x08") { state.value = state.value.slice(0, -1); }
92
- else if (/^\d$/.test(data)) { state.value += data; }
93
- },
94
- };
95
- };
96
- }
97
-
98
- // ── Settings UI ─────────────────────────────────────────────────────────
34
+ // ── Settings UI ─────────────────────────────────────────────────────────────
99
35
 
100
36
  export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
101
37
  // Lazy-load diff (pulls in shiki) — only needed when /archimedes is opened
@@ -103,10 +39,10 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
103
39
  const allConfig = loadAllConfig();
104
40
 
105
41
  const coreConfig: CoreConfig = { ...allConfig.core };
106
- const footerConfig: FooterConfig = { ...allConfig.footer };
107
- const diffConfig: DiffConfig = { ...allConfig.diff };
108
42
  const notifyConfig: NotifyConfig = { ...allConfig.notify };
109
- const sessionNameConfig: SessionNameSettings = { ...allConfig.sessionName };
43
+ const footerConfig = { ...allConfig.footer };
44
+ const diffConfig = { ...allConfig.diff };
45
+ const sessionNameConfig = { ...allConfig.sessionName };
110
46
 
111
47
  // Build composed items from sub-packages
112
48
  const coreItems = getCoreSettingsItems(coreConfig);
@@ -115,64 +51,13 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
115
51
  const notifyItems = getNotifySettingsItems(notifyConfig);
116
52
  const sessionNameItems = getSessionNameSettingsItems(sessionNameConfig);
117
53
 
118
- // Add submenus for text/number fields
119
- const addSubmenus = (items: SettingItem[]) => {
120
- for (const item of items) {
121
- if (item.id === "labelText") {
122
- item.submenu = createTextSubmenu({
123
- label: "Enter label text (ESC to cancel):",
124
- cancelHint: "ESC: cancel",
125
- confirmHint: "ENTER: confirm",
126
- });
127
- } else if (item.id === "labelColor") {
128
- item.submenu = createTextSubmenu({
129
- label: "Enter RGB color (ESC to cancel):",
130
- cancelHint: "ESC: cancel",
131
- confirmHint: "ENTER: confirm",
132
- });
133
- } else if (item.id === "diffTheme") {
134
- item.submenu = createTextSubmenu({
135
- label: "Enter Shiki theme (ESC to cancel):",
136
- cancelHint: "ESC: cancel",
137
- confirmHint: "ENTER: confirm",
138
- });
139
- } else if (item.id === "diffSplitMinWidth") {
140
- item.submenu = createNumberSubmenu({
141
- label: "Enter min width (ESC to cancel):",
142
- cancelHint: "ESC: cancel",
143
- confirmHint: "min 100",
144
- min: 100,
145
- });
146
- } else if (item.id === "diffSplitMinCodeWidth") {
147
- item.submenu = createNumberSubmenu({
148
- label: "Enter min code width (ESC to cancel):",
149
- cancelHint: "ESC: cancel",
150
- confirmHint: "min 30",
151
- min: 30,
152
- });
153
- } else if (item.id === "splitThreshold") {
154
- item.submenu = createNumberSubmenu({
155
- label: "Enter split threshold (ESC to cancel):",
156
- cancelHint: "ESC: cancel",
157
- confirmHint: "min 80",
158
- min: 80,
159
- });
160
- } else if (item.id === "delayMs") {
161
- item.submenu = createNumberSubmenu({
162
- label: "Enter delay in seconds (ESC to cancel):",
163
- cancelHint: "ESC: cancel",
164
- confirmHint: "min 1",
165
- min: 1,
166
- });
167
- }
168
- }
169
- };
170
-
171
- addSubmenus(coreItems);
172
- addSubmenus(diffItems);
173
- addSubmenus(footerItems);
174
- addSubmenus(notifyItems);
175
- addSubmenus(sessionNameItems);
54
+ // The notify package seeds delayMs as "30s" — strip the suffix so the
55
+ // number prompt can be edited in place (typed digits would otherwise
56
+ // append to "30s" and parseInt would discard the edit).
57
+ const delayItem = notifyItems.find((i) => i.id === "delayMs");
58
+ if (delayItem) {
59
+ delayItem.currentValue = String(notifyConfig.delayMs / 1000);
60
+ }
176
61
 
177
62
  const items: SettingItem[] = [
178
63
  ...coreItems,
@@ -180,75 +65,67 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
180
65
  ...diffItems,
181
66
  ...notifyItems,
182
67
  ...sessionNameItems,
183
- {
184
- id: "save",
185
- label: "Save",
186
- description: "Save changes and exit",
187
- currentValue: "",
188
- values: ["Save"],
189
- },
190
68
  ];
191
69
 
192
- ctx.ui.custom((tui: TUI, theme: Theme, _keybindings, done) => {
193
- const settingsList = new SettingsList(items, 10, getSettingsListTheme(), (id: string, newValue: string) => {
194
- switch (id) {
195
- // ── Core settings ──
196
- case "mutedTheme": coreConfig.mutedTheme = newValue === "On"; break;
197
- case "codeUnindent": coreConfig.codeUnindent = newValue === "On"; break;
198
- case "labelText": coreConfig.labelText = newValue; break;
199
- case "labelColor": coreConfig.labelColor = newValue; break;
200
- case "animationStyle": coreConfig.animationStyle = newValue as CoreConfig["animationStyle"]; break;
201
-
202
- // ── Footer settings ──
203
- case "splitThreshold": {
204
- const v = parseInt(newValue, 10);
205
- if (Number.isFinite(v)) footerConfig.splitThreshold = v;
206
- break;
70
+ ctx.ui.custom((_tui, theme, _keybindings, done) => {
71
+ const settingsManager = createSettingsManager({
72
+ items,
73
+ prompts: PROMPTS,
74
+ theme,
75
+ onChange: (id: string, newValue: string) => {
76
+ switch (id) {
77
+ // ── Core settings ──
78
+ case "mutedTheme": coreConfig.mutedTheme = newValue === "On"; break;
79
+ case "codeUnindent": coreConfig.codeUnindent = newValue === "On"; break;
80
+ case "labelText": coreConfig.labelText = newValue; break;
81
+ case "labelColor": coreConfig.labelColor = newValue; break;
82
+ case "animationStyle": coreConfig.animationStyle = newValue as CoreConfig["animationStyle"]; break;
83
+
84
+ // ── Footer settings ──
85
+ case "splitThreshold": {
86
+ const v = parseInt(newValue, 10);
87
+ if (Number.isFinite(v)) footerConfig.splitThreshold = v;
88
+ break;
89
+ }
90
+
91
+ // ── Diff settings ──
92
+ case "diffTheme": diffConfig.diffTheme = newValue; break;
93
+ case "diffSplitMinWidth": {
94
+ const v = parseInt(newValue, 10);
95
+ if (Number.isFinite(v)) diffConfig.diffSplitMinWidth = v;
96
+ break;
97
+ }
98
+ case "diffSplitMinCodeWidth": {
99
+ const v = parseInt(newValue, 10);
100
+ if (Number.isFinite(v)) diffConfig.diffSplitMinCodeWidth = v;
101
+ break;
102
+ }
103
+
104
+ // ── Notify settings ──
105
+ case "enabled": notifyConfig.enabled = newValue === "On"; break;
106
+ case "notifyOnAgentEnd": notifyConfig.notifyOnAgentEnd = newValue === "On"; break;
107
+ case "notifyOnQuestion": notifyConfig.notifyOnQuestion = newValue === "On"; break;
108
+ case "delayMs": {
109
+ const v = parseInt(newValue, 10);
110
+ if (Number.isFinite(v) && v >= 1) notifyConfig.delayMs = v * 1000;
111
+ break;
112
+ }
113
+
114
+ // ── Session name settings ──
115
+ case "sessionNameEnabled": sessionNameConfig.enabled = newValue === "On"; break;
116
+ case "sessionNameModel": sessionNameConfig.model = newValue === "(current model)" ? undefined : newValue; break;
207
117
  }
208
-
209
- // ── Diff settings ──
210
- case "diffTheme": diffConfig.diffTheme = newValue; break;
211
- case "diffSplitMinWidth": {
212
- const v = parseInt(newValue, 10);
213
- if (Number.isFinite(v)) diffConfig.diffSplitMinWidth = v;
214
- break;
215
- }
216
- case "diffSplitMinCodeWidth": {
217
- const v = parseInt(newValue, 10);
218
- if (Number.isFinite(v)) diffConfig.diffSplitMinCodeWidth = v;
219
- break;
220
- }
221
-
222
- // ── Notify settings ──
223
- case "enabled": notifyConfig.enabled = newValue === "On"; break;
224
- case "notifyOnAgentEnd": notifyConfig.notifyOnAgentEnd = newValue === "On"; break;
225
- case "notifyOnQuestion": notifyConfig.notifyOnQuestion = newValue === "On"; break;
226
- case "delayMs": {
227
- const v = parseInt(newValue, 10);
228
- if (Number.isFinite(v) && v >= 1) notifyConfig.delayMs = v * 1000;
229
- break;
230
- }
231
-
232
- // ── Session name settings ──
233
- case "sessionNameEnabled": sessionNameConfig.enabled = newValue === "On"; break;
234
- case "sessionNameModel": sessionNameConfig.model = newValue === "(current model)" ? undefined : newValue; break;
235
-
236
- // ── Save ──
237
- case "save": {
238
- saveCoreConfig(coreConfig);
239
- saveFooterConfig(footerConfig);
240
- saveDiffConfig(diffConfig);
241
- saveNotifyConfig(notifyConfig);
242
- saveSessionNameConfig(sessionNameConfig);
243
- done(undefined);
244
- return;
245
- }
246
- }
247
- }, () => {
248
- // ESC cancels without saving
249
- done(undefined);
118
+ },
119
+ onSave: () => {
120
+ saveCoreConfig(coreConfig);
121
+ saveFooterConfig(footerConfig);
122
+ saveDiffConfig(diffConfig);
123
+ saveNotifyConfig(notifyConfig);
124
+ saveSessionNameConfig(sessionNameConfig);
125
+ done(undefined);
126
+ },
127
+ onClose: () => { done(undefined); },
250
128
  });
251
-
252
- return settingsList;
253
- });
129
+ return settingsManager;
130
+ }, { overlay: true, overlayOptions: OVERLAY_CHROME });
254
131
  }