@xynogen/pix-runtime 0.8.2 → 0.9.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.8.2",
3
+ "version": "0.9.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",
@@ -25,7 +25,9 @@
25
25
  "./config": "./src/runtime.ts",
26
26
  "./sections": "./src/sections/index.ts",
27
27
  "./collapse": "./src/collapse.ts",
28
+ "./icon-catalog": "./src/icon-catalog.ts",
28
29
  "./io": "./src/io.ts",
30
+ "./lfid": "./src/lfid.ts",
29
31
  "./once": "./src/once.ts",
30
32
  "./testing": "./src/testing.ts"
31
33
  },
@@ -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
@@ -24,6 +24,14 @@ export {
24
24
  withAgentBlock,
25
25
  } from "./herdr-state.ts";
26
26
  export { ioTimeoutMs, ioTimeoutSignal } from "./io.ts";
27
+ export {
28
+ generateLfid,
29
+ isLfid,
30
+ LFID_RE,
31
+ type LfidOptions,
32
+ parseLfid,
33
+ uniqueLfid,
34
+ } from "./lfid.ts";
27
35
  export {
28
36
  config,
29
37
  createRuntime,
package/src/lfid.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * LFID — LLM-friendly IDs (`agent-happy-walrus-42`).
3
+ *
4
+ * Raw UUID fragments (`3f2a1b9c-4d5e`, `abc123`) waste parent-model tokens and
5
+ * invite typos when the model must type them back (steer/stop/resume). An LFID
6
+ * is `[prefix]-[adjective]-[noun]-[NN]`: pronounceable, copyable, and
7
+ * self-evidently an ID in a transcript. Two random words + two digits give
8
+ * 100×100×100 = 1M combinations per prefix — plenty for a live agent set, and
9
+ * the owning registry (e.g. pix-subagent's AgentManager) maps LFID → record.
10
+ *
11
+ * `ponytail:` 2-digit suffix caps the namespace at 1M per prefix. If live sets
12
+ * ever approach that, widen the suffix or add a third word here.
13
+ */
14
+
15
+ import { randomInt } from "node:crypto";
16
+
17
+ /** `agent-<adj>-<noun>-<NN>` shape. Prefix is caller-owned (`agent`, `job`, …). */
18
+ export const LFID_RE = /^[a-z][a-z0-9]*-[a-z]+-[a-z]+-\d{2}$/;
19
+
20
+ // Small curated lists: common words, no ambiguous pairs, no profanity.
21
+ // Sized at 100 × 100 so two digits complete a 1M namespace per prefix.
22
+ const ADJECTIVES = (
23
+ "agate amber azure birch bold brave bright brisk calm cedar cinder clever cloudy coral crisp dawn drift dusk eager elm" +
24
+ "ember fair fern fleet flint fresh frost garnet glad golden grand green grove happy hazy heath iris ivory jade juniper" +
25
+ "keen kind lagoon larch light linden lively lotus lucid lunar maple meadow merry misty moss nectar nimble nimbus noble north" +
26
+ "ocean olive opal pearl pine plain plum proud quartz quick quiet rapid reef ridge river rocky round royal rusty sable" +
27
+ "sage sandy sedge sharp shiny silent silver sleek small smart smooth solid sorrel spring steady stone sunny swift tango teal"
28
+ ).split(" ");
29
+
30
+ const NOUNS = (
31
+ "adder albatross anchovy badger beagle bear beaver boar bobcat cobra condor cougar coyote crane cricket deer dove drake duck eagle" +
32
+ "falcon ferret finch fisher fox frog gannet gazelle gecko goose gopher grouse gull hamster hare hawk heron hyena ibis impala" +
33
+ "indigo jackal jaguar jay kelp kestrel koala kudu lark lemur llama lynx magpie manatee marmot marten meerkat mink mole mongoose" +
34
+ "moose narwhal newt numbats ocelot okapi oriole osprey otter owl ox oyster panda pangolin parrot pelican penguin pigeon pika platypus" +
35
+ "porpoise possum puffin python quail quokka rabbit raven rhea robin sable salmon saola seal shark shrew skunk sloth snail sparrow"
36
+ ).split(" ");
37
+
38
+ export interface LfidOptions {
39
+ /** Namespace prefix, e.g. `"agent"`. Defaults to `"agent"`. */
40
+ prefix?: string;
41
+ /** RNG override for tests/determinism. Must return an int in [0, max). */
42
+ rand?: (max: number) => number;
43
+ }
44
+
45
+ /** Generate one LFID, e.g. `agent-happy-walrus-42`. */
46
+ export function generateLfid(opts: LfidOptions = {}): string {
47
+ const prefix = opts.prefix ?? "agent";
48
+ const rand = opts.rand ?? randomInt;
49
+ const pick = (xs: string[]): string => xs[rand(xs.length)] as string;
50
+ const n = String(rand(100)).padStart(2, "0");
51
+ return `${prefix}-${pick(ADJECTIVES)}-${pick(NOUNS)}-${n}`;
52
+ }
53
+
54
+ /** Loose check: is this string LFID-shaped (any prefix)? */
55
+ export function isLfid(s: string): boolean {
56
+ return LFID_RE.test(s);
57
+ }
58
+
59
+ /** Parse an LFID into its parts; undefined when the shape doesn't match. */
60
+ export function parseLfid(
61
+ s: string,
62
+ ): { prefix: string; adjective: string; noun: string; num: string } | undefined {
63
+ const m = LFID_RE.exec(s);
64
+ if (!m) return undefined;
65
+ const [prefix, adjective, noun, num] = s.split("-");
66
+ return {
67
+ prefix: prefix as string,
68
+ adjective: adjective as string,
69
+ noun: noun as string,
70
+ num: num as string,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Generate an LFID not already present in `taken`. Retries a bounded number of
76
+ * times, then throws — a full or near-full namespace is a caller bug, not a
77
+ * loop-candidate. Collisions across live sets are essentially impossible long
78
+ * before this bound (1M combinations), so 64 tries is generous.
79
+ */
80
+ export function uniqueLfid(taken: (id: string) => boolean, opts: LfidOptions = {}): string {
81
+ for (let i = 0; i < 64; i++) {
82
+ const id = generateLfid(opts);
83
+ if (!taken(id)) return id;
84
+ }
85
+ throw new Error("lfid: namespace exhausted (too many live IDs for prefix)");
86
+ }
@@ -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
  }