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

My mods for Claude Code and my status line.
Shows the GitHub issues and pull requests to check for the repository in the current working directory, using the gh CLI.
↗ 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).
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.
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.
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 }
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>.
MIT
hooks/register.tsx 424 lines1import { 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}
424types/index.d.ts 32 lines1export 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