feat: refresh CLI output styling and progress
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
138
src/cli/progress.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user