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

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

import { renderBox } from "./banner.js";

describe("renderBox", () => {
// lines[0] is " ┌" + hBar + "┐", so length - 4 = inner width
const innerWidth = (lines: string[]) => lines[0].length - 4;

afterEach(() => {
vi.restoreAllMocks();
});

it("returns top border, content lines, and bottom border", () => {
const lines = renderBox([" Hello"]);
expect(lines[0]).toMatch(/^ ┌─+┐$/);
expect(lines[lines.length - 1]).toMatch(/^ └─+┘$/);
expect(lines).toHaveLength(3); // top + 1 content + bottom
});

it("respects default minInner of 53", () => {
expect(innerWidth(renderBox([" short"]))).toBeGreaterThanOrEqual(53);
});

it("respects a custom minInner", () => {
expect(innerWidth(renderBox([" hi"], { minInner: 20 }))).toBeGreaterThanOrEqual(20);
});

it("renders null entries as blank box lines", () => {
const lines = renderBox([null]);
expect(lines[1]).toMatch(/^ │ +│$/);
});

it("all lines have equal length — box is aligned", () => {
const lines = renderBox([" short", null, " a much longer line here"]);
const lengths = lines.map((l) => l.length);
expect(new Set(lengths).size).toBe(1);
});

it("expands inner width to fit a long content line", () => {
const longLine = " " + "x".repeat(80);
const [, contentLine] = renderBox([longLine], { minInner: 53 });
expect(contentLine).toContain(longLine);
expect(contentLine.startsWith(" │")).toBe(true);
expect(contentLine.endsWith("│")).toBe(true);
});

it("caps inner width at terminal columns minus 4", () => {
vi.spyOn(process.stdout, "columns", "get").mockReturnValue(70);
const veryLongLine = " " + "x".repeat(200);
const [topBorder] = renderBox([veryLongLine]);
expect(topBorder.length - 4).toBeLessThanOrEqual(66); // 70 - 4
});

it("falls back to 100-column width when stdout.columns is undefined", () => {
vi.spyOn(process.stdout, "columns", "get").mockReturnValue(
undefined as unknown as number,
);
const veryLongLine = " " + "x".repeat(200);
const [topBorder] = renderBox([veryLongLine]);
expect(topBorder.length - 4).toBeLessThanOrEqual(96); // 100 - 4
});

it("does not throw when content exceeds capped inner width", () => {
vi.spyOn(process.stdout, "columns", "get").mockReturnValue(40);
expect(() => renderBox([" " + "x".repeat(100)])).not.toThrow();
});

it("always provides at least 2 trailing spaces before the closing border", () => {
// Core invariant of the PR: padEnd(fixed) was the bug. The +2 in contentMax
// guarantees inner >= longestLine.length + 2 for every line in the box.
// Mock columns so the test is deterministic regardless of terminal width.
vi.spyOn(process.stdout, "columns", "get").mockReturnValue(120);
const url = "https://abc-defgh-ijklmn-opqr.trycloudflare.com";
const urlLine = " Public URL: " + url;
const lines = renderBox([urlLine]);
const contentLine = lines[1];
// Strip the " │" prefix and "│" suffix, then check trailing spaces
const content = contentLine.slice(3, -1);
expect(content.endsWith(" ")).toBe(true);
});
});
35 changes: 35 additions & 0 deletions nemoclaw/src/banner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* renderBox — render content lines inside a Unicode box.
*
* Each entry in `lines` is either a pre-assembled content string or `null`
* for a blank separator row. The inner width is computed as:
*
* min(terminal_cols - 4, max(minInner, longest_line + 2))
*
* The `-4` accounts for the two-space indent and the `│` border on each side.
* This ensures the box never overflows the terminal regardless of content length.
*/
export function renderBox(
lines: (string | null)[],
{ minInner = 53 }: { minInner?: number } = {},
): string[] {
const termCols = Math.max(60, Number(process.stdout.columns || 100));
const maxInner = termCols - 4;
const contentMax = lines.reduce<number>(
(m, l) => (l === null ? m : Math.max(m, l.length + 2)),
minInner,
);
const inner = Math.min(maxInner, contentMax);
const pad = (s: string) => s + " ".repeat(Math.max(0, inner - s.length));
const hBar = "─".repeat(inner);
const blank = " ".repeat(inner);

return [
` ┌${hBar}┐`,
...lines.map((l) => (l === null ? ` │${blank}│` : ` │${pad(l)}│`)),
` └${hBar}┘`,
];
}
19 changes: 11 additions & 8 deletions nemoclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* time.
*/

import { renderBox } from "./banner.js";
import { handleSlashCommand } from "./commands/slash.js";
import {
describeOnboardEndpoint,
Expand Down Expand Up @@ -317,14 +318,16 @@ export default function register(api: OpenClawPluginApi): void {
);
}

const lines: (string | null)[] = [
" NemoClaw registered",
null,
" Endpoint: " + bannerEndpoint,
" Provider: " + bannerProvider,
" Model: " + bannerModel,
" Slash: /nemoclaw",
];

api.logger.info("");
api.logger.info(" ┌─────────────────────────────────────────────────────┐");
api.logger.info(" │ NemoClaw registered │");
api.logger.info(" │ │");
api.logger.info(` │ Endpoint: ${bannerEndpoint.padEnd(40)}│`);
api.logger.info(` │ Provider: ${bannerProvider.padEnd(40)}│`);
api.logger.info(` │ Model: ${bannerModel.padEnd(40)}│`);
api.logger.info(" │ Slash: /nemoclaw │");
api.logger.info(" └─────────────────────────────────────────────────────┘");
for (const line of renderBox(lines)) api.logger.info(line);
api.logger.info("");
}
47 changes: 37 additions & 10 deletions scripts/start-services.sh
Original file line number Diff line number Diff line change
Expand Up @@ -144,24 +144,51 @@ do_start() {
fi

# Print banner
echo ""
echo " ┌─────────────────────────────────────────────────────┐"
echo " │ NemoClaw Services │"
echo " │ │"

local tunnel_url=""
if [ -f "$PIDDIR/cloudflared.log" ]; then
tunnel_url="$(grep -o 'https://[a-z0-9-]*\.trycloudflare\.com' "$PIDDIR/cloudflared.log" 2>/dev/null | head -1 || true)"
fi

# Compute box inner width: max(minInner, longest_content + 2), capped at terminal width - 4.
# The -4 accounts for the two-space indent and the │ border on each side.
local url_label=" Public URL: "
local messaging_text=" Messaging: via OpenClaw native channels (if configured)"
local footer_text=" Run 'openshell term' to monitor egress approvals"
local title_text=" NemoClaw Services"

local min_inner=53
local inner=$min_inner

local messaging_inner=$(( ${#messaging_text} + 2 ))
[ "$messaging_inner" -gt "$inner" ] && inner=$messaging_inner

local footer_inner=$(( ${#footer_text} + 2 ))
[ "$footer_inner" -gt "$inner" ] && inner=$footer_inner

if [ -n "$tunnel_url" ]; then
printf " │ Public URL: %-40s│\n" "$tunnel_url"
local url_inner=$(( ${#url_label} + ${#tunnel_url} + 2 ))
[ "$url_inner" -gt "$inner" ] && inner=$url_inner
fi

echo " │ Messaging: via OpenClaw native channels (if configured) │"
echo " │ │"
echo " │ Run 'openshell term' to monitor egress approvals │"
echo " └─────────────────────────────────────────────────────┘"
local term_cols="${COLUMNS:-80}"
local max_inner=$(( term_cols - 4 ))
[ "$max_inner" -lt 56 ] && max_inner=56 # match TypeScript floor of Math.max(60, cols) - 4
[ "$inner" -gt "$max_inner" ] && inner=$max_inner

local h_bar
h_bar=$(awk -v n="$inner" 'BEGIN { for (i = 0; i < n; i++) printf "─" }')

echo ""
printf " ┌%s┐\n" "$h_bar"
printf " │%s%-*s│\n" "$title_text" $((inner - ${#title_text})) ""
printf " │%-*s│\n" "$inner" ""
if [ -n "$tunnel_url" ]; then
printf " │%s%s%-*s│\n" "$url_label" "$tunnel_url" $((inner - ${#url_label} - ${#tunnel_url})) ""
fi
printf " │%s%-*s│\n" "$messaging_text" $((inner - ${#messaging_text})) ""
printf " │%-*s│\n" "$inner" ""
printf " │%s%-*s│\n" "$footer_text" $((inner - ${#footer_text})) ""
printf " └%s┘\n" "$h_bar"
echo ""
}

Expand Down
84 changes: 84 additions & 0 deletions src/lib/banner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import { renderBox } from "./banner.js";

describe("renderBox", () => {
// lines[0] is " ┌" + hBar + "┐", so length - 4 = inner width
const innerWidth = (lines: string[]) => lines[0].length - 4;

afterEach(() => {
vi.restoreAllMocks();
});

it("returns top border, content lines, and bottom border", () => {
const lines = renderBox([" Hello"]);
expect(lines[0]).toMatch(/^ ┌─+┐$/);
expect(lines[lines.length - 1]).toMatch(/^ └─+┘$/);
expect(lines).toHaveLength(3); // top + 1 content + bottom
});

it("respects default minInner of 53", () => {
expect(innerWidth(renderBox([" short"]))).toBeGreaterThanOrEqual(53);
});

it("respects a custom minInner", () => {
expect(innerWidth(renderBox([" hi"], { minInner: 20 }))).toBeGreaterThanOrEqual(20);
});

it("renders null entries as blank box lines", () => {
const lines = renderBox([null]);
expect(lines[1]).toMatch(/^ │ +│$/);
});

it("all lines have equal length — box is aligned", () => {
const lines = renderBox([" short", null, " a much longer line here"]);
const lengths = lines.map((l) => l.length);
expect(new Set(lengths).size).toBe(1);
});

it("expands inner width to fit a long content line", () => {
const longLine = " " + "x".repeat(80);
const [, contentLine] = renderBox([longLine], { minInner: 53 });
expect(contentLine).toContain(longLine);
expect(contentLine.startsWith(" │")).toBe(true);
expect(contentLine.endsWith("│")).toBe(true);
});

it("caps inner width at terminal columns minus 4", () => {
vi.spyOn(process.stdout, "columns", "get").mockReturnValue(70);
const veryLongLine = " " + "x".repeat(200);
const [topBorder] = renderBox([veryLongLine]);
expect(topBorder.length - 4).toBeLessThanOrEqual(66); // 70 - 4
});

it("falls back to 100-column width when stdout.columns is undefined", () => {
vi.spyOn(process.stdout, "columns", "get").mockReturnValue(
undefined as unknown as number,
);
const veryLongLine = " " + "x".repeat(200);
const [topBorder] = renderBox([veryLongLine]);
expect(topBorder.length - 4).toBeLessThanOrEqual(96); // 100 - 4
});

it("does not throw when content exceeds capped inner width", () => {
vi.spyOn(process.stdout, "columns", "get").mockReturnValue(40);
expect(() => renderBox([" " + "x".repeat(100)])).not.toThrow();
});

it("always provides at least 2 trailing spaces before the closing border", () => {
// Core invariant of the PR: padEnd(fixed) was the bug. The +2 in contentMax
// guarantees inner >= longestLine.length + 2 for every line in the box.
// Mock columns so the test is deterministic regardless of terminal width.
vi.spyOn(process.stdout, "columns", "get").mockReturnValue(120);
const url = "https://abc-defgh-ijklmn-opqr.trycloudflare.com";
const urlLine = " Public URL: " + url;
const lines = renderBox([urlLine]);
const contentLine = lines[1];
// Strip the " │" prefix and "│" suffix, then check trailing spaces
const content = contentLine.slice(3, -1);
expect(content.endsWith(" ")).toBe(true);
});
});
35 changes: 35 additions & 0 deletions src/lib/banner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* renderBox — render content lines inside a Unicode box.
*
* Each entry in `lines` is either a pre-assembled content string or `null`
* for a blank separator row. The inner width is computed as:
*
* min(terminal_cols - 4, max(minInner, longest_line + 2))
*
* The `-4` accounts for the two-space indent and the `│` border on each side.
* This ensures the box never overflows the terminal regardless of content length.
*/
export function renderBox(
lines: (string | null)[],
{ minInner = 53 }: { minInner?: number } = {},
): string[] {
const termCols = Math.max(60, Number(process.stdout.columns || 100));
const maxInner = termCols - 4;
const contentMax = lines.reduce<number>(
(m, l) => (l === null ? m : Math.max(m, l.length + 2)),
minInner,
);
const inner = Math.min(maxInner, contentMax);
const pad = (s: string) => s + " ".repeat(Math.max(0, inner - s.length));
const hBar = "─".repeat(inner);
const blank = " ".repeat(inner);

return [
` ┌${hBar}┐`,
...lines.map((l) => (l === null ? ` │${blank}│` : ` │${pad(l)}│`)),
` └${hBar}┘`,
];
}
24 changes: 11 additions & 13 deletions src/lib/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "node:fs";
import { join } from "node:path";

import { renderBox } from "./banner.js";
import { DASHBOARD_PORT } from "./ports";
import { buildSubprocessEnv } from "./subprocess-env";

Expand Down Expand Up @@ -289,11 +290,6 @@ export async function startAll(opts: ServiceOptions = {}): Promise<void> {
}

// Banner
console.log("");
console.log(" ┌─────────────────────────────────────────────────────┐");
console.log(" │ NemoClaw Services │");
console.log(" │ │");

let tunnelUrl = "";
const cfLogFile = join(pidDir, "cloudflared.log");
if (isRunning(pidDir, "cloudflared") && existsSync(cfLogFile)) {
Expand All @@ -304,15 +300,17 @@ export async function startAll(opts: ServiceOptions = {}): Promise<void> {
}
}

if (tunnelUrl) {
console.log(` │ Public URL: ${tunnelUrl.padEnd(40)}│`);
}
const lines: (string | null)[] = [
" NemoClaw Services",
null,
...(tunnelUrl ? [" Public URL: " + tunnelUrl] : []),
" Messaging: via OpenClaw native channels (if configured)",
null,
" Run 'openshell term' to monitor egress approvals",
];

console.log(" │ Messaging: via OpenClaw native channels (if configured) │");

console.log(" │ │");
console.log(" │ Run 'openshell term' to monitor egress approvals │");
console.log(" └─────────────────────────────────────────────────────┘");
console.log("");
for (const line of renderBox(lines)) console.log(line);
console.log("");
}

Expand Down