SLOPSHOPPER

skills-pane

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

newpanebandcommandprompt
v0.1.0no licenseupdated 2026-10-16keithwalsh/claude-code-mods/skills-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skills-pane
│ ┃ Skills ✕ › fix the failing auth test and add an audit log call │ ┃ No skills loaded yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /skills-pane │ ⎿ skills-pane: Skills pane opened. │ │ ▣ client module ./toggle.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./toggle.tsx
Pane · Skills
No skills loaded yet.
README

claude-code-mods

Tweaks to the Claude desktop app on Windows: collapse-all, an AutoHotkey overlay, and skills-pane, a Claude Code plugin.

collapse-all

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.

How it works

  • It's not a Claude Code plugin. Plugins draw only inside a session (panes, the band above the prompt, the transcript) and can't reach the app's title bar or sidebar.
  • 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.

Limits

  • It needs 64-bit AutoHotkey v2. On Windows 10 the hover square has square corners.
  • It depends on the app's accessibility labels (Forward, Hide sidebar, Show sidebar, Sidebar, More options for …). An app update that renames them will break it.
  • While Claude is focused, the app's accessibility tree stays switched on, which costs Chromium a little CPU.

skills-pane

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

How it works

  • 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.
  • The toggle is a Client (hooks/toggle.tsx) rather than a Button, because the desktop draws its own native buttons and ignores colours set on them.

Limits

  • The toggle's blue (#3A9AEC on #032042) was sampled from the dark theme's Files icon; it doesn't follow a light theme.
  • Where an organization manages Claude Code, skill.prompt and classic.SessionStart run only for plugins it lists in managed settings.
Source 3 files
hooks/register.tsx 184 lines
1import { 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}
184
hooks/toggle.tsx 37 lines
1import 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
37
types/index.d.ts 13 lines
1// 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