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" ;
66import { existsSync } from "node:fs" ;
77import { homedir } from "node:os" ;
88import { join } from "node:path" ;
9- import React , { useEffect , useState } from "react" ;
9+ import React , { useCallback , useEffect , useRef , useState } from "react" ;
1010import { Box , Text , useApp , useInput } from "ink" ;
1111
1212// ── Types ──────────────────────────────────────────────────────────────────────
1313
1414interface AgentStatus {
1515 id : string ;
16- status : "online" | "offline" ;
16+ status : "online" | "busy" | "offline" ;
17+ lastSeen ?: string ;
1718}
1819
1920interface MailMessage {
2021 id : string ;
2122 from : string ;
23+ to : string ;
2224 body : string ;
2325 timestamp : string ;
26+ read ?: boolean ;
2427}
2528
2629interface 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
3336type Panel = "agents" | "mail" | "tasks" | "prs" | "logs" ;
3437const 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
4055function 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
4560function 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
5382function 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
6196const REPO_RE = / ^ [ a - z A - Z 0 - 9 _ . - ] + \/ [ a - z A - Z 0 - 9 _ . - ] + $ / ;
6297
6398function 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
76112function 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
84122function 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
97139function 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
107151function 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
122173function 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
140198function 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
149213function 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
156222function 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