A Skills used panel: every skill run in the session as a badge, the ones of the current reply in green, with who started each and its description on hover

A Claude Code mod that shows which skills Claude used in the session.
It opens a Skills used panel with one badge per skill:
×N once it ran more than once;engineering standup;SKILL.md right under its row, with how often it ran and in which reply it last did.| Icon | Started by |
|---|---|
| person | you, by typing /skill-name |
| sparkles | Claude, on its own |
| link | Claude, while another skill of the same reply was in use |
| robot | a subagent |
The list resets on /clear.
At the prompt of a terminal session:
/plugin install skills-used --marketplace exsec-dev/skills-used
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install skills-used --marketplace exsec-dev/skills-used
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with exsec-dev/skills-used, then Add plugin.
The panel opens by itself with the first skill Claude uses in a session. If you closed it, /skills-used opens it again.
hooks/register.tsx 301 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SkillName, Trigger } from '../types'
5import { frontmatterDescription } from './frontmatter'
6import { splitName, usageLine } from './labels'
7import { badgeWidth, toRows } from './rows'
8import { classifyTrigger, GLYPHS, triggerIcon, triggerLabel } from './triggers'
9
10const PANE = 'skills-used'
11const TITLE = 'Skills used'
12const DESCRIPTION_LIMIT = 280
13
14// Badge tints: translucent, so they sit on the panel in both light and dark themes.
15// The border takes the fill's color: the desktop draws a round border, which rounds the badge.
16const TINT = '#9a98902e'
17const TINT_HOVER = '#9a989047'
18const TINT_ACTIVE = '#5fa85f33'
19const TINT_ACTIVE_HOVER = '#5fa85f4d'
20const GREEN = '#7dba7d'
21const MUTED = '#9a9890'
22const HAIRLINE = '#9a989040'
23const CARD = '#9a98901a'
24const ICON_SIZE = 12
25
26// Skills run in this session, in the order they were first used.
27const used = atom({ plugin: 'skills-used', key: 'used' } as const, [] as SkillName[])
28// Skills run in the reply that is being written right now.
29const current = atom({ plugin: 'skills-used', key: 'current' } as const, [] as SkillName[])
30// Each skill's description from its SKILL.md frontmatter.
31const descriptions = atom(
32 { plugin: 'skills-used', key: 'descriptions' } as const,
33 {} as Record<SkillName, string>,
34)
35// Who started each skill the last time it ran.
36const triggers = atom({ plugin: 'skills-used', key: 'triggers' } as const, {} as Record<SkillName, Trigger>)
37// How many times each skill ran this session.
38const counts = atom({ plugin: 'skills-used', key: 'counts' } as const, {} as Record<SkillName, number>)
39// Replies finished so far, and the reply each skill last ran in: "2 replies ago" stays true without a timer.
40const replies = atom({ plugin: 'skills-used', key: 'replies' } as const, 0)
41const lastReply = atom({ plugin: 'skills-used', key: 'lastReply' } as const, {} as Record<SkillName, number>)
42
43// Markers between events of one reply; a reload drops them.
44let manualCommand: string | undefined
45const pendingSkillCalls = new Map<SkillName, string>()
46
47const addOnce = (name: SkillName) => (list: SkillName[]) =>
48 list.includes(name) ? list : [...list, name]
49
50const shorten = (text: string) => {
51 const flat = text.replace(/\s+/g, ' ').trim()
52
53 return flat.length > DESCRIPTION_LIMIT ? `${flat.slice(0, DESCRIPTION_LIMIT).trimEnd()}…` : flat
54}
55
56// Skills used before their description was recorded: read it from the skill's folder beside this one.
57const tried = new Set<SkillName>()
58async function fillMissing($: EngineInterface, list: SkillName[], known: Record<SkillName, string>) {
59 for (const name of list) {
60 if (known[name] !== undefined || tried.has(name) || name.includes(':')) {
61 continue
62 }
63 tried.add(name)
64 try {
65 const description = frontmatterDescription(await $.fs.read(`${$.plugin.root}/../${name}/SKILL.md`))
66 if (description !== undefined) {
67 await update($, descriptions, all => ({ ...all, [name]: shorten(description) }))
68 }
69 } catch {
70 // Not a skill of the user's skills folder: it stays without a description.
71 }
72 }
73}
74
75export const register: Register = on => {
76 on('session.start', async ($, e, next) => {
77 await $.command.register({
78 name: 'skills-used',
79 description: 'Open the Skills used panel',
80 })
81 // An empty panel would only take room: it opens with the first skill, or now if this session has some.
82 if ((await read($, used)).length > 0) {
83 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
84 }
85
86 return next(e)
87 })
88
89 // A slash command the person typed: a skill run this way is theirs.
90 on('command.run', async ($, e, next) => {
91 manualCommand = e.command.replace(/^\//, '')
92
93 return next(e)
94 })
95
96 // A Skill call, from the main loop or a subagent, is pending until its skill has loaded.
97 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
98 pendingSkillCalls.set(e.skill, e.agentId ?? 'main')
99 try {
100 return await next(e)
101 } finally {
102 pendingSkillCalls.delete(e.skill)
103 }
104 })
105
106 // Fires for /name typed by the person, the Skill tool, and skills preloaded into a subagent.
107 on('skill.prompt', async ($, e, next) => {
108 const trigger = classifyTrigger(e.skill, manualCommand, pendingSkillCalls.get(e.skill), await read($, current))
109 if (trigger.kind === 'manual') {
110 manualCommand = undefined
111 }
112 const isFirst = (await read($, used)).length === 0
113 const reply = await read($, replies)
114 await update($, triggers, all => ({ ...all, [e.skill]: trigger }))
115 await update($, counts, all => ({ ...all, [e.skill]: (all[e.skill] ?? 0) + 1 }))
116 await update($, lastReply, all => ({ ...all, [e.skill]: reply }))
117 await update($, used, addOnce(e.skill))
118 await update($, current, addOnce(e.skill))
119 if (isFirst) {
120 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
121 }
122
123 const description = e.frontmatter.description
124 if (typeof description === 'string' && description.trim() !== '') {
125 await update($, descriptions, all => ({ ...all, [e.skill]: shorten(description) }))
126 }
127
128 return next(e)
129 })
130
131 // The main loop's reply is done: nothing is in use any more.
132 on('turn.complete', async ($, e, next) => {
133 if (e.agentId === undefined) {
134 manualCommand = undefined
135 await update($, current, () => [])
136 await update($, replies, n => n + 1)
137 }
138
139 return next(e)
140 })
141
142 on('session.end', async ($, e, next) => {
143 if (e.reason === 'clear') {
144 await update($, used, () => [])
145 await update($, current, () => [])
146 await update($, triggers, () => ({}))
147 await update($, counts, () => ({}))
148 await update($, lastReply, () => ({}))
149 await update($, replies, () => 0)
150 }
151
152 return next(e)
153 })
154
155 on('command.run', { command: 'skills-used' }, async $ => {
156 const opened = await $.ui.open({ id: PANE, title: TITLE })
157
158 return { text: opened.isPlaced ? 'Skills used panel opened.' : 'The panel did not fit on screen.' }
159 })
160
161 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
162 const elements = $.ui.resolve(e)
163 const { Box, Text } = elements
164 const list = await read($, used)
165 const active = await read($, current)
166 const about = await read($, descriptions)
167 const startedBy = await read($, triggers)
168 const runs = await read($, counts)
169 const replyNow = await read($, replies)
170 const lastIn = await read($, lastReply)
171 void fillMissing($, list, about)
172
173 // The trigger's icon: an Svg where the surface draws one, a glyph elsewhere.
174 const icon = (name: SkillName, isActive: boolean) => {
175 const trigger = startedBy[name]
176 if (trigger === undefined) {
177 return null
178 }
179 const color = isActive ? GREEN : MUTED
180 if (e.surface === 'desktop' && 'Svg' in elements) {
181 const { Svg } = elements
182
183 return (
184 <Svg
185 source={triggerIcon(trigger.kind, color)}
186 alt={triggerLabel(trigger)}
187 width={ICON_SIZE}
188 height={ICON_SIZE}
189 />
190 )
191 }
192
193 return (
194 <Text size="footnote" color={color}>
195 {GLYPHS[trigger.kind]}
196 </Text>
197 )
198 }
199
200 if (list.length === 0) {
201 return (
202 <Text size="footnote" dimColor>
203 No skills used yet.
204 </Text>
205 )
206 }
207
208 const scopeOf = (name: SkillName) => `tile-${list.indexOf(name)}`
209
210 const badge = (name: SkillName) => {
211 const isActive = active.includes(name)
212 const fill = isActive ? TINT_ACTIVE : TINT
213 const hoverFill = isActive ? TINT_ACTIVE_HOVER : TINT_HOVER
214 const { plugin, skill } = splitName(name)
215 const count = runs[name] ?? 1
216
217 return (
218 <Box
219 key={name}
220 borderStyle="round"
221 borderColor={fill}
222 backgroundColor={fill}
223 paddingX={1}
224 flexDirection="row"
225 alignItems="center"
226 gap="xs"
227 hover={{ scope: scopeOf(name), borderColor: hoverFill, backgroundColor: hoverFill }}
228 >
229 {plugin !== undefined && (
230 <Text size="caption" color={MUTED}>
231 {plugin}
232 </Text>
233 )}
234 {isActive ? (
235 <Text size="footnote" color={GREEN}>
236 {skill}
237 </Text>
238 ) : (
239 <Text size="footnote" dimColor>
240 {skill}
241 </Text>
242 )}
243 {count > 1 && (
244 <Text size="caption" color={isActive ? GREEN : MUTED}>
245 ×{count}
246 </Text>
247 )}
248 {icon(name, isActive)}
249 </Box>
250 )
251 }
252
253 // The hovered badge's description: hidden until its badge is under the pointer.
254 const card = (name: SkillName) => (
255 <Box
256 key={`about-${name}`}
257 display="none"
258 hover={{ scope: scopeOf(name), display: 'flex' }}
259 flexDirection="column"
260 borderStyle="round"
261 borderColor={HAIRLINE}
262 backgroundColor={CARD}
263 paddingX={1}
264 >
265 <Text size="footnote" weight="medium">
266 {name}
267 </Text>
268 {startedBy[name] !== undefined && (
269 <Text size="caption" dimColor>
270 {triggerLabel(startedBy[name])}
271 </Text>
272 )}
273 {lastIn[name] !== undefined && (
274 <Text size="caption" dimColor>
275 {usageLine(runs[name] ?? 1, active.includes(name) ? 0 : replyNow - lastIn[name])}
276 </Text>
277 )}
278 <Box marginTop="xs">
279 <Text size="footnote" dimColor wrap="wrap">
280 {about[name] ?? 'No description.'}
281 </Text>
282 </Box>
283 </Box>
284 )
285
286 // Each row's cards sit right under it: a description opens beside its badge, and the rows below move down.
287 return (
288 <Box flexDirection="column" gap="xs">
289 {toRows(list, Math.max(20, e.props.bodyColumns), name => badgeWidth(name) + ((runs[name] ?? 1) > 1 ? 3 : 0)).map(row => (
290 <Box key={`row-${row[0]}`} flexDirection="column" gap="xs">
291 <Box flexDirection="row" flexWrap="wrap" gap="xs">
292 {row.map(badge)}
293 </Box>
294 {row.map(card)}
295 </Box>
296 ))}
297 </Box>
298 )
299 })
300}
301hooks/frontmatter.ts 27 lines1// The `description` of a SKILL.md frontmatter: inline, quoted, or a `|` / `>` block.
2export const frontmatterDescription = (file: string): string | undefined => {
3 const head = /^---\r?\n([\s\S]*?)\r?\n---/.exec(file)?.[1]
4 if (head === undefined) {
5 return undefined
6 }
7 const lines = head.split(/\r?\n/)
8 const at = lines.findIndex(line => /^description:/.test(line))
9 if (at === -1) {
10 return undefined
11 }
12 const inline = (lines[at] ?? '').slice('description:'.length).trim()
13 if (inline !== '' && !/^[|>][-+]?$/.test(inline)) {
14 return inline.replace(/^(['"])([\s\S]*)\1$/, '$2')
15 }
16 const block: string[] = []
17 for (const line of lines.slice(at + 1)) {
18 if (line.trim() !== '' && !/^\s/.test(line)) {
19 break
20 }
21 block.push(line.trim())
22 }
23 const text = block.join(' ').trim()
24
25 return text === '' ? undefined : text
26}
27hooks/labels.ts 22 lines1import type { SkillName } from '../types'
2
3// A plugin skill's name in two parts, so the badge can dim the plugin: `engineering:standup`.
4export const splitName = (name: SkillName): { plugin?: string; skill: string } => {
5 const at = name.lastIndexOf(':')
6
7 return at > 0 && at < name.length - 1 ? { plugin: name.slice(0, at), skill: name.slice(at + 1) } : { skill: name }
8}
9
10// How often a skill ran, and in which reply it last did.
11export const usageLine = (count: number, repliesAgo: number) => {
12 const times = count === 1 ? 'Used once' : `Used ${count} times`
13 const when =
14 repliesAgo <= 0
15 ? 'last in this reply'
16 : repliesAgo === 1
17 ? 'last in the previous reply'
18 : `last ${repliesAgo} replies ago`
19
20 return `${times} · ${when}`
21}
22hooks/rows.ts 32 lines1import type { SkillName } from '../types'
2
3// A badge's width in cells, rounded up: one cell per character, plus padding, border, icon and the gap after it.
4export const badgeWidth = (name: SkillName) => name.length + 7
5
6// Badges in rows that fit the panel, so a hovered badge's description can open right under its row.
7// A badge wider than the panel gets a row of its own.
8export const toRows = (
9 names: SkillName[],
10 columns: number,
11 widthOf: (name: SkillName) => number = badgeWidth,
12): SkillName[][] => {
13 const rows: SkillName[][] = []
14 let row: SkillName[] = []
15 let used = 0
16 for (const name of names) {
17 const width = widthOf(name)
18 if (row.length > 0 && used + width > columns) {
19 rows.push(row)
20 row = []
21 used = 0
22 }
23 row.push(name)
24 used += width
25 }
26 if (row.length > 0) {
27 rows.push(row)
28 }
29
30 return rows
31}
32hooks/triggers.ts 61 lines1import type { SkillName, Trigger } from '../types'
2
3// Outline icons from Tabler Icons (MIT), 24×24, stroked in the given color.
4const PATHS: Record<Trigger['kind'], string> = {
5 manual:
6 '<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"/><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/>',
7 model:
8 '<path d="M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zm0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zm-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6z"/>',
9 chained:
10 '<path d="M9 15l6 -6"/><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"/><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"/>',
11 subagent:
12 '<path d="M6 6a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"/><path d="M12 2v2"/><path d="M9 12v9"/><path d="M15 12v9"/><path d="M5 16l4 -2"/><path d="M15 14l4 2"/><path d="M9 18h6"/><path d="M10 8v.01"/><path d="M14 8v.01"/>',
13}
14
15// Stand-ins where a surface draws no Svg.
16export const GLYPHS: Record<Trigger['kind'], string> = {
17 manual: '/',
18 model: '✦',
19 chained: '↳',
20 subagent: '◇',
21}
22
23export const triggerIcon = (kind: Trigger['kind'], color: string) =>
24 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${PATHS[kind]}</svg>`
25
26export const triggerLabel = (trigger: Trigger) => {
27 switch (trigger.kind) {
28 case 'manual':
29 return 'Started by you'
30 case 'model':
31 return 'Started by Claude'
32 case 'chained':
33 return `Chained after ${trigger.after}`
34 case 'subagent':
35 return 'Started in a subagent'
36 }
37}
38
39// Who started a skill: a typed /name, a Skill call of the main loop or a subagent, or a preload.
40// The four kinds follow madolphe/skill-trace (MIT).
41export const classifyTrigger = (
42 skill: SkillName,
43 manualCommand: string | undefined,
44 pendingAgent: string | undefined,
45 activeInReply: SkillName[],
46): Trigger => {
47 if (manualCommand === skill) {
48 return { kind: 'manual' }
49 }
50 if (pendingAgent === undefined) {
51 // No Skill call to answer for it: preloaded into a subagent as it spawned.
52 return { kind: 'subagent' }
53 }
54 if (pendingAgent !== 'main') {
55 return { kind: 'subagent' }
56 }
57 const before = activeInReply.filter(name => name !== skill).at(-1)
58
59 return before === undefined ? { kind: 'model' } : { kind: 'chained', after: before }
60}
61types/index.d.ts 22 lines1export type SkillName = string
2
3export type Trigger =
4 | { kind: 'manual' }
5 | { kind: 'model' }
6 | { kind: 'chained'; after: SkillName }
7 | { kind: 'subagent' }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'skills-used': {
12 used: SkillName[]
13 current: SkillName[]
14 descriptions: Record<SkillName, string>
15 triggers: Record<SkillName, Trigger>
16 counts: Record<SkillName, number>
17 replies: number
18 lastReply: Record<SkillName, number>
19 }
20 }
21}
22