SLOPSHOPPER

session-hud

把上下文、token、缓存命中、TPS、费用、增删行数、耗时写给原生 statusLine 显示

newguardtimer
★ 1v0.1.0MITupdated 2026-10-04lurenxing628/claude-code-mod-statusline/mod
A shopper browsing a rack in a slop shop
README

Claude Code Mod 状态栏

给 Claude Code 换一个两行的状态栏,显示在输入框和权限模式行之间,每秒刷新:

Opus 5.5 xhigh   丨   上下文 161k ███░░░░░░░░┃567k░░░░░ 1M   丨   改动 3 文件      +82  −40 行   丨   项目 dev   丨   5h  7%    ↻   45m
费用    $73.24   丨   输入 2.1M    输出 26.4k    TPS 117.1   丨   活跃  4m09s      缓存 93.60%   丨   分支 main  丨   周 61%    ↻ 3d11h

第一行是“工作”:模型、上下文、改动、项目、5 小时限额;第二行是“消耗”:费用、token、速度、活跃时间和缓存、分支、周限额。数值都按含义上色,同一列上下两格左右两端对齐。

各项的统计口径

项内容
模型模型名、思考强度(low → max 由绿到红)、开了 fast 模式时显示 ⚡fast
上下文当前上下文 token、进度条、窗口大小;橙色 ┃567k 是自动压缩阈值及其数值。填充色按“已用 ÷ 压缩阈值”分绿/黄/红。首次响应前显示本地估算值(带 ~)
改动Edit(界面上显示为 Update)和 Write 改过的文件数、增删行数,subagent 的也算;用 Bash 命令改的文件不计
项目 / 分支项目目录名;分支读 .git/HEAD,支持 worktree,游离 HEAD 显示短哈希
5h / 周订阅限额的用量(整数,向下取整,100% 才表示用完)和重置倒计时;用 API key 时不显示
费用本会话累计费用(Claude Code 自己的统计)
输入 / 输出本会话所有请求累计,含 subagent;输入 = 未缓存 + 缓存写入 + 缓存读取
TPS主 agent 最近 10 次请求:输出 token 总和 ÷ 流式时间总和(从第一个流式片段到结束),长回答权重大,单次工具调用影响小;不含 subagent
活跃只要有 agent(主 agent 或 subagent)在工作就计时,几个同时工作只算一次;运行中青色,空闲淡蓝
缓存会话累计的缓存读取 ÷ 全部输入,两位小数

安装

需要支持 mod(函数钩子插件)的 Claude Code(作者在 2.1.289 上使用)和 Node.js,Linux / macOS。

  1. 克隆到固定位置(mod 把数据写到 ~/.claude/session-hud/<会话 id>.json,状态栏脚本从那里读):
   git clone https://github.com/lurenxing628/claude-code-mod-statusline.git ~/.claude/session-hud
  1. 在 ~/.claude/settings.json 里加上(已有的设置保留;CLAUDE_CODE_PLUGIN_DIRS 已有值时用 : 隔开追加):
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/session-hud/mod"
     },
     "statusLine": {
       "type": "command",
       "command": "node ~/.claude/session-hud/statusline.js",
       "padding": 0,
       "refreshInterval": 1
     }
   }
  1. 新开一个会话就能看到。正在运行的会话只在启动时读取设置,要重启才生效:退出后重新打开;由后台守护进程托管的会话(claude agents 能列出来的),退出终端再进只是接回旧进程,要用 claude respawn <id> 换新进程。

检查 mod 是否正常:

claude plugin validate ~/.claude/session-hud/mod
claude plugin test ~/.claude/session-hud/mod

工作原理

  • mod(mod/hooks/register.ts):用 Claude Code 的函数钩子统计 token、TPS、改动、活跃时间,每秒把渲染好的格子写到 ~/.claude/session-hud/<会话 id>.json(内容有变化才写)。上下文和费用每次从 $.session.usage() 读;自动压缩阈值每 10 秒从 breakdown 重读一次,压缩之后也能恢复。纯函数(格式、上色、进度条)在 mod/hooks/format.ts。
  • 状态栏脚本(statusline.js):原生 statusLine 命令。从 stdin 读模型、思考强度、目录和限额,读格子文件,按列排版。没有 mod 数据时(比如 mod 没加载)退化成一行简版。7 天没更新的数据文件会被顺手清理。
  • 对齐:列之间用 丨 分隔;同一列上下两格左右两端都对齐:进度条按下方输入/输出/TPS 格的宽度自动伸长,费用金额靠右与思考强度对齐,逐部分对齐的列在部分内的伸缩点(\t)吸收空余。宽度按终端列计算,中文占两列。
  • 颜色:Claude Code 会把整个状态栏压暗,所以每段颜色都以 \x1b[22;…m 开头,先恢复正常亮度再上色,暗色主题下也看得清。

自定义

  • 格子内容、颜色、进度条:mod/hooks/format.ts;统计逻辑:mod/hooks/register.ts。交互式会话会监视 mod 目录,保存后自动热重载(会话自己在一轮对话里改的,等这一轮结束才重载)。
  • 排版、模型、项目分支、限额:statusline.js,下次刷新就生效。

已知限制

  • 改了 mod 的状态结构(mod/types/index.d.ts 里的 PluginState)后热重载,正在运行的会话里的计数可能会清零。
  • 用 Bash 命令改的文件不计入“改动”(Claude Code 自带的行数统计也不算)。
  • Windows 没有测试过。

许可证

MIT

Source 3 files
hooks/register.ts 220 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, SessionContextUsage } from 'claude-code'
3
4import type { HudContext, HudEdits, HudSpeedSample, HudTokens } from '../types'
5import {
6  addSpeedSample,
7  countLines,
8  countPatch,
9  enterLoop,
10  HUD_GAPS,
11  leaveLoop,
12  NO_ACTIVITY,
13  recentTps,
14  renderCells,
15} from './format'
16
17// The statusLine command (~/.claude/session-hud/statusline.js) prints
18// the cells in <HUD_DIR>/<session id>.json under the prompt, the model added.
19const HUD_DIR = '.claude/session-hud'
20const NO_EDITS: HudEdits = { added: 0, removed: 0, files: [] }
21const NO_TOKENS: HudTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 }
22const NO_SPEED: HudSpeedSample[] = []
23const MAX_FILES = 500
24// Shorter streams than this give no meaningful tokens-per-second figure.
25const MIN_TPS_MS = 300
26
27const compactAt = atom({ plugin: 'session-hud', key: 'compactAt' } as const, null)
28const edits = atom({ plugin: 'session-hud', key: 'edits' } as const, NO_EDITS)
29const tokens = atom({ plugin: 'session-hud', key: 'tokens' } as const, NO_TOKENS)
30const speed = atom({ plugin: 'session-hud', key: 'speed' } as const, NO_SPEED)
31const active = atom({ plugin: 'session-hud', key: 'active' } as const, NO_ACTIVITY)
32
33const toHudContext = (c: SessionContextUsage): HudContext => ({
34  tokens: c.tokens ?? null,
35  window: c.window,
36})
37
38const record = ($: EngineInterface, filePath: string, delta: { added: number; removed: number }) =>
39  update($, edits, cur => ({
40    added: cur.added + delta.added,
41    removed: cur.removed + delta.removed,
42    files: cur.files.includes(filePath) ? cur.files : [...cur.files, filePath].slice(-MAX_FILES),
43  }))
44
45const addUsage = ($: EngineInterface, usage: ModelUsage) =>
46  update($, tokens, cur => ({
47    input: cur.input + usage.input_tokens,
48    output: cur.output + usage.output_tokens,
49    cacheRead: cur.cacheRead + usage.cache_read_input_tokens,
50    cacheWrite: cur.cacheWrite + usage.cache_creation_input_tokens,
51    requests: cur.requests + 1,
52  }))
53
54const refreshCompactAt = async ($: EngineInterface) => {
55  try {
56    const { breakdown } = (await $.session.usage({ breakdown: 'summary' })).context
57    if (breakdown === undefined) return
58    const at = breakdown.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null
59    await update($, compactAt, () => at)
60  } catch {
61    // Keep the last threshold; the line draws without it.
62  }
63}
64
65let published = ''
66let windowSeen: number | null = null
67let compactCheckedAt = 0
68let estimate: { at: number; tokens: number | null } = { at: 0, tokens: null }
69const ESTIMATE_EVERY_MS = 5000
70// A compaction can read the threshold while the engine has none to give (it
71// came back off once and stayed so), so it is read again now and then too.
72const COMPACT_EVERY_MS = 10_000
73
74// Before the first response there is no reading of the window: the engine's
75// local estimate (the one /context draws, no API call) stands in, at most
76// every few seconds.
77const estimateTokens = async ($: EngineInterface, now: number) => {
78  if (now - estimate.at < ESTIMATE_EVERY_MS) return estimate.tokens
79  try {
80    const { breakdown } = (await $.session.usage({ breakdown: 'summary' })).context
81    estimate = { at: now, tokens: breakdown?.totalTokens ?? null }
82  } catch {
83    estimate = { at: now, tokens: null }
84  }
85  return estimate.tokens
86}
87
88// Renders the cells and writes them for the statusLine command when they
89// changed. The context and cost are read from the engine on every pass (what
90// the status line itself reads), so they are there as soon as the engine has
91// them; the compaction threshold is looked up again when the window changes
92// and every few seconds.
93const publish = async ($: EngineInterface) => {
94  const home = await $.env.get('HOME')
95  if (home === undefined) return
96  const usage = await $.session.usage()
97  const now = await $.clock.now()
98  if (usage.context.window !== windowSeen || now - compactCheckedAt >= COMPACT_EVERY_MS) {
99    windowSeen = usage.context.window
100    compactCheckedAt = now
101    await refreshCompactAt($)
102  }
103  let context = toHudContext(usage.context)
104  if (context.tokens === null) {
105    const tokens = await estimateTokens($, now)
106    if (tokens !== null) context = { ...context, tokens, isEstimate: true }
107  }
108  const cells = renderCells({
109    context,
110    compactAt: await read($, compactAt),
111    cost: usage.cost?.usd ?? null,
112    edits: await read($, edits),
113    tokens: await read($, tokens),
114    tps: recentTps(await read($, speed)),
115    active: await read($, active),
116    now,
117  })
118  const path = `${home}/${HUD_DIR}/${await $.session.id()}.json`
119  const text = JSON.stringify({ rows: cells, gaps: HUD_GAPS })
120  if (`${path}\n${text}` === published) return
121  published = `${path}\n${text}`
122  await $.fs.write(path, text)
123}
124
125export const register: Register = on => {
126  on('session.start', async ($, e, next) => {
127    const ran = await next(e)
128    // Right away, so a new session never shows the stdin-only fallback for long.
129    $.clock.after(1, () => void publish($))
130    $.clock.every(1000, () => void publish($))
131
132    return ran
133  })
134
135  on('session.end', async ($, e, next) => {
136    const ran = await next(e)
137    if (e.reason === 'clear') {
138      await update($, edits, () => NO_EDITS)
139      await update($, tokens, () => NO_TOKENS)
140      await update($, speed, () => NO_SPEED)
141      await update($, active, () => NO_ACTIVITY)
142    }
143
144    return ran
145  })
146
147  // The active clock: the main loop works from turn.start to its
148  // turn.complete; a subagent, which raises no turn.start, from its first
149  // model request to its turn.complete.
150  on('turn.start', async ($, e, next) => {
151    const t = await $.clock.now()
152    await update($, active, cur => enterLoop(cur, 'main', t))
153
154    return next(e)
155  })
156
157  on('turn.complete', async ($, e, next) => {
158    const t = await $.clock.now()
159    await update($, active, cur => leaveLoop(cur, e.agentId ?? 'main', t))
160
161    return next(e)
162  })
163
164  // Every model request, subagents' included, adds to the token totals; the
165  // TPS figure is over the main loop's last few requests, each timed from its
166  // first streamed chunk (the response envelope) to its stop.
167  on('turn.step', async function* ($, e, next) {
168    if (e.agentId !== undefined) {
169      const loop = e.agentId
170      const t = await $.clock.now()
171      await update($, active, cur => enterLoop(cur, loop, t))
172    }
173    const stream = next(e)
174    let firstAt: number | null = null
175    let stopAt: number | null = null
176    for await (const chunk of stream) {
177      if (firstAt === null) firstAt = performance.now()
178      if (chunk.kind === 'stop') stopAt = performance.now()
179      yield chunk
180    }
181    const result = await stream.result
182    if (result.usage !== null) {
183      await addUsage($, result.usage)
184      const ms = firstAt !== null && stopAt !== null ? stopAt - firstAt : 0
185      if (e.agentId === undefined && ms >= MIN_TPS_MS && result.usage.output_tokens > 0) {
186        const sample = { tokens: result.usage.output_tokens, ms }
187        await update($, speed, cur => addSpeedSample(cur, sample))
188      }
189    }
190
191    return result
192  })
193
194  // Edits made inside subagents count too: they change the same files.
195  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
196    const ran = await next(e)
197    if (ran.deny === undefined && ran.isError !== true && ran.result.staged !== true) {
198      await record($, ran.result.filePath, countPatch(ran.result.structuredPatch))
199    }
200
201    return ran
202  })
203
204  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
205    const ran = await next(e)
206    if (ran.deny === undefined && ran.isError !== true && ran.result.staged !== true) {
207      const r = ran.result
208      const delta =
209        r.structuredPatch.length > 0
210          ? countPatch(r.structuredPatch)
211          : r.type === 'create'
212            ? { added: countLines(r.content), removed: 0 }
213            : { added: 0, removed: 0 }
214      await record($, r.filePath, delta)
215    }
216
217    return ran
218  })
219}
220
hooks/format.ts 204 lines
1import type { HudActive, HudContext, HudEdits, HudSpeedSample, HudTokens } from '../types'
2
3export type HudSnapshot = {
4  context: HudContext | null
5  compactAt: number | null
6  cost: number | null
7  edits: HudEdits
8  tokens: HudTokens
9  tps: number | null
10  active: HudActive
11  now: number
12}
13
14const BAR_CELLS = 20
15
16// Claude Code draws the whole status line dimmed (`<Text dimColor>` around
17// it), so every piece here states its own intensity: 22 (normal) first, then a
18// bright color. Labels are bright white, figures bold in their own color
19// (green/yellow/red where it grades good to bad), separators and the bar's
20// empty cells light gray.
21const ansi = (code: string) => (text: string) => (text === '' ? '' : `\x1b[22;${code}m${text}\x1b[0m`)
22const label = ansi('97')
23const light = ansi('37')
24const green = ansi('1;92')
25const yellow = ansi('1;93')
26const red = ansi('1;91')
27const blue = ansi('1;94')
28const magenta = ansi('1;95')
29const cyan = ansi('1;96')
30const orange = ansi('1;38;5;208')
31const softBlue = ansi('1;38;5;111')
32
33export const countPatch = (hunks: readonly { lines: readonly string[] }[]) => {
34  let added = 0
35  let removed = 0
36  for (const hunk of hunks) {
37    for (const line of hunk.lines) {
38      if (line.startsWith('+')) added += 1
39      else if (line.startsWith('-')) removed += 1
40    }
41  }
42
43  return { added, removed }
44}
45
46export const countLines = (text: string) =>
47  text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
48
49export const formatTokens = (n: number) => {
50  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
51  if (n >= 10_000) return `${Math.round(n / 1000)}k`
52  if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
53
54  return String(n)
55}
56
57// The input and output totals to a tenth of the unit, the zero kept (615.0k,
58// 1.2M); a count under a thousand stays whole.
59export const formatTokensTenths = (n: number) => {
60  if (n >= 999_950) return `${(n / 1_000_000).toFixed(1)}M`
61  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
62
63  return String(n)
64}
65
66export const formatDuration = (ms: number) => {
67  const seconds = Math.floor(Math.max(0, ms) / 1000)
68  if (seconds < 60) return `${seconds}s`
69  const minutes = Math.floor(seconds / 60)
70  if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
71
72  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
73}
74
75// Share of all input tokens (uncached + cache write + cache read) the cache served.
76export const cacheHitPercent = (t: HudTokens) => {
77  const input = t.input + t.cacheWrite + t.cacheRead
78
79  return input === 0 ? null : (t.cacheRead / input) * 100
80}
81
82// TPS is over the main loop's last few requests: their output tokens over
83// their streaming time together, so a long answer weighs more than a request
84// that only called a tool.
85const SPEED_SAMPLES = 10
86
87export const addSpeedSample = (samples: readonly HudSpeedSample[], sample: HudSpeedSample) =>
88  [...samples, sample].slice(-SPEED_SAMPLES)
89
90export const recentTps = (samples: readonly HudSpeedSample[]) => {
91  const tokens = samples.reduce((n, s) => n + s.tokens, 0)
92  const ms = samples.reduce((n, s) => n + s.ms, 0)
93
94  return ms === 0 ? null : (tokens / ms) * 1000
95}
96
97// Active time is the union of the loops' busy spans: the clock runs while at
98// least one loop (the main one, `main`, or a subagent's) is working, once
99// however many are.
100export const NO_ACTIVITY: HudActive = { loops: [], since: null, totalMs: 0 }
101
102export const enterLoop = (a: HudActive, loop: string, now: number): HudActive =>
103  a.loops.includes(loop) ? a : { ...a, loops: [...a.loops, loop], since: a.since ?? now }
104
105export const leaveLoop = (a: HudActive, loop: string, now: number): HudActive => {
106  if (!a.loops.includes(loop)) return a
107  const loops = a.loops.filter(one => one !== loop)
108  if (loops.length > 0 || a.since === null) return { ...a, loops }
109
110  return { loops, since: null, totalMs: a.totalMs + (now - a.since) }
111}
112
113export const activeMs = (a: HudActive, now: number) => a.totalMs + (a.since === null ? 0 : now - a.since)
114
115// Cells the terminal draws for the text, as the statusLine command measures
116// it: CJK two, the rest one.
117const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿가-힣豈-﫿︰-﹏＀-⦆¢-₩⚡]/
118const displayWidth = (text: string) =>
119  [...text.replace(/\x1b\[[0-9;]*m/g, '')].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0)
120
121// `上下文 207k ████░░░░░░░┃567k░░░░░ 1M`: used on the left in the fill's
122// color, the window at the bar's end, and the auto-compaction threshold as a
123// mark with its figure written into the bar beside it (before it when no room
124// is left after).
125const contextPart = (ctx: HudContext | null, threshold: number | null, cells = BAR_CELLS) => {
126  if (ctx === null || ctx.tokens === null) return `${label('上下文 ')}${label('等待首次响应')}`
127  const used = ctx.tokens
128  // The fill's color: how close `used` is to the threshold (or the window).
129  const pressure = used / (threshold ?? (ctx.window || 1))
130  const fill = pressure < 0.6 ? green : pressure < 0.85 ? yellow : red
131  const filled = ctx.window > 0 ? Math.min(cells, Math.round((used / ctx.window) * cells)) : 0
132  // Cells from..to of the bar: filled ones colored by pressure, the rest light gray.
133  const run = (from: number, to: number) =>
134    fill('█'.repeat(Math.max(0, Math.min(to, filled) - from))) +
135    light('░'.repeat(Math.max(0, to - Math.max(from, filled))))
136  let bar = run(0, cells)
137  if (threshold !== null && ctx.window > 0) {
138    const mark = Math.min(cells, Math.max(0, Math.round((threshold / ctx.window) * cells)))
139    const figure = formatTokens(threshold)
140    if (mark + figure.length <= cells) {
141      bar = `${run(0, mark)}${orange(`┃${figure}`)}${run(mark + figure.length, cells)}`
142    } else {
143      const start = Math.max(0, mark - figure.length)
144      bar = `${run(0, start)}${orange(`${figure}┃`)}${run(mark, cells)}`
145    }
146  }
147
148  return `${label('上下文 ')}${fill(`${ctx.isEstimate === true ? '~' : ''}${formatTokens(used)}`)} ${bar} ${softBlue(formatTokens(ctx.window))}`
149}
150
151// Two rows of cells, each cell a list of parts, ANSI-colored: the session's
152// work (model, context, changes), then what it has consumed (cost, tokens and
153// speed, active time and cache). The statusLine command writes the model
154// into the empty first cell, sizes each column to its widest cell, spreads a
155// narrower cell's parts across the width (the spare room goes between them, not
156// after them) and joins the columns with 丨.
157export type HudCells = string[][][]
158
159// Where a part gives way when its column lines parts up and it is narrower
160// than the widest there: the statusLine command puts the room at this mark
161// (a space when there is none), so the figure after it ends with the widest.
162export const FLEX = '\t'
163
164// The least room between the parts of a cell, per column: the cost's label
165// and figure need only a space (the figure ends under the model's effort), the
166// last column's parts stand further apart.
167export const HUD_GAPS = [1, 4, 6] as const
168
169export const renderCells = (s: HudSnapshot): HudCells => {
170  const hit = cacheHitPercent(s.tokens)
171  const hitText = hit === null ? label('—') : (hit >= 80 ? green : hit >= 50 ? yellow : red)(`${hit.toFixed(2)}%`)
172  const input = s.tokens.input + s.tokens.cacheWrite + s.tokens.cacheRead
173  const isActive = s.active.since !== null
174  const speed = [
175    `${label('输入 ')}${blue(formatTokensTenths(input))}`,
176    `${label('输出 ')}${magenta(formatTokensTenths(s.tokens.output))}`,
177    `${label('TPS ')}${s.tps === null ? label('—') : cyan(s.tps.toFixed(1))}`,
178  ]
179  // The bar grows by however much the speed cell below is wider, so the
180  // window's figure ends where the TPS does; a wider bar spreads that cell.
181  const speedWidth = speed.reduce((n, part) => n + displayWidth(part), 0) + HUD_GAPS[1] * (speed.length - 1)
182  const short = speedWidth - displayWidth(contextPart(s.context, s.compactAt))
183  const context = contextPart(s.context, s.compactAt, BAR_CELLS + Math.max(0, short))
184
185  return [
186    [
187      [''],
188      [context],
189      [
190        `${label('改动')}${FLEX}${blue(String(s.edits.files.length))}${label(' 文件')}`,
191        `${green(`+${s.edits.added}`)}${FLEX}${red(`−${s.edits.removed}`)}${label(' 行')}`,
192      ],
193    ],
194    [
195      [label('费用'), s.cost === null ? label('—') : yellow(`$${s.cost.toFixed(2)}`)],
196      speed,
197      [
198        `${label('活跃')}${FLEX}${(isActive ? cyan : softBlue)(formatDuration(activeMs(s.active, s.now)))}`,
199        `${label('缓存')}${FLEX}${hitText}`,
200      ],
201    ],
202  ]
203}
204
types/index.d.ts 37 lines
1// `isEstimate`: no response has reported the window yet, so `tokens` is the
2// engine's local estimate of what the first request will carry.
3export type HudContext = { tokens: number | null; window: number; isEstimate?: boolean }
4
5export type HudEdits = {
6  added: number
7  removed: number
8  files: string[]
9}
10
11// The loops working now (`main` or a subagent's id), since when at least one
12// has been, and the time banked from the busy spans that ended.
13export type HudActive = { loops: string[]; since: number | null; totalMs: number }
14
15// One main-loop request's output and how long it streamed, for the TPS figure.
16export type HudSpeedSample = { tokens: number; ms: number }
17
18export type HudTokens = {
19  input: number
20  output: number
21  cacheRead: number
22  cacheWrite: number
23  requests: number
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'session-hud': {
29      compactAt: number | null
30      edits: HudEdits
31      tokens: HudTokens
32      speed: HudSpeedSample[]
33      active: HudActive
34    }
35  }
36}
37