A rich Claude Code status line drawn by a mod: model, effort, context gauge, request counter, cost and 5h/7d rate-limit gauges in the footer, a summary under…

A Claude Code plugin that draws a rich status line with a mod: code that runs inside Claude Code and draws into its interface, so it updates with the plugin and writes nothing to install itself.
Each figure sits where it reads best:
focus │ email │ branch ● │ Opus 5.5 │ xhigh │ ███ 25% ░░░ ↑49.5k/1.0M ⊞ │ ⟳ 12 │ $0.84 │ █ 6% ░ 2h16 │ █ 5% ░ 4d 17h
focus, memory paused), kept on the left↑input/window in the same colour, and ⊞, which opens the context pane⟳ N, the model requests of the main loop, on a green→red gradientBaking · ⟳ 12…Baked for 14s: ↑+12.3k ↓1.9k · ⟳ 4 · $0.12, what that turn added to the context, its output tokens, its requests and its cost. It is kept, and comes back after claude --resume.⊞ or by /ctx: the grid and categories of /context, kept live, from a local estimate. Détail counts them with the token-count API, as /context does, and adds the memory files, MCP servers, agents, skills and slash commands; that count is taken only on demand and kept until Rafraîchir.🟧 Contexte ≥ 100.0k, 🟥 Contexte ≥ 200.0k, 🟥 5h ≥ 80 %.Rate-limit gauges are green below 50%, yellow from 50–79%, and red at 80% and above. The context gauge and the request counter tier differently — see Session degradation.
When the terminal narrows, the line switches to compact forms (shorter gauges, O5.5, ↑49.5k), then drops, in this order: email, git, ⟳, effort, cost, time to reset, rate-limit gauges, model. The context gauge and ⊞ never go.
Claude Code v2.1.289 or later, in the terminal or the desktop app. Mods draw nothing in VS Code, in claude -p, or through the Agent SDK. The desktop app has no turn summary, and draws the line in the band above the prompt, its gauges as bars, leaving the model and the effort to its own footer.
Install and enable the plugin; the line appears at once. Disabling or uninstalling the plugin removes it.
Settings are the plugin's options, in /plugin (Installed → statusline → configure) or under pluginConfigs in ~/.claude/settings.json:
| Option | Default | Effect |
|---|---|---|
ctx_warn | 100000 | input tokens at which the context gauge turns yellow and a toast fires |
ctx_crit | 200000 | input tokens at which it turns red; also the gauge's full scale |
turns_min | 30 | the request counter stays green up to this |
turns_max | 250 | it is fully red from this |
show_email | false | show the signed-in account's email |
show_git | false | show the branch and a dot when the tree has changes |
Nothing to do. Earlier versions put a statusLine entry in ~/.claude/settings.json; at the first session start the plugin removes it, leaving every other setting as it was, and says so in a toast. It does that only when the entry is provably one it wrote: 2.0.0's guarded command, or the 1.x path with a renderer this plugin shipped, or nothing, behind it. Any other statusLine is yours and stays; if it looks like a status line of this kind, a toast mentions it once.
Two leftovers are harmless and can be deleted by hand: ~/.claude/statusline-plugin.json, and on 1.x installs ~/.claude/statusline-command.sh.
Two segments track how far a session has drifted from its best behaviour: the context gauge and the turn counter. They measure different things and are meant to be read side by side, because they call for different remedies — a full context calls for /compact, a long loop calls for a fresh session.
⟳ N)N counts the model requests of the main loop since the session started (or since the last /clear): one API request per agent-loop iteration — a model call plus the tool calls it triggers. Sub-agent work is excluded.
It is not a count of your prompts. Every prompt costs at least one model call so N does rise by one or more when you send one, but one instruction that fans out into forty tool calls moves it forty times as far as ten short exchanges do. That gap is the point of the segment: a session that looks young in conversation can already be deep in the loop.
There is no bar and no alarm, and that is deliberate:
⟳ N as a hint that something may be going wrong, never as a measurement of what is.The anchors are product choices, not research results. The low anchor of 30 is the measured mean number of LLM invocations per trajectory for the strongest frontier model on SWE-bench Verified (30.71), so green means "within the range where models solve hard tasks". The high anchor of 250 puts saturation past the quartile where coherence collapse triples.
Two published thresholds are deliberately not used. [LoCoBench-Agent][lcb]'s 12–15 turns is read off a six-point scatter plot with no confidence intervals, against the paper's own composite efficiency metric rather than task success. The ~60 rounds of [CaT][cat] is a context-exhaustion point for a 32B model with a 64k window and no context management at all — a token limit wearing a turn-shaped label. Both fire near-permanently on real Claude Code sessions.
The bar reads on the absolute token scale, 0 → CTX_CRIT, not as a fraction of the model's advertised window. Degradation tracks absolute tokens ([NoLiMa][nl]: 11 of 13 models advertising ≥128K fall below half their short-context baseline by 32K), and "% of advertised window" has no primary backing as a degradation metric — on a 1M-token window it would show a near-empty bar well past the danger zone.
So the percentage inside the bar is the position on that absolute scale, not the share of the window. The window share is still there, stated literally by ↑input/window right beside it: ↑150.0k/1.0M next to a 75% bar means 150k tokens used, three-quarters of the way to the 200k mark, on a 1M window.
Both token tiers are numbers we picked. The 200k red tier matches the threshold Claude Code itself uses for exceeds_200k_tokens, but that flag is not read: the harness derives it from this same token count against a hardcoded 200000, so honouring it would silently cap ctx_crit and paint a quarter-full bar red on a tuned 1M window. The yellow tier at 100k has no source at all — it is half the red tier, chosen because the one citable alternative (32K) would leave nearly every real session permanently yellow.
The four anchors are the ctx_warn, ctx_crit, turns_min and turns_max options above. Since the defaults are judgement calls, calibrating them on your own sessions is expected rather than exceptional.
⟳ N restarts from zero on --resume, and when the mod reloads: it counts the requests it has seen, and nothing reports the earlier ones.[cc]: https://arxiv.org/html/2603.24631v2 [lcb]: https://arxiv.org/abs/2511.13998 [cat]: https://arxiv.org/abs/2512.22087 [nl]: https://arxiv.org/html/2502.05167v3
The mod is hooks/register.ts; what it shows and how the line shrinks is the pure hooks/line.ts, and the removal of earlier releases' entry is hooks/migrate.ts. Where each figure goes, and the engine facts the code works around, are in ADR 0002.
Load the checkout with --plugin-dir; saving a file reloads the mod in the running session. Silence a 2.x line still installed with --settings:
touch .statusline-dev-hold # see below
claude --settings '{"statusLine":{"type":"command","command":"true"}}' --plugin-dir .
Loading the checkout also writes the engine's type declarations to .claude-plugin/types/ (gitignored), which tsconfig.json extends:
claude plugin validate . # what the engine reads from the module
claude plugin test # tests/*.test.ts, no session needed
npx -p typescript tsc -p . # once a session has written the types
The hand brake. At session start the mod removes a 2.x or 1.x statusLine entry from ~/.claude/settings.json. Loaded from a checkout — with --plugin-dir, or with the repository added as its own marketplace for an acceptance run — it would remove yours. An empty .statusline-dev-hold at the root of the checkout stops that; it is gitignored and only its existence is read. Remove it to test the migration as a user gets it.
hooks/register.ts 322 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 type Alerted, type Run, type Thresholds, type TurnSummary,
4 type Piece, alerts, alertedFor, barSvg, ctxTier, fit, rampColor, summaryRuns, thirds,
5} from './line.js'
6import { dockedColumns, registerContextPane } from './context-pane.js'
7import { classify, sha256, withoutStatusLine } from './migrate.js'
8
9// Where each figure goes, and why, is ADR 0002. The engine facts this module
10// works around are recorded there too.
11
12
13type Measured = {
14 tokens: number
15 window?: number
16 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
17 costUsd?: number
18}
19
20const DEFAULTS = { ctx_warn: 100000, ctx_crit: 200000, turns_min: 30, turns_max: 250, show_email: false, show_git: false }
21// No event reports the permission mode, so the footer's label is reserved at
22// its widest: `⏵⏵ bypass permissions on`.
23const MODE_LABEL_MAX = 24
24const PRUNE_MS = 30 * 86400000
25
26let cfg: Thresholds & { show_email: boolean; show_git: boolean } = DEFAULTS
27let measured: Measured = { tokens: 0, rateLimits: [] }
28let effort: string | number | undefined
29let requests = 0
30let email: string | null = null
31let git: { branch: string; dirty: boolean } | null = null
32let alerted: Alerted = { ctx: 0, five_hour: false, seven_day: false }
33let turn: { ctx0: number; cost0: number; steps: number; out: number } | null = null
34// A finished turn's summary waits under its duration until its line is drawn,
35// then lives under that line's id, which survives a resume.
36const pending = new Map<number, TurnSummary>()
37let summaries: Record<string, TurnSummary> = {}
38let storeKey = ''
39let hintWidth = 0
40let hasModeLabel = false
41
42const fromUsage = (u: Awaited<ReturnType<EngineInterface['session']['usage']>>): Measured => ({
43 tokens: u.context.tokens ?? 0, window: u.context.window, rateLimits: u.rateLimits, costUsd: u.cost?.usd,
44})
45
46/** Takes a new measurement, toasting the thresholds it crosses upward. */
47function remeasure($: EngineInterface, m: Measured): void {
48 measured = m
49 const a = alerts(measured, alerted, cfg)
50 alerted = a.now
51 if (a.text) $.ui.toast(a.text, { timeoutMs: 8000 })
52 $.ui.invalidate('ui.render')
53}
54
55async function home($: EngineInterface): Promise<string | undefined> {
56 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
57}
58
59async function readEmail($: EngineInterface): Promise<void> {
60 const dir = cfg.show_email ? await home($) : undefined
61 if (!dir) return
62 try {
63 email = JSON.parse(await $.fs.read(dir + '/.claude.json'))?.oauthAccount?.emailAddress ?? null
64 } catch {
65 email = null
66 }
67}
68
69async function readGit($: EngineInterface): Promise<void> {
70 if (!cfg.show_git) return
71 try {
72 const branch = await $.process.run(['git', 'branch', '--show-current'])
73 if (branch.exitCode !== 0) return void (git = null)
74 const status = await $.process.run(['git', 'status', '--porcelain'])
75 git = { branch: branch.stdout.trim() || 'HEAD', dirty: status.stdout.trim() !== '' }
76 } catch {
77 git = null
78 }
79}
80
81/**
82 * Before the first request: the model's own setting, else the session's.
83 * `/effort` keys modelSettings by the exact model id, so `claude-opus-5` is
84 * another model than `claude-opus-5-5`, not a prefix of it.
85 */
86async function readEffort($: EngineInterface): Promise<void> {
87 try {
88 const s = (await $.settings.read()) as { effortLevel?: string; modelSettings?: Record<string, { effortLevel?: string }> }
89 const model = (await $.session.model()).replace(/\[.*\]$/, '')
90 effort = s.modelSettings?.[model]?.effortLevel || s.effortLevel || effort
91 } catch {}
92}
93
94async function loadSummaries($: EngineInterface): Promise<void> {
95 storeKey = 'turns:' + (await $.session.id())
96 summaries = ((await $.store.get(storeKey)) as { turns?: Record<string, TurnSummary> } | undefined)?.turns ?? {}
97}
98
99async function pruneSummaries($: EngineInterface, now: number): Promise<void> {
100 for (const key of await $.store.keys()) {
101 if (!key.startsWith('turns:') || key === storeKey) continue
102 const at = ((await $.store.get(key)) as { at?: number } | undefined)?.at ?? 0
103 if (now - at > PRUNE_MS) await $.store.delete(key)
104 }
105}
106
107/**
108 * Removes the statusLine entry an earlier release wrote, under ADR 0001's
109 * proofs. A checkout carrying `.statusline-dev-hold` never does: loaded with
110 * --plugin-dir it would strip the author's working entry.
111 */
112async function migrate($: EngineInterface): Promise<void> {
113 if (await $.fs.exists($.plugin.root + '/.statusline-dev-hold')) return
114 const dir = await home($)
115 if (!dir) return
116 const settingsPath = dir + '/.claude/settings.json', legacyPath = dir + '/.claude/statusline-command.sh'
117 let text: string
118 try {
119 text = await $.fs.read(settingsPath)
120 } catch {
121 return
122 }
123 let command: string | undefined
124 try {
125 command = JSON.parse(text)?.statusLine?.command
126 } catch {
127 return
128 }
129 const legacySha = (await $.fs.exists(legacyPath)) ? await sha256(await $.fs.read(legacyPath)) : null
130 const owner = classify(command, legacyPath, legacySha)
131 if (owner === 'none') return
132 if (owner === 'foreign') {
133 // Said once per distinct entry: it may well be wanted beside this line.
134 const seen = 'foreign:' + (await sha256(command ?? ''))
135 if (await $.store.get(seen)) return
136 await $.store.set(seen, true)
137 if (command?.includes('statusline')) {
138 $.ui.toast('settings.json garde une statusLine qui doublonne peut-être cette ligne : retirez-la si besoin', { timeoutMs: 12000 })
139 }
140 return
141 }
142 const rewritten = withoutStatusLine(text)
143 if (rewritten === null) return
144 await $.fs.write(settingsPath, rewritten)
145 $.ui.toast(`statusLine de la version ${owner === 'guarded' ? '2.0.0' : '1.x'} retirée de settings.json`, { timeoutMs: 8000 })
146}
147
148/** The columns SessionMode has: what the footer's indent, mode label and hint leave. */
149const available = (columns: number): number => columns - 2 - (hasModeLabel ? MODE_LABEL_MAX + 1 : 0) - hintWidth - 2
150
151const style = (r: Run) => ({
152 ...(r.dim ? { dimColor: true as const } : {}),
153 ...(r.color ? { color: r.color } : {}),
154 ...(r.bg ? { backgroundColor: r.bg } : {}),
155 ...(r.bold ? { bold: true as const } : {}),
156})
157
158async function figures($: EngineInterface) {
159 return {
160 model: await $.session.model(), effort, email: cfg.show_email ? email : null, git: cfg.show_git ? git : null,
161 tokens: measured.tokens, window: measured.window, requests, costUsd: measured.costUsd,
162 rateLimits: measured.rateLimits, now: await $.clock.now(),
163 }
164}
165
166type Leaves = Pick<ReturnType<EngineInterface['ui']['resolve']>, 'Text' | 'Button'>
167
168/** A run as a Text, or as the Button running the slash command it names. */
169const run = ($: EngineInterface, { Text, Button }: Leaves, r: Run) => r.press
170 ? Button({ key: 'press:' + r.press, plain: true, label: r.text, ...(r.dim ? { dimColor: true } : {}), onPress: () => void $.command.run({ command: r.press! }).catch(() => {}) })
171 : Text({ ...style(r), children: [r.text] })
172
173export const register: Register = (on, options) => {
174 cfg = { ...DEFAULTS, ...(options as Partial<typeof DEFAULTS>) }
175 registerContextPane(on)
176
177 on('session.start', async ($, e, next) => {
178 measured = fromUsage(await $.session.usage())
179 alerted = alertedFor(measured, cfg)
180 await loadSummaries($)
181 await Promise.all([readEmail($), readGit($), readEffort($)])
182 // The engine pushes every gauge's change; only the time-left countdowns need a clock
183 $.clock.every(30000, () => $.ui.invalidate('ui.render'))
184 $.ui.invalidate('ui.render')
185 await pruneSummaries($, await $.clock.now())
186 await migrate($)
187 // Served by context-pane.ts, the pane's module, which the footer's ⊞ runs
188 await $.command.register({ name: 'ctx', description: 'Ouvre ou ferme le pane du contexte (grille de /context)', immediate: true })
189 return next(e)
190 })
191
192 // /clear and /resume reach a mod only as session.end: no classic.* event does
193 on('session.end', async ($, e, next) => {
194 if (e.reason === 'clear' || e.reason === 'resume') {
195 requests = 0
196 pending.clear()
197 turn = null
198 // usage() keeps the old window until the next response
199 if (e.reason === 'clear') {
200 measured = { ...measured, tokens: 0 }
201 alerted = { ...alerted, ctx: 0 }
202 }
203 $.ui.invalidate('ui.render')
204 $.clock.after(500, async () => {
205 await loadSummaries($)
206 $.ui.invalidate('ui.render')
207 })
208 }
209 return next(e)
210 })
211
212 on('session.measure', async ($, e, next) => {
213 remeasure($, { tokens: e.context.tokens ?? 0, window: e.context.window, rateLimits: e.rateLimits, costUsd: e.cost?.usd })
214 return next(e)
215 })
216
217 on('turn.start', async ($, e, next) => {
218 const u = await $.session.usage()
219 turn = { ctx0: u.context.tokens ?? 0, cost0: u.cost?.usd ?? 0, steps: 0, out: 0 }
220 return next(e)
221 })
222
223 on('turn.step', async function* ($, e, next) {
224 const main = e.agentId == null
225 if (main) {
226 effort = e.effort ?? effort
227 requests++
228 if (turn) turn.steps++
229 $.ui.invalidate('ui.render')
230 }
231 const result = yield* next(e)
232 if (main && result.usage) {
233 const u = result.usage
234 if (turn) turn.out += u.output_tokens
235 // session.measure fires only at a turn's end: the fill moves with each response
236 remeasure($, { ...measured, tokens: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens })
237 }
238 return result
239 })
240
241 on('turn.complete', async ($, e, next) => {
242 const out = await next(e)
243 if (e.agentId == null && turn) {
244 const u = await $.session.usage()
245 const ctx1 = u.context.tokens ?? turn.ctx0
246 pending.set(e.durationMs, {
247 dCtx: ctx1 - turn.ctx0, tier: ctxTier(ctx1, cfg), out: turn.out, steps: turn.steps,
248 dCost: (u.cost?.usd ?? 0) - turn.cost0,
249 })
250 turn = null
251 await readGit($)
252 $.ui.invalidate('ui.render')
253 }
254 return out
255 })
256
257 // The permanent line, right of the footer; the engine's own modes stay on its left.
258 // The desktop's footer is too narrow for it: there it is the band above the prompt.
259 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
260 if (e.surface === 'desktop') return next(e)
261 const { Box, Text, Button } = $.ui.resolve(e)
262 const modes = e.props.modes.length ? [await next(e), Text({ dimColor: true, children: [' │ '] })] : []
263 const modesWidth = e.props.modes.length ? e.props.modes.join(' & ').length + 3 : 0
264 // The footer spans the docked pane too, though its viewport stops at the transcript
265 const docked = (await $.ui.panes()).some(p => p.isShown) ? dockedColumns() : 0
266 const runs = fit(await figures($), cfg, available(e.viewport?.columns ?? 120) + docked - modesWidth)
267 return Box({ flexDirection: 'row', flexWrap: 'nowrap', children: [...modes, ...runs.map(r => run($, { Text, Button }, r))] })
268 })
269
270 // The desktop's line: its footer shows the model and the effort, and only the
271 // band draws an Svg beside Texts, so its gauges are drawings
272 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
273 if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
274 const els = $.ui.resolve(e), { Box, Text, Button } = els
275 if (!('Svg' in els)) return next(e)
276 const { left, centre, right } = thirds(await figures($), cfg, e.props.bodyColumns, { bars: true, model: false })
277 const draw = (p: Piece) => ('bar' in p ? els.Svg({ ...barSvg(p.bar), alt: p.bar.alt }) : run($, { Text, Button }, p))
278 // The sides grow from nothing alike, so the centre is the band's
279 const side = (ps: Piece[], justifyContent: 'flex-start' | 'flex-end') =>
280 Box({ flexDirection: 'row', flexWrap: 'nowrap', alignItems: 'center', width: 0, flexGrow: 1, justifyContent, children: ps.map(draw) })
281 return Box({ flexDirection: 'row', flexWrap: 'nowrap', alignItems: 'center', columnGap: 2, children: [
282 side(left, 'flex-start'),
283 Box({ flexDirection: 'row', flexShrink: 0, alignItems: 'center', children: centre.map(draw) }),
284 side(right, 'flex-end'),
285 ] })
286 })
287
288 // Passed through: measured only, because SessionMode shares its row
289 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
290 // The hint offers shift+tab only while a mode label is drawn before it
291 const label = e.props.hint.includes('shift+tab')
292 if (e.props.hint.length !== hintWidth || label !== hasModeLabel) {
293 hintWidth = e.props.hint.length
294 hasModeLabel = label
295 $.ui.invalidate('ui.render')
296 }
297 return next(e)
298 })
299
300 // Live request count while Claude works; the suffix is plain text
301 on('ui.render', { component: 'Spinner' }, async ($, e, next) =>
302 requests ? next({ ...e, props: { ...e.props, suffix: ` · ⟳ ${requests}` + e.props.suffix } }) : next(e))
303
304 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
305 let s = summaries[e.requestId]
306 const waiting = pending.get(e.props.durationMs)
307 if (!s && waiting) {
308 s = summaries[e.requestId] = waiting
309 pending.delete(e.props.durationMs)
310 await $.store.set(storeKey, { at: await $.clock.now(), turns: summaries })
311 }
312 if (!s) return next(e)
313 const { Box, Text } = $.ui.resolve(e)
314 const theirs = await next(e)
315 const runs = summaryRuns(s, rampColor(s.steps, cfg))
316 return Box({ flexDirection: 'row', columnGap: 1, children: [
317 theirs,
318 Box({ flexDirection: 'row', flexShrink: 0, children: runs.map(r => Text({ ...style(r), children: [r.text] })) }),
319 ] })
320 })
321}
322hooks/line.ts 224 lines1// What the permanent line shows and how it shrinks. Pure: no engine, no clock,
2// so every rule here is unit-tested without a session.
3
4export type Thresholds = { ctx_warn: number; ctx_crit: number; turns_min: number; turns_max: number }
5
6/**
7 * One styled stretch of text; the engine draws each as a Text, or as a Button
8 * running the slash command `press` names, which draws no colour but `dim`.
9 */
10export type Run = { text: string; color?: string; bg?: string; bold?: boolean; dim?: boolean; press?: string }
11
12export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
13
14export type Figures = {
15 model: string
16 effort?: string | number
17 email?: string | null
18 git?: { branch: string; dirty: boolean } | null
19 /** Input tokens of the live window; 0 before its first response. */
20 tokens: number
21 window?: number
22 requests: number
23 costUsd?: number
24 rateLimits: readonly RateLimit[]
25 now: number
26}
27
28// 2.0.0's palette: named colours follow the terminal's theme, the gauges keep
29// their 256-colour codes as hex.
30const GAUGE_BG = ['#008700', '#af8700', '#af0000'] // 28, 136, 124
31const GAUGE_FG = '#eeeeee' // 255
32const EMPTY_BG = '#444444' // 238
33const EMPTY_FG = '#bcbcbc' // 250
34const TIER_FG = ['green', 'yellow', 'red']
35// 34 70 106 148 184 220 214 208 202 196
36const RAMP = ['#00af00', '#5faf00', '#87af00', '#afd700', '#d7d700', '#ffd700', '#ffaf00', '#ff8700', '#ff5f00', '#ff0000']
37
38/** Dropped one by one, in this order, once the compact forms no longer fit. */
39export const DROP_ORDER = ['email', 'git', 'loops', 'effort', 'cost', 'times', 'rates', 'model'] as const
40type Drop = (typeof DROP_ORDER)[number]
41
42export const tokens = (n: number): string =>
43 n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(Math.round(n))
44
45/** The context tiers on absolute tokens, not on the window: degradation tracks tokens. */
46export const ctxTier = (n: number, t: Thresholds): number => (n >= t.ctx_crit ? 2 : n >= t.ctx_warn ? 1 : 0)
47export const rateTier = (pct: number): number => (pct >= 80 ? 2 : pct >= 50 ? 1 : 0)
48export const tierColor = (tier: number): string => TIER_FG[tier] ?? 'red'
49
50export function rampColor(n: number, t: Thresholds): string {
51 const x = t.turns_max <= t.turns_min ? 1 : Math.min(1, Math.max(0, (n - t.turns_min) / (t.turns_max - t.turns_min)))
52 return RAMP[Math.round(x * 9)] ?? RAMP[9]!
53}
54
55/** `claude-opus-5-5[1m]` → `Opus 5.5`, or `O5.5` compact; anything else as given. */
56export function modelName(id: string, compact: boolean): string {
57 const m = /claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
58 if (!m) return id
59 const family = m[1]![0]!.toUpperCase() + m[1]!.slice(1)
60 return compact ? `${family[0]}${m[2]}.${m[3]}` : `${family} ${m[2]}.${m[3]}`
61}
62
63/** `4d 19h`, `2h14`, `24m`, or '' once past. */
64export function timeLeft(iso: string | undefined, now: number): string {
65 const at = iso ? Date.parse(iso) : NaN
66 if (Number.isNaN(at) || at <= now) return ''
67 const s = Math.floor((at - now) / 1000)
68 const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
69 return d ? `${d}d ${h}h` : h ? `${h}h${String(m).padStart(2, '0')}` : `${m}m`
70}
71
72/** A bar whose fill is the background, with the percentage centred on it. */
73export function gauge(pct: number, tier: number, cells: number): Run[] {
74 const p = Math.min(100, Math.max(0, Math.round(pct)))
75 const label = `${p}%`, filled = Math.round((p / 100) * cells), start = Math.floor((cells - label.length) / 2)
76 const runs: Run[] = []
77 for (let i = 0; i < cells; i++) {
78 const ch = i >= start && i < start + label.length ? label[i - start]! : ' '
79 const fill = i < filled
80 const last = runs[runs.length - 1]
81 if (last && (last.bg === GAUGE_BG[tier]) === fill) last.text += ch
82 else runs.push(fill ? { text: ch, color: GAUGE_FG, bg: GAUGE_BG[tier], bold: true } : { text: ch, color: EMPTY_FG, bg: EMPTY_BG })
83 }
84 return runs
85}
86
87/** A gauge drawn whole, as the desktop's Svg, `cells` wide: its runs would be split apart there. */
88export type Bar = { bar: { pct: number; tier: number; cells: number; alt: string } }
89export type Piece = Run | Bar
90
91/**
92 * How a surface draws the line: `bars`, gauges as drawings; `model`, false where
93 * the surface shows the model and the effort itself.
94 */
95export type Style = { bars: boolean; model: boolean }
96const TERMINAL: Style = { bars: false, model: true }
97
98type Segment = { id: Exclude<Drop, 'times' | 'rates'> | 'ctx' | 'rate'; runs: Piece[]; time?: Run[] }
99
100function segments(f: Figures, t: Thresholds, compact: boolean, st: Style): Segment[] {
101 const cells = compact ? 5 : 13, out: Segment[] = []
102 const meter = (pct: number, tier: number, alt: string): Piece[] => (st.bars ? [{ bar: { pct, tier, cells, alt } }] : gauge(pct, tier, cells))
103 if (f.email) out.push({ id: 'email', runs: [{ text: f.email, color: 'cyan' }] })
104 if (f.git) out.push({ id: 'git', runs: [{ text: f.git.branch, color: 'magenta' }, ...(f.git.dirty ? [{ text: ' ●', color: 'yellow' }] : [])] })
105 if (st.model) out.push({ id: 'model', runs: [{ text: modelName(f.model, compact), color: 'blue' }] })
106 if (st.model && f.effort != null) out.push({ id: 'effort', runs: [{ text: String(f.effort), color: 'yellow' }] })
107 const ct = ctxTier(f.tokens, t)
108 const io = ' ↑' + tokens(f.tokens) + (compact || !f.window ? '' : '/' + tokens(f.window))
109 // A glyph of its own opens /context's pane: a Button would take the gauge's colours
110 out.push({ id: 'ctx', runs: [
111 ...meter((f.tokens / t.ctx_crit) * 100, ct, `contexte ${tokens(f.tokens)}`), { text: io, color: tierColor(ct) }, { text: ' ' }, { text: '⊞', dim: true, press: 'ctx' },
112 ] })
113 if (f.requests) out.push({ id: 'loops', runs: [{ text: `⟳ ${f.requests}`, color: rampColor(f.requests, t) }] })
114 if (f.costUsd != null) out.push({ id: 'cost', runs: [{ text: '$' + f.costUsd.toFixed(2), color: 'green' }] })
115 for (const kind of ['five_hour', 'seven_day']) {
116 const r = f.rateLimits.find(x => x.kind === kind)
117 if (!r) continue
118 const tier = rateTier(Math.round(r.percentUsed)), left = timeLeft(r.resetsAt, f.now)
119 const alt = `${kind === 'five_hour' ? '5h' : '7j'} ${Math.round(r.percentUsed)} %`
120 out.push({ id: 'rate', runs: meter(r.percentUsed, tier, alt), time: left ? [{ text: ' ' + left, color: tierColor(tier) }] : [] })
121 }
122 return out
123}
124
125/** The kept segments, each with its pieces as drawn. */
126function keep(list: Segment[], dropped: ReadonlySet<Drop>): { id: Segment['id']; pieces: Piece[] }[] {
127 return list.filter(s => !dropped.has((s.id === 'rate' ? 'rates' : s.id) as Drop))
128 .map(s => ({ id: s.id, pieces: [...s.runs, ...(s.time && !dropped.has('times') ? s.time : [])] }))
129}
130
131const join = (parts: { pieces: Piece[] }[]): Piece[] =>
132 parts.flatMap((s, i) => (i ? [{ text: ' │ ', dim: true }, ...s.pieces] : s.pieces))
133
134const assemble = (list: Segment[], dropped: ReadonlySet<Drop>): Piece[] => join(keep(list, dropped))
135
136export const width = (runs: readonly Piece[]): number => runs.reduce((n, r) => n + ('bar' in r ? r.bar.cells : [...r.text].length), 0)
137
138/**
139 * The line for `avail` columns: full forms, then compact forms, then segments
140 * dropped in DROP_ORDER. The context gauge, ↑input and ⊞ are never dropped, so the
141 * result can still exceed a very small `avail`.
142 */
143export function fit(f: Figures, t: Thresholds, avail: number, st: Style & { bars: true }): Piece[]
144export function fit(f: Figures, t: Thresholds, avail: number, st?: Style & { bars: false }): Run[]
145export function fit(f: Figures, t: Thresholds, avail: number, st: Style = TERMINAL): Piece[] {
146 return join(choose(f, t, avail, st))
147}
148
149function choose(f: Figures, t: Thresholds, avail: number, st: Style) {
150 const full = segments(f, t, false, st)
151 if (width(assemble(full, new Set())) <= avail) return keep(full, new Set())
152 const compact = segments(f, t, true, st), dropped = new Set<Drop>()
153 for (const d of DROP_ORDER) {
154 if (width(assemble(compact, dropped)) <= avail) break
155 dropped.add(d)
156 }
157 return keep(compact, dropped)
158}
159
160/**
161 * The line fitted as `fit` does, split for a band: the context and what
162 * precedes it on the left, the cost in the centre, the rate limits on the right.
163 */
164export function thirds(f: Figures, t: Thresholds, avail: number, st: Style): { left: Piece[]; centre: Piece[]; right: Piece[] } {
165 const kept = choose(f, t, avail, st)
166 return {
167 left: join(kept.filter(s => s.id !== 'cost' && s.id !== 'rate')),
168 centre: join(kept.filter(s => s.id === 'cost')),
169 right: join(kept.filter(s => s.id === 'rate')),
170 }
171}
172
173const BAR_H = 14
174/** A bar's width in pixels for its `cells`: wide enough for `100%` when compact. */
175export const barWidth = (cells: number): number => Math.max(40, Math.round(cells * 6.5))
176
177/** The bar's markup: the fill in its tier's colour, the percentage centred, legible on either theme. */
178export function barSvg(b: Bar['bar']): { source: string; width: number; height: number } {
179 const w = barWidth(b.cells), p = Math.min(100, Math.max(0, Math.round(b.pct))), fill = Math.round((p / 100) * w)
180 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${BAR_H}" viewBox="0 0 ${w} ${BAR_H}">`
181 + `<rect width="${w}" height="${BAR_H}" rx="3" fill="rgba(128,128,128,0.25)"/>`
182 + (fill ? `<rect width="${fill}" height="${BAR_H}" rx="3" fill="${GAUGE_BG[b.tier]}"/>` : '')
183 + `<text x="${w / 2}" y="${BAR_H / 2}" dy="0.35em" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" font-weight="700"`
184 + ` fill="#ffffff" stroke="rgba(0,0,0,0.45)" stroke-width="2" paint-order="stroke">${p}%</text></svg>`
185 return { source, width: w, height: BAR_H }
186}
187
188export type TurnSummary = { dCtx: number; tier: number; out: number; steps: number; dCost: number }
189
190/** `↑+12.3k ↓1.9k · ⟳ 4 · $0.12`, drawn beside the engine's turn-duration line. */
191export function summaryRuns(s: TurnSummary, rampAt: string): Run[] {
192 const sign = s.dCtx >= 0 ? '+' : '−'
193 return [
194 { text: `↑${sign}${tokens(Math.abs(s.dCtx))}`, color: tierColor(s.tier) },
195 { text: ` ↓${tokens(s.out)}` },
196 { text: ' · ', dim: true },
197 { text: `⟳ ${s.steps}`, color: rampAt },
198 { text: ' · ', dim: true },
199 { text: '$' + s.dCost.toFixed(2), color: 'green' },
200 ]
201}
202
203export type Usage = { tokens: number; rateLimits: readonly RateLimit[] }
204export type Alerted = { ctx: number; five_hour: boolean; seven_day: boolean }
205
206/**
207 * The toast for thresholds crossed upward since `was`, or '' when none, and the
208 * state to remember. Crossings of one measurement share one toast: two toasts
209 * raised in the same tick show only the first.
210 */
211export function alerts(u: Usage, was: Alerted, t: Thresholds): { text: string; now: Alerted } {
212 const parts: string[] = [], tier = ctxTier(u.tokens, t)
213 if (tier > was.ctx) parts.push(tier === 2 ? `🟥 Contexte ≥ ${tokens(t.ctx_crit)}` : `🟧 Contexte ≥ ${tokens(t.ctx_warn)}`)
214 const now: Alerted = { ctx: tier, five_hour: false, seven_day: false }
215 for (const kind of ['five_hour', 'seven_day'] as const) {
216 now[kind] = (u.rateLimits.find(r => r.kind === kind)?.percentUsed ?? 0) >= 80
217 if (now[kind] && !was[kind]) parts.push(`🟥 ${kind === 'five_hour' ? '5h' : '7d'} ≥ 80 %`)
218 }
219 return { text: parts.join(' · '), now }
220}
221
222/** Mutes the thresholds already crossed, so a start or a reload raises no toast. */
223export const alertedFor = (u: Usage, t: Thresholds): Alerted => alerts(u, { ctx: 2, five_hour: true, seven_day: true }, t).now
224hooks/context-pane.ts 164 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { ContextBreakdown, ContextFull, ContextPane } from '../types'
4import { type Figure, type Line, type Span, details, grid, gridSvg, legend, timeOfDay, tokens } from './context-view.js'
5
6// The pane /context would be, toggled by /ctx, which the mod's session.start
7// registers (an event takes one unmatched hook per plugin), and by a press on
8// the footer's `press:ctx` Button. A plugin's own $.command.run skips its own
9// hooks, so the press is taken here, before that Button's fallback runs /ctx.
10// The summary is measured as the pane draws: the mod's invalidate on each
11// measure keeps it live.
12
13const PANE = 'context'
14// The details' rows span the pane: labels take what the numbers leave, numbers right-aligned
15const NUM_W = 7
16// The legend's figures: `863.4k` and `(100.0 %)`, each behind a gap; and its squares' room
17const FIGURE_W: Record<Figure, number> = { tokens: 7, share: 10 }, SQUARE_W = 2
18// The width from which /context draws its full grid, 10×10 (20×10 on a 1M window)
19const GRID_COLUMNS = 80
20
21// What the docked pane takes from the footer's row, its border included: the
22// footer's viewport, like the pane's own, is the transcript's width alone.
23let docked = 0
24export const dockedColumns = (): number => docked
25const pane = atom({ plugin: 'statusline', key: 'ctxPane' } as const, { view: 'summary', full: { status: 'none' } } as ContextPane)
26// Past this, a count shows itself under way; quicker, the pane draws once, with its result
27const BUSY_AFTER_MS = 300
28const unfolded = atom({ plugin: 'statusline', key: 'ctxUnfolded' } as const, [] as string[])
29
30/**
31 * Opens the full view on a fresh count. The desktop keeps showing an earlier
32 * tree of a quick run of redraws, whose presses then miss, so this writes once.
33 */
34async function measureFull($: EngineInterface, columns: number): Promise<void> {
35 let done = false
36 // The count on screen stays while the next is taken: the estimate would flash in between
37 $.clock.after(BUSY_AFTER_MS, () => void (done || update($, pane, (p): ContextPane => ({
38 view: 'full',
39 full: { status: 'busy', previous: p.full.status === 'ready' ? { at: p.full.at, breakdown: p.full.breakdown } : p.full.status === 'busy' ? p.full.previous : undefined },
40 }))))
41 let next: ContextFull
42 try {
43 const b = (await $.session.usage({ breakdown: 'full', columns })).context.breakdown
44 next = b ? { status: 'ready', at: await $.clock.now(), breakdown: b } : { status: 'failed', reason: 'aucune session liée' }
45 } catch (err) {
46 next = { status: 'failed', reason: err instanceof Error ? err.message : String(err) }
47 }
48 done = true
49 await update($, pane, (): ContextPane => ({ view: 'full', full: next }))
50}
51
52const style = (s: Span) => ({
53 ...(s.color ? { color: s.color } : {}),
54 ...(s.dim ? { dimColor: true as const } : {}),
55 ...(s.bold ? { bold: true as const } : {}),
56})
57
58/** Every opening starts on the summary: a full count kept from earlier may be old. */
59async function toggle($: EngineInterface, surface?: string): Promise<void> {
60 if ((await $.ui.panes()).some(p => p.id === PANE)) return $.ui.close({ id: PANE })
61 await update($, pane, (p): ContextPane => ({ ...p, view: 'summary' }))
62 // Opened unfocused, the desktop's first press refocuses it, and misses for that redraw.
63 // The terminal's prompt keeps its keys.
64 await $.ui.open({ id: PANE, title: 'Contexte', ...(surface === 'desktop' ? { focus: true as const } : {}) })
65}
66
67export function registerContextPane(on: Parameters<Register>[0]): void {
68 on('command.run', { command: 'ctx' }, async $ => {
69 await toggle($)
70 return {}
71 })
72
73 on('ui.press', { plugin: 'statusline', element: 'press:ctx' }, async ($, e) => {
74 await toggle($, e.surface)
75 return { element: e.element }
76 })
77
78 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
79 const els = $.ui.resolve(e), { Box, Text, Button } = els
80 // The desktop's font draws /context's glyphs as pictograms: its grid is a drawing
81 const Svg = e.surface === 'desktop' && 'Svg' in els ? els.Svg : undefined
82 // Beside Texts an Svg is not drawn: the legend's squares are a geometric glyph. In a
83 // proportional font a box keeps a row without one aligned: the desktop paints `transparent` white
84 const span = (sp: Span) => Svg && (sp.square || sp.blank)
85 ? Box({ width: SQUARE_W, flexShrink: 0, children: sp.blank ? [] : [Text({ ...style(sp), children: ['■'] })] })
86 : Text({ ...style(sp), children: [sp.text] })
87 // Only the terminal's footer spans the docked pane: elsewhere the redraw would be a press missed
88 const width = e.surface === 'terminal' && e.props.placement === 'dock' ? e.props.bodyColumns + 1 : 0
89 if (width !== docked) {
90 docked = width
91 $.ui.invalidate('ui.render')
92 }
93 // /context draws 5×5 under 80 columns, for its glyphs' width: a drawing has none to keep
94 const columns = Svg ? Math.max(GRID_COLUMNS, e.props.bodyColumns) : e.props.bodyColumns
95 const { view: v, full: f } = await read($, pane), open = await read($, unfolded)
96 const counted = v !== 'full' ? undefined : f.status === 'ready' ? f : f.status === 'busy' ? f.previous : undefined
97 let shown: ContextBreakdown | undefined = counted?.breakdown
98 // The full view draws counts only: before the first, its controls say Calcul…
99 if (v === 'summary') {
100 try {
101 shown = (await $.session.usage({ breakdown: 'summary', columns })).context.breakdown
102 } catch {}
103 }
104
105 // A row's figures close it, each right-aligned in its column: the label takes the room left
106 const figure = (sp: Span) => Box({ width: FIGURE_W[sp.figure!], flexShrink: 0, justifyContent: 'flex-end', children: [span(sp)] })
107 const lines = (ls: Line[]) => ls.map(l => Box({
108 flexDirection: 'row',
109 children: !l.length ? [Text({ children: [' '] })]
110 : !l.some(sp => sp.figure) ? l.map(span)
111 : [Box({ flexDirection: 'row', flexGrow: 1, children: l.filter(sp => !sp.figure).map(span) }), ...l.filter(sp => sp.figure).map(figure)],
112 }))
113 const row = (label: ReturnType<typeof Text>, n: number) => Box({ flexDirection: 'row', children: [
114 Box({ flexGrow: 1, flexShrink: 1, children: [label] }),
115 Box({ width: NUM_W, flexShrink: 0, justifyContent: 'flex-end', children: [Text({ dimColor: true, children: [tokens(n)] })] }),
116 ] })
117 const button = (key: string, label: string, onPress: () => Promise<unknown>) => Button({ key, label, onPress: () => void onPress() })
118 const toSummary = button('ctx-view', 'Résumé', () => update($, pane, (p): ContextPane => ({ ...p, view: 'summary' })))
119 const measure = (key: string, label: string) => button(key, label, () => measureFull($, columns))
120
121 const body = shown
122 // A drawing as wide as the full grid can leave a narrow pane no room: the legend then goes under it
123 ? [Box({ flexDirection: 'row', flexWrap: Svg ? 'wrap' : 'nowrap', columnGap: 3, rowGap: 1, children: [
124 Svg ? Box({ flexShrink: 0, children: [Svg({ ...gridSvg(shown), alt: `contexte : ${shown.percentage} % utilisés` })] })
125 : Box({ flexDirection: 'column', flexShrink: 0, children: lines(grid(shown)) }),
126 // The legend takes what the grid leaves, its whole row once under it: names left, figures right
127 Box({ flexDirection: 'column', flexGrow: 1, children: lines(legend(shown, v === 'full')) }),
128 ] })]
129 : v === 'full' ? [] : [Text({ dimColor: true, children: ['Pas encore de mesure : elle vient avec la première réponse.'] })]
130
131 // One Button toggles the view, first in every state: a pressed Button that vanishes takes
132 // the focus with it, and the desktop's redraw for that blur leaves its next press missed
133 const toggleView = v === 'summary' ? button('ctx-view', 'Détail', () => measureFull($, columns)) : toSummary
134 const controls = []
135 if (v === 'summary') controls.push(Box({ flexDirection: 'row', children: [toggleView] }))
136 else if (f.status === 'busy' || f.status === 'none') controls.push(Box({ flexDirection: 'row', columnGap: 1, children: [toggleView, Text({ dimColor: true, children: [
137 f.status === 'busy' && f.previous ? `Calculé à ${timeOfDay(f.previous.at)} · nouveau calcul…` : 'Calcul…',
138 ] })] }))
139 else if (f.status === 'failed') controls.push(
140 Box({ flexDirection: 'row', columnGap: 1, children: [toggleView, measure('ctx-retry', 'Réessayer')] }),
141 Text({ color: 'error', children: [`Échec du calcul : ${f.reason}`] }),
142 )
143 else {
144 controls.push(Box({ flexDirection: 'row', columnGap: 1, children: [
145 toggleView, Text({ dimColor: true, children: [`Calculé à ${timeOfDay(f.at)}`] }), measure('ctx-refresh', 'Rafraîchir'),
146 ] }))
147 for (const section of details(f.breakdown)) {
148 const isOpen = open.includes(section.id)
149 controls.push(Box({ flexDirection: 'column', children: [
150 row(Button({
151 key: 'ctx-section:' + section.id, plain: true, label: (isOpen ? '▾ ' : '▸ ') + section.title,
152 onPress: () => void update($, unfolded, ids => (ids.includes(section.id) ? ids.filter(id => id !== section.id) : [...ids, section.id])),
153 }), section.tokens),
154 ...(isOpen ? section.rows.map(r => row(
155 Text({ wrap: 'truncate-end', ...(r.dim ? { dimColor: true } : {}), children: [' ' + r.label] }), r.tokens,
156 )) : []),
157 ] }))
158 }
159 }
160
161 return Box({ flexDirection: 'column', rowGap: 1, children: [...body, ...controls] })
162 })
163}
164hooks/migrate.ts 55 lines1// Removes the statusLine entry an earlier release wrote, so the mod's line is
2// not doubled by a frozen copy of the old one. The proofs are ADR 0001's: the
3// entry is touched only when it is provably ours.
4
5/** Sentinel of the guarded command 2.0.0 wrote; its presence proves the entry ours. */
6export const GUARD_MARK = '⚠ renderer absent'
7
8/** sha256 of every renderer released before 2.0.0, which installed to LEGACY. */
9export const LEGACY_SHA256 = new Set([
10 'b0a607d99dec6cc61cf4286fb6cd4ee318949ab84dc74a81c3dc5159439b52b4',
11 '102bbd41a8070d41d781d3ca63ba6bf016b8f597d4e66408fc30405f7ca44f9a',
12])
13
14export type Ownership =
15 | 'none' // no statusLine entry
16 | 'guarded' // 2.0.0's guarded command
17 | 'legacy' // the pre-2.0.0 path, and the file there is a renderer we shipped
18 | 'orphan' // the pre-2.0.0 path, and nothing there
19 | 'foreign' // anything else: someone's own status line
20
21/**
22 * Who owns the entry. `legacySha` is the hash of the file at the pre-2.0.0 path,
23 * `null` when there is none.
24 */
25export function classify(command: string | undefined, legacyPath: string, legacySha: string | null): Ownership {
26 if (!command) return 'none'
27 if (command.includes(GUARD_MARK)) return 'guarded'
28 // The old installer's backup of the user's own script: the legacy path is a
29 // substring of it, so it has to be ruled out first.
30 if (command.includes('.pre-statusline-plugin.bak')) return 'foreign'
31 if (command.includes('$HOME/.claude/statusline-command.sh') || command.includes(legacyPath)) {
32 if (legacySha === null) return 'orphan'
33 if (LEGACY_SHA256.has(legacySha)) return 'legacy'
34 }
35 return 'foreign'
36}
37
38/** `settings.json` without its statusLine, or null when it does not parse. */
39export function withoutStatusLine(text: string): string | null {
40 let parsed: unknown
41 try {
42 parsed = JSON.parse(text)
43 } catch {
44 return null
45 }
46 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
47 const { statusLine: _, ...rest } = parsed as Record<string, unknown>
48 return JSON.stringify(rest, null, 2) + '\n'
49}
50
51export async function sha256(text: string): Promise<string> {
52 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
53 return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
54}
55hooks/context-view.ts 145 lines1// What the context pane shows, from a /context breakdown. Pure: no engine, and
2// nothing from the footer line, so the pane can leave for a mod of its own.
3
4import type { ContextBreakdown } from '../types'
5
6/** How a square is filled, for a surface that draws it as a shape rather than its glyph. */
7export type Square = 'full' | 'partial' | 'free' | 'buffer'
8/**
9 * One styled stretch of text; with `square`, one of /context's squares; with `blank`, a
10 * square's room left empty; with `figure`, a number set right in that column of the rows.
11 */
12export type Span = { text: string; color?: string; dim?: boolean; bold?: boolean; square?: Square; blank?: true; figure?: Figure }
13export type Figure = 'tokens' | 'share'
14export type Line = Span[]
15/** `id` stays put while `title` carries counts; `tokens` is the rows' sum. */
16export type Section = { id: string; title: string; tokens: number; rows: { label: string; tokens: number; dim?: boolean }[] }
17
18export const tokens = (n: number): string =>
19 n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(Math.round(n))
20
21const share = (n: number, of: number): string => (of ? ((n / of) * 100).toFixed(1) : '0.0') + ' %'
22
23/** /context's glyphs: a row's last square is drawn hollow under 0.7 full. */
24function square(kind: ContextBreakdown['categories'][number]['kind'], fullness: number): Square {
25 return kind === 'free' || kind === 'buffer' ? kind : fullness < 0.7 ? 'partial' : 'full'
26}
27const GLYPH: Record<Square, string> = { full: '⛁', partial: '⛀', free: '⛶', buffer: '⛝' }
28
29// /context's theme keys as the desktop paints them (dark, sampled), since an Svg
30// paints no key; `claude` and `permission` were absent from the sample.
31const PAINT: Record<string, string> = {
32 promptBorder: '#484847', inactive: '#c3c2b7', warning: '#db9300', claude: '#d97757', permission: '#b1b9f9',
33 cyan_FOR_SUBAGENTS_ONLY: '#0891b2', green_FOR_SUBAGENTS_ONLY: '#16a34a', purple_FOR_SUBAGENTS_ONLY: '#827dbd',
34}
35const paint = (key: string | undefined): string => (key && (PAINT[key] ?? (key.startsWith('#') ? key : undefined))) || '#888888'
36
37// A row's pitch is the legend's line height, so the two read side by side
38const SQ = 15, GAP = 4
39
40/** One square at (x, y): filled, half-filled, a dot for free space, hatched for the buffer. */
41function squareAt(sq: Square, color: string, x: number, y: number): string {
42 const c = paint(color), r = `x="${x + 0.5}" y="${y + 0.5}" width="${SQ - 1}" height="${SQ - 1}" rx="2"`
43 if (sq === 'full') return `<rect ${r} fill="${c}"/>`
44 if (sq === 'partial') return `<rect ${r} fill="none" stroke="${c}"/><rect x="${x + 0.5}" y="${y + SQ / 2}" width="${SQ - 1}" height="${SQ / 2 - 0.5}" rx="1" fill="${c}"/>`
45 if (sq === 'free') return `<circle cx="${x + SQ / 2}" cy="${y + SQ / 2}" r="1.5" fill="${c}" fill-opacity="0.6"/>`
46 return `<rect ${r} fill="none" stroke="${c}" stroke-opacity="0.8"/><path d="M${x + 3} ${y + 3}L${x + SQ - 3} ${y + SQ - 3}M${x + SQ - 3} ${y + 3}L${x + 3} ${y + SQ - 3}" stroke="${c}" stroke-opacity="0.8"/>`
47}
48
49const svg = (w: number, h: number, body: string): string =>
50 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`
51
52/** The grid as one drawing, for a surface whose font draws the glyphs as pictograms. */
53export function gridSvg(b: ContextBreakdown): { source: string; width: number; height: number } {
54 const kinds = new Map(b.categories.map(c => [c.name, c.kind]))
55 const cols = Math.max(0, ...b.gridRows.map(r => r.length))
56 const width = Math.max(1, cols * (SQ + GAP) - GAP), height = Math.max(1, b.gridRows.length * (SQ + GAP) - GAP)
57 const body = b.gridRows.flatMap((row, j) => row.map((sq, i) =>
58 squareAt(square(kinds.get(sq.categoryName) ?? 'used', sq.squareFullness), sq.color, i * (SQ + GAP), j * (SQ + GAP)))).join('')
59 return { source: svg(width, height, body), width, height }
60}
61
62export function grid(b: ContextBreakdown): Line[] {
63 const kinds = new Map(b.categories.map(c => [c.name, c.kind]))
64 return b.gridRows.map(row => row.map((sq, i) => {
65 const s = square(kinds.get(sq.categoryName) ?? 'used', sq.squareFullness)
66 return { text: (i ? ' ' : '') + GLYPH[s], color: sq.color, square: s }
67 }))
68}
69
70/**
71 * Beside the grid: the model, the total, one row per category, the compaction
72 * threshold. `isCounted` tells the API's count from the local estimate, which
73 * runs well above it.
74 */
75export function legend(b: ContextBreakdown, isCounted: boolean): Line[] {
76 const lines: Line[] = [
77 [{ text: b.model, bold: true }],
78 [
79 { text: `${tokens(b.totalTokens)} / ${tokens(b.rawMaxTokens)} tokens (${b.percentage} %)` },
80 { text: isCounted ? ' · compté' : ' · estimé', dim: true },
81 ],
82 [],
83 ]
84 for (const c of b.categories) {
85 const figures: Span[] = [
86 { text: tokens(c.tokens), dim: true, figure: 'tokens' },
87 // A deferred category's name already says so: its share cell stays, empty, holding the tokens' column
88 { text: c.kind === 'deferred' ? '' : `(${share(c.tokens, b.rawMaxTokens)})`, dim: true, figure: 'share' },
89 ]
90 if (c.kind === 'deferred') lines.push([{ text: ' ', blank: true }, { text: c.name, dim: true }, ...figures])
91 else lines.push([{ text: GLYPH[square(c.kind, 1)] + ' ', color: c.color, square: square(c.kind, 1) }, { text: c.name }, ...figures])
92 }
93 lines.push([], [{
94 text: b.isAutoCompactEnabled && b.autoCompactThreshold != null
95 ? `Autocompact à ${tokens(b.autoCompactThreshold)}` : 'Autocompact désactivé',
96 dim: true,
97 }])
98 return lines
99}
100
101const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
102
103/** A server the engine names by its id alone, as the desktop's own are, is named by its first two tools. */
104export function serverLabel(name: string, tools: readonly string[]): string {
105 if (!UUID.test(name)) return name
106 const short = tools.map(t => (t.startsWith(`mcp__${name}__`) ? t.slice(name.length + 7) : t))
107 return `‹${short.slice(0, 2).join(', ')}${short.length > 2 ? '…' : ''}›`
108}
109
110const byTokens = <T extends { tokens: number }>(rows: T[]): T[] => [...rows].sort((a, b) => b.tokens - a.tokens)
111
112/** What only the full count itemizes; empty sections are left out. */
113export function details(b: ContextBreakdown): Section[] {
114 const servers = new Map<string, { count: number; tokens: number; tools: string[] }>()
115 for (const t of b.mcpTools) {
116 const s = servers.get(t.serverName) ?? { count: 0, tokens: 0, tools: [] }
117 servers.set(t.serverName, { count: s.count + 1, tokens: s.tokens + t.tokens, tools: [...s.tools, t.name] })
118 }
119 const sections: Omit<Section, 'tokens'>[] = [
120 { id: 'memory', title: 'Fichiers mémoire', rows: byTokens(b.memoryFiles).map(f => ({ label: f.path, tokens: f.tokens })) },
121 {
122 id: 'mcp', title: 'Outils MCP',
123 rows: byTokens([...servers].map(([name, s]) => ({
124 label: `${serverLabel(name, s.tools)} (${s.count} outil${s.count > 1 ? 's' : ''})`, tokens: s.tokens, ...(UUID.test(name) ? { dim: true } : {}),
125 }))),
126 },
127 { id: 'agents', title: 'Agents', rows: byTokens(b.agents).map(a => ({ label: `${a.agentType} (${a.source})`, tokens: a.tokens })) },
128 ]
129 if (b.skills) sections.push({
130 id: 'skills', title: `Skills (${b.skills.includedSkills}/${b.skills.totalSkills})`,
131 rows: byTokens(b.skills.skillFrontmatter).map(s => ({ label: s.name, tokens: s.tokens })),
132 })
133 if (b.slashCommands) sections.push({
134 id: 'commands', title: 'Commandes slash',
135 rows: [{ label: `${b.slashCommands.includedCommands}/${b.slashCommands.totalCommands} listées`, tokens: b.slashCommands.tokens }],
136 })
137 return sections.filter(s => s.rows.length).map(s => ({ ...s, tokens: s.rows.reduce((n, r) => n + r.tokens, 0) }))
138}
139
140/** `14:07:32`, in the clock's local time. */
141export function timeOfDay(ms: number): string {
142 const d = new Date(ms)
143 return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
144}
145types/index.d.ts 41 lines1// A contract imports nothing: the breakdown is the part of the engine's
2// SessionContextBreakdown the context pane reads, which that type satisfies.
3
4export type ContextBreakdown = {
5 model: string
6 totalTokens: number
7 rawMaxTokens: number
8 percentage: number
9 isAutoCompactEnabled: boolean
10 autoCompactThreshold?: number
11 categories: { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' | 'deferred' }[]
12 gridRows: { categoryName: string; color: string; squareFullness: number }[][]
13 memoryFiles: { path: string; tokens: number }[]
14 mcpTools: { name: string; serverName: string; tokens: number }[]
15 agents: { agentType: string; source: string; tokens: number }[]
16 skills?: { includedSkills: number; totalSkills: number; skillFrontmatter: { name: string; tokens: number }[] }
17 slashCommands?: { includedCommands: number; totalCommands: number; tokens: number }
18}
19
20export type ContextView = 'summary' | 'full'
21
22/** The view and its count in one value: a press writes once, so the pane draws once. */
23export type ContextPane = { view: ContextView; full: ContextFull }
24
25/** The full count, which costs token-count API calls: taken on demand, then kept as is. */
26export type ContextFull =
27 | { status: 'none' }
28 | { status: 'busy'; previous?: { at: number; breakdown: ContextBreakdown } }
29 | { status: 'ready'; at: number; breakdown: ContextBreakdown }
30 | { status: 'failed'; reason: string }
31
32declare module 'claude-code' {
33 interface PluginState {
34 statusline: {
35 ctxPane: ContextPane
36 /** The full view's sections shown unfolded, by `id`; folded by default. */
37 ctxUnfolded: string[]
38 }
39 }
40}
41