SLOPSHOPPER

desktop-statusline

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…

newbandguardtoastprocesstimer
A shopper browsing a rack in a slop shop
README

desktop-statusline

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.

The desktop-statusline band above the prompt in the Claude Desktop app's Code tab, showing folder, branch, changed files, session cost, context meter, 5-hour and weekly usage limit meters, and last-turn stats

  • Desktop only. In a terminal session it draws nothing and skips its git calls, so your CLI statusLine keeps working as before.
  • No network calls. The only process it starts is git, in the session's folder.
  • Works on any plan. The usage limit meters appear when Claude Code reports plan limits for your account.

What it shows

Four lines on a normal window. On a narrow window the two limit meters stack, which makes five.

  1. Where you are. Folder, git branch, a 🌳 marker in a worktree, commits ahead or behind upstream, and the number of uncommitted files. Right-aligned on the same line: session age, prompt count, and session cost.
  2. Context. A meter for how full the context window is, with tokens used against the window size. When an auto-compact window smaller than the model's is set (/autocompact, the autoCompactWindow setting, or CLAUDE_CODE_AUTO_COMPACT_WINDOW), it measures against that window, as /context does.
  3. Plan usage limits. The 5-hour and weekly limits, each with a meter and a reset countdown.
  4. Last turn. How long it took, the model that answered, the cache hit rate, how long the session has been idle, and how many times it has compacted, with the token count before and after the last compaction.

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.

Install

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.

Settings

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.

When it updates

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.

What it runs, reads and sends

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 count
  • git rev-parse --git-dir --git-common-dir, to tell a worktree from the main checkout

Neither 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.

Check it yourself

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:

  • Hooks: 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.
  • Calls: session usage, cwd and turn count, the running agent list, the clock, toasts, and $.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.

Limitations

  • No lines added or removed. The mods API doesn't expose them, so the band shows the uncommitted file count instead.
  • The cache-cold warning follows your setting, not your account. Claude Code drops to the 5-minute TTL when a subscription starts drawing on usage credits, and the band keeps using whatever cache_ttl says.
  • No effort level or permission mode. The desktop app's footer already shows both.
  • Nothing draws in the VS Code extension, claude -p, or cloud sessions. Mods only draw in the terminal and the desktop Code tab, and this one only draws in the desktop.

Turn it off

Disable it from the Installed tab in /plugin, or uninstall it from your shell:

claude plugin uninstall desktop-statusline@centminmod

Licence

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 348 lines
1import { 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}
348
types/index.d.ts 42 lines
1export 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