SLOPSHOPPER

statusline

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…

newpanebandspinnercommandtoast
v3.2.2MITupdated 2026-10-08sdrik/claude-statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
│ ┃ Contexte ✕ › fix the failing auth test and add an audit log call │ ┃ Pas encore de mesure : elle vient avec la │ ┃ première réponse. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Détail ] ⏺ 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… ↑+0 ↓0 · ⟳ 0 · $0.00 │ │ › /ctx │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ │ Opus 5.5 │ 49% ↑97.4k/200.0k ⊞ │ $0.42 │ 31%

Draws

Pane · Contexte
Pas encore de mesure : elle vient avec la première réponse. [ Détail ]
Turn line
✻ Worked… ↑+0 ↓0 · ⟳ 0 · $0.00
README

statusline

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:

  • Footer, right of the hint line (always there):
  focus │ email │ branch ● │ Opus 5.5 │ xhigh │ ███ 25% ░░░ ↑49.5k/1.0M ⊞ │ ⟳ 12 │ $0.84 │ █ 6% ░ 2h16 │ █ 5% ░ 4d 17h
  • the engine's own modes (focus, memory paused), kept on the left
  • email and git branch with a dot when the tree has changes, both off by default
  • model and effort
  • context gauge on the absolute token scale, with ↑input/window in the same colour, and ⊞, which opens the context pane
  • ⟳ N, the model requests of the main loop, on a green→red gradient
  • total cost of the session
  • 5-hour and 7-day rate-limit gauges, each with the time until reset
  • Spinner, while Claude works: Baking · ⟳ 12…
  • Under each turn, beside 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.
  • Context pane, opened and closed by a click on ⊞ 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.
  • Toast, when a threshold is crossed upward: 🟧 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.

Requirements

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.

Usage

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:

OptionDefaultEffect
ctx_warn100000input tokens at which the context gauge turns yellow and a toast fires
ctx_crit200000input tokens at which it turns red; also the gauge's full scale
turns_min30the request counter stays green up to this
turns_max250it is fully red from this
show_emailfalseshow the signed-in account's email
show_gitfalseshow the branch and a dot when the tree has changes

Upgrading from 2.x or 1.x

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.

Session degradation

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.

Loop turns (⟳ 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:

  • The counter is a correlated symptom, not a cause. The strongest published link between trajectory length and failure ([Coherence Collapse][cc], ρ=0.32, p=3.4×10⁻²³, 63.7% coherence collapse in the longest quartile against 21.7% in the shortest) comes with a warning from its own authors: agents "likely run longer because they are thrashing, not the reverse". Read ⟳ N as a hint that something may be going wrong, never as a measurement of what is.
  • Degradation is continuous, with no cliff to alarm on. Hence a gradient.

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.

Context gauge

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.

Tuning

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.

Known gaps

  • ⟳ 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

Development

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.

Source 6 files
hooks/register.ts 322 lines
1import 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}
322
hooks/line.ts 224 lines
1// 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
224
hooks/context-pane.ts 164 lines
1import { 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}
164
hooks/migrate.ts 55 lines
1// 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}
55
hooks/context-view.ts 145 lines
1// 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}
145
types/index.d.ts 41 lines
1// 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