Band above the prompt with the current branch PR, review state and CI checks

A Claude Code mod that shows the current branch's pull request in a side pane:
PR #12 Add guard
Review approved
Checks 2 passing, 1 failing, 0 pending
The pane opens when a session starts. Reopen it with /pr-status. It refreshes when a session starts and when each turn ends. It reads gh pr view and gh pr checks, so gh must be installed and logged in.
Other states:
no PR for this branch: the branch has no pull request.PR status unavailable: gh is missing, not logged in, or failed./plugin install pr-status --marketplace kk5190/claude-code-mods
claude --plugin-dir . # run a session with this folder loaded; edits hot-reload
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 68 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PrStatus } from '../types'
5import { classifyFailure, countChecks, paneRows, parsePr } from './status'
6
7const PANE = 'pr-status'
8const TITLE = 'PR status'
9
10const status = atom({ plugin: 'pr-status', key: 'status' } as const, null)
11
12// Asks gh about the current branch's PR. A failed or unreadable answer shows as unavailable, never as an error.
13const refresh = async ($: EngineInterface) => {
14 let next: PrStatus
15 try {
16 const view = await $.process.run(['gh', 'pr', 'view', '--json', 'number,title,reviewDecision'])
17 if (view.exitCode !== 0) {
18 next = { kind: classifyFailure(view.stderr) }
19 } else {
20 const { number, title, review } = parsePr(view.stdout)
21 const checks = await $.process.run(['gh', 'pr', 'checks', '--json', 'bucket'])
22 next = { kind: 'pr', number, title, review, checks: countChecks(checks.stdout) }
23 }
24 } catch {
25 next = { kind: 'unavailable' }
26 }
27 await update($, status, () => next)
28}
29
30export const register: Register = on => {
31 on('session.start', async ($, e, next) => {
32 await $.command.register({ name: 'pr-status', description: 'Show the PR status pane' })
33 void $.ui.open({ id: PANE, title: TITLE })
34 await refresh($)
35
36 return next(e)
37 })
38
39 on('command.run', { command: 'pr-status' }, async $ => {
40 await $.ui.open({ id: PANE, title: TITLE })
41
42 return { text: 'PR status pane opened.' }
43 })
44
45 // A turn is where a push or a PR change happens, so the pane refreshes when one ends.
46 on('turn.complete', async ($, e, next) => {
47 await refresh($)
48
49 return next(e)
50 })
51
52 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
53 const { Box, Text } = $.ui.resolve(e)
54 const rows = paneRows(await read($, status))
55
56 return (
57 <Box flexDirection="column" paddingX={1}>
58 {rows.map(row => (
59 <Box key={row.label} flexDirection="column">
60 <Text dimColor>{row.label}</Text>
61 <Text>{row.value}</Text>
62 </Box>
63 ))}
64 </Box>
65 )
66 })
67}
68hooks/status.ts 50 lines1// Pure logic for the PR status pane. No engine imports, so it runs under plain tests.
2
3import type { Checks, PrStatus } from '../types'
4
5const REVIEW: Record<string, string> = {
6 APPROVED: 'approved',
7 CHANGES_REQUESTED: 'changes requested',
8 REVIEW_REQUIRED: 'review required',
9}
10
11// Input is `gh pr view --json number,title,reviewDecision`.
12export const parsePr = (json: string): { number: number; title: string; review: string } => {
13 const pr = JSON.parse(json) as { number: number; title: string; reviewDecision: string }
14 return { number: pr.number, title: pr.title, review: REVIEW[pr.reviewDecision] ?? 'no review yet' }
15}
16
17// Input is `gh pr checks --json bucket`. Unreadable output counts as nothing rather than failing the band.
18export const countChecks = (json: string): Checks => {
19 const counts: Checks = { passing: 0, failing: 0, pending: 0 }
20 try {
21 for (const { bucket } of JSON.parse(json) as { bucket: string }[]) {
22 if (bucket === 'pass') counts.passing += 1
23 else if (bucket === 'fail') counts.failing += 1
24 else if (bucket === 'pending') counts.pending += 1
25 }
26 } catch {
27 // Left at zero.
28 }
29 return counts
30}
31
32// `gh pr view` fails with this text when the branch has no PR; any other failure means gh could not answer.
33export const classifyFailure = (stderr: string): 'none' | 'unavailable' =>
34 stderr.includes('no pull requests found') ? 'none' : 'unavailable'
35
36export type Row = { label: string; value: string }
37
38// One label and value per line of the pane. `null` means the first check has not finished.
39export const paneRows = (status: PrStatus | null): Row[] => {
40 if (status === null) return [{ label: 'PR', value: 'checking…' }]
41 if (status.kind === 'none') return [{ label: 'PR', value: 'no PR for this branch' }]
42 if (status.kind === 'unavailable') return [{ label: 'PR', value: 'PR status unavailable' }]
43 const { passing, failing, pending } = status.checks
44 return [
45 { label: 'PR', value: `#${status.number} ${status.title}` },
46 { label: 'Review', value: status.review },
47 { label: 'Checks', value: `${passing} passing, ${failing} failing, ${pending} pending` },
48 ]
49}
50types/index.d.ts 13 lines1export type Checks = { passing: number; failing: number; pending: number }
2
3export type PrStatus =
4 | { kind: 'pr'; number: number; title: string; review: string; checks: Checks }
5 | { kind: 'none' }
6 | { kind: 'unavailable' }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'pr-status': { status: PrStatus | null }
11 }
12}
13