openclaw/src/cli/banner.ts
kumarabhirup e8f5eddacb
Ironclaw rebrand: new identity, animated CLI banner, and iron palette
Rebrand the project from the OpenClaw/Lobster identity to Ironclaw with
a new iron-metallic visual language across CLI and web UI.

## CLI identity

- Rename default CLI name from `openclaw` to `ironclaw` (keep `openclaw`
  in KNOWN_CLI_NAMES and regex for backward compat)
- Set process.title to `ironclaw`; update all `[openclaw]` log prefixes
  to `[ironclaw]`
- Add `IRONCLAW_*` env var checks (IRONCLAW_HIDE_BANNER,
  IRONCLAW_NO_RESPAWN, IRONCLAW_NODE_OPTIONS_READY,
  IRONCLAW_TAGLINE_INDEX) with fallback to legacy `OPENCLAW_*` variants

## Animated ASCII banner

- Replace the old lobster block-art with a figlet "ANSI Shadow" font
  IRONCLAW ASCII wordmark
- Add `gradient-string` dependency for terminal gradient rendering
- Implement iron shimmer animation: a bright highlight sweeps across the
  ASCII art (~2.5 s at 12 fps, 3 full gradient cycles) using a rotating
  iron-to-silver color array
- Make `emitCliBanner` async to support the animation; update all call
  sites (preaction hook, route, run-main) to await it
- Move banner emission earlier in `runCli()` so it appears for all
  invocations (bare command, subcommands, help) with the existing
  bannerEmitted guard preventing double-emission

## Iron palette and theme

- Rename LOBSTER_PALETTE → IRON_PALETTE in `src/terminal/palette.ts`
  with new cool-steel color tokens (steel grey accent, bright silver
  highlight, dark iron dim, steel bl info)
- Re-export LOBSTER_PALETTE as backward-compatible alias
- Update `src/terminal/theme.ts` to import and use IRON_PALETTE

## Tagline cleanup

- Remove lobster-themed, Apple-specific, and platform-joke taglines
- Fix smart-quote and em-dash formatting across remaining taglines
- Add "Holiday taglines" comment grouping for date-gated entries

## Web UI

- Add `framer-motion`, `fuse.js`, and `next-themes` to web app deps
- Add custom font files: Bookerly (regular/bold/italic), SpaceGrotesk
  (light/regular/medium/semibold/bold), FoundationTitlesHand
- Update chat panel labels: "OpenClaw Chat" → "Ironclaw Chat",
  "Message OpenClaw..." → "Message Ironclaw..."
- Update sidebar header: "OpenClaw Dench" → "Ironclaw"
- CSS formatting cleanup: expand single-lins, add consistent
  blank lines between selector blocks, normalize child combinator
  spacing (li > ul → li>ul)
2026-02-11 23:26:05 -08:00

174 lines
6.6 KiB
TypeScript

import gradient from "gradient-string";
import { resolveCommitHash } from "../infra/git-commit.js";
import { visibleWidth } from "../terminal/ansi.js";
import { isRich, theme } from "../terminal/theme.js";
import { pickTagline, type TaglineOptions } from "./tagline.js";
type BannerOptions = TaglineOptions & {
argv?: string[];
commit?: string | null;
columns?: number;
richTty?: boolean;
};
let bannerEmitted = false;
const hasJsonFlag = (argv: string[]) =>
argv.some((arg) => arg === "--json" || arg.startsWith("--json="));
const hasVersionFlag = (argv: string[]) =>
argv.some((arg) => arg === "--version" || arg === "-V" || arg === "-v");
// ---------------------------------------------------------------------------
// IRONCLAW ASCII art (figlet "ANSI Shadow" font, baked at build time)
// ---------------------------------------------------------------------------
const IRONCLAW_ASCII = [
" ██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗",
" ██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║",
" ██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║",
" ██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║",
" ██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝",
" ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
];
// ---------------------------------------------------------------------------
// Iron-metallic gradient colors (dark iron → bright silver → dark iron)
// ---------------------------------------------------------------------------
const IRON_GRADIENT_COLORS = [
"#374151", // dark iron
"#4B5563",
"#6B7280", // medium iron
"#9CA3AF", // steel
"#D1D5DB", // bright silver
"#F3F4F6", // near-white highlight
"#D1D5DB",
"#9CA3AF",
"#6B7280",
"#4B5563",
];
// ---------------------------------------------------------------------------
// Gradient animation helpers
// ---------------------------------------------------------------------------
function rotateArray<T>(arr: T[], offset: number): T[] {
const n = arr.length;
const o = ((offset % n) + n) % n;
return [...arr.slice(o), ...arr.slice(0, o)];
}
function renderGradientFrame(lines: string[], frame: number): string {
const colors = rotateArray(IRON_GRADIENT_COLORS, frame);
return gradient(colors).multiline(lines.join("\n"));
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Play the iron shimmer animation: a bright highlight sweeps across the
* ASCII art like light glinting off polished metal. Runs for ~2.5 seconds
* at 12 fps, completing 3 full gradient cycles.
*/
async function animateIronBanner(): Promise<void> {
const lineCount = IRONCLAW_ASCII.length;
const fps = 12;
const totalFrames = IRON_GRADIENT_COLORS.length * 3; // 3 full shimmer sweeps
const frameMs = Math.round(1000 / fps);
// Print the first frame to claim vertical space
process.stdout.write(renderGradientFrame(IRONCLAW_ASCII, 0) + "\n");
for (let frame = 1; frame < totalFrames; frame++) {
await sleep(frameMs);
// Move cursor up to overwrite the previous frame
process.stdout.write(`\x1b[${lineCount}A\r`);
process.stdout.write(renderGradientFrame(IRONCLAW_ASCII, frame) + "\n");
}
}
// ---------------------------------------------------------------------------
// Static (non-animated) banner rendering
// ---------------------------------------------------------------------------
export function formatCliBannerArt(options: BannerOptions = {}): string {
const rich = options.richTty ?? isRich();
if (!rich) {
return IRONCLAW_ASCII.join("\n");
}
return renderGradientFrame(IRONCLAW_ASCII, 0);
}
// ---------------------------------------------------------------------------
// One-line version + tagline (prints below the ASCII art)
// ---------------------------------------------------------------------------
export function formatCliBannerLine(version: string, options: BannerOptions = {}): string {
const commit = options.commit ?? resolveCommitHash({ env: options.env });
const commitLabel = commit ?? "unknown";
const tagline = pickTagline(options);
const rich = options.richTty ?? isRich();
const title = "IRONCLAW";
const prefix = " ";
const columns = options.columns ?? process.stdout.columns ?? 120;
const plainFullLine = `${prefix}${title} ${version} (${commitLabel}) — ${tagline}`;
const fitsOnOneLine = visibleWidth(plainFullLine) <= columns;
if (rich) {
if (fitsOnOneLine) {
return `${prefix}${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
}
const line1 = `${prefix}${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)}`;
const line2 = `${prefix}${theme.accentDim(tagline)}`;
return `${line1}\n${line2}`;
}
if (fitsOnOneLine) {
return plainFullLine;
}
const line1 = `${prefix}${title} ${version} (${commitLabel})`;
const line2 = `${prefix}${tagline}`;
return `${line1}\n${line2}`;
}
// ---------------------------------------------------------------------------
// Emit the full banner (animated ASCII art + version line)
// ---------------------------------------------------------------------------
export async function emitCliBanner(version: string, options: BannerOptions = {}) {
if (bannerEmitted) {
return;
}
const argv = options.argv ?? process.argv;
if (!process.stdout.isTTY) {
return;
}
if (hasJsonFlag(argv)) {
return;
}
if (hasVersionFlag(argv)) {
return;
}
bannerEmitted = true;
const rich = options.richTty ?? isRich();
process.stdout.write("\n");
if (rich) {
// Animated iron shimmer
await animateIronBanner();
} else {
// Plain ASCII fallback
process.stdout.write(IRONCLAW_ASCII.join("\n") + "\n");
}
const line = formatCliBannerLine(version, options);
process.stdout.write(`${line}\n\n`);
}
export function hasEmittedCliBanner(): boolean {
return bannerEmitted;
}