Shows live GitHub PR state beside PR links in the transcript, and tells Claude when a PR's state changes.

A Claude Code mod that shows the live state of GitHub pull requests wherever Claude links to one.
#42 Fix flaky login test.gh), logged in. The mod runs gh pr view to read PR state.At a Claude Code prompt:
/plugin install pr-status --marketplace patrickdundas/claude-code-pr-status
Answer y to add the marketplace, then pick a scope.
"tui": "fullscreen") redraws them all.title (url) instead of being clickable, Claude Code does not think your terminal supports hyperlinks. Set FORCE_HYPERLINK=1 in your environment or in the env block of ~/.claude/settings.json.claude plugin validate .
claude plugin test .hooks/register.tsx 167 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PrStatus } from '../types'
5import { layout } from './layout'
6import type { Run } from './layout'
7import { decorate, describeChange, findPrs, textsOf } from './lib'
8import { BATCH_MAX, IDLE_MS, batchArgs, interval, isRecent, parseBatch, refOf } from './poll'
9import type { Seen } from './poll'
10
11const prs = atom({ plugin: 'pr-status', key: 'prs' } as const, {} as Record<string, PrStatus>)
12const isGuided = atom({ plugin: 'pr-status', key: 'isGuided' } as const, false)
13
14const TICK_MS = 5_000
15const DRAWN_ON = new Set(['terminal', 'desktop'])
16
17const GUIDE = `# GitHub pull request links
18This interface replaces every GitHub PR URL in your replies with the PR's live status icon, its number and its title, as one clickable link. Write PR references so that replacement reads cleanly:
19- Write the bare URL (https://github.com/<owner>/<repo>/pull/<number>) where the PR belongs in the sentence, as if it were the PR's name: "I opened https://github.com/o/r/pull/12 for this."
20- Never add the PR's title, its #number, or its open/draft/merged/closed state beside the URL. The replacement already shows them, so they would appear twice.
21- Never wrap the URL in markdown link syntax or backticks, and never put it in a table. Those stop the replacement or its colors.
22- For several PRs, use a list with one URL per item, plus any note about what you did with each.
23Wrong: It's PR #803, "Fix login": https://github.com/o/r/pull/803
24Right: It's https://github.com/o/r/pull/803.`
25const GH = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']
26
27const known = new Set<string>()
28const seen = new Map<string, Seen>()
29const checkedAt = new Map<string, number>()
30let row = 0
31let activeAt = 0
32let isPolling = false
33
34// Only conversation rows mark a PR recent; redrawing an old message must not.
35function track(text: string, now?: number) {
36 for (const { key } of findPrs(text)) {
37 known.add(key)
38 if (now !== undefined) seen.set(key, { row, at: now })
39 }
40}
41
42async function runGh($: EngineInterface, args: string[]): Promise<string | null> {
43 for (const gh of GH) {
44 try {
45 // gh exits non-zero when one PR in the batch is missing, but still prints the rest.
46 return (await $.process.run([gh, ...args], { timeoutMs: 15_000 })).stdout
47 } catch {
48 // gh not at this path; try the next one.
49 }
50 }
51 return null
52}
53
54async function poll($: EngineInterface, force = false) {
55 if (isPolling) return
56 isPolling = true
57 try {
58 const now = await $.clock.now()
59 if (!force && now - activeAt > IDLE_MS) return
60 const stored = await read($, prs)
61 const due = [...known]
62 .filter(key => {
63 const wait = interval(stored[key], isRecent(seen.get(key), row, now))
64 if (force) return wait !== Infinity
65 return now - (checkedAt.get(key) ?? -Infinity) >= wait
66 })
67 .map(refOf)
68 .filter(r => r !== null)
69 .slice(0, BATCH_MAX)
70 if (!due.length) return
71
72 const stdout = await runGh($, batchArgs(due))
73 const results = stdout ? parseBatch(stdout, due) : new Map<string, PrStatus>()
74 const changed: Record<string, PrStatus> = {}
75 const notes: string[] = []
76 for (const { key } of due) {
77 checkedAt.set(key, now)
78 const status = results.get(key)
79 const before = stored[key]
80 if (!status || (before && JSON.stringify(before) === JSON.stringify(status))) continue
81 changed[key] = status
82 const note = before && describeChange(key, before, status)
83 if (note) notes.push(note)
84 }
85
86 if (Object.keys(changed).length > 0) await update($, prs, current => ({ ...current, ...changed }))
87 for (const note of notes) {
88 $.ui.toast(note)
89 await $.session.append({
90 message: { type: 'user', content: [{ type: 'text', text: `[pr-status mod] ${note}` }] },
91 })
92 }
93 } finally {
94 isPolling = false
95 }
96}
97
98export const register: Register = on => {
99 on('session.start', async ($, e, next) => {
100 for (const key of Object.keys(await read($, prs))) known.add(key)
101 activeAt = await $.clock.now()
102 $.clock.every(TICK_MS, () => void poll($))
103 return next(e)
104 })
105
106 // A session renders its system prompt once, so the guide rides the first prompt instead.
107 on('prompt.submit', async ($, e, next) => {
108 activeAt = await $.clock.now()
109 await poll($, true)
110 const surfaces = await $.session.surfaces()
111 const isDrawn = !surfaces.length || surfaces.some(s => DRAWN_ON.has(s))
112 if (!isDrawn || (await read($, isGuided))) return next(e)
113 await update($, isGuided, () => true)
114 return next({ ...e, context: [...(e.context ?? []), GUIDE] })
115 }).catch(($, e, next) => next(e))
116
117 // Compaction can drop the guide from the conversation, so send it again afterwards.
118 on('session.compact', async ($, e, next) => {
119 const result = await next(e)
120 if (!('skip' in result)) await update($, isGuided, () => false)
121 return result
122 }).catch(($, e, next) => next(e))
123
124 on('session.append', async ($, e, next) => {
125 if (!e.agentId) {
126 const now = await $.clock.now()
127 activeAt = now
128 row += 1
129 for (const text of textsOf(e.message.content)) track(text, now)
130 }
131 return next(e)
132 }).catch(($, e, next) => next(e))
133
134 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
135 track(e.props.text)
136 const stored = await read($, prs)
137 const blocks = e.props.isSummary ? null : layout(e.props.text, stored)
138 if (!blocks) {
139 const text = decorate(e.props.text, stored)
140 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
141 }
142
143 const { Box, Text, Link, Markdown } = $.ui.resolve(e)
144 const run = (r: Run, i: number) => {
145 const body = (
146 <Text key={`t${i}`} color={r.color} bold={r.bold} italic={r.italic} underline={r.underline}>
147 {r.text}
148 </Text>
149 )
150 return r.href ? <Link key={`k${i}`} href={r.href}>{body}</Link> : body
151 }
152 return (
153 <Box flexDirection="column">
154 {blocks.map((b, i) =>
155 b.kind === 'md' ? (
156 <Markdown key={`m${i}`} text={b.text} />
157 ) : b.kind === 'blank' ? (
158 <Text key={`b${i}`}> </Text>
159 ) : (
160 <Text key={`l${i}`}>{b.runs.map(run)}</Text>
161 ),
162 )}
163 </Box>
164 )
165 }).catch(($, e, next) => next(e))
166}
167hooks/layout.ts 191 lines1import type { PrStatus } from '../types'
2import { decorate, prKey } from './lib'
3
4export type Run = {
5 text: string
6 color?: string
7 bold?: boolean
8 italic?: boolean
9 underline?: boolean
10 href?: string
11}
12
13export type Block =
14 | { kind: 'md'; text: string }
15 | { kind: 'blank' }
16 | { kind: 'line'; runs: Run[] }
17
18// Nerd Font Octicons, coloured as GitHub does, using Claude Code theme keys.
19const OCT = { open: '', draft: '', merged: '', closed: '', check: '', diff: '' }
20const TITLE_MAX = 72
21
22const PR = (n: string) => String.raw`https://github\.com/(?<o${n}>[\w.-]+)/(?<r${n}>[\w.-]+)/pull/(?<n${n}>\d+)`
23const INLINE = new RegExp(
24 [
25 String.raw`(?<code>\x60[^\x60\n]+\x60)`,
26 String.raw`(?<prmd>\[[^\]\n]*\]\(${PR('1')}[^)\s]*\))`,
27 String.raw`(?<md>\[(?<mdt>[^\]\n]*)\]\((?<mdu>[^)\s]+)\))`,
28 String.raw`(?<bold>\*\*[^*\n]+\*\*)`,
29 String.raw`(?<it>\*[^*\s][^*\n]*\*)`,
30 String.raw`(?<prbare><?${PR('2')}(?![\w/])[^\s)>\]]*>?)`,
31 String.raw`(?<url>https?://[^\s)>\]]+)`,
32 ].join('|'),
33 'g',
34)
35const PR_ANY = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/g
36
37export function iconRuns(s: PrStatus): Run[] {
38 if (s.state === 'MERGED') return [{ text: OCT.merged, color: 'merged' }]
39 if (s.state === 'CLOSED') return [{ text: OCT.closed, color: 'error' }]
40 if (s.isDraft) return [{ text: OCT.draft, color: 'inactive' }]
41 const open: Run = { text: OCT.open, color: 'success' }
42 if (s.reviewDecision === 'APPROVED') return [open, { text: ' ' }, { text: OCT.check, color: 'success' }]
43 if (s.reviewDecision === 'CHANGES_REQUESTED') return [open, { text: ' ' }, { text: OCT.diff, color: 'error' }]
44 return [open]
45}
46
47function title(s: PrStatus): string {
48 return s.title.length > TITLE_MAX ? `${s.title.slice(0, TITLE_MAX - 1).trimEnd()}…` : s.title
49}
50
51const SEP = String.raw`\s*[,:;\u2013\u2014-]?\s*`
52const escape = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
53
54// The badge already shows number and title, so drop a copy written right beside the link.
55function echoes(number: string, s: PrStatus): { before: RegExp; after: RegExp; whole: RegExp } {
56 const num = String.raw`\(?(?:PR\s*)?#${number}\b\)?`
57 const ttl = s.title ? String.raw`["\u201c'*_]*${escape(s.title)}["\u201d'*_]*` : '(?!)'
58 const any = String.raw`(?:${num}(?:${SEP}${ttl})?|${ttl}(?:${SEP}${num})?)`
59 return {
60 before: new RegExp(String.raw`(^|\s)${any}${SEP}$`, 'i'),
61 after: new RegExp(String.raw`^\s*(?:\(${any}\)|${SEP}${num})(?=\W|$)`, 'i'),
62 whole: new RegExp(String.raw`^\s*${any}\s*$`, 'i'),
63 }
64}
65
66const ONLY_SEP = new RegExp(String.raw`^${SEP}$`)
67
68function trimBefore(runs: Run[], e: ReturnType<typeof echoes>) {
69 let end = runs.length
70 for (let i = runs.length - 1; i >= 0 && i >= runs.length - 4; i--) {
71 const r = runs[i] as Run
72 if (r.href) return
73 if (ONLY_SEP.test(r.text)) continue
74 if (e.whole.test(r.text)) {
75 runs.splice(i, end - i)
76 end = i
77 continue
78 }
79 const text = r.text.replace(e.before, '$1')
80 if (text !== r.text) runs.splice(i, end - i, { ...r, text })
81 return
82 }
83}
84
85export function inlineRuns(line: string, prs: Record<string, PrStatus>): Run[] {
86 const runs: Run[] = []
87 let at = 0
88 let echo: ReturnType<typeof echoes> | null = null
89 const plain = (text: string) => {
90 if (echo) text = text.replace(echo.after, '')
91 echo = null
92 if (text) runs.push({ text })
93 }
94 for (const m of line.matchAll(INLINE)) {
95 const g = m.groups ?? {}
96 if (m.index > at) plain(line.slice(at, m.index))
97 else echo = null
98 at = m.index + m[0].length
99 const owner = g.o1 ?? g.o2
100 const repo = g.r1 ?? g.r2
101 const number = g.n1 ?? g.n2
102 if (owner && repo && number) {
103 const href = `https://github.com/${owner}/${repo}/pull/${number}`
104 const s = prs[prKey(owner, repo, number)]
105 if (s) {
106 echo = echoes(number, s)
107 trimBefore(runs, echo)
108 const icons = iconRuns(s)
109 runs.push(...icons, { text: ' ' }, { text: `#${number}`, color: icons[0]?.color, bold: true, href })
110 runs.push({ text: ' ' }, { text: title(s), underline: true, href })
111 continue
112 }
113 else runs.push({ text: href, href })
114 } else if (g.code) runs.push({ text: g.code.slice(1, -1), color: 'permission' })
115 else if (g.md) runs.push(isWeb(g.mdu) ? { text: g.mdt || (g.mdu as string), href: g.mdu } : { text: g.md })
116 else if (g.bold) runs.push({ text: g.bold.slice(2, -2), bold: true })
117 else if (g.it) runs.push({ text: g.it.slice(1, -1), italic: true })
118 else if (g.url) runs.push({ text: g.url, href: g.url })
119 }
120 if (at < line.length) plain(line.slice(at))
121 return runs
122}
123
124function isWeb(url: string | undefined): boolean {
125 return /^https?:\/\//i.test(url ?? '')
126}
127
128function hasKnownPr(line: string, prs: Record<string, PrStatus>): boolean {
129 for (const m of line.matchAll(PR_ANY)) {
130 if (prs[prKey(m[1] as string, m[2] as string, m[3] as string)]) return true
131 }
132 return false
133}
134
135// Lines holding a known PR (outside fences and tables) are drawn by the mod so
136// their icons can be coloured; everything else stays engine-drawn markdown.
137export function layout(text: string, prs: Record<string, PrStatus>): Block[] | null {
138 const blocks: Block[] = []
139 let md: string[] = []
140 let inFence = false
141 let owned = false
142
143 const flush = () => {
144 while (md.length && !md[0]?.trim()) md.shift()
145 let trailingBlank = false
146 while (md.length && !md[md.length - 1]?.trim()) {
147 md.pop()
148 trailingBlank = true
149 }
150 if (md.length) blocks.push({ kind: 'md', text: decorate(md.join('\n'), prs) })
151 if (trailingBlank) blocks.push({ kind: 'blank' })
152 md = []
153 }
154
155 for (const line of text.split('\n')) {
156 if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
157 const isTable = /^\s*\|/.test(line)
158 if (inFence || isTable || !hasKnownPr(line, prs)) {
159 if (!md.length && !line.trim() && blocks.length && blocks[blocks.length - 1]?.kind !== 'blank') {
160 blocks.push({ kind: 'blank' })
161 continue
162 }
163 md.push(line)
164 continue
165 }
166 flush()
167 owned = true
168 const lead = /^(\s*)([-*+]|\d+\.)\s+/.exec(line)
169 const heading = /^#{1,6}\s+/.exec(line)
170 const quote = /^>\s?/.exec(line)
171 const runs: Run[] = []
172 let body = line
173 if (lead) {
174 const marker = /\d/.test(lead[2] as string) ? (lead[2] as string) : '-'
175 runs.push({ text: `${lead[1]}${marker} ` })
176 body = line.slice(lead[0].length)
177 } else if (quote) {
178 runs.push({ text: '▎ ', color: 'inactive' })
179 body = line.slice(quote[0].length)
180 } else if (heading) {
181 body = line.slice(heading[0].length)
182 }
183 const inline = inlineRuns(body, prs)
184 runs.push(...(heading ? inline.map(r => ({ ...r, bold: true })) : inline))
185 blocks.push({ kind: 'line', runs })
186 }
187 flush()
188 while (blocks[blocks.length - 1]?.kind === 'blank') blocks.pop()
189 return owned ? blocks : null
190}
191hooks/lib.ts 112 lines1import type { PrStatus } from '../types'
2
3const PR_URL = String.raw`https://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)`
4
5// A markdown link wrapping a PR URL, or a bare (optionally <angled>) PR URL.
6const LINK = new RegExp(String.raw`\[[^\]\n]*\]\(${PR_URL}[^)\s]*\)|<?${PR_URL}(?![\w/])[^\s)>\]]*>?`, 'g')
7
8export type PrRef = { key: string; url: string }
9
10export function prKey(owner: string, repo: string, number: string): string {
11 return `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`
12}
13
14export function findPrs(text: string): PrRef[] {
15 const found = new Map<string, PrRef>()
16 for (const m of text.matchAll(LINK)) {
17 const [owner = '', repo = '', number = ''] = m[1] ? [m[1], m[2], m[3]] : [m[4], m[5], m[6]]
18 const key = prKey(owner, repo, number)
19 found.set(key, { key, url: `https://github.com/${owner}/${repo}/pull/${number}` })
20 }
21 return [...found.values()]
22}
23
24// Every string inside a row's content blocks, tool results included.
25export function textsOf(value: unknown): string[] {
26 if (typeof value === 'string') return [value]
27 if (Array.isArray(value)) return value.flatMap(textsOf)
28 if (value && typeof value === 'object') {
29 const v = value as { text?: unknown; content?: unknown }
30 return [...textsOf(v.text), ...textsOf(v.content)]
31 }
32 return []
33}
34
35// Nerd Font Octicons: git-pull-request, -draft, git-merge, -closed, check, file-diff.
36const ICON = { open: '\uf407', draft: '\uf4dd', merged: '\uf419', closed: '\uf4dc', approved: '\uf42e', changes: '\uf4d2' }
37const TITLE_MAX = 72
38
39export function stateWord(s: PrStatus): string {
40 if (s.state === 'MERGED') return 'merged'
41 if (s.state === 'CLOSED') return 'closed'
42 if (s.isDraft) return 'draft'
43 if (s.reviewDecision === 'APPROVED') return 'open, approved'
44 if (s.reviewDecision === 'CHANGES_REQUESTED') return 'open, changes requested'
45 return 'open'
46}
47
48export function icon(s: PrStatus): string {
49 if (s.state === 'MERGED') return ICON.merged
50 if (s.state === 'CLOSED') return ICON.closed
51 if (s.isDraft) return ICON.draft
52 if (s.reviewDecision === 'APPROVED') return `${ICON.open} ${ICON.approved}`
53 if (s.reviewDecision === 'CHANGES_REQUESTED') return `${ICON.open} ${ICON.changes}`
54 return ICON.open
55}
56
57function linkText(title: string, number: string): string {
58 const t = title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1).trimEnd()}…` : title
59 return `#${number} ${t}`.replace(/[\\[\]]/g, m => `\\${m}`)
60}
61
62// Code spans and fences are left alone so a link inside code stays literal.
63export function decorate(text: string, prs: Record<string, PrStatus>): string {
64 return text
65 .split(/(```[\s\S]*?(?:```|$)|`[^`\n]*`)/)
66 .map((part, i) =>
67 i % 2 === 1
68 ? part
69 : part.replace(LINK, (match, o1, r1, n1, o2, r2, n2) => {
70 const [owner, repo, number] = o1 ? [o1, r1, n1] : [o2, r2, n2]
71 const s = prs[prKey(owner, repo, number)]
72 if (!s) return match
73 const url = `https://github.com/${owner}/${repo}/pull/${number}`
74 return `${icon(s)} [${linkText(s.title, number)}](${url})`
75 }),
76 )
77 .join('')
78}
79
80// Titles come from strangers on public repos: drop control and bidi-override characters.
81export function cleanTitle(value: unknown): string {
82 return String(value ?? '')
83 .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, ' ')
84 .replace(/\s+/g, ' ')
85 .trim()
86}
87
88export function parseGh(stdout: string): PrStatus | null {
89 try {
90 const j = JSON.parse(stdout)
91 if (j.state !== 'OPEN' && j.state !== 'MERGED' && j.state !== 'CLOSED') return null
92 return {
93 state: j.state,
94 isDraft: Boolean(j.isDraft),
95 title: cleanTitle(j.title),
96 mergedAt: j.mergedAt || null,
97 reviewDecision: j.reviewDecision || null,
98 }
99 } catch {
100 return null
101 }
102}
103
104export function describeChange(key: string, before: PrStatus, after: PrStatus): string | null {
105 const was = stateWord(before)
106 const now = stateWord(after)
107 if (was === now) return null
108 const when = after.state === 'MERGED' && after.mergedAt ? ` at ${after.mergedAt}` : ''
109 // No title here: this note reaches the model, and a title is untrusted text.
110 return `PR ${key} changed state: ${was} → ${now}${when}.`
111}
112hooks/poll.ts 64 lines1import type { PrStatus } from '../types'
2import { cleanTitle } from './lib'
3
4export const RECENT_ROWS = 20
5export const RECENT_MS = 30 * 60_000
6export const IDLE_MS = 30 * 60_000
7export const HOT_MS = 15_000
8export const WARM_MS = 5 * 60_000
9export const CLOSED_MS = 10 * 60_000
10export const BATCH_MAX = 50
11
12export type Seen = { row: number; at: number }
13export type PrRef = { key: string; owner: string; repo: string; number: number }
14
15export function isRecent(seen: Seen | undefined, row: number, now: number): boolean {
16 return !!seen && (row - seen.row <= RECENT_ROWS || now - seen.at <= RECENT_MS)
17}
18
19// Merged PRs never change again; a PR not yet fetched is checked at the fast rate.
20export function interval(status: PrStatus | undefined, recent: boolean): number {
21 if (status?.state === 'MERGED') return Infinity
22 if (status?.state === 'CLOSED') return CLOSED_MS
23 return recent ? HOT_MS : WARM_MS
24}
25
26export function refOf(key: string): PrRef | null {
27 const m = /^([\w.-]+)\/([\w.-]+)#(\d+)$/.exec(key)
28 return m ? { key, owner: m[1] as string, repo: m[2] as string, number: Number(m[3]) } : null
29}
30
31// One GraphQL request for every PR, values passed as variables, never spliced into the query.
32export function batchArgs(refs: PrRef[]): string[] {
33 const vars = refs.map((_, i) => `$o${i}:String!,$r${i}:String!,$n${i}:Int!`).join(',')
34 const fields = 'state isDraft title mergedAt reviewDecision'
35 const body = refs
36 .map((_, i) => `p${i}:repository(owner:$o${i},name:$r${i}){pullRequest(number:$n${i}){${fields}}}`)
37 .join(' ')
38 const args = ['api', 'graphql', '-f', `query=query(${vars}){${body}}`]
39 refs.forEach((r, i) => args.push('-f', `o${i}=${r.owner}`, '-f', `r${i}=${r.repo}`, '-F', `n${i}=${r.number}`))
40 return args
41}
42
43export function parseBatch(stdout: string, refs: PrRef[]): Map<string, PrStatus> {
44 const out = new Map<string, PrStatus>()
45 let data: Record<string, { pullRequest?: Record<string, unknown> | null } | null> = {}
46 try {
47 data = JSON.parse(stdout).data ?? {}
48 } catch {
49 return out
50 }
51 refs.forEach((r, i) => {
52 const pr = data[`p${i}`]?.pullRequest
53 if (!pr || (pr.state !== 'OPEN' && pr.state !== 'MERGED' && pr.state !== 'CLOSED')) return
54 out.set(r.key, {
55 state: pr.state,
56 isDraft: Boolean(pr.isDraft),
57 title: cleanTitle(pr.title),
58 mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null,
59 reviewDecision: typeof pr.reviewDecision === 'string' && pr.reviewDecision ? pr.reviewDecision : null,
60 })
61 })
62 return out
63}
64types/index.d.ts 16 lines1export type PrState = 'OPEN' | 'MERGED' | 'CLOSED'
2
3export type PrStatus = {
4 state: PrState
5 isDraft: boolean
6 title: string
7 mergedAt: string | null
8 reviewDecision: string | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'pr-status': { prs: Record<string, PrStatus>; isGuided: boolean }
14 }
15}
16