SLOPSHOPPER

claude-desktop-mods

Claude Code mods for the Code tab and terminal: /handoff-to asks for a Markdown brief a session in another repository can act on, and a band copies any brief a…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-desktop-mods
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ CONTEXT turn 1 │ ┃ ⏺ Read(src/auth.ts) │ ┃ 97k / 200k tokens 49% used · peak 97k ⎿ Read 6 lines │ ┃ ████████████████████████████████████████████ ⏺ Update(src/auth.ts) │ ┃ ██████████ ⎿ Added 2 lines, removed 1 line │ ┃ ▲ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ■ Bash results 76 ■ Read results 49 ■ Wr │ ┃ Tool results by tool: the engine has not ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ itemized this window yet. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ▾ INSTRUCTIONS · 1 sources ~14 │ ┃ memory CLAUDE.md ━━━━━━━━━━ 14 › /handoff-to │ ┃ ⎿ claude-desktop-mods: Asking for a handoff brief. │ ┃ ▾ MARKDOWN READ · 0 files ~0 │ ┃ No Markdown read yet. │ ┃ │ ┃ tab ↑↓ move · ⏎ zoom · ⏎ on a heading foldsc │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ claude-desktop-mods: ctx 49% · 0 md

Draws

Pane · Context
CONTEXT turn 1 97k / 200k tokens 49% used · peak 97k ██████████████████████████████████████████████████████ ▲ ■ Bash results 76 ■ Read results 49 ■ Write results 26 Tool results by tool: the engine has not itemized this window yet. ▾ INSTRUCTIONS · 1 sources ~14 memory CLAUDE.md ━━━━━━━━━━ 14 ▾ MARKDOWN READ · 0 files ~0 No Markdown read yet. tab ↑↓ move · ⏎ zoom · ⏎ on a heading folds ctx 49% · 0 md
Pane · handoff
No handoff brief.
README

claude-desktop-mods

Claude Code mods: function hooks that run inside a Claude Code session, in the desktop app's Code tab and in the terminal. They ship apart from claude-desktop so each installs, and the hooks hot-reload, on their own.

Claude-only. Copilot has no function hooks; the root hooks.json is empty so Copilot never falls back to hooks/hooks.json.

Includes

Cross-repository handoff

For the moment the work in this repository needs a change in another one. The session keeps going; the other repository gets a brief it can act on without this conversation.

  • /handoff-to [repo and what to change] asks for the brief as Markdown in the reply, fenced ~~~markdown: the target, why, the change, the context the target cannot read quoted rather than linked, done-when, and open questions with their defaults, led by a Title this session line. With no argument the target and change are inferred from the session. Nothing is written to disk.
  • Any reply holding such a brief — from the command, or from asking for one in your own words — raises a band above the prompt: "Handoff for <target>: <change>" with Copy, Show, and Dismiss. Copy puts the whole brief on the clipboard, to paste wherever you choose; Show opens it rendered in a pane, which falls back to plain text for copying when the clipboard does not take it.
FileHolds
hooks/hooks.jsonthe one hooks module, register.ts, under modules
hooks/register.tsregisters both mods; only on crosses into each mod's file
hooks/handoff.tsxthe command, the band, and the pane
hooks/handoff-brief.tsthe prompt, and finding the brief in a reply
types/index.d.tsthe $.state values, named as types in the manifest
tests/handoff.test.tsxthe tests

Context view

/context-view opens a pane showing, for the current session:

  1. Context window — used / limit, the peak, and the largest categories, from the engine's usage API; when it has no breakdown, tool-result tokens by tool.
  2. Instructions loaded — CLAUDE.md and every file its @ imports pull in, .claude/rules/*.md rules that fired because a read path matched their paths, skill bodies (Skill calls), agent bodies (Agent calls with subagent_type), the output style, and hook context such as SessionStart's — each with an approximate token cost.
  3. Markdown read — every .md file read through Read, or cat/sed/head/Get-Content through a shell, grouped by folder, with token cost and read count.
  4. Devbook lens, only when a read file sits under .devbook/ or carries a fenced meta block — grouped by folder (arc42, domain, tech, design, ai) and chapter address, with a warning when most of a folder loads in one turn and when a _meta/ file is read.

Layout. The pane follows the "Context View Redesign" canvas: a meter with a stacked bar of the window's categories, ticks for the peak and the auto-compaction point, and a legend; an amber banner for devbook warnings; foldable INSTRUCTIONS, MARKDOWN READ, and DEVBOOK sections whose rows carry a share bar; and a footer with the keys and the status text. Every row is a button: Tab and the arrows move between rows, Enter on a row zooms, and Enter on a section heading folds it. It draws only boxes, text, and buttons, so it works on every surface.

Zoom. Zooming replaces the overview with that line's page; ← CONTEXT (hotkey b) returns. A Markdown file and a devbook folder get designed pages — four summary tiles, the reads with a bar of the lines each covered, the rules fired, chapters, and outline; or the folder's warnings, a file-by-turn coverage grid, and its chapter addresses. The other lines show their detail as text:

LineDetail
A window categoryWhat the engine itemizes in it: memory files, MCP tools by server, the skill listing, custom agents, or tool results by tool
A memory fileIts @ imports, what imports it, and its outline
A ruleIts globs, every path that matched it and when, and its outline
A skill or agentEach invocation, the tokens an agent returned, and the body's outline
A hook or the output styleEach row it added, or the system prompt sections it occupies
A Markdown fileEvery read with its turn, tool, tokens, and line range, the rules it fired, its devbook chapters, and its outline
A devbook folderIts warnings, the files read in each turn, and every chapter address

The status line shows ctx <n>% · <k> md. Token costs are estimates at four characters a token, except where the engine's breakdown counts a memory file itself. The lens recognises devbook by shape and does not need devbook installed. Reads inside a subagent are listed and marked, but stay out of the by-tool totals, since they never reach this session's window.

FileRole
hooks/context-view/register.tsxThe hooks, the recorded state, and the pane's actions
hooks/context-view/pane.tsxThe overview and the two designed zoom pages, as element trees
hooks/context-view/view.tsThe text detail pages and the merged instruction list
hooks/context-view/model.tsThe pure logic: shell-read parsing, rule globs, @ imports, the devbook lens
tests/context-view*.test.tsThe tests

Develop

claude plugin validate plugins/claude-desktop-mods
claude plugin test plugins/claude-desktop-mods
claude --plugin-dir plugins/claude-desktop-mods

Known issue

Claude Code reserves plugin names that start with claude-, so claude plugin validate reports this plugin's name as an error. Its modules and $.state contract validate clean under any other name.

Install

/plugin install claude-desktop-mods@jsdotnet-ai-plugins

License

UNLICENSED

Author

Job Schepers

Source 8 files
hooks/register.ts 13 lines
1// The plugin's one hooks module: hooks.json names a single module, so each mod registers here.
2// Only `on` crosses the import; each mod keeps every `$` call in its own file.
3
4import type { Register } from 'claude-code'
5
6import { register as contextView } from './context-view/register'
7import { register as handoff } from './handoff'
8
9export const register: Register = (on, options) => {
10  handoff(on, options)
11  contextView(on, options)
12}
13
hooks/context-view/register.tsx 430 lines
1// context-view: a pane showing what fills this session's context — the window, the
2// instructions loaded, the Markdown read, and a devbook lens — with a zoom into any line.
3// Claude Code only; no dependency on devbook: the lens recognises its folders and meta
4// fences by shape.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, SessionUsage } from 'claude-code'
8
9import type {
10  ContextViewInstruction,
11  ContextViewInstructionEvent,
12  ContextViewMdRead,
13  ContextViewRule,
14} from '../../types'
15import {
16  addRead,
17  chapterAddresses,
18  devbookPlace,
19  devbookRoot,
20  estimateTokens,
21  folderOf,
22  formatTokens,
23  hasMetaFence,
24  isMarkdown,
25  isMostOfFolder,
26  markdownReadByShell,
27  memoryImports,
28  normalize,
29  outlineOf,
30  pushEvent,
31  relativeTo,
32  resolvePath,
33  ruleGlobs,
34  ruleMatches,
35  statusText,
36  stripLineNumbers,
37} from './model'
38import { Overview, Zoom } from './pane'
39import type { Actions, Els, PaneData } from './pane'
40
41type $ = EngineInterface
42
43const PANE = 'context-view'
44
45const reads = atom({ plugin: 'claude-desktop-mods', key: 'contextReads' } as const, [])
46const instructions = atom({ plugin: 'claude-desktop-mods', key: 'contextInstructions' } as const, [])
47const rules = atom({ plugin: 'claude-desktop-mods', key: 'contextRules' } as const, [])
48const peak = atom({ plugin: 'claude-desktop-mods', key: 'contextPeak' } as const, 0)
49const byTool = atom({ plugin: 'claude-desktop-mods', key: 'contextByTool' } as const, {})
50const warnings = atom({ plugin: 'claude-desktop-mods', key: 'contextWarnings' } as const, [])
51const turnReads = atom({ plugin: 'claude-desktop-mods', key: 'contextTurnReads' } as const, {})
52const folderSizes = atom({ plugin: 'claude-desktop-mods', key: 'contextFolderSizes' } as const, {})
53const pendingSkills = atom({ plugin: 'claude-desktop-mods', key: 'contextPendingSkills' } as const, [])
54const turn = atom({ plugin: 'claude-desktop-mods', key: 'contextTurn' } as const, 0)
55const focus = atom({ plugin: 'claude-desktop-mods', key: 'contextFocus' } as const, '')
56const folded = atom({ plugin: 'claude-desktop-mods', key: 'contextFolded' } as const, {})
57
58const MEMORY_ROOTS = ['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md']
59
60const readText = async ($: $, path: string): Promise<string | undefined> => {
61  try {
62    return await $.fs.read(path)
63  } catch {
64    return undefined
65  }
66}
67
68const listMarkdown = async ($: $, dir: string, depth = 0): Promise<string[]> => {
69  if (depth > 6) return []
70  let entries: Awaited<ReturnType<$['fs']['list']>> = []
71  try {
72    entries = await $.fs.list(dir)
73  } catch {
74    return []
75  }
76  const out: string[] = []
77  for (const entry of entries) {
78    const path = `${dir}/${entry.name}`
79    if (entry.kind === 'dir' && entry.name !== '_meta' && entry.name !== 'node_modules')
80      out.push(...(await listMarkdown($, path, depth + 1)))
81    else if (entry.kind === 'file' && isMarkdown(entry.name)) out.push(path)
82  }
83  return out
84}
85
86/** A stamp for an event: now, and the turn it falls in. */
87const stamp = async ($: $, note: string): Promise<ContextViewInstructionEvent> => ({
88  at: await $.clock.now(),
89  turn: await read($, turn),
90  note,
91})
92
93/** Adds an instruction, or merges into the one of the same kind and name, keeping its history. */
94const putInstruction = ($: $, entry: ContextViewInstruction, event?: ContextViewInstructionEvent) =>
95  update($, instructions, list => {
96    const known = list.find(i => i.kind === entry.kind && i.name === entry.name)
97    const merged: ContextViewInstruction = {
98      ...known,
99      ...entry,
100      outline: entry.outline ?? known?.outline,
101      refs: entry.refs ?? known?.refs,
102      events: event ? pushEvent(known?.events, event) : known?.events,
103    }
104    return [...list.filter(i => i !== known), merged]
105  })
106
107const blockText = (content: readonly { type: string; [k: string]: unknown }[]): string =>
108  content
109    .map(b => (b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
110    .filter(Boolean)
111    .join('\n')
112
113const warnOnce = ($: $, text: string) =>
114  update($, warnings, list => (list.includes(text) ? list : [...list, text].slice(-50)))
115
116/** CLAUDE.md and the files its `@` imports pull in, recursively. */
117const scanMemory = async ($: $, cwd: string) => {
118  const seen = new Set<string>()
119  const found: ContextViewInstruction[] = []
120  const visit = async (abs: string, why: string | undefined, depth: number) => {
121    if (seen.has(abs.toLowerCase()) || depth > 5) return
122    seen.add(abs.toLowerCase())
123    const text = await readText($, abs)
124    if (text === undefined) return
125    const name = relativeTo(cwd, abs)
126    const imports = memoryImports(text, abs)
127    found.push({
128      kind: 'memory',
129      name,
130      tokens: estimateTokens(text),
131      detail: why,
132      outline: outlineOf(text),
133      refs: imports.map(i => relativeTo(cwd, i)),
134    })
135    for (const imported of imports) await visit(imported, `@ import of ${name}`, depth + 1)
136  }
137  for (const root of MEMORY_ROOTS) await visit(resolvePath(cwd, root), undefined, 0)
138  return found
139}
140
141/** `.claude/rules/*.md`: path-scoped ones wait for a matching read; the rest load always. */
142const scanRules = async ($: $, cwd: string) => {
143  const scoped: ContextViewRule[] = []
144  const always: ContextViewInstruction[] = []
145  for (const abs of await listMarkdown($, `${normalize(cwd)}/.claude/rules`)) {
146    const text = (await readText($, abs)) ?? ''
147    const globs = ruleGlobs(text)
148    const path = relativeTo(cwd, abs)
149    const outline = outlineOf(text)
150    if (globs.length > 0) scoped.push({ path, globs, tokens: estimateTokens(text), outline })
151    else always.push({ kind: 'rule', name: path, tokens: estimateTokens(text), detail: 'loads always', outline })
152  }
153  return { scoped, always }
154}
155
156const refresh = async ($: $) => {
157  let usage: SessionUsage | undefined
158  try {
159    usage = await $.session.usage()
160  } catch {
161    usage = undefined
162  }
163  const tokens = usage?.context.tokens
164  if (tokens !== undefined) await update($, peak, p => Math.max(p, tokens))
165  const list = await read($, reads)
166  $.ui.status(statusText(usage?.context.percent, list.length))
167}
168
169/** Fires the rules whose globs match a read path; returns the ones that matched. */
170const firePathRules = async ($: $, rel: string): Promise<string[]> => {
171  const matched: string[] = []
172  for (const rule of await read($, rules)) {
173    if (!ruleMatches(rule, rel)) continue
174    matched.push(rule.path)
175    const known = (await read($, instructions)).find(i => i.kind === 'rule' && i.name === rule.path)
176    await putInstruction(
177      $,
178      { kind: 'rule', name: rule.path, tokens: rule.tokens, detail: known?.detail ?? `matched ${rel}`, refs: rule.globs },
179      await stamp($, rel),
180    )
181  }
182  return matched
183}
184
185const devbookLens = async ($: $, cwd: string, rel: string, hasMeta: boolean) => {
186  const place = devbookPlace(rel, hasMeta)
187  if (!place) return
188  if (place.isMeta)
189    await warnOnce($, `\`_meta/\` file read: \`${rel}\` — a derived index, not a chapter; load the chapter it points at`)
190  const root = devbookRoot(rel, place.folder)
191  if (!root || place.isMeta) return
192  let size = (await read($, folderSizes))[root]
193  if (size === undefined) {
194    const counted = (await listMarkdown($, `${normalize(cwd)}/${root}`)).length
195    await update($, folderSizes, sizes => ({ ...sizes, [root]: counted }))
196    size = counted
197  }
198  const before = (await read($, turnReads))[root] ?? []
199  if (before.includes(rel)) return
200  const after = [...before, rel]
201  await update($, turnReads, all => ({ ...all, [root]: after }))
202  if (!isMostOfFolder(before.length, size) && isMostOfFolder(after.length, size))
203    await warnOnce(
204      $,
205      `Most of \`${root}\` loaded in one turn (${after.length} of ${size} files) — devbook context is task-scoped: load the chapters the task names, never the folder whole`,
206    )
207}
208
209const recordMarkdown = async (
210  $: $,
211  cwd: string,
212  file: string,
213  text: string,
214  via: string,
215  fired: string[],
216  range?: string,
217  from?: number,
218) => {
219  const rel = relativeTo(cwd, file)
220  const body = stripLineNumbers(text)
221  const hasMeta = hasMetaFence(body)
222  const tokens = estimateTokens(body)
223  const entry: Omit<ContextViewMdRead, 'count' | 'events'> = {
224    path: rel,
225    folder: folderOf(rel),
226    tokens,
227    hasMeta,
228    chapters: hasMeta ? chapterAddresses(rel, body) : [],
229    via,
230    outline: outlineOf(body),
231    rules: fired,
232  }
233  const lines = body.split('\n').length
234  const event = { at: await $.clock.now(), turn: await read($, turn), via, tokens, lines, ...(range ? { range } : {}), ...(from ? { from } : {}) }
235  await update($, reads, list => addRead(list, entry, event))
236  await devbookLens($, cwd, rel, hasMeta)
237}
238
239/** The agent definition the Agent tool runs, when this repository holds it. */
240const agentBody = async ($: $, cwd: string, type: string): Promise<{ path: string; text: string } | undefined> => {
241  const [plugin, name] = type.includes(':') ? type.split(':', 2) : [undefined, type]
242  const candidates = plugin
243    ? [`plugins/${plugin}/agents/${name}.agent.md`, `plugins/${plugin}/agents/${name}.md`]
244    : [`.claude/agents/${name}.md`, `.claude/agents/${name}.agent.md`]
245  for (const candidate of candidates) {
246    const text = await readText($, resolvePath(cwd, candidate))
247    if (text !== undefined) return { path: candidate, text }
248  }
249  return undefined
250}
251
252const readRange = (offset: unknown, limit: unknown): string | undefined => {
253  if (typeof offset !== 'number' && typeof limit !== 'number') return undefined
254  const from = typeof offset === 'number' ? offset : 1
255  return typeof limit === 'number' ? `lines ${from}–${from + limit - 1}` : `from line ${from}`
256}
257
258// The handoff module shares this plugin and hooks session.start and turn.complete without a
259// matcher; the engine allows one such hook per event, so this module matches interactive
260// sessions (the only ones with a pane to draw) and refreshes on each stored response instead.
261export const register: Register = on => {
262  on('session.start', { isInteractive: true }, async ($, e, next) => {
263    await $.command.register({
264      name: 'context-view',
265      description: 'Show what fills this session\'s context: window, instructions, Markdown read, devbook lens',
266    })
267    const memory = await scanMemory($, e.cwd)
268    const { scoped, always } = await scanRules($, e.cwd)
269    await update($, rules, () => scoped)
270    for (const entry of [...memory, ...always]) await putInstruction($, entry)
271    await refresh($)
272    return next(e)
273  })
274
275  on('command.run', { command: 'context-view' }, async $ => {
276    await $.ui.open({ id: PANE, title: 'Context' })
277    return { text: 'Context view opened.' }
278  })
279
280  on('turn.start', async ($, e, next) => {
281    await update($, turnReads, () => ({}))
282    await update($, turn, n => n + 1)
283    return next(e)
284  })
285
286  on('tool.call', async ($, e, next) => {
287    const ran = await next(e)
288    if (ran.deny !== undefined) return ran
289    const cwd = await $.session.cwd()
290    const text = ran.text ?? ''
291    const tool = String(e.tool)
292    const via = e.agentId ? `${tool} · subagent` : tool
293    if (!e.agentId) {
294      const tokens = estimateTokens(text)
295      await update($, byTool, all => ({ ...all, [tool]: (all[tool] ?? 0) + tokens }))
296    }
297
298    if (e.tool === 'Read' && !ran.isError) {
299      const fired = await firePathRules($, relativeTo(cwd, e.file_path))
300      if (isMarkdown(e.file_path))
301        await recordMarkdown($, cwd, e.file_path, text, via, fired, readRange(e.offset, e.limit), typeof e.offset === 'number' ? e.offset : undefined)
302    } else if ((tool === 'Bash' || tool === 'PowerShell') && !ran.isError) {
303      const command = (e as { command?: unknown }).command
304      const files = typeof command === 'string' ? markdownReadByShell(command) : []
305      for (const file of files) {
306        const fired = await firePathRules($, relativeTo(cwd, file))
307        await recordMarkdown($, cwd, file, files.length === 1 ? text : text.slice(0, text.length / files.length), via, fired)
308      }
309    } else if (e.tool === 'Skill' && !e.agentId) {
310      await update($, pendingSkills, list => [...list, e.skill])
311    } else if (e.tool === 'Agent' && e.subagent_type) {
312      const body = await agentBody($, cwd, e.subagent_type)
313      const returned = estimateTokens(text)
314      await putInstruction(
315        $,
316        {
317          kind: 'agent',
318          name: e.subagent_type,
319          tokens: estimateTokens(body?.text),
320          detail: `${body ? body.path : 'body not in this repository'}; runs in the subagent's context`,
321          ...(body ? { outline: outlineOf(body.text) } : {}),
322        },
323        await stamp($, `${e.description} — ~${formatTokens(returned)} returned`),
324      )
325    }
326
327    await refresh($)
328    return ran
329  })
330
331  on('session.append', async ($, e, next) => {
332    const stored = await next(e)
333    if (e.agentId) return stored
334    if (e.door === 'response') await refresh($)
335    const text = blockText(e.message.content)
336    if (!text) return stored
337    const origin = e.origin
338    if (e.door === 'hook-context' || origin.kind === 'hook') {
339      const event = origin.kind === 'hook' ? origin.event : 'hook'
340      const name = `${event} hook`
341      const known = (await read($, instructions)).find(i => i.kind === 'hook' && i.name === name)
342      const tokens = estimateTokens(text)
343      await putInstruction(
344        $,
345        { kind: 'hook', name, tokens: (known?.tokens ?? 0) + tokens, detail: 'additional context', outline: outlineOf(text) },
346        await stamp($, `${formatTokens(tokens)} tokens: ${text.slice(0, 80).replace(/\s+/g, ' ')}…`),
347      )
348    } else if (origin.kind === 'tool' && origin.tool === 'Skill' && e.door !== 'tool-result') {
349      const [skill, ...rest] = await read($, pendingSkills)
350      await update($, pendingSkills, () => rest)
351      if (skill) {
352        const tokens = estimateTokens(text)
353        await putInstruction(
354          $,
355          { kind: 'skill', name: skill, tokens, detail: 'Skill tool', outline: outlineOf(text) },
356          await stamp($, `body loaded, ~${formatTokens(tokens)} tokens`),
357        )
358      }
359    } else if (e.message.name === 'nested_memory') {
360      const path = /Contents of (.+?)(?: \(|:\s*\n)/.exec(text)?.[1]
361      if (path) {
362        const name = relativeTo(await $.session.cwd(), path)
363        const kind = name.includes('.claude/rules/') ? 'rule' : 'memory'
364        await putInstruction(
365          $,
366          { kind, name, tokens: estimateTokens(text), detail: 'loaded on a read', outline: outlineOf(text) },
367          await stamp($, 'loaded by the engine on a read'),
368        )
369      }
370    }
371    return stored
372  })
373
374  on('prompt.compose', async ($, e, next) => {
375    const composed = await next(e)
376    const style = e.outputStyle
377    if (!style) return composed
378    const sections = composed.sections.filter(s => /style/i.test(s.id))
379    const tokens = sections.reduce((n, s) => n + estimateTokens(s.text), 0)
380    const known = (await read($, instructions)).find(i => i.kind === 'output-style')
381    if (known?.name !== style.name || known.tokens !== tokens) {
382      await update($, instructions, list => [
383        ...list.filter(i => i.kind !== 'output-style'),
384        {
385          kind: 'output-style' as const,
386          name: style.name,
387          tokens,
388          detail: sections.length > 0 ? 'system prompt section' : 'section not identified',
389          refs: sections.map(s => s.id),
390          outline: outlineOf(sections.map(s => s.text).join('\n')),
391        },
392      ])
393    }
394    return composed
395  })
396
397  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
398    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
399    const els: Els = { Box, Text, Button, Markdown }
400    let usage: SessionUsage | undefined
401    try {
402      usage = await $.session.usage({ breakdown: 'summary', columns: e.props.bodyColumns })
403    } catch {
404      usage = undefined
405    }
406    const data: PaneData = {
407      usage,
408      cwd: await $.session.cwd(),
409      reads: await read($, reads),
410      instructions: await read($, instructions),
411      rules: await read($, rules),
412      warnings: await read($, warnings),
413      peak: await read($, peak),
414      byTool: await read($, byTool),
415      startedAt: usage?.startedAt ?? 0,
416      turn: await read($, turn),
417      folded: await read($, folded),
418      folderSizes: await read($, folderSizes),
419      columns: e.props.bodyColumns,
420    }
421    const actions: Actions = {
422      zoom: value => void update($, focus, () => value),
423      back: () => void update($, focus, () => ''),
424      toggle: section => void update($, folded, all => ({ ...all, [section]: !all[section] })),
425    }
426    const zoomed = await read($, focus)
427    return (zoomed ? Zoom(els, actions, data, zoomed) : undefined) ?? Overview(els, actions, data)
428  })
429}
430
hooks/handoff.tsx 97 lines
1// Claude-only function hooks for cross-repository handoffs: /handoff-to asks for a Markdown
2// brief another repository's session can act on, and any reply holding one raises a band
3// that copies it or shows it in a pane. Nothing is written to disk; the person decides where
4// the brief goes.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, RenderSurface } from 'claude-code'
8
9import { findBrief, handoffPrompt } from './handoff-brief'
10
11const PANE = 'handoff'
12const SHOWN_MAX = 9000
13
14const brief = atom({ plugin: 'claude-desktop-mods', key: 'brief' } as const, null)
15const isCopyRefused = atom({ plugin: 'claude-desktop-mods', key: 'isCopyRefused' } as const, false)
16
17async function copy($: EngineInterface, surface: RenderSurface) {
18  const current = await read($, brief)
19  if (current === null) return
20  const copied = await $.ui.copy({ text: current.text, surface })
21  await update($, isCopyRefused, () => !copied.isCopied)
22  if (copied.isCopied) {
23    $.ui.toast('Handoff brief copied.')
24  } else {
25    await $.ui.open({ id: PANE, title: 'Handoff' })
26  }
27}
28
29async function dismiss($: EngineInterface) {
30  await update($, brief, () => null)
31  await update($, isCopyRefused, () => false)
32  await $.ui.close({ id: PANE })
33}
34
35export const register: Register = on => {
36  on('session.start', async ($, e, next) => {
37    await $.command.register({
38      name: 'handoff-to',
39      description: 'Write a Markdown handoff brief for a change in another repository',
40      argumentHint: '[repo and what to change]',
41    })
42    return next(e)
43  })
44
45  on('command.run', { command: 'handoff-to' }, async ($, e) => {
46    const text = handoffPrompt(e.args ?? '')
47    // A command.run hook holds the turn a submit would wait on: submit once it has returned.
48    $.clock.after(0, () => void $.prompt.submit({ text, asUser: true }))
49    return { text: 'Asking for a handoff brief.' }
50  })
51
52  on('turn.complete', async ($, e, next) => {
53    const done = await next(e)
54    const found = e.agentId === undefined ? findBrief(e.answer) : null
55    if (found !== null) {
56      await update($, brief, () => found)
57      await update($, isCopyRefused, () => false)
58    }
59    return done
60  })
61
62  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63    const current = await read($, brief)
64    if (e.props.hasSurvey || current === null) return next(e)
65    const { Box, Button, Text } = $.ui.resolve(e)
66    return (
67      <Box>
68        <Text bold wrap="truncate-end">
69          Handoff for {current.target}: {current.title}{' '}
70        </Text>
71        <Button key="copy" label="Copy" variant="primary" onPress={press => void copy($, press.surface)} />
72        <Button key="show" label="Show" onPress={() => void $.ui.open({ id: PANE, title: 'Handoff' })} />
73        <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => void dismiss($)} />
74      </Box>
75    )
76  })
77
78  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
79    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
80    const current = await read($, brief)
81    if (current === null) return <Text dimColor>No handoff brief.</Text>
82    const isRefused = await read($, isCopyRefused)
83    const shown =
84      current.text.length > SHOWN_MAX ? `${current.text.slice(0, SHOWN_MAX)}\n\n…cut here; Copy takes it whole.` : current.text
85    return (
86      <Box flexDirection="column">
87        <Box>
88          <Button key="pane-copy" label="Copy" variant="primary" onPress={press => void copy($, press.surface)} />
89          <Button key="pane-dismiss" label="Dismiss" onPress={() => void dismiss($)} />
90        </Box>
91        {isRefused && <Text bold>The clipboard did not take it: select and copy the text below.</Text>}
92        {isRefused ? <Text>{current.text}</Text> : <Markdown key="brief" text={shown} />}
93      </Box>
94    )
95  })
96}
97
hooks/context-view/model.ts 214 lines
1// Pure logic of the context view: no `$`, so the tests exercise it directly.
2
3import type { ContextViewMdRead, ContextViewReadEvent, ContextViewRule } from '../../types'
4
5export const DEVBOOK_FOLDERS = ['arc42', 'domain', 'tech', 'design', 'ai'] as const
6
7/** Roughly four characters per token: the same rule of thumb /context uses for text it does not count. */
8export const estimateTokens = (text: string | undefined): number =>
9  text ? Math.ceil(text.length / 4) : 0
10
11/** Forward slashes, no trailing slash, a lower-case drive letter. */
12export const normalize = (p: string): string =>
13  p.replace(/\\/g, '/').replace(/\/+$/, '').replace(/^([A-Z]):/, (_, d: string) => `${d.toLowerCase()}:`)
14
15const isAbsolute = (p: string): boolean => /^([a-z]:)?\//i.test(p)
16
17/** Resolves `p` against `base` and folds `.` and `..`. */
18export const resolvePath = (base: string, p: string): string => {
19  const joined = isAbsolute(normalize(p)) ? normalize(p) : `${normalize(base)}/${normalize(p)}`
20  const out: string[] = []
21  for (const part of joined.split('/')) {
22    if (part === '.') continue
23    if (part === '..' && out.length > 1) out.pop()
24    else out.push(part)
25  }
26  return out.join('/')
27}
28
29/** The path relative to `cwd` when it lies under it, else the normalized absolute path. */
30export const relativeTo = (cwd: string, p: string): string => {
31  const root = normalize(cwd)
32  const abs = resolvePath(root, p)
33  const lowerRoot = root.toLowerCase()
34  return abs.toLowerCase().startsWith(`${lowerRoot}/`) ? abs.slice(root.length + 1) : abs
35}
36
37export const folderOf = (rel: string): string => {
38  const at = rel.lastIndexOf('/')
39  return at < 0 ? '.' : rel.slice(0, at)
40}
41
42export const isMarkdown = (p: string): boolean => /\.(md|mdx|markdown)$/i.test(p)
43
44/** Strips the Read tool's `   12\t` line-number prefixes so the estimate counts the file, not the gutter. */
45export const stripLineNumbers = (text: string): string => text.replace(/^ *\d+\t/gm, '')
46
47const READERS = /(^|[\s;|&(])(cat|head|tail|sed|awk|less|more|bat|type|gc|Get-Content|Select-String|nl)(\s|$)/i
48
49/** The Markdown files a shell command reads: a reader verb, then `.md` operands that are no redirect target. */
50export const markdownReadByShell = (command: string): string[] => {
51  if (!READERS.test(command) || /\bsed\s+(-[a-z]*i|--in-place)/.test(command)) return []
52  const tokens = command.match(/"[^"]*"|'[^']*'|[^\s;|&()]+/g) ?? []
53  const files: string[] = []
54  tokens.forEach((raw, i) => {
55    const token = raw.replace(/^['"]|['"]$/g, '').replace(/^-Path[:=]?/i, '')
56    const previous = tokens[i - 1] ?? ''
57    if (!isMarkdown(token) || /^\d?>>?$/.test(previous) || /^>/.test(raw) || token.includes('*')) return
58    if (!files.includes(token)) files.push(token)
59  })
60  return files
61}
62
63/** A minimal glob: `**`, `*`, `?` and `{a,b}`, matched against the whole relative path. */
64export const globToRegExp = (glob: string): RegExp => {
65  let out = ''
66  for (let i = 0; i < glob.length; i++) {
67    const c = glob.charAt(i)
68    if (c === '*' && glob[i + 1] === '*') {
69      const slash = glob[i + 2] === '/'
70      out += slash ? '(?:.*/)?' : '.*'
71      i += slash ? 2 : 1
72    } else if (c === '*') out += '[^/]*'
73    else if (c === '?') out += '[^/]'
74    else if (c === '{') out += '(?:'
75    else if (c === '}') out += ')'
76    else if (c === ',') out += '|'
77    else out += c.replace(/[.+^$()|[\]\\]/g, '\\$&')
78  }
79  return new RegExp(`^${out}$`, 'i')
80}
81
82/** The `paths:` (or `globs:`) of a rule file's frontmatter; empty when it has none and so loads always. */
83export const ruleGlobs = (text: string): string[] => {
84  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]
85  if (!front) return []
86  const lines = front.split(/\r?\n/)
87  const at = lines.findIndex(l => /^(paths|globs):/.test(l))
88  if (at < 0) return []
89  const unquote = (s: string) => s.trim().replace(/^['"]|['"]$/g, '')
90  const inline = (lines[at] ?? "").replace(/^(paths|globs):/, '').trim()
91  if (inline.startsWith('[')) return inline.slice(1, -1).split(',').map(unquote).filter(Boolean)
92  if (inline) return inline.split(',').map(unquote).filter(Boolean)
93  const globs: string[] = []
94  for (const line of lines.slice(at + 1)) {
95    const item = /^\s*-\s*(.+)$/.exec(line)
96    if (!item) break
97    globs.push(unquote(item[1] ?? ""))
98  }
99  return globs
100}
101
102export const ruleMatches = (rule: ContextViewRule, rel: string): boolean =>
103  rule.globs.some(g => globToRegExp(g).test(rel))
104
105/** The `@` imports a memory file names, outside code fences, resolved against the file's folder. */
106export const memoryImports = (text: string, fileAbs: string): string[] => {
107  const prose = text.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
108  const found: string[] = []
109  for (const m of prose.matchAll(/(?:^|\s)@([^\s@]+)/g)) {
110    const target = (m[1] ?? "").replace(/[),.;:]+$/, '')
111    if (!/[/.]/.test(target) || target.startsWith('~') || /^[\w.-]+@/.test(target)) continue
112    const abs = resolvePath(folderOf(normalize(fileAbs)), target)
113    if (!found.includes(abs)) found.push(abs)
114  }
115  return found
116}
117
118export const hasMetaFence = (text: string): boolean => /^ *```meta\s*$/m.test(text)
119
120const slug = (heading: string): string =>
121  heading.trim().toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, '').replace(/\s+/g, '-')
122
123/** Chapter addresses as devbook writes them: the file, plus `#slug` for each heading that carries a meta fence. */
124export const chapterAddresses = (rel: string, text: string): string[] => {
125  const lines = stripLineNumbers(text).split(/\r?\n/)
126  const out: string[] = []
127  let heading: { level: number; text: string } | undefined
128  for (const line of lines) {
129    const h = /^(#{1,6})\s+(.+)$/.exec(line)
130    if (h) heading = { level: (h[1] ?? "#").length, text: h[2] ?? "" }
131    else if (/^ *```meta\s*$/.test(line))
132      out.push(!heading || heading.level === 1 ? rel : `${rel}#${slug(heading.text)}`)
133  }
134  return [...new Set(out)]
135}
136
137export type DevbookPlace = { folder: string; isMeta: boolean }
138
139/** Which devbook folder a read file belongs to, or undefined when it is none of devbook's. */
140export const devbookPlace = (rel: string, hasMeta: boolean): DevbookPlace | undefined => {
141  const parts = rel.split('/')
142  const at = parts.indexOf('.devbook')
143  const isMeta = parts.includes('_meta')
144  if (at >= 0) {
145    const folder = parts[at + 1] ?? ''
146    return { folder: (DEVBOOK_FOLDERS as readonly string[]).includes(folder) ? folder : folder || '.devbook', isMeta }
147  }
148  if (!hasMeta && !isMeta) return undefined
149  const known = parts.find(p => (DEVBOOK_FOLDERS as readonly string[]).includes(p))
150  return { folder: known ?? 'other', isMeta }
151}
152
153/** The devbook folder's root relative to cwd, for counting how much of it there is. */
154export const devbookRoot = (rel: string, folder: string): string | undefined => {
155  const parts = rel.split('/')
156  const at = parts.indexOf(folder)
157  return at < 0 ? undefined : parts.slice(0, at + 1).join('/')
158}
159
160const MAX_EVENTS = 20
161const MAX_OUTLINE = 40
162
163/** The headings of a Markdown text, indented by level, outside code fences; capped. */
164export const outlineOf = (text: string): string[] => {
165  const out: string[] = []
166  let isFenced = false
167  for (const line of stripLineNumbers(text).split(/\r?\n/)) {
168    if (/^ *```/.test(line)) isFenced = !isFenced
169    const h = isFenced ? null : /^(#{1,6})\s+(.+?)\s*#*$/.exec(line)
170    if (h) out.push(`${'  '.repeat((h[1] ?? '#').length - 1)}${h[2] ?? ''}`)
171    if (out.length >= MAX_OUTLINE) break
172  }
173  return out
174}
175
176/** Appends to a capped history, newest last. */
177export const pushEvent = <T>(events: readonly T[] | undefined, event: T): T[] =>
178  [...(events ?? []), event].slice(-MAX_EVENTS)
179
180/** Records one read: a new entry, or one more read of a known path. */
181export const addRead = (
182  reads: readonly ContextViewMdRead[],
183  read: Omit<ContextViewMdRead, 'count' | 'events'>,
184  event: ContextViewReadEvent,
185): ContextViewMdRead[] => {
186  const known = reads.find(r => r.path === read.path)
187  if (!known) return [...reads, { ...read, count: 1, events: [event] }]
188  return reads.map(r =>
189    r === known
190      ? {
191          ...r,
192          count: r.count + 1,
193          tokens: r.tokens + read.tokens,
194          hasMeta: r.hasMeta || read.hasMeta,
195          chapters: [...new Set([...r.chapters, ...read.chapters])],
196          outline: read.outline.length >= (r.outline?.length ?? 0) ? read.outline : r.outline,
197          rules: [...new Set([...(r.rules ?? []), ...read.rules])],
198          via: read.via,
199          events: pushEvent(r.events, event),
200        }
201      : r,
202  )
203}
204
205/** Whether a read of `count` of `total` files in one turn is "most of the folder". */
206export const isMostOfFolder = (count: number, total: number): boolean =>
207  total >= 3 && count >= 3 && count / total > 0.5
208
209export const formatTokens = (n: number): string =>
210  n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n)
211
212export const statusText = (percent: number | undefined, mdCount: number): string =>
213  `ctx ${percent === undefined ? '–' : Math.round(percent)}% · ${mdCount} md`
214
hooks/context-view/pane.tsx 600 lines
1// The context-view pane as element trees, per the "Context View Redesign" canvas: a meter
2// with a stacked category bar, a warning banner, foldable sections of focusable rows, and
3// zoom pages with a breadcrumb back. Every row is a plain Button, so Tab and the arrows walk
4// the rows and Enter zooms; drawn from Box, Text and Button, which every surface has.
5
6import type { BoxProps, ButtonProps, ElementConstructor, TextProps } from 'claude-code'
7
8import type { ContextViewMdRead } from '../../types'
9import { devbookPlace, devbookRoot, formatTokens } from './model'
10import { clip, detail, mergedInstructions } from './view'
11import type { ViewData } from './view'
12
13export type Els = {
14  Box: ElementConstructor<BoxProps>
15  Text: ElementConstructor<TextProps>
16  Button: ElementConstructor<ButtonProps>
17  Markdown?: ElementConstructor<{ key?: string; text: string }>
18}
19
20export type PaneData = ViewData & {
21  turn: number
22  folded: Readonly<Record<string, boolean>>
23  folderSizes: Readonly<Record<string, number>>
24  columns: number
25}
26
27export type Actions = {
28  zoom: (value: string) => void
29  back: () => void
30  toggle: (section: string) => void
31}
32
33export const COLORS = {
34  blue: '#7AA7FF',
35  teal: '#4CC9B0',
36  violet: '#B79CFF',
37  amber: '#F0A848',
38  pink: '#E7769A',
39  light: '#C9CED8',
40  track: '#3A3F4A',
41} as const
42
43const PALETTE = [COLORS.blue, COLORS.teal, COLORS.violet, COLORS.amber, COLORS.pink, COLORS.light]
44const KIND_COLOR: Record<string, string> = {
45  memory: COLORS.blue,
46  rule: COLORS.teal,
47  'output-style': COLORS.violet,
48  hook: COLORS.pink,
49  skill: COLORS.amber,
50  agent: COLORS.light,
51}
52const KIND_LABEL: Record<string, string> = { 'output-style': 'style' }
53const MAX_ROWS = 10
54
55const sum = <T,>(list: readonly T[], f: (x: T) => number) => list.reduce((n, x) => n + f(x), 0)
56const fit = (s: string, n: number) => (s.length <= n ? s.padEnd(n) : `${s.slice(0, Math.max(0, n - 1))}…`)
57
58/** A share bar of `width` cells: filled in `color`, the rest a dim track. */
59export const shareBar = (share: number, width: number): [string, string] => {
60  const filled = Math.max(share > 0 ? 1 : 0, Math.min(width, Math.round(share * width)))
61  return ['━'.repeat(filled), '─'.repeat(width - filled)]
62}
63
64const clock = (at: number, startedAt: number): string => {
65  const s = Math.max(0, Math.round((at - startedAt) / 1000))
66  return s < 60 ? `+${s}s` : s < 3600 ? `+${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `+${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
67}
68
69type Segment = { name: string; tokens: number; color: string }
70
71/** The window's used categories, or tool results by tool where the engine gives no breakdown. */
72export const segments = (d: ViewData): { items: Segment[]; isDerived: boolean } => {
73  const categories = (d.usage?.context.breakdown?.categories ?? []).filter(c => c.kind === 'used' && c.tokens > 0)
74  if (categories.length > 0)
75    return { items: categories.map((c, i) => ({ name: c.name, tokens: c.tokens, color: PALETTE[i % PALETTE.length]! })), isDerived: false }
76  const tools = Object.entries(d.byTool).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).slice(0, 6)
77  return { items: tools.map(([name, tokens], i) => ({ name: `${name} results`, tokens, color: PALETTE[i % PALETTE.length]! })), isDerived: true }
78}
79
80type Folder = { folder: string; root?: string; files: ContextViewMdRead[]; warned: boolean }
81
82const devbookFolders = (d: ViewData): Folder[] => {
83  const map = new Map<string, ContextViewMdRead[]>()
84  for (const r of d.reads) {
85    const place = devbookPlace(r.path, r.hasMeta)
86    if (place && !place.isMeta) map.set(place.folder, [...(map.get(place.folder) ?? []), r])
87  }
88  return [...map].sort((a, b) => a[0].localeCompare(b[0])).map(([folder, files]) => {
89    const root = files[0] ? devbookRoot(files[0].path, folder) : undefined
90    return { folder, root, files, warned: d.warnings.some(w => (root ? w.includes(`\`${root}\``) || w.includes(`${root}/`) : false)) }
91  })
92}
93
94// ------------------------------------------------------------------ pieces
95
96const Breadcrumb = (els: Els, a: Actions, trail: string[]) => {
97  const { Box, Text, Button } = els
98  return (
99    <Box key="crumb" flexDirection="row" gap={1}>
100      <Button key="back" plain hotkey="b" autoFocus onPress={() => a.back()}>
101        ← CONTEXT
102      </Button>
103      <Text dimColor wrap="truncate-start">
104        {trail.map(t => `› ${t}`).join(' ')}
105      </Text>
106    </Box>
107  )
108}
109
110const SectionHeader = (els: Els, a: Actions, d: PaneData, id: string, title: string, count: string, total: string) => {
111  const { Box, Text, Button } = els
112  return (
113    <Box key={`head-${id}`} flexDirection="row" justifyContent="space-between">
114      <Box flexDirection="row" gap={1}>
115        <Button key={`fold:${id}`} plain onPress={() => a.toggle(id)}>
116          {`${d.folded[id] ? '▸' : '▾'} ${title}`}
117        </Button>
118        <Text dimColor>· {count}</Text>
119      </Box>
120      <Text dimColor>{total}</Text>
121    </Box>
122  )
123}
124
125const Row = (els: Els, a: Actions, o: { key: string; tag?: [string, string]; label: string; why?: string; share: number; tokens: string; badge?: string; indent?: number }) => {
126  const { Box, Text, Button } = els
127  const [filled, track] = shareBar(o.share, 10)
128  return (
129    <Box key={`row-${o.key}`} flexDirection="row" gap={1} paddingLeft={o.indent ?? 0}>
130      {o.tag ? <Text color={o.tag[1]}>{fit(o.tag[0], 6)}</Text> : null}
131      <Box flexDirection="row" flexGrow={1} flexShrink={1} gap={1} overflow="hidden">
132        <Button key={`zoom:${o.key}`} plain onPress={() => a.zoom(o.key)}>
133          {o.label}
134        </Button>
135        {o.badge ? <Text color={COLORS.amber}>{o.badge}</Text> : null}
136        {o.why ? (
137          <Text dimColor wrap="truncate-end">
138            {o.why}
139          </Text>
140        ) : null}
141      </Box>
142      <Text>
143        <Text color={o.tag?.[1] ?? COLORS.light}>{filled}</Text>
144        <Text color={COLORS.track}>{track}</Text>
145      </Text>
146      <Text bold>{o.tokens.padStart(6)}</Text>
147    </Box>
148  )
149}
150
151// ------------------------------------------------------------------ overview
152
153const Meter = (els: Els, d: PaneData) => {
154  const { Box, Text } = els
155  const ctx = d.usage?.context
156  const b = ctx?.breakdown
157  const limit = b?.rawMaxTokens ?? ctx?.window ?? 0
158  const used = b?.totalTokens ?? ctx?.tokens
159  const pct = b?.percentage ?? ctx?.percent ?? (used !== undefined && limit ? (used / limit) * 100 : undefined)
160  const compact = b?.autoCompactThreshold
161  const width = Math.max(20, d.columns - 2)
162  const { items, isDerived } = segments(d)
163  const cells = (n: number) => (limit ? Math.round((n / limit) * width) : 0)
164  const segs = items.map(s => ({ ...s, cells: Math.max(1, cells(s.tokens)) }))
165  const usedCells = sum(segs, s => s.cells)
166  const bufferCells = compact && limit ? Math.max(0, width - cells(compact)) : 0
167  const freeCells = Math.max(0, width - usedCells - bufferCells)
168  const ticks = Array.from({ length: width }, () => ' ')
169  if (limit && d.peak) ticks[Math.min(width - 1, cells(d.peak))] = '▲'
170  const compactAt = compact && limit ? Math.min(width - 1, cells(compact)) : undefined
171  return (
172    <Box key="meter" flexDirection="column">
173      <Box flexDirection="row" justifyContent="space-between">
174        <Text>
175          <Text bold>{used === undefined ? '—' : formatTokens(used)}</Text>
176          <Text dimColor> / {limit ? formatTokens(limit) : '?'} tokens</Text>
177        </Text>
178        <Text dimColor>
179          <Text bold>{pct === undefined ? '–' : Math.round(pct)}%</Text> used · peak <Text bold>{formatTokens(d.peak)}</Text>
180          {compact ? ' · compacts at ' : ''}
181          {compact ? <Text bold color={COLORS.amber}>{formatTokens(compact)}</Text> : null}
182        </Text>
183      </Box>
184      <Text>
185        {segs.map(s => (
186          <Text color={s.color}>{'█'.repeat(s.cells)}</Text>
187        ))}
188        <Text color={COLORS.track}>{'█'.repeat(freeCells)}</Text>
189        <Text color={COLORS.track}>{'▒'.repeat(bufferCells)}</Text>
190      </Text>
191      <Text>
192        <Text>{ticks.slice(0, compactAt ?? width).join('')}</Text>
193        {compactAt !== undefined ? <Text color={COLORS.amber}>{`▲${ticks.slice(compactAt + 1).join('')}`}</Text> : null}
194      </Text>
195      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
196        {segs.map(s => (
197          <Box key={`legend-${s.name}`} flexDirection="row" gap={1} width={Math.max(18, Math.floor((d.columns - 4) / 3))}>
198            <Text color={s.color}>■</Text>
199            <Text dimColor wrap="truncate-end">
200              {s.name}
201            </Text>
202            <Text bold>{formatTokens(s.tokens)}</Text>
203          </Box>
204        ))}
205      </Box>
206      {isDerived && segs.length > 0 ? <Text dimColor>Tool results by tool: the engine has not itemized this window yet.</Text> : null}
207    </Box>
208  )
209}
210
211const Banner = (els: Els, a: Actions, d: PaneData, folders: Folder[]) => {
212  const { Box, Text, Button } = els
213  if (d.warnings.length === 0) return null
214  const first = d.warnings[d.warnings.length - 1] ?? ''
215  const target = folders.find(f => f.warned) ?? folders[0]
216  return (
217    <Box key="banner" flexDirection="row" gap={1} borderStyle="round" borderColor={COLORS.amber} paddingX={1}>
218      <Text bold color={COLORS.amber}>
219        !
220      </Text>
221      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
222        <Text bold color={COLORS.amber}>
223          {d.warnings.length} devbook warning{d.warnings.length > 1 ? 's' : ''}
224        </Text>
225        <Text dimColor wrap="truncate-end">
226          {first.replace(/`/g, '')}
227        </Text>
228      </Box>
229      {target ? (
230        <Button key="banner-view" plain onPress={() => a.zoom(`db:${target.folder}`)}>
231          ⏎ view
232        </Button>
233      ) : null}
234    </Box>
235  )
236}
237
238const Instructions = (els: Els, a: Actions, d: PaneData) => {
239  const { Box, Text, Button } = els
240  const list = mergedInstructions(d)
241  const max = Math.max(1, ...list.map(i => i.tokens))
242  const isAll = d.folded['instructions-all'] === true
243  const shown = isAll ? list : list.slice(0, MAX_ROWS)
244  const hidden = list.slice(shown.length)
245  return (
246    <Box key="instructions" flexDirection="column">
247      {SectionHeader(els, a, d, 'instructions', 'INSTRUCTIONS', `${list.length} sources`, `~${formatTokens(sum(list, i => i.tokens))}`)}
248      {d.folded.instructions ? null : (
249        <Box flexDirection="column">
250          {list.length === 0 ? <Text dimColor>None seen yet.</Text> : null}
251          {shown.map(i =>
252            Row(els, a, {
253              key: `in:${i.kind}:${i.name}`,
254              tag: [KIND_LABEL[i.kind] ?? i.kind, KIND_COLOR[i.kind] ?? COLORS.light],
255              label: i.name,
256              why: i.detail,
257              share: i.tokens / max,
258              tokens: formatTokens(i.tokens),
259            }),
260          )}
261          {hidden.length > 0 || isAll ? (
262            <Button key="instructions-more" plain dimColor onPress={() => a.toggle('instructions-all')}>
263              {isAll ? '− show fewer' : `+ ${hidden.length} more · ~${formatTokens(sum(hidden, i => i.tokens))}`}
264            </Button>
265          ) : null}
266        </Box>
267      )}
268    </Box>
269  )
270}
271
272const MarkdownRead = (els: Els, a: Actions, d: PaneData) => {
273  const { Box, Text } = els
274  const folders = new Map<string, ContextViewMdRead[]>()
275  for (const r of d.reads) folders.set(r.folder, [...(folders.get(r.folder) ?? []), r])
276  const sorted = [...folders].sort((x, y) => sum(y[1], r => r.tokens) - sum(x[1], r => r.tokens))
277  const max = Math.max(1, ...d.reads.map(r => r.tokens))
278  return (
279    <Box key="markdown" flexDirection="column">
280      {SectionHeader(els, a, d, 'markdown', 'MARKDOWN READ', `${d.reads.length} files`, `~${formatTokens(sum(d.reads, r => r.tokens))}`)}
281      {d.folded.markdown ? null : (
282        <Box flexDirection="column">
283          {d.reads.length === 0 ? <Text dimColor>No Markdown read yet.</Text> : null}
284          {sorted.map(([folder, files]) => (
285            <Box key={`folder-${folder}`} flexDirection="column">
286              <Box flexDirection="row" justifyContent="space-between">
287                <Text dimColor>{`▾ ${folder === '.' ? './' : `${folder}/`}`}</Text>
288                <Text dimColor>{formatTokens(sum(files, r => r.tokens))}</Text>
289              </Box>
290              {[...files]
291                .sort((x, y) => y.tokens - x.tokens)
292                .map(r =>
293                  Row(els, a, {
294                    key: `md:${r.path}`,
295                    label: folder === '.' ? r.path : r.path.slice(folder.length + 1),
296                    ...(r.count > 1 ? { badge: `×${r.count}` } : {}),
297                    share: r.tokens / max,
298                    tokens: formatTokens(r.tokens),
299                    indent: 2,
300                  }),
301                )}
302            </Box>
303          ))}
304        </Box>
305      )}
306    </Box>
307  )
308}
309
310const Devbook = (els: Els, a: Actions, d: PaneData, folders: Folder[]) => {
311  const { Box, Text, Button } = els
312  if (folders.length === 0) return null
313  const all = folders.flatMap(f => f.files)
314  return (
315    <Box key="devbook" flexDirection="column">
316      {SectionHeader(els, a, d, 'devbook', 'DEVBOOK', `${folders.length} folder${folders.length > 1 ? 's' : ''}`, `~${formatTokens(sum(all, r => r.tokens))}`)}
317      {d.folded.devbook ? null : (
318        <Box flexDirection="row" flexWrap="wrap" gap={1}>
319          {folders.map(f => {
320            const size = Math.max(f.files.length, (f.root ? d.folderSizes[f.root] : undefined) ?? f.files.length)
321            const chapters = new Set(f.files.flatMap(r => r.chapters)).size
322            return (
323              <Box
324                key={`db-${f.folder}`}
325                flexDirection="column"
326                borderStyle="round"
327                borderColor={f.warned ? COLORS.amber : COLORS.track}
328                paddingX={1}
329                flexGrow={1}
330                minWidth={28}
331              >
332                <Box flexDirection="row" justifyContent="space-between" gap={1}>
333                  <Button key={`zoom:db:${f.folder}`} plain onPress={() => a.zoom(`db:${f.folder}`)}>
334                    {f.folder}
335                  </Button>
336                  <Text color={f.warned ? COLORS.amber : undefined} dimColor={!f.warned}>
337                    {`${f.files.length} of ${size}${f.warned ? ' · whole folder' : ` · ${chapters} chapters`}`}
338                  </Text>
339                </Box>
340                <Text>
341                  <Text color={f.warned ? COLORS.amber : COLORS.light}>{'■'.repeat(f.files.length)}</Text>
342                  <Text color={COLORS.track}>{'□'.repeat(Math.min(60, size - f.files.length))}</Text>
343                </Text>
344              </Box>
345            )
346          })}
347        </Box>
348      )}
349    </Box>
350  )
351}
352
353const Footer = (els: Els, d: PaneData, keys: string) => {
354  const { Box, Text } = els
355  const pct = d.usage?.context.percent
356  return (
357    <Box key="footer" flexDirection="row" justifyContent="space-between">
358      <Text dimColor>{keys}</Text>
359      <Text dimColor>{`ctx ${pct === undefined ? '–' : Math.round(pct)}% · ${d.reads.length} md`}</Text>
360    </Box>
361  )
362}
363
364export const Overview = (els: Els, a: Actions, d: PaneData) => {
365  const { Box, Text } = els
366  const folders = devbookFolders(d)
367  const model = d.usage?.context.breakdown?.model
368  return (
369    <Box flexDirection="column" gap={1}>
370      <Box key="title" flexDirection="row" gap={1}>
371        <Text bold>CONTEXT</Text>
372        <Text dimColor>{[model, d.turn ? `turn ${d.turn}` : undefined].filter(Boolean).join(' · ')}</Text>
373      </Box>
374      {Meter(els, d)}
375      {Banner(els, a, d, folders)}
376      {Instructions(els, a, d)}
377      {MarkdownRead(els, a, d)}
378      {Devbook(els, a, d, folders)}
379      {Footer(els, d, 'tab ↑↓ move · ⏎ zoom · ⏎ on a heading folds')}
380    </Box>
381  )
382}
383
384// ------------------------------------------------------------------ zoom pages
385
386const Tile = (els: Els, label: string, value: string, isWarn = false) => {
387  const { Box, Text } = els
388  return (
389    <Box key={`tile-${label}`} flexDirection="column" borderStyle="round" borderColor={isWarn ? COLORS.amber : COLORS.track} paddingX={1} flexGrow={1}>
390      <Text dimColor>{label}</Text>
391      <Text bold color={isWarn ? COLORS.amber : undefined}>
392        {value}
393      </Text>
394    </Box>
395  )
396}
397
398const ColumnHead = (els: Els, title: string, hint?: string) => {
399  const { Box, Text } = els
400  return (
401    <Box key={`colhead-${title}`} flexDirection="row" justifyContent="space-between">
402      <Text bold>{title}</Text>
403      {hint ? <Text dimColor>{hint}</Text> : null}
404    </Box>
405  )
406}
407
408const Outline = (els: Els, outline: readonly string[] | undefined) => {
409  const { Box, Text } = els
410  if (!outline || outline.length === 0) return null
411  return (
412    <Box key="outline" flexDirection="column">
413      {ColumnHead(els, 'OUTLINE', 'the headings the model now holds')}
414      {outline.map((line, i) => {
415        const depth = (line.length - line.trimStart().length) / 2
416        return (
417          <Text key={`o-${i}`}>
418            <Text dimColor>{`${'  '.repeat(depth)}${'#'.repeat(depth + 1)} `}</Text>
419            <Text dimColor={depth > 1}>{line.trim()}</Text>
420          </Text>
421        )
422      })}
423    </Box>
424  )
425}
426
427const MdPage = (els: Els, a: Actions, d: PaneData, path: string) => {
428  const { Box, Text } = els
429  const r = d.reads.find(x => x.path === path)
430  if (!r) return undefined
431  const events = r.events ?? []
432  const first = events[0]?.tokens ?? r.tokens
433  const repeated = Math.max(0, r.tokens - first)
434  const limit = d.usage?.context.breakdown?.rawMaxTokens ?? d.usage?.context.window
435  const total = sum(d.reads, x => x.tokens)
436  const lines = Math.max(1, ...events.map(e => (e.from ?? 1) - 1 + (e.lines ?? 0)))
437  const name = r.path.split('/').pop() ?? r.path
438  const place = devbookPlace(r.path, r.hasMeta)
439  const W = 24
440  return (
441    <Box flexDirection="column" gap={1}>
442      {Breadcrumb(els, a, ['Markdown read', r.path])}
443      <Box key="title" flexDirection="row" justifyContent="space-between">
444        <Box flexDirection="column">
445          <Text bold>{name}</Text>
446          <Text dimColor>{[`${r.folder}/`, place ? 'devbook chapter file' : undefined, r.hasMeta ? 'carries meta fences' : undefined].filter(Boolean).join(' · ')}</Text>
447        </Box>
448        <Box flexDirection="column" alignItems="flex-end">
449          <Text bold>~{formatTokens(r.tokens)}</Text>
450          <Text dimColor>{`tokens over ${r.count} read${r.count > 1 ? 's' : ''}`}</Text>
451        </Box>
452      </Box>
453      <Box key="tiles" flexDirection="row" gap={1}>
454        {Tile(els, 'share of window', limit ? `${((r.tokens / limit) * 100).toFixed(1)}%` : '–')}
455        {Tile(els, 'of Markdown read', total ? `${Math.round((r.tokens / total) * 100)}%` : '–')}
456        {Tile(els, 'read again', repeated > 0 ? `+${formatTokens(repeated)} paid again` : 'no', repeated > 0)}
457        {Tile(els, 'rules fired', String(r.rules?.length ?? 0))}
458      </Box>
459      <Box key="reads" flexDirection="column">
460        {ColumnHead(els, 'READS', 'when · turn · via · lines covered')}
461        {events.map((e, i) => {
462          const from = e.from ?? 1
463          const start = Math.round(((from - 1) / lines) * W)
464          const span = Math.max(1, Math.round(((e.lines ?? lines) / lines) * W))
465          return (
466            <Box key={`read-${i}`} flexDirection="row" gap={1}>
467              <Text dimColor>{fit(clock(e.at, d.startedAt), 8)}</Text>
468              <Text>{fit(`turn ${e.turn}`, 8)}</Text>
469              <Text color={COLORS.teal}>{fit(e.via, 10)}</Text>
470              <Text>
471                <Text color={COLORS.track}>{'─'.repeat(start)}</Text>
472                <Text color={COLORS.light}>{'━'.repeat(Math.min(span, W - start))}</Text>
473                <Text color={COLORS.track}>{'─'.repeat(Math.max(0, W - start - span))}</Text>
474              </Text>
475              <Text dimColor>{e.range ?? 'whole file'}</Text>
476              <Box flexGrow={1} />
477              <Text bold>{formatTokens(e.tokens).padStart(6)}</Text>
478            </Box>
479          )
480        })}
481      </Box>
482      <Box key="cols" flexDirection="row" gap={3}>
483        <Box flexDirection="column" width="50%">
484          {ColumnHead(els, 'RULES THIS READ FIRED')}
485          {(r.rules ?? []).length === 0 ? <Text dimColor>none</Text> : null}
486          {(r.rules ?? []).map(rule => (
487            <Text key={`rule-${rule}`} wrap="truncate-end">
488              <Text color={COLORS.teal}>rule </Text>
489              {rule.split('/').pop()}
490            </Text>
491          ))}
492        </Box>
493        <Box flexDirection="column" width="50%">
494          {ColumnHead(els, 'CHAPTERS')}
495          {r.chapters.filter(c => c.includes('#')).length === 0 ? <Text dimColor>{r.hasMeta ? 'the file itself' : 'not a devbook chapter'}</Text> : null}
496          {r.chapters
497            .filter(c => c.includes('#'))
498            .map(c => (
499              <Text key={`ch-${c}`} wrap="truncate-end">
500                {c.slice(c.indexOf('#'))}
501              </Text>
502            ))}
503        </Box>
504      </Box>
505      {Outline(els, r.outline)}
506      {Footer(els, d, 'b back · tab ↑↓ move')}
507    </Box>
508  )
509}
510
511const DevbookPage = (els: Els, a: Actions, d: PaneData, folder: string) => {
512  const { Box, Text, Button } = els
513  const f = devbookFolders(d).find(x => x.folder === folder)
514  if (!f) return undefined
515  const metaReads = d.reads.filter(r => devbookPlace(r.path, r.hasMeta)?.isMeta && f.root && r.path.startsWith(`${f.root}/`))
516  const rows = [...f.files, ...metaReads]
517  const notes = d.warnings.filter(w => (f.root ? w.includes(f.root) : false))
518  const last = Math.max(d.turn, ...rows.flatMap(r => (r.events ?? []).map(e => e.turn)))
519  const first = Math.max(1, last - 11)
520  const turns = Array.from({ length: Math.max(1, last - first + 1) }, (_, i) => first + i)
521  const nameWidth = Math.min(28, Math.max(8, ...rows.map(r => r.path.length - (f.root?.length ?? 0))))
522  return (
523    <Box flexDirection="column" gap={1}>
524      {Breadcrumb(els, a, ['Devbook', f.root ?? folder])}
525      <Box key="title" flexDirection="row" justifyContent="space-between">
526        <Box flexDirection="column">
527          <Text bold>{folder}</Text>
528          <Text dimColor>{`${f.files.length} chapter file${f.files.length > 1 ? 's' : ''} read${metaReads.length ? ` · ${metaReads.length} derived index` : ''}`}</Text>
529        </Box>
530        <Box flexDirection="column" alignItems="flex-end">
531          <Text bold>~{formatTokens(sum(rows, r => r.tokens))}</Text>
532          <Text dimColor>tokens loaded</Text>
533        </Box>
534      </Box>
535      {notes.map((w, i) => (
536        <Box key={`warn-${i}`} borderStyle="round" borderColor={COLORS.amber} paddingX={1}>
537          <Text color={COLORS.amber}>{w.replace(/`/g, '')}</Text>
538        </Box>
539      ))}
540      <Box key="coverage" flexDirection="column">
541        {ColumnHead(els, 'COVERAGE BY TURN', '■ read that turn')}
542        <Text dimColor>{`${' '.repeat(nameWidth)} ${turns.map(t => String(t).padStart(3)).join('')}`}</Text>
543        {rows.map(r => {
544          const hit = new Set((r.events ?? []).map(e => e.turn))
545          const isMeta = devbookPlace(r.path, r.hasMeta)?.isMeta === true
546          return (
547            <Text key={`cov-${r.path}`}>
548              <Text color={isMeta ? COLORS.amber : undefined}>{fit(r.path.slice((f.root?.length ?? -1) + 1), nameWidth)} </Text>
549              {turns.map(t => (
550                <Text color={hit.has(t) ? (isMeta || f.warned ? COLORS.amber : COLORS.light) : COLORS.track}>{hit.has(t) ? '  ■' : '  ·'}</Text>
551              ))}
552            </Text>
553          )
554        })}
555      </Box>
556      <Box key="chapters" flexDirection="column">
557        {ColumnHead(els, 'CHAPTERS LOADED', `${new Set(f.files.flatMap(r => r.chapters)).size} addresses`)}
558        {f.files.map(r => (
559          <Box key={`file-${r.path}`} flexDirection="column">
560            <Box flexDirection="row" justifyContent="space-between">
561              <Button key={`zoom:md:${r.path}`} plain onPress={() => a.zoom(`md:${r.path}`)}>
562                {r.path.slice((f.root?.length ?? -1) + 1)}
563              </Button>
564              <Text dimColor>{`${formatTokens(r.tokens)}${r.count > 1 ? ` ×${r.count}` : ''}`}</Text>
565            </Box>
566            <Text dimColor wrap="wrap">
567              {r.chapters
568                .filter(c => c.includes('#'))
569                .map(c => c.slice(c.indexOf('#')))
570                .join('  ')}
571            </Text>
572          </Box>
573        ))}
574      </Box>
575      {Footer(els, d, 'b back · tab ↑↓ move · ⏎ zoom a file')}
576    </Box>
577  )
578}
579
580/** A zoom page: the designed pages for a file and a devbook folder, the Markdown detail for the rest. */
581export const Zoom = (els: Els, a: Actions, d: PaneData, focus: string) => {
582  const { Box } = els
583  const at = focus.indexOf(':')
584  const kind = focus.slice(0, at)
585  const rest = focus.slice(at + 1)
586  if (kind === 'md') return MdPage(els, a, d, rest)
587  if (kind === 'db') return DevbookPage(els, a, d, rest)
588  const page = detail(d, focus)
589  if (page === undefined || !els.Markdown) return undefined
590  const { Markdown } = els
591  const trail = kind === 'in' ? ['Instructions', rest.slice(rest.indexOf(':') + 1)] : ['Context window', rest]
592  return (
593    <Box flexDirection="column" gap={1}>
594      {Breadcrumb(els, a, trail)}
595      <Markdown key="detail" text={clip(page)} />
596      {Footer(els, d, 'b back')}
597    </Box>
598  )
599}
600
hooks/handoff-brief.ts 45 lines
1// Pure helpers for hooks/handoff.tsx: the prompt that asks for a cross-repository handoff
2// brief, and finding that brief in a reply.
3
4import type { HandoffBrief } from '../types'
5
6/** The shape the reply is asked for; the band finds the brief by its fence and heading. */
7export function handoffPrompt(args: string): string {
8  const asked = args.trim()
9  const target = asked
10    ? `Target and change, as asked: ${asked}`
11    : 'Infer the target repository and the change from this session; ask first if either is unclear.'
12  return [
13    'Write a cross-repository handoff: a self-contained Markdown brief a session in another repository can act on without this conversation.',
14    target,
15    '',
16    'Reply with the brief as one block fenced by `~~~markdown` and `~~~`, in this shape:',
17    '',
18    'Title this session `<short title>`',
19    '# Handoff — <the change, in a few words>',
20    '**Target repository:** <name, path, or owner/repo> · **From:** <this repository and branch>',
21    '## Why — what this work needs, and what waits or breaks without it',
22    '## Change — what to change in the target repository, concretely',
23    '## Context — quoted, never linked: the interfaces, payloads, errors, and decisions the target cannot read',
24    '## Done when — acceptance criteria and how to verify them',
25    '## Open questions — each with the default to take',
26    '',
27    'Write no file, change nothing, keep secrets out, and do nothing else in this turn.',
28  ].join('\n')
29}
30
31const FENCE = /^(~{3,}|`{4,})markdown[^\n]*\n([\s\S]*?)\n\1[ \t]*$/gm
32
33/** The last handoff brief in a reply: a markdown fence holding a `# Handoff` heading. */
34export function findBrief(answer: string): HandoffBrief | null {
35  let found: HandoffBrief | null = null
36  for (const match of answer.matchAll(FENCE)) {
37    const text = (match[2] ?? '').trim()
38    const heading = /^#\s+Handoff\s*[—–-]\s*(.+)$/m.exec(text)
39    if (!heading?.[1]) continue
40    const target = /\*\*Target repository:\*\*\s*([^·\n]+)/.exec(text)?.[1]?.replace(/`/g, '').trim()
41    found = { title: heading[1].trim(), target: target || 'another repository', text }
42  }
43  return found
44}
45
hooks/context-view/view.ts 282 lines
1// What the context-view pane draws, as Markdown: the overview, the zoom picker's options,
2// and one detail page per line. Pure, so the tests read it without a surface.
3
4import type { SessionUsage } from 'claude-code'
5
6import type { ContextViewInstruction, ContextViewMdRead, ContextViewRule } from '../../types'
7import { devbookPlace, formatTokens, relativeTo } from './model'
8
9export type ViewData = {
10  usage: SessionUsage | undefined
11  cwd: string
12  reads: readonly ContextViewMdRead[]
13  instructions: readonly ContextViewInstruction[]
14  rules: readonly ContextViewRule[]
15  warnings: readonly string[]
16  peak: number
17  byTool: Readonly<Record<string, number>>
18  startedAt: number
19}
20
21export type ZoomOption = { value: string; label: string }
22
23const MARKDOWN_LIMIT = 9500
24const KIND_ORDER = ['memory', 'rule', 'output-style', 'hook', 'skill', 'agent']
25
26export const clip = (text: string): string =>
27  text.length <= MARKDOWN_LIMIT ? text : `${text.slice(0, MARKDOWN_LIMIT).replace(/\n[^\n]*$/, '')}\n\n…`
28
29const cell = (s: string) => s.replace(/\|/g, '\\|')
30const sum = <T>(list: readonly T[], f: (x: T) => number) => list.reduce((n, x) => n + f(x), 0)
31
32const clock = (at: number, startedAt: number): string => {
33  const s = Math.max(0, Math.round((at - startedAt) / 1000))
34  return s < 60 ? `+${s}s` : s < 3600 ? `+${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `+${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
35}
36
37/** The session's instructions with the engine's own memory-file counts folded in. */
38export const mergedInstructions = (d: ViewData): ContextViewInstruction[] => {
39  const merged = [...d.instructions]
40  for (const file of d.usage?.context.breakdown?.memoryFiles ?? []) {
41    const name = relativeTo(d.cwd, file.path)
42    const at = merged.findIndex(
43      i => (i.kind === 'memory' || i.kind === 'rule') && i.name.toLowerCase() === name.toLowerCase(),
44    )
45    const prev = merged[at]
46    if (prev) merged[at] = { ...prev, tokens: file.tokens }
47    else merged.push({ kind: name.includes('/rules/') ? 'rule' : 'memory', name, tokens: file.tokens, detail: file.type })
48  }
49  return merged.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || b.tokens - a.tokens)
50}
51
52const usedCategories = (d: ViewData) =>
53  (d.usage?.context.breakdown?.categories ?? [])
54    .filter(c => c.kind === 'used' && c.tokens > 0)
55    .sort((a, b) => b.tokens - a.tokens)
56
57const devbookFolders = (reads: readonly ContextViewMdRead[]) => {
58  const folders = new Map<string, ContextViewMdRead[]>()
59  for (const r of reads) {
60    const place = devbookPlace(r.path, r.hasMeta)
61    if (place) folders.set(place.folder, [...(folders.get(place.folder) ?? []), r])
62  }
63  return [...folders].sort((a, b) => a[0].localeCompare(b[0]))
64}
65
66// ---------------------------------------------------------------- overview
67
68export const windowSection = (d: ViewData): string => {
69  const ctx = d.usage?.context
70  const breakdown = ctx?.breakdown
71  const limit = breakdown?.rawMaxTokens ?? ctx?.window
72  const used = breakdown?.totalTokens ?? ctx?.tokens
73  const pct = breakdown?.percentage ?? ctx?.percent
74  const lines = ['### Context window', '']
75  lines.push(
76    used === undefined || limit === undefined
77      ? `No response yet in this window${limit ? ` · limit ${formatTokens(limit)}` : ''} · peak ${formatTokens(d.peak)}`
78      : `**${formatTokens(used)} / ${formatTokens(limit)}** (${Math.round(pct ?? (used / limit) * 100)}%) · peak ${formatTokens(d.peak)}`,
79  )
80  const categories = usedCategories(d).slice(0, 6)
81  if (categories.length > 0) {
82    lines.push('', '| Category | Tokens |', '| --- | ---: |')
83    for (const c of categories) lines.push(`| ${cell(c.name)} | ${formatTokens(c.tokens)} |`)
84  } else {
85    const derived = Object.entries(d.byTool).sort((a, b) => b[1] - a[1]).slice(0, 6)
86    if (derived.length > 0) {
87      lines.push('', '| Tool results (derived) | ~Tokens |', '| --- | ---: |')
88      for (const [tool, n] of derived) lines.push(`| ${cell(tool)} | ${formatTokens(n)} |`)
89    }
90  }
91  return lines.join('\n')
92}
93
94export const instructionsSection = (d: ViewData): string => {
95  const merged = mergedInstructions(d)
96  const lines = ['### Instructions loaded', '', `${merged.length} sources · ~${formatTokens(sum(merged, i => i.tokens))} tokens`, '']
97  if (merged.length === 0) return [...lines, '_None seen yet._'].join('\n')
98  lines.push('| Kind | Source | ~Tokens | Why |', '| --- | --- | ---: | --- |')
99  for (const i of merged)
100    lines.push(`| ${i.kind} | \`${cell(i.name)}\` | ${formatTokens(i.tokens)} | ${cell(i.detail ?? '')} |`)
101  return lines.join('\n')
102}
103
104export const markdownSection = (d: ViewData): string => {
105  const lines = ['### Markdown read', '', `${d.reads.length} files · ~${formatTokens(sum(d.reads, r => r.tokens))} tokens`, '']
106  if (d.reads.length === 0) return [...lines, '_No Markdown read yet._'].join('\n')
107  const folders = new Map<string, ContextViewMdRead[]>()
108  for (const r of d.reads) folders.set(r.folder, [...(folders.get(r.folder) ?? []), r])
109  const sorted = [...folders].sort((a, b) => sum(b[1], r => r.tokens) - sum(a[1], r => r.tokens))
110  for (const [folder, files] of sorted) {
111    lines.push(`**\`${folder}/\`** · ~${formatTokens(sum(files, r => r.tokens))}`)
112    for (const r of [...files].sort((a, b) => b.tokens - a.tokens))
113      lines.push(
114        `- \`${r.path.slice(folder === '.' ? 0 : folder.length + 1)}\` · ~${formatTokens(r.tokens)}${r.count > 1 ? ` · read ${r.count}×` : ''}`,
115      )
116    lines.push('')
117  }
118  return lines.join('\n')
119}
120
121export const devbookSection = (d: ViewData): string | undefined => {
122  const folders = devbookFolders(d.reads)
123  if (folders.length === 0 && d.warnings.length === 0) return undefined
124  const lines = ['### Devbook lens', '']
125  for (const note of d.warnings) lines.push(`> ⚠ ${note}`, '>')
126  if (d.warnings.length > 0) lines.push('')
127  for (const [folder, files] of folders) {
128    lines.push(`**${folder}** · ${files.length} files · ~${formatTokens(sum(files, r => r.tokens))}`)
129    for (const r of files) {
130      const chapters = r.chapters.filter(c => c.includes('#')).map(c => c.slice(c.indexOf('#')))
131      lines.push(`- \`${r.path}\`${chapters.length > 0 ? ` — ${chapters.join(', ')}` : ''}`)
132    }
133    lines.push('')
134  }
135  return lines.join('\n')
136}
137
138export const overview = (d: ViewData): string[] => {
139  const devbook = devbookSection(d)
140  return [windowSection(d), instructionsSection(d), markdownSection(d), ...(devbook ? [devbook] : [])]
141}
142
143// ---------------------------------------------------------------- zoom
144
145/** Every line the pane can zoom into, in the overview's order. */
146export const zoomOptions = (d: ViewData): ZoomOption[] => {
147  const options: ZoomOption[] = []
148  const categories = usedCategories(d)
149  for (const c of categories) options.push({ value: `cat:${c.name}`, label: `window · ${c.name} (${formatTokens(c.tokens)})` })
150  if (categories.length === 0)
151    for (const [tool, n] of Object.entries(d.byTool).sort((a, b) => b[1] - a[1]))
152      options.push({ value: `tool:${tool}`, label: `window · ${tool} results (~${formatTokens(n)})` })
153  for (const i of mergedInstructions(d))
154    options.push({ value: `in:${i.kind}:${i.name}`, label: `${i.kind} · ${i.name} (~${formatTokens(i.tokens)})` })
155  for (const r of [...d.reads].sort((a, b) => b.tokens - a.tokens))
156    options.push({ value: `md:${r.path}`, label: `md · ${r.path} (~${formatTokens(r.tokens)}${r.count > 1 ? `, ${r.count}×` : ''})` })
157  for (const [folder, files] of devbookFolders(d.reads))
158    options.push({ value: `db:${folder}`, label: `devbook · ${folder} (${files.length} files)` })
159  return options.slice(0, 300)
160}
161
162const outlineBlock = (outline: readonly string[] | undefined): string[] =>
163  outline && outline.length > 0 ? ['', '**Outline**', '', '```', ...outline, '```'] : []
164
165const mdDetail = (d: ViewData, path: string): string => {
166  const r = d.reads.find(x => x.path === path)
167  if (!r) return `_\`${path}\` is no longer recorded._`
168  const place = devbookPlace(r.path, r.hasMeta)
169  const lines = [
170    `### \`${r.path}\``,
171    '',
172    `~${formatTokens(r.tokens)} tokens over ${r.count} read${r.count > 1 ? 's' : ''} · last via ${r.via}${r.hasMeta ? ' · carries `meta` fences' : ''}`,
173  ]
174  if (r.count > 1) lines.push('', `Read ${r.count}×: every read after the first adds its tokens again unless the earlier result was cleared.`)
175  lines.push('', '| When | Turn | Via | ~Tokens | Range |', '| --- | ---: | --- | ---: | --- |')
176  for (const e of r.events ?? [])
177    lines.push(`| ${clock(e.at, d.startedAt)} | ${e.turn} | ${cell(e.via)} | ${formatTokens(e.tokens)} | ${e.range ?? 'whole file'} |`)
178  if (r.rules?.length) lines.push('', '**Rules this read fired**', '', ...r.rules.map(x => `- \`${x}\``))
179  if (place) {
180    lines.push('', `**Devbook** · folder \`${place.folder}\`${place.isMeta ? ' · a `_meta/` index' : ''}`)
181    if (r.chapters.length > 0) lines.push('', ...r.chapters.map(c => `- \`${c}\``))
182  }
183  lines.push(...outlineBlock(r.outline))
184  return lines.join('\n')
185}
186
187const instructionDetail = (d: ViewData, kind: string, name: string): string => {
188  const i = mergedInstructions(d).find(x => x.kind === kind && x.name === name)
189  if (!i) return `_\`${name}\` is no longer recorded._`
190  const lines = [`### ${i.kind} · \`${i.name}\``, '', `~${formatTokens(i.tokens)} tokens${i.detail ? ` · ${i.detail}` : ''}`]
191  const rule = d.rules.find(x => x.path === i.name)
192  if (i.kind === 'memory' && i.refs?.length) lines.push('', '**`@` imports**', '', ...i.refs.map(x => `- \`${x}\``))
193  if (i.kind === 'memory') {
194    const importedBy = d.instructions.filter(x => x.kind === 'memory' && x.refs?.includes(i.name)).map(x => x.name)
195    if (importedBy.length > 0) lines.push('', `Imported by ${importedBy.map(x => `\`${x}\``).join(', ')}.`)
196  }
197  if (i.kind === 'rule' && rule) lines.push('', '**Globs**', '', ...rule.globs.map(g => `- \`${g}\``))
198  if (i.kind === 'rule' && !rule && i.detail === 'loads always') lines.push('', 'No `paths:` frontmatter, so it loads in every session.')
199  if (i.kind === 'output-style' && i.refs?.length) lines.push('', `System prompt sections: ${i.refs.map(x => `\`${x}\``).join(', ')}`)
200  if (i.events?.length) {
201    const headings: Record<string, string> = { rule: 'Paths that matched', agent: 'Calls', skill: 'Invocations', hook: 'Rows', memory: 'Loads' }
202    const heading = headings[i.kind] ?? 'Events'
203    lines.push('', `**${heading}**`, '', '| When | Turn | What |', '| --- | ---: | --- |')
204    for (const e of i.events) lines.push(`| ${clock(e.at, d.startedAt)} | ${e.turn} | ${cell(e.note)} |`)
205  }
206  lines.push(...outlineBlock(i.outline ?? rule?.outline))
207  return lines.join('\n')
208}
209
210const categoryDetail = (d: ViewData, name: string): string => {
211  const b = d.usage?.context.breakdown
212  const c = b?.categories.find(x => x.name === name)
213  if (!b || !c) return `_No breakdown for \`${name}\` right now._`
214  const lines = [`### ${c.name}`, '', `${formatTokens(c.tokens)} tokens · ${Math.round((c.tokens / b.rawMaxTokens) * 100)}% of the window`]
215  const n = name.toLowerCase()
216  if (n.includes('memory')) {
217    lines.push('', '| File | Loaded from | Tokens |', '| --- | --- | ---: |')
218    for (const f of [...b.memoryFiles].sort((x, y) => y.tokens - x.tokens))
219      lines.push(`| \`${cell(relativeTo(d.cwd, f.path))}\` | ${f.type} | ${formatTokens(f.tokens)} |`)
220  } else if (n.includes('mcp')) {
221    const servers = new Map<string, typeof b.mcpTools>()
222    for (const t of b.mcpTools) servers.set(t.serverName, [...(servers.get(t.serverName) ?? []), t])
223    lines.push('', '| Server | Tools | Loaded | Tokens |', '| --- | ---: | ---: | ---: |')
224    for (const [server, tools] of [...servers].sort((x, y) => sum(y[1], t => t.tokens) - sum(x[1], t => t.tokens)))
225      lines.push(`| ${cell(server)} | ${tools.length} | ${tools.filter(t => t.isLoaded).length} | ${formatTokens(sum(tools, t => t.tokens))} |`)
226  } else if (n.includes('skill') && b.skills) {
227    lines.push('', `${b.skills.includedSkills} of ${b.skills.totalSkills} skills listed`, '', '| Skill | Source | Tokens |', '| --- | --- | ---: |')
228    for (const s of [...b.skills.skillFrontmatter].sort((x, y) => y.tokens - x.tokens).slice(0, 60))
229      lines.push(`| ${cell(s.name)} | ${s.pluginName ?? s.source} | ${formatTokens(s.tokens)} |`)
230  } else if (n.includes('agent')) {
231    lines.push('', '| Agent | Source | Tokens |', '| --- | --- | ---: |')
232    for (const a of [...b.agents].sort((x, y) => y.tokens - x.tokens)) lines.push(`| ${cell(a.agentType)} | ${a.source} | ${formatTokens(a.tokens)} |`)
233  } else if (n.includes('message')) {
234    lines.push('', 'Tool results that went into the messages, by tool (approximate):', '', '| Tool | ~Tokens |', '| --- | ---: |')
235    for (const [tool, t] of Object.entries(d.byTool).sort((x, y) => y[1] - x[1])) lines.push(`| ${cell(tool)} | ${formatTokens(t)} |`)
236  } else lines.push('', '_The engine itemizes nothing further for this category._')
237  if (b.autoCompactThreshold) lines.push('', `Auto-compaction at ${formatTokens(b.autoCompactThreshold)} tokens.`)
238  return lines.join('\n')
239}
240
241const toolDetail = (d: ViewData, tool: string): string => {
242  const files = d.reads.filter(r => (r.events ?? []).some(e => e.via.startsWith(tool)))
243  const lines = [`### ${tool} results`, '', `~${formatTokens(d.byTool[tool] ?? 0)} tokens returned to this session (approximate)`]
244  if (files.length > 0) {
245    lines.push('', '**Markdown it read**', '')
246    for (const r of files.sort((a, b) => b.tokens - a.tokens)) lines.push(`- \`${r.path}\` · ~${formatTokens(r.tokens)}`)
247  }
248  return lines.join('\n')
249}
250
251const devbookDetail = (d: ViewData, folder: string): string => {
252  const files = devbookFolders(d.reads).find(([f]) => f === folder)?.[1] ?? []
253  const lines = [`### Devbook · ${folder}`, '', `${files.length} files · ~${formatTokens(sum(files, r => r.tokens))} tokens`]
254  const notes = d.warnings.filter(w => w.includes(`/${folder}`))
255  if (notes.length > 0) lines.push('', ...notes.map(w => `> ⚠ ${w}`))
256  const byTurn = new Map<number, string[]>()
257  for (const r of files) for (const e of r.events ?? []) byTurn.set(e.turn, [...new Set([...(byTurn.get(e.turn) ?? []), r.path])])
258  if (byTurn.size > 0) {
259    lines.push('', '| Turn | Files read |', '| ---: | --- |')
260    for (const [turn, paths] of [...byTurn].sort((a, b) => a[0] - b[0])) lines.push(`| ${turn} | ${paths.map(p => `\`${p.split('/').pop()}\``).join(', ')} |`)
261  }
262  lines.push('', '**Chapters**', '')
263  for (const r of files) lines.push(`- \`${r.path}\``, ...r.chapters.filter(c => c.includes('#')).map(c => `  - \`${c.slice(c.indexOf('#'))}\``))
264  return lines.join('\n')
265}
266
267/** The detail page for one zoom value, or undefined when the value names nothing known. */
268export const detail = (d: ViewData, focus: string): string | undefined => {
269  const at = focus.indexOf(':')
270  const kind = focus.slice(0, at)
271  const rest = focus.slice(at + 1)
272  if (kind === 'md') return mdDetail(d, rest)
273  if (kind === 'cat') return categoryDetail(d, rest)
274  if (kind === 'tool') return toolDetail(d, rest)
275  if (kind === 'db') return devbookDetail(d, rest)
276  if (kind === 'in') {
277    const split = rest.indexOf(':')
278    return instructionDetail(d, rest.slice(0, split), rest.slice(split + 1))
279  }
280  return undefined
281}
282
types/index.d.ts 111 lines
1// The $.state contract of claude-desktop-mods: its two hooks modules, hooks/handoff.tsx and
2// hooks/context-view/register.tsx, keep their values under the one plugin name. Claude Code only.
3
4/** A cross-repository handoff brief found in a reply. */
5export type HandoffBrief = {
6  /** The change, from the brief's `# Handoff — <change>` heading. */
7  title: string
8  /** The brief's `**Target repository:**` value. */
9  target: string
10  /** The whole brief: what Copy puts on the clipboard. */
11  text: string
12}
13
14/** One read of a Markdown file. */
15export type ContextViewReadEvent = {
16  /** `$.clock.now()` milliseconds. */
17  at: number
18  /** The turn it happened in, counted from 1 since the module loaded. */
19  turn: number
20  via: string
21  tokens: number
22  /** `lines 40–120` for a partial Read; absent for a whole file. */
23  range?: string
24  /** The first line it covered, 1-based. */
25  from?: number
26  /** How many lines it returned. */
27  lines?: number
28}
29
30/** One Markdown file read in this session, by the Read tool or a shell reader. */
31export type ContextViewMdRead = {
32  /** Relative to the session's working directory when under it, else absolute; forward slashes. */
33  path: string
34  /** The folder part of `path`, `.` for the root. */
35  folder: string
36  /** Approximate tokens of every read together (four characters a token). */
37  tokens: number
38  /** How many times it was read. */
39  count: number
40  /** Whether a read showed a fenced ```meta block. */
41  hasMeta: boolean
42  /** Devbook chapter addresses the reads showed: the file, or `file#heading`. */
43  chapters: string[]
44  /** Which tool read it last. */
45  via: string
46  /** Its headings, indented by level, from the fullest read. */
47  outline: string[]
48  /** The path-scoped rules a read of it fired. */
49  rules: string[]
50  /** Each read, newest last, capped. */
51  events: ContextViewReadEvent[]
52}
53
54export type ContextViewInstructionKind = 'memory' | 'rule' | 'skill' | 'agent' | 'output-style' | 'hook'
55
56/** Something that happened to an instruction source: an invocation, a match, a row. */
57export type ContextViewInstructionEvent = { at: number; turn: number; note: string }
58
59/** One instruction source that entered this session's context, with its approximate cost. */
60export type ContextViewInstruction = {
61  kind: ContextViewInstructionKind
62  /** The display name: a path, a skill, an agent type, a style, a hook event. */
63  name: string
64  tokens: number
65  /** What brought it in: `@ import of CLAUDE.md`, `matched plugins/x/agents/a.agent.md`. */
66  detail?: string
67  /** Its headings, indented by level, when it is text the module saw. */
68  outline?: string[]
69  /** A memory file's `@` imports; a rule's globs; an output style's section ids. */
70  refs?: string[]
71  /** Every path a rule matched; every call an agent or skill took. */
72  events?: ContextViewInstructionEvent[]
73}
74
75/** A `.claude/rules/*.md` file that loads only when a path its globs match is read. */
76export type ContextViewRule = { path: string; globs: string[]; tokens: number; outline: string[] }
77
78declare module 'claude-code' {
79  interface PluginState {
80    'claude-desktop-mods': {
81      // hooks/handoff.tsx
82      /** The latest brief, until it is dismissed. */
83      brief: HandoffBrief | null
84      /** Whether the clipboard refused the brief, so the pane shows it as plain text. */
85      isCopyRefused: boolean
86      // hooks/context-view/register.tsx
87      contextReads: ContextViewMdRead[]
88      contextInstructions: ContextViewInstruction[]
89      contextRules: ContextViewRule[]
90      /** The highest input-token count any response of this session was answered over. */
91      contextPeak: number
92      /** Approximate tokens tool results added, by tool: the fallback categories. */
93      contextByTool: Record<string, number>
94      /** Devbook warnings, newest last, each written once. */
95      contextWarnings: string[]
96      /** Devbook files read in the current turn, by folder root. */
97      contextTurnReads: Record<string, string[]>
98      /** How many Markdown files each devbook folder root holds, counted on first read. */
99      contextFolderSizes: Record<string, number>
100      /** Skill names whose body has not yet been seen in the conversation. */
101      contextPendingSkills: string[]
102      /** The turn count since the module loaded. */
103      contextTurn: number
104      /** The line the pane is zoomed into (`md:<path>`, `in:<kind>:<name>`, `cat:<name>`, `db:<folder>`); empty for the overview. */
105      contextFocus: string
106      /** Folded sections of the overview, by id, and `instructions-all` when every instruction row shows. */
107      contextFolded: Record<string, boolean>
108    }
109  }
110}
111