SLOPSHOPPER

review-requests

Lists the open PRs where you are a requested reviewer, with CI status, in a pane toggled from the band above the prompt.

newpanebandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-15sinozu/claude-code-mods/mods/review-requests
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-requests
│ ┃ Reviews ✕ › fix the failing auth test and add an audit log call │ ┃ ▌ Waiting for your review (0) [ Refresh │ ┃ review-requested:@me · open · not yet appro ⏺ Read(src/auth.ts) │ ┃ auto-refresh every 5m, last fetching ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ gh: JSON Parse error: Unexpected EOF ⎿ 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 │ │ › /reviews │ ⎿ review-requests: Review panel opened. 0 PR(s) waiting for your r │ │ ▌ Reviews 0 │ gh: JSON Parse error: Unexpected EOF │ [ Panel ● ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▌ Reviews 0 │ gh: JSON Parse error: Unexpected EOF │ [ Panel ● ]
Pane · Reviews
▌ Waiting for your review (0) [ Refresh ] [ Close ] review-requested:@me · open · not yet approved · auto-refresh every 5m, last fetching gh: JSON Parse error: Unexpected EOF
README

claude-code-mods

Mods for Claude Code: hooks-module plugins that add live panels and controls to the terminal UI.

Mods

session-prs

Shows the PRs created in the current session, with their CI status, in a band above the prompt and in a side panel.

  • The band is a single row: ▌ PRs [n] │ ✓ passed ✗ failed ◌ running │ [ Panel ○ ]. The button toggles the panel on and off.
  • The panel draws one card per PR: a state chip (DRAFT while the PR is a draft, otherwise OPEN / MERGED / CLOSED), +added −deleted, branch → base, the names of the failed checks (passed, running and skipped checks are not listed), review decision, merge state, a link to the PR, and the time since the last fetch. A proportional bar at the top sums up all checks.
  • A PR is picked up from the output of any gh pr create the session runs through the Bash tool, including those run by subagents. From gh stack submit only the Created PR #N (URL) lines are taken: PRs already in the stack (is up to date) and the stack's own number are not added.
  • CI status comes from gh pr view --json and refreshes every 60 seconds; [ Refresh ] fetches at once.
  • /prs also toggles the panel.
  • The list and the panel state are stored under the session id, so they survive a /tui relaunch.
  • Each open PR's card has a [ Squash & merge ] button. It asks "Squash and merge #N?" first; Yes runs gh pr merge <url> --squash, after gh pr ready when the PR is still a draft (the question then says "marks it ready first"). Success toasts and refreshes the list; failure shows the first line of gh's error on the card and brings the button back.

review-requests

Shows the open PRs where you are a requested reviewer, with their CI status, in a band and a side panel.

  • Fetches up to 30 PRs with gh search prs --review-requested=@me --state=open, then adds CI, diff size and review state from gh pr view.
  • Hides PRs you have already approved, and PRs approved as a whole.
  • The band is a single row: ▌ Reviews [n] │ ✗ k with failing CI │ [ Panel ○ ], or CI green on all when nothing fails.
  • The panel draws one card per PR: repo #number, a DRAFT chip, +added −deleted, title, @author, review state, time since the last update, the check counts with the names of failing checks, and a link. Newest update first.
  • Refreshes at start and every 5 minutes; [ Refresh ] fetches at once. /reviews toggles the panel.

With both mods installed the band has two rows: each mod draws its own row and stacks the row of the mod beneath it.

Notes

  • Mouse clicks reach the buttons only under the fullscreen renderer. Switch with /tui fullscreen (/tui default switches back). On the classic renderer use /prs and /reviews, or focus the band with ctrl+x tab: p toggles the PR panel, v the review panel; inside a panel r refreshes and q closes it; in the PR panel m is the merge button of the first open PR, and y / n answer its question.
  • gh must be installed and signed in.
  • The refresh interval of each mod is the REFRESH_MS constant at the top of its hooks/register.tsx.

Install

At the prompt of a terminal session:

/plugin install session-prs --marketplace sinozu/claude-code-mods
/plugin install review-requests --marketplace sinozu/claude-code-mods

From a shell:

claude plugin install session-prs --marketplace sinozu/claude-code-mods
claude plugin install review-requests --marketplace sinozu/claude-code-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with sinozu/claude-code-mods, then Add plugin and pick the mod.

Development

claude --plugin-dir ./mods/session-prs --plugin-dir ./mods/review-requests
claude plugin validate ./mods/<mod>
claude plugin test ./mods/<mod>
Source 2 files
hooks/register.tsx 336 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Bucket, Checks, ReviewPr } from '../types'
5
6const PANE = 'review-requests'
7const REFRESH_MS = 5 * 60_000
8const LIMIT = 30
9const SEARCH_FIELDS = 'number,title,repository,url,author,updatedAt,isDraft'
10const VIEW_FIELDS = 'additions,deletions,reviewDecision,latestReviews,statusCheckRollup'
11
12const prs = atom({ plugin: 'review-requests', key: 'prs' } as const, [])
13const isOpen = atom({ plugin: 'review-requests', key: 'isOpen' } as const, false)
14const fetchedAt = atom({ plugin: 'review-requests', key: 'fetchedAt' } as const, 0)
15const lastError = atom({ plugin: 'review-requests', key: 'error' } as const, null)
16
17type Found = {
18  number: number
19  title: string
20  url: string
21  isDraft: boolean
22  updatedAt: string
23  author: { login: string }
24  repository: { nameWithOwner: string }
25}
26
27type Rollup =
28  | { __typename: 'CheckRun'; name: string; status: string; conclusion: string | null }
29  | { __typename: 'StatusContext'; context: string; state: string }
30
31type View = {
32  additions: number
33  deletions: number
34  reviewDecision: string
35  latestReviews: { author: { login: string }; state: string }[]
36  statusCheckRollup: Rollup[]
37}
38
39let login = ''
40
41const fetchLogin = async ($: EngineInterface) => {
42  try {
43    const ran = await $.process.run(['gh', 'api', 'user', '--jq', '.login'], { timeoutMs: 20_000 })
44    if (ran.exitCode === 0) login = ran.stdout.trim()
45  } catch {
46    login = ''
47  }
48}
49
50// Approved by you, or approved overall: nothing left to review.
51const isApproved = (view: View) =>
52  view.reviewDecision === 'APPROVED' ||
53  (login !== '' && (view.latestReviews ?? []).some(review => review.author.login === login && review.state === 'APPROVED'))
54
55type Color = 'success' | 'error' | 'warning' | 'subtle' | 'text' | 'claude'
56
57const BUCKET: Record<Bucket, { glyph: string; color: Color; label: string }> = {
58  pass: { glyph: '✓', color: 'success', label: 'passed' },
59  fail: { glyph: '✗', color: 'error', label: 'failed' },
60  pending: { glyph: '◌', color: 'warning', label: 'running' },
61  skipped: { glyph: '↷', color: 'subtle', label: 'skipped' },
62}
63
64const bucketOf = (item: Rollup): Bucket => {
65  if (item.__typename === 'StatusContext') {
66    if (item.state === 'SUCCESS') return 'pass'
67    if (item.state === 'PENDING' || item.state === 'EXPECTED') return 'pending'
68    return 'fail'
69  }
70  if (item.status !== 'COMPLETED') return 'pending'
71  if (item.conclusion === 'SUCCESS' || item.conclusion === 'NEUTRAL') return 'pass'
72  if (item.conclusion === 'SKIPPED') return 'skipped'
73  return 'fail'
74}
75
76const nameOf = (item: Rollup) => (item.__typename === 'StatusContext' ? item.context : item.name)
77
78const zero = (): Checks => ({ pass: 0, fail: 0, pending: 0, skipped: 0 })
79
80const withDetails = async ($: EngineInterface, pr: ReviewPr): Promise<ReviewPr | null> => {
81  try {
82    const ran = await $.process.run(['gh', 'pr', 'view', pr.url, '--json', VIEW_FIELDS], { timeoutMs: 20_000 })
83    if (ran.exitCode !== 0) return { ...pr, error: ran.stderr.trim().split('\n')[0] || `gh exited ${ran.exitCode}` }
84    const view = JSON.parse(ran.stdout) as View
85    if (isApproved(view)) return null
86    const checks = zero()
87    const failed: string[] = []
88    for (const item of view.statusCheckRollup ?? []) {
89      const bucket = bucketOf(item)
90      checks[bucket] += 1
91      if (bucket === 'fail') failed.push(nameOf(item))
92    }
93    return {
94      ...pr,
95      additions: view.additions ?? 0,
96      deletions: view.deletions ?? 0,
97      reviewDecision: view.reviewDecision ?? '',
98      checks,
99      failed,
100      error: null,
101    }
102  } catch (error) {
103    return { ...pr, error: error instanceof Error ? error.message : String(error) }
104  }
105}
106
107const refresh = async ($: EngineInterface) => {
108  try {
109    const ran = await $.process.run(
110      ['gh', 'search', 'prs', '--review-requested=@me', '--state=open', `--limit=${LIMIT}`, '--json', SEARCH_FIELDS],
111      { timeoutMs: 30_000 },
112    )
113    if (ran.exitCode !== 0) {
114      await update($, lastError, () => ran.stderr.trim().split('\n')[0] || `gh exited ${ran.exitCode}`)
115      return
116    }
117    const found = JSON.parse(ran.stdout) as Found[]
118    const previous = await read($, prs)
119    const base: ReviewPr[] = found.map(one => {
120      const known = previous.find(pr => pr.url === one.url)
121      return {
122        url: one.url,
123        repo: one.repository.nameWithOwner,
124        number: one.number,
125        title: one.title,
126        author: one.author.login,
127        isDraft: one.isDraft,
128        updatedAt: one.updatedAt,
129        additions: known?.additions ?? 0,
130        deletions: known?.deletions ?? 0,
131        reviewDecision: known?.reviewDecision ?? '',
132        checks: known?.checks ?? zero(),
133        failed: known?.failed ?? [],
134        error: null,
135      }
136    })
137    if (login === '') await fetchLogin($)
138    const detailed = (await Promise.all(base.map(pr => withDetails($, pr)))).filter((pr): pr is ReviewPr => pr !== null)
139    detailed.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0))
140    await update($, prs, () => detailed)
141    await update($, lastError, () => null)
142    const now = await $.clock.now()
143    await update($, fetchedAt, () => now)
144  } catch (error) {
145    await update($, lastError, () => (error instanceof Error ? error.message : String(error)))
146  }
147}
148
149const storeKey = async ($: EngineInterface) => `open:${await $.session.id()}`
150
151const openPane = async ($: EngineInterface) => {
152  await update($, isOpen, () => true)
153  await $.ui.open({ id: PANE, title: 'Reviews' })
154  await $.store.set(await storeKey($), true)
155}
156
157const closePane = async ($: EngineInterface) => {
158  await update($, isOpen, () => false)
159  await $.ui.close({ id: PANE })
160  await $.store.set(await storeKey($), false)
161}
162
163const toneOf = (pr: ReviewPr): { glyph: string; color: Color } => {
164  if (pr.error !== null) return { glyph: '!', color: 'warning' }
165  if (pr.checks.fail > 0) return BUCKET.fail
166  if (pr.checks.pending > 0) return BUCKET.pending
167  if (pr.checks.pass > 0) return BUCKET.pass
168  return { glyph: '·', color: 'subtle' }
169}
170
171const reviewOf = (decision: string): { label: string; color: Color } =>
172  decision === 'CHANGES_REQUESTED' ? { label: 'changes requested', color: 'error' } : { label: 'waiting for review', color: 'warning' }
173
174const ageOf = (now: number, iso: string) => {
175  const then = Date.parse(iso)
176  if (Number.isNaN(then)) return ''
177  const minutes = Math.max(0, Math.round((now - then) / 60_000))
178  if (minutes < 60) return `${minutes}m`
179  const hours = Math.round(minutes / 60)
180  return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`
181}
182
183const sinceOf = (now: number, then: number) => {
184  if (then === 0) return 'fetching'
185  const seconds = Math.max(0, Math.round((now - then) / 1000))
186  return seconds < 60 ? `${seconds}s ago` : `${Math.round(seconds / 60)}m ago`
187}
188
189export const register: Register = on => {
190  on('session.start', async ($, e, next) => {
191    await $.command.register({
192      name: 'reviews',
193      description: 'Open or close the panel of PRs waiting for your review',
194    })
195    $.clock.every(REFRESH_MS, () => void refresh($))
196    void fetchLogin($).then(() => refresh($))
197    if ((await $.store.get(await storeKey($))) === true) {
198      await update($, isOpen, () => true)
199      void $.ui.open({ id: PANE, title: 'Reviews' })
200    }
201
202    return next(e)
203  })
204
205  on('command.run', { command: 'reviews' }, async $ => {
206    if (await read($, isOpen)) {
207      await closePane($)
208      return { text: 'Review panel closed.' }
209    }
210    await openPane($)
211    void refresh($)
212    const list = await read($, prs)
213
214    return { text: `Review panel opened. ${list.length} PR(s) waiting for your review.` }
215  })
216
217  on('ui.close', { id: PANE }, async ($, e, next) => {
218    if (e.origin.kind === 'person') {
219      await update($, isOpen, () => false)
220      await $.store.set(await storeKey($), false)
221    }
222
223    return next(e)
224  })
225
226  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227    if (e.props.hasSurvey) return next(e)
228
229    const { Box, Button, Text } = $.ui.resolve(e)
230    const list = await read($, prs)
231    const open = await read($, isOpen)
232    const error = await read($, lastError)
233    const failing = list.filter(pr => pr.checks.fail > 0).length
234    const tone: Color = error !== null ? 'warning' : failing > 0 ? 'error' : list.length > 0 ? 'claude' : 'subtle'
235    const below = await next(e)
236    const rest = below.type === 'engine' ? null : below
237
238    return (
239      <Box flexDirection="column">
240        <Box gap={1}>
241          <Text color={tone} bold>▌</Text>
242          <Text bold>Reviews</Text>
243          <Text inverse color={tone}> {list.length} </Text>
244          <Text dimColor>│</Text>
245          {failing > 0 && <Text color="error">✗ {failing} with failing CI</Text>}
246          {failing === 0 && list.length > 0 && <Text color="success">CI green on all</Text>}
247          {list.length === 0 && error === null && <Text dimColor>nothing waiting</Text>}
248          {error !== null && <Text color="warning" wrap="truncate">gh: {error}</Text>}
249          <Text dimColor>│</Text>
250          <Button key="toggle" hotkey="v" label={open ? 'Panel ●' : 'Panel ○'} onPress={() => (open ? closePane($) : openPane($))} />
251        </Box>
252        {rest}
253      </Box>
254    )
255  })
256
257  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
258    const { Box, Button, Link, Text } = $.ui.resolve(e)
259    const list = await read($, prs)
260    const error = await read($, lastError)
261    const at = await read($, fetchedAt)
262    const now = await $.clock.now()
263    const width = Math.max(30, e.props.bodyColumns)
264    const inner = width - 4
265
266    return (
267      <Box flexDirection="column" width={width}>
268        <Box justifyContent="space-between" paddingX={1}>
269          <Box gap={1}>
270            <Text color="claude" bold>▌</Text>
271            <Text bold>Waiting for your review</Text>
272            <Text dimColor>({list.length})</Text>
273          </Box>
274          <Box gap={1}>
275            <Button key="refresh" hotkey="r" label="Refresh" onPress={() => refresh($)} />
276            <Button key="close" hotkey="q" label="Close" onPress={() => closePane($)} />
277          </Box>
278        </Box>
279        <Box paddingX={1} marginBottom={1}>
280          <Text dimColor>review-requested:@me · open · not yet approved · auto-refresh every {REFRESH_MS / 60_000}m, last {sinceOf(now, at)}</Text>
281        </Box>
282        {error !== null && (
283          <Box paddingX={1}>
284            <Text color="warning" wrap="truncate">gh: {error}</Text>
285          </Box>
286        )}
287        {list.length === 0 && error === null && (
288          <Box paddingX={1}>
289            <Text dimColor>No open PR is waiting for your review.</Text>
290          </Box>
291        )}
292        {list.map(pr => {
293          const tone = toneOf(pr)
294          const review = reviewOf(pr.reviewDecision)
295          return (
296            <Box key={pr.url} flexDirection="column" borderStyle="round" borderColor={tone.color} paddingX={1} width={width}>
297              <Box justifyContent="space-between">
298                <Box gap={1}>
299                  <Text color={tone.color} bold>{tone.glyph}</Text>
300                  <Text dimColor>{pr.repo}</Text>
301                  <Text bold>#{pr.number}</Text>
302                  {pr.isDraft && <Text inverse color="subtle"> DRAFT </Text>}
303                </Box>
304                <Box gap={1}>
305                  <Text color="diffAdded">+{pr.additions}</Text>
306                  <Text color="diffRemoved">−{pr.deletions}</Text>
307                </Box>
308              </Box>
309              <Text bold wrap="truncate">{pr.title}</Text>
310              <Box justifyContent="space-between">
311                <Box gap={1}>
312                  <Text color="suggestion">@{pr.author}</Text>
313                  <Text dimColor>·</Text>
314                  <Text color={review.color}>{review.label}</Text>
315                </Box>
316                <Text dimColor>updated {ageOf(now, pr.updatedAt)} ago</Text>
317              </Box>
318              <Text dimColor>{'─'.repeat(inner)}</Text>
319              <Box gap={2}>
320                {(Object.keys(BUCKET) as Bucket[]).map(bucket => (
321                  <Text key={bucket} color={pr.checks[bucket] > 0 ? BUCKET[bucket].color : 'subtle'}>
322                    {BUCKET[bucket].glyph} {pr.checks[bucket]}
323                  </Text>
324                ))}
325                {pr.failed.length > 0 && <Text color="error" wrap="truncate">{pr.failed.join(', ')}</Text>}
326              </Box>
327              {pr.error !== null && <Text color="warning" wrap="truncate">gh: {pr.error}</Text>}
328              <Link href={pr.url} label={pr.url.replace('https://github.com/', '')} />
329            </Box>
330          )
331        })}
332      </Box>
333    )
334  })
335}
336
types/index.d.ts 26 lines
1export type Bucket = 'pass' | 'fail' | 'pending' | 'skipped'
2
3export type Checks = Record<Bucket, number>
4
5export type ReviewPr = {
6  url: string
7  repo: string
8  number: number
9  title: string
10  author: string
11  isDraft: boolean
12  updatedAt: string
13  additions: number
14  deletions: number
15  reviewDecision: string
16  checks: Checks
17  failed: string[]
18  error: string | null
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    'review-requests': { prs: ReviewPr[]; isOpen: boolean; fetchedAt: number; error: string | null }
24  }
25}
26