One-row skills band above the prompt: theme-colored labels, Claude-orange dots on active skills and caveman/ponytail modes

A one-row band above the Claude Code prompt that shows which skills this session has used.
<sub>Preview of the band in a dark theme: caveman and ponytail active, seo-audit loading this turn (spinning), the rest used in earlier turns. Labels take your theme's text color, so a light theme shows dark text.</sub>
/name, loaded through the Skill tool, or preloaded into subagents all show up. Plugin skills show their short name (frontend-design, not frontend-design:frontend-design).● in Claude's color marks a skill used this turn. Skills from earlier turns have no dot and are dimmed. The most recent skills come first. ×N counts how many times a skill ran.◐◓◑◒ while Claude works, then shows ● when the turn ends.ponytail:ultra). When they stop, or aren't installed, they disappear.In Claude Code:
/plugin marketplace add codehamster69/skills-band
/plugin install skills-band@rohan-mods
Or from a shell:
claude plugin marketplace add codehamster69/skills-band
claude plugin install skills-band@rohan-mods
Restart Claude Code. The band appears above the prompt right away ("no skills used yet") and fills in as skills run. Skills that ran before the restart are not shown.
The band already shows their badges. If you had a statusLine in ~/.claude/settings.json that only showed those badges, you can remove it to avoid seeing them twice.
claude plugin update skills-band@rohan-mods
claude plugin uninstall skills-band@rohan-mods
hooks/hooks.json with modules).◐◓◑◒● (the Windows Terminal, iTerm2 and most Linux defaults have them).claude plugin validate .
claude plugin test .
The dot color is the ORANGE constant at the top of hooks/register.tsx; everything else follows your theme.
MIT
hooks/register.tsx 155 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Modes, Skill } from '../types'
5
6const used = atom({ plugin: 'skills-band', key: 'used' } as const, [] as Skill[])
7const turn = atom({ plugin: 'skills-band', key: 'turn' } as const, 0)
8const frame = atom({ plugin: 'skills-band', key: 'frame' } as const, 0)
9// whether the session's start reached this module; a mod loaded later never sees ponytail's one announcement
10const sawStart = atom({ plugin: 'skills-band', key: 'sawStart' } as const, false)
11const modes = atom({ plugin: 'skills-band', key: 'modes' } as const, { caveman: null, ponytail: null } as Modes)
12
13const SPIN = ['◐', '◓', '◑', '◒']
14// Claude's own orange marks what is active; everything else takes the surface's text color
15const ORANGE = '#d97757'
16
17// dot: an orange mark before the label, spin while its turn runs; dim: used in an earlier turn
18type Item = { key: string; text: string; dot: 'on' | 'spin' | null; dim: boolean }
19
20// the caveman and ponytail hooks say so in the context they inject; that text, not a flag file, proves a mode reached this session
21const MARKS = {
22 caveman: /CAVEMAN MODE ACTIVE(?: — mode: | \()([a-z0-9-]{1,20})/,
23 ponytail: /PONYTAIL MODE ACTIVE — level: ([a-z0-9-]{1,20})/,
24} as const
25
26const injected = (context: readonly string[] | undefined, name: keyof typeof MARKS) =>
27 (context ?? []).join('\n').match(MARKS[name])?.[1] ?? null
28
29// ponytail injects once at session start, so a later /ponytail level switch is read from its flag; whitelist the text
30const ponytailFlag = async ($: EngineInterface) => {
31 try {
32 const config = await $.env.get('CLAUDE_CONFIG_DIR')
33 const home = config ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''}/.claude`
34 const mode = String(await $.fs.read(`${home}/.ponytail-active`)).split(/\r?\n/)[0].trim()
35 return /^[a-z0-9-]{1,20}$/.test(mode) ? mode : null
36 } catch {
37 return null
38 }
39}
40
41// loaded after the session started (a reload, the desktop's late load): ponytail's flag is the only sign left
42const ponytailFallback = async ($: EngineInterface) => {
43 const isLate = !(await read($, sawStart))
44 if (!isLate && (await read($, modes)).ponytail === null) return
45 const level = await ponytailFlag($)
46 // the flag only relabels a ponytail this session already has, unless the start was missed
47 if (level !== null || isLate) await update($, modes, m => ({ ...m, ponytail: level }))
48}
49
50export const register: Register = on => {
51 let spinner: Timer | undefined
52
53 on('classic.SessionStart', async ($, e, next) => {
54 const ran = await next(e)
55 const context = (ran as { additionalContext?: string[] }).additionalContext
56 await update($, sawStart, () => true)
57 await update($, modes, () => ({ caveman: injected(context, 'caveman'), ponytail: injected(context, 'ponytail') }))
58 return ran
59 })
60
61 on('session.start', async ($, e, next) => {
62 const ran = await next(e)
63 await ponytailFallback($)
64 return ran
65 })
66
67 // caveman re-injects on every prompt while it is on, so the latest prompt decides its badge
68 on('classic.UserPromptSubmit', async ($, e, next) => {
69 const ran = await next(e)
70 const caveman = injected((ran as { additionalContext?: string[] }).additionalContext, 'caveman')
71 await update($, modes, m => ({ ...m, caveman }))
72 return ran
73 })
74
75 on('prompt.submit', async ($, e, next) => {
76 await update($, turn, n => n + 1)
77 const ran = await next(e)
78 await ponytailFallback($)
79 return ran
80 })
81
82 // skill.prompt fires for /name, the Skill tool and subagent preloads alike; tool.call saw only the Skill tool
83 on('skill.prompt', async ($, e, next) => {
84 const ran = await next(e)
85 const now = await read($, turn)
86 await update($, used, list => {
87 const prev = list.find(s => s.name === e.skill)
88 return [...list.filter(s => s.name !== e.skill), { name: e.skill, turn: now, count: (prev?.count ?? 0) + 1 }]
89 })
90 try {
91 spinner ??= $.clock.every(120, () => void update($, frame, f => f + 1))
92 } catch {
93 // no timers on this host: the spinner shows one frame instead of turning
94 }
95 return ran
96 })
97
98 on('turn.complete', async ($, e, next) => {
99 spinner?.cancel()
100 spinner = undefined
101 return next(e)
102 })
103
104 // above the prompt: the desktop draws no plugin tree in the hint line under it, so this band is the only place it shows
105 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
106 if (e.props.hasSurvey) {
107 return next(e)
108 }
109
110 const now = await read($, turn)
111 const tick = await read($, frame)
112 const m = await read($, modes)
113 const skills = [...(await read($, used))].sort((a, b) => b.turn - a.turn)
114 const always = (['caveman', 'ponytail'] as const).flatMap(name => {
115 const mode = m[name]
116 if (mode === null) return []
117 return [{ name, label: mode === 'full' || mode === name ? name : `${name}:${mode}` }]
118 })
119
120 // caveman and ponytail are only listed while their hooks inject, so a listed one is always active
121 const items: Item[] = [
122 ...always.map(a => ({ key: a.name, text: a.label, dot: 'on' as const, dim: false })),
123 ...skills.map(s => {
124 const isFresh = now === s.turn
125 // plugin skills arrive as plugin:skill; the plugin half is noise in the band
126 const label = s.name.split(':').pop() ?? s.name
127 return {
128 key: s.name,
129 text: `${label}${s.count > 1 ? ` ×${s.count}` : ''}`,
130 dot: isFresh ? (e.props.isWorking ? ('spin' as const) : ('on' as const)) : null,
131 dim: !isFresh,
132 }
133 }),
134 ]
135 const empty = skills.length === 0 ? 'no skills used yet' : null
136
137 // theme elements on every surface: an Svg cannot see the app's theme, so its text went unreadable when the two disagreed
138 const { Box, Text } = $.ui.resolve(e)
139 // the terminal's own 'claude' key is its theme's orange; elsewhere Claude's brand orange
140 const orange = e.surface === 'terminal' ? 'claude' : ORANGE
141 return (
142 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2}>
143 <Text key="header" bold>SKILLS</Text>
144 {items.map(it => (
145 <Box key={it.key} flexDirection="row" columnGap={1}>
146 {it.dot && <Text color={orange}>{it.dot === 'spin' ? SPIN[tick % SPIN.length] : '●'}</Text>}
147 <Text dimColor={it.dim}>{it.text}</Text>
148 </Box>
149 ))}
150 {empty && <Text key="empty" dimColor>{empty}</Text>}
151 </Box>
152 )
153 })
154}
155types/index.d.ts 9 lines1export type Skill = { name: string; turn: number; count: number }
2export type Modes = { caveman: string | null; ponytail: string | null }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'skills-band': { used: Skill[]; turn: number; frame: number; modes: Modes; sawStart: boolean }
7 }
8}
9