Skip to content
54 changes: 6 additions & 48 deletions nemoclaw/src/banner.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/** A banner content row; null renders as a blank separator row. */
export type BannerLine = string | null;
// sourceOfTruth: nemoclaw/src/shared/banner-boundary.cts
// This package entry wrapper keeps the ./banner.js import path stable for
// nemoclaw/src/index.ts while the renderer lives in the shared .cts boundary.
import { renderBox as canonicalRenderBox } from "./shared/banner-boundary.cjs";

/** Options for rendering a Unicode terminal banner box. */
export interface RenderBoxOptions {
/** Minimum inner box width, excluding borders. */
minInner?: number;
/** Terminal width to respect. Defaults to process.stdout.columns, then 100. */
columns?: number;
}
export type { BannerLine, RenderBoxOptions } from "./shared/banner-boundary.cjs";

/**
* Render content lines inside a dynamically-sized Unicode box.
*
* The renderer expands to fit long content when the terminal is wide enough and
* otherwise truncates content while preserving a two-space safety gap before the
* closing border. That gap prevents terminal link detectors from treating the
* box-drawing border as part of long URLs or endpoints.
*/
export function renderBox(
lines: BannerLine[],
{ minInner = 53, columns }: RenderBoxOptions = {},
): string[] {
const detectedColumns = columns ?? process.stdout.columns;
const terminalColumns =
Number.isFinite(detectedColumns) && detectedColumns > 0 ? detectedColumns : 100;
const maxInner = Math.max(0, Math.floor(terminalColumns) - 4);
const contentInner = lines.reduce<number>(
(max, line) => (line === null ? max : Math.max(max, line.length + 2)),
minInner,
);
const inner = Math.min(maxInner, Math.max(0, contentInner));

const pad = (line: string): string => {
if (line.length > inner) {
if (inner <= 2) return " ".repeat(inner);
return `${line.slice(0, inner - 2)} `;
}
return line + " ".repeat(inner - line.length);
};

const hBar = "─".repeat(inner);
const blank = " ".repeat(inner);

return [
` ┌${hBar}┐`,
...lines.map((line) => (line === null ? ` │${blank}│` : ` │${pad(line)}│`)),
` └${hBar}┘`,
];
}
export const renderBox = canonicalRenderBox;
64 changes: 64 additions & 0 deletions nemoclaw/src/shared/banner-boundary.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// sourceOfTruth: This is the one implementation of the terminal banner box
// renderer. It is compiled to generated .cjs/.d.cts files by build:cli before
// both the plugin and root CLI are built.
// consumers: The root CLI re-exports renderBox through src/lib/cli/banner.ts
// for src/lib/tunnel/services.ts; the ESM plugin re-exports it through
// nemoclaw/src/banner.ts for nemoclaw/src/index.ts. Keeping one renderer
// prevents the drift already observed between the two former copies.
// regressionTest: src/lib/cli/banner.test.ts and nemoclaw/src/banner.test.ts
// exercise this module through the retained package wrappers.
// removalCondition: remove only when a single package renders the banner.

/** A banner content row; null renders as a blank separator row. */
export type BannerLine = string | null;

/** Options for rendering a Unicode terminal banner box. */
export interface RenderBoxOptions {
/** Minimum inner box width, excluding borders. */
minInner?: number;
/** Terminal width to respect. Defaults to process.stdout.columns, then 100. */
columns?: number;
}

/**
* Render content lines inside a dynamically-sized Unicode box.
*
* The renderer expands to fit long content when the terminal is wide enough and
* otherwise truncates content while preserving a two-space safety gap before the
* closing border. That gap prevents terminal link detectors from treating the
* box-drawing border as part of long URLs or endpoints.
*/
export function renderBox(
lines: BannerLine[],
{ minInner = 53, columns }: RenderBoxOptions = {},
): string[] {
const detectedColumns = columns ?? process.stdout.columns;
const terminalColumns =
Number.isFinite(detectedColumns) && detectedColumns > 0 ? detectedColumns : 100;
const maxInner = Math.max(0, Math.floor(terminalColumns) - 4);
const contentInner = lines.reduce<number>(
(max, line) => (line === null ? max : Math.max(max, line.length + 2)),
minInner,
);
const inner = Math.min(maxInner, Math.max(0, contentInner));

const pad = (line: string): string => {
if (line.length > inner) {
if (inner <= 2) return " ".repeat(inner);
return `${line.slice(0, inner - 2)} `;
}
return line + " ".repeat(inner - line.length);
};

const hBar = "─".repeat(inner);
const blank = " ".repeat(inner);

return [
` ┌${hBar}┐`,
...lines.map((line) => (line === null ? ` │${blank}│` : ` │${pad(line)}│`)),
` └${hBar}┘`,
];
}
1 change: 1 addition & 0 deletions nemoclaw/tsconfig.shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"rootDir": "src"
},
"include": [
"src/shared/banner-boundary.cts",
"src/shared/openshell-policy-boundary.cts",
"src/shared/sandbox-name.cts",
"src/shared/snapshot-sanitizer-boundary.cts"
Expand Down
5 changes: 5 additions & 0 deletions nemoclaw/vitest.project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import path from "node:path";

const repositoryRoot = path.resolve(import.meta.dirname, "..");
const canonicalBannerBoundary = path.resolve(import.meta.dirname, "src/shared/banner-boundary.cts");
const canonicalOpenShellPolicyBoundary = path.resolve(
import.meta.dirname,
"src/shared/openshell-policy-boundary.cts",
Expand Down Expand Up @@ -43,6 +44,10 @@ const pluginVitestProjectOptions = {
// plugin tests exercise the single source of truth rather than a
// possibly-stale build artifact.
alias: [
{
find: /^.*banner-boundary\.cjs$/,
replacement: canonicalBannerBoundary,
},
{
find: /^.*openshell-policy-boundary\.cjs$/,
replacement: canonicalOpenShellPolicyBoundary,
Expand Down
59 changes: 11 additions & 48 deletions src/lib/cli/banner.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,16 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/** A banner content row; null renders as a blank separator row. */
export type BannerLine = string | null;
// sourceOfTruth: nemoclaw/src/shared/banner-boundary.cts
// generatedBoundary: build:cli emits the canonical .cjs/.d.cts before this
// module is compiled (mirrors src/lib/policy/merge.ts). Keep this file
// implementation-free. It keeps the ../cli/banner import path stable for
// src/lib/tunnel/services.ts.
import { renderBox as canonicalRenderBox } from "../../../nemoclaw/dist/shared/banner-boundary.cjs";

/** Options for rendering a Unicode terminal banner box. */
export interface RenderBoxOptions {
/** Minimum inner box width, excluding borders. */
minInner?: number;
/** Terminal width to respect. Defaults to process.stdout.columns, then 100. */
columns?: number;
}
export type {
BannerLine,
RenderBoxOptions,
} from "../../../nemoclaw/dist/shared/banner-boundary.cjs";

/**
* Render content lines inside a dynamically-sized Unicode box.
*
* The renderer expands to fit long content when the terminal is wide enough and
* otherwise truncates content while preserving a two-space safety gap before the
* closing border. That gap prevents terminal link detectors from treating the
* box-drawing border as part of long URLs such as trycloudflare.com links.
*/
export function renderBox(
lines: BannerLine[],
{ minInner = 53, columns }: RenderBoxOptions = {},
): string[] {
const detectedColumns = columns ?? process.stdout.columns;
const terminalColumns =
Number.isFinite(detectedColumns) && detectedColumns > 0 ? detectedColumns : 100;
const maxInner = Math.max(0, Math.floor(terminalColumns) - 4);
const contentInner = lines.reduce<number>(
(max, line) => (line === null ? max : Math.max(max, line.length + 2)),
minInner,
);
const inner = Math.min(maxInner, Math.max(0, contentInner));

const pad = (line: string): string => {
if (line.length > inner) {
if (inner <= 2) return " ".repeat(inner);
return `${line.slice(0, inner - 2)} `;
}
return line + " ".repeat(inner - line.length);
};

const hBar = "─".repeat(inner);
const blank = " ".repeat(inner);

return [
` ┌${hBar}┐`,
...lines.map((line) => (line === null ? ` │${blank}│` : ` │${pad(line)}│`)),
` └${hBar}┘`,
];
}
export const renderBox = canonicalRenderBox;
56 changes: 56 additions & 0 deletions test/package-contract/banner-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

import { describe, expect, it } from "vitest";

const repoRoot = path.join(import.meta.dirname, "..", "..");

type BannerModule = {
renderBox: (
lines: Array<string | null>,
options?: { columns?: number; minInner?: number },
) => string[];
};

// Import the built wrappers by file URL. The compiled CommonJS CLI wrapper
// resolves its own `require("nemoclaw/dist/shared/banner-boundary.cjs")`, so
// this loads the real generated boundary — if that artifact is missing or stale
// the import throws. The source-mode banner tests alias the boundary to its
// .cts source and cannot catch that.
async function importBuilt(...segments: string[]): Promise<BannerModule> {
return (await import(pathToFileURL(path.join(repoRoot, ...segments)).href)) as BannerModule;
}

describe("banner boundary package contract", () => {
it("renders through the generated boundary from both built package wrappers", async () => {
const cli = await importBuilt("dist", "lib", "cli", "banner.js");
const plugin = await importBuilt("nemoclaw", "dist", "banner.js");

// The built CLI wrapper renders through the real compiled boundary. This
// exact output matches the narrow-terminal case in the source banner tests.
expect(cli.renderBox(["abcdef"], { columns: 5 })).toEqual([" ┌─┐", " │ │", " └─┘"]);

// Both built wrappers render identically, so the two packages cannot drift.
const lines = [
" Endpoint: https://integrate.api.nvidia.com/v1",
null,
" Slash: /nemoclaw",
];
expect(cli.renderBox(lines, { columns: 100 })).toEqual(
plugin.renderBox(lines, { columns: 100 }),
);
});

it("ships the generated canonical CJS boundary and its declaration", () => {
const sharedDir = path.join(repoRoot, "nemoclaw", "dist", "shared");
expect(fs.existsSync(path.join(sharedDir, "banner-boundary.cjs"))).toBe(true);
expect(fs.existsSync(path.join(sharedDir, "banner-boundary.d.cts"))).toBe(true);
// The boundary is emitted as .cjs, never a plain .js, so ESM and CommonJS
// consumers resolve the one CommonJS artifact.
expect(fs.existsSync(path.join(sharedDir, "banner-boundary.js"))).toBe(false);
});
});
4 changes: 4 additions & 0 deletions test/plugin-vitest-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ describe("plugin Vitest project contract", () => {
expect(pluginVitestProjectOptions.test.setupFiles).toEqual([fixtureUmaskSetup]);
expect(pluginVitestProjectOptions.test.include).toEqual(["nemoclaw/src/**/*.test.ts"]);
expect(policyAliases).toEqual([
{
find: /^.*banner-boundary\.cjs$/,
replacement: path.join(repositoryRoot, "nemoclaw/src/shared/banner-boundary.cts"),
},
{
find: /^.*openshell-policy-boundary\.cjs$/,
replacement: path.join(repositoryRoot, "nemoclaw/src/shared/openshell-policy-boundary.cts"),
Expand Down
5 changes: 5 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { vitestWatchTriggerPatterns } from "./test/helpers/vitest-watch-triggers
const { isCi, silent } = resolveVitestFeedback();
const LIVE_E2E_PROJECT_TIMEOUT_MS = 30 * 60 * 1000;
const runLiveE2E = shouldRunLiveE2E();
const canonicalBannerBoundary = path.resolve("nemoclaw/src/shared/banner-boundary.cts");
const canonicalOpenShellPolicyBoundary = path.resolve(
"nemoclaw/src/shared/openshell-policy-boundary.cts",
);
Expand All @@ -31,6 +32,10 @@ const canonicalSnapshotSanitizerBoundary = path.resolve(
// source-mode test projects exercise the single source of truth rather than a
// possibly-stale build artifact.
const canonicalSourceAliases = [
{
find: /^.*banner-boundary\.cjs$/,
replacement: canonicalBannerBoundary,
},
{
find: /^.*openshell-policy-boundary\.cjs$/,
replacement: canonicalOpenShellPolicyBoundary,
Expand Down
Loading