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;