From 182e4a5f856e24cd9a350b4ec7a85e9a27fc32ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sat, 21 Mar 2026 09:19:41 -0600 Subject: [PATCH] fix: lazy-load @gsd/pi-tui in shared/ui.ts to prevent /exit crash (#1761) The eager top-level import of @gsd/pi-tui in shared/ui.ts caused any command that transitively loaded the shared/mod barrel (including /exit) to fail when extensions were loaded from ~/.gsd/agent/extensions/ where @gsd/pi-tui has no node_modules resolution path. Replaced the static import with a lazy require() accessor that defers resolution to the first makeUI() call, so modules that import shared/mod for non-TUI exports (constants, format utils, etc.) no longer trigger the unresolvable dependency. Closes #1640 Co-authored-by: Claude Opus 4.6 (1M context) --- .../gsd/tests/lazy-pi-tui-import.test.ts | 46 +++++++++++++++++++ src/resources/extensions/shared/ui.ts | 18 +++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts diff --git a/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts b/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts new file mode 100644 index 000000000..c95e26b91 --- /dev/null +++ b/src/resources/extensions/gsd/tests/lazy-pi-tui-import.test.ts @@ -0,0 +1,46 @@ +// Verifies that shared/ui.ts does NOT eagerly import @gsd/pi-tui at the +// module level. An eager top-level import causes /exit (and any other +// command that transitively loads shared/mod → shared/ui) to blow up when +// @gsd/pi-tui cannot be resolved — e.g. extensions copied to +// ~/.gsd/agent/extensions/ where no node_modules tree exists. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const uiSrc = readFileSync(join(__dirname, "../../shared/ui.ts"), "utf-8"); + +test("shared/ui.ts has no top-level import from @gsd/pi-tui", () => { + // Match lines like: import { ... } from "@gsd/pi-tui"; + // But ignore type-only imports (import type / import("@gsd/pi-tui").X) + // and comments. + const lines = uiSrc.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + // Skip comments and type-only references + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; + // Skip type-only import statements + if (trimmed.startsWith("import type ")) continue; + // Skip inline import() type annotations (erased at runtime) + if (/import\(["']@gsd\/pi-tui["']\)/.test(trimmed) && !trimmed.startsWith("import ")) continue; + + // Flag any eager import statement pulling runtime values from @gsd/pi-tui + if (/^\s*import\s+\{/.test(line) && line.includes("@gsd/pi-tui")) { + assert.fail( + `Found eager top-level import from @gsd/pi-tui — this must be lazy.\n` + + `Line: ${trimmed}`, + ); + } + } +}); + +test("shared/ui.ts lazily resolves @gsd/pi-tui inside makeUI", () => { + // The lazy accessor pattern: require("@gsd/pi-tui") inside a function body + assert.ok( + uiSrc.includes('require("@gsd/pi-tui")'), + "Expected a lazy require(\"@gsd/pi-tui\") call inside a function body", + ); +}); diff --git a/src/resources/extensions/shared/ui.ts b/src/resources/extensions/shared/ui.ts index 7c2e13239..2945110e2 100644 --- a/src/resources/extensions/shared/ui.ts +++ b/src/resources/extensions/shared/ui.ts @@ -29,7 +29,21 @@ */ import { type Theme } from "@gsd/pi-coding-agent"; -import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@gsd/pi-tui"; + +// ─── Lazy @gsd/pi-tui resolution ───────────────────────────────────────────── +// Deferred to first makeUI() call so that importing this module (via the +// shared/mod barrel) does not blow up when @gsd/pi-tui cannot be resolved — +// e.g. for commands like /exit that never render TUI components. + +type PiTuiFns = typeof import("@gsd/pi-tui"); +let _piTui: PiTuiFns | undefined; +function piTui(): PiTuiFns { + if (!_piTui) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + _piTui = require("@gsd/pi-tui") as PiTuiFns; + } + return _piTui; +} // ─── Glyphs ─────────────────────────────────────────────────────────────────── // Change these to restyle every cursor, checkbox, and indicator at once. @@ -201,6 +215,8 @@ export interface UI { export function makeUI(theme: Theme, width: number): UI { // ── Internal helpers ─────────────────────────────────────────────────────── + const { truncateToWidth, visibleWidth, wrapTextWithAnsi } = piTui(); + const add = (s: string): string => truncateToWidth(s, width); const wrap = (s: string): string[] => wrapTextWithAnsi(s, width);