pi-soly 2.1.5 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,466 @@
1
+ // =============================================================================
2
+ // workflows/settings-ui.ts — interactive soly settings editor
3
+ // =============================================================================
4
+ //
5
+ // Modal that lets the user toggle/cycle/± soly config values instead of
6
+ // reading a JSON dump in chat. Lives as a focused overlay panel built on
7
+ // the ListPanel primitive (same shape as the /soly and /rules pickers).
8
+ //
9
+ // Settings shape:
10
+ // - bool : row marker flips ◉/○ on Enter; current value shown in meta
11
+ // - enum : row marker shows the current value; Enter cycles to next
12
+ // - number : row marker shows current value; + / - (also ← / →) step by 1
13
+ //
14
+ // Saving: each change is committed in-memory. On panel close (Esc), the
15
+ // full diff vs the original is written to `<solyDir>/soly.json` (the
16
+ // per-project config file). Global overrides at `~/.agents/soly.json`
17
+ // are left alone — pass `--scope global` in a future iteration to target
18
+ // that path; today the editor is project-scoped only.
19
+ // =============================================================================
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+
24
+ import { matchesKey } from "@earendil-works/pi-tui";
25
+ import type { Component, TUI } from "@earendil-works/pi-tui";
26
+ import type { Theme } from "@earendil-works/pi-coding-agent";
27
+ import { ListPanel, type ListItem, type ListAction, type ListGroup, type PanelKeybindings } from "../visual/list-panel.ts";
28
+
29
+ import { DEFAULT_CONFIG, type SolyConfig } from "../config.ts";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Setting descriptors
33
+ // ---------------------------------------------------------------------------
34
+
35
+ type BoolSetting = {
36
+ kind: "bool";
37
+ key: string;
38
+ label: string;
39
+ description: string;
40
+ get: (c: SolyConfig) => boolean;
41
+ set: (c: SolyConfig, v: boolean) => void;
42
+ };
43
+
44
+ type EnumSetting = {
45
+ kind: "enum";
46
+ key: string;
47
+ label: string;
48
+ description: string;
49
+ get: (c: SolyConfig) => string;
50
+ set: (c: SolyConfig, v: string) => void;
51
+ values: readonly string[];
52
+ };
53
+
54
+ type NumberSetting = {
55
+ kind: "number";
56
+ key: string;
57
+ label: string;
58
+ description: string;
59
+ get: (c: SolyConfig) => number;
60
+ set: (c: SolyConfig, v: number) => void;
61
+ min: number;
62
+ max: number;
63
+ step?: number;
64
+ };
65
+
66
+ export type Setting = BoolSetting | EnumSetting | NumberSetting;
67
+
68
+ function getPath(c: SolyConfig, keys: string[]): unknown {
69
+ let cur: unknown = c;
70
+ for (const k of keys) {
71
+ if (cur == null || typeof cur !== "object") return undefined;
72
+ cur = (cur as Record<string, unknown>)[k];
73
+ }
74
+ return cur;
75
+ }
76
+
77
+ function setPath<T extends object>(target: T, keys: string[], value: unknown): T {
78
+ const next: T = { ...target };
79
+ let cur: Record<string, unknown> = next as Record<string, unknown>;
80
+ for (let i = 0; i < keys.length - 1; i++) {
81
+ const k = keys[i]!;
82
+ const inner = cur[k];
83
+ cur[k] = { ...(typeof inner === "object" && inner !== null ? inner : {}) };
84
+ cur = cur[k] as Record<string, unknown>;
85
+ }
86
+ cur[keys[keys.length - 1]!] = value;
87
+ return next;
88
+ }
89
+
90
+ /** Ordered list of user-editable settings shown in the modal. Complex
91
+ * values (arrays, regex patterns) are intentionally omitted — see the
92
+ * comments in the descriptors. */
93
+ export const SETTINGS: Setting[] = [
94
+ // ---- iteration ----
95
+ {
96
+ kind: "number",
97
+ key: "iteration.retentionDays",
98
+ label: "Iteration retention",
99
+ description: "auto-prune iteration files older than N days (0 = keep forever)",
100
+ get: (c) => c.iteration.retentionDays,
101
+ set: (c, v) => { c.iteration = { ...c.iteration, retentionDays: v }; },
102
+ min: 0, max: 365,
103
+ },
104
+ {
105
+ kind: "bool",
106
+ key: "iteration.includeResearch",
107
+ label: "Include RESEARCH in bundle",
108
+ description: "add RESEARCH.md sections to the per-iteration context bundle",
109
+ get: (c) => c.iteration.includeResearch,
110
+ set: (c, v) => { c.iteration = { ...c.iteration, includeResearch: v }; },
111
+ },
112
+ {
113
+ kind: "bool",
114
+ key: "iteration.includeAntiPatterns",
115
+ label: "Include Anti-Patterns in bundle",
116
+ description: "add the Anti-Patterns table from .continue-here.md to the bundle",
117
+ get: (c) => c.iteration.includeAntiPatterns,
118
+ set: (c, v) => { c.iteration = { ...c.iteration, includeAntiPatterns: v }; },
119
+ },
120
+
121
+ // ---- agent ----
122
+ {
123
+ kind: "bool",
124
+ key: "agent.preferAskPro",
125
+ label: "Prefer ask_pro over picker",
126
+ description: "use the ask_pro multi-question tool instead of soly's built-in picker in discuss flow",
127
+ get: (c) => c.agent.preferAskPro,
128
+ set: (c, v) => { c.agent = { ...c.agent, preferAskPro: v }; },
129
+ },
130
+ {
131
+ kind: "bool",
132
+ key: "agent.toolHints",
133
+ label: "Per-turn tool hints",
134
+ description: "inject context-aware affordance hints (examples → html_artifact, options → decision_deck, …)",
135
+ get: (c) => c.agent.toolHints,
136
+ set: (c, v) => { c.agent = { ...c.agent, toolHints: v }; },
137
+ },
138
+ {
139
+ kind: "enum",
140
+ key: "agent.confirmBeforeCode",
141
+ label: "Confirm-before-code gate",
142
+ description: "before non-trivial coding, make the LLM batch decisions via ask_pro",
143
+ get: (c) => c.agent.confirmBeforeCode as string,
144
+ set: (c, v) => {
145
+ c.agent = { ...c.agent, confirmBeforeCode: v as SolyConfig["agent"]["confirmBeforeCode"] };
146
+ },
147
+ values: ["off", "ask", "scope"],
148
+ },
149
+
150
+ // ---- display ----
151
+ {
152
+ kind: "bool",
153
+ key: "display.defaultRecommendedFirst",
154
+ label: "Default to ⭐ first",
155
+ description: "always show the recommended option as the first row in pickers",
156
+ get: (c) => c.display.defaultRecommendedFirst,
157
+ set: (c, v) => { c.display = { ...c.display, defaultRecommendedFirst: v }; },
158
+ },
159
+ {
160
+ kind: "number",
161
+ key: "display.maxPhasesInStatus",
162
+ label: "Max phases in status",
163
+ description: "cap on how many phases appear in /soly status",
164
+ get: (c) => c.display.maxPhasesInStatus,
165
+ set: (c, v) => { c.display = { ...c.display, maxPhasesInStatus: v }; },
166
+ min: 1, max: 50,
167
+ },
168
+ {
169
+ kind: "number",
170
+ key: "display.maxDecisionsInLog",
171
+ label: "Max decisions in log",
172
+ description: "cap on how many decisions appear in soly log default",
173
+ get: (c) => c.display.maxDecisionsInLog,
174
+ set: (c, v) => { c.display = { ...c.display, maxDecisionsInLog: v }; },
175
+ min: 1, max: 500,
176
+ },
177
+
178
+ // ---- hotReload ----
179
+ {
180
+ kind: "number",
181
+ key: "hotReload.pollMs",
182
+ label: "Hot-reload poll interval (ms)",
183
+ description: "how often to check for rule changes (lower = snappier, higher = less disk I/O)",
184
+ get: (c) => c.hotReload.pollMs,
185
+ set: (c, v) => { c.hotReload = { ...c.hotReload, pollMs: v }; },
186
+ min: 250, max: 60_000, step: 250,
187
+ },
188
+ {
189
+ kind: "bool",
190
+ key: "hotReload.notifyOnRuleChange",
191
+ label: "Notify on rule changes",
192
+ description: "show a notify when rule files change on disk",
193
+ get: (c) => c.hotReload.notifyOnRuleChange,
194
+ set: (c, v) => { c.hotReload = { ...c.hotReload, notifyOnRuleChange: v }; },
195
+ },
196
+
197
+ // ---- chrome ----
198
+ {
199
+ kind: "bool",
200
+ key: "chrome.enabled",
201
+ label: "Chrome (top bar + footer)",
202
+ description: "install soly's status chrome (top bar + custom footer + spinner)",
203
+ get: (c) => c.chrome.enabled,
204
+ set: (c, v) => { c.chrome = { ...c.chrome, enabled: v }; },
205
+ },
206
+ {
207
+ kind: "bool",
208
+ key: "chrome.ascii",
209
+ label: "ASCII fallbacks (no glyphs)",
210
+ description: "use BMP glyphs instead of Nerd-Font icons (for terminals without font support)",
211
+ get: (c) => c.chrome.ascii,
212
+ set: (c, v) => { c.chrome = { ...c.chrome, ascii: v }; },
213
+ },
214
+ {
215
+ kind: "bool",
216
+ key: "chrome.telemetry",
217
+ label: "Working telemetry",
218
+ description: "show live elapsed · ↑↓ tokens · tok/s next to the working spinner",
219
+ get: (c) => c.chrome.telemetry,
220
+ set: (c, v) => { c.chrome = { ...c.chrome, telemetry: v }; },
221
+ },
222
+ {
223
+ kind: "number",
224
+ key: "chrome.spinnerIntervalMs",
225
+ label: "Spinner frame interval (ms)",
226
+ description: "how fast the working spinner animates",
227
+ get: (c) => c.chrome.spinnerIntervalMs,
228
+ set: (c, v) => { c.chrome = { ...c.chrome, spinnerIntervalMs: v }; },
229
+ min: 50, max: 1000, step: 25,
230
+ },
231
+
232
+ // ---- verify ----
233
+ {
234
+ kind: "bool",
235
+ key: "verify.freshContext",
236
+ label: "Fresh-context review",
237
+ description: "strip prior review iterations from context each pass (re-review with truly fresh eyes)",
238
+ get: (c) => c.verify.freshContext,
239
+ set: (c, v) => { c.verify = { ...c.verify, freshContext: v }; },
240
+ },
241
+ {
242
+ kind: "number",
243
+ key: "verify.maxIterations",
244
+ label: "Verify max iterations",
245
+ description: "max self-review passes before the loop stops on its own",
246
+ get: (c) => c.verify.maxIterations,
247
+ set: (c, v) => { c.verify = { ...c.verify, maxIterations: v }; },
248
+ min: 1, max: 20,
249
+ },
250
+
251
+ // ---- editor ----
252
+ {
253
+ kind: "enum",
254
+ key: "editor.command",
255
+ label: "$EDITOR command",
256
+ description: "command used for `/open` and edit-redirect (e.g. code, vim, code-insiders)",
257
+ get: (c) => c.editor.command,
258
+ set: (c, v) => { c.editor = { ...c.editor, command: v }; },
259
+ values: ["code", "code-insiders", "cursor", "vim", "nvim", "nano", "emacs", "subl", "xed"],
260
+ },
261
+ ];
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Modal
265
+ // ---------------------------------------------------------------------------
266
+
267
+ export type Notifier = {
268
+ notify: (text: string, level?: "info" | "warning" | "error") => void;
269
+ };
270
+
271
+ /** Marker tokens for the row glyph. Bool: ●/○ ; enum/number: shows value. */
272
+ function markerFor(s: Setting, value: unknown): string {
273
+ if (s.kind === "bool") return value ? "◉" : "○";
274
+ if (s.kind === "enum") {
275
+ const idx = s.values.indexOf(value as string);
276
+ return idx >= 0 ? String(idx + 1) : "?";
277
+ }
278
+ // number — show value with arrow hints
279
+ return String(value);
280
+ }
281
+
282
+ function displayValue(s: Setting, value: unknown): string {
283
+ if (s.kind === "bool") return value ? "ON" : "OFF";
284
+ if (s.kind === "enum") return String(value);
285
+ if (s.kind === "number") return String(value);
286
+ return String(value);
287
+ }
288
+
289
+ /** Convert a fully-resolved SolyConfig to a plain JSON-serializable object,
290
+ * diffing against `DEFAULT_CONFIG` so we only persist user overrides. */
291
+ function diffVsDefaults(c: SolyConfig): SolyConfig {
292
+ // We treat SolyConfig as Record<string, unknown> for the recursion since
293
+ // TS won't let us declare `out` as both. The fields we touch are all
294
+ // JSON-serializable primitives or nested objects of the same shape.
295
+ const out = JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as unknown as Record<string, unknown>;
296
+ const walk = (target: Record<string, unknown>, source: Record<string, unknown>): void => {
297
+ for (const k of Object.keys(source)) {
298
+ const sv = source[k];
299
+ if (sv && typeof sv === "object" && !Array.isArray(sv)) {
300
+ if (!target[k] || typeof target[k] !== "object") target[k] = {};
301
+ walk(target[k] as Record<string, unknown>, sv as Record<string, unknown>);
302
+ } else if (!deepEqual(target[k], sv)) {
303
+ target[k] = sv;
304
+ }
305
+ }
306
+ };
307
+ walk(out, c as unknown as Record<string, unknown>);
308
+ // Strip any keys that ended up at default (empty walks leave them).
309
+ const prune = (target: Record<string, unknown>, source: Record<string, unknown>): void => {
310
+ for (const k of Object.keys(target)) {
311
+ const tv = target[k];
312
+ const sv = source[k];
313
+ if (tv && typeof tv === "object" && !Array.isArray(tv) && sv && typeof sv === "object") {
314
+ prune(tv as Record<string, unknown>, sv as Record<string, unknown>);
315
+ if (Object.keys(tv).length === 0) delete target[k];
316
+ } else if (deepEqual(tv, sv)) {
317
+ delete target[k];
318
+ }
319
+ }
320
+ };
321
+ prune(out, DEFAULT_CONFIG as unknown as Record<string, unknown>);
322
+ return out as unknown as SolyConfig;
323
+ }
324
+
325
+ function deepEqual(a: unknown, b: unknown): boolean {
326
+ if (a === b) return true;
327
+ if (a === null || b === null) return a === b;
328
+ if (typeof a !== typeof b) return false;
329
+ if (typeof a === "object") {
330
+ const ak = Object.keys(a as object);
331
+ const bk = Object.keys(b as object);
332
+ if (ak.length !== bk.length) return false;
333
+ for (const k of ak) if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
334
+ return true;
335
+ }
336
+ return false;
337
+ }
338
+
339
+ /** Write `data` to `<solyDir>/soly.json`, preserving any existing top-level
340
+ * keys the user has set but soly doesn't know about. */
341
+ function saveConfigFile(solyDir: string, data: SolyConfig): void {
342
+ const target = path.join(solyDir, "soly.json");
343
+ let merged = data as unknown as Record<string, unknown>;
344
+ if (fs.existsSync(target)) {
345
+ try {
346
+ const raw = fs.readFileSync(target, "utf-8");
347
+ const existing = JSON.parse(raw) as Record<string, unknown>;
348
+ // Carry over unknown keys (forward-compat with future fields).
349
+ for (const k of Object.keys(existing)) {
350
+ if (!(k in merged)) merged[k] = existing[k];
351
+ }
352
+ } catch { /* corrupt file → overwrite is fine */ }
353
+ }
354
+ const mergedTyped: SolyConfig = merged as unknown as SolyConfig;
355
+ fs.mkdirSync(solyDir, { recursive: true });
356
+ fs.writeFileSync(target, JSON.stringify(mergedTyped, null, 2) + "\n", "utf-8");
357
+ }
358
+
359
+ export type SettingsUIDeps = {
360
+ ctx: { ui: { custom: <T>(factory: (tui: TUI, theme: Theme, keybindings: PanelKeybindings, done: (result?: T) => void) => Component & { dispose?: () => void } | Promise<Component & { dispose?: () => void }>, opts?: { overlay?: boolean } | undefined) => Promise<T | undefined> } };
361
+ ui: Notifier;
362
+ /** Where to write `<solyDir>/soly.json` (typically `state.solyDir`). */
363
+ solyDir: string;
364
+ /** Live config snapshot — re-read on every call so we pick up writes
365
+ * done by other panels in the same session. */
366
+ getConfig: () => SolyConfig;
367
+ /** Reload the live config from disk after we wrote the file. */
368
+ reloadConfig: () => void;
369
+ };
370
+
371
+ /** Open the interactive settings modal. Mutates `working` in memory, then
372
+ * writes the diff to `<solyDir>/soly.json` on close. */
373
+ export function openSettingsUI(deps: SettingsUIDeps): void {
374
+ const { ctx, ui, solyDir, getConfig, reloadConfig } = deps;
375
+
376
+ // Take a deep copy so we can mutate freely; on close, diff vs defaults
377
+ // and persist the override file.
378
+ const working: SolyConfig = JSON.parse(JSON.stringify(getConfig()));
379
+ const originalJson = JSON.stringify(working);
380
+
381
+ const itemFor = (s: Setting): ListItem => {
382
+ const v = s.get(working);
383
+ return {
384
+ id: s.key,
385
+ marker: markerFor(s, v),
386
+ label: s.label,
387
+ meta: displayValue(s, v),
388
+ body: `${s.description}\n\nkey: ${s.key}${s.kind === "enum" ? `\nvalues: ${s.values.join(" / ")}` : ""}${s.kind === "number" ? `\nrange: ${s.min}..${s.max}${s.step ? ` step ${s.step}` : ""}` : ""}`,
389
+ };
390
+ };
391
+
392
+ const buildGroups = (): ListGroup[] => [
393
+ { id: "iteration", title: "Iteration", icon: "↻", items: SETTINGS.filter((s) => s.key.startsWith("iteration.")).map(itemFor) },
394
+ { id: "agent", title: "Agent", icon: "◉", items: SETTINGS.filter((s) => s.key.startsWith("agent.")).map(itemFor) },
395
+ { id: "display", title: "Display", icon: "▤", items: SETTINGS.filter((s) => s.key.startsWith("display.")).map(itemFor) },
396
+ { id: "reload", title: "Hot reload", icon: "↺", items: SETTINGS.filter((s) => s.key.startsWith("hotReload.")).map(itemFor) },
397
+ { id: "chrome", title: "Chrome", icon: "▰", items: SETTINGS.filter((s) => s.key.startsWith("chrome.")).map(itemFor) },
398
+ { id: "verify", title: "Verify", icon: "✓", items: SETTINGS.filter((s) => s.key.startsWith("verify.")).map(itemFor) },
399
+ { id: "editor", title: "Editor", icon: "▥", items: SETTINGS.filter((s) => s.key.startsWith("editor.")).map(itemFor) },
400
+ ].filter((g) => g.items.length > 0);
401
+
402
+ const findSetting = (key: string): Setting | undefined => SETTINGS.find((s) => s.key === key);
403
+
404
+ const applyToggle = (item: ListItem): void => {
405
+ const s = findSetting(item.id);
406
+ if (!s) return;
407
+ if (s.kind === "bool") {
408
+ const cur = s.get(working);
409
+ s.set(working, !cur);
410
+ } else if (s.kind === "enum") {
411
+ const cur = s.get(working) as string;
412
+ const idx = s.values.indexOf(cur);
413
+ const next = s.values[(idx + 1) % s.values.length]!;
414
+ s.set(working, next);
415
+ } else if (s.kind === "number") {
416
+ const cur = s.get(working);
417
+ const step = s.step ?? 1;
418
+ const next = Math.min(s.max, Math.max(s.min, cur + step));
419
+ s.set(working, next);
420
+ }
421
+ };
422
+
423
+ ctx.ui.custom<void>(
424
+ (tui: TUI, theme: Theme, keybindings: PanelKeybindings, done: () => void) => {
425
+ const stepDown = (item: ListItem): void => {
426
+ const s = findSetting(item.id);
427
+ if (!s || s.kind !== "number") return;
428
+ const step = s.step ?? 1;
429
+ const cur = s.get(working);
430
+ s.set(working, Math.max(s.min, cur - step));
431
+ };
432
+
433
+ return new ListPanel({
434
+ tui,
435
+ theme,
436
+ keybindings,
437
+ done: () => {
438
+ // Persist changes if the in-memory copy diverged from the
439
+ // original. Reads include both default-fills and global
440
+ // overrides — we only store the diff vs defaults.
441
+ if (JSON.stringify(working) !== originalJson) {
442
+ try {
443
+ const diff = diffVsDefaults(working);
444
+ saveConfigFile(solyDir, diff);
445
+ ui.notify(`settings saved → ${path.basename(solyDir)}/soly.json`, "info");
446
+ // Re-read the config from disk so the active config matches
447
+ // what we just wrote.
448
+ reloadConfig();
449
+ } catch (e) {
450
+ ui.notify(`save failed: ${(e as Error).message}`, "error");
451
+ }
452
+ }
453
+ done();
454
+ },
455
+ title: "soly · settings",
456
+ headerRight: `save on Esc → soly.json`,
457
+ groups: buildGroups(),
458
+ onSelect: (it) => applyToggle(it),
459
+ actions: [
460
+ { key: "-", hint: "−1", run: stepDown },
461
+ ],
462
+ });
463
+ },
464
+ { overlay: true },
465
+ );
466
+ }
File without changes