From c399ce4cfc5096dbf25f34184a29923051fb17a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 14:17:03 +0000 Subject: [PATCH 1/6] =?UTF-8?q?fix(services):=20expand=20banner=20box=20to?= =?UTF-8?q?=20prevent=20=E2=94=82=20from=20corrupting=20public=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the cloudflare tunnel URL is longer than 40 chars (typical for trycloudflare.com URLs like ~50 chars), padEnd(40) adds no padding, so the closing │ box character is printed immediately after the URL. Terminals auto-detect this as part of the URL and Punycode-encode the │ (U+2502), turning `.com│` into `.xn--com-hs4a`, producing a broken non-clickable URL. Fix by computing the box inner width dynamically: default 53 chars, but expanded to fit `url + 2 trailing spaces` when the URL is longer. All box lines are rendered with the computed width so borders stay aligned. Same fix applied to scripts/start-services.sh (bash variant). https://claude.ai/code/session_01VBztUR9CyS1QtWaDf5zCFp --- scripts/start-services.sh | 30 ++++++++++++++++++++---------- src/lib/services.ts | 36 ++++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 6aeeb038928..4d3605782ad 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -144,24 +144,34 @@ 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 + # Expand box width if the URL is longer than the default inner width (53) + local min_inner=53 + local inner=$min_inner if [ -n "$tunnel_url" ]; then - printf " │ Public URL: %-40s│\n" "$tunnel_url" + local url_inner=$(( ${#tunnel_url} + 17 )) # " Public URL: " = 15 chars + 2 trailing spaces + [ "$url_inner" -gt "$inner" ] && inner=$url_inner fi + # Messaging line: " Messaging: via OpenClaw native channels (if configured)" = 59 chars + 2 padding + [ $(( 59 + 2 )) -gt "$inner" ] && inner=$(( 59 + 2 )) + local h_bar + h_bar="$(printf '%*s' "$inner" '' | tr ' ' '─')" - echo " │ Messaging: via OpenClaw native channels (if configured) │" - echo " │ │" - echo " │ Run 'openshell term' to monitor egress approvals │" - echo " └─────────────────────────────────────────────────────┘" + echo "" + printf " ┌%s┐\n" "$h_bar" + printf " │ NemoClaw Services%-*s│\n" $(( inner - 19 )) "" + printf " │%-*s│\n" "$inner" "" + if [ -n "$tunnel_url" ]; then + printf " │ Public URL: %-*s│\n" $(( inner - 15 )) "$tunnel_url" + fi + printf " │ Messaging: via OpenClaw native channels (if configured)%-*s│\n" $(( inner - 59 )) "" + printf " │%-*s│\n" "$inner" "" + printf " │ Run 'openshell term' to monitor egress approvals%-*s│\n" $(( inner - 50 )) "" + printf " └%s┘\n" "$h_bar" echo "" } diff --git a/src/lib/services.ts b/src/lib/services.ts index 6e82bc6764d..3ca1c804f29 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -289,11 +289,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 +299,32 @@ export async function startAll(opts: ServiceOptions = {}): Promise { } } - if (tunnelUrl) { - console.log(` │ Public URL: ${tunnelUrl.padEnd(40)}│`); - } + const titleText = " NemoClaw Services"; + const urlPrefix = " Public URL: "; + const messagingText = " Messaging: via OpenClaw native channels (if configured)"; + const footerText = " Run 'openshell term' to monitor egress approvals"; - console.log(" │ Messaging: via OpenClaw native channels (if configured) │"); + // Expand box width if the URL or messaging line is longer than the default inner width (53) + const minInner = 53; + const contentMax = Math.max(messagingText.length + 2, footerText.length + 2); + const inner = tunnelUrl + ? Math.max(minInner, contentMax, urlPrefix.length + tunnelUrl.length + 2) + : Math.max(minInner, contentMax); - console.log(" │ │"); - console.log(" │ Run 'openshell term' to monitor egress approvals │"); - console.log(" └─────────────────────────────────────────────────────┘"); + const pad = (s: string) => s + " ".repeat(inner - s.length); + const hBar = "─".repeat(inner); + + console.log(""); + console.log(` ┌${hBar}┐`); + console.log(` │${pad(titleText)}│`); + console.log(` │${" ".repeat(inner)}│`); + if (tunnelUrl) { + console.log(` │${pad(urlPrefix + tunnelUrl)}│`); + } + console.log(` │${pad(messagingText)}│`); + console.log(` │${" ".repeat(inner)}│`); + console.log(` │${pad(footerText)}│`); + console.log(` └${hBar}┘`); console.log(""); } From 45e37e7d3380ce0235acbd3040d4f5697c196a49 Mon Sep 17 00:00:00 2001 From: Mauro Druwel Date: Fri, 3 Apr 2026 17:17:05 +0200 Subject: [PATCH 2/6] fix(plugin): expand registration banner box dynamically for long values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same dynamic inner-width logic used in src/lib/services.ts to the NemoClaw registered banner in nemoclaw/src/index.ts. The old code used hardcoded .padEnd(40) which would push the closing │ beyond the fixed 53-char horizontal bars whenever an endpoint, provider, or model string exceeded 40 characters, making all lines different lengths. Now inner width = max(53, longestValue + prefix(13) + 2 trailing spaces), so all lines are always the same length regardless of value lengths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nemoclaw/src/index.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/nemoclaw/src/index.ts b/nemoclaw/src/index.ts index f831d723f42..2e344a26662 100644 --- a/nemoclaw/src/index.ts +++ b/nemoclaw/src/index.ts @@ -317,14 +317,21 @@ export default function register(api: OpenClawPluginApi): void { ); } + const bannerPrefix = 13; // length of " Endpoint: " / " Provider: " / " Model: " + const bannerMinInner = 53; + const bannerMaxValueLen = Math.max(bannerEndpoint.length, bannerProvider.length, bannerModel.length); + const bannerInner = Math.max(bannerMinInner, bannerPrefix + bannerMaxValueLen + 2); + const bannerPad = (s: string) => s + " ".repeat(bannerInner - s.length); + const bannerHBar = "─".repeat(bannerInner); + 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(" └─────────────────────────────────────────────────────┘"); + api.logger.info(` ┌${bannerHBar}┐`); + api.logger.info(` │${bannerPad(" NemoClaw registered")}│`); + api.logger.info(` │${" ".repeat(bannerInner)}│`); + api.logger.info(` │${bannerPad(" Endpoint: " + bannerEndpoint)}│`); + api.logger.info(` │${bannerPad(" Provider: " + bannerProvider)}│`); + api.logger.info(` │${bannerPad(" Model: " + bannerModel)}│`); + api.logger.info(` │${bannerPad(" Slash: /nemoclaw")}│`); + api.logger.info(` └${bannerHBar}┘`); api.logger.info(""); } From 965aca47d574093bcbc5332f6ce0f15b605910cb Mon Sep 17 00:00:00 2001 From: Mauro Druwel Date: Fri, 3 Apr 2026 17:21:42 +0200 Subject: [PATCH 3/6] fix(services): replace tr with bash loop for Unicode h-bar generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tr is a byte-level tool — 'tr " " "─"' only emits the first byte (\xe2) of the 3-byte UTF-8 sequence for ─ (U+2500), producing invalid UTF-8 that renders as garbled characters in the terminal. Replace with a bash += loop which correctly appends the full multi-byte character on each iteration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/start-services.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 4d3605782ad..c603f112540 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -158,8 +158,8 @@ do_start() { fi # Messaging line: " Messaging: via OpenClaw native channels (if configured)" = 59 chars + 2 padding [ $(( 59 + 2 )) -gt "$inner" ] && inner=$(( 59 + 2 )) - local h_bar - h_bar="$(printf '%*s' "$inner" '' | tr ' ' '─')" + local h_bar="" + for ((i = 0; i < inner; i++)); do h_bar+="─"; done echo "" printf " ┌%s┐\n" "$h_bar" From e5ae42b5989561df50a22837b26ebe4c9b36fc44 Mon Sep 17 00:00:00 2001 From: Mauro Druwel Date: Fri, 3 Apr 2026 17:25:54 +0200 Subject: [PATCH 4/6] chore(plugin): apply prettier formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nemoclaw/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nemoclaw/src/index.ts b/nemoclaw/src/index.ts index 2e344a26662..317d9d4f2e6 100644 --- a/nemoclaw/src/index.ts +++ b/nemoclaw/src/index.ts @@ -319,7 +319,11 @@ export default function register(api: OpenClawPluginApi): void { const bannerPrefix = 13; // length of " Endpoint: " / " Provider: " / " Model: " const bannerMinInner = 53; - const bannerMaxValueLen = Math.max(bannerEndpoint.length, bannerProvider.length, bannerModel.length); + const bannerMaxValueLen = Math.max( + bannerEndpoint.length, + bannerProvider.length, + bannerModel.length, + ); const bannerInner = Math.max(bannerMinInner, bannerPrefix + bannerMaxValueLen + 2); const bannerPad = (s: string) => s + " ".repeat(bannerInner - s.length); const bannerHBar = "─".repeat(bannerInner); From 8a0c7e7752620f25d687a14bf69f4f9ce17daf73 Mon Sep 17 00:00:00 2001 From: Mauro Druwel Date: Fri, 3 Apr 2026 17:30:34 +0200 Subject: [PATCH 5/6] chore(services): apply shfmt formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/start-services.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/start-services.sh b/scripts/start-services.sh index c603f112540..b6a779ca49a 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -153,7 +153,7 @@ do_start() { local min_inner=53 local inner=$min_inner if [ -n "$tunnel_url" ]; then - local url_inner=$(( ${#tunnel_url} + 17 )) # " Public URL: " = 15 chars + 2 trailing spaces + local url_inner=$((${#tunnel_url} + 17)) # " Public URL: " = 15 chars + 2 trailing spaces [ "$url_inner" -gt "$inner" ] && inner=$url_inner fi # Messaging line: " Messaging: via OpenClaw native channels (if configured)" = 59 chars + 2 padding @@ -163,14 +163,14 @@ do_start() { echo "" printf " ┌%s┐\n" "$h_bar" - printf " │ NemoClaw Services%-*s│\n" $(( inner - 19 )) "" + printf " │ NemoClaw Services%-*s│\n" $((inner - 19)) "" printf " │%-*s│\n" "$inner" "" if [ -n "$tunnel_url" ]; then - printf " │ Public URL: %-*s│\n" $(( inner - 15 )) "$tunnel_url" + printf " │ Public URL: %-*s│\n" $((inner - 15)) "$tunnel_url" fi - printf " │ Messaging: via OpenClaw native channels (if configured)%-*s│\n" $(( inner - 59 )) "" + printf " │ Messaging: via OpenClaw native channels (if configured)%-*s│\n" $((inner - 59)) "" printf " │%-*s│\n" "$inner" "" - printf " │ Run 'openshell term' to monitor egress approvals%-*s│\n" $(( inner - 50 )) "" + printf " │ Run 'openshell term' to monitor egress approvals%-*s│\n" $((inner - 50)) "" printf " └%s┘\n" "$h_bar" echo "" } From 30693a2fe2bc49c847285640969fc9a7b26a1e41 Mon Sep 17 00:00:00 2001 From: Mauro Druwel Date: Wed, 15 Apr 2026 22:27:04 +0200 Subject: [PATCH 6/6] refactor(services): extract renderBox utility, cap at terminal width, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline box-drawing in services.ts, nemoclaw/src/index.ts, and start-services.sh with a shared renderBox helper that: - Derives inner width dynamically from content (fixes the padEnd(40) bug) - Caps at terminal width minus 4 so the box never overflows (uses process.stdout.columns with a 100-col fallback, floored at 60) - Guarantees at least 2 trailing spaces before each closing │ src/lib/banner.ts and nemoclaw/src/banner.ts are identical; they cannot share a module because nemoclaw/ is a separate npm project. Each banner.test.ts covers: border shape, minInner (default + custom), null blank lines, equal-width alignment, long-line expansion, terminal capping, undefined-columns fallback, no-throw on overflow, and the core ≥2-trailing-spaces invariant (columns mocked to 120 for determinism). Co-Authored-By: Claude Sonnet 4.6 --- nemoclaw/src/banner.test.ts | 84 +++++++++++++++++++++++++++++++++++++ nemoclaw/src/banner.ts | 35 ++++++++++++++++ nemoclaw/src/index.ts | 28 +++++-------- scripts/start-services.sh | 37 +++++++++++----- src/lib/banner.test.ts | 84 +++++++++++++++++++++++++++++++++++++ src/lib/banner.ts | 35 ++++++++++++++++ src/lib/services.ts | 34 +++++---------- 7 files changed, 285 insertions(+), 52 deletions(-) create mode 100644 nemoclaw/src/banner.test.ts create mode 100644 nemoclaw/src/banner.ts create mode 100644 src/lib/banner.test.ts create mode 100644 src/lib/banner.ts 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 317d9d4f2e6..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,25 +318,16 @@ export default function register(api: OpenClawPluginApi): void { ); } - const bannerPrefix = 13; // length of " Endpoint: " / " Provider: " / " Model: " - const bannerMinInner = 53; - const bannerMaxValueLen = Math.max( - bannerEndpoint.length, - bannerProvider.length, - bannerModel.length, - ); - const bannerInner = Math.max(bannerMinInner, bannerPrefix + bannerMaxValueLen + 2); - const bannerPad = (s: string) => s + " ".repeat(bannerInner - s.length); - const bannerHBar = "─".repeat(bannerInner); + const lines: (string | null)[] = [ + " NemoClaw registered", + null, + " Endpoint: " + bannerEndpoint, + " Provider: " + bannerProvider, + " Model: " + bannerModel, + " Slash: /nemoclaw", + ]; api.logger.info(""); - api.logger.info(` ┌${bannerHBar}┐`); - api.logger.info(` │${bannerPad(" NemoClaw registered")}│`); - api.logger.info(` │${" ".repeat(bannerInner)}│`); - api.logger.info(` │${bannerPad(" Endpoint: " + bannerEndpoint)}│`); - api.logger.info(` │${bannerPad(" Provider: " + bannerProvider)}│`); - api.logger.info(` │${bannerPad(" Model: " + bannerModel)}│`); - api.logger.info(` │${bannerPad(" Slash: /nemoclaw")}│`); - api.logger.info(` └${bannerHBar}┘`); + 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 b6a779ca49a..11d4593fc23 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -149,28 +149,45 @@ do_start() { tunnel_url="$(grep -o 'https://[a-z0-9-]*\.trycloudflare\.com' "$PIDDIR/cloudflared.log" 2>/dev/null | head -1 || true)" fi - # Expand box width if the URL is longer than the default inner width (53) + # 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 - local url_inner=$((${#tunnel_url} + 17)) # " Public URL: " = 15 chars + 2 trailing spaces + local url_inner=$(( ${#url_label} + ${#tunnel_url} + 2 )) [ "$url_inner" -gt "$inner" ] && inner=$url_inner fi - # Messaging line: " Messaging: via OpenClaw native channels (if configured)" = 59 chars + 2 padding - [ $(( 59 + 2 )) -gt "$inner" ] && inner=$(( 59 + 2 )) - local h_bar="" - for ((i = 0; i < inner; i++)); do h_bar+="─"; done + + 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 " │ NemoClaw Services%-*s│\n" $((inner - 19)) "" + printf " │%s%-*s│\n" "$title_text" $((inner - ${#title_text})) "" printf " │%-*s│\n" "$inner" "" if [ -n "$tunnel_url" ]; then - printf " │ Public URL: %-*s│\n" $((inner - 15)) "$tunnel_url" + printf " │%s%s%-*s│\n" "$url_label" "$tunnel_url" $((inner - ${#url_label} - ${#tunnel_url})) "" fi - printf " │ Messaging: via OpenClaw native channels (if configured)%-*s│\n" $((inner - 59)) "" + printf " │%s%-*s│\n" "$messaging_text" $((inner - ${#messaging_text})) "" printf " │%-*s│\n" "$inner" "" - printf " │ Run 'openshell term' to monitor egress approvals%-*s│\n" $((inner - 50)) "" + 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 3ca1c804f29..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"; @@ -299,32 +300,17 @@ export async function startAll(opts: ServiceOptions = {}): Promise { } } - const titleText = " NemoClaw Services"; - const urlPrefix = " Public URL: "; - const messagingText = " Messaging: via OpenClaw native channels (if configured)"; - const footerText = " Run 'openshell term' to monitor egress approvals"; - - // Expand box width if the URL or messaging line is longer than the default inner width (53) - const minInner = 53; - const contentMax = Math.max(messagingText.length + 2, footerText.length + 2); - const inner = tunnelUrl - ? Math.max(minInner, contentMax, urlPrefix.length + tunnelUrl.length + 2) - : Math.max(minInner, contentMax); - - const pad = (s: string) => s + " ".repeat(inner - s.length); - const hBar = "─".repeat(inner); + 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(""); - console.log(` ┌${hBar}┐`); - console.log(` │${pad(titleText)}│`); - console.log(` │${" ".repeat(inner)}│`); - if (tunnelUrl) { - console.log(` │${pad(urlPrefix + tunnelUrl)}│`); - } - console.log(` │${pad(messagingText)}│`); - console.log(` │${" ".repeat(inner)}│`); - console.log(` │${pad(footerText)}│`); - console.log(` └${hBar}┘`); + for (const line of renderBox(lines)) console.log(line); console.log(""); }