/** * `assistant ui` CLI namespace. * * Subcommands: * - `assistant ui request` — Present an arbitrary interactive surface to * the user and block until they respond. Input is a JSON payload * describing the surface (via `--payload` or stdin). * - `assistant ui confirm` — Convenience wrapper that presents a yes/no * confirmation prompt and exits 0 on confirm, 1 on deny/cancel/timeout. * - `assistant ui snapshot` — Capture a PNG of a staged app view (via the * desktop client) with the current workspace theme applied. * * `request` and `confirm` delegate to the daemon's `ui_request` IPC method, * which manages the surface lifecycle on the active conversation; `snapshot` * delegates to `ui_snapshot`, which is conversation-agnostic. */ import { writeFileSync } from "node:fs"; import type { Command } from "commander"; import { cliIpcCall } from "../../ipc/cli-client.js"; import type { InteractiveUiAction, InteractiveUiResult, } from "../../runtime/interactive-ui-types.js"; import type { UiSnapshotResult } from "../../runtime/routes/ui-snapshot-routes.js"; import { readStdinSync } from "../../util/read-stdin.js"; import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js"; import { registerCommand } from "../lib/register-command.js"; import { log } from "../logger.js"; import { resolveConversationId } from "../utils/conversation-id.js"; import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SNAPSHOT_TIMEOUT_MS, uiHelp, } from "./ui.help.js"; // ── Constants ───────────────────────────────────────────────────────── /** * Extra buffer added to the IPC call timeout beyond the request timeout * so the IPC socket stays open long enough for the daemon to resolve the * surface and send the response. */ const IPC_TIMEOUT_BUFFER_MS = 10_000; // 10s const CONV_ID_HELP = "No conversation ID available.\n" + "Provide --conversation-id explicitly (run 'assistant conversations list' to find it),\n" + "or run this command from a skill or bash tool context."; /** * Action IDs reserved for internal use. Inlined from * `interactive-ui-types.ts` to avoid a hoisted runtime import from daemon * internals, which would pull that module into every CLI invocation. */ const RESERVED_ACTION_IDS = new Set([ "selection_changed", "content_changed", "state_update", "cancel", "dismiss", ]); // ── Payload parsing ─────────────────────────────────────────────────── /** * Read a JSON payload from either the `--payload` flag or stdin. * Returns the parsed object. Throws on invalid input. */ function readPayload(payloadFlag?: string): Record { if (payloadFlag) { try { const parsed = JSON.parse(payloadFlag); if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) { throw new Error( "--payload must be a JSON object (not array or primitive).", ); } return parsed as Record; } catch (err) { if (err instanceof SyntaxError) { throw new Error( `Invalid JSON in --payload: ${err.message}\n` + ' Example: --payload \'{"message":"Are you sure?"}\'', ); } throw err; } } // Read from stdin if (process.stdin.isTTY) { throw new Error( "No payload provided. Use --payload or pipe JSON into stdin.\n" + ' Example: echo \'{"message":"Are you sure?"}\' | assistant ui request', ); } let raw: string; try { raw = readStdinSync(); } catch (err) { throw new Error( `Failed to read stdin: ${err instanceof Error ? err.message : String(err)}`, ); } if (!raw.trim()) { throw new Error( "Empty input on stdin. Provide a valid JSON object.\n" + ' Example: echo \'{"message":"Are you sure?"}\' | assistant ui request', ); } try { const parsed = JSON.parse(raw); if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) { throw new Error( "Stdin payload must be a JSON object (not array or primitive).", ); } return parsed as Record; } catch (err) { if (err instanceof SyntaxError) { throw new Error( `Invalid JSON on stdin: ${err.message}\n` + ' Example: echo \'{"message":"Are you sure?"}\' | assistant ui request', ); } throw err; } } // ── Action parsing ──────────────────────────────────────────────────── /** Valid variant values for action buttons. */ const VALID_VARIANTS = new Set(["primary", "danger", "secondary"]); /** * Parse and validate the `--actions` JSON flag. * * Expected shape: an array of objects, each with: * - `id` (string, required, non-empty) * - `label` (string, required, non-empty) * - `variant` (optional: "primary" | "danger" | "secondary") * * Returns the validated array, or throws with an actionable CLI error. */ function parseActions(raw: string): InteractiveUiAction[] { let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { throw new Error( `Invalid JSON in --actions: ${err instanceof SyntaxError ? err.message : String(err)}\n` + " --actions must be a JSON array of action objects.\n" + ' Example: --actions \'[{"id":"approve","label":"Approve"},{"id":"reject","label":"Reject","variant":"danger"}]\'', ); } if (!Array.isArray(parsed)) { throw new Error( "--actions must be a JSON array of action objects.\n" + ' Example: --actions \'[{"id":"approve","label":"Approve"}]\'', ); } if (parsed.length === 0) { throw new Error( "--actions must contain at least one action.\n" + ' Example: --actions \'[{"id":"approve","label":"Approve"}]\'', ); } const actions: InteractiveUiAction[] = []; for (let i = 0; i < parsed.length; i++) { const item = parsed[i]; if (typeof item !== "object" || item === null || Array.isArray(item)) { throw new Error( `--actions[${i}]: each action must be a JSON object with "id" and "label" fields.\n` + ' Example: {"id":"approve","label":"Approve"}', ); } const obj = item as Record; if (typeof obj.id !== "string" || obj.id.length === 0) { throw new Error( `--actions[${i}]: "id" is required and must be a non-empty string.\n` + ' Example: {"id":"approve","label":"Approve"}', ); } if (RESERVED_ACTION_IDS.has(obj.id)) { const reserved = [...RESERVED_ACTION_IDS].sort().join(", "); throw new Error( `--actions[${i}]: id "${obj.id}" is reserved for internal use. Reserved IDs: ${reserved}`, ); } if (typeof obj.label !== "string" || obj.label.length === 0) { throw new Error( `--actions[${i}]: "label" is required and must be a non-empty string.\n` + ' Example: {"id":"approve","label":"Approve"}', ); } const action: InteractiveUiAction = { id: obj.id, label: obj.label }; if (obj.variant !== undefined) { if (typeof obj.variant !== "string" || !VALID_VARIANTS.has(obj.variant)) { throw new Error( `--actions[${i}]: "variant" must be one of "primary", "danger", or "secondary" (got ${JSON.stringify(obj.variant)}).\n` + ' Example: {"id":"delete","label":"Delete","variant":"danger"}', ); } action.variant = obj.variant as InteractiveUiAction["variant"]; } actions.push(action); } return actions; } // ── Strict integer parsing ──────────────────────────────────────────── /** * Parse a string as a strict positive integer. Rejects inputs like * `"1e3"`, `"30s"`, `"12.5"` that `parseInt` would silently truncate. * Returns the parsed integer or `NaN` on any non-pure-integer input. */ function parseStrictPositiveInt(value: string): number { if (!/^\d+$/.test(value)) { return NaN; } return Number(value); } // ── Registration ────────────────────────────────────────────────────── export function registerUiCommand(program: Command): void { registerCommand(program, { name: uiHelp.name, transport: "ipc", description: uiHelp.description, build: (ui) => { applyCommandHelp(ui, uiHelp); // ── ui request ─────────────────────────────────────────────────── subcommand(ui, "request").action( async (opts: { payload?: string; surfaceType?: string; title?: string; actions?: string; conversationId?: string; timeout?: string; json?: boolean; }) => { // Parse payload let data: Record; try { data = readPayload(opts.payload); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: msg }) + "\n", ); } else { log.error(msg); } process.exitCode = 1; return; } // Resolve conversation ID let conversationId: string; try { conversationId = resolveConversationId({ explicit: opts.conversationId, failureHelp: CONV_ID_HELP, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: msg }) + "\n", ); } else { log.error(msg); } process.exitCode = 1; return; } // Parse actions (if provided) let actions: InteractiveUiAction[] | undefined; if (opts.actions !== undefined) { try { actions = parseActions(opts.actions); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: msg }) + "\n", ); } else { log.error(msg); } process.exitCode = 1; return; } } // Parse timeout const rawTimeout = opts.timeout ?? String(DEFAULT_REQUEST_TIMEOUT_MS); const requestTimeoutMs = parseStrictPositiveInt(rawTimeout); if (isNaN(requestTimeoutMs) || requestTimeoutMs <= 0) { const msg = `Invalid --timeout value "${opts.timeout}". Must be a positive integer (milliseconds).`; if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: msg }) + "\n", ); } else { log.error(msg); } process.exitCode = 1; return; } // Build IPC params const ipcParams: Record = { conversationId, surfaceType: opts.surfaceType ?? "confirmation", data, timeoutMs: requestTimeoutMs, }; if (opts.title) { ipcParams.title = opts.title; } if (actions) { ipcParams.actions = actions; } // Call IPC with timeout budget = request timeout + buffer const ipcTimeoutMs = requestTimeoutMs + IPC_TIMEOUT_BUFFER_MS; const result = await cliIpcCall( "ui_request", { body: ipcParams }, { timeoutMs: ipcTimeoutMs, }, ); if (!result.ok) { if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: result.error }) + "\n", ); } else { log.error(`Error: ${result.error}`); } process.exitCode = 1; return; } if (opts.json) { process.stdout.write( JSON.stringify({ ok: true, ...result.result }) + "\n", ); } else { const r = result.result!; if (r.status === "submitted") { log.info( `User responded: ${r.actionId ?? "submitted"}${r.summary ? ` — ${r.summary}` : ""}`, ); } else if (r.status === "timed_out") { log.info("Request timed out without a response."); } else { log.info("Request was cancelled."); } } }, ); // ── ui confirm ────────────────────────────────────────────────── subcommand(ui, "confirm").action( async (opts: { title?: string; message?: string; confirmLabel?: string; denyLabel?: string; conversationId?: string; timeout?: string; json?: boolean; }) => { // Resolve conversation ID let conversationId: string; try { conversationId = resolveConversationId({ explicit: opts.conversationId, failureHelp: CONV_ID_HELP, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: msg }) + "\n", ); } else { log.error(msg); } process.exitCode = 1; return; } // Parse timeout const rawTimeout = opts.timeout ?? String(DEFAULT_REQUEST_TIMEOUT_MS); const requestTimeoutMs = parseStrictPositiveInt(rawTimeout); if (isNaN(requestTimeoutMs) || requestTimeoutMs <= 0) { const msg = `Invalid --timeout value "${opts.timeout}". Must be a positive integer (milliseconds).`; if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: msg }) + "\n", ); } else { log.error(msg); } process.exitCode = 1; return; } // Build confirmation surface data const confirmLabel = opts.confirmLabel ?? "Confirm"; const denyLabel = opts.denyLabel ?? "Deny"; const data: Record = {}; if (opts.message) { data.message = opts.message; } // Pass custom labels via data payload so the renderer reads them // from ConfirmationSurfaceData.confirmLabel / .cancelLabel. data.confirmLabel = confirmLabel; data.cancelLabel = denyLabel; // Build IPC params const ipcParams: Record = { conversationId, surfaceType: "confirmation", data, actions: [ { id: "confirm", label: confirmLabel, variant: "primary", }, { id: "deny", label: denyLabel, variant: "secondary", }, ], timeoutMs: requestTimeoutMs, }; if (opts.title) { ipcParams.title = opts.title; } // Call IPC with timeout budget const ipcTimeoutMs = requestTimeoutMs + IPC_TIMEOUT_BUFFER_MS; const result = await cliIpcCall( "ui_request", { body: ipcParams }, { timeoutMs: ipcTimeoutMs, }, ); if (!result.ok) { if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: result.error }) + "\n", ); } else { log.error(`Error: ${result.error}`); } process.exitCode = 1; return; } const r = result.result!; const confirmed = r.status === "submitted" && r.actionId === "confirm"; if (opts.json) { const jsonOut: Record = { ok: true, confirmed, status: r.status, actionId: r.actionId, surfaceId: r.surfaceId, }; if (r.decisionToken !== undefined) { jsonOut.decisionToken = r.decisionToken; } if (r.summary !== undefined) { jsonOut.summary = r.summary; } process.stdout.write(JSON.stringify(jsonOut) + "\n"); } else { if (confirmed) { log.info("Confirmed."); } else if (r.status === "timed_out") { log.info("Confirmation timed out."); } else if (r.status === "cancelled") { log.info("Confirmation cancelled."); } else { log.info("Denied."); } } if (!confirmed) { process.exitCode = 1; } }, ); // ── ui snapshot ───────────────────────────────────────────────── subcommand(ui, "snapshot").action( async (opts: { view?: string; out?: string; timeout?: string; json?: boolean; }) => { const emitError = (msg: string): void => { if (opts.json) { process.stdout.write( JSON.stringify({ ok: false, error: msg }) + "\n", ); } else { log.error(msg); } process.exitCode = 1; }; const view = opts.view ?? "sampler"; if (view !== "sampler" && view !== "chat") { emitError( `Invalid --view value "${view}". Must be "sampler" or "chat".`, ); return; } if (!opts.out && !opts.json) { emitError( "Provide --out to write the PNG (or --json for inline base64 output).", ); return; } const rawTimeout = opts.timeout ?? String(DEFAULT_SNAPSHOT_TIMEOUT_MS); const requestTimeoutMs = parseStrictPositiveInt(rawTimeout); if (isNaN(requestTimeoutMs) || requestTimeoutMs <= 0) { emitError( `Invalid --timeout value "${opts.timeout}". Must be a positive integer (milliseconds).`, ); return; } const result = await cliIpcCall( "ui_snapshot", { body: { view, timeoutMs: requestTimeoutMs } }, { timeoutMs: requestTimeoutMs + IPC_TIMEOUT_BUFFER_MS }, ); if (!result.ok) { emitError(`Error: ${result.error}`); return; } const snapshot = result.result!; if (snapshot.themeSource === "invalid" && !opts.json) { log.warn( "The workspace theme file is invalid — the capture shows the built-in theme.", ); for (const issue of snapshot.themeIssues) { log.warn(` - ${issue}`); } } if (!snapshot.ok || !snapshot.pngBase64) { emitError(snapshot.error ?? "The capture returned no image."); return; } if (opts.out) { try { writeFileSync( String(opts.out), Buffer.from(snapshot.pngBase64, "base64"), ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); emitError(`Failed to write PNG to ${opts.out}: ${msg}`); return; } } if (opts.json) { const jsonOut: Record = { ok: true, view, widthPx: snapshot.widthPx, heightPx: snapshot.heightPx, themeSource: snapshot.themeSource, themeIssues: snapshot.themeIssues, }; if (opts.out) { jsonOut.out = opts.out; } else { jsonOut.pngBase64 = snapshot.pngBase64; } process.stdout.write(JSON.stringify(jsonOut) + "\n"); } else { const dims = snapshot.widthPx && snapshot.heightPx ? ` (${snapshot.widthPx}x${snapshot.heightPx})` : ""; log.info(`Snapshot saved to ${opts.out}${dims}`); } }, ); }, }); }