diff --git a/src/cli/nodes-cli/cli-utils.ts b/src/cli/nodes-cli/cli-utils.ts index 45a7569ca..5d4bef1a8 100644 --- a/src/cli/nodes-cli/cli-utils.ts +++ b/src/cli/nodes-cli/cli-utils.ts @@ -1,13 +1,28 @@ import { defaultRuntime } from "../../runtime.js"; +import { isRich, theme } from "../../terminal/theme.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { unauthorizedHintForMessage } from "./rpc.js"; +export function getNodesTheme() { + const rich = isRich(); + const color = (fn: (value: string) => string) => (value: string) => (rich ? fn(value) : value); + return { + rich, + heading: color(theme.heading), + ok: color(theme.success), + warn: color(theme.warn), + muted: color(theme.muted), + error: color(theme.error), + }; +} + export function runNodesCommand(label: string, action: () => Promise) { return runCommandWithRuntime(defaultRuntime, action, (err) => { const message = String(err); - defaultRuntime.error(`nodes ${label} failed: ${message}`); + const { error, warn } = getNodesTheme(); + defaultRuntime.error(error(`nodes ${label} failed: ${message}`)); const hint = unauthorizedHintForMessage(message); - if (hint) defaultRuntime.error(hint); + if (hint) defaultRuntime.error(warn(hint)); defaultRuntime.exit(1); }); } diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index adb3af2ad..d1c020f05 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -9,9 +9,10 @@ import { writeBase64ToFile, } from "../nodes-camera.js"; import { parseDurationMs } from "../parse-duration.js"; -import { runNodesCommand } from "./cli-utils.js"; +import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; +import { renderTable } from "../../terminal/table.js"; const parseFacing = (value: string): CameraFacing => { const v = String(value ?? "") @@ -52,16 +53,30 @@ export function registerNodesCameraCommands(nodes: Command) { } if (devices.length === 0) { - defaultRuntime.log("No cameras reported."); + const { muted } = getNodesTheme(); + defaultRuntime.log(muted("No cameras reported.")); return; } - for (const device of devices) { - const id = typeof device.id === "string" ? device.id : ""; - const name = typeof device.name === "string" ? device.name : "Unknown Camera"; - const position = typeof device.position === "string" ? device.position : "unspecified"; - defaultRuntime.log(`${name} (${position})${id ? ` — ${id}` : ""}`); - } + const { heading, muted } = getNodesTheme(); + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const rows = devices.map((device) => ({ + Name: typeof device.name === "string" ? device.name : "Unknown Camera", + Position: typeof device.position === "string" ? device.position : muted("unspecified"), + ID: typeof device.id === "string" ? device.id : "", + })); + defaultRuntime.log(heading("Cameras")); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Name", header: "Name", minWidth: 14, flex: true }, + { key: "Position", header: "Position", minWidth: 10 }, + { key: "ID", header: "ID", minWidth: 10, flex: true }, + ], + rows, + }).trimEnd(), + ); }); }), { timeoutMs: 60_000 }, diff --git a/src/cli/nodes-cli/register.canvas.ts b/src/cli/nodes-cli/register.canvas.ts index 77a3667ac..2ed31bc40 100644 --- a/src/cli/nodes-cli/register.canvas.ts +++ b/src/cli/nodes-cli/register.canvas.ts @@ -6,7 +6,7 @@ import { writeBase64ToFile } from "../nodes-camera.js"; import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js"; import { parseTimeoutMs } from "../nodes-run.js"; import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js"; -import { runNodesCommand } from "./cli-utils.js"; +import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; @@ -121,7 +121,10 @@ export function registerNodesCanvasCommands(nodes: Command) { params.placement = placement; } await invokeCanvas(opts, "canvas.present", params); - if (!opts.json) defaultRuntime.log("canvas present ok"); + if (!opts.json) { + const { ok } = getNodesTheme(); + defaultRuntime.log(ok("canvas present ok")); + } }); }), ); @@ -135,7 +138,10 @@ export function registerNodesCanvasCommands(nodes: Command) { .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas hide", async () => { await invokeCanvas(opts, "canvas.hide", undefined); - if (!opts.json) defaultRuntime.log("canvas hide ok"); + if (!opts.json) { + const { ok } = getNodesTheme(); + defaultRuntime.log(ok("canvas hide ok")); + } }); }), ); @@ -150,7 +156,10 @@ export function registerNodesCanvasCommands(nodes: Command) { .action(async (url: string, opts: NodesRpcOpts) => { await runNodesCommand("canvas navigate", async () => { await invokeCanvas(opts, "canvas.navigate", { url }); - if (!opts.json) defaultRuntime.log("canvas navigate ok"); + if (!opts.json) { + const { ok } = getNodesTheme(); + defaultRuntime.log(ok("canvas navigate ok")); + } }); }), ); @@ -179,7 +188,10 @@ export function registerNodesCanvasCommands(nodes: Command) { ? (raw as { payload?: { result?: string } }).payload : undefined; if (payload?.result) defaultRuntime.log(payload.result); - else defaultRuntime.log("canvas eval ok"); + else { + const { ok } = getNodesTheme(); + defaultRuntime.log(ok("canvas eval ok")); + } }); }), ); @@ -213,8 +225,11 @@ export function registerNodesCanvasCommands(nodes: Command) { } await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl }); if (!opts.json) { + const { ok } = getNodesTheme(); defaultRuntime.log( - `canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`, + ok( + `canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`, + ), ); } }); @@ -230,7 +245,10 @@ export function registerNodesCanvasCommands(nodes: Command) { .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas a2ui reset", async () => { await invokeCanvas(opts, "canvas.a2ui.reset", undefined); - if (!opts.json) defaultRuntime.log("canvas a2ui reset ok"); + if (!opts.json) { + const { ok } = getNodesTheme(); + defaultRuntime.log(ok("canvas a2ui reset ok")); + } }); }), ); diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 82768b1a1..940e579a3 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js"; -import { runNodesCommand } from "./cli-utils.js"; +import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId, unauthorizedHintForMessage } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; @@ -21,7 +21,8 @@ export function registerNodesInvokeCommands(nodes: Command) { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const command = String(opts.command ?? "").trim(); if (!nodeId || !command) { - defaultRuntime.error("--node and --command required"); + const { error } = getNodesTheme(); + defaultRuntime.error(error("--node and --command required")); defaultRuntime.exit(1); return; } @@ -108,16 +109,21 @@ export function registerNodesInvokeCommands(nodes: Command) { if (stdout) process.stdout.write(stdout); if (stderr) process.stderr.write(stderr); if (timedOut) { - defaultRuntime.error("run timed out"); + const { error } = getNodesTheme(); + defaultRuntime.error(error("run timed out")); defaultRuntime.exit(1); return; } if (exitCode !== null && exitCode !== 0) { const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`); - if (hint) defaultRuntime.error(hint); + if (hint) { + const { warn } = getNodesTheme(); + defaultRuntime.error(warn(hint)); + } } if (exitCode !== null && exitCode !== 0 && !success) { - defaultRuntime.error(`run exit ${exitCode}`); + const { error } = getNodesTheme(); + defaultRuntime.error(error(`run exit ${exitCode}`)); defaultRuntime.exit(1); return; } diff --git a/src/cli/nodes-cli/register.notify.ts b/src/cli/nodes-cli/register.notify.ts index c0e453190..25952a2d8 100644 --- a/src/cli/nodes-cli/register.notify.ts +++ b/src/cli/nodes-cli/register.notify.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; -import { runNodesCommand } from "./cli-utils.js"; +import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; @@ -49,7 +49,8 @@ export function registerNodesNotifyCommand(nodes: Command) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } - defaultRuntime.log("notify ok"); + const { ok } = getNodesTheme(); + defaultRuntime.log(ok("notify ok")); }); }), ); diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index a95a2c9ee..11b4f68f5 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -1,9 +1,10 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; import { formatAge, parsePairingList } from "./format.js"; -import { runNodesCommand } from "./cli-utils.js"; +import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; +import { renderTable } from "../../terminal/table.js"; export function registerNodesPairingCommands(nodes: Command) { nodesCallOpts( @@ -19,16 +20,37 @@ export function registerNodesPairingCommands(nodes: Command) { return; } if (pending.length === 0) { - defaultRuntime.log("No pending pairing requests."); + const { muted } = getNodesTheme(); + defaultRuntime.log(muted("No pending pairing requests.")); return; } - for (const r of pending) { - const name = r.displayName || r.nodeId; - const repair = r.isRepair ? " (repair)" : ""; - const ip = r.remoteIp ? ` · ${r.remoteIp}` : ""; - const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : ""; - defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`); - } + const { heading, warn, muted } = getNodesTheme(); + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const now = Date.now(); + const rows = pending.map((r) => ({ + Request: r.requestId, + Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, + IP: r.remoteIp ?? "", + Requested: + typeof r.ts === "number" + ? `${formatAge(Math.max(0, now - r.ts))} ago` + : muted("unknown"), + Repair: r.isRepair ? warn("yes") : "", + })); + defaultRuntime.log(heading("Pending")); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Request", header: "Request", minWidth: 8 }, + { key: "Node", header: "Node", minWidth: 14, flex: true }, + { key: "IP", header: "IP", minWidth: 10 }, + { key: "Requested", header: "Requested", minWidth: 12 }, + { key: "Repair", header: "Repair", minWidth: 6 }, + ], + rows, + }).trimEnd(), + ); }); }), ); @@ -86,7 +108,8 @@ export function registerNodesPairingCommands(nodes: Command) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } - defaultRuntime.log(`node rename ok: ${nodeId} -> ${name}`); + const { ok } = getNodesTheme(); + defaultRuntime.log(ok(`node rename ok: ${nodeId} -> ${name}`)); }); }), ); diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 629857224..b77d3e305 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -1,11 +1,10 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js"; -import { runNodesCommand } from "./cli-utils.js"; +import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; import { renderTable } from "../../terminal/table.js"; -import { isRich, theme } from "../../terminal/theme.js"; function formatVersionLabel(raw: string) { const trimmed = raw.trim(); @@ -56,11 +55,9 @@ export function registerNodesStatusCommands(nodes: Command) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } - const rich = isRich(); - const ok = (text: string) => (rich ? theme.success(text) : text); - const warn = (text: string) => (rich ? theme.warn(text) : text); - const muted = (text: string) => (rich ? theme.muted(text) : text); + const { ok, warn, muted } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const now = Date.now(); const nodes = parseNodeList(result); const pairedCount = nodes.filter((n) => Boolean(n.paired)).length; const connectedCount = nodes.filter((n) => Boolean(n.connected)).length; @@ -71,24 +68,30 @@ export function registerNodesStatusCommands(nodes: Command) { const rows = nodes.map((n) => { const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId; - const device = (() => { - if (n.deviceFamily && n.modelIdentifier) { - return `${n.deviceFamily} (${n.modelIdentifier})`; - } - return n.deviceFamily ?? n.modelIdentifier ?? ""; - })(); + const perms = formatPermissions(n.permissions); + const versions = formatNodeVersions(n); + const detailParts = [ + n.deviceFamily ? `device: ${n.deviceFamily}` : null, + n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null, + perms ? `perms: ${perms}` : null, + versions, + ].filter(Boolean) as string[]; const caps = Array.isArray(n.caps) ? n.caps.map(String).filter(Boolean).sort().join(", ") : "?"; const paired = n.paired ? ok("paired") : warn("unpaired"); const connected = n.connected ? ok("connected") : muted("disconnected"); + const since = + typeof n.connectedAtMs === "number" + ? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)` + : ""; return { Node: name, ID: n.nodeId, IP: n.remoteIp ?? "", - Device: device, - Status: `${paired} · ${connected}`, + Detail: detailParts.join(" · "), + Status: `${paired} · ${connected}${since}`, Caps: caps, }; }); @@ -100,9 +103,9 @@ export function registerNodesStatusCommands(nodes: Command) { { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "ID", header: "ID", minWidth: 10 }, { key: "IP", header: "IP", minWidth: 10 }, - { key: "Device", header: "Device", minWidth: 14, flex: true }, - { key: "Status", header: "Status", minWidth: 16 }, - { key: "Caps", header: "Caps", minWidth: 10, flex: true }, + { key: "Detail", header: "Detail", minWidth: 18, flex: true }, + { key: "Status", header: "Status", minWidth: 18 }, + { key: "Caps", header: "Caps", minWidth: 12, flex: true }, ], rows, }).trimEnd(), @@ -133,6 +136,7 @@ export function registerNodesStatusCommands(nodes: Command) { : {}; const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId; const connected = Boolean(obj.connected); + const paired = Boolean(obj.paired); const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).sort() : null; const commands = Array.isArray(obj.commands) ? obj.commands.map(String).filter(Boolean).sort() @@ -150,18 +154,38 @@ export function registerNodesStatusCommands(nodes: Command) { }, ); - const parts: string[] = ["Node:", displayName, nodeId]; - if (ip) parts.push(ip); - if (family) parts.push(`device: ${family}`); - if (model) parts.push(`hw: ${model}`); - if (perms) parts.push(`perms: ${perms}`); - if (versions) parts.push(versions); - parts.push(connected ? "connected" : "disconnected"); - parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`); - defaultRuntime.log(parts.join(" · ")); - defaultRuntime.log("Commands:"); + const { heading, ok, warn, muted } = getNodesTheme(); + const status = `${paired ? ok("paired") : warn("unpaired")} · ${ + connected ? ok("connected") : muted("disconnected") + }`; + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const rows = [ + { Field: "ID", Value: nodeId }, + displayName ? { Field: "Name", Value: displayName } : null, + ip ? { Field: "IP", Value: ip } : null, + family ? { Field: "Device", Value: family } : null, + model ? { Field: "Model", Value: model } : null, + perms ? { Field: "Perms", Value: perms } : null, + versions ? { Field: "Version", Value: versions } : null, + { Field: "Status", Value: status }, + { Field: "Caps", Value: caps ? caps.join(", ") : "?" }, + ].filter(Boolean) as Array<{ Field: string; Value: string }>; + + defaultRuntime.log(heading("Node")); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Field", header: "Field", minWidth: 8 }, + { key: "Value", header: "Value", minWidth: 24, flex: true }, + ], + rows, + }).trimEnd(), + ); + defaultRuntime.log(""); + defaultRuntime.log(heading("Commands")); if (commands.length === 0) { - defaultRuntime.log("- (none reported)"); + defaultRuntime.log(muted("- (none reported)")); return; } for (const c of commands) defaultRuntime.log(`- ${c}`); @@ -182,10 +206,7 @@ export function registerNodesStatusCommands(nodes: Command) { } const { pending, paired } = parsePairingList(result); defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`); - const rich = isRich(); - const heading = (text: string) => (rich ? theme.heading(text) : text); - const muted = (text: string) => (rich ? theme.muted(text) : text); - const warn = (text: string) => (rich ? theme.warn(text) : text); + const { heading, muted, warn } = getNodesTheme(); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const now = Date.now(); diff --git a/src/cli/program.nodes-basic.test.ts b/src/cli/program.nodes-basic.test.ts index 722b2d06c..9ab835d2b 100644 --- a/src/cli/program.nodes-basic.test.ts +++ b/src/cli/program.nodes-basic.test.ts @@ -95,12 +95,14 @@ describe("cli program (nodes basics)", () => { const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1"); expect(output).toContain("iOS Node"); - expect(output).toContain("Device"); - expect(output).toContain("iPad (iPad16,6)"); + expect(output).toContain("Detail"); + expect(output).toContain("device: iPad"); + expect(output).toContain("hw: iPad16,6"); expect(output).toContain("Status"); expect(output).toContain("paired"); expect(output).toContain("Caps"); - expect(output).toContain("camera, canvas"); + expect(output).toContain("camera"); + expect(output).toContain("canvas"); }); it("runs nodes status and shows unpaired nodes", async () => { @@ -125,12 +127,12 @@ describe("cli program (nodes basics)", () => { const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1"); - expect(output).toContain("Peter's Tab S10"); - expect(output).toContain("Ultra"); - expect(output).toContain("Device"); - expect(output).toContain("Android"); - expect(output).toContain("SM-"); - expect(output).toContain("X926B"); + expect(output).toContain("Peter's Tab"); + expect(output).toContain("S10 Ultra"); + expect(output).toContain("Detail"); + expect(output).toContain("device: Android"); + expect(output).toContain("hw: samsung"); + expect(output).toContain("SM-X926B"); expect(output).toContain("Status"); expect(output).toContain("unpaired"); expect(output).toContain("connected"); @@ -184,7 +186,7 @@ describe("cli program (nodes basics)", () => { ); const out = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); - expect(out).toContain("Commands:"); + expect(out).toContain("Commands"); expect(out).toContain("canvas.eval"); });