CI state of the current branch above the prompt: a line of checkpoints, branching into jobs when something runs or fails

A Claude Code mod that shows the CI state of the current branch in the band above the prompt.
One checkpoint per workflow of the commit. Green stays one line; a workflow that is running or has failed opens into its jobs.
The pictures are drawn from the mod's own layout code with sample data.
All green. One line, the names of green checkpoints dimmed.
Running. A workflow that is queued or running hangs its jobs under its checkpoint, grouped by the group / job prefix. Finished green jobs are dots without names; the ones still going are named.
Failed. The same, with the failed jobs named.
Expanded with /ci. Every workflow open, every job named.
Not pushed yet. ≠HEAD says the line is about the last pushed commit, not your local HEAD.
Checks from outside Actions. Commit statuses and other apps' check runs follow the workflows as checkpoints of their own. They have no jobs to open.
Narrow terminal. A line wider than the band drops the names of its green checkpoints, so the one that needs a look is never the part cut off.
Fullscreen. Where the terminal passes clicks on, ▸ is a button and the /ci to expand hint is gone.
| Glyph | Color | Meaning |
|---|---|---|
| green | success | |
| red | failed | |
| yellow | running | |
| yellow | queued, or a commit status still pending | |
| dimmed | cancelled | |
| dimmed | skipped |
CI a41c9e2 is the commit the line is about.≠HEAD after it: your local HEAD has nothing reported yet, so the line shows the last pushed commit.04. Validate reads Validate.lint / rubocop goes into the group lint as rubocop. A job with no / has no group.You need:
gh CLI, signed in: gh auth status should say so.Install from a terminal:
claude plugin marketplace add Halvanhelv/claude-ci-line
claude plugin install ci-line@claude-ci-line
or inside Claude Code:
/plugin marketplace add Halvanhelv/claude-ci-line
/plugin install ci-line@claude-ci-line
Then start a new session, or run /reload-plugins in the one you have. The line appears once the commit has something reported on it. There is nothing to configure.
To check it is on: claude plugin list shows ci-line@claude-ci-line as enabled.
| Command | What it does |
|---|---|
/ci | Reads CI again at once and opens every workflow with every job named. Run it again to close them back to running and failed only. |
▸ / ▾ at the end of the line | The same toggle as a button, without the refresh. Works where the terminal passes clicks on (fullscreen). |
Managing the plugin itself:
| Command | What it does |
|---|---|
claude plugin update ci-line@claude-ci-line | Take the latest version. |
claude plugin disable ci-line@claude-ci-line | Turn the line off, keep it installed. |
claude plugin enable ci-line@claude-ci-line | Turn it back on. |
claude plugin uninstall ci-line@claude-ci-line | Remove it. |
/reload-plugins | Pick up any of the above in a running session. |
git push or a gh pr|run|workflow command run through Bash/ciThe band stays empty when:
gh is missing or not signed in, or there is no networkA read that fails keeps the last line instead of clearing it.
claude --plugin-dir .
claude plugin validate .
claude plugin test .hooks/register.tsx 199 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ci, Job, Run } from '../types'
5import { layout, statusState, toState } from './layout'
6import type { Tone } from './layout'
7
8const TICK_MS = 15_000
9const IDLE_MS = 120_000
10const HOT_MS = 180_000
11
12const ci = atom({ plugin: 'ci-line', key: 'ci' } as const, null)
13const isExpanded = atom({ plugin: 'ci-line', key: 'isExpanded' } as const, false)
14
15type GhRun = {
16 id: number
17 name: string
18 status: string
19 conclusion: string | null
20 run_attempt: number
21 created_at: string
22}
23type GhJob = { name: string; status: string; conclusion: string | null }
24type GhCheck = GhJob & { app: { slug: string } | null }
25type GhStatus = { context: string; state: string }
26
27const COLOR: Partial<Record<Tone, string>> = { ok: 'success', fail: 'error', run: 'warning', wait: 'warning' }
28const DIM: Tone[] = ['skip', 'stop', 'dim']
29
30let isFetching = false
31let isActive = false
32let fetchedAt = 0
33let hotUntil = 0
34let lastKey = ''
35let lastJson = ''
36const doneJobs = new Map<string, Job[]>()
37
38// Throws when gh fails, so a failed read keeps the line as it was instead of drawing it green.
39const gh = async <T,>($: EngineInterface, argv: string[]): Promise<T> => {
40 const ran = await $.process.run(['gh', ...argv])
41 if (ran.exitCode !== 0) throw new Error(ran.stderr)
42
43 return JSON.parse(ran.stdout) as T
44}
45
46const jobsOf = async ($: EngineInterface, run: GhRun): Promise<Job[]> => {
47 const attempt = `${run.id}:${run.run_attempt}`
48 const kept = doneJobs.get(attempt)
49 if (kept !== undefined) return kept
50
51 const view = await gh<{ jobs: GhJob[] }>($, ['run', 'view', String(run.id), '--json', 'jobs'])
52 const jobs = view.jobs.map(job => ({ name: job.name, state: toState(job.status, job.conclusion) }))
53 if (run.status === 'completed') doneJobs.set(attempt, jobs)
54
55 return jobs
56}
57
58// Everything that reports on one commit: its workflow runs, whatever branch or event
59// started them, then other apps' check runs and commit statuses.
60const runsOf = async ($: EngineInterface, sha: string): Promise<Run[]> => {
61 const commit = `repos/{owner}/{repo}/commits/${sha}`
62 const [actions, checks, combined] = await Promise.all([
63 gh<{ workflow_runs: GhRun[] }>($, ['api', `repos/{owner}/{repo}/actions/runs?head_sha=${sha}&per_page=100`]),
64 gh<{ check_runs: GhCheck[] }>($, ['api', `${commit}/check-runs?per_page=100`]),
65 gh<{ statuses: GhStatus[] }>($, ['api', `${commit}/status?per_page=100`]),
66 ])
67 const latest = new Map<string, GhRun>()
68 for (const run of actions.workflow_runs) {
69 if (!latest.has(run.name)) latest.set(run.name, run)
70 }
71 const picked = [...latest.values()].sort((a, b) => a.created_at.localeCompare(b.created_at))
72 const workflows = await Promise.all(
73 picked.map(async run => ({
74 id: run.id,
75 name: run.name.replace(/^\d+\.\s*/, ''),
76 state: toState(run.status, run.conclusion),
77 jobs: await jobsOf($, run),
78 })),
79 )
80 const foreign = checks.check_runs.filter(check => check.app?.slug !== 'github-actions')
81
82 return [
83 ...workflows,
84 ...foreign.map(check => ({ id: 0, name: check.name, state: toState(check.status, check.conclusion), jobs: [] })),
85 ...combined.statuses.map(one => ({ id: 0, name: one.context, state: statusState(one.state), jobs: [] })),
86 ]
87}
88
89// HEAD's CI when it has any; otherwise the last pushed commit's, marked as not HEAD.
90const load = async ($: EngineInterface, head: string, pushed: string): Promise<Ci | null> => {
91 const own = await runsOf($, head)
92 if (own.length > 0) return { sha: head, isHead: true, runs: own }
93 if (pushed === '' || pushed === head) return null
94
95 const runs = await runsOf($, pushed)
96
97 return runs.length > 0 ? { sha: pushed, isHead: false, runs } : null
98}
99
100const refresh = async ($: EngineInterface, isForced = false) => {
101 if (isFetching) return
102 isFetching = true
103 try {
104 const git = await $.process.run(['git', 'rev-parse', 'HEAD', '--abbrev-ref', 'HEAD'])
105 const [head = '', branch = ''] = git.stdout.trim().split('\n')
106 const upstream = await $.process.run(['git', 'rev-parse', '--verify', '-q', '@{u}'])
107 const pushed = upstream.exitCode === 0 ? upstream.stdout.trim() : ''
108 const key = git.exitCode === 0 && branch !== 'HEAD' ? `${branch}@${head}@${pushed}` : ''
109 const now = await $.clock.now()
110 // A new commit or push: its runs take a while to appear, so keep polling fast for them.
111 if (key !== lastKey) hotUntil = now + HOT_MS
112 const isDue = isForced || key !== lastKey || isActive || now < hotUntil || now - fetchedAt >= IDLE_MS
113 if (!isDue) return
114
115 lastKey = key
116 fetchedAt = now
117 const next = key === '' ? null : await load($, head, pushed)
118 isActive = next?.runs.some(run => run.id !== 0 && (run.state === 'run' || run.state === 'wait')) ?? false
119 if (next?.isHead === true && next.runs.some(run => run.id !== 0)) hotUntil = 0
120
121 const json = JSON.stringify(next)
122 if (json !== lastJson) {
123 lastJson = json
124 await update($, ci, () => next)
125 }
126 } catch {
127 // gh missing, offline, not a GitHub repo: keep what is shown and try again later.
128 } finally {
129 isFetching = false
130 }
131}
132
133export const register: Register = on => {
134 on('session.start', async ($, e, next) => {
135 await $.command.register({
136 name: 'ci',
137 description: 'Refresh the CI line and toggle its jobs open or closed',
138 })
139 $.clock.every(TICK_MS, () => void refresh($))
140 void refresh($, true)
141
142 return next(e)
143 })
144
145 on('command.run', { command: 'ci' }, async $ => {
146 const isOpen = await update($, isExpanded, was => !was)
147 await refresh($, true)
148
149 return { text: isOpen ? 'CI line: jobs shown.' : 'CI line: jobs shown only while running or failed.' }
150 })
151
152 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
153 const ran = await next(e)
154 if (/\bgit\s+push\b|\bgh\s+(pr|run|workflow)\b/.test(e.command)) {
155 hotUntil = (await $.clock.now()) + HOT_MS
156 $.clock.after(5_000, () => void refresh($, true))
157 }
158
159 return ran
160 })
161
162 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
163 const state = await read($, ci)
164 if (e.props.hasSurvey || state === null || state.runs.length === 0) {
165 return next(e)
166 }
167
168 const isOpen = await read($, isExpanded)
169 const { Box, Button, Text } = $.ui.resolve(e)
170 const isClickable = e.viewport?.isFullscreen === true
171 const columns = (e.props.bodyColumns ?? e.viewport?.columns ?? 200) - (isClickable ? 3 : 19)
172
173 return (
174 <Box flexDirection="column">
175 {layout(state, isOpen, columns).map((row, at) => (
176 <Box>
177 <Text wrap="truncate-end">
178 {row.map(seg => (
179 <Text color={COLOR[seg.tone]} dimColor={DIM.includes(seg.tone)}>
180 {seg.text}
181 </Text>
182 ))}
183 </Text>
184 {at === 0 && (
185 <Button
186 key="toggle"
187 plain
188 label={isOpen ? ' ▾' : ' ▸'}
189 onPress={() => update($, isExpanded, was => !was)}
190 />
191 )}
192 {at === 0 && !isClickable && <Text dimColor> /ci to {isOpen ? 'collapse' : 'expand'}</Text>}
193 </Box>
194 ))}
195 </Box>
196 )
197 })
198}
199hooks/layout.ts 96 lines1import type { Ci, Job, Run, State } from '../types'
2
3export type Tone = State | 'dim' | 'plain'
4export type Seg = { text: string; tone: Tone }
5
6const GLYPH: Record<State, string> = {
7 ok: '●',
8 fail: '✗',
9 run: '◐',
10 wait: '○',
11 skip: '◌',
12 stop: '⊘',
13}
14
15const isQuiet = (state: State) => state === 'ok' || state === 'skip'
16
17export const toState = (status: string, conclusion: string | null): State => {
18 if (status !== 'completed') return status === 'in_progress' ? 'run' : 'wait'
19 if (conclusion === 'success') return 'ok'
20 if (conclusion === 'skipped' || conclusion === 'neutral') return 'skip'
21 if (conclusion === 'cancelled') return 'stop'
22
23 return 'fail'
24}
25
26export const statusState = (state: string): State =>
27 state === 'success' ? 'ok' : state === 'pending' ? 'wait' : 'fail'
28
29// "lint / rubocop" -> group "lint", job "rubocop"; a job with no slash has no group.
30const groupJobs = (jobs: Job[]): [string, Job[]][] => {
31 const groups = new Map<string, Job[]>()
32 for (const job of jobs) {
33 const cut = job.name.indexOf(' / ')
34 const label = cut === -1 ? '' : job.name.slice(0, cut)
35 const name = cut === -1 ? job.name : job.name.slice(cut + 3)
36 groups.set(label, [...(groups.get(label) ?? []), { ...job, name }])
37 }
38
39 return [...groups]
40}
41
42const rail = (rails: number[], width: number) =>
43 Array.from({ length: width }, (_, at) => (rails.includes(at) ? '│' : ' ')).join('')
44
45// First row: the line of checkpoints, one per workflow. Under it, right to left,
46// each open workflow hangs its jobs from its own checkpoint.
47// A line wider than the band drops the names of its green checkpoints, so the ones
48// that need a look are never the part cut off.
49export const layout = (ci: Ci, isExpanded: boolean, columns = Infinity): Seg[][] => {
50 const line: Seg[] = [
51 { text: 'CI ', tone: 'dim' },
52 { text: ci.sha.slice(0, 7), tone: 'plain' },
53 { text: ci.isHead ? ' ' : ' ≠HEAD ', tone: 'dim' },
54 ]
55 let column = line.reduce((sum, seg) => sum + seg.text.length, 0)
56 const full = ci.runs.reduce((sum, run) => sum + 2 + run.name.length, column + 4 * (ci.runs.length - 1))
57 const label = (run: Run) => (full > columns && isQuiet(run.state) ? '' : ` ${run.name}`)
58 const open: { column: number; run: Run }[] = []
59
60 ci.runs.forEach((run, at) => {
61 if (at > 0) {
62 line.push({ text: ' ── ', tone: 'dim' })
63 column += 4
64 }
65 if (run.jobs.length > 0 && (isExpanded || !isQuiet(run.state))) {
66 open.push({ column, run })
67 }
68 line.push(
69 { text: GLYPH[run.state], tone: run.state },
70 { text: label(run), tone: isQuiet(run.state) ? 'dim' : 'plain' },
71 )
72 column += 1 + label(run).length
73 })
74
75 const rows = [line]
76 for (let at = open.length - 1; at >= 0; at -= 1) {
77 const { column: under, run } = open[at]!
78 const rails = open.slice(0, at).map(one => one.column)
79 const groups = groupJobs(run.jobs)
80
81 groups.forEach(([label, jobs], g) => {
82 const fork = g === groups.length - 1 ? '└─' : '├─'
83 const row: Seg[] = [{ text: rail(rails, under) + fork, tone: 'dim' }]
84 if (label !== '') row.push({ text: ` ${label}`, tone: 'plain' })
85 for (const job of jobs) {
86 const isNamed = isExpanded || !isQuiet(job.state)
87 row.push({ text: ` ${GLYPH[job.state]}`, tone: job.state })
88 if (isNamed) row.push({ text: ` ${job.name}`, tone: isQuiet(job.state) ? 'dim' : 'plain' })
89 }
90 rows.push(row)
91 })
92 }
93
94 return rows
95}
96types/index.d.ts 14 lines1export type State = 'ok' | 'fail' | 'run' | 'wait' | 'skip' | 'stop'
2
3export type Job = { name: string; state: State }
4
5export type Run = { id: number; name: string; state: State; jobs: Job[] }
6
7export type Ci = { sha: string; isHead: boolean; runs: Run[] }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'ci-line': { ci: Ci | null; isExpanded: boolean }
12 }
13}
14