SLOPSHOPPER

context-recap

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

newpanebandcommandmodel
v0.2.0MITupdated 2026-10-26plaffitt/claude-context-recap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-recap
│ ┃ context-recap ✕ › fix the failing auth test and add an audit log call │ ┃ [ Recap ] a summary of this session's contex │ ⏺ 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 │ │ › /context-recap │ ⎿ context-recap: Context pane hidden. │ │ + recap ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
+ recap
Pane · context-recap
[ Recap ] a summary of this session's context
README

context-recap

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:

  • Role: who the agent is in this session and what it works on.
  • State: where the work stands now.
  • Done in this session: the main steps.
  • Rules learnt in this session: the instructions and corrections the agent now follows.
  • Waiting on me: the decisions or answers the agent needs from you.

Install

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.

Use

  • Show or hide the pane: the + 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.
  • Refresh: opening the pane runs a new recap when a turn of the conversation ended since the last one. The previous recap stays on screen, marked as updating, until the new one replaces it.
  • Force a recap: the Recap button in the pane (r while the pane has the focus).
  • Cost: each recap shows its tokens: 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.
  • Resumed session: until its first turn ends, a resumed session has no request of its own to fork, so the recap reads the transcript the session saved instead (its newest 400 000 characters, tool calls shortened). That first recap is billed in full; once a turn has ended, recaps fork the session again.

A pane closed by hand stays closed in the next sessions until you show it again.

Keyboard shortcut

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.

Limits

  • The recap covers the session it runs in, not its subagents.
  • A new session before its first turn, or one right after /clear, has nothing to summarise: the pane says so.
  • The band above the prompt yields to Claude Code's own questions and comes back after them.

Develop

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

License

MIT

Source 2 files
hooks/register.tsx 215 lines
1import { 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}
215
types/index.d.ts 20 lines
1export 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