diff --git a/docs/cli/index.md b/docs/cli/index.md index 5d402594c..36160b9b2 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -16,6 +16,12 @@ If you change the CLI code, update this doc. - `--profile `: isolate state under `~/.clawdbot-`. - `-V`, `--version`, `-v`: print version and exit. +## Output styling + +- ANSI colors and progress indicators only render in TTY sessions. +- `--json` (and `--plain` where supported) disables styling for clean output. +- Long-running commands show a progress indicator (OSC 9;4 when supported). + ## Command tree ``` @@ -321,7 +327,7 @@ Options: - `--json` #### `agents add [name]` -Add a new isolated agent. If `--workspace` is omitted, runs the guided wizard. +Add a new isolated agent. Runs the guided wizard unless flags are passed; `--workspace` is required in non-interactive mode. Options: - `--workspace ` diff --git a/package.json b/package.json index c6fcc4ba6..1268a708f 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "grammy": "^1.39.2", "json5": "^2.2.3", "long": "5.3.2", + "osc-progress": "^0.2.0", "playwright-core": "1.57.0", "proper-lockfile": "^4.1.2", "qrcode-terminal": "^0.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a277c6a7..56519d572 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: long: specifier: 5.3.2 version: 5.3.2 + osc-progress: + specifier: ^0.2.0 + version: 0.2.0 playwright-core: specifier: 1.57.0 version: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02) @@ -2428,6 +2431,10 @@ packages: opus-decoder@0.7.11: resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} + osc-progress@0.2.0: + resolution: {integrity: sha512-GJR9XnS8dQ+sAdbhX90RA4WbmEyrso7X9aHMws4MaQ2GRpfEjnOUSZIdOXJQfnIfBoy9oCc7US/MNFCyuJQzjg==} + engines: {node: '>=20'} + oxlint-tsgolint@0.10.1: resolution: {integrity: sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==} hasBin: true @@ -5294,6 +5301,8 @@ snapshots: '@wasm-audio-decoders/common': 9.0.7 optional: true + osc-progress@0.2.0: {} + oxlint-tsgolint@0.10.1: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 0.10.1 diff --git a/src/cli/canvas-cli.ts b/src/cli/canvas-cli.ts index a2ff65357..875b66104 100644 --- a/src/cli/canvas-cli.ts +++ b/src/cli/canvas-cli.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import type { Command } from "commander"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { defaultRuntime } from "../runtime.js"; +import { withProgress } from "./progress.js"; import { writeBase64ToFile } from "./nodes-camera.js"; import { canvasSnapshotTempPath, @@ -80,15 +81,23 @@ const callGatewayCli = async ( opts: CanvasOpts, params?: unknown, ) => - callGateway({ - url: opts.url, - token: opts.token, - method, - params, - timeoutMs: Number(opts.timeout ?? 10_000), - clientName: "cli", - mode: "cli", - }); + withProgress( + { + label: `Canvas ${method}`, + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await callGateway({ + url: opts.url, + token: opts.token, + method, + params, + timeoutMs: Number(opts.timeout ?? 10_000), + clientName: "cli", + mode: "cli", + }), + ); function parseNodeList(value: unknown): NodeListNode[] { const obj = diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index 3b2958f28..ec32ad5cc 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -1,8 +1,8 @@ -import chalk from "chalk"; import type { Command } from "commander"; import type { CronJob, CronSchedule } from "../cron/types.js"; import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; @@ -69,8 +69,6 @@ const CRON_LAST_PAD = 10; const CRON_STATUS_PAD = 9; const CRON_TARGET_PAD = 9; -const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0); - const pad = (value: string, width: number) => value.padEnd(width); const truncate = (value: string, width: number) => { @@ -122,12 +120,6 @@ const formatStatus = (job: CronJob) => { return job.state.lastStatus ?? "idle"; }; -const colorize = ( - rich: boolean, - color: (msg: string) => string, - msg: string, -) => (rich ? color(msg) : msg); - function printCronList(jobs: CronJob[], runtime = defaultRuntime) { if (jobs.length === 0) { runtime.log("No cron jobs."); @@ -145,7 +137,7 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) { pad("Target", CRON_TARGET_PAD), ].join(" "); - runtime.log(rich ? chalk.bold(header) : header); + runtime.log(rich ? theme.heading(header) : header); const now = Date.now(); for (const job of jobs) { @@ -168,26 +160,28 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) { const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD); const coloredStatus = (() => { - if (statusRaw === "ok") return colorize(rich, chalk.green, statusLabel); - if (statusRaw === "error") return colorize(rich, chalk.red, statusLabel); + if (statusRaw === "ok") + return colorize(rich, theme.success, statusLabel); + if (statusRaw === "error") + return colorize(rich, theme.error, statusLabel); if (statusRaw === "running") - return colorize(rich, chalk.yellow, statusLabel); + return colorize(rich, theme.warn, statusLabel); if (statusRaw === "skipped") - return colorize(rich, chalk.gray, statusLabel); - return colorize(rich, chalk.gray, statusLabel); + return colorize(rich, theme.muted, statusLabel); + return colorize(rich, theme.muted, statusLabel); })(); const coloredTarget = job.sessionTarget === "isolated" - ? colorize(rich, chalk.magenta, targetLabel) - : colorize(rich, chalk.cyan, targetLabel); + ? colorize(rich, theme.accentBright, targetLabel) + : colorize(rich, theme.accent, targetLabel); const line = [ - colorize(rich, chalk.cyan, idLabel), - colorize(rich, chalk.white, nameLabel), - colorize(rich, chalk.white, scheduleLabel), - colorize(rich, chalk.gray, nextLabel), - colorize(rich, chalk.gray, lastLabel), + colorize(rich, theme.accent, idLabel), + colorize(rich, theme.info, nameLabel), + colorize(rich, theme.info, scheduleLabel), + colorize(rich, theme.muted, nextLabel), + colorize(rich, theme.muted, lastLabel), coloredStatus, coloredTarget, ].join(" "); diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index dcce4f61b..48f612e35 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -30,6 +30,7 @@ import { } from "../infra/ports.js"; import { defaultRuntime } from "../runtime.js"; import { createDefaultDeps } from "./deps.js"; +import { withProgress } from "./progress.js"; type DaemonStatus = { service: { @@ -74,6 +75,7 @@ export type GatewayRpcOpts = { token?: string; password?: string; timeout?: string; + json?: boolean; }; export type DaemonStatusOptions = { @@ -104,15 +106,23 @@ function parsePort(raw: unknown): number | null { async function probeGatewayStatus(opts: GatewayRpcOpts) { try { - await callGateway({ - url: opts.url, - token: opts.token, - password: opts.password, - method: "status", - timeoutMs: Number(opts.timeout ?? 10_000), - clientName: "cli", - mode: "cli", - }); + await withProgress( + { + label: "Checking gateway status...", + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await callGateway({ + url: opts.url, + token: opts.token, + password: opts.password, + method: "status", + timeoutMs: Number(opts.timeout ?? 10_000), + clientName: "cli", + mode: "cli", + }), + ); return { ok: true } as const; } catch (err) { return { diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 44b3375c0..a61bbdde3 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -33,6 +33,7 @@ import { } from "./daemon-cli.js"; import { createDefaultDeps } from "./deps.js"; import { forceFreePortAndWait } from "./ports.js"; +import { withProgress } from "./progress.js"; type GatewayRpcOpts = { url?: string; @@ -211,17 +212,25 @@ const callGatewayCli = async ( opts: GatewayRpcOpts, params?: unknown, ) => - callGateway({ - url: opts.url, - token: opts.token, - password: opts.password, - method, - params, - expectFinal: Boolean(opts.expectFinal), - timeoutMs: Number(opts.timeout ?? 10_000), - clientName: "cli", - mode: "cli", - }); + withProgress( + { + label: `Gateway ${method}`, + indeterminate: true, + enabled: true, + }, + async () => + await callGateway({ + url: opts.url, + token: opts.token, + password: opts.password, + method, + params, + expectFinal: Boolean(opts.expectFinal), + timeoutMs: Number(opts.timeout ?? 10_000), + clientName: "cli", + mode: "cli", + }), + ); export function registerGatewayCli(program: Command) { program diff --git a/src/cli/gateway-rpc.ts b/src/cli/gateway-rpc.ts index 2d729d606..d67c6cb0c 100644 --- a/src/cli/gateway-rpc.ts +++ b/src/cli/gateway-rpc.ts @@ -1,11 +1,13 @@ import type { Command } from "commander"; import { callGateway } from "../gateway/call.js"; +import { withProgress } from "./progress.js"; export type GatewayRpcOpts = { url?: string; token?: string; timeout?: string; expectFinal?: boolean; + json?: boolean; }; export function addGatewayClientOptions(cmd: Command) { @@ -25,14 +27,22 @@ export async function callGatewayFromCli( params?: unknown, extra?: { expectFinal?: boolean }, ) { - return await callGateway({ - url: opts.url, - token: opts.token, - method, - params, - expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal), - timeoutMs: Number(opts.timeout ?? 10_000), - clientName: "cli", - mode: "cli", - }); + return await withProgress( + { + label: `Gateway ${method}`, + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await callGateway({ + url: opts.url, + token: opts.token, + method, + params, + expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal), + timeoutMs: Number(opts.timeout ?? 10_000), + clientName: "cli", + mode: "cli", + }), + ); } diff --git a/src/cli/nodes-cli.ts b/src/cli/nodes-cli.ts index b19fc9c1d..aa32b82cf 100644 --- a/src/cli/nodes-cli.ts +++ b/src/cli/nodes-cli.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { defaultRuntime } from "../runtime.js"; +import { withProgress } from "./progress.js"; import { type CameraFacing, cameraTempPath, @@ -117,15 +118,23 @@ const callGatewayCli = async ( opts: NodesRpcOpts, params?: unknown, ) => - callGateway({ - url: opts.url, - token: opts.token, - method, - params, - timeoutMs: Number(opts.timeout ?? 10_000), - clientName: "cli", - mode: "cli", - }); + withProgress( + { + label: `Nodes ${method}`, + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await callGateway({ + url: opts.url, + token: opts.token, + method, + params, + timeoutMs: Number(opts.timeout ?? 10_000), + clientName: "cli", + mode: "cli", + }), + ); function formatAge(msAgo: number) { const s = Math.max(0, Math.floor(msAgo / 1000)); diff --git a/src/cli/progress.ts b/src/cli/progress.ts new file mode 100644 index 000000000..33226cc98 --- /dev/null +++ b/src/cli/progress.ts @@ -0,0 +1,138 @@ +import { spinner } from "@clack/prompts"; +import { + createOscProgressController, + supportsOscProgress, +} from "osc-progress"; + +import { theme } from "../terminal/theme.js"; + +const DEFAULT_DELAY_MS = 300; +let activeProgress = 0; + +type ProgressOptions = { + label: string; + indeterminate?: boolean; + total?: number; + enabled?: boolean; + delayMs?: number; + stream?: NodeJS.WriteStream; + fallback?: "spinner" | "none"; +}; + +export type ProgressReporter = { + setLabel: (label: string) => void; + setPercent: (percent: number) => void; + tick: (delta?: number) => void; + done: () => void; +}; + +const noopReporter: ProgressReporter = { + setLabel: () => {}, + setPercent: () => {}, + tick: () => {}, + done: () => {}, +}; + +export function createCliProgress(options: ProgressOptions): ProgressReporter { + if (options.enabled === false) return noopReporter; + if (activeProgress > 0) return noopReporter; + + const stream = options.stream ?? process.stderr; + if (!stream.isTTY) return noopReporter; + + const delayMs = + typeof options.delayMs === "number" ? options.delayMs : DEFAULT_DELAY_MS; + const canOsc = supportsOscProgress(process.env, stream.isTTY); + const allowSpinner = + !canOsc && (options.fallback === undefined || options.fallback === "spinner"); + + let started = false; + let label = options.label; + let total = options.total ?? null; + let completed = 0; + let percent = 0; + let indeterminate = + options.indeterminate ?? + (options.total === undefined || options.total === null); + + activeProgress += 1; + + const controller = canOsc + ? createOscProgressController({ + env: process.env, + isTty: stream.isTTY, + write: (chunk) => stream.write(chunk), + }) + : null; + + const spin = allowSpinner ? spinner() : null; + let timer: NodeJS.Timeout | null = null; + + const applyState = () => { + if (!started) return; + if (controller) { + if (indeterminate) controller.setIndeterminate(label); + else controller.setPercent(label, percent); + } else if (spin) { + spin.message(theme.accent(label)); + } + }; + + const start = () => { + if (started) return; + started = true; + if (spin) { + spin.start(theme.accent(label)); + } + applyState(); + }; + + timer = setTimeout(start, delayMs); + + const setLabel = (next: string) => { + label = next; + applyState(); + }; + + const setPercent = (nextPercent: number) => { + percent = Math.max(0, Math.min(100, Math.round(nextPercent))); + indeterminate = false; + applyState(); + }; + + const tick = (delta = 1) => { + if (!total) return; + completed = Math.min(total, completed + delta); + const nextPercent = + total > 0 ? Math.round((completed / total) * 100) : 0; + setPercent(nextPercent); + }; + + const done = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + if (!started) { + activeProgress = Math.max(0, activeProgress - 1); + return; + } + if (controller) controller.clear(); + if (spin) spin.stop(); + activeProgress = Math.max(0, activeProgress - 1); + }; + + return { setLabel, setPercent, tick, done }; +} + +export async function withProgress( + options: ProgressOptions, + work: (progress: ProgressReporter) => Promise, +): Promise { + const progress = createCliProgress(options); + try { + return await work(progress); + } finally { + progress.done(); + } +} diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 6071714c6..d6941aec6 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -7,6 +7,7 @@ import { } from "../config/sessions.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import type { RuntimeEnv } from "../runtime.js"; +import { withProgress } from "../cli/progress.js"; import { normalizeMessageProvider } from "../utils/message-provider.js"; import { agentCommand } from "./agent.js"; @@ -125,26 +126,34 @@ export async function agentViaGatewayCommand( const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp"; const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey(); - const response = await callGateway({ - method: "agent", - params: { - message: body, - to: opts.to, - sessionId: opts.sessionId, - sessionKey, - thinking: opts.thinking, - deliver: Boolean(opts.deliver), - provider, - timeout: timeoutSeconds, - lane: opts.lane, - extraSystemPrompt: opts.extraSystemPrompt, - idempotencyKey, + const response = await withProgress( + { + label: "Waiting for agent reply…", + indeterminate: true, + enabled: opts.json !== true, }, - expectFinal: true, - timeoutMs: gatewayTimeoutMs, - clientName: "cli", - mode: "cli", - }); + async () => + await callGateway({ + method: "agent", + params: { + message: body, + to: opts.to, + sessionId: opts.sessionId, + sessionKey, + thinking: opts.thinking, + deliver: Boolean(opts.deliver), + provider, + timeout: timeoutSeconds, + lane: opts.lane, + extraSystemPrompt: opts.extraSystemPrompt, + idempotencyKey, + }, + expectFinal: true, + timeoutMs: gatewayTimeoutMs, + clientName: "cli", + mode: "cli", + }), + ); if (opts.json) { runtime.log(JSON.stringify(response, null, 2)); diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts new file mode 100644 index 000000000..8d4125528 --- /dev/null +++ b/src/commands/agents.add.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RuntimeEnv } from "../runtime.js"; + +const configMocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(), + writeConfigFile: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readConfigFileSnapshot: configMocks.readConfigFileSnapshot, + writeConfigFile: configMocks.writeConfigFile, + }; +}); + +import { agentsAddCommand } from "./agents.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +const baseSnapshot = { + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], +}; + +describe("agents add command", () => { + beforeEach(() => { + configMocks.readConfigFileSnapshot.mockReset(); + configMocks.writeConfigFile.mockClear(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + }); + + it("requires --workspace when flags are present", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + + await agentsAddCommand({ name: "Work" }, runtime, { hasFlags: true }); + + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("--workspace"), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 9a8aefac2..0e7b2684a 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -343,12 +343,22 @@ function buildProviderBindings(params: { export async function agentsAddCommand( opts: AgentsAddOptions, runtime: RuntimeEnv = defaultRuntime, + params?: { hasFlags?: boolean }, ) { const cfg = await requireValidConfig(runtime); if (!cfg) return; const workspaceFlag = opts.workspace?.trim(); const nameInput = opts.name?.trim(); + const hasFlags = params?.hasFlags === true; + + if (hasFlags && !workspaceFlag) { + runtime.error( + "Non-interactive mode requires --workspace. Re-run without flags to use the wizard.", + ); + runtime.exit(1); + return; + } if (workspaceFlag) { if (!nameInput) { diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 8d2ee4430..c4d794382 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -31,9 +31,11 @@ import { import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { createCliProgress } from "../cli/progress.js"; 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 { resolveUserPath, sleep } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { @@ -90,6 +92,27 @@ type ConfigureWizardParams = { sections?: WizardSection[]; }; +const startOscSpinner = (label: string) => { + const spin = spinner(); + spin.start(theme.accent(label)); + const osc = createCliProgress({ + label, + indeterminate: true, + enabled: true, + fallback: "none", + }); + return { + update: (message: string) => { + spin.message(theme.accent(message)); + osc.setLabel(message); + }, + stop: (message: string) => { + osc.done(); + spin.stop(message); + }, + }; +}; + async function promptGatewayConfig( cfg: ClawdbotConfig, runtime: RuntimeEnv, @@ -283,8 +306,7 @@ async function promptAuthConfig( "Browser will open. Paste the code shown after login (code#state).", "Anthropic OAuth", ); - const spin = spinner(); - spin.start("Waiting for authorization…"); + const spin = startOscSpinner("Waiting for authorization…"); let oauthCreds: OAuthCredentials | null = null; try { oauthCreds = await loginAnthropic( @@ -341,21 +363,20 @@ async function promptAuthConfig( ].join("\n"), "OpenAI Codex OAuth", ); - const spin = spinner(); - spin.start("Starting OAuth flow…"); + const spin = startOscSpinner("Starting OAuth flow…"); let manualCodePromise: Promise | undefined; try { const creds = await loginOpenAICodex({ onAuth: async ({ url }) => { if (isRemote) { - spin.message("OAuth URL ready (see below)…"); + spin.update("OAuth URL ready (see below)…"); runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); manualCodePromise = text({ message: "Paste the redirect URL (or authorization code)", validate: (value) => (value?.trim() ? undefined : "Required"), }).then((value) => String(guardCancel(value, runtime))); } else { - spin.message("Complete sign-in in browser…"); + spin.update("Complete sign-in in browser…"); await openUrl(url); runtime.log(`Open: ${url}`); } @@ -372,7 +393,7 @@ async function promptAuthConfig( ); return String(code); }, - onProgress: (msg) => spin.message(msg), + onProgress: (msg) => spin.update(msg), }); spin.stop("OpenAI OAuth complete"); if (creds) { @@ -429,8 +450,7 @@ async function promptAuthConfig( ].join("\n"), "Google Antigravity OAuth", ); - const spin = spinner(); - spin.start("Starting OAuth flow…"); + const spin = startOscSpinner("Starting OAuth flow…"); let oauthCreds: OAuthCredentials | null = null; try { oauthCreds = await loginAntigravityVpsAware( @@ -439,12 +459,12 @@ async function promptAuthConfig( spin.stop("OAuth URL ready"); runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); } else { - spin.message("Complete sign-in in browser…"); + spin.update("Complete sign-in in browser…"); await openUrl(url); runtime.log(`Open: ${url}`); } }, - (msg) => spin.message(msg), + (msg) => spin.update(msg), ); spin.stop("Antigravity OAuth complete"); if (oauthCreds) { diff --git a/src/commands/health.ts b/src/commands/health.ts index 382382997..b4be98e07 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -4,6 +4,7 @@ import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; +import { withProgress } from "../cli/progress.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; import { resolveTelegramToken } from "../telegram/token.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; @@ -114,10 +115,18 @@ export async function healthCommand( runtime: RuntimeEnv, ) { // Always query the running gateway; do not open a direct Baileys socket here. - const summary = await callGateway({ - method: "health", - timeoutMs: opts.timeoutMs, - }); + const summary = await withProgress( + { + label: "Checking gateway health…", + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await callGateway({ + method: "health", + timeoutMs: opts.timeoutMs, + }), + ); // Gateway reachability defines success; provider issues are reported but not fatal here. const fatal = false; diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index fe5402c3e..72a4636b7 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -5,7 +5,6 @@ import { discoverAuthStorage, discoverModels, } from "@mariozechner/pi-coding-agent"; -import chalk from "chalk"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; import { @@ -36,6 +35,7 @@ import { shouldEnableShellEnvFallback, } from "../../infra/shell-env.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { colorize, isRich as isRichTerminal, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; import { DEFAULT_MODEL, @@ -52,43 +52,36 @@ const LOCAL_PAD = 5; const AUTH_PAD = 5; const isRich = (opts?: { json?: boolean; plain?: boolean }) => - Boolean( - process.stdout.isTTY && chalk.level > 0 && !opts?.json && !opts?.plain, - ); + Boolean(isRichTerminal() && !opts?.json && !opts?.plain); const pad = (value: string, size: number) => value.padEnd(size); -const colorize = ( - rich: boolean, - color: (value: string) => string, - value: string, -) => (rich ? color(value) : value); - const formatKey = (key: string, rich: boolean) => - colorize(rich, chalk.yellow, key); + colorize(rich, theme.warn, key); const formatValue = (value: string, rich: boolean) => - colorize(rich, chalk.white, value); + colorize(rich, theme.info, value); const formatKeyValue = ( key: string, value: string, rich: boolean, - valueColor: (value: string) => string = chalk.white, + valueColor: (value: string) => string = theme.info, ) => `${formatKey(key, rich)}=${colorize(rich, valueColor, value)}`; -const formatSeparator = (rich: boolean) => colorize(rich, chalk.gray, " | "); +const formatSeparator = (rich: boolean) => + colorize(rich, theme.muted, " | "); const formatTag = (tag: string, rich: boolean) => { if (!rich) return tag; - if (tag === "default") return chalk.greenBright(tag); - if (tag === "image") return chalk.magentaBright(tag); - if (tag === "configured") return chalk.cyan(tag); - if (tag === "missing") return chalk.red(tag); - if (tag.startsWith("fallback#")) return chalk.yellow(tag); - if (tag.startsWith("img-fallback#")) return chalk.yellowBright(tag); - if (tag.startsWith("alias:")) return chalk.blue(tag); - return chalk.gray(tag); + if (tag === "default") return theme.success(tag); + if (tag === "image") return theme.accentBright(tag); + if (tag === "configured") return theme.accent(tag); + if (tag === "missing") return theme.error(tag); + if (tag.startsWith("fallback#")) return theme.warn(tag); + if (tag.startsWith("img-fallback#")) return theme.warn(tag); + if (tag.startsWith("alias:")) return theme.accentDim(tag); + return theme.muted(tag); }; const truncate = (value: string, max: number) => { @@ -450,7 +443,7 @@ function printModelTable( pad("Auth", AUTH_PAD), "Tags", ].join(" "); - runtime.log(rich ? chalk.bold(header) : header); + runtime.log(rich ? theme.heading(header) : header); for (const row of rows) { const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD); @@ -470,26 +463,26 @@ function printModelTable( const coloredInput = colorize( rich, - row.input.includes("image") ? chalk.magenta : chalk.white, + row.input.includes("image") ? theme.accentBright : theme.info, inputLabel, ); const coloredLocal = colorize( rich, - row.local === null ? chalk.gray : row.local ? chalk.green : chalk.gray, + row.local === null ? theme.muted : row.local ? theme.success : theme.muted, localLabel, ); const coloredAuth = colorize( rich, row.available === null - ? chalk.gray + ? theme.muted : row.available - ? chalk.green - : chalk.red, + ? theme.success + : theme.error, authLabel, ); const line = [ - rich ? chalk.cyan(keyLabel) : keyLabel, + rich ? theme.accent(keyLabel) : keyLabel, coloredInput, ctxLabel, coloredLocal, @@ -762,71 +755,72 @@ export async function modelsStatusCommand( } const rich = isRich(opts); - const label = (value: string) => colorize(rich, chalk.cyan, value.padEnd(14)); + const label = (value: string) => + colorize(rich, theme.accent, value.padEnd(14)); const displayDefault = rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel; runtime.log( - `${label("Config")}${colorize(rich, chalk.gray, ":")} ${colorize(rich, chalk.white, CONFIG_PATH_CLAWDBOT)}`, + `${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, CONFIG_PATH_CLAWDBOT)}`, ); runtime.log( - `${label("Agent dir")}${colorize(rich, chalk.gray, ":")} ${colorize( + `${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize( rich, - chalk.white, + theme.info, shortenHomePath(agentDir), )}`, ); runtime.log( - `${label("Default")}${colorize(rich, chalk.gray, ":")} ${colorize( + `${label("Default")}${colorize(rich, theme.muted, ":")} ${colorize( rich, - chalk.green, + theme.success, displayDefault, )}`, ); runtime.log( `${label(`Fallbacks (${fallbacks.length || 0})`)}${colorize( rich, - chalk.gray, + theme.muted, ":", )} ${colorize( rich, - fallbacks.length ? chalk.yellow : chalk.gray, + fallbacks.length ? theme.warn : theme.muted, fallbacks.length ? fallbacks.join(", ") : "-", )}`, ); runtime.log( - `${label("Image model")}${colorize(rich, chalk.gray, ":")} ${colorize( + `${label("Image model")}${colorize(rich, theme.muted, ":")} ${colorize( rich, - imageModel ? chalk.magenta : chalk.gray, + imageModel ? theme.accentBright : theme.muted, imageModel || "-", )}`, ); runtime.log( `${label(`Image fallbacks (${imageFallbacks.length || 0})`)}${colorize( rich, - chalk.gray, + theme.muted, ":", )} ${colorize( rich, - imageFallbacks.length ? chalk.magentaBright : chalk.gray, + imageFallbacks.length ? theme.accentBright : theme.muted, imageFallbacks.length ? imageFallbacks.join(", ") : "-", )}`, ); runtime.log( `${label(`Aliases (${Object.keys(aliases).length || 0})`)}${colorize( rich, - chalk.gray, + theme.muted, ":", )} ${colorize( rich, - Object.keys(aliases).length ? chalk.cyan : chalk.gray, + Object.keys(aliases).length ? theme.accent : theme.muted, Object.keys(aliases).length ? Object.entries(aliases) .map(([alias, target]) => rich - ? `${chalk.blue(alias)} ${chalk.gray("->")} ${chalk.white( + ? `${theme.accentDim(alias)} ${theme.muted("->")} ${theme.info( target, )}` : `${alias} -> ${target}`, @@ -838,41 +832,41 @@ export async function modelsStatusCommand( runtime.log( `${label(`Configured models (${allowed.length || 0})`)}${colorize( rich, - chalk.gray, + theme.muted, ":", )} ${colorize( rich, - allowed.length ? chalk.white : chalk.gray, + allowed.length ? theme.info : theme.muted, allowed.length ? allowed.join(", ") : "all", )}`, ); runtime.log(""); - runtime.log(colorize(rich, chalk.bold, "Auth overview")); + runtime.log(colorize(rich, theme.heading, "Auth overview")); runtime.log( - `${label("Auth store")}${colorize(rich, chalk.gray, ":")} ${colorize( + `${label("Auth store")}${colorize(rich, theme.muted, ":")} ${colorize( rich, - chalk.white, + theme.info, shortenHomePath(resolveAuthStorePathForDisplay()), )}`, ); runtime.log( - `${label("Shell env")}${colorize(rich, chalk.gray, ":")} ${colorize( + `${label("Shell env")}${colorize(rich, theme.muted, ":")} ${colorize( rich, - shellFallbackEnabled ? chalk.green : chalk.gray, + shellFallbackEnabled ? theme.success : theme.muted, shellFallbackEnabled ? "on" : "off", )}${ applied.length - ? colorize(rich, chalk.gray, ` (applied: ${applied.join(", ")})`) + ? colorize(rich, theme.muted, ` (applied: ${applied.join(", ")})`) : "" }`, ); runtime.log( `${label( `Providers w/ OAuth (${providersWithOauth.length || 0})`, - )}${colorize(rich, chalk.gray, ":")} ${colorize( + )}${colorize(rich, theme.muted, ":")} ${colorize( rich, - providersWithOauth.length ? chalk.white : chalk.gray, + providersWithOauth.length ? theme.info : theme.muted, providersWithOauth.length ? providersWithOauth.join(", ") : "-", )}`, ); @@ -883,9 +877,9 @@ export async function modelsStatusCommand( bits.push( formatKeyValue( "effective", - `${colorize(rich, chalk.magenta, entry.effective.kind)}:${colorize( + `${colorize(rich, theme.accentBright, entry.effective.kind)}:${colorize( rich, - chalk.gray, + theme.muted, entry.effective.detail, )}`, rich, @@ -930,6 +924,6 @@ export async function modelsStatusCommand( ), ); } - runtime.log(`- ${chalk.bold(entry.provider)} ${bits.join(separator)}`); + runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`); } } diff --git a/src/commands/poll.ts b/src/commands/poll.ts index 14802bb88..1f5e0aac9 100644 --- a/src/commands/poll.ts +++ b/src/commands/poll.ts @@ -8,6 +8,7 @@ import { } from "../infra/outbound/format.js"; import { normalizePollInput, type PollInput } from "../polls.js"; import type { RuntimeEnv } from "../runtime.js"; +import { withProgress } from "../cli/progress.js"; function parseIntOption(value: unknown, label: string): number | undefined { if (value === undefined || value === null) return undefined; @@ -57,25 +58,33 @@ export async function pollCommand( return; } - const result = await callGateway<{ - messageId: string; - toJid?: string; - channelId?: string; - }>({ - method: "poll", - params: { - to: opts.to, - question: normalized.question, - options: normalized.options, - maxSelections: normalized.maxSelections, - durationHours: normalized.durationHours, - provider, - idempotencyKey: randomIdempotencyKey(), + const result = await withProgress( + { + label: `Sending poll via ${provider}…`, + indeterminate: true, + enabled: opts.json !== true, }, - timeoutMs: 10_000, - clientName: "cli", - mode: "cli", - }); + async () => + await callGateway<{ + messageId: string; + toJid?: string; + channelId?: string; + }>({ + method: "poll", + params: { + to: opts.to, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + durationHours: normalized.durationHours, + provider, + idempotencyKey: randomIdempotencyKey(), + }, + timeoutMs: 10_000, + clientName: "cli", + mode: "cli", + }), + ); runtime.log( success( diff --git a/src/commands/providers.ts b/src/commands/providers.ts index 1ca15342b..a829689f4 100644 --- a/src/commands/providers.ts +++ b/src/commands/providers.ts @@ -1,11 +1,9 @@ -import { spinner } from "@clack/prompts"; -import chalk from "chalk"; - import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID, loadAuthProfileStore, } from "../agents/auth-profiles.js"; +import { withProgress } from "../cli/progress.js"; import type { ClawdbotConfig } from "../config/config.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { @@ -36,6 +34,7 @@ import { listTelegramAccountIds, resolveTelegramAccount, } from "../telegram/accounts.js"; +import { theme } from "../terminal/theme.js"; import { formatTerminalLink } from "../utils.js"; import { listWhatsAppAccountIds, @@ -61,7 +60,7 @@ type ChatProvider = (typeof CHAT_PROVIDERS)[number]; function docsLink(path: string, label?: string): string { const url = `${DOCS_ROOT}${path}`; - return formatTerminalLink(url, label ?? url); + return formatTerminalLink(label ?? url, url, { fallback: url }); } type ProvidersListOptions = { @@ -136,17 +135,17 @@ function formatAccountLabel(params: { accountId: string; name?: string }) { } const colorValue = (value: string) => { - if (value === "none") return chalk.red(value); - if (value === "env") return chalk.cyan(value); - return chalk.green(value); + if (value === "none") return theme.error(value); + if (value === "env") return theme.accent(value); + return theme.success(value); }; function formatEnabled(value: boolean | undefined): string { - return value === false ? chalk.red("disabled") : chalk.green("enabled"); + return value === false ? theme.error("disabled") : theme.success("enabled"); } function formatConfigured(value: boolean): string { - return value ? chalk.green("configured") : chalk.yellow("not configured"); + return value ? theme.success("configured") : theme.warn("not configured"); } function formatTokenSource(source?: string): string { @@ -160,7 +159,7 @@ function formatSource(label: string, source?: string): string { } function formatLinked(value: boolean): string { - return value ? chalk.green("linked") : chalk.yellow("not linked"); + return value ? theme.success("linked") : theme.warn("not linked"); } function applyAccountName(params: { @@ -501,14 +500,14 @@ export async function providersListCommand( } const lines: string[] = []; - lines.push(chalk.bold("Chat providers:")); + lines.push(theme.heading("Chat providers:")); for (const accountId of whatsappAccounts) { const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); const linked = await webAuthExists(authDir); const name = cfg.whatsapp?.accounts?.[accountId]?.name; lines.push( - `- ${chalk.cyan("WhatsApp")} ${chalk.bold( + `- ${theme.accent("WhatsApp")} ${theme.heading( formatAccountLabel({ accountId, name, @@ -524,7 +523,7 @@ export async function providersListCommand( for (const accountId of telegramAccounts) { const account = resolveTelegramAccount({ cfg, accountId }); lines.push( - `- ${chalk.cyan("Telegram")} ${chalk.bold( + `- ${theme.accent("Telegram")} ${theme.heading( formatAccountLabel({ accountId, name: account.name, @@ -538,7 +537,7 @@ export async function providersListCommand( for (const accountId of discordAccounts) { const account = resolveDiscordAccount({ cfg, accountId }); lines.push( - `- ${chalk.cyan("Discord")} ${chalk.bold( + `- ${theme.accent("Discord")} ${theme.heading( formatAccountLabel({ accountId, name: account.name, @@ -553,7 +552,7 @@ export async function providersListCommand( const account = resolveSlackAccount({ cfg, accountId }); const configured = Boolean(account.botToken && account.appToken); lines.push( - `- ${chalk.cyan("Slack")} ${chalk.bold( + `- ${theme.accent("Slack")} ${theme.heading( formatAccountLabel({ accountId, name: account.name, @@ -570,12 +569,12 @@ export async function providersListCommand( for (const accountId of signalAccounts) { const account = resolveSignalAccount({ cfg, accountId }); lines.push( - `- ${chalk.cyan("Signal")} ${chalk.bold( + `- ${theme.accent("Signal")} ${theme.heading( formatAccountLabel({ accountId, name: account.name, }), - )}: ${formatConfigured(account.configured)}, base=${chalk.dim( + )}: ${formatConfigured(account.configured)}, base=${theme.muted( account.baseUrl, )}, ${formatEnabled(account.enabled)}`, ); @@ -584,7 +583,7 @@ export async function providersListCommand( for (const accountId of imessageAccounts) { const account = resolveIMessageAccount({ cfg, accountId }); lines.push( - `- ${chalk.cyan("iMessage")} ${chalk.bold( + `- ${theme.accent("iMessage")} ${theme.heading( formatAccountLabel({ accountId, name: account.name, @@ -594,14 +593,14 @@ export async function providersListCommand( } lines.push(""); - lines.push(chalk.bold("Auth providers (OAuth + API keys):")); + lines.push(theme.heading("Auth providers (OAuth + API keys):")); if (authProfiles.length === 0) { - lines.push(chalk.dim("- none")); + lines.push(theme.muted("- none")); } else { for (const profile of authProfiles) { - const external = profile.isExternal ? chalk.dim(" (synced)") : ""; + const external = profile.isExternal ? theme.muted(" (synced)") : ""; lines.push( - `- ${chalk.cyan(profile.id)} (${chalk.green(profile.type)}${external})`, + `- ${theme.accent(profile.id)} (${theme.success(profile.type)}${external})`, ); } } @@ -610,11 +609,11 @@ export async function providersListCommand( if (includeUsage) { runtime.log(""); - const usage = await loadUsageWithSpinner(runtime); + const usage = await loadUsageWithProgress(runtime); if (usage) { const usageLines = formatUsageReportLines(usage); if (usageLines.length > 0) { - usageLines[0] = chalk.cyan(usageLines[0]); + usageLines[0] = theme.accent(usageLines[0]); runtime.log(usageLines.join("\n")); } } @@ -628,27 +627,15 @@ export async function providersListCommand( ); } -async function loadUsageWithSpinner( +async function loadUsageWithProgress( runtime: RuntimeEnv, ): Promise> | null> { - const rich = Boolean(process.stdout.isTTY); - if (!rich) { - try { - return await loadProviderUsageSummary(); - } catch (err) { - runtime.error(String(err)); - return null; - } - } - - const spin = spinner(); - spin.start(chalk.cyan("Fetching usage snapshot…")); try { - const usage = await loadProviderUsageSummary(); - spin.stop(chalk.green("Usage snapshot ready")); - return usage; + return await withProgress( + { label: "Fetching usage snapshot…", indeterminate: true, enabled: true }, + async () => await loadProviderUsageSummary(), + ); } catch (err) { - spin.stop(chalk.red("Usage snapshot failed")); runtime.error(String(err)); return null; } @@ -660,18 +647,26 @@ export async function providersStatusCommand( ) { const timeoutMs = Number(opts.timeout ?? 10_000); try { - const payload = await callGateway({ - method: "providers.status", - params: { probe: Boolean(opts.probe), timeoutMs }, - timeoutMs, - }); + const payload = await withProgress( + { + label: "Checking provider status…", + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await callGateway({ + method: "providers.status", + params: { probe: Boolean(opts.probe), timeoutMs }, + timeoutMs, + }), + ); if (opts.json) { runtime.log(JSON.stringify(payload, null, 2)); return; } const data = payload as Record; const lines: string[] = []; - lines.push(chalk.green("Gateway reachable.")); + lines.push(theme.success("Gateway reachable.")); const accountLines = ( label: string, accounts: Array>, diff --git a/src/commands/send.ts b/src/commands/send.ts index 7a5cbfc24..114d8218f 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -11,6 +11,7 @@ import { } from "../infra/outbound/format.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import type { RuntimeEnv } from "../runtime.js"; +import { withProgress } from "../cli/progress.js"; import { normalizeMessageProvider } from "../utils/message-provider.js"; export async function sendCommand( @@ -50,20 +51,28 @@ export async function sendCommand( if (!resolvedTarget.ok) { throw resolvedTarget.error; } - const results = await deliverOutboundPayloads({ - cfg: loadConfig(), - provider, - to: resolvedTarget.to, - payloads: [{ text: opts.message, mediaUrl: opts.media }], - deps: { - sendWhatsApp: deps.sendMessageWhatsApp, - sendTelegram: deps.sendMessageTelegram, - sendDiscord: deps.sendMessageDiscord, - sendSlack: deps.sendMessageSlack, - sendSignal: deps.sendMessageSignal, - sendIMessage: deps.sendMessageIMessage, + const results = await withProgress( + { + label: `Sending via ${provider}…`, + indeterminate: true, + enabled: opts.json !== true, }, - }); + async () => + await deliverOutboundPayloads({ + cfg: loadConfig(), + provider, + to: resolvedTarget.to, + payloads: [{ text: opts.message, mediaUrl: opts.media }], + deps: { + sendWhatsApp: deps.sendMessageWhatsApp, + sendTelegram: deps.sendMessageTelegram, + sendDiscord: deps.sendMessageDiscord, + sendSlack: deps.sendMessageSlack, + sendSignal: deps.sendMessageSignal, + sendIMessage: deps.sendMessageIMessage, + }, + }), + ); const last = results.at(-1); const summary = formatOutboundDeliverySummary(provider, last); runtime.log(success(summary)); @@ -105,7 +114,14 @@ export async function sendCommand( mode: "cli", }); - const result = await sendViaGateway(); + const result = await withProgress( + { + label: `Sending via ${provider}…`, + indeterminate: true, + enabled: opts.json !== true, + }, + async () => await sendViaGateway(), + ); runtime.log( success( diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 4bd2b7c76..2e792e2f7 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -1,5 +1,3 @@ -import chalk from "chalk"; - import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -15,6 +13,7 @@ import { } from "../config/sessions.js"; import { info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; +import { isRich, theme } from "../terminal/theme.js"; type SessionRow = { key: string; @@ -41,8 +40,6 @@ const AGE_PAD = 9; const MODEL_PAD = 14; const TOKENS_PAD = 20; -const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0); - const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; @@ -54,10 +51,10 @@ const truncateKey = (key: string) => { const colorByPct = (label: string, pct: number | null, rich: boolean) => { if (!rich || pct === null) return label; - if (pct >= 95) return chalk.red(label); - if (pct >= 80) return chalk.yellow(label); - if (pct >= 60) return chalk.green(label); - return chalk.gray(label); + if (pct >= 95) return theme.error(label); + if (pct >= 80) return theme.warn(label); + if (pct >= 60) return theme.success(label); + return theme.muted(label); }; const formatTokensCell = ( @@ -79,21 +76,21 @@ const formatTokensCell = ( const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => { const label = kind.padEnd(KIND_PAD); if (!rich) return label; - if (kind === "group") return chalk.magenta(label); - if (kind === "global") return chalk.yellow(label); - if (kind === "direct") return chalk.cyan(label); - return chalk.gray(label); + if (kind === "group") return theme.accentBright(label); + if (kind === "global") return theme.warn(label); + if (kind === "direct") return theme.accent(label); + return theme.muted(label); }; const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => { const ageLabel = updatedAt ? formatAge(Date.now() - updatedAt) : "unknown"; const padded = ageLabel.padEnd(AGE_PAD); - return rich ? chalk.gray(padded) : padded; + return rich ? theme.muted(padded) : padded; }; const formatModelCell = (model: string | null | undefined, rich: boolean) => { const label = (model ?? "unknown").padEnd(MODEL_PAD); - return rich ? chalk.white(label) : label; + return rich ? theme.info(label) : label; }; const formatFlagsCell = (row: SessionRow, rich: boolean) => { @@ -107,7 +104,7 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => { row.sessionId ? `id:${row.sessionId}` : null, ].filter(Boolean); const label = flags.join(" "); - return label.length === 0 ? "" : rich ? chalk.gray(label) : label; + return label.length === 0 ? "" : rich ? theme.muted(label) : label; }; const formatAge = (ms: number | null | undefined) => { @@ -240,7 +237,7 @@ export async function sessionsCommand( "Flags", ].join(" "); - runtime.log(rich ? chalk.bold(header) : header); + runtime.log(rich ? theme.heading(header) : header); for (const row of rows) { const model = row.model ?? configModel; @@ -251,7 +248,7 @@ export async function sessionsCommand( const total = row.totalTokens ?? input + output; const keyLabel = truncateKey(row.key).padEnd(KEY_PAD); - const keyCell = rich ? chalk.cyan(keyLabel) : keyLabel; + const keyCell = rich ? theme.accent(keyLabel) : keyLabel; const line = [ formatKindCell(row.kind, rich), diff --git a/src/commands/status.ts b/src/commands/status.ts index 708f0b4d8..0b7e14c73 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -18,6 +18,7 @@ import { formatUsageReportLines, loadProviderUsageSummary, } from "../infra/provider-usage.js"; +import { withProgress } from "../cli/progress.js"; import { peekSystemEvents } from "../infra/system-events.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; @@ -233,13 +234,29 @@ export async function statusCommand( ) { const summary = await getStatusSummary(); const usage = opts.usage - ? await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }) + ? await withProgress( + { + label: "Fetching usage snapshot…", + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), + ) : undefined; const health: HealthSummary | undefined = opts.deep - ? await callGateway({ - method: "health", - timeoutMs: opts.timeoutMs, - }) + ? await withProgress( + { + label: "Checking gateway health…", + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await callGateway({ + method: "health", + timeoutMs: opts.timeoutMs, + }), + ) : undefined; if (opts.json) { diff --git a/src/globals.ts b/src/globals.ts index 3f8655a02..192ff4eff 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -1,5 +1,5 @@ -import chalk from "chalk"; import { getLogger, isFileLogLevelEnabled } from "./logging.js"; +import { theme } from "./terminal/theme.js"; let globalVerbose = false; let globalYes = false; @@ -24,12 +24,12 @@ export function logVerbose(message: string) { // ignore logger failures to avoid breaking verbose printing } if (!globalVerbose) return; - console.log(chalk.gray(message)); + console.log(theme.muted(message)); } export function logVerboseConsole(message: string) { if (!globalVerbose) return; - console.log(chalk.gray(message)); + console.log(theme.muted(message)); } export function setYes(v: boolean) { @@ -40,7 +40,7 @@ export function isYes() { return globalYes; } -export const success = chalk.green; -export const warn = chalk.yellow; -export const info = chalk.cyan; -export const danger = chalk.red; +export const success = theme.success; +export const warn = theme.warn; +export const info = theme.info; +export const danger = theme.error; diff --git a/src/terminal/theme.ts b/src/terminal/theme.ts new file mode 100644 index 000000000..e084db5cf --- /dev/null +++ b/src/terminal/theme.ts @@ -0,0 +1,36 @@ +import chalk from "chalk"; + +export const LOBSTER_PALETTE = { + accent: "#FF5A2D", + accentBright: "#FF7A3D", + accentDim: "#D14A22", + info: "#FF8A5B", + success: "#2FBF71", + warn: "#FFB020", + error: "#E23D2D", + muted: "#8B7F77", +} as const; + +const hex = (value: string) => chalk.hex(value); + +export const theme = { + accent: hex(LOBSTER_PALETTE.accent), + accentBright: hex(LOBSTER_PALETTE.accentBright), + accentDim: hex(LOBSTER_PALETTE.accentDim), + info: hex(LOBSTER_PALETTE.info), + success: hex(LOBSTER_PALETTE.success), + warn: hex(LOBSTER_PALETTE.warn), + error: hex(LOBSTER_PALETTE.error), + muted: hex(LOBSTER_PALETTE.muted), + heading: chalk.bold.hex(LOBSTER_PALETTE.accent), + command: hex(LOBSTER_PALETTE.accentBright), + option: hex(LOBSTER_PALETTE.warn), +} as const; + +export const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0); + +export const colorize = ( + rich: boolean, + color: (value: string) => string, + value: string, +) => (rich ? color(value) : value); diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts index 2619d8f10..4cf985e86 100644 --- a/src/wizard/clack-prompter.ts +++ b/src/wizard/clack-prompter.ts @@ -14,6 +14,8 @@ import { import type { WizardProgress, WizardPrompter } from "./prompts.js"; import { WizardCancelledError } from "./prompts.js"; +import { createCliProgress } from "../cli/progress.js"; +import { theme } from "../terminal/theme.js"; function guardCancel(value: T | symbol): T { if (isCancel(value)) { @@ -74,10 +76,22 @@ export function createClackPrompter(): WizardPrompter { ), progress: (label: string): WizardProgress => { const spin = spinner(); - spin.start(label); + spin.start(theme.accent(label)); + const osc = createCliProgress({ + label, + indeterminate: true, + enabled: true, + fallback: "none", + }); return { - update: (message) => spin.message(message), - stop: (message) => spin.stop(message), + update: (message) => { + spin.message(theme.accent(message)); + osc.setLabel(message); + }, + stop: (message) => { + osc.done(); + spin.stop(message); + }, }; }, };