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…

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.
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.| File | Holds |
|---|---|
hooks/hooks.json | the one hooks module, register.ts, under modules |
hooks/register.ts | registers both mods; only on crosses into each mod's file |
hooks/handoff.tsx | the command, the band, and the pane |
hooks/handoff-brief.ts | the prompt, and finding the brief in a reply |
types/index.d.ts | the $.state values, named as types in the manifest |
tests/handoff.test.tsx | the tests |
/context-view opens a pane showing, for the current session:
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..md file read through Read, or cat/sed/head/Get-Content through a shell, grouped by folder, with token cost and read count..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:
| Line | Detail |
|---|---|
| A window category | What the engine itemizes in it: memory files, MCP tools by server, the skill listing, custom agents, or tool results by tool |
| A memory file | Its @ imports, what imports it, and its outline |
| A rule | Its globs, every path that matched it and when, and its outline |
| A skill or agent | Each invocation, the tokens an agent returned, and the body's outline |
| A hook or the output style | Each row it added, or the system prompt sections it occupies |
| A Markdown file | Every read with its turn, tool, tokens, and line range, the rules it fired, its devbook chapters, and its outline |
| A devbook folder | Its 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.
| File | Role |
|---|---|
hooks/context-view/register.tsx | The hooks, the recorded state, and the pane's actions |
hooks/context-view/pane.tsx | The overview and the two designed zoom pages, as element trees |
hooks/context-view/view.ts | The text detail pages and the merged instruction list |
hooks/context-view/model.ts | The pure logic: shell-read parsing, rule globs, @ imports, the devbook lens |
tests/context-view*.test.ts | The tests |
claude plugin validate plugins/claude-desktop-mods
claude plugin test plugins/claude-desktop-mods
claude --plugin-dir plugins/claude-desktop-mods
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.
/plugin install claude-desktop-mods@jsdotnet-ai-plugins
UNLICENSED
Job Schepers
hooks/register.ts 13 lines1// 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}
13hooks/context-view/register.tsx 430 lines1// 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}
430hooks/handoff.tsx 97 lines1// 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}
97hooks/context-view/model.ts 214 lines1// 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`
214hooks/context-view/pane.tsx 600 lines1// 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}
600hooks/handoff-brief.ts 45 lines1// 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}
45hooks/context-view/view.ts 282 lines1// 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}
282types/index.d.ts 111 lines1// 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