SLOPSHOPPER

ctx-control

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx-control
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ Profile [ None ] + New │ ┃ 0 tokens ride along with every message ● ctx-control: Context pane opened. │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ● [ Skills 0 ] ● 2: Tools 0 ● 3: Files 0 ⎿ Read 6 lines │ ┃ Hiding nothing yet. In /work/app ⏺ Update(src/auth.ts) │ ┃ Showing commands until your first message c ⎿ Added 2 lines, removed 1 line │ ┃ Search skills… i: Show details [ ⏺ Bash(bun test) │ ┃ Nothing here yet. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ctx │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context
Profile [ None ] + New 0 tokens ride along with every message ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ● [ Skills 0 ] ● 2: Tools 0 ● 3: Files 0 ● 4: Other 0 Hiding nothing yet. In /work/app . Showing commands until your first message confirms wh… Search skills… i: Show details [ Hide all ] Nothing here yet.
README

ctx-control

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.

Source 3 files
hooks/register.tsx 2559 lines
1import { 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 lines
1export 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}
833
types/index.d.ts 17 lines
1export 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