Status band above the prompt in the Claude Desktop app's Code tab: folder, git branch and state, session age, prompt count and cost, a context meter, 5-hour…

A Claude Code mod that puts a status band above the prompt in the Claude Desktop app's Code tab.
The Claude Code CLI has a statusLine setting that runs a script and prints its output under the prompt. The desktop Code tab never runs that script, so desktop sessions show none of it. This mod reads the same session data from inside Claude Code and draws it in the desktop app instead.

statusLine keeps working as before.git, in the session's folder.Four lines on a normal window. On a narrow window the two limit meters stack, which makes five.
/autocompact, the autoCompactWindow setting, or CLAUDE_CODE_AUTO_COMPACT_WINDOW), it measures against that window, as /context does.Each running subagent gets a row of its own, up to three, followed by a +N more agents running line.
Meters turn amber at 80% and red at 95%. A cache hit rate under 50% shows as a warning, and so does an idle time past the prompt-cache TTL, marked (cache cold). A toast pops up when a usage limit crosses 80% and again at 95%, once per limit window.
Mods need Claude Code v2.1.287 or later. The desktop app ships its own copy of Claude Code, so keep the app updated too.
Run these inside the Claude Code terminal CLI (claude in your shell):
/plugin marketplace add centminmod/claude-plugins
/plugin install desktop-statusline@centminmod
Then start a new session in the desktop app's Code tab. The band appears once the session has data to show, usually right away.
One setting, cache_ttl, sets how long your session can sit idle before the band marks the prompt cache cold. Claude Code asks for it when you enable the plugin, and you can change it later in /plugin or /config. Enter 1h or 5m; any other value counts as 1h.
Claude Code's prompt caching docs give these defaults for the main conversation:
| Account | Default TTL | Set cache_ttl to | | :- | :- | :- | | Claude subscription, within plan usage | 1 hour | 1h, the default | | API key, cloud provider, or a subscription drawing on usage credits | 5 minutes | 5m |
A promptCacheTtl setting or the CLAUDE_CODE_PROMPT_CACHE_TTL environment variable overrides those defaults, so match whatever you set there. The docs don't split Pro from Max. If you're not sure which TTL you get, run this and look at usage.cache_creation in the output:
claude -p "hello" --output-format json
One-hour cache writes count under ephemeral_1h_input_tokens, five-minute writes under ephemeral_5m_input_tokens.
Claude Code stores your answer in ~/.claude/settings.json:
{
"pluginConfigs": {
"desktop-statusline@centminmod": {
"options": { "cache_ttl": "5m" }
}
}
}
See Cache lifetime for which TTL your sessions get.
The band refreshes when the session starts, when the desktop app attaches, after every turn, when a plan limit's percentage changes, two seconds after Claude starts a subagent, and every 60 seconds.
Programs it runs. Only git, with two fixed read-only commands, in the session's folder and with a 5-second timeout:
git status --porcelain=v2 --branch, for the branch, commits ahead or behind upstream, and the uncommitted file countgit rev-parse --git-dir --git-common-dir, to tell a worktree from the main checkoutNeither command contacts a remote, and the mod runs them only in desktop sessions. It starts no other program. Both commands are written out in full at their $.process.run calls in hooks/register.tsx. The session's folder ($.session.cwd) is passed to them only as the working directory; nothing else the mod reads, such as the prompt count from $.session.turns, is passed to them. Their output goes only into the band.
What it reads. Through Claude Code's mods API, it reads the session's usage (context size, cost, and the 5-hour and weekly plan limits), the session's folder, the number of prompts, the start time, and the type and short task description of each running subagent. It asks for the usage with Claude Code's local /context estimate (no API call) and uses only its auto-compact window and whether auto-compaction is on; it ignores the rest of that estimate, such as memory file paths and tool names. From each finished turn it reads the duration, the model, and the token counts used for the cache hit rate. After a compaction it reads the token counts before and after. It doesn't read prompt or response text, files, environment variables, or credentials such as API keys or tokens. The plan limits come from Claude Code, not from your login.
What it sends. Nothing. It makes no network calls and writes no files. What it reads goes only into the band above the prompt and the usage-limit toasts, and into the session's $.state, which ends with the session.
The one URL in the code, http://www.w3.org/2000/svg, is the standard SVG namespace in the meter bars' xmlns attribute. It labels the markup as SVG and is never fetched. The token counts next to it are the context and compaction sizes the band displays, not API keys or login tokens.
What its hooks change. Nothing. The session.compact hook waits for the compaction to finish, records the before and after token counts, and returns the result unchanged. The tool.call hook for the Agent tool schedules a band refresh two seconds later and passes the call through unchanged.
Before installing a mod, you can list what it hooks and calls without running it. Clone this repo and run:
claude plugin validate plugins/desktop-statusline
For this mod the report lists:
session.start, session.attach for the desktop, session.measure, turn.complete, tool.call for the Agent tool only, session.compact, and ui.render for the AbovePrompt band.$.process.run for git status --porcelain=v2 --branch and git rev-parse --git-dir --git-common-dir.It keeps its values in the session's $.state and writes no files.
cache_ttl says.claude -p, or cloud sessions. Mods only draw in the terminal and the desktop Code tab, and this one only draws in the desktop.Disable it from the Installed tab in /plugin, or uninstall it from your shell:
claude plugin uninstall desktop-statusline@centminmod
MIT. See LICENSE.
hooks/register.tsx 348 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, ProcessRunResult, Register } from 'claude-code'
3
4import type { Limit, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'desktop-statusline', key: 'snap' } as const, null)
7const warned = atom({ plugin: 'desktop-statusline', key: 'warned' } as const, [])
8const lastTurn = atom({ plugin: 'desktop-statusline', key: 'lastTurn' } as const, null)
9const compactions = atom({ plugin: 'desktop-statusline', key: 'compactions' } as const, null)
10
11const REFRESH_MS = 60_000
12const WARN_AT = [95, 80]
13const LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
14const LABEL_CELLS = 8
15const PERCENT_CELLS = 5
16const DETAIL_GAP = 2
17const METER_GAP = 3
18const AGENT_ROWS = 3
19// Desktop metrics in CSS px, measured from screenshots of the band: a Box `width` cell, one
20// column of `bodyColumns`, and an average glyph of the band's proportional font. Bars are SVG
21// in px, so the limits row is fitted in px, keeping 5% spare for the estimate.
22const CELL_PX = 15
23const COL_PX = 12.5
24const CHAR_PX = 13
25const SPARE = 0.95
26const MIN_BAR_PX = 60
27const MAX_BAR_PX = 180
28
29const label = (kind: string) => LABELS[kind] ?? kind.replace(/_/g, ' ')
30const tone = (percent: number) => (percent >= 95 ? 'error' : percent >= 80 ? 'warning' : undefined)
31
32const tokens = (n: number) =>
33 n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
34
35// Drawn as an image, so it cannot follow the theme: a translucent track reads on light and dark.
36const bar = (percent: number, width: number) => {
37 const fill = percent >= 95 ? '#e5484d' : percent >= 80 ? '#e0a030' : '#2f7de1'
38 const filled = Math.round((Math.min(percent, 100) / 100) * width)
39 return (
40 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8">` +
41 `<rect width="${width}" height="8" rx="4" fill="#808080" fill-opacity="0.3"/>` +
42 `<rect width="${filled}" height="8" rx="4" fill="${fill}"/></svg>`
43 )
44}
45
46const until = (iso: string, now: number) => {
47 const minutes = Math.max(0, Math.round((Date.parse(iso) - now) / 60_000))
48 if (minutes < 60) return `${minutes}m`
49 if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
50 return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
51}
52
53const elapsed = (ms: number) => {
54 const s = Math.round(ms / 1000)
55 if (s < 60) return `${s}s`
56 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
57 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
58}
59
60const ago = (ms: number) => {
61 const minutes = Math.floor(ms / 60_000)
62 if (minutes < 1) return '<1m'
63 if (minutes < 60) return `${minutes}m`
64 if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
65 return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
66}
67
68const cacheHit = (u: ModelUsage) => {
69 const input = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
70 return input === 0 ? null : Math.round((u.cache_read_input_tokens / input) * 100)
71}
72
73const isOnDesktop = async ($: EngineInterface) => (await $.session.surfaces()).includes('desktop')
74
75// Each git command is written out in full at its call; this only reads the result.
76const output = async (run: Promise<ProcessRunResult>) => {
77 try {
78 const ran = await run
79 return ran.exitCode === 0 ? ran.stdout.trim() : null
80 } catch {
81 return null
82 }
83}
84
85const warn = async ($: EngineInterface, limits: Limit[], now: number) => {
86 const seen = await read($, warned)
87 const fresh: string[] = []
88
89 for (const limit of limits) {
90 const threshold = WARN_AT.find(t => limit.percent >= t)
91 const key = `${limit.kind}@${threshold}@${limit.resetsAt}`
92 if (threshold === undefined || seen.includes(key)) continue
93
94 fresh.push(key)
95 const resets = limit.resetsAt ? ` · resets in ${until(limit.resetsAt, now)}` : ''
96 $.ui.toast(`${label(limit.kind)} usage limit at ${limit.percent}%${resets}`, { timeoutMs: 8000 })
97 }
98
99 if (fresh.length > 0) await update($, warned, s => [...s, ...fresh].slice(-50))
100}
101
102const refresh = async ($: EngineInterface) => {
103 if (!(await isOnDesktop($))) return
104
105 const [usage, cwd, now, agents, prompts] = await Promise.all([
106 // A local estimate (no API calls), asked for only its compaction window.
107 $.session.usage({ breakdown: 'summary' }),
108 $.session.cwd(),
109 $.clock.now(),
110 $.agent.list(),
111 $.session.turns(),
112 ])
113 const [status, dirs] = await Promise.all([
114 output($.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 5000 })),
115 output($.process.run(['git', 'rev-parse', '--git-dir', '--git-common-dir'], { cwd, timeoutMs: 5000 })),
116 ])
117
118 const lines = status?.split('\n') ?? []
119 const head = lines.find(l => l.startsWith('# branch.head '))?.slice(14)
120 const ab = lines.find(l => l.startsWith('# branch.ab '))?.match(/\+(\d+) -(\d+)/)
121 const [gitDir, commonDir] = dirs?.split('\n') ?? []
122 const limits = usage.rateLimits.map(r => ({
123 kind: r.kind,
124 percent: r.percentUsed,
125 resetsAt: r.resetsAt ?? null,
126 }))
127 // Measure against the auto-compact window when one smaller than the model's is set (the
128 // autoCompactWindow setting, /autocompact, CLAUDE_CODE_AUTO_COMPACT_WINDOW), as /context does.
129 const { tokens: contextTokens, window: modelWindow, breakdown } = usage.context
130 const contextWindow = breakdown?.isAutoCompactEnabled ? Math.min(modelWindow, breakdown.rawMaxTokens) : modelWindow
131
132 const snap: Snapshot = {
133 at: now,
134 startedAt: usage.startedAt,
135 prompts,
136 dir: cwd.split('/').pop() || cwd,
137 branch: head && head !== '(detached)' ? head : null,
138 isWorktree: gitDir !== commonDir,
139 ahead: Number(ab?.[1] ?? 0),
140 behind: Number(ab?.[2] ?? 0),
141 changed: lines.filter(l => l && !l.startsWith('#')).length,
142 contextPercent: contextTokens === undefined ? null : Math.round((contextTokens / contextWindow) * 100),
143 contextTokens: contextTokens ?? null,
144 contextWindow,
145 costUsd: usage.cost?.usd ?? null,
146 limits,
147 agents: agents
148 .filter(a => a.status === 'running')
149 .map(a => ({ type: a.type, description: a.description })),
150 }
151
152 await update($, snapshot, () => snap)
153 await warn($, limits, now)
154}
155
156export const register: Register = (on, options) => {
157 // The main conversation's prompt-cache TTL (the `cache_ttl` option): 1 hour on a Claude
158 // subscription within plan usage, 5 minutes with pay-as-you-go API billing, a cloud provider or usage credits.
159 const cacheTtlMs = options.cache_ttl === '5m' ? 5 * 60_000 : 60 * 60_000
160
161 on('session.start', async ($, e, next) => {
162 const started = await next(e)
163 await refresh($)
164 $.clock.every(REFRESH_MS, () => void refresh($))
165
166 return started
167 })
168
169 on('session.attach', { surface: 'desktop' }, async ($, e, next) => {
170 const attached = await next(e)
171 await refresh($)
172
173 return attached
174 })
175
176 on('session.measure', async ($, e, next) => {
177 const measured = await next(e)
178 await refresh($)
179
180 return measured
181 })
182
183 on('turn.complete', async ($, e, next) => {
184 const completed = await next(e)
185 if (!e.agentId) {
186 const at = await $.clock.now()
187 await update($, lastTurn, () => ({
188 at,
189 durationMs: e.durationMs,
190 model: e.usage?.model ?? null,
191 cacheHit: e.usage ? cacheHit(e.usage) : null,
192 }))
193 }
194 await refresh($)
195
196 return completed
197 })
198
199 // A spawned agent shows as running only once it has started; look again shortly after.
200 on('tool.call', { tool: 'Agent' }, ($, e, next) => {
201 $.clock.after(2000, () => void refresh($))
202
203 return next(e)
204 })
205
206 on('session.compact', async ($, e, next) => {
207 const result = await next(e)
208 if (e.trigger === 'precompute' || e.agentId || result.messages === undefined) return result
209
210 const { startedAt } = await $.session.usage()
211 await update($, compactions, c => ({
212 since: startedAt,
213 count: (c?.since === startedAt ? c.count : 0) + 1,
214 before: result.tokensBefore ?? null,
215 after: result.tokensAfter ?? null,
216 }))
217
218 return result
219 })
220
221 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
222 if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
223
224 const snap = await read($, snapshot)
225 if (snap === null) return next(e)
226
227 const { Box, Svg, Text } = $.ui.resolve(e)
228
229 const meter = (name: string, percent: number | null, detail: string, barPx: number) => (
230 <Box flexDirection="row" alignItems="center">
231 <Box width={LABEL_CELLS} flexShrink={0}>
232 <Text>{name}</Text>
233 </Box>
234 {percent === null ? (
235 <Text dimColor>{'waiting for first response'}</Text>
236 ) : (
237 <Box flexDirection="row" alignItems="center" flexShrink={0}>
238 <Svg source={bar(percent, barPx)} alt={`${name} ${percent}% used`} width={barPx} height={8} />
239 <Box width={PERCENT_CELLS} justifyContent="flex-end">
240 <Text color={tone(percent)}>{`${percent}%`}</Text>
241 </Box>
242 </Box>
243 )}
244 <Box marginLeft={DETAIL_GAP} flexShrink={0}>
245 <Text dimColor>{detail}</Text>
246 </Box>
247 </Box>
248 )
249
250 // Fit the limit meters on one row: try "resets in 2h 8m", then "↻ 2h 8m", and stack them
251 // (the one case of 5 lines) only when even the short form leaves a bar under MIN_BAR_PX.
252 const availPx = e.props.bodyColumns * COL_PX * SPARE
253 const fixedPx = (LABEL_CELLS + PERCENT_CELLS + DETAIL_GAP) * CELL_PX
254 const n = Math.max(1, snap.limits.length)
255 const fit = (isLong: boolean) => {
256 const details = snap.limits.map(l => (l.resetsAt ? `${isLong ? 'resets in ' : '↻ '}${until(l.resetsAt, snap.at)}` : ''))
257 const textPx = details.reduce((sum, d) => sum + d.length * CHAR_PX, 0)
258 const barPx = Math.floor((availPx - n * fixedPx - (n - 1) * METER_GAP * CELL_PX - textPx) / n)
259 return { details, barPx: Math.min(MAX_BAR_PX, barPx) }
260 }
261 const long = fit(true)
262 const { details, barPx: sharedBar } = long.barPx >= MIN_BAR_PX ? long : fit(false)
263 const isStacked = sharedBar < MIN_BAR_PX
264 const longestPx = Math.max(0, ...details.map(d => d.length * CHAR_PX))
265 const limitBar = isStacked
266 ? Math.max(40, Math.min(MAX_BAR_PX, Math.floor(availPx - fixedPx - longestPx)))
267 : sharedBar
268 const used = snap.contextTokens === null ? '' : `${tokens(snap.contextTokens)} / ${tokens(snap.contextWindow)}`
269 const contextRoomPx = Math.floor(availPx - fixedPx - used.length * CHAR_PX)
270 const contextBar = Math.max(40, Math.min(isStacked ? limitBar : 2 * limitBar, contextRoomPx))
271
272 const where =
273 `📁 ${snap.dir}` +
274 (snap.branch === null ? '' : ` 🌿 ${snap.branch}${snap.isWorktree ? ' 🌳' : ''}`) +
275 `${snap.ahead ? ` ↑${snap.ahead}` : ''}${snap.behind ? ` ↓${snap.behind}` : ''}` +
276 `${snap.changed ? ` ● ${snap.changed} changed` : ''}`
277 const session =
278 `session ${ago(snap.at - snap.startedAt)} · ${snap.prompts} prompt${snap.prompts === 1 ? '' : 's'}` +
279 (snap.costUsd === null ? '' : ` · $${snap.costUsd.toFixed(2)}`)
280
281 // Bottom line parts: dim unless they need attention (warning) or are live (agents).
282 const activity: { text: string; emphasis?: 'warning' | 'live' }[] = []
283 const turn = await read($, lastTurn)
284 if (turn !== null && turn.at >= snap.startedAt) {
285 const idleMs = Math.max(0, snap.at - turn.at)
286 const isCold = idleMs >= cacheTtlMs
287 activity.push({ text: `Last turn ${elapsed(turn.durationMs)}${turn.model ? ` on ${turn.model}` : ''}` })
288 if (turn.cacheHit !== null) {
289 activity.push({ text: `cache ${turn.cacheHit}%`, emphasis: turn.cacheHit < 50 ? 'warning' : undefined })
290 }
291 if (!e.props.isWorking) {
292 activity.push({ text: `idle ${ago(idleMs)}${isCold ? ' (cache cold)' : ''}`, emphasis: isCold ? 'warning' : undefined })
293 }
294 }
295 const c = await read($, compactions)
296 if (c !== null && c.since === snap.startedAt) {
297 const sizes = c.before === null || c.after === null ? '' : ` (last ${tokens(c.before)} → ${tokens(c.after)})`
298 activity.push({ text: `compacted ${c.count}×${sizes}` })
299 }
300 // Running agents get rows of their own (description truncated, type kept), so a
301 // long description never runs off the "Last turn" line.
302 const agentRows = snap.agents.slice(0, AGENT_ROWS)
303 const moreAgents = snap.agents.length - agentRows.length
304
305 return (
306 <Box flexDirection="column">
307 <Box flexDirection="row" justifyContent="space-between" width="100%">
308 <Text wrap="truncate-end">{where}</Text>
309 <Box marginLeft={2} flexShrink={0}>
310 <Text dimColor>{session}</Text>
311 </Box>
312 </Box>
313 {meter('Context', snap.contextPercent, used, contextBar)}
314 {snap.limits.length > 0 && (
315 <Box flexDirection={isStacked ? 'column' : 'row'} columnGap={METER_GAP}>
316 {snap.limits.map((l, i) => meter(label(l.kind), l.percent, details[i] ?? '', limitBar))}
317 </Box>
318 )}
319 {activity.length > 0 && (
320 <Box flexDirection="row">
321 {activity.map((a, i) => (
322 <Box flexDirection="row">
323 {i > 0 && <Text dimColor>{' · '}</Text>}
324 <Text
325 wrap="truncate-end"
326 color={a.emphasis === 'warning' ? 'warning' : undefined}
327 dimColor={a.emphasis === undefined}
328 >
329 {a.text}
330 </Text>
331 </Box>
332 ))}
333 </Box>
334 )}
335 {agentRows.map(a => (
336 <Box flexDirection="row" justifyContent="space-between" width="100%">
337 <Text wrap="truncate-end">{`⏳ ${a.description || a.type}`}</Text>
338 <Box marginLeft={2} flexShrink={0}>
339 <Text dimColor>{a.type.split(':').pop()}</Text>
340 </Box>
341 </Box>
342 ))}
343 {moreAgents > 0 && <Text dimColor>{` +${moreAgents} more agent${moreAgents > 1 ? 's' : ''} running`}</Text>}
344 </Box>
345 )
346 })
347}
348types/index.d.ts 42 lines1export type Limit = { kind: string; percent: number; resetsAt: string | null }
2
3export type Agent = { type: string; description: string }
4
5export type Snapshot = {
6 at: number
7 startedAt: number
8 prompts: number
9 dir: string
10 branch: string | null
11 isWorktree: boolean
12 ahead: number
13 behind: number
14 changed: number
15 contextPercent: number | null
16 contextTokens: number | null
17 contextWindow: number
18 costUsd: number | null
19 limits: Limit[]
20 agents: Agent[]
21}
22
23export type TurnStat = {
24 at: number
25 durationMs: number
26 model: string | null
27 cacheHit: number | null
28}
29
30export type Compactions = { since: number; count: number; before: number | null; after: number | null }
31
32declare module 'claude-code' {
33 interface PluginState {
34 'desktop-statusline': {
35 snap: Snapshot | null
36 warned: string[]
37 lastTurn: TurnStat | null
38 compactions: Compactions | null
39 }
40 }
41}
42