diff --git a/nemoclaw/src/banner.test.ts b/nemoclaw/src/banner.test.ts new file mode 100644 index 00000000000..3c3eb29bf7f --- /dev/null +++ b/nemoclaw/src/banner.test.ts @@ -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); + }); +}); diff --git a/nemoclaw/src/banner.ts b/nemoclaw/src/banner.ts new file mode 100644 index 00000000000..0ca9b3954c9 --- /dev/null +++ b/nemoclaw/src/banner.ts @@ -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( + (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}┘`, + ]; +} diff --git a/nemoclaw/src/index.ts b/nemoclaw/src/index.ts index f831d723f42..7b4b75ed931 100644 --- a/nemoclaw/src/index.ts +++ b/nemoclaw/src/index.ts @@ -11,6 +11,7 @@ * time. */ +import { renderBox } from "./banner.js"; import { handleSlashCommand } from "./commands/slash.js"; import { describeOnboardEndpoint, @@ -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(""); } diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 6aeeb038928..11d4593fc23 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -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 "" } diff --git a/src/lib/banner.test.ts b/src/lib/banner.test.ts new file mode 100644 index 00000000000..3c3eb29bf7f --- /dev/null +++ b/src/lib/banner.test.ts @@ -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); + }); +}); diff --git a/src/lib/banner.ts b/src/lib/banner.ts new file mode 100644 index 00000000000..0ca9b3954c9 --- /dev/null +++ b/src/lib/banner.ts @@ -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( + (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}┘`, + ]; +} diff --git a/src/lib/services.ts b/src/lib/services.ts index 6e82bc6764d..053556047f2 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -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"; @@ -289,11 +290,6 @@ export async function startAll(opts: ServiceOptions = {}): Promise { } // 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)) { @@ -304,15 +300,17 @@ export async function startAll(opts: ServiceOptions = {}): Promise { } } - 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(""); }