A pane listing the skills loaded in this session, with a toggle above the prompt

Tweaks to the Claude desktop app on Windows: collapse-all, an AutoHotkey overlay, and skills-pane, a Claude Code plugin.
Adds a "collapse all" icon to the title bar, just right of the Forward arrow. Clicking it collapses every section in the Code tab's sidebar (Pinned, Recents and each custom group).
Run it by double-clicking collapse-all\CollapseAll.ahk in Explorer. To start it at login, put a shortcut to that file in shell:startup. To stop it, right-click the green "H" tray icon and choose Exit.
Don't start it from a terminal inside Claude (or from a Claude Code session): it then belongs to Claude's process tree and closes when Claude does.
ClaudeSidebar.ahk reads the app through Windows UI Automation. Section headers are the expand/collapse buttons in the Sidebar group, and each one is collapsed through its ExpandCollapse pattern.CollapseButton.ahk draws a small always-on-top window (it never takes focus) over the title bar, with Lucide's chevrons-down-up icon drawn in GDI+. It shows only while Claude is focused, maximised and showing its sidebar (it reads the sidebar toggle's "Hide sidebar" / "Show sidebar" label), and it takes its colours from the title bar, so it works with light and dark themes. CollapseAll.ahk is the entry point.Forward, Hide sidebar, Show sidebar, Sidebar, More options for …). An app update that renames them will break it.A Claude Code plugin that opens a Skills pane listing the skills loaded in the session. Under "Injected at session start" it also lists context that SessionStart hooks added (such as i-have-adhd, which never loads as a skill). In the desktop app the pane docks on the right. A Skills toggle above the prompt opens and closes it: lit blue while the pane is open, dimmed while it's closed. /skills-pane opens it too (/skills is taken by the desktop app).
Install it from a terminal, using this folder as a local marketplace:
claude plugin marketplace add "C:\Personal Programs\claude-code-mods"
claude plugin install skills-pane@claude-code-mods
The plugin runs straight from skills-pane/, so an edit there reaches new sessions with no reinstall (/reload-plugins picks it up in a running terminal session).
skill.prompt fires on every skill load (/name, the Skill tool, or preloaded into a subagent). Skills loaded before the plugin are read back from the transcript's Skill tool calls.classic.SessionStart sits above the settings and plugin hooks, so it sees the context they return. Each entry is named by its first # heading, else its first line.Client (hooks/toggle.tsx) rather than a Button, because the desktop draws its own native buttons and ignores colours set on them.#3A9AEC on #032042) was sampled from the dark theme's Files icon; it doesn't follow a light theme.skill.prompt and classic.SessionStart run only for plugins it lists in managed settings.hooks/register.tsx 184 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const PANE = 'skills'
5const TITLE = 'Skills'
6const skills = atom({ plugin: 'skills-pane', key: 'skills' } as const, [])
7const injected = atom({ plugin: 'skills-pane', key: 'injected' } as const, [])
8const isClosed = atom({ plugin: 'skills-pane', key: 'isClosed' } as const, false)
9
10async function openPane($: EngineInterface) {
11 const opened = await $.ui.open({ id: PANE, title: TITLE }).catch((error: unknown) => ({
12 isPlaced: false as const,
13 reason: error instanceof Error ? error.message : String(error),
14 }))
15 if (opened.isPlaced) await update($, isClosed, () => false)
16
17 return opened
18}
19
20// The mod's own close does not pass through its own ui.close hook, so it sets the flag here.
21async function closePane($: EngineInterface) {
22 await $.ui.close({ id: PANE })
23 await update($, isClosed, () => true)
24}
25
26// Each adds the names not listed yet, keeping first-seen order.
27const addSkills = ($: EngineInterface, names: string[]) =>
28 update($, skills, seen => [...new Set([...seen, ...names])])
29const addInjected = ($: EngineInterface, names: string[]) =>
30 update($, injected, seen => [...new Set([...seen, ...names.filter(Boolean)])])
31
32// Names a SessionStart hook's context by its first "# heading", else its first line.
33function label(context: string) {
34 const heading = /^#\s+(.+?)\s*$/m.exec(context)?.[1]
35 const first = context.trim().split(/\r?\n/)[0] ?? ''
36
37 return heading ?? (first.length > 60 ? `${first.slice(0, 57)}...` : first)
38}
39
40// Skills the model loaded through the Skill tool before this module was loaded.
41async function loadedSoFar($: EngineInterface) {
42 const names: string[] = []
43 for (const message of await $.session.messages())
44 for (const use of message.toolUses)
45 if (use.tool === 'Skill' && !use.isError && typeof use.input.skill === 'string')
46 names.push(use.input.skill)
47
48 return names
49}
50
51// Context SessionStart hooks injected before this module was loaded. The model's
52// copy of the conversation carries each as "SessionStart:<source> hook success: <text>".
53const HOOK_CONTEXT = /SessionStart:\w+ hook success: ([\s\S]*?)(?=<\/system-reminder>|SessionStart:\w+ hook success: |$)/g
54
55async function injectedSoFar($: EngineInterface) {
56 const labels: string[] = []
57 for (const message of await $.session.messages({ as: 'api' }))
58 if (message.role === 'user')
59 for (const block of message.content)
60 if (block.type === 'text' && typeof block.text === 'string')
61 for (const match of block.text.matchAll(HOOK_CONTEXT))
62 labels.push(label(match[1] ?? ''))
63
64 return labels
65}
66
67export const register: Register = on => {
68 on('session.start', async ($, e, next) => {
69 const started = await next(e)
70 // Not "skills": the desktop app answers /skills itself, with its Skills settings page.
71 await $.command.register({ name: 'skills-pane', description: 'Show the skills loaded in this session' })
72 await addSkills($, await loadedSoFar($))
73 await addInjected($, await injectedSoFar($))
74 void openPane($)
75
76 return started
77 })
78
79 // The desktop app attaches after the session starts; open again so it seats the pane.
80 on('session.attach', async ($, e, next) => {
81 const attached = await next(e)
82 void openPane($)
83
84 return attached
85 })
86
87 // Every skill load: typed as /name, called through the Skill tool, or preloaded into a subagent.
88 on('skill.prompt', async ($, e, next) => {
89 await addSkills($, [e.skill])
90
91 return next(e)
92 })
93
94 // The settings and plugin hooks run beneath this one; their context comes back one entry per hook.
95 on('classic.SessionStart', async ($, e, next) => {
96 const result = await next(e)
97 await addInjected($, (result.additionalContext ?? []).map(label))
98
99 return result
100 })
101
102 // Closed by the person's X, the band's toggle or another plugin; an unload is not a close.
103 on('ui.close', async ($, e, next) => {
104 const closed = await next(e)
105 if (e.id === PANE && e.origin.kind !== 'unload') await update($, isClosed, () => true)
106
107 return closed
108 })
109
110 // A toggle for the pane above the prompt, drawn by ./toggle.tsx: lit while open,
111 // dimmed while closed. A desktop Button takes no colours, so it is a Client where
112 // the surface has them, and a plain Button elsewhere (VS Code, mobile). While the
113 // engine shows a survey here, its band is drawn above the toggle, not replaced.
114 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
115 const isOpen = !(await read($, isClosed))
116 const survey = e.props.hasSurvey ? await next(e) : undefined
117 if (e.surface === 'terminal' || e.surface === 'desktop') {
118 const { Box, Client } = $.ui.resolve(e)
119
120 return (
121 <Box flexDirection="column">
122 {survey}
123 <Box>
124 <Client key="toggle-skills" module="./toggle.tsx" props={{ isOpen }} />
125 </Box>
126 </Box>
127 )
128 }
129 const { Box, Button } = $.ui.resolve(e)
130
131 return (
132 <Box flexDirection="column">
133 {survey}
134 <Box>
135 <Button
136 key="toggle-skills"
137 label="Skills"
138 dimColor={!isOpen}
139 onPress={() => (isOpen ? closePane($) : openPane($))}
140 />
141 </Box>
142 </Box>
143 )
144 })
145
146 // The toggle was clicked.
147 on('ui.message', async ($, e, next) => {
148 if (e.element !== 'toggle-skills') return next(e)
149 if (await read($, isClosed)) await openPane($)
150 else await closePane($)
151
152 return {}
153 })
154
155 on('command.run', { command: 'skills-pane' }, async $ => {
156 const opened = await openPane($)
157
158 return { text: opened.isPlaced ? 'Skills pane opened.' : `Skills pane not placed: ${opened.reason}` }
159 })
160
161 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
162 const { Box, Text } = $.ui.resolve(e)
163 const loaded = await read($, skills)
164 const atStart = await read($, injected)
165
166 return (
167 <Box flexDirection="column">
168 {loaded.length === 0 && <Text dimColor>No skills loaded yet.</Text>}
169 {loaded.map(name => (
170 <Text>{name}</Text>
171 ))}
172 {atStart.length > 0 && (
173 <Box flexDirection="column" marginTop={1}>
174 <Text dimColor>Injected at session start</Text>
175 {atStart.map(name => (
176 <Text>{name}</Text>
177 ))}
178 </Box>
179 )}
180 </Box>
181 )
182 })
183}
184hooks/toggle.tsx 37 lines1import type { ClientModule } from 'claude-code'
2
3// The Skills toggle above the prompt, drawn by hand because a desktop Button takes
4// no colours: lit in the blue the title bar uses for an open panel (sampled from the
5// dark theme's Files icon), dimmed while the pane is closed. A click posts { toggle }.
6const ACTIVE = '#3A9AEC'
7const ACTIVE_BG = '#032042'
8const HOVER_BG = '#2C2C2C'
9
10type Props = { isOpen: boolean }
11type State = { isHovered: boolean }
12
13const Toggle: ClientModule<Props, State> = (props, surface) => {
14 const { Box, Text } = surface.elements
15 const isHovered = surface.state?.isHovered ?? false
16 // Unmeasured (0 x 0) counts as inside; measured, a release off the toggle cancels.
17 const isInside = (x: number, y: number) =>
18 surface.columns === 0 || (x >= 0 && y >= 0 && x < surface.columns && y < surface.rows)
19
20 // The instance holds the pointer from a press in its region to the release.
21 surface.onPointer(event => {
22 if (event.type === 'enter') surface.setState({ isHovered: true })
23 if (event.type === 'leave') surface.setState({ isHovered: false })
24 if (event.type === 'up' && event.button === 'left' && isInside(event.x, event.y)) surface.post({ toggle: true })
25 })
26
27 return (
28 <Box paddingX={1} backgroundColor={props.isOpen ? ACTIVE_BG : isHovered ? HOVER_BG : undefined}>
29 <Text color={props.isOpen ? ACTIVE : undefined} dimColor={!props.isOpen && !isHovered}>
30 Skills
31 </Text>
32 </Box>
33 )
34}
35
36export default Toggle
37types/index.d.ts 13 lines1// Skill names in the order they were first loaded this session.
2export type Skills = string[]
3
4// Labels of the context SessionStart hooks injected, in the order first seen.
5export type Injected = string[]
6
7declare module 'claude-code' {
8 interface PluginState {
9 // isClosed: the pane was closed, so the band's toggle draws dimmed and reopens it.
10 'skills-pane': { skills: Skills; injected: Injected; isClosed: boolean }
11 }
12}
13