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
This commit is contained in:
Peter Steinberger
2026-07-12 11:39:10 -07:00
committed by GitHub
parent c0f0107a5c
commit 24a4f0bbbf
8 changed files with 310 additions and 53 deletions

View File

@@ -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);
});
});

204
src/cli/claw-banner.ts Normal file
View File

@@ -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<void>;
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<void>((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<void>;
write: (chunk: string) => void;
}): Promise<void> {
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<void> {
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,
};

View File

@@ -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

View File

@@ -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"));

View File

@@ -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<void>) => {
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("█");

View File

@@ -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<void> {
await printClawBanner(runtime);
}
/** Records wizard provenance metadata on config writes. */

View File

@@ -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");

View File

@@ -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();