Two-line status above the prompt: model, directory, git branch, context fill and rate-limit usage

A personal Claude Code plugin marketplace of terminal UI mods: a status band above the prompt and two side panes.
| Plugin | What it adds | Command |
|---|---|---|
prompt-status | A two-line band above the prompt: model and context window, current directory, git branch, context fill and rate-limit usage | — |
token-usage | A pane with context fill, session token totals, cost and rate limits | /token-usage |
git-changes | A pane listing the files changed in the working tree, with colored git diff stats | /changes |
/plugin marketplace add matr1xp/local-mods
/plugin install prompt-status@local-mods
/plugin install token-usage@local-mods
/plugin install git-changes@local-mods
Renders above the prompt in every session:

model setting.*) refresh after each turn and once a minute. A detached HEAD shows its short commit hash.Opens a pane at session start. Run /token-usage to reopen it. The pane shows:

Running /clear resets the session totals.
Opens a pane in interactive sessions. Run /changes to reopen it and print a summary. The pane lists every file that differs from HEAD, whether staged, unstaged or untracked:

M, A, D, R, ??, …), +added / -deleted counts and a git --stat-style bar. Binary files show bin.● marker on files whose stats changed since the previous scan.Edit, Write, NotebookEdit and Bash tool calls, and every 4 seconds while it is on screen. Press r to refresh by hand.GIT_OPTIONAL_LOCKS=0, so polling never holds index.lock while you run your own git commands.new..claude-plugin/marketplace.json marketplace manifest listing the plugins
<plugin>/
.claude-plugin/plugin.json name, version, description
hooks/hooks.json points at the hooks module
hooks/register.tsx the mod: event hooks and UI rendering
types/index.d.ts shapes of the state each mod stores
tests/*.test.ts(x) tests using claude-code/testing
Run a plugin's tests, then validate the marketplace:
claude plugin test git-changes
claude plugin validate .
After changing a plugin, bump version in its .claude-plugin/plugin.json so other machines pick up the update with claude plugin update <plugin>@local-mods.
MIT. The software is provided as is, without warranty of any kind, and the author is not liable for any damage arising from its use.
hooks/register.tsx 205 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Git, Limit, Reading } from '../types'
5
6const BAR_WIDTH = 10
7const TICK_MS = 60_000
8
9const COLOR = {
10 model: 'cyan',
11 dir: 'yellow',
12 git: 'magenta',
13 separator: 'gray',
14 label: 'gray',
15 context: 'green',
16 usage: 'magenta',
17 reset: 'gray',
18} as const
19
20const EMPTY: Reading = { model: null, contextWindow: null, contextPercent: null, limit: null }
21
22const reading = atom({ plugin: 'prompt-status', key: 'reading' } as const, EMPTY)
23const git = atom({ plugin: 'prompt-status', key: 'git' } as const, null)
24const now = atom({ plugin: 'prompt-status', key: 'now' } as const, 0)
25
26const FAMILIES = ['opus', 'sonnet', 'haiku', 'fable']
27
28const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
29
30/** `claude-opus-5-5` → `Opus 5.5`; an alias such as `opus[1m]` → `Opus`. */
31export const modelName = (model: string): string => {
32 const id = model.match(/claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/)
33 if (id?.[1] && id[2]) return `${capitalize(id[1])} ${id[2]}${id[3] ? `.${id[3]}` : ''}`
34 const alias = model.toLowerCase().match(/^([a-z]+)/)?.[1]
35 if (alias && FAMILIES.includes(alias)) return capitalize(alias)
36 return model
37}
38
39/** 1_000_000 → `1M`, 200_000 → `200k`. */
40export const windowName = (tokens: number): string =>
41 tokens >= 1_000_000
42 ? `${+(tokens / 1_000_000).toFixed(1)}M`
43 : `${Math.round(tokens / 1_000)}k`
44
45/** Minutes until `resetsAt`: `36m`, `2h 5m`, `3d`; empty when unknown or past. */
46export const untilReset = (resetsAt: string | undefined, nowMs: number): string => {
47 if (!resetsAt) return ''
48 const at = Date.parse(resetsAt)
49 if (Number.isNaN(at)) return ''
50 const minutes = Math.ceil((at - nowMs) / 60_000)
51 if (minutes <= 0) return ''
52 if (minutes < 60) return `${minutes}m`
53 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
54 return `${Math.round(minutes / (24 * 60))}d`
55}
56
57/** The five-hour window when reported, otherwise the fullest one. */
58const pickLimit = (limits: readonly SessionRateLimit[]): Limit | null => {
59 const chosen =
60 limits.find(l => l.kind === 'five_hour') ??
61 [...limits].sort((a, b) => b.percentUsed - a.percentUsed)[0]
62 return chosen ? { percentUsed: chosen.percentUsed, resetsAt: chosen.resetsAt } : null
63}
64
65const cells = (percent: number): number =>
66 Math.round((Math.min(100, Math.max(0, percent)) / 100) * BAR_WIDTH)
67
68/** Parses `git status --porcelain=v2 --branch`. */
69export const parseStatus = (stdout: string): Omit<Git, 'dir'> => {
70 let branch: string | null = null
71 let oid: string | null = null
72 let isDirty = false
73 for (const line of stdout.split('\n')) {
74 if (line.startsWith('# branch.head ')) branch = line.slice('# branch.head '.length)
75 else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length)
76 else if (line.length > 0 && !line.startsWith('#')) isDirty = true
77 }
78 if (branch === '(detached)') branch = oid && oid !== '(initial)' ? oid.slice(0, 7) : 'HEAD'
79 return { branch, isDirty }
80}
81
82const refresh = async ($: EngineInterface): Promise<void> => {
83 const cwd = await $.session.cwd()
84 const dir = cwd.split('/').filter(Boolean).pop() ?? cwd
85 let next: Git = { dir, branch: null, isDirty: false }
86 try {
87 const status = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], {
88 cwd,
89 timeoutMs: 5_000,
90 })
91 if (status.exitCode === 0) next = { dir, ...parseStatus(status.stdout) }
92 } catch {
93 // git missing or too slow: show the directory alone
94 }
95 const nowMs = await $.clock.now()
96 await update($, git, () => next)
97 await update($, now, () => nowMs)
98}
99
100export const register: Register = on => {
101 on('session.start', async ($, e, next) => {
102 const [usage, settings] = await Promise.all([$.session.usage(), $.settings.read()])
103 const model = typeof settings.model === 'string' ? settings.model : null
104 await update($, reading, r => ({
105 model: r.model ?? model,
106 contextWindow: usage.context.window,
107 contextPercent: usage.context.percent ?? null,
108 limit: pickLimit(usage.rateLimits),
109 }))
110 await refresh($)
111 $.clock.every(TICK_MS, () => refresh($))
112
113 return next(e)
114 })
115
116 on('turn.step', async function* ($, e, next) {
117 if (e.agentId === undefined) {
118 await update($, reading, r => (r.model === e.model ? r : { ...r, model: e.model }))
119 }
120
121 return yield* next(e)
122 })
123
124 on('session.measure', async ($, e, next) => {
125 await update($, reading, r => ({
126 ...r,
127 contextWindow: e.context.window,
128 contextPercent: e.context.percent ?? r.contextPercent,
129 limit: pickLimit(e.rateLimits) ?? r.limit,
130 }))
131 const nowMs = await $.clock.now()
132 await update($, now, () => nowMs)
133
134 return next(e)
135 })
136
137 on('turn.complete', async ($, e, next) => {
138 await refresh($)
139
140 return next(e)
141 })
142
143 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
144 if (e.props.hasSurvey) return next(e)
145
146 const { Box, Text } = $.ui.resolve(e)
147 const r = await read($, reading)
148 const g = await read($, git)
149 const nowMs = await read($, now)
150
151 const window = r.contextWindow ? ` (${windowName(r.contextWindow)} context)` : ''
152 const model = r.model ? `${modelName(r.model)}${window}` : 'Claude'
153 const sep = <Text color={COLOR.separator}> │ </Text>
154
155 const contextPercent = r.contextPercent === null ? null : Math.round(r.contextPercent)
156 const usagePercent = r.limit === null ? null : Math.round(r.limit.percentUsed)
157 const reset = r.limit ? untilReset(r.limit.resetsAt, nowMs || (await $.clock.now())) : ''
158
159 const bar = (percent: number, color: string) => (
160 <Box>
161 <Text color={color}>{'█'.repeat(cells(percent))}</Text>
162 <Text color={color} dimColor>
163 {'░'.repeat(BAR_WIDTH - cells(percent))}
164 </Text>
165 </Box>
166 )
167
168 return (
169 <Box flexDirection="column">
170 <Box>
171 <Text color={COLOR.model}>[{model}]</Text>
172 {g && sep}
173 {g && <Text color={COLOR.dir}>{g.dir}</Text>}
174 {g?.branch && (
175 <Text color={COLOR.git}>
176 {' '}git:({g.branch}
177 {g.isDirty ? '*' : ''})
178 </Text>
179 )}
180 </Box>
181 <Box>
182 <Text color={COLOR.label}>Context </Text>
183 {contextPercent === null ? (
184 <Text color={COLOR.label}>–</Text>
185 ) : (
186 <Box>
187 {bar(contextPercent, COLOR.context)}
188 <Text color={COLOR.context}> {contextPercent}%</Text>
189 </Box>
190 )}
191 {usagePercent !== null && sep}
192 {usagePercent !== null && (
193 <Box>
194 <Text color={COLOR.label}>Usage </Text>
195 {bar(usagePercent, COLOR.usage)}
196 <Text color={COLOR.usage}> {usagePercent}%</Text>
197 {reset && <Text color={COLOR.reset}> (resets in {reset})</Text>}
198 </Box>
199 )}
200 </Box>
201 </Box>
202 )
203 })
204}
205types/index.d.ts 21 lines1export type Limit = { percentUsed: number; resetsAt?: string }
2
3export type Reading = {
4 model: string | null
5 contextWindow: number | null
6 contextPercent: number | null
7 limit: Limit | null
8}
9
10export type Git = { dir: string; branch: string | null; isDirty: boolean }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'prompt-status': {
15 reading: Reading
16 git: Git | null
17 now: number
18 }
19 }
20}
21