See and control the skills, tools, files, and other context sent with every message

ctx-control hides selected startup context and blocks selected tools per working folder before the next model request. It covers instruction files, memory, skill listings, MCP server instructions, SessionStart hook output, the subagent type list, built-in tools, deferred tools, and whole MCP servers.
Run /ctx to open the tabbed pane. Included items are marked ●; press an item or group to change it. Skills, tools, files, and other startup context have separate tabs with filtering and paging. Eager tool rows cycle through Sent, On demand, and Blocked. Deferred tools remain searchable until blocked. Each tool group has a Block all / Unblock all button; unblocking keeps each tool's Sent or On demand choice. Blocking an MCP server also covers tools that server adds later. Turn on Show details (i) and hover a row whose name or description is cut off to see all of it, with its token count. Details stay off until you turn them on, and the choice is kept.
Profiles are named sets of what's on, on demand, hidden, and blocked, such as a Coding profile without vidIQ or Tella and an Editing one without rea or Playwright. The Profile row at the top of the pane picks one for this folder; + New saves what's on and off now under a name. Change things after picking a profile and the row offers Save to it or Revert. A folder's profile is applied again each session, so saving to a profile reaches every folder that uses it, and unsaved changes last until the session ends.
Token counts come from Claude Code's own context breakdown (the numbers /context and the desktop app's Context panel show), read in the background when a session starts, when the pane opens and after turns. Built-in tools and MCP server docs are reported only as totals, so the pane shares those out and marks the shares "about". The grey "Fixed" part of the bar is the system prompt and the conversation, which no switch reaches; with it, the headline equals Claude Code's figure until you hide something. Claude Code's own panel keeps counting hidden items, since it can't see what a mod removes; the pane's headline drops.
Commands:
/ctx
/ctx list
/ctx tools
/ctx profile [<name>|save <name>|rename <name> <new-name>|delete <name>|none]
/ctx hide <id-or-pattern> [...]
/ctx show <id-or-pattern> [...]
/ctx only <pattern> [...]
/ctx block <tool|server>
/ctx unblock <tool|server>
/ctx reset
/ctx save-default
/ctx help
Patterns may end in *, such as skill:superpowers:* or mcpdoc:*. A block target may be a full tool name, mcp__<server>__*, or a server label shown in the pane (for example vidiq). Folder settings take precedence over the default preset. Changes apply from the next message and rewrite the prompt cache once if made mid-session.
hooks/register.tsx 2559 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ThemeKey, ToolCallResult } from 'claude-code'
3
4import {
5 TAB_ORDER,
6 abbreviateHome,
7 applyEngineCounts,
8 blockCounts,
9 budgetCells,
10 contextSummary,
11 createToolCatalogItem,
12 engineCountsFrom,
13 formatCount,
14 wrappedLineCount,
15 displayMcpServer,
16 filterDeferredToolNames,
17 normalizeMcpServerName,
18 estimateTokens,
19 filterMcpInstructions,
20 filterSkillListing,
21 folderLabel,
22 formatTokens,
23 isHidden,
24 isServerBlockPattern,
25 isToolBlocked,
26 isToggleable,
27 findProfile,
28 migratePatterns,
29 normalizePatterns,
30 paginateHeights,
31 parseMcpInstructions,
32 parseMcpToolServer,
33 parseProfiles,
34 parseSkillListing,
35 presetDiffers,
36 profileNameError,
37 profileNames,
38 resolvePreset,
39 sanitizeCatalog,
40 showFromPreset,
41 skillNamespace,
42 splitArguments,
43 stringArray,
44 tabTotals,
45 toolState,
46 withoutMatchingToolBlocks,
47} from './lib'
48import type {
49 CatalogItem,
50 CatalogTab,
51 ContextSummary,
52 EngineCounts,
53 ParsedTextItem,
54 Profiles,
55 ToolState,
56} from './lib'
57
58const PANE = 'ctx-control'
59const activeTab = atom(
60 { plugin: 'ctx-control', key: 'activeTab' } as const,
61 'skills' as CatalogTab,
62)
63const filter = atom({ plugin: 'ctx-control', key: 'filter' } as const, '')
64const page = atom({ plugin: 'ctx-control', key: 'page' } as const, 0)
65const expandedToolGroups = atom(
66 { plugin: 'ctx-control', key: 'expandedToolGroups' } as const,
67 [] as string[],
68)
69// The pane's profile row: whether the name field is open, what's typed in it, and which
70// profile's Delete has been pressed once and waits for a second press.
71const profileNaming = atom({ plugin: 'ctx-control', key: 'profileNaming' } as const, false)
72const profileDraft = atom({ plugin: 'ctx-control', key: 'profileDraft' } as const, '')
73const profileDeleting = atom({ plugin: 'ctx-control', key: 'profileDeleting' } as const, '')
74// Whether rows show their hover cards; off until the person turns it on, kept across sessions.
75const showCards = atom({ plugin: 'ctx-control', key: 'showCards' } as const, false)
76const SHOW_CARDS_KEY = 'showCards'
77const INVALIDATED = [
78 'prompt.context',
79 'prompt.section',
80 'prompt.attachment',
81 'tool.describe',
82] as const
83
84const TAB_LABELS: Record<CatalogTab, string> = {
85 skills: 'Skills',
86 tools: 'Tools',
87 files: 'Files',
88 other: 'Other',
89}
90
91const TAB_COLORS: Record<CatalogTab, ThemeKey> = {
92 skills: 'suggestion',
93 tools: 'permission',
94 files: 'success',
95 other: 'warning',
96}
97
98let activeCwd = ''
99let homeDir: string | undefined
100let homeLoaded = false
101let applyToastShown = false
102let pendingPresetChange = false
103let activePreset: string[] = []
104let profiles: Profiles = {}
105// The profile this folder last picked. Its patterns are applied again at every session start,
106// so changes made since picking it last until they're saved to it or the session ends.
107let activeProfile: string | undefined
108
109const catalogCache = new Map<string, CatalogItem[]>()
110const catalogQueue = new Map<string, Promise<void>>()
111const presetCache = new Map<string, string[]>()
112const skillFallbackByCwd = new Map<string, boolean>()
113const confirmedSkillsByCwd = new Set<string>()
114const knownSkills = new Set<string>()
115const eagerTools = new Set<string>()
116// Built-in tools the engine reported as on demand; a built-in in neither set is assumed eager.
117const deferredBuiltins = new Set<string>()
118const knownMcpServers = new Set<string>()
119// Other-tab items seen in this session; the rest are remembered from earlier ones.
120const confirmedOther = new Set<string>()
121
122// Claude Code's own context breakdown (what its Context panel shows). It doesn't apply this
123// mod's hiding (verified), so the pane subtracts what it hides itself.
124let engineCounts: EngineCounts | undefined
125let engineCountsAt = 0
126let engineCountsInFlight: Promise<void> | undefined
127// Each read asks the token-count API, so automatic refreshes wait this long between reads.
128const ENGINE_COUNTS_MIN_AGE_MS = 30_000
129
130type SectionRow = {
131 kind: 'section'
132 key: string
133 label: string
134}
135
136type CaptionRow = {
137 kind: 'caption'
138 key: string
139 text: string
140}
141
142type GroupRow = {
143 kind: 'group'
144 key: string
145 label: string
146 items: CatalogItem[]
147 mode: 'generic' | 'eager' | 'deferred'
148 expanded?: boolean
149}
150
151type ItemRow = {
152 kind: 'item'
153 key: string
154 item: CatalogItem
155}
156
157type EmptyRow = {
158 kind: 'empty'
159 key: string
160 text: string
161}
162
163type PaneRow = SectionRow | CaptionRow | GroupRow | ItemRow | EmptyRow
164
165// What a hover card shows: the full name, what it is, and a dim line on what it costs.
166type HoverCard = { title: string; body?: string; meta?: string }
167
168// Cuts text to at most `max` characters at a word boundary, with an ellipsis.
169function clipAtWord(text: string, max: number): string {
170 if (text.length <= max) return text
171 const cut = text.slice(0, max - 1)
172 const space = cut.lastIndexOf(' ')
173 return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`
174}
175
176// Rows still to blank after our own /ctx ran: caveat, command line, output (that order, verified).
177let ctxRowsToBlank = 0
178
179function isCtxCommandRow(content: unknown): boolean {
180 if (ctxRowsToBlank === 0) return false
181 const text = Array.isArray(content)
182 ? content.map(block => (block !== null && typeof block === 'object' && 'text' in block && typeof block.text === 'string' ? block.text : '')).join('\n')
183 : ''
184 const ours = text.includes('<local-command-caveat>') ||
185 /<command-name>\/(ctx-control:)?ctx<\/command-name>/.test(text) ||
186 text.includes('<local-command-stdout>')
187 if (!ours) {
188 ctxRowsToBlank = 0
189 return false
190 }
191 ctxRowsToBlank -= 1
192 return true
193}
194
195function errorMessage(error: unknown): string {
196 return error instanceof Error ? error.message : String(error)
197}
198
199function log($: EngineInterface, message: string, toDebug = false): void {
200 try {
201 $.ui.log(message, toDebug ? { to: 'debug' } : undefined)
202 } catch {
203 // Logging must never turn a best-effort update or /ctx into a skipped hook.
204 }
205}
206
207async function trySet(
208 $: EngineInterface,
209 key: string,
210 value: unknown,
211): Promise<boolean> {
212 try {
213 await $.store.set(key, value)
214 return true
215 } catch (error) {
216 log($, `ctx-control: could not save ${key}: ${errorMessage(error)}`, true)
217 return false
218 }
219}
220
221async function cwdForSession($: EngineInterface): Promise<string> {
222 if (activeCwd !== '') return activeCwd
223 activeCwd = await $.session.cwd()
224 return activeCwd
225}
226
227async function loadHome($: EngineInterface): Promise<string | undefined> {
228 if (!homeLoaded) {
229 homeDir = await $.env.get('HOME')
230 homeLoaded = true
231 }
232 return homeDir
233}
234
235const serverNameHints = new Map<string, string>()
236
237async function loadServerNameHints($: EngineInterface): Promise<void> {
238 try {
239 const stored = await $.store.get('mcp:names')
240 if (stored !== null && typeof stored === 'object') {
241 for (const [server, name] of Object.entries(stored as Record<string, unknown>)) {
242 if (typeof name === 'string') serverNameHints.set(server, name)
243 }
244 }
245 } catch {
246 // names are a nicety; the raw server id still shows
247 }
248}
249
250async function saveServerNameHints(
251 $: EngineInterface,
252 items: readonly { id: string; label: string }[],
253): Promise<void> {
254 let changed = false
255 for (const item of items) {
256 const server = item.id.slice('mcpdoc:'.length)
257 if (item.label === server || serverNameHints.get(server) === item.label) continue
258 serverNameHints.set(server, item.label)
259 changed = true
260 }
261 if (changed) await trySet($, 'mcp:names', Object.fromEntries(serverNameHints))
262}
263
264function serverLabel(server: string, toolNames: readonly string[]): string {
265 return serverNameHints.get(server) ??
266 [...serverNameHints.entries()].find(([key]) => normalizeMcpServerName(key) === normalizeMcpServerName(server))?.[1] ??
267 displayMcpServer(server, toolNames)
268}
269
270async function loadCatalog(_$: EngineInterface, cwd: string): Promise<CatalogItem[]> {
271 const cached = catalogCache.get(cwd)
272 if (cached !== undefined) return cached
273 catalogCache.set(cwd, [])
274 return []
275}
276
277function sameCatalogItem(left: CatalogItem, right: CatalogItem): boolean {
278 return left.id === right.id &&
279 left.tab === right.tab &&
280 left.label === right.label &&
281 left.description === right.description &&
282 left.tokens === right.tokens &&
283 left.server === right.server &&
284 left.eager === right.eager &&
285 left.kind === right.kind
286}
287
288async function mergeCatalog(
289 $: EngineInterface,
290 cwd: string,
291 incoming: readonly CatalogItem[],
292): Promise<void> {
293 if (incoming.length === 0) return
294 const previous = catalogQueue.get(cwd) ?? Promise.resolve()
295 const queued = previous.then(() => mergeCatalogNow($, cwd, incoming))
296 catalogQueue.set(cwd, queued)
297 await queued
298 if (catalogQueue.get(cwd) === queued) catalogQueue.delete(cwd)
299}
300
301async function mergeCatalogNow(
302 $: EngineInterface,
303 cwd: string,
304 incoming: readonly CatalogItem[],
305): Promise<void> {
306 const current = await loadCatalog($, cwd)
307 const byId = new Map(current.map(item => [item.id, item]))
308 let changed = false
309
310 for (const item of incoming) {
311 const previous = byId.get(item.id)
312 if (previous === undefined || !sameCatalogItem(previous, item)) {
313 byId.set(item.id, item)
314 changed = true
315 }
316 }
317
318 const other = incoming.filter(item => item.tab === 'other')
319 if (other.length > 0) await rememberOtherItems($, other)
320
321 if (!changed) return
322 const merged = [...byId.values()].sort((left, right) => left.id.localeCompare(right.id))
323 catalogCache.set(cwd, merged)
324 $.ui.invalidate('ui.render')
325}
326
327async function replaceCatalogTab(
328 $: EngineInterface,
329 cwd: string,
330 tab: CatalogTab,
331 incoming: readonly CatalogItem[],
332): Promise<void> {
333 const previous = catalogQueue.get(cwd) ?? Promise.resolve()
334 const queued = previous.then(async () => {
335 const current = await loadCatalog($, cwd)
336 const replacement = [...incoming].sort((left, right) => left.id.localeCompare(right.id))
337 const next = [
338 ...current.filter(item => item.tab !== tab),
339 ...replacement,
340 ].sort((left, right) => left.id.localeCompare(right.id))
341 if (
342 current.length === next.length &&
343 current.every((item, index) => {
344 const candidate = next[index]
345 return candidate !== undefined && sameCatalogItem(item, candidate)
346 })
347 ) return
348 catalogCache.set(cwd, next)
349 if (tab === 'other') await rememberOtherItems($, replacement)
350 $.ui.invalidate('ui.render')
351 })
352 catalogQueue.set(cwd, queued)
353 await queued
354 if (catalogQueue.get(cwd) === queued) catalogQueue.delete(cwd)
355}
356
357let rememberedOtherItems: CatalogItem[] = []
358
359function mergeNewestItems(
360 current: readonly CatalogItem[],
361 incoming: readonly CatalogItem[],
362): CatalogItem[] {
363 const byId = new Map(current.map(item => [item.id, item]))
364 for (const item of incoming) {
365 byId.delete(item.id)
366 byId.set(item.id, item)
367 }
368 return [...byId.values()].slice(-60)
369}
370
371async function rememberOtherItems(
372 $: EngineInterface,
373 incoming: readonly CatalogItem[],
374): Promise<void> {
375 const next = mergeNewestItems(rememberedOtherItems, incoming.filter(item => item.tab === 'other'))
376 if (JSON.stringify(next) === JSON.stringify(rememberedOtherItems)) return
377 rememberedOtherItems = next
378 await trySet($, 'other:items', next)
379}
380
381async function deleteStoreKey($: EngineInterface, key: string): Promise<void> {
382 try {
383 await $.store.delete(key)
384 } catch (error) {
385 log($, `ctx-control: could not delete ${key}: ${errorMessage(error)}`, true)
386 }
387}
388
389async function migrateStore(
390 $: EngineInterface,
391 home: string | undefined,
392): Promise<void> {
393 let keys: string[] = []
394 try {
395 keys = await $.store.keys()
396 } catch (error) {
397 log($, `ctx-control: could not list its store: ${errorMessage(error)}`, true)
398 }
399
400 const storedKnownRaw = await $.store.get('skills:known').catch(() => undefined)
401 const storedKnown = stringArray(storedKnownRaw) ?? []
402 const migratedKnown = new Set(storedKnown)
403 const storedOtherRaw = await $.store.get('other:items').catch(() => undefined)
404 const storedOther = sanitizeCatalog(storedOtherRaw).filter(item => item.tab === 'other')
405 let migratedOther = storedOther
406 const catalogKeys = keys.filter(key => key.startsWith('catalog:'))
407
408 for (const key of catalogKeys) {
409 const catalog = sanitizeCatalog(await $.store.get(key).catch(() => undefined))
410 for (const item of catalog) {
411 if (item.tab === 'skills' && item.id.startsWith('skill:')) {
412 migratedKnown.add(item.id.slice('skill:'.length))
413 }
414 }
415 migratedOther = mergeNewestItems(
416 migratedOther,
417 catalog.filter(item => item.tab === 'other'),
418 )
419 }
420
421 const stalePresetKeys: string[] = []
422 const scratchRoot = home === undefined
423 ? undefined
424 : `${home}/Library/Application Support/Claude/scratch-workspaces/`
425 if (scratchRoot !== undefined) {
426 for (const key of keys) {
427 const prefix = ['preset:', 'profile:'].find(candidate => key.startsWith(candidate))
428 if (prefix === undefined) continue
429 const path = key.slice(prefix.length)
430 if (!path.startsWith(scratchRoot)) continue
431 const exists = await $.fs.stat(path).then(() => true).catch(() => false)
432 if (!exists) stalePresetKeys.push(key)
433 }
434 }
435
436 // Deleting old catalogs first is what lets migration recover an already-full store.
437 for (const key of [...catalogKeys, ...stalePresetKeys]) await deleteStoreKey($, key)
438
439 knownSkills.clear()
440 for (const name of migratedKnown) knownSkills.add(name)
441 rememberedOtherItems = migratedOther.slice(-60)
442 const seededKnown = normalizePatterns([...knownSkills])
443 if (
444 stringArray(storedKnownRaw) === undefined ||
445 JSON.stringify(storedKnown) !== JSON.stringify(seededKnown)
446 ) await trySet($, 'skills:known', seededKnown)
447 if (
448 !Array.isArray(storedOtherRaw) ||
449 JSON.stringify(storedOther) !== JSON.stringify(rememberedOtherItems)
450 ) await trySet($, 'other:items', rememberedOtherItems)
451}
452
453async function loadToolRegistry($: EngineInterface): Promise<void> {
454 const raw = await $.store.get('tools:eager').catch(() => undefined)
455 const parsed = stringArray(raw)
456 eagerTools.clear()
457 for (const name of parsed ?? []) eagerTools.add(name)
458 if (parsed === undefined) await trySet($, 'tools:eager', [])
459 deferredBuiltins.clear()
460 const deferred = stringArray(await $.store.get('tools:deferred').catch(() => undefined))
461 for (const name of deferred ?? []) deferredBuiltins.add(name)
462}
463
464async function rememberSkills(
465 $: EngineInterface,
466 names: readonly string[],
467): Promise<void> {
468 const before = normalizePatterns([...knownSkills])
469 for (const name of names) knownSkills.add(name)
470 const next = normalizePatterns([...knownSkills])
471 if (JSON.stringify(before) !== JSON.stringify(next)) await trySet($, 'skills:known', next)
472}
473
474async function recordToolEagerness(
475 $: EngineInterface,
476 name: string,
477 eager: boolean,
478): Promise<void> {
479 const before = normalizePatterns([...eagerTools])
480 if (eager) eagerTools.add(name)
481 else eagerTools.delete(name)
482 const next = normalizePatterns([...eagerTools])
483 if (JSON.stringify(before) !== JSON.stringify(next)) await trySet($, 'tools:eager', next)
484
485 // MCP tools are on demand unless listed eager; only built-ins need the opposite list.
486 if (parseMcpToolServer(name) !== undefined) return
487 const deferredBefore = deferredBuiltins.has(name)
488 if (eager) deferredBuiltins.delete(name)
489 else deferredBuiltins.add(name)
490 if (deferredBefore !== deferredBuiltins.has(name)) {
491 await trySet($, 'tools:deferred', normalizePatterns([...deferredBuiltins]))
492 }
493}
494
495// `full` counts with the token-count API, as /context and the desktop's Context panel do;
496// `summary` is local and free but overstates built-in tools (24.2k against 13.2k, measured).
497async function readBreakdown($: EngineInterface) {
498 try {
499 const full = (await $.session.usage({ breakdown: 'full' })).context.breakdown
500 if (full !== undefined) return full
501 } catch (error) {
502 log($, `ctx-control: full context breakdown failed, using the estimate: ${errorMessage(error)}`, true)
503 }
504 return (await $.session.usage({ breakdown: 'summary' })).context.breakdown
505}
506
507async function fetchEngineCounts($: EngineInterface): Promise<void> {
508 // Stamped before asking, so a failing or empty answer is retried at most every so often.
509 engineCountsAt = Date.now()
510 try {
511 const breakdown = await readBreakdown($)
512 if (breakdown === undefined) return
513 const next = engineCountsFrom(breakdown)
514 if (JSON.stringify(next) === JSON.stringify(engineCounts)) return
515 engineCounts = next
516 // Instruction files otherwise only arrive with the first message; the breakdown has them now.
517 await mergeCatalog($, activeCwd, Object.entries(next.memoryFiles).map(([path, tokens]) => {
518 const kind = next.memoryFileTypes[path] ?? 'memory'
519 return {
520 id: `file:${path}`,
521 tab: 'files' as const,
522 label: abbreviateHome(path, homeDir),
523 description: kind,
524 kind,
525 tokens,
526 }
527 }))
528 $.ui.invalidate('ui.render')
529 await refreshStatus($, activeCwd)
530 } catch (error) {
531 log($, `ctx-control: could not read Claude Code's context breakdown: ${errorMessage(error)}`, true)
532 }
533}
534
535let trailingRefresh: { cancel: () => void } | undefined
536
537/** Best effort: the pane falls back to estimates until Claude Code's breakdown arrives. */
538async function refreshEngineCounts($: EngineInterface, force = false): Promise<void> {
539 const age = Date.now() - engineCountsAt
540 if (!force && age < ENGINE_COUNTS_MIN_AGE_MS) {
541 // Too soon to ask again: read once more when the wait is over, so the last change still lands.
542 try {
543 trailingRefresh ??= $.clock.after(ENGINE_COUNTS_MIN_AGE_MS - age, () => {
544 trailingRefresh = undefined
545 void refreshEngineCounts($)
546 })
547 } catch (error) {
548 log($, `ctx-control: could not schedule a context recount: ${errorMessage(error)}`, true)
549 }
550 return
551 }
552 trailingRefresh?.cancel()
553 trailingRefresh = undefined
554 if (engineCountsInFlight !== undefined) return engineCountsInFlight
555 engineCountsInFlight = fetchEngineCounts($)
556 try {
557 await engineCountsInFlight
558 } finally {
559 engineCountsInFlight = undefined
560 }
561}
562
563function isConfirmedOther(item: CatalogItem): boolean {
564 if (confirmedOther.has(item.id)) return true
565 if (!item.id.startsWith('mcpdoc:')) return false
566 const heading = normalizeMcpServerName(item.id.slice('mcpdoc:'.length))
567 return [...knownMcpServers].some(server => normalizeMcpServerName(server) === heading)
568}
569
570/** The catalog as the pane counts it: Claude Code's numbers applied, unconfirmed leftovers marked. */
571function viewCatalog(catalog: readonly CatalogItem[]): CatalogItem[] {
572 const marked = catalog.map(item =>
573 item.tab === 'other' && !isConfirmedOther(item) ? { ...item, stale: true } : item,
574 )
575 return applyEngineCounts(marked, engineCounts)
576}
577
578async function summaryFor(
579 $: EngineInterface,
580 cwd: string,
581): Promise<{ catalog: CatalogItem[]; hidden: string[]; summary: ContextSummary }> {
582 const catalog = viewCatalog(await loadCatalog($, cwd))
583 const hidden = await loadHidden($, cwd)
584 return { catalog, hidden, summary: contextSummary(catalog, hidden, engineCounts) }
585}
586
587async function refreshAvailableCatalog(
588 $: EngineInterface,
589 cwd: string,
590): Promise<void> {
591 const [commands, tools] = await Promise.all([$.command.list(), $.tool.list()])
592 const fallback = knownSkills.size === 0
593 skillFallbackByCwd.set(cwd, fallback)
594
595 const skillItems = commands
596 .filter(command => fallback
597 ? command.source === 'plugin' || command.source === 'user'
598 : knownSkills.has(command.name))
599 .map(command => ({
600 id: `skill:${command.name}`,
601 tab: 'skills' as const,
602 label: command.name,
603 description: command.description,
604 tokens: estimateTokens(`- ${command.name}: ${command.description}`),
605 }))
606 const toolItems = tools.map(tool => {
607 const eager = eagerTools.has(tool.name) || (!tool.mcp && !deferredBuiltins.has(tool.name))
608 const server = parseMcpToolServer(tool.name)
609 if (server !== undefined) knownMcpServers.add(server)
610 return createToolCatalogItem(
611 tool.name,
612 tool.description,
613 eager ? undefined : true,
614 )
615 })
616
617 if (!confirmedSkillsByCwd.has(cwd)) {
618 await replaceCatalogTab($, cwd, 'skills', skillItems)
619 }
620 await replaceCatalogTab($, cwd, 'tools', toolItems)
621}
622
623async function migrateStoredPreset(
624 $: EngineInterface,
625 key: string,
626 value: unknown,
627): Promise<string[] | undefined> {
628 const parsed = stringArray(value)
629 if (parsed === undefined) return undefined
630 const migrated = migratePatterns(parsed)
631 if (JSON.stringify(parsed) !== JSON.stringify(migrated)) {
632 await trySet($, key, migrated)
633 }
634 return migrated
635}
636
637async function loadPresetAtSessionStart($: EngineInterface, cwd: string): Promise<string[]> {
638 const folderKey = `preset:${cwd}`
639 const folder = await migrateStoredPreset($, folderKey, await $.store.get(folderKey))
640 const defaults = await migrateStoredPreset($, 'preset:default', await $.store.get('preset:default'))
641 const hidden = resolvePreset(folder, defaults)
642 presetCache.set(cwd, hidden)
643 activePreset = hidden
644 return hidden
645}
646
647async function loadHidden(_$: EngineInterface, cwd: string): Promise<string[]> {
648 const cached = presetCache.get(cwd)
649 if (cached !== undefined) return cached
650 return cwd === activeCwd ? activePreset : []
651}
652
653function invalidateContext($: EngineInterface): void {
654 for (const event of INVALIDATED) $.ui.invalidate(event)
655 $.ui.invalidate('ui.render')
656}
657
658async function refreshStatus($: EngineInterface, cwd: string): Promise<void> {
659 const { saved } = (await summaryFor($, cwd)).summary
660 const counts = blockCounts(activePreset)
661 const blocked = counts.servers + counts.tools
662 const parts = [
663 ...(saved === 0 ? [] : [`-${formatTokens(saved)} hidden`]),
664 ...(blocked === 0 ? [] : [`blocking ${counts.servers} server${counts.servers === 1 ? '' : 's'} and ${counts.tools} tool${counts.tools === 1 ? '' : 's'}`]),
665 ]
666 $.ui.status(parts.length === 0 ? undefined : `ctx: ${parts.join(', ')}`)
667}
668
669async function saveFolderPreset(
670 $: EngineInterface,
671 cwd: string,
672 hidden: readonly string[],
673): Promise<void> {
674 const normalized = migratePatterns(hidden)
675 presetCache.set(cwd, normalized)
676 if (cwd === activeCwd) activePreset = normalized
677 pendingPresetChange = true
678 invalidateContext($)
679 await refreshStatus($, cwd)
680 await trySet($, `preset:${cwd}`, normalized)
681}
682
683function showApplyToast($: EngineInterface): void {
684 if (applyToastShown) return
685 applyToastShown = true
686 $.ui.toast(
687 'Takes effect on your next message. The first one after a change costs a little more while the cache refreshes.',
688 )
689}
690
691async function loadProfiles($: EngineInterface, cwd: string): Promise<void> {
692 profiles = parseProfiles(await $.store.get('profiles').catch(() => undefined))
693 const pointer = await $.store.get(`profile:${cwd}`).catch(() => undefined)
694 activeProfile = typeof pointer === 'string' ? findProfile(profiles, pointer) : undefined
695 if (typeof pointer === 'string' && activeProfile === undefined) {
696 await deleteStoreKey($, `profile:${cwd}`)
697 }
698 const patterns = activeProfile === undefined ? undefined : profiles[activeProfile]
699 if (patterns !== undefined && presetDiffers(patterns, await loadHidden($, cwd))) {
700 presetCache.set(cwd, patterns)
701 if (cwd === activeCwd) activePreset = patterns
702 await trySet($, `preset:${cwd}`, patterns)
703 }
704}
705
706async function saveProfiles($: EngineInterface): Promise<boolean> {
707 return trySet($, 'profiles', profiles)
708}
709
710async function setActiveProfile(
711 $: EngineInterface,
712 cwd: string,
713 name: string | undefined,
714): Promise<void> {
715 activeProfile = name
716 if (name === undefined) await deleteStoreKey($, `profile:${cwd}`)
717 else await trySet($, `profile:${cwd}`, name)
718 $.ui.invalidate('ui.render')
719}
720
721async function useProfile($: EngineInterface, cwd: string, name: string): Promise<void> {
722 const patterns = profiles[name]
723 if (patterns === undefined) return
724 await setActiveProfile($, cwd, name)
725 await saveFolderPreset($, cwd, patterns)
726 showApplyToast($)
727}
728
729// Saves what this folder has on and off now as a profile, new or replacing one, and uses it.
730async function saveProfile($: EngineInterface, cwd: string, name: string): Promise<void> {
731 profiles = { ...profiles, [name]: await loadHidden($, cwd) }
732 if (!await saveProfiles($)) return
733 await setActiveProfile($, cwd, name)
734}
735
736async function deleteProfile($: EngineInterface, cwd: string, name: string): Promise<void> {
737 const { [name]: _removed, ...rest } = profiles
738 profiles = rest
739 await saveProfiles($)
740 // The folder keeps what the profile had on and off; it just isn't tied to a profile now.
741 if (activeProfile === name) await setActiveProfile($, cwd, undefined)
742 else $.ui.invalidate('ui.render')
743}
744
745async function renameProfile(
746 $: EngineInterface,
747 cwd: string,
748 from: string,
749 to: string,
750): Promise<void> {
751 const { [from]: patterns, ...rest } = profiles
752 if (patterns === undefined) return
753 profiles = { ...rest, [to]: patterns }
754 await saveProfiles($)
755 if (activeProfile === from) await setActiveProfile($, cwd, to)
756 else $.ui.invalidate('ui.render')
757}
758
759// Drops the folder's own settings and profile, so the default preset (or nothing) applies.
760async function resetFolder($: EngineInterface, cwd: string): Promise<void> {
761 await $.store.delete(`preset:${cwd}`)
762 const defaults = migratePatterns(stringArray(await $.store.get('preset:default')) ?? [])
763 presetCache.set(cwd, defaults)
764 activePreset = defaults
765 pendingPresetChange = true
766 if (activeProfile !== undefined) await setActiveProfile($, cwd, undefined)
767 invalidateContext($)
768 await refreshStatus($, cwd)
769}
770
771async function submitProfileName($: EngineInterface, cwd: string, value: string): Promise<void> {
772 const name = value.trim()
773 if (profileNameError(name) !== undefined) {
774 await update($, profileDraft, () => value)
775 return
776 }
777 await saveProfile($, cwd, findProfile(profiles, name) ?? name)
778 await update($, profileNaming, () => false)
779 await update($, profileDraft, () => '')
780}
781
782async function setItemsVisibility(
783 $: EngineInterface,
784 cwd: string,
785 ids: readonly string[],
786 show: boolean,
787): Promise<void> {
788 if (ids.length === 0) return
789 const catalog = await loadCatalog($, cwd)
790 const hidden = await loadHidden($, cwd)
791 const next = show
792 ? showFromPreset(hidden, ids, catalog.map(item => item.id))
793 : migratePatterns([...hidden, ...ids])
794 await saveFolderPreset($, cwd, next)
795 showApplyToast($)
796}
797
798async function toggleItem($: EngineInterface, cwd: string, id: string): Promise<void> {
799 const catalog = await loadCatalog($, cwd)
800 const item = catalog.find(candidate => candidate.id === id)
801 if (item === undefined) return
802 if (item.tab === 'tools') {
803 await toggleTool($, cwd, item)
804 return
805 }
806 if (!isToggleable(item)) return
807 const hidden = await loadHidden($, cwd)
808 await setItemsVisibility($, cwd, [id], isHidden(id, hidden))
809}
810
811function toolName(item: CatalogItem): string {
812 return item.id.startsWith('tool:') ? item.id.slice('tool:'.length) : item.id
813}
814
815async function setToolState(
816 $: EngineInterface,
817 cwd: string,
818 item: CatalogItem,
819 state: ToolState,
820): Promise<void> {
821 const name = toolName(item)
822 const hidden = await loadHidden($, cwd)
823 let next = withoutMatchingToolBlocks(hidden, name).filter(pattern => pattern !== item.id)
824 if (state === 'demand' && item.eager === true) next.push(item.id)
825 if (state === 'blocked') next.push(`block:${name}`)
826 await saveFolderPreset($, cwd, next)
827 showApplyToast($)
828}
829
830async function toggleTool(
831 $: EngineInterface,
832 cwd: string,
833 item: CatalogItem,
834): Promise<void> {
835 const hidden = await loadHidden($, cwd)
836 const state = toolState(item, hidden)
837 const next = item.eager === true
838 ? state === 'sent'
839 ? 'demand'
840 : state === 'demand'
841 ? 'blocked'
842 : 'sent'
843 : state === 'blocked' ? 'demand' : 'blocked'
844 await setToolState($, cwd, item, next)
845}
846
847async function toggleToolGroup(
848 $: EngineInterface,
849 cwd: string,
850 items: readonly CatalogItem[],
851 mode: 'eager' | 'deferred',
852): Promise<void> {
853 if (items.length === 0) return
854 const hidden = await loadHidden($, cwd)
855 const states = items.map(item => toolState(item, hidden))
856 const allBlocked = states.every(state => state === 'blocked')
857 const allSent = states.every(state => state === 'sent')
858 let next = [...hidden]
859 for (const item of items) {
860 next = withoutMatchingToolBlocks(next, toolName(item)).filter(pattern => pattern !== item.id)
861 }
862
863 if (allBlocked) {
864 if (mode === 'eager') {
865 // Clearing both kinds of rule returns eager tools to Sent.
866 }
867 } else if (mode === 'eager' && allSent) {
868 next.push(...items.map(item => item.id))
869 } else {
870 const server = items[0]?.server
871 if (server === undefined) next.push(...items.map(item => `block:${toolName(item)}`))
872 else next.push(`block:mcp__${server}__*`)
873 }
874
875 await saveFolderPreset($, cwd, next)
876 showApplyToast($)
877}
878
879async function setToolsBlocked(
880 $: EngineInterface,
881 cwd: string,
882 items: readonly CatalogItem[],
883 blocked: boolean,
884): Promise<void> {
885 let next = await loadHidden($, cwd)
886 for (const item of items) {
887 const name = toolName(item)
888 next = withoutMatchingToolBlocks(next, name)
889 if (blocked) next.push(`block:${name}`)
890 }
891 await saveFolderPreset($, cwd, next)
892 showApplyToast($)
893}
894
895// Blocks or unblocks a whole tool group. An MCP server is blocked by one wildcard, so tools
896// it adds later are blocked too; unblocking keeps each tool's Sent or On demand choice.
897async function setGroupBlocked(
898 $: EngineInterface,
899 cwd: string,
900 items: readonly CatalogItem[],
901 blocked: boolean,
902): Promise<void> {
903 const server = items[0]?.server
904 if (!blocked || server === undefined) {
905 await setToolsBlocked($, cwd, items, blocked)
906 return
907 }
908 let next = await loadHidden($, cwd)
909 for (const item of items) next = withoutMatchingToolBlocks(next, toolName(item))
910 next.push(`block:mcp__${server}__*`)
911 await saveFolderPreset($, cwd, next)
912 showApplyToast($)
913}
914
915async function toggleItems(
916 $: EngineInterface,
917 cwd: string,
918 ids: readonly string[],
919): Promise<void> {
920 const hidden = await loadHidden($, cwd)
921 const allHidden = ids.length > 0 && ids.every(id => isHidden(id, hidden))
922 await setItemsVisibility($, cwd, ids, allHidden)
923}
924
925async function setActiveTab($: EngineInterface, value: CatalogTab): Promise<void> {
926 await update($, activeTab, () => value)
927 await update($, page, () => 0)
928}
929
930async function toggleShowCards($: EngineInterface): Promise<void> {
931 const next = !(await read($, showCards))
932 await update($, showCards, () => next)
933 await trySet($, SHOW_CARDS_KEY, next)
934}
935
936async function setFilter($: EngineInterface, value: string): Promise<void> {
937 await update($, filter, () => value)
938 await update($, page, () => 0)
939}
940
941async function setPage($: EngineInterface, value: number): Promise<void> {
942 await update($, page, () => Math.max(0, Math.floor(value)))
943}
944
945async function toggleExpandedToolGroup($: EngineInterface, key: string): Promise<void> {
946 await update($, expandedToolGroups, current =>
947 current.includes(key)
948 ? current.filter(candidate => candidate !== key)
949 : [...current, key],
950 )
951 await update($, page, () => 0)
952}
953
954async function openPane($: EngineInterface, refresh = true) {
955 if (refresh) await refreshAvailableCatalog($, await cwdForSession($))
956 const opened = await $.ui.open({ id: PANE, title: 'Context' })
957 void refreshEngineCounts($, true)
958 return opened
959}
960
961function catalogItemFromParsed(item: ParsedTextItem, tab: CatalogTab): CatalogItem {
962 return {
963 id: item.id,
964 tab,
965 label: item.label,
966 description: item.description,
967 tokens: estimateTokens(item.text),
968 }
969}
970
971function itemMatches(item: CatalogItem, query: string): boolean {
972 if (query === '') return true
973 return item.label.toLowerCase().includes(query) ||
974 item.description.toLowerCase().includes(query) ||
975 item.id.toLowerCase().includes(query) ||
976 (item.server?.toLowerCase().includes(query) ?? false)
977}
978
979function sumTokens(items: readonly CatalogItem[]): number {
980 return items.reduce((sum, item) => sum + item.tokens, 0)
981}
982
983function sortedItems(items: readonly CatalogItem[]): CatalogItem[] {
984 return [...items].sort(
985 (left, right) => right.tokens - left.tokens || left.label.localeCompare(right.label),
986 )
987}
988
989function groupedBy(
990 items: readonly CatalogItem[],
991 groupFor: (item: CatalogItem) => string,
992): Array<{ key: string; label: string; items: CatalogItem[] }> {
993 const byKey = new Map<string, CatalogItem[]>()
994 for (const item of items) {
995 const key = groupFor(item)
996 byKey.set(key, [...(byKey.get(key) ?? []), item])
997 }
998 return [...byKey.entries()].map(([key, members]) => ({
999 key,
1000 label: key,
1001 items: sortedItems(members),
1002 }))
1003}
1004
1005function toolGroups(
1006 items: readonly CatalogItem[],
1007): Array<{ key: string; label: string; items: CatalogItem[] }> {
1008 const groups = groupedBy(items, item => item.server ?? 'Built-in').map(group => ({
1009 ...group,
1010 label: group.key === 'Built-in'
1011 ? 'Built-in'
1012 : serverLabel(group.key, group.items.map(item => item.label)),
1013 }))
1014 return groups.sort((left, right) => {
1015 if (left.key === 'Built-in') return -1
1016 if (right.key === 'Built-in') return 1
1017 return sumTokens(right.items) - sumTokens(left.items) || left.label.localeCompare(right.label)
1018 })
1019}
1020
1021function genericGroups(
1022 items: readonly CatalogItem[],
1023 tab: Exclude<CatalogTab, 'tools'>,
1024): Array<{ key: string; label: string; items: CatalogItem[] }> {
1025 if (tab === 'skills') {
1026 return groupedBy(items, skillNamespace).sort(
1027 (left, right) => sumTokens(right.items) - sumTokens(left.items) ||
1028 left.label.localeCompare(right.label),
1029 )
1030 }
1031 if (tab === 'files') {
1032 const order: string[] = ['Instruction files', 'Memory']
1033 return groupedBy(items, item => item.id.startsWith('file:')
1034 ? 'Instruction files'
1035 : 'Memory')
1036 .sort((left, right) => order.indexOf(left.key) - order.indexOf(right.key))
1037 }
1038 const order: string[] = ['MCP server docs', 'Hook output', 'Subagents', 'Other']
1039 return groupedBy(items, item => {
1040 if (item.id.startsWith('mcpdoc:')) return 'MCP server docs'
1041 if (item.id === 'hook:context') return 'Hook output'
1042 if (item.id === 'attachment:agent_listing_delta') return 'Subagents'
1043 return 'Other'
1044 }).sort((left, right) => order.indexOf(left.key) - order.indexOf(right.key))
1045}
1046
1047function emptyMessage(tab: CatalogTab, hasItemsInTab: boolean, query: string): string {
1048 if (hasItemsInTab && query !== '') return 'No matches.'
1049 if (tab === 'other') {
1050 return 'Nothing here yet in this session. MCP instructions and hook output show up on your next message.'
1051 }
1052 return 'Nothing here yet.'
1053}
1054
1055function rowsForTab(
1056 catalog: readonly CatalogItem[],
1057 tab: CatalogTab,
1058 query: string,
1059 expanded: readonly string[] = [],
1060): PaneRow[] {
1061 const tabItems = catalog.filter(item => item.tab === tab)
1062 const filtered = tabItems.filter(item => itemMatches(item, query))
1063 if (filtered.length === 0) {
1064 return [{
1065 kind: 'empty',
1066 key: `empty:${tab}`,
1067 text: emptyMessage(tab, tabItems.length > 0, query),
1068 }]
1069 }
1070
1071 const rows: PaneRow[] = []
1072 if (tab !== 'tools') {
1073 for (const group of genericGroups(filtered, tab)) {
1074 rows.push({ ...group, kind: 'group', key: `group:${tab}:${group.key}`, mode: 'generic' })
1075 for (const item of group.items) {
1076 rows.push({ kind: 'item', key: `item:${item.id}`, item })
1077 }
1078 }
1079 return rows
1080 }
1081
1082 const eager = filtered.filter(item => item.eager === true)
1083 if (eager.length > 0) {
1084 rows.push({
1085 kind: 'section',
1086 key: 'section:sent',
1087 label: 'Sent with every message',
1088 })
1089 for (const group of toolGroups(eager)) {
1090 rows.push({
1091 ...group,
1092 kind: 'group',
1093 key: `group:tool:eager:${group.key}`,
1094 mode: 'eager',
1095 })
1096 for (const item of group.items) {
1097 rows.push({ kind: 'item', key: `item:${item.id}`, item })
1098 }
1099 }
1100 }
1101
1102 const deferred = filtered.filter(item => item.eager !== true)
1103 if (deferred.length > 0) {
1104 rows.push({
1105 kind: 'section',
1106 key: 'section:demand',
1107 label: 'On demand',
1108 })
1109 for (const group of toolGroups(deferred)) {
1110 const groupKey = `group:tool:deferred:${group.key}`
1111 const isExpanded = query !== '' || expanded.includes(group.key)
1112 rows.push({
1113 kind: 'group',
1114 key: groupKey,
1115 label: group.label,
1116 items: group.items,
1117 mode: 'deferred',
1118 expanded: isExpanded,
1119 })
1120 if (isExpanded) {
1121 for (const item of group.items) {
1122 rows.push({ kind: 'item', key: `item:${item.id}`, item })
1123 }
1124 }
1125 }
1126 }
1127 return rows
1128}
1129
1130function groupState(
1131 items: readonly CatalogItem[],
1132 hidden: readonly string[],
1133 mode: GroupRow['mode'],
1134): string {
1135 if (mode !== 'generic') {
1136 const states = items.map(item => toolState(item, hidden))
1137 if (states.every(state => state === 'blocked')) return 'Blocked'
1138 if (mode === 'deferred' && states.every(state => state !== 'blocked')) return 'Available'
1139 if (mode === 'eager' && states.every(state => state === 'sent')) return 'Sent'
1140 if (mode === 'eager' && states.every(state => state === 'demand')) return 'On demand'
1141 return 'Mixed'
1142 }
1143 const hiddenCount = items.filter(item => isHidden(item.id, hidden)).length
1144 if (hiddenCount === 0) return 'On'
1145 if (hiddenCount === items.length) return 'Off'
1146 return 'Mixed'
1147}
1148
1149function truncateEnd(value: string, width: number): string {
1150 const available = Math.max(0, Math.floor(width))
1151 if (value.length <= available) return value
1152 if (available === 0) return ''
1153 if (available === 1) return '…'
1154 return `${value.slice(0, available - 1)}…`
1155}
1156
1157function rowName(row: PaneRow): string {
1158 if (row.kind === 'item') return row.item.label
1159 if (row.kind === 'section' || row.kind === 'group') {
1160 return row.label
1161 }
1162 return ''
1163}
1164
1165function tabLabels(
1166 totals: ReturnType<typeof tabTotals>,
1167): Record<CatalogTab, string> {
1168 return Object.fromEntries(
1169 TAB_ORDER.map(tab => [tab, `${TAB_LABELS[tab]} ${formatTokens(totals[tab].sentTokens)}`]),
1170 ) as Record<CatalogTab, string>
1171}
1172
1173function wrappedTabRows(
1174 labels: Readonly<Record<CatalogTab, string>>,
1175 width: number,
1176 extraLabels: readonly string[] = [],
1177): number {
1178 let rows = 1
1179 let used = 0
1180 for (const label of [...TAB_ORDER.map(tab => labels[tab]), ...extraLabels]) {
1181 const itemWidth = Math.min(width, label.length + 6)
1182 const nextWidth = used === 0 ? itemWidth : used + 2 + itemWidth
1183 if (used > 0 && nextWidth > width) {
1184 rows += 1
1185 used = itemWidth
1186 } else {
1187 used = nextWidth
1188 }
1189 }
1190 return rows
1191}
1192
1193// Rows a wrapping line of buttons takes, each counted as `[ label ]`.
1194function wrappedChipRows(labels: readonly string[], width: number, gap: number): number {
1195 let rows = 1
1196 let used = 0
1197 for (const label of labels) {
1198 const itemWidth = Math.min(width, label.length + 4)
1199 const nextWidth = used === 0 ? itemWidth : used + gap + itemWidth
1200 if (used > 0 && nextWidth > width) {hooks/lib.ts 833 lines1export const TAB_ORDER = ['skills', 'tools', 'files', 'other'] as const
2
3export type CatalogTab = (typeof TAB_ORDER)[number]
4
5export type CatalogItem = {
6 id: string
7 tab: CatalogTab
8 label: string
9 description: string
10 tokens: number
11 server?: string
12 eager?: boolean
13 kind?: string
14 /** Remembered from an earlier session and not seen in this one: shown, never counted. */
15 stale?: boolean
16 /** Its tokens are a share of a total Claude Code reports only as a whole. */
17 approximate?: boolean
18}
19
20export type ParsedTextItem = {
21 id: string
22 label: string
23 description: string
24 text: string
25 start: number
26 end: number
27}
28
29export type TabTotal = {
30 count: number
31 sentTokens: number
32 savedTokens: number
33}
34
35export type Pagination = {
36 page: number
37 pageCount: number
38 pageSize: number
39 start: number
40 end: number
41 showPager: boolean
42}
43
44export type WeightedPagination = {
45 page: number
46 pageCount: number
47 start: number
48 end: number
49 showPager: boolean
50}
51
52export type BudgetCells = {
53 sent: Record<CatalogTab, number>
54 fixed: number
55 hidden: number
56}
57
58/** Claude Code's own context breakdown, reduced to what the pane needs. */
59export type EngineCounts = {
60 /** Every `used` row together: what Claude Code's Context panel shows as used. */
61 used: number
62 systemPrompt: number
63 messages: number
64 systemTools: number
65 mcpInstructions: number
66 agents: number
67 skills: Record<string, number>
68 memoryFiles: Record<string, number>
69 /** Each memory file's kind as Claude Code names it (`User`, `Project`, …). */
70 memoryFileTypes: Record<string, string>
71 /** Eager (loaded) MCP tools only. */
72 mcpTools: Record<string, number>
73}
74
75/** The slice of `SessionContextBreakdown` that `engineCountsFrom` reads. */
76export type BreakdownInput = {
77 categories: readonly { name: string; tokens: number; kind: string }[]
78 memoryFiles: readonly { path: string; tokens: number; type?: string }[]
79 mcpTools: readonly { name: string; tokens: number; isLoaded: boolean }[]
80 agents: readonly { tokens: number }[]
81 skills?: { skillFrontmatter: readonly { name: string; tokens: number }[] }
82}
83
84export type ContextSummary = {
85 totals: Record<CatalogTab, TabTotal>
86 /** What the four tabs send. */
87 tabsSent: number
88 /** What hiding and blocking take out. */
89 saved: number
90 /** The system prompt, the conversation and the rest no switch reaches. */
91 fixed: number
92 /** Everything that rides along with the next message. */
93 total: number
94 /** True when the numbers come from Claude Code's breakdown, not estimates. */
95 fromEngine: boolean
96}
97
98export type ToolState = 'sent' | 'demand' | 'blocked'
99
100const LEGACY_TAB_BY_GROUP: Record<string, CatalogTab | undefined> = {
101 'instruction-files': 'files',
102 memory: 'files',
103 skills: 'skills',
104 'mcp-docs': 'other',
105 'hook-output': 'other',
106 'mcp-tool-schemas': undefined,
107}
108
109export function estimateTokens(text: string): number {
110 return Math.ceil(text.length / 4)
111}
112
113export function formatTokens(tokens: number): string {
114 if (tokens < 1_000) return String(tokens)
115 const value = Math.round(tokens / 100) / 10
116 return `${String(value).replace(/\.0$/, '')}k`
117}
118
119export function matchesPattern(id: string, pattern: string): boolean {
120 return pattern.endsWith('*') ? id.startsWith(pattern.slice(0, -1)) : id === pattern
121}
122
123export function isHidden(id: string, patterns: readonly string[]): boolean {
124 return patterns.some(pattern => matchesPattern(id, pattern))
125}
126
127export function isBlockPattern(pattern: string): boolean {
128 return pattern.startsWith('block:')
129}
130
131export function isServerBlockPattern(pattern: string): boolean {
132 return /^block:mcp__.+__\*$/.test(pattern)
133}
134
135export function isToolBlocked(tool: string, patterns: readonly string[]): boolean {
136 return patterns.some(pattern =>
137 isBlockPattern(pattern) && matchesPattern(tool, pattern.slice('block:'.length)),
138 )
139}
140
141export function toolState(item: CatalogItem, patterns: readonly string[]): ToolState {
142 const name = item.id.startsWith('tool:') ? item.id.slice('tool:'.length) : item.id
143 if (isToolBlocked(name, patterns)) return 'blocked'
144 if (item.eager !== true || isHidden(item.id, patterns)) return 'demand'
145 return 'sent'
146}
147
148export function withoutMatchingToolBlocks(
149 patterns: readonly string[],
150 tool: string,
151): string[] {
152 return patterns.filter(pattern =>
153 !isBlockPattern(pattern) || !matchesPattern(tool, pattern.slice('block:'.length)),
154 )
155}
156
157export function filterDeferredToolNames(
158 text: string,
159 patterns: readonly string[],
160): string {
161 return text
162 .split('\n')
163 .filter(line => !isToolBlocked(line.trim(), patterns))
164 .join('\n')
165}
166
167export function blockCounts(patterns: readonly string[]): {
168 servers: number
169 tools: number
170} {
171 let servers = 0
172 let tools = 0
173 for (const pattern of new Set(patterns.filter(isBlockPattern))) {
174 if (isServerBlockPattern(pattern)) servers += 1
175 else tools += 1
176 }
177 return { servers, tools }
178}
179
180export function normalizePatterns(patterns: readonly string[]): string[] {
181 return [...new Set(patterns.map(pattern => pattern.trim()).filter(Boolean))].sort()
182}
183
184export function migratePatterns(patterns: readonly string[]): string[] {
185 return normalizePatterns(patterns.filter(pattern => !pattern.startsWith('mcptools:')))
186}
187
188export function stringArray(value: unknown): string[] | undefined {
189 if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) return undefined
190 return normalizePatterns(value)
191}
192
193export function resolvePreset(
194 folderPreset: readonly string[] | undefined,
195 defaultPreset: readonly string[] | undefined,
196): string[] {
197 return migratePatterns(folderPreset ?? defaultPreset ?? [])
198}
199
200export function showFromPreset(
201 hidden: readonly string[],
202 requests: readonly string[],
203 catalogIds: readonly string[],
204): string[] {
205 const wanted = normalizePatterns(requests)
206 const targetIds = catalogIds.filter(id => wanted.some(pattern => matchesPattern(id, pattern)))
207 const originallyHidden = catalogIds.filter(id => isHidden(id, hidden))
208 const stillHidden = originallyHidden.filter(
209 id => !wanted.some(pattern => matchesPattern(id, pattern)),
210 )
211
212 const kept = hidden.filter(pattern => {
213 if (wanted.some(request => request === pattern || matchesPattern(pattern, request))) return false
214 return !targetIds.some(id => matchesPattern(id, pattern))
215 })
216
217 for (const id of stillHidden) {
218 if (!isHidden(id, kept)) kept.push(id)
219 }
220
221 return migratePatterns(kept)
222}
223
224function parseTextBlocks(
225 text: string,
226 startPattern: RegExp,
227 identify: (line: string) => { id: string; label: string; description: string } | undefined,
228): ParsedTextItem[] {
229 const lines = text.split('\n')
230 const starts: Array<{
231 start: number
232 identity: { id: string; label: string; description: string }
233 }> = []
234 for (let index = 0; index < lines.length; index += 1) {
235 const line = lines[index] ?? ''
236 if (!startPattern.test(line)) continue
237 const identity = identify(line)
238 if (identity !== undefined) starts.push({ start: index, identity })
239 }
240
241 return starts.map(({ start, identity }, index) => {
242 const end = starts[index + 1]?.start ?? lines.length
243 return { ...identity, text: lines.slice(start, end).join('\n'), start, end }
244 })
245}
246
247export function parseSkillListing(text: string): ParsedTextItem[] {
248 return parseTextBlocks(text, /^- /, line => {
249 const rest = line.slice(2)
250 const delimiter = rest.indexOf(': ')
251 const name = (delimiter === -1 ? rest : rest.slice(0, delimiter)).trim()
252 const description = delimiter === -1 ? '' : rest.slice(delimiter + 2).trim()
253 return name.length > 0
254 ? { id: `skill:${name}`, label: name, description }
255 : undefined
256 })
257}
258
259export function filterSkillListing(text: string, hidden: readonly string[]): string | null {
260 return filterParsedBlocks(text, parseSkillListing(text), hidden)
261}
262
263export function normalizeMcpServerName(name: string): string {
264 return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
265}
266
267export function parseMcpInstructions(
268 text: string,
269 knownServers: readonly string[] = [],
270): ParsedTextItem[] {
271 const normalizedServers = new Set(
272 knownServers.map(normalizeMcpServerName).filter(name => name.length > 0),
273 )
274 const useEveryHeading = normalizedServers.size === 0
275 return parseTextBlocks(text, /^## /, line => {
276 const name = line.slice(3).trim()
277 if (
278 name.length === 0 ||
279 (!useEveryHeading && !normalizedServers.has(normalizeMcpServerName(name)))
280 ) return undefined
281 return {
282 id: `mcpdoc:${name}`,
283 label: name,
284 description: 'MCP server instructions',
285 }
286 }).map(item => {
287 const body = item.text.split('\n').slice(1).join('\n')
288 const raw = item.id.slice('mcpdoc:'.length)
289 const label = guessMcpServerName(raw, body)
290 const firstLine = body.split('\n').map(line => line.trim()).find(line => line !== '' && !line.startsWith('#'))
291 const description = firstLine === undefined ? 'MCP server instructions' : summarizeDescription(firstLine)
292 return { ...item, label, description }
293 })
294}
295
296const NAME_STOPWORDS = new Set([
297 'The', 'This', 'That', 'These', 'Use', 'Used', 'Using', 'When', 'Where', 'What', 'Which', 'With',
298 'For', 'From', 'And', 'But', 'Not', 'Never', 'Always', 'Only', 'Each', 'Every', 'Any', 'All',
299 'You', 'Your', 'Its', 'It', 'If', 'Then', 'Else', 'One', 'Two', 'Call', 'Read', 'Write', 'Make',
300 'Get', 'Set', 'Add', 'See', 'Ask', 'Keep', 'Do', 'Don', 'Tools', 'Tool', 'MCP', 'API', 'URL',
301 'JSON', 'ID', 'IDs', 'Claude', 'Returns', 'Return', 'Note', 'Important', 'IMPORTANT', 'True',
302 'False', 'Treat', 'Tools', 'Core', 'Choosing', 'Finding', 'Dates', 'Recommend', 'Search',
303 'Create', 'Save', 'Link', 'Users', 'User', 'Here', 'There', 'Some', 'More', 'Most', 'First',
304])
305
306/** A readable name for an MCP server whose id is a UUID, from its own instructions text. */
307export function guessMcpServerName(server: string, body: string, toolNames: readonly string[] = []): string {
308 if (!isUuidLike(server)) return displayMcpServer(server, toolNames)
309 const lines = body.split('\n').map(line => line.trim()).filter(line => line !== '')
310
311 // "Claude Docs: living docs you create…" → "Claude Docs"
312 const lead = /^([A-Z][\w.&+-]*(?: [A-Z][\w.&+-]*){0,2}):\s/.exec(lines[0] ?? '')
313 if (lead?.[1] !== undefined) return lead[1]
314
315 // "## Todoist Task and Project Management Tools" → "Todoist"
316 const heading = lines.find(line => /^#{2,3} /.test(line))
317 const headingWord = heading?.replace(/^#+ /, '').split(/\s+/)[0]
318 if (headingWord !== undefined && /^[A-Z][\w.-]{2,}$/.test(headingWord) && !NAME_STOPWORDS.has(headingWord)) {
319 return headingWord
320 }
321
322 // The capitalised word the text keeps repeating ("Notion")
323 const counts = new Map<string, number>()
324 for (const match of body.matchAll(/\b[A-Z][a-zA-Z0-9]{2,}\b/g)) {
325 const word = match[0]
326 if (NAME_STOPWORDS.has(word)) continue
327 counts.set(word, (counts.get(word) ?? 0) + 1)
328 }
329 const top = [...counts.entries()].sort((left, right) => right[1] - left[1])[0]
330 if (top !== undefined && top[1] >= 2) return top[0]
331
332 const fromTools = displayMcpServer(server, toolNames)
333 if (fromTools !== server && !fromTools.endsWith('…')) return fromTools
334 return `Connector ${server.slice(0, 8)}`
335}
336
337export function filterMcpInstructions(
338 text: string,
339 hidden: readonly string[],
340 knownServers: readonly string[] = [],
341): string | null {
342 return filterParsedBlocks(text, parseMcpInstructions(text, knownServers), hidden)
343}
344
345function filterParsedBlocks(
346 text: string,
347 items: readonly ParsedTextItem[],
348 hidden: readonly string[],
349): string | null {
350 if (items.length === 0) return text
351 if (items.every(item => isHidden(item.id, hidden))) return null
352 const lines = text.split('\n')
353 const removed = new Set<number>()
354 for (const item of items) {
355 if (!isHidden(item.id, hidden)) continue
356 for (let index = item.start; index < item.end; index += 1) removed.add(index)
357 }
358 return lines.filter((_line, index) => !removed.has(index)).join('\n')
359}
360
361export function parseMcpToolServer(tool: string): string | undefined {
362 return /^mcp__(.+?)__.+/.exec(tool)?.[1]
363}
364
365export function parseMcpToolName(tool: string): string {
366 return /^mcp__.+?__(.+)/.exec(tool)?.[1] ?? tool
367}
368
369export function summarizeDescription(description: string, limit = 100): string {
370 const normalized = description.trim().replace(/\s+/g, ' ')
371 const sentence = /^.*?[.!?](?:\s|$)/.exec(normalized)?.[0].trim() ?? normalized
372 return sentence.slice(0, Math.max(0, limit)).trimEnd()
373}
374
375export function createToolCatalogItem(
376 name: string,
377 description: string,
378 isDeferred: boolean | undefined,
379): CatalogItem {
380 const server = parseMcpToolServer(name)
381 const summary = summarizeDescription(description)
382 return {
383 id: `tool:${name}`,
384 tab: 'tools',
385 label: server === undefined ? name : parseMcpToolName(name),
386 description: summary,
387 tokens: estimateTokens(name + description),
388 ...(server === undefined ? {} : { server }),
389 eager: isDeferred !== true,
390 }
391}
392
393function isUuidLike(server: string): boolean {
394 return /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(server)
395}
396
397export function displayMcpServer(server: string, toolNames: readonly string[]): string {
398 if (!isUuidLike(server)) {
399 return server.replace(/^claude_ai_/i, '').replace(/_/g, ' ').trim()
400 }
401
402 const names = [...toolNames].sort((left, right) => left.localeCompare(right))
403 const parts = names.map(name => name.split(/[_-]+/).filter(Boolean))
404 let shared = parts[0]?.length ?? 0
405 for (let index = 1; index < parts.length; index += 1) {
406 let count = 0
407 while (
408 count < shared &&
409 count < (parts[index]?.length ?? 0) &&
410 parts[0]?.[count]?.toLowerCase() === parts[index]?.[count]?.toLowerCase()
411 ) count += 1
412 shared = count
413 }
414 const prefix = (parts[0] ?? []).slice(0, shared).join(' ')
415 if (prefix.length >= 3) return prefix
416
417 const fallback = names.slice(0, 2).join(', ')
418 return fallback === '' ? server : `${fallback} …`
419}
420
421export function skillNamespace(item: Pick<CatalogItem, 'label'> | string): string {
422 const name = typeof item === 'string' ? item : item.label
423 const delimiter = name.lastIndexOf(':')
424 return delimiter === -1 ? 'Personal & built-in' : name.slice(0, delimiter)
425}
426
427export function isToggleable(item: CatalogItem): boolean {
428 return item.tab !== 'tools' || item.eager === true
429}
430
431export function tabTotals(
432 catalog: readonly CatalogItem[],
433 hidden: readonly string[],
434): Record<CatalogTab, TabTotal> {
435 const totals: Record<CatalogTab, TabTotal> = {
436 skills: { count: 0, sentTokens: 0, savedTokens: 0 },
437 tools: { count: 0, sentTokens: 0, savedTokens: 0 },
438 files: { count: 0, sentTokens: 0, savedTokens: 0 },
439 other: { count: 0, sentTokens: 0, savedTokens: 0 },
440 }
441 for (const item of catalog) {
442 const total = totals[item.tab]
443 total.count += 1
444 if (!isToggleable(item) || item.stale === true) continue
445 const blocked = item.tab === 'tools' && isToolBlocked(
446 item.id.startsWith('tool:') ? item.id.slice('tool:'.length) : item.id,
447 hidden,
448 )
449 if (blocked || isHidden(item.id, hidden)) total.savedTokens += item.tokens
450 else total.sentTokens += item.tokens
451 }
452 return totals
453}
454
455export function paginate(
456 totalRows: number,
457 viewportRows: number,
458 fixedRows: number,
459 requestedPage: number,
460): Pagination {
461 const withoutPager = Math.max(1, Math.floor(viewportRows) - fixedRows)
462 const showPager = totalRows > withoutPager
463 const pageSize = Math.max(1, withoutPager - (showPager ? 1 : 0))
464 const pageCount = Math.max(1, Math.ceil(totalRows / pageSize))
465 const page = Math.max(0, Math.min(Math.floor(requestedPage), pageCount - 1))
466 const start = page * pageSize
467 return {
468 page,
469 pageCount,
470 pageSize,
471 start,
472 end: Math.min(totalRows, start + pageSize),
473 showPager,
474 }
475}
476
477export function paginateHeights(
478 rowHeights: readonly number[],
479 viewportRows: number,
480 fixedRows: number,
481 requestedPage: number,
482): WeightedPagination {
483 const roomWithoutPager = Math.max(1, Math.floor(viewportRows) - fixedRows)
484 const totalHeight = rowHeights.reduce(
485 (sum, height) => sum + Math.max(1, Math.floor(height)),
486 0,
487 )
488 const showPager = totalHeight > roomWithoutPager
489 const capacity = Math.max(1, roomWithoutPager - (showPager ? 1 : 0))
490 const pages: Array<{ start: number; end: number }> = []
491 let start = 0
492 let height = 0
493
494 for (let index = 0; index < rowHeights.length; index += 1) {
495 const rowHeight = Math.max(1, Math.floor(rowHeights[index] ?? 1))
496 if (index > start && height + rowHeight > capacity) {
497 pages.push({ start, end: index })
498 start = index
499 height = 0
500 }
501 height += rowHeight
502 }
503 pages.push({ start, end: rowHeights.length })
504
505 const pageCount = Math.max(1, pages.length)
506 const page = Math.max(0, Math.min(Math.floor(requestedPage), pageCount - 1))
507 const selected = pages[page] ?? { start: 0, end: 0 }
508 return { page, pageCount, ...selected, showPager }
509}
510
511export function budgetCells(
512 totals: Readonly<Record<CatalogTab, TabTotal>>,
513 requestedWidth: number,
514 fixedTokens = 0,
515): BudgetCells {
516 const width = Math.max(0, Math.floor(requestedWidth))
517 const sent = Object.fromEntries(TAB_ORDER.map(tab => [tab, 0])) as Record<
518 CatalogTab,
519 number
520 >
521 const fixedSent = Math.max(0, fixedTokens)
522 const total = fixedSent + TAB_ORDER.reduce(
523 (sum, tab) => sum + totals[tab].sentTokens + totals[tab].savedTokens,
524 0,
525 )
526 if (width === 0 || total === 0) return { sent, fixed: 0, hidden: width }
527
528 // The fixed part is one more segment, drawn after the four tabs.
529 type Segment = CatalogTab | 'fixed'
530 const tokensOf = (segment: Segment): number =>
531 segment === 'fixed' ? fixedSent : totals[segment].sentTokens
532 const cells: Record<Segment, number> = { ...sent, fixed: 0 }
533 const segments: Segment[] = [...TAB_ORDER, 'fixed']
534 const nonZero = segments.filter(segment => tokensOf(segment) > 0)
535 const minimum = width >= nonZero.length ? 1 : 0
536 for (const segment of nonZero) {
537 cells[segment] = Math.max(minimum, Math.round(width * tokensOf(segment) / total))
538 }
539
540 let overflow = segments.reduce((sum, segment) => sum + cells[segment], 0) - width
541 while (overflow > 0) {
542 const reducible = [...segments]
543 .filter(segment => cells[segment] > minimum)
544 .sort((left, right) => cells[right] - cells[left])[0]
545 if (reducible === undefined) break
546 cells[reducible] -= 1
547 overflow -= 1
548 }
549
550 for (const tab of TAB_ORDER) sent[tab] = cells[tab]
551 const used = segments.reduce((sum, segment) => sum + cells[segment], 0)
552 return { sent, fixed: cells.fixed, hidden: Math.max(0, width - used) }
553}
554
555/** Splits `total` across `weights` in proportion, the parts summing to `total` exactly. */
556export function apportion(total: number, weights: readonly number[]): number[] {
557 const whole = Math.max(0, Math.round(total))
558 if (weights.length === 0) return []
559 const positive = weights.map(weight => Math.max(0, weight))
560 const sum = positive.reduce((acc, weight) => acc + weight, 0)
561 const shares = sum === 0
562 ? positive.map(() => whole / weights.length)
563 : positive.map(weight => whole * weight / sum)
564 const parts = shares.map(Math.floor)
565 let left = whole - parts.reduce((acc, part) => acc + part, 0)
566 const byRemainder = shares
567 .map((share, index) => ({ index, remainder: share - Math.floor(share) }))
568 .sort((a, b) => b.remainder - a.remainder || a.index - b.index)
569 for (const { index } of byRemainder) {
570 if (left <= 0) break
571 parts[index] = (parts[index] ?? 0) + 1
572 left -= 1
573 }
574 return parts
575}
576
577/** Reads Claude Code's breakdown rows by name; any `used` row it doesn't know stays in `used`. */
578export function engineCountsFrom(breakdown: BreakdownInput): EngineCounts {
579 const row = (name: string): number => breakdown.categories
580 .filter(category => category.kind === 'used' && category.name.toLowerCase() === name)
581 .reduce((sum, category) => sum + category.tokens, 0)
582 const byName = <T extends { tokens: number }>(
583 entries: readonly T[],
584 keyOf: (entry: T) => string,
585 ): Record<string, number> => {
586 const result: Record<string, number> = {}
587 for (const entry of entries) result[keyOf(entry)] = (result[keyOf(entry)] ?? 0) + entry.tokens
588 return result
589 }
590 return {
591 used: breakdown.categories
592 .filter(category => category.kind === 'used')
593 .reduce((sum, category) => sum + category.tokens, 0),
594 systemPrompt: row('system prompt'),
595 messages: row('messages'),
596 systemTools: row('system tools'),
597 mcpInstructions: row('mcp server instructions'),
598 agents: breakdown.agents.reduce((sum, agent) => sum + agent.tokens, 0),
599 skills: byName(breakdown.skills?.skillFrontmatter ?? [], skill => skill.name),
600 memoryFiles: byName(breakdown.memoryFiles, file => file.path),
601 memoryFileTypes: Object.fromEntries(
602 breakdown.memoryFiles.map(file => [file.path, (file.type ?? 'memory').toLowerCase()]),
603 ),
604 mcpTools: byName(breakdown.mcpTools.filter(tool => tool.isLoaded), tool => tool.name),
605 }
606}
607
608/** An on-demand tool costs only its line in the list of on-demand tool names. */
609export function deferredNameTokens(tool: string): number {
610 return estimateTokens(`${tool}\n`)
611}
612
613/**
614 * Replaces estimates with Claude Code's own numbers where it reports them per item, and
615 * shares out the totals it reports only as a whole (built-in tools, MCP server docs).
616 */
617export function applyEngineCounts(
618 catalog: readonly CatalogItem[],
619 counts: EngineCounts | undefined,
620): CatalogItem[] {
621 const result = catalog.map(item => {
622 if (item.tab === 'tools' && item.eager !== true) {
623 return { ...item, tokens: deferredNameTokens(item.id.slice('tool:'.length)) }
624 }
625 if (counts === undefined) return item
626 if (item.tab === 'skills') {
627 const tokens = counts.skills[item.label]
628 return tokens === undefined ? item : { ...item, tokens }
629 }
630 if (item.tab === 'files' && item.id.startsWith('file:')) {
631 const tokens = counts.memoryFiles[item.id.slice('file:'.length)]
632 return tokens === undefined ? item : { ...item, tokens }
633 }
634 if (item.tab === 'tools' && item.server !== undefined) {
635 const tokens = counts.mcpTools[item.id.slice('tool:'.length)]
636 return tokens === undefined ? item : { ...item, tokens }
637 }
638 if (item.id === 'attachment:agent_listing_delta' && counts.agents > 0 && item.stale !== true) {
639 return { ...item, tokens: counts.agents }
640 }
641 return item
642 })
643 if (counts === undefined) return result
644
645 const shareOut = (members: number[], total: number): void => {
646 if (members.length === 0 || total <= 0) return
647 const parts = apportion(total, members.map(index => result[index]?.tokens ?? 0))
648 members.forEach((index, position) => {
649 const item = result[index]
650 if (item !== undefined) result[index] = { ...item, tokens: parts[position] ?? 0, approximate: true }
651 })
652 }
653 const indexes = (test: (item: CatalogItem) => boolean): number[] =>
654 result.flatMap((item, index) => (test(item) ? [index] : []))
655 shareOut(
656 indexes(item => item.tab === 'tools' && item.eager === true && item.server === undefined),
657 counts.systemTools,
658 )
659 shareOut(
660 indexes(item => item.tab === 'other' && item.id.startsWith('mcpdoc:') && item.stale !== true),
661 counts.mcpInstructions,
662 )
663 return result
664}
665
666/**
667 * The headline and its parts. With Claude Code's breakdown the total is its `used` figure
668 * less what this mod removes, so it matches Claude Code's panel whenever nothing is hidden.
669 */
670export function contextSummary(
671 catalog: readonly CatalogItem[],
672 hidden: readonly string[],
673 counts: EngineCounts | undefined,
674): ContextSummary {
675 const totals = tabTotals(catalog, hidden)
676 const tabsSent = TAB_ORDER.reduce((sum, tab) => sum + totals[tab].sentTokens, 0)
677 const saved = TAB_ORDER.reduce((sum, tab) => sum + totals[tab].savedTokens, 0)
678 if (counts === undefined) {
679 return { totals, tabsSent, saved, fixed: 0, total: tabsSent, fromEngine: false }
680 }
681 const total = Math.max(tabsSent, counts.used - saved)
682 return { totals, tabsSent, saved, fixed: total - tabsSent, total, fromEngine: true }
683}
684
685export function formatCount(value: number): string {
686 return String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
687}
688
689/** How many rows `text` takes when wrapped to `width` columns. */
690export function wrappedLineCount(text: string, width: number): number {
691 const room = Math.max(1, Math.floor(width))
692 return text.split('\n').reduce(
693 (sum, line) => sum + Math.max(1, Math.ceil(line.length / room)),
694 0,
695 )
696}
697
698export function abbreviateHome(path: string, home: string | undefined): string {
699 if (home === undefined || home === '') return path
700 if (path === home) return '~'
701 return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
702}
703
704export function folderLabel(path: string, home: string | undefined): string {
705 const scratchRoot = home === undefined
706 ? undefined
707 : `${home}/Library/Application Support/Claude/scratch-workspaces/`
708 if (scratchRoot !== undefined && path.startsWith(scratchRoot)) {
709 return "this session's folder"
710 }
711 return abbreviateHome(path, home)
712}
713
714export function splitArguments(input: string): string[] {
715 const values: string[] = []
716 let value = ''
717 let quote: '"' | "'" | undefined
718 let escaping = false
719
720 for (const character of input.trim()) {
721 if (escaping) {
722 value += character
723 escaping = false
724 } else if (character === '\\' && quote !== "'") {
725 escaping = true
726 } else if (quote !== undefined) {
727 if (character === quote) quote = undefined
728 else value += character
729 } else if (character === '"' || character === "'") {
730 quote = character
731 } else if (/\s/.test(character)) {
732 if (value !== '') {
733 values.push(value)
734 value = ''
735 }
736 } else {
737 value += character
738 }
739 }
740
741 if (escaping) value += '\\'
742 if (value !== '') values.push(value)
743 return values
744}
745
746function legacyDescription(id: string, group: string | undefined): string {
747 if (id === 'section:memory') return 'Memory section'
748 if (id === 'hook:context') return 'Hook-added context'
749 if (id === 'attachment:agent_listing_delta') return 'Available subagent types'
750 if (group === 'mcp-docs') return 'MCP server instructions'
751 return ''
752}
753
754export function sanitizeCatalog(value: unknown): CatalogItem[] {
755 if (!Array.isArray(value)) return []
756 const tabs = new Set<string>(TAB_ORDER)
757 const result: CatalogItem[] = []
758 for (const item of value) {
759 if (typeof item !== 'object' || item === null) continue
760 const record = item as Record<string, unknown>
761 if (
762 typeof record.id !== 'string' ||
763 record.id.startsWith('mcptools:') ||
764 typeof record.label !== 'string' ||
765 typeof record.tokens !== 'number'
766 ) continue
767
768 const legacyGroup = typeof record.group === 'string' ? record.group : undefined
769 const tab = typeof record.tab === 'string' && tabs.has(record.tab)
770 ? record.tab as CatalogTab
771 : legacyGroup === undefined
772 ? undefined
773 : LEGACY_TAB_BY_GROUP[legacyGroup]
774 if (tab === undefined) continue
775
776 const candidate: CatalogItem = {
777 id: record.id,
778 tab,
779 label: record.label,
780 description: typeof record.description === 'string'
781 ? record.description
782 : legacyDescription(record.id, legacyGroup),
783 tokens: Math.max(0, Math.ceil(record.tokens)),
784 }
785 if (typeof record.server === 'string') candidate.server = record.server
786 if (typeof record.eager === 'boolean') candidate.eager = record.eager
787 if (typeof record.kind === 'string') candidate.kind = record.kind
788 result.push(candidate)
789 }
790 return result
791}
792
793// Profiles: named sets of the same patterns a folder preset holds, kept in one store key.
794export type Profiles = Record<string, string[]>
795
796const PROFILE_SUBCOMMANDS = new Set(['save', 'delete', 'rename', 'none'])
797export const PROFILE_NAME_MAX = 32
798
799export function parseProfiles(value: unknown): Profiles {
800 if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
801 const profiles: Profiles = {}
802 for (const [name, patterns] of Object.entries(value as Record<string, unknown>)) {
803 const parsed = stringArray(patterns)
804 if (parsed !== undefined && profileNameError(name) === undefined) {
805 profiles[name] = migratePatterns(parsed)
806 }
807 }
808 return profiles
809}
810
811export function profileNameError(name: string): string | undefined {
812 if (name.trim() === '') return 'Give the profile a name.'
813 if (name !== name.trim()) return 'A profile name can\'t start or end with a space.'
814 if (name.length > PROFILE_NAME_MAX) return `Keep the name to ${PROFILE_NAME_MAX} characters.`
815 if (PROFILE_SUBCOMMANDS.has(name.toLowerCase())) return `"${name}" is a /ctx profile command; pick another name.`
816 return undefined
817}
818
819export function profileNames(profiles: Profiles): string[] {
820 return Object.keys(profiles).sort((left, right) => left.localeCompare(right))
821}
822
823// Finds a profile by name, ignoring case, so `/ctx profile coding` finds "Coding".
824export function findProfile(profiles: Profiles, name: string): string | undefined {
825 if (Object.hasOwn(profiles, name)) return name
826 const lower = name.toLowerCase()
827 return profileNames(profiles).find(candidate => candidate.toLowerCase() === lower)
828}
829
830export function presetDiffers(left: readonly string[], right: readonly string[]): boolean {
831 return JSON.stringify(migratePatterns(left)) !== JSON.stringify(migratePatterns(right))
832}
833types/index.d.ts 17 lines1export type CtxControlTab = 'skills' | 'tools' | 'files' | 'other'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'ctx-control': {
6 activeTab: CtxControlTab
7 filter: string
8 page: number
9 expandedToolGroups: string[]
10 profileNaming: boolean
11 profileDraft: string
12 profileDeleting: string
13 showCards: boolean
14 }
15 }
16}
17