@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.1.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
+ }
@@ -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
- 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")
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 === "\u001b" || data === "q") {
185
+ else if (matchesKey(data, "escape") || matchesKey(data, "q")) {
177
186
  done(null);
178
187
  return;
179
188
  } else return;
@@ -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
- });