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

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.
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).
✓ passed, ✗ N failed, … N pending), the review decision, and how many review threads are unresolved.master/main, or when gh is missing or failing.The buttons show only when they apply, and each submits a prompt:
| Button | When | Prompt |
|---|---|---|
Commit + draft PR (c) | no PR yet, or uncommitted changes | Commit the changes, push, and open a draft PR |
Check feedback (f) | open PR with unresolved threads | I'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 check | CI 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.
/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.
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.
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.
claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.tsx 293 lines1import { 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}
293types/index.d.ts 30 lines1export 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