UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

246 lines (245 loc) 11 kB
// Pure two-pane frame composer for `wfm run --ui`. // // renderFrame(state) is a plain function: given a TuiFrameState it returns an // array of exactly `state.height` display rows, each at most `state.width` // visible columns wide (ANSI-aware — see ansi.ts). No I/O, no timers, no // Date.now(): every time-dependent value is derived from `state.now`, which // callers refresh on their own render loop. import { elapsedFrom, formatDurationMs } from "../runFormat.js"; import { padVisible, truncateVisible, visibleWidth } from "./ansi.js"; // Below these thresholds the two-pane layout has no room to stay readable; // fall back to a single-column stack instead of corrupting column math. const DEGRADED_MIN_WIDTH = 60; const DEGRADED_MIN_HEIGHT = 12; // Fixed chrome rows around the variable-height content area: top border, // attach-info row, pane-title separator, footer separator, two footer rows, // bottom border. const CHROME_ROWS = 7; const KEY_HINTS = "↑/↓ select step f follow current a approve r resume c cancel q quit"; const QA_ICONS = { PROCEED: "✓", RETRY_CURRENT: "↻", ROLLBACK_PREVIOUS: "⤺", RESTART_ALL: "⟲", }; export function renderFrame(state) { if (state.width < DEGRADED_MIN_WIDTH || state.height < DEGRADED_MIN_HEIGHT) { return renderDegradedFrame(state); } return renderFullFrame(state); } // --- generic row-fitting helpers ------------------------------------------------- /** Truncates or right-pads `text` so visibleWidth(result) === width (never more). */ function fitExact(text, width) { const w = visibleWidth(text); if (w > width) { return truncateVisible(text, width); } if (w < width) { return padVisible(text, width); } return text; } /** Pads `text` with trailing box-drawing dashes up to `width` (truncates if it overflows). */ function fillDashes(text, width) { const w = visibleWidth(text); if (w >= width) { return truncateVisible(text, width); } return text + "─".repeat(width - w); } // --- full (non-degraded) frame ---------------------------------------------------- function renderFullFrame(state) { const { width, height } = state; const contentRows = height - CHROME_ROWS; const paneLeftWidth = Math.min(38, Math.floor(width * 0.4)); const paneRightWidth = width - 3 - paneLeftWidth; const rows = []; rows.push(buildTopBorder(state, width)); rows.push(buildAttachRow(state, width)); rows.push(buildPaneTitleRow(state, paneLeftWidth, paneRightWidth)); const stepLines = buildStepPaneLines(state, paneLeftWidth, contentRows); const activityLines = buildActivityPaneLines(state, paneRightWidth, contentRows); for (let i = 0; i < contentRows; i++) { rows.push(`│${stepLines[i]}${activityLines[i]}│`); } rows.push(`├${"─".repeat(paneLeftWidth)}${"─".repeat(paneRightWidth)}┤`); rows.push(buildFooterRow(width, buildApprovalOrStatusText(state))); rows.push(buildFooterRow(width, state.theme.dim(KEY_HINTS))); rows.push(`└${"─".repeat(width - 2)}┘`); return rows.map((row) => fitExact(row, width)); } function buildTopBorder(state, width) { const { snapshot, theme } = state; const title = snapshot?.workflowTitle ?? "wfm run"; const statusSegment = snapshot ? theme.runStatus(snapshot.status, `● ${snapshot.status.replace(/_/g, " ")} · elapsed ${computeElapsed(snapshot, state.now)}`) : theme.dim("● initializing…"); const left = `┌─ ${title} `; const right = ` ${statusSegment} ─┐`; const fill = Math.max(0, width - visibleWidth(left) - visibleWidth(right)); return left + "─".repeat(fill) + right; } function buildAttachRow(state, width) { const { snapshot } = state; const tokenPreview = state.attachToken ? `${state.attachToken.slice(0, 8)}…` : "—"; const runPreview = snapshot?.runId ? `${snapshot.runId.slice(0, 8)}…` : "—"; const left = `│ Attach: ${state.attachUrl} (token ${tokenPreview})`; const right = `run ${runPreview} │`; const fill = Math.max(1, width - visibleWidth(left) - visibleWidth(right)); return left + " ".repeat(fill) + right; } function buildPaneTitleRow(state, paneLeftWidth, paneRightWidth) { const { snapshot, selectedStepKey } = state; const steps = snapshot?.steps ?? []; const succeeded = steps.filter((s) => s.status === "succeeded").length; const leftTitle = ` Steps (${succeeded}/${steps.length} done) `; const selectedStep = selectedStepKey ? steps.find((s) => s.stepKey === selectedStepKey) : undefined; const rightTitle = selectedStep ? ` Activity: ${selectedStep.stepKey} · ${selectedStep.status.replace(/_/g, " ")} ` : " Activity: (none) "; const leftSeg = fillDashes(leftTitle, paneLeftWidth); const rightSeg = fillDashes(rightTitle, paneRightWidth); return `├${leftSeg}${rightSeg}┤`; } function buildFooterRow(width, text) { const inner = width - 2; const content = ` ${text}`; return `│${fitExact(content, inner)}│`; } function buildApprovalOrStatusText(state) { const { snapshot, theme } = state; const waiting = snapshot?.waitingForApproval; if (waiting) { const actionHint = waiting.validation === "external" ? "[r]esume" : "[a]pprove"; const text = `◌ ${waiting.stepKey} waiting for approval — ${actionHint} [c]ancel`; return theme.runStatus("waiting_for_approval", text); } if (state.statusMessage) { return theme.dim(state.statusMessage); } return ""; } function buildStepPaneLines(state, paneLeftWidth, contentRows) { const { snapshot, theme, selectedStepKey, stepDetails, now } = state; const steps = snapshot?.steps ?? []; const flatItems = []; steps.forEach((step, idx) => { const index1based = idx + 1; flatItems.push({ step, index1based, isBadge: false }); const detail = stepDetails.get(step.stepKey); const qaAction = detail?.lastExecution.qaAction ?? null; const showBadge = step.attempt > 1 || (qaAction !== null && qaAction !== "PROCEED"); if (showBadge) { flatItems.push({ step, index1based, isBadge: true }); } }); let windowStart = 0; if (flatItems.length > contentRows) { const selIdx = selectedStepKey ? flatItems.findIndex((item) => !item.isBadge && item.step.stepKey === selectedStepKey) : -1; if (selIdx >= 0) { windowStart = Math.min(Math.max(0, selIdx - Math.floor(contentRows / 2)), flatItems.length - contentRows); } } const lines = []; for (let i = 0; i < contentRows; i++) { const item = flatItems[windowStart + i]; if (!item) { if (i === 0 && steps.length === 0 && !snapshot) { lines.push(fitExact(theme.dim(" Initializing…"), paneLeftWidth)); } else { lines.push(padVisible("", paneLeftWidth)); } continue; } const selected = !item.isBadge && item.step.stepKey === selectedStepKey; lines.push(item.isBadge ? buildBadgeLine(item, paneLeftWidth, theme, stepDetails) : buildStepLine(item, paneLeftWidth, theme, selected, now)); } return lines; } function buildStepLine(item, paneLeftWidth, theme, selected, now) { const { step, index1based } = item; const icon = theme.stepIcon(step.status); const duration = stepDuration(step, now); const raw = `${icon} ${index1based} ${step.stepKey} ${step.adapter} ${duration}`.trimEnd(); const fitted = fitExact(raw, paneLeftWidth); return selected ? theme.inverse(fitted) : theme.stepStatus(step.status, fitted); } function buildBadgeLine(item, paneLeftWidth, theme, stepDetails) { const { step } = item; const detail = stepDetails.get(step.stepKey); const parts = []; if (step.attempt > 1) { parts.push(theme.dim(`attempt ${step.attempt}`)); } const qaAction = detail?.lastExecution.qaAction ?? null; if (qaAction && qaAction !== "PROCEED") { parts.push(theme.qaAction(qaAction, `${QA_ICONS[qaAction]} ${qaAction}`)); } const raw = ` ${parts.join(" · ")}`; return fitExact(raw, paneLeftWidth); } function stepDuration(step, now) { if (step.startedAt && step.finishedAt) { return formatDurationMs(Date.parse(step.finishedAt) - Date.parse(step.startedAt)); } if (step.startedAt && step.status === "running") { return elapsedFrom(step.startedAt, now); } return ""; } function computeElapsed(snapshot, now) { if (!snapshot.startedAt) { return "0s"; } const endTs = snapshot.endedAt ? Date.parse(snapshot.endedAt) : now; return elapsedFrom(snapshot.startedAt, endTs); } // --- right pane: activity log -------------------------------------------------- function buildActivityPaneLines(state, paneRightWidth, contentRows) { const { logs, selectedStepKey, theme } = state; const lines = selectedStepKey ? logs.tail(selectedStepKey, contentRows) : []; const rendered = []; for (let i = 0; i < contentRows; i++) { const line = lines[i]; if (!line) { rendered.push(padVisible("", paneRightWidth)); continue; } const raw = line.kind === "meta" ? `▸ ${line.text}` : line.text; const truncated = truncateVisible(raw, paneRightWidth); const colored = line.kind === "stderr" ? theme.stderrText(truncated) : line.kind === "meta" ? theme.accent(truncated) : truncated; rendered.push(padVisible(colored, paneRightWidth)); } return rendered; } // --- degraded (narrow/short terminal) frame ---------------------------------------- function renderDegradedFrame(state) { const { snapshot, theme, width, height, selectedStepKey, now } = state; const rows = []; const title = snapshot?.workflowTitle ?? "wfm run"; const statusWord = snapshot ? snapshot.status.replace(/_/g, " ") : "initializing"; const headerText = snapshot ? theme.runStatus(snapshot.status, `${title}${statusWord}`) : theme.dim(`${title}${statusWord}`); rows.push(fitExact(headerText, width)); const steps = snapshot?.steps ?? []; const stepRowBudget = Math.max(0, height - 1); for (let i = 0; i < stepRowBudget; i++) { const step = steps[i]; if (!step) { rows.push(padVisible("", width)); continue; } const icon = theme.stepIcon(step.status); const duration = stepDuration(step, now); const raw = `${icon} ${i + 1} ${step.stepKey} ${step.adapter} ${duration}`.trimEnd(); const selected = step.stepKey === selectedStepKey; const fitted = fitExact(raw, width); rows.push(selected ? theme.inverse(fitted) : theme.stepStatus(step.status, fitted)); } return rows.slice(0, height); }