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 +10 -9
- package/src/config.ts +21 -0
- package/src/index.ts +5 -0
- package/src/settings-manager.ts +364 -0
- package/src/settings.ts +87 -198
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-archimedes",
|
|
3
|
-
"version": "2.0
|
|
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/
|
|
15
|
-
"@pi-archimedes/
|
|
16
|
-
"@pi-archimedes/
|
|
17
|
-
"@pi-archimedes/
|
|
18
|
-
"@pi-archimedes/
|
|
19
|
-
"@pi-archimedes/
|
|
20
|
-
"@pi-archimedes/
|
|
21
|
-
"@pi-archimedes/
|
|
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 {
|
|
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
|
-
|
|
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
|
-
// ──
|
|
22
|
+
// ── Free-input prompt descriptors (keyed by item.id) ───────────────────────
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
label:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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((
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
241
|
-
});
|
|
129
|
+
return settingsManager;
|
|
130
|
+
}, { overlay: true, overlayOptions: OVERLAY_CHROME });
|
|
242
131
|
}
|