SLOPSHOPPER

pr-bar

The current branch's GitHub PR as a bar above the prompt or a panel, with one-press commit, review and CI actions

newpanebandcommandprocesstimer
v0.1.0MITupdated 2026-10-08gugahoi/claude-mods/plugins/pr-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-bar
│ ┃ Pull request ✕ › fix the failing auth test and add an audit log call │ ┃ No pull request here: not a git repo, the │ ┃ default branch, or gh is unavailable. ⏺ 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 │ │ › /pr │ ⎿ pr-bar: Pull request panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pull request
No pull request here: not a git repo, the default branch, or gh is unavailable.
README

pr-bar

A Claude Code mod that shows the GitHub pull request for the session's current git branch in a bar above the prompt box, with its actions as buttons under it.

 PR #128 · draft   ✗ 1 failed   2 unresolved  changes requested  feat(app): let pull…
[ Check feedback ] [ Fix CI ]
> _

The bar is a full-width grey row; the PR state and CI result are coloured pills on it: draft grey, open green, merged purple, closed red; CI green, amber while pending, red on a failure. The title links to the PR and is cut to fit the row.

Install

At the prompt of a Claude Code terminal session:

/plugin install pr-bar --marketplace gugahoi/claude-mods

Answer y to add the marketplace, then pick a scope.

Needs the GitHub CLI installed and signed in (gh auth login).

What it shows

  • No PR for the branch: the branch name and a Commit + draft PR button.
  • A PR: its number (a link) and title, draft / open / merged / closed, a CI summary (✓ passed, ✗ N failed, … N pending), the review decision, and how many review threads are unresolved.
  • Nothing outside a git repo, on a detached HEAD, on master/main, or when gh is missing or failing.

The buttons show only when they apply, and each submits a prompt:

ButtonWhenPrompt
Commit + draft PR (c)no PR yet, or uncommitted changesCommit the changes, push, and open a draft PR
Check feedback (f)open PR with unresolved threadsI've received feedback on PR #n. Check each unresolved review comment: is it correct and do we need to fix it?
Fix CI (x)open PR with a failed checkCI is failing on PR #n. Find out why and fix it.

Click a button, or focus the band (ctrl+x tab) and press its letter: c, f or x. The bar steps aside while a survey uses the band.

The panel: /pr

/pr opens the same PR in a panel, docked beside the transcript in fullscreen or above the prompt otherwise, with room for the detail the bar leaves out:

 PR #128 · draft   ✗ 1 failed   2 unresolved
feat(app): let pull to refresh start on the home screen on iOS and Android
feat/pull-to-refresh · changes requested

Checks
✗ App / test
… App / deploy
✓ 14 passed

Unresolved threads (2)
src/feed/useVideoPreloader.ts:134 @reviewer
  Should this guard against a missing ref?

[ Check feedback ] [ Fix CI ]

Every check and thread is a link to it on GitHub. The buttons are the bar's. /pr opens the panel with the keyboard, so c, f and x press them at once (unless you have text in the prompt); Esc hands the keyboard back to the prompt, ctrl+x tab returns to the panel, and ctrl+x x closes it.

Choose a layout

The layout setting picks where the PR shows:

  • bar (default): the bar above the prompt. /pr opens the panel when you want the detail.
  • panel: no bar. The panel opens by itself when a session starts on a branch with something to show, and /pr reopens it after you close it.

Change it in /config (the pr-bar Layout row); the mod reloads with the new layout. /plugin install also asks for it.

How it refreshes

It runs git rev-parse, git status --porcelain, gh pr view --json ... and one gh api graphql query for the review threads when the session starts, after each turn (Claude may have pushed or switched branch), and every 60 seconds so CI results arrive while the session is idle. One fetch runs at a time.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 2 files
hooks/register.tsx 293 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { PrBarCheck, PrBarChecks, PrBarPr, PrBarSnapshot, PrBarThread } from '../types'
5
6const snapshot = atom({ plugin: 'pr-bar', key: 'snapshot' } as const, null)
7const PANE = 'pr'
8
9const FIELDS = 'number,title,state,isDraft,url,statusCheckRollup,reviewDecision'
10// ponytail: first 100 threads only; paginate if a PR ever has more
11const THREADS =
12  'query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved path line comments(first:1){nodes{author{login} body url}}}}}}}'
13
14// The buckets `gh pr checks` uses. A CheckRun carries `conclusion` ('' while it runs), a StatusContext `state`.
15const FAILED = ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
16const PASSED = ['SUCCESS', 'NEUTRAL', 'SKIPPED']
17const ORDER = { failed: 0, pending: 1, passed: 2 } as const
18
19type GhCheck = {
20  conclusion?: string
21  state?: string
22  name?: string
23  workflowName?: string
24  context?: string
25  detailsUrl?: string
26  targetUrl?: string
27}
28type GhView = {
29  number: number
30  title: string
31  url: string
32  state: 'OPEN' | 'MERGED' | 'CLOSED'
33  isDraft: boolean
34  reviewDecision: string
35  statusCheckRollup: GhCheck[]
36}
37type GhThread = {
38  isResolved: boolean
39  path: string
40  line: number | null
41  comments: { nodes: { author: { login: string } | null; body: string; url: string }[] }
42}
43
44const bucket = (c: GhCheck): keyof PrBarChecks => {
45  const s = c.conclusion || c.state || ''
46  return FAILED.includes(s) ? 'failed' : PASSED.includes(s) ? 'passed' : 'pending'
47}
48
49export function summarise(rollup: readonly GhCheck[]): PrBarChecks {
50  const sum = { passed: 0, failed: 0, pending: 0 }
51  for (const c of rollup) sum[bucket(c)] += 1
52  return sum
53}
54
55export const ci = ({ passed, failed, pending }: PrBarChecks) =>
56  failed ? `✗ ${failed} failed` : pending ? `… ${pending} pending` : passed ? '✓ passed' : 'no checks'
57
58const STATE_BG = { draft: 'inactive', open: 'success', merged: 'merged', closed: 'error' } as const
59const ciBg = ({ passed, failed, pending }: PrBarChecks) =>
60  failed ? 'error' : pending ? 'warning' : passed ? 'success' : 'inactive'
61
62export function buttons({ isDirty, pr }: PrBarSnapshot) {
63  const out: { key: string; label: string; hotkey: string; prompt: string }[] = []
64  const isLive = pr?.state === 'draft' || pr?.state === 'open'
65  if (!pr || isDirty)
66    out.push({ key: 'commit', label: 'Commit + draft PR', hotkey: 'c', prompt: 'Commit the changes, push, and open a draft PR' })
67  if (pr && isLive && pr.threads.length > 0)
68    out.push({
69      key: 'feedback',
70      label: 'Check feedback',
71      hotkey: 'f',
72      prompt: `I've received feedback on PR #${pr.number}. Check each unresolved review comment: is it correct and do we need to fix it?`,
73    })
74  if (pr && isLive && pr.checks.failed > 0)
75    out.push({ key: 'ci', label: 'Fix CI', hotkey: 'x', prompt: `CI is failing on PR #${pr.number}. Find out why and fix it.` })
76  return out
77}
78
79export async function load($: EngineInterface): Promise<PrBarSnapshot | null> {
80  const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
81  const branch = head.stdout.trim()
82  // ponytail: default branch by name, and gh is skipped there (no PR is ever opened from it)
83  if (head.exitCode !== 0 || ['HEAD', 'master', 'main'].includes(branch)) return null
84  const [status, view] = await Promise.all([
85    $.process.run(['git', 'status', '--porcelain']),
86    $.process.run(['gh', 'pr', 'view', '--json', FIELDS]),
87  ])
88  const isDirty = status.stdout.trim() !== ''
89  if (view.exitCode !== 0) return /no pull requests found/i.test(view.stderr) ? { branch, isDirty, pr: null } : null
90  const pr: GhView = JSON.parse(view.stdout)
91  const threads = await $.process.run([
92    'gh', 'api', 'graphql',
93    '-F', 'owner={owner}', '-F', 'repo={repo}', '-F', `number=${pr.number}`, '-f', `query=${THREADS}`,
94  ])
95  if (threads.exitCode !== 0) return null
96  const nodes: GhThread[] = JSON.parse(threads.stdout).data.repository.pullRequest.reviewThreads.nodes
97  const checkRuns: PrBarCheck[] = pr.statusCheckRollup
98    .map(c => ({
99      name: c.workflowName ? `${c.workflowName} / ${c.name}` : (c.name ?? c.context ?? 'check'),
100      result: bucket(c),
101      url: c.detailsUrl ?? c.targetUrl ?? pr.url,
102    }))
103    .sort((a, b) => ORDER[a.result] - ORDER[b.result])
104  const open: PrBarThread[] = nodes
105    .filter(n => !n.isResolved)
106    .map(n => {
107      const first = n.comments.nodes[0]
108      return {
109        path: n.path,
110        line: n.line,
111        author: first?.author?.login ?? 'ghost',
112        body: first?.body.split('\n').find(l => l.trim())?.trim() ?? '',
113        url: first?.url ?? pr.url,
114      }
115    })
116  return {
117    branch,
118    isDirty,
119    pr: {
120      number: pr.number,
121      title: pr.title,
122      url: pr.url,
123      state: pr.state === 'OPEN' && pr.isDraft ? 'draft' : (pr.state.toLowerCase() as PrBarPr['state']),
124      checks: summarise(pr.statusCheckRollup),
125      checkRuns,
126      review: pr.reviewDecision.toLowerCase().replaceAll('_', ' '),
127      threads: open,
128    },
129  }
130}
131
132let isRunning = false
133let isQueued = false
134
135// One fetch at a time; a refresh asked for mid-fetch runs once more after it, so a push isn't missed.
136// Resolves the snapshot it stored, or undefined when it only queued a rerun.
137async function refresh($: EngineInterface) {
138  if (isRunning) return void (isQueued = true)
139  isRunning = true
140  let snap: PrBarSnapshot | null = null
141  try {
142    do {
143      isQueued = false
144      snap = await load($).catch(() => null) // gh or git missing: draw nothing
145      await update($, snapshot, () => snap)
146    } while (isQueued)
147  } finally {
148    isRunning = false
149  }
150  return snap
151}
152
153// Drawing helpers shared by the bar and the panel.
154type Els = ElementTable
155
156const pill = (Text: Els['Text'], bg: string, text: string) => (
157  <Text backgroundColor={bg} color="inverseText" bold>{` ${text} `}</Text>
158)
159
160// A list, not a fragment: `<>...</>` is a column Box here.
161const pills = (Text: Els['Text'], pr: PrBarPr) => [
162  pill(Text, STATE_BG[pr.state], `PR #${pr.number} · ${pr.state}`),
163  pill(Text, ciBg(pr.checks), ci(pr.checks)),
164  pr.threads.length ? pill(Text, 'warning', `${pr.threads.length} unresolved`) : null,
165]
166
167const actions = ($: EngineInterface, { Box, Button }: Els, snap: PrBarSnapshot, marginTop = 0) => {
168  const list = buttons(snap)
169  return list.length > 0 ? (
170    <Box gap={1} marginTop={marginTop}>
171      {list.map(b => (
172        <Button key={b.key} label={b.label} hotkey={b.hotkey} onPress={() => $.prompt.submit({ text: b.prompt, asUser: true })} />
173      ))}
174    </Box>
175  ) : null
176}
177
178export const register: Register = (on, options) => {
179  const isPanel = options.layout === 'panel'
180
181  on('session.start', async ($, e, next) => {
182    await $.command.register({ name: 'pr', description: "Show this branch's pull request in a panel" })
183    // The panel layout opens the panel once, when the branch has something to show.
184    void refresh($).then(snap => (isPanel && snap ? $.ui.open({ id: PANE, title: 'Pull request' }) : undefined))
185    $.clock.every(60_000, () => void refresh($)) // CI moves while the session is idle
186    return next(e)
187  })
188
189  on('turn.complete', ($, e, next) => {
190    if (!e.agentId) void refresh($) // Claude may have committed, pushed or switched branch
191    return next(e)
192  })
193
194  // Asked for, so it asks for the keyboard too: its buttons' letters work at once.
195  on('command.run', { command: 'pr' }, async $ => {
196    void refresh($)
197    await $.ui.open({ id: PANE, title: 'Pull request', focus: true })
198    return { text: 'Pull request panel opened.' }
199  })
200
201  // Above the prompt (the bar layout): the PR as a row of pills, its actions
202  // under it. A band Button takes its hotkey once the band has focus.
203  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
204    const snap = await read($, snapshot)
205    if (isPanel || !snap || e.props.hasSurvey) return next(e)
206    const els = $.ui.resolve(e)
207    const { Box, Text, Link } = els
208    const { pr } = snap
209    const bar = pr
210      ? [
211          ...pills(Text, pr),
212          pr.review ? <Text>{pr.review}</Text> : null,
213          <Box flexShrink={1}>
214            <Text wrap="truncate-end">
215              <Link href={pr.url} label={pr.title} />
216            </Text>
217          </Box>,
218        ]
219      : [pill(Text, 'inactive', 'no PR'), <Text>{snap.branch}</Text>]
220    return (
221      <Box flexDirection="column">
222        <Box flexDirection="row" gap={1} width={e.props.bodyColumns} backgroundColor="subtle">
223          {bar}
224        </Box>
225        {actions($, els, snap)}
226      </Box>
227    )
228  })
229
230  // The panel: the same PR with each check that isn't green and each unresolved
231  // thread listed, every one a link. A Button takes its hotkey once the panel has focus.
232  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233    const snap = await read($, snapshot)
234    const els = $.ui.resolve(e)
235    const { Box, Text, Link } = els
236    if (!snap) return <Text dimColor>No pull request here: not a git repo, the default branch, or gh is unavailable.</Text>
237    const { pr } = snap
238    if (!pr)
239      return (
240        <Box flexDirection="column">
241          <Box gap={1}>{[pill(Text, 'inactive', 'no PR'), <Text>{snap.branch}</Text>]}</Box>
242          {actions($, els, snap, 1)}
243        </Box>
244      )
245
246    const heading = (text: string) => (
247      <Box marginTop={1}>
248        <Text bold>{text}</Text>
249      </Box>
250    )
251    const notGreen = pr.checkRuns.filter(c => c.result !== 'passed')
252    return (
253      <Box flexDirection="column">
254        <Box gap={1}>{pills(Text, pr)}</Box>
255        <Box marginTop={1}>
256          <Text bold wrap="truncate-end">
257            <Link href={pr.url} label={pr.title} />
258          </Text>
259        </Box>
260        <Text dimColor wrap="truncate-end">
261          {[snap.branch, pr.review].filter(Boolean).join(' · ')}
262        </Text>
263
264        {heading('Checks')}
265        {notGreen.map(c => (
266          <Text color={c.result === 'failed' ? 'error' : 'warning'} wrap="truncate-end">
267            {c.result === 'failed' ? '✗ ' : '… '}
268            <Link href={c.url} label={c.name} />
269          </Text>
270        ))}
271        {pr.checks.passed ? <Text color="success">✓ {pr.checks.passed} passed</Text> : null}
272        {pr.checkRuns.length === 0 ? <Text dimColor>No checks</Text> : null}
273
274        {pr.threads.length ? heading(`Unresolved threads (${pr.threads.length})`) : null}
275        {pr.threads.map(t => (
276          <Box flexDirection="column">
277            <Text wrap="truncate-start">
278              <Link href={t.url} label={t.line ? `${t.path}:${t.line}` : t.path} />
279              <Text dimColor> @{t.author}</Text>
280            </Text>
281            <Text dimColor wrap="truncate-end">
282              {'  '}
283              {t.body}
284            </Text>
285          </Box>
286        ))}
287
288        {actions($, els, snap, 1)}
289      </Box>
290    )
291  })
292}
293
types/index.d.ts 30 lines
1export type PrBarChecks = { passed: number; failed: number; pending: number }
2
3/** One check run or status, bucketed as `gh pr checks` does. */
4export type PrBarCheck = { name: string; result: keyof PrBarChecks; url: string }
5
6/** An unresolved review thread and its first comment's first line. */
7export type PrBarThread = { path: string; line: number | null; author: string; body: string; url: string }
8
9export type PrBarPr = {
10  number: number
11  title: string
12  url: string
13  state: 'draft' | 'open' | 'merged' | 'closed'
14  checks: PrBarChecks
15  /** every check, failed first, then pending, then passed */
16  checkRuns: PrBarCheck[]
17  /** reviewDecision in words ("review required"), '' when GitHub has none */
18  review: string
19  threads: PrBarThread[]
20}
21
22/** null: draw nothing (not a repo, detached, default branch, gh failing) */
23export type PrBarSnapshot = { branch: string; isDirty: boolean; pr: PrBarPr | null }
24
25declare module 'claude-code' {
26  interface PluginState {
27    'pr-bar': { snapshot: PrBarSnapshot | null }
28  }
29}
30