SLOPSHOPPER

context-compass

What a Claude Code session has in context: budget, files and how much of each was read, stale files, skills, helper agents, and what compaction summarised.

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-compass
│ ┃ Context compass ✕ › fix the failing auth test and add an audit log call │ ┃ main live 4 (3 full · 1 partial) · │ ┃ pre-compact 0 · helper 0 · ~129 tok ⏺ Read(src/auth.ts) │ ┃ main/helper · full/partial · ⎿ Read 6 lines │ ┃ live/pre-compact (pre-compact = summary ⏺ Update(src/auth.ts) │ ┃ only) ⎿ Added 2 lines, removed 1 line │ ┃ Read (1) ⏺ Bash(bun test) │ ┃ /work/app/src/auth.ts ⎿ 3 pass, 1 fail │ ┃ main · full · live │ ┃ Edited / written (3) ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ /work/app/src/auth.ts │ ┃ main · partial · live · 41 ✻ Worked for 42s · done 4:20 PM │ ┃ /work/app/src/audit.ts │ ┃ main · full · live · 43 › /context-compass │ ┃ /work/app/src/cache.ts ⎿ context-compass: Context compass pane docked beside the transcri │ ┃ main · full · live · 45 │ ┃ Shell: output in context, files not tracked │ ┃ (4) │ ┃ $ bun test │ ┃ $ git status --porcelain │ ┃ $ rm -rf build && git push --force origin │ ┃ main │ ┃ $ cat .env │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context compass
main live 4 (3 full · 1 partial) · pre-compact 0 · helper 0 · ~129 tok main/helper · full/partial · live/pre-compact (pre-compact = summary only) Read (1) /work/app/src/auth.ts main · full · live Edited / written (3) /work/app/src/auth.ts main · partial · live · 41 /work/app/src/audit.ts main · full · live · 43 /work/app/src/cache.ts main · full · live · 45 Shell: output in context, files not tracked (4) $ bun test $ git status --porcelain $ rm -rf build && git push --force origin main $ cat .env
README

context-compass

A live sidebar for Claude Code that shows what the session has in context: the budget, every file and how much of it was read, files that changed on disk after the read, the skills loaded, helper agents, and what compaction turned into summary.

It reads the session transcript Claude Code already writes (~/.claude/projects/…/<session>.jsonl). Nothing is sent anywhere. No dependencies beyond Node 18+.

 CONTEXT                42k / 200k  21%
 ━━━━━━━╸━━━━━━━━━━━━━━━━━━━━━┃━━━━━━━━
 118k free             compacts ≈160k
 ██████████████████████████████████████
 ■ sys 13k ■ chat 22k ■ files 7k ■ 330
 ──────────────────────────────────────
 FILES 7           ●4 △1 ■1 ○1   size ↓
 ▾ arcsql                          4.8k
 ● schema.generated.ts             4.3k
 ● pyodideService.ts          38%   416
 ▾ drive-eqms                       832
 △ release_guard_test.ts      14%   311
 ...

Install

As a plugin (recommended)

In Claude Code:

/plugin install context-compass --marketplace JesseVent/claude-mods

While developing it, load the folder in every session instead, so edits to the mod reload as you save:

export CLAUDE_CODE_PLUGIN_DIRS="/path/to/claude-mods/plugins/context-compass"

Restart Claude Code once so the hooks load. Then:

/context-compass

In Claude Code's fullscreen layout, 110 columns or wider, that docks the Context compass pane beside the transcript, and the pane opens there by itself in later sessions until you close it. Anywhere else it splits a 42-column terminal pane to the right (tmux, or iTerm2 without tmux) and leaves focus on Claude. CTX_SIDEBAR_WIDTH=48 changes the width.

The plugin adds two hooks (SessionStart, UserPromptSubmit) that record which transcript belongs to which directory, and where the plugin is installed, in ~/.claude/context-compass/. That keeps the sidebar on the right session after /clear or /resume.

Without the plugin

Run it in any terminal, in the same directory as the Claude Code session:

node /path/to/claude-mods/plugins/context-compass/bin/context-compass.mjs

It follows the newest transcript for that directory.

tmux key binding (optional)

# ~/.tmux.conf: prefix + C opens the sidebar beside the current pane
bind-key C run-shell -c "#{pane_current_path}" "TMUX_PANE=#{pane_id} sh /path/to/claude-mods/plugins/context-compass/bin/ctx-open.sh"

One-line status in Claude Code (optional)

The same data as a single line under the prompt. Add to ~/.claude/settings.json:

{ "statusLine": { "type": "command", "command": "node /path/to/claude-mods/plugins/context-compass/bin/context-compass.mjs --band" } }
ctx ━━╸━━━━━━━┃━━ 42k/200k · ●5 △1 · ◆1

Reading it

GlyphMeaning
●in context, full text
△changed on disk after Claude last read or edited it
■image
○summarised: read before a compaction, only the summary remains
◆helper agent; its reads stay out of main context, only its summary counts
✦skill loaded, by the Skill tool or a typed /name, with the tokens its text added
»text a hook put in context: a SessionStart or UserPromptSubmit hook's output, or any hook's additional context. Labelled with the skill it names, else its first line; ×5 means a per-prompt hook added it five times
38%share of the file's lines Claude has seen (blank means the whole file)

The top bar is the window; ┃ marks the auto-compact point. The second bar splits what is in context: system prompt and tools, conversation, files, helper summaries.

Opening files

File names are terminal hyperlinks. Cmd-click (macOS) or ctrl-click opens the file in your system's default app for that file type. In file detail, the folder path is a link too.

This works in iTerm2, WezTerm, Kitty, Ghostty and the VS Code terminal. Terminals without hyperlink support show plain text.

Inside tmux (3.4 or later), add this to ~/.tmux.conf and restart tmux:

set -as terminal-features ",*:hyperlinks"

To open in an editor instead, set a URL template. {path} is the file and {line} is the first line Claude read:

export CTX_LINK_URL='vscode://file/{path}:{line}'   # or cursor://file/{path}:{line}

CTX_LINKS=0 or --no-links turns links off.

Keys

KeyAction
↑↓ j kmove
enterfile detail (line ranges seen, cost, read history), or fold a project
spacefold the project
/filter by path
ssort: size, name, recent
oopen in $EDITOR in a new tmux window
ycopy the path
rtype "read the parts of … you have not seen" (or "re-read …") into the Claude pane, without pressing enter
?keys and legend
qquit

r needs tmux, and the sidebar opened with ctx-open.sh or /context-compass, which records the Claude pane.

Options

Flag / envDefault
--window N, CTX_WINDOW200k, or 1M once a session passes 200kcontext window
--compact-at N, CTX_COMPACT_ATCLAUDE_AUTOCOMPACT_PCT_OVERRIDE if set, else 80% (shown as ≈)auto-compact point
--root DIR, CTX_ROOTcommon parent of the files readwhere project groups start
--transcript FILE, CTX_TRANSCRIPTfollow this directory's sessionpin one transcript
--cwd DIRcurrent directorywhich session to follow
--ascii, CTX_ASCII=1auto from localeASCII glyphs only
--no-color, NO_COLOR
--no-links, CTX_LINKS=0links onclickable file names
CTX_LINK_URLfile:// (system default app)link template, {path} and {line}
--once --cols N --rows Nprint one frame and exit
--bandone-line status; reads Claude Code's statusLine JSON on stdin

Colours use truecolor when COLORTERM=truecolor, otherwise the nearest xterm-256 values.

How the numbers are made

  • Context total is exact: the API usage Claude Code records on the latest assistant message (input + cache read + cache write + output).
  • System + tools is the first request's input minus the first prompt.
  • Files and helper summaries are estimates at 4 characters per token, from the tool results in the transcript. Images use width × height / 750.
  • Conversation is the remainder.
  • Seen % merges every line range read since the last compaction.
  • Stale compares the file's modified time with the last time Claude read or edited it.

Limits

  • The transcript format is not a public API. A Claude Code update can change it; the parser skips what it does not recognise rather than failing.
  • The window size is not in the transcript, so 1M sessions are recognised only once usage passes 200k. Set --window to be exact.
  • The auto-compact point is a guess unless you set it.
  • Subagent reads are counted per helper but not listed by file.
  • The pane needs tmux or iTerm2. Elsewhere, run the sidebar in a second terminal.

Tests

npm test

They build a transcript in the same shape as real ones and check coverage, compaction, staleness, helpers, totals, and that every rendered line is exactly the pane width at several sizes, in Unicode and ASCII, and that file links are well formed.

Source 2 files
hooks/register.tsx 439 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextFile, HookText, LoadedSkill } from '../types'
5
6const PANE = 'context-compass'
7const files = atom({ plugin: 'context-compass', key: 'files' } as const, [] as ContextFile[])
8const shell = atom({ plugin: 'context-compass', key: 'shell' } as const, [] as string[])
9const skills = atom({ plugin: 'context-compass', key: 'skills' } as const, [] as LoadedSkill[])
10const hookText = atom({ plugin: 'context-compass', key: 'hookText' } as const, [] as HookText[])
11
12let home = ''
13
14type Seen = Omit<ContextFile, 'isCompacted'>
15
16const tokensOf = (text: string) => Math.ceil(text.length / 4)
17const group = (via: ContextFile['via']) => (via === 'mention' ? 'read' : via === 'write' ? 'edit' : via)
18
19export function mergeRanges(ranges: readonly (readonly [number, number])[]): [number, number][] {
20  const out: [number, number][] = []
21  for (const [from, to] of [...ranges].sort((a, b) => a[0] - b[0])) {
22    const last = out[out.length - 1]
23    if (last && from <= last[1] + 1) last[1] = Math.max(last[1], to)
24    else out.push([from, to])
25  }
26  return out
27}
28
29function merge(old: ContextFile, seen: Seen): ContextFile {
30  // A re-read after compaction starts over: what entered before it is summary only.
31  if (old.isCompacted) return { ...seen, isCompacted: false }
32  return {
33    ...old,
34    ...seen,
35    detail: seen.detail ?? old.detail,
36    ranges: old.ranges && seen.ranges ? mergeRanges([...old.ranges, ...seen.ranges]) : undefined,
37    total: seen.total ?? old.total,
38    isSnippet: old.isSnippet && seen.isSnippet ? true : undefined,
39    tokens: old.tokens + seen.tokens,
40  }
41}
42
43async function record($: EngineInterface, seen: Seen) {
44  await update($, files, list => {
45    const i = list.findIndex(
46      f => f.path === seen.path && f.agentId === seen.agentId && group(f.via) === group(seen.via),
47    )
48    if (i < 0) return [...list, { ...seen, isCompacted: false }]
49    return list.map((f, j) => (j === i ? merge(f, seen) : f))
50  })
51}
52
53// As lib/parse.mjs hookLabel: the skill a hook's text names, else its first line, past the engine's "<Event> hook success: " prefix.
54export function hookLabel(text: string): string {
55  const named = /'([\w:.-]+)' skill/.exec(text)
56  if (named?.[1]) return named[1]
57  const body = text.replace(/^(?:<[^>]*>\s*)?[^\n]{0,80}?\bhook [a-z ]+: /, '')
58  for (const line of body.split('\n')) {
59    const l = line.replace(/<[^>]*>/g, '').replace(/^[#=*\s]+|[=*\s]+$/g, '')
60    if (l) return l
61  }
62  return ''
63}
64
65// ponytail: digits blanked so a per-prompt hook (a timestamp) is one row; two hooks whose first lines differ only in numbers merge
66const hookKey = (h: Pick<HookText, 'event' | 'label'>) => `${h.event} ${h.label.replace(/\d+/g, '#')}`
67
68export function depth(f: ContextFile): string {
69  if (f.isSnippet) return 'partial'
70  if (!f.ranges || !f.total) return 'full'
71  const seen = f.ranges.reduce((n, [from, to]) => n + to - from + 1, 0)
72  return seen >= f.total ? 'full' : `partial ${Math.floor((seen / f.total) * 100)}%`
73}
74
75const kTokens = (n: number) => (n === 0 ? '' : n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`)
76const fit = (text: string, width: number) =>
77  text.length <= width ? text : `…${text.slice(text.length - Math.max(1, width - 1))}`
78
79// A sidebar only: docked beside a fullscreen transcript, never inline above the prompt.
80const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Context compass', columns: 64 })
81let triedOpen = false
82
83// Where the pane cannot dock, the sidebar is a tmux or iTerm2 split running bin/context-compass.mjs.
84// A mod is not told its own folder; this plugin's SessionStart command hook records it in plugin-root.
85const OPEN_SPLIT = `f="$HOME/.claude/context-compass/plugin-root"
86[ -s "$f" ] || { echo "Context compass: its SessionStart hook has not run yet; restart Claude Code."; exit 1; }
87sh "$(cat "$f")/bin/ctx-open.sh"`
88const openSplit = async ($: EngineInterface) => {
89  const r = await $.process.run(['sh', '-c', OPEN_SPLIT])
90  return (r.stdout + r.stderr).trim() || `context-compass exited ${r.exitCode}`
91}
92
93export const register: Register = on => {
94  on('session.start', async ($, e, next) => {
95    home = (await $.env.get('HOME')) ?? ''
96    await $.command.register({
97      name: 'context-compass',
98      description: "Show which files and skills are in Claude's context, and how much of each",
99    })
100
101    return next(e)
102  })
103
104  // Only a render hook's viewport says whether the surface docks panes, so the unasked open waits for one.
105  // As the built-in sidebars do: a pane the person closed stays closed in later sessions until /context-compass.
106  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107    if (!triedOpen && e.viewport?.isFullscreen === true) {
108      triedOpen = true
109      if ((await $.store.get('open')) !== false) void openPane($)
110    }
111
112    return next(e)
113  }).catch(($, e, next) => next(e))
114
115  on('ui.close', { id: PANE }, async ($, e, next) => {
116    if (e.origin.kind === 'person') await $.store.set('open', false)
117
118    return next(e)
119  }).catch(($, e, next) => next(e))
120
121  on('command.run', { command: 'context-compass' }, async ($, e) => {
122    const { isFullscreen, columns } = e.presentation
123    // The main screen, and a fullscreen one under 110 columns, would seat the pane inline.
124    if (!isFullscreen || columns < 110) return { text: await openSplit($) }
125    await $.store.set('open', true)
126    const opened = await openPane($)
127    if (!opened.isPlaced) return { text: `Context compass pane is open but not drawn yet: ${opened.reason}` }
128
129    return { text: 'Context compass pane docked beside the transcript.' }
130  })
131
132  on('session.end', async ($, e, next) => {
133    if (e.reason === 'clear') {
134      await update($, files, () => [])
135      await update($, shell, () => [])
136      await update($, skills, () => [])
137      await update($, hookText, () => [])
138    }
139
140    return next(e)
141  })
142
143  // A settings hook's text (SessionStart output, additionalContext, ...) as Claude reads it; a subagent's stays out of main context.
144  on('session.append', async ($, e, next) => {
145    const r = await next(e)
146    if (e.origin.kind === 'hook' && e.message.role && !e.agentId) {
147      const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('\n')
148      const seen = { event: e.origin.event, label: hookLabel(text) }
149      if (text.trim()) {
150        await update($, hookText, list => {
151          const old = list.find(h => hookKey(h) === hookKey(seen))
152          const count = (old?.count ?? 0) + 1
153          return [...list.filter(h => h !== old), { ...seen, count, tokens: (old?.tokens ?? 0) + tokensOf(text) }]
154        })
155      }
156    }
157
158    return r
159  }).catch(($, e, next) => next(e))
160
161  // Typed as /name, run by the Skill tool, or preloaded into a subagent. A compaction re-attaches loaded skills, so they stay live.
162  on('skill.prompt', async ($, e, next) => {
163    const r = await next(e)
164    await update($, skills, list => {
165      const old = list.find(s => s.name === e.skill)
166      const tokens = (old?.tokens ?? 0) + tokensOf(r.text)
167      return [...list.filter(s => s !== old), { name: e.skill, source: e.pluginName ?? e.source, tokens }]
168    })
169
170    return r
171  }).catch(($, e, next) => next(e))
172
173  on('prompt.context', async ($, e, next) => {
174    const r = await next(e)
175    const loaded = r.instructionFiles ?? e.instructionFiles
176    if (loaded) {
177      await update($, files, list => [
178        ...list.filter(f => f.via !== 'instructions' || f.detail === 'nested'),
179        ...loaded.map(f => ({
180          path: f.path,
181          via: 'instructions' as const,
182          detail: f.kind,
183          tokens: tokensOf(f.content),
184          isCompacted: false,
185        })),
186      ])
187    }
188
189    return r
190  }).catch(($, e, next) => next(e))
191
192  on('prompt.mention', async ($, e, next) => {
193    const r = await next(e)
194    if (r.deny === undefined && r.type === 'file') {
195      await record($, {
196        path: e.path,
197        via: 'mention',
198        ranges: e.offset === undefined ? undefined : [[e.offset, e.offset + (e.limit ?? 1) - 1]],
199        tokens: 0,
200        agentId: e.agentId,
201      })
202    }
203
204    return r
205  }).catch(($, e, next) => next(e))
206
207  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
208    const r = await next(e)
209    if (r.deny !== undefined || r.isError) return r
210    const res = r.result
211    const base = { path: e.file_path, via: 'read' as const, agentId: e.agentId }
212    if (res.type === 'text') {
213      const { startLine, numLines, totalLines, content, truncatedByTokenCap } = res.file
214      await record($, {
215        ...base,
216        ranges: numLines > 0 ? [[startLine, startLine + numLines - 1]] : undefined,
217        total: totalLines,
218        isSnippet: truncatedByTokenCap ? true : undefined,
219        tokens: tokensOf(content),
220      })
221    } else if (res.type !== 'file_unchanged') {
222      await record($, { ...base, detail: res.type, isSnippet: e.pages ? true : undefined, tokens: 0 })
223    }
224
225    return r
226  }).catch(($, e, next) => next(e))
227
228  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
229    const r = await next(e)
230    if (r.deny === undefined && !r.isError) {
231      await record($, {
232        path: e.file_path,
233        via: 'edit',
234        isSnippet: true,
235        tokens: tokensOf(e.old_string + e.new_string),
236        agentId: e.agentId,
237      })
238    }
239
240    return r
241  }).catch(($, e, next) => next(e))
242
243  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
244    const r = await next(e)
245    if (r.deny === undefined && !r.isError) {
246      await record($, {
247        path: e.notebook_path,
248        via: 'edit',
249        isSnippet: true,
250        tokens: tokensOf(e.new_source),
251        agentId: e.agentId,
252      })
253    }
254
255    return r
256  }).catch(($, e, next) => next(e))
257
258  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
259    const r = await next(e)
260    if (r.deny === undefined && !r.isError) {
261      await record($, { path: e.file_path, via: 'write', tokens: tokensOf(e.content), agentId: e.agentId })
262    }
263
264    return r
265  }).catch(($, e, next) => next(e))
266
267  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
268    const r = await next(e)
269    if (r.deny === undefined && !e.agentId) await update($, shell, list => [...list, e.command].slice(-20))
270
271    return r
272  }).catch(($, e, next) => next(e))
273
274  on('session.append', { door: 'attachment' }, async ($, e, next) => {
275    const r = await next(e)
276    if (e.message.name === 'nested_memory') {
277      const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('\n')
278      // ponytail: path parsed from the rendered row (the payload is not on e); breaks if its wording changes
279      for (const [, path] of text.matchAll(/Contents of (.+?)(?: \([^)]*\))?:\n/g)) {
280        if (path) await record($, { path, via: 'instructions', detail: 'nested', tokens: tokensOf(text), agentId: e.agentId })
281      }
282    }
283
284    return r
285  }).catch(($, e, next) => next(e))
286
287  on('session.compact', async ($, e, next) => {
288    const r = await next(e)
289    // CLAUDE.md files are sent again after a compaction; everything else is summary only.
290    // ponytail: the engine also re-attaches a few recent files; they read pre-compact until re-read
291    if (e.trigger !== 'precompute' && r.messages) {
292      await update($, files, list =>
293        list.map(f =>
294          f.agentId === e.agentId && !(f.via === 'instructions' && f.detail !== 'nested')
295            ? { ...f, isCompacted: true }
296            : f,
297        ),
298      )
299      // What hooks added before is summary now; SessionStart hooks run again and add theirs afresh.
300      if (!e.agentId) await update($, hookText, () => [])
301    }
302
303    return r
304  }).catch(($, e, next) => next(e))
305
306  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307    const { Box, Text } = $.ui.resolve(e)
308    const list = await read($, files)
309    const cmds = await read($, shell)
310    const loadedSkills = await read($, skills)
311    const hooks = [...(await read($, hookText))].sort((a, b) => b.tokens - a.tokens)
312    const columns = e.props.bodyColumns
313
314    const live = list.filter(f => !f.agentId && !f.isCompacted)
315    const partial = live.filter(f => depth(f) !== 'full').length
316    const liveTokens = live.reduce((n, f) => n + f.tokens, 0)
317    const stale = list.filter(f => !f.agentId && f.isCompacted).length
318    const helper = list.filter(f => f.agentId).length
319
320    const row = (f: ContextFile) => {
321      const where = f.agentId ? 'helper' : 'main'
322      const how = depth(f)
323      const when = f.isCompacted ? 'pre-compact' : 'live'
324      const lines =
325        how.startsWith('partial') && f.ranges
326          ? ` L${f.ranges.map(([from, to]) => `${from}-${to}`).join(',')}${f.total ? ` of ${f.total}` : ''}`
327          : f.detail && f.via !== 'instructions'
328            ? ` ${f.detail}`
329            : ''
330      const tok = kTokens(f.tokens)
331      const shown = home && f.path.startsWith(home) ? `~${f.path.slice(home.length)}` : f.path
332      const width = Math.max(8, columns - 33 - fit(lines, 24).length - tok.length)
333      const isFaded = Boolean(f.agentId) || f.isCompacted
334
335      // A sidebar is too narrow for one line: the path on its own line, the labels under it.
336      if (columns < 90) {
337        return (
338          <Box key={`${f.via}:${f.agentId ?? ''}:${f.path}`} flexDirection="column">
339            <Text dimColor={isFaded}>{fit(shown, columns)}</Text>
340            <Box flexDirection="row">
341              <Text dimColor={Boolean(f.agentId)}>{`  ${where}`}</Text>
342              <Text dimColor> · </Text>
343              <Text color={how === 'full' ? 'success' : 'warning'}>{how}</Text>
344              <Text dimColor> · </Text>
345              <Text dimColor={f.isCompacted}>{when}</Text>
346              <Text dimColor>{fit(`${lines}${tok ? ` · ${tok}` : ''}`, Math.max(8, columns - 40))}</Text>
347            </Box>
348          </Box>
349        )
350      }
351
352      return (
353        <Box key={`${f.via}:${f.agentId ?? ''}:${f.path}`} flexDirection="row">
354          <Text dimColor={Boolean(f.agentId)}>{where.padEnd(7)}</Text>
355          <Text color={how === 'full' ? 'success' : 'warning'}>{how.padEnd(13)}</Text>
356          <Text dimColor={f.isCompacted}>{when.padEnd(12)}</Text>
357          <Text dimColor={isFaded}>{fit(shown, width)}</Text>
358          <Text dimColor>{fit(lines, 24)}</Text>
359          <Text dimColor> {tok}</Text>
360        </Box>
361      )
362    }
363
364    const sections: [string, ContextFile[]][] = [
365      ['Read', list.filter(f => group(f.via) === 'read')],
366      ['Edited / written', list.filter(f => group(f.via) === 'edit')],
367      ['Instructions', list.filter(f => f.via === 'instructions')],
368    ]
369    const body = sections.flatMap(([title, entries]) =>
370      entries.length === 0
371        ? []
372        : [
373            <Text key={title} bold>
374              {title} ({entries.length})
375            </Text>,
376            ...entries.map(row),
377          ],
378    )
379    if (loadedSkills.length > 0) {
380      body.push(
381        <Text key="skills" bold>
382          Skills ({loadedSkills.length})
383        </Text>,
384        ...loadedSkills.map(s => {
385          const tail = ` ${s.source} · ${kTokens(s.tokens)}`
386          return (
387            <Box key={`skill:${s.name}`} flexDirection="row">
388              <Text>{fit(s.name, Math.max(8, columns - tail.length))}</Text>
389              <Text dimColor>{tail}</Text>
390            </Box>
391          )
392        }),
393      )
394    }
395    if (hooks.length > 0) {
396      body.push(
397        <Text key="hooks" bold>
398          Hook text ({hooks.length})
399        </Text>,
400        ...hooks.map(hook => {
401          const tail = ` ${hook.event}${hook.count > 1 ? ` ×${hook.count}` : ''} · ${kTokens(hook.tokens)}`
402          return (
403            <Box key={`hook:${hookKey(hook)}`} flexDirection="row">
404              <Text>{fit(hook.label, Math.max(8, columns - tail.length))}</Text>
405              <Text dimColor>{tail}</Text>
406            </Box>
407          )
408        }),
409      )
410    }
411    if (cmds.length > 0) {
412      body.push(
413        <Text key="shell" bold>
414          Shell: output in context, files not tracked ({cmds.length})
415        </Text>,
416        ...cmds.slice(-5).map((c, i) => (
417          <Text key={`sh${i}`} dimColor>
418            {fit(`$ ${c.replace(/\s+/g, ' ')}`, columns)}
419          </Text>
420        )),
421      )
422    }
423
424    return (
425      <Box flexDirection="column">
426        <Text>
427          main live {live.length} ({live.length - partial} full · {partial} partial) · pre-compact {stale} · helper{' '}
428          {helper} · ~{kTokens(liveTokens) || 0} tok
429        </Text>
430        <Text dimColor>main/helper · full/partial · live/pre-compact (pre-compact = summary only)</Text>
431        {list.length === 0 && cmds.length === 0 && loadedSkills.length === 0 && hooks.length === 0 && (
432          <Text dimColor>No files in context yet.</Text>
433        )}
434        {body}
435      </Box>
436    )
437  })
438}
439
types/index.d.ts 43 lines
1export type ContextFile = {
2  path: string
3  via: 'instructions' | 'mention' | 'read' | 'edit' | 'write'
4  /** CLAUDE.md tier (user, project, memory, nested), or a Read's kind (image, pdf) */
5  detail?: string
6  /** Lines seen, merged; absent means the whole file */
7  ranges?: [number, number][]
8  /** The file's total lines, from a Read */
9  total?: number
10  /** Only part of the file entered (an Edit's snippet, a capped Read, PDF pages) */
11  isSnippet?: boolean
12  /** ≈ chars/4 of what entered */
13  tokens: number
14  /** Set when a helper (subagent) loop read it: not in the main context */
15  agentId?: string
16  /** A compaction ran after it entered: only the summary remains */
17  isCompacted: boolean
18}
19
20export type LoadedSkill = {
21  name: string
22  /** The plugin it came with, else where it was found (userSettings, built-in, ...) */
23  source: string
24  /** ≈ chars/4 of its prompt, summed over every load */
25  tokens: number
26}
27
28/** Text a settings hook put in front of Claude (SessionStart output, additionalContext, ...), grouped by event and first line */
29export type HookText = {
30  event: string
31  /** The skill the text names, else its first line */
32  label: string
33  /** How many times it was added (a per-prompt hook adds once per prompt) */
34  count: number
35  tokens: number
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    'context-compass': { files: ContextFile[]; shell: string[]; skills: LoadedSkill[]; hookText: HookText[] }
41  }
42}
43