pi-archimedes 2.1.0 → 2.3.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 +13 -10
- package/src/index.ts +6 -2
- package/src/settings-manager.ts +364 -0
- package/src/settings.ts +83 -206
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-archimedes",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -11,21 +11,24 @@
|
|
|
11
11
|
],
|
|
12
12
|
"main": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@pi-archimedes/core": "2.
|
|
15
|
-
"@pi-archimedes/diff": "2.
|
|
16
|
-
"@pi-archimedes/
|
|
17
|
-
"@pi-archimedes/
|
|
18
|
-
"@pi-archimedes/
|
|
19
|
-
"@pi-archimedes/
|
|
20
|
-
"@pi-archimedes/
|
|
21
|
-
"@pi-archimedes/
|
|
22
|
-
"@pi-archimedes/session-name": "2.
|
|
14
|
+
"@pi-archimedes/core": "2.3.0",
|
|
15
|
+
"@pi-archimedes/diff": "2.3.0",
|
|
16
|
+
"@pi-archimedes/footer": "2.3.0",
|
|
17
|
+
"@pi-archimedes/image-paste": "2.3.0",
|
|
18
|
+
"@pi-archimedes/notify": "2.3.0",
|
|
19
|
+
"@pi-archimedes/subagent": "2.3.0",
|
|
20
|
+
"@pi-archimedes/todo": "2.3.0",
|
|
21
|
+
"@pi-archimedes/ask": "2.3.0",
|
|
22
|
+
"@pi-archimedes/session-name": "2.3.0",
|
|
23
|
+
"@pi-archimedes/mcp": "2.3.0"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
26
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
26
27
|
"@earendil-works/pi-tui": ">=0.1.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
31
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
29
32
|
"typescript": "^6.0.0"
|
|
30
33
|
},
|
|
31
34
|
"pi": {
|
package/src/index.ts
CHANGED
|
@@ -66,12 +66,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
66
66
|
currentCtx = ctx;
|
|
67
67
|
|
|
68
68
|
// ── Parallel lazy-load all three packages (saves ~100ms vs sequential) ──
|
|
69
|
-
const [diffMod, ipMod, saMod] = await Promise.all([
|
|
69
|
+
const [diffMod, ipMod, saMod, mcpMod] = await Promise.all([
|
|
70
70
|
import("@pi-archimedes/diff").catch((e) => { console.error("[archimedes] diff load failed:", e); return null; }),
|
|
71
71
|
import("@pi-archimedes/image-paste").catch((e) => { console.error("[archimedes] image-paste load failed:", e); return null; }),
|
|
72
72
|
import("@pi-archimedes/subagent").catch((e) => { console.error("[archimedes] subagent load failed:", e); return null; }),
|
|
73
|
+
import("@pi-archimedes/mcp").catch((e) => { console.error("[archimedes] mcp load failed:", e); return null; }),
|
|
73
74
|
]);
|
|
74
|
-
archTime("
|
|
75
|
+
archTime("4 packages loaded in parallel");
|
|
75
76
|
|
|
76
77
|
// Each session_start fires on a fresh Extension (pi creates a new
|
|
77
78
|
// ExtensionRunner per session). Registration is safe to — and must —
|
|
@@ -96,6 +97,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
96
97
|
saMod.registerSubagent(pi);
|
|
97
98
|
saMod.registerAgentsCommand(pi);
|
|
98
99
|
}
|
|
100
|
+
if (mcpMod) {
|
|
101
|
+
mcpMod.registerMcp(pi);
|
|
102
|
+
}
|
|
99
103
|
});
|
|
100
104
|
|
|
101
105
|
// Register /archimedes command
|
|
@@ -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 {
|
|
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
|
-
// ──
|
|
22
|
+
// ── Free-input prompt descriptors (keyed by item.id) ───────────────────────
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
label:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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((
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
253
|
-
});
|
|
129
|
+
return settingsManager;
|
|
130
|
+
}, { overlay: true, overlayOptions: OVERLAY_CHROME });
|
|
254
131
|
}
|