SLOPSHOPPER

essential-conversation

Shows each turn's user prompt paired with Claude Code's last answer for that turn in a side pane (/conversation).

newpanerowscommand
v0.1.0no licenseupdated 2026-09-22SuzumiyaAoba/claude-essential-conversation-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · essential-conversation
› fix the failing auth test and add an audit log call ● essential-conversation: essential-conversation loaded — /conversation toggles the pane ⏺ 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 › /conversation ⎿ essential-conversation: Conversation pane closed ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-essential-conversation

A Claude Code plugin written with Mods (function hooks). It lists, in a side pane, the pair of each turn's typed prompt and Claude Code's last answer for that turn.

Note: Claude Mods (function hooks) are an Early Access feature as of September 2026; the API may still change.

File layout

.claude-plugin/plugin.json   # Plugin metadata
hooks/hooks.json             # Declares register.tsx to be loaded
hooks/register.tsx           # The mod itself (event registration, pane rendering)
tsconfig.json                 # Dev-time config for the types /plugin-types generates

Usage

  1. Enable function hooks (via ~/.claude/settings.json or an env var at session start).
   {
     "env": {
       "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
     }
   }
  1. Start a session with this directory as the plugin dir.
   CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .
  1. Run /conversation in the session to toggle the pane.

Development

To refresh the type definitions, run the following inside a session (writes .claude/types/claude-code.d.ts):

/plugin-types

Static validation of the plugin:

claude plugin validate .
Source 1 files
hooks/register.tsx 501 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { On, SessionMessage } from 'claude-code'
5
6const PANE_ID = 'essential-conversation'
7const PANE_TITLE = 'Conversation'
8const COMMAND_NAME = 'conversation'
9const MAX_PAIRS = 50
10const STORE_KEY = 'pairs'
11// Markdown's own hard cap (claude-code.d.ts); past it the element is refused outright.
12const MAX_MARKDOWN_CHARS = 10000
13// The same marker the transcript itself draws before an assistant block.
14const ANSWER_MARKER = '⏺ '
15
16type Pair = {
17  turnId: string
18  prompt: string
19  answer: string | null
20  isAborted: boolean
21  /**
22   * The transcript's own id for this pair's opening UserMessage row, learned
23   * from that row's own `ui.render` (its `requestId`) rather than guessed
24   * from `turnId` — the only id `$.ui.scroll` accepts. Null until that row
25   * has been drawn at least once: a live `turn.start` gets one, but a
26   * history-reconstructed pair (pairsFromMessages) never does — the engine
27   * raises no `ui.render` for a row replayed by session.start/`/resume`, so
28   * there is nothing here to learn it from.
29   */
30  realId: string | null
31}
32
33type PromptSegment =
34  | { type: 'text'; value: string }
35  | { type: 'pasted'; key: string; content: string }
36
37// The closing tag repeats `id="..."` too (`</pasted_content id="aedf">`,
38// not the bare `</pasted_content>` an XML reader would expect) — both
39// alternatives share this one shape, told apart below by the leading `/`.
40const PASTE_TOKEN = /<\/?pasted_content id="[^"]*">/g
41
42/**
43 * Splits a prompt on `<pasted_content id="...">...</pasted_content id="...">`
44 * spans (what the composer wraps a paste in) so each one can fold on its
45 * own, collapsed by default, instead of the whole prompt drawing as one
46 * wall of text. Depth-counted, not a lazy regex match: the pasted text
47 * itself can contain literal `<pasted_content>`-looking substrings (pasting
48 * a reply that quoted one), and only a real span's own matching close
49 * should end it. An unterminated span (an open with no matching close)
50 * falls back to the untouched prompt as one 'text' segment — nothing here
51 * is lost.
52 */
53function splitPastedContent(turnId: string, prompt: string): PromptSegment[] {
54  const segments: PromptSegment[] = []
55  let cursor = 0
56  let depth = 0
57  let blockStart = -1
58  let pasteIndex = 0
59  let match: RegExpExecArray | null
60
61  // A run of plain text right against a paste's tags is mostly the
62  // composer's own framing newlines; trimmed, an all-whitespace run (e.g.
63  // between two adjacent pastes) drops out rather than drawing as a blank
64  // line above or below the paste's own box.
65  function pushText(value: string) {
66    const trimmed = value.trim()
67
68    if (trimmed !== '') {
69      segments.push({ type: 'text', value: trimmed })
70    }
71  }
72
73  PASTE_TOKEN.lastIndex = 0
74
75  while ((match = PASTE_TOKEN.exec(prompt)) !== null) {
76    if (!match[0].startsWith('</')) {
77      if (depth === 0) {
78        if (match.index > cursor) {
79          pushText(prompt.slice(cursor, match.index))
80        }
81
82        blockStart = match.index
83      }
84
85      depth += 1
86      continue
87    }
88
89    if (depth === 0) {
90      continue
91    }
92
93    depth -= 1
94
95    if (depth === 0) {
96      const openTagEnd = prompt.indexOf('>', blockStart) + 1
97      const blockEnd = match.index + match[0].length
98
99      segments.push({
100        type: 'pasted',
101        key: `${turnId}:${pasteIndex}`,
102        // The composer wraps a paste in its own leading/trailing newline
103        // (`<pasted_content id="x">\ncontent\n</pasted_content ...>`), which
104        // read as blank lines above and below the text once drawn.
105        content: prompt.slice(openTagEnd, match.index).trim(),
106      })
107      pasteIndex += 1
108      cursor = blockEnd
109    }
110  }
111
112  if (depth !== 0) {
113    return [{ type: 'text', value: prompt }]
114  }
115
116  if (cursor < prompt.length) {
117    pushText(prompt.slice(cursor))
118  }
119
120  return segments
121}
122
123/**
124 * A transcript row that opens a turn: a typed user prompt, not a tool
125 * result. Mirrors the built-in diff pane's own row test, since
126 * `$.session.messages()` gives no `turnId` for history read this way.
127 */
128function isPromptRow(message: SessionMessage): boolean {
129  return (
130    message.role === 'user' &&
131    (message.toolResults === undefined || message.toolResults.length === 0) &&
132    message.text !== ''
133  )
134}
135
136/** Reconstructs prompt/answer pairs already in the transcript (fresh session.start, /resume). */
137function pairsFromMessages(messages: readonly SessionMessage[]): Pair[] {
138  const starts = messages.flatMap((message, at) => (isPromptRow(message) ? [at] : []))
139
140  return starts.map((start, ordinal) => {
141    const rows = messages.slice(start, starts[ordinal + 1])
142    const lastAnswer = [...rows].reverse().find(row => row.role === 'assistant' && row.text !== '')
143
144    return {
145      turnId: `history-${start}`,
146      prompt: rows[0]?.text ?? '',
147      answer: lastAnswer?.text ?? null,
148      isAborted: false,
149      realId: null,
150    }
151  })
152}
153
154type StoredPairs = {
155  sessionId: string
156  pairs: Pair[]
157}
158
159/**
160 * Validates `$.store.get(STORE_KEY)`'s `unknown` shape before trusting it as
161 * this session's own last-saved state, normalizing a `realId` an older
162 * version of this plugin may have stored without one.
163 */
164function parseStoredPairs(value: unknown): StoredPairs | null {
165  if (typeof value !== 'object' || value === null || !('sessionId' in value) || !('pairs' in value)) {
166    return null
167  }
168
169  const { sessionId, pairs } = value as { sessionId: unknown; pairs: unknown }
170
171  if (typeof sessionId !== 'string' || !Array.isArray(pairs)) {
172    return null
173  }
174
175  return {
176    sessionId,
177    pairs: (pairs as Pair[]).map(pair => ({ ...pair, realId: pair.realId ?? null })),
178  }
179}
180
181function answerTextOf(pair: Pair): string {
182  if (pair.answer === null) {
183    return `${ANSWER_MARKER}… generating a response`
184  }
185
186  const text = pair.answer === '' ? '(no response to show)' : pair.answer
187  const withStatus = pair.isAborted ? `${text}\n⏸ interrupted` : text
188  const budget = MAX_MARKDOWN_CHARS - ANSWER_MARKER.length
189  const body = withStatus.length > budget ? `${withStatus.slice(0, budget - 1)}…` : withStatus
190
191  return `${ANSWER_MARKER}${body}`
192}
193
194export function register(on: On) {
195  const pairs: Pair[] = []
196  let invalidate: (() => void) | null = null
197  let isPaneOpen = false
198  // Set once at session.start; the key persisted state is saved under, so a
199  // reload of this same running session can tell its own last save apart
200  // from another session's (a stale run, or /resume onto a different one).
201  let sessionId: string | null = null
202  // A per-turnId fold state for the pane alone — purely a display
203  // preference, so it starts empty (every card open) each time the plugin
204  // (re)loads rather than being kept in $.store with the pairs themselves.
205  const collapsedTurnIds = new Set<string>()
206  // Same idea, one entry per pasted-content span (PromptSegment's key):
207  // collapsed (absent) by default, expanded once its own toggle is pressed.
208  const expandedPasteKeys = new Set<string>()
209
210  function toggleCollapsed(turnId: string) {
211    if (collapsedTurnIds.has(turnId)) {
212      collapsedTurnIds.delete(turnId)
213    } else {
214      collapsedTurnIds.add(turnId)
215    }
216
217    invalidate?.()
218  }
219
220  function togglePaste(key: string) {
221    if (expandedPasteKeys.has(key)) {
222      expandedPasteKeys.delete(key)
223    } else {
224      expandedPasteKeys.add(key)
225    }
226
227    invalidate?.()
228  }
229
230  function pushPrompt(turnId: string, text: string) {
231    pairs.push({ turnId, prompt: text, answer: null, isAborted: false, realId: null })
232
233    if (pairs.length > MAX_PAIRS) {
234      pairs.splice(0, pairs.length - MAX_PAIRS)
235    }
236  }
237
238  function setAnswer(turnId: string, answer: string, isAborted: boolean) {
239    const pair = pairs.find(p => p.turnId === turnId)
240
241    if (pair) {
242      pair.answer = answer
243      pair.isAborted = isAborted
244    }
245  }
246
247  /**
248   * Matches a drawn UserMessage row back to the pair it opened, by its exact
249   * text against the oldest pair still missing a `realId` — the live
250   * transcript draws prompts in the same order `pairs` holds them, so the
251   * earliest unmatched match is the right one even when two turns share
252   * identical text. Only ever fires for a live turn's row; a history one is
253   * never drawn, so it is never called for those (see Pair.realId).
254   */
255  function learnRealId(text: string, requestId: string): boolean {
256    const pair = pairs.find(p => p.realId === null && p.prompt === text)
257
258    if (!pair) {
259      return false
260    }
261
262    pair.realId = requestId
263    invalidate?.()
264
265    return true
266  }
267
268  on('session.start', async ($, e, next) => {
269    invalidate = () => $.ui.invalidate('ui.render')
270    sessionId = await $.session.id().catch(() => null)
271
272    // A hot reload of this plugin re-runs register() from scratch, wiping
273    // pairs and, worse, losing the real turnId of any turn still in
274    // flight — pairsFromMessages would then mint that same turn a fresh
275    // history-N id, and the turn.complete already on its way (carrying the
276    // original turnId) would never find it again. Restoring exactly what
277    // was last saved under this same sessionId keeps that turn's identity
278    // (and any realId already learned for other turns) intact across the
279    // reload; only a genuinely new or /resume'd session falls back to
280    // reconstructing from the transcript.
281    const stored = parseStoredPairs(await $.store.get(STORE_KEY).catch(() => undefined))
282
283    if (stored !== null && sessionId !== null && stored.sessionId === sessionId) {
284      pairs.push(...stored.pairs)
285    } else {
286      const messages = await $.session.messages().catch((): SessionMessage[] => [])
287      pairs.push(...pairsFromMessages(messages))
288    }
289
290    if (sessionId !== null) {
291      $.store.set(STORE_KEY, { sessionId, pairs }).catch(() => undefined)
292    }
293
294    await $.command
295      .register({
296        name: COMMAND_NAME,
297        description: 'Toggle the prompt / last-answer pane for this session',
298      })
299      .catch(() => undefined)
300
301    // Shown at once when there is room; too narrow, the engine holds it
302    // undrawn until /conversation (a person's own open is placed at any
303    // width) — see $.ui.open's PaneOpenArgs docs.
304    await $.ui
305      .open({ id: PANE_ID, title: PANE_TITLE, holdToasts: true })
306      .then(() => {
307        isPaneOpen = true
308        // Oldest-first puts prior history above the fold; start scrolled to
309        // the newest turn instead of wherever the pane happens to mount.
310        return $.ui.scroll({ to: 'end', in: PANE_ID })
311      })
312      .catch(() => undefined)
313
314    $.ui.log(`essential-conversation loaded — /${COMMAND_NAME} toggles the pane`)
315
316    return next(e)
317  })
318
319  // A subagent's run also raises turn.complete (with agentId set) but no
320  // turn.start of its own, so only the main loop's turns are tracked here.
321  on('turn.start', ($, e, next) => {
322    if (e.text !== '') {
323      pushPrompt(e.turnId, e.text)
324      invalidate?.()
325      // Oldest-first now puts a fresh card at the bottom; without this the
326      // pane can sit scrolled to wherever it last was and the new card (and
327      // later its answer) never comes into view on its own.
328      $.ui.scroll({ to: 'end', in: PANE_ID }).catch(() => undefined)
329
330      if (sessionId !== null) {
331        $.store.set(STORE_KEY, { sessionId, pairs }).catch(() => undefined)
332      }
333    }
334
335    return next(e)
336  })
337
338  on('turn.complete', ($, e, next) => {
339    if (e.agentId === undefined) {
340      setAnswer(e.turnId, e.answer, e.isAborted)
341      invalidate?.()
342      $.ui.scroll({ to: 'end', in: PANE_ID }).catch(() => undefined)
343
344      if (sessionId !== null) {
345        $.store.set(STORE_KEY, { sessionId, pairs }).catch(() => undefined)
346      }
347    }
348
349    return next(e)
350  })
351
352  // Core's own row, not this plugin's — observing it here (rather than
353  // trusting turn.start's turnId to double as the message id) is what makes
354  // a live prompt's jump button work. It does nothing for history: the
355  // engine never raises this for a row session.start/`/resume` replayed.
356  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
357    if (learnRealId(e.props.text, e.requestId) && sessionId !== null) {
358      $.store.set(STORE_KEY, { sessionId, pairs }).catch(() => undefined)
359    }
360
361    return next(e)
362  })
363
364  on('command.run', { command: COMMAND_NAME }, async ($, e, next) => {
365    if (isPaneOpen) {
366      await $.ui.close({ id: PANE_ID })
367      isPaneOpen = false
368
369      return { text: `${PANE_TITLE} pane closed` }
370    }
371
372    await $.ui.open({ id: PANE_ID, title: PANE_TITLE, holdToasts: true, focus: true })
373    isPaneOpen = true
374
375    return { text: `${PANE_TITLE} pane opened` }
376  })
377
378  // Catches a close the person made directly (Esc, ctrl+x x), which never
379  // runs through this plugin's own $.ui.close call above.
380  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
381    const result = await next(e)
382
383    if (result.deny === undefined) {
384      isPaneOpen = false
385    }
386
387    return result
388  })
389
390  on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
391    const result = await next(e)
392
393    pairs.length = 0
394
395    if (e.command === 'resume') {
396      const messages = await $.session.messages().catch((): SessionMessage[] => [])
397      pairs.push(...pairsFromMessages(messages))
398    }
399
400    invalidate?.()
401    $.ui.scroll({ to: 'end', in: PANE_ID }).catch(() => undefined)
402
403    if (sessionId !== null) {
404      $.store.set(STORE_KEY, { sessionId, pairs }).catch(() => undefined)
405    }
406
407    return result
408  })
409
410  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
411    if (e.requestId !== PANE_ID) {
412      return next(e)
413    }
414
415    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
416    // Oldest first, top to bottom — the same order the transcript itself reads in.
417    const shown = pairs
418
419    // Scrolls the main transcript to this pair's opening prompt. Only a
420    // person's own click/Enter reaches here — ui.scroll refuses a plugin-
421    // initiated jump on a transcript row — and only once learnRealId has
422    // matched this pair to its UserMessage row's own requestId; until then
423    // the button is left out rather than offering a jump that always fails.
424    function jumpToRealId(requestId: string) {
425      $.ui
426        .scroll({ to: { requestId }, block: 'start' })
427        .then(result => {
428          if (result.deny !== undefined) {
429            $.ui.log(`Jump failed: ${result.deny}`)
430          }
431        })
432        .catch(() => undefined)
433    }
434
435    return (
436      <Box flexDirection="column" paddingRight={1}>
437        <Text dimColor>{pairs.length} turn(s)</Text>
438        {shown.length === 0 && <Text dimColor>No turns yet</Text>}
439        {shown.map(pair => {
440          const { realId } = pair
441          const isCollapsed = collapsedTurnIds.has(pair.turnId)
442          const promptSegments = splitPastedContent(pair.turnId, pair.prompt)
443
444          return (
445            <Box key={pair.turnId} flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
446              <Box flexDirection="row" justifyContent="space-between">
447                <Button plain dimColor onPress={() => toggleCollapsed(pair.turnId)}>
448                  {isCollapsed ? '[+]' : '[-]'}
449                </Button>
450                {realId !== null && (
451                  <Button plain dimColor onPress={() => jumpToRealId(realId)}>
452                    ⤴ Jump to start
453                  </Button>
454                )}
455              </Box>
456              <Box flexDirection="column">
457                {promptSegments.map((segment, index) => {
458                  const prefix = index === 0 ? '❯ ' : ''
459
460                  if (segment.type === 'text') {
461                    return (
462                      <Text key={`${pair.turnId}:text:${index}`} bold color="cyan" wrap="wrap">
463                        {prefix}
464                        {segment.value}
465                      </Text>
466                    )
467                  }
468
469                  const isPasteOpen = expandedPasteKeys.has(segment.key)
470
471                  const pasteLabel = `${prefix}${isPasteOpen ? '[-]' : '[+]'} pasted content (${segment.content.length} chars)${isPasteOpen ? '' : ' — click to expand'}`
472
473                  return (
474                    <Box key={segment.key} flexDirection="column">
475                      <Button plain dimColor onPress={() => togglePaste(segment.key)}>
476                        {pasteLabel}
477                      </Button>
478                      {isPasteOpen && (
479                        <Box borderStyle="round" borderDimColor paddingX={1}>
480                          <Text bold color="cyan" wrap="wrap">
481                            {segment.content}
482                          </Text>
483                        </Box>
484                      )}
485                    </Box>
486                  )
487                })}
488              </Box>
489              {!isCollapsed && (
490                <Box marginTop={1}>
491                  <Markdown text={answerTextOf(pair)} />
492                </Box>
493              )}
494            </Box>
495          )
496        })}
497      </Box>
498    )
499  })
500}
501