import { Box, Text, useApp, useInput, useStdout } from 'ink'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { AuditCategoryEvent } from '../../services/audit'; import type { SystemAuditReport } from '../../services/audit/types'; import { type ModalState, emptyReport, findingId, findingsForCategory, selectedFinding, useAuditState, } from './audit-state'; import { copyToClipboard, formatCommandLogForClipboard, formatFindingForClipboard, } from './clipboard'; import { KeyBar } from './keybar'; import { handleKey } from './keymap'; import { AnalyzingModal } from './modals/analyzing'; import { CelebrationModal } from './modals/celebration'; import { ReauditPromptModal } from './modals/reaudit-prompt'; import { RemediateModal } from './modals/remediate'; import { LIST_PANE_CHROME, MOUSE_OFF, MOUSE_ON, paneAt, parseMouseEvent } from './mouse'; import { CategoriesPane } from './panes/categories'; import { CommandLogPane } from './panes/command-log'; import { DetailPane } from './panes/detail'; import { FINDINGS_CHROME, computeScrollWindow } from './panes/findings'; import { FindingsPane } from './panes/findings'; import { SummaryPane } from './panes/summary'; import { runCelilo } from './spawn'; import { type Theme, type ThemeName, getTheme } from './theme'; /** * Callback the audit runner uses to surface progress strings (e.g. * "Checking caddy") into the TUI without writing to stdout. */ export type AuditProgress = (message: string) => void; /** * Callback for per-category lifecycle events (start / end). Drives * the analyzing modal's fuel-gauges. Optional — the runner can omit * it for headless/text use. */ export type AuditCategoryProgress = (event: AuditCategoryEvent) => void; /** * The audit runner: takes a progress callback (free-form messages) * and an optional category callback (lifecycle events), and returns * the report. Called from inside the TUI so its progress flows into * pane 1 and the analyzing modal. */ export type AuditRunner = ( onProgress: AuditProgress, onCategory?: AuditCategoryProgress, ) => Promise; interface Props { /** A finished report (no progress) or a runner the TUI invokes itself. */ source: { kind: 'report'; report: SystemAuditReport } | { kind: 'runner'; run: AuditRunner }; /** Visual theme — picks dialog colors. Defaults to dark. */ theme?: ThemeName; } /** * Track the terminal's column / row count, updating on resize. * * Ink's `useStdout` exposes the stream but not a reactive size — we * subscribe to its `'resize'` event and re-render the layout to fill * the new dimensions. Falls back to 80×24 when stdout doesn't expose * dimensions (e.g. ink-testing-library's mock). */ function useTerminalSize(): { columns: number; rows: number } { const { stdout } = useStdout(); const [size, setSize] = useState({ columns: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, }); useEffect(() => { if (!stdout) return; const handler = () => { setSize({ columns: stdout.columns, rows: stdout.rows }); }; stdout.on('resize', handler); return () => { stdout.off('resize', handler); }; }, [stdout]); return size; } export function AuditTui({ source, theme: themeName }: Props) { const initialReport = source.kind === 'report' ? source.report : emptyReport(); // When source is a runner, an audit kicks off in useEffect on the // very next tick — start the reducer in `auditing: true` so the // first paint shows the spinner instead of the empty report's // "0 blocked, 0 drift" placeholder. const [state, dispatch] = useAuditState( initialReport, themeName ?? 'dark', source.kind === 'runner', ); const { exit } = useApp(); const { columns, rows } = useTerminalSize(); const theme: Theme = getTheme(state.theme); // Stash the runner in a ref so re-audit (`R`) can re-invoke it // without needing the source as a dep on every effect. const runnerRef = useRef(source.kind === 'runner' ? source.run : null); /** * Run the audit (mount or `R`-triggered) and dispatch progress / * final report into the reducer. Skips silently if no runner is * available (`source.kind === 'report'`). */ const runAudit = useCallback(() => { const run = runnerRef.current; if (!run) return; // Reset the per-category fuel-gauges and pop the analyzing // modal up front. The modal auto-dismisses (via the reducer) // when every category-end event has fired. dispatch({ type: 'reset-category-progress' }); dispatch({ type: 'open-analyzing' }); dispatch({ type: 'set-auditing', auditing: true }); const onCategory: AuditCategoryProgress = (event) => { if (event.phase === 'start') { dispatch({ type: 'category-start', category: event.category }); return; } const findings = event.findings ?? []; // Same ranking as `computeVerdict`: an unmeasured category is not clean // and is not the same statement as a measured difference (D7). const verdict: 'clean' | 'unmeasured' | 'drift' | 'blocked' = findings.some( (f) => f.severity === 'blocked', ) ? 'blocked' : findings.some((f) => f.severity === 'unmeasured') ? 'unmeasured' : findings.length > 0 ? 'drift' : 'clean'; dispatch({ type: 'category-end', category: event.category, verdict }); }; run((message) => dispatch({ type: 'progress', message }), onCategory) .then((report) => { dispatch({ type: 'set-report', report }); dispatch({ type: 'set-auditing', auditing: false }); // Inbox-zero — pop the celebration so the user gets a moment // of closure rather than just staring at empty panes. if (report.findings.length === 0) { dispatch({ type: 'open-celebration' }); } }) .catch((err) => { dispatch({ type: 'progress', message: `audit failed: ${err instanceof Error ? err.message : String(err)}`, }); dispatch({ type: 'set-auditing', auditing: false }); // Audit failed mid-flight — close the analyzing modal so // the user isn't stuck staring at half-filled fuel-gauges. dispatch({ type: 'close-modal' }); }); }, [dispatch]); // Kick off the initial audit on mount. // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect useEffect(() => { runAudit(); }, []); // Modal-aware input handling. When a modal is open, route keys // there; otherwise hand off to the regular keymap. useInput((input, key) => { // Swallow SGR mouse sequences — they reach useInput as // `key.escape: true` plus an `input` like `[<0;5;3M`. Mouse // handling lives in the stdin effect below. if (key.escape && /^\[<\d+;\d+;\d+[Mm]/.test(input)) return; if (state.helpOpen) { if (key.escape || input === '?' || input === 'q') { dispatch({ type: 'toggle-help' }); } return; } if (state.modal?.kind === 'remediate') { // ink-text-input handles printable keys + Enter; we just watch // for Esc to cancel here. Enter is wired through the modal's // onSubmit prop below. if (key.escape) dispatch({ type: 'close-modal' }); return; } if (state.modal?.kind === 'reaudit-prompt') { if (input === 'y' || input === 'Y' || key.return) { dispatch({ type: 'close-modal' }); runAudit(); return; } if (input === 'n' || input === 'N' || key.escape) { dispatch({ type: 'close-modal' }); return; } return; } if (state.modal?.kind === 'celebration') { if (input === 'R' || input === 'r' || key.return) { dispatch({ type: 'close-modal' }); runAudit(); return; } if (input === 'q' || (key.ctrl && input === 'c')) { exit(); return; } if (key.escape) { dispatch({ type: 'close-modal' }); return; } return; } if (state.modal?.kind === 'analyzing') { // Modal is non-interactive — only quit shortcuts work. The // modal auto-dismisses when the audit finishes. if (input === 'q' || (key.ctrl && input === 'c')) exit(); return; } const result = handleKey(input, key, state.focusedPane); for (const action of result.actions) dispatch(action); if (result.signal === 'quit') exit(); if (result.signal === 'reaudit') runAudit(); if (result.signal === 'remediate') openRemediate(); if (result.signal === 'copy') copyForFocusedPane(); if (result.signal === 'toggle-theme') toggleTheme(); }); /** * Flip the active theme and flash the new theme name so the user * sees feedback even when no modal is open (the only place where * theme colors are visible today). */ function toggleTheme() { const next = state.theme === 'dark' ? 'light' : 'dark'; dispatch({ type: 'toggle-theme' }); dispatch({ type: 'flash', message: `Theme: ${next}` }); setTimeout(() => dispatch({ type: 'flash', message: null }), 1500); } /** * Copy the focused pane's content to the system clipboard via * OSC 52. Detail pane → the selected finding (formatted for * humans). Log pane → the entire command log. Other panes → * silent no-op (nothing useful to paste). * * Flashes a transient confirmation in the keybar; cleared by a * 1.5s timer (hard-coded — the message is short enough to read * in less, and a configurable duration would be over-engineering). */ function copyForFocusedPane() { let payload: string | null = null; if (state.focusedPane === 'detail') { const finding = selectedFinding(state); if (finding) payload = formatFindingForClipboard(finding); } else if (state.focusedPane === 'log') { if (state.commandLog.length > 0) payload = formatCommandLogForClipboard(state.commandLog); } if (!payload) return; copyToClipboard(payload); dispatch({ type: 'flash', message: 'Copied to clipboard' }); setTimeout(() => dispatch({ type: 'flash', message: null }), 1500); } /** * Open the remediation modal for whichever finding is selected. * Allowed only when the user is on the Findings or Detail pane, * the selected finding has a remediation, AND that remediation is * actionable (a runnable celilo command, not descriptive guidance). * Non-actionable findings (capability ABI mismatches, schema * migrations, etc.) require code or out-of-band work; surfacing the * modal would be misleading. */ function openRemediate() { if (state.focusedPane !== 'findings' && state.focusedPane !== 'detail') return; const list = findingsForCategory(state.groups, state.selectedCategory); const finding = list.find((f) => findingId(f) === state.selectedFindingId); if (!finding || !finding.actionable) return; const command = finding.remediation ?? ''; if (!command) return; dispatch({ type: 'open-remediate', findingId: findingId(finding), command, }); } /** Submit the remediation modal: close it, spawn the command, stream output. */ function submitRemediate(command: string) { if (!command.trim()) return; const startedAt = Date.now(); dispatch({ type: 'close-modal' }); dispatch({ type: 'log-start', startedAt, cmd: command }); runCelilo(command, { onLine: (stream, text) => { dispatch({ type: 'log-line', startedAt, stream, text }); }, onExit: (exitCode) => { dispatch({ type: 'log-finish', startedAt, exitCode }); if (exitCode === 0) { dispatch({ type: 'open-reaudit-prompt' }); } }, }); } // Compute exact pixel dimensions for every pane up front so flexbox // never has a chance to redistribute space based on content. The // pane components ignore these props in favor of their borders, but // we pass them through wrapper Boxes that fence off the geometry. const layout = computeLayout(columns, rows, state.groups.length); // Mouse-click → pane focus + (for list panes) row selection. // Reads raw stdin bytes in parallel with Ink's keyboard handling // and parses SGR mouse-press events. Only left-button presses // fire; releases and modifier-button events are ignored. Skipped // while a modal is open — clicks shouldn't bleed through to focus // or selection changes. useEffect(() => { if (state.modal !== null) return; const handler = (data: Buffer) => { const event = parseMouseEvent(data.toString()); if (!event || !event.pressed || event.button !== 0) return; const hit = paneAt(event.col, event.row, { bodyHeight: layout.bodyHeight, leftColWidth: layout.leftColWidth, summaryHeight: layout.summaryHeight, categoriesHeight: layout.categoriesHeight, detailHeight: layout.detailHeight, }); if (!hit) return; dispatch({ type: 'focus', pane: hit.pane }); // Translate row-in-pane to a list index for the two scrollable // panes. Categories has no scroll (always shows all groups); // findings does — apply the same scroll-window math the pane // uses when rendering. if (hit.pane === 'categories') { const idx = hit.rowInPane - LIST_PANE_CHROME; if (idx >= 0 && idx < state.groups.length) { dispatch({ type: 'select-category', index: idx }); } return; } if (hit.pane === 'findings') { const findings = findingsForCategory(state.groups, state.selectedCategory); if (findings.length === 0) return; const overflowRow = findings.length > Math.max(layout.findingsHeight - 4, 0) ? 1 : 0; const visible = Math.max(layout.findingsHeight - 4 - overflowRow, 0); const selectedIndex = Math.max( findings.findIndex((f) => findingId(f) === state.selectedFindingId), 0, ); const { start } = computeScrollWindow(selectedIndex, findings.length, visible); const idx = start + (hit.rowInPane - FINDINGS_CHROME); if (idx >= 0 && idx < findings.length) { dispatch({ type: 'select-finding', index: idx }); } } }; process.stdin.on('data', handler); return () => { process.stdin.off('data', handler); }; }, [ layout, state.modal, state.groups, state.selectedCategory, state.selectedFindingId, dispatch, ]); if (state.helpOpen) { return ( ); } return ( {state.modal && ( {state.modal.kind === 'remediate' && ( dispatch({ type: 'set-modal-command', command: value })} onSubmit={(value) => submitRemediate(value)} /> )} {state.modal.kind === 'reaudit-prompt' && } {state.modal.kind === 'celebration' && } {state.modal.kind === 'analyzing' && ( )} )} ); } function modalKind( m: ModalState, ): 'remediate' | 'reaudit-prompt' | 'celebration' | 'analyzing' | null { if (!m) return null; return m.kind; } /** * Render `children` centered on top of an absolutely-positioned * full-screen box. Ink supports `position="absolute"` on Box, so the * modal lifts out of the document flow and overlays whatever's below. */ function ModalOverlay({ columns, rows, children, }: { columns: number; rows: number; children: React.ReactNode; }) { return ( {children} ); } interface Layout { bodyHeight: number; leftColWidth: number; rightColWidth: number; summaryHeight: number; categoriesHeight: number; findingsHeight: number; detailHeight: number; commandLogHeight: number; } /** * Compute fixed dimensions for every pane. * * The pane heights are derived from terminal dimensions and the * number of drift categories — the only inputs that should ever * change layout. As the user navigates between categories or * findings, these inputs are constant, so the geometry is stable. * * - Summary pane: 6 rows (constant — title + verdict + counts + * timestamp + borders). * - Categories pane: max(N + 4, 5) where N is category count * (border + title + spacer + N rows; minimum to render at least * the title cleanly when there are no findings). * - Findings pane: whatever's left after Summary + Categories. * - Detail / CommandLog: split right column 50/50. */ function computeLayout(columns: number, rows: number, categoryCount: number): Layout { const bodyHeight = Math.max(rows - 1, 0); const leftColWidth = Math.max(Math.floor(columns * 0.4), 30); const rightColWidth = Math.max(columns - leftColWidth, 0); const summaryHeight = 6; const categoriesHeight = Math.max(categoryCount + 4, 5); const findingsHeight = Math.max(bodyHeight - summaryHeight - categoriesHeight, 3); const detailHeight = Math.floor(bodyHeight / 2); const commandLogHeight = bodyHeight - detailHeight; return { bodyHeight, leftColWidth, rightColWidth, summaryHeight, categoriesHeight, findingsHeight, detailHeight, commandLogHeight, }; } function HelpOverlay() { const rows: [string, string][] = [ ['↑ ↓ / j k', 'Move selection'], ['Enter', 'Focus (Summary → Categories → Findings → Detail)'], ['Esc', 'Step back; quits from Summary/Categories'], ['Tab / Shift-Tab', 'Cycle focus across panes'], ['1 / 2 / 3 / 4 / 5', 'Jump to pane'], ['r', 'Remediate (open modal for selected finding)'], ['R', 'Re-run audit'], ['?', 'Toggle this overlay'], ['q / Ctrl-C', 'Quit'], ]; return ( Keymap {rows.map(([k, desc]) => ( {k} {desc} ))} press ? or Esc to close ); } /** * ANSI sequences for the alternate screen buffer (xterm-style). * * Entering the alt-screen gives the TUI a clean canvas separate from * the user's normal scrollback (matching vim, htop, lazygit). On * exit, the terminal restores the prior contents. * * ENTER CSI ? 1049 h switch to alt-screen * HOME CSI H move cursor to top-left * EXIT CSI ? 1049 l restore primary screen * SHOW CSI ? 25 h show cursor (in case Ink hid it) */ const ESC = '\x1b'; const ALT_SCREEN_ENTER = `${ESC}[?1049h${ESC}[H`; const ALT_SCREEN_EXIT = `${ESC}[?1049l${ESC}[?25h`; /** * Render the TUI synchronously and resolve when the user exits. * * Throws if stdin is not a TTY — Ink needs raw-mode access to read * keypresses, and a piped/redirected stdin can't grant it. The * thrown message points the user at `--json` for the non-interactive * path. */ export async function renderAuditTui( input: SystemAuditReport | AuditRunner, options: { theme?: ThemeName } = {}, ): Promise { if (!process.stdin.isTTY) { throw new Error( '`celilo system audit --tui` requires an interactive terminal (stdin is not a TTY).\n' + 'For non-interactive use, run `celilo system audit` (text) or `celilo system audit --json`.', ); } const source: Props['source'] = typeof input === 'function' ? { kind: 'runner', run: input } : { kind: 'report', report: input }; const { render } = await import('ink'); process.stdout.write(ALT_SCREEN_ENTER + MOUSE_ON); // Ensure we always restore the primary screen and disable mouse // reporting, even on a thrown render — otherwise the user's // terminal stays in mouse-capture mode after exit. const restore = () => process.stdout.write(MOUSE_OFF + ALT_SCREEN_EXIT); process.on('exit', restore); try { const instance = render(); await instance.waitUntilExit(); } finally { process.off('exit', restore); restore(); } }