opencode-gh-ci 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/package.json +1 -2
  2. package/server.ts +43 -99
  3. package/shared.ts +131 -133
  4. package/tui.tsx +140 -157
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-gh-ci",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "description": "GitHub Actions CI status in the OpenCode sidebar with live elapsed timers.",
7
7
  "license": "MIT",
@@ -37,7 +37,6 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@opencode-ai/plugin": "*",
40
- "@opentui/core": "*",
41
40
  "@opentui/solid": "*",
42
41
  "solid-js": "*"
43
42
  }
package/server.ts CHANGED
@@ -5,6 +5,7 @@ import type { Plugin } from "@opencode-ai/plugin"
5
5
  import {
6
6
  PLUGIN_ID,
7
7
  parseOptions,
8
+ readCacheText,
8
9
  buildCachePath,
9
10
  buildRegistryPath,
10
11
  registerSession,
@@ -14,56 +15,45 @@ import {
14
15
  emptyCache,
15
16
  nowISO,
16
17
  } from "./shared"
17
-
18
- import type { WorkflowJob, WorkflowRun, CICache } from "./shared"
18
+ import type { WorkflowJob, WorkflowRun } from "./shared"
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
- // Git branch detection
21
+ // Git helpers
22
22
  // ---------------------------------------------------------------------------
23
23
 
24
24
  async function detectBranch(cwd: string): Promise<string> {
25
25
  const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
26
- cwd,
27
- stdout: "pipe",
28
- stderr: "pipe",
26
+ cwd, stdout: "pipe", stderr: "pipe",
29
27
  })
30
28
  const out = await new Response(proc.stdout).text()
31
- const code = await proc.exited
32
- if (code !== 0) return ""
33
- return out.trim()
29
+ return (await proc.exited) === 0 ? out.trim() : ""
34
30
  }
35
31
 
36
- // ---------------------------------------------------------------------------
37
- // GitHub CLI data fetching
38
- // ---------------------------------------------------------------------------
39
-
40
32
  async function runCmd(args: string[], cwd: string): Promise<string> {
41
33
  const proc = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
42
34
  const out = await new Response(proc.stdout).text()
43
- const code = await proc.exited
44
- if (code !== 0) throw new Error(`exit ${code}: ${args.join(" ")}`)
35
+ if ((await proc.exited) !== 0) throw new Error(`exit: ${args.join(" ")}`)
45
36
  return out.trim()
46
37
  }
47
38
 
39
+ // ---------------------------------------------------------------------------
40
+ // GitHub CLI data fetching
41
+ // ---------------------------------------------------------------------------
42
+
48
43
  async function fetchJobsForRun(runId: number, cwd: string): Promise<WorkflowJob[]> {
49
- const jobsRaw = await runCmd(
50
- ["gh", "api",
51
- `repos/{owner}/{repo}/actions/runs/${runId}/jobs`,
44
+ const raw = await runCmd(
45
+ ["gh", "api", `repos/{owner}/{repo}/actions/runs/${runId}/jobs`,
52
46
  "--jq", ".jobs[] | {name,status,conclusion,started_at,completed_at}"],
53
47
  cwd,
54
48
  )
55
- return jobsRaw
56
- .split("\n")
57
- .filter((l: string) => l.trim())
58
- .map((l: string) => { try { return JSON.parse(l) } catch { return null } })
49
+ return raw.split("\n")
50
+ .filter((l) => l.trim())
51
+ .map((l) => { try { return JSON.parse(l) } catch { return null } })
59
52
  .filter(Boolean)
60
53
  }
61
54
 
62
55
  async function fetchRuns(
63
- branch: string,
64
- cwd: string,
65
- maxRuns: number,
66
- pushWindowMs: number,
56
+ branch: string, cwd: string, maxRuns: number, pushWindowMs: number,
67
57
  ): Promise<WorkflowRun[]> {
68
58
  const raw = await runCmd(
69
59
  ["gh", "run", "list", "--branch", branch, "--limit", String(maxRuns),
@@ -75,54 +65,37 @@ async function fetchRuns(
75
65
 
76
66
  const latestTime = new Date(allRuns[0].createdAt).getTime()
77
67
  const pushRuns = allRuns.filter(
78
- (r: any) => Math.abs(new Date(r.createdAt).getTime() - latestTime) < pushWindowMs
68
+ (r: any) => Math.abs(new Date(r.createdAt).getTime() - latestTime) < pushWindowMs,
79
69
  )
80
70
 
81
- const results: WorkflowRun[] = await Promise.all(
82
- pushRuns.map(async (r: any) => {
83
- const jobs = await fetchJobsForRun(r.databaseId, cwd)
84
- return {
85
- id: r.databaseId,
86
- name: r.name,
87
- status: r.status,
88
- conclusion: r.conclusion,
89
- head_branch: r.headBranch,
90
- jobs,
91
- }
92
- })
71
+ return Promise.all(
72
+ pushRuns.map(async (r: any) => ({
73
+ id: r.databaseId,
74
+ name: r.name,
75
+ status: r.status,
76
+ conclusion: r.conclusion,
77
+ head_branch: r.headBranch,
78
+ jobs: await fetchJobsForRun(r.databaseId, cwd),
79
+ })),
93
80
  )
94
-
95
- return results
96
81
  }
97
82
 
98
83
  // ---------------------------------------------------------------------------
99
84
  // Cache I/O
100
85
  // ---------------------------------------------------------------------------
101
86
 
102
- async function readCache(filePath: string): Promise<CICache> {
103
- try {
104
- const text = await readFile(filePath, "utf8")
105
- const parsed = JSON.parse(text)
106
- if (!parsed || typeof parsed !== "object") return emptyCache()
107
- return {
108
- version: 1,
109
- updatedAt: parsed.updatedAt || nowISO(),
110
- branch: parsed.branch || "",
111
- runs: parsed.runs || (parsed.run ? [parsed.run] : []),
112
- error: parsed.error || null,
113
- }
114
- } catch {
115
- return emptyCache()
116
- }
87
+ async function readCache(filePath: string) {
88
+ try { return readCacheText(await readFile(filePath, "utf8")) }
89
+ catch { return emptyCache() }
117
90
  }
118
91
 
119
- async function writeCache(filePath: string, cache: CICache): Promise<void> {
92
+ async function writeCache(filePath: string, cache: any): Promise<void> {
120
93
  await mkdir(dirname(filePath), { recursive: true })
121
- await writeFile(filePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8")
94
+ await writeFile(filePath, JSON.stringify(cache, null, 2) + "\n", "utf8")
122
95
  }
123
96
 
124
97
  // ---------------------------------------------------------------------------
125
- // Plugin entry
98
+ // Plugin
126
99
  // ---------------------------------------------------------------------------
127
100
 
128
101
  const server: Plugin = async (input, options) => {
@@ -133,17 +106,9 @@ const server: Plugin = async (input, options) => {
133
106
  const cachePath = buildCachePath()
134
107
  const registryPath = buildRegistryPath(cwd)
135
108
 
136
- // Clean up orphaned sessions from dead processes
137
109
  await cleanupOrphans(registryPath)
110
+ await registerSession(registryPath, { cachePath, pid: process.pid, startedAt: nowISO() })
138
111
 
139
- // Register this session
140
- await registerSession(registryPath, {
141
- cachePath,
142
- pid: process.pid,
143
- startedAt: nowISO(),
144
- })
145
-
146
- // Global last-refresh timestamp — shared by poll timer and event handlers
147
112
  let lastRefreshAt = 0
148
113
 
149
114
  const refresh = async () => {
@@ -153,65 +118,44 @@ const server: Plugin = async (input, options) => {
153
118
  await writeCache(cachePath, { ...emptyCache(), error: "Not a git repo" })
154
119
  return
155
120
  }
156
-
157
- const runs = await fetchRuns(branch, cwd, opts.max_runs, opts.push_window_ms)
158
121
  await writeCache(cachePath, {
159
- version: 1,
160
- updatedAt: nowISO(),
161
- branch,
162
- runs,
122
+ version: 1, updatedAt: nowISO(), branch,
123
+ runs: await fetchRuns(branch, cwd, opts.max_runs, opts.push_window_ms),
163
124
  error: null,
164
125
  })
165
126
  } catch (e: any) {
166
127
  const prev = await readCache(cachePath)
167
- await writeCache(cachePath, {
168
- ...prev,
169
- updatedAt: nowISO(),
170
- error: e?.message ?? "fetch failed",
171
- })
128
+ await writeCache(cachePath, { ...prev, updatedAt: nowISO(), error: e?.message ?? "fetch failed" })
172
129
  }
173
130
  lastRefreshAt = Date.now()
174
131
  }
175
132
 
176
- // Global debounce — skips if any source (poll or event) already refreshed recently
177
133
  const maybeRefresh = () => {
178
- if (Date.now() - lastRefreshAt >= opts.debounce_ms) {
179
- void refresh()
180
- }
134
+ if (Date.now() - lastRefreshAt >= opts.debounce_ms) void refresh()
181
135
  }
182
136
 
183
- // Initial fetch + periodic poll (also debounced)
184
137
  void refresh()
185
- const timer = setInterval(() => maybeRefresh(), opts.server_poll_ms)
138
+ const timer = setInterval(maybeRefresh, opts.server_poll_ms)
186
139
  timer.unref?.()
187
140
 
188
- // Cleanup on graceful shutdown
189
141
  const cleanup = async () => {
190
142
  clearInterval(timer)
191
143
  await cleanupSession(cachePath)
192
144
  await deregisterSession(registryPath, cachePath)
193
145
  }
194
146
 
195
- const onExit = () => { void cleanup() }
196
- process.on("exit", onExit)
197
- process.on("SIGINT", onExit)
198
- process.on("SIGTERM", onExit)
147
+ process.on("exit", () => void cleanup())
148
+ process.on("SIGINT", () => void cleanup())
149
+ process.on("SIGTERM", () => void cleanup())
199
150
 
200
- // Build return object with dynamic event handlers
201
151
  const handlers: Record<string, unknown> = { dispose: cleanup }
202
-
203
152
  if (opts.refresh_on_events) {
204
153
  for (const event of opts.refresh_on_events) {
205
- handlers[event] = async () => {
206
- maybeRefresh()
207
- }
154
+ handlers[event] = async () => maybeRefresh()
208
155
  }
209
156
  }
210
157
 
211
158
  return handlers
212
159
  }
213
160
 
214
- export default {
215
- id: PLUGIN_ID,
216
- server,
217
- }
161
+ export default { id: PLUGIN_ID, server }
package/shared.ts CHANGED
@@ -8,10 +8,70 @@ export const PLUGIN_ID = "gh-ci"
8
8
  const BASE_DIR = path.join(os.tmpdir(), "opencode-gh-ci")
9
9
 
10
10
  // ---------------------------------------------------------------------------
11
- // Options
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface WorkflowJob {
15
+ name: string
16
+ status: "queued" | "in_progress" | "completed" | "waiting"
17
+ conclusion: string | null
18
+ started_at: string | null
19
+ completed_at: string | null
20
+ }
21
+
22
+ export interface WorkflowRun {
23
+ id: number
24
+ name: string
25
+ status: string
26
+ conclusion: string | null
27
+ head_branch: string
28
+ jobs: WorkflowJob[]
29
+ }
30
+
31
+ export interface CICache {
32
+ version: 1
33
+ updatedAt: string
34
+ branch: string
35
+ runs: WorkflowRun[]
36
+ error: string | null
37
+ }
38
+
39
+ export interface RegistryEntry {
40
+ cachePath: string
41
+ pid: number
42
+ startedAt: string
43
+ }
44
+
45
+ export interface HideFilters {
46
+ workflows: RegExp[]
47
+ jobs: RegExp[]
48
+ }
49
+
50
+ export type DetailLevel = "overall" | "workflows" | "jobs"
51
+
52
+ export interface JobsDetailOptions {
53
+ collapse_single_workflow: boolean
54
+ }
55
+
56
+ export interface PluginOptions {
57
+ enabled: boolean
58
+ server_poll_ms: number
59
+ tui_poll_ms: number
60
+ debounce_ms: number
61
+ push_window_ms: number
62
+ max_runs: number
63
+ max_name_length: number
64
+ right_align_elapsed: boolean
65
+ refresh_on_events: string[] | false
66
+ hide: HideFilters
67
+ detail: DetailLevel
68
+ jobs_detail: JobsDetailOptions
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Options parsing
12
73
  // ---------------------------------------------------------------------------
13
74
 
14
- // All plugin trigger events that can be subscribed to
15
75
  const ALL_EVENTS = [
16
76
  "chat.message",
17
77
  "tool.execute.before",
@@ -20,8 +80,6 @@ const ALL_EVENTS = [
20
80
  "shell.env",
21
81
  ] as const
22
82
 
23
- const DEFAULT_EVENTS = [...ALL_EVENTS] as string[]
24
-
25
83
  const DEFAULTS = {
26
84
  enabled: true,
27
85
  server_poll_ms: 10_000,
@@ -29,23 +87,18 @@ const DEFAULTS = {
29
87
  debounce_ms: 10_000,
30
88
  push_window_ms: 60_000,
31
89
  max_runs: 10,
90
+ max_name_length: 24,
91
+ right_align_elapsed: true,
32
92
  } as const
33
93
 
34
- export interface HideFilters {
35
- workflows: RegExp[]
36
- jobs: RegExp[]
37
- }
94
+ const DEFAULT_JOBS_DETAIL: JobsDetailOptions = { collapse_single_workflow: true }
38
95
 
39
- export interface PluginOptions {
40
- enabled: boolean
41
- server_poll_ms: number
42
- tui_poll_ms: number
43
- debounce_ms: number
44
- push_window_ms: number
45
- max_runs: number
46
- refresh_on_events: string[] | false
47
- hide: HideFilters
48
- collapse_single_workflow: boolean
96
+ const EMPTY_OPTIONS: PluginOptions = {
97
+ ...DEFAULTS,
98
+ refresh_on_events: [...ALL_EVENTS],
99
+ hide: { workflows: [], jobs: [] },
100
+ detail: "jobs",
101
+ jobs_detail: { ...DEFAULT_JOBS_DETAIL },
49
102
  }
50
103
 
51
104
  const toRecord = (v: unknown): Record<string, unknown> | undefined =>
@@ -53,8 +106,9 @@ const toRecord = (v: unknown): Record<string, unknown> | undefined =>
53
106
  ? Object.fromEntries(Object.entries(v))
54
107
  : undefined
55
108
 
56
- const toBool = (v: unknown, fallback: boolean): boolean =>
57
- typeof v === "boolean" ? v : fallback
109
+ const toBool = (v: unknown, fb: boolean) => (typeof v === "boolean" ? v : fb)
110
+ const toNum = (v: unknown, fb: number, min = 0) =>
111
+ typeof v === "number" && Number.isFinite(v) ? Math.max(min, v) : fb
58
112
 
59
113
  const toRegexList = (v: unknown): RegExp[] => {
60
114
  if (!Array.isArray(v)) return []
@@ -64,37 +118,42 @@ const toRegexList = (v: unknown): RegExp[] => {
64
118
  .filter((r): r is RegExp => r !== null)
65
119
  }
66
120
 
67
- const toNum = (v: unknown, fallback: number, min = 0): number => {
68
- if (typeof v !== "number" || !Number.isFinite(v)) return fallback
69
- return Math.max(min, v)
70
- }
71
-
72
121
  export function parseOptions(raw: unknown): PluginOptions {
73
122
  const rec = toRecord(raw)
74
- if (!rec) return { ...DEFAULTS, refresh_on_events: [...DEFAULT_EVENTS], hide: { workflows: [], jobs: [] }, collapse_single_workflow: true }
75
-
76
- // Parse refresh_on_events:
77
- // false → disabled
78
- // true → all available events
79
- // undefined → default events (chat.message)
80
- // ["chat.message", "tool.execute.after"] → specific events
81
- let refreshOnEvents: string[] | false
123
+ if (!rec) return EMPTY_OPTIONS
124
+
125
+ // refresh_on_events: false | true | string[]
82
126
  const roe = rec.refresh_on_events
83
- if (roe === false) {
84
- refreshOnEvents = false
85
- } else if (roe === true) {
86
- refreshOnEvents = [...ALL_EVENTS]
87
- } else if (Array.isArray(roe)) {
88
- refreshOnEvents = roe.filter((e): e is string => typeof e === "string")
89
- } else {
90
- refreshOnEvents = [...DEFAULT_EVENTS]
91
- }
127
+ const refreshOnEvents: string[] | false =
128
+ roe === false ? false
129
+ : roe === true ? [...ALL_EVENTS]
130
+ : Array.isArray(roe) ? roe.filter((e): e is string => typeof e === "string")
131
+ : [...ALL_EVENTS]
92
132
 
93
- // Parse hide: { workflows: [...], jobs: [...] }
133
+ // hide: { workflows: [...], jobs: [...] }
94
134
  const hideRec = toRecord(rec.hide)
95
- const hide: HideFilters = {
96
- workflows: toRegexList(hideRec?.workflows),
97
- jobs: toRegexList(hideRec?.jobs),
135
+
136
+ // detail: "overall" | "workflows" | "jobs" | { jobs: {...} } | { workflows: {} } | { overall: {} }
137
+ const VALID_LEVELS: DetailLevel[] = ["overall", "workflows", "jobs"]
138
+ let detail: DetailLevel = "jobs"
139
+ let jobsDetail: JobsDetailOptions = { ...DEFAULT_JOBS_DETAIL }
140
+
141
+ const d = rec.detail
142
+ if (typeof d === "string" && VALID_LEVELS.includes(d as DetailLevel)) {
143
+ detail = d as DetailLevel
144
+ } else if (d && typeof d === "object" && !Array.isArray(d)) {
145
+ const dRec = d as Record<string, unknown>
146
+ if ("jobs" in dRec) {
147
+ detail = "jobs"
148
+ const jRec = toRecord(dRec.jobs)
149
+ jobsDetail = {
150
+ collapse_single_workflow: toBool(jRec?.collapse_single_workflow, DEFAULT_JOBS_DETAIL.collapse_single_workflow),
151
+ }
152
+ } else if ("workflows" in dRec) {
153
+ detail = "workflows"
154
+ } else if ("overall" in dRec) {
155
+ detail = "overall"
156
+ }
98
157
  }
99
158
 
100
159
  return {
@@ -104,9 +163,15 @@ export function parseOptions(raw: unknown): PluginOptions {
104
163
  debounce_ms: toNum(rec.debounce_ms, DEFAULTS.debounce_ms, 1000),
105
164
  push_window_ms: toNum(rec.push_window_ms, DEFAULTS.push_window_ms, 10_000),
106
165
  max_runs: toNum(rec.max_runs, DEFAULTS.max_runs, 1),
166
+ max_name_length: toNum(rec.max_name_length, DEFAULTS.max_name_length, 10),
167
+ right_align_elapsed: toBool(rec.right_align_elapsed, DEFAULTS.right_align_elapsed),
107
168
  refresh_on_events: refreshOnEvents,
108
- hide,
109
- collapse_single_workflow: toBool(rec.collapse_single_workflow, true),
169
+ hide: {
170
+ workflows: toRegexList(hideRec?.workflows),
171
+ jobs: toRegexList(hideRec?.jobs),
172
+ },
173
+ detail,
174
+ jobs_detail: jobsDetail,
110
175
  }
111
176
  }
112
177
 
@@ -114,7 +179,6 @@ export function parseOptions(raw: unknown): PluginOptions {
114
179
  // Filtering
115
180
  // ---------------------------------------------------------------------------
116
181
 
117
- /** Filter runs and jobs based on hide regexes. Returns a new array (no mutation). */
118
182
  export function filterRuns(runs: WorkflowRun[] | undefined, hide: HideFilters): WorkflowRun[] {
119
183
  if (!runs?.length) return []
120
184
  if (!hide.workflows.length && !hide.jobs.length) return runs
@@ -123,48 +187,13 @@ export function filterRuns(runs: WorkflowRun[] | undefined, hide: HideFilters):
123
187
  .filter((r) => !hide.workflows.some((re) => re.test(r.name)))
124
188
  .map((r) => {
125
189
  if (!hide.jobs.length) return r
126
- const filteredJobs = r.jobs.filter((j) => !hide.jobs.some((re) => re.test(j.name)))
127
- return filteredJobs.length === r.jobs.length ? r : { ...r, jobs: filteredJobs }
190
+ const filtered = r.jobs.filter((j) => !hide.jobs.some((re) => re.test(j.name)))
191
+ return filtered.length === r.jobs.length ? r : { ...r, jobs: filtered }
128
192
  })
129
193
  }
130
194
 
131
195
  // ---------------------------------------------------------------------------
132
- // Domain types
133
- // ---------------------------------------------------------------------------
134
-
135
- export interface WorkflowJob {
136
- name: string
137
- status: "queued" | "in_progress" | "completed" | "waiting"
138
- conclusion: string | null
139
- started_at: string | null
140
- completed_at: string | null
141
- }
142
-
143
- export interface WorkflowRun {
144
- id: number
145
- name: string
146
- status: string
147
- conclusion: string | null
148
- head_branch: string
149
- jobs: WorkflowJob[]
150
- }
151
-
152
- export interface CICache {
153
- version: 1
154
- updatedAt: string
155
- branch: string
156
- runs: WorkflowRun[]
157
- error: string | null
158
- }
159
-
160
- export interface RegistryEntry {
161
- cachePath: string
162
- pid: number
163
- startedAt: string
164
- }
165
-
166
- // ---------------------------------------------------------------------------
167
- // Cache path helpers
196
+ // Cache paths
168
197
  // ---------------------------------------------------------------------------
169
198
 
170
199
  export const nowISO = () => new Date().toISOString()
@@ -177,25 +206,22 @@ export const emptyCache = (): CICache => ({
177
206
  error: null,
178
207
  })
179
208
 
180
- /** Random per-session cache directory: <tmpdir>/opencode-gh-ci/<uuid>/ci.json */
181
209
  export function buildCachePath(): string {
182
210
  return path.join(BASE_DIR, randomUUID(), "ci.json")
183
211
  }
184
212
 
185
- /** Deterministic registry file per project: <tmpdir>/opencode-gh-ci/<hash>.registry.json */
186
213
  export function buildRegistryPath(root: string): string {
187
214
  const digest = createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 12)
188
215
  return path.join(BASE_DIR, `${digest}.registry.json`)
189
216
  }
190
217
 
191
218
  // ---------------------------------------------------------------------------
192
- // Registry helpers
219
+ // Registry
193
220
  // ---------------------------------------------------------------------------
194
221
 
195
222
  export async function readRegistry(registryPath: string): Promise<RegistryEntry[]> {
196
223
  try {
197
- const text = await readFile(registryPath, "utf8")
198
- const parsed = JSON.parse(text)
224
+ const parsed = JSON.parse(await readFile(registryPath, "utf8"))
199
225
  return Array.isArray(parsed) ? parsed : []
200
226
  } catch {
201
227
  return []
@@ -208,35 +234,20 @@ async function writeRegistry(registryPath: string, entries: RegistryEntry[]): Pr
208
234
  }
209
235
 
210
236
  function isPidAlive(pid: number): boolean {
211
- try {
212
- process.kill(pid, 0)
213
- return true
214
- } catch {
215
- return false
216
- }
237
+ try { process.kill(pid, 0); return true } catch { return false }
217
238
  }
218
239
 
219
- /** Register this session in the registry */
220
- export async function registerSession(
221
- registryPath: string,
222
- entry: RegistryEntry,
223
- ): Promise<void> {
240
+ export async function registerSession(registryPath: string, entry: RegistryEntry): Promise<void> {
224
241
  const entries = await readRegistry(registryPath)
225
242
  entries.push(entry)
226
243
  await writeRegistry(registryPath, entries)
227
244
  }
228
245
 
229
- /** Remove this session from the registry */
230
- export async function deregisterSession(
231
- registryPath: string,
232
- cachePath: string,
233
- ): Promise<void> {
246
+ export async function deregisterSession(registryPath: string, cachePath: string): Promise<void> {
234
247
  const entries = await readRegistry(registryPath)
235
- const filtered = entries.filter((e) => e.cachePath !== cachePath)
236
- await writeRegistry(registryPath, filtered)
248
+ await writeRegistry(registryPath, entries.filter((e) => e.cachePath !== cachePath))
237
249
  }
238
250
 
239
- /** Scan registry, remove entries with dead PIDs, delete their cache directories */
240
251
  export async function cleanupOrphans(registryPath: string): Promise<void> {
241
252
  const entries = await readRegistry(registryPath)
242
253
  const alive: RegistryEntry[] = []
@@ -245,26 +256,15 @@ export async function cleanupOrphans(registryPath: string): Promise<void> {
245
256
  if (isPidAlive(entry.pid)) {
246
257
  alive.push(entry)
247
258
  } else {
248
- try {
249
- const dir = path.dirname(entry.cachePath)
250
- await rm(dir, { recursive: true, force: true })
251
- } catch {
252
- // ignore
253
- }
259
+ try { await rm(path.dirname(entry.cachePath), { recursive: true, force: true }) } catch {}
254
260
  }
255
261
  }
256
262
 
257
263
  await writeRegistry(registryPath, alive)
258
264
  }
259
265
 
260
- /** Delete this session's cache directory */
261
266
  export async function cleanupSession(cachePath: string): Promise<void> {
262
- try {
263
- const dir = path.dirname(cachePath)
264
- await rm(dir, { recursive: true, force: true })
265
- } catch {
266
- // ignore
267
- }
267
+ try { await rm(path.dirname(cachePath), { recursive: true, force: true }) } catch {}
268
268
  }
269
269
 
270
270
  // ---------------------------------------------------------------------------
@@ -279,7 +279,7 @@ export function readCacheText(text: string): CICache {
279
279
  version: 1,
280
280
  updatedAt: parsed.updatedAt || nowISO(),
281
281
  branch: parsed.branch || "",
282
- runs: parsed.runs || (parsed.run ? [parsed.run] : []),
282
+ runs: parsed.runs || [],
283
283
  error: parsed.error || null,
284
284
  }
285
285
  } catch {
@@ -294,22 +294,20 @@ export function readCacheText(text: string): CICache {
294
294
  export const DOT = "\u2022"
295
295
  export const PULSE_FRAMES = ["\u2022", "\u25E6"]
296
296
 
297
- export function formatElapsed(seconds: number): string {
297
+ function formatElapsed(seconds: number): string {
298
298
  if (seconds < 0) seconds = 0
299
299
  const m = Math.floor(seconds / 60)
300
300
  const s = seconds % 60
301
- if (m > 0) return `${m}m ${s}s`
302
- return `${s}s`
301
+ return m > 0 ? `${m}m ${s}s` : `${s}s`
303
302
  }
304
303
 
305
304
  export function getJobElapsed(job: WorkflowJob, nowSec: number): string {
306
305
  if (!job.started_at) return ""
307
306
  const started = Math.floor(new Date(job.started_at).getTime() / 1000)
308
- if (job.completed_at) {
309
- const completed = Math.floor(new Date(job.completed_at).getTime() / 1000)
310
- return formatElapsed(completed - started)
311
- }
312
- return formatElapsed(nowSec - started)
307
+ const end = job.completed_at
308
+ ? Math.floor(new Date(job.completed_at).getTime() / 1000)
309
+ : nowSec
310
+ return formatElapsed(end - started)
313
311
  }
314
312
 
315
313
  export function truncate(s: string, max: number): string {
package/tui.tsx CHANGED
@@ -17,21 +17,18 @@ import {
17
17
  getJobElapsed,
18
18
  truncate,
19
19
  } from "./shared"
20
- import type { CICache, HideFilters, WorkflowJob, WorkflowRun } from "./shared"
20
+ import type { CICache, DetailLevel, HideFilters, JobsDetailOptions, WorkflowJob, WorkflowRun } from "./shared"
21
21
 
22
22
  type Api = Parameters<TuiPlugin>[0]
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
- // Color helpers
25
+ // Helpers
26
26
  // ---------------------------------------------------------------------------
27
27
 
28
28
  function dotColor(theme: any, status: string, conclusion: string | null) {
29
29
  if (status === "completed" && conclusion === "success") return theme.success
30
30
  if (status === "completed" && conclusion === "failure") return theme.error
31
- if (status === "completed" && conclusion === "skipped") return theme.textMuted
32
- if (status === "completed" && conclusion === "cancelled") return theme.textMuted
33
31
  if (status === "in_progress") return theme.warning
34
- if (status === "queued" || status === "waiting") return theme.textMuted
35
32
  return theme.textMuted
36
33
  }
37
34
 
@@ -45,38 +42,74 @@ function overallStatus(runs: WorkflowRun[]): { status: string; conclusion: strin
45
42
  return { status: "completed", conclusion: runs[0]?.conclusion ?? null }
46
43
  }
47
44
 
48
- // ---------------------------------------------------------------------------
49
- // Read cache from disk
50
- // ---------------------------------------------------------------------------
45
+ function countItems(items: { status: string; conclusion: string | null }[]): string {
46
+ const pending = items.filter((i) => i.status === "in_progress" || i.status === "queued" || i.status === "waiting").length
47
+ const success = items.filter((i) => i.status === "completed" && i.conclusion === "success").length
48
+ const failed = items.filter((i) => i.status === "completed" && i.conclusion === "failure").length
49
+ const skipped = items.filter((i) => i.status === "completed" && (i.conclusion === "skipped" || i.conclusion === "cancelled")).length
50
+ const parts: string[] = []
51
+ if (pending) parts.push(`${pending} pending`)
52
+ if (success) parts.push(`${success} passed`)
53
+ if (failed) parts.push(`${failed} failed`)
54
+ if (skipped) parts.push(`${skipped} skipped`)
55
+ return parts.join(", ")
56
+ }
51
57
 
52
58
  async function readCache(filePath: string): Promise<CICache> {
53
- try {
54
- return readCacheText(await readFile(filePath, "utf8"))
55
- } catch {
56
- return emptyCache()
57
- }
59
+ try { return readCacheText(await readFile(filePath, "utf8")) }
60
+ catch { return emptyCache() }
58
61
  }
59
62
 
60
- /** Find this session's cache file via the registry (matched by PID) */
61
63
  async function discoverCachePath(registryPath: string): Promise<string | null> {
62
64
  const entries = await readRegistry(registryPath)
63
- const mine = entries.find((e) => e.pid === process.pid)
64
- return mine?.cachePath ?? null
65
+ return entries.find((e) => e.pid === process.pid)?.cachePath ?? null
65
66
  }
66
67
 
67
68
  // ---------------------------------------------------------------------------
68
- // Per-workflow row (stateless — toggle state lifted to parent)
69
+ // Job row
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const JobRow = (props: {
73
+ job: WorkflowJob; theme: any; nowSec: number; pulseFrame: number
74
+ indent: string; maxLen: number; rightAlign: boolean
75
+ }) => {
76
+ const elapsed = () => getJobElapsed(props.job, props.nowSec)
77
+ const isActive = () => props.job.status === "in_progress"
78
+ const dot = () => isActive() ? PULSE_FRAMES[props.pulseFrame % PULSE_FRAMES.length] : DOT
79
+ const nameLen = () => props.maxLen - props.indent.length
80
+
81
+ const label = () => (
82
+ <text flexGrow={props.rightAlign ? 1 : undefined}>
83
+ <span style={{ fg: dotColor(props.theme, props.job.status, props.job.conclusion) }}>
84
+ {props.indent}{dot()}
85
+ </span>
86
+ <span style={{ fg: props.theme.text }}>
87
+ {" "}{truncate(props.job.name, nameLen())}
88
+ </span>
89
+ {!props.rightAlign && elapsed() ? <span style={{ fg: props.theme.textMuted }}> {elapsed()}</span> : null}
90
+ </text>
91
+ )
92
+
93
+ if (!props.rightAlign) return label()
94
+
95
+ return (
96
+ <box flexDirection="row" width="100%" gap={0}>
97
+ {label()}
98
+ {elapsed() ? <text fg={props.theme.textMuted}>{elapsed()}</text> : null}
99
+ </box>
100
+ )
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Workflow row (multi-workflow mode)
69
105
  // ---------------------------------------------------------------------------
70
106
 
71
107
  const WorkflowRow = (props: {
72
- run: WorkflowRun
73
- theme: any
74
- nowSec: number
75
- pulseFrame: number
76
- expanded: boolean
77
- onToggle: () => void
108
+ run: WorkflowRun; theme: any; nowSec: number; pulseFrame: number
109
+ expanded: boolean; onToggle: () => void; maxLen: number; rightAlign: boolean
78
110
  }) => {
79
111
  const arrowColor = () => dotColor(props.theme, props.run.status, props.run.conclusion)
112
+ const visibleJobs = createMemo(() => props.expanded ? props.run.jobs : [])
80
113
 
81
114
  return (
82
115
  <box flexDirection="column" gap={0}>
@@ -86,33 +119,15 @@ const WorkflowRow = (props: {
86
119
  {props.expanded ? "▼" : "▶"}
87
120
  </span>
88
121
  <span style={{ fg: props.theme.text }}>
89
- {" "}{truncate(props.run.name, 28)}
122
+ {" "}{truncate(props.run.name, props.maxLen)}
90
123
  </span>
91
124
  </text>
92
- <Show when={props.expanded}>
93
- <Index each={props.run.jobs}>
94
- {(job) => {
95
- const elapsed = () => getJobElapsed(job(), props.nowSec)
96
- const isJobActive = () => job().status === "in_progress"
97
- const jobDot = () => isJobActive() ? PULSE_FRAMES[props.pulseFrame % PULSE_FRAMES.length] : DOT
98
- return (
99
- <box flexDirection="row" width="100%" gap={0}>
100
- <text flexGrow={1}>
101
- <span style={{ fg: dotColor(props.theme, job().status, job().conclusion) }}>
102
- {" "}{jobDot()}
103
- </span>
104
- <span style={{ fg: props.theme.text }}>
105
- {" "}{truncate(job().name, 22)}
106
- </span>
107
- </text>
108
- <Show when={elapsed()}>
109
- <text fg={props.theme.textMuted}>{elapsed()}</text>
110
- </Show>
111
- </box>
112
- )
113
- }}
114
- </Index>
115
- </Show>
125
+ <Index each={visibleJobs()}>
126
+ {(job) => (
127
+ <JobRow job={job()} theme={props.theme} nowSec={props.nowSec} pulseFrame={props.pulseFrame}
128
+ indent={" "} maxLen={props.maxLen} rightAlign={props.rightAlign} />
129
+ )}
130
+ </Index>
116
131
  </box>
117
132
  )
118
133
  }
@@ -121,59 +136,42 @@ const WorkflowRow = (props: {
121
136
  // Main sidebar card
122
137
  // ---------------------------------------------------------------------------
123
138
 
124
- const CICard = (props: { api: Api; theme: any; tuiPollMs: number; hide: HideFilters; collapseSingleWorkflow: boolean }) => {
139
+ const CICard = (props: {
140
+ api: Api; theme: any; tuiPollMs: number; hide: HideFilters
141
+ detail: DetailLevel; jobsDetail: JobsDetailOptions
142
+ maxNameLength: number; rightAlignElapsed: boolean
143
+ }) => {
125
144
  const [cache, setCache] = createSignal<CICache>(emptyCache())
126
145
  const [nowSec, setNowSec] = createSignal(Math.floor(Date.now() / 1000))
127
146
  const [collapsed, setCollapsed] = createSignal(false)
128
147
  const [pulseFrame, setPulseFrame] = createSignal(0)
129
148
  const [cachePath, setCachePath] = createSignal<string | null>(null)
130
-
131
- // Toggle state keyed by run ID — persists across cache updates
132
149
  const [expandedMap, setExpandedMap] = createSignal<Record<number, boolean>>({})
133
150
 
134
- const toggleRun = (runId: number) => {
135
- setExpandedMap((prev) => ({
136
- ...prev,
137
- [runId]: prev[runId] === undefined ? false : !prev[runId],
138
- }))
139
- }
140
-
141
- const isRunExpanded = (runId: number) => {
142
- const map = expandedMap()
143
- return map[runId] === undefined ? true : map[runId]
144
- }
151
+ const toggleRun = (id: number) =>
152
+ setExpandedMap((m) => ({ ...m, [id]: m[id] === undefined ? false : !m[id] }))
153
+ const isRunExpanded = (id: number) => expandedMap()[id] ?? true
145
154
 
146
155
  const registryPath = buildRegistryPath(props.api.state.path.directory)
147
156
 
148
157
  const load = async () => {
149
- // Discover cache path if not yet found
150
- let path = cachePath()
151
- if (!path) {
152
- path = await discoverCachePath(registryPath)
153
- if (path) setCachePath(path)
154
- }
155
- if (!path) return
156
-
157
- setCache(await readCache(path))
158
+ let p = cachePath()
159
+ if (!p) { p = await discoverCachePath(registryPath); if (p) setCachePath(p) }
160
+ if (p) setCache(await readCache(p))
158
161
  }
159
162
 
160
163
  onMount(() => {
161
164
  void load()
162
- const pollTimer = setInterval(() => void load(), props.tuiPollMs)
163
- const tickTimer = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000)
164
- onCleanup(() => {
165
- clearInterval(pollTimer)
166
- clearInterval(tickTimer)
167
- })
165
+ const poll = setInterval(() => void load(), props.tuiPollMs)
166
+ const tick = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000)
167
+ onCleanup(() => { clearInterval(poll); clearInterval(tick) })
168
168
  })
169
169
 
170
- // Pulse animation — only runs when something is in progress
171
170
  const hasActive = createMemo(() =>
172
171
  (cache().runs ?? []).some((r) =>
173
- r.status === "in_progress" || r.jobs.some((j) => j.status === "in_progress")
174
- )
172
+ r.status === "in_progress" || r.jobs.some((j) => j.status === "in_progress"),
173
+ ),
175
174
  )
176
-
177
175
  createEffect(() => {
178
176
  if (!hasActive()) return
179
177
  const id = setInterval(() => setPulseFrame((f) => f + 1), 500)
@@ -185,115 +183,99 @@ const CICard = (props: { api: Api; theme: any; tuiPollMs: number; hide: HideFilt
185
183
  const hasRuns = createMemo(() => runs().length > 0)
186
184
  const overall = createMemo(() => hasRuns() ? overallStatus(runs()) : null)
187
185
 
188
- const isSingleCollapsed = createMemo(() =>
189
- props.collapseSingleWorkflow && runs().length === 1
186
+ const canToggle = () => props.detail !== "overall"
187
+ const isSingle = createMemo(() =>
188
+ props.detail === "jobs" && props.jobsDetail.collapse_single_workflow && runs().length === 1,
190
189
  )
191
190
 
192
- function countItems(items: { status: string; conclusion: string | null }[]) {
193
- const pending = items.filter((i) => i.status === "in_progress" || i.status === "queued" || i.status === "waiting").length
194
- const success = items.filter((i) => i.status === "completed" && i.conclusion === "success").length
195
- const failed = items.filter((i) => i.status === "completed" && i.conclusion === "failure").length
196
- const skipped = items.filter((i) => i.status === "completed" && (i.conclusion === "skipped" || i.conclusion === "cancelled")).length
197
- const parts: string[] = []
198
- if (pending) parts.push(`${pending} pending`)
199
- if (success) parts.push(`${success} passed`)
200
- if (failed) parts.push(`${failed} failed`)
201
- if (skipped) parts.push(`${skipped} skipped`)
202
- return parts.join(", ")
203
- }
204
-
205
191
  const summary = createMemo(() => {
206
192
  const all = runs()
207
193
  if (!all.length) return ""
208
- const text = isSingleCollapsed() ? countItems(all[0].jobs) : countItems(all)
209
- return `(${text})`
194
+ return `(${isSingle() ? countItems(all[0].jobs) : countItems(all)})`
210
195
  })
211
196
 
212
- // Global status icon: checkmark, cross, or spinner
213
- const globalStatusIcon = createMemo(() => {
197
+ const globalIcon = createMemo(() => {
214
198
  const o = overall()
215
199
  if (!o) return { icon: "", color: "" }
216
200
  if (o.status === "in_progress" || o.status === "queued")
217
201
  return { icon: PULSE_FRAMES[pulseFrame() % PULSE_FRAMES.length], color: props.theme.warning }
218
- if (o.status === "completed" && o.conclusion === "success")
219
- return { icon: "\u2713", color: props.theme.success }
220
- if (o.status === "completed" && o.conclusion === "failure")
221
- return { icon: "\u2717", color: props.theme.error }
222
- if (o.status === "completed" && (o.conclusion === "skipped" || o.conclusion === "cancelled"))
223
- return { icon: "-", color: props.theme.textMuted }
224
- return { icon: "?", color: props.theme.textMuted }
202
+ if (o.conclusion === "success") return { icon: "\u2713", color: props.theme.success }
203
+ if (o.conclusion === "failure") return { icon: "\u2717", color: props.theme.error }
204
+ return { icon: "-", color: props.theme.textMuted }
205
+ })
206
+
207
+ // Computed arrays empty arrays produce zero DOM nodes
208
+ const statusMessages = createMemo(() => {
209
+ if (error()) return [{ text: ` ${error()}`, color: props.theme.error }]
210
+ if (!hasRuns()) return [{ text: " waiting...", color: props.theme.textMuted }]
211
+ return [] as { text: string; color: any }[]
225
212
  })
226
213
 
227
- // Flat list of displayable jobs for single-collapsed mode
214
+ // "workflows" detail: workflow names with status dots
215
+ const workflowRows = createMemo(() => {
216
+ if (props.detail !== "workflows" || !hasRuns() || collapsed()) return []
217
+ return runs()
218
+ })
219
+
220
+ // "jobs" detail with single workflow collapsed: flat job list
228
221
  const flatJobs = createMemo(() => {
229
- if (!hasRuns() || collapsed() || !isSingleCollapsed()) return []
222
+ if (props.detail !== "jobs" || !hasRuns() || collapsed() || !isSingle()) return []
230
223
  return runs()[0]?.jobs ?? []
231
224
  })
232
225
 
233
- // Runs to display in multi-workflow mode
226
+ // "jobs" detail with multiple workflows
234
227
  const displayRuns = createMemo(() => {
235
- if (!hasRuns() || collapsed() || isSingleCollapsed()) return []
228
+ if (props.detail !== "jobs" || !hasRuns() || collapsed() || isSingle()) return []
236
229
  return runs()
237
230
  })
238
231
 
239
- // Status messages as a computed array (empty = no node rendered)
240
- const statusMessages = createMemo(() => {
241
- if (error()) return [{ text: ` ${error()}`, color: props.theme.error }]
242
- if (!hasRuns()) return [{ text: " waiting...", color: props.theme.textMuted }]
243
- return [] as { text: string; color: any }[]
244
- })
245
-
246
232
  return (
247
233
  <box flexDirection="column" gap={0}>
248
- <box flexDirection="row" width="100%" gap={0} onMouseDown={() => hasRuns() && setCollapsed((c) => !c)}>
234
+ {/* Header */}
235
+ <box flexDirection="row" width="100%" gap={0} onMouseDown={() => canToggle() && hasRuns() && setCollapsed((c) => !c)}>
249
236
  <text flexGrow={1} fg={props.theme.text}>
250
237
  <Show when={hasRuns()} fallback={<b>CI</b>}>
251
238
  <span>
252
- {collapsed() ? "▶" : "▼"}{" "}<b>CI</b>
253
- <span style={{ fg: props.theme.textMuted }}>
254
- {" "}{summary()}
255
- </span>
239
+ {canToggle() ? (collapsed() ? "▶ " : "▼ ") : ""}<b>CI</b>
240
+ <span style={{ fg: props.theme.textMuted }}>{" "}{summary()}</span>
256
241
  </span>
257
242
  </Show>
258
243
  </text>
259
244
  <Show when={hasRuns()}>
260
- <text fg={globalStatusIcon().color}>{globalStatusIcon().icon}</text>
245
+ <text fg={globalIcon().color}>{globalIcon().icon}</text>
261
246
  </Show>
262
247
  </box>
248
+ {/* Status messages (error / waiting) */}
263
249
  <Index each={statusMessages()}>
264
250
  {(msg) => <text fg={msg().color}>{msg().text}</text>}
265
251
  </Index>
252
+ {/* Workflows detail */}
253
+ <Index each={workflowRows()}>
254
+ {(run) => (
255
+ <text>
256
+ <span style={{ fg: dotColor(props.theme, run().status, run().conclusion) }}>
257
+ {" "}{DOT}
258
+ </span>
259
+ <span style={{ fg: props.theme.text }}>
260
+ {" "}{truncate(run().name, props.maxNameLength)}
261
+ </span>
262
+ </text>
263
+ )}
264
+ </Index>
265
+ {/* Jobs detail: single workflow collapsed */}
266
266
  <Index each={flatJobs()}>
267
- {(job) => {
268
- const elapsed = () => getJobElapsed(job(), nowSec())
269
- const isJobActive = () => job().status === "in_progress"
270
- const jobDot = () => isJobActive() ? PULSE_FRAMES[pulseFrame() % PULSE_FRAMES.length] : DOT
271
- return (
272
- <box flexDirection="row" width="100%" gap={0}>
273
- <text flexGrow={1}>
274
- <span style={{ fg: dotColor(props.theme, job().status, job().conclusion) }}>
275
- {jobDot()}
276
- </span>
277
- <span style={{ fg: props.theme.text }}>
278
- {" "}{truncate(job().name, 28)}
279
- </span>
280
- </text>
281
- <Show when={elapsed()}>
282
- <text fg={props.theme.textMuted}>{elapsed()}</text>
283
- </Show>
284
- </box>
285
- )
286
- }}
267
+ {(job) => (
268
+ <JobRow job={job()} theme={props.theme} nowSec={nowSec()} pulseFrame={pulseFrame()}
269
+ indent={""} maxLen={props.maxNameLength} rightAlign={props.rightAlignElapsed} />
270
+ )}
287
271
  </Index>
272
+ {/* Jobs detail: multiple workflows */}
288
273
  <For each={displayRuns()}>
289
274
  {(run) => (
290
275
  <WorkflowRow
291
- run={run}
292
- theme={props.theme}
293
- nowSec={nowSec()}
294
- pulseFrame={pulseFrame()}
295
- expanded={isRunExpanded(run.id)}
296
- onToggle={() => toggleRun(run.id)}
276
+ run={run} theme={props.theme} nowSec={nowSec()} pulseFrame={pulseFrame()}
277
+ expanded={isRunExpanded(run.id)} onToggle={() => toggleRun(run.id)}
278
+ maxLen={props.maxNameLength} rightAlign={props.rightAlignElapsed}
297
279
  />
298
280
  )}
299
281
  </For>
@@ -313,15 +295,16 @@ const tui: TuiPlugin = async (api, options) => {
313
295
  order: 350,
314
296
  slots: {
315
297
  sidebar_content(ctx) {
316
- return <CICard api={api} theme={ctx.theme.current} tuiPollMs={opts.tui_poll_ms} hide={opts.hide} collapseSingleWorkflow={opts.collapse_single_workflow} />
298
+ return (
299
+ <CICard
300
+ api={api} theme={ctx.theme.current} tuiPollMs={opts.tui_poll_ms}
301
+ hide={opts.hide} detail={opts.detail} jobsDetail={opts.jobs_detail}
302
+ maxNameLength={opts.max_name_length} rightAlignElapsed={opts.right_align_elapsed}
303
+ />
304
+ )
317
305
  },
318
306
  },
319
307
  })
320
308
  }
321
309
 
322
- const plugin: TuiPluginModule & { id: string } = {
323
- id: PLUGIN_ID,
324
- tui,
325
- }
326
-
327
- export default plugin
310
+ export default { id: PLUGIN_ID, tui } satisfies TuiPluginModule & { id: string }