Shows the current branch's PR above the prompt, styled like the app's PR chip

A Claude Code mod that shows the current branch's open pull request above the prompt, styled like the desktop app's PR chip:
#number (links to GitHub), repo name, branch+additions −deletions+/−, files changed, and a ↻ refresh button beside the age); × hides the chip for the sessionIt runs gh pr view on the session's branch at session start, after every turn and every 5 minutes while the session has been active in the last 30 (so not overnight). It needs the GitHub CLI installed and logged in. A PR that merges or closes while shown stays on the chip with its state; no open PR on the branch otherwise means no chip. /pr-band-demo holds until /pr-band.
pr-band is a mod, so it needs Claude Code v2.1.286 or later.
/plugin marketplace add atlabsai/dev-skills
/plugin install pr-band@atlabs-dev-skills
To turn it on for everyone working in a repository, add this to the repository's .claude/settings.json:
{
"extraKnownMarketplaces": {
"atlabs-dev-skills": {
"source": { "source": "github", "repo": "atlabsai/dev-skills" },
"autoUpdate": true
}
},
"enabledPlugins": { "pr-band@atlabs-dev-skills": true }
}
/pr-band: show the chip again and refresh it from gh/pr-band-demo: fill the chip with sample dataCheck changes with claude plugin validate plugins/pr-band.
hooks/register.tsx 342 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CiState, PrChip, PrState } from '../types'
5
6const pr = atom({ plugin: 'pr-band', key: 'pr' } as const, null)
7const isHidden = atom({ plugin: 'pr-band', key: 'isHidden' } as const, false)
8
9type Check = { conclusion?: string; status?: string; state?: string }
10
11type GhPrView = {
12 number: number
13 title: string
14 url: string
15 headRefName: string
16 author: { login: string }
17 createdAt: string
18 additions: number
19 deletions: number
20 changedFiles: number
21 state: 'OPEN' | 'CLOSED' | 'MERGED'
22 statusCheckRollup?: Check[]
23}
24
25const FAILED = new Set([
26 'FAILURE',
27 'ERROR',
28 'CANCELLED',
29 'TIMED_OUT',
30 'ACTION_REQUIRED',
31 'STARTUP_FAILURE',
32])
33const PENDING_STATES = new Set(['PENDING', 'EXPECTED'])
34
35const CI_COLOR: Record<CiState, string> = {
36 pass: 'green',
37 fail: 'red',
38 pending: 'yellow',
39 none: 'gray',
40}
41const PILL_BG = '#2a2a2a'
42const TOOLTIP_BG = '#1e1e1e'
43const REFRESH_MS = 5 * 60_000
44// No polling once the session has been idle this long (overnight, say); the
45// next prompt refreshes and wakes it.
46const IDLE_STOP_MS = 30 * 60_000
47
48const GH_STATE: Record<GhPrView['state'], PrState> = {
49 OPEN: 'open',
50 MERGED: 'merged',
51 CLOSED: 'closed',
52}
53
54/** Lucide icons (ISC): git-pull-request, git-merge, git-pull-request-closed. */
55const ICON_PATHS: Record<PrState, string> = {
56 open:
57 '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/>' +
58 '<path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" x2="6" y1="9" y2="21"/>',
59 merged:
60 '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/>' +
61 '<path d="M6 21V9a9 9 0 0 0 9 9"/>',
62 closed:
63 '<circle cx="6" cy="6" r="3"/><path d="M6 9v12"/><path d="m21 3-6 6"/>' +
64 '<path d="m21 9-6-6"/><path d="M18 11.5V15"/><circle cx="18" cy="18" r="3"/>',
65}
66
67/** The app popup's state pill: label, terminal colour, SVG stroke, pill tint. */
68const STATE_STYLE: Record<PrState, { label: string; text: string; stroke: string; bg: string }> = {
69 open: { label: 'Open', text: 'green', stroke: '#3fb950', bg: '#1d3b2a' },
70 merged: { label: 'Merged', text: 'magenta', stroke: '#a371f7', bg: '#2e2347' },
71 closed: { label: 'Closed', text: 'red', stroke: '#f85149', bg: '#3b1d1d' },
72}
73
74function iconSvg(state: PrState): string {
75 return (
76 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
77 `stroke="${STATE_STYLE[state].stroke}" stroke-width="2" stroke-linecap="round" ` +
78 `stroke-linejoin="round">${ICON_PATHS[state]}</svg>`
79 )
80}
81
82const DEMO: PrChip = {
83 number: 42,
84 title: 'feat(search): filter results by date range',
85 url: 'https://github.com/octo-org/octo-repo/pull/42',
86 state: 'open',
87 owner: 'octo-org',
88 repo: 'octo-repo',
89 branch: 'feature/search-date-filter',
90 author: 'octocat',
91 createdAt: '2026-10-05T12:00:00Z',
92 additions: 8542,
93 deletions: 132,
94 changedFiles: 33,
95 ci: 'pass',
96}
97
98function ciState(checks: Check[]): CiState {
99 if (checks.length === 0) return 'none'
100 const results = checks.map(c => (c.conclusion || c.state || '').toUpperCase())
101 if (results.some(r => FAILED.has(r))) return 'fail'
102 const isRunning = checks.some(
103 c =>
104 (c.status !== undefined && c.status !== 'COMPLETED') ||
105 PENDING_STATES.has((c.state || '').toUpperCase()),
106 )
107 return isRunning ? 'pending' : 'pass'
108}
109
110/** `https://github.com/<owner>/<repo>/pull/<n>` -> `{ owner, repo }`. */
111function ownerRepoFromPrUrl(url: string): { owner: string; repo: string } {
112 const [, , , owner, repo] = url.split('/')
113 return { owner: owner ?? '', repo: repo ?? '' }
114}
115
116/** `19h ago`, the largest whole unit, like the app's PR popup. */
117function ago(iso: string, nowMs: number): string {
118 const seconds = Math.max(0, Math.round((nowMs - Date.parse(iso)) / 1000))
119 const units: [string, number][] = [['d', 86400], ['h', 3600], ['m', 60]]
120 for (const [unit, size] of units) {
121 if (seconds >= size) return `${Math.floor(seconds / size)}${unit} ago`
122 }
123 return 'just now'
124}
125
126function toChip(view: GhPrView): PrChip {
127 return {
128 number: view.number,
129 title: view.title,
130 url: view.url,
131 state: GH_STATE[view.state],
132 ...ownerRepoFromPrUrl(view.url),
133 branch: view.headRefName,
134 author: view.author.login,
135 createdAt: view.createdAt,
136 additions: view.additions,
137 deletions: view.deletions,
138 changedFiles: view.changedFiles,
139 ci: ciState(view.statusCheckRollup ?? []),
140 }
141}
142
143let latestRefresh = 0
144let isRefreshing = false
145// /pr-band-demo pins sample data until /pr-band; refreshes leave it alone.
146let isDemo = false
147let poll: Timer | null = null
148let lastActiveMs = 0
149
150/** Re-reads the branch's PR. Never rejects (callers fire and forget): "no PR"
151 * clears the chip, any other failure keeps the last one, and a newer call wins. */
152async function refresh($: EngineInterface): Promise<void> {
153 if (isDemo) return
154 const mine = ++latestRefresh
155 isRefreshing = true
156 try {
157 const { exitCode, stdout, stderr } = await $.process.run([
158 'gh', 'pr', 'view', '--json',
159 'number,title,url,headRefName,author,createdAt,additions,deletions,'
160 + 'changedFiles,state,statusCheckRollup',
161 ])
162 if (mine !== latestRefresh) return
163 if (exitCode !== 0) {
164 if (/no (open )?pull requests? found/i.test(stderr)) {
165 await update($, pr, () => null)
166 }
167 return
168 }
169 const chip = toChip(JSON.parse(stdout) as GhPrView)
170 // A merged/closed PR stays only if it is the one already shown: a reused
171 // branch name must not resurface an old PR.
172 const shown = await read($, pr)
173 const isKept = chip.state === 'open' || shown?.number === chip.number
174 await update($, pr, () => (isKept ? chip : null))
175 } catch {
176 // gh missing or unreadable output: keep whatever the chip last showed.
177 } finally {
178 if (mine === latestRefresh) isRefreshing = false
179 }
180}
181
182/** The periodic tick: skipped while a refresh runs, for a demo, hidden or
183 * merged/closed chip, and after IDLE_STOP_MS without activity. */
184async function tick($: EngineInterface): Promise<void> {
185 if (isRefreshing || isDemo || (await read($, isHidden))) return
186 if ((await $.clock.now()) - lastActiveMs > IDLE_STOP_MS) return
187 const shown = await read($, pr)
188 if (shown !== null && shown.state !== 'open') return
189 await refresh($)
190}
191
192export const register: Register = on => {
193 on('session.start', async ($, e, next) => {
194 const started = await next(e)
195 lastActiveMs = await $.clock.now()
196 await $.command.register({
197 name: 'pr-band',
198 description: "Show this branch's PR chip again and refresh it",
199 })
200 await $.command.register({
201 name: 'pr-band-demo',
202 description: 'Fill the PR chip with demo data',
203 })
204 void refresh($)
205 // Between turns too, so a merge or new CI result shows without a message.
206 poll?.cancel()
207 poll = $.clock.every(REFRESH_MS, () => {
208 void tick($)
209 })
210 return started
211 })
212
213 on('prompt.submit', async ($, e, next) => {
214 lastActiveMs = await $.clock.now()
215 return next(e)
216 })
217
218 on('command.run', { command: 'pr-band' }, async $ => {
219 lastActiveMs = await $.clock.now()
220 isDemo = false
221 await update($, isHidden, () => false)
222 await refresh($)
223 return { text: (await read($, pr)) ? 'PR chip refreshed.' : 'No PR on this branch.' }
224 })
225
226 on('command.run', { command: 'pr-band-demo' }, async $ => {
227 isDemo = true
228 await update($, isHidden, () => false)
229 await update($, pr, () => DEMO)
230 return { text: 'PR chip showing demo data; /pr-band restores the real PR.' }
231 })
232
233 on('turn.complete', async ($, e, next) => {
234 const result = await next(e)
235 lastActiveMs = await $.clock.now()
236 void refresh($)
237 return result
238 })
239
240 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
241 const chip = await read($, pr)
242 if (e.props.hasSurvey || chip === null || (await read($, isHidden))) {
243 return next(e)
244 }
245
246 const { Box, Button, Link, Svg, Text } = $.ui.resolve(e)
247 const age = ago(chip.createdAt, await $.clock.now())
248 // Svg exists on the desktop, editor and mobile surfaces; the terminal
249 // refuses a tree that uses it, so it keeps the glyph.
250 const style = STATE_STYLE[chip.state]
251 const prIcon =
252 e.surface === 'terminal' ? (
253 <Text color={style.text}>⎇</Text>
254 ) : (
255 <Svg source={iconSvg(chip.state)} alt={`${style.label} pull request`} width={14} height={14} />
256 )
257
258 return (
259 <Box
260 key="pr-chip"
261 position="relative"
262 flexDirection="row"
263 alignItems="center"
264 justifyContent="space-between"
265 paddingX={1}
266 gap={1}
267 >
268 <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1}>
269 {prIcon}
270 <Link href={chip.url} label={`#${chip.number}`} />
271 <Text dimColor>{chip.repo}</Text>
272 <Box backgroundColor={PILL_BG} paddingX={1} flexShrink={1}>
273 <Text wrap="truncate-end">{chip.branch}</Text>
274 </Box>
275 </Box>
276 <Box flexDirection="row" alignItems="center" gap={1}>
277 <Box backgroundColor={PILL_BG} paddingX={1}>
278 <Text color="green">+{chip.additions.toLocaleString('en-US')}</Text>
279 <Text> </Text>
280 <Text color="red">−{chip.deletions.toLocaleString('en-US')}</Text>
281 </Box>
282 <Box backgroundColor={PILL_BG} paddingX={1}>
283 <Text color={CI_COLOR[chip.ci]}>●</Text>
284 <Text> CI</Text>
285 </Box>
286 <Button key="hide" label="×" onPress={() => update($, isHidden, () => true)} />
287 </Box>
288 <Box
289 position="absolute"
290 bottom={3}
291 left={2}
292 width={60}
293 display="none"
294 hover={{ display: 'flex' }}
295 flexDirection="column"
296 gap={1}
297 borderStyle="round"
298 borderColor="gray"
299 backgroundColor={TOOLTIP_BG}
300 paddingX={1}
301 >
302 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
303 <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1}>
304 <Box
305 backgroundColor={style.bg}
306 paddingX={1}
307 flexDirection="row"
308 alignItems="center"
309 gap={1}
310 flexShrink={0}
311 >
312 {prIcon}
313 <Text color={style.text}>{style.label}</Text>
314 </Box>
315 <Text dimColor wrap="truncate-end">
316 {chip.owner}/{chip.repo} #{chip.number}
317 </Text>
318 </Box>
319 <Box flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
320 <Text dimColor>{age}</Text>
321 <Button key="refresh" label="↻" onPress={() => refresh($)} />
322 </Box>
323 </Box>
324 <Text>{chip.title}</Text>
325 <Box flexDirection="row" justifyContent="space-between" gap={1}>
326 <Text dimColor>{chip.author}</Text>
327 <Box flexDirection="row" gap={1}>
328 <Box backgroundColor={PILL_BG} paddingX={1}>
329 <Text color="green">+{chip.additions.toLocaleString('en-US')}</Text>
330 <Text color="red">−{chip.deletions.toLocaleString('en-US')}</Text>
331 </Box>
332 <Box backgroundColor={PILL_BG} paddingX={1}>
333 <Text dimColor>{chip.changedFiles} files</Text>
334 </Box>
335 </Box>
336 </Box>
337 </Box>
338 </Box>
339 )
340 })
341}
342types/index.d.ts 26 lines1export type CiState = 'pass' | 'fail' | 'pending' | 'none'
2
3export type PrState = 'open' | 'merged' | 'closed'
4
5export type PrChip = {
6 number: number
7 title: string
8 url: string
9 state: PrState
10 owner: string
11 repo: string
12 branch: string
13 author: string
14 createdAt: string
15 additions: number
16 deletions: number
17 changedFiles: number
18 ci: CiState
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'pr-band': { pr: PrChip | null; isHidden: boolean }
24 }
25}
26