@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 +7 -0
- package/extensions/section-headers.ts +28 -6
- package/extensions/ui-render-tweaks.ts +17 -15
- package/lib/ansi.ts +18 -0
- package/lib/box.ts +11 -5
- package/package.json +1 -1
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 || (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
145
|
-
const bgOpen = ansiBgOpen(
|
|
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),
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
`${
|
|
241
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
38
|
+
if (bgType && theme?.bg) {
|
|
36
39
|
try {
|
|
37
|
-
return lines.map((l) => applyBg(l, width, (s) => theme
|
|
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.
|
|
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",
|