An ADHD-friendly session recap above the prompt: /vp-cc-recap on demand, and automatically after you have been idle

An ADHD-friendly session recap for Claude Code, drawn above the prompt, in the language you write in.
After a break, the hard part of a long session is starting again: you have to rebuild what you were doing, what is finished, and what comes next before you can act. The built-in session recap gives one line of history. vp-cc-recap gives you the one thing to do next and puts it one keypress away.
recap ⏭ Hand to Claude: Move the plugin into the repo
🎯 Ship an ADHD-friendly recap
1 Start 2 Details 3 Dismiss Last reply 12 min ago
The recap tag is drawn in inverse video, so the recap stands apart from other plugins' blocks in the same band.
This is the English version. In a session where you write another language, the same layout appears with the model's translation of every label.
| Action | How | | :- | :- | | Show a recap now | Run /vp-cc-recap | | Get one automatically | Leave the session idle for idleMinutes after a turn ends | | Act on the first line | Press 1 (Start, Done or Reply) | | Show or hide finished items | Press 2 (Details / Less) | | Dismiss | Press 3 (Dismiss), or send any prompt |
You can click the buttons. The mods documentation says a digit typed into an empty prompt box presses the matching button above the prompt. That shortcut has not been tested in the Desktop app yet.
What each primary button fills in:
| First line | Button | Text put in the prompt box | | :- | :- | :- | | ⏭ Hand to Claude | Start | The instruction for Claude | | ⏭ Your next step | Done | I finished: <step> | | ⚠ Waiting on you | Reply | About "<question>": |
If the prompt box already holds a draft, the text goes after it, so your draft stays.
| Option | Default | Meaning | | :- | :- | :- | | idleMinutes | 5 | Minutes after a turn ends with no new prompt before a recap is prepared. 0 turns the automatic recap off. Range 0 to 120. |
Set it in /plugin (installed copy), or in settings.json under pluginConfigs, keyed by the plugin id (vp-cc-recap@vp-cc-mods when installed from this marketplace).
$.model.fork call: the session's own last request, with the recap prompt appended. It reuses the conversation's prompt cache, as the built-in recap does, so it adds one short request.next, next_by, waiting, goal, done, and ui, the band's labels translated into the user's language. The mod lays them out.{n}, {step}, {question}) keeps its English default. A reply that is not valid JSON is shown as Markdown instead.From the repository root:
node scripts/check.mjs vp-cc-recap # layout, English-only, validate and tests
claude --plugin-dir plugins/vp-cc-recap # try the branch copy in the terminal
The tests mount the band on the terminal and desktop surfaces and stub the model, the clock and the prompt box. See the repository AGENTS.md for the full development flow, including the Desktop check.
hooks/register.tsx 333 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { RecapContent, RecapLabels, RecapTrigger, RecapView } from '../types'
5
6const HIDDEN: RecapView = { phase: 'hidden' }
7
8// English defaults. The model sends these back translated into the user's
9// language with each recap; a missing or malformed label keeps its default.
10const DEFAULT_LABELS: RecapLabels = {
11 handToClaude: 'Hand to Claude',
12 yourStep: 'Your next step',
13 waiting: 'Waiting on you',
14 then: 'Then',
15 start: 'Start',
16 done: 'Done',
17 reply: 'Reply',
18 details: 'Details',
19 less: 'Less',
20 dismiss: 'Dismiss',
21 lastReplyJustNow: 'Last reply just now',
22 lastReplyMinutesAgo: 'Last reply {n} min ago',
23 doneFill: 'I finished: {step}',
24 replyFill: 'About "{question}": ',
25 preparing: 'Preparing recap…',
26 nothingYet: 'Nothing to recap in this session yet',
27 failed: 'Recap failed',
28 fillFailed: 'Could not fill the prompt box; type the next step yourself',
29}
30
31// Placeholders a label must keep, so a translation cannot drop the value.
32const REQUIRED_PLACEHOLDERS: Partial<Record<keyof RecapLabels, string>> = {
33 lastReplyMinutesAgo: '{n}',
34 doneFill: '{step}',
35 replyFill: '{question}',
36}
37
38const view = atom({ plugin: 'vp-cc-recap', key: 'view' } as const, HIDDEN)
39const isExpanded = atom({ plugin: 'vp-cc-recap', key: 'isExpanded' } as const, false)
40const labels = atom({ plugin: 'vp-cc-recap', key: 'labels' } as const, DEFAULT_LABELS)
41
42// The one user message the fork answers. It reads the whole session from the
43// main thread's cached prefix, so only this message and the reply are new.
44const RECAP_PROMPT = `The user is coming back to this session after a break and has trouble holding context (ADHD). Write a recap that gets them moving again.
45
46Reply with one JSON object and nothing else, no code fence:
47{"next": "...", "next_by": "claude", "waiting": "...", "goal": "...", "done": ["..."], "ui": ${JSON.stringify(DEFAULT_LABELS)}}
48
49- next: exactly one concrete next step, one short phrase.
50- next_by: "claude" when the step is work for you, written as an instruction the user could send you; "user" when the user does it themselves (try, check, decide, run something), written as an instruction to the user.
51- waiting: only when you are blocked on the user's decision or approval, the question as one short phrase; otherwise omit the key.
52- goal: what this session is working toward, one short sentence.
53- done: at most 3 finished items, each a few words, newest first.
54- ui: the interface labels above, translated into the user's language. Keep every {placeholder} exactly as written. Keep them as short as the English.
55
56Language: write every value, ui labels included, in the language the user has mostly written in during this session. State outcomes, not process. No tool names, file paths or ids unless the next step needs one.`
57
58const asText = (value: unknown, limit: number) =>
59 typeof value === 'string' ? value.trim().slice(0, limit) : ''
60
61function parseLabels(value: unknown): RecapLabels {
62 if (typeof value !== 'object' || value === null) return DEFAULT_LABELS
63 const given = value as Record<string, unknown>
64 const result: RecapLabels = { ...DEFAULT_LABELS }
65 for (const key of Object.keys(DEFAULT_LABELS) as (keyof RecapLabels)[]) {
66 const text = typeof given[key] === 'string' ? (given[key] as string).slice(0, 80) : ''
67 const placeholder = REQUIRED_PLACEHOLDERS[key]
68 if (text.trim() !== '' && (placeholder === undefined || text.includes(placeholder))) {
69 result[key] = text
70 }
71 }
72 return result
73}
74
75function parseRecap(reply: string): { content: RecapContent; labels: RecapLabels } | undefined {
76 const start = reply.indexOf('{')
77 const end = reply.lastIndexOf('}')
78 if (start === -1 || end <= start) return undefined
79 try {
80 const data: unknown = JSON.parse(reply.slice(start, end + 1))
81 if (typeof data !== 'object' || data === null) return undefined
82 const fields = data as Record<string, unknown>
83 const next = asText(fields.next, 120)
84 const goal = asText(fields.goal, 120)
85 if (next === '' || goal === '') return undefined
86 const nextBy = fields.next_by === 'user' ? 'user' : 'claude'
87 const waiting = asText(fields.waiting, 120)
88 const done = Array.isArray(fields.done)
89 ? fields.done.map(item => asText(item, 80)).filter(item => item !== '').slice(0, 3)
90 : []
91 const content: RecapContent =
92 waiting === '' ? { next, nextBy, goal, done } : { next, nextBy, waiting, goal, done }
93 return { content, labels: parseLabels(fields.ui) }
94 } catch {
95 return undefined
96 }
97}
98
99async function makeRecap($: EngineInterface, trigger: RecapTrigger, lastTurnAt: number | undefined) {
100 await update($, view, () => ({ phase: 'generating', trigger }))
101 await update($, isExpanded, () => false)
102 const reply = await $.model.fork({ prompt: RECAP_PROMPT })
103 if (reply.isAnswered && reply.text.trim() !== '') {
104 const raw = reply.text.trim()
105 const parsed = parseRecap(raw)
106 if (parsed !== undefined) {
107 await update($, labels, () => parsed.labels)
108 }
109 await update($, view, () => ({ phase: 'shown', trigger, raw, content: parsed?.content, lastTurnAt }))
110 return
111 }
112 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
113 await update($, view, () =>
114 trigger === 'manual' ? { phase: 'error', trigger, reason: 'nothing-yet' } : HIDDEN,
115 )
116 return
117 }
118 const detail = reply.isAnswered ? 'empty-reply' : reply.reason
119 await update($, view, () =>
120 trigger === 'manual' ? { phase: 'error', trigger, reason: 'failed', detail } : HIDDEN,
121 )
122}
123
124/** Puts the next step in the prompt box without overwriting a draft, then hides the band. */
125async function startNext($: EngineInterface, text: string, fillFailed: string) {
126 const box = await $.prompt.read()
127 const filled = await $.prompt.fill(
128 box.text.trim() === '' ? { text } : { text: `\n${text}`, mode: 'append' },
129 )
130 if (!filled.isFilled) {
131 $.ui.toast(`vp-cc-recap: ${fillFailed}`)
132 return
133 }
134 await update($, view, () => HIDDEN)
135}
136
137const minutesAgo = (now: number, at: number | undefined) =>
138 at === undefined ? undefined : Math.max(0, Math.round((now - at) / 60_000))
139
140export const register: Register = (on, options) => {
141 const idleMinutes = Math.max(0, Number(options.idleMinutes ?? 5))
142 let idleTimer: Timer | undefined
143 let isGenerating = false
144 let lastTurnAt: number | undefined
145
146 const stopIdleTimer = () => {
147 idleTimer?.cancel()
148 idleTimer = undefined
149 }
150
151 on('session.start', async ($, e, next) => {
152 await $.command.register({
153 name: 'vp-cc-recap',
154 description: 'Show where this session stands and the one next step, above the prompt',
155 })
156
157 return next(e)
158 })
159
160 // No transcript line: the band is the answer, and nothing reaches the model.
161 on('command.run', { command: 'vp-cc-recap' }, async $ => {
162 stopIdleTimer()
163 if (!isGenerating) {
164 isGenerating = true
165 try {
166 await makeRecap($, 'manual', lastTurnAt)
167 } finally {
168 isGenerating = false
169 }
170 }
171
172 return {}
173 })
174
175 // New work makes a shown recap stale: hide it, then prepare a fresh one once
176 // the person has been idle for `idleMinutes`.
177 on('turn.complete', async ($, e, next) => {
178 const done = await next(e)
179 if (e.agentId !== undefined) {
180 return done
181 }
182 lastTurnAt = await $.clock.now()
183 const current = await read($, view)
184 if (current.phase === 'shown' || current.phase === 'error') {
185 await update($, view, () => HIDDEN)
186 }
187 stopIdleTimer()
188 if (idleMinutes > 0) {
189 idleTimer = $.clock.after(idleMinutes * 60_000, () => {
190 idleTimer = undefined
191 if (isGenerating) return
192 isGenerating = true
193 void makeRecap($, 'idle', lastTurnAt).finally(() => {
194 isGenerating = false
195 })
196 })
197 }
198
199 return done
200 })
201
202 // The person is back and acting: drop the timer and the recap.
203 on('prompt.submit', async ($, e, next) => {
204 stopIdleTimer()
205 const current = await read($, view)
206 if (current.phase === 'shown' || current.phase === 'error') {
207 await update($, view, () => HIDDEN)
208 }
209
210 return next(e)
211 })
212
213 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
214 const current = await read($, view)
215 if (current.phase === 'hidden' || e.props.hasSurvey) {
216 return next(e)
217 }
218 // An idle recap prepares quietly; only one the person asked for shows progress.
219 if (current.phase === 'generating' && current.trigger === 'idle') {
220 return next(e)
221 }
222
223 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
224 // The band is shared: draw above what the other plugins and the engine draw,
225 // behind this plugin's name so the person can tell the blocks apart.
226 const below = await next(e)
227 const stack = (tree: JSX.Element) => (
228 <Box flexDirection="column">
229 <Box gap={1} alignItems="flex-start">
230 <Text inverse bold>{' recap '}</Text>
231 {tree}
232 </Box>
233 {below}
234 </Box>
235 )
236 const text = await read($, labels)
237 const hide = () => update($, view, () => HIDDEN)
238 const dismiss = <Button key="dismiss" label={text.dismiss} hotkey="3" plain dimColor onPress={hide} />
239
240 if (current.phase === 'generating') {
241 return stack(
242 <Box>
243 <Text dimColor>⏳ {text.preparing}</Text>
244 </Box>,
245 )
246 }
247
248 if (current.phase === 'error') {
249 const message =
250 current.reason === 'nothing-yet' ? text.nothingYet : `${text.failed} (${current.detail ?? 'unknown'})`
251 return stack(
252 <Box gap={2}>
253 <Text dimColor>{message}</Text>
254 {dismiss}
255 </Box>,
256 )
257 }
258
259 const content = current.content
260 const expanded = await read($, isExpanded)
261 const ago = minutesAgo(await $.clock.now(), current.lastTurnAt)
262 const footer =
263 ago === undefined ? '' : ago === 0 ? text.lastReplyJustNow : text.lastReplyMinutesAgo.replace('{n}', () => String(ago))
264
265 if (content === undefined) {
266 return stack(
267 <Box flexDirection="column">
268 <Markdown text={current.raw.slice(0, 10_000)} />
269 {dismiss}
270 </Box>,
271 )
272 }
273
274 // The one thing to do comes first; a waiting decision outranks the next step.
275 // The primary button fits who acts: a prompt for Claude, a report back
276 // after the person's own step, or an answer to the waiting question.
277 const primary =
278 content.waiting !== undefined
279 ? {
280 label: `⚠ ${text.waiting}`,
281 text: content.waiting,
282 button: text.reply,
283 fill: text.replyFill.replace('{question}', () => content.waiting ?? ''),
284 }
285 : content.nextBy === 'user'
286 ? {
287 label: `⏭ ${text.yourStep}`,
288 text: content.next,
289 button: text.done,
290 fill: text.doneFill.replace('{step}', () => content.next),
291 }
292 : { label: `⏭ ${text.handToClaude}`, text: content.next, button: text.start, fill: content.next }
293
294 return stack(
295 <Box flexDirection="column">
296 <Text>
297 <Text bold>{primary.label}: </Text>
298 <Text bold>{primary.text}</Text>
299 </Text>
300 <Text dimColor>🎯 {content.goal}</Text>
301 {expanded && content.waiting !== undefined && (
302 <Text dimColor>
303 ⏭ {text.then}: {content.next}
304 </Text>
305 )}
306 {expanded &&
307 content.done.map(item => (
308 <Text dimColor>✅ {item}</Text>
309 ))}
310 <Box gap={2}>
311 <Button
312 key="start"
313 label={primary.button}
314 hotkey="1"
315 plain
316 onPress={() => startNext($, primary.fill, text.fillFailed)}
317 />
318 <Button
319 key="more"
320 label={expanded ? text.less : text.details}
321 hotkey="2"
322 plain
323 dimColor
324 onPress={() => update($, isExpanded, value => !value)}
325 />
326 {dismiss}
327 {footer !== '' && <Text dimColor>{footer}</Text>}
328 </Box>
329 </Box>,
330 )
331 })
332}
333types/index.d.ts 71 lines1/** What started a recap. */
2export type RecapTrigger = 'manual' | 'idle'
3
4/**
5 * Every string the band draws. The model returns them in the user's language
6 * with each recap; English defaults fill any it leaves out or gets wrong.
7 * `{n}`, `{step}` and `{question}` are placeholders the mod fills in.
8 */
9export type RecapLabels = {
10 handToClaude: string
11 yourStep: string
12 waiting: string
13 then: string
14 start: string
15 done: string
16 reply: string
17 details: string
18 less: string
19 dismiss: string
20 lastReplyJustNow: string
21 lastReplyMinutesAgo: string
22 doneFill: string
23 replyFill: string
24 preparing: string
25 nothingYet: string
26 failed: string
27 fillFailed: string
28}
29
30/** The recap the model returns, parsed from its JSON reply. */
31export type RecapContent = {
32 /** The one action the person can take now. */
33 next: string
34 /** Who carries out `next`: Claude, from a prompt, or the person themselves. */
35 nextBy: 'claude' | 'user'
36 /** A decision waiting on the person, when there is one; it outranks `next`. */
37 waiting?: string
38 /** What the session is working toward, one sentence. */
39 goal: string
40 /** At most three finished items. */
41 done: string[]
42}
43
44/** What the band above the prompt shows. */
45export type RecapView =
46 | { phase: 'hidden' }
47 | { phase: 'generating'; trigger: RecapTrigger }
48 | {
49 phase: 'shown'
50 trigger: RecapTrigger
51 /** Parsed recap; absent when the reply was not the expected JSON. */
52 content?: RecapContent
53 /** The reply as written, drawn when `content` is absent. */
54 raw: string
55 /** When the last main-thread turn ended, in clock milliseconds. */
56 lastTurnAt?: number
57 }
58 | { phase: 'error'; trigger: RecapTrigger; reason: 'nothing-yet' | 'failed'; detail?: string }
59
60declare module 'claude-code' {
61 interface PluginState {
62 'vp-cc-recap': {
63 view: RecapView
64 /** Whether the band shows the finished items too. */
65 isExpanded: boolean
66 /** The labels from the latest recap, so states drawn before a reply match its language. */
67 labels: RecapLabels
68 }
69 }
70}
71