A recap of the conversation you just cleared, as a card to share: time worked, turns, tool calls, files and lines changed, test runs and cost.

When you /clear a conversation, a pane opens with a recap of it as a card you can share: the time Claude worked, turns, tool calls, files and lines changed, test runs and cost.

The card is drawn for the Desktop app. In the terminal the pane lists the same figures as text.
go test, cargo test, bun test, npm test and the like).A tile with nothing to say gives its place to the next one, so a card never shows a zero: a research conversation shows subagents and connectors where a build shows files and lines. The card names the project by its folder and never shows a file name or a path.
/clear opens the recap of the conversation you just cleared, once it had at least one turn. Esc closes it./session-recap shows the conversation so far without clearing it, or the last recap again when the new conversation has not started.~/Pictures/Session Recaps and shows it in the Finder. Both need macOS: the image is drawn by Quick Look, so it is set in the system typeface with nothing to install.claude plugin marketplace add endless-fr/claude-mods
claude plugin install session-recap@endless-claude-mods
Run /reload-plugins in a session that is already open. Requires Claude Code v2.1.287 or later in the terminal, v2.1.286 or later in the Desktop app.
The mod makes no network requests, calls no model and sends nothing anywhere.
qlmanage and sips to draw the image, osascript to copy it, mkdir, cp and open to save and show it, rm to remove its working files from the temporary folder./clear, to open the pane once the command has run. It changes nothing about what /clear does.To list this yourself before installing, run claude plugin validate mods/session-recap from a clone.
claude plugin validate mods/session-recap
claude plugin test mods/session-recap
The counting and the choice of tiles live in hooks/stats.ts, the drawing in hooks/card.ts and the image commands in hooks/export.ts, all three free of any engine call. hooks/register.tsx wires them to the session and runs the commands.
Inspired by OneWave AI's session-wrapped. Session Recap is written from scratch and shares no code with it.
hooks/register.tsx 265 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Recap, Stats } from '../types'
5import { cardSvg } from './card'
6import { copyOf, drawingOf, savingOf } from './export'
7import { altOf, emptyStats, recapOf, tilesOf, withCall, withStep, withTurn } from './stats'
8import type { Call } from './stats'
9
10const PANE = 'session-recap'
11const TITLE = 'Session Recap'
12const NOTHING = 'Nothing to recap yet. The recap opens when you /clear a conversation.'
13
14const stats = atom({ plugin: 'session-recap', key: 'stats' } as const, null as Stats | null)
15const card = atom({ plugin: 'session-recap', key: 'card' } as const, null as Recap | null)
16const outcome = atom({ plugin: 'session-recap', key: 'outcome' } as const, '')
17
18// True from the moment a /clear froze a recap until the command that typed it has run.
19let hasCleared = false
20// The conversation the last /clear ended. A /clear starts the session's state
21// over, so what session.end wrote there is gone by the time the pane draws:
22// the module carries it across, into the new conversation's state.
23let lastCleared: { recap: Recap; cost: number | null } | null = null
24
25const costOf = async ($: EngineInterface) => (await $.session.usage()).cost?.usd ?? null
26
27/** The arguments of a tool call the counts read, whichever tool it was. */
28function callOf(e: { tool: string }): Call {
29 const input = e as Record<string, unknown>
30 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : undefined)
31
32 return {
33 tool: e.tool,
34 filePath: text('file_path') ?? text('notebook_path'),
35 oldText: text('old_string'),
36 newText: text('new_string') ?? text('content') ?? text('new_source'),
37 command: text('command'),
38 skill: text('skill'),
39 }
40}
41
42/** The running conversation as a recap, and what the session has cost by now; no recap before its first turn ends. */
43async function freeze($: EngineInterface, isSoFar: boolean): Promise<{ recap: Recap | null; cost: number | null }> {
44 const counted = await read($, stats)
45 const cost = await costOf($)
46 if (!counted || counted.turns === 0) return { recap: null, cost }
47
48 return { recap: recapOf(counted, { now: await $.clock.now(), cwd: await $.session.cwd(), cost, isSoFar }), cost }
49}
50
51async function show($: EngineInterface, recap: Recap) {
52 await update($, card, () => recap)
53 await update($, outcome, () => '')
54}
55
56/** Puts the last cleared conversation into the new one's state, and starts its counts from the cost so far. */
57async function carry($: EngineInterface) {
58 if (!lastCleared) return
59 const { recap, cost } = lastCleared
60 if ((await read($, card)) === null) await show($, recap)
61 if ((await read($, stats)) === null) await update($, stats, counted => counted ?? emptyStats(cost))
62}
63
64const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, holdToasts: true })
65
66/** Runs one command; true when it exited cleanly. Rejects when it cannot start. */
67const done = async ($: EngineInterface, argv: string[]) => (await $.process.run(argv, { timeoutMs: 20_000 })).exitCode === 0
68
69/** Runs the commands in order; true when every one exited cleanly, stopping at the first that did not. */
70async function allDone($: EngineInterface, list: string[][]) {
71 for (const argv of list) if (!(await done($, argv))) return false
72
73 return true
74}
75
76async function copy($: EngineInterface, png: string) {
77 return (await done($, copyOf(png))) ? 'Copied. Paste it anywhere.' : "Couldn't reach the clipboard. Nothing was copied."
78}
79
80async function save($: EngineInterface, recap: Recap, png: string) {
81 const home = await $.env.get('HOME')
82 if (!home) return "Couldn't find your home folder. Nothing was saved."
83 const saving = savingOf(recap, png, home)
84 if (!(await allDone($, saving.file))) return "Couldn't write to Pictures. Nothing was saved."
85 await done($, saving.reveal)
86
87 return `Saved to ${saving.where}.`
88}
89
90/** Makes the image of the card on show, copies or saves it, and resolves to what to tell the person. */
91async function image($: EngineInterface, recap: Recap, isSave: boolean): Promise<string> {
92 const nothing = isSave ? 'Nothing was saved.' : 'Nothing was copied.'
93 const drawing = drawingOf(recap, await $.env.get('TMPDIR'))
94 try {
95 await $.fs.write(drawing.svg, drawing.source)
96 try {
97 if (!(await allDone($, drawing.draw))) return `Couldn't make the image. ${nothing}`
98
99 return isSave ? await save($, recap, drawing.png) : await copy($, drawing.png)
100 } finally {
101 await $.process.run(drawing.clean)
102 }
103 } catch {
104 // A command that cannot start: these tools are macOS's own.
105 return `The image needs macOS. ${nothing}`
106 }
107}
108
109/** A press of Copy image or Save image: makes the image and says what came of it. */
110async function share($: EngineInterface, isSave: boolean) {
111 const recap = await read($, card)
112 if (!recap) return
113 await update($, outcome, () => '')
114 const said = await image($, recap, isSave)
115 await update($, outcome, () => said)
116}
117
118function desktopPane($: EngineInterface, { Box, Button, Svg, Text }: Elements['desktop'], recap: Recap, said: string): RenderElement {
119 return (
120 <Box flexDirection="column" gap={1}>
121 {/* An image, not `isInteractive`: it takes the pane's width and still
122 animates, where the frame stays 150 tall and the card shrinks into it. */}
123 <Svg source={cardSvg(recap)} alt={altOf(recap)} />
124 <Box gap={1} alignItems="center">
125 <Button key="copy" variant="primary" onPress={() => void share($, false)}>
126 Copy image
127 </Button>
128 <Button key="save" variant="secondary" onPress={() => void share($, true)}>
129 Save image
130 </Button>
131 {said ? (
132 <Box key="outcome">
133 <Text dimColor>{said}</Text>
134 </Box>
135 ) : null}
136 </Box>
137 </Box>
138 )
139}
140
141/** The same figures as rows of text, where no surface draws the card. */
142function listPane({ Box, Text }: Pick<Elements['terminal'], 'Box' | 'Text'>, recap: Recap): RenderElement {
143 const { headline, accent, small } = tilesOf(recap)
144
145 return (
146 <Box flexDirection="column" paddingX={1}>
147 <Box gap={1}>
148 <Text bold>{headline.project}</Text>
149 <Text dimColor>{headline.date}</Text>
150 </Box>
151 <Box gap={1} marginBottom={1}>
152 <Text bold>{headline.time}</Text>
153 <Text dimColor>{headline.note}</Text>
154 </Box>
155 {[accent, ...small].map(tile => (
156 <Box gap={1}>
157 <Box width={13}>
158 <Text dimColor>{tile.label}</Text>
159 </Box>
160 <Text bold>{tile.removed ? `${tile.value} ${tile.removed}` : tile.value}</Text>
161 {tile.note ? <Text dimColor>{tile.note}</Text> : null}
162 </Box>
163 ))}
164 </Box>
165 )
166}
167
168export const register: Register = on => {
169 on('session.start', async ($, e, next) => {
170 await $.command.register({ name: 'session-recap', description: 'Show the recap of this conversation, or of the last one you cleared' })
171 // A reload of the mod starts this module over, not the conversation.
172 if ((await read($, stats)) === null) {
173 const cost = await costOf($)
174 await update($, stats, counted => counted ?? emptyStats(cost))
175 }
176
177 return next(e)
178 })
179
180 on('tool.call', async ($, e, next) => {
181 const ran = await next(e)
182 if (ran.deny === undefined) await update($, stats, counted => withCall(counted ?? emptyStats(null), callOf(e)))
183
184 return ran
185 })
186
187 // Each request of the main conversation: a subagent's tokens are its own.
188 on('turn.step', async function* ($, e, next) {
189 const result = yield* next(e)
190 const { usage } = result
191 if (e.agentId === undefined && usage) await update($, stats, counted => withStep(counted ?? emptyStats(null), usage))
192
193 return result
194 })
195
196 on('turn.complete', async ($, e, next) => {
197 const result = await next(e)
198 if (e.agentId === undefined) await update($, stats, counted => withTurn(counted ?? emptyStats(null), e.durationMs))
199
200 return result
201 })
202
203 on('session.end', async ($, e, next) => {
204 if (e.reason !== 'clear') return next(e)
205 // The process goes on with a new conversation, and no session.start says so.
206 const { recap, cost } = await freeze($, false)
207 await update($, stats, () => emptyStats(cost))
208 if (recap) {
209 lastCleared = { recap, cost }
210 await show($, recap)
211 }
212 const ended = await next(e)
213 if (recap) {
214 hasCleared = true
215 await carry($)
216 await open($).catch(() => undefined)
217 }
218
219 return ended
220 })
221
222 on('command.run', { command: 'clear' }, async ($, e, next) => {
223 hasCleared = false
224 const ran = await next(e)
225 // Opened from session.end the pane is unasked for, and waits undrawn on a
226 // narrow terminal. Here the person's own command is behind it.
227 if (hasCleared) {
228 await carry($)
229 await open($).catch(() => undefined)
230 }
231 hasCleared = false
232
233 return ran
234 })
235
236 on('command.run', { command: 'session-recap' }, async $ => {
237 const { recap } = await freeze($, true)
238 if (recap) await show($, recap)
239 else if ((await read($, card)) === null) {
240 if (!lastCleared) return { text: NOTHING }
241 await show($, lastCleared.recap)
242 }
243 const opened = await open($)
244
245 return opened.isPlaced ? {} : { text: `Session Recap is open but not drawn yet: ${opened.reason}` }
246 })
247
248 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
249 // Until the new conversation's state holds it, the recap the module carried.
250 const recap = (await read($, card)) ?? lastCleared?.recap ?? null
251 const said = await read($, outcome)
252 if (!recap) {
253 const { Box, Text } = $.ui.resolve(e)
254
255 return (
256 <Box paddingX={1}>
257 <Text dimColor>{NOTHING}</Text>
258 </Box>
259 )
260 }
261
262 return e.surface === 'desktop' ? desktopPane($, $.ui.resolve(e), recap, said) : listPane($.ui.resolve(e), recap)
263 })
264}
265hooks/card.ts 168 lines1/** The recap as one drawing: a bento card of rounded tiles, animated for the pane and still for the image. Free of any engine call. */
2
3import { SMALL_TILES, tilesOf } from './stats'
4import type { Headline, Recap, Tile } from './stats'
5
6export const CARD = { width: 1200, height: 675 }
7
8const EDGE = 24
9const GAP = 12
10const COLUMNS = [484, 322, 322]
11const ROW = 201
12const CORNER = 28
13
14const GROUND = '#000000'
15const SURFACE = '#1C1C1E'
16const INK = '#F5F5F7'
17const GREY = '#8E8E93'
18const ACCENT = '#0A84FF'
19const PLUS = '#30D158'
20const MINUS = '#FF453A'
21
22// The system face: San Francisco in the Desktop app and under Quick Look.
23const FACE = "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Helvetica, Arial, sans-serif"
24// What one character of a semibold figure takes of its size, tracking included.
25const ADVANCE = 0.56
26
27// One tile after another: when the first starts, what each waits on the last, and how long one takes.
28const REVEAL = { first: 0.1, step: 0.09, takes: 0.7, rise: 16 }
29
30/** One tile's place on the grid of three columns and three rows, and what it shows. */
31export type Cell =
32 | { kind: 'headline' | 'credit'; column: number; row: number; columns: number; rows: number }
33 | { kind: 'accent' | 'small'; column: number; row: number; columns: number; rows: number; tile: Tile }
34
35// Where the small tiles go, in the order they are filled.
36const SLOTS = [
37 [2, 0],
38 [1, 1],
39 [2, 1],
40 [1, 2],
41 [2, 2],
42] as const
43
44/**
45 * Where each tile stands. With fewer than five small tiles the card closes
46 * up, so no cell is left empty and no tile shows a zero.
47 */
48export function layoutOf(recap: Recap): Cell[] {
49 const { accent, small } = tilesOf(recap)
50 const count = Math.min(small.length, SMALL_TILES)
51 const one = (kind: 'small' | 'accent', tile: Tile, column: number, row: number, columns = 1, rows = 1): Cell => ({ kind, column, row, columns, rows, tile })
52 // The fourth of four takes the last cell, the credit having taken the one before it.
53 const slots = count === 4 ? [SLOTS[0], SLOTS[1], SLOTS[2], SLOTS[4]] : SLOTS
54 const smalls =
55 count <= 2
56 ? [...(count === 2 ? [one('small', small[0] as Tile, 2, 0)] : []), ...(count >= 1 ? [one('small', small[count - 1] as Tile, 1, 1, 2)] : [])]
57 : small.slice(0, count).map((tile, i) => one('small', tile, slots[i]?.[0] ?? 2, slots[i]?.[1] ?? 2))
58
59 return [
60 { kind: 'headline', column: 0, row: 0, columns: 1, rows: 2 },
61 one('accent', accent, 1, 0, count <= 1 ? 2 : 1, count === 0 ? 2 : 1),
62 ...smalls,
63 { kind: 'credit', column: 0, row: 2, columns: count === 5 ? 1 : count === 4 ? 2 : 3, rows: 1 },
64 ]
65}
66
67const escape = (text: string) => text.replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c] ?? c)
68
69const cut = (text: string, most: number) => (text.length > most ? `${text.slice(0, most)}…` : text)
70
71/** The size at which `text` fits `width`, no larger than `size` and no smaller than `least`. */
72const fit = (text: string, width: number, size: number, least: number) => Math.max(least, Math.min(size, Math.floor((width / (Math.max(1, text.length) * ADVANCE)) * 10) / 10))
73
74type Type = { size: number; fill?: string; weight?: number; tracking?: number; opacity?: number }
75
76function text(x: number, y: number, content: string, { size, fill = INK, weight = 400, tracking = 0, opacity }: Type): string {
77 const extras = `${tracking ? ` letter-spacing="${tracking}"` : ''}${opacity === undefined ? '' : ` fill-opacity="${opacity}"`}`
78
79 return `<text x="${x}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}"${extras}>${content}</text>`
80}
81
82const span = (column: number, columns: number) => COLUMNS.slice(column, column + columns).reduce((sum, width) => sum + width, 0) + GAP * (columns - 1)
83const left = (column: number) => EDGE + COLUMNS.slice(0, column).reduce((sum, width) => sum + width + GAP, 0)
84
85function drawHeadline(headline: Headline, width: number, height: number): string {
86 const inset = 32
87 const size = fit(headline.time, width - 2 * inset, 128, 64)
88
89 return (
90 text(inset, 60, escape(cut(headline.project, 24)), { size: 27, weight: 600, tracking: -0.3 }) +
91 text(inset, 92, escape(headline.date), { size: 21, fill: GREY }) +
92 text(inset - 4, height - 78, escape(headline.time), { size, weight: 600, tracking: -size * 0.035 }) +
93 text(inset, height - 34, escape(headline.note), { size: 23, fill: GREY })
94 )
95}
96
97function drawTile(tile: Tile, isAccent: boolean, width: number, height: number): string {
98 const inset = 28
99 const room = width - 2 * inset
100 const shown = cut(tile.value, 20)
101 // The removed lines are written smaller, after a space.
102 const length = tile.removed ? `${shown} ${tile.removed}`.length - tile.removed.length * 0.4 : shown.length
103 const size = fit('x'.repeat(Math.ceil(length)), room, 58, 24)
104 const value = tile.removed
105 ? `<tspan fill="${PLUS}">${escape(shown)}</tspan> <tspan fill="${MINUS}" font-size="${Math.round(size * 0.6)}">${escape(tile.removed)}</tspan>`
106 : escape(shown)
107 const base = height - (tile.note ? 62 : 32)
108
109 return (
110 text(inset, 52, escape(tile.label), isAccent ? { size: 21, fill: '#FFFFFF', opacity: 0.86, weight: 500 } : { size: 21, fill: GREY, weight: 500 }) +
111 text(inset - 2, base, value, { size, weight: 600, fill: isAccent ? '#FFFFFF' : INK, tracking: -size * 0.03 }) +
112 (tile.note ? text(inset, height - 28, escape(tile.note), { size: 19, fill: isAccent ? '#FFFFFF' : GREY, ...(isAccent ? { opacity: 0.86 } : {}) }) : '')
113 )
114}
115
116// The mod's mark: a bento of four, one tile lit.
117const MARK = [0, 1, 2, 3].map(i => `<rect x="${28 + (i % 2) * 15}" y="${30 + Math.floor(i / 2) * 15}" width="12" height="12" rx="3.5" fill="${i === 1 ? ACCENT : '#48484A'}"/>`).join('')
118
119function drawCredit(height: number): string {
120 return MARK + text(28, height - 60, 'Session Recap', { size: 24, weight: 600, tracking: -0.2 }) + text(28, height - 30, 'by endless · Claude Code', { size: 19, fill: GREY })
121}
122
123/** How a tile comes in: unseen until its turn, then up into place. A surface that does not animate shows it as drawn. */
124function reveal(order: number): string {
125 const waits = REVEAL.first + order * REVEAL.step
126 const ends = waits + REVEAL.takes
127 const timing = `keyTimes="0;${(waits / ends).toFixed(3)};1" dur="${ends.toFixed(2)}s" fill="freeze" calcMode="spline" keySplines="0 0 1 1;0.16 1 0.3 1"`
128
129 return (
130 `<animate attributeName="opacity" values="0;0;1" ${timing}/>` +
131 `<animateTransform attributeName="transform" type="translate" values="0 ${REVEAL.rise};0 ${REVEAL.rise};0 0" ${timing}/>`
132 )
133}
134
135function body(recap: Recap, isAnimated: boolean): string {
136 const { headline } = tilesOf(recap)
137
138 return layoutOf(recap)
139 .map((cell, order) => {
140 const width = span(cell.column, cell.columns)
141 const height = ROW * cell.rows + GAP * (cell.rows - 1)
142 const inside =
143 'tile' in cell ? drawTile(cell.tile, cell.kind === 'accent', width, height) : cell.kind === 'headline' ? drawHeadline(headline, width, height) : drawCredit(height)
144 const tile = `<rect width="${width}" height="${height}" rx="${CORNER}" fill="${cell.kind === 'accent' ? ACCENT : SURFACE}"/>${inside}`
145
146 return `<g transform="translate(${left(cell.column)} ${EDGE + cell.row * (ROW + GAP)})">${isAnimated ? `<g>${reveal(order)}${tile}</g>` : tile}</g>`
147 })
148 .join('')
149}
150
151const open = (width: number, height: number) => `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${FACE}">`
152
153/** The card as the pane shows it, its tiles coming in one after another. */
154export function cardSvg(recap: Recap): string {
155 return `${open(CARD.width, CARD.height)}<rect width="${CARD.width}" height="${CARD.height}" rx="${CORNER + EDGE / 2}" fill="${GROUND}"/>${body(recap, true)}</svg>`
156}
157
158/**
159 * The card as the image is made from it: still, and centred on a square of
160 * its own ground, because Quick Look draws a square and the frame is cut out
161 * of it afterwards.
162 */
163export function exportSvg(recap: Recap): string {
164 const side = CARD.width
165
166 return `${open(side, side)}<rect width="${side}" height="${side}" fill="${GROUND}"/><g transform="translate(0 ${(side - CARD.height) / 2})">${body(recap, false)}</g></svg>`
167}
168hooks/export.ts 61 lines1/** The card as an image: the commands that draw it with the tools macOS ships with, then copy or save it. Free of any engine call: the hooks run what this lays out, and only when a button is pressed. */
2
3import type { Recap } from '../types'
4import { CARD, exportSvg } from './card'
5
6// Two image pixels to each point of the card.
7const SCALE = 2
8const FOLDER = 'Session Recaps'
9
10/** What drawing the card to a PNG takes: a file to write, commands to run in order, and the working files to remove after. */
11export type Drawing = { svg: string; source: string; png: string; draw: string[][]; clean: string[] }
12
13/** Lays out the drawing of `recap` in the temporary folder `tmpdir` names. */
14export function drawingOf(recap: Recap, tmpdir: string | undefined): Drawing {
15 const dir = `${(tmpdir ?? '/tmp').replace(/\/+$/, '')}/session-recap`
16 const svg = `${dir}/card.svg`
17 const square = `${svg}.png`
18 const png = `${dir}/card.png`
19 const side = String(CARD.width * SCALE)
20
21 return {
22 svg,
23 source: exportSvg(recap),
24 png,
25 // Quick Look draws the system face, and only ever a square: sips cuts the card out of its middle.
26 draw: [
27 ['qlmanage', '-t', '-s', side, '-o', dir, svg],
28 ['sips', '-c', String(CARD.height * SCALE), side, square, '--out', png],
29 ],
30 clean: ['rm', '-f', svg, square, png],
31 }
32}
33
34/** The command that puts the PNG at `png` on the clipboard. */
35export const copyOf = (png: string): string[] => ['osascript', '-e', `set the clipboard to (read (POSIX file "${png.replace(/[\\"]/g, c => `\\${c}`)}") as «class PNGf»)`]
36
37const two = (value: number) => String(value).padStart(2, '0')
38
39/** `2026-01-05-1407`, in the machine's own time. */
40function stamp(ms: number): string {
41 const date = new Date(ms)
42
43 return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}-${two(date.getHours())}${two(date.getMinutes())}`
44}
45
46/** What saving the PNG takes: the commands that file it under Pictures, and the one that shows it in the Finder. */
47export function savingOf(recap: Recap, png: string, home: string): { where: string; file: string[][]; reveal: string[] } {
48 const folder = `${home}/Pictures/${FOLDER}`
49 const name = recap.project.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'session'
50 const path = `${folder}/${name}-${stamp(recap.at)}.png`
51
52 return {
53 where: `Pictures › ${FOLDER}`,
54 file: [
55 ['mkdir', '-p', folder],
56 ['cp', png, path],
57 ],
58 reveal: ['open', '-R', path],
59 }
60}
61hooks/stats.ts 214 lines1/** What a conversation did: the counts the hooks feed, the recap frozen from them, and how its numbers read. Free of any drawing or engine call. */
2
3import type { Recap, Stats } from '../types'
4
5export type { Recap, Stats }
6
7/** One finished tool call, as much of it as the counts need. */
8export type Call = {
9 tool: string
10 filePath?: string | undefined
11 /** What an edit replaced and what it wrote; a written file has only the second. */
12 oldText?: string | undefined
13 newText?: string | undefined
14 command?: string | undefined
15 skill?: string | undefined
16}
17
18/** One request's token counts, as the API spells them. */
19export type Usage = { model: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
20
21export type Tile = { label: string; value: string; note?: string; removed?: string }
22export type Headline = { project: string; date: string; time: string; note: string }
23export type Tiles = { headline: Headline; accent: Tile; small: Tile[] }
24
25export const SMALL_TILES = 5
26
27const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
28const SUBAGENTS = new Set(['Agent', 'Task'])
29const TEST_RUN =
30 /\b(vitest|jest|mocha|pytest|rspec|phpunit|playwright\s+test|go\s+test|cargo\s+test|bun\s+test|deno\s+test|claude\s+plugin\s+test|(npm|pnpm|yarn)\s+(run\s+)?test\b|npm\s+t\b)/
31
32export const isTestRun = (command: string) => TEST_RUN.test(command)
33
34export const emptyStats = (costAtStart: number | null): Stats => ({
35 costAtStart,
36 workedMs: 0,
37 turns: 0,
38 tools: {},
39 files: [],
40 added: 0,
41 removed: 0,
42 testRuns: 0,
43 subagents: 0,
44 skills: [],
45 connectors: [],
46 tokens: 0,
47 model: '',
48})
49
50const linesOf = (text: string) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'))
51
52/** The lines an edit added and removed, the ones it kept at either end left out. An estimate, not a diff. */
53function changed(oldText: string, newText: string): { added: number; removed: number } {
54 const before = linesOf(oldText)
55 const after = linesOf(newText)
56 const most = Math.min(before.length, after.length)
57 let head = 0
58 while (head < most && before[head] === after[head]) head++
59 let tail = 0
60 while (tail < most - head && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail++
61
62 return { added: after.length - head - tail, removed: before.length - head - tail }
63}
64
65/** `mcp__claude_ai_Gmail__search_threads` is the server `claude_ai_Gmail` and the tool `search_threads`. */
66function mcp(tool: string): { server: string; name: string } | null {
67 const parts = tool.split('__')
68
69 return parts[0] === 'mcp' && parts.length >= 3 ? { server: parts[1] ?? '', name: parts.slice(2).join('__') } : null
70}
71
72const once = (list: string[], one: string | undefined) => (one && !list.includes(one) ? [...list, one] : list)
73
74/** Folds one finished tool call into the counts. */
75export function withCall(stats: Stats, call: Call): Stats {
76 const isEdit = EDITS.has(call.tool)
77 const lines = isEdit && call.newText !== undefined ? changed(call.oldText ?? '', call.newText) : { added: 0, removed: 0 }
78
79 return {
80 ...stats,
81 tools: { ...stats.tools, [call.tool]: (stats.tools[call.tool] ?? 0) + 1 },
82 files: isEdit ? once(stats.files, call.filePath) : stats.files,
83 added: stats.added + lines.added,
84 removed: stats.removed + lines.removed,
85 testRuns: stats.testRuns + (call.tool === 'Bash' && call.command && isTestRun(call.command) ? 1 : 0),
86 subagents: stats.subagents + (SUBAGENTS.has(call.tool) ? 1 : 0),
87 skills: call.tool === 'Skill' ? once(stats.skills, call.skill) : stats.skills,
88 connectors: once(stats.connectors, mcp(call.tool)?.server),
89 }
90}
91
92/** Folds one finished turn of the main conversation into the counts. */
93export const withTurn = (stats: Stats, durationMs: number): Stats => ({ ...stats, turns: stats.turns + 1, workedMs: stats.workedMs + Math.max(0, durationMs) })
94
95/** Folds one request of the main conversation into the counts. */
96export const withStep = (stats: Stats, usage: Usage): Stats => ({
97 ...stats,
98 tokens: stats.tokens + usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
99 model: usage.model || stats.model,
100})
101
102/** The most called tool, the first by name among equals; an MCP tool without its server. */
103function topTool(tools: Record<string, number>): string | null {
104 const [first] = Object.entries(tools).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
105
106 return first ? (mcp(first[0])?.name ?? first[0]) : null
107}
108
109/** `claude-opus-5-5[1m]` is `Opus 5.5`; a name already written out passes through. */
110export function modelLabel(model: string): string {
111 const id = model.replace(/\[.*\]$/, '').trim()
112 if (!id.startsWith('claude-')) return id
113 const parts = id.split('-').slice(1).filter(part => !/^\d{8}$/.test(part))
114 const family = parts.find(part => /^[a-z]+$/i.test(part))
115 const version = parts.filter(part => /^\d+$/.test(part)).join('.')
116
117 return family ? `${family[0]?.toUpperCase()}${family.slice(1)}${version ? ` ${version}` : ''}` : id
118}
119
120/** Freezes the counts into what the card shows; `cost` is what the session has cost by now. */
121export function recapOf(stats: Stats, now: { now: number; cwd: string; cost: number | null; isSoFar: boolean }): Recap {
122 return {
123 project: now.cwd.split(/[\\/]/).filter(Boolean).pop() ?? '',
124 at: now.now,
125 model: modelLabel(stats.model),
126 workedMs: stats.workedMs,
127 turns: stats.turns,
128 toolCalls: Object.values(stats.tools).reduce((sum, calls) => sum + calls, 0),
129 topTool: topTool(stats.tools),
130 files: stats.files.length,
131 added: stats.added,
132 removed: stats.removed,
133 testRuns: stats.testRuns,
134 subagents: stats.subagents,
135 skills: stats.skills.length,
136 connectors: stats.connectors.length,
137 tokens: stats.tokens,
138 cost: now.cost === null || stats.costAtStart === null ? null : Math.max(0, now.cost - stats.costAtStart),
139 isSoFar: now.isSoFar,
140 }
141}
142
143/** `1h 42m`, `14m`, `45s`. */
144export function span(ms: number): string {
145 const minutes = Math.floor(ms / 60_000)
146 if (minutes < 1) return `${Math.max(0, Math.round(ms / 1000))}s`
147 if (minutes < 60) return `${minutes}m`
148
149 return `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${minutes % 60}m` : ''}`
150}
151
152const UNITS: [number, string][] = [
153 [1e9, 'B'],
154 [1e6, 'M'],
155 [1e3, 'K'],
156]
157
158/** `864`, `1,077`, `12.4K`, `2.1M`. */
159export function count(value: number): string {
160 if (value < 10_000) return String(Math.round(value)).replace(/\B(?=(\d{3})+$)/g, ',')
161 const [size, unit] = UNITS.find(([floor]) => value >= floor) ?? [1, '']
162 const scaled = value / size
163
164 return `${scaled >= 100 ? Math.round(scaled) : scaled.toFixed(1).replace(/\.0$/, '')}${unit}`
165}
166
167/** `$6.40`, and `$142` from a hundred dollars. */
168export const dollars = (usd: number) => (usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`)
169
170const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
171
172/** `5 Jan 2026`, in the machine's own time. */
173export function dateLabel(ms: number): string {
174 const date = new Date(ms)
175
176 return `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`
177}
178
179/** What each tile of the card says. No tile shows a zero: one with nothing to say gives its place to the next. */
180export function tilesOf(recap: Recap): Tiles {
181 const hasCalls = recap.toolCalls > 0
182 const hasCost = recap.cost !== null && recap.cost >= 0.005
183 const tokens = recap.tokens > 0 ? count(recap.tokens) : null
184 // Tokens stand on the accent tile when no tool was called, so nowhere else then.
185 const spareTokens = hasCalls ? tokens : null
186 const turns = `${count(recap.turns)} turn${recap.turns === 1 ? '' : 's'}`
187
188 const candidates: (Tile | false | null)[] = [
189 recap.files > 0 && { label: 'Files edited', value: count(recap.files) },
190 recap.added + recap.removed > 0 && { label: 'Lines', value: `+${count(recap.added)}`, removed: `−${count(recap.removed)}` },
191 recap.topTool !== null && { label: 'Most used', value: recap.topTool },
192 recap.testRuns > 0 && { label: 'Test runs', value: count(recap.testRuns) },
193 hasCost && { label: 'Cost', value: dollars(recap.cost ?? 0), ...(spareTokens ? { note: `${spareTokens} tokens` } : {}) },
194 recap.subagents > 0 && { label: 'Subagents', value: count(recap.subagents) },
195 recap.skills > 0 && { label: 'Skills', value: count(recap.skills) },
196 recap.connectors > 0 && { label: 'Connectors', value: count(recap.connectors) },
197 !hasCost && spareTokens !== null && { label: 'Tokens', value: spareTokens },
198 ]
199
200 return {
201 headline: { project: recap.project, date: `${recap.isSoFar ? 'So far · ' : ''}${dateLabel(recap.at)}`, time: span(recap.workedMs), note: recap.model ? `${turns} on ${recap.model}` : turns },
202 accent: hasCalls ? { label: 'Tool calls', value: count(recap.toolCalls) } : { label: 'Tokens', value: tokens ?? '0' },
203 small: candidates.filter((tile): tile is Tile => Boolean(tile)).slice(0, SMALL_TILES),
204 }
205}
206
207/** The card in a sentence or two, for someone who cannot see it. */
208export function altOf(recap: Recap): string {
209 const { headline, accent, small } = tilesOf(recap)
210 const said = [accent, ...small].map(tile => `${tile.label}: ${tile.value}${tile.removed ? ` ${tile.removed}` : ''}${tile.note ? `, ${tile.note}` : ''}.`)
211
212 return `Session recap of ${headline.project}, ${headline.date}: ${headline.time} worked, ${headline.note}. ${said.join(' ')}`
213}
214types/index.d.ts 61 lines1/** The mod's values in `$.state`: the session's, kept through a reload of the mod. */
2
3/** The counts of one conversation, from the session's start or the last /clear. */
4export type Stats = {
5 /** Dollars the session had cost when the conversation began; null where nothing keeps count. */
6 costAtStart: number | null
7 /** The time Claude spent working, summed over the main conversation's turns. */
8 workedMs: number
9 turns: number
10 /** Calls by tool name, the main conversation's and its subagents'. */
11 tools: Record<string, number>
12 /** The paths edited, each once. They never leave this value: the card shows how many. */
13 files: string[]
14 added: number
15 removed: number
16 testRuns: number
17 subagents: number
18 skills: string[]
19 connectors: string[]
20 tokens: number
21 /** The model that answered the main conversation's last request, by the id the API reports. */
22 model: string
23}
24
25/** A conversation as the card shows it: figures only, and the project's folder name. */
26export type Recap = {
27 project: string
28 /** When it was frozen, in the clock's milliseconds. */
29 at: number
30 model: string
31 workedMs: number
32 turns: number
33 toolCalls: number
34 topTool: string | null
35 files: number
36 added: number
37 removed: number
38 testRuns: number
39 subagents: number
40 skills: number
41 connectors: number
42 tokens: number
43 /** Dollars this conversation cost; null where nothing keeps count. */
44 cost: number | null
45 /** True for a conversation still running, shown by /recap before any /clear. */
46 isSoFar: boolean
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 'session-recap': {
52 /** The running conversation's counts; null until the session's start gave it a cost to count from. */
53 stats: Stats | null
54 /** What the pane shows: the last conversation cleared, or the running one when /recap asked for it. */
55 card: Recap | null
56 /** What the last press of Copy image or Save image came to; empty before any. */
57 outcome: string
58 }
59 }
60}
61