A two-row status band: model, folder, git branch, context window, tokens, cache rate, API cost, tokens/s and TTFT

English | 简体中文
A two-row, colored status band above the prompt, drawn by the mod itself: no status line command, no script, no settings to edit.
✦ Opus 5.5 high claude-mods ⎇ main ●2 ⇡1 ctx ▰▰▰▱▱▱▱▱▱▱ 31% 62k/200k
↑ 1.2M in ↓ 35k out cache 92% $1.23 121 tok/s avg 129 TTFT 1.1s
| Figure | What it is |
|---|---|
✦ Opus 5.5 high | The session's model, and the reasoning effort of the latest request |
claude-mods | The session's folder |
⎇ main ●2 ⇡1 ⇣0 | Git branch (the short commit when detached), changed files, commits ahead of and behind upstream |
ctx ▰▰▰▱▱ 31% 62k/200k | Context window in use: the latest request's input against the model's window. Green under 50%, yellow under 80%, red above |
↑ in / ↓ out | Tokens read (input, cache reads and writes included) and written (output) by every model request of the session, subagents included |
cache 92% | Share of those input tokens served from the prompt cache |
$1.23 | The session's cost at API prices, as /cost totals it |
tok/s avg | Output tokens/s of the latest main-loop request, and the session's token-weighted average |
TTFT | Latest main-loop request: from sending it to its first stream event (network, queueing and prefill) |
Claude Code's own status line input carries no timing of the model's stream, so the mod hooks turn.step, the streaming event of each model request, and times the chunks as they arrive. A request that generates fewer than 50 tokens or streams for less than 250 ms (a lone tool call) arrives in one burst, so it leaves the shown tok/s alone; it still counts toward the average.
At the prompt of a terminal session:
/plugin install statusline --marketplace kadaliao/claude-mods
or from a shell:
claude plugin marketplace add kadaliao/claude-mods
claude plugin install statusline@kadaliao
The band shows from the next session, or after /reload-plugins. ctrl+x ctrl+a (or the [-] beside it) collapses it.
If you had tps-meter installed, it is replaced by this mod: claude plugin uninstall tps-meter@kadaliao.
The band redraws after every model request, slash command and turn, and every 10 seconds. Git is read with git status --porcelain=2 --branch on session start, on each prompt, after Bash, Edit, Write and NotebookEdit tool calls, after each turn, and on the 10-second tick.
$.model.complete) are in the cost but not in the token totals./clear starts them over.claude --plugin-dir ~/workspace/claude-mods/statusline # a session with the working copy loaded
claude plugin validate ~/workspace/claude-mods/statusline
claude plugin test ~/workspace/claude-mods/statusline
License: MIT
hooks/register.tsx 343 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnStepInput, TurnUsage } from 'claude-code'
3
4import type { Git, Meter, Speed } from '../types'
5
6// A two-row status band above the prompt:
7//
8// ✦ Opus 5.5 high claude-mods ⎇ main ●2 ⇡1 ctx ▰▰▰▱▱▱▱▱▱▱ 31% 62k/200k
9// ↑ 1.2M in ↓ 35k out cache 92% $1.23 121 tok/s avg 129 TTFT 1.1s
10//
11// Tokens add up every model request of the session, subagents' included, so
12// they go with the cost; tok/s and TTFT time the main loop's requests alone.
13
14// A step that generates only a few tokens arrives in one burst, so its rate
15// is noise: it counts toward the average but leaves the shown rate alone.
16const MIN_TOKENS = 50
17const MIN_DECODE_MS = 250
18const REFRESH_MS = 10_000
19const KEEP_SESSIONS = 50
20const BAR_CELLS = 10
21const GIT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
22
23const meter = atom({ plugin: 'statusline', key: 'meter' } as const, null)
24const git = atom({ plugin: 'statusline', key: 'git' } as const, null)
25
26type Timing = { ttftMs: number; decodeMs: number }
27
28const emptyMeter = (sessionId: string): Meter => ({
29 sessionId,
30 tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
31 speed: { ttftMs: null, tps: null, steps: 0, outputTokens: 0, decodeMs: 0, ttftTotalMs: 0 },
32 model: null,
33 effort: null,
34})
35
36const round1 = (n: number) => Math.round(n * 10) / 10
37const storeKey = (sessionId: string) => `meter:${sessionId}`
38
39// --- the session's totals ---------------------------------------------------
40
41// $.state survives a hot reload; $.store carries the totals to a resumed session.
42async function ensureMeter($: EngineInterface): Promise<string> {
43 const id = await $.session.id()
44 if ((await read($, meter))?.sessionId === id) return id
45 const saved = (await $.store.get(storeKey(id))) as Meter | undefined
46 const loaded = saved?.sessionId === id ? saved : emptyMeter(id)
47 await update($, meter, m => (m?.sessionId === id ? m : loaded))
48 return id
49}
50
51function add(m: Meter, e: TurnStepInput, usage: TurnUsage, timing: Timing | undefined): Meter {
52 const tokens = {
53 input: m.tokens.input + usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
54 output: m.tokens.output + usage.output_tokens,
55 cacheRead: m.tokens.cacheRead + usage.cache_read_input_tokens,
56 cacheWrite: m.tokens.cacheWrite + usage.cache_creation_input_tokens,
57 }
58 if (timing === undefined) return { ...m, tokens }
59
60 const { ttftMs, decodeMs } = timing
61 const out = usage.output_tokens
62 const isMeasurable = out >= MIN_TOKENS && decodeMs >= MIN_DECODE_MS
63 const speed: Speed = {
64 ttftMs: Math.round(ttftMs),
65 tps: isMeasurable ? round1((out / decodeMs) * 1000) : m.speed.tps,
66 steps: m.speed.steps + 1,
67 outputTokens: m.speed.outputTokens + out,
68 decodeMs: Math.round(m.speed.decodeMs + decodeMs),
69 ttftTotalMs: Math.round(m.speed.ttftTotalMs + ttftMs),
70 }
71 const effort = e.effort === undefined ? null : String(e.effort)
72 return { ...m, tokens, speed, model: usage.model || e.model, effort }
73}
74
75async function record($: EngineInterface, e: TurnStepInput, usage: TurnUsage, timing?: Timing): Promise<void> {
76 const id = await ensureMeter($)
77 const saved = await update($, meter, m => add(m?.sessionId === id ? m : emptyMeter(id), e, usage, timing))
78 if (saved !== null) await $.store.set(storeKey(id), saved)
79}
80
81async function pruneStore($: EngineInterface): Promise<void> {
82 const keys = (await $.store.keys()).filter(k => k.startsWith('meter:'))
83 for (const k of keys.slice(0, Math.max(0, keys.length - KEEP_SESSIONS))) await $.store.delete(k)
84}
85
86// --- git ------------------------------------------------------------------------
87
88export function parseGit(stdout: string): Git {
89 let head = ''
90 let oid = ''
91 let ahead = 0
92 let behind = 0
93 let changed = 0
94 for (const line of stdout.split('\n')) {
95 if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length)
96 else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length)
97 else if (line.startsWith('# branch.ab ')) {
98 const ab = /\+(\d+) -(\d+)/.exec(line)
99 ahead = Number(ab?.[1] ?? 0)
100 behind = Number(ab?.[2] ?? 0)
101 } else if (line !== '' && !line.startsWith('#')) changed += 1
102 }
103 const branch = head === '(detached)' ? oid.slice(0, 7) : head
104 return { branch, ahead, behind, changed }
105}
106
107let isGitBusy = false
108
109async function refreshGit($: EngineInterface): Promise<void> {
110 if (isGitBusy) return
111 isGitBusy = true
112 try {
113 const run = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=2', '--branch'], {
114 timeoutMs: 3000,
115 })
116 const found = run.exitCode === 0 ? parseGit(run.stdout) : null
117 if (JSON.stringify(await read($, git)) !== JSON.stringify(found)) await update($, git, () => found)
118 } catch {
119 // git missing or too slow: keep what is shown
120 } finally {
121 isGitBusy = false
122 }
123}
124
125function redraw($: EngineInterface): void {
126 try {
127 $.ui.invalidate('ui.render')
128 } catch {
129 // nothing drawn to redraw
130 }
131}
132
133// --- formatting -----------------------------------------------------------------
134
135export function fmtTokens(n: number): string {
136 if (n < 1000) return String(n)
137 if (n < 10_000) return `${round1(n / 1000)}k`
138 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
139 if (n < 10_000_000) return `${Math.round(n / 10_000) / 100}M`
140 return `${round1(n / 1_000_000)}M`
141}
142
143export function fmtMs(ms: number): string {
144 if (ms < 1000) return `${Math.round(ms)}ms`
145 if (ms < 60_000) return `${round1(ms / 1000)}s`
146 return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
147}
148
149const ALIAS = /^(opus|sonnet|haiku|fable|default|best)$/i
150const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
151
152export function prettyModel(id: string): string {
153 const bare = id.replace(/\[[^\]]*\]$/, '')
154 const named = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:[-.](\d{1,2}))?(?!\d)/i.exec(bare)
155 if (named?.[1] && named[2]) return `${cap(named[1])} ${named[2]}${named[3] ? `.${named[3]}` : ''}`
156 const older = /claude-(\d+)(?:-(\d))?-(opus|sonnet|haiku)/i.exec(bare)
157 if (older?.[1] && older[3]) return `${cap(older[3])} ${older[1]}${older[2] ? `.${older[2]}` : ''}`
158 return ALIAS.test(bare) ? cap(bare) : bare
159}
160
161// The session's model may be an alias ("opus"); the last request names the
162// model it resolved to, unless the person has since switched to another family.
163export function modelName(session: string, lastStep: string | null): string {
164 if (lastStep === null || /claude-/i.test(session)) return prettyModel(session)
165 const family = /(opus|sonnet|haiku|fable)/i.exec(session)?.[1]
166 if (family && !lastStep.toLowerCase().includes(family.toLowerCase())) return prettyModel(session)
167 return prettyModel(lastStep)
168}
169
170const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
171
172const level = (pct: number, warn: number, bad: number) => (pct >= bad ? 'error' : pct >= warn ? 'warning' : 'success')
173
174// --- hooks ----------------------------------------------------------------------
175
176export const register: Register = on => {
177 let ticker: { cancel: () => void } | undefined
178
179 on('session.start', async ($, e, next) => {
180 const started = await next(e)
181 await ensureMeter($).catch(() => {})
182 void pruneStore($).catch(() => {})
183 void refreshGit($)
184 ticker?.cancel()
185 ticker = $.clock.every(REFRESH_MS, () => {
186 void refreshGit($)
187 redraw($)
188 })
189 return started
190 })
191
192 on('prompt.submit', ($, e, next) => {
193 void refreshGit($)
194 return next(e)
195 })
196
197 on('command.run', async ($, e, next) => {
198 // /model, /clear, /compact and the rest change what the band shows.
199 const ran = await next(e)
200 redraw($)
201 return ran
202 })
203
204 on('tool.call', async ($, e, next) => {
205 const ran = await next(e)
206 if (GIT_TOOLS.has(e.tool)) void refreshGit($)
207 return ran
208 })
209
210 on('turn.complete', async ($, e, next) => {
211 const done = await next(e)
212 void refreshGit($)
213 redraw($)
214 return done
215 })
216
217 on('turn.step', async function* ($, e, next) {
218 const isMain = e.agentId === undefined
219 const sentAt = performance.now()
220 const stream = next(e)
221 let firstAt: number | undefined
222 for await (const chunk of stream) {
223 const now = performance.now()
224 firstAt ??= now
225 if (chunk.kind === 'stop' && chunk.usage) {
226 const timing = isMain ? { ttftMs: firstAt - sentAt, decodeMs: now - firstAt } : undefined
227 await record($, e, chunk.usage, timing).catch(() => {})
228 }
229 yield chunk
230 }
231 const result = await stream.result
232 // The engine has priced the request by now: cost and context moved.
233 redraw($)
234 return result
235 })
236
237 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
238 // What the mods beneath draw (danmaku's lanes, say) stays, above the two rows.
239 const below = await next(e)
240 if (e.props.hasSurvey) return below
241 const { Box, Text } = $.ui.resolve(e)
242
243 const [id, saved, repo, usage, sessionModel, cwd] = await Promise.all([
244 $.session.id(),
245 read($, meter),
246 read($, git),
247 $.session.usage(),
248 $.session.model(),
249 $.session.cwd(),
250 ])
251 const m = saved?.sessionId === id ? saved : emptyMeter(id)
252 const { tokens, speed } = m
253 const none = <Text dimColor>–</Text>
254
255 const ctxPct = usage.context.percent
256 const filled = ctxPct === undefined ? 0 : Math.min(BAR_CELLS, Math.round((ctxPct / 100) * BAR_CELLS))
257 const ctxColor = level(ctxPct ?? 0, 50, 80)
258
259 const cachePct = tokens.input > 0 ? Math.round((tokens.cacheRead / tokens.input) * 100) : undefined
260 const cacheColor = cachePct === undefined ? 'subtle' : cachePct >= 70 ? 'success' : cachePct >= 40 ? 'warning' : 'error'
261
262 const avgTps = speed.decodeMs > 0 ? Math.round((speed.outputTokens / speed.decodeMs) * 1000) : undefined
263 const ttftColor = speed.ttftMs === null ? 'subtle' : level(speed.ttftMs, 2000, 5000)
264 const cost = usage.cost?.usd
265
266 return (
267 <Box flexDirection="column">
268 {below}
269 <Box flexWrap="wrap" columnGap={3}>
270 <Text>
271 <Text color="claude" bold>
272 ✦ {modelName(sessionModel, m.model)}
273 </Text>
274 {m.effort !== null && <Text dimColor> {m.effort}</Text>}
275 </Text>
276 <Text>
277 <Text color="ide" bold>
278 {basename(cwd)}
279 </Text>
280 {repo !== null && (
281 <Text>
282 <Text color="merged"> ⎇ {repo.branch}</Text>
283 {repo.changed > 0 && <Text color="warning"> ●{repo.changed}</Text>}
284 {repo.ahead > 0 && <Text color="suggestion"> ⇡{repo.ahead}</Text>}
285 {repo.behind > 0 && <Text color="suggestion"> ⇣{repo.behind}</Text>}
286 </Text>
287 )}
288 </Text>
289 <Text>
290 <Text dimColor>ctx </Text>
291 <Text color={ctxColor}>{'▰'.repeat(filled)}</Text>
292 <Text dimColor>{'▱'.repeat(BAR_CELLS - filled)}</Text>
293 {ctxPct === undefined ? (
294 <Text dimColor> {fmtTokens(usage.context.window)}</Text>
295 ) : (
296 <Text>
297 <Text color={ctxColor} bold>
298 {' '}
299 {ctxPct}%
300 </Text>
301 <Text dimColor>
302 {' '}
303 {fmtTokens(usage.context.tokens ?? 0)}/{fmtTokens(usage.context.window)}
304 </Text>
305 </Text>
306 )}
307 </Text>
308 </Box>
309 <Box flexWrap="wrap" columnGap={3}>
310 <Text>
311 <Text color="ide">↑ </Text>
312 {fmtTokens(tokens.input)}
313 <Text dimColor> in </Text>
314 <Text color="merged"> ↓ </Text>
315 {fmtTokens(tokens.output)}
316 <Text dimColor> out</Text>
317 </Text>
318 <Text>
319 <Text dimColor>cache </Text>
320 {cachePct === undefined ? none : <Text color={cacheColor}>{cachePct}%</Text>}
321 </Text>
322 {cost === undefined ? (
323 none
324 ) : (
325 <Text color="warning" bold>
326 ${cost.toFixed(2)}
327 </Text>
328 )}
329 <Text>
330 {speed.tps === null ? none : <Text color="suggestion" bold>{Math.round(speed.tps)}</Text>}
331 <Text dimColor> tok/s</Text>
332 {avgTps !== undefined && <Text dimColor> avg {avgTps}</Text>}
333 </Text>
334 <Text>
335 <Text dimColor>TTFT </Text>
336 {speed.ttftMs === null ? none : <Text color={ttftColor}>{fmtMs(speed.ttftMs)}</Text>}
337 </Text>
338 </Box>
339 </Box>
340 )
341 })
342}
343types/index.d.ts 31 lines1// Token totals of every model request this session made, subagents' included.
2export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
3
4// Speed of the main loop's model requests.
5export type Speed = {
6 ttftMs: number | null
7 // Output tokens/s of the latest request long enough to measure.
8 tps: number | null
9 steps: number
10 outputTokens: number
11 decodeMs: number
12 ttftTotalMs: number
13}
14
15export type Meter = {
16 sessionId: string
17 tokens: Tokens
18 speed: Speed
19 // The model and effort the latest main-loop request named.
20 model: string | null
21 effort: string | null
22}
23
24export type Git = { branch: string; ahead: number; behind: number; changed: number }
25
26declare module 'claude-code' {
27 interface PluginState {
28 statusline: { meter: Meter | null; git: Git | null }
29 }
30}
31