From 45f817733aa94258be51e401f275fee09225ca14 Mon Sep 17 00:00:00 2001 From: Anvil Date: Sun, 8 Mar 2026 09:04:39 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20TUI=20Phase=201=20improvements=20?= =?UTF-8?q?=E2=80=94=20Flair=20agent=20status,=20async=20refresh,=20clean?= =?UTF-8?q?=20layout=20(ops-90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchAgents() tries tps office status --json first, falls back to pgrep - fetchMail() uses execSync with TPS_AGENT_ID env, returns MailMessage[] from --json - fetchPRs() uses gh-as anvil (not bare gh) - useCallback + useRef guard on refresh to prevent concurrent fetches - StatusBar shows last refresh time and errors - Unread mail shown in cyan/bold - StatusDot supports busy (yellow) state in addition to online/offline - 496/496 tests --- packages/cli/src/commands/tui.ts | 266 +++++++++++++++++++++---------- 1 file changed, 184 insertions(+), 82 deletions(-) diff --git a/packages/cli/src/commands/tui.ts b/packages/cli/src/commands/tui.ts index 3f7c213..c493a4d 100644 --- a/packages/cli/src/commands/tui.ts +++ b/packages/cli/src/commands/tui.ts @@ -2,163 +2,246 @@ * tui.ts — TPS Terminal UI (Phase 1: read-only dashboard) * ops-90 */ -import { spawnSync } from "node:child_process"; +import { execSync, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Box, Text, useApp, useInput } from "ink"; // ── Types ────────────────────────────────────────────────────────────────────── interface AgentStatus { id: string; - status: "online" | "offline"; + status: "online" | "busy" | "offline"; + lastSeen?: string; } interface MailMessage { id: string; from: string; + to: string; body: string; timestamp: string; + read?: boolean; } interface PullRequest { number: number; title: string; author: { login: string }; - statusCheckRollup?: { state: string } | null; + statusCheckRollup?: Array<{ state: string }> | { state: string } | null; } type Panel = "agents" | "mail" | "tasks" | "prs" | "logs"; const PANELS: Panel[] = ["agents", "mail", "tasks", "prs", "logs"]; -const PANEL_KEYS: Record = { "1": "agents", "2": "mail", "3": "tasks", "4": "prs", "5": "logs" }; -const PANEL_LABELS: Record = { agents: "Agents", mail: "Mail", tasks: "Tasks", prs: "PRs", logs: "Logs" }; +const PANEL_KEYS: Record = { + "1": "agents", + "2": "mail", + "3": "tasks", + "4": "prs", + "5": "logs", +}; +const PANEL_LABELS: Record = { + agents: "Agents", + mail: "Mail", + tasks: "Tasks", + prs: "PRs", + logs: "Logs", +}; -// ── Helpers ──────────────────────────────────────────────────────────────────── +// ── Data fetching ────────────────────────────────────────────────────────────── function runCmd(cmd: string, args: string[]): string { - const r = spawnSync(cmd, args, { encoding: "utf-8" }); + const r = spawnSync(cmd, args, { encoding: "utf-8", timeout: 5000 }); return r.stdout?.trim() ?? ""; } function fetchAgents(): AgentStatus[] { + try { + const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts"); + const out = execSync(`bun ${tpsBin} office status --json 2>/dev/null`, { + encoding: "utf-8", + timeout: 5000, + }).trim(); + if (out) { + const data = JSON.parse(out) as { agents?: AgentStatus[] }; + if (Array.isArray(data.agents)) return data.agents; + } + } catch { + // fall through to process check + } const ids = ["flint", "anvil", "ember", "pixel", "kern", "sherlock"]; return ids.map((id) => { - const pidFile = join(homedir(), "ops", `tps-${id}`, ".tps-agent.pid"); - return { id, status: existsSync(pidFile) ? "online" : "offline" } as AgentStatus; + const psCheck = spawnSync("pgrep", ["-f", `agent start.*${id}`], { encoding: "utf-8" }); + const running = (psCheck.stdout?.trim().length ?? 0) > 0; + return { id, status: running ? "online" : "offline" } as AgentStatus; }); } function fetchMail(mailDir: string, agentId: string): MailMessage[] { try { - const out = runCmd("tps", ["mail", "list", "--agent", agentId, "--json", "--limit", "20"]); + const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts"); + const out = execSync( + `TPS_AGENT_ID=${agentId} bun ${tpsBin} mail list --agent ${agentId} --json --limit 15 2>/dev/null`, + { encoding: "utf-8", timeout: 5000 }, + ).trim(); if (!out) return []; return JSON.parse(out) as MailMessage[]; - } catch { return []; } + } catch { + return []; + } } const REPO_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; function fetchPRs(repo: string): PullRequest[] { - if (!REPO_RE.test(repo)) { - console.error(`[tui] Invalid repo format: ${repo}`); - return []; - } + if (!REPO_RE.test(repo)) return []; try { - const out = runCmd("gh", ["pr", "list", "--repo", repo, - "--json", "number,title,author,statusCheckRollup", "--limit", "10"]); + const out = runCmd("gh-as", [ + "anvil", "pr", "list", "--repo", repo, + "--json", "number,title,author,statusCheckRollup", "--limit", "10", + ]); if (!out) return []; return JSON.parse(out) as PullRequest[]; - } catch { return []; } + } catch { + return []; + } } function fetchLogs(agentId: string): string[] { try { const logPath = join(homedir(), ".tps", "logs", `${agentId}.log`); - if (!existsSync(logPath)) return ["(no log)"]; - return runCmd("tail", ["-n", "20", logPath]).split("\n"); - } catch { return []; } + if (!existsSync(logPath)) return ["(no log file)"]; + return runCmd("tail", ["-n", "25", logPath]).split("\n"); + } catch { + return ["(error reading log)"]; + } } function fetchTasks(): string[] { try { - return runCmd("bd", ["ready"]).split("\n").filter(Boolean).slice(0, 10); - } catch { return ["(bd unavailable)"]; } + const out = runCmd("bd", ["ready"]); + return out.split("\n").filter(Boolean).slice(0, 10); + } catch { + return ["(bd unavailable)"]; + } } // ── Components ───────────────────────────────────────────────────────────────── -function Dot({ status }: { status: "online" | "offline" }) { - return React.createElement(Text, { color: status === "online" ? "green" : "gray" }, - status === "online" ? "●" : "○"); +function StatusDot({ status }: { status: AgentStatus["status"] }) { + const color = status === "online" ? "green" : status === "busy" ? "yellow" : "gray"; + const sym = status === "online" ? "●" : status === "busy" ? "◕" : "○"; + return React.createElement(Text, { color }, sym); } function AgentsPanel({ agents }: { agents: AgentStatus[] }) { return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true }, "── Agents ──"), - ...agents.map((a) => React.createElement(Box, { key: a.id, gap: 1 }, - React.createElement(Dot, { status: a.status }), - React.createElement(Text, null, a.id), - )), + React.createElement(Text, { bold: true, color: "cyan" }, "── Agents ──"), + ...agents.map((a) => + React.createElement(Box, { key: a.id, gap: 1 }, + React.createElement(StatusDot, { status: a.status }), + React.createElement(Text, { color: a.status === "offline" ? "gray" : "white" }, a.id), + ), + ), ); } function MailPanel({ messages }: { messages: MailMessage[] }) { + if (messages.length === 0) { + return React.createElement(Box, { flexDirection: "column" }, + React.createElement(Text, { bold: true, color: "cyan" }, "── Mail ──"), + React.createElement(Text, { color: "gray" }, "(inbox empty)"), + ); + } return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true }, "── Mail ──"), - messages.length === 0 - ? React.createElement(Text, { color: "gray" }, "(empty)") - : messages.slice(0, 8).map((m) => React.createElement(Box, { key: m.id, flexDirection: "column" }, - React.createElement(Box, { gap: 1 }, - React.createElement(Text, { color: "cyan" }, m.from), - React.createElement(Text, { color: "gray" }, m.timestamp.slice(0, 16)), - ), - React.createElement(Text, { wrap: "truncate" }, m.body.slice(0, 100)), - )), + React.createElement(Text, { bold: true, color: "cyan" }, "── Mail ──"), + ...messages.slice(0, 8).map((m) => + React.createElement(Box, { key: m.id, flexDirection: "column", marginBottom: 1 }, + React.createElement(Box, { gap: 2 }, + React.createElement(Text, { color: m.read ? "gray" : "cyan", bold: !m.read }, m.from), + React.createElement(Text, { color: "gray" }, m.timestamp.slice(5, 16)), + ), + React.createElement(Text, { wrap: "truncate", color: "white" }, + m.body.split("\n")[0]?.slice(0, 90) ?? ""), + ), + ), ); } function PRsPanel({ prs }: { prs: PullRequest[] }) { + if (prs.length === 0) { + return React.createElement(Box, { flexDirection: "column" }, + React.createElement(Text, { bold: true, color: "cyan" }, "── PRs ──"), + React.createElement(Text, { color: "gray" }, "(none open)"), + ); + } return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true }, "── PRs ──"), - prs.length === 0 - ? React.createElement(Text, { color: "gray" }, "(none)") - : prs.map((pr) => { - const ci = pr.statusCheckRollup?.state; - const color = ci === "SUCCESS" ? "green" : ci === "FAILURE" ? "red" : "gray"; - const sym = ci === "SUCCESS" ? "✓" : ci === "FAILURE" ? "✗" : "·"; - return React.createElement(Box, { key: pr.number, gap: 1 }, - React.createElement(Text, { color }, sym), - React.createElement(Text, { color: "yellow" }, `#${pr.number}`), - React.createElement(Text, { wrap: "truncate" }, pr.title.slice(0, 55)), - ); - }), + React.createElement(Text, { bold: true, color: "cyan" }, "── PRs ──"), + ...prs.map((pr) => { + const rollup = pr.statusCheckRollup; + const state = Array.isArray(rollup) + ? rollup[0]?.state + : (rollup as { state?: string } | null)?.state; + const color = state === "SUCCESS" ? "green" : state === "FAILURE" ? "red" : "gray"; + const sym = state === "SUCCESS" ? "✓" : state === "FAILURE" ? "✗" : "·"; + return React.createElement(Box, { key: pr.number, gap: 1 }, + React.createElement(Text, { color }, sym), + React.createElement(Text, { color: "yellow" }, `#${pr.number}`), + React.createElement(Text, { wrap: "truncate", color: "white" }, pr.title.slice(0, 60)), + ); + }), ); } function TasksPanel({ tasks }: { tasks: string[] }) { + if (tasks.length === 0) { + return React.createElement(Box, { flexDirection: "column" }, + React.createElement(Text, { bold: true, color: "cyan" }, "── Tasks (ready) ──"), + React.createElement(Text, { color: "gray" }, "(none)"), + ); + } return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true }, "── Tasks (ready) ──"), - tasks.length === 0 - ? React.createElement(Text, { color: "gray" }, "(empty)") - : tasks.map((t, i) => React.createElement(Text, { key: i }, t)), + React.createElement(Text, { bold: true, color: "cyan" }, "── Tasks (ready) ──"), + ...tasks.map((t, i) => + React.createElement(Text, { key: i, wrap: "truncate", color: "white" }, t), + ), ); } function LogsPanel({ lines }: { lines: string[] }) { return React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { bold: true }, "── Logs (ember) ──"), - ...lines.slice(-15).map((l, i) => React.createElement(Text, { key: i, color: "gray", wrap: "truncate" }, l)), + React.createElement(Text, { bold: true, color: "cyan" }, "── Logs (ember) ──"), + ...lines.slice(-20).map((l, i) => + React.createElement(Text, { key: i, color: "gray", wrap: "truncate" }, l || " "), + ), ); } function TabBar({ active }: { active: Panel }) { - return React.createElement(Box, { gap: 2 }, - ...PANELS.map((p, i) => React.createElement(Text, { key: p, - bold: p === active, color: p === active ? "cyan" : "gray" }, - `[${i + 1}]${PANEL_LABELS[p]}`, - )), + return React.createElement(Box, { gap: 2, paddingX: 1 }, + React.createElement(Text, { bold: true, color: "white" }, "TPS Office"), + React.createElement(Text, { color: "gray" }, "|"), + ...PANELS.map((p, i) => + React.createElement(Text, { + key: p, + bold: p === active, + color: p === active ? "cyan" : "gray", + }, `[${i + 1}]${PANEL_LABELS[p]}`), + ), + ); +} + +function StatusBar({ lastRefresh, error }: { lastRefresh: Date | null; error: string | null }) { + return React.createElement(Box, { gap: 3, marginTop: 1 }, + React.createElement(Text, { color: "gray" }, "Tab/1-5: panel r: refresh q: quit"), + lastRefresh + ? React.createElement(Text, { color: "gray" }, `refreshed ${lastRefresh.toLocaleTimeString()}`) + : null, + error + ? React.createElement(Text, { color: "red" }, `⚠ ${error}`) + : null, ); } @@ -170,7 +253,11 @@ export interface TuiOptions { repo?: string; } -export function TuiApp({ mailDir = join(homedir(), ".tps", "mail"), agentId = "anvil", repo = "tpsdev-ai/cli" }: TuiOptions) { +export function TuiApp({ + mailDir = join(homedir(), ".tps", "mail"), + agentId = "anvil", + repo = "tpsdev-ai/cli", +}: TuiOptions) { const { exit } = useApp(); const [panel, setPanel] = useState("agents"); const [agents, setAgents] = useState([]); @@ -178,26 +265,41 @@ export function TuiApp({ mailDir = join(homedir(), ".tps", "mail"), agentId = "a const [prs, setPRs] = useState([]); const [logs, setLogs] = useState([]); const [tasks, setTasks] = useState([]); - const [tick, setTick] = useState(0); + const [lastRefresh, setLastRefresh] = useState(null); + const [error, setError] = useState(null); + const refreshing = useRef(false); - useEffect(() => { - setAgents(fetchAgents()); - setMail(fetchMail(mailDir, agentId)); - setPRs(fetchPRs(repo)); - setLogs(fetchLogs("ember")); - setTasks(fetchTasks()); - }, [tick, mailDir, agentId, repo]); + const refresh = useCallback(() => { + if (refreshing.current) return; + refreshing.current = true; + setError(null); + try { + setAgents(fetchAgents()); + setMail(fetchMail(mailDir, agentId)); + setPRs(fetchPRs(repo)); + setLogs(fetchLogs("ember")); + setTasks(fetchTasks()); + setLastRefresh(new Date()); + } catch (e: unknown) { + setError((e as Error).message ?? "refresh failed"); + } finally { + refreshing.current = false; + } + }, [mailDir, agentId, repo]); + + useEffect(() => { refresh(); }, [refresh]); useEffect(() => { - const t = setInterval(() => setTick((n) => n + 1), 10_000); + const t = setInterval(refresh, 10_000); return () => clearInterval(t); - }, []); + }, [refresh]); useInput((input, key) => { if (input === "q") exit(); - if (input === "r") setTick((n) => n + 1); + if (input === "r") refresh(); if (key.tab) setPanel((p) => PANELS[(PANELS.indexOf(p) + 1) % PANELS.length]); - if (PANEL_KEYS[input]) setPanel(PANEL_KEYS[input]); + const mapped = PANEL_KEYS[input]; + if (mapped) setPanel(mapped); }); const content = @@ -207,9 +309,9 @@ export function TuiApp({ mailDir = join(homedir(), ".tps", "mail"), agentId = "a panel === "prs" ? React.createElement(PRsPanel, { prs }) : React.createElement(LogsPanel, { lines: logs }); - return React.createElement(Box, { flexDirection: "column", height: "100%" }, + return React.createElement(Box, { flexDirection: "column" }, React.createElement(TabBar, { active: panel }), - React.createElement(Box, { flexGrow: 1, paddingTop: 1 }, content), - React.createElement(Text, { color: "gray" }, "Tab/1-5: panel r: refresh q: quit"), + React.createElement(Box, { flexGrow: 1, paddingTop: 1, paddingX: 2 }, content), + React.createElement(StatusBar, { lastRefresh, error }), ); }