@xynogen/pix-runtime 0.16.0 → 0.16.1

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.16.0",
3
+ "version": "0.16.1",
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",
package/src/exec.ts CHANGED
@@ -97,7 +97,7 @@ export interface ToolExecOptions {
97
97
  onStatus?: (status: ToolStatus) => void;
98
98
  /** Test seam for the host OS. */
99
99
  host?: HostPlatform;
100
- /** Cap on captured stdout/stderr bytes each (default 50 MB). */
100
+ /** Cap on captured stdout/stderr bytes each (default 50 MB). Past it the child is stopped. */
101
101
  maxBuffer?: number;
102
102
  /** Args are already quoted for the target (cmd.exe `/c` lines); Node must not re-quote. */
103
103
  verbatim?: boolean;
@@ -111,7 +111,7 @@ export interface ToolRunResult {
111
111
  /** Raw stdout for binary output (clipboard images, archives). */
112
112
  stdoutBytes: Buffer;
113
113
  timedOut: boolean;
114
- /** Output passed `maxBuffer` and was cut. A zero exit code is then not a complete result. */
114
+ /** Output passed `maxBuffer`; the child was stopped and output cut. Never a complete result. */
115
115
  truncated: boolean;
116
116
  /** The binary that ran and where it was found. */
117
117
  tool: ResolvedTool;
@@ -220,7 +220,11 @@ function collect(
220
220
  let truncated = false;
221
221
  const push = (list: Buffer[], chunk: Buffer, len: number): number => {
222
222
  const room = cap - len;
223
- if (chunk.length > room) truncated = true;
223
+ // Like pi.exec: stop a runaway child instead of draining output nobody keeps.
224
+ if (chunk.length > room) {
225
+ truncated = true;
226
+ stop();
227
+ }
224
228
  if (room <= 0) return len;
225
229
  list.push(chunk.length > room ? chunk.subarray(0, room) : chunk);
226
230
  return len + Math.min(chunk.length, room);
package/src/os.ts CHANGED
@@ -46,7 +46,7 @@ export function openCommand(
46
46
  target: string,
47
47
  host: HostPlatform,
48
48
  app?: string,
49
- ): { name: string; args: string[]; verbatim?: boolean } {
49
+ ): { name: string; args: string[]; verbatim?: boolean; cwd?: string } {
50
50
  if (host.os === "darwin") return { name: "open", args: app ? ["-a", app, target] : [target] };
51
51
  if (host.os === "win32") {
52
52
  // `start` is a cmd.exe builtin; "" is its window-title argument.
@@ -54,8 +54,9 @@ export function openCommand(
54
54
  return { name: "cmd", args: ["/d", "/s", "/c", `"${parts.join(" ")}"`], verbatim: true };
55
55
  }
56
56
  if (host.wsl && !app) {
57
- // WSL: hand the target to Windows so the user's default browser opens.
58
- return { name: "wslview", args: [target] };
57
+ // WSL: hand the target to Windows so the user's default browser opens. Windows programs
58
+ // cannot start from a long `\\wsl.localhost\...` cwd, so start from `/`.
59
+ return { name: "wslview", args: [target], cwd: "/" };
59
60
  }
60
61
  return { name: app ?? "xdg-open", args: [target] };
61
62
  }
@@ -71,6 +72,7 @@ export async function openTarget(target: string, opts: OpenOptions = {}): Promis
71
72
  if (cmd.name === "wslview" && !resolveTool("wslview", opts))
72
73
  cmd = openCommand(target, { ...host, wsl: false });
73
74
  const result = await runTool(cmd.name, cmd.args, {
75
+ cwd: cmd.cwd,
74
76
  env: opts.env,
75
77
  host,
76
78
  timeoutMs: opts.timeoutMs ?? 15_000,
@@ -140,10 +142,16 @@ export function pickPreferredMime(types: readonly string[]): string | null {
140
142
  }
141
143
 
142
144
  /** Output bytes when the tool exists and exits 0; null otherwise (probing, never throws). */
143
- function probe(name: string, args: string[], timeoutMs: number, opts: OsOptions): Buffer | null {
145
+ function probe(
146
+ name: string,
147
+ args: string[],
148
+ timeoutMs: number,
149
+ opts: OsOptions,
150
+ cwd?: string,
151
+ ): Buffer | null {
144
152
  if (!resolveTool(name, opts)) return null;
145
153
  try {
146
- const r = runToolSync(name, args, { env: opts.env, host: opts.host, timeoutMs });
154
+ const r = runToolSync(name, args, { env: opts.env, host: opts.host, timeoutMs, cwd });
147
155
  return r.code === 0 ? r.stdoutBytes : null;
148
156
  } catch {
149
157
  return null;
@@ -203,7 +211,8 @@ function viaPowerShell(host: HostPlatform, opts: OsOptions): ClipboardImage | nu
203
211
  "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
204
212
  ].join("; ");
205
213
  const args = ["-NoProfile", "-STA", "-Command", script];
206
- const out = probe("powershell", args, POWERSHELL_TIMEOUT_MS, opts);
214
+ // From WSL, Windows programs cannot start from a long `\\wsl.localhost\...` cwd.
215
+ const out = probe("powershell", args, POWERSHELL_TIMEOUT_MS, opts, host.wsl ? "/" : undefined);
207
216
  if (out?.toString("utf-8").trim() !== "ok") return null;
208
217
  const bytes = readFileSync(tmpFile);
209
218
  return bytes.length ? { bytes, mimeType: "image/png" } : null;
@@ -42,6 +42,21 @@ function row<T>(r: SettingRow<T>): SettingRow<unknown> {
42
42
  return r as SettingRow<unknown>;
43
43
  }
44
44
 
45
+ /** Pi-owned theme row: not stored in pix.json, applied through `ctx.ui.setTheme`. */
46
+ interface ThemeRow {
47
+ kind: "theme";
48
+ section: string;
49
+ label: string;
50
+ values: readonly string[];
51
+ current: () => string;
52
+ }
53
+
54
+ type Row = SettingRow<unknown> | ThemeRow;
55
+
56
+ function isThemeRow(row: Row): row is ThemeRow {
57
+ return "kind" in row;
58
+ }
59
+
45
60
  /** Render a token count as a compact label: 1_000_000 → "1M", 150_000 → "150k". */
46
61
  function formatTokens(tokens: number): string {
47
62
  return tokens % 1_000_000 === 0 ? `${tokens / 1_000_000}M` : `${tokens / 1000}k`;
@@ -291,12 +306,32 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
291
306
  if (data && typeof data === "object" && "registered" in data) data.registered = true;
292
307
  });
293
308
  pi.registerCommand("pix", {
294
- description: "pix: open shared settings (edit pix.json)",
295
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
309
+ description: "pix: open shared settings (edit pix.json); /pix <tab> opens that tab",
310
+ getArgumentCompletions: (prefix: string) => {
311
+ const p = prefix.trim().toLowerCase();
312
+ const items = [...TABS, ...packageSettings(pi).map((entry) => entry.label)]
313
+ .map((label) => label.toLowerCase())
314
+ .filter((name) => name.startsWith(p))
315
+ .map((name) => ({ value: name, label: name }));
316
+ return items.length ? items : null;
317
+ },
318
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
296
319
  // Re-parse pix.json so section parsers remain the single validation source.
297
320
  await runtime.reload({ origin: "command", source: "pix" });
298
321
  const panels = packageSettings(pi);
299
322
  const settings = visibleSettings(pi);
323
+ // Pi owns the active theme (settings.json); `/pix` only cycles it through `ctx.ui.setTheme`.
324
+ const themeNames = (ctx.ui.getAllThemes?.() ?? []).map((t) => t.name).sort();
325
+ const themeRow: ThemeRow | undefined =
326
+ themeNames.length > 1
327
+ ? {
328
+ kind: "theme",
329
+ section: "Pretty",
330
+ label: "theme",
331
+ values: themeNames,
332
+ current: () => ctx.ui.theme.name ?? "",
333
+ }
334
+ : undefined;
300
335
  // SAFETY: Runtime UI supports custom overlays beyond the published command context type.
301
336
  const ui = ctx.ui as unknown as {
302
337
  custom?: (f: unknown, opts?: unknown) => Promise<unknown>;
@@ -338,7 +373,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
338
373
  ),
339
374
  ...panels.map((entry) => entry.label),
340
375
  ].sort((a, b) => tabRank(a) - tabRank(b));
341
- let tab = "Settings";
376
+ const wanted = (args ?? "").trim().toLowerCase();
377
+ let tab = tabs.find((name) => name.toLowerCase() === wanted) ?? "Settings";
342
378
  let embedded:
343
379
  | { render(width: number): string[]; handleInput(data: string): void; dispose?(): void }
344
380
  | undefined;
@@ -429,11 +465,27 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
429
465
  mountPanel();
430
466
  };
431
467
 
432
- const rows = () =>
433
- settings.filter((r) => (r.section === "Footer") === (tab === "Footer"));
468
+ // `/pix <tab>` opens straight into that tab.
469
+ if (tab === "Binaries") binariesTab().refresh();
470
+ mountPanel();
471
+
472
+ const rows = (): Row[] => {
473
+ const list = settings.filter((r) => (r.section === "Footer") === (tab === "Footer"));
474
+ return tab === "Settings" && themeRow ? [themeRow, ...list] : list;
475
+ };
434
476
  const cycle = (direction: -1 | 1) => {
435
477
  const row = rows()[selected];
436
478
  if (!row) return;
479
+ if (isThemeRow(row)) {
480
+ const cur = row.values.indexOf(row.current());
481
+ const start = cur === -1 && direction === -1 ? 0 : cur;
482
+ const next = row.values[(start + direction + row.values.length) % row.values.length];
483
+ const result = next ? ctx.ui.setTheme(next) : undefined;
484
+ if (result && !result.success)
485
+ ui.notify(`pix: theme ${next}: ${result.error ?? "failed"}`, "error");
486
+ tui.requestRender();
487
+ return;
488
+ }
437
489
  // Functional updater: the runtime queue serializes writes, and the
438
490
  // callback sees the latest committed value — so rapid presses each
439
491
  // advance exactly one step instead of collapsing on a stale snapshot.
@@ -538,8 +590,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
538
590
  const sel = i === selected;
539
591
  const cursor = sel ? theme.fg("accent", "→") : " ";
540
592
  const label = theme.fg(sel ? "accent" : "text", row.label.padEnd(labelW));
541
- const value = row.read(runtime.get(row.handle));
542
- const isDefault = value === row.read(row.handle.defaults);
593
+ const value = isThemeRow(row) ? row.current() : row.read(runtime.get(row.handle));
594
+ const isDefault = isThemeRow(row) || value === row.read(row.handle.defaults);
543
595
  settingBodyLines[i] = body.length;
544
596
  body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
545
597
  }