feat: refresh CLI output styling and progress

This commit is contained in:
Peter Steinberger
2026-01-08 05:19:57 +01:00
parent ab98ffe9fe
commit 28cd2e4c24
24 changed files with 652 additions and 273 deletions

View File

@@ -16,6 +16,12 @@ If you change the CLI code, update this doc.
- `--profile <name>`: isolate state under `~/.clawdbot-<name>`. - `--profile <name>`: isolate state under `~/.clawdbot-<name>`.
- `-V`, `--version`, `-v`: print version and exit. - `-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 ## Command tree
``` ```
@@ -321,7 +327,7 @@ Options:
- `--json` - `--json`
#### `agents add [name]` #### `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: Options:
- `--workspace <dir>` - `--workspace <dir>`

View File

@@ -113,6 +113,7 @@
"grammy": "^1.39.2", "grammy": "^1.39.2",
"json5": "^2.2.3", "json5": "^2.2.3",
"long": "5.3.2", "long": "5.3.2",
"osc-progress": "^0.2.0",
"playwright-core": "1.57.0", "playwright-core": "1.57.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",

9
pnpm-lock.yaml generated
View File

@@ -112,6 +112,9 @@ importers:
long: long:
specifier: 5.3.2 specifier: 5.3.2
version: 5.3.2 version: 5.3.2
osc-progress:
specifier: ^0.2.0
version: 0.2.0
playwright-core: playwright-core:
specifier: 1.57.0 specifier: 1.57.0
version: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02) version: 1.57.0(patch_hash=66f1f266424dbe354068aaa5bba87bfb0e1d7d834a938c25dd70d43cdf1c1b02)
@@ -2428,6 +2431,10 @@ packages:
opus-decoder@0.7.11: opus-decoder@0.7.11:
resolution: {integrity: sha512-+e+Jz3vGQLxRTBHs8YJQPRPc1Tr+/aC6coV/DlZylriA29BdHQAYXhvNRKtjftof17OFng0+P4wsFIqQu3a48A==} 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: oxlint-tsgolint@0.10.1:
resolution: {integrity: sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==} resolution: {integrity: sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==}
hasBin: true hasBin: true
@@ -5294,6 +5301,8 @@ snapshots:
'@wasm-audio-decoders/common': 9.0.7 '@wasm-audio-decoders/common': 9.0.7
optional: true optional: true
osc-progress@0.2.0: {}
oxlint-tsgolint@0.10.1: oxlint-tsgolint@0.10.1:
optionalDependencies: optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.10.1 '@oxlint-tsgolint/darwin-arm64': 0.10.1

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import type { Command } from "commander"; import type { Command } from "commander";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { withProgress } from "./progress.js";
import { writeBase64ToFile } from "./nodes-camera.js"; import { writeBase64ToFile } from "./nodes-camera.js";
import { import {
canvasSnapshotTempPath, canvasSnapshotTempPath,
@@ -80,15 +81,23 @@ const callGatewayCli = async (
opts: CanvasOpts, opts: CanvasOpts,
params?: unknown, params?: unknown,
) => ) =>
callGateway({ withProgress(
url: opts.url, {
token: opts.token, label: `Canvas ${method}`,
method, indeterminate: true,
params, enabled: opts.json !== true,
timeoutMs: Number(opts.timeout ?? 10_000), },
clientName: "cli", async () =>
mode: "cli", 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[] { function parseNodeList(value: unknown): NodeListNode[] {
const obj = const obj =

View File

@@ -1,8 +1,8 @@
import chalk from "chalk";
import type { Command } from "commander"; import type { Command } from "commander";
import type { CronJob, CronSchedule } from "../cron/types.js"; import type { CronJob, CronSchedule } from "../cron/types.js";
import { danger } from "../globals.js"; import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import type { GatewayRpcOpts } from "./gateway-rpc.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } 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_STATUS_PAD = 9;
const CRON_TARGET_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 pad = (value: string, width: number) => value.padEnd(width);
const truncate = (value: string, width: number) => { const truncate = (value: string, width: number) => {
@@ -122,12 +120,6 @@ const formatStatus = (job: CronJob) => {
return job.state.lastStatus ?? "idle"; 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) { function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
if (jobs.length === 0) { if (jobs.length === 0) {
runtime.log("No cron jobs."); runtime.log("No cron jobs.");
@@ -145,7 +137,7 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
pad("Target", CRON_TARGET_PAD), pad("Target", CRON_TARGET_PAD),
].join(" "); ].join(" ");
runtime.log(rich ? chalk.bold(header) : header); runtime.log(rich ? theme.heading(header) : header);
const now = Date.now(); const now = Date.now();
for (const job of jobs) { for (const job of jobs) {
@@ -168,26 +160,28 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD); const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD);
const coloredStatus = (() => { const coloredStatus = (() => {
if (statusRaw === "ok") return colorize(rich, chalk.green, statusLabel); if (statusRaw === "ok")
if (statusRaw === "error") return colorize(rich, chalk.red, statusLabel); return colorize(rich, theme.success, statusLabel);
if (statusRaw === "error")
return colorize(rich, theme.error, statusLabel);
if (statusRaw === "running") if (statusRaw === "running")
return colorize(rich, chalk.yellow, statusLabel); return colorize(rich, theme.warn, statusLabel);
if (statusRaw === "skipped") if (statusRaw === "skipped")
return colorize(rich, chalk.gray, statusLabel); return colorize(rich, theme.muted, statusLabel);
return colorize(rich, chalk.gray, statusLabel); return colorize(rich, theme.muted, statusLabel);
})(); })();
const coloredTarget = const coloredTarget =
job.sessionTarget === "isolated" job.sessionTarget === "isolated"
? colorize(rich, chalk.magenta, targetLabel) ? colorize(rich, theme.accentBright, targetLabel)
: colorize(rich, chalk.cyan, targetLabel); : colorize(rich, theme.accent, targetLabel);
const line = [ const line = [
colorize(rich, chalk.cyan, idLabel), colorize(rich, theme.accent, idLabel),
colorize(rich, chalk.white, nameLabel), colorize(rich, theme.info, nameLabel),
colorize(rich, chalk.white, scheduleLabel), colorize(rich, theme.info, scheduleLabel),
colorize(rich, chalk.gray, nextLabel), colorize(rich, theme.muted, nextLabel),
colorize(rich, chalk.gray, lastLabel), colorize(rich, theme.muted, lastLabel),
coloredStatus, coloredStatus,
coloredTarget, coloredTarget,
].join(" "); ].join(" ");

View File

@@ -30,6 +30,7 @@ import {
} from "../infra/ports.js"; } from "../infra/ports.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { createDefaultDeps } from "./deps.js"; import { createDefaultDeps } from "./deps.js";
import { withProgress } from "./progress.js";
type DaemonStatus = { type DaemonStatus = {
service: { service: {
@@ -74,6 +75,7 @@ export type GatewayRpcOpts = {
token?: string; token?: string;
password?: string; password?: string;
timeout?: string; timeout?: string;
json?: boolean;
}; };
export type DaemonStatusOptions = { export type DaemonStatusOptions = {
@@ -104,15 +106,23 @@ function parsePort(raw: unknown): number | null {
async function probeGatewayStatus(opts: GatewayRpcOpts) { async function probeGatewayStatus(opts: GatewayRpcOpts) {
try { try {
await callGateway({ await withProgress(
url: opts.url, {
token: opts.token, label: "Checking gateway status...",
password: opts.password, indeterminate: true,
method: "status", enabled: opts.json !== true,
timeoutMs: Number(opts.timeout ?? 10_000), },
clientName: "cli", async () =>
mode: "cli", 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; return { ok: true } as const;
} catch (err) { } catch (err) {
return { return {

View File

@@ -33,6 +33,7 @@ import {
} from "./daemon-cli.js"; } from "./daemon-cli.js";
import { createDefaultDeps } from "./deps.js"; import { createDefaultDeps } from "./deps.js";
import { forceFreePortAndWait } from "./ports.js"; import { forceFreePortAndWait } from "./ports.js";
import { withProgress } from "./progress.js";
type GatewayRpcOpts = { type GatewayRpcOpts = {
url?: string; url?: string;
@@ -211,17 +212,25 @@ const callGatewayCli = async (
opts: GatewayRpcOpts, opts: GatewayRpcOpts,
params?: unknown, params?: unknown,
) => ) =>
callGateway({ withProgress(
url: opts.url, {
token: opts.token, label: `Gateway ${method}`,
password: opts.password, indeterminate: true,
method, enabled: true,
params, },
expectFinal: Boolean(opts.expectFinal), async () =>
timeoutMs: Number(opts.timeout ?? 10_000), await callGateway({
clientName: "cli", url: opts.url,
mode: "cli", 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) { export function registerGatewayCli(program: Command) {
program program

View File

@@ -1,11 +1,13 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { callGateway } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
import { withProgress } from "./progress.js";
export type GatewayRpcOpts = { export type GatewayRpcOpts = {
url?: string; url?: string;
token?: string; token?: string;
timeout?: string; timeout?: string;
expectFinal?: boolean; expectFinal?: boolean;
json?: boolean;
}; };
export function addGatewayClientOptions(cmd: Command) { export function addGatewayClientOptions(cmd: Command) {
@@ -25,14 +27,22 @@ export async function callGatewayFromCli(
params?: unknown, params?: unknown,
extra?: { expectFinal?: boolean }, extra?: { expectFinal?: boolean },
) { ) {
return await callGateway({ return await withProgress(
url: opts.url, {
token: opts.token, label: `Gateway ${method}`,
method, indeterminate: true,
params, enabled: opts.json !== true,
expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal), },
timeoutMs: Number(opts.timeout ?? 10_000), async () =>
clientName: "cli", await callGateway({
mode: "cli", url: opts.url,
}); token: opts.token,
method,
params,
expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal),
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: "cli",
mode: "cli",
}),
);
} }

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { withProgress } from "./progress.js";
import { import {
type CameraFacing, type CameraFacing,
cameraTempPath, cameraTempPath,
@@ -117,15 +118,23 @@ const callGatewayCli = async (
opts: NodesRpcOpts, opts: NodesRpcOpts,
params?: unknown, params?: unknown,
) => ) =>
callGateway({ withProgress(
url: opts.url, {
token: opts.token, label: `Nodes ${method}`,
method, indeterminate: true,
params, enabled: opts.json !== true,
timeoutMs: Number(opts.timeout ?? 10_000), },
clientName: "cli", async () =>
mode: "cli", await callGateway({
}); url: opts.url,
token: opts.token,
method,
params,
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: "cli",
mode: "cli",
}),
);
function formatAge(msAgo: number) { function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000)); const s = Math.max(0, Math.floor(msAgo / 1000));

138
src/cli/progress.ts Normal file
View File

@@ -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<T>(
options: ProgressOptions,
work: (progress: ProgressReporter) => Promise<T>,
): Promise<T> {
const progress = createCliProgress(options);
try {
return await work(progress);
} finally {
progress.done();
}
}

View File

@@ -7,6 +7,7 @@ import {
} from "../config/sessions.js"; } from "../config/sessions.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { withProgress } from "../cli/progress.js";
import { normalizeMessageProvider } from "../utils/message-provider.js"; import { normalizeMessageProvider } from "../utils/message-provider.js";
import { agentCommand } from "./agent.js"; import { agentCommand } from "./agent.js";
@@ -125,26 +126,34 @@ export async function agentViaGatewayCommand(
const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp"; const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp";
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey(); const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const response = await callGateway<GatewayAgentResponse>({ const response = await withProgress(
method: "agent", {
params: { label: "Waiting for agent reply…",
message: body, indeterminate: true,
to: opts.to, enabled: opts.json !== true,
sessionId: opts.sessionId,
sessionKey,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
provider,
timeout: timeoutSeconds,
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,
idempotencyKey,
}, },
expectFinal: true, async () =>
timeoutMs: gatewayTimeoutMs, await callGateway<GatewayAgentResponse>({
clientName: "cli", method: "agent",
mode: "cli", 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) { if (opts.json) {
runtime.log(JSON.stringify(response, null, 2)); runtime.log(JSON.stringify(response, null, 2));

View File

@@ -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<typeof import("../config/config.js")>();
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();
});
});

View File

@@ -343,12 +343,22 @@ function buildProviderBindings(params: {
export async function agentsAddCommand( export async function agentsAddCommand(
opts: AgentsAddOptions, opts: AgentsAddOptions,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) { ) {
const cfg = await requireValidConfig(runtime); const cfg = await requireValidConfig(runtime);
if (!cfg) return; if (!cfg) return;
const workspaceFlag = opts.workspace?.trim(); const workspaceFlag = opts.workspace?.trim();
const nameInput = opts.name?.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 (workspaceFlag) {
if (!nameInput) { if (!nameInput) {

View File

@@ -31,9 +31,11 @@ import {
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { createCliProgress } from "../cli/progress.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, sleep } from "../utils.js"; import { resolveUserPath, sleep } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js"; import { createClackPrompter } from "../wizard/clack-prompter.js";
import { import {
@@ -90,6 +92,27 @@ type ConfigureWizardParams = {
sections?: WizardSection[]; 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( async function promptGatewayConfig(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
runtime: RuntimeEnv, runtime: RuntimeEnv,
@@ -283,8 +306,7 @@ async function promptAuthConfig(
"Browser will open. Paste the code shown after login (code#state).", "Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth", "Anthropic OAuth",
); );
const spin = spinner(); const spin = startOscSpinner("Waiting for authorization…");
spin.start("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null; let oauthCreds: OAuthCredentials | null = null;
try { try {
oauthCreds = await loginAnthropic( oauthCreds = await loginAnthropic(
@@ -341,21 +363,20 @@ async function promptAuthConfig(
].join("\n"), ].join("\n"),
"OpenAI Codex OAuth", "OpenAI Codex OAuth",
); );
const spin = spinner(); const spin = startOscSpinner("Starting OAuth flow…");
spin.start("Starting OAuth flow…");
let manualCodePromise: Promise<string> | undefined; let manualCodePromise: Promise<string> | undefined;
try { try {
const creds = await loginOpenAICodex({ const creds = await loginOpenAICodex({
onAuth: async ({ url }) => { onAuth: async ({ url }) => {
if (isRemote) { 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`); runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
manualCodePromise = text({ manualCodePromise = text({
message: "Paste the redirect URL (or authorization code)", message: "Paste the redirect URL (or authorization code)",
validate: (value) => (value?.trim() ? undefined : "Required"), validate: (value) => (value?.trim() ? undefined : "Required"),
}).then((value) => String(guardCancel(value, runtime))); }).then((value) => String(guardCancel(value, runtime)));
} else { } else {
spin.message("Complete sign-in in browser…"); spin.update("Complete sign-in in browser…");
await openUrl(url); await openUrl(url);
runtime.log(`Open: ${url}`); runtime.log(`Open: ${url}`);
} }
@@ -372,7 +393,7 @@ async function promptAuthConfig(
); );
return String(code); return String(code);
}, },
onProgress: (msg) => spin.message(msg), onProgress: (msg) => spin.update(msg),
}); });
spin.stop("OpenAI OAuth complete"); spin.stop("OpenAI OAuth complete");
if (creds) { if (creds) {
@@ -429,8 +450,7 @@ async function promptAuthConfig(
].join("\n"), ].join("\n"),
"Google Antigravity OAuth", "Google Antigravity OAuth",
); );
const spin = spinner(); const spin = startOscSpinner("Starting OAuth flow…");
spin.start("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null; let oauthCreds: OAuthCredentials | null = null;
try { try {
oauthCreds = await loginAntigravityVpsAware( oauthCreds = await loginAntigravityVpsAware(
@@ -439,12 +459,12 @@ async function promptAuthConfig(
spin.stop("OAuth URL ready"); spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else { } else {
spin.message("Complete sign-in in browser…"); spin.update("Complete sign-in in browser…");
await openUrl(url); await openUrl(url);
runtime.log(`Open: ${url}`); runtime.log(`Open: ${url}`);
} }
}, },
(msg) => spin.message(msg), (msg) => spin.update(msg),
); );
spin.stop("Antigravity OAuth complete"); spin.stop("Antigravity OAuth complete");
if (oauthCreds) { if (oauthCreds) {

View File

@@ -4,6 +4,7 @@ import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js"; import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { withProgress } from "../cli/progress.js";
import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js";
import { resolveTelegramToken } from "../telegram/token.js"; import { resolveTelegramToken } from "../telegram/token.js";
import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveWhatsAppAccount } from "../web/accounts.js";
@@ -114,10 +115,18 @@ export async function healthCommand(
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
// Always query the running gateway; do not open a direct Baileys socket here. // Always query the running gateway; do not open a direct Baileys socket here.
const summary = await callGateway<HealthSummary>({ const summary = await withProgress(
method: "health", {
timeoutMs: opts.timeoutMs, label: "Checking gateway health…",
}); indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway<HealthSummary>({
method: "health",
timeoutMs: opts.timeoutMs,
}),
);
// Gateway reachability defines success; provider issues are reported but not fatal here. // Gateway reachability defines success; provider issues are reported but not fatal here.
const fatal = false; const fatal = false;

View File

@@ -5,7 +5,6 @@ import {
discoverAuthStorage, discoverAuthStorage,
discoverModels, discoverModels,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import chalk from "chalk";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { import {
@@ -36,6 +35,7 @@ import {
shouldEnableShellEnvFallback, shouldEnableShellEnvFallback,
} from "../../infra/shell-env.js"; } from "../../infra/shell-env.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { colorize, isRich as isRichTerminal, theme } from "../../terminal/theme.js";
import { shortenHomePath } from "../../utils.js"; import { shortenHomePath } from "../../utils.js";
import { import {
DEFAULT_MODEL, DEFAULT_MODEL,
@@ -52,43 +52,36 @@ const LOCAL_PAD = 5;
const AUTH_PAD = 5; const AUTH_PAD = 5;
const isRich = (opts?: { json?: boolean; plain?: boolean }) => const isRich = (opts?: { json?: boolean; plain?: boolean }) =>
Boolean( Boolean(isRichTerminal() && !opts?.json && !opts?.plain);
process.stdout.isTTY && chalk.level > 0 && !opts?.json && !opts?.plain,
);
const pad = (value: string, size: number) => value.padEnd(size); 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) => const formatKey = (key: string, rich: boolean) =>
colorize(rich, chalk.yellow, key); colorize(rich, theme.warn, key);
const formatValue = (value: string, rich: boolean) => const formatValue = (value: string, rich: boolean) =>
colorize(rich, chalk.white, value); colorize(rich, theme.info, value);
const formatKeyValue = ( const formatKeyValue = (
key: string, key: string,
value: string, value: string,
rich: boolean, rich: boolean,
valueColor: (value: string) => string = chalk.white, valueColor: (value: string) => string = theme.info,
) => `${formatKey(key, rich)}=${colorize(rich, valueColor, value)}`; ) => `${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) => { const formatTag = (tag: string, rich: boolean) => {
if (!rich) return tag; if (!rich) return tag;
if (tag === "default") return chalk.greenBright(tag); if (tag === "default") return theme.success(tag);
if (tag === "image") return chalk.magentaBright(tag); if (tag === "image") return theme.accentBright(tag);
if (tag === "configured") return chalk.cyan(tag); if (tag === "configured") return theme.accent(tag);
if (tag === "missing") return chalk.red(tag); if (tag === "missing") return theme.error(tag);
if (tag.startsWith("fallback#")) return chalk.yellow(tag); if (tag.startsWith("fallback#")) return theme.warn(tag);
if (tag.startsWith("img-fallback#")) return chalk.yellowBright(tag); if (tag.startsWith("img-fallback#")) return theme.warn(tag);
if (tag.startsWith("alias:")) return chalk.blue(tag); if (tag.startsWith("alias:")) return theme.accentDim(tag);
return chalk.gray(tag); return theme.muted(tag);
}; };
const truncate = (value: string, max: number) => { const truncate = (value: string, max: number) => {
@@ -450,7 +443,7 @@ function printModelTable(
pad("Auth", AUTH_PAD), pad("Auth", AUTH_PAD),
"Tags", "Tags",
].join(" "); ].join(" ");
runtime.log(rich ? chalk.bold(header) : header); runtime.log(rich ? theme.heading(header) : header);
for (const row of rows) { for (const row of rows) {
const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD); const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD);
@@ -470,26 +463,26 @@ function printModelTable(
const coloredInput = colorize( const coloredInput = colorize(
rich, rich,
row.input.includes("image") ? chalk.magenta : chalk.white, row.input.includes("image") ? theme.accentBright : theme.info,
inputLabel, inputLabel,
); );
const coloredLocal = colorize( const coloredLocal = colorize(
rich, rich,
row.local === null ? chalk.gray : row.local ? chalk.green : chalk.gray, row.local === null ? theme.muted : row.local ? theme.success : theme.muted,
localLabel, localLabel,
); );
const coloredAuth = colorize( const coloredAuth = colorize(
rich, rich,
row.available === null row.available === null
? chalk.gray ? theme.muted
: row.available : row.available
? chalk.green ? theme.success
: chalk.red, : theme.error,
authLabel, authLabel,
); );
const line = [ const line = [
rich ? chalk.cyan(keyLabel) : keyLabel, rich ? theme.accent(keyLabel) : keyLabel,
coloredInput, coloredInput,
ctxLabel, ctxLabel,
coloredLocal, coloredLocal,
@@ -762,71 +755,72 @@ export async function modelsStatusCommand(
} }
const rich = isRich(opts); 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 = const displayDefault =
rawModel && rawModel !== resolvedLabel rawModel && rawModel !== resolvedLabel
? `${resolvedLabel} (from ${rawModel})` ? `${resolvedLabel} (from ${rawModel})`
: resolvedLabel; : resolvedLabel;
runtime.log( 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( runtime.log(
`${label("Agent dir")}${colorize(rich, chalk.gray, ":")} ${colorize( `${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize(
rich, rich,
chalk.white, theme.info,
shortenHomePath(agentDir), shortenHomePath(agentDir),
)}`, )}`,
); );
runtime.log( runtime.log(
`${label("Default")}${colorize(rich, chalk.gray, ":")} ${colorize( `${label("Default")}${colorize(rich, theme.muted, ":")} ${colorize(
rich, rich,
chalk.green, theme.success,
displayDefault, displayDefault,
)}`, )}`,
); );
runtime.log( runtime.log(
`${label(`Fallbacks (${fallbacks.length || 0})`)}${colorize( `${label(`Fallbacks (${fallbacks.length || 0})`)}${colorize(
rich, rich,
chalk.gray, theme.muted,
":", ":",
)} ${colorize( )} ${colorize(
rich, rich,
fallbacks.length ? chalk.yellow : chalk.gray, fallbacks.length ? theme.warn : theme.muted,
fallbacks.length ? fallbacks.join(", ") : "-", fallbacks.length ? fallbacks.join(", ") : "-",
)}`, )}`,
); );
runtime.log( runtime.log(
`${label("Image model")}${colorize(rich, chalk.gray, ":")} ${colorize( `${label("Image model")}${colorize(rich, theme.muted, ":")} ${colorize(
rich, rich,
imageModel ? chalk.magenta : chalk.gray, imageModel ? theme.accentBright : theme.muted,
imageModel || "-", imageModel || "-",
)}`, )}`,
); );
runtime.log( runtime.log(
`${label(`Image fallbacks (${imageFallbacks.length || 0})`)}${colorize( `${label(`Image fallbacks (${imageFallbacks.length || 0})`)}${colorize(
rich, rich,
chalk.gray, theme.muted,
":", ":",
)} ${colorize( )} ${colorize(
rich, rich,
imageFallbacks.length ? chalk.magentaBright : chalk.gray, imageFallbacks.length ? theme.accentBright : theme.muted,
imageFallbacks.length ? imageFallbacks.join(", ") : "-", imageFallbacks.length ? imageFallbacks.join(", ") : "-",
)}`, )}`,
); );
runtime.log( runtime.log(
`${label(`Aliases (${Object.keys(aliases).length || 0})`)}${colorize( `${label(`Aliases (${Object.keys(aliases).length || 0})`)}${colorize(
rich, rich,
chalk.gray, theme.muted,
":", ":",
)} ${colorize( )} ${colorize(
rich, rich,
Object.keys(aliases).length ? chalk.cyan : chalk.gray, Object.keys(aliases).length ? theme.accent : theme.muted,
Object.keys(aliases).length Object.keys(aliases).length
? Object.entries(aliases) ? Object.entries(aliases)
.map(([alias, target]) => .map(([alias, target]) =>
rich rich
? `${chalk.blue(alias)} ${chalk.gray("->")} ${chalk.white( ? `${theme.accentDim(alias)} ${theme.muted("->")} ${theme.info(
target, target,
)}` )}`
: `${alias} -> ${target}`, : `${alias} -> ${target}`,
@@ -838,41 +832,41 @@ export async function modelsStatusCommand(
runtime.log( runtime.log(
`${label(`Configured models (${allowed.length || 0})`)}${colorize( `${label(`Configured models (${allowed.length || 0})`)}${colorize(
rich, rich,
chalk.gray, theme.muted,
":", ":",
)} ${colorize( )} ${colorize(
rich, rich,
allowed.length ? chalk.white : chalk.gray, allowed.length ? theme.info : theme.muted,
allowed.length ? allowed.join(", ") : "all", allowed.length ? allowed.join(", ") : "all",
)}`, )}`,
); );
runtime.log(""); runtime.log("");
runtime.log(colorize(rich, chalk.bold, "Auth overview")); runtime.log(colorize(rich, theme.heading, "Auth overview"));
runtime.log( runtime.log(
`${label("Auth store")}${colorize(rich, chalk.gray, ":")} ${colorize( `${label("Auth store")}${colorize(rich, theme.muted, ":")} ${colorize(
rich, rich,
chalk.white, theme.info,
shortenHomePath(resolveAuthStorePathForDisplay()), shortenHomePath(resolveAuthStorePathForDisplay()),
)}`, )}`,
); );
runtime.log( runtime.log(
`${label("Shell env")}${colorize(rich, chalk.gray, ":")} ${colorize( `${label("Shell env")}${colorize(rich, theme.muted, ":")} ${colorize(
rich, rich,
shellFallbackEnabled ? chalk.green : chalk.gray, shellFallbackEnabled ? theme.success : theme.muted,
shellFallbackEnabled ? "on" : "off", shellFallbackEnabled ? "on" : "off",
)}${ )}${
applied.length applied.length
? colorize(rich, chalk.gray, ` (applied: ${applied.join(", ")})`) ? colorize(rich, theme.muted, ` (applied: ${applied.join(", ")})`)
: "" : ""
}`, }`,
); );
runtime.log( runtime.log(
`${label( `${label(
`Providers w/ OAuth (${providersWithOauth.length || 0})`, `Providers w/ OAuth (${providersWithOauth.length || 0})`,
)}${colorize(rich, chalk.gray, ":")} ${colorize( )}${colorize(rich, theme.muted, ":")} ${colorize(
rich, rich,
providersWithOauth.length ? chalk.white : chalk.gray, providersWithOauth.length ? theme.info : theme.muted,
providersWithOauth.length ? providersWithOauth.join(", ") : "-", providersWithOauth.length ? providersWithOauth.join(", ") : "-",
)}`, )}`,
); );
@@ -883,9 +877,9 @@ export async function modelsStatusCommand(
bits.push( bits.push(
formatKeyValue( formatKeyValue(
"effective", "effective",
`${colorize(rich, chalk.magenta, entry.effective.kind)}:${colorize( `${colorize(rich, theme.accentBright, entry.effective.kind)}:${colorize(
rich, rich,
chalk.gray, theme.muted,
entry.effective.detail, entry.effective.detail,
)}`, )}`,
rich, 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)}`);
} }
} }

View File

@@ -8,6 +8,7 @@ import {
} from "../infra/outbound/format.js"; } from "../infra/outbound/format.js";
import { normalizePollInput, type PollInput } from "../polls.js"; import { normalizePollInput, type PollInput } from "../polls.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { withProgress } from "../cli/progress.js";
function parseIntOption(value: unknown, label: string): number | undefined { function parseIntOption(value: unknown, label: string): number | undefined {
if (value === undefined || value === null) return undefined; if (value === undefined || value === null) return undefined;
@@ -57,25 +58,33 @@ export async function pollCommand(
return; return;
} }
const result = await callGateway<{ const result = await withProgress(
messageId: string; {
toJid?: string; label: `Sending poll via ${provider}`,
channelId?: string; indeterminate: true,
}>({ enabled: opts.json !== true,
method: "poll",
params: {
to: opts.to,
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationHours: normalized.durationHours,
provider,
idempotencyKey: randomIdempotencyKey(),
}, },
timeoutMs: 10_000, async () =>
clientName: "cli", await callGateway<{
mode: "cli", 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( runtime.log(
success( success(

View File

@@ -1,11 +1,9 @@
import { spinner } from "@clack/prompts";
import chalk from "chalk";
import { import {
CLAUDE_CLI_PROFILE_ID, CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID,
loadAuthProfileStore, loadAuthProfileStore,
} from "../agents/auth-profiles.js"; } from "../agents/auth-profiles.js";
import { withProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { import {
@@ -36,6 +34,7 @@ import {
listTelegramAccountIds, listTelegramAccountIds,
resolveTelegramAccount, resolveTelegramAccount,
} from "../telegram/accounts.js"; } from "../telegram/accounts.js";
import { theme } from "../terminal/theme.js";
import { formatTerminalLink } from "../utils.js"; import { formatTerminalLink } from "../utils.js";
import { import {
listWhatsAppAccountIds, listWhatsAppAccountIds,
@@ -61,7 +60,7 @@ type ChatProvider = (typeof CHAT_PROVIDERS)[number];
function docsLink(path: string, label?: string): string { function docsLink(path: string, label?: string): string {
const url = `${DOCS_ROOT}${path}`; const url = `${DOCS_ROOT}${path}`;
return formatTerminalLink(url, label ?? url); return formatTerminalLink(label ?? url, url, { fallback: url });
} }
type ProvidersListOptions = { type ProvidersListOptions = {
@@ -136,17 +135,17 @@ function formatAccountLabel(params: { accountId: string; name?: string }) {
} }
const colorValue = (value: string) => { const colorValue = (value: string) => {
if (value === "none") return chalk.red(value); if (value === "none") return theme.error(value);
if (value === "env") return chalk.cyan(value); if (value === "env") return theme.accent(value);
return chalk.green(value); return theme.success(value);
}; };
function formatEnabled(value: boolean | undefined): string { 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 { 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 { function formatTokenSource(source?: string): string {
@@ -160,7 +159,7 @@ function formatSource(label: string, source?: string): string {
} }
function formatLinked(value: boolean): 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: { function applyAccountName(params: {
@@ -501,14 +500,14 @@ export async function providersListCommand(
} }
const lines: string[] = []; const lines: string[] = [];
lines.push(chalk.bold("Chat providers:")); lines.push(theme.heading("Chat providers:"));
for (const accountId of whatsappAccounts) { for (const accountId of whatsappAccounts) {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const linked = await webAuthExists(authDir); const linked = await webAuthExists(authDir);
const name = cfg.whatsapp?.accounts?.[accountId]?.name; const name = cfg.whatsapp?.accounts?.[accountId]?.name;
lines.push( lines.push(
`- ${chalk.cyan("WhatsApp")} ${chalk.bold( `- ${theme.accent("WhatsApp")} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name, name,
@@ -524,7 +523,7 @@ export async function providersListCommand(
for (const accountId of telegramAccounts) { for (const accountId of telegramAccounts) {
const account = resolveTelegramAccount({ cfg, accountId }); const account = resolveTelegramAccount({ cfg, accountId });
lines.push( lines.push(
`- ${chalk.cyan("Telegram")} ${chalk.bold( `- ${theme.accent("Telegram")} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
@@ -538,7 +537,7 @@ export async function providersListCommand(
for (const accountId of discordAccounts) { for (const accountId of discordAccounts) {
const account = resolveDiscordAccount({ cfg, accountId }); const account = resolveDiscordAccount({ cfg, accountId });
lines.push( lines.push(
`- ${chalk.cyan("Discord")} ${chalk.bold( `- ${theme.accent("Discord")} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
@@ -553,7 +552,7 @@ export async function providersListCommand(
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });
const configured = Boolean(account.botToken && account.appToken); const configured = Boolean(account.botToken && account.appToken);
lines.push( lines.push(
`- ${chalk.cyan("Slack")} ${chalk.bold( `- ${theme.accent("Slack")} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
@@ -570,12 +569,12 @@ export async function providersListCommand(
for (const accountId of signalAccounts) { for (const accountId of signalAccounts) {
const account = resolveSignalAccount({ cfg, accountId }); const account = resolveSignalAccount({ cfg, accountId });
lines.push( lines.push(
`- ${chalk.cyan("Signal")} ${chalk.bold( `- ${theme.accent("Signal")} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
}), }),
)}: ${formatConfigured(account.configured)}, base=${chalk.dim( )}: ${formatConfigured(account.configured)}, base=${theme.muted(
account.baseUrl, account.baseUrl,
)}, ${formatEnabled(account.enabled)}`, )}, ${formatEnabled(account.enabled)}`,
); );
@@ -584,7 +583,7 @@ export async function providersListCommand(
for (const accountId of imessageAccounts) { for (const accountId of imessageAccounts) {
const account = resolveIMessageAccount({ cfg, accountId }); const account = resolveIMessageAccount({ cfg, accountId });
lines.push( lines.push(
`- ${chalk.cyan("iMessage")} ${chalk.bold( `- ${theme.accent("iMessage")} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
@@ -594,14 +593,14 @@ export async function providersListCommand(
} }
lines.push(""); lines.push("");
lines.push(chalk.bold("Auth providers (OAuth + API keys):")); lines.push(theme.heading("Auth providers (OAuth + API keys):"));
if (authProfiles.length === 0) { if (authProfiles.length === 0) {
lines.push(chalk.dim("- none")); lines.push(theme.muted("- none"));
} else { } else {
for (const profile of authProfiles) { for (const profile of authProfiles) {
const external = profile.isExternal ? chalk.dim(" (synced)") : ""; const external = profile.isExternal ? theme.muted(" (synced)") : "";
lines.push( 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) { if (includeUsage) {
runtime.log(""); runtime.log("");
const usage = await loadUsageWithSpinner(runtime); const usage = await loadUsageWithProgress(runtime);
if (usage) { if (usage) {
const usageLines = formatUsageReportLines(usage); const usageLines = formatUsageReportLines(usage);
if (usageLines.length > 0) { if (usageLines.length > 0) {
usageLines[0] = chalk.cyan(usageLines[0]); usageLines[0] = theme.accent(usageLines[0]);
runtime.log(usageLines.join("\n")); runtime.log(usageLines.join("\n"));
} }
} }
@@ -628,27 +627,15 @@ export async function providersListCommand(
); );
} }
async function loadUsageWithSpinner( async function loadUsageWithProgress(
runtime: RuntimeEnv, runtime: RuntimeEnv,
): Promise<Awaited<ReturnType<typeof loadProviderUsageSummary>> | null> { ): Promise<Awaited<ReturnType<typeof loadProviderUsageSummary>> | 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 { try {
const usage = await loadProviderUsageSummary(); return await withProgress(
spin.stop(chalk.green("Usage snapshot ready")); { label: "Fetching usage snapshot…", indeterminate: true, enabled: true },
return usage; async () => await loadProviderUsageSummary(),
);
} catch (err) { } catch (err) {
spin.stop(chalk.red("Usage snapshot failed"));
runtime.error(String(err)); runtime.error(String(err));
return null; return null;
} }
@@ -660,18 +647,26 @@ export async function providersStatusCommand(
) { ) {
const timeoutMs = Number(opts.timeout ?? 10_000); const timeoutMs = Number(opts.timeout ?? 10_000);
try { try {
const payload = await callGateway({ const payload = await withProgress(
method: "providers.status", {
params: { probe: Boolean(opts.probe), timeoutMs }, label: "Checking provider status…",
timeoutMs, indeterminate: true,
}); enabled: opts.json !== true,
},
async () =>
await callGateway({
method: "providers.status",
params: { probe: Boolean(opts.probe), timeoutMs },
timeoutMs,
}),
);
if (opts.json) { if (opts.json) {
runtime.log(JSON.stringify(payload, null, 2)); runtime.log(JSON.stringify(payload, null, 2));
return; return;
} }
const data = payload as Record<string, unknown>; const data = payload as Record<string, unknown>;
const lines: string[] = []; const lines: string[] = [];
lines.push(chalk.green("Gateway reachable.")); lines.push(theme.success("Gateway reachable."));
const accountLines = ( const accountLines = (
label: string, label: string,
accounts: Array<Record<string, unknown>>, accounts: Array<Record<string, unknown>>,

View File

@@ -11,6 +11,7 @@ import {
} from "../infra/outbound/format.js"; } from "../infra/outbound/format.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { withProgress } from "../cli/progress.js";
import { normalizeMessageProvider } from "../utils/message-provider.js"; import { normalizeMessageProvider } from "../utils/message-provider.js";
export async function sendCommand( export async function sendCommand(
@@ -50,20 +51,28 @@ export async function sendCommand(
if (!resolvedTarget.ok) { if (!resolvedTarget.ok) {
throw resolvedTarget.error; throw resolvedTarget.error;
} }
const results = await deliverOutboundPayloads({ const results = await withProgress(
cfg: loadConfig(), {
provider, label: `Sending via ${provider}`,
to: resolvedTarget.to, indeterminate: true,
payloads: [{ text: opts.message, mediaUrl: opts.media }], enabled: opts.json !== true,
deps: {
sendWhatsApp: deps.sendMessageWhatsApp,
sendTelegram: deps.sendMessageTelegram,
sendDiscord: deps.sendMessageDiscord,
sendSlack: deps.sendMessageSlack,
sendSignal: deps.sendMessageSignal,
sendIMessage: deps.sendMessageIMessage,
}, },
}); 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 last = results.at(-1);
const summary = formatOutboundDeliverySummary(provider, last); const summary = formatOutboundDeliverySummary(provider, last);
runtime.log(success(summary)); runtime.log(success(summary));
@@ -105,7 +114,14 @@ export async function sendCommand(
mode: "cli", 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( runtime.log(
success( success(

View File

@@ -1,5 +1,3 @@
import chalk from "chalk";
import { lookupContextTokens } from "../agents/context.js"; import { lookupContextTokens } from "../agents/context.js";
import { import {
DEFAULT_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS,
@@ -15,6 +13,7 @@ import {
} from "../config/sessions.js"; } from "../config/sessions.js";
import { info } from "../globals.js"; import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
type SessionRow = { type SessionRow = {
key: string; key: string;
@@ -41,8 +40,6 @@ const AGE_PAD = 9;
const MODEL_PAD = 14; const MODEL_PAD = 14;
const TOKENS_PAD = 20; const TOKENS_PAD = 20;
const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0);
const formatKTokens = (value: number) => const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; `${(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) => { const colorByPct = (label: string, pct: number | null, rich: boolean) => {
if (!rich || pct === null) return label; if (!rich || pct === null) return label;
if (pct >= 95) return chalk.red(label); if (pct >= 95) return theme.error(label);
if (pct >= 80) return chalk.yellow(label); if (pct >= 80) return theme.warn(label);
if (pct >= 60) return chalk.green(label); if (pct >= 60) return theme.success(label);
return chalk.gray(label); return theme.muted(label);
}; };
const formatTokensCell = ( const formatTokensCell = (
@@ -79,21 +76,21 @@ const formatTokensCell = (
const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => { const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
const label = kind.padEnd(KIND_PAD); const label = kind.padEnd(KIND_PAD);
if (!rich) return label; if (!rich) return label;
if (kind === "group") return chalk.magenta(label); if (kind === "group") return theme.accentBright(label);
if (kind === "global") return chalk.yellow(label); if (kind === "global") return theme.warn(label);
if (kind === "direct") return chalk.cyan(label); if (kind === "direct") return theme.accent(label);
return chalk.gray(label); return theme.muted(label);
}; };
const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => { const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => {
const ageLabel = updatedAt ? formatAge(Date.now() - updatedAt) : "unknown"; const ageLabel = updatedAt ? formatAge(Date.now() - updatedAt) : "unknown";
const padded = ageLabel.padEnd(AGE_PAD); 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 formatModelCell = (model: string | null | undefined, rich: boolean) => {
const label = (model ?? "unknown").padEnd(MODEL_PAD); 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) => { const formatFlagsCell = (row: SessionRow, rich: boolean) => {
@@ -107,7 +104,7 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => {
row.sessionId ? `id:${row.sessionId}` : null, row.sessionId ? `id:${row.sessionId}` : null,
].filter(Boolean); ].filter(Boolean);
const label = flags.join(" "); 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) => { const formatAge = (ms: number | null | undefined) => {
@@ -240,7 +237,7 @@ export async function sessionsCommand(
"Flags", "Flags",
].join(" "); ].join(" ");
runtime.log(rich ? chalk.bold(header) : header); runtime.log(rich ? theme.heading(header) : header);
for (const row of rows) { for (const row of rows) {
const model = row.model ?? configModel; const model = row.model ?? configModel;
@@ -251,7 +248,7 @@ export async function sessionsCommand(
const total = row.totalTokens ?? input + output; const total = row.totalTokens ?? input + output;
const keyLabel = truncateKey(row.key).padEnd(KEY_PAD); const keyLabel = truncateKey(row.key).padEnd(KEY_PAD);
const keyCell = rich ? chalk.cyan(keyLabel) : keyLabel; const keyCell = rich ? theme.accent(keyLabel) : keyLabel;
const line = [ const line = [
formatKindCell(row.kind, rich), formatKindCell(row.kind, rich),

View File

@@ -18,6 +18,7 @@ import {
formatUsageReportLines, formatUsageReportLines,
loadProviderUsageSummary, loadProviderUsageSummary,
} from "../infra/provider-usage.js"; } from "../infra/provider-usage.js";
import { withProgress } from "../cli/progress.js";
import { peekSystemEvents } from "../infra/system-events.js"; import { peekSystemEvents } from "../infra/system-events.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveWhatsAppAccount } from "../web/accounts.js";
@@ -233,13 +234,29 @@ export async function statusCommand(
) { ) {
const summary = await getStatusSummary(); const summary = await getStatusSummary();
const usage = opts.usage 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; : undefined;
const health: HealthSummary | undefined = opts.deep const health: HealthSummary | undefined = opts.deep
? await callGateway<HealthSummary>({ ? await withProgress(
method: "health", {
timeoutMs: opts.timeoutMs, label: "Checking gateway health…",
}) indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway<HealthSummary>({
method: "health",
timeoutMs: opts.timeoutMs,
}),
)
: undefined; : undefined;
if (opts.json) { if (opts.json) {

View File

@@ -1,5 +1,5 @@
import chalk from "chalk";
import { getLogger, isFileLogLevelEnabled } from "./logging.js"; import { getLogger, isFileLogLevelEnabled } from "./logging.js";
import { theme } from "./terminal/theme.js";
let globalVerbose = false; let globalVerbose = false;
let globalYes = false; let globalYes = false;
@@ -24,12 +24,12 @@ export function logVerbose(message: string) {
// ignore logger failures to avoid breaking verbose printing // ignore logger failures to avoid breaking verbose printing
} }
if (!globalVerbose) return; if (!globalVerbose) return;
console.log(chalk.gray(message)); console.log(theme.muted(message));
} }
export function logVerboseConsole(message: string) { export function logVerboseConsole(message: string) {
if (!globalVerbose) return; if (!globalVerbose) return;
console.log(chalk.gray(message)); console.log(theme.muted(message));
} }
export function setYes(v: boolean) { export function setYes(v: boolean) {
@@ -40,7 +40,7 @@ export function isYes() {
return globalYes; return globalYes;
} }
export const success = chalk.green; export const success = theme.success;
export const warn = chalk.yellow; export const warn = theme.warn;
export const info = chalk.cyan; export const info = theme.info;
export const danger = chalk.red; export const danger = theme.error;

36
src/terminal/theme.ts Normal file
View File

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

View File

@@ -14,6 +14,8 @@ import {
import type { WizardProgress, WizardPrompter } from "./prompts.js"; import type { WizardProgress, WizardPrompter } from "./prompts.js";
import { WizardCancelledError } from "./prompts.js"; import { WizardCancelledError } from "./prompts.js";
import { createCliProgress } from "../cli/progress.js";
import { theme } from "../terminal/theme.js";
function guardCancel<T>(value: T | symbol): T { function guardCancel<T>(value: T | symbol): T {
if (isCancel(value)) { if (isCancel(value)) {
@@ -74,10 +76,22 @@ export function createClackPrompter(): WizardPrompter {
), ),
progress: (label: string): WizardProgress => { progress: (label: string): WizardProgress => {
const spin = spinner(); const spin = spinner();
spin.start(label); spin.start(theme.accent(label));
const osc = createCliProgress({
label,
indeterminate: true,
enabled: true,
fallback: "none",
});
return { return {
update: (message) => spin.message(message), update: (message) => {
stop: (message) => spin.stop(message), spin.message(theme.accent(message));
osc.setLabel(message);
},
stop: (message) => {
osc.done();
spin.stop(message);
},
}; };
}, },
}; };