SLOPSHOPPER

skill-map

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

newpaneguardcommandprompt
v0.1.1no licenseupdated 2026-10-07vuon9/cc-mods/plugins/skill-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-map
│ ┃ Skills ✕ › fix the failing auth test and add an audit log call │ ┃ ████████████░░░░░░░░░░░░░ 49% │ ┃ ■ <2k ■ 2-6k ■ 6k+ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No skills loaded yet. ⏺ 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 │ │ › /skill-map │ ⎿ skill-map: Skill map opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skills
████████████░░░░░░░░░░░░░ 49% ■ <2k ■ 2-6k ■ 6k+ No skills loaded yet.
README

skill-map

skill-map pane with six skills, one reloaded and three greyed out after a compaction

<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
  • The square's color is the skill's size: green under 2k tokens, amber 2k to 6k, red 6k and up. Tokens are estimated as characters / 4.
  • [+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.
  • After a compaction, a skill whose text was summarized away turns grey and crossed out.
  • The bar on top is the context window, filled to current usage, with a marker where each skill landed.

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

Install

claude plugin marketplace add vuon9/cc-mods
claude plugin install skill-map@cc-mods

Update with claude plugin update skill-map.

Limits

  • Skill loads are spotted by the line 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.
  • Reading a 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.
  • Built and tested on Claude Code 2.1.288 to 2.1.292.

Develop

From the repo root:

claude --plugin-dir plugins/skill-map
claude plugin test plugins/skill-map
Source 2 files
hooks/register.tsx 188 lines
1import { 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> &lt;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}
188
types/index.d.ts 16 lines
1export 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