From 24a4f0bbbf856fb39d28b2956b8ce2ee717ee4b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 11:39:10 -0700 Subject: [PATCH] feat(cli): animate the claw banner and show it on gateway startup (#105540) * feat(cli): animate the claw banner and show it on foreground gateway startup * fix(cli): restore the terminal when the banner animation is interrupted --- src/cli/claw-banner.test.ts | 87 ++++++++++++ src/cli/claw-banner.ts | 204 +++++++++++++++++++++++++++ src/cli/gateway-cli/run.ts | 7 + src/commands/onboard-guided.ts | 2 +- src/commands/onboard-helpers.test.ts | 12 +- src/commands/onboard-helpers.ts | 47 +----- src/flows/doctor-health.ts | 2 +- src/wizard/setup.ts | 2 +- 8 files changed, 310 insertions(+), 53 deletions(-) create mode 100644 src/cli/claw-banner.test.ts create mode 100644 src/cli/claw-banner.ts diff --git a/src/cli/claw-banner.test.ts b/src/cli/claw-banner.test.ts new file mode 100644 index 000000000000..1edba66fd968 --- /dev/null +++ b/src/cli/claw-banner.test.ts @@ -0,0 +1,87 @@ +// Claw banner tests: static/animated gating and the final-frame invariant. +import { describe, expect, it, vi } from "vitest"; +import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { printClawBanner, testing } from "./claw-banner.js"; + +const runtimeStub = () => { + const log = vi.fn(); + return { runtime: { log } as unknown as RuntimeEnv, log }; +}; + +async function runAnimated(rng: () => number) { + const chunks: string[] = []; + const { runtime } = runtimeStub(); + await printClawBanner(runtime, { + columns: 120, + isTty: true, + rich: true, + env: {}, + rng, + sleep: async () => {}, + write: (chunk) => chunks.push(chunk), + }); + return chunks; +} + +describe("printClawBanner", () => { + it("prints the static banner when not animatable", async () => { + const { runtime, log } = runtimeStub(); + await printClawBanner(runtime, { columns: 120, isTty: false, env: {} }); + const output = stripAnsi(String(log.mock.calls[0]?.[0])); + expect(output.split("\n")[0]).toBe("▄███▄ ▄███▄"); + expect(output).toContain("█▀▀▀█ █▀▀▀█ █▀▀▀▀ █▄ █"); + }); + + it("stays static under CI even on a rich TTY", async () => { + const { runtime, log } = runtimeStub(); + await printClawBanner(runtime, { columns: 120, isTty: true, rich: true, env: { CI: "1" } }); + expect(log).toHaveBeenCalledTimes(1); + }); + + it("falls back to the plain title on narrow terminals", async () => { + const { runtime, log } = runtimeStub(); + await printClawBanner(runtime, { columns: 50, isTty: true, rich: true, env: {} }); + const output = String(log.mock.calls[0]?.[0]); + expect(output).toContain("OPENCLAW"); + expect(output).not.toContain("█"); + }); + + it("animates on a rich TTY and settles on the exact static banner", async () => { + const chunks = await runAnimated(() => 0); + expect(chunks[0]).toBe("\x1b[?25l"); + expect(chunks).toContain("\x1b[?25h"); + const frames = chunks.filter((chunk) => chunk.includes("\x1b[K")); + expect(frames.length).toBeGreaterThan(10); + const finalRows = stripAnsi(frames[frames.length - 1] ?? "") + .split("\n") + .filter((row) => row.length > 0); + expect(finalRows).toEqual(testing.staticBannerLines().map((line) => stripAnsi(line))); + }); + + it("installs scoped signal handlers only while animating", async () => { + const before = process.listenerCount("SIGINT"); + let during = -1; + const { runtime } = runtimeStub(); + await printClawBanner(runtime, { + columns: 120, + isTty: true, + rich: true, + env: {}, + rng: () => 0.99, + sleep: async () => { + during = Math.max(during, process.listenerCount("SIGINT")); + }, + write: () => {}, + }); + expect(during).toBe(before + 1); + expect(process.listenerCount("SIGINT")).toBe(before); + }); + + it("varies snips and shimmer passes with the rng", async () => { + // rng below the thresholds adds a second shimmer pass and a second snip. + const maximal = (await runAnimated(() => 0)).filter((c) => c.includes("\x1b[K")); + const minimal = (await runAnimated(() => 0.99)).filter((c) => c.includes("\x1b[K")); + expect(maximal.length).toBeGreaterThan(minimal.length); + }); +}); diff --git a/src/cli/claw-banner.ts b/src/cli/claw-banner.ts new file mode 100644 index 000000000000..d5c01c88cc6a --- /dev/null +++ b/src/cli/claw-banner.ts @@ -0,0 +1,204 @@ +// Shared OpenClaw banner: the pixel lobster mascot beside the OPENCLAW +// wordmark, with a short startup animation on rich interactive terminals. +// Used by the wizard flows (doctor/onboard/configure) and the foreground +// gateway run; non-TTY and CI paths always get the plain static banner. +import { + decorativeEmoji, + supportsDecorativeEmoji, +} from "../../packages/terminal-core/src/decorative-emoji.js"; +import { restoreTerminalState } from "../../packages/terminal-core/src/restore.js"; +import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; +import type { RuntimeEnv } from "../runtime.js"; + +// Art is pregenerated from pixel bitmaps (two pixel rows per terminal row via +// ▀▄█). Mascot and wordmark are separate so they can be tinted independently; +// the wordmark starts on mascot row 2, keeping the claws above the text line. +const MASCOT_ART = [ + "▄███▄ ▄███▄", + "▀█▄█▀ ▀█▄█▀", + " ▀▄ ▄▀", + " ██ █ ██", + " ▀█████▀", + " ▄█▀ █ ▀█▄", +] as const; +// Claw tips with the pincer notch widened; swapping the top two rows in and +// out produces the "snip". +const MASCOT_OPEN_ROWS = ["▄█▀█▄ ▄█▀█▄", "▀█ █▀ ▀█ █▀"] as const; +const MASCOT_WIDTH = 15; +const WORDMARK_ROW_OFFSET = 2; + +const WORDMARK_ART = [ + "█▀▀▀█ █▀▀▀█ █▀▀▀▀ █▄ █ █▀▀▀▀ █ █▀▀▀█ █ █", + "█ █ █▀▀▀▀ █▀▀▀ █ ▀▄█ █ █ █▀▀▀█ █▄▀▄█", + "▀▀▀▀▀ ▀ ▀▀▀▀▀ ▀ ▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀ ▀ ▀", +] as const; +const GAP = 3; +const BANNER_WIDTH = MASCOT_WIDTH + GAP + 48; +const ROWS = MASCOT_ART.length; + +export type ClawBannerOptions = { + columns?: number; + isTty?: boolean; + rich?: boolean; + env?: NodeJS.ProcessEnv; + /** Injectable randomness for the animation garnish (tests pin it). */ + rng?: () => number; + sleep?: (ms: number) => Promise; + write?: (chunk: string) => void; +}; + +type CellTint = (col: number) => (text: string) => string; + +const identityTint: (text: string) => string = (text) => text; + +// Composes one banner frame. Tints run per glyph column so the wipe edge and +// shimmer band can cut through individual letters. +function composeFrame(params: { + mascotRows?: readonly string[]; + mascotTint?: CellTint; + wordmarkTint?: CellTint; +}): string[] { + const mascotRows = params.mascotRows ?? MASCOT_ART; + const lines: string[] = []; + for (let row = 0; row < ROWS; row++) { + const mascotRow = (mascotRows[row] ?? "").padEnd(MASCOT_WIDTH).slice(0, MASCOT_WIDTH); + let out = ""; + for (let col = 0; col < mascotRow.length; col++) { + const ch = mascotRow[col] ?? " "; + out += ch === " " ? " " : (params.mascotTint?.(col) ?? theme.accent)(ch); + } + const wordmarkRow = WORDMARK_ART[row - WORDMARK_ROW_OFFSET]; + if (wordmarkRow) { + out += " ".repeat(GAP); + for (let col = 0; col < wordmarkRow.length; col++) { + const ch = wordmarkRow[col] ?? " "; + out += + ch === " " ? " " : (params.wordmarkTint?.(MASCOT_WIDTH + GAP + col) ?? identityTint)(ch); + } + } + lines.push(out.replace(/\s+$/, "")); + } + return lines; +} + +function staticBannerLines(): string[] { + return composeFrame({}); +} + +function plainTitleLine(): string { + const icon = decorativeEmoji("🦞"); + return supportsDecorativeEmoji() && icon ? `${icon} OPENCLAW ${icon}` : "OPENCLAW"; +} + +const defaultSleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +// One combined entrance: a left-to-right molt wipe reveals the color, a +// shimmer band sweeps the wordmark, and the claws snip. The rng varies the +// shimmer passes and snip count a little so back-to-back runs don't feel +// canned; every sequence ends on the exact static banner. +async function animateBanner(opts: { + rng: () => number; + sleep: (ms: number) => Promise; + write: (chunk: string) => void; +}): Promise { + const { rng, sleep, write } = opts; + let drewFrame = false; + const draw = (lines: string[]) => { + const prefix = drewFrame ? `\x1b[${ROWS}F` : ""; + drewFrame = true; + write(`${prefix}${lines.map((line) => `\x1b[K${line}`).join("\n")}\n`); + }; + // Ctrl-C during the ~1s sequence would otherwise kill the process with the + // cursor still hidden: default signal death skips the finally block. The + // banner runs before any other component installs signal handlers, so a + // scoped restore-and-exit handler is safe here and removed right after. + const onSignal = (signal: "SIGINT" | "SIGTERM") => { + restoreTerminalState(`claw banner ${signal}`); + process.exit(signal === "SIGINT" ? 130 : 143); + }; + const onSigint = () => onSignal("SIGINT"); + const onSigterm = () => onSignal("SIGTERM"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + write("\x1b[?25l"); + try { + // Molt wipe: dim shell ahead of a bright 2-column edge, color behind it. + const wipeSteps = 9; + for (let step = 0; step <= wipeSteps; step++) { + const edge = Math.round((BANNER_WIDTH * step) / wipeSteps); + const tintAt = + (colored: (text: string) => string): CellTint => + (col) => + col < edge ? colored : col < edge + 2 ? theme.accentBright : theme.muted; + draw( + composeFrame({ + mascotTint: tintAt(theme.accent), + wordmarkTint: tintAt(identityTint), + }), + ); + await sleep(45); + } + // Shimmer: a bright band sweeps the wordmark; rarely it runs twice. + const shimmerPasses = rng() < 0.2 ? 2 : 1; + for (let pass = 0; pass < shimmerPasses; pass++) { + for (let x = MASCOT_WIDTH; x < BANNER_WIDTH + 6; x += 4) { + const band: CellTint = (col) => + col >= x && col < x + 6 ? theme.accentBright : identityTint; + draw(composeFrame({ wordmarkTint: band })); + await sleep(40); + } + } + // Snip: claws open and close once, sometimes twice. + const snips = rng() < 0.4 ? 2 : 1; + for (let snip = 0; snip < snips; snip++) { + draw(composeFrame({ mascotRows: [...MASCOT_OPEN_ROWS, ...MASCOT_ART.slice(2)] })); + await sleep(95); + draw(staticBannerLines()); + await sleep(115); + } + draw(staticBannerLines()); + } finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + write("\x1b[?25h"); + } +} + +/** + * Prints the OpenClaw banner: animated on rich interactive terminals, static + * otherwise, plain title on terminals too narrow for the art. + */ +export async function printClawBanner( + runtime: RuntimeEnv, + options: ClawBannerOptions = {}, +): Promise { + const columns = options.columns ?? process.stdout.columns ?? 80; + if (columns < BANNER_WIDTH) { + runtime.log(`${plainTitleLine()}\n`); + return; + } + const env = options.env ?? process.env; + const animate = + (options.isTty ?? process.stdout.isTTY ?? false) && + (options.rich ?? isRich()) && + !env.CI && + !env.VITEST; + if (!animate) { + runtime.log(`${staticBannerLines().join("\n")}\n`); + return; + } + await animateBanner({ + rng: options.rng ?? Math.random, + sleep: options.sleep ?? defaultSleep, + write: options.write ?? ((chunk) => process.stdout.write(chunk)), + }); + (options.write ?? ((chunk: string) => process.stdout.write(chunk)))("\n"); +} + +export const testing = { + staticBannerLines, + BANNER_WIDTH, +}; diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index d0f3f4f72e54..c8df42de5a40 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -52,6 +52,7 @@ import { setConsoleSubsystemFilter, setConsoleTimestampPrefix } from "../../logg import { withDiagnosticPhase } from "../../logging/diagnostic-phase.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { defaultRuntime } from "../../runtime.js"; +import { printClawBanner } from "../claw-banner.js"; import { formatCliCommand } from "../command-format.js"; import { formatInvalidConfigPort, formatInvalidPortOption } from "../error-format.js"; import { withProgress } from "../progress.js"; @@ -601,6 +602,12 @@ export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunR process.env.OPENCLAW_RAW_STREAM_PATH = rawStreamPath; } + // Foreground TTY runs get the banner before the module-loading spinner; + // managed/piped runs (launchd, tests, logs) stay banner-free. + if (process.stdout.isTTY) { + await printClawBanner(defaultRuntime); + } + const startupTrace = createGatewayCliStartupTrace(); // The heaviest part of gateway startup is loading the server module tree diff --git a/src/commands/onboard-guided.ts b/src/commands/onboard-guided.ts index e50fd111cdd2..f6ac1713b8ff 100644 --- a/src/commands/onboard-guided.ts +++ b/src/commands/onboard-guided.ts @@ -229,7 +229,7 @@ async function runGuidedOnboardingFlow( const onboardHelpers = await import("./onboard-helpers.js"); const prompter = await (deps.createPrompter?.() ?? import("../wizard/clack-prompter.js").then(({ createClackPrompter }) => createClackPrompter())); - onboardHelpers.printWizardHeader(runtime); + await onboardHelpers.printWizardHeader(runtime); await prompter.intro(t("wizard.guided.intro")); await prompter.note(t("wizard.guided.escapeHatches"), t("wizard.guided.welcomeTitle")); diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 70e51747f35b..3c9056ef0b28 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -35,11 +35,11 @@ describe("onboard error summaries", () => { }); describe("printWizardHeader", () => { - const withColumns = (columns: number | undefined, run: () => void) => { + const withColumns = async (columns: number | undefined, run: () => Promise) => { const previous = Object.getOwnPropertyDescriptor(process.stdout, "columns"); Object.defineProperty(process.stdout, "columns", { value: columns, configurable: true }); try { - run(); + await run(); } finally { if (previous) { Object.defineProperty(process.stdout, "columns", previous); @@ -49,9 +49,9 @@ describe("printWizardHeader", () => { } }; - it("prints the mascot beside the wordmark with claws above the text line", () => { + it("prints the mascot beside the wordmark with claws above the text line", async () => { const log = vi.fn(); - withColumns(120, () => printWizardHeader({ log } as unknown as RuntimeEnv)); + await withColumns(120, () => printWizardHeader({ log } as unknown as RuntimeEnv)); const output = stripAnsi(String(log.mock.calls[0]?.[0])); const rows = output.split("\n"); // Claw row stands alone above the wordmark; the eye row shares a line with it. @@ -60,9 +60,9 @@ describe("printWizardHeader", () => { expect(rows[3]).toContain("██ █ ██"); }); - it("falls back to the plain title on narrow terminals", () => { + it("falls back to the plain title on narrow terminals", async () => { const log = vi.fn(); - withColumns(50, () => printWizardHeader({ log } as unknown as RuntimeEnv)); + await withColumns(50, () => printWizardHeader({ log } as unknown as RuntimeEnv)); const output = String(log.mock.calls[0]?.[0]); expect(output).toContain("OPENCLAW"); expect(output).not.toContain("█"); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index ff7be4707f2e..cc6397f7351b 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -11,12 +11,7 @@ import { ConnectErrorDetailCodes, readConnectErrorDetailCode, } from "../../packages/gateway-protocol/src/connect-error-details.js"; -import { - decorativeEmoji, - supportsDecorativeEmoji, -} from "../../packages/terminal-core/src/decorative-emoji.js"; import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style.js"; -import { theme } from "../../packages/terminal-core/src/theme.js"; import { resolveAgentEffectiveModelPrimary, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, @@ -24,6 +19,7 @@ import { resolveWorkspaceAttestationPaths, shouldRemoveWorkspaceAttestation, } from "../agents/workspace.js"; +import { printClawBanner } from "../cli/claw-banner.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import { resolveConfigPath } from "../config/paths.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; @@ -169,46 +165,9 @@ export function validateGatewayPasswordInput(value: unknown): string | undefined return undefined; } -// Wizard banner art, pregenerated from pixel bitmaps (two pixel rows per -// terminal row via ▀▄█). The mascot rows and wordmark rows are separate so the -// mascot can take the accent color; the wordmark starts on mascot row 2, which -// keeps the claws poking above the text line. Keep row alignment when editing. -const WIZARD_MASCOT_ART = [ - "▄███▄ ▄███▄", - "▀█▄█▀ ▀█▄█▀", - " ▀▄ ▄▀", - " ██ █ ██", - " ▀█████▀", - " ▄█▀ █ ▀█▄", -] as const; -const WIZARD_MASCOT_WIDTH = 15; -const WIZARD_WORDMARK_ROW_OFFSET = 2; - -const WIZARD_WORDMARK_ART = [ - "█▀▀▀█ █▀▀▀█ █▀▀▀▀ █▄ █ █▀▀▀▀ █ █▀▀▀█ █ █", - "█ █ █▀▀▀▀ █▀▀▀ █ ▀▄█ █ █ █▀▀▀█ █▄▀▄█", - "▀▀▀▀▀ ▀ ▀▀▀▀▀ ▀ ▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀ ▀ ▀", -] as const; -const WIZARD_HEADER_WIDTH = WIZARD_MASCOT_WIDTH + 3 + 47; - /** Prints the onboarding banner: pixel mascot beside the OPENCLAW wordmark. */ -export function printWizardHeader(runtime: RuntimeEnv) { - // Narrow terminals (mobile SSH) would wrap the art mid-glyph; fall back to - // the plain title line the way the compact CLI banner handles tight widths. - const columns = process.stdout.columns ?? 80; - if (columns < WIZARD_HEADER_WIDTH) { - const icon = decorativeEmoji("🦞"); - runtime.log(supportsDecorativeEmoji() && icon ? `${icon} OPENCLAW ${icon}\n` : "OPENCLAW\n"); - return; - } - const lines = WIZARD_MASCOT_ART.map((mascotRow, index) => { - const wordmarkRow = WIZARD_WORDMARK_ART[index - WIZARD_WORDMARK_ROW_OFFSET]; - if (!wordmarkRow) { - return theme.accent(mascotRow); - } - return `${theme.accent(mascotRow.padEnd(WIZARD_MASCOT_WIDTH))} ${wordmarkRow}`; - }); - runtime.log(`${lines.join("\n")}\n`); +export async function printWizardHeader(runtime: RuntimeEnv): Promise { + await printClawBanner(runtime); } /** Records wizard provenance metadata on config writes. */ diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index d4b87afb6716..b3e17771d905 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -23,7 +23,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions const { createDoctorPrompter } = await import("../commands/doctor-prompter.js"); const { printWizardHeader } = await import("../commands/onboard-helpers.js"); const prompter = createDoctorPrompter({ runtime: effectiveRuntime, options }); - printWizardHeader(effectiveRuntime); + await printWizardHeader(effectiveRuntime); intro("OpenClaw doctor"); const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 28751fc0626e..1b30ec8f5359 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -136,7 +136,7 @@ async function runSetupWizardOnce( let runtime = runtimeInput; runtime ??= defaultRuntime; const onboardHelpers = await import("../commands/onboard-helpers.js"); - onboardHelpers.printWizardHeader(runtime); + await onboardHelpers.printWizardHeader(runtime); await prompter.intro(t("wizard.setup.intro")); const snapshot = await readSetupConfigFileSnapshot();