pi-soly 2.1.4 → 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.
@@ -0,0 +1,173 @@
1
+ # Temporary Files Rule
2
+
3
+ > **OS-aware temp paths.** Never hardcode `/tmp` — it's wrong on Windows
4
+ > (`%TEMP%` resolves to `C:\Users\<user>\AppData\Local\Temp`, not `/tmp`),
5
+ > wrong on macOS (`$TMPDIR` is per-user, e.g. `/var/folders/xx/yy/T/`),
6
+ > and fragile in multi-tenant / CI scenarios. Always resolve the
7
+ > OS-correct temp dir at runtime and put files under a project-scoped
8
+ > subfolder.
9
+
10
+ ## The rule
11
+
12
+ When creating temporary files — intermediate build artifacts, scratch
13
+ data, debug dumps, test fixtures, atomic-write staging, anything that
14
+ isn't meant to outlive the process:
15
+
16
+ 1. **Resolve the OS temp dir at runtime.** Never hardcode `/tmp`,
17
+ `/var/folders`, `C:\Temp`, `~/tmp`, or any other literal path.
18
+ 2. **Use a project-scoped subfolder** like `<tempdir>/<project>-<hash>/`
19
+ so multiple projects don't collide and cleanup is easy.
20
+ 3. **Clean up after yourself** — when the work is done, remove the
21
+ directory (or use `try`/`finally` / `trap EXIT` / `defer`).
22
+ 4. **Prefer atomic-write patterns** for files that will be renamed into
23
+ place: write to a temp file in the **same directory** as the target,
24
+ `fsync` if durability matters, then rename. Avoids half-written
25
+ files on crash and avoids cross-device rename failures.
26
+
27
+ ## How to get the temp dir per runtime
28
+
29
+ | Runtime | API | Notes |
30
+ |---------------|------------------------------|------------------------------------|
31
+ | Node.js / Bun | `os.tmpdir()` | already OS-aware; returns absolute |
32
+ | Python | `tempfile.gettempdir()` | stdlib |
33
+ | Go | `os.TempDir()` | stdlib |
34
+ | Rust | `std::env::temp_dir()` | stdlib; returns `PathBuf` |
35
+ | .NET | `Path.GetTempPath()` | returns trailing slash |
36
+ | Java | `System.getProperty("java.io.tmpdir")` | JVM-resolved |
37
+ | Bash (POSIX) | `${TMPDIR:-/tmp}` | fallback only if env unset |
38
+ | PowerShell | `$env:TEMP` | Windows |
39
+ | cmd.exe | `%TEMP%` | Windows |
40
+
41
+ **Do not** `cd /tmp && touch foo`. Use the API for the runtime you're in.
42
+
43
+ ## Why this matters
44
+
45
+ - **Windows**: `C:\Temp` may not exist or be writable; the real temp is
46
+ `C:\Users\<user>\AppData\Local\Temp`. Hardcoding `/tmp` either fails
47
+ outright or pollutes the wrong location.
48
+ - **macOS**: `$TMPDIR` is per-user and changes per reboot. `/tmp` is a
49
+ shared system directory, not appropriate for app scratch data.
50
+ - **Linux / multi-user**: `/tmp` is shared across users on the host.
51
+ Mixing files across users is a data-leak / collision risk.
52
+ - **CI/CD**: runners typically wipe `/tmp` between jobs but leave the
53
+ per-user temp dir intact, so using the right dir keeps artifacts
54
+ available for inspection post-run.
55
+ - **Sandboxing**: macOS sandbox and certain Linux sandboxes redirect
56
+ `/tmp` to a per-process private dir. The runtime API follows these
57
+ redirects; hardcoded `/tmp` does not.
58
+
59
+ ## Examples
60
+
61
+ ### Node.js / Bun
62
+
63
+ ```ts
64
+ import * as fs from "node:fs";
65
+ import * as os from "node:os";
66
+ import * as path from "node:path";
67
+
68
+ const projectId = "pi-soly"; // or hash(repoUrl) for uniqueness
69
+ const scratchDir = path.join(os.tmpdir(), `${projectId}-${process.pid}`);
70
+ fs.mkdirSync(scratchDir, { recursive: true });
71
+
72
+ try {
73
+ // ... use scratchDir for intermediate files ...
74
+ } finally {
75
+ fs.rmSync(scratchDir, { recursive: true, force: true });
76
+ }
77
+ ```
78
+
79
+ For atomic writes to a target file:
80
+
81
+ ```ts
82
+ import * as fs from "node:fs";
83
+ import * as os from "node:os";
84
+ import * as path from "node:path";
85
+
86
+ function atomicWrite(target: string, data: string | Uint8Array): void {
87
+ const dir = path.dirname(target);
88
+ const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.tmp`);
89
+ const fh = fs.openSync(tmp, "w");
90
+ try {
91
+ fs.writeSync(fh, data);
92
+ fs.fsyncSync(fh);
93
+ } finally {
94
+ fs.closeSync(fh);
95
+ }
96
+ fs.renameSync(tmp, target); // atomic on same filesystem
97
+ }
98
+ ```
99
+
100
+ ### Python
101
+
102
+ ```python
103
+ import shutil
104
+ import tempfile
105
+
106
+ scratch = tempfile.mkdtemp(prefix="myproject-")
107
+ try:
108
+ # ... use scratch ...
109
+ pass
110
+ finally:
111
+ shutil.rmtree(scratch, ignore_errors=True)
112
+
113
+ # Atomic write:
114
+ import os
115
+ def atomic_write(path: str, data: bytes) -> None:
116
+ tmp = f"{path}.{os.getpid()}.tmp"
117
+ with open(tmp, "wb") as f:
118
+ f.write(data)
119
+ f.flush()
120
+ os.fsync(f.fileno())
121
+ os.replace(tmp, path)
122
+ ```
123
+
124
+ ### Bash (POSIX: Linux / macOS)
125
+
126
+ ```bash
127
+ SCRATCH="${TMPDIR:-/tmp}/myproject-$$"
128
+ mkdir -p "$SCRATCH"
129
+ trap 'rm -rf "$SCRATCH"' EXIT
130
+ # ... work in "$SCRATCH" ...
131
+ ```
132
+
133
+ ### PowerShell (Windows)
134
+
135
+ ```powershell
136
+ $scratch = Join-Path $env:TEMP "myproject-$PID"
137
+ New-Item -ItemType Directory -Path $scratch -Force | Out-Null
138
+ try {
139
+ # ... work in $scratch ...
140
+ } finally {
141
+ Remove-Item -Recurse -Force $scratch -ErrorAction SilentlyContinue
142
+ }
143
+ ```
144
+
145
+ ## Forbidden patterns
146
+
147
+ ```ts
148
+ // ❌ Hardcoded path — breaks on Windows, breaks sandboxing
149
+ const dir = "/tmp/myapp";
150
+ const dir = "C:\\Temp\\myapp";
151
+ const dir = path.join("/tmp", "myapp");
152
+
153
+ // ❌ mkdir without cleanup — leaks across runs
154
+ fs.mkdirSync("/tmp/myapp", { recursive: true });
155
+ // ... no rmSync ...
156
+
157
+ // ❌ Atomic write to /tmp when target is elsewhere — cross-device rename fails
158
+ const tmp = `/tmp/${path.basename(target)}`;
159
+ fs.writeFileSync(tmp, data);
160
+ fs.renameSync(tmp, target); // EXDEV on different filesystems
161
+
162
+ // ✓ Correct: resolve temp dir at runtime, project-scoped, cleaned up
163
+ const dir = path.join(os.tmpdir(), `${projectId}-${process.pid}`);
164
+ fs.mkdirSync(dir, { recursive: true });
165
+ try { /* work */ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
166
+ ```
167
+
168
+ ## Override
169
+
170
+ This rule is **built-in to the soly extension** and ships with every
171
+ install. It has highest priority and cannot be overridden by user rules.
172
+ If you need a different convention, name your rule differently
173
+ (e.g., `temp-files-windows-only.md` so the paths don't collide).
package/commands.ts CHANGED
@@ -44,7 +44,8 @@ import {
44
44
  } from "./intent.ts";
45
45
  import type { SolyConfig } from "./config.ts";
46
46
  import { initSolyProject } from "./init.js";
47
- import { ListPanel, type ListItem, type ListAction } from "./visual/list-panel.ts";
47
+ import { ListPanel, type ListItem, type ListAction, type ListGroup } from "./visual/list-panel.ts";
48
+ import { openSettingsUI } from "./workflows/settings-ui.ts";
48
49
  import { getArtifactServer, ensureArtifactServer, artifactDir } from "./artifact/session.ts";
49
50
  import { parseSolyCommand, type SolyCommand, type WorkflowVerb } from "./workflows/parser.ts";
50
51
  import { buildNewTransform } from "./workflows/new.ts";
@@ -69,16 +70,17 @@ export interface CommandsDeps {
69
70
  refreshState: () => void;
70
71
  updateStatus: (ui: CommandUI) => void;
71
72
  getConfig: () => SolyConfig;
73
+ reloadConfig: () => void;
72
74
  getIntentDocs: () => IntentDoc[];
73
75
  }
74
76
 
75
- /** Open a focused list modal (overlay) for the given items + actions. */
77
+ /** Open a focused list modal (overlay) for the given grouped items. */
76
78
  async function openListPanel(
77
79
  ctx: ExtensionCommandContext,
78
80
  spec: {
79
81
  title: string;
80
82
  headerRight?: string;
81
- build: () => ListItem[];
83
+ build: () => ListGroup[];
82
84
  actions?: ListAction[];
83
85
  onSelect?: (item: ListItem) => void;
84
86
  },
@@ -92,7 +94,7 @@ async function openListPanel(
92
94
  done: () => done(),
93
95
  title: spec.title,
94
96
  headerRight: spec.headerRight,
95
- items: spec.build(),
97
+ groups: spec.build(),
96
98
  actions: spec.actions,
97
99
  refresh: spec.build,
98
100
  onSelect: spec.onSelect,
@@ -180,7 +182,9 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
180
182
  await openListPanel(ctx, {
181
183
  title: "soly · rules",
182
184
  headerRight: `${getRules().length} rules · ${formatTok(analytics.totalTokens)} · ${analytics.contextBudgetPct.toFixed(1)}%`,
183
- build: () => getRules().map(ruleItem),
185
+ build: () => [
186
+ { id: "rules", title: "Rules", icon: "▤", items: getRules().map(ruleItem) },
187
+ ],
184
188
  actions: [
185
189
  { key: "e", hint: "enable", run: (it) => { const r = getRules().find((x) => x.relPath === it.id); if (r) r.enabled = true; } },
186
190
  { key: "d", hint: "disable", run: (it) => { const r = getRules().find((x) => x.relPath === it.id); if (r) r.enabled = false; } },
@@ -458,14 +462,20 @@ What must the LLM do?
458
462
  await openListPanel(ctx, {
459
463
  title: "soly · docs",
460
464
  headerRight: `${docs.length} docs · ${formatTok(total)}`,
461
- build: () =>
462
- getIntentDocs().map((d) => ({
463
- id: d.relPath,
464
- marker: "",
465
- label: d.title || d.relPath,
466
- meta: `${d.kind} · ${formatTok(d.tokens)} tok${d.oversized ? " · oversized" : ""}`,
467
- body: d.preview,
468
- })),
465
+ build: () => [
466
+ {
467
+ id: "docs",
468
+ title: "Docs",
469
+ icon: "☖",
470
+ items: getIntentDocs().map((d) => ({
471
+ id: d.relPath,
472
+ marker: "○",
473
+ label: d.title || d.relPath,
474
+ meta: `${d.kind} · ${formatTok(d.tokens)} tok${d.oversized ? " · oversized" : ""}`,
475
+ body: d.preview,
476
+ })),
477
+ },
478
+ ],
469
479
  });
470
480
  return;
471
481
  }
@@ -488,10 +498,7 @@ What must the LLM do?
488
498
  // /soly (also hosts `/soly init` — see the early dispatch in the handler)
489
499
  // ============================================================================
490
500
 
491
- pi.registerCommand("soly", {
492
- description:
493
- "soly: project state inspection (position, plan, state, phases, etc.) — type 'help' for subcommand picker",
494
- handler: async (args, ctx) => {
501
+ const solyBody = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
495
502
  const ui: CommandUI = {
496
503
  notify: (t, k) => ctx.ui.notify(t, k ?? "info"),
497
504
  select: async (label, options) => {
@@ -860,6 +867,25 @@ What must the LLM do?
860
867
  description: "one-shot: import .agents/phases/<NN>-<slug>/plans/PLAN.md as plan branches",
861
868
  run: () => runWorkflow("migrate", [], ctx, ui),
862
869
  },
870
+ where: {
871
+ description: "alias for `position` — current position + progress",
872
+ run: (parts) => {
873
+ const p = subcommands.position;
874
+ if (p) void p.run(parts);
875
+ },
876
+ },
877
+ settings: {
878
+ description: "interactive config editor (toggles, enums, numbers)",
879
+ run: () => {
880
+ openSettingsUI({
881
+ ctx,
882
+ ui,
883
+ solyDir: state.solyDir,
884
+ getConfig,
885
+ reloadConfig: deps.reloadConfig,
886
+ });
887
+ },
888
+ },
863
889
  };
864
890
 
865
891
  // Single-width BMP glyphs only — astral/VS16 emoji are mis-measured by
@@ -869,6 +895,7 @@ What must the LLM do?
869
895
  research: "⊙", roadmap: "≣", progress: "▰",
870
896
  phases: "▭", tasks: "✓", task: "◍",
871
897
  features: "★", milestone: "◈", reload: "↻", config: "▣",
898
+ settings: "⚙", where: "◎",
872
899
  };
873
900
 
874
901
  // Short live preview shown in the modal's preview pane per subcommand.
@@ -878,6 +905,7 @@ What must the LLM do?
878
905
  const phaseFile = (suffix: string) =>
879
906
  s.currentPhase ? readIfExists(path.join(s.currentPhase.dir, `${s.currentPhase.slug}-${suffix}.md`)) : null;
880
907
  switch (name) {
908
+ case "where": return s.position ? `${s.position.phase} · ${s.position.plan} · ${s.position.status} · ${s.progress.percent}%` : `${s.milestone} — no position set`;
881
909
  case "position": return s.position ? `${s.position.phase} · ${s.position.plan} · ${s.position.status} · ${s.progress.percent}%` : `${s.milestone} — no position set`;
882
910
  case "progress": return `${s.progress.percent}% · ${s.progress.completedPhases}/${s.progress.totalPhases} phases · ${s.progress.completedPlans}/${s.progress.totalPlans} plans`;
883
911
  case "phases": return s.phases.length ? s.phases.map((p) => `${p.number}.${p.name}`).join(" · ") : "no phases";
@@ -889,20 +917,55 @@ What must the LLM do?
889
917
  case "plan": return s.currentPlanPath ? teaser(readIfExists(s.currentPlanPath)) : "no current plan";
890
918
  case "context": return s.currentPhase ? teaser(phaseFile("CONTEXT")) || "CONTEXT.md not found" : "no current phase";
891
919
  case "research": return s.currentPhase ? teaser(phaseFile("RESEARCH")) || "RESEARCH.md not found" : "no current phase";
892
- case "config": return "merged config — press to view the JSON";
920
+ case "settings": return "interactive config editor toggles, enums, numbers";
921
+ case "config": return "merged config JSON (read-only view)";
893
922
  case "reload": return "re-read project state from disk";
894
923
  default: return subcommands[name]?.description ?? "";
895
924
  }
896
925
  };
897
926
 
898
- const solyItems = (): ListItem[] =>
899
- Object.keys(subcommands).map((name) => ({
900
- id: name,
901
- marker: ICONS[name] ?? "",
902
- label: name,
903
- meta: subcommands[name]!.description,
904
- body: previewFor(name),
905
- }));
927
+ // Top-to-bottom group order in the /soly modal. Items in
928
+ // `items[]` show in declaration order; subcommands not listed here
929
+ // still work via `/soly <name>` directly but don't appear in the picker.
930
+ const SOLY_GROUP_ORDER = ["status", "inspect", "manage"] as const;
931
+ const SOLY_GROUPS: Record<string, { id: string; title: string; icon: string; items: string[] }> = {
932
+ status: {
933
+ id: "status",
934
+ title: "Status",
935
+ icon: "▰",
936
+ items: ["where", "progress"],
937
+ },
938
+ inspect: {
939
+ id: "inspect",
940
+ title: "Inspect",
941
+ icon: "▤",
942
+ items: ["plan", "state", "roadmap", "context", "phases", "tasks", "milestone"],
943
+ },
944
+ manage: {
945
+ id: "manage",
946
+ title: "Manage",
947
+ icon: "⚙",
948
+ items: ["settings", "reload", "config"],
949
+ },
950
+ };
951
+
952
+ const solyGroups = (): ListGroup[] =>
953
+ SOLY_GROUP_ORDER.map((gid) => {
954
+ const def = SOLY_GROUPS[gid]!;
955
+ const items: ListItem[] = [];
956
+ for (const name of def.items) {
957
+ const spec = subcommands[name];
958
+ if (!spec) continue;
959
+ items.push({
960
+ id: name,
961
+ marker: ICONS[name] ?? "▸",
962
+ label: name,
963
+ meta: spec.description,
964
+ body: previewFor(name),
965
+ });
966
+ }
967
+ return { id: def.id, title: def.title, icon: def.icon, items };
968
+ }).filter((g) => g.items.length > 0);
906
969
 
907
970
  // Plain-select fallback for non-TUI (RPC/print) modes.
908
971
  const picker = async (label: string) => {
@@ -920,8 +983,8 @@ What must the LLM do?
920
983
  const s = getState();
921
984
  await openListPanel(ctx, {
922
985
  title: "soly · state",
923
- headerRight: `${s.phases.length} phases · ${s.progress.percent}%`,
924
- build: solyItems,
986
+ headerRight: `${s.phases.length} phases · ${s.progress.percent}% · /sly /s`,
987
+ build: solyGroups,
925
988
  onSelect: (it) => {
926
989
  void subcommands[it.id]?.run([it.id]);
927
990
  },
@@ -942,7 +1005,21 @@ What must the LLM do?
942
1005
  }
943
1006
 
944
1007
  await subcommands[sub].run(parts);
945
- },
1008
+ };
1009
+ pi.registerCommand("soly", {
1010
+ description:
1011
+ "soly: project state inspection (position, plan, state, phases, etc.) — type 'help' for subcommand picker",
1012
+ handler: solyBody,
1013
+ });
1014
+ // Short aliases — same body, three names. /sly is the typing-friendly form;
1015
+ // /s is the speed-freak form.
1016
+ pi.registerCommand("sly", {
1017
+ description: "alias for /soly",
1018
+ handler: solyBody,
1019
+ });
1020
+ pi.registerCommand("s", {
1021
+ description: "alias for /soly",
1022
+ handler: solyBody,
946
1023
  });
947
1024
  // ============================================================================
948
1025
  // /artifacts — browse this session's html_artifact gallery
@@ -990,14 +1067,20 @@ What must the LLM do?
990
1067
  title: "soly · artifacts",
991
1068
  // BMP-only header (no long URL — it overflowed the bar). Count only.
992
1069
  headerRight: `${server.count} artifact${server.count === 1 ? "" : "s"}`,
993
- build: () =>
994
- (getArtifactServer()?.list() ?? []).map((a) => ({
995
- id: a.id,
996
- marker: "", // BMP glyph — an astral emoji (🖼) breaks ListPanel width
997
- label: a.title,
998
- meta: new Date(a.createdAt).toLocaleTimeString(),
999
- body: a.url,
1000
- })),
1070
+ build: () => [
1071
+ {
1072
+ id: "artifacts",
1073
+ title: "Artifacts",
1074
+ icon: "▦",
1075
+ items: (getArtifactServer()?.list() ?? []).map((a) => ({
1076
+ id: a.id,
1077
+ marker: "▦", // BMP glyph — an astral emoji (🖼) breaks ListPanel width
1078
+ label: a.title,
1079
+ meta: new Date(a.createdAt).toLocaleTimeString(),
1080
+ body: a.url,
1081
+ })),
1082
+ },
1083
+ ],
1001
1084
  onSelect: (it) => {
1002
1085
  const a = getArtifactServer()?.list().find((x) => x.id === it.id);
1003
1086
  if (a) void openExternally(pi, a.url);
package/core.ts CHANGED
@@ -573,17 +573,26 @@ export function buildRulesSection(
573
573
  if (r.interactiveOnly) interactive.push(r.relPath);
574
574
  }
575
575
 
576
+ // Split into built-in (shipped with soly) vs project/user rules. The two
577
+ // groups render in two distinct sections so the LLM can see the priority
578
+ // structure at a glance: vendor rules at the top (cannot be overridden),
579
+ // user rules below (can be edited locally).
580
+ const builtInRules = applicable.filter((r) => r.source === "built-in");
581
+ const userRules = applicable.filter((r) => r.source !== "built-in");
582
+
576
583
  // Optional grouping: phase rules in their own group, then everything else.
577
- let blocks: string[];
584
+ // Phase grouping only applies to user rules — built-in rules are always
585
+ // always-on and don't move with phases.
586
+ let userBlocks: string[];
578
587
  let headerHint: string;
579
588
  if (options?.groupByPhase) {
580
589
  const phase = options.phaseNumber;
581
- const phaseRules = applicable.filter((r) => r.phaseNumber === phase);
582
- const otherRules = applicable.filter((r) => r.phaseNumber !== phase);
583
- blocks = [...phaseRules.map(render), ...otherRules.map(render)];
590
+ const phaseRules = userRules.filter((r) => r.phaseNumber === phase);
591
+ const otherRules = userRules.filter((r) => r.phaseNumber !== phase);
592
+ userBlocks = [...phaseRules.map(render), ...otherRules.map(render)];
584
593
  headerHint = `Phase ${phase} rules are loaded for the currently active phase; all other rules are always-on. Inline @see references are resolved recursively.`;
585
594
  } else {
586
- blocks = applicable.map(render);
595
+ userBlocks = userRules.map(render);
587
596
  headerHint = `The following rules are loaded from \`.agents/rules/\` and \`~/.agents/rules/\` and are mandatory. Follow them strictly. Inline @see references are resolved recursively.`;
588
597
  }
589
598
 
@@ -593,7 +602,26 @@ export function buildRulesSection(
593
602
  .join(", ")}_`
594
603
  : "";
595
604
 
596
- const section = `
605
+ // Built-in section (shipped with soly, cannot be overridden).
606
+ // Renders ABOVE the MANDATORY project-rules section so the LLM sees the
607
+ // vendor content first, with a stronger "this is system-managed" framing.
608
+ const builtInSection = builtInRules.length
609
+ ? `
610
+
611
+ ## 🔒 Built-in rules (shipped with soly)
612
+
613
+ **The following rules ship with the soly extension and apply to every
614
+ session.** They are the highest-priority rules and cannot be overridden
615
+ by user rules in \`.agents/rules/\` or \`~/.agents/rules/\` (a user rule
616
+ at the same relPath is dropped silently). Follow them strictly.
617
+
618
+ ${builtInRules.map(render).join("\n\n---\n\n")}
619
+ `
620
+ : "";
621
+
622
+ // Project/user rules section (the existing MANDATORY contract).
623
+ const projectSection = userBlocks.length
624
+ ? `
597
625
 
598
626
  ## ⚠️ MANDATORY: soly project rules
599
627
 
@@ -601,8 +629,11 @@ export function buildRulesSection(
601
629
 
602
630
  ${headerHint}
603
631
 
604
- ${blocks.join("\n\n---\n\n")}${skippedNote}
605
- `;
632
+ ${userBlocks.join("\n\n---\n\n")}${skippedNote}
633
+ `
634
+ : "";
635
+
636
+ const section = builtInSection + projectSection;
606
637
 
607
638
  return { section, loaded: applicable.map(ruleKey), interactive };
608
639
  }
package/index.ts CHANGED
@@ -375,6 +375,15 @@ export default function solyExtension(pi: ExtensionAPI) {
375
375
  refreshState: () => refreshState(),
376
376
  updateStatus: (ui) => updateStatus(ui),
377
377
  getConfig: getActiveConfig,
378
+ reloadConfig: () => {
379
+ if (!sessionCwd) return;
380
+ const cfgResult = loadConfig(sessionCwd);
381
+ activeConfig = cfgResult.config;
382
+ // Re-warn about config drift (mirrors the warnings at session_start).
383
+ for (const w of cfgResult.warnings) {
384
+ pi.sendUserMessage?.(`soly: ${w}`, { deliverAs: "followUp" });
385
+ }
386
+ },
378
387
  getIntentDocs: () => intentDocs,
379
388
  });
380
389
 
package/mcp/mcp-panel.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
2
+ import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../visual/panel-keys.ts";
3
3
  import { isToolExcluded } from "./types.ts";
4
4
  import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.ts";
5
5
  import { resourceNameToToolName } from "./resource-tools.ts";
@@ -1,5 +1,5 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
2
+ import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../visual/panel-keys.ts";
3
3
  import type { ImportKind } from "./types.ts";
4
4
  import type { ConfigWritePreview, McpDiscoverySummary } from "./config.ts";
5
5
  import type { McpOnboardingState } from "./onboarding-state.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -60,7 +60,8 @@
60
60
  "init.ts",
61
61
  "codemap.ts",
62
62
  "state.ts",
63
- "hotreload.ts"
63
+ "hotreload.ts",
64
+ "built-in-rules"
64
65
  ],
65
66
  "keywords": [
66
67
  "pi",
@@ -15,26 +15,42 @@
15
15
  import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
16
16
  import type { Component, TUI } from "@earendil-works/pi-tui";
17
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
- import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../mcp/panel-keys.ts";
18
+ import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
19
+ // (PanelKeybindings re-exported by re-export side below.)
19
20
 
20
21
  /** One row in the panel. `body` is shown in the preview pane when selected. */
21
22
  export type ListItem = { id: string; marker: string; label: string; meta?: string; body?: string };
22
23
 
24
+ /** A logical group within the panel (rendered as a labelled separator). */
25
+ export type ListGroup = {
26
+ id: string;
27
+ /** Single glyph + short title shown on the separator row (e.g. "📊 Status"). */
28
+ title: string;
29
+ icon: string;
30
+ items: ListItem[];
31
+ };
32
+
23
33
  /** A key-triggered action over the selected item (e.g. enable/disable/reload). */
24
34
  export type ListAction = { key: string; hint: string; run: (item: ListItem) => void };
25
35
 
36
+ // Re-export PanelKeybindings so consumers don't need to know about the
37
+ // internal ./panel-keys.ts module.
38
+ export type { PanelKeybindings, PanelKeys };
39
+
26
40
  export type ListPanelProps = {
27
41
  tui: TUI;
28
42
  theme: Theme;
29
43
  keybindings?: PanelKeybindings;
44
+ // (Above prop is the public re-export anchor.)
30
45
  done: () => void;
31
46
  title: string;
32
47
  /** Right-aligned header text (e.g. counts / token budget). */
33
48
  headerRight?: string;
34
- items: ListItem[];
49
+ /** Items, grouped. A single group is fine. Cursor skips group headers. */
50
+ groups: ListGroup[];
35
51
  actions?: ListAction[];
36
52
  /** Re-read items after an action mutates state. */
37
- refresh?: () => ListItem[];
53
+ refresh?: () => ListGroup[];
38
54
  /** Fired on Enter for the selected item; the panel then closes. When set,
39
55
  * the footer shows an "⏎ open" hint. */
40
56
  onSelect?: (item: ListItem) => void;
@@ -54,17 +70,20 @@ function fuzzyScore(query: string, text: string): number {
54
70
  return qi === q.length ? 1 : 0;
55
71
  }
56
72
 
73
+ /** Index into `flatRows` (mixed item + group header rows). */
74
+ type RowIndex = number;
75
+
57
76
  export class ListPanel implements Component {
58
77
  private readonly p: ListPanelProps;
59
78
  private readonly keys: PanelKeys;
60
- private items: ListItem[];
79
+ private groups: ListGroup[];
61
80
  private query = "";
62
81
  private searching = false;
63
- private selected = 0;
82
+ private selected: RowIndex = 0;
64
83
 
65
84
  constructor(props: ListPanelProps) {
66
85
  this.p = props;
67
- this.items = props.items;
86
+ this.groups = props.groups;
68
87
  this.keys = createPanelKeys(props.keybindings);
69
88
  }
70
89
 
@@ -72,22 +91,65 @@ export class ListPanel implements Component {
72
91
  /* stateless cache */
73
92
  }
74
93
 
75
- private filtered(): ListItem[] {
76
- if (!this.query) return this.items;
77
- return this.items
78
- .map((it) => ({ it, s: fuzzyScore(this.query, `${it.label} ${it.meta ?? ""}`) }))
79
- .filter((x) => x.s > 0)
80
- .sort((a, b) => b.s - a.s)
81
- .map((x) => x.it);
94
+ /** Flatten groups into rows (items + header separators). Headers carry a
95
+ * `group` reference for rendering; cursor skips them. */
96
+ private flatRows(): Array<{ kind: "item"; item: ListItem; group: ListGroup } | { kind: "header"; group: ListGroup }> {
97
+ const out: Array<{ kind: "item"; item: ListItem; group: ListGroup } | { kind: "header"; group: ListGroup }> = [];
98
+ for (const g of this.groups) {
99
+ // Skip groups that have no items — no point showing an empty separator.
100
+ if (g.items.length === 0) continue;
101
+ out.push({ kind: "header", group: g });
102
+ for (const item of g.items) out.push({ kind: "item", item, group: g });
103
+ }
104
+ return out;
82
105
  }
83
106
 
84
- private clamp(list: ListItem[]): void {
85
- if (this.selected >= list.length) this.selected = Math.max(0, list.length - 1);
107
+ /** Apply fuzzy filter; when searching, group headers are still shown but
108
+ * only groups that contain at least one matching item survive, and
109
+ * within those groups only matching items are returned. */
110
+ private filteredRows(): Array<{ kind: "item"; item: ListItem; group: ListGroup } | { kind: "header"; group: ListGroup }> {
111
+ if (!this.query) return this.flatRows();
112
+ const q = this.query.toLowerCase();
113
+ const matchedByGroup = new Map<ListGroup, Set<ListItem>>();
114
+ for (const g of this.groups) {
115
+ const matched = new Set<ListItem>();
116
+ for (const it of g.items) {
117
+ if (fuzzyScore(q, `${it.label} ${it.meta ?? ""}`) > 0) matched.add(it);
118
+ }
119
+ if (matched.size > 0) matchedByGroup.set(g, matched);
120
+ }
121
+ const out: Array<{ kind: "item"; item: ListItem; group: ListGroup } | { kind: "header"; group: ListGroup }> = [];
122
+ for (const g of this.groups) {
123
+ const matched = matchedByGroup.get(g);
124
+ if (!matched) continue;
125
+ out.push({ kind: "header", group: g });
126
+ for (const it of g.items) if (matched.has(it)) out.push({ kind: "item", item: it, group: g });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ private clamp(list: { kind: "item" | "header" }[]): void {
132
+ if (list.length === 0) {
133
+ this.selected = 0;
134
+ return;
135
+ }
136
+ // First clamp into valid range.
137
+ if (this.selected >= list.length) this.selected = list.length - 1;
86
138
  if (this.selected < 0) this.selected = 0;
139
+ // Then walk to the nearest non-header row — prefer forward (visual
140
+ // scan direction), fall back to backward.
141
+ if (list[this.selected]?.kind === "header") {
142
+ for (let i = this.selected; i < list.length; i++) {
143
+ if (list[i]?.kind === "item") { this.selected = i; return; }
144
+ }
145
+ for (let i = this.selected; i >= 0; i--) {
146
+ if (list[i]?.kind === "item") { this.selected = i; return; }
147
+ }
148
+ }
87
149
  }
88
150
 
89
151
  handleInput(data: string): void {
90
- const list = this.filtered();
152
+ const list = this.filteredRows();
91
153
  this.clamp(list);
92
154
 
93
155
  if (this.searching) {
@@ -109,23 +171,28 @@ export class ListPanel implements Component {
109
171
  }
110
172
  if (this.keys.selectUp(data)) {
111
173
  this.selected = Math.max(0, this.selected - 1);
174
+ // Skip group headers when moving up.
175
+ while (this.selected > 0 && list[this.selected]?.kind === "header") this.selected--;
112
176
  } else if (this.keys.selectDown(data)) {
113
177
  this.selected = Math.min(list.length - 1, this.selected + 1);
178
+ // Skip group headers when moving down.
179
+ while (this.selected < list.length - 1 && list[this.selected]?.kind === "header") this.selected++;
114
180
  } else if (data === "/") {
115
181
  this.searching = true;
116
182
  } else if (matchesKey(data, "return")) {
117
183
  const current = list[this.selected];
118
- if (this.p.onSelect && current) {
119
- this.p.onSelect(current);
184
+ // Headers are not selectable — ignore Enter on them.
185
+ if (current && current.kind === "item" && this.p.onSelect) {
186
+ this.p.onSelect(current.item);
120
187
  this.p.done();
121
188
  return;
122
189
  }
123
190
  } else {
124
191
  const action = this.p.actions?.find((a) => a.key === data);
125
192
  const current = list[this.selected];
126
- if (action && current) {
127
- action.run(current);
128
- if (this.p.refresh) this.items = this.p.refresh();
193
+ if (action && current && current.kind === "item") {
194
+ action.run(current.item);
195
+ if (this.p.refresh) this.groups = this.p.refresh();
129
196
  }
130
197
  }
131
198
  this.p.tui.requestRender();
@@ -136,7 +203,7 @@ export class ListPanel implements Component {
136
203
  const dim = (s: string) => theme.fg("dim", s);
137
204
  const muted = (s: string) => theme.fg("muted", s);
138
205
  const inner = Math.max(20, width - 4);
139
- const list = this.filtered();
206
+ const list = this.filteredRows();
140
207
  this.clamp(list);
141
208
 
142
209
  const out: string[] = [];
@@ -144,19 +211,39 @@ export class ListPanel implements Component {
144
211
  out.push(this.searchLine(inner, dim, muted));
145
212
  out.push(this.frame("", inner, dim));
146
213
 
147
- // Windowed list around the selection.
214
+ // Windowed view around the selection.
215
+ const selectedRow = list[this.selected];
148
216
  const start = Math.max(0, Math.min(this.selected - Math.floor(MAX_ROWS / 2), Math.max(0, list.length - MAX_ROWS)));
149
217
  const window = list.slice(start, start + MAX_ROWS);
150
218
  if (window.length === 0) out.push(this.frame(dim(" (no matches)"), inner, dim));
151
- for (let i = 0; i < window.length; i++) out.push(this.rowLine(window[i] as ListItem, start + i === this.selected, inner, dim, muted));
219
+ for (let i = 0; i < window.length; i++) {
220
+ const r = window[i];
221
+ if (!r) continue;
222
+ const absoluteIdx = start + i;
223
+ if (r.kind === "header") {
224
+ out.push(this.groupHeaderLine(r.group, inner, dim, muted));
225
+ } else {
226
+ const sel = absoluteIdx === this.selected && selectedRow?.kind === "item";
227
+ out.push(this.rowLine(r.item, sel, inner, dim, muted));
228
+ }
229
+ }
152
230
 
231
+ // Preview shows the selected item only — headers have no preview.
153
232
  out.push(this.ruleLine("preview", inner, dim));
154
- for (const line of this.previewLines(list[this.selected], inner)) out.push(this.frame(" " + muted(line), inner, dim));
233
+ const previewItem = selectedRow?.kind === "item" ? selectedRow.item : undefined;
234
+ for (const line of this.previewLines(previewItem, inner)) out.push(this.frame(" " + muted(line), inner, dim));
155
235
 
156
236
  out.push(this.footerLine(inner, dim));
157
237
  return out;
158
238
  }
159
239
 
240
+ /** Full-width separator row: `📊 Status ─────────`. */
241
+ private groupHeaderLine(g: ListGroup, inner: number, dim: (s: string) => string, muted: (s: string) => string): string {
242
+ const head = ` ${dim(g.icon)} ${muted(g.title)} `;
243
+ const dashes = Math.max(0, inner - visibleWidth(head));
244
+ return dim(`│${head}${"─".repeat(dashes)} │`);
245
+ }
246
+
160
247
  private frame(content: string, inner: number, dim: (s: string) => string): string {
161
248
  const pad = Math.max(0, inner - visibleWidth(content));
162
249
  return dim("│ ") + content + " ".repeat(pad) + dim(" │");
@@ -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