SLOPSHOPPER

chudarin-context-mod

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…

newpanebandguardcommandprompt
v0.1.0MITupdated 2026-10-16schudarin/chudarin-context-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chudarin-context-mod
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ No data until the first reply. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ctx │ ⎿ chudarin-context-mod: Panel open. Esc or /ctx closes it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context
No data until the first reply.
README

<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.

Русская версия

👀 What you see

The panel's home page

From top to bottom:

  • Context, 5-hour limit, weekly limit. Three meters, green, yellow or red. Under the context meter, roughly how many turns are left before auto-compaction at the current pace, and how fast the context grows compared with your past sessions (🐌 🐈 🐇 🚀; it needs three sessions of five or more turns). Under the 5-hour meter, how long the limit lasts at the pace of the last hour.
  • What fills the context. One bar for the whole window, split into four groups: tool work, Claude Code itself, your conversation, skills and rules. Click a group to open it.
  • A hint. One line, only when one item is big and you can do something about it: "Bash output took 61k. Ask Claude to run tests and go through long logs in a subagent: only the summary comes back." "Put in chat" puts a ready request or command in the prompt box; you send it yourself. "Hide" hides it for the session; the item page also has "Don't show again". Most of the advice comes from Anthropic's guide to reducing token usage.
  • What an action costs. The average price of one screenshot, one file written, one Bash command, one skill. This is the part that teaches: you see which habits are expensive.
  • This session. How long it has run and how many messages you sent, how much of the 5-hour and weekly limits it used, what one message costs on average, and the compactions. A long session makes every message dearer, since each one sends the whole context again. The limits are shared, so sessions open side by side count too.
  • Context load chart, folded. One column per turn, as tall as what that turn added, so a heavy turn stands out as a peak. Below it, the five biggest jumps with their cause (point at one to light its column), and every compaction.

Click a group to see its items, click an item to get its help page:

A groupAn item
Tool work, item by itemBash output: what it is, why so much, what to do

The panel speaks English unless Claude Code's language setting is Russian.

📦 Install

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.

🕹️ Using it

CommandWhat it does
/ctxOpens the panel. Again, or Esc while the panel has focus, closes it
/ctx-lineTurns 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.

🔢 Where the numbers come from

  • Categories are the ones /context shows, from Claude Code's own estimate.
  • Messages are split by reading the conversation: tool results by tool, code Claude wrote, tool arguments, reminders Claude Code adds, loaded skills, your messages, Claude's replies, images. The split is estimated at about 4 characters per token, then scaled so the parts add up to the Messages total from /context.
  • Images count as 1.5k each. The conversation doesn't carry their size, so this is a guess.
  • This session adds up the rises of each limit's percent while the session runs, and counts the prompts you send (slash commands aside). The log is kept per session, survives a restart and --resume, and holds the last 30 sessions. It starts when the mod is installed; earlier history isn't counted.
  • Limits come from your plan's rate-limit windows. With an API key there are none, and the limit meters don't show.

Nothing leaves your machine. The mod makes no network calls.

⚠️ Limits

  • Mods are an early-access part of Claude Code. An update can change the API and break the panel until I fix it.
  • Tested in a terminal on macOS. The desktop app is covered by tests only; VS Code and mobile aren't tested.
  • Where an organization manages Claude Code, hooks on compaction don't run in plugins a person installs. The panel works, but the compaction lines in "This session" stay at zero.

🔄 Update and remove

claude plugin update chudarin-context-mod@chudarin      # newest version, restart to apply
claude plugin uninstall chudarin-context-mod@chudarin   # remove

🎨 Also by me

chudarin-design-skills: a design agent for Figma. A separate plugin in the same catalog.

📄 License

MIT, see LICENSE.

Source 7 files
hooks/register.tsx 750 lines
1import { 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}
750
hooks/entries.ts 242 lines
1// 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}
242
hooks/help.ts 342 lines
1// 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}
342
hooks/i18n.ts 199 lines
1// 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
199
hooks/history.ts 65 lines
1// 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}
65
hooks/model.ts 194 lines
1// 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}
194
types/index.d.ts 78 lines
1export 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