A pane that shows the GitHub checks of the current branch's pull request

Five mods for Claude Code that show what is happening in a session without getting in your way:
| Mod | Where it shows | What it does |
|---|---|---|
Tokachu (context-pet) | Band above the prompt | A pet that grows and turns from green to red as the context fills up |
| usage-meter | Status line under the prompt | Your 5-hour and 7-day usage limits, with warnings at 80% and 95% |
| turn-notify | Desktop notification | Tells you when a turn of one minute or more is done |
| changed-files | Side pane | The files that differ from HEAD, as git diff shows them, with added and removed lines |
| pr-checks | Side pane | The GitHub checks of the current branch's pull request: failed, running, passed |
The mods work together: each one uses a different part of the screen, and the two panes show as tabs of the side pane. You can install all of them or only the ones you want.
You need a Claude Code version that supports mods (plugins with function hooks). The mods are tested with Claude Code 2.1.295.
Add the marketplace, then install the mods you want:
claude plugin marketplace add RenaudLavoisier/claude-code-mods
claude plugin install context-pet@claude-code-mods --scope user
claude plugin install usage-meter@claude-code-mods --scope user
claude plugin install turn-notify@claude-code-mods --scope user
claude plugin install changed-files@claude-code-mods --scope user
claude plugin install pr-checks@claude-code-mods --scope user
With --scope user, the mods load in every Claude Code session. In a session that is already open, run /reload-plugins to load them.
To get a new version:
claude plugin marketplace update claude-code-mods
claude plugin update context-pet@claude-code-mods # same for the other mods
To remove a mod:
claude plugin uninstall context-pet@claude-code-mods
Tokachu lives above the prompt and eats your context. The more of the context window the conversation uses, the bigger and redder Tokachu gets.
/\___/\ Tokachu · 10% of context
( ^_^ ) 20k / 200k tokens
\_____/ feeling great
/\_____________/\ Tokachu · 70% of context
( O_O ) 140k / 200k tokens
( ) bloated
( )
( )
\_______________/
#20df20) at 0%, through yellow (#dfdf20) at 50%, to red (#df2020) at 100%.| Context used | Face | Mood |
|---|---|---|
| Less than 40% | ^_^ | feeling great |
| 40% to 59% | o_o | well fed |
| 60% to 79% | O_O | bloated |
| 80% to 89% | >_< | about to burst |
| 90% or more | x_x | about to explode, try /compact |
While Claude works, the mood line shows nom nom….
Tokachu ate too much: N% of context./clear: Tokachu goes back to its small, green size./pet: hides or shows Tokachu (Tokachu is taking a nap. / Tokachu is back.).Shows your plan's usage limits in the status line under the prompt:
limits 5h 42% (resets in 2h10) · 7d 82% (resets in 3d4h) ⚠
5h (five-hour limit), 7d (seven-day limit) and spend (spend limit), as Claude Code reports them.⚠ and a toast says, for example, 5h usage limit at 81%.Sends a desktop notification when a turn of one minute or more ends, so you can do something else while Claude works.
Claude Code
Done in 2m14s: The migration is ready and all tests pass.
Done in <duration>: <first line of the answer>. The first line is cleaned of Markdown marks and cut at 120 characters. If the turn fails, the message is Turn failed after <duration> (or Turn refused after <duration>)./config (Notifications). Your own Notification hooks also receive it.A side pane that lists the tracked files that differ from HEAD in the session's git repository, as git diff and git status show them:
+12 -3 app/src/core/tasks.py
+40 -0 app/tests/test_tasks.py added
+0 -25 app/legacy.py deleted
bin logo.png
4 files · +52 -28 · press a file to mention it
HEAD (git diff HEAD). Untracked files are not listed: git add a new file to see it. The list shows every change in the repository, whoever made it: Claude, you in your editor, or a shell command.+N lines added and -N lines removed, then added or deleted when the file is not only modified. bin marks a binary file. Paths are relative to the session's directory.Edit, Write, NotebookEdit or Bash, when you type /changes, and every 5 seconds while the pane is open. Git runs with --no-optional-locks, so these reads never block your own git commands./changes, which opens the pane at any width.@path in the prompt.Not in a git repository.A side pane that shows the GitHub checks of the pull request of the current git branch:
PR #3021
feat(core): Log caller and force flags of task API launches
✓ 14 ✗ 1 ● 2 ○ 2
✗ Run linters
● Run unit tests
● Run functional tests
✓ Run Sonarqube analysis
…
↻ refresh
gh), logged in with gh auth login. The mod runs git branch --show-current, gh pr view and gh pr checks.✗) and cancelled (⊘) first, then running (●), passed (✓) and skipped (○). PR #N and each check name are links to GitHub.↻ refresh. In between, every 30 seconds while a check runs or after you change branch, else every 2 minutes.PR #N: all checks passed or PR #N: N checks failed./pr-checks, which opens the pane at any width./pr-checks: opens the pane and lists every check with its link in the conversation, so you can ask Claude to fix a failing one.No pull request for branch <branch>. If gh fails, the pane shows the error.Each mod is a plugin folder:
context-pet/
├── .claude-plugin/plugin.json # name, version, description
├── hooks/hooks.json # points to register.tsx
├── hooks/register.tsx # the hooks
├── hooks/*.ts # pure helpers
├── types/index.d.ts # the mod's state (when it has one)
└── tests/*.test.ts
To work on the mods, clone the repository and add the clone as a local marketplace. Claude Code reads a local marketplace in place, so /reload-plugins loads your changes without a reinstall:
git clone git@github.com:RenaudLavoisier/claude-code-mods.git
claude plugin marketplace add ./claude-code-mods
claude plugin install context-pet@claude-code-mods --scope user
Check and test a mod:
claude plugin validate ./context-pet
claude plugin test ./context-pet
Before you push a change, increase version in the mod's plugin.json, so that claude plugin update finds the new version.
hooks/register.tsx 252 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Check, CheckBucket, PullRequest, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'pr-checks', key: 'snapshot' } as const, null)
7const hasOpened = atom({ plugin: 'pr-checks', key: 'hasOpened' } as const, false)
8
9const PANE = 'pr-checks'
10const TITLE = 'PR checks'
11// While a check runs or after a branch change, gh is read every TICK_MS; else every IDLE_REFRESH_MS.
12const TICK_MS = 30_000
13const IDLE_REFRESH_MS = 120_000
14const BUCKETS: readonly CheckBucket[] = ['pass', 'fail', 'pending', 'skipping', 'cancel']
15// Failing checks first, then running, passed and skipped.
16const ORDER: readonly CheckBucket[] = ['fail', 'cancel', 'pending', 'pass', 'skipping']
17const MARKS: Record<CheckBucket, string> = {
18 fail: '✗',
19 cancel: '⊘',
20 pending: '●',
21 pass: '✓',
22 skipping: '○',
23}
24const COLORS: Record<CheckBucket, 'error' | 'warning' | 'success' | undefined> = {
25 fail: 'error',
26 cancel: 'error',
27 pending: 'warning',
28 pass: 'success',
29 skipping: undefined,
30}
31
32type Counts = Record<CheckBucket, number>
33
34function countChecks(checks: readonly Check[]): Counts {
35 const counts: Counts = { pass: 0, fail: 0, pending: 0, skipping: 0, cancel: 0 }
36 for (const check of checks) counts[check.bucket] += 1
37 return counts
38}
39
40function sortChecks(checks: readonly Check[]): Check[] {
41 return ORDER.flatMap(bucket => checks.filter(one => one.bucket === bucket))
42}
43
44function firstLine(text: string): string {
45 return text.trim().split('\n')[0] ?? ''
46}
47
48function parseChecks(stdout: string): Check[] {
49 const rows: unknown = JSON.parse(stdout)
50 if (!Array.isArray(rows)) return []
51 return rows.map(row => ({
52 name: String(row?.name ?? ''),
53 bucket: BUCKETS.includes(row?.bucket) ? row.bucket : 'pending',
54 link: String(row?.link ?? ''),
55 }))
56}
57
58async function currentBranch($: EngineInterface): Promise<string> {
59 const git = await $.process.run(['git', 'branch', '--show-current'])
60 return git.exitCode === 0 ? git.stdout.trim() : ''
61}
62
63async function fetchSnapshot($: EngineInterface): Promise<Snapshot> {
64 const updatedAt = await $.clock.now()
65 const branch = await currentBranch($)
66 if (branch === '') return { branch, pr: null, checks: [], error: null, updatedAt }
67
68 const view = await $.process.run(['gh', 'pr', 'view', '--json', 'number,title,url,state'])
69 if (view.exitCode !== 0) {
70 const isMissing = /no pull requests found/i.test(view.stderr)
71 return { branch, pr: null, checks: [], error: isMissing ? null : firstLine(view.stderr), updatedAt }
72 }
73 const pr = JSON.parse(view.stdout) as PullRequest
74
75 // gh exits 1 when a check failed and 8 while one is pending: stdout still holds the JSON.
76 const run = await $.process.run(['gh', 'pr', 'checks', String(pr.number), '--json', 'name,bucket,link'])
77 if (run.stdout.trim() === '') {
78 const isEmpty = /no checks reported/i.test(run.stderr)
79 return { branch, pr, checks: [], error: isEmpty ? null : firstLine(run.stderr), updatedAt }
80 }
81
82 return { branch, pr, checks: parseChecks(run.stdout), error: null, updatedAt }
83}
84
85function announce($: EngineInterface, previous: Snapshot | null, current: Snapshot): void {
86 if (!previous?.pr || !current.pr || previous.pr.number !== current.pr.number) return
87 const before = countChecks(previous.checks)
88 const after = countChecks(current.checks)
89 if (before.pending === 0 || after.pending > 0) return
90
91 const failed = after.fail + after.cancel
92 $.ui.toast(
93 failed === 0
94 ? `PR #${current.pr.number}: all checks passed`
95 : `PR #${current.pr.number}: ${failed} check${failed > 1 ? 's' : ''} failed`,
96 )
97}
98
99function describeSnapshot(current: Snapshot | null): string {
100 if (current === null) return 'PR checks: not loaded yet.'
101 if (current.pr === null) {
102 return current.error ?? `No pull request for branch ${current.branch || '(detached HEAD)'}.`
103 }
104
105 const { pr, checks } = current
106 const counts = countChecks(checks)
107 const lines = [
108 `PR #${pr.number} ${pr.title} (${pr.state.toLowerCase()}) ${pr.url}`,
109 `${counts.pass} passed, ${counts.fail} failed, ${counts.cancel} cancelled, ${counts.pending} pending, ${counts.skipping} skipped`,
110 ]
111 for (const check of sortChecks(checks)) {
112 lines.push(`${MARKS[check.bucket]} ${check.name}${check.link ? ` ${check.link}` : ''}`)
113 }
114 if (current.error !== null) lines.push(`(last refresh failed: ${current.error})`)
115
116 return lines.join('\n')
117}
118
119// Opened unasked once per session, the first time the branch has a pull request.
120async function openOnce($: EngineInterface): Promise<void> {
121 if (await read($, hasOpened)) return
122 await update($, hasOpened, () => true)
123
124 // Opened unasked, the pane waits undrawn below 144 columns: point to /pr-checks.
125 const opened = await $.ui.open({ id: PANE, title: TITLE })
126 if (!opened.isPlaced) {
127 $.ui.toast('Terminal too narrow to open the PR checks pane: type /pr-checks to see it', {
128 timeoutMs: 10_000,
129 })
130 }
131}
132
133let isRefreshing = false
134
135async function refresh($: EngineInterface): Promise<void> {
136 if (isRefreshing) return
137 isRefreshing = true
138 try {
139 const previous = await read($, snapshot)
140 let current: Snapshot
141 try {
142 current = await fetchSnapshot($)
143 } catch (error) {
144 const message = error instanceof Error ? error.message : String(error)
145 current = previous
146 ? { ...previous, error: message }
147 : { branch: '', pr: null, checks: [], error: message, updatedAt: await $.clock.now() }
148 }
149 await update($, snapshot, () => current)
150 announce($, previous, current)
151 if (current.pr !== null) await openOnce($)
152 } finally {
153 isRefreshing = false
154 }
155}
156
157async function tick($: EngineInterface): Promise<void> {
158 const current = await read($, snapshot)
159 if (current === null) return refresh($)
160
161 const isPending = countChecks(current.checks).pending > 0
162 const isOld = (await $.clock.now()) - current.updatedAt >= IDLE_REFRESH_MS
163 if (isPending || isOld || (await currentBranch($)) !== current.branch) {
164 await refresh($)
165 }
166}
167
168export const register: Register = on => {
169 on('session.start', async ($, e, next) => {
170 await $.command.register({
171 name: 'pr-checks',
172 description: 'Show the checks of the current branch pull request in a pane',
173 })
174 $.clock.after(1, () => void refresh($))
175 $.clock.every(TICK_MS, () => void tick($))
176
177 return next(e)
178 })
179
180 on('turn.complete', ($, e, next) => {
181 $.clock.after(1, () => void refresh($))
182
183 return next(e)
184 })
185
186 on('command.run', { command: 'pr-checks' }, async $ => {
187 await refresh($)
188 await $.ui.open({ id: PANE, title: TITLE })
189
190 return { text: describeSnapshot(await read($, snapshot)) }
191 })
192
193 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
194 const { Box, Button, Link, Text } = $.ui.resolve(e)
195 const current = await read($, snapshot)
196 const refreshButton = (
197 <Button key="refresh" plain onPress={() => refresh($)}>
198 <Text dimColor>↻ refresh</Text>
199 </Button>
200 )
201
202 if (current === null) return <Text dimColor>Reading the checks…</Text>
203 if (current.pr === null) {
204 return (
205 <Box flexDirection="column">
206 <Text dimColor>
207 {current.error ?? `No pull request for branch ${current.branch || '(detached HEAD)'}.`}
208 </Text>
209 <Box marginTop={1}>{refreshButton}</Box>
210 </Box>
211 )
212 }
213
214 const { pr, checks } = current
215 const counts = countChecks(checks)
216 const failed = counts.fail + counts.cancel
217
218 return (
219 <Box flexDirection="column">
220 <Box columnGap={1}>
221 <Link href={pr.url} label={`PR #${pr.number}`} />
222 {pr.state !== 'OPEN' && <Text color="merged">{pr.state.toLowerCase()}</Text>}
223 </Box>
224 <Text dimColor wrap="truncate">
225 {pr.title}
226 </Text>
227 <Box columnGap={1} marginBottom={1}>
228 {checks.length === 0 && <Text dimColor>No checks.</Text>}
229 {counts.pass > 0 && <Text color="success">✓ {counts.pass}</Text>}
230 {failed > 0 && <Text color="error">✗ {failed}</Text>}
231 {counts.pending > 0 && <Text color="warning">● {counts.pending}</Text>}
232 {counts.skipping > 0 && <Text dimColor>○ {counts.skipping}</Text>}
233 </Box>
234 {sortChecks(checks).map(check => (
235 <Box columnGap={1}>
236 <Text color={COLORS[check.bucket]} dimColor={check.bucket === 'skipping'}>
237 {MARKS[check.bucket]}
238 </Text>
239 {check.link ? <Link href={check.link} label={check.name} /> : <Text>{check.name}</Text>}
240 </Box>
241 ))}
242 {current.error !== null && (
243 <Box marginTop={1}>
244 <Text dimColor>Last refresh failed: {current.error}</Text>
245 </Box>
246 )}
247 <Box marginTop={1}>{refreshButton}</Box>
248 </Box>
249 )
250 })
251}
252types/index.d.ts 25 lines1export type CheckBucket = 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel'
2
3export type Check = { name: string; bucket: CheckBucket; link: string }
4
5export type PullRequest = {
6 number: number
7 title: string
8 url: string
9 state: string
10}
11
12export type Snapshot = {
13 branch: string
14 pr: PullRequest | null
15 checks: Check[]
16 error: string | null
17 updatedAt: number
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'pr-checks': { snapshot: Snapshot | null; hasOpened: boolean }
23 }
24}
25