SLOPSHOPPER

session-meter

Context, cost, tool calls and turn time above the prompt

newbandguardcommandtimer
v0.1.0MITupdated 2026-10-14plopaur/claude-mod-session-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-meter
› 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 › /meter ⎿ session-meter: Session meter hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

session-meter

A Claude Code mod that draws a live status band above the prompt: context usage, rate limits, session cost, tool calls, turn timing and 30-day token totals.

Features

  • Context bar with percent and tokens used / window. Green; yellow above 150k tokens; red above 50% of the window (both configurable, see below).
  • Rate limit bars for the 5-hour and weekly limits, with percent used and a countdown to reset (↻). Green; yellow above 80%; red above 90%.
  • API cost of the current session (n/a if unavailable).
  • Tool call counts for this turn and this session (main loop only; subagent calls are not counted).
  • Turn timing: elapsed time of the current turn (cyan while it runs), otherwise the duration of the last turn, plus average and total (12.3s, 2m03s, 1h02m). Shows no turns yet before the first turn.
  • Tool counts and turn timing start over on /clear, as the API cost does.
  • 30-day token totals (total, input ↑, output ↓) read from ~/.claude/stats-cache.json.
  • Two layouts that switch on terminal width: wide (2 rows, from 135 columns) and compact (4 rows, 74 columns). Columns are fixed-width, so values do not shift as numbers change, and in the wide layout weekly lines up with api cost.
  • ASCII mode for ssh and non-UTF-8 terminals, detected automatically.
  • Desktop app: in the Claude desktop app's Code tab the meter keeps the same rows, with Unicode glyphs, thin rounded bars and fixed-width columns, since the app draws in a proportional font. METER_CHARSET=ascii still forces ASCII there.
  • /meter command to toggle the meter.
  • Hides itself while a survey is shown. Updates every second during a turn and once a minute otherwise.

Layouts

Wide, at 135 columns or more:

────────────────────────────────────────
context  ███░░░░░░░░░░░  24%  48k/ 200k │ api cost $1.23  │  tools 4 turn / 31 session  │  turn 12.3s · avg 9.8s · total 2m03s
5h limit ██████░░░░░░░░  41% ↻2h10m     │ weekly ███░░░░░░░░░░░  22% ↻4d3h  │  30d tokens 710M  ↑ 6k  ↓ 5.5M (est.) · to 10-10

Compact, below 135 columns (fits 74 columns):

──────────────────────────────────────────────────────────────────────────
context  ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  24% 48k/200k
5h limit █████░░░░░░░  41% ↻2h10m   │  weekly ███░░░░░░░░░  22% ↻4d3h
tools 4/31  │  turn 12.3s · avg 9.8s · total 2m03s
30d tokens 710M  ↑ 6k  ↓ 5.5M (est.) · to 10-10  │  api cost $1.23

Desktop app (Code tab):

<img src="docs/desktop.png" width="1000" alt="session-meter in the Claude desktop app">

Fields

FieldSource
contextContext window usage from $.session.usage(); green; yellow above 150k tokens; red above 50% (configurable)
api costSession cost from $.session.usage(), n/a if unavailable
toolsTool calls this turn / this session. Main loop only; subagent calls are not counted
turnElapsed time of the current turn (cyan), otherwise the last turn's duration, plus average and total (main loop only)
5h limit, weeklyRate limits with percent used and time until reset (↻); green, yellow above 80%, red above 90%
30d tokensTotal, input (↑) and output (↓) tokens over the last 30 days, from ~/.claude/stats-cache.json

About the 30-day figures

  • The total sums dailyModelTokens (which includes cache tokens) for days on or after the cutoff date (30 days ago, by your local date).
  • The cache only gives input/output split for all cached days, so the split is scaled by the 30-day share of the total. It assumes a similar input/output mix throughout.
  • (est.) appears when the cache starts before the 30-day window or has no valid firstSessionDate.
  • to MM-DD is the newest day in the cache. Claude Code refreshes that file, not this mod, so it can lag.
  • The mod re-reads the file once a minute.
  • It locates the file by finding /.claude/ in the plugin path. If the plugin lives elsewhere, the 30d field shows n/a.

SSH and plain terminals

The meter switches to plain ASCII when the locale is not UTF-8 or the session runs over ssh (SSH_CONNECTION or SSH_TTY is set), because fonts and locales on the remote side often lack █ ░ │ ↻. Every glyph is one cell wide in both sets, so the layout does not change.

--------------------------------------------------------------------------
context  ############-------------------------------------  24% 48k/200k
5h limit #####-------  41% ~2h10m   |  weekly ###---------  22% ~4d3h
tools 4/31  |  turn 12.3s - avg 9.8s - total 2m03s
30d tokens 710M  in 6k  out 5.5M (est.) - to 10-10  |  api cost $1.23

Set METER_CHARSET=unicode or METER_CHARSET=ascii to override the detection.

Configuration

OptionDefaultMeaning
contextYellowTokens150000The context bar turns yellow above this many tokens
contextRedPercent50The context bar turns red above this percent of the window

Set them in the /config menu (or under pluginConfigs in settings). A change reloads the mod.

Install

/plugin install session-meter --marketplace plopaur/claude-mod-session-meter

Usage

  • /meter toggles the meter on and off.

Files

  • hooks/register.tsx: all logic and rendering
  • hooks/hooks.json: module list
  • types/index.d.ts: state types
  • tests/: render, charset, and time and number formatting tests (claude plugin test .)
  • .claude-plugin/: plugin manifest and marketplace.json; Claude Code writes its type definitions to types/ there (git-ignored)

Known limits

  • Rows are not truncated; below 74 columns they wrap.
  • The clock ticks every second only while a turn runs; otherwise it updates once a minute, so reset countdowns refresh at that rate.

License

MIT

Source 2 files
hooks/register.tsx 469 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Stats, Tokens30d } from '../types'
5
6const EMPTY: Stats = {
7  turns: 0,
8  totalMs: 0,
9  lastMs: null,
10  turnTools: 0,
11  sessionTools: 0,
12  turnStartedAt: null,
13}
14const stats = atom({ plugin: 'session-meter', key: 'stats' } as const, EMPTY)
15const now = atom({ plugin: 'session-meter', key: 'now' } as const, 0)
16const isHidden = atom({ plugin: 'session-meter', key: 'isHidden' } as const, false)
17
18const tokens30d = atom({ plugin: 'session-meter', key: 'tokens30d' } as const, null as Tokens30d | null)
19
20const BAR = 14
21const DAY_MS = 86_400_000
22
23// Thresholds sit where rounding would carry into the next unit, so 999,999 reads 1.0M, never 1000k
24export function millions(tokens: number): string {
25  if (tokens >= 999_500_000) {
26    return `${(tokens / 1e9).toFixed(1)}B`
27  }
28  if (tokens >= 9_950_000) {
29    return `${Math.round(tokens / 1e6)}M`
30  }
31  if (tokens >= 999_500) {
32    return `${(tokens / 1e6).toFixed(1)}M`
33  }
34  if (tokens >= 1000) {
35    return `${Math.round(tokens / 1e3)}k`
36  }
37
38  return String(tokens)
39}
40const LIMIT_BAR = 14
41const WIDE_COLUMNS = 135
42// Compact: the 5h bar starts under the context bar and the weekly bar ends where it ends
43const COMPACT_LIMIT_BAR = 12
44const COMPACT_COLUMNS = 74
45const LIMIT_SUFFIX = 13 // ' 100% ↻23h59m'
46
47// Every glyph is one cell wide in both sets, so the layout math is the same
48const UNICODE = { full: '█', empty: '░', sep: '│', rule: '─', reset: '↻', up: '↑ ', down: '↓ ', dot: '·' }
49const ASCII = { full: '#', empty: '-', sep: '|', rule: '-', reset: '~', up: 'in ', down: 'out ', dot: '-' }
50type Glyphs = typeof UNICODE
51let glyphs: Glyphs = UNICODE
52// METER_CHARSET as read at session start; the desktop draws Unicode unless it says ascii
53let forcedCharset: string | undefined
54const sep = (g: Glyphs) => `  ${g.sep}  `
55// Wide layout: the first separator on both rows is tighter and sits in the same column
56const firstSep = (g: Glyphs) => ` ${g.sep} `
57const CONTEXT_BAR_COMPACT = 2 * COMPACT_LIMIT_BAR + LIMIT_SUFFIX + 5 + 'weekly '.length
58const LABEL = 'context  '
59const TOKENS_WIDTH = 11 // ' 999k/1.5M', ' 1.2M/  2M'
60// Wide layout: first column ends here on both rows, so api cost and weekly line up
61const WIDE_FIRST = LABEL.length + BAR + 5 + TOKENS_WIDTH
62// Desktop bar: stretches to its column, fill and track in the bar colors the terminal uses
63const STRIP_HEIGHT = 6
64const STRIP_COLORS: Record<string, string> = { green: '#4eba65', yellow: '#e5b84b', red: '#e5534b' }
65export function stripSvg(fraction: number, color: string): string {
66  const fill = Math.max(0, Math.min(100, fraction * 100))
67  const r = STRIP_HEIGHT / 2
68
69  return (
70    `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${STRIP_HEIGHT}" preserveAspectRatio="none">` +
71    `<rect width="100%" height="${STRIP_HEIGHT}" rx="${r}" fill="#888" fill-opacity="0.3"/>` +
72    (fill > 0 ? `<rect width="${fill}%" height="${STRIP_HEIGHT}" rx="${r}" fill="${STRIP_COLORS[color] ?? color}"/>` : '') +
73    '</svg>'
74  )
75}
76
77const LIMITS = [
78  { kind: 'five_hour', label: '5h limit' },
79  { kind: 'seven_day', label: 'weekly' },
80] as const
81
82// Right-aligned in three columns so 5% and 100% take the same width
83function pct(percent: number): string {
84  return ` ${String(percent).padStart(3)}%`
85}
86
87function barColor(percent: number): string {
88  return percent > 90 ? 'red' : percent > 80 ? 'yellow' : 'green'
89}
90
91function until(resetsAt: string | undefined, time: number, g: Glyphs): string {
92  const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
93  if (Number.isNaN(at)) {
94    return ''
95  }
96  const minutes = Math.max(0, Math.round((at - time) / 60000))
97  const days = Math.floor(minutes / 1440)
98  const hours = Math.floor((minutes % 1440) / 60)
99
100  const mark = g.reset
101
102  return days > 0 ? ` ${mark}${days}d${hours}h` : hours > 0 ? ` ${mark}${hours}h${String(minutes % 60).padStart(2, '0')}m` : ` ${mark}${minutes}m`
103}
104
105const two = (n: number) => String(n).padStart(2, '0')
106
107// Rounds once, then splits, so 119.6s reads 2m00s, never 1m60s
108export function seconds(ms: number): string {
109  const tenths = Math.round(ms / 100)
110  if (tenths < 600) {
111    return `${(tenths / 10).toFixed(1)}s`
112  }
113  const total = Math.round(ms / 1000)
114  const hours = Math.floor(total / 3600)
115  const minutes = Math.floor((total % 3600) / 60)
116
117  return hours > 0 ? `${hours}h${two(minutes)}m` : `${minutes}m${two(total % 60)}s`
118}
119
120// At most four characters: 999k, then 1M, 1.5M
121export function kilo(tokens: number): string {
122  if (tokens >= 999_500) {
123    return `${Number((tokens / 1e6).toFixed(1))}M`
124  }
125
126  return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
127}
128
129// Local calendar date, matching the dates Claude Code writes to stats-cache.json
130export function localDate(time: number): string {
131  const d = new Date(time)
132
133  return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
134}
135
136export const register: Register = (on, options) => {
137  const yellowTokens = Number(options.contextYellowTokens)
138  const redPercent = Number(options.contextRedPercent)
139
140  on('session.start', async ($, e, next) => {
141    await $.command.register({
142      name: 'meter',
143      description: 'Hide or show the session meter above the prompt',
144    })
145    // Plain ASCII when the locale is not UTF-8 or the session is over ssh; METER_CHARSET=unicode|ascii overrides
146    const forced = await $.env.get('METER_CHARSET')
147    forcedCharset = forced
148    const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_CTYPE')) || (await $.env.get('LANG')) || ''
149    const isRemote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
150    const isUtf8 = /utf-?8/i.test(locale)
151    glyphs = forced === 'unicode' || (forced !== 'ascii' && isUtf8 && !isRemote) ? UNICODE : ASCII
152    // Needs the plugin to live under a `.claude/` directory; otherwise the 30d readout shows n/a
153    const marker = $.plugin.root.indexOf('/.claude/')
154    const home = marker === -1 ? null : $.plugin.root.slice(0, marker)
155    // Claude Code's own daily usage cache; it is refreshed by Claude Code, not by this mod
156    const refreshTokens = async () => {
157      try {
158        if (home === null) {
159          throw new Error('no .claude directory above the plugin root')
160        }
161        const cache = JSON.parse(await $.fs.read(`${home}/.claude/stats-cache.json`)) as {
162          dailyModelTokens?: { date: string; tokensByModel: Record<string, number> }[]
163          modelUsage?: Record<string, Record<string, number>>
164          firstSessionDate?: string
165        }
166        const since = (await $.clock.now()) - 30 * DAY_MS
167        // Days are plain local YYYY-MM-DD strings; compare them as strings instead of parsing to UTC midnight
168        const cutoff = localDate(since)
169        let total = 0
170        let asOf = ''
171        for (const day of cache.dailyModelTokens ?? []) {
172          asOf = day.date > asOf ? day.date : asOf
173          if (day.date >= cutoff) {
174            total += Object.values(day.tokensByModel).reduce((a, b) => a + b, 0)
175          }
176        }
177        // modelUsage splits input/output but covers every day the cache holds; scale it to the 30 days
178        let allInput = 0
179        let allOutput = 0
180        let allTotal = 0
181        for (const m of Object.values(cache.modelUsage ?? {})) {
182          allInput += m.inputTokens ?? 0
183          allOutput += m.outputTokens ?? 0
184          allTotal += (m.inputTokens ?? 0) + (m.outputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0)
185        }
186        const share = allTotal > 0 ? Math.min(1, total / allTotal) : 0
187        const input = Math.round(allInput * share)
188        const output = Math.round(allOutput * share)
189        // A missing or unparsable first-session date counts as an estimate
190        const isEstimate = !(Date.parse(cache.firstSessionDate ?? '') >= since)
191        await update($, tokens30d, () => ({ total, input, output, isEstimate, asOf }))
192      } catch {
193        await update($, tokens30d, () => null)
194      }
195    }
196    void refreshTokens()
197    let ticks = 0
198    $.clock.every(1000, async () => {
199      try {
200        ticks += 1
201        const current = await read($, stats)
202        if (current.turnStartedAt !== null || ticks % 60 === 0) {
203          const time = await $.clock.now()
204          await update($, now, () => time)
205        }
206        if (ticks % 60 === 0) {
207          await refreshTokens()
208        }
209      } catch {
210        // a failed tick is retried on the next one
211      }
212    })
213
214    return next(e)
215  })
216
217  on('command.run', { command: 'meter' }, async $ => {
218    const hidden = await update($, isHidden, h => !h)
219
220    return { text: hidden ? 'Session meter hidden.' : 'Session meter shown.' }
221  })
222
223  // /clear starts the session's cost over without a new session.start; start the counters over with it
224  on('session.end', async ($, e, next) => {
225    if (e.reason === 'clear') {
226      await update($, stats, () => EMPTY)
227    }
228
229    return next(e)
230  })
231
232  on('turn.start', async ($, e, next) => {
233    const time = await $.clock.now()
234    await update($, now, () => time)
235    await update($, stats, s => ({ ...s, turnTools: 0, turnStartedAt: time }))
236
237    return next(e)
238  })
239
240  on('tool.call', async ($, e, next) => {
241    // Main loop only, matching turn.complete; subagent calls are not counted
242    if (e.agentId === undefined) {
243      void update($, stats, s => ({
244        ...s,
245        turnTools: s.turnTools + 1,
246        sessionTools: s.sessionTools + 1,
247      })).catch(() => undefined)
248    }
249
250    return next(e)
251  })
252
253  on('turn.complete', async ($, e, next) => {
254    if (e.agentId === undefined) {
255      await update($, stats, s => ({
256        ...s,
257        turns: s.turns + 1,
258        totalMs: s.totalMs + e.durationMs,
259        lastMs: e.durationMs,
260        turnStartedAt: null,
261      }))
262    }
263
264    return next(e)
265  })
266
267  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
268    if (e.props.hasSurvey || (await read($, isHidden))) {
269      return next(e)
270    }
271
272    const elements = $.ui.resolve(e)
273    const { Box, Text } = elements
274    const Svg = 'Svg' in elements ? elements.Svg : null
275    const s = await read($, stats)
276    const time = await read($, now)
277    const { context, cost, rateLimits } = await $.session.usage()
278    const clockNow = await $.clock.now()
279
280    // The desktop draws in a proportional font: columns are fixed-width boxes there, not padding,
281    // and its own process carries no UTF-8 locale, so it draws Unicode unless METER_CHARSET=ascii
282    const isDesktop = e.surface === 'desktop'
283    const g = isDesktop && forcedCharset !== 'ascii' ? UNICODE : glyphs
284    type TextStyle = { color?: string; dimColor?: boolean }
285    const cell = (width: number, text: string, style: TextStyle, align: 'flex-start' | 'center' | 'flex-end' = 'flex-start') =>
286      isDesktop ? (
287        <Box width={width} flexShrink={0} justifyContent={align}>
288          <Text {...style}>{text.trim()}</Text>
289        </Box>
290      ) : (
291        <Text {...style}>{text}</Text>
292      )
293    // Desktop: a thin SVG strip in place of █ and ░, whose widths in a proportional font are not a cell's
294    const bar = (width: number, filled: number, color: string) =>
295      isDesktop && Svg !== null ? (
296        <Box width={width} flexShrink={0} flexDirection="column" justifyContent="center">
297          <Svg source={stripSvg(filled / width, color)} alt={`${Math.round((filled / width) * 100)}%`} height={STRIP_HEIGHT} />
298        </Box>
299      ) : (
300        <Box flexDirection="row" flexShrink={0}>
301          <Text color={color}>{g.full.repeat(filled)}</Text>
302          <Text dimColor>{g.empty.repeat(width - filled)}</Text>
303        </Box>
304      )
305
306    const percent = Math.round(context.percent ?? 0)
307    const meterColor = percent > redPercent ? 'red' : (context.tokens ?? 0) > yellowTokens ? 'yellow' : 'green'
308    const isCompact = e.props.bodyColumns < WIDE_COLUMNS
309    // Wide: right-align both numbers so ' 48k/ 200k' and '999k/1000k' take the same width
310    const tokens =
311      context.tokens === undefined
312        ? ''
313        : isCompact || isDesktop
314          ? ` ${kilo(context.tokens)}/${kilo(context.window)}`
315          : ` ${kilo(context.tokens).padStart(4)}/${kilo(context.window).padStart(5)}`
316    const barCells = isCompact ? CONTEXT_BAR_COMPACT : BAR
317    const filled = Math.min(barCells, Math.round((percent / 100) * barCells))
318
319    const isRunning = s.turnStartedAt !== null
320    const turnText = isRunning
321      ? seconds(Math.max(0, time - (s.turnStartedAt ?? time)))
322      : s.lastMs === null
323        ? 'no turns yet'
324        : seconds(s.lastMs)
325    const runningMs = isRunning ? Math.max(0, time - (s.turnStartedAt ?? time)) : 0
326    const average = s.turns > 0 ? ` ${g.dot} avg ${seconds(s.totalMs / s.turns)}` : ''
327    const total = s.turns > 0 || isRunning ? ` ${g.dot} total ${seconds(s.totalMs + runningMs)}` : ''
328
329    const used30 = await read($, tokens30d)
330    const limits = LIMITS.map(({ kind, label }, i) => {
331      const limit = rateLimits.find(r => r.kind === kind)
332      const lead = i === 0 ? '' : isCompact ? sep(g) : firstSep(g)
333      const leadCell = lead === '' ? null : cell(lead.length, lead, { dimColor: true }, 'center')
334      const name = `${label} `
335      const length = isCompact ? COMPACT_LIMIT_BAR : LIMIT_BAR
336      // Pad the first limit's suffix so the weekly bar starts at a fixed column
337      const suffixWidth = isCompact ? LIMIT_SUFFIX : WIDE_FIRST - name.length - length
338      if (limit === undefined) {
339        if (isDesktop) {
340          return (
341            <Box key={kind} flexDirection="row">
342              {leadCell}
343              {cell(name.length, name, { dimColor: true })}
344              {bar(length, 0, 'green')}
345              {cell(i === 0 ? suffixWidth : ' n/a'.length, ' n/a', { dimColor: true })}
346            </Box>
347          )
348        }
349        return (
350          <Box key={kind} flexDirection="row">
351            <Text dimColor>{lead}{name}{g.empty.repeat(length)} n/a{i === 0 ? ' '.repeat(suffixWidth - ' n/a'.length) : ''}</Text>
352          </Box>
353        )
354      }
355      const used = Math.round(limit.percentUsed)
356      const cells = Math.min(length, Math.round((used / 100) * length))
357      const color = barColor(used)
358      const reset = until(limit.resetsAt, clockNow, g)
359
360      if (isDesktop) {
361        return (
362          <Box key={kind} flexDirection="row">
363            {leadCell}
364            {cell(name.length, name, { dimColor: true })}
365            {bar(length, cells, color)}
366            <Box flexDirection="row" flexShrink={0} width={i === 0 ? suffixWidth : undefined}>
367              {cell(pct(used).length, pct(used), { color }, 'flex-end')}
368              <Text dimColor>{reset}</Text>
369            </Box>
370          </Box>
371        )
372      }
373      const pad = i === 0 ? ' '.repeat(Math.max(0, suffixWidth - pct(used).length - reset.length)) : ''
374
375      return (
376        <Box key={kind} flexDirection="row">
377          <Text dimColor>{lead}{name}</Text>
378          {bar(length, cells, color)}
379          <Text color={color}>{pct(used)}</Text>
380          <Text dimColor>{reset}{pad}</Text>
381        </Box>
382      )
383    })
384
385    const contextBar = (
386      <Box flexDirection="row">
387        {cell(LABEL.length, LABEL, { dimColor: true })}
388        {bar(barCells, filled, meterColor)}
389        {cell(pct(percent).length, pct(percent), { color: meterColor }, 'flex-end')}
390        {isCompact ? <Text dimColor>{tokens}</Text> : cell(TOKENS_WIDTH, tokens.padEnd(TOKENS_WIDTH), { dimColor: true })}
391      </Box>
392    )
393    const apiCost = (
394      <Box flexDirection="row">
395        <Text dimColor>api cost </Text>
396        <Text>{cost === undefined ? 'n/a' : `$${cost.usd.toFixed(2)}`}</Text>
397      </Box>
398    )
399    const tokens30 = (
400      <Box flexDirection="row">
401        <Text dimColor>30d tokens </Text>
402        {used30 === null ? (
403          <Text dimColor>n/a</Text>
404        ) : (
405          <Box flexDirection="row">
406            <Text>{millions(used30.total)}</Text>
407            <Text dimColor>  {g.up}</Text>
408            <Text>{millions(used30.input)}</Text>
409            <Text dimColor>  {g.down}</Text>
410            <Text>{millions(used30.output)}</Text>
411            <Text dimColor>{used30.isEstimate ? ' (est.)' : ''}{used30.asOf === '' ? '' : ` ${g.dot} to ${used30.asOf.slice(5)}`}</Text>
412          </Box>
413        )}
414      </Box>
415    )
416    const dividerColumns = isCompact ? Math.min(COMPACT_COLUMNS, e.props.bodyColumns) : e.props.bodyColumns
417    // The desktop frames the band itself; a rule of glyphs would not match its width
418    const divider = isDesktop ? null : <Text dimColor wrap="truncate-end">{g.rule.repeat(Math.max(1, dividerColumns))}</Text>
419
420    if (isCompact) {
421      // Compact layout: four rows, each at most 74 columns
422      return (
423        <Box flexDirection="column">
424          {divider}
425          {contextBar}
426          <Box flexDirection="row">{limits}</Box>
427          <Box flexDirection="row">
428            <Text dimColor>tools </Text>
429            <Text>{s.turnTools}</Text>
430            <Text dimColor>/</Text>
431            <Text>{s.sessionTools}</Text>
432            <Text dimColor>{sep(g)}turn </Text>
433            <Text color={isRunning ? 'cyan' : undefined}>{turnText}</Text>
434            <Text dimColor>{average}{total}</Text>
435          </Box>
436          <Box flexDirection="row">
437            {tokens30}
438            <Text dimColor>{sep(g)}</Text>
439            {apiCost}
440          </Box>
441        </Box>
442      )
443    }
444
445    return (
446      <Box flexDirection="column">
447        {divider}
448        <Box flexDirection="row">
449          {contextBar}
450          {cell(firstSep(g).length, firstSep(g), { dimColor: true }, 'center')}
451          {apiCost}
452          <Text dimColor>{sep(g)}tools </Text>
453          <Text>{s.turnTools}</Text>
454          <Text dimColor> turn / </Text>
455          <Text>{s.sessionTools}</Text>
456          <Text dimColor> session{sep(g)}turn </Text>
457          <Text color={isRunning ? 'cyan' : undefined}>{turnText}</Text>
458          <Text dimColor>{average}{total}</Text>
459        </Box>
460        <Box flexDirection="row">
461          {limits}
462          <Text dimColor>{sep(g)}</Text>
463          {tokens30}
464        </Box>
465      </Box>
466    )
467  })
468}
469
types/index.d.ts 17 lines
1export type Stats = {
2  turns: number
3  totalMs: number
4  lastMs: number | null
5  turnTools: number
6  sessionTools: number
7  turnStartedAt: number | null
8}
9
10export type Tokens30d = { total: number; input: number; output: number; isEstimate: boolean; asOf: string }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'session-meter': { stats: Stats; now: number; isHidden: boolean; tokens30d: Tokens30d | null }
15  }
16}
17