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

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).
/compact it records what went in and what came back, then opens Your compaction history on it./show-last-compact reopens the pane on the newest compaction.◧ 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.☾/☀): plugins aren't told the app's light or dark theme, so pick it here. The choice is remembered across sessions./compact, if any. The engine's own summary prompt isn't available to plugins.‹ Previous and Next › sit in the column header and again at its bottom.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.
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.
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:
/compact.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.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.
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:
/show-last-compact command and answers only that command.session.compact it lets the compaction run unchanged, then records the result. It never changes, skips or delays a compaction.session.start and session.end only to load and reset its own list, and passes both through unchanged.To remove what it leaves behind, delete the visible-compact folder in your temp folder, or ~/.claude/visible-compact if you chose home.
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.
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.
claude plugin validate .
claude plugin test .
Layout:
.claude-plugin/plugin.json: manifest.claude-plugin/icon.png: directory iconhooks/register.tsx: hooks, storage and the panehooks/chunk.ts: message flattening and paragraph-aware chunkingtypes/index.d.ts: shared types and the $.state contracttests/: unit tests for the chunkerThe Claude Code function-hooks API is in early access and may change between releases.
MIT. See LICENSE.
hooks/register.tsx 387 lines1import { 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}
387hooks/chunk.ts 160 lines1import 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 })
160types/index.d.ts 56 lines1/** 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