SLOPSHOPPER

context-statusline

A card above the prompt: the model, the context window in tapered colour bands, prompt cache warmth, and the git repo and branch, collapsing to fit any…

newbandguardprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 97k ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░▏░░░░░░░▏░░░░▏░░▏░▏░ 200k │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ Opus 5.5 ctx 97k ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░▏░░░░░░░▏░░░░▏░░▏░▏░ 200k │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

context-statusline

A Claude Code plugin that draws a card above the prompt with the model, the context window, prompt cache warmth, and the git repo and branch.

╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ◆ Opus 5.5   ctx 450k ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▌▓▓▓▓▓▓▓▌▓░░░▏░░▏░▏░ 1M (550k left)   cache warm 57m · hit 97%    platform   ui-676 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Install

/plugin marketplace add nthplusio/skills
/plugin install context-statusline@nthplusio

The repo and branch icons are Nerd Font glyphs. Without a Nerd Font they draw as blank cells.

What the card shows

  • Model: the session's model, for example Opus 5.5.
  • Context: tokens used on the left, the window size on the right, and a bar of the whole window between them. The bar is split into bands by how comfortable that much context is:
  • The target band, green, takes half the bar. It runs to 200k on a larger window, or over the first half of a window of 200k or less.
  • Five bands from yellow to red split the rest of the window into equal token ranges, but each takes less of the bar than the one before (20, 12, 8, 6 and 4%), since that context is a worse place to be.
  • Used cells are bright, the window ahead is dimmed in its band's colour, and ▌/▏ marks where a band starts. A band too narrow for a divider is marked by its colour alone.
  • Past 200k, or at the cap, the room left follows: (550k left).
  • Cache: how long the prompt cache stays warm after the last response, and the share of the last request the cache served: cache warm 57m · hit 97%. Once the time runs out it reads cold 12m ago; after a model switch, cold (model switch). The countdown updates every 15 seconds and ignores subagents, which cache their own prefixes.
  • Git: the repository and branch of the working directory, outside a repository nothing.

Narrow terminals

The card always fits on one line. As the terminal narrows, the bar first shrinks from 40 cells to 20, then parts give way in this order:

  1. The repository name, trimmed to 12 cells and then dropped.
  2. The cache hit rate.
  3. The branch, trimmed from the front (…ui-676) and then dropped.
  4. The room note.
  5. The cache segment.
  6. The bar shrinks to 10 cells, then the ctx label and the bar go.
  7. The model name is trimmed last.

A narrower terminal never shows more than a wider one, and never a longer bar.

Settings

/config lists one setting, Prompt cache TTL (cacheTtl): 1h, the default, or 5m. Claude Code reports the cache lifetime only when the model changes, so until then the card counts down from this setting. Subscription sessions use a 1-hour cache; API-key sessions default to 5 minutes.

Develop

Load a checkout in place of the installed plugin:

claude --plugin-dir plugins/context-statusline

Run the tests, which include a sweep over every terminal width from 20 to 200 columns:

claude plugin test plugins/context-statusline
claude plugin validate plugins/context-statusline

tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes the first time it loads the plugin. Once it has loaded the plugin, npx tsc -p plugins/context-statusline type-checks it. The generated types are gitignored.

All layout and formatting is pure and lives in hooks/format.ts; hooks/register.tsx gathers the figures and draws the strings the layout returns.

Source 3 files
hooks/register.tsx 181 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CacheSegment } from '../types'
5import { TTL_MS, formatCache, formatStatus, hitPercent, layout } from './format.ts'
6
7const line = atom({ plugin: 'context-statusline', key: 'line' } as const, null)
8const cacheSource = atom({ plugin: 'context-statusline', key: 'cacheSource' } as const, null)
9const cache = atom({ plugin: 'context-statusline', key: 'cache' } as const, null)
10
11// How often the countdown is recomputed. A write only lands when the text changed.
12const TICK_MS = 15000
13
14const basename = (path: string) => path.trim().replace(/\/+$/, '').split('/').pop() ?? ''
15
16// No session call gives the branch, so it comes from git in the working directory.
17const branchOf = async ($: EngineInterface, cwd: string) => {
18  const r = await $.process.run(['git', '--no-optional-locks', '-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'])
19  return r.exitCode === 0 ? basename(r.stdout) : ''
20}
21
22const refresh = async ($: EngineInterface) => {
23  try {
24    const [usage, modelId, cwd, repo] = await Promise.all([
25      $.session.usage(),
26      $.session.model(),
27      $.session.cwd(),
28      $.session.repo(),
29    ])
30    const branch = repo ? await branchOf($, cwd).catch(() => '') : ''
31    const segments = formatStatus({
32      modelId,
33      tokens: usage.context.tokens ?? 0,
34      cap: usage.context.window ?? 0,
35      git: repo ? { repo: basename(repo.root), branch } : null,
36    })
37    await update($, line, () => segments)
38  } catch {
39    // Keep the last card if a figure is unavailable.
40  }
41}
42
43const sameCache = (a: CacheSegment | null, b: CacheSegment | null) =>
44  a === b || (!!a && !!b && a.label === b.label && a.hit === b.hit && a.color === b.color)
45
46// The countdown as it reads now; written only when the text changed.
47const recomputeCache = async ($: EngineInterface, configuredTtl: '5m' | '1h') => {
48  try {
49    const [src, now] = await Promise.all([read($, cacheSource), $.clock.now()])
50    const next = formatCache(src, now, TTL_MS[src?.ttl ?? configuredTtl])
51    const prev = await read($, cache)
52    if (!sameCache(prev, next)) await update($, cache, () => next)
53  } catch {
54    // Keep the last reading if the clock or state is unavailable.
55  }
56}
57
58export const register: Register = (on, options) => {
59  // The engine reports the TTL only on a model switch; until then, the setting.
60  const configuredTtl = options.cacheTtl === '5m' ? '5m' : '1h'
61
62  // Module variables start over on reload, and so do the timers: one per load.
63  let ticking = false
64
65  on('session.start', async ($, e, next) => {
66    const result = await next(e)
67    void refresh($)
68    void recomputeCache($, configuredTtl)
69    if (!ticking) {
70      ticking = true
71      $.clock.every(TICK_MS, () => void recomputeCache($, configuredTtl))
72    }
73    return result
74  })
75
76  // The TTL clock starts at the main thread's last response. Subagents cache
77  // their own prefixes, so their steps are left out.
78  on('turn.step', async function* ($, e, next) {
79    const r = yield* next(e)
80    if (!e.agentId && r.usage) {
81      const at = await $.clock.now()
82      const hit = hitPercent(r.usage)
83      await update($, cacheSource, src => ({ at, hit, ttl: src?.ttl ?? null, isForfeited: false }))
84      await recomputeCache($, configuredTtl)
85    }
86    return r
87  })
88
89  // A resumed transcript's cache may still be warm; /clear and compaction
90  // start a new prefix, so there is nothing to show until the next response.
91  on('classic.SessionStart', async ($, e, next) => {
92    const result = await next(e)
93    if (e.seconds_since_last_response !== undefined) {
94      const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
95      await update($, cacheSource, src => ({ at, hit: null, ttl: src?.ttl ?? null, isForfeited: false }))
96    } else if (e.source === 'clear' || e.source === 'compact') {
97      await update($, cacheSource, () => null)
98    }
99    await recomputeCache($, configuredTtl)
100    return result
101  })
102
103  on('classic.PostModelSwitch', async ($, e, next) => {
104    const result = await next(e)
105    await update($, cacheSource, src =>
106      src ? { ...src, ttl: e.cache_ttl, isForfeited: e.from_model !== e.to_model } : null)
107    await recomputeCache($, configuredTtl)
108    return result
109  })
110
111  on('turn.complete', async ($, e, next) => {
112    const result = await next(e)
113    void refresh($)
114    return result
115  })
116
117  on('tool.call', async ($, e, next) => {
118    const result = await next(e)
119    void refresh($)
120    return result
121  })
122
123  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
124    const s = await read($, line)
125    // A card stored by an older version of this module lacks the raw figures.
126    if (s === null || typeof s.meter.used !== 'number' || e.props.hasSurvey || e.props.maxRows < 1) return next(e)
127    const c = await read($, cache)
128
129    const { Box, Text } = $.ui.resolve(e)
130    const columns = e.props.bodyColumns
131    const framed = e.props.maxRows >= 3
132    // Every string drawn comes from the layout, which measured them to fit.
133    const l = layout(s, c, framed ? columns : columns + 4)
134
135    // Segments keep their width; should the measure ever be off, the git
136    // segment shrinks and truncates instead of the card wrapping.
137    return (
138      <Box
139        width={columns}
140        borderStyle={framed ? 'round' : undefined}
141        borderDimColor
142        paddingX={framed ? 1 : 0}
143        flexDirection="row"
144        justifyContent="space-between"
145      >
146        <Box flexShrink={0}>
147          <Text bold>{l.model}</Text>
148        </Box>
149        <Box flexShrink={0}>
150          <Text wrap="truncate-end">
151            {l.meter.label ? <Text dimColor>{l.meter.label}</Text> : null}
152            <Text color={`ansi256(${l.meter.color})`}>{l.meter.tokens}</Text>
153            {l.meter.bar.map(r => <Text color={`ansi256(${r.color})`} dimColor={r.isDim}>{r.text}</Text>)}
154            {l.meter.total ? <Text dimColor>{l.meter.total}</Text> : null}
155            {l.meter.room ? <Text dimColor>{l.meter.room}</Text> : null}
156          </Text>
157        </Box>
158        {l.cache ? (
159          <Box flexShrink={0}>
160            <Text wrap="truncate-end">
161              <Text dimColor>{l.cache.label}</Text>
162              <Text color={`ansi256(${l.cache.color})`}>{l.cache.warmth}</Text>
163              {l.cache.hit ? <Text dimColor>{l.cache.hit}</Text> : null}
164            </Text>
165          </Box>
166        ) : null}
167        {l.git ? (
168          <Box flexShrink={1} minWidth={0}>
169            <Text wrap="truncate-start">
170              {l.git.repoIcon ? <Text dimColor>{l.git.repoIcon}</Text> : null}
171              {l.git.repo ? <Text>{l.git.repo}</Text> : null}
172              <Text dimColor>{l.git.branchIcon}</Text>
173              <Text>{l.git.branch}</Text>
174            </Text>
175          </Box>
176        ) : null}
177      </Box>
178    )
179  })
180}
181
hooks/format.ts 302 lines
1// Pure status formatting. The renderer applies colours; nothing here draws.
2
3import type { CacheSegment, CacheSource, StatusSegments } from '../types'
4
5export const TOP = 200000
6const GREEN = 40
7const RAMP = [220, 214, 208, 173, 167]
8
9export type StatusInput = {
10  modelId: string
11  tokens: number
12  cap: number
13  git: { repo: string; branch: string } | null
14}
15
16const div = (a: number, b: number) => Math.trunc(a / b)
17
18// claude-opus-5-5 -> Opus 5.5, claude-haiku-4-5-20251001 -> Haiku 4.5.
19// Anything that does not look like a Claude model ID comes back unchanged,
20// less any " (...)" suffix.
21export const modelDisplayName = (id: string): string => {
22  const m = /^claude-([a-z]+)((?:-\d+)+?)(?:-\d{8})?(?:\[[^\]]*\])?$/.exec(id.trim())
23  if (!m || !m[1] || !m[2]) return id.replace(/ \(.*/, '')
24  const family = m[1].charAt(0).toUpperCase() + m[1].slice(1)
25  return `${family} ${m[2].slice(1).split('-').join('.')}`
26}
27
28// The window in bands: the target, green, then the 5-step ramp in equal
29// token ranges to the cap. The target runs to the 200k reference on a larger
30// window, and over the first half of a window of 200k or less. Unknown cap:
31// the 200k reference, one green band.
32export type Band = { from: number; to: number; color: number }
33
34export const bands = (cap: number): Band[] => {
35  if (cap <= 0) return [{ from: 0, to: TOP, color: GREEN }]
36  const target = cap > TOP ? TOP : div(cap, 2)
37  const step = (i: number) => target + div((cap - target) * i, RAMP.length)
38  return [
39    { from: 0, to: target, color: GREEN },
40    ...RAMP.map((color, i) => ({ from: step(i), to: step(i + 1), color })),
41  ]
42}
43
44// The band `tokens` is in; a count on a boundary belongs to the band below.
45const bandIndex = (bs: Band[], tokens: number) => {
46  const i = bs.findLastIndex(b => b.from < tokens)
47  return Math.max(i, 0)
48}
49
50export const meterColor = (tokens: number, cap: number): number => {
51  const bs = bands(cap)
52  return bs[bandIndex(bs, tokens)]?.color ?? GREEN
53}
54
55// Share of the bar each band takes, in percent: the target half of it, each
56// band past it less, as the context is a worse place to be.
57export const SHARES = [50, 20, 12, 8, 6, 4]
58
59// Cells per band at `width`: each band's share rounded, at least 1 cell
60// while the bar has one for every band, the rounding settled on the target.
61export const bandCells = (count: number, width: number): number[] => {
62  const shares = count === 1 ? [100] : SHARES.slice(0, count)
63  const total = shares.reduce((a, b) => a + b, 0)
64  const min = width >= count ? 1 : 0
65  const cells = shares.map(p => Math.max(min, Math.round((p * width) / total)))
66  cells[0] = (cells[0] ?? 0) + width - cells.reduce((a, b) => a + b, 0)
67  return cells
68}
69
70// One styled stretch of the bar: used cells bright, the window ahead dim.
71export type BarRun = { text: string; color: number; isDim: boolean }
72
73// `width` cells over the whole window, each band in its own share of them,
74// filled across a band in proportion to the tokens used within it. A used
75// cell is ▓, one ahead ░, in its band's colour; a band's first cell is a
76// divider instead, ▌ used or ▏ ahead, when the band has 2 cells or more.
77// Narrower, its colour change alone marks it. Runs of one style are merged.
78export const meterBar = (used: number, cap: number, width: number): BarRun[] => {
79  const bs = bands(cap)
80  const cells = bandCells(bs.length, width)
81  const starts = cells.map((_, i) => cells.slice(0, i).reduce((a, b) => a + b, 0))
82  const at = bandIndex(bs, used)
83  const band = bs[at]
84  const filled = !band || used >= (bs.at(-1)?.to ?? 0) ? width
85    : (starts[at] ?? 0) + div((cells[at] ?? 0) * (used - band.from), Math.max(band.to - band.from, 1))
86  const runs: BarRun[] = []
87  bs.forEach((b, bi) => {
88    const n = cells[bi] ?? 0
89    for (let j = 0; j < n; j++) {
90      const isUsed = (starts[bi] ?? 0) + j < filled
91      const glyph = bi > 0 && j === 0 && n >= 2 ? (isUsed ? '▌' : '▏') : isUsed ? '▓' : '░'
92      const last = runs.at(-1)
93      if (last && last.color === b.color && last.isDim === !isUsed) last.text += glyph
94      else runs.push({ text: glyph, color: b.color, isDim: !isUsed })
95    }
96  })
97  return runs
98}
99
100const k = (n: number) => `${div(n + 500, 1000)}k`
101
102// The window's size: 1M, 1.5M, 200k.
103const size = (n: number) =>
104  n >= 1000000 && n % 100000 === 0 ? `${n / 1000000}M` : k(n)
105
106export const formatStatus = ({ modelId, tokens, cap, git }: StatusInput): StatusSegments => {
107  tokens = Math.max(Math.trunc(tokens), 0)
108  cap = Math.max(Math.trunc(cap), 0)
109
110  // Room is shown once past the 200k reference, or at a smaller window's cap.
111  let room = ''
112  if (cap > 0 && (tokens > TOP || tokens >= cap)) {
113    room = tokens >= cap ? '(cap reached)' : `(${k(cap - tokens)} left)`
114  }
115
116  return {
117    model: modelDisplayName(modelId),
118    meter: { color: meterColor(tokens, cap), used: tokens, cap, tokens: k(tokens), total: cap > 0 ? size(cap) : '', room },
119    git,
120  }
121}
122
123// Prompt cache warmth. The TTL runs from the main thread's last response,
124// since each cache read refreshes it; a model switch forfeits the entry.
125export const TTL_MS = { '5m': 5 * 60000, '1h': 60 * 60000 } as const
126const COLD = 167
127
128// 45s, 12m, 1h, 1h 5m. Remaining time rounds up so "1m" shows until it lapses.
129const duration = (ms: number, roundUp = false) => {
130  const s = Math.max((roundUp ? Math.ceil : Math.floor)(ms / 1000), 0)
131  if (s < 60) return `${s}s`
132  const m = (roundUp ? Math.ceil : Math.floor)(s / 60)
133  if (m < 60) return `${m}m`
134  return m % 60 ? `${div(m, 60)}h ${m % 60}m` : `${div(m, 60)}h`
135}
136
137// Green while more than half the TTL is left, then yellow, orange below a fifth.
138export const warmthColor = (left: number, ttlMs: number): number =>
139  left <= 0 ? COLD : left * 2 > ttlMs ? GREEN : left * 5 > ttlMs ? 220 : 208
140
141export const formatCache = (src: CacheSource | null, now: number, ttlMs: number): CacheSegment | null => {
142  if (!src) return null
143  const age = now - src.at
144  const left = ttlMs - age
145  const hit = src.hit === null ? '' : `hit ${src.hit}%`
146  if (src.isForfeited) return { color: COLD, isWarm: false, label: 'cold (model switch)', hit }
147  if (left <= 0) return { color: COLD, isWarm: false, label: `cold ${duration(age)} ago`, hit }
148  return { color: warmthColor(left, ttlMs), isWarm: true, label: `warm ${duration(left, true)}`, hit }
149}
150
151// Share of a request's input the cache served, as a whole percentage.
152export const hitPercent = (u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) => {
153  const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
154  return total > 0 ? Math.round((u.cache_read_input_tokens * 100) / total) : null
155}
156
157// Terminal cells a string takes: wide East Asian characters and emoji take 2,
158// combining marks, joiners and variation selectors none, everything else 1.
159// Ambiguous-width symbols (◆ ▓ ░ · …) and Nerd Font icons count as 1.
160const isZeroWidth = (c: number) =>
161  (c >= 0x300 && c <= 0x36f) || (c >= 0x200b && c <= 0x200d) ||
162  (c >= 0xfe00 && c <= 0xfe0f) || (c >= 0xe0100 && c <= 0xe01ef)
163
164const isWide = (c: number) =>
165  (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0x303e) || (c >= 0x3041 && c <= 0x33ff) ||
166  (c >= 0x3400 && c <= 0x4dbf) || (c >= 0x4e00 && c <= 0x9fff) || (c >= 0xa000 && c <= 0xa4cf) ||
167  (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) ||
168  (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1f64f) ||
169  (c >= 0x1f900 && c <= 0x1f9ff) || (c >= 0x20000 && c <= 0x3fffd)
170
171export const cellWidth = (s: string): number => {
172  let w = 0
173  for (const ch of s) {
174    const c = ch.codePointAt(0) ?? 0
175    w += isZeroWidth(c) ? 0 : isWide(c) ? 2 : 1
176  }
177  return w
178}
179
180// Cuts `s` to at most `max` cells, an ellipsis marking the cut at its end or
181// its start ("…ui-676": a branch's tail says the most).
182export const truncate = (s: string, max: number, from: 'end' | 'start' = 'end'): string => {
183  if (cellWidth(s) <= max) return s
184  if (max < 1) return ''
185  const chars = [...s]
186  if (from === 'start') chars.reverse()
187  const kept: string[] = []
188  let w = 1
189  for (const ch of chars) {
190    const cw = cellWidth(ch)
191    if (w + cw > max) break
192    kept.push(ch)
193    w += cw
194  }
195  return from === 'start' ? `…${kept.reverse().join('')}` : `${kept.join('')}…`
196}
197
198// The card as drawn: every string here is drawn verbatim, and `width` is
199// measured from these same strings, so the fit cannot drift from the paint.
200export type Layout = {
201  model: string
202  meter: { label: string; tokens: string; bar: BarRun[]; total: string; room: string; color: number }
203  cache: { label: string; warmth: string; hit: string; color: number } | null
204  git: { repoIcon: string; repo: string; branchIcon: string; branch: string } | null
205  width: number
206}
207
208export const REPO_ICON = '\uf07c'
209export const BRANCH_ICON = '\ue0a0'
210// Cells a trimmed repo or branch name keeps before it is dropped instead,
211// and a trimmed model name before nothing narrower is left.
212export const MIN_NAME = 12
213export const MIN_MODEL = 4
214// The bar's width: the least it shrinks to before other parts give way, the
215// least once only the model and meter are left, and the most it stretches to.
216// Under 2 cells a band the dividers crowd out the fill.
217export const MIN_BAR = 20
218export const NARROW_BAR = 10
219export const MAX_BAR = 40
220// The card's frame and padding, and the least room between two segments.
221const CHROME = 4
222const GAP = 3
223
224type Level = 'full' | 'trim' | 'off'
225type Step = {
226  repo: Level; branch: Level; model: 'full' | 'trim'
227  hit: boolean; room: boolean; cache: boolean; ctxLabel: boolean; bar: boolean
228  /** The bar's width at this step, and the most spare room stretches it to. */
229  barWidth: number
230  barGrowTo: number
231}
232type Names = { repo: string; branch: string; model: string }
233
234const ALL: Step = { repo: 'full', branch: 'full', model: 'full', hit: true, room: true, cache: true, ctxLabel: true, bar: true, barWidth: MIN_BAR, barGrowTo: MAX_BAR }
235
236// The order parts give way in, each step keeping what the last one dropped
237// dropped: the bar shrinks to MIN_BAR, then the repo, the cache hit rate and
238// the branch go (repo and branch trimmed first), the room note, the cache,
239// the bar down to NARROW_BAR, the meter's label, the bar, and last the model
240// name, trimmed. Only the full card and the model-and-meter card stretch the
241// bar, each no further than the step before it drew it, so a narrower
242// terminal never draws a longer bar: room a dropped part frees goes to the
243// spacing.
244const STEPS: Step[] = ([
245  {}, { repo: 'trim', barGrowTo: MIN_BAR }, { repo: 'off' }, { branch: 'trim' },
246  { branch: 'full', hit: false }, { branch: 'trim' }, { branch: 'off' },
247  { room: false }, { cache: false }, { barWidth: NARROW_BAR }, { ctxLabel: false, barGrowTo: NARROW_BAR }, { bar: false }, { model: 'trim' },
248] as Partial<Step>[]).reduce<Step[]>((steps, patch) => [...steps, { ...(steps.at(-1) ?? ALL), ...patch }], [])
249
250const compose = (s: StatusSegments, c: CacheSegment | null, st: Step, names: Names, barWidth = st.barWidth): Layout => {
251  const model = `◆ ${names.model}`
252  const meter = {
253    label: st.ctxLabel ? 'ctx ' : '',
254    tokens: st.bar ? `${s.meter.tokens} ` : s.meter.tokens,
255    bar: st.bar ? meterBar(s.meter.used, s.meter.cap, barWidth) : [],
256    total: st.bar && s.meter.total ? ` ${s.meter.total}` : '',
257    room: st.room && s.meter.room ? ` ${s.meter.room}` : '',
258    color: s.meter.color,
259  }
260  const cache = st.cache && c
261    ? { label: 'cache ', warmth: c.label, hit: st.hit && c.hit ? ` · ${c.hit}` : '', color: c.color }
262    : null
263  const git = s.git && s.git.branch && st.branch !== 'off'
264    ? {
265        repoIcon: st.repo === 'off' ? '' : `${REPO_ICON} `,
266        repo: st.repo === 'off' ? '' : `${names.repo}  `,
267        branchIcon: `${BRANCH_ICON} `,
268        branch: names.branch,
269      }
270    : null
271  const segments = [
272    model,
273    meter.label + meter.tokens + meter.bar.map(r => r.text).join('') + meter.total + meter.room,
274    ...(cache ? [cache.label + cache.warmth + cache.hit] : []),
275    ...(git ? [git.repoIcon + git.repo + git.branchIcon + git.branch] : []),
276  ]
277  const width = CHROME + segments.reduce((w, t) => w + cellWidth(t), 0) + GAP * (segments.length - 1)
278  return { model, meter, cache, git, width }
279}
280
281// The fullest card that fits in `columns` cells. A trimming step gives its
282// name exactly the room left, never under its minimum, so a wider terminal
283// never draws less than a narrower one. Spare room goes to the bar as far as
284// the step allows; the rest is spread between segments. Below the narrowest step's width the
285// narrowest is returned as it is.
286export const layout = (s: StatusSegments, c: CacheSegment | null, columns: number): Layout => {
287  const full: Names = { repo: s.git?.repo ?? '', branch: s.git?.branch ?? '', model: s.model }
288  for (const st of STEPS) {
289    const l = compose(s, c, st, full)
290    // Room to spare stretches the bar; the bar is back at its least before
291    // any later step trims or drops a part.
292    if (l.width <= columns) return st.bar ? compose(s, c, st, full, Math.min(st.barGrowTo, st.barWidth + columns - l.width)) : l
293    const key = st.repo === 'trim' ? 'repo' : st.branch === 'trim' ? 'branch' : st.model === 'trim' ? 'model' : null
294    if (!key) continue
295    const room = cellWidth(full[key]) - (l.width - columns)
296    if (room < (key === 'model' ? MIN_MODEL : MIN_NAME)) continue
297    const t = compose(s, c, st, { ...full, [key]: truncate(full[key], room, key === 'branch' ? 'start' : 'end') })
298    if (t.width <= columns) return t
299  }
300  return compose(s, c, STEPS.at(-1) ?? ALL, { ...full, model: truncate(full.model, MIN_MODEL) })
301}
302
types/index.d.ts 52 lines
1export type StatusMeter = {
2  /** 256-colour index of the band the used context is in, for the token count. */
3  color: number
4  /** Tokens used and the window they count against; the bar is drawn from these at the width the layout gives it. */
5  used: number
6  cap: number
7  /** Tokens used, in k: "450k". */
8  tokens: string
9  /** The window's size: "1M", "200k"; "" when unknown. */
10  total: string
11  /** Room before the real cap: "(550k left)", "(cap reached)" or "". */
12  room: string
13}
14
15export type StatusSegments = {
16  model: string
17  meter: StatusMeter
18  git: { repo: string; branch: string } | null
19}
20
21/** What the cache figures are computed from; kept across reloads. */
22export type CacheSource = {
23  /** When the main thread's last response arrived, ms since the epoch. */
24  at: number
25  /** Share of the last request's input the cache served, 0..100; null when unknown. */
26  hit: number | null
27  /** TTL the engine reported on a model switch; null until one did. */
28  ttl: '5m' | '1h' | null
29  /** A model switch forfeited the cache; cleared by the next response. */
30  isForfeited: boolean
31}
32
33export type CacheSegment = {
34  /** 256-colour index for the warmth label. */
35  color: number
36  isWarm: boolean
37  /** "warm 57m", "cold 12m ago" or "cold (model switch)". */
38  label: string
39  /** "hit 97%", or "" when unknown. */
40  hit: string
41}
42
43declare module 'claude-code' {
44  interface PluginState {
45    'context-statusline': {
46      line: StatusSegments | null
47      cacheSource: CacheSource | null
48      cache: CacheSegment | null
49    }
50  }
51}
52