import type { AppConfig, HandoffRecord } from "@openhandoff/shared"; import { spawnSync } from "node:child_process"; import { createBackendClientFromConfig, filterHandoffs, formatRelativeAge, groupHandoffStatus } from "@openhandoff/client"; import { CLI_BUILD_ID } from "./build-id.js"; import { resolveTuiTheme, type TuiTheme } from "./theme.js"; interface KeyEventLike { name?: string; ctrl?: boolean; meta?: boolean; } const HELP_LINES = [ "Shortcuts", "Ctrl-H toggle cheatsheet", "Enter switch to branch", "Ctrl-A attach to session", "Ctrl-O open PR in browser", "Ctrl-X archive branch / close PR", "Ctrl-Y merge highlighted PR", "Ctrl-S sync handoff with remote", "Ctrl-N / Down next row", "Ctrl-P / Up previous row", "Backspace delete filter", "Type filter by branch/PR/author", "Esc / Ctrl-C cancel", "", "Legend", "Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued", ]; const COLUMN_WIDTHS = { diff: 10, agent: 5, pr: 6, author: 10, ci: 7, review: 8, age: 5, } as const; interface DisplayRow { name: string; diff: string; agent: string; pr: string; author: string; ci: string; review: string; age: string; } interface RenderOptions { width?: number; height?: number; } function pad(input: string, width: number): string { if (width <= 0) { return ""; } const chars = Array.from(input); const text = chars.length > width ? `${chars.slice(0, Math.max(1, width - 1)).join("")}…` : input; return text.padEnd(width, " "); } function truncateToLen(input: string, maxLen: number): string { if (maxLen <= 0) { return ""; } return Array.from(input).slice(0, maxLen).join(""); } function fitLine(input: string, width: number): string { if (width <= 0) { return ""; } const clipped = truncateToLen(input, width); const len = Array.from(clipped).length; if (len >= width) { return clipped; } return `${clipped}${" ".repeat(width - len)}`; } function overlayLine(base: string, overlay: string, startCol: number, width: number): string { const out = Array.from(fitLine(base, width)); const src = Array.from(truncateToLen(overlay, Math.max(0, width - startCol))); for (let i = 0; i < src.length; i += 1) { const col = startCol + i; if (col >= 0 && col < out.length) { out[col] = src[i] ?? " "; } } return out.join(""); } function buildFooterLine(width: number, segments: string[], right: string): string { if (width <= 0) { return ""; } const rightLen = Array.from(right).length; if (width <= rightLen + 1) { return truncateToLen(right, width); } const leftMax = width - rightLen - 1; let used = 0; let left = ""; let first = true; for (const segment of segments) { const chunk = first ? segment : ` | ${segment}`; const clipped = truncateToLen(chunk, leftMax - used); if (!clipped) { break; } left += clipped; used += Array.from(clipped).length; first = false; if (used >= leftMax) { break; } } const padding = " ".repeat(Math.max(0, leftMax - used) + 1); return `${left}${padding}${right}`; } function agentSymbol(status: HandoffRecord["status"]): string { const group = groupHandoffStatus(status); if (group === "running") return "🤖"; if (group === "idle") return "💬"; if (group === "error") return "⚠"; if (group === "queued") return "◌"; return "-"; } function toDisplayRow(row: HandoffRecord): DisplayRow { const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : ""; const prLabel = row.prUrl ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` : row.prSubmitted ? "sub" : "-"; const ciLabel = row.ciStatus ?? "-"; const reviewLabel = row.reviewStatus ? row.reviewStatus === "approved" ? "ok" : row.reviewStatus === "changes_requested" ? "chg" : row.reviewStatus === "pending" ? "..." : row.reviewStatus : "-"; return { name: `${conflictPrefix}${row.title || row.branchName}`, diff: row.diffStat ?? "-", agent: agentSymbol(row.status), pr: prLabel, author: row.prAuthor ?? "-", ci: ciLabel, review: reviewLabel, age: formatRelativeAge(row.updatedAt), }; } function helpLines(width: number): string[] { const popupWidth = Math.max(40, Math.min(width - 2, 100)); const innerWidth = Math.max(2, popupWidth - 2); const borderTop = `┌${"─".repeat(innerWidth)}┐`; const borderBottom = `└${"─".repeat(innerWidth)}┘`; const lines = [borderTop]; for (const line of HELP_LINES) { lines.push(`│${pad(line, innerWidth)}│`); } lines.push(borderBottom); return lines; } export function formatRows( rows: HandoffRecord[], selected: number, workspaceId: string, status: string, searchQuery = "", showHelp = false, options: RenderOptions = {}, ): string { const totalWidth = options.width ?? process.stdout.columns ?? 120; const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24); const fixedWidth = COLUMN_WIDTHS.diff + COLUMN_WIDTHS.agent + COLUMN_WIDTHS.pr + COLUMN_WIDTHS.author + COLUMN_WIDTHS.ci + COLUMN_WIDTHS.review + COLUMN_WIDTHS.age; const separators = 7; const prefixWidth = 2; const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth)); const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)"; const header = [ ` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`, "-".repeat(Math.max(24, Math.min(totalWidth, 180))), ]; const body = rows.length === 0 ? ["No branches found."] : rows.map((row, index) => { const marker = index === selected ? "┃ " : " "; const display = toDisplayRow(row); return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`; }); const footer = fitLine(buildFooterLine(totalWidth, ["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status], `v${CLI_BUILD_ID}`), totalWidth); const contentHeight = totalHeight - 1; const lines = [...header, ...body].map((line) => fitLine(line, totalWidth)); const page = lines.slice(0, contentHeight); while (page.length < contentHeight) { page.push(" ".repeat(totalWidth)); } if (showHelp) { const popup = helpLines(totalWidth); const startRow = Math.max(0, Math.floor((contentHeight - popup.length) / 2)); for (let i = 0; i < popup.length; i += 1) { const target = startRow + i; if (target >= page.length) { break; } const popupLine = popup[i] ?? ""; const popupLen = Array.from(popupLine).length; const startCol = Math.max(0, Math.floor((totalWidth - popupLen) / 2)); page[target] = overlayLine(page[target] ?? "", popupLine, startCol, totalWidth); } } return [...page, footer].join("\n"); } interface OpenTuiLike { createCliRenderer?: (options?: Record) => Promise; TextRenderable?: new ( ctx: any, options: { id: string; content: string }, ) => { content: unknown; fg?: string; bg?: string; }; fg?: (color: string) => (input: unknown) => unknown; bg?: (color: string) => (input: unknown) => unknown; StyledText?: new (chunks: unknown[]) => unknown; } interface StyledTextApi { fg: (color: string) => (input: unknown) => unknown; bg: (color: string) => (input: unknown) => unknown; StyledText: new (chunks: unknown[]) => unknown; } function buildStyledContent(content: string, theme: TuiTheme, api: StyledTextApi): unknown { const lines = content.split("\n"); const chunks: unknown[] = []; const footerIndex = Math.max(0, lines.length - 1); for (let i = 0; i < lines.length; i += 1) { const line = lines[i] ?? ""; let fgColor = theme.text; let bgColor: string | undefined; if (line.startsWith("┃ ")) { const marker = "┃ "; const rest = line.slice(marker.length); bgColor = theme.highlightBg; const markerChunk = api.bg(bgColor)(api.fg(theme.selectionBorder)(marker)); const restChunk = api.bg(bgColor)(api.fg(theme.highlightFg)(rest)); chunks.push(markerChunk); chunks.push(restChunk); if (i < lines.length - 1) { chunks.push(api.fg(theme.text)("\n")); } continue; } if (i === 0) { fgColor = theme.header; } else if (i === 1) { fgColor = theme.muted; } else if (i === footerIndex) { fgColor = theme.status; } else if (line.startsWith("┌") || line.startsWith("│") || line.startsWith("└")) { fgColor = theme.info; } let chunk: unknown = api.fg(fgColor)(line); if (bgColor) { chunk = api.bg(bgColor)(chunk); } chunks.push(chunk); if (i < lines.length - 1) { chunks.push(api.fg(theme.text)("\n")); } } return new api.StyledText(chunks); } export async function runTui(config: AppConfig, workspaceId: string): Promise { const core = (await import("@opentui/core")) as OpenTuiLike; const createCliRenderer = core.createCliRenderer; const TextRenderable = core.TextRenderable; const styleApi = core.fg && core.bg && core.StyledText ? { fg: core.fg, bg: core.bg, StyledText: core.StyledText } : null; if (!createCliRenderer || !TextRenderable) { throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports"); } const themeResolution = resolveTuiTheme(config); const client = createBackendClientFromConfig(config); const renderer = await createCliRenderer({ exitOnCtrlC: false }); const text = new TextRenderable(renderer, { id: "openhandoff-switch", content: "Loading...", }); text.fg = themeResolution.theme.text; text.bg = themeResolution.theme.background; renderer.root.add(text); renderer.start(); let allRows: HandoffRecord[] = []; let filteredRows: HandoffRecord[] = []; let selected = 0; let searchQuery = ""; let showHelp = false; let status = "loading..."; let busy = false; let closed = false; let timer: ReturnType | null = null; const clampSelected = (): void => { if (filteredRows.length === 0) { selected = 0; return; } if (selected < 0) { selected = 0; return; } if (selected >= filteredRows.length) { selected = filteredRows.length - 1; } }; const render = (): void => { if (closed) { return; } const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, { width: renderer.width ?? process.stdout.columns, height: renderer.height ?? process.stdout.rows, }); text.content = styleApi ? buildStyledContent(output, themeResolution.theme, styleApi) : output; renderer.requestRender(); }; const refresh = async (): Promise => { if (closed) { return; } try { allRows = await client.listHandoffs(workspaceId); if (closed) { return; } filteredRows = filterHandoffs(allRows, searchQuery); clampSelected(); status = `handoffs=${allRows.length} filtered=${filteredRows.length}`; } catch (err) { if (closed) { return; } status = err instanceof Error ? err.message : String(err); } render(); }; const selectedRow = (): HandoffRecord | null => { if (filteredRows.length === 0) { return null; } return filteredRows[selected] ?? null; }; let resolveDone: () => void = () => {}; const done = new Promise((resolve) => { resolveDone = () => resolve(); }); const close = (output?: string): void => { if (closed) { return; } closed = true; if (timer) { clearInterval(timer); timer = null; } process.off("SIGINT", handleSignal); process.off("SIGTERM", handleSignal); renderer.destroy(); if (output) { console.log(output); } resolveDone(); }; const handleSignal = (): void => { close(); }; const runActionWithRefresh = async (label: string, fn: () => Promise, success: string): Promise => { if (busy) { return; } busy = true; status = `${label}...`; render(); try { await fn(); status = success; await refresh(); } catch (err) { status = err instanceof Error ? err.message : String(err); render(); } finally { busy = false; } }; await refresh(); timer = setInterval(() => { void refresh(); }, 10_000); process.once("SIGINT", handleSignal); process.once("SIGTERM", handleSignal); const keyInput = (renderer.keyInput ?? renderer.keyHandler) as { on: (name: string, cb: (event: KeyEventLike) => void) => void } | undefined; if (!keyInput) { clearInterval(timer); renderer.destroy(); throw new Error("OpenTUI key input handler is unavailable"); } keyInput.on("keypress", (event: KeyEventLike) => { if (closed) { return; } const name = event.name ?? ""; const ctrl = Boolean(event.ctrl); if (ctrl && name === "h") { showHelp = !showHelp; render(); return; } if (showHelp) { if (name === "escape") { showHelp = false; render(); } return; } if (name === "q" || name === "escape" || (ctrl && name === "c")) { close(); return; } if ((ctrl && name === "n") || name === "down") { if (filteredRows.length > 0) { selected = selected >= filteredRows.length - 1 ? 0 : selected + 1; render(); } return; } if ((ctrl && name === "p") || name === "up") { if (filteredRows.length > 0) { selected = selected <= 0 ? filteredRows.length - 1 : selected - 1; render(); } return; } if (name === "backspace") { searchQuery = searchQuery.slice(0, -1); filteredRows = filterHandoffs(allRows, searchQuery); selected = 0; render(); return; } if (name === "return" || name === "enter") { const row = selectedRow(); if (!row || busy) { return; } busy = true; status = `switching ${row.handoffId}...`; render(); void (async () => { try { const result = await client.switchHandoff(workspaceId, row.handoffId); close(`cd ${result.switchTarget}`); } catch (err) { busy = false; status = err instanceof Error ? err.message : String(err); render(); } })(); return; } if (ctrl && name === "a") { const row = selectedRow(); if (!row || busy) { return; } busy = true; status = `attaching ${row.handoffId}...`; render(); void (async () => { try { const result = await client.attachHandoff(workspaceId, row.handoffId); close(`target=${result.target} session=${result.sessionId ?? "none"}`); } catch (err) { busy = false; status = err instanceof Error ? err.message : String(err); render(); } })(); return; } if (ctrl && name === "x") { const row = selectedRow(); if (!row) { return; } void runActionWithRefresh(`archiving ${row.handoffId}`, async () => client.runAction(workspaceId, row.handoffId, "archive"), `archived ${row.handoffId}`); return; } if (ctrl && name === "s") { const row = selectedRow(); if (!row) { return; } void runActionWithRefresh(`syncing ${row.handoffId}`, async () => client.runAction(workspaceId, row.handoffId, "sync"), `synced ${row.handoffId}`); return; } if (ctrl && name === "y") { const row = selectedRow(); if (!row) { return; } void runActionWithRefresh( `merging ${row.handoffId}`, async () => { await client.runAction(workspaceId, row.handoffId, "merge"); await client.runAction(workspaceId, row.handoffId, "archive"); }, `merged+archived ${row.handoffId}`, ); return; } if (ctrl && name === "o") { const row = selectedRow(); if (!row?.prUrl) { status = "no PR URL available for this handoff"; render(); return; } const openCmd = process.platform === "darwin" ? "open" : "xdg-open"; spawnSync(openCmd, [row.prUrl], { stdio: "ignore" }); status = `opened ${row.prUrl}`; render(); return; } if (!ctrl && !event.meta && name.length === 1) { searchQuery += name; filteredRows = filterHandoffs(allRows, searchQuery); selected = 0; render(); } }); await done; }