SLOPSHOPPER

Visible Compact

Shows the conversation before and after /compact side by side in a pane; /show-last-compact reopens it

newpanecommandtoast
v0.1.0MITupdated 2026-10-05nvsravank/visible-compact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · visible-compact
│ ┃ visible-compact ✕ › fix the failing auth test and add an audit log call │ ┃ No compaction recorded for this session yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /show-last-compact │ ⎿ visible-compact: No compaction recorded for this session yet. Ru │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · visible-compact
No compaction recorded for this session yet.
README

visible-compact

A Claude Code mod that shows what a compaction actually did. The Your compaction history pane puts the compacted result (after) next to the conversation that was sent to be compacted (before).

What it does

  • On /compact it records what went in and what came back, then opens Your compaction history on it.
  • Auto-compactions and subagent compactions are recorded too. They don't open the pane, but they show up in its picker.
  • /show-last-compact reopens the pane on the newest compaction.

The pane

  • Picker: every compaction in this session, newest first, labelled with time, main or subagent, and trigger.
  • View toggle (top right): ◧ After, ◫ Both, ◨ Before. The buttons sit in the same order as the columns: after on the left, before on the right. When the pane is narrower than 80 columns, "both" stacks after above before, with extra space above Before.
  • Theme (☾/☀): plugins aren't told the app's light or dark theme, so pick it here. The choice is remembered across sessions.
  • Tokens line: "Tokens reduced to X from Y", colored to match the After and Before headers, with how long the compaction took on the right.
  • Instructions: the text typed after /compact, if any. The engine's own summary prompt isn't available to plugins.
  • Chunks: each message is split into chunks of at most 4,000 characters. Splits fall at paragraph breaks where possible, then at line breaks, then at spaces. Each chunk is labelled with its role, message number and part, and drawn as Markdown, the way an assistant reply is. Tool calls are shown with their inputs, and tool results in full, both as code blocks so their line breaks and indents are kept. A code block is never split across chunks unless it is longer than one chunk; then each part repeats its fence.
  • Paging: each column shows 10 chunks per page. ‹ Previous and Next › sit in the column header and again at its bottom.

Storage

Each compaction is saved under <location>/visible-compact/sessions/<session id>/. That folder holds an index.json plus the chunk files, at most 100 chunks per file. Because the folder is named after the session id, restarting Claude Code or using --resume keeps your compactions. /clear starts a new session id with nothing recorded.

The Where compactions are saved setting (storage) chooses the location:

  • temp (default): the OS temp folder (TMPDIR, TEMP or TMP, else /tmp). The OS cleans it up for you. Linux usually wipes /tmp on reboot. macOS clears it on reboot and removes files left unused for about 3 days. Windows Storage Sense may clear it. Resuming an old session can therefore show an empty picker.
  • home: ~/.claude/visible-compact. Compactions are kept until you delete them, at roughly 4–5 MB for a 1M-token compaction.

Changing the setting doesn't move existing compactions. The picker lists only what's in the current location.

Type temp or home for this setting. Any other value is treated as temp.

Install (local)

claude --plugin-dir /path/to/visible-compact

For the Desktop app, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (separate several folders with :), then restart the app.

What it runs and changes

It sends nothing anywhere. The mod makes no network requests and runs no programs. It reads your conversation only when it is compacted, and shows it in its own pane.

What it reads:

  • At each compaction: the messages sent to be compacted, the summary that came back, the token counts, how long it took, and any instructions you typed after /compact.
  • The environment variables TMPDIR, TEMP and TMP (or HOME and USERPROFILE with the home setting), only to find the folder it saves to. It reads no other variables, and no credentials.
  • The names of running subagents, to label their compactions in the picker.

What it writes: a copy of each compaction, as JSON, under <location>/visible-compact/sessions/<session id>/ (see Storage). It writes nothing else on disk. It remembers your light/dark choice in Claude Code's plugin storage.

  • These files are plain data that only this mod reads back, to draw its pane. No tool runs them or reads them as settings or instructions.
  • It never writes a build, start-up, settings or instructions file.
  • The path is built at run time, from the temp or home folder and the session id, so it can't be written as fixed text.

Files it ships: the only image is .claude-plugin/icon.png, the icon for the plugin directory. Nothing in the mod loads or runs it.

How it hooks into Claude Code:

  • It adds the /show-last-compact command and answers only that command.
  • On session.compact it lets the compaction run unchanged, then records the result. It never changes, skips or delays a compaction.
  • It hooks session.start and session.end only to load and reset its own list, and passes both through unchanged.
  • It doesn't watch tool calls or prompts, and never approves, blocks or alters anything.

To remove what it leaves behind, delete the visible-compact folder in your temp folder, or ~/.claude/visible-compact if you chose home.

Platforms

Tested on macOS, in the terminal and the Desktop app. It is written to work on Linux and Windows too, but it has not been tested on Windows yet. If you try it there, please open an issue whether it works or not.

Feedback

Bug reports and feature requests are welcome as issues. This project doesn't accept pull requests; see CONTRIBUTING.md. Report security problems privately, as described in SECURITY.md.

Develop

claude plugin validate .
claude plugin test .

Layout:

  • .claude-plugin/plugin.json: manifest
  • .claude-plugin/icon.png: directory icon
  • hooks/register.tsx: hooks, storage and the pane
  • hooks/chunk.ts: message flattening and paragraph-aware chunking
  • types/index.d.ts: shared types and the $.state contract
  • tests/: unit tests for the chunker

The Claude Code function-hooks API is in early access and may change between releases.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 387 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Chunk, CompactMeta, Side, Theme, ViewMode } from '../types'
5import { chunkMessages } from './chunk'
6
7/** Chunks per file: 100 × 4,000 chars stays well under the 4 MiB a file read or write allows. */
8export const FILE_CHUNKS = 100
9
10type Storage = 'temp' | 'home'
11// From the plugin's settings; a change there reloads the module, so register sets it again.
12let storage: Storage = 'temp'
13
14const baseDir = async ($: EngineInterface) => {
15  if (storage === 'home') {
16    const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '.'
17    return `${home}/.claude/visible-compact`
18  }
19  const temp =
20    (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? '/tmp'
21  return `${temp.replace(/[\\/]$/, '')}/visible-compact`
22}
23
24const sessionDir = async ($: EngineInterface) => `${await baseDir($)}/sessions/${await $.session.id()}`
25
26const chunkFile = (dir: string, id: string, side: Side, file: number) =>
27  `${dir}/${id}.${side}.${file}.json`
28
29export const readIndex = async ($: EngineInterface): Promise<CompactMeta[]> => {
30  const path = `${await sessionDir($)}/index.json`
31  if (!(await $.fs.exists(path))) return []
32  try {
33    return JSON.parse(await $.fs.read(path)) as CompactMeta[]
34  } catch {
35    return []
36  }
37}
38
39export const saveCompaction = async (
40  $: EngineInterface,
41  meta: CompactMeta,
42  chunks: Record<Side, Chunk[]>,
43): Promise<CompactMeta[]> => {
44  const dir = await sessionDir($)
45  for (const side of ['before', 'after'] as const) {
46    const list = chunks[side]
47    for (let file = 0; file * FILE_CHUNKS < list.length; file++)
48      await $.fs.write(
49        chunkFile(dir, meta.id, side, file),
50        JSON.stringify(list.slice(file * FILE_CHUNKS, (file + 1) * FILE_CHUNKS)),
51      )
52  }
53  const index = [...(await readIndex($)), meta]
54  await $.fs.write(`${dir}/index.json`, JSON.stringify(index))
55
56  return index
57}
58
59// A few files' worth of chunks, so paging back and forth doesn't re-read.
60const cache = new Map<string, Chunk[]>()
61
62export const readChunks = async (
63  $: EngineInterface,
64  id: string,
65  side: Side,
66  from: number,
67  count: number,
68): Promise<Chunk[]> => {
69  const dir = await sessionDir($)
70  const out: Chunk[] = []
71  for (let at = from; at < from + count; ) {
72    const file = Math.floor(at / FILE_CHUNKS)
73    const path = chunkFile(dir, id, side, file)
74    let list = cache.get(path)
75    if (list === undefined) {
76      if (!(await $.fs.exists(path))) break
77      list = JSON.parse(await $.fs.read(path)) as Chunk[]
78      cache.set(path, list)
79      if (cache.size > 6) cache.delete(cache.keys().next().value!)
80    }
81    const offset = at - file * FILE_CHUNKS
82    const taken = list.slice(offset, offset + (from + count - at))
83    if (taken.length === 0) break
84    out.push(...taken)
85    at += taken.length
86  }
87
88  return out
89}
90
91const PANE = 'visible-compact'
92const TITLE = 'Your compaction history'
93const COMMAND = 'show-last-compact'
94// Below this many body columns the two sides stack instead of sitting side by side.
95const SIDE_BY_SIDE_MIN = 80
96// Chunks drawn per page of each column; at 4,000 chars a chunk, a page is at most 40k chars.
97const PAGE_CHUNKS = 10
98// Plugins aren't told the app's theme, so the person picks with the ☀/☾ toggle (as in
99// multirepo-diff-mod). Each side's color heads its column and marks its number in the tokens
100// line. Solid hex only: the desktop did not draw rgba().
101type Palette = Record<Side | 'user' | 'assistant', string>
102const PALETTES: Record<Theme, Palette> = {
103  light: { after: '#1a7f37', before: '#9a6700', user: '#0969da', assistant: '#8250df' },
104  dark: { after: '#7ee787', before: '#e3b341', user: '#79c0ff', assistant: '#d2a8ff' },
105}
106const THEME_STORE_NAME = 'theme'
107
108const index = atom({ plugin: 'visible-compact', key: 'index' } as const, [])
109const selected = atom({ plugin: 'visible-compact', key: 'selected' } as const, null)
110const view = atom({ plugin: 'visible-compact', key: 'view' } as const, 'both')
111const pages = atom({ plugin: 'visible-compact', key: 'pages' } as const, { before: 0, after: 0 })
112const theme = atom({ plugin: 'visible-compact', key: 'theme' } as const, 'light')
113
114const toggleTheme = async ($: EngineInterface) => {
115  const next = await update($, theme, current => (current === 'light' ? 'dark' : 'light'))
116  await $.store.set(THEME_STORE_NAME, next)
117}
118
119const tokens = (n?: number) =>
120  n === undefined ? '?' : n >= 1000 ? `${Math.round(n / 1000).toLocaleString()}k` : String(n)
121
122const clock = (at: number) => new Date(at).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })
123
124const who = (meta: CompactMeta) => (meta.agentId === undefined ? 'main' : (meta.agentLabel ?? `agent ${meta.agentId}`))
125
126const optionLabel = (meta: CompactMeta) => `${clock(meta.at)} · ${who(meta)} · ${meta.trigger}`
127
128const agentLabel = async ($: EngineInterface, agentId: string) => {
129  const agent = (await $.agent.list()).find(a => a.id === agentId)
130
131  return agent === undefined ? undefined : `${agent.type}: ${agent.description}`
132}
133
134const show = async ($: EngineInterface, id: string | null) => {
135  await update($, selected, () => id)
136  await update($, pages, () => ({ before: 0, after: 0 }))
137}
138
139export const register: Register = (on, options) => {
140  storage = options.storage === 'home' ? 'home' : 'temp'
141
142  on('session.start', async ($, e, next) => {
143    await $.command.register({
144      name: COMMAND,
145      description: 'Reopen "Your compaction history": what was compacted, side by side with what came back',
146    })
147    // Compactions live on disk under the session id, so a restart or --resume picks them up again.
148    const saved = await readIndex($)
149    await update($, index, () => saved)
150    // Restore the light/dark pick saved in an earlier session.
151    const savedTheme = await $.store.get(THEME_STORE_NAME)
152    if (savedTheme === 'light' || savedTheme === 'dark') await update($, theme, () => savedTheme)
153
154    return next(e)
155  })
156
157  // /clear goes on under a new session id, with nothing compacted yet.
158  on('session.end', async ($, e, next) => {
159    if (e.reason === 'clear') {
160      await update($, index, () => [])
161      await show($, null)
162    }
163
164    return next(e)
165  })
166
167  on('session.compact', async ($, e, next) => {
168    const started = Date.now()
169    const result = await next(e)
170    if (e.trigger === 'precompute' || result.skip !== undefined) return result
171
172    const before = chunkMessages(e.messages)
173    const after = chunkMessages(result.messages)
174    const meta: CompactMeta = {
175      id: `${started.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
176      at: started,
177      durationMs: Date.now() - started,
178      trigger: e.trigger,
179      agentId: e.agentId,
180      agentLabel: e.agentId === undefined ? undefined : await agentLabel($, e.agentId),
181      instructions: e.instructions,
182      tokensBefore: result.tokensBefore,
183      tokensAfter: result.tokensAfter,
184      usage: result.usage,
185      messages: { before: e.messages.length, after: result.messages.length },
186      chunks: { before: before.length, after: after.length },
187    }
188    try {
189      const saved = await saveCompaction($, meta, { before, after })
190      await update($, index, () => saved)
191    } catch (error) {
192      $.ui.toast(`visible-compact: could not save the compaction (${String(error)})`)
193
194      return result
195    }
196    // Your own /compact opens the pane on it; auto and subagent compactions just join the picker.
197    if (e.trigger === 'manual' && e.agentId === undefined) {
198      await show($, null)
199      void $.ui.open({ id: PANE, title: TITLE })
200    }
201
202    return result
203  })
204
205  on('command.run', { command: COMMAND }, async $ => {
206    if ((await read($, index)).length === 0)
207      return { text: 'No compaction recorded for this session yet. Run /compact first.' }
208    await show($, null)
209    await $.ui.open({ id: PANE, title: TITLE })
210
211    return { text: 'Opened "Your compaction history".' }
212  })
213
214  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
215    const ui = $.ui.resolve(e)
216    const { Box, Text, Button, Markdown } = ui
217    const all = await read($, index)
218    if (all.length === 0) return <Text dimColor>No compaction recorded for this session yet.</Text>
219
220    const pick = await read($, selected)
221    const meta = all.find(m => m.id === pick) ?? all[all.length - 1]!
222    const mode = await read($, view)
223    const page = await read($, pages)
224    const columns = e.props.bodyColumns
225    const colorTheme = await read($, theme)
226    const colors = PALETTES[colorTheme]
227
228    const setView = (next: ViewMode) => () => void update($, view, () => next)
229    // Laid out as the columns are: after on the left, before on the right.
230    // A Button's label is one plain string, so the selected mode is drawn as bold
231    // text in its column's color instead; pressing it would change nothing anyway.
232    const modes = [
233      { mode: 'after' as const, label: '◧ After', selected: <Text bold color={colors.after}>◧ After</Text> },
234      {
235        mode: 'both' as const,
236        label: '◫ Both',
237        selected: (
238          <Text>
239            <Text bold color={colors.after}>◫ Bo</Text>
240            <Text bold color={colors.before}>th</Text>
241          </Text>
242        ),
243      },
244      { mode: 'before' as const, label: '◨ Before', selected: <Text bold color={colors.before}>◨ Before</Text> },
245    ]
246    const toggle = (
247      <Box gap={1}>
248        {modes.map(m =>
249          m.mode === mode ? (
250            <Box key={m.mode}>{m.selected}</Box>
251          ) : (
252            <Button key={m.mode} plain dimColor onPress={setView(m.mode)}>
253              {m.label}
254            </Button>
255          ),
256        )}
257        {/* Shows the mode a press switches to: ☾ while light, ☀ while dark. */}
258        <Button plain onPress={() => void toggleTheme($)}>
259          {colorTheme === 'light' ? '☾' : '☀'}
260        </Button>
261      </Box>
262    )
263
264    const newestFirst = [...all].reverse()
265    const options = newestFirst.map((m, i) => ({
266      value: m.id,
267      label: `${i === 0 ? '(latest) ' : ''}${optionLabel(m)}`,
268    }))
269    const pickId = (id: string) => void show($, id === newestFirst[0]?.id ? null : id)
270    // Mobile draws no Select: there the latest few are buttons instead.
271    const picker =
272      'Select' in ui ? (
273        <ui.Select key="compaction" label="Compaction: " value={meta.id} options={options} onSelect={pickId} />
274      ) : (
275        <Box flexDirection="column">
276          {options.slice(0, 5).map(o => (
277            <Button key={o.value} plain dimColor={o.value !== meta.id} onPress={() => pickId(o.value)}>
278              {o.label}
279            </Button>
280          ))}
281        </Box>
282      )
283
284    // When, who and what triggered it are in the picker; message counts head each column.
285    const stats = (
286      <Box justifyContent="space-between">
287        {/* Only the words are dim, so the bold numbers don't inherit it. */}
288        <Text>
289          <Text dimColor>Tokens reduced to </Text>
290          <Text bold color={colors.after}>
291            {tokens(meta.tokensAfter)}
292          </Text>
293          <Text dimColor> from </Text>
294          <Text bold color={colors.before}>
295            {tokens(meta.tokensBefore)}
296          </Text>
297        </Text>
298        <Text dimColor>Took: {(meta.durationMs / 1000).toFixed(1)}s</Text>
299      </Box>
300    )
301
302    // `gap` is the blank rows above the column, so a stacked Before stands apart from After's end.
303    const column = async (side: Side, width: number, gap = 1) => {
304      const total = meta.chunks[side]
305      const pageCount = Math.max(1, Math.ceil(total / PAGE_CHUNKS))
306      const at = Math.min(page[side], pageCount - 1)
307      const chunks = await readChunks($, meta.id, side, at * PAGE_CHUNKS, PAGE_CHUNKS)
308      const turn = (by: number) => () =>
309        void update($, pages, p => ({ ...p, [side]: Math.max(0, Math.min(pageCount - 1, at + by)) }))
310
311      const pager = pageCount > 1 && (
312        <Box gap={1}>
313          <Button plain dimColor={at === 0} onPress={turn(-1)}>
314            ‹ Previous
315          </Button>
316          <Text dimColor>
317            {at + 1}/{pageCount}
318          </Text>
319          <Button plain dimColor={at === pageCount - 1} onPress={turn(1)}>
320            Next ›
321          </Button>
322        </Box>
323      )
324
325      return (
326        <Box flexDirection="column" width={width} paddingRight={1} marginTop={gap}>
327          <Box justifyContent="space-between">
328            <Text bold underline color={colors[side]}>
329              {side === 'after' ? 'After' : 'Before'} ({meta.messages[side]} msgs, {total} chunks)
330            </Text>
331            {pager}
332          </Box>
333          {chunks.length === 0 && <Text dimColor>(empty)</Text>}
334          {chunks.map((chunk: Chunk) => (
335            <Box flexDirection="column" marginTop={1}>
336              <Text dimColor>
337                <Text bold color={chunk.role === 'user' ? colors.user : colors.assistant}>
338                  {chunk.role}
339                </Text>{' '}
340                · msg {chunk.m + 1}
341                {chunk.n > 1 ? ` · part ${chunk.i + 1}/${chunk.n}` : ''}
342              </Text>
343              <Markdown text={chunk.text} />
344            </Box>
345          ))}
346          {pager && (
347            <Box marginTop={1} justifyContent="flex-end">
348              {pager}
349            </Box>
350          )}
351        </Box>
352      )
353    }
354
355    const isSideBySide = mode === 'both' && columns >= SIDE_BY_SIDE_MIN
356    const half = isSideBySide ? Math.floor(columns / 2) : columns
357    const body =
358      mode === 'after' ? (
359        await column('after', columns)
360      ) : mode === 'before' ? (
361        await column('before', columns)
362      ) : (
363        <Box flexDirection={isSideBySide ? 'row' : 'column'}>
364          {await column('after', half)}
365          {await column('before', half, isSideBySide ? 1 : 3)}
366        </Box>
367      )
368
369    return (
370      <Box flexDirection="column">
371        <Box justifyContent="space-between">
372          {picker}
373          {toggle}
374        </Box>
375        {stats}
376        <Box marginTop={1}>
377          <Text wrap="wrap">
378            <Text bold>Instructions: </Text>
379            {meta.instructions ?? <Text dimColor>none typed after /compact (the default summary prompt only)</Text>}
380          </Text>
381        </Box>
382        {body}
383      </Box>
384    )
385  })
386}
387
hooks/chunk.ts 160 lines
1import type { SessionMessage } from 'claude-code'
2
3import type { Chunk } from '../types'
4
5// Well under the 10,000 characters a Markdown element draws.
6export const CHUNK_CHARS = 4000
7
8/** Splits one over-long piece at the last match of `at` before the limit, else hard. */
9const cut = (text: string, max: number, at: RegExp): string[] => {
10  const out: string[] = []
11  let rest = text
12  while (rest.length > max) {
13    const window = rest.slice(0, max)
14    let end = -1
15    for (const match of window.matchAll(at)) end = match.index + match[0].length
16    if (end <= 0) end = max
17    out.push(rest.slice(0, end))
18    rest = rest.slice(end)
19  }
20  if (rest.length > 0) out.push(rest)
21
22  return out
23}
24
25/** Cuts prose into paragraphs no longer than `max`: at line breaks, then spaces, then anywhere. */
26const prosePieces = (text: string, max: number): string[] =>
27  text
28    .split(/(?<=\n\s*\n)/)
29    .flatMap(p => (p.length <= max ? [p] : cut(p, max, /\n/g)))
30    .flatMap(p => (p.length <= max ? [p] : cut(p, max, / /g)))
31
32// An opening code fence: up to three spaces, then three or more backticks or tildes.
33const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/
34
35type Fence = { open: string; close: string }
36
37/** A closing fence line: the same character as the opening run, at least as many. */
38const closesFence = (marker: string, line: string) =>
39  new RegExp('^ {0,3}' + marker[0] + '{' + marker.length + ',}\\s*$').test(line)
40type Segment = { text: string; fence?: Fence }
41
42/** Splits text into prose and fenced code blocks, each block whole with its fence lines. */
43const segments = (text: string): Segment[] => {
44  const out: Segment[] = []
45  let prose = ''
46  let block: { text: string; open: string; marker: string } | undefined
47  for (const line of text.split(/(?<=\n)/)) {
48    if (block === undefined) {
49      const open = FENCE_OPEN.exec(line)
50      if (open === null) {
51        prose += line
52        continue
53      }
54      if (prose.length > 0) out.push({ text: prose })
55      prose = ''
56      block = { text: line, open: line.replace(/\n$/, ''), marker: open[1]! }
57      continue
58    }
59    block.text += line
60    if (closesFence(block.marker, line)) {
61      out.push({ text: block.text, fence: { open: block.open, close: block.marker } })
62      block = undefined
63    }
64  }
65  // A block never closed runs to the end, as Markdown draws it.
66  if (block !== undefined) out.push({ text: block.text, fence: { open: block.open, close: block.marker } })
67  if (prose.length > 0) out.push({ text: prose })
68
69  return out
70}
71
72/**
73 * Cuts a code block too long for one chunk at line breaks, and closes and reopens
74 * the fence around each part so every part still draws as code. The first part is
75 * sized to `firstRoom`, what is left of the chunk it joins, so the label above the
76 * block stays with the start of it.
77 */
78const blockPieces = (text: string, { open, close }: Fence, max: number, firstRoom: number): string[] => {
79  // The fence lines and their three line breaks come out of every part's room.
80  const overhead = open.length + close.length + 3
81  const room = max - overhead
82  if (room <= 0) return prosePieces(text, max)
83  const lines = text.split(/(?<=\n)/).slice(1)
84  if (lines.length > 0 && closesFence(close, lines.at(-1)!)) lines.pop()
85
86  const parts: string[] = []
87  let rest = lines.join('')
88  // Too little left to be worth filling: start the block on a fresh chunk.
89  let budget = firstRoom - overhead >= max / 10 ? firstRoom - overhead : room
90  while (rest.length > 0) {
91    let [head = ''] = cut(rest, budget, /\n/g)
92    if (head.length === budget && !head.includes('\n')) [head = ''] = cut(rest, budget, / /g)
93    parts.push(open + '\n' + head.replace(/\n$/, '') + '\n' + close + '\n')
94    rest = rest.slice(head.length)
95    budget = room
96  }
97
98  return parts
99}
100
101/**
102 * Packs paragraphs into chunks of at most `max` chars. A paragraph longer than
103 * that is cut at line breaks, then at spaces, then anywhere. A fenced code block
104 * is never cut while it fits in one chunk; a longer one is cut at line breaks
105 * with its fence repeated on each part.
106 */
107export const chunkText = (text: string, max = CHUNK_CHARS): string[] => {
108  const chunks: string[] = []
109  let current = ''
110  const add = (piece: string) => {
111    if (current.length + piece.length > max && current.length > 0) {
112      chunks.push(current)
113      current = ''
114    }
115    current += piece
116  }
117  for (const s of segments(text)) {
118    if (s.fence === undefined) prosePieces(s.text, max).forEach(add)
119    else if (s.text.length <= max) add(s.text)
120    else blockPieces(s.text, s.fence, max, max - current.length).forEach(add)
121  }
122  if (current.length > 0) chunks.push(current)
123
124  // Blank lines are dropped from both ends; the first line keeps its indent.
125  return chunks.map(c => c.replace(/^(\s*\n)+/, '').replace(/\s+$/, '')).filter(c => c.length > 0)
126}
127
128/**
129 * Wraps raw text in a code fence one backtick longer than any run inside it, so
130 * Markdown draws it as written: line breaks, indents and backticks kept.
131 */
132export const fenced = (text: string, language = ''): string => {
133  const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map(run => run.length))
134  const fence = '`'.repeat(Math.max(3, longest + 1))
135
136  return fence + language + '\n' + text + '\n' + fence
137}
138
139/**
140 * A message flattened to text: its prose as written, then each tool call's input
141 * and each tool result, fenced as code since they aren't Markdown.
142 */
143export const messageText = (m: SessionMessage): string => {
144  const parts: string[] = []
145  if (m.text) parts.push(m.text)
146  for (const use of m.toolUses)
147    parts.push('[tool: ' + use.tool + ']\n' + fenced(JSON.stringify(use.input, null, 2), 'json'))
148  for (const res of m.toolResults ?? [])
149    parts.push('[result' + (res.isError ? ' (error)' : '') + ']\n' + fenced(res.text))
150
151  return parts.join('\n\n')
152}
153
154export const chunkMessages = (messages: readonly SessionMessage[]): Chunk[] =>
155  messages.flatMap((message, m) => {
156    const texts = chunkText(messageText(message))
157
158    return texts.map((text, i) => ({ m, role: message.role, i, n: texts.length, text }))
159  })
160
types/index.d.ts 56 lines
1/** Which columns the pane shows. */
2/** The palette the pane draws in; plugins aren't told the app's theme. */
3export type Theme = 'light' | 'dark'
4
5export type ViewMode = 'after' | 'both' | 'before'
6
7export type Side = 'before' | 'after'
8
9/** One piece of a message, at most CHUNK_CHARS long, cut at a paragraph break where possible. */
10export type Chunk = {
11  /** Index of the message in its side's transcript. */
12  m: number
13  role: 'user' | 'assistant'
14  /** This chunk's position within the message, and how many the message has. */
15  i: number
16  n: number
17  text: string
18}
19
20export type ModelUsageSummary = {
21  input_tokens: number
22  output_tokens: number
23  cache_read_input_tokens: number
24  cache_creation_input_tokens: number
25}
26
27/** What the index keeps for each compaction; the chunks themselves live in files beside it. */
28export type CompactMeta = {
29  id: string
30  at: number
31  durationMs: number
32  trigger: 'manual' | 'auto' | 'plugin'
33  /** Absent for the main conversation. */
34  agentId?: string
35  agentLabel?: string
36  instructions?: string
37  tokensBefore?: number
38  tokensAfter?: number
39  usage?: ModelUsageSummary
40  messages: Record<Side, number>
41  chunks: Record<Side, number>
42}
43
44declare module 'claude-code' {
45  interface PluginState {
46    'visible-compact': {
47      index: CompactMeta[]
48      /** The compaction shown; null follows the newest. */
49      selected: string | null
50      view: ViewMode
51      pages: Record<Side, number>
52      theme: Theme
53    }
54  }
55}
56