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