Shows each turn's user prompt paired with Claude Code's last answer for that turn in a side pane (/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.
.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
~/.claude/settings.json or an env var at session start). {
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .
/conversation in the session to toggle the pane.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 .hooks/register.tsx 501 lines1/* @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