SLOPSHOPPER

lens

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

newpanerowsguardcommandmodel
A shopper browsing a rack in a slop shop
README

lens

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.

What it does

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:

  • In progress: work started but not finished, or next steps Claude said it would take.
  • Needs you: questions Claude asked, choices it offered, approvals or actions it requested.
  • Done: what this turn finished.

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.

In the transcript

The inline button cycles three modes. Replace is the default:

  • off: replies draw as usual.
  • callout: a strip under each answer shows the headline, the list counts and anything that needs you, plus open in lens ›.
  • replace: each answer becomes one box. It holds the headline and the lists, the purpose buttons, 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.

In the side pane

/lens [all|progress|blocked|done] opens a pane with:

  • Session so far: what's still open after the latest turn, and what got done across the session.
  • The focused turn: the turn in full, with original to swap in Claude's reply.
  • Nearby turns: as headlines, with ✋ 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.

Notes

  • Display only. Replace mode changes what you see, never what Claude reads or what the transcript file stores.
  • Cost. One Haiku call per turn when you first view it, at most three at a time, plus one for the session overview after each new turn while the pane is open.
  • Earlier turns. When the lens loads (a resumed session, or the mod added mid-session), it rebuilds the last 10 turns already in the transcript. Those turns have no timestamp, because the conversation API doesn't expose message times. Older turns, and the reply being streamed, draw as usual.

Limits of the mod API this works around

These come from Claude Code's mod API (2.1.293, early access) and shaped the design. Some may change in later releases.

  • No type size for text. 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:
  • SVG headings can't be selected or copied.
  • They wrap lines by estimating character width, so a line can break a little early or late.
  • They pick light or dark colours from the system setting, which can differ from the app's theme.
  • Colours have no light/dark pair outside SVG. 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.
  • Rewriting a reply means redrawing it. To add the callout or replace the answer, the mod draws the reply's text itself with Markdown. Spacing, the reply's opening bullet and code styling can look slightly different from replies the app draws.
  • Replies carry no turn id. A reply is drawn with a message id, and the hook that would tie rows to turns (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.
  • The desktop reports neither scroll position nor selection. In the desktop Code tab, replies are drawn without 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.
  • Render hooks can't start work. A drawing may read state but not write it or call the model, so replies note which digests they're missing and a timer fetches them, three at a time.
  • Hotkeys only show on plain buttons. Buttons drawn as real buttons hide their hotkey, so the pane's shortcuts work but aren't labelled.

Develop

claude plugin validate mods/lens
claude plugin test mods/lens
Source 7 files
hooks/register.tsx 512 lines
1import { 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}
512
hooks/heading.ts 65 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
65
hooks/backfill.ts 89 lines
1import 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}
89
hooks/match.ts 23 lines
1import 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}
23
hooks/time.ts 10 lines
1// 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}
10
hooks/digest.ts 87 lines
1import 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}
87
types/index.d.ts 37 lines
1export 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