@xynogen/pix-runtime 0.1.0 → 0.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/package.json +5 -2
- package/src/collapse.ts +31 -0
- package/src/pix-command.ts +14 -5
- package/src/runtime.test.ts +0 -244
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
|
+
"!src/**/*.test.ts",
|
|
12
13
|
"README.md",
|
|
13
14
|
"DESIGN.md",
|
|
14
15
|
"LICENSE"
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
"./config": "./src/runtime.ts",
|
|
24
25
|
"./sections": "./src/sections/index.ts",
|
|
25
26
|
"./collapse": "./src/collapse.ts",
|
|
27
|
+
"./once": "./src/once.ts",
|
|
26
28
|
"./testing": "./src/testing.ts"
|
|
27
29
|
},
|
|
28
30
|
"keywords": [
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
"access": "public"
|
|
48
50
|
},
|
|
49
51
|
"peerDependencies": {
|
|
50
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
52
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
53
|
+
"@earendil-works/pi-tui": "*"
|
|
51
54
|
}
|
|
52
55
|
}
|
package/src/collapse.ts
CHANGED
|
@@ -18,3 +18,34 @@ export function shouldCollapse(toolName: string): boolean {
|
|
|
18
18
|
export function collapseDelayMs(): number {
|
|
19
19
|
return pixRuntime().get(collapseSection).delaySec * 1000;
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/** Per-card render state bag for the collapse timer. */
|
|
23
|
+
export interface CollapseState {
|
|
24
|
+
collapsed?: boolean;
|
|
25
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run the collapse timer for a tool card. Call this inside `renderResult`.
|
|
30
|
+
*
|
|
31
|
+
* @param toolName — the tool name (e.g. "bash", "read") for per-tool config
|
|
32
|
+
* @param state — the render context's `state` bag (mutable, per-card)
|
|
33
|
+
* @param invalidate — `context.invalidate()` to trigger re-render
|
|
34
|
+
* @param expanded — whether the host currently requests the detailed view
|
|
35
|
+
* @returns `true` if the card is currently collapsed and not expanded
|
|
36
|
+
*/
|
|
37
|
+
export function tickCollapse(
|
|
38
|
+
toolName: string,
|
|
39
|
+
state: CollapseState,
|
|
40
|
+
invalidate: () => void,
|
|
41
|
+
expanded = false,
|
|
42
|
+
): boolean {
|
|
43
|
+
if (!shouldCollapse(toolName)) return false;
|
|
44
|
+
if (!state.timer && !state.collapsed) {
|
|
45
|
+
state.timer = setTimeout(() => {
|
|
46
|
+
state.collapsed = true;
|
|
47
|
+
invalidate();
|
|
48
|
+
}, collapseDelayMs());
|
|
49
|
+
}
|
|
50
|
+
return state.collapsed === true && !expanded;
|
|
51
|
+
}
|
package/src/pix-command.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
10
11
|
import type { PixRuntime } from "./runtime.ts";
|
|
11
12
|
import type { DeepPartial, SectionHandle } from "./schema.ts";
|
|
12
13
|
import { collapseSection } from "./sections/collapse.ts";
|
|
@@ -168,12 +169,20 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
168
169
|
},
|
|
169
170
|
invalidate: () => {},
|
|
170
171
|
handleInput: (data: string) => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
// matchesKey handles both legacy bytes and Kitty CSI-u encodings
|
|
173
|
+
// for letters and special keys alike — raw string compares like
|
|
174
|
+
// `data === "k"` silently fail under the Kitty keyboard protocol.
|
|
175
|
+
if (matchesKey(data, "k") || matchesKey(data, "up")) move(-1);
|
|
176
|
+
else if (matchesKey(data, "j") || matchesKey(data, "down")) move(1);
|
|
177
|
+
else if (matchesKey(data, "h") || matchesKey(data, "left")) cycle(-1);
|
|
178
|
+
else if (
|
|
179
|
+
matchesKey(data, "l") ||
|
|
180
|
+
matchesKey(data, "right") ||
|
|
181
|
+
matchesKey(data, "space") ||
|
|
182
|
+
matchesKey(data, "enter")
|
|
183
|
+
)
|
|
175
184
|
cycle(1);
|
|
176
|
-
else if (data
|
|
185
|
+
else if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
177
186
|
done(null);
|
|
178
187
|
return;
|
|
179
188
|
} else return;
|
package/src/runtime.test.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
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
|
-
});
|