SLOPSHOPPER

gh-review

Pane and prompt bar with the GitHub issues and PRs to check for the repository in the working directory

newpanebandcommandtoaststatus
v0.2.0MITupdated 2026-10-07debba/claude-mods/plugins/gh-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gh-review
│ ┃ GitHub ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ⎇ GitHub │ ⏺ Read(src/auth.ts) │ ┃ │ no repository │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ No GitHub repository in /work/app JSON │ ⎿ 3 pass, 1 fail │ ┃ │ Parse error: Unexpected EOF │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ [ ⟳ Refresh ] [ ✕ Close ] ✻ Worked for 42s · done 4:20 PM │ ┃ title / ↗ web: browser · ✦ Claude: draft a │ ┃ prompt · 1/2/3 tabs · r refresh · x close › /gh-review │ ⎿ gh-review: GitHub pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GitHub
╭──────────────────────────────────────────────────────────╮ │ ⎇ GitHub │ │ no repository │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ No GitHub repository in /work/app JSON Parse error: │ │ Unexpected EOF │ ╰──────────────────────────────────────────────────────────╯ [ ⟳ Refresh ] [ ✕ Close ] title / ↗ web: browser · ✦ Claude: draft a prompt · 1/2/3 tabs · r refresh · x close
README

claude-mods

My mods for Claude Code and my status line.

Mods

gh-review

Shows the GitHub issues and pull requests to check for the repository in the current working directory, using the gh CLI.

  • A bar above the prompt: repository, items waiting on you, open PRs and issues. Click a count to open the pane on that tab.
  • A closable pane with three tabs (PRs, issues, your PRs). Each row shows author, review state or labels, and age.
  • Click a title (or ↗ web) to open it in the browser.
  • ✦ Claude drafts a prompt in the input box to handle that PR or issue inside Claude Code. Nothing is sent until you press Enter, and the prompt asks Claude not to comment, approve or merge without asking.
  • /gh-review toggles the pane, /gh-review refresh refreshes. Data refreshes every 5 minutes and when the session changes directory.

Requires gh installed and authenticated (gh auth login).

repo-sync

Keeps a clone of this repository committed, and leaves the push to you. After each Claude Code turn, and every two minutes, it validates the plugins that changed and commits everything with a message naming what changed (Update gh-review, statusline). When local commits are waiting, a row above the prompt shows ⇡ claude-mods: N to push with a Push button. /repo-sync commits now, /repo-sync push commits and pushes.

It does nothing unless the clone opts in:

git config claude-mods.autosync true

The flag lives in the clone's local git config, so forks and installed copies never commit on their own.

Install

Requires jq, and gh authenticated (gh auth login) for gh-review and the PR number in the status line.

curl -fsSL https://raw.githubusercontent.com/debba/claude-mods/main/install.sh | bash

Or from a clone:

git clone https://github.com/debba/claude-mods && cd claude-mods
./install.sh                  # gh-review from the marketplace + the status line
./install.sh --no-statusline  # plugins only
./install.sh --no-plugins     # status line only

The script backs up ~/.claude/settings.json and any existing ~/.claude/statusline.sh before changing them. Restart Claude Code afterwards.

To install only the mod from inside Claude Code instead:

/plugin install gh-review --marketplace debba/claude-mods

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

Status line

statusline/statusline.sh shows the model, git branch and PR, context usage, 5-hour and weekly limits, time to the 5-hour reset and a link to the usage dashboard. It needs jq, and gh for the PR number.

cp statusline/statusline.sh ~/.claude/statusline.sh
# or, to edit it in the clone:
ln -s "$PWD/statusline/statusline.sh" ~/.claude/statusline.sh

Then in ~/.claude/settings.json:

"statusLine": { "type": "command", "command": "~/.claude/statusline.sh", "padding": 0 }

Developing

Work from a clone so edits hot-reload in every session and repo-sync commits them:

./install.sh --dev

This points CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json at the clone's plugins, symlinks ~/.claude/statusline.sh to statusline/statusline.sh, and sets git config claude-mods.autosync true in the clone.

Check a mod with claude plugin validate plugins/<name> and claude plugin test plugins/<name>.

License

MIT

Source 2 files
hooks/register.tsx 424 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Item, Snapshot, Tab } from '../types'
5
6const PANE = 'gh-review'
7const TITLE = 'GitHub'
8const REFRESH_MS = 5 * 60 * 1000
9
10const EMPTY: Snapshot = {
11  cwd: '',
12  repo: '',
13  me: '',
14  reviewRequested: [],
15  toReview: [],
16  mine: [],
17  assigned: [],
18  awaitingReply: [],
19  error: '',
20  isLoading: false,
21  fetchedAt: 0,
22}
23
24const snapshot = atom({ plugin: 'gh-review', key: 'snapshot' } as const, EMPTY)
25const activeTab = atom({ plugin: 'gh-review', key: 'tab' } as const, 'pr' as Tab)
26
27type Login = { login?: string; name?: string } | null
28
29type RawCommon = { number: number; title: string; url: string; author: Login; updatedAt?: string }
30
31export type RawPr = RawCommon & {
32  isDraft: boolean
33  reviewDecision: string
34  reviewRequests: Login[]
35}
36
37export type RawIssue = RawCommon & {
38  assignees: Login[]
39  comments: { author: Login }[]
40  labels: { name: string }[]
41}
42
43const loginOf = (who: Login) => who?.login ?? who?.name ?? '?'
44
45const DECISIONS: Record<string, string> = {
46  APPROVED: 'approved',
47  CHANGES_REQUESTED: 'changes requested',
48  REVIEW_REQUIRED: 'review required',
49}
50
51const toItem = (kind: Item['kind'], raw: RawCommon, note: string): Item => ({
52  kind,
53  number: raw.number,
54  title: raw.title,
55  url: raw.url,
56  author: loginOf(raw.author),
57  note,
58  updatedAt: raw.updatedAt ? Date.parse(raw.updatedAt) : 0,
59})
60
61export const classifyPrs = (prs: RawPr[], me: string) => {
62  const reviewRequested: Item[] = []
63  const toReview: Item[] = []
64  const mine: Item[] = []
65  for (const pr of prs) {
66    const decision = DECISIONS[pr.reviewDecision] ?? 'no review'
67    if (loginOf(pr.author) === me) {
68      mine.push(toItem('pr', pr, pr.isDraft ? 'draft' : decision))
69    } else if (pr.reviewRequests.some(who => loginOf(who) === me)) {
70      reviewRequested.push(toItem('pr', pr, decision))
71    } else if (!pr.isDraft && pr.reviewDecision !== 'APPROVED') {
72      toReview.push(toItem('pr', pr, decision))
73    }
74  }
75
76  return { reviewRequested, toReview, mine }
77}
78
79export const classifyIssues = (issues: RawIssue[], me: string) => {
80  const assigned: Item[] = []
81  const awaitingReply: Item[] = []
82  for (const issue of issues) {
83    const labels = issue.labels.map(label => label.name).join(', ')
84    const count = issue.comments.length
85    const note = [`${count} comment${count === 1 ? '' : 's'}`, labels].filter(Boolean).join(' · ')
86    const last = issue.comments.at(-1)
87    const lastAuthor = last ? loginOf(last.author) : loginOf(issue.author)
88    if (issue.assignees.some(who => loginOf(who) === me)) {
89      assigned.push(toItem('issue', issue, note))
90    } else if (lastAuthor !== me) {
91      awaitingReply.push(toItem('issue', issue, note))
92    }
93  }
94
95  return { assigned, awaitingReply }
96}
97
98export const ageOf = (updatedAt: number, now: number) => {
99  if (!updatedAt || !now) return ''
100  const minutes = Math.max(0, Math.round((now - updatedAt) / 60_000))
101  if (minutes < 60) return `${minutes}m`
102  const hours = Math.round(minutes / 60)
103  if (hours < 24) return `${hours}h`
104  const days = Math.round(hours / 24)
105
106  return days < 60 ? `${days}d` : `${Math.round(days / 30)}mo`
107}
108
109const fit = (text: string, max: number) => (text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text)
110
111const gh = async ($: Engine, cwd: string, args: string[]) => {
112  const ran = await $.process.run(['gh', ...args], { cwd, timeoutMs: 60_000 })
113  if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `gh ${args[0]} exit ${ran.exitCode}`)
114
115  return ran.stdout
116}
117
118const refresh = async ($: Engine) => {
119  const cwd = await $.session.cwd()
120  await update($, snapshot, value => ({ ...(value ?? EMPTY), isLoading: true }))
121  try {
122    const repo = JSON.parse(await gh($, cwd, ['repo', 'view', '--json', 'nameWithOwner'])).nameWithOwner as string
123    const me = (await gh($, cwd, ['api', 'user', '--jq', '.login'])).trim()
124    const [prJson, issueJson] = await Promise.all([
125      gh($, cwd, [
126        'pr',
127        'list',
128        '--state',
129        'open',
130        '--limit',
131        '100',
132        '--json',
133        'number,title,url,author,updatedAt,isDraft,reviewDecision,reviewRequests',
134      ]),
135      gh($, cwd, [
136        'issue',
137        'list',
138        '--state',
139        'open',
140        '--limit',
141        '100',
142        '--json',
143        'number,title,url,author,updatedAt,assignees,comments,labels',
144      ]),
145    ])
146    const prs = classifyPrs(JSON.parse(prJson) as RawPr[], me)
147    const issues = classifyIssues(JSON.parse(issueJson) as RawIssue[], me)
148    const fetchedAt = await $.clock.now()
149    await update($, snapshot, () => ({ ...EMPTY, ...prs, ...issues, cwd, repo, me, fetchedAt }))
150  } catch (error) {
151    const message = error instanceof Error ? error.message : String(error)
152    await update($, snapshot, () => ({ ...EMPTY, cwd, error: message }))
153  }
154}
155
156const openInBrowser = async ($: Engine, item: Item) => {
157  const cwd = await $.session.cwd()
158  const ran = await $.process.run(['gh', item.kind, 'view', String(item.number), '--web'], { cwd })
159  $.ui.toast(ran.exitCode === 0 ? `Opening #${item.number} in the browser` : `Could not open #${item.number}`)
160}
161
162export const claudePrompt = (item: Item) =>
163  item.kind === 'pr'
164    ? `Handle PR #${item.number} "${item.title}" (${item.url}). ` +
165      `Read it with \`gh pr view ${item.number} --comments\` and \`gh pr diff ${item.number}\`, ` +
166      'summarize what changes, flag problems or risks and suggest next steps. ' +
167      'Do not comment, approve or merge without asking me first.'
168    : `Handle issue #${item.number} "${item.title}" (${item.url}). ` +
169      `Read it with \`gh issue view ${item.number} --comments\`, understand the problem, ` +
170      'find the code involved and propose a fix or a draft reply. ' +
171      'Do not post comments without asking me first.'
172
173const handInClaude = async ($: Engine, item: Item) => {
174  const filled = await $.prompt.fill({ text: claudePrompt(item) })
175  $.ui.toast(
176    filled.isFilled
177      ? `Prompt for #${item.number} ready: review it and press Enter`
178      : `Cannot write to the prompt right now`,
179  )
180}
181
182const isPaneOpen = async ($: Engine) => (await $.ui.panes()).some(pane => pane.id === PANE)
183
184const togglePane = async ($: Engine) => {
185  if (await isPaneOpen($)) {
186    await $.ui.close({ id: PANE })
187
188    return false
189  }
190  await $.ui.open({ id: PANE, title: TITLE })
191  void refresh($)
192
193  return true
194}
195
196export const register: Register = on => {
197  on('session.start', async ($, e, next) => {
198    await $.command.register({
199      name: 'gh-review',
200      description: 'Toggle the pane with the GitHub issues and PRs to check (arg: refresh)',
201    })
202    // The old version pinned a status line; the band above the prompt replaces it.
203    $.ui.status(undefined)
204    void refresh($)
205    $.clock.every(REFRESH_MS, () => void refresh($))
206
207    return next(e)
208  })
209
210  on('command.run', { command: 'gh-review' }, async ($, e) => {
211    if (e.args.trim() === 'refresh') {
212      await refresh($)
213
214      return { text: 'Issues and PRs refreshed.' }
215    }
216    const isOpen = await togglePane($)
217
218    return { text: isOpen ? 'GitHub pane opened.' : 'GitHub pane closed.' }
219  })
220
221  // Repo changes when the session moves to another directory: refetch after the turn.
222  on('turn.complete', async ($, e, next) => {
223    const result = await next(e)
224    const current = await read($, snapshot)
225    if (!current.isLoading && current.cwd !== (await $.session.cwd())) void refresh($)
226
227    return result
228  })
229
230  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
231    const { Box, Text, Button } = $.ui.resolve(e)
232    const data = await read($, snapshot)
233    const tab = await read($, activeTab)
234    const width = Math.max(30, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
235
236    const row = (item: Item, accent: string) => (
237      <Box
238        key={`row-${item.kind}-${item.number}`}
239        flexDirection="column"
240        paddingX={1}
241        hover={{ backgroundColor: 'promptBorder' }}
242      >
243        <Box flexDirection="row" gap={1}>
244          <Text color={accent} bold>
245            {`#${item.number}`.padEnd(6)}
246          </Text>
247          <Button
248            plain
249            key={`open-${item.kind}-${item.number}`}
250            label={fit(item.title, width - 22)}
251            hover={{ color: 'permission', underline: true }}
252            onPress={() => void openInBrowser($, item)}
253          />
254        </Box>
255        <Box flexDirection="row" gap={1}>
256          <Text dimColor wrap="truncate-end">
257            {`       @${item.author} · ${item.note}${item.updatedAt ? ` · ${ageOf(item.updatedAt, data.fetchedAt)} ago` : ''}`}
258          </Text>
259          <Button
260            plain
261            dimColor
262            key={`claude-${item.kind}-${item.number}`}
263            label="✦ Claude"
264            hover={{ color: 'claude' }}
265            onPress={() => void handInClaude($, item)}
266          />
267          <Button
268            plain
269            dimColor
270            key={`web-${item.kind}-${item.number}`}
271            label="↗ web"
272            hover={{ color: 'permission' }}
273            onPress={() => void openInBrowser($, item)}
274          />
275        </Box>
276      </Box>
277    )
278
279    const section = (title: string, items: Item[], accent: string) => (
280      <Box flexDirection="column" marginTop={1}>
281        <Box flexDirection="row" gap={1}>
282          <Text color={accent} bold>
283            {title}
284          </Text>
285          <Text dimColor>{`${items.length}`}</Text>
286        </Box>
287        <Text dimColor>{'─'.repeat(Math.min(width, 60))}</Text>
288        {items.length === 0 && <Text dimColor>{'  ✓ nothing to do'}</Text>}
289        {items.map(item => row(item, accent))}
290      </Box>
291    )
292
293    const prCount = data.reviewRequested.length + data.toReview.length
294    const issueCount = data.assigned.length + data.awaitingReply.length
295    const tabButton = (id: Tab, label: string, hotkey: string) => (
296      <Button
297        key={`tab-${id}`}
298        hotkey={hotkey}
299        variant={tab === id ? 'primary' : 'secondary'}
300        dimColor={tab !== id}
301        onPress={() => void update($, activeTab, () => id)}
302      >
303        {label}
304      </Button>
305    )
306
307    const updatedAt = data.fetchedAt ? new Date(data.fetchedAt).toLocaleTimeString().slice(0, 5) : '…'
308
309    return (
310      <Box flexDirection="column">
311        <Box flexDirection="column" borderStyle="round" borderColor="permission" paddingX={1}>
312          <Text bold wrap="truncate-end">
313            {data.repo !== '' ? `⎇  ${data.repo}` : '⎇  GitHub'}
314          </Text>
315          <Text dimColor wrap="truncate-end">
316            {data.isLoading
317              ? '⟳ refreshing…'
318              : data.repo !== ''
319                ? `@${data.me} · updated at ${updatedAt}`
320                : 'no repository'}
321          </Text>
322          {data.repo !== '' && (
323            <Box flexDirection="row" gap={2}>
324              <Text color="permission">{`● ${data.reviewRequested.length + data.assigned.length} for you`}</Text>
325              <Text>{`◆ ${prCount} PR`}</Text>
326              <Text>{`◇ ${issueCount} issue`}</Text>
327            </Box>
328          )}
329        </Box>
330
331        {data.error !== '' && (
332          <Box borderStyle="round" borderColor="error" paddingX={1} marginTop={1}>
333            <Text color="error" wrap="wrap">
334              {`No GitHub repository in ${data.cwd}\n${data.error}`}
335            </Text>
336          </Box>
337        )}
338
339        {data.repo !== '' && (
340          <Box flexDirection="row" gap={1} marginTop={1}>
341            {tabButton('pr', `PR ${prCount}`, '1')}
342            {tabButton('issue', `Issue ${issueCount}`, '2')}
343            {tabButton('mine', `Mine ${data.mine.length}`, '3')}
344          </Box>
345        )}
346
347        {data.repo !== '' && tab === 'pr' && (
348          <Box flexDirection="column">
349            {section('◉ Review requested from you', data.reviewRequested, 'permission')}
350            {section('◆ To review', data.toReview, 'text')}
351          </Box>
352        )}
353        {data.repo !== '' && tab === 'issue' && (
354          <Box flexDirection="column">
355            {section('◉ Assigned to you', data.assigned, 'permission')}
356            {section('◇ Awaiting your reply', data.awaitingReply, 'text')}
357          </Box>
358        )}
359        {data.repo !== '' && tab === 'mine' && section('◆ Your PRs', data.mine, 'success')}
360
361        <Box flexDirection="row" gap={1} marginTop={1}>
362          <Button key="refresh" hotkey="r" variant="primary" onPress={() => void refresh($)}>
363            {data.isLoading ? 'Refreshing…' : '⟳ Refresh'}
364          </Button>
365          <Button key="close" hotkey="x" role="dismiss" onPress={() => void $.ui.close({ id: PANE })}>
366            ✕ Close
367          </Button>
368        </Box>
369        <Text dimColor>{'title / ↗ web: browser · ✦ Claude: draft a prompt · 1/2/3 tabs · r refresh · x close'}</Text>
370      </Box>
371    )
372  })
373
374  // Compact bar above the prompt in place of the engine's status line; other plugins' rows stay below it.
375  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
376    const data = await read($, snapshot)
377    if (e.props.hasSurvey || data.repo === '') return next(e)
378
379    const { Box, Text, Button } = $.ui.resolve(e)
380    const forMe = data.reviewRequested.length + data.assigned.length
381    const prCount = data.reviewRequested.length + data.toReview.length
382    const issueCount = data.assigned.length + data.awaitingReply.length
383    const openOn = (tab: Tab) => async () => {
384      await update($, activeTab, () => tab)
385      if (!(await isPaneOpen($))) await togglePane($)
386    }
387
388    const below = await next(e)
389
390    return (
391      <Box flexDirection="column">
392        <Box key="gh-band" flexDirection="row" gap={2} paddingX={1}>
393          <Text color="permission" bold>{`⎇ ${data.repo}`}</Text>
394          {forMe > 0 && <Text color="permission">{`● ${forMe} for you`}</Text>}
395          <Button
396            plain
397            key="band-pr"
398            label={`◆ ${prCount} PR`}
399            hover={{ color: 'permission' }}
400            onPress={() => void openOn('pr')()}
401          />
402          <Button
403            plain
404            key="band-issue"
405            label={`◇ ${issueCount} issue`}
406            hover={{ color: 'permission' }}
407            onPress={() => void openOn('issue')()}
408          />
409          <Button
410            plain
411            dimColor
412            key="band-toggle"
413            label="▤ pane"
414            hover={{ color: 'permission' }}
415            onPress={() => void togglePane($)}
416          />
417          {data.isLoading && <Text dimColor>⟳</Text>}
418        </Box>
419        {below}
420      </Box>
421    )
422  })
423}
424
types/index.d.ts 32 lines
1export type Item = {
2  kind: 'pr' | 'issue'
3  number: number
4  title: string
5  url: string
6  author: string
7  note: string
8  updatedAt: number
9}
10
11export type Snapshot = {
12  cwd: string
13  repo: string
14  me: string
15  reviewRequested: Item[]
16  toReview: Item[]
17  mine: Item[]
18  assigned: Item[]
19  awaitingReply: Item[]
20  error: string
21  isLoading: boolean
22  fetchedAt: number
23}
24
25export type Tab = 'pr' | 'issue' | 'mine'
26
27declare module 'claude-code' {
28  interface PluginState {
29    'gh-review': { snapshot: Snapshot; tab: Tab }
30  }
31}
32