// packages/cli/src/tui/App.tsx // Fulcrum Cockpit TUI — Ink v4 terminal dashboard // // Layout: // [header bar] // [task board pane] [agent runs pane] // [event stream pane] [policy/blocked pane] // // Navigation: tab to cycle panes, arrow keys within pane, q to quit, // u to unblock selected blocked run, k to kill selected run, n to create task. import React, { useState, useEffect, useCallback, useRef } from 'react' import { Box, Text, useInput, useApp, measureElement } from 'ink' // ── Types ────────────────────────────────────────────────────────────────────── interface Task { task_id: string display_id: string title: string status: string status_category: string priority: string assigned_to: string | null } interface AgentRun { run_id: string role: string status: string task_id: string | null started_at: string heartbeat_at: string | null blocker: string | null } interface EventLine { id: string ts: string role: string verb: string detail: string isBlocked?: boolean isPolicy?: boolean } interface PmOverview { delivery_health?: { active_work?: number blocked_work?: number blocked_pct?: number open_reviews?: number } issues?: { blocked?: number in_review?: number } plans?: { active?: number } focus?: { blocked_issues?: Array<{ display_id?: string; title?: string }> active_plans?: Array<{ display_id?: string; title?: string }> pending_reviews?: Array<{ review_id?: string; target_type?: string; target_id?: string }> } } type Pane = 'board' | 'agents' | 'events' | 'policy' | 'pm' const PANE_ORDER: Pane[] = ['board', 'agents', 'events', 'policy', 'pm'] // ── Helpers ──────────────────────────────────────────────────────────────────── function fmtTs(iso: string): string { const d = new Date(iso) const hh = String(d.getHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0') const ss = String(d.getSeconds()).padStart(2, '0') return `${hh}:${mm}:${ss}` } function fmtAge(startedAt: string): string { const sec = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000) if (sec < 60) return `${sec}s` if (sec < 3600) return `${Math.floor(sec / 60)}m` return `${Math.floor(sec / 3600)}h` } function statusColor(status: string): string { switch (status) { case 'running': case 'active': case 'in_progress': return 'green' case 'blocked': return 'red' case 'waiting': case 'starting': return 'yellow' case 'done': case 'completed': case 'finished': return 'gray' case 'failed': case 'aborted': return 'redBright' default: return 'white' } } function formatEventFromPayload(evtType: string, payload: Record): Omit { const role = (payload['role'] as string | undefined) ?? (payload['agent_role'] as string | undefined) ?? 'system' let verb = evtType.replace(/_/g, ' ') let detail = '' let isBlocked = false let isPolicy = false switch (evtType) { case 'agent_run_started': verb = 'started'; detail = String(payload['run_id'] ?? ''); break case 'agent_run_blocked': verb = 'BLOCKED'; detail = String(payload['reason'] ?? payload['run_id'] ?? ''); isBlocked = true; break case 'agent_run_finished': verb = 'finished'; detail = String(payload['run_id'] ?? ''); break case 'agent_run_failed': verb = 'failed'; detail = String(payload['error'] ?? payload['run_id'] ?? ''); break case 'agent_run_progress': verb = 'heartbeat'; detail = String(payload['status'] ?? ''); break case 'task_created': verb = 'created task'; detail = String(payload['title'] ?? payload['task_id'] ?? ''); break case 'task_status_changed': verb = 'task status'; detail = `${payload['from'] ?? '?'} → ${payload['status'] ?? '?'}`; break case 'policy_denied': verb = 'POLICY DENIED'; detail = `${payload['tool_name'] ?? ''}: ${payload['reason'] ?? ''}`; isPolicy = true; break case 'memory_written': verb = 'wrote memory'; detail = String(payload['content'] ?? '').slice(0, 40); break case 'merge_completed': verb = 'merged'; detail = String(payload['task_id'] ?? ''); break } return { role, verb, detail, isBlocked, isPolicy } } // ── Data fetching ────────────────────────────────────────────────────────────── const DEFAULT_PORT = '4721' async function fetchJson(path: string): Promise { const port = process.env['FULCRUM_MONITOR_PORT'] ?? DEFAULT_PORT try { const res = await fetch(`http://127.0.0.1:${port}${path}`) if (!res.ok) return null return await res.json() as T } catch { return null } } async function postJson(path: string, body: unknown): Promise { const port = process.env['FULCRUM_MONITOR_PORT'] ?? DEFAULT_PORT const token = process.env['FULCRUM_MONITOR_TOKEN'] try { const res = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(body), }) return res.ok } catch { return false } } // ── Components ───────────────────────────────────────────────────────────────── function Header({ wsId, activePane, runCount, lastEventTs }: { wsId: string | null activePane: Pane runCount: number lastEventTs: string | null }) { return ( ⬡ Fulcrum {wsId && ws:{wsId}} | {runCount} active {lastEventTs && | last:{lastEventTs}} | pane: {activePane} [tab=switch q=quit u=unblock k=kill n=new task] ) } function TaskBoard({ tasks, selected, focused }: { tasks: Task[] selected: number focused: boolean }) { const byCategory = { backlog: tasks.filter(t => t.status_category === 'backlog'), active: tasks.filter(t => t.status_category === 'active'), blocked: tasks.filter(t => t.status_category === 'blocked'), done: tasks.filter(t => t.status_category === 'done'), } return ( Task Board {(['backlog', 'active', 'blocked', 'done'] as const).map(cat => ( {cat.toUpperCase()} ({byCategory[cat].length}) {byCategory[cat].slice(0, 8).map((t, i) => ( {t.display_id || t.task_id.slice(0, 6)} {t.title.slice(0, 18)} ))} ))} ) } function AgentRuns({ runs, selected, focused }: { runs: AgentRun[] selected: number focused: boolean }) { const visible = runs.slice(0, 15) return ( Agent Runs (last {runs.length}) {visible.length === 0 && No active runs} {visible.map((run, i) => ( {run.role.slice(0, 16).padEnd(16)} {run.task_id?.slice(0, 8) ?? 'none'.padEnd(8)} {fmtAge(run.started_at)} ))} ) } function EventStream({ events, focused }: { events: EventLine[] focused: boolean }) { return ( Event Stream {events.slice(0, 20).map(evt => ( [{evt.ts}] {evt.role.slice(0, 12).padEnd(12)} {evt.verb} {evt.detail.slice(0, 40)} ))} ) } function PolicyPane({ blocked, selected, focused }: { blocked: AgentRun[] selected: number focused: boolean }) { return ( Blocked Runs ({blocked.length}) {blocked.length === 0 && No blocked runs} {blocked.map((run, i) => ( {run.role.slice(0, 16)} {run.blocker && {run.blocker.slice(0, 40)}} ))} ) } function PmPane({ overview, focused }: { overview: PmOverview | null focused: boolean }) { const health = overview?.delivery_health ?? {} const issues = overview?.issues ?? {} const plans = overview?.plans ?? {} const focus = overview?.focus ?? {} const blockedIssues = focus.blocked_issues ?? [] const activePlans = focus.active_plans ?? [] return ( PM Overview Active work: {health.active_work ?? 0} | Blocked: {health.blocked_pct ?? 0}% Open reviews: {health.open_reviews ?? 0} | Active plans: {plans.active ?? 0} Blocked issues: {issues.blocked ?? 0} | In review: {issues.in_review ?? 0} {blockedIssues.slice(0, 2).map(issue => ( {issue.display_id ?? 'issue'} {issue.title ?? ''} ))} {activePlans.slice(0, 2).map(plan => ( {plan.display_id ?? 'plan'} {plan.title ?? ''} ))} ) } // ── Main App ─────────────────────────────────────────────────────────────────── export default function App() { const { exit } = useApp() // Data state const [tasks, setTasks] = useState([]) const [runs, setRuns] = useState([]) const [events, setEvents] = useState([]) const [pmOverview, setPmOverview] = useState(null) const [wsId, setWsId] = useState(null) const [lastEventTs, setLastEventTs] = useState(null) const [monitorDown, setMonitorDown] = useState(false) // Navigation state const [activePane, setActivePane] = useState('board') const [selected, setSelected] = useState(0) const [newTaskMode, setNewTaskMode] = useState(false) const [newTaskTitle, setNewTaskTitle] = useState('') const [statusMsg, setStatusMsg] = useState(null) const eventCountRef = useRef(0) // Derived const blocked = runs.filter(r => r.status === 'blocked') const activeRuns = runs.filter(r => ['running', 'waiting', 'starting'].includes(r.status)) const paneItems = activePane === 'board' ? tasks : activePane === 'agents' ? runs : activePane === 'policy' ? blocked : activePane === 'pm' ? (pmOverview?.focus?.blocked_issues ?? []) : events const paneLen = paneItems.length // ── Data loading ────────────────────────────────────────────────────────── async function loadData() { const [statusData, tasksData, agentsData, pmData] = await Promise.all([ fetchJson<{ workspace_id: string | null }>('/status'), fetchJson<{ data: Task[] }>('/tasks'), fetchJson<{ data: AgentRun[] }>('/agents'), fetchJson<{ data: PmOverview }>('/pm/overview'), ]) if (!statusData) { setMonitorDown(true) // Fall back to direct DB reads (read-only, no keyboard actions) try { const { getDb, runMigrations } = await import('fulcrum-agent-core') const db = getDb() runMigrations(db) const rows = db.prepare('SELECT * FROM tasks ORDER BY created_at DESC LIMIT 50').all() as Task[] setTasks(rows) } catch { /* no DB either */ } return } setMonitorDown(false) if (statusData.workspace_id) setWsId(statusData.workspace_id) if (tasksData?.data) setTasks(tasksData.data) if (agentsData?.data) setRuns(agentsData.data) if (pmData?.data) setPmOverview(pmData.data) } // ── SSE streaming ────────────────────────────────────────────────────────── useEffect(() => { loadData() const port = process.env['FULCRUM_MONITOR_PORT'] ?? DEFAULT_PORT const url = `http://127.0.0.1:${port}/events/stream` let aborted = false const controller = new AbortController() async function connect() { try { const res = await fetch(url, { signal: controller.signal, headers: { Accept: 'text/event-stream' } }) if (!res.ok || !res.body) { setMonitorDown(true); return } const reader = res.body.getReader() const decoder = new TextDecoder() let buf = '' while (!aborted) { const { value, done } = await reader.read() if (done) break buf += decoder.decode(value, { stream: true }) const lines = buf.split('\n') buf = lines.pop() ?? '' let dataLine = '' for (const line of lines) { if (line.startsWith('data:')) { dataLine = line.slice(5).trim() } else if (line === '' && dataLine) { try { const evt = JSON.parse(dataLine) as { evt_type: string; payload: Record; created_at: string; event_id?: string } const { role, verb, detail, isBlocked, isPolicy } = formatEventFromPayload(evt.evt_type, evt.payload ?? {}) const ts = fmtTs(evt.created_at || new Date().toISOString()) const id = evt.event_id ?? String(++eventCountRef.current) setEvents(prev => [{ id, ts, role, verb, detail, isBlocked, isPolicy }, ...prev].slice(0, 200)) setLastEventTs(ts) // Refresh panels on relevant events if (evt.evt_type.startsWith('agent_run') || evt.evt_type.startsWith('task')) { loadData() } } catch { /* skip malformed */ } dataLine = '' } } } } catch (err) { if (!aborted) { setMonitorDown(true) setTimeout(connect, 3000) } } } void connect() const interval = setInterval(() => { void loadData() }, 10000) return () => { aborted = true controller.abort() clearInterval(interval) } }, []) // ── Keyboard input ───────────────────────────────────────────────────────── useInput(async (input, key) => { if (newTaskMode) { if (key.escape) { setNewTaskMode(false) setNewTaskTitle('') return } if (key.return) { if (newTaskTitle.trim()) { const ok = await postJson('/tasks', { title: newTaskTitle.trim(), workspace_id: wsId ?? undefined }) setStatusMsg(ok ? `Task created: ${newTaskTitle}` : 'Failed to create task') setTimeout(() => setStatusMsg(null), 3000) if (ok) await loadData() } setNewTaskMode(false) setNewTaskTitle('') return } if (key.backspace || key.delete) { setNewTaskTitle(prev => prev.slice(0, -1)) return } if (!key.ctrl && !key.meta && input) { setNewTaskTitle(prev => prev + input) } return } if (input === 'q' || input === 'Q') { exit() return } if (key.tab) { const idx = PANE_ORDER.indexOf(activePane) setActivePane(PANE_ORDER[(idx + 1) % PANE_ORDER.length]!) setSelected(0) return } if (key.upArrow) { setSelected(prev => Math.max(0, prev - 1)) return } if (key.downArrow) { setSelected(prev => Math.min(Math.max(0, paneLen - 1), prev + 1)) return } // Actions if (input === 'n' || input === 'N') { if (!monitorDown) setNewTaskMode(true) return } if (input === 'u' || input === 'U') { // Unblock selected blocked run const run = blocked[selected] if (run) { const ok = await postJson(`/runs/${run.run_id}/unblock`, {}) setStatusMsg(ok ? `Unblocked ${run.run_id.slice(0, 8)}` : 'Unblock failed (check auth token?)') setTimeout(() => setStatusMsg(null), 3000) if (ok) await loadData() } return } if (input === 'k' || input === 'K') { // Kill selected run const run = activePane === 'agents' ? runs[selected] : activePane === 'policy' ? blocked[selected] : null if (run) { const ok = await postJson(`/runs/${run.run_id}/kill`, { reason: 'Operator kill via TUI' }) setStatusMsg(ok ? `Killed ${run.run_id.slice(0, 8)}` : 'Kill failed (check auth token?)') setTimeout(() => setStatusMsg(null), 3000) if (ok) await loadData() } return } }) // ── Render ───────────────────────────────────────────────────────────────── return (
{monitorDown && ( Monitor not running — read-only DB mode. Start `fulcrum serve monitor` for live updates and actions. )} {newTaskMode && ( New task title: {newTaskTitle} [Enter=create Esc=cancel] )} {statusMsg && ( {statusMsg} )} ) }