SLOPSHOPPER

explain-again

After a longer reply, offers to re-explain it in plain English, as a diagram, a web page or a video, sent now or edited first (after Karpathy's output-format…

newbandrowscommandprompttimer
v0.3.0MITupdated 2026-10-07smughead/explain-again
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · explain-again
› 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 › /explain ⎿ explain-again: Pick a format above the message box. Explain your last reply another way “Done. I made `refresh` reject expired claims, added an audit call, and created [ In plain English ] [ As a diagram ] [ As a web page ] [ As a video ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Explain your last reply another way “Done. I made `refresh` reject expired claims, added an audit ca [ In plain English ] [ As a diagram ] [ As a web page ] [ As a video ]
README

Explain Again

A Claude Code mod that re-explains Claude's latest long reply in a format that is easier to take in: in plain English, as a diagram, as a web page, or as a video.

Clicking "As a web page" under a long Claude reply, then "Send now". Claude builds an interactive page that explains the reply.

It grew out of Andrej Karpathy's post of October 1, 2026 about output formats for understanding what language models produce: clear controlled writing (ASD-STE100), diagrams, web pages and explainer videos. As AI does more of the work, more of our job becomes understanding it. This mod puts those formats one click away, right where a reply loses you.

What you see

Under Claude's latest long reply (about 100 words or more):

Explain another way   [In plain English]   [As a diagram]   [As a web page]   [As a video]

Click one and the same row turns into a choice:

As a diagram:   [Send now]   [Edit first]   Back
  • Send now asks Claude right away.
  • Edit first puts the request in your message box so you can adjust it before sending.
  • Back returns to the four formats.

The format you pick then stays on for the rest of the session: every later long reply comes in that format, with no extra click. Under the latest reply (short ones too, while a format is on) the row shows what is on, a Stop button, and the other formats if you want to switch:

Explaining as a video   [Stop]   Switch to   [In plain English]   [As a diagram]   [As a web page]

Edit first turns the format on once you send the draft, as long as it still asks for that format (for example, it still says "video"). The choice is forgotten when the session ends.

The row moves to each new long reply and steps aside while Claude is working. Short answers get no row unless a format is on. Web pages and videos are saved outside your project, so your code folders stay clean.

Commands

  • /explain opens the same choice above the message box, for your last reply.
  • /explain plain, /explain diagram, /explain page or /explain video sends right away and keeps that format on.
  • /explain off stops it.

Install

From GitHub:

claude plugin marketplace add smughead/explain-again
claude plugin install explain-again@explain-again

Then start a new session. The mod loads when a session starts.

For development (live editing): point Claude Code at this folder in ~/.claude/settings.json. Terminal sessions reload the mod whenever a file changes. Desktop app sessions only do so with CLAUDE_CODE_PLUGIN_DIR_WATCH set; without it they keep the version they started with, so test changes in a new session.

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/Documents/projects/explain-again", "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1" } }

Either way, the setting is read when a session starts.

Requires Claude Code 2.1.286 or later. Works in the desktop app's Code tab and in the terminal.

Develop

Use the Claude Code build that ships with the desktop app (a standalone claude may be too old for mods):

claude plugin validate ./.claude-plugin/plugin.json
claude plugin validate .
claude plugin test .

The first line checks the mod itself; the second checks the install file (marketplace.json).

Why the mod looks and behaves the way it does, and what the platform can't do yet: docs/decisions.md.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 318 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4// Replies shorter than this (characters) don't get the row: "Done", yes/no, etc. While a format is on,
5// every latest reply gets it, so the format and Stop stay in view (a diagram reply has little text).
6const MIN_ANSWER_LENGTH = 600
7
8// The Markdown element draws at most this many characters; a longer reply keeps the engine's own drawing.
9const MAX_MARKDOWN_LENGTH = 10000
10
11// How much of the reply the /explain chooser quotes so the person can see which reply it means.
12const QUOTE_LENGTH = 80
13
14// The main conversation's latest answer, whether it is finished (the row may sit under it), whether /explain opened
15// the chooser, and which format was clicked (its Send now / Edit first choice is showing).
16const lastAnswer = atom({ plugin: 'explain-again', key: 'lastAnswer' } as const, null)
17const isRowOffered = atom({ plugin: 'explain-again', key: 'isRowOffered' } as const, false)
18const isChooserOpen = atom({ plugin: 'explain-again', key: 'isChooserOpen' } as const, false)
19const picked = atom({ plugin: 'explain-again', key: 'picked' } as const, null)
20
21// The format that stays on for this session (every later long reply comes in it, until Stop), and the
22// format whose Edit first draft is waiting in the message box (sending it turns that format on).
23const keptFormat = atom({ plugin: 'explain-again', key: 'keptFormat' } as const, null)
24const draftFormat = atom({ plugin: 'explain-again', key: 'draftFormat' } as const, null)
25
26// One entry per format in Karpathy's post, in his "but even better" order. Each request reads like
27// something the person would type, so it is easy to edit; anything saved goes outside the project.
28const FORMATS = [
29  {
30    key: 'plain',
31    label: 'In plain English',
32    request:
33      'Explain your last reply again in plain, simple English: short sentences, one idea each, everyday words (ASD-STE100 style, about 80% strict).',
34    kept: 'in plain, simple English: short sentences, one idea each, everyday words (ASD-STE100 style, about 80% strict)',
35  },
36  {
37    key: 'diagram',
38    label: 'As a diagram',
39    request: 'Show your last reply as a diagram I can take in at a glance, right here in the chat.',
40    kept: 'as a diagram they can take in at a glance, right here in the chat, with only the words it needs',
41  },
42  {
43    key: 'page',
44    label: 'As a web page',
45    request:
46      'Turn your last reply into an interactive web page that explains it. Save it outside this project (a temporary folder is fine) and open it for me.',
47    kept: 'as an interactive web page that explains it, saved outside this project (a temporary folder is fine) and opened for them, with a two-line summary in the chat',
48  },
49  {
50    key: 'video',
51    label: 'As a video',
52    request:
53      'Make a short 3Blue1Brown-style explainer video of your last reply. Save it outside this project, prefer free local tools and a free local voice, and tell me your plan and how long it will take before starting anything slow or installing anything.',
54    kept: 'as a short 3Blue1Brown-style explainer video, saved outside this project with free local tools and a free local voice, with a two-line summary in the chat. Before starting anything slow or installing anything, say your plan and how long it will take',
55  },
56] as const
57
58type Format = (typeof FORMATS)[number]
59type Shared = Pick<Elements['desktop'], 'Box' | 'Button' | 'Text'>
60
61const normalize = (text: string) => text.replace(/\s+/g, ' ').trim()
62
63// Messages the person sent themselves: typed in the terminal, in the desktop app (its Code tab hosts
64// Claude Code through the SDK, so its messages arrive as `sdk`), or from their phone or the web.
65const PERSON_ORIGINS = ['composer', 'sdk', 'bridge']
66
67// What Claude reads beside each of the person's messages while a format stays on; they never see it.
68const keptNote = (format: Format) =>
69  `The person chose to keep Explain Again's "${format.label}" format on for this session. If your reply to this message would be long (about 100 words or more), first do what they asked, then give the reply ${format.kept}. Keep short replies short. The format stays on until they stop it.`
70
71// "In plain English" reads as "Explaining in plain English"; the others as "Explaining as a video".
72const statusFor = (format: Format) => `Explaining ${format.label.charAt(0).toLowerCase()}${format.label.slice(1)}`
73
74// `/explain diagram`, `/explain web`, `/explain plainer`...: a format's key or any word of its label.
75function formatFor(args: string): Format | undefined {
76  const word = args.trim().toLowerCase()
77
78  if (word.length < 3) {
79    return undefined
80  }
81
82  return FORMATS.find(format => format.key === word || format.label.toLowerCase().split(' ').includes(word))
83}
84
85function actionsFor($: EngineInterface) {
86  const reset = async () => {
87    await update($, picked, () => null)
88    await update($, isChooserOpen, () => false)
89  }
90
91  return {
92    reset,
93    pick: (format: Format) => update($, picked, () => format.key),
94    back: () => update($, picked, () => null),
95    stop: async () => {
96      await update($, keptFormat, () => null)
97      await update($, draftFormat, () => null)
98    },
99    // Sent as the person's own words: they chose it. The transcript still names the plugin.
100    // The format then stays on for the rest of the session.
101    send: async (format: Format) => {
102      await reset()
103      await update($, keptFormat, () => format.key)
104      await $.prompt.submit({ text: format.request, asUser: true })
105    },
106    // Cues the request up in the message box to edit before sending; sending it keeps the format on.
107    edit: async (format: Format) => {
108      await reset()
109      await update($, draftFormat, () => format.key)
110      await $.prompt.fill({ text: format.request })
111    },
112  }
113}
114
115// The choices, in one row that swaps in place: first the four formats; after a click, that format's
116// Send now / Edit first. Same height, spacing and gray lead in both states, so the swap stays calm.
117// While a format stays on, the row leads with it and Stop, then offers the other formats to switch to.
118// The gray "Switch to" splits the two groups, so Stop does not read as one more format.
119function choiceRow(
120  { Box, Button, Text }: Shared,
121  actions: ReturnType<typeof actionsFor>,
122  pickedKey: string | null,
123  keptKey: string | null,
124  lead?: string,
125) {
126  const chosen = FORMATS.find(format => format.key === pickedKey)
127  const kept = FORMATS.find(format => format.key === keptKey)
128
129  if (chosen !== undefined) {
130    return (
131      <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
132        <Text dimColor>{chosen.label}:</Text>
133        <Button key="send-now" label="Send now" variant="primary" onPress={() => actions.send(chosen)} />
134        <Button key="edit-first" label="Edit first" onPress={() => actions.edit(chosen)} />
135        <Button key="back" label="Back" plain dimColor onPress={actions.back} />
136      </Box>
137    )
138  }
139
140  if (kept !== undefined) {
141    return (
142      <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
143        <Text dimColor>{statusFor(kept)}</Text>
144        <Button key="stop" label="Stop" onPress={actions.stop} />
145        <Box marginLeft={1}>
146          <Text dimColor>Switch to</Text>
147        </Box>
148        {FORMATS.filter(format => format !== kept).map(format => (
149          <Button key={format.key} label={format.label} onPress={() => actions.pick(format)} />
150        ))}
151      </Box>
152    )
153  }
154
155  return (
156    <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
157      {lead !== undefined && <Text dimColor>{lead}</Text>}
158      {FORMATS.map(format => (
159        <Button key={format.key} label={format.label} onPress={() => actions.pick(format)} />
160      ))}
161    </Box>
162  )
163}
164
165export const register: Register = on => {
166  on('session.start', async ($, e, next) => {
167    await $.command.register({
168      name: 'explain',
169      description: 'Explain your last reply another way',
170      argumentHint: '[plain | diagram | page | video | off]',
171    })
172
173    return next(e)
174  })
175
176  // While Claude works on something new, the row steps away from the previous reply.
177  on('turn.start', async ($, e, next) => {
178    await update($, isRowOffered, () => false)
179    await update($, picked, () => null)
180
181    return next(e)
182  })
183
184  // The person's own messages carry the kept format's note for Claude. A sent Edit first draft turns
185  // its format on, unless the person rewrote it into something else (it no longer names the format).
186  on('prompt.submit', async ($, e, next) => {
187    if (!PERSON_ORIGINS.includes(e.origin.kind)) {
188      return next(e)
189    }
190
191    const draftKey = await read($, draftFormat)
192
193    if (draftKey !== null) {
194      await update($, draftFormat, () => null)
195
196      if (e.text.toLowerCase().includes(draftKey)) {
197        await update($, keptFormat, () => draftKey)
198      }
199    }
200
201    const keptKey = await read($, keptFormat)
202    const kept = FORMATS.find(format => format.key === keptKey)
203
204    if (kept === undefined) {
205      return next(e)
206    }
207
208    return next({ ...e, context: [...(e.context ?? []), keptNote(kept)] })
209  })
210
211  on('turn.complete', async ($, e, next) => {
212    const isMainAnswer = e.agentId === undefined && e.reason === 'answer' && e.answer.trim() !== ''
213
214    if (isMainAnswer) {
215      await update($, lastAnswer, () => e.answer)
216      await update($, isRowOffered, () => true)
217      await update($, isChooserOpen, () => false)
218      await update($, picked, () => null)
219    }
220
221    return next(e)
222  })
223
224  // `/explain` opens the chooser above the message box; `/explain diagram` (or plain, page, video) sends at
225  // once and keeps that format on; `/explain off` stops it.
226  on('command.run', { command: 'explain' }, async ($, e) => {
227    if (['off', 'stop'].includes(e.args.trim().toLowerCase())) {
228      const keptKey = await read($, keptFormat)
229      const kept = FORMATS.find(format => format.key === keptKey)
230
231      await actionsFor($).stop()
232
233      return { text: kept === undefined ? 'No format is on.' : `Stopped ${statusFor(kept).toLowerCase()}.` }
234    }
235
236    if ((await read($, lastAnswer)) === null) {
237      return { text: 'Nothing to explain yet. Ask Claude something first.' }
238    }
239
240    const format = formatFor(e.args)
241
242    if (format !== undefined) {
243      // On a timer so the request outlives this command's own run, then starts once the session is idle.
244      await update($, keptFormat, () => format.key)
245      $.clock.after(0, () => void $.prompt.submit({ text: format.request, asUser: true }))
246
247      return { text: `Explaining your last reply ${format.label.toLowerCase()}, and later long replies too. /explain off stops it.` }
248    }
249
250    if (e.args.trim() !== '') {
251      return { text: `Unknown format "${e.args.trim()}". Try plain, diagram, page, video or off.` }
252    }
253
254    await update($, picked, () => null)
255    await update($, isChooserOpen, () => true)
256
257    return { text: 'Pick a format above the message box.' }
258  })
259
260  // The row: under the last piece of the latest long reply only (any latest reply while a format is on).
261  // Every other reply keeps the engine's own drawing, and the row moves on as soon as Claude starts something new.
262  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
263    const answer = await read($, lastAnswer)
264    const isFinished = await read($, isRowOffered)
265    const keptKey = await read($, keptFormat)
266    const { text } = e.props
267    const piece = normalize(text)
268    const isOffered = isFinished && answer !== null && (keptKey !== null || answer.trim().length >= MIN_ANSWER_LENGTH)
269    const isLatestEnding = isOffered && piece !== '' && normalize(answer).endsWith(piece)
270
271    if (!isLatestEnding || text.length > MAX_MARKDOWN_LENGTH) {
272      return next(e)
273    }
274
275    const elements = $.ui.resolve(e)
276    const { Box, Markdown } = elements
277
278    return (
279      <Box flexDirection="column">
280        <Markdown text={text} />
281        <Box marginTop={1}>{choiceRow(elements, actionsFor($), await read($, picked), keptKey, 'Explain another way')}</Box>
282      </Box>
283    )
284  })
285
286  // The chooser /explain opens above the message box, for the latest reply.
287  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
288    const answer = await read($, lastAnswer)
289    const isOpen = await read($, isChooserOpen)
290
291    if (e.props.hasSurvey || e.props.isWorking || !isOpen || answer === null) {
292      return next(e)
293    }
294
295    const elements = $.ui.resolve(e)
296    const { Box, Button, Text } = elements
297    const actions = actionsFor($)
298    const flat = normalize(answer)
299    const opening = flat.length > QUOTE_LENGTH ? `${flat.slice(0, QUOTE_LENGTH)}…` : flat
300
301    // Heading and Dismiss share the top line so Dismiss can never be pushed out of view.
302    return (
303      <Box flexDirection="column">
304        <Box flexDirection="row" justifyContent="space-between" alignItems="center" marginBottom={1}>
305          <Box flexDirection="row" gap={1} flexShrink={1}>
306            <Box flexShrink={0}>
307              <Text bold>Explain your last reply another way</Text>
308            </Box>
309            <Text dimColor wrap="truncate">“{opening}”</Text>
310          </Box>
311          <Button key="dismiss" label="Dismiss" role="dismiss" plain dimColor onPress={actions.reset} />
312        </Box>
313        {choiceRow(elements, actions, await read($, picked), await read($, keptFormat))}
314      </Box>
315    )
316  })
317}
318
types/index.d.ts 15 lines
1export type Flag = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'explain-again': {
6      lastAnswer: string | null
7      isRowOffered: boolean
8      isChooserOpen: boolean
9      picked: string | null
10      keptFormat: string | null
11      draftFormat: string | null
12    }
13  }
14}
15