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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-21 09:19:41 -06:00 committed by GitHub
parent 305b426f5f
commit 182e4a5f85
2 changed files with 63 additions and 1 deletions

View file

@ -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",
);
});

View file

@ -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);