SLOPSHOPPER

vp-cc-recap

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

newbandcommandtoastpromptmodel
v0.2.2MITupdated 2026-10-06VdustR/vp-cc-mods/plugins/vp-cc-recap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vp-cc-recap
› fix the failing auth test and add an audit log call ⏺ 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 › /vp-cc-recap recap OK 3: Dismiss ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
recap OK 3: Dismiss ⟨Claude Code's own drawing⟩
README

vp-cc-recap

An ADHD-friendly session recap for Claude Code, drawn above the prompt, in the language you write in.

Why

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.

  • The next step comes first. The first line is a single action, in bold. When Claude is waiting on your decision, that question takes the first line instead, because nothing else moves until you answer it.
  • Little to read. The default view is three lines: the next step, the goal in one sentence, and the buttons. Finished items stay behind Details.
  • Starting takes one press. The primary button puts the next step in the prompt box for you to review and send. It never sends anything on its own.
  • It tells who acts. A step for Claude reads ⏭ Hand to Claude and its Start button fills in the instruction. A step for you reads ⏭ Your next step and its Done button fills in a report back to Claude.
  • A sense of time. The last line says how long ago the last reply arrived, for when you lose track of time.
  • Your language. The model writes the recap and every label in the language you have mostly used in the session, so the band switches language with you. English is the fallback.
  • It stays quiet. The command leaves nothing in the transcript and nothing in what Claude reads. An automatic recap prepares in the background and appears only when it is ready. A new turn or a new prompt hides a stale one.

What it looks like

 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.

Use

| 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.

Configure

| 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).

How it works

  • The recap is one $.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.
  • The model answers in JSON: next, next_by, waiting, goal, done, and ui, the band's labels translated into the user's language. The mod lays them out.
  • A label that is missing, empty, or drops its placeholder ({n}, {step}, {question}) keeps its English default. A reply that is not valid JSON is shown as Markdown instead.
  • The latest labels are kept for the session, so states drawn before a reply arrives (preparing, nothing to recap yet, failed) use the same language as the last recap. Before the first recap they are in English.
  • The idle timer starts when a main-thread turn completes. A subagent's turn does not start it. A new prompt cancels the timer.

Limits

  • The language follows the model's reading of the session. A session that mixes languages gets the one the model judges you use most.
  • The idle timer counts from the end of a turn. Whether the Desktop window is focused is not tracked.
  • A mod loaded in the middle of a session misses the turn that was running when it loaded. The automatic recap starts after the next turn.
  • Each recap is one extra model request.

Develop

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.

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