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

Mods for Claude Code: hooks-module plugins that add live panels and controls to the terminal UI.
Shows the PRs created in the current session, with their CI status, in a band above the prompt and in a side panel.
▌ PRs [n] │ ✓ passed ✗ failed ◌ running │ [ Panel ○ ]. The button toggles the panel on and off.+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.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.gh pr view --json and refreshes every 60 seconds; [ Refresh ] fetches at once./prs also toggles the panel./tui relaunch.[ 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.Shows the open PRs where you are a requested reviewer, with their CI status, in a band and a side panel.
gh search prs --review-requested=@me --state=open, then adds CI, diff size and review state from gh pr view.▌ Reviews [n] │ ✗ k with failing CI │ [ Panel ○ ], or CI green on all when nothing fails.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.[ 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.
/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.REFRESH_MS constant at the top of its hooks/register.tsx.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.
claude --plugin-dir ./mods/session-prs --plugin-dir ./mods/review-requests
claude plugin validate ./mods/<mod>
claude plugin test ./mods/<mod>hooks/register.tsx 336 lines1import { 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}
336types/index.d.ts 26 lines1export 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