feat(cli): add configurable banner tagline mode

This commit is contained in:
Peter Steinberger
2026-03-03 00:31:42 +00:00
parent f6233cfa5c
commit 1b5ac8b0b1
15 changed files with 206 additions and 4 deletions

60
src/cli/banner.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.fn();
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
let formatCliBannerLine: typeof import("./banner.js").formatCliBannerLine;
beforeAll(async () => {
({ formatCliBannerLine } = await import("./banner.js"));
});
beforeEach(() => {
loadConfigMock.mockReset();
loadConfigMock.mockReturnValue({});
});
describe("formatCliBannerLine", () => {
it("hides tagline text when cli.banner.taglineMode is off", () => {
loadConfigMock.mockReturnValue({
cli: { banner: { taglineMode: "off" } },
});
const line = formatCliBannerLine("2026.3.3", {
commit: "abc1234",
richTty: false,
});
expect(line).toBe("🦞 OpenClaw 2026.3.3 (abc1234)");
});
it("uses default tagline when cli.banner.taglineMode is default", () => {
loadConfigMock.mockReturnValue({
cli: { banner: { taglineMode: "default" } },
});
const line = formatCliBannerLine("2026.3.3", {
commit: "abc1234",
richTty: false,
});
expect(line).toBe("🦞 OpenClaw 2026.3.3 (abc1234) — All your chats, one OpenClaw.");
});
it("prefers explicit tagline mode over config", () => {
loadConfigMock.mockReturnValue({
cli: { banner: { taglineMode: "off" } },
});
const line = formatCliBannerLine("2026.3.3", {
commit: "abc1234",
richTty: false,
mode: "default",
});
expect(line).toBe("🦞 OpenClaw 2026.3.3 (abc1234) — All your chats, one OpenClaw.");
});
});

View File

@@ -1,8 +1,9 @@
import { loadConfig } from "../config/config.js";
import { resolveCommitHash } from "../infra/git-commit.js";
import { visibleWidth } from "../terminal/ansi.js";
import { isRich, theme } from "../terminal/theme.js";
import { hasRootVersionAlias } from "./argv.js";
import { pickTagline, type TaglineOptions } from "./tagline.js";
import { pickTagline, type TaglineMode, type TaglineOptions } from "./tagline.js";
type BannerOptions = TaglineOptions & {
argv?: string[];
@@ -35,18 +36,42 @@ const hasJsonFlag = (argv: string[]) =>
const hasVersionFlag = (argv: string[]) =>
argv.some((arg) => arg === "--version" || arg === "-V") || hasRootVersionAlias(argv);
function parseTaglineMode(value: unknown): TaglineMode | undefined {
if (value === "random" || value === "default" || value === "off") {
return value;
}
return undefined;
}
function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined {
const explicit = parseTaglineMode(options.mode);
if (explicit) {
return explicit;
}
try {
return parseTaglineMode(loadConfig().cli?.banner?.taglineMode);
} catch {
// Fall back to default random behavior when config is missing/invalid.
return undefined;
}
}
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 tagline = pickTagline({ ...options, mode: resolveTaglineMode(options) });
const rich = options.richTty ?? isRich();
const title = "🦞 OpenClaw";
const prefix = "🦞 ";
const columns = options.columns ?? process.stdout.columns ?? 120;
const plainFullLine = `${title} ${version} (${commitLabel})${tagline}`;
const plainBaseLine = `${title} ${version} (${commitLabel})`;
const plainFullLine = tagline ? `${plainBaseLine}${tagline}` : plainBaseLine;
const fitsOnOneLine = visibleWidth(plainFullLine) <= columns;
if (rich) {
if (fitsOnOneLine) {
if (!tagline) {
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(`(${commitLabel})`)}`;
}
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
@@ -54,13 +79,19 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)}`;
if (!tagline) {
return line1;
}
const line2 = `${" ".repeat(prefix.length)}${theme.accentDim(tagline)}`;
return `${line1}\n${line2}`;
}
if (fitsOnOneLine) {
return plainFullLine;
}
const line1 = `${title} ${version} (${commitLabel})`;
const line1 = plainBaseLine;
if (!tagline) {
return line1;
}
const line2 = `${" ".repeat(prefix.length)}${tagline}`;
return `${line1}\n${line2}`;
}

21
src/cli/tagline.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_TAGLINE, pickTagline } from "./tagline.js";
describe("pickTagline", () => {
it("returns empty string when mode is off", () => {
expect(pickTagline({ mode: "off" })).toBe("");
});
it("returns default tagline when mode is default", () => {
expect(pickTagline({ mode: "default" })).toBe(DEFAULT_TAGLINE);
});
it("keeps OPENCLAW_TAGLINE_INDEX behavior in random mode", () => {
const value = pickTagline({
mode: "random",
env: { OPENCLAW_TAGLINE_INDEX: "0" } as NodeJS.ProcessEnv,
});
expect(value.length).toBeGreaterThan(0);
expect(value).not.toBe(DEFAULT_TAGLINE);
});
});

View File

@@ -1,4 +1,5 @@
const DEFAULT_TAGLINE = "All your chats, one OpenClaw.";
export type TaglineMode = "random" | "default" | "off";
const HOLIDAY_TAGLINES = {
newYear:
@@ -248,6 +249,7 @@ export interface TaglineOptions {
env?: NodeJS.ProcessEnv;
random?: () => number;
now?: () => Date;
mode?: TaglineMode;
}
export function activeTaglines(options: TaglineOptions = {}): string[] {
@@ -260,6 +262,12 @@ export function activeTaglines(options: TaglineOptions = {}): string[] {
}
export function pickTagline(options: TaglineOptions = {}): string {
if (options.mode === "off") {
return "";
}
if (options.mode === "default") {
return DEFAULT_TAGLINE;
}
const env = options.env ?? process.env;
const override = env?.OPENCLAW_TAGLINE_INDEX;
if (override !== undefined) {