SLOPSHOPPER

ci-line

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

newbandguardcommandprocesstimer
v0.1.0no licenseupdated 2026-10-06Halvanhelv/claude-ci-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ci-line
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ci ⎿ ci-line: CI line: jobs shown. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ci-line

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.

What it looks like

The pictures are drawn from the mod's own layout code with sample data.

All green. One line, the names of green checkpoints dimmed.

All green

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.

Two workflows running

Failed. The same, with the failed jobs named.

A workflow failed

Expanded with /ci. Every workflow open, every job named.

Expanded

Not pushed yet. ≠HEAD says the line is about the last pushed commit, not your local HEAD.

Local HEAD is not the commit CI ran on

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.

Commit statuses and outside checks

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.

Narrow terminal

Fullscreen. Where the terminal passes clicks on, ▸ is a button and the /ci to expand hint is gone.

Fullscreen

Reading the line

GlyphColorMeaning
●greensuccess
✗redfailed
◐yellowrunning
○yellowqueued, or a commit status still pending
⊘dimmedcancelled
◌dimmedskipped
  • 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.
  • A leading number in a workflow name is dropped: 04. Validate reads Validate.
  • A job named lint / rubocop goes into the group lint as rubocop. A job with no / has no group.
  • When a workflow ran more than once on the commit, its latest run is the one shown.

Setup

You need:

  • Claude Code with plugins.
  • The gh CLI, signed in: gh auth status should say so.
  • A repository with a GitHub remote, on a branch (not a detached HEAD).

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.

Commands

CommandWhat it does
/ciReads 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 lineThe same toggle as a button, without the refresh. Works where the terminal passes clicks on (fullscreen).

Managing the plugin itself:

CommandWhat it does
claude plugin update ci-line@claude-ci-lineTake the latest version.
claude plugin disable ci-line@claude-ci-lineTurn the line off, keep it installed.
claude plugin enable ci-line@claude-ci-lineTurn it back on.
claude plugin uninstall ci-line@claude-ci-lineRemove it.
/reload-pluginsPick up any of the above in a running session.

When it refreshes

  • every 15 seconds while a workflow is queued or running, and for 3 minutes after a commit or push while its runs have yet to appear
  • every 2 minutes otherwise
  • on the next tick after the branch, HEAD or the upstream ref changes
  • 5 seconds after a git push or a gh pr|run|workflow command run through Bash
  • at once on /ci

Nothing shows

The band stays empty when:

  • gh is missing or not signed in, or there is no network
  • the directory is not a GitHub repository, or HEAD is detached
  • nothing has reported on the commit yet (and the branch has no pushed commit with CI)

A read that fails keeps the last line instead of clearing it.

Not shown

  • Review state.
  • Checks a branch protection requires that never reported.
  • Runs on other branches (a deploy after merge).
  • A newer commit someone else pushed, while your local HEAD has CI of its own: the line stays on HEAD until you pull.

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 199 lines
1import { 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}
199
hooks/layout.ts 96 lines
1import 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}
96
types/index.d.ts 14 lines
1export 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