Sorts each of Claude's replies into what's in progress, what needs you and what got done, in a side pane and in place in the transcript

Sorts each of Claude's replies into three lists, in progress, needs you and done, under a one-line headline. You see where the work stands without reading the whole reply. The idea comes from Amelia Wattenberger's fish-eye essay: show the part you're looking at in detail, with the context around it summarised.
After each main-loop turn, Haiku reads your prompt and Claude's final answer and returns a headline plus up to five items per list:
One call covers all three lists, so switching between them never calls the model again. Digests are cached per turn for the session.
Every view is stamped with when Claude finished the original reply (with the date once it isn't today), not when it was summarised.
The inline button cycles three modes. Replace is the default:
open in lens ›.show original and open in lens ›. The box stays the same size and place whether it is still sorting, showing the lists or showing the original, so the transcript doesn't jump. Text Claude wrote between tool calls folds to its first line./lens [all|progress|blocked|done] opens a pane with:
original to swap in Claude's reply.✋ n when they're waiting on you.Keys while the pane has focus: a/1/2/3 choose the list, n cycles the inline mode, o toggles the original, and k/j/l move between turns.
These come from Claude Code's mod API (2.1.293, early access) and shaped the design. Some may change in later releases.
Text and Markdown draw at the app's body size. Markdown ##/### headings use the app's own small heading styles, and Text offers only bold, italic, underline, colour and background. The only way to draw a larger heading is an Svg element, so on the desktop every heading here is an SVG image. That costs three things:Text and Box colours take a theme name or a fixed hex. The lens uses mid-tone hex values that read on both themes, rather than separate shades for each.Markdown. Spacing, the reply's opening bullet and code styling can look slightly different from replies the app draws.session.append) is declared in the types but refused at load in this build. The mod matches each reply to its turn by text, so two replies that open identically can be matched to the wrong turn.onScreen, and $.ui.selection() answers undefined. A pane that follows what you're reading is therefore only possible in the fullscreen terminal, so it isn't in this version.claude plugin validate mods/lens
claude plugin test mods/lenshooks/register.tsx 512 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ResolveInput, Timer } from 'claude-code'
3
4import type { Digest, InlineMode, Purpose, Turn, View } from '../types'
5import { LENS, headingSvg, mid } from './heading'
6import type { Hue } from './heading'
7import { turnsFrom, withBackfill } from './backfill'
8import { endsAnswer, turnContaining } from './match'
9import { timeOf } from './time'
10import { BUCKETS, PURPOSES, overviewRequest, parseDigest, titleOf, turnRequest } from './digest'
11
12const PANE = 'lens'
13const MODEL = 'haiku'
14const NEAR = 2 // turns this close to the focus show their headline; farther ones fold away
15const TICK_MS = 400 // how often the inline rows' wanted digests are generated
16const REPLY_MAX = 8000 // characters kept per reply block for matching
17const INLINE_MAX = 3 // inline digests generated at once
18const BACKFILL = 10 // earlier turns rebuilt from the transcript when the lens loads
19
20const turns = atom({ plugin: 'lens', key: 'turns' } as const, [])
21const purpose = atom({ plugin: 'lens', key: 'purpose' } as const, 'all')
22const focus = atom({ plugin: 'lens', key: 'focus' } as const, null)
23const views = atom({ plugin: 'lens', key: 'views' } as const, {})
24const isOpen = atom({ plugin: 'lens', key: 'isOpen' } as const, false)
25const inline = atom({ plugin: 'lens', key: 'inline' } as const, 'replace')
26const expanded = atom({ plugin: 'lens', key: 'expanded' } as const, [])
27
28const MODES: InlineMode[] = ['off', 'callout', 'replace']
29// Sessions from before the third mode stored this as a boolean.
30const modeOf = (stored: unknown): InlineMode =>
31 stored === false ? 'off' : stored === true ? 'callout' : MODES.includes(stored as InlineMode) ? (stored as InlineMode) : 'replace'
32
33const viewKey = (turnId: string) => `digest|${turnId}`
34const overviewKey = (lastTurnId: string) => `digest|overview|${lastTurnId}`
35
36const focusIndex = (list: Turn[], id: string | null) => {
37 const found = id === null ? -1 : list.findIndex(t => t.id === id)
38
39 return found === -1 ? list.length - 1 : found
40}
41
42async function generate($: EngineInterface, key: string, ask: { system: string; prompt: string; maxTokens: number }) {
43 const known = (await read($, views))[key]
44 if (known && known.status !== 'error') return
45
46 const pending: View = { status: 'pending', text: '' }
47 await update($, views, v => ({ ...v, [key]: pending }))
48 // A call that rejects (the engine refusing to send it) ends as an error view
49 // too, never left pending: an error is what the next refresh retries.
50 let view: View
51 try {
52 const result = await $.model.complete({ model: MODEL, effort: 'low', timeoutMs: 60_000, ...ask })
53 const digest = result.isAnswered ? parseDigest(result.text) : undefined
54 view = digest
55 ? { status: 'done', text: digest.headline, digest }
56 : { status: 'error', text: result.isAnswered ? "couldn't read the summary" : `couldn't summarize (${result.reason})` }
57 } catch {
58 view = { status: 'error', text: "couldn't summarize" }
59 }
60 await update($, views, v => ({ ...v, [key]: view }))
61}
62
63// One line per earlier turn, so a digest knows what led up to its turn.
64const contextFor = (list: Turn[], known: Record<string, View>, i: number) =>
65 list.slice(Math.max(0, i - 6), i).map(t => {
66 const view = known[viewKey(t.id)]
67
68 return view?.status === 'done' ? view.text : titleOf(t)
69 })
70
71// Fills what the pane is about to show: the focused turn and its neighbours,
72// and the session overview.
73async function refresh($: EngineInterface) {
74 const list = await read($, turns)
75 if (list.length === 0) return
76
77 const f = focusIndex(list, await read($, focus))
78 const known = await read($, views)
79
80 const jobs: Promise<void>[] = []
81 list.forEach((t, i) => {
82 if (Math.abs(i - f) <= NEAR) jobs.push(generate($, viewKey(t.id), turnRequest(t, contextFor(list, known, i))))
83 })
84 const last = list.at(-1)
85 if (last) jobs.push(generate($, overviewKey(last.id), overviewRequest(list.slice(-20))))
86 await Promise.all(jobs)
87}
88
89const shownBuckets = (d: Digest, p: Purpose) => BUCKETS.filter(b => (p === 'all' ? d[b.id].length > 0 : b.id === p))
90
91// A heading at a real size: SVG text where the surface draws SVG (the desktop),
92// else bold coloured text, as the terminal has one type size.
93function heading($: EngineInterface, e: ResolveInput, key: string, text: string, size: number, hue: Hue, columns: number) {
94 // By surface, not by the table: every surface's table answers `in` for Svg.
95 if (e.surface !== 'terminal') {
96 const { Box, Svg } = $.ui.resolve({ ...e, surface: e.surface })
97 const svg = headingSvg(text, size, hue, Math.min(720, Math.max(240, columns * 8)))
98
99 return (
100 <Box key={key}>
101 <Svg source={svg.source} alt={text} width={svg.width} height={svg.height} />
102 </Box>
103 )
104 }
105 const { Text } = $.ui.resolve(e)
106
107 return (
108 <Text key={key} bold color={hue === 'text' ? undefined : mid(hue)}>
109 {text}
110 </Text>
111 )
112}
113
114// The digest's lists, one heading each in the list's own colour, as many as the
115// purpose asks for. `all` skips empty ones.
116function sections($: EngineInterface, e: ResolveInput, d: Digest, p: Purpose, size: number, columns: number) {
117 const { Box, Markdown } = $.ui.resolve(e)
118 const shown = shownBuckets(d, p)
119 if (shown.length === 0) return <Markdown text="_nothing to report_" />
120
121 return (
122 <Box flexDirection="column" rowGap={1}>
123 {shown.map(b => (
124 <Box key={`s-${b.id}`} flexDirection="column">
125 {heading($, e, `h-${b.id}`, `${b.mark} ${b.label}`, size, b.hue, columns)}
126 <Markdown text={d[b.id].length === 0 ? `_${b.empty}_` : d[b.id].map(x => `- ${x}`).join('\n')} />
127 </Box>
128 ))}
129 </Box>
130 )
131}
132
133const tally = (d: Digest) => BUCKETS.map(b => `${b.mark} ${d[b.id].length}`).join(' ')
134
135// A message row carries no turn id; once matched by its text, it keeps its turn.
136const turnOfMessage = new Map<string, string>()
137const turnFor = (list: Turn[], requestId: string, text: string) => {
138 const known = turnOfMessage.get(requestId)
139 if (known) return known
140 const turn = turnContaining(list, text)
141 if (turn) turnOfMessage.set(requestId, turn.id)
142
143 return turn?.id
144}
145
146async function openPane($: EngineInterface) {
147 await update($, isOpen, () => true)
148 await $.ui.open({ id: PANE, title: 'Lens' })
149 await refresh($)
150}
151
152// The inline rows ask for the digests they lack; a render may not start work,
153// so they note the turn here and a timer generates a few at a time.
154const wanted = new Set<string>()
155let inFlight = 0
156let ticker: Timer | undefined
157
158async function fillInline($: EngineInterface) {
159 if (wanted.size === 0 || inFlight >= INLINE_MAX) return
160
161 const list = await read($, turns)
162 const known = await read($, views)
163 const jobs: Promise<void>[] = []
164 for (const turnId of [...wanted].reverse()) {
165 if (inFlight >= INLINE_MAX) break
166 wanted.delete(turnId)
167 const i = list.findIndex(t => t.id === turnId)
168 const turn = list[i]
169 const key = viewKey(turnId)
170 if (turn === undefined || known[key] !== undefined) continue
171 inFlight += 1
172 jobs.push(generate($, key, turnRequest(turn, contextFor(list, known, i))).finally(() => (inFlight -= 1)))
173 }
174 await Promise.all(jobs)
175}
176
177// The inline row's button: show this turn in the pane, opening it if need be.
178async function showTurn($: EngineInterface, id: string) {
179 const list = await read($, turns)
180 await update($, focus, () => (id === list.at(-1)?.id ? null : id))
181 if (await read($, isOpen)) await refresh($)
182 else await openPane($)
183}
184
185// The turns from before the lens loaded (a resumed session, or the mod added
186// mid-session): the last few are rebuilt from the transcript, without times.
187async function backfill($: EngineInterface) {
188 try {
189 const rebuilt = turnsFrom(await $.session.messages())
190 if (rebuilt.length > 0) await update($, turns, known => withBackfill(known, rebuilt, BACKFILL).slice(-50))
191 } catch {
192 // No transcript to read: the lens starts from the next turn.
193 }
194}
195
196const toggleExpanded = ($: EngineInterface, id: string) =>
197 update($, expanded, ids => (ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id].slice(-200)))
198
199export const register: Register = on => {
200 // The turn in flight; a reload mid-turn loses it, which only drops that turn's file list.
201 let prompt = ''
202 let files: string[] = []
203 let tools = 0
204
205 on('session.start', async ($, e, next) => {
206 ticker?.cancel()
207 ticker = $.clock.every(TICK_MS, () => void fillInline($))
208 await $.command.register({
209 name: 'lens',
210 description: 'Sort this session into in progress, needs you and done: /lens [all|progress|blocked|done]',
211 argumentHint: '[purpose]',
212 })
213 await backfill($)
214
215 return next(e)
216 })
217
218 on('command.run', { command: 'lens' }, async ($, e) => {
219 const words: Record<string, Purpose> = { all: 'all', progress: 'progress', blocked: 'blocked', needs: 'blocked', done: 'done' }
220 for (const word of e.args.toLowerCase().split(/\s+/).filter(Boolean)) {
221 const u = words[word]
222 if (u) await update($, purpose, () => u)
223 }
224 if ((await read($, turns)).length === 0) {
225 await update($, isOpen, () => true)
226 await $.ui.open({ id: PANE, title: 'Lens' })
227
228 return { text: 'Lens opened. It fills in after the next answer.' }
229 }
230 await openPane($)
231
232 return { text: 'Lens opened.' }
233 })
234
235 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
236 const mode = modeOf(await read($, inline))
237 if (e.props.isSummary || mode === 'off') return next(e)
238 const list = await read($, turns)
239 const id = turnFor(list, e.requestId, e.props.text)
240 const turn = list.find(t => t.id === id)
241 if (turn === undefined) return next(e) // still streaming, or from before the lens
242
243 const isEnd = endsAnswer(turn, e.props.text)
244 const isExpanded = (await read($, expanded)).includes(turn.id)
245 const p = await read($, purpose)
246 const view = (await read($, views))[viewKey(turn.id)]
247 if (view === undefined) wanted.add(turn.id)
248 const d = view?.digest
249 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
250 const label = PURPOSES.find(x => x.id === p)?.label ?? p
251 const columns = e.viewport?.columns ?? 80
252 const when = timeOf(turn.at, await $.clock.now().catch(() => Date.now()))
253
254 const color = mid(LENS)
255 const openButton = <Button key={`lens-${turn.id}`} label="open in lens ›" onPress={() => showTurn($, turn.id)} />
256 const status = view?.status === 'error' ? `${view.text}; showing the original` : 'sorting…'
257
258 // Callout: the original, then a strip with the headline and the purpose's items
259 // (with `all`, the counts and whatever needs the person).
260 if (mode === 'callout') {
261 if (!isEnd) return next(e)
262 const items = d === undefined ? [] : p === 'all' ? d.blocked.map(x => `✋ ${x}`) : d[p].map(x => `• ${x}`)
263
264 return (
265 <Box flexDirection="column" rowGap={1}>
266 <Markdown text={e.props.text} />
267 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
268 <Box columnGap={2} alignItems="center">
269 <Text bold color={color}>
270 ◆ Lens
271 </Text>
272 {when !== undefined && <Text dimColor>{when}</Text>}
273 <Box flexShrink={1} flexGrow={1}>
274 <Text dimColor={d === undefined}>{d?.headline ?? status}</Text>
275 </Box>
276 {openButton}
277 </Box>
278 {d !== undefined && p === 'all' && <Text dimColor>{tally(d)}</Text>}
279 {d !== undefined && p !== 'all' && items.length === 0 && <Text dimColor>{BUCKETS.find(b => b.id === p)?.empty}</Text>}
280 {items.map((x, i) => (
281 <Text key={`i-${i}`} color={p === 'all' ? mid('amber') : undefined}>
282 {x}
283 </Text>
284 ))}
285 </Box>
286 </Box>
287 )
288 }
289
290 // Replace: the steps between tool calls fold to one dim line each. The answer
291 // is one box in every state (sorting, the lists, the original), so
292 // nothing around it moves as it fills in or flips; only its body changes.
293 if (!isEnd) {
294 return (
295 <Text dimColor wrap="truncate-end">
296 · {e.props.text.trim().split('\n')[0]}
297 </Text>
298 )
299 }
300 const isOriginal = isExpanded || d === undefined
301
302 return (
303 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} rowGap={1}>
304 <Box columnGap={2} alignItems="center">
305 <Text bold color={color}>
306 ◆ Lens · {d === undefined || isExpanded ? 'original' : label}
307 </Text>
308 {when !== undefined && <Text dimColor>{when}</Text>}
309 <Box flexShrink={1} flexGrow={1}>
310 {d === undefined && <Text dimColor>{status}</Text>}
311 </Box>
312 {d !== undefined && (
313 <Button key={`orig-${turn.id}`} label={isExpanded ? 'show lens view' : 'show original'} onPress={() => toggleExpanded($, turn.id)} />
314 )}
315 {openButton}
316 </Box>
317 {/* The pane's purpose buttons, here too: they refilter every reply at once. */}
318 <Box flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
319 {PURPOSES.map(x => (
320 // While the original shows, the lists are not on screen: no choice is
321 // highlighted and the buttons draw dim and do nothing (Button has no disabled).
322 <Button
323 key={`ru-${turn.id}-${x.id}`}
324 variant={!isOriginal && x.id === p ? 'primary' : 'secondary'}
325 dimColor={isOriginal}
326 label={x.label}
327 onPress={() => (isOriginal ? undefined : update($, purpose, () => x.id))}
328 />
329 ))}
330 </Box>
331 {isOriginal ? (
332 <Markdown text={e.props.text} />
333 ) : (
334 <Box flexDirection="column" rowGap={1}>
335 {heading($, e, 'headline', d.headline, 20, 'text', columns)}
336 {sections($, e, d, p, 16, columns)}
337 </Box>
338 )}
339 </Box>
340 )
341 })
342
343 on('ui.close', async ($, e, next) => {
344 if (e.id === PANE) await update($, isOpen, () => false)
345
346 return next(e)
347 })
348
349 on('turn.start', ($, e, next) => {
350 prompt = e.text
351 files = []
352 tools = 0
353
354 return next(e)
355 })
356
357 on('tool.call', ($, e, next) => {
358 if (e.agentId === undefined) {
359 tools += 1
360 const input = ('input' in e ? e.input : undefined) as { file_path?: unknown; notebook_path?: unknown } | undefined
361 const path = input?.file_path ?? input?.notebook_path
362 if (typeof path === 'string' && !files.includes(path)) files.push(path)
363 }
364
365 return next(e)
366 })
367
368 on('turn.complete', async ($, e, next) => {
369 if (e.agentId === undefined && e.reason === 'answer' && e.answer.trim() !== '') {
370 // The turn's assistant text blocks: every row after the last prompt the person typed.
371 // Missing them only costs matching on the turn's earlier blocks, never the turn.
372 let replies: string[] = []
373 try {
374 const rows = await $.session.messages()
375 const start = rows.findLastIndex(r => r.role === 'user' && (r.toolResults ?? []).length === 0)
376 replies = rows
377 .slice(start + 1)
378 .filter(r => r.role === 'assistant' && r.text.trim() !== '' && r.text !== e.answer)
379 .map(r => r.text.slice(0, REPLY_MAX))
380 } catch {
381 replies = []
382 }
383 // The reply's own time, which every view stamps; a clock that fails costs the stamp, not the turn.
384 const at = await $.clock.now().catch(() => undefined)
385 const turn: Turn = { id: e.turnId, prompt, answer: e.answer, files: [...files], tools, replies, at }
386 await update($, turns, list => [...list, turn].slice(-50))
387 if (await read($, isOpen)) $.clock.after(50, () => void refresh($))
388 }
389
390 return next(e)
391 })
392
393 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
394 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
395 const list = await read($, turns)
396 const p = await read($, purpose)
397 const known = await read($, views)
398 const mode = modeOf(await read($, inline))
399 const columns = e.props.bodyColumns
400 const color = mid(LENS)
401
402 const sortBy = (u: Purpose) => () => update($, purpose, () => u)
403 const goTo = (id: string | null) => async () => {
404 await update($, focus, () => id)
405 await refresh($)
406 }
407 const cycleInline = () => update($, inline, () => MODES[(MODES.indexOf(mode) + 1) % MODES.length] ?? 'replace')
408
409 const header = (
410 <Box flexDirection="column">
411 <Box flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
412 {PURPOSES.map(x => (
413 <Button key={`u-${x.id}`} hotkey={x.hotkey} variant={x.id === p ? 'primary' : 'secondary'} label={x.label} onPress={sortBy(x.id)} />
414 ))}
415 <Text dimColor> │ </Text>
416 <Button key="inline" hotkey="n" label={`inline: ${mode}`} onPress={cycleInline} />
417 </Box>
418 </Box>
419 )
420
421 if (list.length === 0) {
422 return (
423 <Box flexDirection="column" gap={1}>
424 {header}
425 <Text dimColor>No answers yet. The lens fills in after Claude's next reply.</Text>
426 </Box>
427 )
428 }
429
430 const f = focusIndex(list, await read($, focus))
431 const now = await $.clock.now().catch(() => Date.now())
432 const shownOriginal = await read($, expanded)
433 const body = (v: View | undefined) =>
434 v?.digest !== undefined ? (
435 <Box flexDirection="column" rowGap={1}>
436 <Text bold>{v.digest.headline}</Text>
437 {sections($, e, v.digest, p, 15, columns)}
438 </Box>
439 ) : (
440 <Text dimColor>{v?.status === 'error' ? v.text : 'sorting…'}</Text>
441 )
442 const overview = known[overviewKey(list.at(-1)?.id ?? '')]
443
444 const rows = list.map((t, i) => {
445 const d = Math.abs(i - f)
446 const view = known[viewKey(t.id)]
447 if (d === 0) {
448 const isOriginal = shownOriginal.includes(t.id)
449
450 return (
451 <Box key={`t-${t.id}`} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} rowGap={1}>
452 <Box columnGap={2} alignItems="center">
453 <Box flexShrink={1} flexGrow={1}>
454 {heading($, e, 'title', `${i + 1}. ${titleOf(t)}`, 18, 'text', columns - 14)}
455 </Box>
456 <Button key="pane-orig" hotkey="o" label={isOriginal ? 'lens view' : 'original'} onPress={() => toggleExpanded($, t.id)} />
457 </Box>
458 {(t.at !== undefined || t.files.length > 0) && (
459 <Text dimColor wrap="truncate-end">
460 {[timeOf(t.at, now), ...t.files].filter(Boolean).join(' · ')}
461 </Text>
462 )}
463 {isOriginal ? <Markdown text={t.answer} /> : body(view)}
464 </Box>
465 )
466 }
467 if (d <= NEAR) {
468 const asks = view?.digest?.blocked.length ?? 0
469 const when = timeOf(t.at, now)
470
471 return (
472 <Button
473 key={`t-${t.id}`}
474 plain
475 dimColor
476 label={`${i + 1} ${when !== undefined ? `${when} ` : ''}${view?.status === 'done' ? view.text : titleOf(t)}${asks > 0 ? ` ✋ ${asks}` : ''}`}
477 onPress={goTo(i === list.length - 1 ? null : t.id)}
478 />
479 )
480 }
481
482 return null
483 })
484
485 const older = Math.max(0, f - NEAR)
486 const newer = Math.max(0, list.length - 1 - f - NEAR)
487
488 return (
489 <Box flexDirection="column" gap={1}>
490 {header}
491 <Box flexDirection="column" rowGap={1}>
492 {heading($, e, 'session', 'Session so far', 20, LENS, columns)}
493 {list.at(-1)?.at !== undefined && <Text dimColor>as of {timeOf(list.at(-1)?.at, now)}</Text>}
494 {body(overview)}
495 </Box>
496 <Box flexDirection="column">
497 {older > 0 && <Text dimColor>… {older} earlier</Text>}
498 {rows}
499 {newer > 0 && <Text dimColor>… {newer} later</Text>}
500 </Box>
501 <Box columnGap={1}>
502 {f > 0 && <Button key="prev" hotkey="k" label="‹ older" onPress={goTo(list[f - 1]?.id ?? null)} />}
503 {f < list.length - 1 && (
504 <Button key="next" hotkey="j" label="newer ›" onPress={goTo(f + 1 === list.length - 1 ? null : (list[f + 1]?.id ?? null))} />
505 )}
506 {f < list.length - 1 && <Button key="latest" hotkey="l" label="latest »" onPress={goTo(null)} />}
507 </Box>
508 </Box>
509 )
510 })
511}
512hooks/heading.ts 65 lines1// Headings drawn as SVG text, the one way a plugin sets a type size on the
2// desktop: its Text and Markdown take the app's body size and its small
3// heading styles. The SVG is drawn as an image, so it carries its own colours
4// for light and dark and wraps its own lines.
5
6// Four hues, none shared: one per list, whose colour means the same everywhere,
7// and the lens's own for its frames. `mid` reads on either theme and colours
8// borders and text; `light` and `dark` are the headings' shades.
9export const PALETTE = {
10 text: { mid: '#8a8780', light: '#1f1e1d', dark: '#f0eee6' },
11 // the lists
12 blue: { mid: '#3b82f6', light: '#1d4ed8', dark: '#93b4fd' },
13 amber: { mid: '#f59e0b', light: '#b45309', dark: '#fcd34d' },
14 green: { mid: '#22c55e', light: '#15803d', dark: '#86efac' },
15 // the lens
16 violet: { mid: '#a855f7', light: '#7e22ce', dark: '#d8b4fe' },
17} as const
18
19export type Hue = keyof typeof PALETTE
20
21export const LENS: Hue = 'violet'
22
23export const mid = (hue: Hue) => PALETTE[hue].mid
24
25const escape = (text: string) =>
26 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
27
28// Greedy word wrap on an estimate of the glyph width: system-ui runs about
29// 0.55em per character at these weights.
30function wrap(text: string, perLine: number) {
31 const lines: string[] = []
32 let line = ''
33 for (const word of text.split(/\s+/).filter(Boolean)) {
34 if (line !== '' && line.length + 1 + word.length > perLine) {
35 lines.push(line)
36 line = word
37 } else {
38 line = line === '' ? word : `${line} ${word}`
39 }
40 }
41 if (line !== '') lines.push(line)
42
43 return lines.length ? lines : ['']
44}
45
46export function headingSvg(text: string, size: number, hue: Hue, maxWidth: number) {
47 const perLine = Math.max(8, Math.floor(maxWidth / (size * 0.55)))
48 const lines = wrap(text, perLine)
49 const lineHeight = Math.round(size * 1.3)
50 const width = Math.min(maxWidth, Math.ceil(Math.max(...lines.map(l => l.length)) * size * 0.58) + 4)
51 const height = lines.length * lineHeight + Math.round(size * 0.35)
52 const { light, dark } = PALETTE[hue]
53 const spans = lines.map((l, i) => `<tspan x="0" y="${Math.round(size + i * lineHeight)}">${escape(l)}</tspan>`).join('')
54
55 return {
56 width,
57 height,
58 source:
59 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
60 `<style>text{fill:${light};font:600 ${size}px system-ui,-apple-system,"Segoe UI",sans-serif}` +
61 `@media (prefers-color-scheme:dark){text{fill:${dark}}}</style>` +
62 `<text>${spans}</text></svg>`,
63 }
64}
65hooks/backfill.ts 89 lines1import type { SessionMessage } from 'claude-code'
2
3import type { Turn } from '../types'
4
5const REPLY_MAX = 8000 // characters kept per reply block for matching
6
7// A prompt the person typed: a user row with text and no tool results, and not
8// one of the rows the engine writes in their name (command records, reminders).
9const isPrompt = (row: SessionMessage) =>
10 row.role === 'user' &&
11 (row.toolResults ?? []).length === 0 &&
12 row.text.trim() !== '' &&
13 !/^\s*<(local-command|command-|system-reminder|task-notification)/.test(row.text)
14
15// A stable id from the turn's text, so a turn rebuilt twice is the same turn.
16const hash = (text: string) => {
17 let h = 5381
18 for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
19
20 return (h >>> 0).toString(36)
21}
22
23/**
24 * The turns a transcript holds, rebuilt from its rows: each typed prompt and
25 * the assistant text that followed it, the last block being the answer. Rows
26 * carry no times, so a rebuilt turn has none.
27 */
28export function turnsFrom(rows: readonly SessionMessage[]): Turn[] {
29 const turns: Turn[] = []
30 let open: { prompt: string; texts: string[]; files: string[]; tools: number } | undefined
31 const close = () => {
32 const answer = open?.texts.at(-1)
33 if (open === undefined || answer === undefined) return
34 turns.push({
35 id: `backfill:${hash(`${open.prompt}\n${answer.slice(0, 500)}`)}`,
36 prompt: open.prompt,
37 answer,
38 files: open.files,
39 tools: open.tools,
40 replies: open.texts.slice(0, -1).map(t => t.slice(0, REPLY_MAX)),
41 })
42 }
43
44 for (const row of rows) {
45 if (isPrompt(row)) {
46 close()
47 open = { prompt: row.text, texts: [], files: [], tools: 0 }
48 continue
49 }
50 if (open === undefined || row.role !== 'assistant') continue
51 if (row.text.trim() !== '') open.texts.push(row.text)
52 for (const use of row.toolUses) {
53 if (use.agentId !== undefined) continue
54 open.tools += 1
55 const path = use.input.file_path ?? use.input.notebook_path
56 if (typeof path === 'string' && !open.files.includes(path)) open.files.push(path)
57 }
58 }
59 close()
60
61 return turns
62}
63
64/**
65 * The turns the lens already knows, with up to `limit` of the transcript's
66 * earlier ones filled in around them, in transcript order. A known turn keeps
67 * its id, time and files; it is recognised by its answer.
68 */
69export function withBackfill(known: Turn[], rebuilt: Turn[], limit: number): Turn[] {
70 const byAnswer = new Map(known.map(t => [t.answer.trim(), t]))
71 const missing = rebuilt.filter(t => !byAnswer.has(t.answer.trim()))
72 const added = new Set(limit > 0 ? missing.slice(-limit) : [])
73
74 const merged: Turn[] = []
75 for (const t of rebuilt) {
76 const kept = byAnswer.get(t.answer.trim())
77 if (kept !== undefined) {
78 merged.push(kept)
79 byAnswer.delete(t.answer.trim())
80 } else if (added.has(t)) {
81 merged.push(t)
82 }
83 }
84
85 // Known turns the transcript no longer shows were compacted away, so they are
86 // older than anything it holds: they stay, ahead of it.
87 return [...byAnswer.values(), ...merged]
88}
89hooks/match.ts 23 lines1import type { Turn } from '../types'
2
3// Drawn text and markdown source differ in punctuation and spacing, so both
4// sides are reduced to lowercase words before matching.
5export const words = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
6
7// A message row carries no turn id, so find the turn whose text
8// holds its opening; the latest wins, as a repeated phrase most likely means it.
9export const turnContaining = (list: Turn[], text: string) => {
10 const probe = words(text).slice(0, 120).trim()
11 if (probe.length < 8) return undefined
12
13 // Turns kept before `replies` existed lack it and match on their answer alone.
14 return list.findLast(t => [...(t.replies ?? []), t.answer].some(r => words(r).includes(probe)))
15}
16
17// True for the block that ends the turn's answer: the one a summary row goes under.
18export const endsAnswer = (turn: Turn, text: string) => {
19 const tail = words(text).slice(-120)
20
21 return tail.length > 0 && words(turn.answer).endsWith(tail)
22}
23hooks/time.ts 10 lines1// When the original reply was written, in the person's local time, with the
2// day once it is not today. Absent for turns kept before times were recorded.
3export const timeOf = (at: number | undefined, now: number) => {
4 if (at === undefined) return undefined
5 const d = new Date(at)
6 const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
7
8 return new Date(now).toDateString() === d.toDateString() ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${time}`
9}
10hooks/digest.ts 87 lines1import type { Bucket, Digest, Purpose, Turn } from '../types'
2import type { Hue } from './heading'
3
4export const PURPOSES: { id: Purpose; label: string; hotkey: string }[] = [
5 { id: 'all', label: 'all', hotkey: 'a' },
6 { id: 'progress', label: 'in progress', hotkey: '1' },
7 { id: 'blocked', label: 'needs you', hotkey: '2' },
8 { id: 'done', label: 'done', hotkey: '3' },
9]
10
11export const BUCKETS: { id: Bucket; label: string; mark: string; hue: Hue; empty: string }[] = [
12 { id: 'progress', label: 'In progress', mark: '◐', hue: 'blue', empty: 'nothing in progress' },
13 { id: 'blocked', label: 'Needs you', mark: '✋', hue: 'amber', empty: 'nothing waiting on you' },
14 { id: 'done', label: 'Done', mark: '✓', hue: 'green', empty: 'nothing finished' },
15]
16
17// Who every digest is written for: someone following along, not doing the work.
18const READER =
19 'someone following the work who wants to know where it stands: what is underway, what needs their input, and what got finished'
20
21// Keeps the head and the tail: an answer's asks of the person usually close it.
22const clip = (text: string, max: number) =>
23 text.length > max ? `${text.slice(0, max * 0.6)}\n…[middle cut]…\n${text.slice(-max * 0.4)}` : text
24
25const SYSTEM =
26 `You sort an AI coding assistant's work for one reader: ${READER}. ` +
27 'Use only facts present in the material; never invent progress, asks or results.'
28
29const FORMAT =
30 'Reply with JSON only, no code fence: {"headline": string, "progress": string[], "blocked": string[], "done": string[]}\n' +
31 '- headline: at most 15 words, what this means for the reader.\n' +
32 '- progress: work started but not finished, or next steps the assistant said it would take.\n' +
33 '- blocked: anything waiting on the user: questions asked, choices offered, approvals or actions requested.\n' +
34 '- done: things completed: changes made, checks run, questions answered.\n' +
35 'Each item at most 15 words, written for this reader, most important first; at most 5 per list. ' +
36 'Leave out what this reader would not care about; empty lists are fine.'
37
38export function turnRequest(turn: Turn, context: string[]) {
39 const before = context.length ? `Earlier in the session (oldest first):\n${context.map(c => `- ${c}`).join('\n')}\n\n` : ''
40 const files = turn.files.length ? `Files touched: ${turn.files.join(', ')}\n` : ''
41
42 return {
43 system: SYSTEM,
44 maxTokens: 700,
45 prompt:
46 `${before}The user asked:\n<ask>\n${clip(turn.prompt, 4000)}\n</ask>\n\n` +
47 `${files}Tool calls: ${turn.tools}\n\n` +
48 `The assistant answered:\n<answer>\n${clip(turn.answer, 12000)}\n</answer>\n\n` +
49 `Sort this one turn. "done" is only what this turn completed.\n${FORMAT}`,
50 }
51}
52
53export function overviewRequest(turns: Turn[]) {
54 const lines = turns.map((t, i) => `${i + 1}. Asked: ${clip(t.prompt, 300)}\n Answered: ${clip(t.answer, 900)}`)
55
56 return {
57 system: SYSTEM,
58 maxTokens: 700,
59 prompt:
60 `The session so far, turn by turn:\n${lines.join('\n')}\n\n` +
61 'Sort where the session stands now. "progress" and "blocked" are what is still open after the latest turn: ' +
62 `drop anything a later turn finished or the user answered. "done" is what was finished across the session.\n${FORMAT}`,
63 }
64}
65
66const strings = (v: unknown) =>
67 Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').slice(0, 5) : []
68
69export function parseDigest(text: string): Digest | undefined {
70 const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
71 try {
72 const raw = JSON.parse(body) as Record<string, unknown>
73 const headline = typeof raw.headline === 'string' ? raw.headline.trim() : ''
74 if (headline === '') return undefined
75
76 return { headline, progress: strings(raw.progress), blocked: strings(raw.blocked), done: strings(raw.done) }
77 } catch {
78 return undefined
79 }
80}
81
82export const titleOf = (turn: Turn) => {
83 const line = turn.prompt.split('\n').find(l => l.trim()) ?? '(no prompt)'
84
85 return line.length > 80 ? `${line.slice(0, 79)}…` : line
86}
87types/index.d.ts 37 lines1export type Bucket = 'progress' | 'blocked' | 'done'
2
3export type Purpose = 'all' | Bucket
4
5export type InlineMode = 'off' | 'callout' | 'replace'
6
7export type Turn = {
8 id: string
9 prompt: string
10 answer: string
11 files: string[]
12 tools: number
13 /** Each assistant text block of this turn, to find the turn a transcript message belongs to. */
14 replies: string[]
15 /** When Claude finished the reply, in ms since the epoch; absent on turns kept before it was recorded. */
16 at?: number
17}
18
19/** A turn (or the session) sorted: a headline and what is in progress, needs the person, or got done. */
20export type Digest = { headline: string } & Record<Bucket, string[]>
21
22export type View = { status: 'pending' | 'done' | 'error'; text: string; digest?: Digest }
23
24declare module 'claude-code' {
25 interface PluginState {
26 lens: {
27 turns: Turn[]
28 purpose: Purpose
29 focus: string | null
30 views: Record<string, View>
31 isOpen: boolean
32 inline: InlineMode
33 expanded: string[]
34 }
35 }
36}
37