From 83ed5eef1f94c6b2d001b3f06fe2fcaf573ad30c Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 14 Jul 2026 19:14:16 +0000 Subject: [PATCH] fix(tui): guarded DynamicBorder against uninitialized module-level theme Extensions importing legacy pi UI components (e.g. DynamicBorder from @earendil-works/pi-coding-agent) receive a second src module graph in npm-package installs. Host startup assigns the module-level `theme` only inside the bundled dist copy, so the src-graph copy stays undefined and DynamicBorder.render dereferenced `theme.boxRound` unconditionally, throwing "undefined is not an object" and taking down the whole TUI. Route the default color through the existing `fgOrPlain` guard and fall back to the default rounded glyph when `theme` is undefined, matching the `typeof theme === "undefined"` degradation already used by fgOrPlain and getSettingsListTheme. Fixes #5366 --- .../__tests__/dynamic-border.test.ts | 36 +++++++++++++++++++ .../src/modes/components/dynamic-border.ts | 15 ++++---- 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/__tests__/dynamic-border.test.ts diff --git a/packages/coding-agent/src/modes/components/__tests__/dynamic-border.test.ts b/packages/coding-agent/src/modes/components/__tests__/dynamic-border.test.ts new file mode 100644 index 000000000..d6134a345 --- /dev/null +++ b/packages/coding-agent/src/modes/components/__tests__/dynamic-border.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { getThemeByName, setThemeInstance, theme } from "../../theme/theme"; +import { DynamicBorder } from "../dynamic-border"; + +describe("DynamicBorder", () => { + // Regression for #5366: extensions importing legacy pi UI components get a + // second `src` module graph whose module-level `theme` is never assigned by + // host startup. `render()` must degrade to plain glyphs instead of throwing + // "undefined is not an object (evaluating 'theme.boxRound')" and killing the + // TUI. Bun isolates modules per test file, so `theme` is undefined here. + it("renders plain glyphs when the module-level theme is uninitialized", () => { + expect(theme).toBeUndefined(); + + const border = new DynamicBorder(str => `<${str}>`); + const lines = border.render(4); + + expect(lines).toEqual(["<────>"]); + }); + + it("degrades the default color to plain text when theme is uninitialized", () => { + expect(theme).toBeUndefined(); + + // The default color function must not dereference `theme.fg`. + const border = new DynamicBorder(); + expect(border.render(3)).toEqual(["───"]); + }); + + it("paints with theme.boxRound.horizontal once the theme is initialized", async () => { + const loaded = await getThemeByName("dark"); + if (!loaded) throw new Error("theme unavailable"); + setThemeInstance(loaded); + + const border = new DynamicBorder(str => `<${str}>`); + expect(border.render(3)).toEqual([`<${loaded.boxRound.horizontal.repeat(3)}>`]); + }); +}); diff --git a/packages/coding-agent/src/modes/components/dynamic-border.ts b/packages/coding-agent/src/modes/components/dynamic-border.ts index fb70166eb..efec92eb1 100644 --- a/packages/coding-agent/src/modes/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/components/dynamic-border.ts @@ -1,19 +1,21 @@ import type { Component } from "@oh-my-pi/pi-tui"; -import { theme } from "../../modes/theme/theme"; +import { fgOrPlain, theme } from "../../modes/theme/theme"; /** * Dynamic border component that adjusts to viewport width. * - * Note: When used from hooks loaded via jiti, the global `theme` may be undefined - * because jiti creates a separate module cache. Always pass an explicit color - * function when using DynamicBorder in components exported for hook use. + * Note: the module-level `theme` may be `undefined` — when loaded through jiti + * (separate module cache) or from a second `src` module graph in npm-package + * installs, where the host bundle assigns `theme` but this copy never sees it + * (issue #5366). Both the default color and `render()` degrade to plain, + * unstyled output instead of crashing the TUI. */ export class DynamicBorder implements Component { #color: (str: string) => string; #cachedWidth = -1; #cachedLines: string[] | undefined; - constructor(color: (str: string) => string = str => theme.fg("border", str)) { + constructor(color: (str: string) => string = str => fgOrPlain("border", str)) { this.#color = color; } @@ -26,7 +28,8 @@ export class DynamicBorder implements Component { if (this.#cachedLines && this.#cachedWidth === width) { return this.#cachedLines; } - const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))]; + const horizontal = typeof theme === "undefined" ? "─" : theme.boxRound.horizontal; + const lines = [this.#color(horizontal.repeat(Math.max(1, width)))]; this.#cachedWidth = width; this.#cachedLines = lines; return lines;