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

@@ -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 =

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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",
}),
);
}

View File

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

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();
}
}