SLOPSHOPPER

thread-setup

Choose per thread whether Claude uses auto memory and which skills it can see, from a /setup panel, with defaults for new threads.

newpanebandspinnerguardcommand
v0.2.0MITupdated 2026-10-09tadassuksteris/thread-setup
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thread-setup
│ ┃ Thread setup ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ This thread │ ⏺ Read(src/auth.ts) │ ┃ │ ○ Memory off ● All skills on │ ⎿ Read 6 lines │ ┃ │ New threads │ ⏺ Update(src/auth.ts) │ ┃ │ ○ Memory off ● All skills on │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ │ Matches your defaults. │ ⎿ 3 pass, 1 fail │ ┃ │ │ │ ┃ │ [ Reset ] [ Save as default ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Memory [ Off ] │ › /setup │ ┃ │ Saved memories for this project. A │ ⎿ thread-setup: Thread setup: memory off · all skills on. │ ┃ │ change applies from your next message. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Skills [ All on ] [ All off ] │ │ ┃ │ Off hides a skill from Claude in this │ │ ┃ │ thread, and blocks it if called. │ │ ┃ │ No skills read yet. They show up here │ │ ┃ │ after your next message. │ │ ┃ ╰──────────────────────────────────────────╯ │ Memory for this thread [ Off ] · [ Skills & more ] [ Dismiss ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Memory for this thread [ Off ] · [ Skills & more ] [ Dismiss ]
Pane · Thread setup
╭──────────────────────────────────────────────────────────╮ │ This thread │ │ ○ Memory off ● All skills on │ │ New threads │ │ ○ Memory off ● All skills on │ │ │ │ Matches your defaults. │ │ │ │ [ Reset ] [ Save as default ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Memory [ Off ] │ │ Saved memories for this project. A change applies from │ │ your next message. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Skills [ All on ] [ All off ] │ │ Off hides a skill from Claude in this thread, and blocks │ │ it if called. │ │ No skills read yet. They show up here after your next │ │ message. │ ╰──────────────────────────────────────────────────────────╯
README

thread-setup

A Claude Code mod for choosing, per thread, whether Claude uses auto memory and which skills it can see. /setup opens a panel with both switches and saves what new threads start with.

Built and tested on Claude Code 2.1.291 on macOS, in the terminal and in the desktop app's Code tab.

What it does

Memory. Each thread starts with memory off unless you change the default. While it's off, the mod:

  • removes the memory section from the system prompt
  • drops auto-memory files from the context of the first message
  • refuses tool calls that read or write the memory folder (~/.claude/projects/<project>/memory/, or the folder named by autoMemoryDirectory), and the memory_* tools

Skills. Any skill can be switched off for a thread. Claude no longer finds it in its skill list, and a call to it through the Skill tool or as /name is refused. Subagents get the same filtered list.

Defaults. "Save as default" stores the thread's switches as the starting point for new threads.

Install

  1. Clone the repo:
   git clone https://github.com/tadassuksteris/thread-setup ~/.claude/mods/thread-setup
  1. Add these keys to ~/.claude/settings.json, merged with what's already there:
   {
     "autoMemoryEnabled": true,
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/thread-setup"
     }
   }

Auto memory has to be on: the mod can keep memory out of a thread, but it can't switch memory on. If CLAUDE_CODE_PLUGIN_DIRS already lists folders, add this one after a :.

  1. Start a new session, in the terminal or the desktop app, and run /setup. The panel should open.

The mod starts every thread with memory off. If you'd rather have memory on by default, run /mem default on once.

To try the mod for one terminal session without changing settings:

claude --plugin-dir ~/.claude/mods/thread-setup

Update

git -C ~/.claude/mods/thread-setup pull

Sessions started after the pull load the new version.

Uninstall

  1. In ~/.claude/settings.json, remove the CLAUDE_CODE_PLUGIN_DIRS entry. Set autoMemoryEnabled back to false if you had it off before.
  2. Delete ~/.claude/mods/thread-setup.
  3. To clear the choices the mod saved, delete ~/.claude/plugins/store/thread-setup_*.json.

Use

CommandWhat it does
/setupOpens the panel
/memOpens the memory switch above the prompt and prints the status
/mem on, /mem offSwitches memory for this thread
/mem statusPrints the status, including what was kept out so far
/mem default on, /mem default offSets memory for new threads

The panel has:

  • A status card comparing this thread with your defaults, with Reset and Save as default.
  • The memory switch.
  • The skill list, grouped as your skills, each plugin's skills, and built-in skills. Click a heading to fold its group, and use the filter, the All/On/Off view or the description length to find a skill. Filtering down to three skills or fewer shows their full descriptions.
  • On desktop, Mod settings at the bottom, with the switch for the status line above the prompt.

In the terminal, a new thread opens with the memory switch above the prompt, and the footer shows memory off and the number of skills off. The desktop app starts a session only when you send the first message, so there's nothing to show before that. Send /setup as the first message to choose before Claude reads anything. Once the session is running, the desktop app shows a status line above the prompt: memory, skills off, and whether the thread differs from your defaults, with a Change button and a close control. Closing it switches it off in every thread; the switch under Mod settings in the panel turns it back on.

When a change takes effect

Claude reads memory and the skill list when a conversation starts. Choosing before the first message gives a clean thread. A change made later applies from the next message, but Claude has already seen what it read before.

Changing skills mid-thread sends the skill list again, so the next request can't reuse the prompt cache.

Limits

  • The mod fails open. If it doesn't load, or one of its hooks fails, memory is on for that session, because auto memory is on globally. A mod that fails to load gets a dim line in the transcript.
  • The skill list is read from the message Claude receives, which names one skill per - name: description line. If a Claude Code update changes that format, the panel shows no skills. Skills already switched off are still refused when called.
  • For Bash, the guard checks the command text for the memory folder's path. A command that reaches the folder without naming it isn't caught.
  • Attachments that carry memory are recognised by name (anything with "memor" in it, except nested_memory, which is a CLAUDE.md in a subfolder).

Files

PathContents
hooks/register.tsxThe hooks, and every function that touches the engine ($)
hooks/panel.tsx, hooks/band.tsxThe panel and the row above the prompt, as views of plain data
hooks/controls.tsxThe buttons both views draw: switches, choices and actions, in one style
hooks/skills.tsReading and filtering the skill list, grouping skills
hooks/memory.tsDeciding whether a tool call touches auto memory
hooks/setup.tsComparing setups, store keys and checks on stored values
hooks/text.tsStatus wording
types/index.d.tsThe session values the mod keeps, declared for the engine
tests/Tests, run with claude plugin test

The engine follows $ only into functions declared in the same file as the hook that passes it, and reads state names only from that file's source. That's why everything that reads or writes state lives in register.tsx, and the other modules take plain values.

The mod keeps your defaults, the last skill list, folded groups and the description length in Claude Code's plugin store (~/.claude/plugins/store/thread-setup_*.json).

Development

claude plugin validate ~/.claude/mods/thread-setup
claude plugin test ~/.claude/mods/thread-setup

An interactive terminal session watches the folders in CLAUDE_CODE_PLUGIN_DIRS and reloads the mod when a file is saved. Desktop sessions also watch when CLAUDE_CODE_PLUGIN_DIR_WATCH=1 is set.

License

MIT. See LICENSE.

Source 9 files
hooks/register.tsx 458 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DescriptionMode, MemoryMode, Setup, SkillInfo, SkillView } from '../types'
5import { bandView, footerModes } from './band'
6import type { BandActions } from './band'
7import { isMemoryAttachment, MEMORY_DENIED, resolveMemoryDir, touchesMemory } from './memory'
8import { PANE_ID, PANE_TITLE, panelView } from './panel'
9import type { PanelActions, PanelInput } from './panel'
10import { listingWithout, parseListing, skillDenied, toSkillInfo } from './skills'
11import {
12  FACTORY_SETUP,
13  isSameSetup,
14  isSkillInfo,
15  isStringList,
16  mergeSkills,
17  NOTHING_KEPT_OUT,
18  STORE_KEYS,
19  toggled,
20} from './setup'
21import { describeSetup, statusReport } from './text'
22
23// Everything that touches the engine lives in this file: the engine follows `$`
24// only into functions declared beside the hook that hands it over. The other
25// modules are plain logic and views.
26
27// The session's values, which the UI draws from. The engine reads each one off
28// this file's source, so they are declared here, with literal plugin and key.
29const isThreadSetUp = atom({ plugin: 'thread-setup', key: 'isThreadSetUp' } as const, false)
30const memory = atom({ plugin: 'thread-setup', key: 'memory' } as const, FACTORY_SETUP.memory)
31const skillsOff = atom({ plugin: 'thread-setup', key: 'skillsOff' } as const, [] as string[])
32const defaults = atom({ plugin: 'thread-setup', key: 'defaults' } as const, FACTORY_SETUP)
33const keptOut = atom({ plugin: 'thread-setup', key: 'keptOut' } as const, NOTHING_KEPT_OUT)
34const isBandOpen = atom({ plugin: 'thread-setup', key: 'isBandOpen' } as const, false)
35const isStatusLineOn = atom({ plugin: 'thread-setup', key: 'isStatusLineOn' } as const, true)
36const skills = atom({ plugin: 'thread-setup', key: 'skills' } as const, [] as SkillInfo[])
37const isSkillListLive = atom({ plugin: 'thread-setup', key: 'isSkillListLive' } as const, false)
38const skillQuery = atom({ plugin: 'thread-setup', key: 'skillQuery' } as const, '')
39const skillView = atom({ plugin: 'thread-setup', key: 'skillView' } as const, 'all' as SkillView)
40const descriptionMode = atom(
41  { plugin: 'thread-setup', key: 'descriptionMode' } as const,
42  'line' as DescriptionMode,
43)
44const collapsedGroups = atom(
45  { plugin: 'thread-setup', key: 'collapsedGroups' } as const,
46  [] as string[],
47)
48
49const MEM_USAGE = 'Usage: /mem [on | off | status | default on | default off]'
50
51// Read from settings at each load, so the memory guard checks no file per call.
52let customMemoryDir: string | undefined
53
54// ── Reading and writing the session's values ─────────────────────────────
55
56async function loadStored($: EngineInterface) {
57  const storedMemory = await $.store.get(STORE_KEYS.defaultMemory)
58  const storedOff = await $.store.get(STORE_KEYS.defaultSkillsOff)
59  await update($, defaults, () => ({
60    memory: storedMemory === 'on' ? 'on' : 'off',
61    skillsOff: isStringList(storedOff) ? storedOff : [],
62  }))
63
64  const folded = await $.store.get(STORE_KEYS.collapsedGroups)
65  if (isStringList(folded)) await update($, collapsedGroups, () => folded)
66
67  if ((await $.store.get(STORE_KEYS.descriptionMode)) === 'full') {
68    await update($, descriptionMode, () => 'full')
69  }
70
71  if ((await $.store.get(STORE_KEYS.statusLine)) === 'off') await update($, isStatusLineOn, () => false)
72
73  // Until this thread's skill listing arrives, the panel shows the last one seen.
74  if ((await read($, skills)).length === 0) {
75    const known = await $.store.get(STORE_KEYS.knownSkills)
76    if (Array.isArray(known)) await update($, skills, () => known.filter(isSkillInfo))
77  }
78}
79
80async function readSettings($: EngineInterface) {
81  return (await $.settings.read()) as Record<string, unknown>
82}
83
84async function loadMemorySettings($: EngineInterface) {
85  customMemoryDir = resolveMemoryDir((await readSettings($)).autoMemoryDirectory, await $.env.get('HOME'))
86}
87
88async function currentSetup($: EngineInterface): Promise<Setup> {
89  return { memory: await read($, memory), skillsOff: await read($, skillsOff) }
90}
91
92async function isMemoryOn($: EngineInterface) {
93  return (await read($, memory)) === 'on'
94}
95
96// Each of these answers is cached by the engine; ask again under the new mode.
97function askAgainForMemory($: EngineInterface) {
98  $.ui.invalidate('prompt.section')
99  $.ui.invalidate('prompt.context')
100  $.ui.invalidate('prompt.attachment')
101}
102
103async function setMemory($: EngineInterface, mode: MemoryMode) {
104  await update($, memory, () => mode)
105  askAgainForMemory($)
106}
107
108async function toggleMemory($: EngineInterface) {
109  await update($, memory, mode => (mode === 'on' ? 'off' : 'on'))
110  askAgainForMemory($)
111}
112
113async function setSkillsOff($: EngineInterface, names: readonly string[]) {
114  await update($, skillsOff, () => [...new Set(names)].sort())
115  // The skill listing is an attachment; ask for it again so it drops or regains them.
116  $.ui.invalidate('prompt.attachment')
117}
118
119async function toggleSkill($: EngineInterface, name: string) {
120  await update($, skillsOff, names => toggled(names, name))
121  $.ui.invalidate('prompt.attachment')
122}
123
124/** All on, or every skill of the current list off. */
125async function setAllSkills($: EngineInterface, isOn: boolean) {
126  await setSkillsOff($, isOn ? [] : (await read($, skills)).map(skill => skill.name))
127}
128
129async function applyDefaults($: EngineInterface) {
130  const start = await read($, defaults)
131  await setMemory($, start.memory)
132  await setSkillsOff($, start.skillsOff)
133}
134
135async function saveDefaults($: EngineInterface, setup: Setup) {
136  await $.store.set(STORE_KEYS.defaultMemory, setup.memory)
137  await $.store.set(STORE_KEYS.defaultSkillsOff, setup.skillsOff)
138  await update($, defaults, () => setup)
139}
140
141/** Brings the thread back to the defaults; nothing to do when it already matches them. */
142async function resetToDefaults($: EngineInterface) {
143  if (isSameSetup(await currentSetup($), await read($, defaults))) return
144  await applyDefaults($)
145}
146
147async function saveThreadAsDefaults($: EngineInterface) {
148  const setup = await currentSetup($)
149  if (isSameSetup(setup, await read($, defaults))) return
150  await saveDefaults($, setup)
151  $.ui.toast(`New threads start with ${describeSetup(setup)}`)
152}
153
154/** A new conversation in this session (/clear): the defaults again, and the switch row. */
155async function startFresh($: EngineInterface) {
156  await applyDefaults($)
157  await update($, keptOut, () => NOTHING_KEPT_OUT)
158  await setBandOpen($, true)
159}
160
161async function setBandOpen($: EngineInterface, isOpen: boolean) {
162  await update($, isBandOpen, () => isOpen)
163}
164
165/** Shows or hides the desktop status line, in every thread. */
166async function setStatusLine($: EngineInterface, isOn: boolean) {
167  await update($, isStatusLineOn, () => isOn)
168  await $.store.set(STORE_KEYS.statusLine, isOn ? 'on' : 'off')
169}
170
171/** Flips the status line, from whatever it is when pressed. */
172async function toggleStatusLine($: EngineInterface) {
173  await update($, isStatusLineOn, isOn => !isOn)
174  await $.store.set(STORE_KEYS.statusLine, (await read($, isStatusLineOn)) ? 'on' : 'off')
175}
176
177/** Closing the status line is switching it off; say where it comes back from. */
178async function closeStatusLine($: EngineInterface) {
179  await setStatusLine($, false)
180  $.ui.toast('Status line off. Turn it back on in /setup, under Mod settings.')
181}
182
183/** Takes in the main thread's skill listing, and keeps it for the next thread's panel. */
184async function rememberSkills($: EngineInterface, seen: SkillInfo[]) {
185  // The first listing replaces the last thread's; later ones (skills added) merge in.
186  const earlier = (await read($, isSkillListLive)) ? await read($, skills) : []
187  const merged = mergeSkills(earlier, seen)
188  await update($, skills, () => merged)
189  await update($, isSkillListLive, () => true)
190  await $.store.set(STORE_KEYS.knownSkills, merged)
191}
192
193async function toggleGroup($: EngineInterface, label: string) {
194  await update($, collapsedGroups, groups => toggled(groups, label))
195  await $.store.set(STORE_KEYS.collapsedGroups, await read($, collapsedGroups))
196}
197
198async function openPanel($: EngineInterface) {
199  // Focused, the panel takes the first click as a press rather than as focus.
200  await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true })
201}
202
203// ── What the views draw from, and what their buttons do ──────────────────
204
205async function readPanelInput(
206  $: EngineInterface,
207  surface: PanelInput['surface'],
208  width: number,
209): Promise<PanelInput> {
210  return {
211    thread: await currentSetup($),
212    start: await read($, defaults),
213    skills: await read($, skills),
214    isLive: await read($, isSkillListLive),
215    query: await read($, skillQuery),
216    view: await read($, skillView),
217    describe: await read($, descriptionMode),
218    collapsed: await read($, collapsedGroups),
219    isStatusLineOn: await read($, isStatusLineOn),
220    surface,
221    width,
222  }
223}
224
225// Handlers run later, on a press: each reads the values it needs then, never
226// the ones the drawing saw.
227function panelActions($: EngineInterface): PanelActions {
228  return {
229    toggleMemory: () => toggleMemory($),
230    toggleSkill: name => toggleSkill($, name),
231    setAllSkills: isOn => setAllSkills($, isOn),
232    toggleGroup: label => toggleGroup($, label),
233    setQuery: query => update($, skillQuery, () => query),
234    setView: view => update($, skillView, () => view),
235    setDescriptionMode: async mode => {
236      await update($, descriptionMode, () => mode)
237      await $.store.set(STORE_KEYS.descriptionMode, mode)
238    },
239    resetToDefaults: () => resetToDefaults($),
240    saveAsDefaults: () => saveThreadAsDefaults($),
241    toggleStatusLine: () => toggleStatusLine($),
242  }
243}
244
245function bandActions($: EngineInterface): BandActions {
246  return {
247    toggleMemory: () => toggleMemory($),
248    openPanel: () => openPanel($),
249    close: () => setBandOpen($, false),
250    closeStatusLine: () => closeStatusLine($),
251  }
252}
253
254// ── Hooks ────────────────────────────────────────────────────────────────
255
256export const register: Register = on => {
257  on('session.start', async ($, e, next) => {
258    await $.command.register({
259      name: 'setup',
260      description: 'Thread setup: memory and skills for this thread, and the defaults',
261    })
262    await $.command.register({
263      name: 'mem',
264      description: 'Memory for this thread: on, off, status, or set the default',
265      argumentHint: 'on | off | status | default on|off',
266    })
267    await loadStored($)
268    await loadMemorySettings($)
269    // A reload leaves open sites drawn by the last load: redraw them with this one's handlers.
270    $.ui.invalidate('ui.render')
271
272    const hasMessages = (await $.session.messages()).length > 0
273    // A hot reload fires session.start again; only the first load sets the thread up.
274    if (!(await read($, isThreadSetUp))) {
275      await update($, isThreadSetUp, () => true)
276      await applyDefaults($)
277      // Only the terminal is up before the first message. The desktop app starts
278      // the session with that message, too late to choose: there, /setup opens it.
279      await setBandOpen($, !hasMessages && e.surface === 'terminal')
280    }
281    // Loaded into a thread already under way: ask for the skill listing again to read it.
282    if (hasMessages && !(await read($, isSkillListLive))) $.ui.invalidate('prompt.attachment')
283
284    return next(e)
285  })
286
287  // /clear starts a new conversation in the same process. Both events below see
288  // it; starting fresh is idempotent, so whichever one's state lands is right.
289  on('classic.SessionStart', async ($, e, next) => {
290    if (e.source === 'clear') await startFresh($)
291    if (e.source === 'resume') await setBandOpen($, false)
292
293    return next(e)
294  })
295
296  on('session.end', async ($, e, next) => {
297    if (e.reason === 'clear') await startFresh($)
298
299    return next(e)
300  })
301
302  on('prompt.submit', async ($, e, next) => {
303    // The first real message settles the choice; slash commands (like /mem) don't.
304    if (!e.text.trimStart().startsWith('/')) await setBandOpen($, false)
305
306    return next(e)
307  })
308
309  // Commands
310
311  on('command.run', { command: 'setup' }, async $ => {
312    await openPanel($)
313
314    return { text: `Thread setup: ${describeSetup(await currentSetup($))}.` }
315  })
316
317  on('command.run', { command: 'mem' }, async ($, e) => {
318    const [verb = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
319
320    if (verb === 'on' || verb === 'off') {
321      const hasStarted = (await $.session.messages()).length > 0
322      await setMemory($, verb)
323      const note = hasStarted ? ' It applies from the next message on.' : ''
324
325      return { text: `Memory is ${verb} for this thread.${note}` }
326    }
327
328    if (verb === 'default' && (value === 'on' || value === 'off')) {
329      await saveDefaults($, { ...(await read($, defaults)), memory: value })
330
331      return { text: `New threads now start with memory ${value}.` }
332    }
333
334    // Bare /mem also opens the switch row, at any point in the thread.
335    if (verb === '') await setBandOpen($, true)
336    if (verb !== '' && verb !== 'status') return { text: MEM_USAGE }
337
338    return {
339      text: statusReport({
340        thread: await currentSetup($),
341        start: await read($, defaults),
342        kept: await read($, keptOut),
343        isAutoMemoryEnabled: (await readSettings($)).autoMemoryEnabled !== false,
344      }),
345    }
346  })
347
348  // Memory: kept from the model while off
349
350  on('prompt.section', { name: 'memory' }, async ($, e, next) => {
351    const computed = await next(e)
352    if (computed.text === null || (await isMemoryOn($))) return computed
353
354    await update($, keptOut, kept => ({ ...kept, isSectionDropped: true }))
355
356    return { text: null }
357  })
358
359  on('prompt.context', async ($, e, next) => {
360    const computed = await next(e)
361    const files = computed.instructionFiles
362    if (files === undefined || (await isMemoryOn($))) return computed
363
364    const dropped = files.filter(file => file.kind === 'memory').map(file => file.path)
365    if (dropped.length === 0) return computed
366
367    await update($, keptOut, kept => ({ ...kept, files: [...new Set([...kept.files, ...dropped])] }))
368
369    return { ...computed, instructionFiles: files.filter(file => file.kind !== 'memory') }
370  })
371
372  on('prompt.attachment', async ($, e, next) => {
373    if (!isMemoryAttachment(e.type) || (await isMemoryOn($))) return next(e)
374
375    await update($, keptOut, kept => ({
376      ...kept,
377      attachments: [...new Set([...kept.attachments, e.type])],
378    }))
379
380    return { text: null }
381  })
382
383  on('tool.call', async ($, e, next) => {
384    const input = e as unknown as Record<string, unknown>
385    if (!touchesMemory(String(e.tool), input, customMemoryDir) || (await isMemoryOn($))) return next(e)
386
387    await update($, keptOut, kept => ({ ...kept, blockedCalls: kept.blockedCalls + 1 }))
388
389    return { deny: MEMORY_DENIED }
390  })
391
392  // Skills: hidden from the listing and blocked while off
393
394  on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
395    const listing = parseListing(e.text)
396    if (listing.entries.length === 0) return next(e)
397
398    // The main thread's listing is the one the panel shows; a subagent's is only filtered.
399    if (e.agentId === undefined) {
400      const commands = await $.command.list()
401      const yours = new Set(commands.filter(command => command.source === 'user').map(command => command.name))
402      await rememberSkills($, listing.entries.map(entry => toSkillInfo(entry, yours)))
403    }
404
405    const off = new Set(await read($, skillsOff))
406    if (!listing.entries.some(entry => off.has(entry.name))) return next(e)
407
408    const text = listingWithout(listing, off)
409
410    return text === null ? { text: null } : next({ ...e, text })
411  })
412
413  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
414    if (!(await read($, skillsOff)).includes(e.skill)) return next(e)
415
416    return { deny: skillDenied(e.skill) }
417  })
418
419  // A skill typed as /name, or preloaded, never goes through the Skill tool.
420  on('skill.prompt', async ($, e, next) => {
421    if (!(await read($, skillsOff)).includes(e.skill)) return next(e)
422
423    return { text: skillDenied(e.skill) }
424  })
425
426  // UI
427
428  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
429    const Input = e.surface === 'mobile' ? undefined : $.ui.resolve({ ...e, surface: e.surface }).Input
430    const input = await readPanelInput($, e.surface, e.props.bodyColumns)
431
432    return panelView($.ui.resolve(e), Input, input, panelActions($))
433  })
434
435  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
436    const tree = bandView(
437      $.ui.resolve(e),
438      {
439        isOpen: await read($, isBandOpen),
440        hasSurvey: e.props.hasSurvey,
441        surface: e.surface,
442        thread: await currentSetup($),
443        start: await read($, defaults),
444        isStatusLineOn: await read($, isStatusLineOn),
445      },
446      bandActions($),
447    )
448
449    return tree ?? next(e)
450  })
451
452  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
453    const modes = footerModes(e.props.modes, await currentSetup($))
454
455    return modes === undefined ? next(e) : next({ ...e, props: { ...e.props, modes } })
456  })
457}
458
hooks/band.tsx 100 lines
1import type { RenderElement, RenderSurface } from 'claude-code'
2
3import type { Setup } from '../types'
4import { action, toggle } from './controls'
5import type { Ui } from './controls'
6import { isSameSetup } from './setup'
7import { plural } from './text'
8
9// Like the panel, a view: plain values in, callbacks out.
10
11type BandInput = {
12  isOpen: boolean
13  hasSurvey: boolean
14  surface: RenderSurface
15  thread: Setup
16  start: Setup
17  isStatusLineOn: boolean
18}
19
20export type BandActions = {
21  toggleMemory: () => unknown
22  openPanel: () => unknown
23  close: () => unknown
24  closeStatusLine: () => unknown
25}
26
27/** The desktop's stand-in for the footer labels it doesn't draw: this thread's setup, at a glance. */
28function statusLine(ui: Ui, input: BandInput, actions: BandActions) {
29  const { Box, Text } = ui
30  const { memory, skillsOff } = input.thread
31  const isMemoryOn = memory === 'on'
32  const offCount = skillsOff.length
33
34  return (
35    <Box justifyContent="space-between" alignItems="center" gap={2}>
36      <Box alignItems="center" columnGap={2} flexWrap="wrap" flexShrink={1} minWidth={0}>
37        <Text bold>Thread setup</Text>
38        <Box>
39          <Text color={isMemoryOn ? 'green' : undefined} dimColor={!isMemoryOn}>
40            {isMemoryOn ? '●' : '○'}{' '}
41          </Text>
42          <Text>Memory {memory}</Text>
43        </Box>
44        <Box>
45          <Text color={offCount === 0 ? 'green' : 'yellow'}>● </Text>
46          {offCount === 0 ? (
47            <Text>All skills on</Text>
48          ) : (
49            <Text color="yellow">{plural(offCount, 'skill')} off</Text>
50          )}
51        </Box>
52        {!isSameSetup(input.thread, input.start) && <Text dimColor>Differs from your defaults</Text>}
53      </Box>
54      <Box alignItems="center" gap={1} flexShrink={0}>
55        {action(ui, 'change', 'Change', actions.openPanel)}
56        {action(ui, 'close-status', 'Close', actions.closeStatusLine, { isDismiss: true })}
57      </Box>
58    </Box>
59  )
60}
61
62function switchRow(ui: Ui, input: BandInput, actions: BandActions) {
63  const { Box, Text } = ui
64
65  return (
66    <Box gap={1} alignItems="center">
67      <Text dimColor>Memory for this thread</Text>
68      {toggle(ui, 'memory', input.thread.memory === 'on', actions.toggleMemory, input.surface === 'terminal')}
69      <Text dimColor>·</Text>
70      {action(ui, 'more', 'Skills & more', actions.openPanel)}
71      {action(ui, 'dismiss', 'Dismiss', actions.close, { isDismiss: true })}
72    </Box>
73  )
74}
75
76/**
77 * The band above the prompt: the switch row while it's open; on desktop, the
78 * status line once it's closed. Undefined leaves the band to the engine.
79 */
80export function bandView(ui: Ui, input: BandInput, actions: BandActions): RenderElement | undefined {
81  if (input.hasSurvey) return undefined
82  if (input.isOpen) return switchRow(ui, input, actions)
83
84  // The terminal shows the state in its footer; the desktop app doesn't draw
85  // footer labels, so there the closed band keeps a status line instead.
86  if (input.surface !== 'desktop' || !input.isStatusLineOn) return undefined
87
88  return statusLine(ui, input, actions)
89}
90
91/** The footer's mode labels with this thread's switches added; undefined when there's none to add. */
92export function footerModes(modes: readonly string[], thread: Setup) {
93  const added: string[] = []
94  // "memory paused" is the engine's own label for the same state.
95  if (thread.memory === 'off' && !modes.includes('memory paused')) added.push('memory off')
96  if (thread.skillsOff.length > 0) added.push(`${plural(thread.skillsOff.length, 'skill')} off`)
97
98  return added.length === 0 ? undefined : [...modes, ...added]
99}
100
hooks/memory.ts 36 lines
1// Auto memory lives in ~/.claude/projects/<project>/memory/, unless settings
2// name another folder (autoMemoryDirectory).
3const DEFAULT_MEMORY_DIR = /\.claude\/projects\/[^/\s'"]+\/memory(\/|\b|$)/
4const MEMORY_TOOLS = new Set(['memory_list', 'memory_read', 'memory_write'])
5// Tool inputs that may name a path; Bash names one inside its command.
6const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'command'] as const
7
8export const MEMORY_DENIED =
9  'Memory is off for this thread (thread-setup mod), so memory files and tools are blocked. ' +
10  'Carry on without them; the person can turn memory back on with /mem on.'
11
12/** A settings path with `~/` expanded and no trailing slash; undefined when it names nothing. */
13export function resolveMemoryDir(dir: unknown, home: string | undefined) {
14  if (typeof dir !== 'string' || dir.trim() === '') return undefined
15  const expanded = dir.startsWith('~/') && home !== undefined ? `${home}/${dir.slice(2)}` : dir
16
17  return expanded.replace(/\/+$/, '')
18}
19
20/** True when a tool call would read or write auto memory. */
21export function touchesMemory(tool: string, input: Record<string, unknown>, customDir?: string) {
22  if (MEMORY_TOOLS.has(tool.split('__').at(-1) ?? tool)) return true
23
24  return PATH_FIELDS.some(field => {
25    const value = input[field]
26    if (typeof value !== 'string') return false
27
28    return DEFAULT_MEMORY_DIR.test(value) || (customDir !== undefined && value.includes(customDir))
29  })
30}
31
32/** An attachment that carries auto memory; nested_memory is a CLAUDE.md in a subfolder. */
33export function isMemoryAttachment(type: string) {
34  return /memor/i.test(type) && type !== 'nested_memory'
35}
36
hooks/panel.tsx 431 lines
1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2
3import type { DescriptionMode, Setup, SkillInfo, SkillView } from '../types'
4import { action, choice, slotFor, toggle, toggleWidth } from './controls'
5import type { Ui } from './controls'
6import { groupSkills, shortName } from './skills'
7import { isSameSetup } from './setup'
8import { clip } from './text'
9
10// The panel is a view: it draws from plain values and calls back through
11// PanelActions, so nothing here touches the engine.
12
13export const PANE_ID = 'thread-setup'
14export const PANE_TITLE = 'Thread setup'
15
16type InputElement = Elements['terminal']['Input']
17
18/** What the panel draws from, read from the session's values. */
19export type PanelInput = {
20  thread: Setup
21  start: Setup
22  skills: SkillInfo[]
23  /** False while `skills` is the last thread's list. */
24  isLive: boolean
25  query: string
26  view: SkillView
27  describe: DescriptionMode
28  collapsed: readonly string[]
29  /** The desktop status line above the prompt is wanted. */
30  isStatusLineOn: boolean
31  surface: RenderSurface
32  width: number
33}
34
35export type PanelActions = {
36  toggleMemory: () => unknown
37  toggleSkill: (name: string) => unknown
38  setAllSkills: (isOn: boolean) => unknown
39  toggleGroup: (label: string) => unknown
40  setQuery: (query: string) => unknown
41  setView: (view: SkillView) => unknown
42  setDescriptionMode: (mode: DescriptionMode) => unknown
43  resetToDefaults: () => unknown
44  saveAsDefaults: () => unknown
45  toggleStatusLine: () => unknown
46}
47
48// Below this many cells the panel stacks what sits side by side when it's wide.
49const NARROW_BELOW = 64
50// Rows indent past the fold arrow of their group heading; less when narrow.
51const INDENT_TERMINAL = 2
52const INDENT_DESKTOP = 5
53const INDENT_NARROW = 2
54// What a row spends besides the name, indent and switch: the dot, the gap
55// before the description, and the card's border and padding.
56const ROW_CHROME = 14
57// A filter this narrow shows each match's whole description.
58const FULL_WHEN_AT_MOST = 3
59// The status grid's columns, when it's wide enough for a grid.
60const LABEL_COLUMN = 14
61const MEMORY_COLUMN = 16
62// Half a line between rows on desktop, where each row's toggle is a pill that
63// would otherwise touch the next; the terminal's bracketed buttons need none.
64const DESKTOP_ROW_GAP = 0.5
65const DESKTOP_ROW_HOVER = '#ffffff0f'
66
67/** The input with what the drawing works out from it. */
68type Panel = PanelInput & {
69  isAtDefaults: boolean
70  off: ReadonlySet<string>
71  shown: SkillInfo[]
72  /** Whole descriptions: chosen, or a filter narrow enough to show them. */
73  isFull: boolean
74  /** A filter or a view is on: every match shows, folded groups included. */
75  isNarrowed: boolean
76  isTerminal: boolean
77  /** Too narrow to set things side by side: they stack. */
78  isNarrow: boolean
79}
80
81function toPanel(input: PanelInput): Panel {
82  const query = input.query.trim().toLowerCase()
83  const off = new Set(input.thread.skillsOff)
84  const isInView = (skill: SkillInfo) => input.view === 'all' || (input.view === 'off') === off.has(skill.name)
85  const matches = (skill: SkillInfo) =>
86    query === '' || skill.name.toLowerCase().includes(query) || skill.details.toLowerCase().includes(query)
87  const shown = input.skills.filter(skill => isInView(skill) && matches(skill))
88
89  return {
90    ...input,
91    query,
92    isAtDefaults: isSameSetup(input.thread, input.start),
93    off,
94    shown,
95    isFull: input.describe === 'full' || (query !== '' && shown.length <= FULL_WHEN_AT_MOST),
96    isNarrowed: query !== '' || input.view !== 'all',
97    isTerminal: input.surface === 'terminal',
98    isNarrow: input.width < NARROW_BELOW,
99  }
100}
101
102/** One line of the status grid: this thread's, or what new threads start with. */
103function statusGridRow(ui: Ui, panel: Panel, label: string, setup: Setup, isMain: boolean) {
104  const { Box, Text } = ui
105  const isMemoryOn = setup.memory === 'on'
106  const offCount = setup.skillsOff.length
107  const total = panel.skills.length
108  const title = (
109    <Text bold={isMain} dimColor={!isMain}>
110      {label}
111    </Text>
112  )
113  const memoryPart = (
114    <Box flexShrink={0}>
115      <Text color={isMemoryOn ? 'green' : undefined} dimColor={!isMemoryOn}>
116        {isMemoryOn ? '●' : '○'}{' '}
117      </Text>
118      <Text dimColor={!isMain}>Memory {setup.memory}</Text>
119    </Box>
120  )
121  const skillsPart = (
122    <Box flexShrink={0}>
123      <Text color={offCount === 0 ? 'green' : 'yellow'}>● </Text>
124      {offCount === 0 ? (
125        <Text dimColor={!isMain}>All skills on</Text>
126      ) : (
127        <Box>
128          <Text dimColor={!isMain}>
129            {total > 0 ? `${Math.max(0, total - offCount)} skills on · ` : 'Skills: '}
130          </Text>
131          <Text color="yellow">{offCount} off</Text>
132        </Box>
133      )}
134    </Box>
135  )
136
137  if (panel.isNarrow) {
138    return (
139      <Box flexDirection="column">
140        {title}
141        <Box columnGap={3} flexWrap="wrap">
142          {memoryPart}
143          {skillsPart}
144        </Box>
145      </Box>
146    )
147  }
148
149  return (
150    <Box>
151      <Box width={LABEL_COLUMN} flexShrink={0}>
152        {title}
153      </Box>
154      <Box width={MEMORY_COLUMN} flexShrink={0}>
155        {memoryPart}
156      </Box>
157      {skillsPart}
158    </Box>
159  )
160}
161
162function statusCard(ui: Ui, panel: Panel, actions: PanelActions) {
163  const { Box, Text } = ui
164
165  return (
166    <Box
167      flexDirection="column"
168      borderStyle="round"
169      borderColor={panel.isAtDefaults ? undefined : 'yellow'}
170      borderDimColor={panel.isAtDefaults}
171      paddingX={1}
172    >
173      {statusGridRow(ui, panel, 'This thread', panel.thread, true)}
174      {statusGridRow(ui, panel, 'New threads', panel.start, false)}
175      {/* Always drawn, so the card keeps its size: only the colours change. */}
176      <Box
177        flexDirection={panel.isNarrow ? 'column' : 'row'}
178        justifyContent="space-between"
179        alignItems={panel.isNarrow ? 'flex-start' : 'center'}
180        rowGap={1}
181        marginTop={1}
182      >
183        {panel.isAtDefaults ? (
184          <Text dimColor>Matches your defaults.</Text>
185        ) : (
186          <Text color="yellow">This thread differs from your defaults.</Text>
187        )}
188        <Box flexShrink={0} gap={1}>
189          {action(ui, 'reset-default', 'Reset', actions.resetToDefaults, { isIdle: panel.isAtDefaults })}
190          {action(ui, 'save-default', 'Save as default', actions.saveAsDefaults, {
191            isIdle: panel.isAtDefaults,
192            isMain: true,
193          })}
194        </Box>
195      </Box>
196    </Box>
197  )
198}
199
200function memoryCard(ui: Ui, panel: Panel, actions: PanelActions) {
201  const { Box, Text } = ui
202
203  return (
204    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
205      <Box justifyContent="space-between" alignItems="center">
206        <Text bold>Memory</Text>
207        {toggle(ui, 'memory', panel.thread.memory === 'on', actions.toggleMemory, panel.isTerminal)}
208      </Box>
209      <Text dimColor>Saved memories for this project. A change applies from your next message.</Text>
210    </Box>
211  )
212}
213
214/**
215 * The mod's own settings, apart from the thread's: below the cards, under a
216 * dim label rather than a heading. Its one setting is the status line, which
217 * only the desktop app draws.
218 */
219function modSettings(ui: Ui, panel: Panel, actions: PanelActions) {
220  const { Box, Text } = ui
221
222  return (
223    <Box flexDirection="column" paddingX={1} marginTop={1}>
224      <Text dimColor>Mod settings</Text>
225      <Box justifyContent="space-between" alignItems="center" columnGap={1}>
226        <Box flexShrink={1} minWidth={0}>
227          <Text>Status line above the prompt</Text>
228        </Box>
229        {toggle(ui, 'status-line', panel.isStatusLineOn, actions.toggleStatusLine, panel.isTerminal)}
230      </Box>
231      <Text dimColor>Shows this thread's setup in every thread. Closing it there switches this off.</Text>
232    </Box>
233  )
234}
235
236function skillRow(ui: Ui, panel: Panel, actions: PanelActions, skill: SkillInfo) {
237  const { Box, Text } = ui
238  const isOff = panel.off.has(skill.name)
239  const name = shortName(skill.name)
240  const indent = panel.isTerminal ? INDENT_TERMINAL : panel.isNarrow ? INDENT_NARROW : INDENT_DESKTOP
241  const room = panel.width - name.length - indent - toggleWidth(panel.isTerminal) - ROW_CHROME
242  const blurb = clip(skill.description, Math.max(24, room))
243
244  return (
245    <Box
246      key={`row-${skill.name}`}
247      flexDirection="column"
248      paddingLeft={indent}
249      hover={panel.surface === 'desktop' ? { backgroundColor: DESKTOP_ROW_HOVER } : undefined}
250    >
251      <Box justifyContent="space-between" alignItems="center" columnGap={1}>
252        {/* Only the description gives way (minWidth 0), so the name stays whole and the switch in view. */}
253        <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
254          <Box flexShrink={0}>
255            <Text color={isOff ? undefined : 'green'} dimColor={isOff}>
256              {isOff ? '○' : '●'}{' '}
257            </Text>
258            <Text bold={!isOff} dimColor={isOff} strikethrough={isOff}>
259              {name}
260            </Text>
261          </Box>
262          {/* An off skill keeps only its name, unless descriptions are full. */}
263          {!panel.isFull && !isOff && (
264            <Box flexShrink={1} minWidth={0} overflow="hidden">
265              <Text dimColor wrap="truncate-end">
266                {'  '}
267                {blurb}
268              </Text>
269            </Box>
270          )}
271        </Box>
272        {toggle(ui, `skill-${skill.name}`, !isOff, () => actions.toggleSkill(skill.name), panel.isTerminal)}
273      </Box>
274      {panel.isFull && (
275        <Box paddingLeft={2} paddingBottom={1}>
276          <Text dimColor wrap="wrap">
277            {skill.details}
278          </Text>
279        </Box>
280      )}
281    </Box>
282  )
283}
284
285function skillGroup(
286  ui: Ui,
287  panel: Panel,
288  actions: PanelActions,
289  group: { label: string; skills: SkillInfo[] },
290) {
291  const { Box, Button, Text } = ui
292  const offCount = group.skills.filter(skill => panel.off.has(skill.name)).length
293  const isFolded = panel.collapsed.includes(group.label) && !panel.isNarrowed
294  const title = `${group.label} · ${group.skills.length}`
295
296  return (
297    <Box
298      key={`group-${group.label}`}
299      flexDirection="column"
300      rowGap={panel.surface === 'desktop' ? DESKTOP_ROW_GAP : 0}
301    >
302      {/* The whole heading folds the group: a wide target, not just the arrow. */}
303      <Box key={`head-${group.label}`} alignItems="center">
304        {panel.isNarrowed ? (
305          <Text bold>{title}</Text>
306        ) : (
307          <Button
308            key={`fold-${group.label}`}
309            label={`${isFolded ? '▸' : '▾'} ${title}`}
310            plain={panel.isTerminal ? true : undefined}
311            hover={{ underline: true }}
312            onPress={() => actions.toggleGroup(group.label)}
313          />
314        )}
315        {offCount > 0 && <Text color="yellow"> · {offCount} off</Text>}
316      </Box>
317      {!isFolded && group.skills.map(skill => skillRow(ui, panel, actions, skill))}
318    </Box>
319  )
320}
321
322function skillsCard(ui: Ui, Input: InputElement | undefined, panel: Panel, actions: PanelActions) {
323  const { Box, Text } = ui
324  const total = panel.skills.length
325  const offCount = panel.skills.filter(skill => panel.off.has(skill.name)).length
326  // Sized for the largest count the tabs can show, so a count that grows moves nothing.
327  const tabSlot = slotFor([`Off ${total}`, `All ${total}`], panel.isTerminal)
328
329  return (
330    <Box flexDirection="column" gap={1} borderStyle="round" borderDimColor paddingX={1}>
331      <Box flexDirection="column">
332        <Box justifyContent="space-between" alignItems="center">
333          <Text bold>Skills</Text>
334          <Box alignItems="center" gap={1}>
335            {action(ui, 'skills-all-on', 'All on', () => actions.setAllSkills(true), { isIdle: offCount === 0 })}
336            {action(ui, 'skills-all-off', 'All off', () => actions.setAllSkills(false), {
337              isIdle: total > 0 && offCount === total,
338            })}
339          </Box>
340        </Box>
341        <Text dimColor>Off hides a skill from Claude in this thread, and blocks it if called.</Text>
342        {total === 0 && <Text dimColor>No skills read yet. They show up here after your next message.</Text>}
343        {total > 0 && !panel.isLive && (
344          <Text dimColor>
345            Showing your last thread's skills until this thread's list is read, with your next message.
346          </Text>
347        )}
348      </Box>
349      {total > 0 && (
350        <Box
351          flexDirection={panel.isNarrow ? 'column' : 'row'}
352          justifyContent="space-between"
353          alignItems={panel.isNarrow ? 'flex-start' : 'center'}
354          flexWrap="wrap"
355          columnGap={2}
356          rowGap={panel.isNarrow ? 1 : 0}
357        >
358          <Box
359            flexDirection={panel.isNarrow ? 'column' : 'row'}
360            alignItems={panel.isNarrow ? 'flex-start' : 'center'}
361            columnGap={1}
362            rowGap={1}
363          >
364            {Input !== undefined && (
365              <Input
366                key="skill-filter"
367                placeholder="Filter skills"
368                value={panel.query}
369                onInput={value => actions.setQuery(value)}
370                onSubmit={value => actions.setQuery(value)}
371              />
372            )}
373            <Box alignItems="center" columnGap={1} flexShrink={0}>
374              <Text dimColor>Show</Text>
375              {choice(
376                ui,
377                'view',
378                panel.view,
379                [
380                  { value: 'all', label: `All ${total}` },
381                  { value: 'on', label: `On ${total - offCount}` },
382                  { value: 'off', label: `Off ${offCount}` },
383                ],
384                actions.setView,
385                tabSlot,
386              )}
387            </Box>
388          </Box>
389          <Box alignItems="center" columnGap={1} flexShrink={0}>
390            <Text dimColor>Descriptions</Text>
391            {choice(
392              ui,
393              'describe',
394              panel.describe,
395              [
396                { value: 'line', label: 'One line' },
397                { value: 'full', label: 'Full' },
398              ],
399              actions.setDescriptionMode,
400            )}
401          </Box>
402        </Box>
403      )}
404      {groupSkills(panel.shown).map(group => skillGroup(ui, panel, actions, group))}
405      {total > 0 && panel.shown.length === 0 && (
406        <Text dimColor>{panel.view === 'off' && panel.query === '' ? 'No skills are off.' : 'No skill matches.'}</Text>
407      )}
408    </Box>
409  )
410}
411
412/** The whole panel. `Input` is absent where the surface draws no text field. */
413export function panelView(
414  ui: Ui,
415  Input: InputElement | undefined,
416  input: PanelInput,
417  actions: PanelActions,
418): RenderElement {
419  const { Box } = ui
420  const panel = toPanel(input)
421
422  return (
423    <Box flexDirection="column" gap={1}>
424      {statusCard(ui, panel, actions)}
425      {memoryCard(ui, panel, actions)}
426      {skillsCard(ui, Input, panel, actions)}
427      {panel.surface === 'desktop' && modSettings(ui, panel, actions)}
428    </Box>
429  )
430}
431
hooks/skills.ts 89 lines
1import type { SkillInfo } from '../types'
2
3// The skill listing names one skill per "- name: description" line; a
4// description sometimes runs on over the lines after it.
5const SKILL_LINE = /^- (\S+): ?(.*)$/
6
7const GROUP_YOURS = 'Your skills'
8const GROUP_BUILT_IN = 'Built in'
9
10type ListingEntry = { name: string; lines: string[] }
11type Listing = { header: string[]; entries: ListingEntry[] }
12
13export function parseListing(text: string): Listing {
14  const listing: Listing = { header: [], entries: [] }
15
16  for (const line of text.split('\n')) {
17    const name = SKILL_LINE.exec(line)?.[1]
18    const last = listing.entries.at(-1)
19
20    if (name !== undefined) listing.entries.push({ name, lines: [line] })
21    else if (last !== undefined) last.lines.push(line)
22    else listing.header.push(line)
23  }
24
25  return listing
26}
27
28/** The listing without the skills that are off: null when none is left. */
29export function listingWithout(listing: Listing, off: ReadonlySet<string>) {
30  const kept = listing.entries.filter(entry => !off.has(entry.name))
31  if (kept.length === 0) return null
32
33  return [...listing.header, ...kept.flatMap(entry => entry.lines)].join('\n')
34}
35
36/** "anthropic-skills:docs" goes under its plugin; the rest are yours or built in. */
37function groupOf(name: string, yours: ReadonlySet<string>) {
38  const cut = name.indexOf(':')
39  if (cut > 0) {
40    const prefix = name.slice(0, cut).replace(/[-_]+/g, ' ')
41
42    return prefix.charAt(0).toUpperCase() + prefix.slice(1)
43  }
44
45  return yours.has(name) ? GROUP_YOURS : GROUP_BUILT_IN
46}
47
48/** `yours` names the person's own skills: their user and project slash commands. */
49export function toSkillInfo(entry: ListingEntry, yours: ReadonlySet<string>): SkillInfo {
50  const first = SKILL_LINE.exec(entry.lines[0] ?? '')?.[2] ?? ''
51  const whole = [first, ...entry.lines.slice(1)].join(' ')
52
53  return {
54    name: entry.name,
55    description: first.replace(/\s+/g, ' ').trim().slice(0, 240),
56    details: whole.replace(/\s+/g, ' ').trim().slice(0, 4000),
57    group: groupOf(entry.name, yours),
58  }
59}
60
61/** The name without its plugin prefix, as the panel shows it under that plugin's heading. */
62export function shortName(name: string) {
63  return name.slice(name.indexOf(':') + 1)
64}
65
66function groupRank(group: string) {
67  if (group === GROUP_YOURS) return 0
68  if (group === GROUP_BUILT_IN) return 2
69
70  return 1
71}
72
73/** Skills by heading: yours first, then each plugin's, then built in. */
74export function groupSkills(list: readonly SkillInfo[]) {
75  const groups = new Map<string, SkillInfo[]>()
76  for (const skill of list) groups.set(skill.group, [...(groups.get(skill.group) ?? []), skill])
77
78  return [...groups.entries()]
79    .map(([label, members]) => ({ label, skills: members }))
80    .sort((a, b) => groupRank(a.label) - groupRank(b.label) || a.label.localeCompare(b.label))
81}
82
83export function skillDenied(name: string) {
84  return (
85    `The ${name} skill is off for this thread (thread-setup mod). ` +
86    'Carry on without it; the person can turn it back on with /setup.'
87  )
88}
89
hooks/setup.ts 49 lines
1import type { KeptOut, Setup, SkillInfo } from '../types'
2
3// What a thread starts with until the person saves defaults of their own.
4export const FACTORY_SETUP: Setup = { memory: 'off', skillsOff: [] }
5
6export const NOTHING_KEPT_OUT: KeptOut = {
7  isSectionDropped: false,
8  files: [],
9  attachments: [],
10  blockedCalls: 0,
11}
12
13// Kept across sessions in $.store.
14export const STORE_KEYS = {
15  defaultMemory: 'defaultMemory',
16  defaultSkillsOff: 'defaultSkillsOff',
17  knownSkills: 'knownSkills',
18  collapsedGroups: 'collapsedGroups',
19  descriptionMode: 'descriptionMode',
20  statusLine: 'statusLine',
21} as const
22
23export function isStringList(value: unknown): value is string[] {
24  return Array.isArray(value) && value.every(item => typeof item === 'string')
25}
26
27export function isSkillInfo(value: unknown): value is SkillInfo {
28  if (typeof value !== 'object' || value === null) return false
29  const skill = value as Record<string, unknown>
30
31  return ['name', 'description', 'details', 'group'].every(key => typeof skill[key] === 'string')
32}
33
34export function isSameSetup(a: Setup, b: Setup) {
35  return a.memory === b.memory && a.skillsOff.join('\n') === b.skillsOff.join('\n')
36}
37
38/** The list with `item` added, or taken out if it was there; sorted. */
39export function toggled(list: readonly string[], item: string) {
40  return (list.includes(item) ? list.filter(other => other !== item) : [...list, item]).sort()
41}
42
43/** A later listing's skills over the earlier ones, by name; sorted. */
44export function mergeSkills(earlier: readonly SkillInfo[], later: readonly SkillInfo[]) {
45  const kept = earlier.filter(skill => !later.some(other => other.name === skill.name))
46
47  return [...kept, ...later].sort((a, b) => a.name.localeCompare(b.name))
48}
49
hooks/text.ts 53 lines
1import type { KeptOut, Setup } from '../types'
2
3export function plural(count: number, word: string) {
4  return `${count} ${word}${count === 1 ? '' : 's'}`
5}
6
7/** "memory off · 2 skills off", or "memory on · all skills on". */
8export function describeSetup(setup: Setup) {
9  const off = setup.skillsOff.length
10  const skillsPart = off === 0 ? 'all skills on' : `${plural(off, 'skill')} off`
11
12  return `memory ${setup.memory} · ${skillsPart}`
13}
14
15/** Cuts text to `room` characters with an ellipsis; nothing when there's hardly room. */
16export function clip(text: string, room: number) {
17  if (room < 8) return ''
18
19  return text.length <= room ? text : `${text.slice(0, room - 1)}…`
20}
21
22/** What `/mem status` prints. */
23export function statusReport(report: {
24  thread: Setup
25  start: Setup
26  kept: KeptOut
27  isAutoMemoryEnabled: boolean
28}) {
29  const { thread, start, kept } = report
30  const lines = [
31    `Memory is ${thread.memory} for this thread. New threads start with ${describeSetup(start)}.`,
32  ]
33
34  if (thread.skillsOff.length > 0) lines.push(`Skills off this thread: ${thread.skillsOff.join(', ')}.`)
35
36  if (!report.isAutoMemoryEnabled) {
37    lines.push(
38      'Auto memory is turned off in settings (autoMemoryEnabled: false), so there is no memory to load either way.',
39    )
40  }
41
42  const parts = [
43    kept.isSectionDropped && 'the memory prompt section',
44    kept.files.length > 0 && plural(kept.files.length, 'memory file'),
45    kept.attachments.length > 0 && `attachments: ${kept.attachments.join(', ')}`,
46    kept.blockedCalls > 0 && plural(kept.blockedCalls, 'blocked tool call'),
47  ].filter(Boolean)
48
49  if (parts.length > 0) lines.push(`Kept out so far: ${parts.join(' · ')}.`)
50
51  return lines.join('\n')
52}
53
hooks/controls.tsx 113 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3// Every button the panel and the band draw comes from here, under one rule:
4// bright means on, selected, or worth pressing now; grey and dimmed is the rest.
5
6export type Ui = Elements[RenderSurface]
7
8// Controls whose label changes ("On" to "Off", a count) sit in slots of a fixed
9// width, so nothing beside them moves: the longest label, and room for the
10// terminal's "[ " and " ]", or a desktop pill's padding.
11const TOGGLE_LABELS = ['On', 'Off'] as const
12const SLOT_ROOM_TERMINAL = 4
13const SLOT_ROOM_DESKTOP = 3
14
15export function slotFor(labels: readonly string[], isTerminal: boolean) {
16  const room = isTerminal ? SLOT_ROOM_TERMINAL : SLOT_ROOM_DESKTOP
17
18  return Math.max(...labels.map(label => label.length)) + room
19}
20
21export function toggleWidth(isTerminal: boolean) {
22  return slotFor(TOGGLE_LABELS, isTerminal)
23}
24
25/**
26 * A switch: one button whose label is its state, bright when on and dimmed
27 * when off. A press flips whatever the state is then, not what was drawn.
28 */
29export function toggle(ui: Ui, key: string, isOn: boolean, onToggle: () => unknown, isTerminal: boolean) {
30  const { Box, Button } = ui
31
32  return (
33    <Box width={toggleWidth(isTerminal)} justifyContent="center" flexShrink={0}>
34      <Button
35        key={key}
36        label={isOn ? 'On' : 'Off'}
37        variant={isOn ? 'primary' : 'secondary'}
38        dimColor={!isOn}
39        onPress={() => onToggle()}
40      />
41    </Box>
42  )
43}
44
45/**
46 * One of a few options: the selected one bright, the others dimmed. Given a
47 * slot, each option sits in one of that width, for labels that change (a
48 * count); without, they sit side by side.
49 */
50export function choice<T extends string>(
51  ui: Ui,
52  prefix: string,
53  selected: T,
54  options: readonly { value: T; label: string }[],
55  onPick: (value: T) => unknown,
56  slot?: number,
57) {
58  const { Box, Button } = ui
59  const button = (option: { value: T; label: string }) => (
60    <Button
61      key={`${prefix}-${option.value}`}
62      label={option.label}
63      variant={selected === option.value ? 'primary' : 'secondary'}
64      dimColor={selected !== option.value}
65      onPress={() => onPick(option.value)}
66    />
67  )
68
69  if (slot === undefined) {
70    return (
71      <Box flexShrink={0} gap={1}>
72        {options.map(button)}
73      </Box>
74    )
75  }
76
77  return (
78    <Box flexShrink={0}>
79      {options.map(option => (
80        <Box key={`${prefix}-slot-${option.value}`} width={slot} justifyContent="center">
81          {button(option)}
82        </Box>
83      ))}
84    </Box>
85  )
86}
87
88type ActionOptions = {
89  /** It would do nothing now: dimmed. */
90  isIdle?: boolean
91  /** The one thing worth pressing here: bright, unless it's idle. */
92  isMain?: boolean
93  /** It closes what it sits in; a desktop draws its own close control for it. */
94  isDismiss?: boolean
95}
96
97/** A button that does something once: grey, dimmed while it would do nothing. */
98export function action(ui: Ui, key: string, label: string, onPress: () => unknown, options: ActionOptions = {}) {
99  const { Button } = ui
100  const isIdle = options.isIdle === true
101
102  return (
103    <Button
104      key={key}
105      label={label}
106      variant={options.isMain === true && !isIdle ? 'primary' : 'secondary'}
107      dimColor={isIdle}
108      role={options.isDismiss === true ? 'dismiss' : undefined}
109      onPress={() => onPress()}
110    />
111  )
112}
113
types/index.d.ts 57 lines
1export type MemoryMode = 'on' | 'off'
2
3/** A thread's switches: what this thread runs with, or what new threads start with. */
4export type Setup = { memory: MemoryMode; skillsOff: string[] }
5
6/** What the mod has kept from the model this thread while memory was off. */
7export type KeptOut = {
8  isSectionDropped: boolean
9  files: string[]
10  attachments: string[]
11  blockedCalls: number
12}
13
14/** One skill as the skill listing names it. */
15export type SkillInfo = {
16  name: string
17  /** The description's first line. */
18  description: string
19  /** The whole description, every line joined. */
20  details: string
21  /** The heading it is listed under: its plugin prefix, "Your skills" or "Built in". */
22  group: string
23}
24
25/** Which skills the panel lists: every one, the ones on, or the ones off. */
26export type SkillView = 'all' | 'on' | 'off'
27
28/** How much of each description the panel shows. */
29export type DescriptionMode = 'line' | 'full'
30
31declare module 'claude-code' {
32  interface PluginState {
33    'thread-setup': {
34      /** Set once this load has given the thread its defaults; a hot reload keeps it. */
35      isThreadSetUp: boolean
36      memory: MemoryMode
37      skillsOff: string[]
38      /** What new threads start with; kept across threads. */
39      defaults: Setup
40      keptOut: KeptOut
41      /** The switch row above the prompt. */
42      isBandOpen: boolean
43      /** The desktop status line above the prompt; closing it switches it off. Kept across threads. */
44      isStatusLineOn: boolean
45      skills: SkillInfo[]
46      /** False while `skills` is the last thread's list, before this thread's arrives. */
47      isSkillListLive: boolean
48      skillQuery: string
49      skillView: SkillView
50      /** Kept across threads. */
51      descriptionMode: DescriptionMode
52      /** Group headings folded shut; kept across threads. */
53      collapsedGroups: string[]
54    }
55  }
56}
57