@xynogen/pix-runtime 0.1.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,188 @@
1
+ /**
2
+ * pix-command.ts — the `/pix` command: unified settings overlay for pix.json.
3
+ *
4
+ * Runtime owns this because it edits the shared document. Rows are declared
5
+ * here against typed sections; persistence goes through `runtime.update()`.
6
+ * Headless hosts get a notify summary instead of the overlay.
7
+ */
8
+
9
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
+ import type { PixRuntime } from "./runtime.ts";
11
+ import type { DeepPartial, SectionHandle } from "./schema.ts";
12
+ import { collapseSection } from "./sections/collapse.ts";
13
+ import { gateSection } from "./sections/gate.ts";
14
+ import { optimizerSection } from "./sections/optimizer.ts";
15
+ import { prettySection } from "./sections/pretty.ts";
16
+
17
+ interface SettingRow<T> {
18
+ section: string;
19
+ label: string;
20
+ handle: SectionHandle<string, T>;
21
+ values: readonly string[];
22
+ read: (v: T) => string;
23
+ patch: (value: string) => DeepPartial<T>;
24
+ }
25
+
26
+ /** Capture a row's section type, then erase it so rows can share one list. */
27
+ function row<T>(r: SettingRow<T>): SettingRow<unknown> {
28
+ return r as SettingRow<unknown>;
29
+ }
30
+
31
+ const SETTINGS: SettingRow<unknown>[] = [
32
+ row({
33
+ section: "Pretty",
34
+ label: "icons",
35
+ handle: prettySection,
36
+ values: ["nerd", "unicode", "ascii"],
37
+ read: (v) => v.icons,
38
+ patch: (value) => ({ icons: value as "nerd" | "unicode" | "ascii" }),
39
+ }),
40
+ row({
41
+ section: "Pretty",
42
+ label: "ls style",
43
+ handle: prettySection,
44
+ values: ["grid", "tree"],
45
+ read: (v) => v.lsStyle,
46
+ patch: (value) => ({ lsStyle: value as "grid" | "tree" }),
47
+ }),
48
+ row({
49
+ section: "Collapse",
50
+ label: "enabled",
51
+ handle: collapseSection,
52
+ values: ["true", "false"],
53
+ read: (v) => String(v.enabled),
54
+ patch: (value) => ({ enabled: value === "true" }),
55
+ }),
56
+ row({
57
+ section: "Collapse",
58
+ label: "delay (sec)",
59
+ handle: collapseSection,
60
+ values: ["5", "10", "15", "20", "30", "60"],
61
+ read: (v) => String(v.delaySec),
62
+ patch: (value) => ({ delaySec: Number(value) }),
63
+ }),
64
+ row({
65
+ section: "Optimizer",
66
+ label: "caveman",
67
+ handle: optimizerSection,
68
+ values: ["off", "lite", "full", "ultra", "micro"],
69
+ read: (v) => v.caveman,
70
+ patch: (value) => ({ caveman: value as "off" | "lite" | "full" | "ultra" | "micro" }),
71
+ }),
72
+ row({
73
+ section: "Optimizer",
74
+ label: "rtk",
75
+ handle: optimizerSection,
76
+ values: ["on", "off"],
77
+ read: (v) => v.rtk,
78
+ patch: (value) => ({ rtk: value as "on" | "off" }),
79
+ }),
80
+ row({
81
+ section: "Gate",
82
+ label: "disable defaults",
83
+ handle: gateSection,
84
+ values: ["false", "true"],
85
+ read: (v) => String(v.disableDefaults),
86
+ patch: (value) => ({ disableDefaults: value === "true" }),
87
+ }),
88
+ ];
89
+
90
+ function buildSummary(runtime: PixRuntime): string {
91
+ const lines = [`pix settings (${runtime.path})`, ""];
92
+ let lastSection = "";
93
+ for (const row of SETTINGS) {
94
+ if (row.section !== lastSection) {
95
+ if (lastSection) lines.push("");
96
+ lines.push(`[${row.section}]`);
97
+ lastSection = row.section;
98
+ }
99
+ const value = row.read(runtime.get(row.handle));
100
+ const isDefault = value === row.values[0];
101
+ lines.push(` ${row.label}: ${value}${isDefault ? "" : " *"}`);
102
+ }
103
+ return lines.join("\n");
104
+ }
105
+
106
+ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void {
107
+ pi.registerCommand("pix", {
108
+ description: "pix: open shared settings (edit pix.json)",
109
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
110
+ const ui = ctx.ui as unknown as {
111
+ custom?: (f: unknown, opts?: unknown) => Promise<unknown>;
112
+ notify(m: string, t?: "info" | "warning" | "error"): void;
113
+ };
114
+
115
+ if (typeof ui.custom !== "function") {
116
+ ui.notify(buildSummary(runtime), "info");
117
+ return;
118
+ }
119
+
120
+ const boxW = 52;
121
+ await ui.custom(
122
+ (
123
+ tui: { requestRender(): void },
124
+ theme: {
125
+ fg(c: string, t: string): string;
126
+ bold(t: string): string;
127
+ },
128
+ _kb: unknown,
129
+ done: (v: null) => void,
130
+ ) => {
131
+ let selected = 0;
132
+
133
+ const cycle = (direction: -1 | 1) => {
134
+ const row = SETTINGS[selected];
135
+ if (!row) return;
136
+ const cur = row.values.indexOf(row.read(runtime.get(row.handle)));
137
+ const next = (cur + direction + row.values.length) % row.values.length;
138
+ const val = row.values[next];
139
+ if (val === undefined) return;
140
+ void runtime.update(row.handle, row.patch(val), { origin: "command", source: "pix" });
141
+ };
142
+ const move = (direction: -1 | 1) => {
143
+ selected = (selected + direction + SETTINGS.length) % SETTINGS.length;
144
+ };
145
+
146
+ return {
147
+ render: () => {
148
+ const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
149
+ const lines: string[] = [theme.fg("accent", theme.bold(" pix settings")), ""];
150
+ let lastSection = "";
151
+ for (let i = 0; i < SETTINGS.length; i++) {
152
+ const row = SETTINGS[i]!;
153
+ if (row.section !== lastSection) {
154
+ if (lastSection) lines.push("");
155
+ lines.push(theme.fg("dim", ` ${row.section}`));
156
+ lastSection = row.section;
157
+ }
158
+ const sel = i === selected;
159
+ const cursor = sel ? theme.fg("accent", "→") : " ";
160
+ const label = theme.fg(sel ? "accent" : "text", row.label.padEnd(labelW));
161
+ const value = row.read(runtime.get(row.handle));
162
+ const isDefault = value === row.values[0];
163
+ lines.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
164
+ }
165
+ lines.push("");
166
+ lines.push(theme.fg("dim", "←→ change · ↑↓ move · esc close"));
167
+ return lines;
168
+ },
169
+ invalidate: () => {},
170
+ handleInput: (data: string) => {
171
+ if (data === "k" || data === "\u001b[A") move(-1);
172
+ else if (data === "j" || data === "\u001b[B") move(1);
173
+ else if (data === "h" || data === "\u001b[D") cycle(-1);
174
+ else if (data === "l" || data === "\u001b[C" || data === " " || data === "\r")
175
+ cycle(1);
176
+ else if (data === "\u001b" || data === "q") {
177
+ done(null);
178
+ return;
179
+ } else return;
180
+ tui.requestRender();
181
+ },
182
+ };
183
+ },
184
+ { overlay: true, overlayOptions: { anchor: "center", width: boxW, maxHeight: "80%" } },
185
+ );
186
+ },
187
+ });
188
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * registry.ts — internal built-in section registry.
3
+ *
4
+ * V1 keeps registration internal: there is no public `registerSection()` until
5
+ * a real optional-package use case proves its lifecycle. Duplicate keys fail at
6
+ * construction. Unknown raw sections are preserved elsewhere (persistence), so
7
+ * this closed set does not block forward compatibility.
8
+ */
9
+
10
+ import type { ConfigSection, SectionHandle } from "./schema.ts";
11
+ import { builtinSections } from "./sections/index.ts";
12
+
13
+ export class SectionRegistry {
14
+ private readonly byKey = new Map<string, ConfigSection<unknown>>();
15
+
16
+ constructor(handles: readonly SectionHandle<string, unknown>[] = builtinSections) {
17
+ for (const handle of handles) this.add(handle);
18
+ }
19
+
20
+ private add(handle: SectionHandle<string, unknown>): void {
21
+ if (this.byKey.has(handle.key)) {
22
+ throw new Error(`pix-runtime: duplicate section key "${handle.key}"`);
23
+ }
24
+ this.byKey.set(handle.key, handle.__section);
25
+ }
26
+
27
+ get(key: string): ConfigSection<unknown> | undefined {
28
+ return this.byKey.get(key);
29
+ }
30
+
31
+ all(): ConfigSection<unknown>[] {
32
+ return [...this.byKey.values()];
33
+ }
34
+
35
+ keys(): string[] {
36
+ return [...this.byKey.keys()];
37
+ }
38
+ }
@@ -0,0 +1,244 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { migrate } from "./migrations.ts";
5
+ import type { StorageAdapter } from "./persistence.ts";
6
+ import { createRuntime } from "./runtime.ts";
7
+ import { stripDefaults } from "./schema.ts";
8
+ import { collapseSection } from "./sections/collapse.ts";
9
+ import { gateSection } from "./sections/gate.ts";
10
+ import { optimizerSection } from "./sections/optimizer.ts";
11
+ import { prettySection } from "./sections/pretty.ts";
12
+ import { createIsolatedRuntime, type IsolatedRuntime } from "./testing.ts";
13
+
14
+ let iso: IsolatedRuntime | null = null;
15
+ function fresh(): IsolatedRuntime {
16
+ iso = createIsolatedRuntime();
17
+ return iso;
18
+ }
19
+ afterEach(() => {
20
+ iso?.cleanup();
21
+ iso = null;
22
+ });
23
+
24
+ function readConfig(dir: string): Record<string, unknown> {
25
+ const p = join(dir, "pix.json");
26
+ return existsSync(p) ? (JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>) : {};
27
+ }
28
+
29
+ describe("schema and normalization", () => {
30
+ it("resolves defaults for a missing file", () => {
31
+ const { runtime } = fresh();
32
+ expect(runtime.get(prettySection).icons).toBe("nerd");
33
+ expect(runtime.get(collapseSection).enabled).toBe(true);
34
+ expect(runtime.get(optimizerSection).rtk).toBe("on");
35
+ });
36
+
37
+ it("falls back invalid fields individually and diagnoses bad gate regex", () => {
38
+ const { runtime, agentDir } = fresh();
39
+ writeFileSync(
40
+ join(agentDir, "pix.json"),
41
+ JSON.stringify({
42
+ pretty: { icons: "bogus", maxRenderLines: -5 },
43
+ gate: { extraRules: [{ pattern: "(" }, { pattern: "ok" }] },
44
+ }),
45
+ );
46
+ runtime.reload();
47
+ expect(runtime.get(prettySection).icons).toBe("nerd");
48
+ expect(runtime.get(prettySection).maxRenderLines).toBe(150);
49
+ expect(runtime.get(gateSection).extraRules).toHaveLength(1);
50
+ expect(runtime.diagnostics().some((d) => d.code === "INVALID_VALUE")).toBe(true);
51
+ });
52
+
53
+ it("returns deeply frozen snapshots", () => {
54
+ const { runtime } = fresh();
55
+ const pretty = runtime.get(prettySection) as { icons: string };
56
+ expect(() => {
57
+ pretty.icons = "ascii";
58
+ }).toThrow();
59
+ });
60
+
61
+ it("strips defaults recursively", () => {
62
+ const section: Record<string, unknown> = {
63
+ icons: "nerd",
64
+ diff: { splitMinWidth: 150, splitMinCodeWidth: 99 },
65
+ };
66
+ const remaining = stripDefaults(section, prettySection.defaults as Record<string, unknown>);
67
+ expect(remaining).toBeGreaterThan(0);
68
+ expect(section).toEqual({ diff: { splitMinCodeWidth: 99 } });
69
+ });
70
+ });
71
+
72
+ describe("persistence", () => {
73
+ it("writes only non-default values and preserves unknown fields", async () => {
74
+ const { runtime, agentDir } = fresh();
75
+ writeFileSync(join(agentDir, "pix.json"), JSON.stringify({ future: { x: 1 } }));
76
+ await runtime.init();
77
+ await runtime.update(prettySection, { icons: "ascii" });
78
+ const doc = readConfig(agentDir);
79
+ expect(doc.$version).toBe(1);
80
+ expect(doc.future).toEqual({ x: 1 });
81
+ expect(doc.pretty).toEqual({ icons: "ascii" });
82
+ expect(doc.collapse).toBeUndefined(); // all defaults → omitted
83
+ });
84
+
85
+ it("leaves no temp file after a successful write", async () => {
86
+ const { runtime, agentDir } = fresh();
87
+ await runtime.update(collapseSection, { delaySec: 30 });
88
+ const p = join(agentDir, "pix.json");
89
+ const dir = readFileSync(p, "utf-8");
90
+ expect(dir).toContain("30");
91
+ // no stray tmp files
92
+ const { readdirSync } = await import("node:fs");
93
+ expect(readdirSync(agentDir).some((f) => f.includes(".tmp-"))).toBe(false);
94
+ });
95
+
96
+ it("no-op updates neither write nor emit", async () => {
97
+ const { runtime } = fresh();
98
+ const change = await runtime.update(prettySection, { icons: "nerd" });
99
+ expect(change).toBeUndefined();
100
+ });
101
+
102
+ it("functional updater sees the latest on-disk value", async () => {
103
+ const { runtime, agentDir } = fresh();
104
+ writeFileSync(join(agentDir, "pix.json"), JSON.stringify({ collapse: { delaySec: 20 } }));
105
+ const change = await runtime.update(collapseSection, (c) => ({
106
+ ...c,
107
+ delaySec: c.delaySec + 5,
108
+ }));
109
+ expect(change?.current.get(collapseSection).delaySec).toBe(25);
110
+ });
111
+
112
+ it("preserves the snapshot when a write fails", async () => {
113
+ const storage: StorageAdapter = {
114
+ path: "/virtual/pix.json",
115
+ readRaw: () => undefined,
116
+ ensureDir: () => {},
117
+ writeAtomic: () => {
118
+ throw new Error("disk full");
119
+ },
120
+ };
121
+ const runtime = createRuntime({ agentDir: "/virtual", storage });
122
+ const change = await runtime.update(prettySection, { icons: "ascii" });
123
+ expect(change).toBeUndefined();
124
+ expect(runtime.get(prettySection).icons).toBe("nerd");
125
+ expect(runtime.diagnostics().some((d) => d.code === "WRITE_FAILED")).toBe(true);
126
+ });
127
+
128
+ it("serializes concurrent updates without lost fields", async () => {
129
+ const { runtime, agentDir } = fresh();
130
+ await Promise.all([
131
+ runtime.update(prettySection, { icons: "ascii" }),
132
+ runtime.update(collapseSection, { delaySec: 30 }),
133
+ runtime.update(optimizerSection, { caveman: "lite" }),
134
+ ]);
135
+ const doc = readConfig(agentDir);
136
+ expect((doc.pretty as { icons: string }).icons).toBe("ascii");
137
+ expect((doc.collapse as { delaySec: number }).delaySec).toBe(30);
138
+ expect((doc.optimizer as { caveman: string }).caveman).toBe("lite");
139
+ });
140
+ });
141
+
142
+ describe("migration", () => {
143
+ it("adds $version and strips legacy color keys", () => {
144
+ const { document, changed } = migrate(
145
+ { pretty: { icons: "ascii", theme: "x", diff: { bgAdd: "#000", splitMinWidth: 170 } } },
146
+ { diagnostic: () => {} },
147
+ );
148
+ expect(changed).toBe(true);
149
+ expect(document.$version).toBe(1);
150
+ const pretty = document.pretty as Record<string, unknown>;
151
+ expect(pretty.theme).toBeUndefined();
152
+ expect((pretty.diff as Record<string, unknown>).bgAdd).toBeUndefined();
153
+ expect((pretty.diff as Record<string, unknown>).splitMinWidth).toBe(170);
154
+ });
155
+
156
+ it("imports optimizer.json once and archives it", async () => {
157
+ const { runtime, agentDir } = fresh();
158
+ writeFileSync(
159
+ join(agentDir, "optimizer.json"),
160
+ JSON.stringify({ caveman: "ultra", rtk: "off" }),
161
+ );
162
+ await runtime.init();
163
+ expect(runtime.get(optimizerSection).caveman).toBe("ultra");
164
+ expect(runtime.get(optimizerSection).rtk).toBe("off");
165
+ expect(existsSync(join(agentDir, "optimizer.json"))).toBe(false);
166
+ expect(existsSync(join(agentDir, "optimizer.json.migrated-v1"))).toBe(true);
167
+ });
168
+
169
+ it("canonical optimizer value wins a sidecar conflict", async () => {
170
+ const { runtime, agentDir } = fresh();
171
+ writeFileSync(join(agentDir, "pix.json"), JSON.stringify({ optimizer: { caveman: "lite" } }));
172
+ writeFileSync(join(agentDir, "optimizer.json"), JSON.stringify({ caveman: "ultra" }));
173
+ await runtime.init();
174
+ expect(runtime.get(optimizerSection).caveman).toBe("lite");
175
+ });
176
+
177
+ it("enters read-only mode for a future version", async () => {
178
+ const { runtime, agentDir } = fresh();
179
+ writeFileSync(join(agentDir, "pix.json"), JSON.stringify({ $version: 999 }));
180
+ await runtime.init();
181
+ const change = await runtime.update(prettySection, { icons: "ascii" });
182
+ expect(change).toBeUndefined();
183
+ expect(runtime.diagnostics().some((d) => d.code === "UNSUPPORTED_CONFIG_VERSION")).toBe(true);
184
+ });
185
+ });
186
+
187
+ describe("events and lifecycle", () => {
188
+ it("emits changed paths and current snapshot", async () => {
189
+ const { runtime } = fresh();
190
+ const seen: string[][] = [];
191
+ runtime.subscribe((c) => seen.push([...c.changed]));
192
+ await runtime.update(prettySection, { icons: "ascii" });
193
+ expect(seen).toEqual([["pretty.icons"]]);
194
+ });
195
+
196
+ it("respects path filters", async () => {
197
+ const { runtime } = fresh();
198
+ let hits = 0;
199
+ runtime.subscribe(() => hits++, { paths: ["optimizer.*"] });
200
+ await runtime.update(prettySection, { icons: "ascii" });
201
+ expect(hits).toBe(0);
202
+ await runtime.update(optimizerSection, { caveman: "lite" });
203
+ expect(hits).toBe(1);
204
+ });
205
+
206
+ it("delivers an immediate snapshot to new subscribers", () => {
207
+ const { runtime } = fresh();
208
+ let got: string | undefined;
209
+ runtime.subscribe(
210
+ (c) => {
211
+ got = c.current.get(prettySection).icons;
212
+ },
213
+ { immediate: true },
214
+ );
215
+ expect(got).toBe("nerd");
216
+ });
217
+
218
+ it("isolates listener errors", async () => {
219
+ const { runtime } = fresh();
220
+ runtime.subscribe(() => {
221
+ throw new Error("boom");
222
+ });
223
+ let ok = false;
224
+ runtime.subscribe(() => {
225
+ ok = true;
226
+ });
227
+ await runtime.update(prettySection, { icons: "ascii" });
228
+ expect(ok).toBe(true);
229
+ expect(runtime.diagnostics().some((d) => d.code === "LISTENER_FAILED")).toBe(true);
230
+ });
231
+
232
+ it("init is single-flight and idempotent", async () => {
233
+ const { runtime } = fresh();
234
+ const [a, b] = await Promise.all([runtime.init(), runtime.init()]);
235
+ expect(a).toBe(b);
236
+ });
237
+
238
+ it("reset restores defaults", async () => {
239
+ const { runtime } = fresh();
240
+ await runtime.update(prettySection, { icons: "ascii" });
241
+ await runtime.reset(prettySection);
242
+ expect(runtime.get(prettySection).icons).toBe("nerd");
243
+ });
244
+ });