SLOPSHOPPER

pr-band

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

newbandcommandpromptprocesstimer
★ 1v0.2.0MITupdated 2026-10-09atlabsai/dev-skills/plugins/pr-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-band
› 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 › /pr-band ⎿ pr-band: No PR on this branch. ⎇ #42 https://github.com/octo-org/octo-repo/pull/42 octo-repo feature/search-date-filter ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⎇ #42 https://github.com/octo-org/octo-repo/pull/42 octo-repo feature/search-date-filter
README

pr-band

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
  • a CI dot: green passing, red failing, yellow running, grey no checks
  • on hover, a card like the app's PR popup (Open / Merged / Closed, owner/repo #number, age, title, author, +/−, files changed, and a ↻ refresh button beside the age); × hides the chip for the session

It 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.

Load it

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 }
}

Commands

  • /pr-band: show the chip again and refresh it from gh
  • /pr-band-demo: fill the chip with sample data

Check changes with claude plugin validate plugins/pr-band.

Source 2 files
hooks/register.tsx 342 lines
1import { 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}
342
types/index.d.ts 26 lines
1export 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