@xynogen/pix-runtime 0.8.1 → 0.8.4

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.8.1",
3
+ "version": "0.8.4",
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,7 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "src",
12
- "!src/**/*.test.ts",
12
+ "!src/**/*.test.*",
13
13
  "README.md",
14
14
  "DESIGN.md",
15
15
  "LICENSE"
@@ -21,9 +21,11 @@
21
21
  },
22
22
  "exports": {
23
23
  ".": "./src/index.ts",
24
+ "./atomic-write": "./src/atomic-write.ts",
24
25
  "./config": "./src/runtime.ts",
25
26
  "./sections": "./src/sections/index.ts",
26
27
  "./collapse": "./src/collapse.ts",
28
+ "./icon-catalog": "./src/icon-catalog.ts",
27
29
  "./io": "./src/io.ts",
28
30
  "./once": "./src/once.ts",
29
31
  "./testing": "./src/testing.ts"
@@ -0,0 +1,35 @@
1
+ /**
2
+ * atomic-write.ts — write a file so readers never see a partial result.
3
+ *
4
+ * Writes to a unique sibling temp file, then renames over the target (rename is
5
+ * atomic on the same filesystem). A crash mid-write leaves the old file intact.
6
+ * Parent dirs are created.
7
+ *
8
+ * NOT a replacement for pix-runtime's ConfigStorage.writeAtomic, which adds a
9
+ * cross-process lock. Use this for plain single-writer files (per-tool JSON
10
+ * state, caches).
11
+ */
12
+
13
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+
16
+ /** Atomically write `contents` to `target`. Mode defaults to 0o600. */
17
+ export function writeFileAtomicSync(
18
+ target: string,
19
+ contents: string | Uint8Array,
20
+ mode = 0o600,
21
+ ): void {
22
+ mkdirSync(dirname(target), { recursive: true });
23
+ const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
24
+ try {
25
+ writeFileSync(tmp, contents, { mode });
26
+ renameSync(tmp, target);
27
+ } catch (err) {
28
+ try {
29
+ rmSync(tmp, { force: true });
30
+ } catch {
31
+ /* best-effort cleanup */
32
+ }
33
+ throw err;
34
+ }
35
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * icon-catalog.ts — semantic icon catalog, treated like an l10n message table.
3
+ *
4
+ * Packages must NOT hardcode glyph codepoints. Instead they ask for an icon by
5
+ * its semantic role — `icon("cwd")`, `icon("paste.image")` — and this module
6
+ * resolves it against the single active icon mode, exactly like `t("key")`
7
+ * resolves a translation against the active locale.
8
+ *
9
+ * catalog: key -> { nerd, unicode, ascii } (the "messages")
10
+ * mode: "nerd" | "unicode" | "ascii" (the "locale")
11
+ * icon(k): catalog[k][mode] (the "t(key)")
12
+ *
13
+ * One global mode governs the whole stack. It is switched via the `/pix`
14
+ * settings command (in pix-data), persisted to `~/.pi/agent/pix.json`
15
+ * (`pretty.icons`), and seeded from the PRETTY_ICONS env var on first load.
16
+ *
17
+ * Why a catalog instead of per-package toggles: reskinning or fixing a
18
+ * missing-glyph ("tofu") problem becomes a one-file edit here, and there is
19
+ * exactly ONE knob (the mode) rather than one env var per package.
20
+ */
21
+
22
+ /** Presentation modes, in /pix settings cycle order. nerd = Nerd Font PUA glyphs. */
23
+ export type IconMode = "nerd" | "unicode" | "ascii";
24
+
25
+ /** All modes in cycle order. */
26
+ export const ICON_MODES: readonly IconMode[] = ["nerd", "unicode", "ascii"];
27
+
28
+ /** Force text (non-emoji) presentation for symbols that default to emoji. */
29
+ const VS = "\uFE0E";
30
+
31
+ /**
32
+ * The catalog. Each semantic key maps to one glyph per mode.
33
+ * - nerd: Nerd Font Private Use Area codepoint (needs a patched font).
34
+ * - unicode: standard BMP glyph that ships with virtually every monospace
35
+ * font (no Nerd Font required); +VS to force text presentation.
36
+ * - ascii: pure ASCII, renders on literally any terminal.
37
+ *
38
+ * Keys are SEMANTIC ROLES, never glyph names — consumers reference meaning.
39
+ */
40
+ const CATALOG = {
41
+ // ── footer / status segments ──────────────────────────────────────────
42
+ model: { nerd: "\u{F06A9}", unicode: `\u25C8${VS}`, ascii: "M" },
43
+ lsp: { nerd: "\u{F0626}", unicode: `\u25C9${VS}`, ascii: "LSP" },
44
+ mcp: { nerd: "\u{F048D}", unicode: `\u25D0${VS}`, ascii: "MCP" },
45
+ cwd: { nerd: "\u{F024B}", unicode: `\u2302${VS}`, ascii: "~" },
46
+ folder: { nerd: "\u{F024B}", unicode: `\u2302${VS}`, ascii: "/" },
47
+ afk: { nerd: "\u{F0310}", unicode: `\u2328${VS}`, ascii: "kbd" },
48
+
49
+ // ── footer indicators (git status, score) ─────────────────────────────
50
+ "git.unstaged": { nerd: "\u2717", unicode: "\u2717", ascii: "x" },
51
+ "git.ahead": { nerd: "\u21E1", unicode: "\u21E1", ascii: "^" },
52
+ "git.behind": { nerd: "\u21E3", unicode: "\u21E3", ascii: "v" },
53
+ "net.in": { nerd: "\u21E1", unicode: "\u21E1", ascii: "in" },
54
+ "net.out": { nerd: "\u21E3", unicode: "\u21E3", ascii: "out" },
55
+ score: { nerd: "\u26A1", unicode: "\u26A1", ascii: "S" },
56
+
57
+ // ── misc ──────────────────────────────────────────────────────────────
58
+ ok: { nerd: "\u2713", unicode: "\u2713", ascii: "ok" },
59
+ warn: { nerd: "\u26A0", unicode: "\u26A0", ascii: "!" },
60
+ error: { nerd: "\u2717", unicode: "\u2717", ascii: "x" },
61
+
62
+ // ── permission / security modal titles ────────────────────────────────
63
+ // Semantic keys for the danger prompts (🔐 root, 🔑 secret). nerd = Nerd
64
+ // Font glyph, unicode = a widely-shipped BMP symbol forced to text
65
+ // presentation, ascii = tofu-free token.
66
+ lock: { nerd: "\u{F0341}", unicode: `\u{1F512}${VS}`, ascii: "[!]" },
67
+ secret: { nerd: "\u{F0306}", unicode: `\u{1F511}${VS}`, ascii: "[key]" },
68
+ settings: { nerd: "\u{F0493}", unicode: `\u2699${VS}`, ascii: "[*]" },
69
+ update: { nerd: "\u{F01DA}", unicode: `\u2193${VS}`, ascii: "[v]" },
70
+
71
+ // ── shared status glyphs (checklists, panels, markers) ────────────────
72
+ // nerd/unicode keep the historical literal so mixed-glyph rows stay
73
+ // aligned; ascii mode swaps in tofu-free tokens. `⚡` (energetic
74
+ // warning/killed/denied) intentionally stays a local literal — it is not
75
+ // part of this set.
76
+ "status.ok": { nerd: "\u2713", unicode: `\u2713${VS}`, ascii: "ok" },
77
+ "status.error": { nerd: "\u2717", unicode: `\u2717${VS}`, ascii: "x" },
78
+ // `⚠` is East-Asian wide (2 cells); consumers that place it in an aligned
79
+ // marker column must normalize width via `padIcon` (pix-pretty/utils).
80
+ "status.warn": { nerd: "\u26A0", unicode: `\u26A0${VS}`, ascii: "!" },
81
+ "status.pending": { nerd: "\u25CB", unicode: `\u25CB${VS}`, ascii: "o" },
82
+ "status.running": { nerd: "\u25D0", unicode: `\u25D0${VS}`, ascii: "*" },
83
+ "status.active": { nerd: "\u25CF", unicode: `\u25CF${VS}`, ascii: "*" },
84
+ "status.done": { nerd: "\u25CF", unicode: `\u25CF${VS}`, ascii: "x" },
85
+ "status.blocked": { nerd: "\u2298", unicode: `\u2298${VS}`, ascii: "!" },
86
+
87
+ // ── welcome banner ────────────────────────────────────────────────────
88
+ ready: { nerd: "\u{F0633}", unicode: `\u2713${VS}`, ascii: "ok" },
89
+
90
+ // ── paste chips (pix-display) ─────────────────────────────────────────
91
+ "paste.image": { nerd: "\u{F02E9}", unicode: `\u25A3${VS}`, ascii: "img" },
92
+ "paste.text": { nerd: "\u{F027F}", unicode: `\u25A4${VS}`, ascii: "txt" },
93
+
94
+ // ── model picker (pix-models) ─────────────────────────────────────────
95
+ "picker.model": { nerd: "\u{F0229}", unicode: `\u25C8${VS}`, ascii: "M" },
96
+
97
+ // ── optimizer suite (pix-optimizer) ───────────────────────────────────
98
+ "opt.caveman": { nerd: "\u{F0710}", unicode: `\u2664${VS}`, ascii: "Cv" },
99
+ "opt.rtk": { nerd: "\u{F04E5}", unicode: `\u2661${VS}`, ascii: "Rk" },
100
+ "opt.toon": { nerd: "\u{F05C0}", unicode: `\u2662${VS}`, ascii: "Tn" },
101
+ "opt.ponytail": { nerd: "\u{F0190}", unicode: `\u2667${VS}`, ascii: "Pt" },
102
+ "opt.title": { nerd: "\u{F0DAB}", unicode: `\u25C8${VS}`, ascii: "*" },
103
+
104
+ // ── subagent widget (pix-subagent) ────────────────────────────────────
105
+ agent: { nerd: "\u{F0BA0}", unicode: `\u2699${VS}`, ascii: "@" },
106
+ turns: { nerd: "\u{F006A}", unicode: `\u21BB${VS}`, ascii: "~" },
107
+ tools: { nerd: "\u{F1064}", unicode: `\u2692${VS}`, ascii: "T" },
108
+ tokens: { nerd: "\u{F027F}", unicode: `\u25A4${VS}`, ascii: "tk" },
109
+ } as const;
110
+
111
+ /** Every valid semantic icon key. */
112
+ export type IconKey = keyof typeof CATALOG;
113
+
114
+ /** All catalog keys (useful for /pix previews and tests). */
115
+ export const ICON_KEYS = Object.keys(CATALOG) as IconKey[];
116
+
117
+ /**
118
+ * Active mode. Seeded from PRETTY_ICONS env (back-compat: none/off => ascii),
119
+ * then overridden by a persisted choice when the host loads pix.json.
120
+ */
121
+ function envMode(): IconMode {
122
+ const raw = (process.env.PRETTY_ICONS ?? "").toLowerCase();
123
+ if (raw === "nerd" || raw === "unicode" || raw === "ascii") return raw;
124
+ if (raw === "none" || raw === "off") return "ascii";
125
+ return "nerd";
126
+ }
127
+
128
+ let activeMode: IconMode = envMode();
129
+
130
+ /** Current global icon mode. */
131
+ export function getIconMode(): IconMode {
132
+ return activeMode;
133
+ }
134
+
135
+ /**
136
+ * Mode-change subscribers. Most consumers resolve icon() at render time and
137
+ * need no notification, but PUSHED-status consumers (e.g. the optimizer cell,
138
+ * drawn once via setStatus) must repaint when the mode flips. They subscribe
139
+ * here; setIconMode fires every callback on an actual change.
140
+ */
141
+ type ModeListener = (mode: IconMode) => void;
142
+ const listeners = new Set<ModeListener>();
143
+
144
+ /** Subscribe to global icon-mode changes. Returns an unsubscribe fn. */
145
+ export function onIconModeChange(cb: ModeListener): () => void {
146
+ listeners.add(cb);
147
+ return () => listeners.delete(cb);
148
+ }
149
+
150
+ /**
151
+ * Set the global icon mode (does NOT persist — callers that want persistence
152
+ * use the /pix command, which writes pix.json then calls this). Fires
153
+ * subscribers only on an actual change (no-op re-sets are ignored).
154
+ */
155
+ export function setIconMode(mode: IconMode): void {
156
+ if (!ICON_MODES.includes(mode) || mode === activeMode) return;
157
+ activeMode = mode;
158
+ for (const cb of listeners) cb(mode);
159
+ }
160
+
161
+ /**
162
+ * Resolve a semantic icon key to its glyph for the active mode — the `t(key)`
163
+ * of this module. Unknown keys return "" (fail soft: never throw mid-render).
164
+ */
165
+ export function icon(key: IconKey): string {
166
+ const entry = CATALOG[key];
167
+ return entry ? entry[activeMode] : "";
168
+ }
169
+
170
+ /** Resolve a key for an explicit mode (used by /pix previews + tests). */
171
+ export function iconFor(key: IconKey, mode: IconMode): string {
172
+ const entry = CATALOG[key];
173
+ return entry ? entry[mode] : "";
174
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { writeFileAtomicSync } from "./atomic-write.ts";
1
2
  export { collapseDelayMs, shouldCollapse } from "./collapse.ts";
2
3
  export type {
3
4
  ConfigChange,
@@ -15,6 +15,7 @@ import {
15
15
  visibleWidth,
16
16
  wrapTextWithAnsi,
17
17
  } from "@earendil-works/pi-tui";
18
+ import { icon } from "./icon-catalog.ts";
18
19
  import type { PixRuntime } from "./runtime.ts";
19
20
  import type { DeepPartial, SectionHandle } from "./schema.ts";
20
21
  import { collapseSection } from "./sections/collapse.ts";
@@ -184,7 +185,7 @@ const SETTINGS: SettingRow<unknown>[] = [
184
185
  ];
185
186
 
186
187
  function buildSummary(runtime: PixRuntime): string {
187
- const lines = [`pix settings (${runtime.path})`, ""];
188
+ const lines = [`Pix Settings (${runtime.path})`, ""];
188
189
  let lastSection = "";
189
190
  for (const row of SETTINGS) {
190
191
  if (row.section !== lastSection) {
@@ -299,7 +300,9 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
299
300
  tui.terminal?.rows,
300
301
  runtime.get(prettySection).maxRenderHeight,
301
302
  ),
302
- header: [theme.fg("accent", theme.bold(" pix settings")), ""],
303
+ title: `${icon("settings")} Pix Settings`,
304
+ titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
305
+ header: [""],
303
306
  body,
304
307
  footer: [
305
308
  "",
@@ -378,6 +381,10 @@ interface RuntimeModalOptions {
378
381
  selectedBodyLine?: number;
379
382
  color: (s: string) => string;
380
383
  bg?: (s: string) => string;
384
+ /** Plain title embedded in the top border (`╭─ title ──╮`). */
385
+ title?: string;
386
+ /** Color for the embedded title. Defaults to `color`. */
387
+ titleColor?: (s: string) => string;
381
388
  }
382
389
 
383
390
  interface RuntimeModalResult {
@@ -451,11 +458,22 @@ function frameModal(opts: RuntimeModalOptions): RuntimeModalResult {
451
458
  function frameRows(
452
459
  width: number,
453
460
  lines: string[],
454
- opts: Pick<RuntimeModalOptions, "color" | "bg">,
461
+ opts: Pick<RuntimeModalOptions, "color" | "bg" | "title" | "titleColor">,
455
462
  ): string[] {
456
463
  const bg = opts.bg ?? ((s: string) => s);
457
464
  const inner = Math.max(1, width - 4);
458
465
  const dashes = "─".repeat(width - 2);
466
+ // Top border: bare, or `╭─ title ──╮` when a title is given. Chrome around
467
+ // the label is 5 cols (2 corners, 1 lead dash, 2 pad spaces); tail fills the
468
+ // rest so the row stays exactly `width` visible cells.
469
+ const topBorder = ((): string => {
470
+ const span = width - 5;
471
+ if (!opts.title || span < 3) return opts.color(`╭${dashes}╮`);
472
+ const paint = opts.titleColor ?? opts.color;
473
+ const label = truncateToWidth(opts.title, span - 1, "…");
474
+ const tail = "─".repeat(Math.max(1, span - visibleWidth(label)));
475
+ return `${opts.color("╭─ ")}${paint(label)}${opts.color(` ${tail}╮`)}`;
476
+ })();
459
477
  const SENTINEL = "\x00";
460
478
  const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
461
479
  const reassert = (s: string): string => {
@@ -470,5 +488,5 @@ function frameRows(
470
488
  const padded = fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted)));
471
489
  return bg(`${opts.color("│")} ${reassert(padded)} ${opts.color("│")}`);
472
490
  };
473
- return [bg(opts.color(`╭${dashes}╮`)), ...lines.map(row), bg(opts.color(`╰${dashes}╯`))];
491
+ return [bg(topBorder), ...lines.map(row), bg(opts.color(`╰${dashes}╯`))];
474
492
  }