A Claude Code mod: a side panel that shows what fills your context window, what your plan limits look like, and what each kind of action costs, with…

<img alt="chudarin-context-mod: a context panel for Claude Code" src="assets/banner-light.svg" width="100%">
<a href="#-install"><img alt="Claude Code mod" src="https://img.shields.io/badge/Claude_Code-mod-D97757"></a> <a href="#-limits"><img alt="early access" src="https://img.shields.io/badge/mods_API-early_access-6B6B75"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-2EA44F"></a>
A side panel for Claude Code that shows where your context goes. Type /ctx, and a panel opens next to the chat: how full the context is, how much of your plan limits is left, what fills the window, and what each kind of action costs.
I made it to learn how context works. /context gives a table of categories, and in a long session most of the window sits in one line called Messages. That line doesn't tell you whether it was the screenshots, the test output or the files Claude rewrote. The panel splits it open, and next to every item says in plain words what it is, why it's that big, and what you can do about it.

From top to bottom:
Click a group to see its items, click an item to get its help page:
| A group | An item |
|---|---|
![]() | ![]() |
The panel speaks English unless Claude Code's language setting is Russian.
You need Claude Code with mods. I built and tested it on Claude Code 2.1.300 in a terminal.
/plugin marketplace add schudarin/chudarin-design-skills
/plugin install chudarin-context-mod@chudarin
The first line adds my plugin catalog, chudarin. It also lists a design agent for Figma; you don't have to install it, the two are separate. Restart Claude Code after installing, then type /ctx.
| Command | What it does |
|---|---|
/ctx | Opens the panel. Again, or Esc while the panel has focus, closes it |
/ctx-line | Turns on a one-line summary above the prompt. Off by default |
Click a row to open it, ← Back to return. If clicks don't land, focus the panel first: click it or press ctrl+x tab.
In a fullscreen terminal wide enough, the panel docks beside the chat. Otherwise it opens above the prompt.
/context shows, from Claude Code's own estimate./context.--resume, and holds the last 30 sessions. It starts when the mod is installed; earlier history isn't counted.Nothing leaves your machine. The mod makes no network calls.
claude plugin update chudarin-context-mod@chudarin # newest version, restart to apply
claude plugin uninstall chudarin-context-mod@chudarin # remove
chudarin-design-skills: a design agent for Figma. A separate plugin in the same catalog.
MIT, see LICENSE.
hooks/register.tsx 750 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LimitTrack, Page, SessionLog, Snapshot, Step } from '../types'
5import { GROUPS, actionPrices, buildEntries, groupLabel, groupTotals, insight } from './entries'
6import { HELP_IDS, helpFor } from './help'
7import { type Lang, langOf, UI } from './i18n'
8import { addCompact, addLimit, addPoint, addPrompt, addRequest, emptyLog, pruneKeys, sessionKey } from './history'
9import { chartRows, detailsFor, jumps, k, label, limitForecast, pace, resetLabel, sparkline, speed, trackLimit, turnRises, usualRise, windowRows } from './model'
10
11const log = atom({ plugin: 'chudarin-context-mod', key: 'log' } as const, null)
12const heaviest = atom({ plugin: 'chudarin-context-mod', key: 'heaviest' } as const, null)
13const snapshot = atom({ plugin: 'chudarin-context-mod', key: 'snapshot' } as const, null)
14const expanded = atom({ plugin: 'chudarin-context-mod', key: 'expanded' } as const, [])
15const page = atom({ plugin: 'chudarin-context-mod', key: 'page' } as const, { kind: 'home' } as Page)
16const bandOn = atom({ plugin: 'chudarin-context-mod', key: 'bandOn' } as const, false)
17const lang = atom({ plugin: 'chudarin-context-mod', key: 'lang' } as const, 'en' as Lang)
18const usual = atom({ plugin: 'chudarin-context-mod', key: 'usual' } as const, null)
19const hidden = atom({ plugin: 'chudarin-context-mod', key: 'hidden' } as const, [])
20const sent = atom({ plugin: 'chudarin-context-mod', key: 'sent' } as const, [])
21const muted = atom({ plugin: 'chudarin-context-mod', key: 'muted' } as const, [])
22const limitTrack = atom({ plugin: 'chudarin-context-mod', key: 'limitTrack' } as const, null)
23const minute = atom({ plugin: 'chudarin-context-mod', key: 'minute' } as const, 0)
24// Store key for the 5-hour limit samples: the limit is shared by every session.
25const LIMIT = 'limit:five_hour'
26
27function isTrack(v: unknown): v is LimitTrack {
28 const t = v as LimitTrack | null
29 return !!t && typeof t.resetsAt === 'string' && Array.isArray(t.samples)
30}
31
32// One more sample of the 5-hour limit. The store is merged in, so every session adds to one track.
33async function sampleLimit($: EngineInterface, resetsAt: string, percent: number) {
34 const now = await $.clock.now()
35 const stored = await $.store.get(LIMIT).catch(() => undefined)
36 const own = await read($, limitTrack)
37 const base = isTrack(stored) && (!own || stored.samples.length >= own.samples.length) ? stored : own
38 const next = trackLimit(base, resetsAt, now, percent)
39 await update($, limitTrack, () => next)
40 await $.store.set(LIMIT, next).catch(err => $.ui.log(`chudarin-context-mod: limit samples not saved: ${String(err)}`))
41}
42// Store key for advice hidden for good: it outlives sessions.
43const MUTED = 'muted'
44
45// Marks advice as done once its request was sent or its command run: what counts is
46// what reached Claude, not what the panel put in the box.
47async function markDone($: EngineInterface, matches: (prompt: string) => boolean) {
48 const ids = HELP_IDS.filter(id => (['ru', 'en'] as const).some(l => {
49 const p = helpFor(id, l).prompt
50 return p !== null && matches(p)
51 }))
52 if (ids.length === 0) return
53 const add = (prev: string[]) => [...prev, ...ids.filter(id => !prev.includes(id))]
54 await update($, sent, add)
55}
56
57const PANE = 'chudarin-context-mod'
58// Figures older than this get the "data is stale" line.
59const STALE_MS = 5 * 60_000
60// Session logs kept in the store; older ones are dropped.
61const KEEP_SESSIONS = 30
62const BAND_POINTS = 24
63const CHART_ROWS = 6
64// Lower blocks keep a gap between stacked rows; a dim dot marks the empty part of a meter.
65const BLOCK = '▆'
66const EMPTY = '·'
67
68type Level = 'ok' | 'warn' | 'bad'
69const LEVEL_COLOR: Record<Level, 'success' | 'warning' | 'error'> = { ok: 'success', warn: 'warning', bad: 'error' }
70
71function levelOf(percent: number, warn: number, bad: number): Level {
72 return percent > bad ? 'bad' : percent >= warn ? 'warn' : 'ok'
73}
74
75// Split a width among values in proportion, at least one cell for any value above zero.
76function widths(values: readonly number[], width: number): number[] {
77 const sum = values.reduce((a, b) => a + b, 0)
78 if (sum <= 0) return values.map(() => 0)
79 const out = values.map(v => (v > 0 ? Math.max(1, Math.round((v / sum) * width)) : 0))
80 let extra = out.reduce((a, b) => a + b, 0) - width
81 while (extra > 0) {
82 const i = out.indexOf(Math.max(...out))
83 out[i]! -= 1
84 extra -= 1
85 }
86 return out
87}
88
89// Change the session log in the panel's state and persist it, so a restart or --resume keeps it.
90async function saveLog($: EngineInterface, change: (l: SessionLog) => SessionLog) {
91 const id = await $.session.id()
92 const now = await $.clock.now()
93 let next: SessionLog | null = null
94 await update($, log, prev => (next = change(prev ?? emptyLog(now))))
95 if (next) await $.store.set(sessionKey(id), next)
96}
97
98// Claude Code's `language` setting decides the panel's language.
99async function readLang($: EngineInterface): Promise<Lang> {
100 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
101 return langOf(settings.language)
102}
103
104async function refresh($: EngineInterface) {
105 const l = await readLang($)
106 await update($, lang, () => l)
107 const usage = await $.session.usage({ breakdown: 'summary' })
108 const b = usage.context.breakdown
109 if (!b) return
110 const messages = await $.session.messages({ as: 'api' })
111 const categories = windowRows(b.categories)
112 const entries = buildEntries(categories, detailsFor(b), messages, l)
113 const next: Snapshot = {
114 lang: l,
115 at: await $.clock.now(),
116 entries,
117 prices: actionPrices(messages, l),
118 limits: usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
119 threshold: b.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : usage.context.window,
120 window: usage.context.window,
121 used: entries.reduce((n, e) => n + e.tokens, 0),
122 }
123 await update($, snapshot, () => next)
124 const five = usage.rateLimits.find(r => /five_hour/.test(r.kind) && !/fable/i.test(r.kind))
125 if (five?.resetsAt) await sampleLimit($, five.resetsAt, five.percentUsed)
126 // What this session spent of each limit; the log is a nice-to-have, a failure must not stop the refresh.
127 const week = usage.rateLimits.find(r => /seven_day/.test(r.kind) && !/fable/i.test(r.kind))
128 for (const [kind, r] of [['five_hour', five], ['seven_day', week]] as const)
129 if (r?.resetsAt) {
130 const { resetsAt, percentUsed } = r
131 await saveLog($, l => addLimit(l, kind, resetsAt, percentUsed)).catch(err => $.ui.log(`chudarin-context-mod: limit not logged: ${String(err)}`))
132 }
133}
134
135export const register: Register = on => {
136 on('session.start', async ($, e, next) => {
137 const r = await next(e)
138 const t0 = UI[await readLang($)]
139 await $.command.register({ name: 'ctx', description: t0.cmdPanel })
140 await $.command.register({ name: 'ctx-line', description: t0.cmdLine })
141 // Pick up this session's log (after a restart or --resume), or start one.
142 const id = await $.session.id()
143 const stored = (await $.store.get(sessionKey(id))) as SessionLog | undefined
144 const now = await $.clock.now()
145 // Logs from older versions lack newer fields; the empty log fills them in.
146 await update($, log, () => (stored ? { ...emptyLog(stored.startedAt), ...stored } : emptyLog(now)))
147 for (const key of pruneKeys(await $.store.keys(), KEEP_SESSIONS)) await $.store.delete(key)
148 // The usual pace, from the other sessions in the store.
149 const others: SessionLog[] = []
150 for (const key of await $.store.keys())
151 if (key.startsWith('session:') && key !== sessionKey(id)) {
152 const other = (await $.store.get(key)) as SessionLog | undefined
153 if (other) others.push(other)
154 }
155 await update($, usual, () => usualRise(others))
156 await update($, hidden, () => [])
157 await update($, sent, () => [])
158 // Only a list of ids counts; anything else in the store is ignored.
159 const rawMuted = await $.store.get(MUTED)
160 const mutedBefore = Array.isArray(rawMuted) ? rawMuted.filter((x): x is string => typeof x === 'string') : []
161 await update($, muted, () => mutedBefore)
162 const { context } = await $.session.usage()
163 if (context.tokens !== undefined && !stored?.points.length) {
164 const tokens = context.tokens
165 await saveLog($, l => addPoint(l, tokens, null))
166 }
167 await refresh($)
168 // A minute clock for the session's life. The pane reads `minute`, so each write redraws it:
169 // an idle session still shows a passed reset time and, after five minutes, that its figures
170 // are stale. Redraw only; reading the data again here would keep it from ever looking stale.
171 $.clock.every(60_000, () => {
172 void $.clock
173 .now()
174 .then(at => update($, minute, () => at))
175 .catch(err => $.ui.log(`chudarin-context-mod: minute clock failed: ${String(err)}`))
176 })
177 return r
178 })
179
180 // Any slash command, built-in ones included: running /mcp or /effort counts as done.
181 on('command.run', async ($, e, next) => {
182 const r = await next(e)
183 await markDone($, p => p === `/${e.command}`).catch(err => $.ui.log(`chudarin-context-mod: ${String(err)}`))
184 return r
185 }).catch(($, e, next) => (next.called ? {} : next(e)))
186
187 on('command.run', { command: 'ctx' }, async $ => {
188 const t = UI[await read($, lang)]
189 const panes = await $.ui.panes()
190 if (panes.some(p => p.id === PANE)) {
191 await $.ui.close({ id: PANE })
192 return { text: t.panelClosed }
193 }
194 await update($, page, () => ({ kind: 'home' }) as Page)
195 await refresh($).catch(err => $.ui.log(`chudarin-context-mod: refresh failed: ${String(err)}`))
196 await $.ui.open({ id: PANE, title: t.paneTitle, focus: true, closeOnEscape: true })
197 return { text: t.panelOpened }
198 })
199
200 on('command.run', { command: 'ctx-line' }, async $ => {
201 const t = UI[await read($, lang)]
202 const isOn = !(await read($, bandOn))
203 await update($, bandOn, () => isOn)
204 return { text: isOn ? t.lineOn : t.lineOff }
205 })
206
207 on('prompt.submit', async ($, e, next) => {
208 await update($, heaviest, () => null)
209 await markDone($, p => !p.startsWith('/') && e.text.includes(p))
210 if (!e.command && !e.isMeta) await saveLog($, addPrompt).catch(err => $.ui.log(`chudarin-context-mod: prompt not logged: ${String(err)}`))
211 return next(e)
212 }).catch(($, e, next) => next(e))
213
214 on('tool.call', async ($, e, next) => {
215 const r = await next(e)
216 // Rough estimate: ~4 characters per token.
217 const tokens = Math.round((r.text?.length ?? 0) / 4)
218 const step: Step = { label: label(e as Record<string, unknown>), tokens }
219 await update($, heaviest, prev => (prev && prev.tokens >= tokens ? prev : step))
220 return r
221 }).catch(($, e, next) => next(e))
222
223 // Every model request of the main conversation: what it read and wrote.
224 on('turn.step', async function* ($, e, next) {
225 const r = yield* next(e)
226 const usage = r?.usage
227 if (!e.agentId && usage) await saveLog($, l => addRequest(l, usage))
228 return r
229 })
230
231 on('session.compact', async ($, e, next) => {
232 const r = await next(e)
233 const isMain = !e.agentId && e.trigger !== 'precompute'
234 if (isMain && r.skip === undefined) {
235 const { tokensBefore, tokensAfter } = r
236 if (tokensBefore !== undefined && tokensAfter !== undefined)
237 await saveLog($, l => addCompact(l, e.trigger, tokensBefore, tokensAfter))
238 }
239 return r
240 }).catch(($, e, next) => next(e))
241
242 on('session.measure', async ($, e, next) => {
243 const tokens = e.context.tokens
244 if (tokens !== undefined) {
245 const cause = (await read($, heaviest))?.label ?? null
246 // The log is a nice-to-have: a failure here must not stop the panel's refresh.
247 await saveLog($, l => addPoint(l, tokens, cause)).catch(err => $.ui.log(`chudarin-context-mod: log not saved: ${String(err)}`))
248 }
249 await refresh($)
250 return next(e)
251 })
252
253 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
254 if (e.props.hasSurvey || !(await read($, bandOn))) return next(e)
255 const points = ((await read($, log))?.points ?? []).slice(-BAND_POINTS).map(p => p.tokens)
256 const total = points[points.length - 1]
257 const snap = await read($, snapshot)
258 if (total === undefined || !snap) return next(e)
259 const { Box, Text } = $.ui.resolve(e)
260 return (
261 <Box>
262 <Text dimColor>
263 ctx {UI[snap.lang].ofWindow(k(total), k(snap.window))} · {Math.round((total / snap.window) * 100)}%{' '}
264 <Text color="suggestion">{sparkline(points)}</Text>
265 {' /ctx'}
266 </Text>
267 </Box>
268 )
269 })
270
271 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
272 try {
273 const { Box, Button, Text } = $.ui.resolve(e)
274 const snap = await read($, snapshot)
275 const t = UI[await read($, lang)]
276 if (!snap) return <Text dimColor>{t.noData}</Text>
277
278 const session = await read($, log)
279 const points = session?.points ?? []
280 const fiveSpent = session?.limits?.five_hour?.spent ?? null
281 const weekSpent = session?.limits?.seven_day?.spent ?? null
282 const open = await read($, expanded)
283 const where = await read($, page)
284 // Subscribes the pane to the minute clock: each tick redraws it.
285 await read($, minute)
286 const now = await $.clock.now()
287 // Two empty columns on each side of every page; the grid fits inside them.
288 const PAD = 2
289 const W = Math.max(40, e.props.bodyColumns - 2 * PAD)
290 // One grid for every list: name, tokens, share, then a bar or a chevron.
291 const NAME = 30
292 const NUM = 7
293 const PCT = 6
294 // An empty row where column labels used to be: keeps the rhythm without the words.
295 const columnsHead = <Text> </Text>
296 // Two empty rows between the home page's main blocks.
297 const sectionGap = (
298 <Box flexDirection="column">
299 <Text> </Text>
300 <Text> </Text>
301 </Box>
302 )
303 const go = (to: Page) => () => update($, page, () => to)
304 const pctOfUsed = (n: number) => (snap.used ? `${Math.round((n / snap.used) * 100)}%` : '')
305 const ofUsed = (n: number) => t.ofUsed(k(n), pctOfUsed(n))
306 const total = points[points.length - 1]?.tokens ?? snap.used
307
308 const back = (to: Page) => (
309 <Box flexDirection="column">
310 <Button key="back" plain label={t.back} onPress={go(to)}>
311 <Text dimColor>{t.back}</Text>
312 </Button>
313 <Text> </Text>
314 </Box>
315 )
316 // A title with a figure on the right; `edge` is the column the figure ends at.
317 const titleRow = (title: string, right: string, edge = W) => (
318 <Text>
319 <Text bold>{title.toUpperCase()}</Text>
320 <Text dimColor>{right.padStart(Math.max(1, edge - title.length))}</Text>
321 </Text>
322 )
323 // A title with a note right after it, for a note that reads with the title.
324 const titleNote = (title: string, note: string) => (
325 <Text>
326 <Text bold>{title.toUpperCase()}</Text>
327 <Text dimColor>{` ${note}`}</Text>
328 </Text>
329 )
330
331 // "Don't show again" is for good and kept in the store; there is no way back.
332 const setMuted = async (change: (prev: string[]) => string[]) => {
333 await update($, muted, change)
334 await $.store.set(MUTED, await read($, muted)).catch(err => $.ui.log(`chudarin-context-mod: hidden advice not saved: ${String(err)}`))
335 }
336 const mutedIds = await read($, muted)
337 // Adds the request after whatever the person has typed. The hint stays until it is sent.
338 const putInChat = (h: { id: string; prompt: string | null }) => async () => {
339 if (!h.prompt) return
340 const draft = (await $.prompt.read()).text
341 await $.prompt.fill({ text: draft.trim() ? ` ${h.prompt}` : h.prompt, mode: draft.trim() ? 'append' : 'replace' })
342 }
343 const done = await read($, sent)
344 const hiddenIds = await read($, hidden)
345 // Muted for good, sent to Claude this session, hidden by the person this session, or shown.
346 const stateOf = (id: string) =>
347 mutedIds.includes(id) ? 'muted' : done.includes(id) ? 'sent' : hiddenIds.includes(id) ? 'hidden' : 'shown'
348
349 // ---- entry page: what it is, why so much, what to do ----
350 if (where.kind === 'entry') {
351 const entry = snap.entries.find(x => x.id === where.id)
352 const help = helpFor(where.id, snap.lang)
353 const parent: Page = { kind: 'group', id: entry?.group ?? help.group }
354 if (!entry)
355 return (
356 <Box flexDirection="column" paddingX={PAD}>
357 {back(parent)}
358 <Text dimColor>{t.entryGone}</Text>
359 </Box>
360 )
361 const average = entry.count ? Math.round(entry.tokens / entry.count) : 0
362 const state = stateOf(entry.id)
363 return (
364 <Box flexDirection="column" paddingX={PAD}>
365 {back(parent)}
366 {titleRow(entry.label, ofUsed(entry.tokens))}
367 <Text> </Text>
368 <Text bold>{t.whatIs}</Text>
369 <Box paddingLeft={2}>
370 <Text>{help.what}</Text>
371 </Box>
372 <Text> </Text>
373 <Text bold>{t.whySoMuch}</Text>
374 <Box paddingLeft={2} flexDirection="column">
375 <Text>{entry.count > 1 ? t.times(entry.count, k(average)) : t.once}</Text>
376 {entry.top.length > 0 && <Text dimColor>{t.heaviest}</Text>}
377 {entry.top.map(c => (
378 <Text wrap="truncate-end">
379 {`+${k(c.tokens)}`.padStart(7)}
380 {' '}
381 {c.label}
382 </Text>
383 ))}
384 </Box>
385 {/* advice hidden for good leaves no trace here either */}
386 {state !== 'muted' && (
387 <Box flexDirection="column">
388 <Text> </Text>
389 <Text bold>{t.whatToDo}</Text>
390 <Box paddingLeft={2} flexDirection="column">
391 {/* hidden this session: the mark below stands in for the advice */}
392 {state !== 'hidden' && <Text>{help.todo ?? t.nothingToDo}</Text>}
393 {/* one row of actions; what it holds depends on the advice's state */}
394 {help.todo && (
395 <Box flexDirection="row">
396 {state === 'sent' && <Text dimColor>{help.prompt?.startsWith('/') ? t.hintRan : t.hintSent}</Text>}
397 {state === 'hidden' && <Text dimColor>{t.adviceHidden}</Text>}
398 {state === 'shown' && help.prompt && (
399 <Button key="entry-fill" plain label={t.hintFill} onPress={putInChat({ id: entry.id, prompt: help.prompt })}>
400 <Text color="claude">{t.hintFill}</Text>
401 </Button>
402 )}
403 {(state !== 'shown' || help.prompt) && <Text>{' '}</Text>}
404 {state === 'shown' ? (
405 <Button key="entry-hide" plain label={t.hideAdvice} onPress={() => update($, hidden, prev => [...prev, entry.id])}>
406 <Text dimColor>{t.hideAdvice}</Text>
407 </Button>
408 ) : (
409 <Button key="entry-mute" plain label={t.neverShow} onPress={() => setMuted(prev => [...prev, entry.id])}>
410 <Text color="claude">{t.neverShow}</Text>
411 </Button>
412 )}
413 </Box>
414 )}
415 </Box>
416 </Box>
417 )}
418 </Box>
419 )
420 }
421
422 // ---- group page: its entries ----
423 if (where.kind === 'group') {
424 const group = GROUPS.find(g => g.id === where.id) ?? GROUPS[0]!
425 const list = snap.entries.filter(x => x.group === group.id)
426 const sum = list.reduce((n, x) => n + x.tokens, 0)
427 const max = Math.max(0, ...list.map(x => x.tokens))
428 const barW = Math.max(4, W - NAME - NUM - PCT - 4)
429 return (
430 <Box flexDirection="column" paddingX={PAD}>
431 {back({ kind: 'home' })}
432 {titleRow(groupLabel(group.id, snap.lang), ofUsed(sum))}
433 <Text> </Text>
434 {columnsHead}
435 {list.length === 0 && <Text dimColor>{t.emptyGroup}</Text>}
436 {list.map(x => {
437 // Only the name is the button: the pointer inverts a button, and an inverted bar breaks.
438 const name = x.label.slice(0, NAME - 1)
439 return (
440 <Box key={`row-${x.id}`} flexDirection="row">
441 <Button key={`entry-${x.id}`} plain label={x.label} onPress={go({ kind: 'entry', id: x.id })}>
442 {name}
443 </Button>
444 <Text>
445 {' '.repeat(NAME - name.length)}
446 {k(x.tokens).padStart(NUM)}
447 <Text dimColor>{pctOfUsed(x.tokens).padStart(PCT)}</Text>
448 {' '}
449 <Text color={group.color}>
450 {BLOCK.repeat(max > 0 ? Math.max(1, Math.round((x.tokens / max) * barW)) : 0).padEnd(barW)}
451 </Text>
452 <Text dimColor> ▸</Text>
453 </Text>
454 </Box>
455 )
456 })}
457 </Box>
458 )
459 }
460
461 // ---- home ----
462 const v = pace(points)
463 const fill = snap.window ? (total / snap.window) * 100 : 0
464 const turnsLeft = v.recent > 0 ? Math.floor((snap.threshold - total) / v.recent) : null
465 // Limit figures arrive with model replies. In an idle session the reset time can pass while the
466 // percent stays old: then the window has started over, so the limit shows as reset.
467 const limit = (re: RegExp) => {
468 const l = snap.limits.find(x => re.test(x.kind) && !/fable/i.test(x.kind))
469 const passed = !!l?.resetsAt && Date.parse(l.resetsAt) <= now
470 return l && { ...l, percentUsed: passed ? 0 : l.percentUsed, passed }
471 }
472 const limitLine = (l: { resetsAt?: string | null; passed: boolean }) =>
473 !l.resetsAt ? '' : l.passed ? t.wasReset(resetLabel(l.resetsAt, now)) : t.resets(resetLabel(l.resetsAt, now))
474 const five = limit(/five_hour/)
475 const track = await read($, limitTrack)
476 const forecast =
477 five?.resetsAt && !five.passed && track?.resetsAt === five.resetsAt
478 ? limitForecast(track, now, Date.parse(five.resetsAt))
479 : null
480 const fiveForecast = forecast === null ? '' : forecast === 'untilReset' ? t.limitUntilReset : t.limitLeft(forecast.minutes)
481 const week = limit(/seven_day/)
482 const tiles = [
483 {
484 title: t.tileContext,
485 percent: fill,
486 level: levelOf((total / snap.threshold) * 100, 50, 80),
487 lines: [t.ofWindow(k(total), k(snap.window)), turnsLeft !== null ? t.turnsLeft(turnsLeft) : ''],
488 },
489 ...(five
490 ? [
491 {
492 title: t.tile5h,
493 percent: five.percentUsed,
494 level: levelOf(five.percentUsed, 70, 90),
495 lines: [limitLine(five), fiveForecast],
496 },
497 ]
498 : []),
499 ...(week
500 ? [
501 {
502 title: t.tileWeek,
503 percent: week.percentUsed,
504 level: levelOf(week.percentUsed, 70, 90),
505 lines: [limitLine(week), ''],
506 },
507 ]
508 : []),
509 ]
510 const gap = 2
511 const colW = Math.floor((W - gap * (tiles.length - 1)) / tiles.length)
512 const meterW = Math.max(6, colW - 2)
513 const cell = (text: string, i: number) => text.padEnd(colW + (i < tiles.length - 1 ? gap : 0)).slice(0, colW + gap)
514
515 const groups = groupTotals(snap.entries, snap.lang)
516 const usedGroups = groups.filter(g => g.tokens > 0)
517 const stack = widths(
518 usedGroups.map(g => g.tokens),
519 W - (usedGroups.length - 1),
520 )
521 const hint = insight(snap.entries, fill, snap.used, snap.lang, [...hiddenIds, ...done, ...mutedIds])
522 const tempo = speed(points, await read($, usual))
523 const prices = snap.prices.slice(0, 6)
524 const half = Math.ceil(prices.length / 2)
525 const priceW = Math.floor((W - 4) / 2)
526 const toggle = (id: string) => () =>
527 update($, expanded, prev => (prev.includes(id) ? prev.filter(n => n !== id) : [...prev, id]))
528 const isOpen = (id: string) => open.includes(id)
529 // One column per turn: what that turn added, so a heavy turn stands out as a peak.
530 const shown = points.slice(-Math.max(10, W - 8) - 1)
531 const rises = turnRises(shown)
532 const chartMax = Math.max(0, ...rises)
533 // The five biggest jumps, listed below the chart; each one's column joins its hover group.
534 const peaks = jumps(shown, 5)
535 const peakAt = new Map(peaks.map((j, n) => [shown.findIndex(p => p.turn === j.turn) - 1, n + 1]))
536 // A chart row split so each peak's cell joins its hover group: pointing at a jump in the
537 // list lights its column, and pointing at the column lights the jump. The surface does the
538 // lighting; no hook runs.
539 const chartLine = (line: string) => {
540 const parts: ReturnType<typeof Text>[] = []
541 let run = ''
542 const flush = () => {
543 if (run) parts.push(<Text color="suggestion">{run}</Text>)
544 run = ''
545 }
546 for (let c = 0; c < line.length; c++) {
547 const n = peakAt.get(c)
548 if (n === undefined) {
549 run += line[c]
550 continue
551 }
552 flush()
553 parts.push(
554 <Text color="suggestion" hover={{ scope: `peak-${n}`, color: 'claude', bold: true }}>
555 {line[c]}
556 </Text>,
557 )
558 }
559 flush()
560 return parts
561 }
562
563 return (
564 <Box flexDirection="column" paddingX={PAD}>
565 {/* three tiles on one grid */}
566 <Text>
567 {tiles.map((t, i) => (
568 <Text bold dimColor>
569 {cell(t.title, i)}
570 </Text>
571 ))}
572 </Text>
573 <Text>
574 {tiles.map((t, i) => (
575 <Text bold color={LEVEL_COLOR[t.level]}>
576 {cell(`${Math.round(t.percent)}%`, i)}
577 </Text>
578 ))}
579 </Text>
580 <Text>
581 {tiles.map((t, i) => {
582 const n = Math.min(meterW, Math.max(t.percent > 0 ? 1 : 0, Math.round((t.percent / 100) * meterW)))
583 return (
584 <Text>
585 <Text color={LEVEL_COLOR[t.level]}>{BLOCK.repeat(n)}</Text>
586 <Text dimColor>{EMPTY.repeat(meterW - n)}</Text>
587 {' '.repeat(colW - meterW + (i < tiles.length - 1 ? gap : 0))}
588 </Text>
589 )
590 })}
591 </Text>
592 {[0, 1].map(row =>
593 tiles.some(t => t.lines[row]) ? (
594 <Text dimColor>{tiles.map((t, i) => cell(t.lines[row] ?? '', i)).join('')}</Text>
595 ) : null,
596 )}
597 {tempo && <Text>{t.speed(tempo.kind, tempo.ratio)}</Text>}
598 {sectionGap}
599
600 {/* what fills the window */}
601 {titleRow(t.whatFills, k(snap.used))}
602 <Text>
603 {usedGroups.map((g, i) => (
604 <Text>
605 {i > 0 ? ' ' : ''}
606 <Text color={g.color}>{BLOCK.repeat(stack[i] ?? 0)}</Text>
607 </Text>
608 ))}
609 </Text>
610 {columnsHead}
611 {usedGroups.map(g => {
612 const name = g.label.slice(0, NAME - 3)
613 return (
614 <Box key={`row-${g.id}`} flexDirection="row">
615 <Text color={g.color}>■ </Text>
616 <Button key={`group-${g.id}`} plain label={g.label} onPress={go({ kind: 'group', id: g.id })}>
617 {name}
618 </Button>
619 <Text>
620 {' '.repeat(NAME - 2 - name.length)}
621 {k(g.tokens).padStart(NUM)}
622 <Text dimColor>{pctOfUsed(g.tokens).padStart(PCT)}</Text>
623 <Text dimColor>{' ▸'}</Text>
624 </Text>
625 </Box>
626 )
627 })}
628 {hint && (
629 <Box flexDirection="column">
630 <Text> </Text>
631 {/* the arrow in its own column, so wrapped lines line up with the buttons */}
632 <Box flexDirection="row">
633 <Text bold color="claude">
634 →{' '}
635 </Text>
636 <Box flexDirection="column" flexShrink={1}>
637 <Text>{hint.text}</Text>
638 </Box>
639 </Box>
640 <Box flexDirection="row" paddingLeft={2}>
641 {hint.prompt && (
642 <Button key="hint-fill" plain label={t.hintFill} onPress={putInChat(hint)}>
643 <Text color="claude">{t.hintFill}</Text>
644 </Button>
645 )}
646 {hint.prompt && <Text>{' '}</Text>}
647 <Button key="hint-hide" plain label={t.hintHide} onPress={() => update($, hidden, prev => [...prev, hint.id])}>
648 <Text dimColor>{t.hintHide}</Text>
649 </Button>
650 </Box>
651 </Box>
652 )}
653 {sectionGap}
654
655 {/* what one action costs */}
656 <Text bold>{t.prices.toUpperCase()}</Text>
657 <Text dimColor>{t.pricesHint}</Text>
658 {prices.length === 0 && <Text dimColor>{t.noActions}</Text>}
659 {Array.from({ length: half }, (_, row) => {
660 const left = prices[row]
661 const right = prices[row + half]
662 const priceCell = (p: typeof left) =>
663 p ? `${p.name.padEnd(priceW - 7).slice(0, priceW - 7)}${k(p.average).padStart(5)}` : ''
664 return (
665 <Text>
666 {priceCell(left).padEnd(priceW + 4)}
667 {priceCell(right)}
668 </Text>
669 )
670 })}
671 {sectionGap}
672
673 {/* the whole session, compactions included */}
674 {session && session.requests > 0 && (
675 <Box flexDirection="column">
676 {titleNote(t.session, t.since(resetLabel(new Date(session.startedAt).toISOString(), now)))}
677 <Text>{t.sessionRuns(Math.max(0, Math.round((now - session.startedAt) / 60_000)), session.prompts ?? 0)}</Text>
678 {fiveSpent !== null && <Text>{t.sessionSpent(fiveSpent, weekSpent)}</Text>}
679 {fiveSpent !== null && fiveSpent > 0 && (session.prompts ?? 0) > 0 && (
680 <Text>{t.perMessage(fiveSpent / (session.prompts ?? 1))}</Text>
681 )}
682 <Text>
683 {t.compactsLine(session.compacts.length, k(session.compacts.reduce((n, c) => n + Math.max(0, c.before - c.after), 0)))}
684 </Text>
685 {sectionGap}
686 </Box>
687 )}
688
689 {/* folded details */}
690 <Button key="block-dynamics" plain label={t.growth} onPress={toggle('block:dynamics')}>
691 <Text>
692 <Text dimColor>{isOpen('block:dynamics') ? '▾ ' : '▸ '}</Text>
693 {t.growth}
694 </Text>
695 </Button>
696 {isOpen('block:dynamics') && (
697 <Box flexDirection="column" paddingLeft={2}>
698 {chartRows(
699 rises,
700 CHART_ROWS,
701 ).map((line, i) => (
702 // A row of sibling Texts, not one Text: a nested Text can't light its hover group,
703 // and a peak's cells must light the list line too.
704 <Box flexDirection="row">
705 <Text dimColor>{`${(i === 0 ? k(chartMax) : '').padStart(5)} `}</Text>
706 {chartLine(line)}
707 </Box>
708 ))}
709 <Text dimColor>
710 {' 0 '}
711 {shown.length > 2 ? t.turnRange(shown[1]!.turn, shown[shown.length - 1]!.turn) : ''}
712 </Text>
713 {peaks.map((j, n) => (
714 <Text wrap="truncate-end" hover={{ scope: `peak-${n + 1}`, color: 'claude' }}>
715 {`+${k(j.delta)}`.padStart(6)}
716 <Text dimColor>{` ${t.turn(j.turn)} `}</Text>
717 {j.cause ?? t.conversation}
718 </Text>
719 ))}
720 {(session?.compacts ?? []).map(c => (
721 <Text wrap="truncate-end">
722 {`−${k(Math.max(0, c.before - c.after))}`.padStart(6)}
723 <Text dimColor>{` ${t.turn(c.turn)} `}</Text>
724 {t.compactLine(c.trigger === 'manual' ? t.manual : c.trigger === 'auto' ? t.auto : t.plugin, k(c.before), k(c.after))}
725 </Text>
726 ))}
727 </Box>
728 )}
729
730 {/* only once the figures are five minutes old; limits move only when a reply comes in */}
731 {now - (snap.at ?? now) >= STALE_MS && <Text> </Text>}
732 {now - (snap.at ?? now) >= STALE_MS && (
733 <Box flexDirection="row">
734 <Text dimColor>{`${t.stale(resetLabel(new Date(snap.at).toISOString(), now))} · `}</Text>
735 <Button key="refresh" plain label={t.refresh} onPress={() => refresh($)}>
736 <Text color="claude">{t.refresh}</Text>
737 </Button>
738 </Box>
739 )}
740 </Box>
741 )
742 } catch (err) {
743 const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
744 $.ui.log(`chudarin-context-mod: pane render failed: ${text}`)
745 const { Text } = $.ui.resolve(e)
746 return <Text color="error">chudarin-context-mod: {text}</Text>
747 }
748 })
749}
750hooks/entries.ts 242 lines1// Turns /context categories and the conversation into the dashboard's groups and entries.
2import type { Category, Item } from '../types'
3
4import { type GroupId, helpFor } from './help'
5import { type Lang, type Strings, UI } from './i18n'
6import { IMAGE_TOKENS, type Message, k, kind, label } from './model'
7
8export type Call = { label: string; tokens: number }
9export type Entry = { id: string; label: string; group: GroupId; tokens: number; count: number; top: Call[] }
10export type GroupTotal = { id: GroupId; label: string; color: string; tokens: number }
11export type Price = { name: string; count: number; average: number }
12
13// Fixed order: a group keeps its color whatever its size. Blue, orange, aqua, violet from the
14// dataviz categorical palette, aqua and violet a step darker so one set works on any terminal:
15// checked with the dataviz validator on #000000, #1a1a19, #282828, #eeeeee, #f5f5f0 and #ffffff,
16// every check passing on each. Yellow is skipped: it means "warning" on the meters.
17export const GROUPS: readonly { id: GroupId; text: keyof Strings; color: string }[] = [
18 { id: 'tools', text: 'groupTools', color: '#3987e5' },
19 { id: 'service', text: 'groupService', color: '#d95926' },
20 { id: 'chat', text: 'groupChat', color: '#178f66' },
21 { id: 'rules', text: 'groupRules', color: '#7a6ce4' },
22]
23
24export function groupLabel(id: GroupId, lang: Lang): string {
25 const g = GROUPS.find(x => x.id === id) ?? GROUPS[0]!
26 return UI[lang][g.text] as string
27}
28
29const TOP_CALLS = 5
30const CODE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit'])
31const SKILL_BODY = /^Base directory for this skill: (\S+)/
32
33type Block = { type: string; [field: string]: unknown }
34
35function textOf(content: unknown): { chars: number; images: number } {
36 if (typeof content === 'string') return { chars: content.length, images: 0 }
37 if (!Array.isArray(content)) return { chars: 0, images: 0 }
38 let chars = 0
39 let images = 0
40 for (const b of content as Block[]) {
41 if (b.type === 'text' && typeof b.text === 'string') chars += b.text.length
42 if (b.type === 'image') images += 1
43 }
44 return { chars, images }
45}
46
47type Piece = { id: string; tokens: number; call: string | null; action: string | null; callId: string | null }
48
49// Every piece of the conversation, tagged with its entry id, the call it belongs to and the action kind.
50function pieces(messages: readonly Message[]): Piece[] {
51 const out: Piece[] = []
52 const calls = new Map<string, { name: string; label: string }>()
53 for (const m of messages) {
54 for (const b of m.content) {
55 if (m.role === 'assistant') {
56 if (b.type === 'text' && typeof b.text === 'string')
57 out.push({ id: 'assistant', tokens: b.text.length / 4, call: null, action: null, callId: null })
58 if (b.type === 'thinking' && typeof b.thinking === 'string')
59 out.push({ id: 'thinking', tokens: b.thinking.length / 4, call: null, action: null, callId: null })
60 if (b.type === 'tool_use' && typeof b.id === 'string' && typeof b.name === 'string') {
61 const input = (b.input ?? {}) as Record<string, unknown>
62 const name = b.name
63 const callLabel = label({ ...input, tool: name })
64 calls.set(b.id, { name, label: callLabel })
65 out.push({
66 id: CODE_TOOLS.has(name) ? 'code' : 'args',
67 tokens: JSON.stringify(input).length / 4,
68 call: callLabel,
69 action: name,
70 callId: b.id,
71 })
72 }
73 continue
74 }
75 if (b.type === 'tool_result') {
76 const call = typeof b.tool_use_id === 'string' ? calls.get(b.tool_use_id) : undefined
77 const { chars, images } = textOf(b.content)
78 // A Skill call answers with one line; its body arrives as its own row below.
79 const id = call?.name === 'Skill' ? 'args' : `result:${kind(call?.name ?? 'tool')}`
80 out.push({
81 id,
82 tokens: chars / 4,
83 call: call?.label ?? null,
84 action: call?.name ?? null,
85 callId: typeof b.tool_use_id === 'string' ? b.tool_use_id : null,
86 })
87 for (let i = 0; i < images; i++)
88 out.push({ id: 'images', tokens: IMAGE_TOKENS, call: null, action: 'image', callId: null })
89 } else if (b.type === 'text' && typeof b.text === 'string') {
90 const text = b.text.trimStart()
91 const skill = SKILL_BODY.exec(text)
92 if (skill) {
93 const name = skill[1]!.split('/').filter(Boolean).pop() ?? 'skill'
94 out.push({ id: 'skills-loaded', tokens: text.length / 4, call: name, action: 'skill', callId: null })
95 } else if (text.startsWith('<system-reminder>')) {
96 out.push({ id: 'reminders', tokens: text.length / 4, call: null, action: null, callId: null })
97 } else {
98 out.push({ id: 'user', tokens: text.length / 4, call: null, action: null, callId: null })
99 }
100 } else if (b.type === 'image') {
101 out.push({ id: 'images', tokens: IMAGE_TOKENS, call: null, action: 'image', callId: null })
102 }
103 }
104 }
105 return out
106}
107
108// All entries: categories from /context, with Messages replaced by the parts of the conversation.
109export function buildEntries(
110 categories: readonly Category[],
111 details: Record<string, Item[]>,
112 messages: readonly Message[],
113 lang: Lang,
114): Entry[] {
115 const entries: Entry[] = []
116 for (const c of categories) {
117 if (c.isFree || /^messages/i.test(c.name) || c.tokens <= 0) continue
118 const id = `cat:${c.name}`
119 const help = helpFor(id, lang)
120 const list = details[c.name] ?? []
121 entries.push({
122 id,
123 label: help.label,
124 group: help.group,
125 tokens: c.tokens,
126 count: list.length,
127 top: list.slice(0, TOP_CALLS).map(i => ({ label: i.name, tokens: i.tokens })),
128 })
129 }
130
131 const sums = new Map<string, { tokens: number; count: number; calls: Map<string, number> }>()
132 for (const p of pieces(messages)) {
133 const s = sums.get(p.id) ?? { tokens: 0, count: 0, calls: new Map<string, number>() }
134 s.tokens += p.tokens
135 s.count += 1
136 if (p.call) s.calls.set(p.call, (s.calls.get(p.call) ?? 0) + p.tokens)
137 sums.set(p.id, s)
138 }
139 const raw = [...sums.values()].reduce((n, s) => n + s.tokens, 0)
140 const measured = categories.find(c => /^messages/i.test(c.name))?.tokens ?? 0
141 const scale = raw > 0 && measured > 0 ? measured / raw : 1
142
143 for (const [id, s] of sums) {
144 const tokens = Math.round(s.tokens * scale)
145 if (tokens <= 0) continue
146 const help = helpFor(id, lang)
147 entries.push({
148 id,
149 label: help.label,
150 group: help.group,
151 tokens,
152 count: s.count,
153 top: [...s.calls]
154 .map(([callLabel, t]) => ({ label: callLabel, tokens: Math.round(t * scale) }))
155 .sort((a, b) => b.tokens - a.tokens)
156 .slice(0, TOP_CALLS),
157 })
158 }
159 return entries.sort((a, b) => b.tokens - a.tokens)
160}
161
162export function groupTotals(entries: readonly Entry[], lang: Lang): GroupTotal[] {
163 return GROUPS.map(g => ({
164 id: g.id,
165 label: groupLabel(g.id, lang),
166 color: g.color,
167 tokens: entries.filter(e => e.group === g.id).reduce((n, e) => n + e.tokens, 0),
168 }))
169}
170
171const ACTION_TEXT: Record<string, keyof Strings> = {
172 Write: 'actWrite',
173 Edit: 'actEdit',
174 MultiEdit: 'actEdit',
175 NotebookEdit: 'actNotebook',
176 Read: 'actRead',
177 Bash: 'actBash',
178 WebSearch: 'actWebSearch',
179 WebFetch: 'actWebFetch',
180 Grep: 'actSearch',
181 Glob: 'actSearch',
182 Agent: 'actAgent',
183 image: 'actImage',
184 skill: 'actSkill',
185}
186
187function actionName(action: string, lang: Lang): string | null {
188 if (action === 'Skill' || action === 'AskUserQuestion' || action === 'ToolSearch') return null
189 if (action.startsWith('mcp__')) return `MCP ${action.split('__')[1]}`
190 const text = ACTION_TEXT[action]
191 return text ? (UI[lang][text] as string) : action
192}
193
194// Average price of one action: what I sent plus what came back, in estimated tokens.
195export function actionPrices(messages: readonly Message[], lang: Lang): Price[] {
196 const perCall = new Map<string, { action: string; tokens: number }>()
197 const single: { action: string; tokens: number }[] = []
198 for (const p of pieces(messages)) {
199 if (!p.action) continue
200 if (p.callId) {
201 const c = perCall.get(p.callId) ?? { action: p.action, tokens: 0 }
202 c.tokens += p.tokens
203 perCall.set(p.callId, c)
204 } else {
205 single.push({ action: p.action, tokens: p.tokens })
206 }
207 }
208 const sums = new Map<string, { count: number; tokens: number }>()
209 for (const { action, tokens } of [...perCall.values(), ...single]) {
210 const name = actionName(action, lang)
211 if (!name) continue
212 const s = sums.get(name) ?? { count: 0, tokens: 0 }
213 sums.set(name, { count: s.count + 1, tokens: s.tokens + tokens })
214 }
215 return [...sums]
216 .map(([name, s]) => ({ name, count: s.count, average: Math.round(s.tokens / s.count) }))
217 .filter(p => p.average > 0)
218 .sort((a, b) => b.average - a.average)
219}
220
221// One line worth acting on, or nothing.
222export type Hint = { id: string; text: string; prompt: string | null }
223
224// The biggest entry with advice, if it holds over a tenth of the used context.
225// `hidden`: hints the person acted on or hid this session.
226export function insight(
227 entries: readonly Entry[],
228 fill: number,
229 used: number,
230 lang: Lang,
231 hidden: readonly string[] = [],
232): Hint | null {
233 for (const e of entries) {
234 const help = helpFor(e.id, lang)
235 if (!help.todo || hidden.includes(e.id)) continue
236 if (used > 0 && e.tokens / used > 0.1)
237 return { id: e.id, text: `${e.label} ${help.took} ${k(e.tokens)}. ${help.todo}`, prompt: help.prompt }
238 return null
239 }
240 return null
241}
242hooks/help.ts 342 lines1// Plain-language names and help for every kind of entry the dashboard shows, in Russian and English.
2import type { Lang } from './i18n'
3
4export type GroupId = 'tools' | 'service' | 'chat' | 'rules'
5
6type Text = {
7 label: string
8 what: string
9 // What the person can do about it; null when nothing can be done.
10 todo: string | null
11 // What the hint's button puts in the prompt box: a request to Claude or a command.
12 prompt?: string
13}
14
15export type Help = Omit<Text, 'prompt'> & {
16 prompt: string | null
17 group: GroupId
18 // The insight sentence: "<label> <took> <tokens>. <todo>"
19 took: string
20}
21
22type Entry = {
23 group: GroupId
24 // Past-tense Russian verb agreeing with the label.
25 took: 'занял' | 'заняла' | 'заняли' | 'заняло'
26 ru: Text
27 en: Text
28}
29
30const WEB_RU = 'Дальше ищи в интернете и читай страницы в субагенте, в чат возвращай только итог.'
31const WEB_EN = 'From now on, search the web and read pages in a subagent, and bring back only the summary.'
32
33const HELP: Record<string, Entry> = {
34 'result:Bash': {
35 group: 'tools',
36 took: 'занял',
37 ru: {
38 label: 'Вывод команд Bash',
39 what: 'Всё, что напечатали команды, которые запускал Claude: тесты, сборка, поиск, git. Вывод остаётся в контексте до конца сессии.',
40 todo: 'Проси Claude запускать тесты и разбирать длинные логи в субагенте: в чат вернётся только итог.',
41 prompt: 'Дальше запускай тесты и разбирай длинные логи в субагенте, в чат возвращай только итог.',
42 },
43 en: {
44 label: 'Bash output',
45 what: 'Everything the commands Claude ran printed: tests, builds, searches, git. The output stays in the context until the session ends.',
46 todo: 'Ask Claude to run tests and go through long logs in a subagent: only the summary comes back.',
47 prompt: 'From now on, run tests and go through long logs in a subagent, and bring back only the summary.',
48 },
49 },
50 'result:Read': {
51 group: 'tools',
52 took: 'заняли',
53 ru: {
54 label: 'Прочитанные файлы',
55 what: 'Содержимое файлов, которые прочитал Claude. Файл остаётся в контексте целиком, даже если нужна была пара строк.',
56 todo: 'Называй в задаче файл и функцию: чем точнее просьба, тем меньше Claude читает.',
57 },
58 en: {
59 label: 'Files read',
60 what: 'The contents of files Claude read. A whole file stays in the context even when only a few lines were needed.',
61 todo: 'Name the file and function in your request: the more precise it is, the less Claude reads.',
62 },
63 },
64 'result:WebSearch': {
65 group: 'tools',
66 took: 'занял',
67 ru: { label: 'Поиск в вебе', what: 'Результаты поиска в интернете: заголовки, ссылки, выдержки.', todo: 'Проси Claude искать в интернете и читать страницы в субагенте: в чат вернётся только итог.', prompt: WEB_RU },
68 en: { label: 'Web search', what: 'Web search results: titles, links, snippets.', todo: 'Ask Claude to search the web and read pages in a subagent: only the summary comes back.', prompt: WEB_EN },
69 },
70 'result:WebFetch': {
71 group: 'tools',
72 took: 'заняли',
73 ru: {
74 label: 'Загруженные страницы',
75 what: 'Веб-страницы, которые Claude загрузил и пересказал в текст.',
76 todo: 'Проси Claude искать в интернете и читать страницы в субагенте: в чат вернётся только итог.',
77 prompt: WEB_RU,
78 },
79 en: {
80 label: 'Fetched pages',
81 what: 'Web pages Claude fetched and turned into text.',
82 todo: 'Ask Claude to search the web and read pages in a subagent: only the summary comes back.',
83 prompt: WEB_EN,
84 },
85 },
86 'result:Grep': {
87 group: 'tools',
88 took: 'занял',
89 ru: { label: 'Поиск по файлам', what: 'Найденные строки и пути файлов.', todo: 'Называй в задаче файл и функцию: чем точнее просьба, тем меньше Claude читает.' },
90 en: { label: 'File search', what: 'Matching lines and file paths.', todo: 'Name the file and function in your request: the more precise it is, the less Claude reads.' },
91 },
92 'result:Glob': {
93 group: 'tools',
94 took: 'заняли',
95 ru: { label: 'Списки файлов', what: 'Пути файлов, найденные по шаблону.', todo: 'Называй в задаче файл и функцию: чем точнее просьба, тем меньше Claude читает.' },
96 en: { label: 'File lists', what: 'File paths matched by a pattern.', todo: 'Name the file and function in your request: the more precise it is, the less Claude reads.' },
97 },
98 'result:Agent': {
99 group: 'tools',
100 took: 'заняли',
101 ru: {
102 label: 'Отчёты субагентов',
103 what: 'Итоговые отчёты субагентов. Их собственная работа в этот контекст не попадает, только отчёт.',
104 todo: null,
105 },
106 en: {
107 label: 'Subagent reports',
108 what: "Final reports from subagents. Their own work stays out of this context; only the report comes back.",
109 todo: null,
110 },
111 },
112 'result:AskUserQuestion': {
113 group: 'chat',
114 took: 'заняли',
115 ru: { label: 'Твои ответы на вопросы', what: 'Варианты и ответы из вопросов, которые задавал Claude.', todo: null },
116 en: { label: 'Your answers to questions', what: 'Options and answers from the questions Claude asked.', todo: null },
117 },
118 'result:ToolSearch': {
119 group: 'service',
120 took: 'заняли',
121 ru: { label: 'Подгруженные схемы', what: 'Описания инструментов, которые Claude подгрузил по ходу работы.', todo: null },
122 en: { label: 'Loaded tool schemas', what: 'Descriptions of tools Claude loaded along the way.', todo: null },
123 },
124 code: {
125 group: 'tools',
126 took: 'занял',
127 ru: {
128 label: 'Код, который написал Claude',
129 what: 'Содержимое файлов, которые Claude создал или поправил через Write и Edit. Текст уходит целиком и остаётся в контексте.',
130 todo: null,
131 },
132 en: {
133 label: 'Code Claude wrote',
134 what: 'The contents of files Claude created or changed with Write and Edit. The text is sent in full and stays in the context.',
135 todo: null,
136 },
137 },
138 args: {
139 group: 'tools',
140 took: 'заняли',
141 ru: { label: 'Аргументы вызовов', what: 'Команды, запросы и параметры, с которыми Claude вызывал инструменты.', todo: null },
142 en: { label: 'Tool call arguments', what: 'Commands, queries and parameters Claude passed to tools.', todo: null },
143 },
144 reminders: {
145 group: 'service',
146 took: 'заняли',
147 ru: {
148 label: 'Служебные вставки',
149 what: 'Сообщения, которые Claude Code сам добавляет в разговор: список скиллов, изменения в файлах, состояние задач, подсказки хуков. Чем длиннее сессия, тем их больше.',
150 todo: null,
151 },
152 en: {
153 label: 'System reminders',
154 what: 'Messages Claude Code adds to the conversation on its own: the skill list, file changes, task state, hook notes. The longer the session, the more of them.',
155 todo: null,
156 },
157 },
158 'skills-loaded': {
159 group: 'rules',
160 took: 'заняли',
161 ru: {
162 label: 'Загруженные скиллы',
163 what: 'Полный текст скиллов, которые вызвал Claude. Скилл остаётся в контексте до конца сессии.',
164 todo: null,
165 },
166 en: {
167 label: 'Loaded skills',
168 what: 'The full text of skills Claude invoked. A skill stays in the context until the session ends.',
169 todo: null,
170 },
171 },
172 images: {
173 group: 'chat',
174 took: 'заняли',
175 ru: {
176 label: 'Скриншоты и картинки',
177 what: 'Картинки, которые ты прислал или вернули инструменты. Вес оценён грубо: 1.5k за штуку.',
178 todo: null,
179 },
180 en: {
181 label: 'Screenshots and images',
182 what: 'Images you sent or tools returned. The weight is a rough guess: 1.5k each.',
183 todo: null,
184 },
185 },
186 user: {
187 group: 'chat',
188 took: 'заняли',
189 ru: { label: 'Твои сообщения', what: 'Всё, что ты написал или вставил в чат.', todo: null },
190 en: { label: 'Your messages', what: 'Everything you typed or pasted into the chat.', todo: null },
191 },
192 assistant: {
193 group: 'chat',
194 took: 'заняли',
195 ru: { label: 'Ответы Claude', what: 'Текст ответов Claude тебе.', todo: null },
196 en: { label: "Claude's replies", what: "The text of Claude's replies to you.", todo: null },
197 },
198 thinking: {
199 group: 'chat',
200 took: 'заняли',
201 ru: { label: 'Рассуждения Claude', what: 'Рассуждения Claude перед ответом, которые возвращаются в контекст.', todo: 'На простых задачах снижай /effort: рассуждений станет меньше.', prompt: '/effort' },
202 en: { label: "Claude's thinking", what: 'Reasoning before a reply that goes back into the context.', todo: 'Lower /effort on simple tasks: Claude will reason less.', prompt: '/effort' },
203 },
204 'cat:System prompt': {
205 group: 'service',
206 took: 'занял',
207 ru: { label: 'Системный промпт', what: 'Базовые инструкции Claude Code. Есть в каждой сессии, уменьшить нельзя.', todo: null },
208 en: { label: 'System prompt', what: "Claude Code's base instructions. Present in every session; it cannot be reduced.", todo: null },
209 },
210 'cat:System tools': {
211 group: 'service',
212 took: 'заняли',
213 ru: {
214 label: 'Схемы встроенных инструментов',
215 what: 'Описания Bash, Read, Edit и других встроенных инструментов. Отправляются с каждым запросом.',
216 todo: null,
217 },
218 en: {
219 label: 'Built-in tool schemas',
220 what: 'Descriptions of Bash, Read, Edit and the other built-in tools. Sent with every request.',
221 todo: null,
222 },
223 },
224 'cat:MCP tools': {
225 group: 'service',
226 took: 'заняли',
227 ru: {
228 label: 'Схемы MCP-инструментов',
229 what: 'Описания подгруженных инструментов MCP-серверов.',
230 todo: 'Отключи ненужные серверы в /mcp. Где есть CLI вроде gh, он не занимает контекст описаниями инструментов.',
231 prompt: '/mcp',
232 },
233 en: { label: 'MCP tool schemas', what: 'Descriptions of loaded MCP server tools.', todo: "Turn off servers you don't use in /mcp. Where a CLI like gh exists, it adds no tool descriptions to the context.",
234 prompt: '/mcp',
235 },
236 },
237 'cat:MCP server instructions': {
238 group: 'service',
239 took: 'заняли',
240 ru: {
241 label: 'Инструкции MCP-серверов',
242 what: 'Правила, которые MCP-серверы добавляют в начало сессии.',
243 todo: 'Отключи ненужные серверы в /mcp. Где есть CLI вроде gh, он не занимает контекст описаниями инструментов.',
244 prompt: '/mcp',
245 },
246 en: {
247 label: 'MCP server instructions',
248 what: 'Rules MCP servers add at the start of a session.',
249 todo: "Turn off servers you don't use in /mcp. Where a CLI like gh exists, it adds no tool descriptions to the context.",
250 prompt: '/mcp',
251 },
252 },
253 'cat:Custom agents': {
254 group: 'rules',
255 took: 'заняли',
256 ru: { label: 'Описания агентов', what: 'Описания твоих агентов из .claude/agents, чтобы Claude знал, кого можно позвать.', todo: null },
257 en: { label: 'Agent descriptions', what: 'Descriptions of your agents from .claude/agents, so Claude knows whom it can call.', todo: null },
258 },
259 'cat:Memory files': {
260 group: 'rules',
261 took: 'заняли',
262 ru: {
263 label: 'CLAUDE.md и память',
264 what: 'Твои файлы инструкций: CLAUDE.md и индекс памяти. Загружаются в каждую сессию.',
265 todo: 'Держи CLAUDE.md до 200 строк, инструкции для отдельных процессов перенеси в скиллы: они загружаются, только когда нужны.',
266 prompt: 'Сократи CLAUDE.md до 200 строк: инструкции для отдельных процессов вынеси в скиллы.',
267 },
268 en: {
269 label: 'CLAUDE.md and memory',
270 what: 'Your instruction files: CLAUDE.md and the memory index. Loaded into every session.',
271 todo: 'Keep CLAUDE.md under 200 lines; move instructions for specific workflows into skills, which load only when needed.',
272 prompt: 'Trim CLAUDE.md to under 200 lines: move instructions for specific workflows into skills.',
273 },
274 },
275 'cat:Skills': {
276 group: 'rules',
277 took: 'занял',
278 ru: {
279 label: 'Список скиллов',
280 what: 'Короткие описания всех установленных скиллов, чтобы Claude знал, какие есть.',
281 todo: 'Удали скиллы, которыми не пользуешься.',
282 },
283 en: {
284 label: 'Skill list',
285 what: 'Short descriptions of every installed skill, so Claude knows what is there.',
286 todo: "Remove skills you don't use.",
287 },
288 },
289}
290
291export const HELP_IDS = Object.keys(HELP)
292
293const flat = (e: Entry, lang: Lang): Help => ({
294 ...e[lang],
295 prompt: e[lang].prompt ?? null,
296 group: e.group,
297 took: lang === 'ru' ? e.took : 'took',
298})
299
300export function helpFor(id: string, lang: Lang): Help {
301 const known = HELP[id]
302 if (known) return flat(known, lang)
303 if (id.startsWith('result:MCP ')) {
304 const server = id.slice('result:MCP '.length)
305 return flat(
306 {
307 group: 'tools',
308 took: 'заняли',
309 ru: {
310 label: `Ответы MCP ${server}`,
311 what: `Данные, которые вернул MCP-сервер ${server}.`,
312 todo: null,
313 },
314 en: { label: `MCP ${server} replies`, what: `Data the ${server} MCP server returned.`, todo: null },
315 },
316 lang,
317 )
318 }
319 if (id.startsWith('result:')) {
320 const tool = id.slice('result:'.length)
321 return flat(
322 {
323 group: 'tools',
324 took: 'заняли',
325 ru: { label: `Результаты ${tool}`, what: `Ответы инструмента ${tool}.`, todo: null },
326 en: { label: `${tool} results`, what: `What the ${tool} tool returned.`, todo: null },
327 },
328 lang,
329 )
330 }
331 const name = id.startsWith('cat:') ? id.slice('cat:'.length) : id
332 return flat(
333 {
334 group: 'service',
335 took: 'заняла',
336 ru: { label: name, what: 'Часть контекста, которую /context считает отдельной категорией.', todo: null },
337 en: { label: name, what: 'A part of the context that /context counts as its own category.', todo: null },
338 },
339 lang,
340 )
341}
342hooks/i18n.ts 199 lines1// Interface strings in Russian and English. The language follows Claude Code's `language` setting.
2
3export type Lang = 'ru' | 'en'
4
5type SpeedKind = 'snail' | 'cat' | 'rabbit' | 'rocket'
6const ICON: Record<SpeedKind, string> = { snail: '🐌', cat: '🐈', rabbit: '🐇', rocket: '🚀' }
7// Minutes, rounded for a forecast: to 5 under an hour, to 10 above.
8const roundMinutes = (m: number) => (m < 60 ? Math.max(5, Math.round(m / 5) * 5) : Math.round(m / 10) * 10)
9const duration = (m: number, h: string, min: string) => {
10 const r = roundMinutes(m)
11 const hours = Math.floor(r / 60)
12 const rest = r % 60
13 return [hours ? `${hours} ${h}` : '', rest ? `${rest} ${min}` : ''].filter(Boolean).join(' ')
14}
15// Time a session has run: "2 h 30 min", "40 min", "2 h".
16const elapsed = (m: number, h: string, min: string) => {
17 const hours = Math.floor(m / 60)
18 const rest = m % 60
19 return [hours ? `${hours} ${h}` : '', rest || !hours ? `${rest} ${min}` : ''].filter(Boolean).join(' ')
20}
21// A percent to one decimal, without a trailing .0.
22const percent = (x: number, comma: boolean) => {
23 const s = String(Math.round(x * 10) / 10)
24 return comma ? s.replace('.', ',') : s
25}
26// Russian noun form for a count: 1 сообщение, 2 сообщения, 5 сообщений.
27const plural = (n: number, one: string, few: string, many: string) =>
28 n % 10 === 1 && n % 100 !== 11 ? one : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? few : many
29// How many times faster or slower, to one decimal: 1.74 → 1.7, 0.5 → 2.
30const times = (ratio: number) => Math.round((ratio >= 1 ? ratio : 1 / ratio) * 10) / 10
31
32export function langOf(setting: unknown): Lang {
33 return typeof setting === 'string' && /^(ru|russian|русск)/i.test(setting.trim()) ? 'ru' : 'en'
34}
35
36const ru = {
37 cmdPanel: 'Панель: что съедает контекст',
38 cmdLine: 'Строка контекста над полем ввода: вкл/выкл',
39 panelClosed: 'Панель контекста закрыта.',
40 panelOpened: 'Панель открыта. Esc или /ctx закрывает.',
41 lineOn: 'Строка контекста включена.',
42 lineOff: 'Строка контекста выключена.',
43 paneTitle: 'Контекст',
44 noData: 'Нет данных до первого ответа.',
45 renderFailed: 'отрисовка упала',
46 back: '← Назад',
47 entryGone: 'Этого пункта сейчас нет в контексте.',
48 whatIs: 'Что это',
49 whySoMuch: 'Почему столько',
50 times: (n: number, avg: string) => `${n} раз, в среднем ${avg} за раз`,
51 once: '1 раз',
52 heaviest: 'Самые тяжёлые:',
53 whatToDo: 'Что делать',
54 nothingToDo: 'Ничего: это постоянная часть работы Claude Code.',
55 emptyGroup: 'Здесь пока пусто.',
56 ofUsed: (tokens: string, pct: string) => `${tokens} · ${pct} занятого`,
57 tileContext: 'КОНТЕКСТ',
58 tile5h: 'ЛИМИТ 5 Ч',
59 tileWeek: 'ЛИМИТ НЕДЕЛЯ',
60 ofWindow: (used: string, window: string) => `${used} из ${window}`,
61 turnsLeft: (n: number) => `хватит на ~${n} ходов`,
62 limitLeft: (minutes: number) => `хватит на ~${duration(minutes, 'ч', 'мин')}`,
63 limitUntilReset: 'хватит до сброса',
64 hintFill: 'Вставить в чат',
65 hintHide: 'Скрыть',
66 hintSent: '✓ Отправлено Claude',
67 adviceHidden: '✓ Совет скрыт',
68 hideAdvice: 'Скрыть совет',
69 neverShow: 'Не показывать снова',
70 hintRan: '✓ Команда запущена',
71 speed: (kind: SpeedKind, ratio: number) => {
72 if (kind === 'cat') return `${ICON.cat} контекст растёт как обычно`
73 const x = times(ratio)
74 const word = Number.isInteger(x) && x >= 5 ? 'раз' : 'раза'
75 return `${ICON[kind]} контекст растёт в ${String(x).replace('.', ',')} ${word} ${kind === 'snail' ? 'медленнее' : 'быстрее'} обычного`
76 },
77 resets: (at: string) => `сброс ${at}`,
78 wasReset: (at: string) => `сбросился в ${at}`,
79 stale: (at: string) => `Данные устарели, последнее обновление в ${at}`,
80 refresh: 'Обновить',
81 whatFills: 'Чем занят контекст',
82 prices: 'Сколько стоит действие',
83 pricesHint: 'чем дешевле способ, тем дольше хватит контекста',
84 noActions: 'Пока нет действий.',
85 session: 'За сессию',
86 since: (at: string) => `с ${at}`,
87 sessionRuns: (minutes: number, prompts: number) =>
88 `Идёт ${elapsed(minutes, 'ч', 'мин')}, ${prompts} ${plural(prompts, 'твоё сообщение', 'твоих сообщения', 'твоих сообщений')}`,
89 sessionSpent: (five: number, week: number | null) =>
90 `Съела ${percent(five, true)}% лимита на 5 часов${week === null ? '' : ` и ${percent(week, true)}% недельного`}`,
91 perMessage: (pct: number) => `Одно сообщение в среднем: ${percent(pct, true)}% лимита на 5 часов`,
92 compactsLine: (n: number, dropped: string) => (n === 0 ? 'Компактов не было' : `Компактов: ${n}, сбросили ${dropped}`),
93 growth: 'График загрузки контекста',
94 turnRange: (from: number, to: number) => `ход ${from} → ход ${to}`,
95 turn: (n: number) => `ход ${n}`,
96 conversation: 'переписка',
97 compactLine: (how: string, before: string, after: string) => `компакт ${how}: ${before} → ${after}`,
98 manual: 'вручную',
99 auto: 'автоматически',
100 plugin: 'плагином',
101 groupTools: 'Работа инструментов',
102 groupService: 'Служебное Claude Code',
103 groupChat: 'Наш разговор',
104 groupRules: 'Скиллы и правила',
105 actWrite: 'Записать файл',
106 actEdit: 'Правка файла',
107 actNotebook: 'Правка ноутбука',
108 actRead: 'Прочитать файл',
109 actBash: 'Команда в Bash',
110 actWebSearch: 'Поиск в вебе',
111 actWebFetch: 'Загрузить страницу',
112 actSearch: 'Поиск по файлам',
113 actAgent: 'Субагент',
114 actImage: 'Скриншот',
115 actSkill: 'Скилл',
116}
117
118const en: typeof ru = {
119 cmdPanel: 'Panel: what fills your context',
120 cmdLine: 'Context line above the prompt: on/off',
121 panelClosed: 'Context panel closed.',
122 panelOpened: 'Panel open. Esc or /ctx closes it.',
123 lineOn: 'Context line on.',
124 lineOff: 'Context line off.',
125 paneTitle: 'Context',
126 noData: 'No data until the first reply.',
127 renderFailed: 'drawing failed',
128 back: '← Back',
129 entryGone: 'This item is not in the context right now.',
130 whatIs: 'What it is',
131 whySoMuch: 'Why so much',
132 times: (n, avg) => `${n} times, ${avg} each on average`,
133 once: 'once',
134 heaviest: 'Heaviest:',
135 whatToDo: 'What to do',
136 nothingToDo: 'Nothing: this is a fixed part of how Claude Code works.',
137 emptyGroup: 'Nothing here yet.',
138 ofUsed: (tokens, pct) => `${tokens} · ${pct} of used`,
139 tileContext: 'CONTEXT',
140 tile5h: '5-HOUR LIMIT',
141 tileWeek: 'WEEKLY LIMIT',
142 ofWindow: (used, window) => `${used} of ${window}`,
143 turnsLeft: n => `about ${n} turns left`,
144 limitLeft: minutes => `about ${duration(minutes, 'h', 'min')} left`,
145 limitUntilReset: 'lasts until reset',
146 hintFill: 'Put in chat',
147 hintHide: 'Hide',
148 hintSent: '✓ Sent to Claude',
149 adviceHidden: '✓ Advice hidden',
150 hideAdvice: 'Hide advice',
151 neverShow: "Don't show again",
152 hintRan: '✓ Command run',
153 speed: (kind, ratio) =>
154 kind === 'cat'
155 ? `${ICON.cat} context grows at your usual pace`
156 : `${ICON[kind]} context grows ${times(ratio)}× ${kind === 'snail' ? 'slower' : 'faster'} than usual`,
157 resets: at => `resets ${at}`,
158 wasReset: at => `reset at ${at}`,
159 stale: at => `Data is stale, last updated at ${at}`,
160 refresh: 'Refresh',
161 whatFills: 'What fills the context',
162 prices: 'What an action costs',
163 pricesHint: 'the cheaper the way, the longer the context lasts',
164 noActions: 'No actions yet.',
165 session: 'This session',
166 since: at => `since ${at}`,
167 sessionRuns: (minutes, prompts) => `Running ${elapsed(minutes, 'h', 'min')}, ${prompts} ${prompts === 1 ? 'message' : 'messages'} from you`,
168 sessionSpent: (five, week) =>
169 `Used ${percent(five, false)}% of the 5-hour limit${week === null ? '' : ` and ${percent(week, false)}% of the weekly one`}`,
170 perMessage: pct => `One message on average: ${percent(pct, false)}% of the 5-hour limit`,
171 compactsLine: (n, dropped) => (n === 0 ? 'No compactions' : `Compactions: ${n}, dropped ${dropped}`),
172 growth: 'Context load chart',
173 turnRange: (from, to) => `turn ${from} → turn ${to}`,
174 turn: n => `turn ${n}`,
175 conversation: 'conversation',
176 compactLine: (how, before, after) => `${how} compaction: ${before} → ${after}`,
177 manual: 'manual',
178 auto: 'automatic',
179 plugin: 'plugin',
180 groupTools: 'Tool work',
181 groupService: 'Claude Code itself',
182 groupChat: 'Our conversation',
183 groupRules: 'Skills and rules',
184 actWrite: 'Write a file',
185 actEdit: 'Edit a file',
186 actNotebook: 'Edit a notebook',
187 actRead: 'Read a file',
188 actBash: 'Bash command',
189 actWebSearch: 'Web search',
190 actWebFetch: 'Fetch a page',
191 actSearch: 'Search files',
192 actAgent: 'Subagent',
193 actImage: 'Screenshot',
194 actSkill: 'Skill',
195}
196
197export const UI: Record<Lang, typeof ru> = { ru, en }
198export type Strings = typeof ru
199hooks/history.ts 65 lines1// The session log: what the model read and wrote, the context after each turn and every
2// compaction. Kept in $.store per session, so it survives a restart or --resume.
3import type { SessionLog } from '../types'
4
5const MAX_POINTS = 300
6
7export function emptyLog(startedAt: number): SessionLog {
8 return { startedAt, requests: 0, read: 0, cached: 0, written: 0, points: [], compacts: [], prompts: 0, limits: {} }
9}
10
11export function sessionKey(id: string): string {
12 return `session:${id}`
13}
14
15type Usage = {
16 input_tokens: number
17 cache_read_input_tokens: number
18 cache_creation_input_tokens: number
19 output_tokens: number
20}
21
22// One model request: everything it read (fresh, cached and newly cached) and what it wrote.
23export function addRequest(log: SessionLog, u: Usage): SessionLog {
24 return {
25 ...log,
26 requests: log.requests + 1,
27 read: log.read + u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
28 cached: log.cached + u.cache_read_input_tokens,
29 written: log.written + u.output_tokens,
30 }
31}
32
33// The context size after a turn, with the heaviest step of that turn as its likely cause.
34export function addPoint(log: SessionLog, tokens: number, cause: string | null): SessionLog {
35 const last = log.points[log.points.length - 1]
36 if (last?.tokens === tokens) return log
37 const point = { tokens, turn: (last?.turn ?? 0) + 1, cause }
38 return { ...log, points: [...log.points, point].slice(-MAX_POINTS) }
39}
40
41// One more prompt from the person. Logs written before this field count from zero.
42export function addPrompt(log: SessionLog): SessionLog {
43 return { ...log, prompts: (log.prompts ?? 0) + 1 }
44}
45
46// A plan limit's percent now. Rises in the same window add up; after a reset the percent
47// starts from zero, so all of it counts.
48export function addLimit(log: SessionLog, kind: string, resetsAt: string, percent: number): SessionLog {
49 const limits = log.limits ?? {}
50 const prev = limits[kind]
51 const spent = !prev ? 0 : prev.resetsAt === resetsAt ? prev.spent + Math.max(0, percent - prev.last) : prev.spent + percent
52 return { ...log, limits: { ...limits, [kind]: { resetsAt, last: percent, spent } } }
53}
54
55export function addCompact(log: SessionLog, trigger: string, before: number, after: number): SessionLog {
56 const turn = log.points[log.points.length - 1]?.turn ?? 0
57 return { ...log, compacts: [...log.compacts, { turn, trigger, before, after }] }
58}
59
60// Session keys to drop so that only the newest `keep` remain (the store holds them in insertion order).
61export function pruneKeys(keys: readonly string[], keep: number): string[] {
62 const sessions = keys.filter(k => k.startsWith('session:'))
63 return sessions.slice(0, Math.max(0, sessions.length - keep))
64}
65hooks/model.ts 194 lines1// Formatting and small maths shared by the panel: no engine calls and no words here.
2import type { Category, Item, LimitTrack, Point } from '../types'
3
4const BARS = '▁▂▃▄▅▆▇█'
5const EIGHTHS = ' ▁▂▃▄▅▆▇█'
6// A flat guess per image: the transcript does not carry their pixel size.
7export const IMAGE_TOKENS = 1500
8
9type Block = { type: string; [field: string]: unknown }
10export type Message = { role: 'user' | 'assistant'; content: readonly Block[] }
11
12export function k(tokens: number): string {
13 if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
14 if (tokens >= 9_950) return `${Math.round(tokens / 1000)}k`
15 if (tokens >= 1000) return `${(tokens / 1000).toFixed(1).replace(/\.0$/, '')}k`
16 return String(tokens)
17}
18
19// Cut a text to max characters on a word boundary, ending with an ellipsis.
20export function shorten(text: string, max: number): string {
21 if (text.length <= max) return text
22 const cut = text.slice(0, max - 1)
23 const space = cut.lastIndexOf(' ')
24 return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`
25}
26
27// Group name for a tool: built-in tools by name, MCP tools by server.
28export function kind(tool: string): string {
29 return tool.startsWith('mcp__') ? `MCP ${tool.split('__')[1]}` : tool
30}
31
32// Short name for a tool call: tool plus its main argument.
33export function label(e: Record<string, unknown>): string {
34 const tool = String(e.tool)
35 const short = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(':') : tool
36 const arg =
37 (typeof e.file_path === 'string' && e.file_path.split('/').pop()) ||
38 (typeof e.skill === 'string' && e.skill) ||
39 (typeof e.description === 'string' && e.description) ||
40 (typeof e.command === 'string' && e.command.split(/\s+/).slice(0, 2).join(' ')) ||
41 (typeof e.query === 'string' && e.query) ||
42 (typeof e.url === 'string' && e.url) ||
43 (typeof e.pattern === 'string' && e.pattern) ||
44 ''
45 return arg ? `${short} ${shorten(arg, 45)}` : short
46}
47
48export function sparkline(points: readonly number[]): string {
49 if (points.length === 0) return ''
50 const lo = Math.min(...points)
51 const hi = Math.max(...points)
52 const span = hi - lo || 1
53 return points.map(p => BARS[Math.round(((p - lo) / span) * (BARS.length - 1))]).join('')
54}
55
56// A column chart from zero, one column per value, top row first; each cell in eighths.
57export function chartRows(values: readonly number[], height: number): string[] {
58 const max = Math.max(0, ...values)
59 return Array.from({ length: height }, (_, i) => {
60 const row = height - 1 - i
61 return values
62 .map(v => {
63 const level = max > 0 ? Math.round((v / max) * height * 8) : 0
64 return EIGHTHS[Math.min(8, Math.max(0, level - row * 8))]
65 })
66 .join('')
67 })
68}
69
70const two = (n: number) => String(n).padStart(2, '0')
71
72// Local time: HH:MM today, otherwise DD.MM HH:MM.
73export function resetLabel(iso: string, now: number): string {
74 const at = new Date(iso)
75 const today = new Date(now)
76 const time = `${two(at.getHours())}:${two(at.getMinutes())}`
77 const isToday =
78 at.getFullYear() === today.getFullYear() && at.getMonth() === today.getMonth() && at.getDate() === today.getDate()
79 return isToday ? time : `${two(at.getDate())}.${two(at.getMonth() + 1)} ${time}`
80}
81
82// Average rise per turn: over the last five turns and over all of them.
83export function pace(points: readonly Point[]): { recent: number; overall: number } {
84 const rises: number[] = []
85 for (let i = 1; i < points.length; i++) rises.push(Math.max(0, points[i]!.tokens - points[i - 1]!.tokens))
86 const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0)
87 return { recent: mean(rises.slice(-5)), overall: mean(rises) }
88}
89
90// How much the context grew each turn; drops (compactions) are left out.
91function rises(points: readonly { tokens: number }[]): number[] {
92 const out: number[] = []
93 for (let i = 1; i < points.length; i++) {
94 const d = points[i]!.tokens - points[i - 1]!.tokens
95 if (d > 0) out.push(d)
96 }
97 return out
98}
99
100function median(xs: readonly number[]): number {
101 const s = [...xs].sort((a, b) => a - b)
102 const mid = Math.floor(s.length / 2)
103 return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2
104}
105
106// The usual rise per turn: the median, over past sessions, of each one's median rise.
107// Medians, so one session full of screenshots doesn't set the norm.
108export function usualRise(logs: readonly { points: readonly { tokens: number }[] }[]): number | null {
109 const perSession = logs.map(l => rises(l.points)).filter(r => r.length >= 5).map(median)
110 return perSession.length >= 3 ? median(perSession) : null
111}
112
113export type SpeedKind = 'snail' | 'cat' | 'rabbit' | 'rocket'
114
115// The last few turns against the usual pace.
116export function speed(points: readonly Point[], usual: number | null): { kind: SpeedKind; ratio: number } | null {
117 const recent = rises(points).slice(-5)
118 if (!usual || recent.length < 3) return null
119 const ratio = Math.round((median(recent) / usual) * 100) / 100
120 const kind: SpeedKind = ratio < 0.67 ? 'snail' : ratio < 1.5 ? 'cat' : ratio < 2 ? 'rabbit' : 'rocket'
121 return { kind, ratio }
122}
123
124const LIMIT_SAMPLES = 300
125const MINUTE = 60_000
126
127// Samples of one limit window; a new reset time starts a new track.
128export function trackLimit(track: LimitTrack | null, resetsAt: string, at: number, percent: number): LimitTrack {
129 const samples = track?.resetsAt === resetsAt ? track.samples : []
130 return { resetsAt, samples: [...samples, { at, percent }].slice(-LIMIT_SAMPLES) }
131}
132
133// When the limit runs out at the pace of the last hour; 'untilReset' when it lasts until the reset.
134// Null until there are at least ten minutes of samples in that hour.
135export function limitForecast(track: LimitTrack, now: number, resetsAt: number): { minutes: number } | 'untilReset' | null {
136 const recent = track.samples.filter(s => s.at >= now - 60 * MINUTE)
137 const first = recent[0]
138 const last = recent[recent.length - 1]
139 if (!first || !last || now - first.at < 10 * MINUTE) return null
140 const perMinute = (last.percent - first.percent) / ((now - first.at) / MINUTE)
141 if (perMinute <= 0) return 'untilReset'
142 const minutes = Math.round((100 - last.percent) / perMinute)
143 return now + minutes * MINUTE >= resetsAt ? 'untilReset' : { minutes }
144}
145
146// What each turn added to the context, oldest first; a compaction's drop counts as nothing.
147export function turnRises(points: readonly { tokens: number }[]): number[] {
148 return points.slice(1).map((p, i) => Math.max(0, p.tokens - points[i]!.tokens))
149}
150
151// The biggest rises from one turn to the next, with what likely caused them.
152export function jumps(points: readonly Point[], n: number): { delta: number; turn: number; cause: string | null }[] {
153 const out: { delta: number; turn: number; cause: string | null }[] = []
154 for (let i = 1; i < points.length; i++) {
155 const delta = points[i]!.tokens - points[i - 1]!.tokens
156 if (delta > 0) out.push({ delta, turn: points[i]!.turn, cause: points[i]!.cause })
157 }
158 return out.sort((a, b) => b.delta - a.delta).slice(0, n)
159}
160
161// The /context rows that sit in the window: used content by size, then free space.
162export function windowRows(
163 rows: readonly { name: string; tokens: number; kind: string; color: string }[],
164): Category[] {
165 const used = rows.filter(r => r.kind === 'used').sort((a, b) => b.tokens - a.tokens)
166 const free = rows.filter(r => r.kind === 'free')
167 return [...used, ...free].map(({ name, tokens, color, kind }) => ({ name, tokens, color, isFree: kind === 'free' }))
168}
169
170type BreakdownLists = {
171 categories: readonly { name: string; tokens: number }[]
172 skills?: { skillFrontmatter: readonly { name: string; tokens: number }[] }
173 memoryFiles: readonly { path: string; tokens: number }[]
174 mcpTools: readonly { name: string; serverName: string; tokens: number; isLoaded: boolean }[]
175 agents: readonly { agentType: string; tokens: number }[]
176}
177
178// Per-item lists for the /context categories that have them, keyed by the category's name.
179export function detailsFor(b: BreakdownLists): Record<string, Item[]> {
180 const lists: [RegExp, () => Item[]][] = [
181 [/skill/i, () => (b.skills?.skillFrontmatter ?? []).map(s => ({ name: s.name, tokens: s.tokens }))],
182 [/memory/i, () => b.memoryFiles.map(f => ({ name: f.path.split('/').slice(-2).join('/'), tokens: f.tokens }))],
183 [/mcp tools/i, () => b.mcpTools.filter(t => t.isLoaded).map(t => ({ name: `${t.serverName}: ${t.name}`, tokens: t.tokens }))],
184 [/agent/i, () => b.agents.map(a => ({ name: a.agentType, tokens: a.tokens }))],
185 ]
186 const out: Record<string, Item[]> = {}
187 for (const { name } of b.categories) {
188 const match = lists.find(([re]) => re.test(name))
189 const items = match ? match[1]() : []
190 if (items.length > 0) out[name] = [...items].sort((x, y) => y.tokens - x.tokens)
191 }
192 return out
193}
194types/index.d.ts 78 lines1export type Step = { label: string; tokens: number }
2export type Category = { name: string; tokens: number; color: string; isFree: boolean }
3export type Item = { name: string; tokens: number }
4export type Point = { tokens: number; turn: number; cause: string | null }
5export type Compact = { turn: number; trigger: string; before: number; after: number }
6// Samples of one rate-limit window, keyed by its reset time.
7export type LimitTrack = { resetsAt: string; samples: { at: number; percent: number }[] }
8
9// One session's running totals, kept in $.store under `session:<id>`.
10export type SessionLog = {
11 startedAt: number
12 requests: number
13 // Everything the model read across requests: fresh, cached and newly cached input.
14 read: number
15 cached: number
16 written: number
17 points: Point[]
18 compacts: Compact[]
19 // Prompts the person sent (slash commands left out).
20 prompts: number
21 // What this session spent of each plan limit, by kind (five_hour, seven_day).
22 limits: Record<string, LimitSpend>
23}
24
25// One plan limit as the session saw it: the last percent and how much it has added up.
26export type LimitSpend = { resetsAt: string; last: number; spent: number }
27
28export type GroupId = 'tools' | 'service' | 'chat' | 'rules'
29
30// Everything the pane reads, refreshed after each turn.
31export type Snapshot = {
32 lang: 'ru' | 'en'
33 // When it was read, for the "updated at" line.
34 at: number
35 entries: {
36 id: string
37 label: string
38 group: GroupId
39 tokens: number
40 count: number
41 top: { label: string; tokens: number }[]
42 }[]
43 prices: { name: string; count: number; average: number }[]
44 limits: { kind: string; percentUsed: number; resetsAt?: string }[]
45 // Where auto-compaction starts, or the whole window when it is off.
46 threshold: number
47 window: number
48 used: number
49}
50
51export type Page = { kind: 'home' } | { kind: 'group'; id: string } | { kind: 'entry'; id: string }
52
53declare module 'claude-code' {
54 interface PluginState {
55 'chudarin-context-mod': {
56 log: SessionLog | null
57 heaviest: Step | null
58 snapshot: Snapshot | null
59 expanded: string[]
60 page: Page
61 bandOn: boolean
62 lang: 'ru' | 'en'
63 // The usual context rise per turn over past sessions; null until there are enough.
64 usual: number | null
65 // Hints acted on or hidden this session, by entry id.
66 hidden: string[]
67 // Advice sent to Claude or run as a command this session, by entry id.
68 sent: string[]
69 // Advice the person hid for good, by entry id; kept in $.store under `muted`.
70 muted: string[]
71 // Samples of the 5-hour limit in its current window, for the forecast; kept in $.store.
72 limitTrack: LimitTrack | null
73 // Written once a minute; the pane reads it, so the write redraws an open pane.
74 minute: number
75 }
76 }
77}
78