@xynogen/pix-runtime 0.15.1 → 0.16.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.15.1",
3
+ "version": "0.16.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",
@@ -135,11 +135,15 @@ function envMode(): IconMode {
135
135
  return "nerd";
136
136
  }
137
137
 
138
- let activeMode: IconMode = envMode();
138
+ const shared = globalThis as {
139
+ __pixIconState?: { mode: IconMode; listeners: Set<(mode: IconMode) => void> };
140
+ };
141
+ shared.__pixIconState ??= { mode: envMode(), listeners: new Set() };
142
+ const state = shared.__pixIconState;
139
143
 
140
144
  /** Current global icon mode. */
141
145
  export function getIconMode(): IconMode {
142
- return activeMode;
146
+ return state.mode;
143
147
  }
144
148
 
145
149
  /**
@@ -149,7 +153,7 @@ export function getIconMode(): IconMode {
149
153
  * here; setIconMode fires every callback on an actual change.
150
154
  */
151
155
  type ModeListener = (mode: IconMode) => void;
152
- const listeners = new Set<ModeListener>();
156
+ const listeners = state.listeners;
153
157
 
154
158
  /** Subscribe to global icon-mode changes. Returns an unsubscribe fn. */
155
159
  export function onIconModeChange(cb: ModeListener): () => void {
@@ -163,8 +167,8 @@ export function onIconModeChange(cb: ModeListener): () => void {
163
167
  * subscribers only on an actual change (no-op re-sets are ignored).
164
168
  */
165
169
  export function setIconMode(mode: IconMode): void {
166
- if (!ICON_MODES.includes(mode) || mode === activeMode) return;
167
- activeMode = mode;
170
+ if (!ICON_MODES.includes(mode) || mode === state.mode) return;
171
+ state.mode = mode;
168
172
  for (const cb of listeners) cb(mode);
169
173
  }
170
174
 
@@ -174,7 +178,7 @@ export function setIconMode(mode: IconMode): void {
174
178
  */
175
179
  export function icon(key: IconKey): string {
176
180
  const entry = CATALOG[key];
177
- return entry ? entry[activeMode] : "";
181
+ return entry ? entry[state.mode] : "";
178
182
  }
179
183
 
180
184
  /** Resolve a key for an explicit mode (used by /pix previews + tests). */
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  parseLfid,
33
33
  uniqueLfid,
34
34
  } from "./lfid.ts";
35
+ export { type PackageSettings, registerPackageSettings } from "./pix-command.ts";
35
36
  export {
36
37
  config,
37
38
  createRuntime,
package/src/once.ts CHANGED
@@ -10,7 +10,25 @@
10
10
  * skip, new instance => run. The registry lives on globalThis because jiti
11
11
  * (`moduleCache: false`) re-evaluates this module on every load pass.
12
12
  */
13
- export function once(pi: object, key: string, fn: () => void): void {
13
+ type PackageHost = {
14
+ events?: {
15
+ emit(channel: string, data: unknown): void;
16
+ on(channel: string, handler: (data: unknown) => void): unknown;
17
+ };
18
+ };
19
+
20
+ const PACKAGE_QUERY = "pix:package-loaded";
21
+
22
+ /** Check after extension loading (for example, session_start), not factory order. */
23
+ export function isPackageLoaded(pi: PackageHost, key: string): boolean {
24
+ const loaded = (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
25
+ if (loaded?.get(pi)?.has(key)) return true;
26
+ const query = { key, loaded: false };
27
+ pi.events?.emit(PACKAGE_QUERY, query);
28
+ return query.loaded;
29
+ }
30
+
31
+ export function once(pi: PackageHost, key: string, fn: () => void): void {
14
32
  const g = globalThis as { __pixOnce?: WeakMap<object, Set<string>> };
15
33
  if (!g.__pixOnce) g.__pixOnce = new WeakMap<object, Set<string>>();
16
34
  const registry = g.__pixOnce;
@@ -18,6 +36,12 @@ export function once(pi: object, key: string, fn: () => void): void {
18
36
  if (!loaded) {
19
37
  loaded = new Set<string>();
20
38
  registry.set(pi, loaded);
39
+ pi.events?.on(PACKAGE_QUERY, (data) => {
40
+ if (!data || typeof data !== "object" || !("key" in data) || !("loaded" in data)) return;
41
+ if (typeof data.key === "string" && registry.get(pi)?.has(data.key)) {
42
+ data.loaded = true;
43
+ }
44
+ });
21
45
  }
22
46
  if (loaded.has(key)) return;
23
47
  loaded.add(key);
package/src/os.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { userInfo } from "node:os";
11
12
  import { join } from "node:path";
12
13
  import { resolveTool } from "./binaries/resolve.ts";
13
14
  import { quoteForCmd, runTool, runToolSync } from "./exec.ts";
@@ -19,6 +20,19 @@ export interface OsOptions {
19
20
  host?: HostPlatform;
20
21
  }
21
22
 
23
+ /** Current OS account name; unavailable accounts do not prevent startup. */
24
+ export function username(): string | undefined {
25
+ try {
26
+ return (
27
+ userInfo()
28
+ .username.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
29
+ .trim() || undefined
30
+ );
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
22
36
  // ── Open a URL or path ──────────────────────────────────────────────────────
23
37
 
24
38
  export interface OpenOptions extends OsOptions {
@@ -18,7 +18,8 @@ import {
18
18
  } from "@earendil-works/pi-tui";
19
19
  import { createBinariesTab } from "./binaries-tab.ts";
20
20
  import { icon } from "./icon-catalog.ts";
21
- import type { PixRuntime } from "./runtime.ts";
21
+ import { isPackageLoaded } from "./once.ts";
22
+ import { type PixRuntime, pixRuntime } from "./runtime.ts";
22
23
  import { type DeepPartial, deepMerge, type SectionHandle } from "./schema.ts";
23
24
  import { collapseSection } from "./sections/collapse.ts";
24
25
  import { compactionSection } from "./sections/compaction.ts";
@@ -213,11 +214,34 @@ function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
213
214
  }
214
215
 
215
216
  const TABS = ["Settings", "Binaries", "Footer"] as const;
217
+ /** Most frequently changed tabs sit leftmost; unknown package tabs go before rare ones. */
218
+ const TAB_ORDER = ["Settings", "Tools", "MCP", "Optimizer", "Web", "Voice"];
219
+ const RARE_TABS = ["Footer", "Binaries"];
220
+ function tabRank(label: string): number {
221
+ const common = TAB_ORDER.indexOf(label);
222
+ if (common >= 0) return common;
223
+ const rare = RARE_TABS.indexOf(label);
224
+ return rare >= 0 ? 100 + rare : 50;
225
+ }
226
+ const SECTION_PACKAGES: Record<string, string> = {
227
+ Compaction: "pix-core",
228
+ Edit: "pix-diagnostics",
229
+ Gate: "pix-gate",
230
+ Footer: "pix-footer",
231
+ };
232
+
233
+ function visibleSettings(pi: ExtensionAPI): SettingRow<unknown>[] {
234
+ return SETTINGS.filter(
235
+ (row) =>
236
+ !SECTION_PACKAGES[row.section] ||
237
+ isPackageLoaded(pi, SECTION_PACKAGES[row.section] as string),
238
+ );
239
+ }
216
240
 
217
- function buildSummary(runtime: PixRuntime): string {
241
+ function buildSummary(runtime: PixRuntime, settings: SettingRow<unknown>[]): string {
218
242
  const lines = [`Pix Settings (${runtime.path})`, ""];
219
243
  let lastSection = "";
220
- for (const row of SETTINGS) {
244
+ for (const row of settings) {
221
245
  if (row.section !== lastSection) {
222
246
  if (lastSection) lines.push("");
223
247
  lines.push(`[${row.section}]`);
@@ -236,12 +260,43 @@ function buildSummary(runtime: PixRuntime): string {
236
260
  return lines.join("\n");
237
261
  }
238
262
 
263
+ export type PackageSettings = {
264
+ id: string;
265
+ label: string;
266
+ open(ctx: ExtensionCommandContext): Promise<void>;
267
+ };
268
+
269
+ const SETTINGS_QUERY = "pix:settings";
270
+ const COMMAND_QUERY = "pix:settings-command";
271
+
272
+ /** Register only from an active package factory; Pi clears subscriptions on reload. */
273
+ export function registerPackageSettings(pi: ExtensionAPI, settings: PackageSettings): void {
274
+ pi.events?.on(SETTINGS_QUERY, (data: unknown) => {
275
+ if (data instanceof Map) data.set(settings.id, settings);
276
+ });
277
+ registerPixCommand(pi, pixRuntime());
278
+ }
279
+
280
+ function packageSettings(pi: ExtensionAPI): PackageSettings[] {
281
+ const entries = new Map<string, PackageSettings>();
282
+ pi.events?.emit(SETTINGS_QUERY, entries);
283
+ return [...entries.values()].sort((a, b) => a.label.localeCompare(b.label));
284
+ }
285
+
239
286
  export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void {
287
+ const claim = { registered: false };
288
+ pi.events?.emit(COMMAND_QUERY, claim);
289
+ if (claim.registered) return;
290
+ pi.events?.on(COMMAND_QUERY, (data: unknown) => {
291
+ if (data && typeof data === "object" && "registered" in data) data.registered = true;
292
+ });
240
293
  pi.registerCommand("pix", {
241
294
  description: "pix: open shared settings (edit pix.json)",
242
295
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
243
296
  // Re-parse pix.json so section parsers remain the single validation source.
244
297
  await runtime.reload({ origin: "command", source: "pix" });
298
+ const panels = packageSettings(pi);
299
+ const settings = visibleSettings(pi);
245
300
  // SAFETY: Runtime UI supports custom overlays beyond the published command context type.
246
301
  const ui = ctx.ui as unknown as {
247
302
  custom?: (f: unknown, opts?: unknown) => Promise<unknown>;
@@ -249,7 +304,12 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
249
304
  };
250
305
 
251
306
  if (typeof ui.custom !== "function") {
252
- ui.notify(buildSummary(runtime), "info");
307
+ ui.notify(
308
+ [buildSummary(runtime, settings), ...panels.map((panel) => `[${panel.label}]`)].join(
309
+ "\n",
310
+ ),
311
+ "info",
312
+ );
253
313
  return;
254
314
  }
255
315
 
@@ -272,7 +332,76 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
272
332
  let visibleBodyLines = 1;
273
333
  let maxBodyOffset = 0;
274
334
  let inspectingPage = false;
275
- let tab: (typeof TABS)[number] = "Settings";
335
+ const tabs = [
336
+ ...TABS.filter(
337
+ (name) => name !== "Footer" || settings.some((row) => row.section === "Footer"),
338
+ ),
339
+ ...panels.map((entry) => entry.label),
340
+ ].sort((a, b) => tabRank(a) - tabRank(b));
341
+ let tab = "Settings";
342
+ let embedded:
343
+ | { render(width: number): string[]; handleInput(data: string): void; dispose?(): void }
344
+ | undefined;
345
+ let leavePanel: (() => void) | undefined;
346
+ let panelGeneration = 0;
347
+ let panelShrink = 1;
348
+ const mountPanel = () => {
349
+ const generation = ++panelGeneration;
350
+ leavePanel?.();
351
+ embedded?.dispose?.();
352
+ embedded = undefined;
353
+ leavePanel = undefined;
354
+ const panel = panels.find((entry) => entry.label === tab);
355
+ if (!panel) return;
356
+ const panelContext = {
357
+ ...ctx,
358
+ ui: {
359
+ ...ctx.ui,
360
+ custom: async (
361
+ factory: (
362
+ tui: unknown,
363
+ theme: unknown,
364
+ kb: unknown,
365
+ done: (value: unknown) => void,
366
+ ) => NonNullable<typeof embedded>,
367
+ ) => {
368
+ if (generation !== panelGeneration) return null;
369
+ return new Promise<unknown>((resolve) => {
370
+ leavePanel = () => resolve(null);
371
+ // The /pix tab row takes one line; render() shrinks the panel's terminal until both fit.
372
+ const panelTui = {
373
+ ...tui,
374
+ terminal: {
375
+ ...tui.terminal,
376
+ get rows() {
377
+ return Math.max(
378
+ 1,
379
+ (tui.terminal?.rows ?? process.stdout.rows ?? 24) - panelShrink,
380
+ );
381
+ },
382
+ },
383
+ };
384
+ embedded = factory(panelTui, theme, kb, (value: unknown) => {
385
+ if (generation !== panelGeneration) return resolve(null);
386
+ if (value == null) {
387
+ embedded = undefined;
388
+ done(null);
389
+ }
390
+ resolve(value);
391
+ });
392
+ tui.requestRender();
393
+ });
394
+ },
395
+ },
396
+ } as unknown as ExtensionCommandContext;
397
+ void panel.open(panelContext).catch((error) => {
398
+ if (generation === panelGeneration)
399
+ ui.notify(
400
+ `pix: ${panel.label}: ${error instanceof Error ? error.message : String(error)}`,
401
+ "error",
402
+ );
403
+ });
404
+ };
276
405
  let binaries: ReturnType<typeof createBinariesTab> | undefined;
277
406
  const binariesTab = () => {
278
407
  binaries ??= createBinariesTab({
@@ -283,22 +412,25 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
283
412
  return binaries;
284
413
  };
285
414
  const tabBar = () =>
286
- TABS.map((name) =>
287
- name === tab
288
- ? theme.fg("accent", theme.bold(` ${name} `))
289
- : theme.fg("muted", ` ${name} `),
290
- ).join(theme.fg("muted", "│"));
415
+ tabs
416
+ .map((name) =>
417
+ name === tab
418
+ ? theme.fg("accent", theme.bold(` ${name} `))
419
+ : theme.fg("muted", ` ${name} `),
420
+ )
421
+ .join(theme.fg("muted", "│"));
291
422
  const switchTab = (direction: -1 | 1) => {
292
- const next = (TABS.indexOf(tab) + direction + TABS.length) % TABS.length;
293
- tab = TABS[next] ?? "Settings";
423
+ const next = (tabs.indexOf(tab) + direction + tabs.length) % tabs.length;
424
+ tab = tabs[next] ?? "Settings";
294
425
  selected = 0;
295
426
  bodyOffset = 0;
296
427
  inspectingPage = false;
297
428
  if (tab === "Binaries") binariesTab().refresh();
429
+ mountPanel();
298
430
  };
299
431
 
300
432
  const rows = () =>
301
- SETTINGS.filter((r) => (r.section === "Footer") === (tab === "Footer"));
433
+ settings.filter((r) => (r.section === "Footer") === (tab === "Footer"));
302
434
  const cycle = (direction: -1 | 1) => {
303
435
  const row = rows()[selected];
304
436
  if (!row) return;
@@ -370,6 +502,26 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
370
502
  maxBodyOffset = result.maxBodyOffset;
371
503
  return result.lines;
372
504
  }
505
+ if (!TABS.some((name) => name === tab)) {
506
+ if (embedded) {
507
+ let lines = embedded.render(width);
508
+ // Keep the composite inside the same budget as built-in tabs.
509
+ for (let tries = 0; lines.length + 1 > frame.maxHeight && tries < 8; tries++) {
510
+ panelShrink += lines.length + 1 - frame.maxHeight;
511
+ lines = embedded.render(width);
512
+ }
513
+ const panelWidth = visibleWidth(lines[0] ?? "");
514
+ lines.splice(1, 0, ...frameRows(panelWidth, [tabBar()], frame).slice(1, -1));
515
+ return lines;
516
+ }
517
+ return frameModal({
518
+ ...frame,
519
+ header: [tabBar(), ""],
520
+ body: [theme.fg("dim", ` Loading ${tab} settings…`)],
521
+ footer: ["", tabGuide + guideSep + guide("esc", "close")],
522
+ bodyOffset: 0,
523
+ }).lines;
524
+ }
373
525
  const settings = rows();
374
526
  const labelW = Math.max(...settings.map((r) => r.label.length));
375
527
  const body: string[] = [];
@@ -415,6 +567,11 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
415
567
  maxBodyOffset = result.maxBodyOffset;
416
568
  return result.lines;
417
569
  },
570
+ dispose: () => {
571
+ panelGeneration++;
572
+ leavePanel?.();
573
+ embedded?.dispose?.();
574
+ },
418
575
  invalidate: () => {},
419
576
  handleInput: (data: string) => {
420
577
  if (tab === "Binaries" && binariesTab().editing) {
@@ -435,6 +592,10 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
435
592
  tui.requestRender();
436
593
  return;
437
594
  }
595
+ if (!TABS.some((name) => name === tab) && embedded) {
596
+ embedded.handleInput(data);
597
+ return;
598
+ }
438
599
  const pageUp = kb?.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp);
439
600
  const pageDown =
440
601
  kb?.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown);
@@ -65,7 +65,18 @@ export const gateSection = defineSection<"gate", GateConfig>({
65
65
  }
66
66
  return {
67
67
  guardrails: enumOr(raw.guardrails, ["on", "off"], DEFAULTS.guardrails),
68
- autoApprove: strArr(raw.autoApprove),
68
+ autoApprove: strArr(raw.autoApprove).filter((pattern, i) => {
69
+ const err = regexError(pattern);
70
+ if (err) {
71
+ ctx.diagnostic({
72
+ code: "INVALID_VALUE",
73
+ severity: "warning",
74
+ path: `gate.autoApprove[${i}]`,
75
+ message: `invalid regex: ${err}`,
76
+ });
77
+ }
78
+ return !err;
79
+ }),
69
80
  extraRules,
70
81
  };
71
82
  },