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.
- package/built-in-rules/temp-files.md +173 -0
- package/commands.ts +120 -37
- package/core.ts +39 -8
- package/index.ts +9 -0
- package/mcp/mcp-panel.ts +1 -1
- package/mcp/mcp-setup-panel.ts +1 -1
- package/package.json +3 -2
- package/visual/list-panel.ts +112 -25
- package/workflows/settings-ui.ts +466 -0
- /package/{mcp → visual}/panel-keys.ts +0 -0
|
@@ -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
|
|
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: () =>
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
463
|
-
id:
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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:
|
|
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
|
-
|
|
995
|
-
id:
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
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 =
|
|
582
|
-
const otherRules =
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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 "
|
|
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";
|
package/mcp/mcp-setup-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 "
|
|
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.
|
|
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",
|
package/visual/list-panel.ts
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
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?: () =>
|
|
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
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
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
|
-
|
|
85
|
-
|
|
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.
|
|
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
|
-
|
|
119
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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++)
|
|
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
|
-
|
|
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
|