fix: separate pi-tui-dependent layout utils to fix report generation (#1527)

Report generation in auto-loop uses native dynamic import() which
bypasses jiti's alias resolution. The import chain
metrics.js → mod.js → ui.js → @gsd/pi-tui failed because Node
cannot resolve @gsd/pi-tui from ~/.gsd/agent/extensions/.

Split ANSI-aware layout helpers (padRight, joinColumns, centerLine,
fitColumns) into layout-utils.ts and keep format-utils.ts pure so
report modules can import formatting functions without pulling in
the @gsd/pi-tui dependency.
This commit is contained in:
Jeremy McSpadden 2026-03-19 22:14:03 -05:00 committed by GitHub
parent 2fcbb40c09
commit aa8d3ee059
6 changed files with 72 additions and 54 deletions

View file

@ -2,7 +2,8 @@
// Human-readable display of past auto-mode unit executions.
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { formatDuration, padRight, truncateWithEllipsis } from "../shared/format-utils.js";
import { formatDuration, truncateWithEllipsis } from "../shared/format-utils.js";
import { padRight } from "../shared/layout-utils.js";
import {
getLedger, getProjectTotals, formatCost, formatTokenCount,
aggregateBySlice, aggregateByPhase, aggregateByModel, loadLedgerFromDisk,

View file

@ -20,8 +20,10 @@ import { getAndClearSkills } from "./skill-telemetry.js";
import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
import { parseUnitId } from "./unit-id.js";
// Re-export from shared — canonical implementation lives in format-utils.
export { formatTokenCount } from "../shared/mod.js";
// Re-export from shared — import directly from format-utils to avoid pulling
// in the full barrel (mod.js → ui.js → @gsd/pi-tui) which breaks when loaded
// outside jiti's alias resolution (e.g. dynamic import in auto-loop reports).
export { formatTokenCount } from "../shared/format-utils.js";
// ─── Types ────────────────────────────────────────────────────────────────────

View file

@ -1,12 +1,12 @@
/**
* Shared formatting and layout utilities for TUI dashboard components.
* Shared pure formatting utilities no @gsd/pi-tui dependency.
*
* Consolidates helpers that were previously duplicated across
* auto-dashboard.ts, dashboard-overlay.ts, and visualizer-views.ts.
* ANSI-aware layout helpers (padRight, joinColumns, centerLine, fitColumns)
* live in layout-utils.ts to avoid pulling @gsd/pi-tui into modules that
* run outside jiti's alias resolution (e.g. HTML report generation via
* dynamic import in auto-loop).
*/
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
// ─── Duration Formatting ──────────────────────────────────────────────────────
/** Format a millisecond duration as a compact human-readable string. */
@ -31,45 +31,6 @@ export function formatTokenCount(count: number): string {
return `${(count / 1_000_000).toFixed(2)}M`;
}
// ─── Layout Helpers ───────────────────────────────────────────────────────────
/** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
export function padRight(content: string, width: number): string {
const vis = visibleWidth(content);
return content + " ".repeat(Math.max(0, width - vis));
}
/** Build a line with left-aligned and right-aligned content. */
export function joinColumns(left: string, right: string, width: number): string {
const leftW = visibleWidth(left);
const rightW = visibleWidth(right);
if (leftW + rightW + 2 > width) {
return truncateToWidth(`${left} ${right}`, width);
}
return left + " ".repeat(width - leftW - rightW) + right;
}
/** Center content within `width` (ANSI-aware). */
export function centerLine(content: string, width: number): string {
const vis = visibleWidth(content);
if (vis >= width) return truncateToWidth(content, width);
const leftPad = Math.floor((width - vis) / 2);
return " ".repeat(leftPad) + content;
}
/** Join as many parts as fit within `width`, separated by `separator`. */
export function fitColumns(parts: string[], width: number, separator = " "): string {
const filtered = parts.filter(Boolean);
if (filtered.length === 0) return "";
let result = filtered[0];
for (let i = 1; i < filtered.length; i++) {
const candidate = `${result}${separator}${filtered[i]}`;
if (visibleWidth(candidate) > width) break;
result = candidate;
}
return truncateToWidth(result, width);
}
// ─── Text Truncation ─────────────────────────────────────────────────────────
/** Truncate a string to `maxLength` characters, replacing the last character with an ellipsis if needed. */

View file

@ -0,0 +1,49 @@
/**
* ANSI-aware TUI layout utilities that depend on @gsd/pi-tui.
*
* Separated from format-utils.ts so that modules needing only pure
* formatting (e.g. HTML report generation) can import format-utils
* without pulling in the @gsd/pi-tui dependency which fails when
* loaded outside jiti's alias resolution context.
*/
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
// ─── Layout Helpers ───────────────────────────────────────────────────────────
/** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
export function padRight(content: string, width: number): string {
const vis = visibleWidth(content);
return content + " ".repeat(Math.max(0, width - vis));
}
/** Build a line with left-aligned and right-aligned content. */
export function joinColumns(left: string, right: string, width: number): string {
const leftW = visibleWidth(left);
const rightW = visibleWidth(right);
if (leftW + rightW + 2 > width) {
return truncateToWidth(`${left} ${right}`, width);
}
return left + " ".repeat(width - leftW - rightW) + right;
}
/** Center content within `width` (ANSI-aware). */
export function centerLine(content: string, width: number): string {
const vis = visibleWidth(content);
if (vis >= width) return truncateToWidth(content, width);
const leftPad = Math.floor((width - vis) / 2);
return " ".repeat(leftPad) + content;
}
/** Join as many parts as fit within `width`, separated by `separator`. */
export function fitColumns(parts: string[], width: number, separator = " "): string {
const filtered = parts.filter(Boolean);
if (filtered.length === 0) return "";
let result = filtered[0];
for (let i = 1; i < filtered.length; i++) {
const candidate = `${result}${separator}${filtered[i]}`;
if (visibleWidth(candidate) > width) break;
result = candidate;
}
return truncateToWidth(result, width);
}

View file

@ -13,15 +13,18 @@ export {
stripAnsi,
formatTokenCount,
formatDuration,
padRight,
joinColumns,
centerLine,
fitColumns,
sparkline,
normalizeStringArray,
fileLink,
} from "./format-utils.js";
export {
padRight,
joinColumns,
centerLine,
fitColumns,
} from "./layout-utils.js";
export { shortcutDesc } from "./terminal.js";
export { toPosixPath } from "./path-display.js";
export { showInterviewRound } from "./interview-ui.js";

View file

@ -2,13 +2,15 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
formatDuration,
sparkline,
stripAnsi,
} from "../format-utils.js";
import {
padRight,
joinColumns,
centerLine,
fitColumns,
sparkline,
stripAnsi,
} from "../format-utils.js";
} from "../layout-utils.js";
describe("formatDuration", () => {
it("formats seconds", () => {