A side pane with a Recap button that summarises the session's context without touching the conversation

A Claude Code mod that summarises the context of the current session in a side pane, on demand. The recap runs as a side question over the session's own transcript (like /btw): it never interrupts the agent, never adds to its context, and reuses the prompt cache, so it costs about as much as a short turn.
Each recap covers:
At the prompt of a Claude Code terminal session:
/plugin install context-recap --marketplace plaffitt/claude-context-recap
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install context-recap --marketplace plaffitt/claude-context-recap
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with plaffitt/claude-context-recap, then Add plugin.
+ recap / − recap button at the bottom right, above the prompt, or /context-recap. The pane's own close mark (or ctrl+x x while it has the focus) closes it too.Recap button in the pane (r while the pane has the focus).new (uncached input, billed in full), written to cache, read from cache (billed at about a tenth) and out. A read from cache near zero means the prompt cache had expired and the whole transcript was billed.A pane closed by hand stays closed in the next sessions until you show it again.
Plugins cannot define their own keybinding actions, so the toggle button borrows diff:back, an action Claude Code only handles inside the diff dialog. To toggle the pane with alt+r, add this binding to the Chat context of ~/.claude/keybindings.json:
{
"context": "Chat",
"bindings": {
"meta+r": "diff:back"
}
}
Merge it with your existing Chat bindings, then restart the session. Any other key works too: bind it to diff:back instead, and set the plugin's shortcut option (in /config, or pluginConfigs in ~/.claude/settings.json) to the keys the button should show, or to an empty string to show none. The option only labels the button: the plugin cannot read your keybindings.
Prefer the Chat context: the shortcut then works from the prompt, and other views keep their own keys. A chord bound in Global makes its first key a prefix in every view: ctrl+x r there would shadow the single ctrl+x of the agents view, which deletes a session. The shortcut works while the band above the prompt is expanded: collapsing it (its [-] or ctrl+x ctrl+a) hides the button and its shortcut until you expand it again.
/clear, has nothing to summarise: the pane says so.claude --plugin-dir /path/to/claude-context-recap # load it in one session
claude plugin validate /path/to/claude-context-recap
claude plugin test /path/to/claude-context-recap
hooks/register.tsx 215 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Recap, Usage } from '../types'
5
6const PANE = 'context-recap'
7const TITLE = 'Context'
8// Change the tag when Recap's fields change: it declines what an earlier
9// version stored, which outlives a reload.
10const recap = atom({ plugin: 'context-recap', key: 'recap' } as const, {} as Recap, { shape: 'recap-2' })
11// Whether the pane is on screen, kept by this module: $.ui.panes() did not
12// report it shown right after an open.
13const shown = atom({ plugin: 'context-recap', key: 'shown' } as const, false)
14// Turns of the main conversation completed so far: a recap older than the
15// latest one is refreshed when the pane opens.
16const turns = atom({ plugin: 'context-recap', key: 'turns' } as const, 0)
17
18// Plugins cannot define keybinding actions: the band's toggle borrows one the
19// engine only handles inside the diff dialog, bound in ~/.claude/keybindings.json.
20// The plugin cannot read that binding, so the button's label comes from the
21// `shortcut` option.
22const TOGGLE_ACTION = 'diff:back'
23
24const PROMPT = `Side question, not part of the task: summarise your whole context for me in a few short paragraphs, in the language you use with me. Do not use any tool and do not change what you are doing.
25
26Use these bold headings, and skip a heading that has nothing under it:
27- **Role**: who you are in this session and what you work on, in 1 or 2 sentences.
28- **State**: where the work stands now (branch, PR, release, what is running).
29- **Done in this session**: the main steps, one line each.
30- **Rules learnt in this session**: instructions or corrections I gave that you now follow.
31- **Waiting on me**: the decisions or answers you need from me, one line each.
32
33Plain text and short lists only, no preamble and no closing line.`
34
35// A resumed session holds no request of its own until a turn ends, so there
36// is nothing to fork: the recap then reads the transcript the session saved.
37const SYSTEM = `You are the agent of the Claude Code session whose transcript the user message opens with. Answer as that agent, about that session.`
38
39// The transcript's newest characters a recap reads, about 100 000 tokens.
40const BUDGET = 400_000
41
42const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)} […]` : text)
43
44const transcript = (messages: readonly SessionMessage[]) => {
45 const lines = messages.map(m => {
46 const tools = m.toolUses.map(t => `[${t.tool} ${clip(JSON.stringify(t.input), 200)} → ${clip(t.text ?? '', 300)}]`)
47
48 return [`${m.role === 'user' ? 'User' : 'Assistant'}: ${m.text}`, ...tools].join('\n')
49 })
50 const kept: string[] = []
51 let size = 0
52 for (const line of lines.reverse()) {
53 if (size + line.length > BUDGET) break
54 kept.unshift(line)
55 size += line.length
56 }
57
58 return kept.join('\n\n')
59}
60
61const summarise = async ($: EngineInterface) => {
62 const r = await $.model.fork({ prompt: PROMPT })
63 if (r.isAnswered || r.reason !== 'nothing-to-fork') return r
64 const messages = await $.session.messages()
65 if (messages.length === 0) return r
66
67 return $.model.complete({
68 model: await $.session.model(),
69 system: SYSTEM,
70 prompt: [{ text: `<transcript>\n${transcript(messages)}\n</transcript>`, cache: true }, { text: PROMPT }],
71 maxTokens: 4096,
72 })
73}
74
75const clock = (ms: number) => {
76 const d = new Date(ms)
77 const pad = (n: number) => String(n).padStart(2, '0')
78
79 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
80}
81
82const why = (r: { reason: string; status?: number | null; error?: string }) => {
83 if (r.reason === 'nothing-to-fork') return 'nothing to summarise yet: wait for the end of a first turn'
84 if (r.reason === 'api-error') return `API error ${r.error ?? ''} ${r.status ?? ''}`.trim()
85 if (r.reason === 'empty-reply') return 'the model answered without text'
86
87 return 'cancelled'
88}
89
90// Runs a recap; the last one stays on screen until the new one replaces it.
91const run = async ($: EngineInterface) => {
92 if ((await read($, recap)).running) return
93
94 const turn = await read($, turns)
95 const startedAt = await $.clock.now()
96 await update($, recap, (current): Recap => ({ last: current.last, running: { startedAt } }))
97 const r = await summarise($)
98 const at = await $.clock.now()
99 await update($, recap, (current): Recap =>
100 r.isAnswered
101 ? { last: { text: r.text.trim(), at, usage: r.usage, turn } }
102 : { last: current.last, error: { reason: why(r), at, usage: 'usage' in r ? r.usage : undefined } },
103 )
104}
105
106// Refreshes the recap when a turn ended since the shown one was made.
107const refresh = async ($: EngineInterface) => {
108 const { last } = await read($, recap)
109 if (!last || last.turn < (await read($, turns))) await run($)
110}
111
112const count = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
113
114// Uncached input is billed in full and cache writes a bit above it; cache reads
115// cost a tenth, so the first 2 figures are what the recap really costs.
116const cost = (u: Usage) =>
117 `${count(u.input_tokens)} new, ${count(u.cache_creation_input_tokens)} written to cache, ` +
118 `${count(u.cache_read_input_tokens)} read from cache, ${count(u.output_tokens)} out`
119
120// The person's last choice, kept across sessions: a pane closed by hand stays
121// closed until it is toggled open again.
122const HIDDEN = 'hidden'
123
124const open = async ($: EngineInterface) => {
125 const opened = await $.ui.open({ id: PANE, title: TITLE })
126 await update($, shown, () => opened.isPlaced)
127}
128
129const toggle = async ($: EngineInterface) => {
130 if (await read($, shown)) {
131 await $.store.set(HIDDEN, true)
132 await $.ui.close({ id: PANE })
133 // A plugin's own close does not reach its ui.close hook.
134 await update($, shown, () => false)
135
136 return 'hidden'
137 }
138 await $.store.set(HIDDEN, false)
139 await open($)
140 void refresh($)
141
142 return 'shown'
143}
144
145export const register: Register = (on, options) => {
146 const keys = String(options.shortcut ?? '')
147
148 on('session.start', async ($, e, next) => {
149 await $.command.register({
150 name: 'context-recap',
151 description: 'Show or hide the Context pane, which summarises this session on demand',
152 })
153 if ((await $.store.get(HIDDEN)) !== true) void open($)
154
155 return next(e)
156 })
157
158 on('command.run', { command: 'context-recap' }, async $ => ({ text: `Context pane ${await toggle($)}.` }))
159
160 on('turn.complete', async ($, e, next) => {
161 if (e.agentId === undefined) await update($, turns, n => n + 1)
162
163 return next(e)
164 }).catch(($, e, next) => next(e))
165
166 // Any close turns the band's button back to "+"; the pane's own close mark
167 // or ctrl+x x also keeps it closed in the next sessions.
168 on('ui.close', { id: PANE }, async ($, e, next) => {
169 await update($, shown, () => false)
170 if (e.origin.kind === 'person') await $.store.set(HIDDEN, true)
171
172 return next(e)
173 }).catch(($, e, next) => next(e))
174
175 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
176 if (e.props.hasSurvey) return next(e)
177 const { Box, Text, Button } = $.ui.resolve(e)
178 const isShown = await read($, shown)
179
180 return (
181 <Box flexDirection="row" justifyContent="flex-end" width={e.props.bodyColumns}>
182 <Button key="toggle" plain action={TOGGLE_ACTION} onPress={() => void toggle($)}>
183 {isShown ? '− recap' : '+ recap'}
184 {keys ? <Text dimColor> {keys}</Text> : null}
185 </Button>
186 </Box>
187 )
188 })
189
190 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
191 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
192 const { last, running, error } = await read($, recap)
193
194 return (
195 <Box flexDirection="column" gap={1}>
196 <Box flexDirection="row" gap={1}>
197 <Button key="recap" variant="primary" hotkey="r" onPress={() => void run($)}>
198 {running ? 'Recap running…' : 'Recap'}
199 </Button>
200 {running && <Text color="yellow">updating{last ? `, below is the recap of ${clock(last.at)}` : ''}</Text>}
201 {!running && last && <Text dimColor>at {clock(last.at)}</Text>}
202 {!running && !last && !error && <Text dimColor>a summary of this session's context</Text>}
203 </Box>
204 {error && !running && (
205 <Text color="red">
206 No recap at {clock(error.at)}: {error.reason}
207 </Text>
208 )}
209 {last && <Markdown text={last.text} />}
210 {last?.usage && <Text dimColor>{cost(last.usage)}</Text>}
211 </Box>
212 )
213 })
214}
215types/index.d.ts 20 lines1export type Usage = {
2 input_tokens: number
3 output_tokens: number
4 cache_read_input_tokens: number
5 cache_creation_input_tokens: number
6}
7
8// The last recap stays on screen while a newer one runs or after one fails.
9export type Recap = {
10 last?: { text: string; at: number; usage?: Usage; turn: number }
11 running?: { startedAt: number }
12 error?: { reason: string; at: number; usage?: Usage }
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'context-recap': { recap: Shaped<Recap>; shown: boolean; turns: number }
18 }
19}
20