SLOPSHOPPER

neo-statusline

One-line status bar under the prompt: model, effort, git, context, prompt cache, rate limits, cost.

newspinnerguardprocesstimer
v0.1.0no licenseupdated 2026-10-23Hoxiee/claude-code-mods/neo-statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · neo-statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ● ◆ Opus 5.5 │ ◇ ✓ │ ctx ████░░░░ 49% 97k/200k │ cache — 5m? │ 5h 31% │ $0.42 │ up 30m ? for shortcuts

Draws

Prompt hint
● ◆ Opus 5.5 │ ◇ ✓ │ ctx ████░░░░ 49% 97k/200k │ cache — 5m? │ 5h 31% │ $0.42 │ up 30m ? for shortcuts
README

neo-statusline

English | Русский

A one-line status bar for Claude Code, drawn under the prompt:

● ◆ Sonnet 5.5:xhi │ ◇ main ~2 +1 ↑1 │ ctx ████░░░░ 13% 127k/1M │ cache 99% Σ98% 1h 42m → 20:45 │ 5h 21% (4h17m) │ 7d 7% (6d17h) │ $1.20 │ up 19m

neo-statusline in Claude Code

A capture from a live session, right after its second request: the prefix is read back in full (100%), the session total is Σ86%, the cache lives for one hour.

SegmentShows
● / ◉idle / a turn is running
◆ Sonnet 5.5:xhimodel and thinking effort (lo, md, hi, xhi, max)
◇ main ~2 +1 ↑1branch; ~ modified, + added, - deleted, → renamed, ? untracked, ! conflicts, ↑/↓ ahead/behind, ✓ clean
ctxcontext window fill, with a bar and token counts
cacheprompt cache of this session: the last request's hit ratio, Σ the whole session's, +Nk written by the last request, miss when it lost the cached prefix, the lifetime (5m / 1h), time left and the moment it goes cold
5h, 7drate-limit windows and time until reset
$session cost
upsession length

Bars and percentages turn yellow, orange and red as they fill (50 / 65 / 80%). The line drops detail on narrow terminals. The engine's own hint line stays beneath it, minus the (shift+tab to cycle) and N agents reminders.

Install

The mod draws the PromptHint component, so it needs a terminal Claude Code with mods. Add the mod folder to ~/.claude/settings.json:

{
  "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/neocc-mods/neo-statusline" },
  "pluginConfigs": { "neo-statusline": { "cacheTtl": "1h" } }
}

Options

cacheTtl: lifetime of the prompt cache. The engine does not report it, so auto (the default) infers it from cache hits after a pause longer than five minutes and shows 5m? until it has proof. Set 5m or 1h to fix it.

showLimits (checkbox, on by default): show the 5h / 7d subscription limit windows. Turn it off if another tool, such as a bar, already shows them. A gateway spend limit is not affected.

Cache numbers

The ratio is read / (read + written + uncached) of the last request, not of the whole turn: a turn sums all its requests, which hides what the cache is doing right now. Σ sums every request of the session. Each session keeps its own numbers, so a new session or a /clear starts from cache — instead of showing another session's countdown. Subagent requests are not counted: they have a cache of their own.

A lower ratio right after a long tool result is normal (+Nk is the new text being cached); miss is the real failure, and means the previous prefix was not read back.

For the full picture of a session, tools/ccache reads its transcript:

ln -s "$PWD/tools/ccache" ~/.local/bin/ccache
ccache            # the newest session of the current directory
ccache 82045d4f   # a session id prefix

It lists every API call (new / written / read tokens), marks CACHE BREAK and COLD requests, and prints the session ratio and when the cache expires.

Check

claude plugin validate .
claude plugin test .
Source 1 files
hooks/register.tsx 466 lines
1import type { Hook, Register, SessionUsage } from 'claude-code'
2
3type Api = Parameters<Hook<'session.start'>>[0]
4
5type Git = {
6  branch: string
7  ahead: number
8  behind: number
9  modified: number
10  added: number
11  deleted: number
12  renamed: number
13  untracked: number
14  conflicts: number
15}
16
17type Ttl = '5m' | '1h'
18// One main-thread request: `fresh` is the uncached part of the prompt; `broke` marks a
19// request that lost most of what the previous one had cached.
20type Cache = { ratio: number; read: number; write: number; fresh: number; at: number; broke: boolean }
21type Total = { read: number; write: number; fresh: number }
22type Saved = { cache: Cache | null; total: Total; ttl: Ttl | null }
23
24const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
25const TICK_MS = 15_000
26const STORE_KEY = 'cache-v2'
27const KEEP_SESSIONS = 12
28const EFFORT_KEY = 'effort-v1'
29
30const C = {
31  ink: '#1e1e2e',
32  label: '#a6adc8',
33  empty: '#585b70',
34  sep: '#585b70',
35  blue: '#89b4fa',
36  sky: '#89dceb',
37  teal: '#94e2d5',
38  mauve: '#cba6f7',
39  pink: '#f5c2e7',
40  green: '#a6e3a1',
41  lime: '#b8f0a0',
42  yellow: '#f9e2af',
43  orange: '#fab387',
44  red: '#f38ba8',
45}
46
47const usageColor = (pct: number) =>
48  pct >= 80 ? C.red : pct >= 65 ? C.orange : pct >= 50 ? C.yellow : C.green
49
50const cacheColor = (ratio: number) =>
51  ratio >= 90 ? C.lime : ratio >= 75 ? C.green : ratio >= 50 ? C.yellow : ratio > 0 ? C.orange : C.red
52
53const tokens = (n: number) =>
54  n >= 1_000_000
55    ? `${+(n / 1_000_000).toFixed(1)}M`
56    : n >= 1000
57      ? `${Math.round(n / 1000)}k`
58      : `${n}`
59
60const span = (ms: number) => {
61  const m = Math.max(0, Math.round(ms / 60_000))
62  const h = Math.floor(m / 60)
63  const d = Math.floor(h / 24)
64
65  return d >= 1
66    ? `${d}d${h % 24 ? `${h % 24}h` : ''}`
67    : h >= 1
68      ? `${h}h${m % 60 ? `${m % 60}m` : ''}`
69      : `${m}m`
70}
71
72const countdown = (ms: number) =>
73  ms < 60_000 ? `${Math.max(0, Math.ceil(ms / 1000))}s` : span(ms)
74
75const clock = (ms: number) => {
76  const d = new Date(ms)
77
78  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
79}
80
81const bar = (pct: number, width: number) => {
82  const filled = Math.min(width, Math.max(pct > 0 ? 1 : 0, Math.round((pct / 100) * width)))
83
84  return { on: '█'.repeat(filled), off: '░'.repeat(width - filled) }
85}
86
87const middle = (s: string, max: number) =>
88  s.length <= max ? s : `${s.slice(0, Math.ceil((max - 1) / 2))}…${s.slice(s.length - Math.floor((max - 1) / 2))}`
89
90const prettyModel = (id: string) => {
91  const m = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(id)
92
93  if (m) {
94    const name = m[1]!
95    return `${name[0]!.toUpperCase()}${name.slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
96  }
97
98  return id.replace(/\[.*\]$/, '')
99}
100
101const EFFORT: Record<string, string> = { low: 'lo', medium: 'md', high: 'hi', xhigh: 'xhi', max: 'max' }
102
103const parseGit = (out: string): Git => {
104  const g: Git = { branch: '', ahead: 0, behind: 0, modified: 0, added: 0, deleted: 0, renamed: 0, untracked: 0, conflicts: 0 }
105  let oid = ''
106
107  for (const line of out.split('\n')) {
108    if (line.startsWith('# branch.head ')) g.branch = line.slice(14)
109    else if (line.startsWith('# branch.oid ')) oid = line.slice(13, 20)
110    else if (line.startsWith('# branch.ab ')) {
111      const m = /\+(\d+) -(\d+)/.exec(line)
112      g.ahead = Number(m?.[1] ?? 0)
113      g.behind = Number(m?.[2] ?? 0)
114    } else if (line.startsWith('? ')) g.untracked += 1
115    else if (line.startsWith('u ')) g.conflicts += 1
116    else if (line.startsWith('1 ') || line.startsWith('2 ')) {
117      const xy = line.slice(2, 4)
118      if (line[0] === '2') g.renamed += 1
119      else if (xy.includes('A')) g.added += 1
120      if (xy.includes('D')) g.deleted += 1
121      if (/[MT]/.test(xy)) g.modified += 1
122    }
123  }
124
125  if (g.branch === '(detached)') g.branch = oid
126  return g
127}
128
129const S: {
130  git: Git | null
131  cwd: string
132  model: string
133  effort: string
134  // The session the cache numbers belong to: the store is shared by every session, and a
135  // /clear changes the id without a `session.start`.
136  sid: string | null
137  // The last main-thread request, and the sums over the whole session.
138  cache: Cache | null
139  total: Total
140  // The TTL the cache is known to have; null while `auto` has not seen proof.
141  ttl: Ttl | null
142  isBusy: boolean
143} = { git: null, cwd: '', model: '', effort: '', sid: null, cache: null, total: { read: 0, write: 0, fresh: 0 }, ttl: null, isBusy: false }
144
145const sessionId = ($: Api) => $.session.id().catch(() => '')
146
147// Points the cache numbers at the current session, loading what the store kept for it.
148async function bind($: Api) {
149  const sid = await sessionId($)
150  if (sid === S.sid) return
151
152  const all = ((await $.store.get(STORE_KEY)) ?? {}) as Record<string, Saved>
153  const saved = all[sid]
154
155  S.sid = sid
156  S.cache = saved?.cache ?? null
157  S.total = saved?.total ?? { read: 0, write: 0, fresh: 0 }
158  S.ttl = saved?.ttl === '5m' || saved?.ttl === '1h' ? saved.ttl : null
159}
160
161async function save($: Api) {
162  if (S.sid === null) return
163
164  const all = ((await $.store.get(STORE_KEY)) ?? {}) as Record<string, Saved>
165  all[S.sid] = { cache: S.cache, total: S.total, ttl: S.ttl }
166
167  // Drop the least recently used sessions so the store does not grow for ever.
168  const stale = Object.keys(all)
169    .sort((a, b) => (all[b]!.cache?.at ?? 0) - (all[a]!.cache?.at ?? 0))
170    .slice(KEEP_SESSIONS)
171  for (const k of stale) delete all[k]
172
173  await $.store.set(STORE_KEY, all)
174}
175
176async function load($: Api) {
177  await bind($)
178
179  const effort = await $.store.get(EFFORT_KEY)
180  if (typeof effort === 'string') S.effort = effort
181}
182
183// One main-thread request landed: its own numbers are the cache's state now.
184async function record($: Api, u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }, forced: Ttl | null) {
185  await bind($)
186
187  const now = await $.clock.now()
188  const prev = S.cache
189  const fresh = u.input_tokens
190  const read = u.cache_read_input_tokens
191  const write = u.cache_creation_input_tokens
192  const total = fresh + write + read
193  const ratio = total > 0 ? Math.round((read / total) * 100) : 0
194  const gap = prev ? now - prev.at : 0
195
196  // After a pause past 5m a warm read proves the 1h cache; a cold miss on
197  // a big prompt (inside the hour) proves 5m.
198  if (forced === null && prev && gap > TTL_MS['5m'] + 30_000 && total > 4000) {
199    if (ratio >= 50) S.ttl = '1h'
200    else if (ratio <= 10 && gap < TTL_MS['1h']) S.ttl = '5m'
201  }
202
203  const broke = prev !== null && prev.read > 5000 && read < prev.read * 0.5
204
205  S.cache = { ratio, read, write, fresh, at: now, broke }
206  S.total = { read: S.total.read + read, write: S.total.write + write, fresh: S.total.fresh + fresh }
207  await save($)
208}
209
210async function refresh($: Api) {
211  if (S.isBusy) return
212  S.isBusy = true
213
214  try {
215    S.cwd = await $.session.cwd()
216    S.model = await $.session.model()
217
218    const run = await $.process.run(
219      ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'],
220      { cwd: S.cwd, timeoutMs: 4000 },
221    )
222    S.git = run.exitCode === 0 ? parseGit(run.stdout) : null
223  } catch {
224    S.git = null
225  } finally {
226    S.isBusy = false
227    $.ui.invalidate('ui.render')
228  }
229}
230
231export const register: Register = (on, options) => {
232  const forced = options.cacheTtl === '5m' || options.cacheTtl === '1h' ? options.cacheTtl : null
233  const showLimits = options.showLimits !== false
234
235  on('session.start', async ($, e, next) => {
236    await load($)
237    $.clock.every(TICK_MS, () => void refresh($))
238    void refresh($)
239
240    return next(e)
241  })
242
243  on('turn.step', async function* ($, e, next) {
244    if (e.agentId === undefined) {
245      S.model = e.model
246      const effort = typeof e.effort === 'string' ? e.effort : e.effort === undefined ? '' : String(e.effort)
247
248      if (effort !== S.effort) {
249        S.effort = effort
250        await $.store.set(EFFORT_KEY, effort)
251      }
252    }
253
254    // A turn's own `usage` is the sum over all its requests; the cache's state is the last one's.
255    const res = yield* next(e)
256
257    if (e.agentId === undefined && res.usage) await record($, res.usage, forced)
258
259    return res
260  })
261
262  on('turn.complete', async ($, e, next) => {
263    void refresh($)
264
265    return next(e)
266  })
267
268  on('tool.call', async ($, e, next) => {
269    const ran = await next(e)
270
271    if (e.tool === 'Bash' || e.tool === 'Edit' || e.tool === 'Write') void refresh($)
272
273    return ran
274  })
275
276  on('ui.render', { component: 'PromptHint' }, async ($, e) => {
277    const { Box, Text } = $.ui.resolve(e)
278    const now = await $.clock.now()
279    // A /clear or a resume moves the process to another session without a `session.start`.
280    await bind($).catch(() => {})
281    // A failing read must not take the whole bar down with it.
282    const usage = await $.session.usage().catch((): SessionUsage => ({
283      startedAt: now,
284      context: { window: 0 },
285      rateLimits: [],
286    }))
287    const cols = e.viewport?.columns ?? 120
288    // 0: bare essentials, 1: + details, 2: + everything.
289    const level = cols >= 150 ? 2 : cols >= 115 ? 1 : 0
290    const { git, cache, effort } = S
291    const sep = <Text color={C.sep}> │ </Text>
292    const items: unknown[] = []
293
294    // Model, effort.
295    items.push(
296      <Text>
297        <Text color={e.props.isWorking ? C.orange : C.green} bold>
298          {e.props.isWorking ? '◉' : '●'}{' '}
299        </Text>
300        <Text color={C.mauve} bold>
301          ◆ {prettyModel(S.model || (await $.session.model().catch(() => '')) || 'claude')}
302        </Text>
303        {effort ? (
304          <Text color={C.pink} bold>
305            :{EFFORT[effort] ?? effort}
306          </Text>
307        ) : null}
308      </Text>,
309    )
310
311    // Branch and what changed in it.
312    if (git) {
313      const parts: [string, number, string][] = [
314        ['~', git.modified, C.yellow],
315        ['+', git.added, C.green],
316        ['-', git.deleted, C.red],
317        ['→', git.renamed, C.sky],
318        ['?', git.untracked, C.label],
319      ]
320      const changed = parts.filter(([, n]) => n > 0)
321
322      items.push(
323        <Text>
324          <Text color={C.teal} bold>
325            ◇ {middle(git.branch, level === 0 ? 16 : 24)}
326          </Text>
327          {changed.length === 0 && git.conflicts === 0 ? <Text color={C.green}> ✓</Text> : null}
328          {changed.map(([k, n, color]) => (
329            <Text color={color}> {k}{n}</Text>
330          ))}
331          {git.conflicts > 0 ? (
332            <Text color={C.red} bold>
333              {' '}!{git.conflicts}
334            </Text>
335          ) : null}
336          {git.ahead > 0 ? <Text color={C.yellow}> ↑{git.ahead}</Text> : null}
337          {git.behind > 0 ? <Text color={C.red}> ↓{git.behind}</Text> : null}
338        </Text>,
339      )
340    }
341
342    // Context window.
343    const pct = usage.context.percent
344
345    if (pct !== undefined) {
346      const used = usage.context.tokens ?? 0
347      const color = used > 250_000 && pct < 65 ? C.yellow : usageColor(pct)
348      const b = bar(pct, level === 0 ? 6 : 8)
349
350      items.push(
351        <Text>
352          <Text color={C.label}>ctx </Text>
353          <Text color={color}>{b.on}</Text>
354          <Text color={C.empty}>{b.off}</Text>
355          <Text color={color} bold>
356            {' '}{Math.round(pct)}%
357          </Text>
358          {level >= 1 ? (
359            <Text color={C.label}>
360              {' '}{tokens(used)}/{tokens(usage.context.window)}
361            </Text>
362          ) : null}
363          {pct >= 80 ? <Text color={C.red} bold> !</Text> : null}
364        </Text>,
365      )
366    }
367
368    // Prompt cache: the last request's hit ratio (Σ: the whole session's), its lifetime,
369    // and when it goes cold. `+Nk` is what the last request wrote, `miss` a lost prefix.
370    const ttl: Ttl | null = forced ?? S.ttl
371    const sum = S.total.read + S.total.write + S.total.fresh
372    const sumRatio = sum > 0 ? Math.round((S.total.read / sum) * 100) : 0
373
374    if (cache) {
375      const left = TTL_MS[ttl ?? '5m'] - (now - cache.at)
376      const frac = left / TTL_MS[ttl ?? '5m']
377      const timeColor = left <= 0 ? C.red : frac < 0.2 ? C.orange : frac < 0.5 ? C.yellow : C.sky
378
379      items.push(
380        <Text>
381          <Text color={C.label}>cache </Text>
382          <Text color={cacheColor(cache.ratio)} bold>
383            {cache.ratio}%
384          </Text>
385          {level >= 1 ? <Text color={C.label}> Σ{sumRatio}%</Text> : null}
386          {cache.broke ? (
387            <Text color={C.red} bold>
388              {' '}miss
389            </Text>
390          ) : null}
391          {level >= 1 && cache.write >= 4000 ? <Text color={C.orange}> +{tokens(cache.write)}</Text> : null}
392          <Text color={C.label}> {ttl ?? '5m?'}</Text>
393          {left > 0 ? (
394            <Text color={timeColor} bold>
395              {' '}{countdown(left)}
396              {level >= 1 ? <Text color={C.label} bold={false}> → {clock(cache.at + TTL_MS[ttl ?? '5m'])}</Text> : null}
397            </Text>
398          ) : (
399            <Text color={C.red} bold>
400              {' '}cold
401            </Text>
402          )}
403        </Text>,
404      )
405    } else {
406      items.push(<Text color={C.label}>cache — {forced ?? '5m?'}</Text>)
407    }
408
409    // Rate limits.
410    for (const r of usage.rateLimits) {
411      if (!showLimits && (r.kind === 'five_hour' || r.kind === 'seven_day')) continue
412
413      const label = r.kind === 'five_hour' ? '5h' : r.kind === 'seven_day' ? '7d' : r.kind === 'spend_limit' ? 'spend' : r.kind
414      const reset = r.resetsAt ? Date.parse(r.resetsAt) - now : NaN
415
416      items.push(
417        <Text>
418          <Text color={C.label}>{label} </Text>
419          <Text color={usageColor(r.percentUsed)} bold>
420            {Math.round(r.percentUsed)}%
421          </Text>
422          {level >= 1 && Number.isFinite(reset) && reset > 0 ? <Text color={C.label}> ({span(reset)})</Text> : null}
423        </Text>,
424      )
425    }
426
427    // Cost, session length.
428    if (usage.cost) {
429      items.push(
430        <Text color={C.yellow} bold>
431          ${usage.cost.usd.toFixed(2)}
432        </Text>,
433      )
434    }
435
436    if (level >= 2) items.push(<Text color={C.label}>up {span(now - usage.startedAt)}</Text>)
437
438    // The engine's hint minus the "(shift+tab to cycle)" and "N agents" reminders, and the
439    // separators it leaves behind.
440    const hint = e.props.hint
441      .replace(/\(shift\+tab to cycle\)/gi, '')
442      .replace(/[←→]?\s*\d+\s+agents?/gi, '')
443      .replace(/^[\s·•]+|[\s·•]+$/g, '')
444
445    // One row of status; the engine's own hint (shortcuts, agents) stays
446    // beneath it when anything is left of it.
447    return (
448      <Box flexDirection="column">
449        <Text wrap="truncate-end">
450          {items.map((item, i) => (
451            <Text>
452              {i > 0 ? sep : null}
453              {item as never}
454            </Text>
455          ))}
456        </Text>
457        {hint ? (
458          <Text dimColor wrap="truncate-end">
459            {hint}
460          </Text>
461        ) : null}
462      </Box>
463    )
464  })
465}
466