Pane showing which skills are loaded into the main conversation, how big they are, and where in the context window they landed


<sub>Drawn by the mod from sample data.</sub>
A side pane listing every skill loaded into the main conversation, one line each:
■ vmode [+1] 2.3k 4→61% t1
[+1] counts loads that added the skill's text again. Typing /name twice does that. Claude Code skips a repeat Skill tool load by itself, so those don't count.4→61% is how full the context window was at the first and the latest load. t1 is the turn of the latest load./skill-map opens or closes the pane. /skill-map reset clears it. The pane opens by itself on the first skill load; in a terminal that only happens at 144 columns or wider, while /skill-map works at any width. It asks for 30 columns, about 220px.
Only the main conversation counts. Skills a subagent loads stay in the subagent's context and are left out.
claude plugin marketplace add vuon9/cc-mods
claude plugin install skill-map@cc-mods
Update with claude plugin update skill-map.
Base directory for this skill: ... that Claude Code puts at the top of each one. If a release rewords it, the pane stops picking skills up.SKILL.md counts only as a whole file: the Read tool without a line range, or a plain cat. head, grep and piped reads don't.From the repo root:
claude --plugin-dir plugins/skill-map
claude plugin test plugins/skill-maphooks/register.tsx 188 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { SkillLoad } from '../types'
5
6const PANE = 'skill-map'
7// About 30 monospace cells is 210-240px at the usual 7-8px cell.
8const COLUMNS = 30
9const loads = atom({ plugin: 'skill-map', key: 'loads' } as const, [])
10const used = atom({ plugin: 'skill-map', key: 'used' } as const, 0)
11const turn = atom({ plugin: 'skill-map', key: 'turn' } as const, 0)
12
13const SMALL_MAX = 2000
14const MEDIUM_MAX = 6000
15const TIER_COLOR = { small: '#4caf50', medium: '#e0a030', large: '#e05050' }
16
17type Tier = keyof typeof TIER_COLOR
18
19const tierOf = (tokens: number): Tier =>
20 tokens < SMALL_MAX ? 'small' : tokens < MEDIUM_MAX ? 'medium' : 'large'
21
22const estimate = (text: string) => Math.ceil(text.length / 4)
23const kilo = (tokens: number) => `${(tokens / 1000).toFixed(1)}k`
24
25const blockText = (block: { type: string; [field: string]: unknown }): string => {
26 if (block.type === 'text') return String(block.text)
27 if (block.type !== 'tool_result') return ''
28 if (typeof block.content === 'string') return block.content
29 return Array.isArray(block.content) ? block.content.map(blockText).join('\n') : ''
30}
31
32// Only whole-file reads count: `head`, `wc`, `grep` or a piped `cat` would log a skill that never entered context.
33const WHOLE_CAT = /(?:^|[;&]\s*)cat\s+(['"]?)(\S+\/SKILL\.md)\1\s*(?:$|[;&])/
34
35const skillFile = (e: { tool: string; [field: string]: unknown }): string | undefined => {
36 if (e.tool === 'Read' && e.offset === undefined && e.limit === undefined && String(e.file_path).endsWith('/SKILL.md')) {
37 return String(e.file_path)
38 }
39 if (e.tool === 'Bash') return WHOLE_CAT.exec(String(e.command))?.[2]
40 return undefined
41}
42
43// Every skill load, typed or through the Skill tool, lands as a meta row with this header; skill.prompt does not fire for typed skills on every build.
44const SKILL_ROW = /^Base directory for this skill: (.+)\n/
45
46const KEPT_SKILL = /^(?:Base directory for this skill: .*\/([^/\n]+)|name: ([\w:-]+))$/gm
47
48export const skillsIn = (messages: readonly SessionMessage[]) =>
49 new Set(
50 messages
51 .flatMap(one => [one.text, ...(one.toolResults ?? []).map(result => result.text)])
52 .flatMap(text => [...text.matchAll(KEPT_SKILL)].map(match => match[1] ?? match[2])),
53 )
54
55export const skillOfRow = (text: string) => {
56 const dir = SKILL_ROW.exec(text)?.[1]
57 return dir === undefined ? undefined : { skill: dir.split('/').at(-1) ?? dir, tokens: estimate(text) }
58}
59
60async function isPaneUp($: EngineInterface) {
61 return (await $.ui.panes()).some(pane => pane.id === PANE)
62}
63
64async function record($: EngineInterface, skill: string, tokens: number) {
65 const { context } = await $.session.usage()
66 const percent = context.percent ?? 0
67 const now = await read($, turn)
68 await update($, loads, list => {
69 const old = list.find(one => one.skill === skill)
70 const entry: SkillLoad = {
71 skill,
72 tokens,
73 firstPercent: old?.firstPercent ?? percent,
74 lastPercent: percent,
75 turn: now,
76 reloads: old === undefined ? 0 : old.reloads + 1,
77 isCompacted: false,
78 }
79 return old === undefined ? [...list, entry] : list.map(one => (one === old ? entry : one))
80 })
81 if (!(await isPaneUp($))) void $.ui.open({ id: PANE, title: 'Skills', columns: COLUMNS })
82}
83
84export const register: Register = on => {
85 on('session.start', async ($, e, next) => {
86 await $.command.register({
87 name: 'skill-map',
88 description: 'Toggle the pane of skills loaded into this conversation; `/skill-map reset` clears it',
89 })
90 return next(e)
91 })
92
93 on('command.run', { command: 'skill-map' }, async ($, e) => {
94 if (e.args.trim() === 'reset') {
95 await update($, loads, () => [])
96 return { text: 'Skill map cleared.' }
97 }
98 const wasOpen = await isPaneUp($)
99 if (wasOpen) await $.ui.close({ id: PANE })
100 else await $.ui.open({ id: PANE, title: 'Skills', columns: COLUMNS })
101 return { text: wasOpen ? 'Skill map closed.' : 'Skill map opened.' }
102 })
103
104 on('prompt.submit', async ($, e, next) => {
105 await update($, turn, n => n + 1)
106 return next(e)
107 }).catch(($, e, next) => next(e))
108
109 on('session.measure', async ($, e, next) => {
110 await update($, used, () => e.context.percent ?? 0)
111 return next(e)
112 })
113
114 on('session.append', async ($, e, next) => {
115 if (e.agentId !== undefined) return next(e)
116 const loaded = e.message.isMeta ? skillOfRow(e.message.content.map(blockText).join('\n')) : undefined
117 if (loaded !== undefined) await record($, loaded.skill, loaded.tokens)
118 return next(e)
119 }).catch(($, e, next) => next(e))
120
121 on('session.compact', async ($, e, next) => {
122 const result = await next(e)
123 if (e.agentId !== undefined || e.trigger === 'precompute' || result.skip !== undefined) return result
124 const kept = skillsIn(result.messages)
125 await update($, loads, list => list.map(one => ({ ...one, isCompacted: !kept.has(one.skill) })))
126 return result
127 }).catch(($, e, next) => next(e))
128
129 on('tool.call', async ($, e, next) => {
130 const path = e.agentId === undefined ? skillFile(e) : undefined
131 if (path === undefined) return next(e)
132 const ran = await next(e)
133 if (ran.deny === undefined && !ran.isError && ran.text) {
134 await record($, path.split('/').at(-2) ?? path, estimate(ran.text))
135 }
136 return ran
137 }).catch(($, e, next) => next(e))
138
139 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
140 const { Box, Text } = $.ui.resolve(e)
141 const list = await read($, loads)
142 const fill = await read($, used)
143 const width = Math.min(COLUMNS, e.viewport?.columns ?? COLUMNS)
144 const barWidth = width - 5
145 const cells = Array.from({ length: barWidth }, (_, i) => {
146 const at = list.findLast(one => Math.min(barWidth - 1, Math.floor((one.lastPercent / 100) * barWidth)) === i)
147 if (at !== undefined) return { glyph: '▮', color: TIER_COLOR[tierOf(at.tokens)] }
148 return { glyph: i < Math.round((fill / 100) * barWidth) ? '█' : '░', color: undefined }
149 })
150
151 return (
152 <Box flexDirection="column" width={width}>
153 <Text>
154 {cells.map(cell => (
155 <Text color={cell.color} dimColor={cell.color === undefined}>
156 {cell.glyph}
157 </Text>
158 ))}
159 <Text dimColor> {fill}%</Text>
160 </Text>
161 <Text dimColor>
162 <Text color={TIER_COLOR.small}>■</Text> <2k <Text color={TIER_COLOR.medium}>■</Text> 2-6k{' '}
163 <Text color={TIER_COLOR.large}>■</Text> 6k+
164 </Text>
165 <Text> </Text>
166 {list.length === 0 && <Text dimColor>No skills loaded yet.</Text>}
167 {list.map(one => (
168 <Box key={`skill-${one.skill}`} flexDirection="row" width={width}>
169 <Box flexShrink={1}>
170 <Text dimColor={one.isCompacted} strikethrough={one.isCompacted} wrap="truncate-end">
171 <Text color={TIER_COLOR[tierOf(one.tokens)]}>■ </Text>
172 {one.skill}
173 {one.reloads > 0 && <Text bold> [+{one.reloads}]</Text>}
174 </Text>
175 </Box>
176 <Box flexShrink={0}>
177 <Text dimColor>
178 {' '}
179 {kilo(one.tokens)} {one.reloads > 0 ? `${one.firstPercent}→${one.lastPercent}%` : `${one.lastPercent}%`} t{one.turn}
180 </Text>
181 </Box>
182 </Box>
183 ))}
184 </Box>
185 )
186 })
187}
188types/index.d.ts 16 lines1export type SkillLoad = {
2 skill: string
3 tokens: number
4 firstPercent: number
5 lastPercent: number
6 turn: number
7 reloads: number
8 isCompacted: boolean
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'skill-map': { loads: SkillLoad[]; used: number; turn: number }
14 }
15}
16