SLOPSHOPPER

ctx-usage-dock

A docked pane and a status line that break the context window down by category: messages, skills, MCP tools, memory files, rules and agents

newpanecommandstatus
v0.2.0no licenseupdated 2026-10-24seanmars/my-agent-plugins/plugins/ctx-usage-dock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx-usage-dock
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ No count yet. Press Refresh. │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Refresh ] [ Full count ] [ Turn off ] ⎿ 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-usage-dock │ ⎿ ctx-usage-dock: ctx-usage-dock is on. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context
No count yet. Press Refresh. [ Refresh ] [ Full count ] [ Turn off ]
README

ctx-usage-dock

在 transcript 右側 dock 一個 pane, 依類別顯示 context window 的用量. Status line 也顯示一行摘要.

19% · 38.0k / 200.0k
claude-opus-5-5 · summary · 12:05 · compacts at 167.0k
████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
System prompt                  3.0k 1.5%
MCP tools                      5.0k 2.5%
Messages                     30.0k 15.0%
Free space                  162.0k 81.0%

Skills (2/2 listed)                 700
  group-commit · group-commit       500
MCP servers                        5.0k
  linear · 2/3 loaded              5.0k
Memory files                       1.2k
  .claude/CLAUDE.md · User         1.2k
Rules                               400
  rules/style.md · Project          400

[ Refresh ] [ Full count ] [ Turn off ]

行為

項目作用
pane和 /context 相同的類別, 加上 Skills, MCP servers, Memory files, Rules, Agents, Slash commands 的明細. 每組最多列 8 項
status linectx 19% 38.0k/200.0k │ Messages 30.0k · MCP tools 5.0k · System prompt 3.0k
自動更新session 開始, main loop 每個 turn 結束, compaction 之後, /clear 或 /resume 之後
Refresh (r)用 summary 重新計算
Full count (f)用 full 重新計算
Turn off (o)和 /ctx-usage-dock off 相同
/ctx-usage-dock 或 /ctx-usage-dock on開啟功能, 開啟 pane 並重新計算
/ctx-usage-dock off關閉功能: 關閉 pane, 清除 status line, 停止自動計算. 設定存在 $.store, 之後的 session 也保持關閉

運作方式

  • 資料取自 $.session.usage({ breakdown }), 格式和 SDK 的 get_context_usage 相同.
  • summary 在本地估算, 不呼叫 API. full 對每個 tool 和 memory file 呼叫 token-count API, 和 /context 相同.
  • Rules 是 path 在 .claude/rules/ 下的 memory file. 其他 memory file 歸在 Memory files.
  • MCP servers 的 tokens 只計算已載入的 schema. 按需載入且還沒被搜尋的 schema 不在 window 裡.

限制

  • Terminal 需要 fullscreen 且至少 110 欄, pane 才會 dock 在右側. 寬度不足時 pane 顯示在 prompt 上方.
  • Session 開始時自動開啟 pane 需要至少 144 欄. 寬度不足時, 執行 /ctx-usage-dock 開啟.
  • summary 是估算值, totalTokens 不一定等於 API 回報的 input tokens.

需求

  • Claude Code ≥ 2.1.310
  • CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

開發

claude --plugin-dir ./plugins/ctx-usage-dock --debug   # 存檔即 hot-reload
claude plugin validate ./plugins/ctx-usage-dock
claude plugin test ./plugins/ctx-usage-dock
Source 2 files
hooks/register.tsx 330 lines
1/**
2 * ctx-usage-dock: the context window broken down by category, as /context
3 * breaks it down, in a pane docked beside the transcript and on the status line.
4 *
5 * Each main-loop turn, compaction and new session recounts with the free
6 * `summary` estimate; the pane's `Full count` button asks the token-count API
7 * for each category, as /context does.
8 *
9 * `/ctx-usage-dock off` closes the pane, clears the status line and stops the
10 * counting, in this session and the next ones, until `/ctx-usage-dock on`.
11 *
12 * Everything that takes `$` is a top-level declaration: the loader inventories
13 * what a hooks module reaches for through `$`.
14 */
15import { atom, read, update } from 'claude-code'
16import type { ContextBreakdownDetail, EngineInterface, RenderElement, Register, SessionContextBreakdown } from 'claude-code'
17
18import type { UsageCategory, UsageGroup, UsageItem, UsageSnapshot } from '../types'
19
20const PLUGIN = 'ctx-usage-dock'
21const PANE = 'ctx-usage'
22const TITLE = 'Context'
23/** Items listed under a group before `+N more`. */
24const TOP_ITEMS = 8
25/** Categories the status line names after the percentage. */
26const STATUS_CATEGORIES = 3
27/** A rules file sits in a `.claude/rules` folder; every other memory file is memory. */
28const RULES_PATH = /[\\/]\.claude[\\/]rules[\\/]/
29/** The `$.store` key that keeps the on/off switch across sessions; unset is on. */
30const ENABLED_KEY = 'enabled'
31
32// Change the tag when UsageSnapshot's fields change: it declines what an
33// earlier version stored, which outlives a reload.
34const snapshot = atom({ plugin: 'ctx-usage-dock', key: 'snapshot' } as const, null, { shape: 'snapshot-1' })
35const isCounting = atom({ plugin: 'ctx-usage-dock', key: 'isCounting' } as const, false)
36
37/** One count at a time: a turn that ends while a full count runs waits for the next. */
38let isRunning = false
39
40// ── Numbers ─────────────────────────────────────────────────────────────────
41
42function formatTokens(tokens: number): string {
43  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
44  if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`
45  return String(tokens)
46}
47
48function formatPercent(tokens: number, max: number): string {
49  return max > 0 ? `${((tokens / max) * 100).toFixed(1)}%` : ''
50}
51
52function formatTime(epochMs: number): string {
53  const at = new Date(epochMs)
54  return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
55}
56
57/** The last two segments of a path: `.claude/CLAUDE.md`. */
58function shortPath(path: string): string {
59  return path.split(/[\\/]/).filter(Boolean).slice(-2).join('/')
60}
61
62const byTokens = (a: { tokens: number }, b: { tokens: number }) => b.tokens - a.tokens
63const sumTokens = (items: readonly { tokens: number }[]) => items.reduce((sum, item) => sum + item.tokens, 0)
64
65// ── Breakdown to snapshot ───────────────────────────────────────────────────
66
67function group(title: string, items: UsageItem[], note?: string): UsageGroup {
68  return { title, tokens: sumTokens(items), note, items: [...items].sort(byTokens) }
69}
70
71/** One item per MCP server: the tokens of its loaded schemas, and how many are loaded. */
72function mcpServers(breakdown: SessionContextBreakdown): UsageItem[] {
73  const servers = new Map<string, { tokens: number; loaded: number; total: number }>()
74  for (const tool of breakdown.mcpTools) {
75    const server = servers.get(tool.serverName) ?? { tokens: 0, loaded: 0, total: 0 }
76    server.total += 1
77    if (tool.isLoaded) {
78      server.loaded += 1
79      server.tokens += tool.tokens
80    }
81    servers.set(tool.serverName, server)
82  }
83  return [...servers].map(([name, server]) => ({
84    label: name,
85    tokens: server.tokens,
86    note: `${server.loaded}/${server.total} loaded`,
87  }))
88}
89
90function memoryItems(breakdown: SessionContextBreakdown, isRules: boolean): UsageItem[] {
91  return breakdown.memoryFiles
92    .filter((file) => RULES_PATH.test(file.path) === isRules)
93    .map((file) => ({ label: shortPath(file.path), tokens: file.tokens, note: file.type }))
94}
95
96function toSnapshot(breakdown: SessionContextBreakdown, detail: ContextBreakdownDetail, countedAt: number): UsageSnapshot {
97  const { skills, slashCommands } = breakdown
98  const groups = [
99    skills &&
100      group(
101        'Skills',
102        skills.skillFrontmatter.map((skill) => ({ label: skill.name, tokens: skill.tokens, note: skill.pluginName })),
103        `${skills.includedSkills}/${skills.totalSkills} listed`,
104      ),
105    breakdown.mcpTools.length > 0 && group('MCP servers', mcpServers(breakdown)),
106    group('Memory files', memoryItems(breakdown, false)),
107    group('Rules', memoryItems(breakdown, true)),
108    breakdown.agents.length > 0 &&
109      group('Agents', breakdown.agents.map((agent) => ({ label: agent.agentType, tokens: agent.tokens, note: agent.source }))),
110    slashCommands && {
111      title: 'Slash commands',
112      tokens: slashCommands.tokens,
113      note: `${slashCommands.includedCommands}/${slashCommands.totalCommands} listed`,
114      items: [],
115    },
116  ].filter((one): one is UsageGroup => Boolean(one) && (one as UsageGroup).tokens > 0)
117
118  return {
119    detail,
120    countedAt,
121    model: breakdown.model,
122    totalTokens: breakdown.totalTokens,
123    maxTokens: breakdown.rawMaxTokens,
124    percentage: breakdown.percentage,
125    autoCompactAt: breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined,
126    categories: breakdown.categories.map(({ name, tokens, color, kind }) => ({ name, tokens, color, kind })),
127    groups,
128  }
129}
130
131/** `ctx 42% 84.2k/200k │ Messages 30.1k · System tools 12.4k · MCP tools 8.0k` */
132function statusLine(snap: UsageSnapshot): string {
133  const head = `ctx ${snap.percentage}% ${formatTokens(snap.totalTokens)}/${formatTokens(snap.maxTokens)}`
134  const top = snap.categories
135    .filter((category) => category.kind === 'used' && category.tokens > 0)
136    .sort(byTokens)
137    .slice(0, STATUS_CATEGORIES)
138    .map((category) => `${category.name} ${formatTokens(category.tokens)}`)
139  return top.length > 0 ? `${head} │ ${top.join(' · ')}` : head
140}
141
142// ── On and off ──────────────────────────────────────────────────────────────
143
144async function isEnabled($: EngineInterface): Promise<boolean> {
145  return (await $.store.get(ENABLED_KEY)) !== false
146}
147
148async function turnOn($: EngineInterface): Promise<void> {
149  await $.store.set(ENABLED_KEY, true)
150  await $.ui.open({ id: PANE, title: TITLE })
151  await recount($, 'summary')
152}
153
154async function turnOff($: EngineInterface): Promise<void> {
155  await $.store.set(ENABLED_KEY, false)
156  await $.ui.close({ id: PANE })
157  $.ui.status(undefined)
158}
159
160/** The count a turn, a compaction or a new session asks for: none while turned off. */
161async function autoRecount($: EngineInterface): Promise<void> {
162  if (await isEnabled($)) await recount($, 'summary')
163}
164
165// ── Counting ────────────────────────────────────────────────────────────────
166
167async function recount($: EngineInterface, detail: ContextBreakdownDetail): Promise<void> {
168  if (isRunning) return
169  isRunning = true
170  try {
171    await update($, isCounting, () => true)
172    const { context } = await $.session.usage({ breakdown: detail })
173    if (!context.breakdown) return
174    const snap = toSnapshot(context.breakdown, detail, await $.clock.now())
175    await update($, snapshot, () => snap)
176    // Turned off while the count ran: the status line stays clear.
177    if (await isEnabled($)) $.ui.status(statusLine(snap))
178  } catch (error) {
179    $.ui.log(`${PLUGIN}: counting the context failed: ${String(error)}`, { to: 'debug' })
180  } finally {
181    isRunning = false
182    await update($, isCounting, () => false).catch(() => undefined)
183  }
184}
185
186// ── Drawing ─────────────────────────────────────────────────────────────────
187
188type Site = Parameters<EngineInterface['ui']['resolve']>[0]
189
190/** The window as one bar: each used category its own colour, the rest free. */
191function windowBar($: EngineInterface, site: Site, snap: UsageSnapshot, width: number): RenderElement {
192  const { Box, Text } = $.ui.resolve(site)
193  const max = Math.max(snap.maxTokens, snap.totalTokens, 1)
194  let used = 0
195  let drawn = 0
196  const segments = snap.categories
197    .filter((category) => category.kind === 'used' && category.tokens > 0)
198    .map((category) => {
199      used += category.tokens
200      const end = Math.round((used / max) * width)
201      const cells = end - drawn
202      drawn = end
203      return cells > 0 ? <Text color={category.color}>{'█'.repeat(cells)}</Text> : null
204    })
205  return (
206    <Box flexDirection="row">
207      {segments}
208      <Text dimColor>{'░'.repeat(Math.max(0, width - drawn))}</Text>
209    </Box>
210  )
211}
212
213function row($: EngineInterface, site: Site, label: string, value: string, color?: string): RenderElement {
214  const { Box, Text } = $.ui.resolve(site)
215  return (
216    <Box flexDirection="row" justifyContent="space-between" gap={1}>
217      <Text color={color} wrap="truncate-end">
218        {label}
219      </Text>
220      <Text>{value}</Text>
221    </Box>
222  )
223}
224
225function groupBlock($: EngineInterface, site: Site, usageGroup: UsageGroup): RenderElement {
226  const { Box, Text } = $.ui.resolve(site)
227  const shown = usageGroup.items.slice(0, TOP_ITEMS)
228  const hidden = usageGroup.items.length - shown.length
229  const title = usageGroup.note ? `${usageGroup.title} (${usageGroup.note})` : usageGroup.title
230  return (
231    <Box flexDirection="column" marginTop={1}>
232      <Box flexDirection="row" justifyContent="space-between" gap={1}>
233        <Text bold>{title}</Text>
234        <Text bold>{formatTokens(usageGroup.tokens)}</Text>
235      </Box>
236      {shown.map((item) =>
237        row($, site, `  ${item.label}${item.note ? ` · ${item.note}` : ''}`, formatTokens(item.tokens)),
238      )}
239      {hidden > 0 && <Text dimColor>{`  +${hidden} more`}</Text>}
240    </Box>
241  )
242}
243
244function snapshotView($: EngineInterface, site: Site, snap: UsageSnapshot, width: number): RenderElement {
245  const { Box, Text } = $.ui.resolve(site)
246  const compactAt = snap.autoCompactAt ? ` · compacts at ${formatTokens(snap.autoCompactAt)}` : ''
247  return (
248    <Box flexDirection="column">
249      <Text bold>{`${snap.percentage}% · ${formatTokens(snap.totalTokens)} / ${formatTokens(snap.maxTokens)}`}</Text>
250      <Text dimColor wrap="truncate-end">{`${snap.model} · ${snap.detail} · ${formatTime(snap.countedAt)}${compactAt}`}</Text>
251      {windowBar($, site, snap, width)}
252      <Box flexDirection="column" marginTop={1}>
253        {snap.categories.map((category) =>
254          row(
255            $,
256            site,
257            category.name,
258            `${formatTokens(category.tokens)} ${formatPercent(category.tokens, snap.maxTokens)}`.trim(),
259            category.kind === 'used' ? category.color : undefined,
260          ),
261        )}
262      </Box>
263      {snap.groups.map((usageGroup) => groupBlock($, site, usageGroup))}
264    </Box>
265  )
266}
267
268export const register: Register = (on) => {
269  on('session.start', async ($, e, next) => {
270    await $.command.register({
271      name: PLUGIN,
272      description: 'Show the context usage pane, or turn it off and on',
273      argumentHint: '[on|off]',
274    })
275    if (await isEnabled($)) void $.ui.open({ id: PANE, title: TITLE })
276    const started = await next(e)
277    await autoRecount($)
278    return started
279  })
280
281  on('command.run', { command: PLUGIN }, async ($, e) => {
282    const arg = e.args.trim().toLowerCase()
283    if (arg === 'off') {
284      await turnOff($)
285      return { text: 'ctx-usage-dock is off. Run /ctx-usage-dock on to turn it on again.' }
286    }
287    if (arg !== '' && arg !== 'on') return { text: 'Usage: /ctx-usage-dock [on|off]' }
288    await turnOn($)
289    return { text: 'ctx-usage-dock is on.' }
290  })
291
292  on('turn.complete', async ($, e, next) => {
293    const done = await next(e)
294    if (e.agentId === undefined) await autoRecount($)
295    return done
296  })
297
298  on('session.compact', async ($, e, next) => {
299    const compacted = await next(e)
300    if (e.agentId === undefined) await autoRecount($)
301    return compacted
302  })
303
304  // `/clear`, `/resume` and a fork switch sessions without another `session.start`.
305  on('classic.SessionStart', async ($, e, next) => {
306    const started = await next(e)
307    await autoRecount($)
308    return started
309  }).catch(($, e, next) => next(e))
310
311  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
312    const { Box, Text, Button } = $.ui.resolve(e)
313    const snap = await read($, snapshot)
314    const counting = await read($, isCounting)
315    const width = Math.max(10, e.props.bodyColumns)
316
317    return (
318      <Box flexDirection="column">
319        {snap ? snapshotView($, e, snap, width) : <Text dimColor>No count yet. Press Refresh.</Text>}
320        <Box flexDirection="row" gap={1} marginTop={1}>
321          <Button label="Refresh" hotkey="r" onPress={() => void recount($, 'summary')} />
322          <Button label="Full count" hotkey="f" onPress={() => void recount($, 'full')} />
323          <Button label="Turn off" hotkey="o" onPress={() => void turnOff($)} />
324          {counting && <Text dimColor>counting...</Text>}
325        </Box>
326      </Box>
327    )
328  })
329}
330
types/index.d.ts 57 lines
1/** How a count was made: `summary` estimates locally, `full` asks the token-count API. */
2export type CountDetail = 'summary' | 'full'
3
4/** What a /context row is: content in the window, free space, the compaction buffer, or on-demand tool schemas. */
5export type CategoryKind = 'used' | 'free' | 'buffer' | 'deferred'
6
7/** One line under a group: a skill, an MCP server, a memory file. */
8export type UsageItem = {
9  label: string
10  tokens: number
11  /** What follows the tokens: `3/20 loaded`. */
12  note?: string
13}
14
15/** A list /context prints beneath its grid: Skills, MCP tools, Memory files, Rules, Agents. */
16export type UsageGroup = {
17  title: string
18  tokens: number
19  /** What follows the title: `40/45 listed`. */
20  note?: string
21  items: UsageItem[]
22}
23
24/** One row /context prints beside its grid. */
25export type UsageCategory = {
26  name: string
27  tokens: number
28  /** The theme key /context draws the row in. */
29  color: string
30  kind: CategoryKind
31}
32
33/** The context window as the last count broke it down. */
34export type UsageSnapshot = {
35  detail: CountDetail
36  /** When the count finished, in epoch milliseconds. */
37  countedAt: number
38  model: string
39  totalTokens: number
40  maxTokens: number
41  percentage: number
42  autoCompactAt?: number
43  categories: UsageCategory[]
44  groups: UsageGroup[]
45}
46
47declare module 'claude-code' {
48  interface PluginState {
49    'ctx-usage-dock': {
50      /** The last count; null before the first one lands. */
51      snapshot: Shaped<UsageSnapshot | null>
52      /** True while a count runs. */
53      isCounting: boolean
54    }
55  }
56}
57