@bismawy/pi-arnative 0.3.5 → 0.3.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.6] - 2026-10-05
4
+
5
+ ### Fixed
6
+ - Fixed a startup crash: `Cannot read properties of null (reading 'fg')`. pi's `[Extension issues]` diagnostic box passed a null theme into the box renderer, which dereferenced it and killed the process with `uncaughtException`. It only surfaced on machines where an extension reported a problem (a conflicting shortcut, a failed `session_start`, …), because that is when the diagnostic is drawn. The renderer now draws an uncolored box for the first frame instead of throwing (`lib/box.ts`).
7
+ - The active theme now lives in a single `globalThis` slot. `/reload` re-imports extension modules, so two copies can coexist, and only one of them receives `session_start`. The copy that owns the `loadedResourcesContainer` patch used to keep a permanently-null theme; it now paints with the live theme (`lib/ansi.ts`, `extensions/section-headers.ts`, `extensions/ui-render-tweaks.ts`).
8
+ - Removed the `activeThemeProxy ?? Theme` fallback in the package-update box: `Theme` is a class, not an instance, so the fallback would itself have thrown `th.fg is not a function`. The box component now tolerates a null theme (`extensions/ui-render-tweaks.ts`).
9
+
3
10
  ## [0.3.5] - 2026-10-04
4
11
 
5
12
  ### Fixed
@@ -31,7 +31,7 @@ import {
31
31
  type TuiMouseEventResult,
32
32
  } from "@earendil-works/pi-tui";
33
33
  import { renderBoxLines } from "../lib/box.ts";
34
- import { stripAnsi, themeOf } from "../lib/ansi.ts";
34
+ import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.ts";
35
35
  import { assert, isMain } from "../lib/check.ts";
36
36
  import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
37
37
  import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
@@ -54,8 +54,6 @@ export const SECTION_ICONS: Record<TabKey, string> = {
54
54
 
55
55
  export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
56
56
 
57
- let activeThemeProxy: Themeish = null;
58
-
59
57
  const MODEL_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentModel");
60
58
  const THINKING_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentThinking");
61
59
 
@@ -474,7 +472,7 @@ export class ArnativeHeader implements Component {
474
472
  if (width < 36) {
475
473
  return [centerLine(`pi v${VERSION}`, width)];
476
474
  }
477
- const th = this.themeProxy || (activeThemeProxy as Themeish);
475
+ const th = this.themeProxy || getActiveTheme<Themeish>();
478
476
  const side = fgFirst(th, ["dim"], "│");
479
477
 
480
478
  const infoLines: string[] = [];
@@ -689,7 +687,7 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
689
687
  const text = themedTextOf(child);
690
688
  if (text.includes("[Extension issues]") && typeof child.render === "function") {
691
689
  const origRender = child.render.bind(child);
692
- child.render = (w: number) => renderBoxLines(activeThemeProxy, w, origRender(w), undefined, "warning");
690
+ child.render = (w: number) => renderBoxLines(getActiveTheme<Themeish>(), w, origRender(w), undefined, "warning");
693
691
  return origAddChild.call(this, child);
694
692
  }
695
693
  // Any other element inside loadedResourcesContainer (e.g. Spacer) is hidden too
@@ -729,7 +727,9 @@ export default function (pi: ExtensionAPI) {
729
727
  }
730
728
  resourcesListingShown(quiet === false);
731
729
  for (const key of ["Context", "Skills", "Extensions"] as const) tabStore.set(key, []);
732
- activeThemeProxy = themeOf<Themeish>(ctx) ?? activeThemeProxy;
730
+ // A context without a theme (project-trust prompt) must not drop the one we have.
731
+ const th = themeOf<Themeish>(ctx);
732
+ if (th) setActiveTheme(th);
733
733
  // Register the header for the active preset
734
734
  applyHeaderPreset(ctx);
735
735
  });
@@ -1067,5 +1067,27 @@ if (isMain(import.meta.url)) {
1067
1067
  assert(sectionBodyOf(helpBlock) === "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", "sectionBodyOf reads the build shape");
1068
1068
  assert(sectionNameOf(sectionBodyOf(helpBlock)!) === null, "startup help block is not a resource section");
1069
1069
 
1070
+ // pi's "[Extension issues]" diagnostic renders while the installing module's theme is
1071
+ // still null (a `/reload` copy never saw session_start). The box must draw uncolored,
1072
+ // not deref null — that crash was reported in the wild.
1073
+ setActiveTheme(null);
1074
+ const issuesChild = {
1075
+ build: () => "[Extension issues]\n extension \"x\" failed",
1076
+ render: (_w: number) => ["[Extension issues]", ' extension "x" failed'],
1077
+ };
1078
+ container.addChild(issuesChild);
1079
+ const issuesLines = issuesChild.render(80);
1080
+ assert(issuesLines[0]!.startsWith("\u256d") && issuesLines.at(-1)!.startsWith("\u2570"), "extension-issues box renders without a theme");
1081
+
1082
+ // The theme proxy is globalThis-backed, so the copy that owns the addChild patch still
1083
+ // paints once the other copy's session_start has stored the theme.
1084
+ setActiveTheme({ fg: (c: string, t: string) => `<${c}>${t}</${c}>` });
1085
+ const coloredChild = {
1086
+ build: () => "[Extension issues]\n extension \"x\" failed",
1087
+ render: (_w: number) => ["[Extension issues]", ' extension "x" failed'],
1088
+ };
1089
+ container.addChild(coloredChild);
1090
+ assert(coloredChild.render(80)[0]!.includes("<warning>"), "shared theme proxy colors the box even in the patched copy");
1091
+
1070
1092
  console.log("section-headers.ts self-check OK");
1071
1093
  }
@@ -21,7 +21,6 @@ import {
21
21
  getMarkdownTheme,
22
22
  initTheme,
23
23
  InteractiveMode,
24
- keyText,
25
24
  Theme,
26
25
  UserMessageComponent,
27
26
  type ExtensionAPI,
@@ -40,12 +39,13 @@ import {
40
39
  } from "@earendil-works/pi-tui";
41
40
  import { readdirSync, readFileSync } from "node:fs";
42
41
  import { renderBoxLines } from "../lib/box.ts";
43
- import { ansiBgOpen, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
42
+ import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
44
43
  import { assert, isMain } from "../lib/check.ts";
45
44
  import { contrast, parseOklch, toHex } from "../lib/color.ts";
46
45
  import { expandKeyName } from "../lib/format.ts";
47
46
 
48
- let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
47
+ type ActiveTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null;
48
+ const activeTheme = () => getActiveTheme<Exclude<ActiveTheme, null>>();
49
49
 
50
50
  // Hide pi's built-in "Reloading keybindings, extensions, skills..." box
51
51
  // (hardcoded in handleReloadCommand, no auto-hide). Patches Container.render
@@ -141,8 +141,8 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[SELECTIO
141
141
  if (typeof origHighlight === "function") {
142
142
  TuiAltScreen.prototype.applySelectionHighlight = function (text: string): string {
143
143
  const highlighted = origHighlight.call(this, text);
144
- const fgOpen = ansiFgOpen(activeThemeProxy);
145
- const bgOpen = ansiBgOpen(activeThemeProxy, "selectedBg");
144
+ const fgOpen = ansiFgOpen(activeTheme());
145
+ const bgOpen = ansiBgOpen(activeTheme(), "selectedBg");
146
146
  return fgOpen && bgOpen ? recolorSelection(highlighted, fgOpen, bgOpen) : highlighted;
147
147
  };
148
148
  }
@@ -161,7 +161,7 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[PILL_KEY
161
161
  layout: unknown,
162
162
  width: number,
163
163
  ) {
164
- return withAccentPill(this, () => origComposite.call(this, screen, layout, width), activeThemeProxy);
164
+ return withAccentPill(this, () => origComposite.call(this, screen, layout, width), activeTheme());
165
165
  };
166
166
  }
167
167
  }
@@ -190,7 +190,7 @@ if (ExtensionSelectorComponent?.prototype && !(globalThis as Record<symbol, bool
190
190
  };
191
191
  const origUpdateList = proto.updateList;
192
192
  proto.updateList = function (this: typeof proto) {
193
- const th = activeThemeProxy;
193
+ const th = activeTheme();
194
194
  if (!th) return origUpdateList.call(this); // no theme yet -> pi's default
195
195
  this.listContainer.clear();
196
196
  for (let i = 0; i < this.options.length; i++) {
@@ -206,7 +206,8 @@ if (ExtensionSelectorComponent?.prototype && !(globalThis as Record<symbol, bool
206
206
  export default function uiRenderTweaks(pi: ExtensionAPI) {
207
207
  // The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
208
208
  pi.on("session_start", async (_event, ctx) => {
209
- activeThemeProxy = themeOf<typeof activeThemeProxy>(ctx) ?? activeThemeProxy;
209
+ const th = themeOf<Exclude<ActiveTheme, null>>(ctx);
210
+ if (th) setActiveTheme(th);
210
211
  });
211
212
 
212
213
  // 4. `pi-jev-eye-review` contract message: use our own border box.
@@ -224,9 +225,9 @@ export default function uiRenderTweaks(pi: ExtensionAPI) {
224
225
  // body text is unchanged.
225
226
  export class PackageUpdateBoxComponent implements Component {
226
227
  private packages: string[];
227
- private th: { fg(c: string, t: string): string };
228
+ private th: { fg(c: string, t: string): string } | null;
228
229
 
229
- constructor(packages: string[], th: { fg(c: string, t: string): string }) {
230
+ constructor(packages: string[], th: { fg(c: string, t: string): string } | null) {
230
231
  this.packages = packages;
231
232
  this.th = th;
232
233
  }
@@ -235,10 +236,11 @@ export class PackageUpdateBoxComponent implements Component {
235
236
 
236
237
  render(width: number): string[] {
237
238
  const th = this.th;
239
+ const fg = (color: string, text: string) => (th ? th.fg(color, text) : text);
238
240
  const rows = [
239
- th.fg("warning", "\x1b[1m\uf449 Package Updates Available\x1b[22m"),
240
- `${th.fg("muted", "Package updates are available. Run ")}${th.fg("accent", "pi update --extensions")}`,
241
- th.fg("muted", "Packages:"),
241
+ fg("warning", "\x1b[1m\uf449 Package Updates Available\x1b[22m"),
242
+ `${fg("muted", "Package updates are available. Run ")}${fg("accent", "pi update --extensions")}`,
243
+ fg("muted", "Packages:"),
242
244
  ...this.packages.map((pkg) => `- ${pkg}`),
243
245
  ];
244
246
  return renderBoxLines(th, width, rows, undefined, "warning");
@@ -257,7 +259,7 @@ if (InteractiveMode?.prototype && !(globalThis as Record<symbol, boolean>)[PACKA
257
259
  ui: { requestRender(): void };
258
260
  };
259
261
  self.chatContainer.addChild(new Spacer(1));
260
- self.chatContainer.addChild(new PackageUpdateBoxComponent(packages, activeThemeProxy ?? Theme));
262
+ self.chatContainer.addChild(new PackageUpdateBoxComponent(packages, activeTheme()));
261
263
  self.ui.requestRender();
262
264
  };
263
265
  }
@@ -333,7 +335,7 @@ if (CompactionSummaryMessageComponent?.prototype && !(globalThis as Record<symbo
333
335
  const proto = CompactionSummaryMessageComponent.prototype as any;
334
336
  const origUpdateDisplay = proto.updateDisplay;
335
337
  proto.updateDisplay = function (): void {
336
- const th = activeThemeProxy;
338
+ const th = activeTheme();
337
339
  if (!th) return origUpdateDisplay.call(this);
338
340
  const message = this.message as { tokensBefore?: number; summary?: string } | undefined;
339
341
  const tokenStr = Number(message?.tokensBefore ?? 0).toLocaleString();
package/lib/ansi.ts CHANGED
@@ -25,6 +25,19 @@ export function themeOf<T>(ctx: unknown): T | null {
25
25
  return (ctx as { ui?: { theme?: T } } | null | undefined)?.ui?.theme ?? null;
26
26
  }
27
27
 
28
+ // Shared active-theme slot. `/reload` re-imports an extension's modules, so two copies
29
+ // can coexist: one carries the session_start handler, the other owns a stacked UI patch.
30
+ // globalThis gives both the same theme, so the patched copy paints instead of drawing
31
+ // uncolored (or, before the null-guard fix, crashing). Set from `session_start`.
32
+ const THEME_PROXY_KEY = Symbol.for("pi-arnative.activeThemeProxy");
33
+ export function getActiveTheme<T>(): T | null {
34
+ return (globalThis as Record<symbol, T>)[THEME_PROXY_KEY] ?? null;
35
+ }
36
+ export function setActiveTheme<T>(th: T | null): void {
37
+ if (th) (globalThis as Record<symbol, T>)[THEME_PROXY_KEY] = th;
38
+ else delete (globalThis as Record<symbol, T>)[THEME_PROXY_KEY];
39
+ }
40
+
28
41
  /** Painter for the `accentSoft` token, which only arnative themes define; other themes fall back to accent (probed once). */
29
42
  export function accentSoftOf(th: { fg?(c: string, t: string): string } | null): (s: string) => string {
30
43
  const fg = th?.fg?.bind(th);
@@ -46,6 +59,11 @@ if (isMain(import.meta.url)) {
46
59
  assert(themeOf({ ui: { theme: "T" } }) === "T", "themeOf reads ctx.ui.theme");
47
60
  assert(themeOf(null) === null, "themeOf tolerates a missing ctx");
48
61
 
62
+ setActiveTheme({ fg: (c, t) => `<${c}>${t}</${c}>` });
63
+ assert(getActiveTheme<object>() !== null, "active theme round-trips through globalThis");
64
+ setActiveTheme(null);
65
+ assert(getActiveTheme<object>() === null, "clearing the active theme leaves no stale slot");
66
+
49
67
  const tag = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
50
68
  assert(accentSoftOf(tag)("x") === "<accentSoft>x</accentSoft>", "accentSoftOf uses accentSoft when the theme defines it");
51
69
  const noSoft = {
package/lib/box.ts CHANGED
@@ -23,18 +23,21 @@ const applyBg = (line: string, width: number, bgFn: (text: string) => string): s
23
23
  return bgFn(line + " ".repeat(pad));
24
24
  };
25
25
 
26
- /** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color. */
27
- export function renderBoxLines(theme: BoxTheme, width: number, rows: string[], bgType?: string, borderColor = "dim"): string[] {
28
- const dim = (s: string) => theme.fg(borderColor, s);
26
+ /** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color.
27
+ * `theme` may be null: a box is patched in at module load, before `session_start`
28
+ * delivers the active theme. An uncolored box for that first frame beats a null
29
+ * deref that kills the process. */
30
+ export function renderBoxLines(theme: BoxTheme | null, width: number, rows: string[], bgType?: string, borderColor = "dim"): string[] {
31
+ const dim = (s: string) => (theme ? theme.fg(borderColor, s) : s);
29
32
  const inner = Math.max(8, width - 4);
30
33
  const lines = [boxEdge("╭", "╮", width, dim)];
31
34
  for (const row of rows) {
32
35
  for (const line of wrapTextWithAnsi(row, inner)) lines.push(boxRow(line, width, dim));
33
36
  }
34
37
  lines.push(boxEdge("╰", "╯", width, dim));
35
- if (bgType && theme.bg) {
38
+ if (bgType && theme?.bg) {
36
39
  try {
37
- return lines.map((l) => applyBg(l, width, (s) => theme.bg!(bgType, s)));
40
+ return lines.map((l) => applyBg(l, width, (s) => theme!.bg!(bgType, s)));
38
41
  } catch {
39
42
  return lines;
40
43
  }
@@ -50,5 +53,8 @@ if (isMain(import.meta.url)) {
50
53
  assert(plain[1] === "│ hello │", "content line padded flush");
51
54
  assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "long line wrapped");
52
55
  assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg ignored when the theme has no bg");
56
+ const nullTheme = renderBoxLines(null, 24, ["hello"], "customMessageBg");
57
+ assert(nullTheme.every((l) => visibleWidth(l) === 24), "null theme renders an uncolored box instead of throwing");
58
+ assert(nullTheme[0]!.startsWith("╭") && nullTheme[1] === "│ hello │", "null-theme frame and padding intact");
53
59
  console.log("lib/box.ts OK");
54
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Refined aesthetics. Cohesive tools. Built for Pi. Warm themes, rounded tool boxes, tabbed header, compact footer and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",