Skip to content

Commit bb59974

Browse files
authored
feat: TUI Phase 1 improvements — Flair agent status, async refresh, clean layout (ops-90) (#191)
- 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
1 parent 58d570b commit bb59974

1 file changed

Lines changed: 184 additions & 82 deletions

File tree

  • packages/cli/src/commands

packages/cli/src/commands/tui.ts

Lines changed: 184 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,163 +2,246 @@
22
* tui.ts — TPS Terminal UI (Phase 1: read-only dashboard)
33
* ops-90
44
*/
5-
import { spawnSync } from "node:child_process";
5+
import { execSync, spawnSync } from "node:child_process";
66
import { existsSync } from "node:fs";
77
import { homedir } from "node:os";
88
import { join } from "node:path";
9-
import React, { useEffect, useState } from "react";
9+
import React, { useCallback, useEffect, useRef, useState } from "react";
1010
import { Box, Text, useApp, useInput } from "ink";
1111

1212
// ── Types ──────────────────────────────────────────────────────────────────────
1313

1414
interface AgentStatus {
1515
id: string;
16-
status: "online" | "offline";
16+
status: "online" | "busy" | "offline";
17+
lastSeen?: string;
1718
}
1819

1920
interface MailMessage {
2021
id: string;
2122
from: string;
23+
to: string;
2224
body: string;
2325
timestamp: string;
26+
read?: boolean;
2427
}
2528

2629
interface PullRequest {
2730
number: number;
2831
title: string;
2932
author: { login: string };
30-
statusCheckRollup?: { state: string } | null;
33+
statusCheckRollup?: Array<{ state: string }> | { state: string } | null;
3134
}
3235

3336
type Panel = "agents" | "mail" | "tasks" | "prs" | "logs";
3437
const PANELS: Panel[] = ["agents", "mail", "tasks", "prs", "logs"];
35-
const PANEL_KEYS: Record<string, Panel> = { "1": "agents", "2": "mail", "3": "tasks", "4": "prs", "5": "logs" };
36-
const PANEL_LABELS: Record<Panel, string> = { agents: "Agents", mail: "Mail", tasks: "Tasks", prs: "PRs", logs: "Logs" };
38+
const PANEL_KEYS: Record<string, Panel> = {
39+
"1": "agents",
40+
"2": "mail",
41+
"3": "tasks",
42+
"4": "prs",
43+
"5": "logs",
44+
};
45+
const PANEL_LABELS: Record<Panel, string> = {
46+
agents: "Agents",
47+
mail: "Mail",
48+
tasks: "Tasks",
49+
prs: "PRs",
50+
logs: "Logs",
51+
};
3752

38-
// ── Helpers ────────────────────────────────────────────────────────────────────
53+
// ── Data fetching ──────────────────────────────────────────────────────────────
3954

4055
function runCmd(cmd: string, args: string[]): string {
41-
const r = spawnSync(cmd, args, { encoding: "utf-8" });
56+
const r = spawnSync(cmd, args, { encoding: "utf-8", timeout: 5000 });
4257
return r.stdout?.trim() ?? "";
4358
}
4459

4560
function fetchAgents(): AgentStatus[] {
61+
try {
62+
const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts");
63+
const out = execSync(`bun ${tpsBin} office status --json 2>/dev/null`, {
64+
encoding: "utf-8",
65+
timeout: 5000,
66+
}).trim();
67+
if (out) {
68+
const data = JSON.parse(out) as { agents?: AgentStatus[] };
69+
if (Array.isArray(data.agents)) return data.agents;
70+
}
71+
} catch {
72+
// fall through to process check
73+
}
4674
const ids = ["flint", "anvil", "ember", "pixel", "kern", "sherlock"];
4775
return ids.map((id) => {
48-
const pidFile = join(homedir(), "ops", `tps-${id}`, ".tps-agent.pid");
49-
return { id, status: existsSync(pidFile) ? "online" : "offline" } as AgentStatus;
76+
const psCheck = spawnSync("pgrep", ["-f", `agent start.*${id}`], { encoding: "utf-8" });
77+
const running = (psCheck.stdout?.trim().length ?? 0) > 0;
78+
return { id, status: running ? "online" : "offline" } as AgentStatus;
5079
});
5180
}
5281

5382
function fetchMail(mailDir: string, agentId: string): MailMessage[] {
5483
try {
55-
const out = runCmd("tps", ["mail", "list", "--agent", agentId, "--json", "--limit", "20"]);
84+
const tpsBin = join(homedir(), "ops", "tps", "packages", "cli", "bin", "tps.ts");
85+
const out = execSync(
86+
`TPS_AGENT_ID=${agentId} bun ${tpsBin} mail list --agent ${agentId} --json --limit 15 2>/dev/null`,
87+
{ encoding: "utf-8", timeout: 5000 },
88+
).trim();
5689
if (!out) return [];
5790
return JSON.parse(out) as MailMessage[];
58-
} catch { return []; }
91+
} catch {
92+
return [];
93+
}
5994
}
6095

6196
const REPO_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
6297

6398
function fetchPRs(repo: string): PullRequest[] {
64-
if (!REPO_RE.test(repo)) {
65-
console.error(`[tui] Invalid repo format: ${repo}`);
66-
return [];
67-
}
99+
if (!REPO_RE.test(repo)) return [];
68100
try {
69-
const out = runCmd("gh", ["pr", "list", "--repo", repo,
70-
"--json", "number,title,author,statusCheckRollup", "--limit", "10"]);
101+
const out = runCmd("gh-as", [
102+
"anvil", "pr", "list", "--repo", repo,
103+
"--json", "number,title,author,statusCheckRollup", "--limit", "10",
104+
]);
71105
if (!out) return [];
72106
return JSON.parse(out) as PullRequest[];
73-
} catch { return []; }
107+
} catch {
108+
return [];
109+
}
74110
}
75111

76112
function fetchLogs(agentId: string): string[] {
77113
try {
78114
const logPath = join(homedir(), ".tps", "logs", `${agentId}.log`);
79-
if (!existsSync(logPath)) return ["(no log)"];
80-
return runCmd("tail", ["-n", "20", logPath]).split("\n");
81-
} catch { return []; }
115+
if (!existsSync(logPath)) return ["(no log file)"];
116+
return runCmd("tail", ["-n", "25", logPath]).split("\n");
117+
} catch {
118+
return ["(error reading log)"];
119+
}
82120
}
83121

84122
function fetchTasks(): string[] {
85123
try {
86-
return runCmd("bd", ["ready"]).split("\n").filter(Boolean).slice(0, 10);
87-
} catch { return ["(bd unavailable)"]; }
124+
const out = runCmd("bd", ["ready"]);
125+
return out.split("\n").filter(Boolean).slice(0, 10);
126+
} catch {
127+
return ["(bd unavailable)"];
128+
}
88129
}
89130

90131
// ── Components ─────────────────────────────────────────────────────────────────
91132

92-
function Dot({ status }: { status: "online" | "offline" }) {
93-
return React.createElement(Text, { color: status === "online" ? "green" : "gray" },
94-
status === "online" ? "●" : "○");
133+
function StatusDot({ status }: { status: AgentStatus["status"] }) {
134+
const color = status === "online" ? "green" : status === "busy" ? "yellow" : "gray";
135+
const sym = status === "online" ? "●" : status === "busy" ? "◕" : "○";
136+
return React.createElement(Text, { color }, sym);
95137
}
96138

97139
function AgentsPanel({ agents }: { agents: AgentStatus[] }) {
98140
return React.createElement(Box, { flexDirection: "column" },
99-
React.createElement(Text, { bold: true }, "── Agents ──"),
100-
...agents.map((a) => React.createElement(Box, { key: a.id, gap: 1 },
101-
React.createElement(Dot, { status: a.status }),
102-
React.createElement(Text, null, a.id),
103-
)),
141+
React.createElement(Text, { bold: true, color: "cyan" }, "── Agents ──"),
142+
...agents.map((a) =>
143+
React.createElement(Box, { key: a.id, gap: 1 },
144+
React.createElement(StatusDot, { status: a.status }),
145+
React.createElement(Text, { color: a.status === "offline" ? "gray" : "white" }, a.id),
146+
),
147+
),
104148
);
105149
}
106150

107151
function MailPanel({ messages }: { messages: MailMessage[] }) {
152+
if (messages.length === 0) {
153+
return React.createElement(Box, { flexDirection: "column" },
154+
React.createElement(Text, { bold: true, color: "cyan" }, "── Mail ──"),
155+
React.createElement(Text, { color: "gray" }, "(inbox empty)"),
156+
);
157+
}
108158
return React.createElement(Box, { flexDirection: "column" },
109-
React.createElement(Text, { bold: true }, "── Mail ──"),
110-
messages.length === 0
111-
? React.createElement(Text, { color: "gray" }, "(empty)")
112-
: messages.slice(0, 8).map((m) => React.createElement(Box, { key: m.id, flexDirection: "column" },
113-
React.createElement(Box, { gap: 1 },
114-
React.createElement(Text, { color: "cyan" }, m.from),
115-
React.createElement(Text, { color: "gray" }, m.timestamp.slice(0, 16)),
116-
),
117-
React.createElement(Text, { wrap: "truncate" }, m.body.slice(0, 100)),
118-
)),
159+
React.createElement(Text, { bold: true, color: "cyan" }, "── Mail ──"),
160+
...messages.slice(0, 8).map((m) =>
161+
React.createElement(Box, { key: m.id, flexDirection: "column", marginBottom: 1 },
162+
React.createElement(Box, { gap: 2 },
163+
React.createElement(Text, { color: m.read ? "gray" : "cyan", bold: !m.read }, m.from),
164+
React.createElement(Text, { color: "gray" }, m.timestamp.slice(5, 16)),
165+
),
166+
React.createElement(Text, { wrap: "truncate", color: "white" },
167+
m.body.split("\n")[0]?.slice(0, 90) ?? ""),
168+
),
169+
),
119170
);
120171
}
121172

122173
function PRsPanel({ prs }: { prs: PullRequest[] }) {
174+
if (prs.length === 0) {
175+
return React.createElement(Box, { flexDirection: "column" },
176+
React.createElement(Text, { bold: true, color: "cyan" }, "── PRs ──"),
177+
React.createElement(Text, { color: "gray" }, "(none open)"),
178+
);
179+
}
123180
return React.createElement(Box, { flexDirection: "column" },
124-
React.createElement(Text, { bold: true }, "── PRs ──"),
125-
prs.length === 0
126-
? React.createElement(Text, { color: "gray" }, "(none)")
127-
: prs.map((pr) => {
128-
const ci = pr.statusCheckRollup?.state;
129-
const color = ci === "SUCCESS" ? "green" : ci === "FAILURE" ? "red" : "gray";
130-
const sym = ci === "SUCCESS" ? "✓" : ci === "FAILURE" ? "✗" : "·";
131-
return React.createElement(Box, { key: pr.number, gap: 1 },
132-
React.createElement(Text, { color }, sym),
133-
React.createElement(Text, { color: "yellow" }, `#${pr.number}`),
134-
React.createElement(Text, { wrap: "truncate" }, pr.title.slice(0, 55)),
135-
);
136-
}),
181+
React.createElement(Text, { bold: true, color: "cyan" }, "── PRs ──"),
182+
...prs.map((pr) => {
183+
const rollup = pr.statusCheckRollup;
184+
const state = Array.isArray(rollup)
185+
? rollup[0]?.state
186+
: (rollup as { state?: string } | null)?.state;
187+
const color = state === "SUCCESS" ? "green" : state === "FAILURE" ? "red" : "gray";
188+
const sym = state === "SUCCESS" ? "✓" : state === "FAILURE" ? "✗" : "·";
189+
return React.createElement(Box, { key: pr.number, gap: 1 },
190+
React.createElement(Text, { color }, sym),
191+
React.createElement(Text, { color: "yellow" }, `#${pr.number}`),
192+
React.createElement(Text, { wrap: "truncate", color: "white" }, pr.title.slice(0, 60)),
193+
);
194+
}),
137195
);
138196
}
139197

140198
function TasksPanel({ tasks }: { tasks: string[] }) {
199+
if (tasks.length === 0) {
200+
return React.createElement(Box, { flexDirection: "column" },
201+
React.createElement(Text, { bold: true, color: "cyan" }, "── Tasks (ready) ──"),
202+
React.createElement(Text, { color: "gray" }, "(none)"),
203+
);
204+
}
141205
return React.createElement(Box, { flexDirection: "column" },
142-
React.createElement(Text, { bold: true }, "── Tasks (ready) ──"),
143-
tasks.length === 0
144-
? React.createElement(Text, { color: "gray" }, "(empty)")
145-
: tasks.map((t, i) => React.createElement(Text, { key: i }, t)),
206+
React.createElement(Text, { bold: true, color: "cyan" }, "── Tasks (ready) ──"),
207+
...tasks.map((t, i) =>
208+
React.createElement(Text, { key: i, wrap: "truncate", color: "white" }, t),
209+
),
146210
);
147211
}
148212

149213
function LogsPanel({ lines }: { lines: string[] }) {
150214
return React.createElement(Box, { flexDirection: "column" },
151-
React.createElement(Text, { bold: true }, "── Logs (ember) ──"),
152-
...lines.slice(-15).map((l, i) => React.createElement(Text, { key: i, color: "gray", wrap: "truncate" }, l)),
215+
React.createElement(Text, { bold: true, color: "cyan" }, "── Logs (ember) ──"),
216+
...lines.slice(-20).map((l, i) =>
217+
React.createElement(Text, { key: i, color: "gray", wrap: "truncate" }, l || " "),
218+
),
153219
);
154220
}
155221

156222
function TabBar({ active }: { active: Panel }) {
157-
return React.createElement(Box, { gap: 2 },
158-
...PANELS.map((p, i) => React.createElement(Text, { key: p,
159-
bold: p === active, color: p === active ? "cyan" : "gray" },
160-
`[${i + 1}]${PANEL_LABELS[p]}`,
161-
)),
223+
return React.createElement(Box, { gap: 2, paddingX: 1 },
224+
React.createElement(Text, { bold: true, color: "white" }, "TPS Office"),
225+
React.createElement(Text, { color: "gray" }, "|"),
226+
...PANELS.map((p, i) =>
227+
React.createElement(Text, {
228+
key: p,
229+
bold: p === active,
230+
color: p === active ? "cyan" : "gray",
231+
}, `[${i + 1}]${PANEL_LABELS[p]}`),
232+
),
233+
);
234+
}
235+
236+
function StatusBar({ lastRefresh, error }: { lastRefresh: Date | null; error: string | null }) {
237+
return React.createElement(Box, { gap: 3, marginTop: 1 },
238+
React.createElement(Text, { color: "gray" }, "Tab/1-5: panel r: refresh q: quit"),
239+
lastRefresh
240+
? React.createElement(Text, { color: "gray" }, `refreshed ${lastRefresh.toLocaleTimeString()}`)
241+
: null,
242+
error
243+
? React.createElement(Text, { color: "red" }, `⚠ ${error}`)
244+
: null,
162245
);
163246
}
164247

@@ -170,34 +253,53 @@ export interface TuiOptions {
170253
repo?: string;
171254
}
172255

173-
export function TuiApp({ mailDir = join(homedir(), ".tps", "mail"), agentId = "anvil", repo = "tpsdev-ai/cli" }: TuiOptions) {
256+
export function TuiApp({
257+
mailDir = join(homedir(), ".tps", "mail"),
258+
agentId = "anvil",
259+
repo = "tpsdev-ai/cli",
260+
}: TuiOptions) {
174261
const { exit } = useApp();
175262
const [panel, setPanel] = useState<Panel>("agents");
176263
const [agents, setAgents] = useState<AgentStatus[]>([]);
177264
const [mail, setMail] = useState<MailMessage[]>([]);
178265
const [prs, setPRs] = useState<PullRequest[]>([]);
179266
const [logs, setLogs] = useState<string[]>([]);
180267
const [tasks, setTasks] = useState<string[]>([]);
181-
const [tick, setTick] = useState(0);
268+
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
269+
const [error, setError] = useState<string | null>(null);
270+
const refreshing = useRef(false);
182271

183-
useEffect(() => {
184-
setAgents(fetchAgents());
185-
setMail(fetchMail(mailDir, agentId));
186-
setPRs(fetchPRs(repo));
187-
setLogs(fetchLogs("ember"));
188-
setTasks(fetchTasks());
189-
}, [tick, mailDir, agentId, repo]);
272+
const refresh = useCallback(() => {
273+
if (refreshing.current) return;
274+
refreshing.current = true;
275+
setError(null);
276+
try {
277+
setAgents(fetchAgents());
278+
setMail(fetchMail(mailDir, agentId));
279+
setPRs(fetchPRs(repo));
280+
setLogs(fetchLogs("ember"));
281+
setTasks(fetchTasks());
282+
setLastRefresh(new Date());
283+
} catch (e: unknown) {
284+
setError((e as Error).message ?? "refresh failed");
285+
} finally {
286+
refreshing.current = false;
287+
}
288+
}, [mailDir, agentId, repo]);
289+
290+
useEffect(() => { refresh(); }, [refresh]);
190291

191292
useEffect(() => {
192-
const t = setInterval(() => setTick((n) => n + 1), 10_000);
293+
const t = setInterval(refresh, 10_000);
193294
return () => clearInterval(t);
194-
}, []);
295+
}, [refresh]);
195296

196297
useInput((input, key) => {
197298
if (input === "q") exit();
198-
if (input === "r") setTick((n) => n + 1);
299+
if (input === "r") refresh();
199300
if (key.tab) setPanel((p) => PANELS[(PANELS.indexOf(p) + 1) % PANELS.length]);
200-
if (PANEL_KEYS[input]) setPanel(PANEL_KEYS[input]);
301+
const mapped = PANEL_KEYS[input];
302+
if (mapped) setPanel(mapped);
201303
});
202304

203305
const content =
@@ -207,9 +309,9 @@ export function TuiApp({ mailDir = join(homedir(), ".tps", "mail"), agentId = "a
207309
panel === "prs" ? React.createElement(PRsPanel, { prs }) :
208310
React.createElement(LogsPanel, { lines: logs });
209311

210-
return React.createElement(Box, { flexDirection: "column", height: "100%" },
312+
return React.createElement(Box, { flexDirection: "column" },
211313
React.createElement(TabBar, { active: panel }),
212-
React.createElement(Box, { flexGrow: 1, paddingTop: 1 }, content),
213-
React.createElement(Text, { color: "gray" }, "Tab/1-5: panel r: refresh q: quit"),
314+
React.createElement(Box, { flexGrow: 1, paddingTop: 1, paddingX: 2 }, content),
315+
React.createElement(StatusBar, { lastRefresh, error }),
214316
);
215317
}

0 commit comments

Comments
 (0)