pi-archimedes 2.0.1 → 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.0.1",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,14 +11,15 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/footer": "2.0.1",
15
- "@pi-archimedes/image-paste": "2.0.1",
16
- "@pi-archimedes/core": "2.0.1",
17
- "@pi-archimedes/ask": "2.0.1",
18
- "@pi-archimedes/diff": "2.0.1",
19
- "@pi-archimedes/subagent": "2.0.1",
20
- "@pi-archimedes/todo": "2.0.1",
21
- "@pi-archimedes/notify": "2.0.1"
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"
22
23
  },
23
24
  "peerDependencies": {
24
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
package/src/config.ts CHANGED
@@ -74,11 +74,32 @@ export function loadAllConfig(): {
74
74
  footer: FooterConfig;
75
75
  diff: DiffConfig;
76
76
  notify: NotifyConfig;
77
+ sessionName: SessionNameSettings;
77
78
  } {
78
79
  return {
79
80
  core: loadCoreConfig(),
80
81
  footer: loadFooterConfig(),
81
82
  diff: loadDiffConfig(),
82
83
  notify: loadNotifyConfig(),
84
+ sessionName: loadSessionNameConfigWrapper(),
83
85
  };
84
86
  }
87
+
88
+ // ── Session name config ─────────────────────────────────────────────────
89
+
90
+ import type { SessionNameSettings } from "@pi-archimedes/session-name";
91
+ import { loadSessionNameConfig } from "@pi-archimedes/session-name";
92
+ export type { SessionNameSettings } from "@pi-archimedes/session-name";
93
+
94
+ export const DEFAULT_SESSION_NAME_CONFIG: SessionNameSettings = {
95
+ enabled: true,
96
+ model: undefined,
97
+ };
98
+
99
+ export function loadSessionNameConfigWrapper(): SessionNameSettings {
100
+ return loadSessionNameConfig();
101
+ }
102
+
103
+ export function saveSessionNameConfig(config: SessionNameSettings): void {
104
+ saveConfig("archimedes.sessionName", config);
105
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ const _moduleEvalAt = Date.now();
11
11
  import { registerTodo } from "@pi-archimedes/todo";
12
12
  import { registerAsk } from "@pi-archimedes/ask";
13
13
  import { registerNotify } from "@pi-archimedes/notify";
14
+ import { registerSessionName } from "@pi-archimedes/session-name";
14
15
  import { loadDiffConfig } from "./config.js";
15
16
  import { openSettings } from "./settings.js"
16
17
 
@@ -45,6 +46,10 @@ export default function (pi: ExtensionAPI): void {
45
46
  registerNotify(pi);
46
47
  archTime("registerNotify");
47
48
 
49
+ // Register session-name
50
+ registerSessionName(pi);
51
+ archTime("registerSessionName");
52
+
48
53
  archTime("factory end");
49
54
 
50
55
  // session_shutdown handler (top-level to prevent accumulation on /reload)
@@ -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,98 +1,37 @@
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";
9
+ import { getSessionNameSettingsItems } from "@pi-archimedes/session-name";
10
10
  import {
11
11
  loadAllConfig,
12
12
  saveCoreConfig,
13
13
  saveFooterConfig,
14
14
  saveDiffConfig,
15
15
  saveNotifyConfig,
16
- ANIMATION_STYLES,
16
+ saveSessionNameConfig,
17
17
  type CoreConfig,
18
- type FooterConfig,
19
- type DiffConfig,
20
18
  type NotifyConfig,
21
19
  } from "./config.js";
20
+ import { createSettingsManager, type PromptDescriptor } from "./settings-manager.js";
22
21
 
23
- // ── Factory: text submenu ───────────────────────────────────────────────
22
+ // ── Free-input prompt descriptors (keyed by item.id) ───────────────────────
24
23
 
25
- function createTextSubmenu(opts: {
26
- label: string;
27
- cancelHint?: string;
28
- confirmHint?: string;
29
- }): (currentValue: string, done: (selectedValue?: string) => void) => import("@earendil-works/pi-tui").Component {
30
- return (currentValue: string, done: (selectedValue?: string) => void) => {
31
- const state = { value: currentValue };
32
- return {
33
- invalidate(): void { /* no-op */ },
34
- render(): string[] {
35
- const hints: string[] = [];
36
- if (opts.cancelHint) hints.push(opts.cancelHint);
37
- if (opts.confirmHint) hints.push(opts.confirmHint);
38
- return [
39
- opts.label,
40
- "",
41
- ` ${state.value}`,
42
- "",
43
- hints.join(" | "),
44
- ];
45
- },
46
- handleInput(data: string): void {
47
- if (data === "\x1b") { done(); return; }
48
- if (data === "\r" || data === "\n") { done(state.value); return; }
49
- if (data === "\x7f" || data === "\x08") { state.value = state.value.slice(0, -1); }
50
- else if (data.length === 1) { state.value += data; }
51
- },
52
- };
53
- };
54
- }
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
+ };
55
33
 
56
- // ── Factory: number submenu ─────────────────────────────────────────────
57
-
58
- function createNumberSubmenu(opts: {
59
- label: string;
60
- cancelHint?: string;
61
- confirmHint?: string;
62
- min?: number;
63
- }): (currentValue: string, done: (selectedValue?: string) => void) => import("@earendil-works/pi-tui").Component {
64
- return (currentValue: string, done: (selectedValue?: string) => void) => {
65
- const state = { value: currentValue };
66
- return {
67
- invalidate(): void { /* no-op */ },
68
- render(): string[] {
69
- const hints: string[] = [];
70
- if (opts.cancelHint) hints.push(opts.cancelHint);
71
- if (opts.confirmHint) hints.push(opts.confirmHint);
72
- return [
73
- opts.label,
74
- "",
75
- ` ${state.value}`,
76
- "",
77
- hints.join(" | "),
78
- ];
79
- },
80
- handleInput(data: string): void {
81
- if (data === "\x1b") { done(); return; }
82
- if (data === "\r" || data === "\n") {
83
- const n = parseInt(state.value, 10);
84
- if (Number.isFinite(n) && (!opts.min || n >= opts.min)) done(String(n));
85
- else done();
86
- return;
87
- }
88
- if (data === "\x7f" || data === "\x08") { state.value = state.value.slice(0, -1); }
89
- else if (/^\d$/.test(data)) { state.value += data; }
90
- },
91
- };
92
- };
93
- }
94
-
95
- // ── Settings UI ─────────────────────────────────────────────────────────
34
+ // ── Settings UI ─────────────────────────────────────────────────────────────
96
35
 
97
36
  export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
98
37
  // Lazy-load diff (pulls in shiki) — only needed when /archimedes is opened
@@ -100,143 +39,93 @@ export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Pro
100
39
  const allConfig = loadAllConfig();
101
40
 
102
41
  const coreConfig: CoreConfig = { ...allConfig.core };
103
- const footerConfig: FooterConfig = { ...allConfig.footer };
104
- const diffConfig: DiffConfig = { ...allConfig.diff };
105
42
  const notifyConfig: NotifyConfig = { ...allConfig.notify };
43
+ const footerConfig = { ...allConfig.footer };
44
+ const diffConfig = { ...allConfig.diff };
45
+ const sessionNameConfig = { ...allConfig.sessionName };
106
46
 
107
47
  // Build composed items from sub-packages
108
48
  const coreItems = getCoreSettingsItems(coreConfig);
109
49
  const footerItems = getFooterSettingsItems();
110
50
  const diffItems = getDiffSettingsItems();
111
51
  const notifyItems = getNotifySettingsItems(notifyConfig);
52
+ const sessionNameItems = getSessionNameSettingsItems(sessionNameConfig);
112
53
 
113
- // Add submenus for text/number fields
114
- const addSubmenus = (items: SettingItem[]) => {
115
- for (const item of items) {
116
- if (item.id === "labelText") {
117
- item.submenu = createTextSubmenu({
118
- label: "Enter label text (ESC to cancel):",
119
- cancelHint: "ESC: cancel",
120
- confirmHint: "ENTER: confirm",
121
- });
122
- } else if (item.id === "labelColor") {
123
- item.submenu = createTextSubmenu({
124
- label: "Enter RGB color (ESC to cancel):",
125
- cancelHint: "ESC: cancel",
126
- confirmHint: "ENTER: confirm",
127
- });
128
- } else if (item.id === "diffTheme") {
129
- item.submenu = createTextSubmenu({
130
- label: "Enter Shiki theme (ESC to cancel):",
131
- cancelHint: "ESC: cancel",
132
- confirmHint: "ENTER: confirm",
133
- });
134
- } else if (item.id === "diffSplitMinWidth") {
135
- item.submenu = createNumberSubmenu({
136
- label: "Enter min width (ESC to cancel):",
137
- cancelHint: "ESC: cancel",
138
- confirmHint: "min 100",
139
- min: 100,
140
- });
141
- } else if (item.id === "diffSplitMinCodeWidth") {
142
- item.submenu = createNumberSubmenu({
143
- label: "Enter min code width (ESC to cancel):",
144
- cancelHint: "ESC: cancel",
145
- confirmHint: "min 30",
146
- min: 30,
147
- });
148
- } else if (item.id === "splitThreshold") {
149
- item.submenu = createNumberSubmenu({
150
- label: "Enter split threshold (ESC to cancel):",
151
- cancelHint: "ESC: cancel",
152
- confirmHint: "min 80",
153
- min: 80,
154
- });
155
- } else if (item.id === "delayMs") {
156
- item.submenu = createNumberSubmenu({
157
- label: "Enter delay in seconds (ESC to cancel):",
158
- cancelHint: "ESC: cancel",
159
- confirmHint: "min 1",
160
- min: 1,
161
- });
162
- }
163
- }
164
- };
165
-
166
- addSubmenus(coreItems);
167
- addSubmenus(diffItems);
168
- addSubmenus(footerItems);
169
- addSubmenus(notifyItems);
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
+ }
170
61
 
171
62
  const items: SettingItem[] = [
172
63
  ...coreItems,
173
64
  ...footerItems,
174
65
  ...diffItems,
175
66
  ...notifyItems,
176
- {
177
- id: "save",
178
- label: "Save",
179
- description: "Save changes and exit",
180
- currentValue: "",
181
- values: ["Save"],
182
- },
67
+ ...sessionNameItems,
183
68
  ];
184
69
 
185
- ctx.ui.custom((tui: TUI, theme: Theme, _keybindings, done) => {
186
- const settingsList = new SettingsList(items, 10, getSettingsListTheme(), (id: string, newValue: string) => {
187
- switch (id) {
188
- // ── Core settings ──
189
- case "mutedTheme": coreConfig.mutedTheme = newValue === "On"; break;
190
- case "codeUnindent": coreConfig.codeUnindent = newValue === "On"; break;
191
- case "labelText": coreConfig.labelText = newValue; break;
192
- case "labelColor": coreConfig.labelColor = newValue; break;
193
- case "animationStyle": coreConfig.animationStyle = newValue as CoreConfig["animationStyle"]; break;
194
-
195
- // ── Footer settings ──
196
- case "splitThreshold": {
197
- const v = parseInt(newValue, 10);
198
- if (Number.isFinite(v)) footerConfig.splitThreshold = v;
199
- 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;
200
117
  }
201
-
202
- // ── Diff settings ──
203
- case "diffTheme": diffConfig.diffTheme = newValue; break;
204
- case "diffSplitMinWidth": {
205
- const v = parseInt(newValue, 10);
206
- if (Number.isFinite(v)) diffConfig.diffSplitMinWidth = v;
207
- break;
208
- }
209
- case "diffSplitMinCodeWidth": {
210
- const v = parseInt(newValue, 10);
211
- if (Number.isFinite(v)) diffConfig.diffSplitMinCodeWidth = v;
212
- break;
213
- }
214
-
215
- // ── Notify settings ──
216
- case "enabled": notifyConfig.enabled = newValue === "On"; break;
217
- case "notifyOnAgentEnd": notifyConfig.notifyOnAgentEnd = newValue === "On"; break;
218
- case "notifyOnQuestion": notifyConfig.notifyOnQuestion = newValue === "On"; break;
219
- case "delayMs": {
220
- const v = parseInt(newValue, 10);
221
- if (Number.isFinite(v) && v >= 1) notifyConfig.delayMs = v * 1000;
222
- break;
223
- }
224
-
225
- // ── Save ──
226
- case "save": {
227
- saveCoreConfig(coreConfig);
228
- saveFooterConfig(footerConfig);
229
- saveDiffConfig(diffConfig);
230
- saveNotifyConfig(notifyConfig);
231
- done(undefined);
232
- return;
233
- }
234
- }
235
- }, () => {
236
- // ESC cancels without saving
237
- 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); },
238
128
  });
239
-
240
- return settingsList;
241
- });
129
+ return settingsManager;
130
+ }, { overlay: true, overlayOptions: OVERLAY_CHROME });
242
131
  }