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

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.
Memory. Each thread starts with memory off unless you change the default. While it's off, the mod:
~/.claude/projects/<project>/memory/, or the folder named by autoMemoryDirectory), and the memory_* toolsSkills. 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.
git clone https://github.com/tadassuksteris/thread-setup ~/.claude/mods/thread-setup
~/.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 :.
/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
git -C ~/.claude/mods/thread-setup pull
Sessions started after the pull load the new version.
~/.claude/settings.json, remove the CLAUDE_CODE_PLUGIN_DIRS entry. Set autoMemoryEnabled back to false if you had it off before.~/.claude/mods/thread-setup.~/.claude/plugins/store/thread-setup_*.json.| Command | What it does |
|---|---|
/setup | Opens the panel |
/mem | Opens the memory switch above the prompt and prints the status |
/mem on, /mem off | Switches memory for this thread |
/mem status | Prints the status, including what was kept out so far |
/mem default on, /mem default off | Sets memory for new threads |
The panel has:
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.
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.
- 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.nested_memory, which is a CLAUDE.md in a subfolder).| Path | Contents |
|---|---|
hooks/register.tsx | The hooks, and every function that touches the engine ($) |
hooks/panel.tsx, hooks/band.tsx | The panel and the row above the prompt, as views of plain data |
hooks/controls.tsx | The buttons both views draw: switches, choices and actions, in one style |
hooks/skills.ts | Reading and filtering the skill list, grouping skills |
hooks/memory.ts | Deciding whether a tool call touches auto memory |
hooks/setup.ts | Comparing setups, store keys and checks on stored values |
hooks/text.ts | Status wording |
types/index.d.ts | The 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).
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.
MIT. See LICENSE.
hooks/register.tsx 458 lines1import { 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}
458hooks/band.tsx 100 lines1import 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}
100hooks/memory.ts 36 lines1// 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}
36hooks/panel.tsx 431 lines1import 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}
431hooks/skills.ts 89 lines1import 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}
89hooks/setup.ts 49 lines1import 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}
49hooks/text.ts 53 lines1import 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}
53hooks/controls.tsx 113 lines1import 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}
113types/index.d.ts 57 lines1export 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