diff --git a/AGENTS.md b/AGENTS.md index 42088930c..3b66890c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ - **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. +- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f9aeca8..102b3a992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ - Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19 - Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true). - Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete +- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete ## 2026.1.8 diff --git a/docs/cli/index.md b/docs/cli/index.md index 28a91d6f3..0130c9cb0 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -36,6 +36,8 @@ Clawdbot uses a lobster palette for CLI output. - `error` (#E23D2D): errors, failures. - `muted` (#8B7F77): de-emphasis, metadata. +Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”). + ## Command tree ``` diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 5e4c33902..e15ba0aef 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -1,14 +1,14 @@ import path from "node:path"; import { - confirm, - intro, - multiselect, - note, - outro, - select, + confirm as clackConfirm, + intro as clackIntro, + multiselect as clackMultiselect, + note as clackNote, + outro as clackOutro, + select as clackSelect, spinner, - text, + text as clackText, } from "@clack/prompts"; import { loginOpenAICodex, @@ -39,6 +39,11 @@ import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { theme } from "../terminal/theme.js"; +import { + stylePromptHint, + stylePromptMessage, + stylePromptTitle, +} from "../terminal/prompt-style.js"; import { resolveUserPath, sleep } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { @@ -104,6 +109,43 @@ type ConfigureWizardParams = { sections?: WizardSection[]; }; +const intro = (message: string) => + clackIntro(stylePromptTitle(message) ?? message); +const outro = (message: string) => + clackOutro(stylePromptTitle(message) ?? message); +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); +const text = (params: Parameters[0]) => + clackText({ + ...params, + message: stylePromptMessage(params.message), + }); +const confirm = (params: Parameters[0]) => + clackConfirm({ + ...params, + message: stylePromptMessage(params.message), + }); +const select = (params: Parameters>[0]) => + clackSelect({ + ...params, + message: stylePromptMessage(params.message), + options: params.options.map((opt) => + opt.hint === undefined + ? opt + : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }); +const multiselect = (params: Parameters>[0]) => + clackMultiselect({ + ...params, + message: stylePromptMessage(params.message), + options: params.options.map((opt) => + opt.hint === undefined + ? opt + : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }); + const startOscSpinner = (label: string) => { const spin = spinner(); spin.start(theme.accent(label)); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 13a158846..6c2bb9e9a 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -1,4 +1,4 @@ -import { note } from "@clack/prompts"; +import { note as clackNote } from "@clack/prompts"; import { buildAuthHealthSummary, @@ -13,8 +13,12 @@ import { resolveApiKeyForProfile, } from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); + export async function maybeRepairAnthropicOAuthProfileId( cfg: ClawdbotConfig, prompter: DoctorPrompter, diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 51d869562..d0ec0940a 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { note } from "@clack/prompts"; +import { note as clackNote } from "@clack/prompts"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; @@ -31,6 +31,10 @@ import { type GatewayDaemonRuntime, } from "./daemon-runtime.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; + +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); function detectGatewayRuntime( programArguments: string[] | undefined, diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 8bbe596d9..3c0d20fbc 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -1,7 +1,7 @@ import os from "node:os"; import path from "node:path"; -import { note } from "@clack/prompts"; +import { note as clackNote } from "@clack/prompts"; import type { ClawdbotConfig } from "../config/config.js"; import { @@ -12,8 +12,12 @@ import { writeConfigFile, } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import { resolveUserPath } from "../utils.js"; +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); + function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string { const override = env.CLAWDIS_CONFIG_PATH?.trim(); if (override) return override; diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index ad085d99d..52cf0adfc 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -1,6 +1,7 @@ import { confirm, select } from "@clack/prompts"; import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js"; import { guardCancel } from "./onboard-helpers.js"; export type DoctorOptions = { @@ -42,7 +43,15 @@ export function createDoctorPrompter(params: { if (nonInteractive) return false; if (shouldRepair) return true; if (!canPrompt) return Boolean(p.initialValue ?? false); - return guardCancel(await confirm(p), params.runtime) === true; + return ( + guardCancel( + await confirm({ + ...p, + message: stylePromptMessage(p.message), + }), + params.runtime, + ) === true + ); }; return { @@ -56,7 +65,15 @@ export function createDoctorPrompter(params: { if (shouldRepair && shouldForce) return true; if (shouldRepair && !shouldForce) return false; if (!canPrompt) return Boolean(p.initialValue ?? false); - return guardCancel(await confirm(p), params.runtime) === true; + return ( + guardCancel( + await confirm({ + ...p, + message: stylePromptMessage(p.message), + }), + params.runtime, + ) === true + ); }, confirmSkipInNonInteractive: async (p) => { if (nonInteractive) return false; @@ -65,7 +82,18 @@ export function createDoctorPrompter(params: { }, select: async (p: Parameters[0], fallback: T) => { if (!canPrompt || shouldRepair) return fallback; - return guardCancel(await select(p), params.runtime) as T; + return guardCancel( + await select({ + ...p, + message: stylePromptMessage(p.message), + options: p.options.map((opt) => + opt.hint === undefined + ? opt + : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }), + params.runtime, + ) as T; }, shouldRepair, shouldForce, diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index f94a3822f..6d1b6e6ce 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { note } from "@clack/prompts"; +import { note as clackNote } from "@clack/prompts"; import { DEFAULT_SANDBOX_BROWSER_IMAGE, @@ -12,9 +12,13 @@ import { import type { ClawdbotConfig } from "../config/config.js"; import { runCommandWithTimeout, runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import { replaceModernName } from "./doctor-legacy-config.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); + type SandboxScriptInfo = { scriptPath: string; cwd: string; diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 917b86d28..bc16fcc29 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -1,11 +1,15 @@ -import { note } from "@clack/prompts"; +import { note as clackNote } from "@clack/prompts"; import type { ClawdbotConfig } from "../config/config.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import { readProviderAllowFromStore } from "../pairing/pairing-store.js"; import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; import { resolveTelegramToken } from "../telegram/token.js"; import { normalizeE164 } from "../utils.js"; +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); + export async function noteSecurityWarnings(cfg: ClawdbotConfig) { const warnings: string[] = []; diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 195b6f520..9eb492ee3 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { note } from "@clack/prompts"; +import { note as clackNote } from "@clack/prompts"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; @@ -13,8 +13,12 @@ import { resolveSessionTranscriptsDirForAgent, resolveStorePath, } from "../config/sessions.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); + type DoctorPrompterLike = { confirmSkipInNonInteractive: (params: { message: string; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 2db1a1cbf..650ad08f9 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { intro, note, outro } from "@clack/prompts"; +import { intro as clackIntro, note as clackNote, outro as clackOutro } from "@clack/prompts"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdbotConfig } from "../config/config.js"; import { @@ -20,6 +20,7 @@ import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import { resolveUserPath, sleep } from "../utils.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, @@ -71,6 +72,13 @@ import { } from "./onboard-helpers.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; +const intro = (message: string) => + clackIntro(stylePromptTitle(message) ?? message); +const outro = (message: string) => + clackOutro(stylePromptTitle(message) ?? message); +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); + function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index e48abeb22..680e7a458 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -1,6 +1,10 @@ import { spawnSync } from "node:child_process"; -import { confirm, select, text } from "@clack/prompts"; +import { + confirm as clackConfirm, + select as clackSelect, + text as clackText, +} from "@clack/prompts"; import { CLAUDE_CLI_PROFILE_ID, @@ -11,9 +15,34 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { + stylePromptHint, + stylePromptMessage, +} from "../../terminal/prompt-style.js"; import { applyAuthProfileConfig } from "../onboard-auth.js"; import { updateConfig } from "./shared.js"; +const confirm = (params: Parameters[0]) => + clackConfirm({ + ...params, + message: stylePromptMessage(params.message), + }); +const text = (params: Parameters[0]) => + clackText({ + ...params, + message: stylePromptMessage(params.message), + }); +const select = (params: Parameters>[0]) => + clackSelect({ + ...params, + message: stylePromptMessage(params.message), + options: params.options.map((opt) => + opt.hint === undefined + ? opt + : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }); + type TokenProvider = "anthropic"; function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null { diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 1c2380d08..01b5a03c6 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -1,4 +1,8 @@ -import { cancel, isCancel, multiselect } from "@clack/prompts"; +import { + cancel, + isCancel, + multiselect as clackMultiselect, +} from "@clack/prompts"; import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; import { type ModelScanResult, @@ -7,11 +11,27 @@ import { import { withProgressTotals } from "../../cli/progress.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { + stylePromptHint, + stylePromptMessage, + stylePromptTitle, +} from "../../terminal/prompt-style.js"; import { formatMs, formatTokenK, updateConfig } from "./shared.js"; const MODEL_PAD = 42; const CTX_PAD = 8; +const multiselect = (params: Parameters>[0]) => + clackMultiselect({ + ...params, + message: stylePromptMessage(params.message), + options: params.options.map((opt) => + opt.hint === undefined + ? opt + : { ...opt, hint: stylePromptHint(opt.hint) }, + ), + }); + const pad = (value: string, size: number) => value.padEnd(size); const truncate = (value: string, max: number) => { @@ -268,7 +288,7 @@ export async function modelsScanCommand( }); if (isCancel(selection)) { - cancel("Model scan cancelled."); + cancel(stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled."); runtime.exit(0); } @@ -285,7 +305,9 @@ export async function modelsScanCommand( }); if (isCancel(imageSelection)) { - cancel("Model scan cancelled."); + cancel( + stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled.", + ); runtime.exit(0); } diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 887d766ec..c8e777772 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -17,6 +17,7 @@ import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { VERSION } from "../version.js"; import type { @@ -27,7 +28,7 @@ import type { export function guardCancel(value: T, runtime: RuntimeEnv): T { if (isCancel(value)) { - cancel("Setup cancelled."); + cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled."); runtime.exit(0); } return value; diff --git a/src/commands/systemd-linger.ts b/src/commands/systemd-linger.ts index 5f6af2abb..f14434c96 100644 --- a/src/commands/systemd-linger.ts +++ b/src/commands/systemd-linger.ts @@ -1,10 +1,14 @@ -import { note } from "@clack/prompts"; +import { note as clackNote } from "@clack/prompts"; import { enableSystemdUserLinger, readSystemdUserLingerStatus, } from "../daemon/systemd.js"; import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; + +const note = (message: string, title?: string) => + clackNote(message, stylePromptTitle(title)); export type LingerPrompter = { confirm?: (params: { diff --git a/src/terminal/palette.ts b/src/terminal/palette.ts new file mode 100644 index 000000000..847cda3f4 --- /dev/null +++ b/src/terminal/palette.ts @@ -0,0 +1,12 @@ +// Lobster palette tokens for CLI/UI theming. "lobster seam" == use this palette. +// Keep in sync with docs/cli/index.md (CLI palette section). +export const LOBSTER_PALETTE = { + accent: "#FF5A2D", + accentBright: "#FF7A3D", + accentDim: "#D14A22", + info: "#FF8A5B", + success: "#2FBF71", + warn: "#FFB020", + error: "#E23D2D", + muted: "#8B7F77", +} as const; diff --git a/src/terminal/prompt-style.ts b/src/terminal/prompt-style.ts new file mode 100644 index 000000000..c33083c31 --- /dev/null +++ b/src/terminal/prompt-style.ts @@ -0,0 +1,10 @@ +import { isRich, theme } from "./theme.js"; + +export const stylePromptMessage = (message: string): string => + isRich() ? theme.accent(message) : message; + +export const stylePromptTitle = (title?: string): string | undefined => + title && isRich() ? theme.heading(title) : title; + +export const stylePromptHint = (hint?: string): string | undefined => + hint && isRich() ? theme.muted(hint) : hint; diff --git a/src/terminal/theme.ts b/src/terminal/theme.ts index c11a28d93..8eccb3f2c 100644 --- a/src/terminal/theme.ts +++ b/src/terminal/theme.ts @@ -1,16 +1,6 @@ import chalk from "chalk"; -// Semantic palette for CLI output. Keep in sync with docs/cli/index.md. -export const LOBSTER_PALETTE = { - accent: "#FF5A2D", - accentBright: "#FF7A3D", - accentDim: "#D14A22", - info: "#FF8A5B", - success: "#2FBF71", - warn: "#FFB020", - error: "#E23D2D", - muted: "#8B7F77", -} as const; +import { LOBSTER_PALETTE } from "./palette.js"; const hex = (value: string) => chalk.hex(value); diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts index 537fcfc5a..eeae829ff 100644 --- a/src/wizard/clack-prompter.ts +++ b/src/wizard/clack-prompter.ts @@ -13,12 +13,17 @@ import { } from "@clack/prompts"; import { createCliProgress } from "../cli/progress.js"; import { theme } from "../terminal/theme.js"; +import { + stylePromptHint, + stylePromptMessage, + stylePromptTitle, +} from "../terminal/prompt-style.js"; import type { WizardProgress, WizardPrompter } from "./prompts.js"; import { WizardCancelledError } from "./prompts.js"; function guardCancel(value: T | symbol): T { if (isCancel(value)) { - cancel("Setup cancelled."); + cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled."); throw new WizardCancelledError(); } return value as T; @@ -27,21 +32,23 @@ function guardCancel(value: T | symbol): T { export function createClackPrompter(): WizardPrompter { return { intro: async (title) => { - intro(title); + intro(stylePromptTitle(title) ?? title); }, outro: async (message) => { - outro(message); + outro(stylePromptTitle(message) ?? message); }, note: async (message, title) => { - note(message, title); + note(message, stylePromptTitle(title)); }, select: async (params) => guardCancel( await select({ - message: params.message, + message: stylePromptMessage(params.message), options: params.options.map((opt) => { const base = { value: opt.value, label: opt.label }; - return opt.hint === undefined ? base : { ...base, hint: opt.hint }; + return opt.hint === undefined + ? base + : { ...base, hint: stylePromptHint(opt.hint) }; }) as Option<(typeof params.options)[number]["value"]>[], initialValue: params.initialValue, }), @@ -49,10 +56,12 @@ export function createClackPrompter(): WizardPrompter { multiselect: async (params) => guardCancel( await multiselect({ - message: params.message, + message: stylePromptMessage(params.message), options: params.options.map((opt) => { const base = { value: opt.value, label: opt.label }; - return opt.hint === undefined ? base : { ...base, hint: opt.hint }; + return opt.hint === undefined + ? base + : { ...base, hint: stylePromptHint(opt.hint) }; }) as Option<(typeof params.options)[number]["value"]>[], initialValues: params.initialValues, }), @@ -60,7 +69,7 @@ export function createClackPrompter(): WizardPrompter { text: async (params) => guardCancel( await text({ - message: params.message, + message: stylePromptMessage(params.message), initialValue: params.initialValue, placeholder: params.placeholder, validate: params.validate, @@ -69,7 +78,7 @@ export function createClackPrompter(): WizardPrompter { confirm: async (params) => guardCancel( await confirm({ - message: params.message, + message: stylePromptMessage(params.message), initialValue: params.initialValue, }), ),