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…

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 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
/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.
Opus 5.5.▌/▏ marks where a band starts. A band too narrow for a divider is marked by its colour alone.(550k left).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.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:
…ui-676) and then dropped.ctx label and the bar go.A narrower terminal never shows more than a wider one, and never a longer bar.
/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.
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.
hooks/register.tsx 181 lines1import { 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}
181hooks/format.ts 302 lines1// 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}
302types/index.d.ts 52 lines1export 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