A pane that lists the files that differ from HEAD, as git diff shows them, untracked files left out

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 175 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { changesOf, parseNumstat, parseStatus, relativeTo } from './files'
5import type { FileChange, Snapshot } from '../types'
6
7const PANE = 'changed-files'
8const TITLE = 'Changed files'
9// While the pane is open, how often git is read again, to follow changes made outside the session.
10const POLL_MS = 5_000
11// Once one of these tools has run, git is read again at once.
12const CHANGING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
13
14const changes = atom({ plugin: 'changed-files', key: 'snapshot' } as const, { isRepo: true, files: [] })
15const hasOpened = atom({ plugin: 'changed-files', key: 'hasOpened' } as const, false)
16
17// The empty tree: the base of a repository with no commit yet.
18const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
19
20// --no-optional-locks: a read from the timer never takes index.lock from the person's own git.
21async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
22 const ran = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 10_000 })
23
24 return ran.exitCode === 0 ? ran.stdout : null
25}
26
27// What `git diff HEAD` and `git status` say changed in the repository that holds cwd, untracked files left out.
28async function snapshot($: EngineInterface, cwd: string): Promise<Snapshot> {
29 const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
30 if (top === null) return { isRepo: false, files: [] }
31
32 const root = top.trim()
33 const head = await git($, root, ['rev-parse', '--verify', '--quiet', 'HEAD'])
34 const [diff, status] = await Promise.all([
35 git($, root, ['diff', head === null ? EMPTY_TREE : 'HEAD', '--numstat', '-z', '--no-renames']),
36 git($, root, ['status', '--porcelain=v1', '-z', '--untracked-files=no', '--no-renames']),
37 ])
38 if (diff === null || status === null) throw new Error(`git diff or git status failed in ${root}`)
39
40 return { isRepo: true, files: changesOf(root, parseNumstat(diff), parseStatus(status)) }
41}
42
43let running: Promise<Snapshot> | null = null
44let isStale = false
45let poll: Timer | null = null
46
47// One git read at a time: a refresh asked during one reads again once it is done.
48function refresh($: EngineInterface): Promise<Snapshot> {
49 if (running !== null) {
50 isStale = true
51
52 return running
53 }
54 running = (async () => {
55 let latest: Snapshot
56 do {
57 isStale = false
58 latest = await snapshot($, await $.session.cwd())
59 const current = await read($, changes)
60 if (JSON.stringify(current) !== JSON.stringify(latest)) {
61 await update($, changes, () => latest)
62 }
63 } while (isStale)
64
65 return latest
66 })().finally(() => {
67 running = null
68 })
69
70 return running
71}
72
73function refreshQuietly($: EngineInterface): Promise<Snapshot | null> {
74 return refresh($).catch(error => {
75 $.ui.log(`changed-files: ${String(error)}`, { to: 'debug' })
76
77 return null
78 })
79}
80
81async function openPane($: EngineInterface) {
82 const opened = await $.ui.open({ id: PANE, title: TITLE })
83 poll ??= $.clock.every(POLL_MS, () => void refreshQuietly($))
84
85 return opened
86}
87
88export const register: Register = on => {
89 on('session.start', async ($, e, next) => {
90 await $.command.register({
91 name: 'changes',
92 description: 'Show the files that differ from HEAD in this repository',
93 })
94 void refreshQuietly($)
95
96 return next(e)
97 })
98
99 on('command.run', { command: 'changes' }, async $ => {
100 await refreshQuietly($)
101 await openPane($)
102
103 return { text: 'Changed files pane opened.' }
104 })
105
106 on('ui.close', async ($, e, next) => {
107 const closed = await next(e)
108 if (e.id === PANE) {
109 poll?.cancel()
110 poll = null
111 }
112
113 return closed
114 }).catch(($, e, next) => next(e))
115
116 on('tool.call', async ($, e, next) => {
117 const ran = await next(e)
118 if (ran.deny !== undefined || !CHANGING_TOOLS.has(String(e.tool))) {
119 return ran
120 }
121
122 const latest = await refreshQuietly($)
123 if (latest !== null && latest.files.length > 0 && !(await read($, hasOpened))) {
124 await update($, hasOpened, () => true)
125 // Opened unasked, the pane waits undrawn below 144 columns: point to /changes.
126 const opened = await openPane($)
127 if (!opened.isPlaced) {
128 $.ui.toast('Terminal too narrow to open the pane: type /changes to see the changed files', {
129 timeoutMs: 10_000,
130 })
131 }
132 }
133
134 return ran
135 }).catch(($, e, next) => next(e)) // A failure here never runs the tool twice: next replays.
136
137 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
138 const { Box, Button, Text } = $.ui.resolve(e)
139 const { isRepo, files } = await read($, changes)
140 const cwd = await $.session.cwd()
141 const room = Math.max(1, (e.viewport?.rows ?? 24) - 6)
142 const added = files.reduce((sum, file) => sum + (file.added ?? 0), 0)
143 const removed = files.reduce((sum, file) => sum + (file.removed ?? 0), 0)
144 const lines = (file: FileChange) =>
145 file.added === null || file.removed === null
146 ? [<Text dimColor>{file.isBinary ? 'bin' : '?'}</Text>]
147 : [<Text color="success">+{file.added}</Text>, ' ', <Text color="error">-{file.removed}</Text>]
148
149 return (
150 <Box flexDirection="column">
151 {!isRepo && <Text dimColor>Not in a git repository.</Text>}
152 {isRepo && files.length === 0 && <Text dimColor>No change against HEAD.</Text>}
153 {files.slice(0, room).map(file => (
154 <Button
155 key={file.path}
156 plain
157 onPress={() => $.prompt.fill({ text: `@${relativeTo(cwd, file.path)} `, mode: 'insert' })}
158 >
159 {lines(file)} {relativeTo(cwd, file.path)}
160 <Text dimColor>{file.kind === 'modified' ? '' : ` ${file.kind}`}</Text>
161 </Button>
162 ))}
163 {files.length > room && <Text dimColor>…and {files.length - room} more</Text>}
164 {files.length > 0 && (
165 <Box marginTop={1}>
166 <Text dimColor>
167 {files.length} files · +{added} -{removed} · press a file to mention it
168 </Text>
169 </Box>
170 )}
171 </Box>
172 )
173 })
174}
175hooks/files.ts 53 lines1import type { FileChange, FileKind } from '../types'
2
3export type Count = Pick<FileChange, 'added' | 'removed' | 'isBinary'>
4
5export const BINARY: Count = { added: null, removed: null, isBinary: true }
6
7// `git diff --numstat -z --no-renames`: "added\tremoved\tpath\0" per file, "-\t-" for a binary one.
8export function parseNumstat(out: string): Map<string, Count> {
9 const counts = new Map<string, Count>()
10 for (const record of out.split('\0')) {
11 const [added, removed, ...path] = record.split('\t')
12 if (added === undefined || removed === undefined || path.length === 0) continue
13
14 counts.set(path.join('\t'), added === '-' ? BINARY : { added: Number(added), removed: Number(removed), isBinary: false })
15 }
16
17 return counts
18}
19
20// `git status --porcelain=v1 -z --no-renames`: "XY path\0" per file, X the index and Y the work tree.
21export function parseStatus(out: string): Map<string, string> {
22 const codes = new Map<string, string>()
23 for (const record of out.split('\0')) {
24 if (record.length > 3) codes.set(record.slice(3), record.slice(0, 2))
25 }
26
27 return codes
28}
29
30export function kindOf(code: string | undefined): FileKind {
31 if (code?.[0] === 'A') return 'added'
32 if (code?.includes('D')) return 'deleted'
33
34 return 'modified'
35}
36
37// The files that differ from the base, sorted by path.
38export function changesOf(root: string, counts: Map<string, Count>, codes: Map<string, string>): FileChange[] {
39 return [...counts]
40 .map(([path, count]): FileChange => ({
41 path: `${root}/${path}`,
42 kind: kindOf(codes.get(path)),
43 ...count,
44 }))
45 .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
46}
47
48export function relativeTo(cwd: string, path: string): string {
49 const root = cwd.endsWith('/') ? cwd : `${cwd}/`
50
51 return path.startsWith(root) ? path.slice(root.length) : path
52}
53types/index.d.ts 21 lines1// How git sees a file against HEAD; untracked files are not listed.
2export type FileKind = 'modified' | 'added' | 'deleted'
3
4// added and removed are null for a binary file.
5export type FileChange = {
6 path: string
7 kind: FileKind
8 added: number | null
9 removed: number | null
10 isBinary: boolean
11}
12
13// isRepo is false when the session's directory is not in a git repository.
14export type Snapshot = { isRepo: boolean; files: FileChange[] }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'changed-files': { snapshot: Snapshot; hasOpened: boolean }
19 }
20}
21