// Visual adaptation of oh-my-pi's MIT-licensed GitHub Actions renderer. // Copyright (c) 2025 Mario Zechner and oh-my-pi contributors. See NOTICE.md. import { keyHint, type Theme } from "@earendil-works/pi-coding-agent"; import { Container, type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { BackgroundWatchSnapshot, GithubActionsWatchInput, RunWatchDetails, RunWatchJobDetails, RunWatchRunDetails, } from "./types.ts"; export type SymbolMode = "unicode" | "nerd" | "ascii"; /** * Braille frames are the conventional TUI spinner: they read as rotation at terminal font * sizes, occupy one column, and Nerd Fonts inherit them from the patched base font, so both * the unicode and nerd presets use them. */ const BRAILLE_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; interface SymbolSet { github: string; success: string; error: string; running: string; /** Animation frames used for in-progress work; falls back to `running` when empty. */ spinner: string[]; pending: string; horizontal: string; vertical: string; topLeft: string; topRight: string; bottomLeft: string; bottomRight: string; leftTee: string; rightTee: string; ellipsis: string; } const SYMBOLS: Record = { unicode: { github: "⎇", success: "✔", error: "✘", running: "●", spinner: BRAILLE_SPINNER, pending: "○", horizontal: "─", vertical: "│", topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", leftTee: "├", rightTee: "┤", ellipsis: "…", }, nerd: { github: "\uEA84", success: "\uf00c", error: "\uf00d", running: "\uf111", // Deliberately braille rather than a nerd-native set: nf-md-circle_slice_1..8 fills a // pie instead of rotating, which reads as a static circle at terminal font sizes. spinner: BRAILLE_SPINNER, pending: "\uf10c", horizontal: "─", vertical: "│", topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", leftTee: "├", rightTee: "┤", ellipsis: "…", }, ascii: { github: "gh", success: "[ok]", error: "[!!]", running: "[x]", spinner: ["[|]", "[/]", "[-]", "[\\]"], pending: "[/]", horizontal: "-", vertical: "|", topLeft: "+", topRight: "+", bottomLeft: "+", bottomRight: "+", leftTee: "+", rightTee: "+", ellipsis: "...", }, }; const SUCCESS = new Set(["success", "neutral", "skipped"]); const FAILURE = new Set(["failure", "timed_out", "cancelled", "action_required", "startup_failure"]); const RUNNING = new Set(["in_progress"]); const COLLAPSED_LOG_LINES = 5; /** Spinner frame duration. The widget repaints on this cadence while any job is running. */ export const SPINNER_INTERVAL_MS = 125; /** Widget: workflow runs listed per watch before the overflow line. */ const WIDGET_MAX_RUNS = 3; /** Widget: job lines shared across all active watches, so three watches stay bounded. */ const WIDGET_JOB_LINE_BUDGET = 12; const WIDGET_MIN_JOB_LINES = 3; /** Frames advance on wall-clock time so every card and widget stays in phase. */ function spinnerFrame(now: number): number { return Math.floor(now / SPINNER_INTERVAL_MS); } function spinnerIcon(symbols: SymbolSet, frame: number): string { const frames = symbols.spinner; if (frames.length === 0) return symbols.running; const index = ((frame % frames.length) + frames.length) % frames.length; return frames[index] ?? symbols.running; } function isPassedJob(job: RunWatchJobDetails): boolean { return Boolean(job.conclusion && SUCCESS.has(job.conclusion)); } export function jobLineBudget(activeWatches: number): number { if (activeWatches <= 1) return WIDGET_JOB_LINE_BUDGET; return Math.max(WIDGET_MIN_JOB_LINES, Math.floor(WIDGET_JOB_LINE_BUDGET / activeWatches)); } /** True when any watched job is still in progress, i.e. the spinner should keep ticking. */ export function hasRunningJob(details: RunWatchDetails | undefined): boolean { if (!details || details.state !== "watching") return false; return details.runs.some( (run) => (run.status && RUNNING.has(run.status)) || run.jobs.some((job) => job.status && RUNNING.has(job.status)), ); } export function isRunWatchDetails(value: unknown): value is RunWatchDetails { if (!value || typeof value !== "object") return false; const details = value as Partial; return ( details.schemaVersion === 1 && (details.mode === "run" || details.mode === "commit") && typeof details.repo === "string" && (details.state === "watching" || details.state === "completed" || details.state === "failed" || details.state === "no-runs") && Array.isArray(details.runs) && Array.isArray(details.failedLogs) ); } export function detectSymbolMode(env: NodeJS.ProcessEnv = process.env): SymbolMode { const explicit = (env.PI_GITHUB_ACTIONS_WATCH_SYMBOLS ?? env.PI_SYMBOL_PRESET)?.toLowerCase(); if (explicit === "unicode" || explicit === "nerd" || explicit === "ascii") return explicit; if (env.TERM === "dumb") return "ascii"; return "unicode"; } function replaceTabs(value: string): string { return value.replace(/\t/g, " "); } function singleLine(value: string): string { return replaceTabs(value).replace(/[\r\n]+/g, " "); } function shortSha(value: string | undefined): string | undefined { return value?.slice(0, 7); } function truncate(value: string, width: number, symbols: SymbolSet): string { if (width <= 0) return ""; return truncateToWidth(value, width, symbols.ellipsis); } function pad(value: string, width: number, symbols: SymbolSet): string { const fitted = truncate(value, width, symbols); return fitted + " ".repeat(Math.max(0, width - visibleWidth(fitted))); } function rawBorder(symbols: SymbolSet, width: number, position: "top" | "bottom"): string { if (width <= 0) return ""; if (width === 1) return symbols.horizontal; const left = position === "top" ? symbols.topLeft : symbols.bottomLeft; const right = position === "top" ? symbols.topRight : symbols.bottomRight; return left + symbols.horizontal.repeat(Math.max(0, width - 2)) + right; } function divider(symbols: SymbolSet, width: number, label?: string): string { if (width <= 0) return ""; if (width === 1) return symbols.horizontal; const inner = width - 2; const text = label ? ` ${label} ` : ""; const fitted = truncate(text, inner, symbols); return symbols.leftTee + fitted + symbols.horizontal.repeat(Math.max(0, inner - visibleWidth(fitted))) + symbols.rightTee; } function bodyLine( content: string, symbols: SymbolSet, width: number, styleBorder: (value: string) => string, ): string { if (width <= 0) return ""; if (width === 1) return styleBorder(symbols.vertical); return styleBorder(symbols.vertical) + pad(content, width - 2, symbols) + styleBorder(symbols.vertical); } function watchHeader(details: RunWatchDetails): string { if (details.mode === "run" && details.runs[0]) { const prefix = details.state === "watching" ? "watching " : ""; return `${prefix}run #${details.runs[0].id} on ${details.repo}`; } const sha = shortSha(details.headSha) ?? "this commit"; if (details.state === "watching") return `watching ${sha} on ${details.repo}`; if (details.state === "no-runs") return `no workflow runs for ${sha} on ${details.repo}`; return `workflow runs for ${sha} on ${details.repo}`; } function initialHeader(args: GithubActionsWatchInput): string { const run = args.run?.trim(); if (run) { const match = run.match(/\/actions\/runs\/(\d+)/); return `watching run #${singleLine(match?.[1] ?? run)}`; } if (args.branch?.trim()) return `watching ${singleLine(args.branch.trim())}`; return "watching current HEAD"; } function jobVisual(job: RunWatchJobDetails, symbols: SymbolSet, frame: number): { icon: string; iconColor: "accent" | "error" | "warning" | "muted"; textColor: "success" | "error" | "warning" | "muted"; } { if (job.conclusion && SUCCESS.has(job.conclusion)) { return { icon: symbols.success, iconColor: "accent", textColor: "success" }; } if (job.conclusion && FAILURE.has(job.conclusion)) { return { icon: symbols.error, iconColor: "error", textColor: "error" }; } if (job.status && RUNNING.has(job.status)) { return { icon: spinnerIcon(symbols, frame), iconColor: "warning", textColor: "warning" }; } return { icon: symbols.pending, iconColor: "muted", textColor: "muted" }; } function jobLine(job: RunWatchJobDetails, width: number, theme: Theme, symbols: SymbolSet, frame: number): string { const visual = jobVisual(job, symbols, frame); const prefix = theme.fg(visual.iconColor, `${visual.icon} `); const duration = job.durationSeconds === undefined ? undefined : `${job.durationSeconds}s`; const styledDuration = duration ? theme.fg(visual.textColor, duration) : undefined; const prefixWidth = visibleWidth(prefix); const durationWidth = styledDuration ? visibleWidth(styledDuration) + 1 : 0; const showDuration = Boolean(styledDuration) && width - prefixWidth - durationWidth >= 3; const nameWidth = Math.max(0, width - prefixWidth - (showDuration ? durationWidth : 0)); const name = theme.fg(visual.textColor, truncate(singleLine(job.name), nameWidth, symbols)); let line = `${prefix}${name}`; if (showDuration && styledDuration) { line += " ".repeat(Math.max(1, width - visibleWidth(line) - visibleWidth(styledDuration))); line += styledDuration; } return truncate(line, width, symbols); } /** One-line stand-in for the passed jobs of a run: "4 passed (unit-tests, code-quality, +2)". */ function passedSummaryLine( passed: RunWatchJobDetails[], width: number, theme: Theme, symbols: SymbolSet, ): string { const prefix = theme.fg("accent", `${symbols.success} `); const named = passed.slice(0, 2).map((job) => singleLine(job.name)); const remainder = passed.length - named.length; const names = named.length > 0 ? ` (${[...named, ...(remainder > 0 ? [`+${remainder}`] : [])].join(", ")})` : ""; const text = `${passed.length} passed${names}`; const textWidth = Math.max(0, width - visibleWidth(prefix)); const fitted = truncate(text, textWidth, symbols); // Drop the name list rather than truncating it mid-word on narrow widgets. const final = visibleWidth(fitted) < visibleWidth(`${passed.length} passed`) + 1 ? truncate(`${passed.length} passed`, textWidth, symbols) : fitted; return `${prefix}${theme.fg("success", final)}`; } function runLines(run: RunWatchRunDetails, width: number, theme: Theme, symbols: SymbolSet, frame: number): string[] { const label = singleLine(run.workflowName ?? run.displayTitle ?? "GitHub Actions"); const meta = run.branch ?? shortSha(run.headSha); let heading = theme.fg("accent", label); if (meta) heading += ` ${theme.fg("text", singleLine(meta))}`; heading += ` ${theme.fg("muted", `#${run.id}`)}`; const lines = [truncate(heading, width, symbols)]; if (run.jobs.length === 0) { lines.push(theme.fg("dim", "waiting for workflow jobs...")); return lines; } for (const job of run.jobs) lines.push(jobLine(job, width, theme, symbols, frame)); return lines; } function expandKeyHint(): string { try { return keyHint("app.tools.expand", "to expand"); } catch { // Standalone render tests and non-interactive consumers may not initialize // Pi's global keybinding registry. Interactive Pi always takes the branch above. return "ctrl+o to expand"; } } function failedLogLines(details: RunWatchDetails, width: number, expanded: boolean, theme: Theme, symbols: SymbolSet): string[] { const lines: string[] = []; for (const entry of details.failedLogs) { const context = entry.workflowName ? `${entry.workflowName} #${entry.runId}` : `run #${entry.runId}`; lines.push( theme.fg("error", `${symbols.error} ${singleLine(entry.jobName)}`) + ` ${theme.fg("muted", singleLine(context))}`, ); if (!entry.available || !entry.tail) { lines.push(theme.fg("dim", " log tail unavailable")); continue; } const all = replaceTabs(entry.tail) .split("\n") .filter((line) => line.length > 0); const count = expanded ? all.length : Math.min(COLLAPSED_LOG_LINES, all.length); for (const line of all.slice(-count)) { lines.push(theme.fg("dim", ` ${truncate(line, Math.max(1, width - 2), symbols)}`)); } if (!expanded && all.length > count) { const hint = expandKeyHint(); lines.push(theme.fg("dim", ` ${symbols.ellipsis} ${all.length - count} more log lines (${hint})`)); } } return lines; } function borderColor(details: RunWatchDetails | undefined, error: string | undefined): "error" | "success" | "borderMuted" | "warning" { if (error || details?.outcome === "failure" || details?.state === "failed") return "error"; if (details?.state === "completed" && details.outcome === "success") return "success"; if (details?.state === "no-runs") return "warning"; return "borderMuted"; } export function renderWatchLines(options: { args: GithubActionsWatchInput; details?: RunWatchDetails; error?: string; expanded: boolean; width: number; theme: Theme; symbolMode: SymbolMode; now?: number; }): string[] { const { args, details, error, expanded, theme } = options; const width = Math.max(0, options.width); if (width === 0) return []; const symbols = SYMBOLS[options.symbolMode]; const frame = spinnerFrame(options.now ?? Date.now()); const failed = Boolean(error || details?.outcome === "failure" || details?.state === "failed"); const icon = failed ? theme.fg("error", symbols.error) : theme.fg("accent", symbols.github); const titleColor = failed ? "error" : "accent"; const headerMeta = details ? watchHeader(details) : initialHeader(args); const header = truncate( `${icon} ${theme.fg(titleColor, theme.bold("GitHub Run Watch"))} ${theme.fg("muted", headerMeta)}`, width, symbols, ); const color = borderColor(details, error); const colorBorder = (line: string) => theme.fg(color, line); const lines: string[] = [header, colorBorder(rawBorder(symbols, width, "top"))]; const innerWidth = Math.max(0, width - 2); const add = (line = "") => lines.push(bodyLine(line, symbols, width, colorBorder)); if (error) { for (const errorLine of replaceTabs(error).split(/\r?\n/)) add(theme.fg("error", errorLine)); } else if (!details) { add(theme.fg("dim", "waiting for workflow data...")); } else { if (details.note) { for (const noteLine of replaceTabs(details.note).split(/\r?\n/)) add(theme.fg("dim", noteLine)); } if (details.mode === "commit" && details.runs.length === 0) { add( theme.fg( details.state === "no-runs" ? "warning" : "dim", details.state === "no-runs" ? "no workflow runs to watch" : "waiting for workflow runs...", ), ); } else { details.runs.forEach((run, index) => { if (index > 0) add(); for (const line of runLines(run, innerWidth, theme, symbols, frame)) add(line); }); } if (details.failedLogs.length > 0) { lines.push(colorBorder(divider(symbols, width, "failed logs"))); for (const line of failedLogLines(details, innerWidth, expanded, theme, symbols)) add(line); } if (details.artifactPath) { lines.push(colorBorder(divider(symbols, width, "artifact"))); add(theme.fg("dim", `full logs: ${details.artifactPath}`)); } } lines.push(colorBorder(rawBorder(symbols, width, "bottom"))); return lines.map((line) => truncate(line, width, symbols)); } export function renderBackgroundWatchLines(options: { tasks: BackgroundWatchSnapshot[]; width: number; theme: Theme; symbolMode: SymbolMode; now?: number; }): string[] { const width = Math.max(0, options.width); if (width === 0) return []; const { theme } = options; const symbols = SYMBOLS[options.symbolMode]; const frame = spinnerFrame(options.now ?? Date.now()); const budget = jobLineBudget(options.tasks.length); const lines: string[] = []; for (const [taskIndex, task] of options.tasks.entries()) { if (taskIndex > 0) lines.push(theme.fg("borderMuted", truncate(symbols.horizontal.repeat(width), width, symbols))); const details = task.latestDetails; const target = details ? details.mode === "run" && details.runs[0] ? `${details.repo} run #${details.runs[0].id}` : `${details.repo}@${shortSha(details.headSha) ?? "HEAD"}` : task.input.run ? `run ${singleLine(task.input.run)}` : `${task.input.repo ?? "current repo"}@${task.input.branch ?? "HEAD"}`; const state = task.state === "stopping" ? "stopping" : `${task.elapsedSeconds}s`; lines.push( truncate( `${theme.fg("accent", symbols.github)} ${theme.fg("accent", theme.bold(task.watchId))} ${theme.fg("muted", target)} ${theme.fg(task.state === "stopping" ? "warning" : "dim", state)}`, width, symbols, ), ); if (!details) { lines.push(theme.fg("dim", truncate(" waiting for workflow data...", width, symbols))); continue; } if (details.note) lines.push(theme.fg("dim", truncate(` ${singleLine(details.note)}`, width, symbols))); if (details.runs.length === 0) { const message = details.state === "no-runs" ? " no workflow runs to watch" : " waiting for workflow runs..."; lines.push(theme.fg(details.state === "no-runs" ? "warning" : "dim", truncate(message, width, symbols))); continue; } const displayedRuns = details.runs.slice(0, WIDGET_MAX_RUNS); let usedJobLines = 0; let hiddenJobs = 0; for (const run of displayedRuns) { const visual = jobVisual( { id: run.id, name: run.workflowName ?? run.displayTitle ?? "GitHub Actions", status: run.status, conclusion: run.conclusion }, symbols, frame, ); const runName = `${visual.icon} ${singleLine(run.workflowName ?? run.displayTitle ?? "GitHub Actions")} #${run.id}`; lines.push(theme.fg(visual.textColor, truncate(` ${runName}`, width, symbols))); // Passed jobs collapse into one line so running and failed jobs are never the // ones pushed out of the widget. const passed = run.jobs.filter(isPassedJob); const collapsePassed = passed.length >= 2; if (collapsePassed) { if (usedJobLines < budget) { lines.push(` ${passedSummaryLine(passed, Math.max(0, width - 4), theme, symbols)}`); usedJobLines += 1; } else { hiddenJobs += passed.length; } } for (const job of run.jobs) { if (collapsePassed && isPassedJob(job)) continue; if (usedJobLines >= budget) { hiddenJobs += 1; continue; } lines.push(` ${jobLine(job, Math.max(0, width - 4), theme, symbols, frame)}`); usedJobLines += 1; } } const hiddenRuns = Math.max(0, details.runs.length - displayedRuns.length); for (const run of details.runs.slice(displayedRuns.length)) hiddenJobs += run.jobs.length; const overflow: string[] = []; if (hiddenRuns > 0) overflow.push(`${hiddenRuns} more workflow${hiddenRuns === 1 ? "" : "s"}`); if (hiddenJobs > 0) overflow.push(`${hiddenJobs} more job${hiddenJobs === 1 ? "" : "s"}`); if (overflow.length > 0) { lines.push(theme.fg("dim", truncate(` ${symbols.ellipsis} ${overflow.join(", ")}`, width, symbols))); } } return lines.map((line) => truncate(line, width, symbols)); } export class BackgroundWatchWidget implements Component { private readonly symbolMode: SymbolMode; constructor( private readonly getTasks: () => BackgroundWatchSnapshot[], private readonly theme: Theme, symbolMode = detectSymbolMode(), private readonly now: () => number = Date.now, ) { this.symbolMode = symbolMode; } render(width: number): string[] { return renderBackgroundWatchLines({ tasks: this.getTasks(), width, theme: this.theme, symbolMode: this.symbolMode, now: this.now(), }); } invalidate(): void { // Rendering is derived from current task snapshots and theme on every pass. } } export class WatchCardComponent implements Component { private details?: RunWatchDetails; private error?: string; private expanded = false; private args: GithubActionsWatchInput; private theme: Theme; private readonly symbolMode: SymbolMode; private signature = ""; constructor(args: GithubActionsWatchInput, theme: Theme, symbolMode = detectSymbolMode()) { this.args = args; this.theme = theme; this.symbolMode = symbolMode; } update(options: { args?: GithubActionsWatchInput; details?: RunWatchDetails; error?: string; expanded?: boolean; theme?: Theme; }): boolean { if (options.args) this.args = options.args; if (options.details !== undefined) this.details = options.details; if (options.error !== undefined) this.error = options.error; if (options.expanded !== undefined) this.expanded = options.expanded; if (options.theme) this.theme = options.theme; const next = JSON.stringify({ args: this.args, details: this.details, error: this.error, expanded: this.expanded, }); const changed = next !== this.signature; this.signature = next; return changed; } render(width: number): string[] { return renderWatchLines({ args: this.args, details: this.details, error: this.error, expanded: this.expanded, width, theme: this.theme, symbolMode: this.symbolMode, }); } invalidate(): void { // ANSI is generated from the current theme on every render; nothing stale is cached. } } export interface WatchRendererState { card?: WatchCardComponent; } export function getWatchCard( state: WatchRendererState, lastComponent: Component | undefined, args: GithubActionsWatchInput, theme: Theme, ): WatchCardComponent { if (lastComponent instanceof WatchCardComponent) state.card = lastComponent; if (!state.card) state.card = new WatchCardComponent(args, theme); state.card.update({ args, theme }); return state.card; } export function emptyResultComponent(lastComponent: Component | undefined): Component { return lastComponent instanceof Container ? lastComponent : new Container(); }