A button under Claude's last reply that copies everything Claude wrote since your last message: every reply block, its visible thinking, and the questions it…

<img src="icon.svg" width="128" height="128" alt="Copy latest replies icon">
<h1 align="center">Copy latest replies</h1>
<strong>One button to copy everything Claude said since your last message.</strong><br> A Claude Code mod for the terminal and the Desktop app · MIT · <a href="#privacy-and-permissions">Privacy</a>
<img src="docs/button.png" width="350" alt="The Copy latest replies button under Claude's last reply"> <img src="docs/button-copied.png" width="350" alt="The same button showing a green check after copying">
Claude Code now splits a long piece of work into several reply blocks. Each time a subagent sends the session a message, a new block starts, with its own copy button. After a few hours of autonomous work, what Claude told you since your last message can be spread over twenty blocks.
Reading all of it matters. We review less and less of the code an agent writes, but the agent's report is where the decisions are: what it chose, what it skipped, what it needs from you. Copying it block by block is slow and easy to get wrong.
This mod adds one button under Claude's last reply. It copies everything Claude wrote since the last message you sent, in order, ready to paste into your notes, a review tool such as SpeedRead, or another model.
Everything Claude shows you since the last message you sent from the prompt box:
What it leaves out: your messages, tool calls and their output.
Reply blocks are separated by a markdown rule, exactly where the app shows a new copy button. Questions are set apart the same way:
Placeholders added and translators told. Now the open choices.
---
Q: [PostgreSQL] Move the containers to the PostgreSQL 18 client?
A: Client 18 (Recommended)
---
Noted: PostgreSQL 18 client. Starting the migration.
---
The second reviewer found one blocking issue in the harness…
Once the button is pressed, a green check replaces its label for a moment. The command /copy-latest-replies does the same from the prompt.
In a Claude Code session:
/plugin install copy-latest-replies --marketplace TomySpagnoletti/cc-mods-copy-latest-replies
Or from your shell:
claude plugin marketplace add TomySpagnoletti/cc-mods-copy-latest-replies
claude plugin install copy-latest-replies@cc-mods-copy-latest-replies
Then start a new session, or run /reload-plugins.
Requirements: Claude Code v2.1.287 or later in the terminal, or a Desktop app whose Code tab reports v2.1.286 or later in /status. Claude Code mods are an early-access feature, so a future release may change what they can do.
The mod reads the session's transcript, the JSONL file Claude Code keeps on your disk, rather than what the session holds in memory. That way it still finds everything after a compaction.
{"kind": "human"}, and nothing else carries it: not task notifications, not messages from subagents, not compaction summaries, not your answers to Claude's questions. The mod copies everything after the last row with that mark.grep and reads from there in chunks.Everything stays on your machine. The mod makes no network request, and the only place it sends anything is your clipboard.
What it reads. Your session's transcript, which holds the conversation, and the session's messages, to know which reply is the last one. To locate the transcript it reads two environment variables, CLAUDE_CONFIG_DIR and HOME.
What it sends, and where. The copied text, to your clipboard, and only when you press the button or run /copy-latest-replies. It goes through Claude Code's own copy call. When that call fails on the terminal or the Desktop app, the mod hands the same text to your system's clipboard tool on its standard input.
Which programs it runs, and why. Each program name is fixed in the code. The arguments are the transcript's path, the session id and line numbers.
| Program | Command | Why |
|---|---|---|
find | find <config dir>/projects -maxdepth 2 -name <session id>.jsonl | Locate the transcript, when no hook event has named it yet |
grep | grep -n -o -F '"origin":{"kind":"human"}' <transcript> | Find the lines of the prompts you sent |
wc | wc -l <transcript> | Count the transcript's lines |
sed | sed -n '<first>,<last>p' <transcript> | Read the transcript from your last prompt on, in chunks |
pbcopy, powershell, wl-copy, xclip | pbcopy · powershell -NoProfile -Command "Set-Clipboard -Value ([Console]::In.ReadToEnd())" · wl-copy · xclip -selection clipboard | Clipboard fallback on macOS, Windows, Wayland and X11, tried in that order, only when Claude Code's copy call fails |
Which events it hooks. None of them change what passes through.
| Event | What the mod does |
|---|---|
session.start | Registers the /copy-latest-replies command |
classic.SessionStart, classic.UserPromptSubmit | Keeps the transcript path the event carries, and passes the event on unchanged |
turn.start, turn.complete | Hides the button while Claude works on a turn of the main conversation, and shows it again when the turn ends |
command.run, for /copy-latest-replies only | Copies the replies and answers ✓ Copied or ✗ Nothing copied |
ui.render, for AssistantMessage | Draws the button under Claude's last reply, and leaves the reply itself as it is |
You can check all of this before installing: claude plugin validate on a clone of this repository prints every event the mod hooks and every call it makes.
claude plugin validate --strict .
claude plugin test .
To try a change, start Claude Code with claude --plugin-dir . from a clone.
MIT © 2026 Tomy Spagnoletti Duval
hooks/register.tsx 202 lines1import type { EngineInterface, Register, RenderSurface, SessionMessage } from 'claude-code'
2
3import { isLocalSurface, SYSTEM_CLIPBOARD_TOOLS } from './clipboard'
4import { CopyButton } from './copy-button'
5import { currentFeedback, endFeedback, type FeedbackKind, startFeedback, STEP_MS, stepFeedback } from './feedback'
6import { parseRows, type Replies, repliesSinceLastPrompt, SENT_PROMPT_MARKER, sentPromptLines } from './transcript'
7
8// Everything that calls `$` lives in this file: the engine follows `$` into
9// functions declared here, never across an import.
10
11const COMMAND = 'copy-latest-replies'
12
13// A read through `$.process.run` returns at most 4 MiB: a transcript is read
14// in ranges of lines, halved whenever one comes back cut short.
15const FIRST_CHUNK_LINES = 2000
16
17// The session's transcript file, as the engine hands it to its hook events.
18let transcriptPath: string | null = null
19
20// Whether Claude is working on a turn of the main conversation.
21let isWorking = false
22
23let feedbackTimer: { cancel: () => void } | null = null
24
25// Subagents run turns of their own; only the main conversation's count.
26const isMainLoop = (e: object): boolean => (e as { agentId?: string }).agentId === undefined
27
28/** Claude's last message with text in it, the one the button sits under. */
29function lastClaudeMessage(messages: readonly SessionMessage[]): string {
30 return messages.findLast(m => m.role === 'assistant' && m.text.trim() !== '')?.text ?? ''
31}
32
33/** Whether a message as a surface draws it is the given stored message. */
34function isSameMessage(drawn: string, stored: string): boolean {
35 const collapse = (text: string) => text.replace(/\s+/g, ' ').trim()
36 const a = collapse(drawn)
37 const b = collapse(stored)
38 return a !== '' && b !== '' && (a === b || a.endsWith(b) || b.endsWith(a))
39}
40
41/** The transcript file: as the hook events named it, else found by session id. */
42async function findTranscript($: EngineInterface): Promise<string | null> {
43 if (transcriptPath !== null) return transcriptPath
44 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
45 const sessionId = await $.session.id()
46 const { stdout } = await $.process.run(['find', `${configDir}/projects`, '-maxdepth', '2', '-name', `${sessionId}.jsonl`])
47 transcriptPath = stdout.split('\n').find(line => line.trim() !== '') ?? null
48 return transcriptPath
49}
50
51/** The transcript from line `from` (1-based) to its end. */
52async function readTranscriptFrom($: EngineInterface, path: string, from: number): Promise<string> {
53 const { stdout } = await $.process.run(['wc', '-l', path])
54 const lineCount = Number.parseInt(stdout.trim(), 10) + 1
55 const chunks: string[] = []
56 let start = from
57 let size = FIRST_CHUNK_LINES
58 while (start <= lineCount) {
59 const end = start + size - 1
60 const chunk = await $.process.run(['sed', '-n', `${start},${end}p`, path])
61 if (chunk.isStdoutTruncated && size > 1) {
62 size = Math.ceil(size / 2)
63 continue
64 }
65 // A single line past 4 MiB holds a picture or a tool's output, never text to copy.
66 if (!chunk.isStdoutTruncated) chunks.push(chunk.stdout)
67 start = end + 1
68 }
69 return chunks.join('')
70}
71
72/**
73 * What Claude wrote since the last prompt sent from the prompt box. Reads the
74 * transcript from that prompt's line on; when the branch on screen goes back
75 * further (a rewound conversation), from the prompt sent before it.
76 */
77async function readLatestReplies($: EngineInterface): Promise<Replies | null> {
78 const path = await findTranscript($)
79 if (path === null) return null
80 const { stdout } = await $.process.run(['grep', '-n', '-o', '-F', SENT_PROMPT_MARKER, path])
81 const promptLines = sentPromptLines(stdout)
82 for (const from of [...promptLines.reverse(), 1]) {
83 const search = repliesSinceLastPrompt(parseRows(await readTranscriptFrom($, path, from)))
84 if (search.isFound) return search.replies
85 }
86 return null
87}
88
89/**
90 * Puts `text` on the clipboard of the surface the person acted on, falling
91 * back to the system clipboard when that surface is on this machine.
92 */
93async function copyToClipboard($: EngineInterface, text: string, surface?: RenderSurface): Promise<boolean> {
94 const result = await $.ui.copy(surface === undefined ? { text } : { text, surface })
95 if (result.isCopied) return true
96 if (!isLocalSurface(surface)) return false
97 for (const argv of SYSTEM_CLIPBOARD_TOOLS) {
98 try {
99 const { exitCode } = await $.process.run(argv, { stdin: text, timeoutMs: 5000 })
100 if (exitCode === 0) return true
101 } catch {
102 // Not installed on this machine: try the next one.
103 }
104 }
105 return false
106}
107
108async function copyLatestReplies($: EngineInterface, surface?: RenderSurface): Promise<boolean> {
109 try {
110 const replies = await readLatestReplies($)
111 return replies !== null && replies.blocks > 0 && (await copyToClipboard($, replies.text, surface))
112 } catch {
113 return false
114 }
115}
116
117function stopFeedback(): void {
118 feedbackTimer?.cancel()
119 feedbackTimer = null
120 endFeedback()
121}
122
123/** Plays the feedback for `kind`, redrawing at each step until it ends. */
124function playFeedback($: EngineInterface, kind: FeedbackKind): void {
125 stopFeedback()
126 startFeedback(kind)
127 feedbackTimer = $.clock.every(STEP_MS, () => {
128 if (!stepFeedback()) stopFeedback()
129 $.ui.invalidate('ui.render')
130 })
131 $.ui.invalidate('ui.render')
132}
133
134export const register: Register = on => {
135 on('session.start', async ($, e, next) => {
136 await $.command.register({
137 name: COMMAND,
138 description: "Copy Claude's replies since your last message, without tool calls",
139 immediate: true,
140 })
141 return next(e)
142 })
143
144 // Every hook event names the transcript file; keep the latest.
145 on('classic.SessionStart', async ($, e, next) => {
146 transcriptPath = e.transcript_path
147 return next(e)
148 }).catch(($, e, next) => next(e))
149 on('classic.UserPromptSubmit', async ($, e, next) => {
150 transcriptPath = e.transcript_path
151 return next(e)
152 }).catch(($, e, next) => next(e))
153
154 on('turn.start', async ($, e, next) => {
155 if (isMainLoop(e)) {
156 isWorking = true
157 stopFeedback()
158 $.ui.invalidate('ui.render')
159 }
160 return next(e)
161 })
162
163 on('turn.complete', async ($, e, next) => {
164 const result = await next(e)
165 if (isMainLoop(e)) {
166 isWorking = false
167 $.ui.invalidate('ui.render')
168 }
169 return result
170 })
171
172 on('command.run', { command: COMMAND }, async $ => {
173 const isCopied = await copyLatestReplies($)
174 return { text: isCopied ? '✓ Copied' : '✗ Nothing copied' }
175 }).catch(() => ({ text: '✗ Nothing copied' }))
176
177 // The button sits under Claude's last message, aligned left. Messages are
178 // drawn on every surface, the mobile app included.
179 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
180 const message = await next(e)
181 if (isWorking) return message
182 if (!isSameMessage(e.props.text, lastClaudeMessage(await $.session.messages()))) return message
183
184 const onPress = async () => {
185 if (currentFeedback() !== null) return
186 const isCopied = await copyLatestReplies($, e.surface)
187 playFeedback($, isCopied ? 'copied' : 'failed')
188 }
189
190 const ui = $.ui.resolve(e)
191 const { Box } = ui
192 return (
193 <Box flexDirection="column">
194 {message}
195 <Box flexDirection="row" justifyContent="flex-start">
196 {CopyButton({ ui, surface: e.surface, feedback: currentFeedback(), onPress })}
197 </Box>
198 </Box>
199 )
200 })
201}
202hooks/clipboard.ts 19 lines1import type { RenderSurface } from 'claude-code'
2
3/** The system's own clipboard tools, tried in order: macOS, Windows, Wayland, X11. */
4export const SYSTEM_CLIPBOARD_TOOLS: readonly (readonly string[])[] = [
5 ['pbcopy'],
6 ['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value ([Console]::In.ReadToEnd())'],
7 ['wl-copy'],
8 ['xclip', '-selection', 'clipboard'],
9]
10
11/**
12 * Whether a surface runs on the machine the session runs on, where the
13 * system clipboard is the person's own. A remote surface (the mobile app)
14 * writes its own clipboard or none.
15 */
16export function isLocalSurface(surface: RenderSurface | undefined): boolean {
17 return surface === undefined || surface === 'terminal' || surface === 'desktop'
18}
19hooks/copy-button.tsx 83 lines1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2
3import { FULL_LEVEL, type Feedback } from './feedback'
4
5export const LABEL = 'Copy latest replies'
6
7type Ui = Pick<Elements[RenderSurface], 'Box' | 'Text' | 'Button'>
8
9const MARK = { copied: '✓', failed: '✗' } as const
10const MARK_COLOR = { copied: 'success', failed: 'error' } as const
11
12// A translucent tint, growing with each level of the fade (hex alpha).
13const TINT = { copied: '#2da44e', failed: '#cf222e' } as const
14const TINT_ALPHA: readonly string[] = ['00', '10', '1c', '28', '34', '40']
15
16// Fully shown, an opaque layer from the person's theme goes under the tint:
17// it hides the label, since an app's native button ignores the color of its
18// own text, and the theme's translucent colors would let it show through.
19const BASE = 'inverseText'
20
21/**
22 * The copy button, or the feedback in its place. The feedback never changes
23 * the button's size: the terminal centers the mark in the label's width, an
24 * app keeps its native button and lays the feedback over it.
25 */
26export function CopyButton(props: {
27 ui: Ui
28 surface: RenderSurface
29 feedback: Feedback | null
30 onPress: () => void
31}): RenderElement {
32 const { ui, surface, feedback, onPress } = props
33 const { Box, Text, Button } = ui
34
35 if (feedback === null) {
36 return <Button key="copy" label={LABEL} dimColor onPress={onPress} />
37 }
38
39 const mark = (
40 <Text color={MARK_COLOR[feedback.kind]} bold>
41 {MARK[feedback.kind]}
42 </Text>
43 )
44
45 // A monospace grid: spaces around the mark take the label's exact width.
46 if (surface === 'terminal') {
47 const left = Math.floor((LABEL.length - 1) / 2)
48 const right = LABEL.length - 1 - left
49 return (
50 <Button key="copy" onPress={onPress}>
51 {' '.repeat(left)}
52 {mark}
53 {' '.repeat(right)}
54 </Button>
55 )
56 }
57
58 // The tint fades in over the label; once full, the opaque base hides the
59 // label and the mark takes its place.
60 const isFull = feedback.level >= FULL_LEVEL
61 const tint = TINT[feedback.kind] + (TINT_ALPHA[Math.min(feedback.level, FULL_LEVEL)] ?? '00')
62
63 return (
64 <Box key="copy-slot" position="relative">
65 <Button key="copy" label={LABEL} onPress={onPress} />
66 <Box position="absolute" top={0} left={0} right={0} bottom={0} {...(isFull ? { backgroundColor: BASE } : {})}>
67 <Box
68 position="absolute"
69 top={0}
70 left={0}
71 right={0}
72 bottom={0}
73 backgroundColor={tint}
74 justifyContent="center"
75 alignItems="center"
76 >
77 {isFull ? mark : null}
78 </Box>
79 </Box>
80 </Box>
81 )
82}
83hooks/feedback.ts 52 lines1export type FeedbackKind = 'copied' | 'failed'
2
3/** The feedback on screen: what it reports and how far it has faded in. */
4export type Feedback = {
5 kind: FeedbackKind
6 /** From 1 to FULL_LEVEL while fading in, holding, then fading out. */
7 level: number
8}
9
10/** The level at which the feedback is fully shown. */
11export const FULL_LEVEL = 5
12
13/** One level per step: a site redraws at most ten times a second. */
14export const STEP_MS = 100
15
16const HOLD_STEPS = 18
17
18// Fade in (0.5 s), hold, fade out (0.4 s): about 2.7 seconds in all.
19const LEVELS: readonly number[] = [
20 ...Array.from({ length: FULL_LEVEL }, (_, i) => i + 1),
21 ...Array.from({ length: HOLD_STEPS }, () => FULL_LEVEL),
22 ...Array.from({ length: FULL_LEVEL - 1 }, (_, i) => FULL_LEVEL - 1 - i),
23]
24
25let current: Feedback | null = null
26let step = 0
27
28/** The feedback on screen, or null when the button shows. */
29export function currentFeedback(): Feedback | null {
30 return current
31}
32
33/** Starts the feedback for `kind` at its first level. */
34export function startFeedback(kind: FeedbackKind): void {
35 step = 0
36 current = { kind, level: LEVELS[0] ?? FULL_LEVEL }
37}
38
39/** Moves the feedback one step on; false once it has ended. */
40export function stepFeedback(): boolean {
41 if (current === null) return false
42 step += 1
43 const level = LEVELS[step]
44 current = level === undefined ? null : { kind: current.kind, level }
45 return current !== null
46}
47
48/** Ends the feedback at once. */
49export function endFeedback(): void {
50 current = null
51}
52hooks/transcript.ts 211 lines1// Reads a Claude Code session transcript (one JSON row per line) and gathers
2// what Claude wrote since the last prompt the person sent from the prompt box.
3//
4// A prompt sent from the prompt box is the only row the engine marks with the
5// origin `{ kind: "human" }`: a user row when it starts a turn, a
6// `queued_command` attachment when it was sent while Claude was working.
7// Task notifications, messages from subagents, compaction summaries and
8// answers to Claude's questions all carry another origin, or none.
9
10/** The exact text of that origin in a transcript line. */
11export const SENT_PROMPT_MARKER = '"origin":{"kind":"human"}'
12
13
14// The copied text is the app's reply blocks, one per native copy button, and
15// the questions Claude asked with the answers given. Blocks are separated by a
16// markdown rule; so is each question, from its neighbors.
17const RULE = '\n\n---\n\n'
18const PARAGRAPH = '\n\n'
19
20type Origin = { kind?: string }
21
22type ContentBlock = {
23 type?: string
24 text?: string
25 thinking?: string
26 id?: string
27 name?: string
28 tool_use_id?: string
29 input?: { questions?: Question[] }
30}
31
32type Question = { question?: string; header?: string }
33
34/** One transcript row, reduced to the fields read here. */
35export type Row = {
36 type?: string
37 uuid?: string
38 parentUuid?: string | null
39 logicalParentUuid?: string | null
40 isSidechain?: boolean
41 origin?: Origin
42 message?: { model?: string; content?: string | ContentBlock[] }
43 toolUseResult?: unknown
44 attachment?: { type?: string; origin?: Origin }
45}
46
47export type Replies = {
48 /** What to copy: the blocks, oldest first, with their questions. */
49 text: string
50 /** How many reply blocks there are, as many as the app's copy buttons. */
51 blocks: number
52}
53
54export type RepliesSearch =
55 | { isFound: true; replies: Replies }
56 /** The branch goes on before the rows given: read from an earlier line. */
57 | { isFound: false }
58
59/** Parses the rows of a transcript, skipping lines that are not JSON. */
60export function parseRows(text: string): Row[] {
61 const rows: Row[] = []
62 for (const line of text.split('\n')) {
63 if (line.trim() === '') continue
64 try {
65 rows.push(JSON.parse(line) as Row)
66 } catch {
67 // A line cut short while the engine was writing it.
68 }
69 }
70 return rows
71}
72
73/** Whether a row is a prompt the person sent from the prompt box. */
74export function isSentPrompt(row: Row): boolean {
75 if (row.isSidechain === true) return false
76 if (row.type === 'user') return row.origin?.kind === 'human'
77 return row.type === 'attachment' && row.attachment?.type === 'queued_command' && row.attachment.origin?.kind === 'human'
78}
79
80const blocksOf = (row: Row): ContentBlock[] => (Array.isArray(row.message?.content) ? row.message.content : [])
81
82const isToolResult = (row: Row): boolean => blocksOf(row).some(block => block.type === 'tool_result')
83
84// Messages that reach the session show inside the reply block in progress
85// when they are task notifications ("4 background tasks completed"). Any
86// other incoming message, such as one from a subagent ("Message from
87// subagent", "Received a message"), opens a new reply block with its own copy
88// button. Rows without an origin (a compaction summary, a skill's text, an
89// image caption) are not messages and stay in the block too.
90const IN_BLOCK_ORIGINS: readonly string[] = ['task-notification']
91
92const opensBlock = (row: Row): boolean =>
93 row.type === 'user' &&
94 !isToolResult(row) &&
95 row.origin?.kind !== undefined &&
96 !IN_BLOCK_ORIGINS.includes(row.origin.kind)
97
98/**
99 * The rows of the conversation on screen, oldest first, after the last
100 * prompt the person sent. Follows each row's parent from the newest one, so
101 * an abandoned branch is left out, and crosses a compaction through its
102 * logical parent.
103 */
104function rowsSinceLastPrompt(rows: readonly Row[]): Row[] | null {
105 const byUuid = new Map<string, Row>()
106 for (const row of rows) if (row.uuid !== undefined) byUuid.set(row.uuid, row)
107
108 const branch: Row[] = []
109 let row = rows.findLast(candidate => candidate.uuid !== undefined && candidate.isSidechain !== true)
110 while (row !== undefined) {
111 if (isSentPrompt(row)) return branch.reverse()
112 branch.push(row)
113 const parent = row.parentUuid ?? row.logicalParentUuid ?? null
114 if (parent === null) return branch.reverse()
115 row = byUuid.get(parent)
116 if (row === undefined) return null
117 }
118 return branch.reverse()
119}
120
121/** `Q: [header] question` and `A: answer`, one pair per question asked. */
122function questionPairs(asked: readonly Question[], toolUseResult: unknown): string[] {
123 const answers =
124 typeof toolUseResult === 'object' && toolUseResult !== null
125 ? (toolUseResult as { answers?: Record<string, string> }).answers
126 : undefined
127 // A string result, or none, means the person declined the questions.
128 const isDeclined = answers === undefined
129 return asked.map(({ question = '', header }) => {
130 const q = header === undefined ? question : `[${header}] ${question}`
131 const a = isDeclined ? '(declined)' : (answers[question] ?? '(no answer)')
132 return `Q: ${q}\nA: ${a}`
133 })
134}
135
136// What the walk over the branch produces: text, or a markdown rule.
137type Part = string | typeof RULE
138
139/** Joins the parts: a rule where one stands, a blank line elsewhere. */
140function render(parts: readonly Part[]): string {
141 let text = ''
142 let isRuleDue = false
143 for (const part of parts) {
144 if (part === RULE) {
145 isRuleDue = text !== ''
146 continue
147 }
148 if (text !== '') text += isRuleDue ? RULE : PARAGRAPH
149 text += part
150 isRuleDue = false
151 }
152 return text
153}
154
155/**
156 * What Claude wrote since the last prompt the person sent, block by block as
157 * the app shows them: the text of Claude's messages, the thinking it shows,
158 * and the questions Claude asked with the answers given. Tool calls and their
159 * results are left out.
160 */
161export function repliesSinceLastPrompt(rows: readonly Row[]): RepliesSearch {
162 const branch = rowsSinceLastPrompt(rows)
163 if (branch === null) return { isFound: false }
164
165 const askedQuestions = new Map<string, Question[]>()
166 const parts: Part[] = []
167 let blocks = 0
168 let isBlockOpen = false
169 const write = (part: string) => {
170 if (!isBlockOpen) blocks += 1
171 isBlockOpen = true
172 parts.push(part)
173 }
174
175 for (const row of branch) {
176 if (row.type === 'assistant' && row.message?.model !== '<synthetic>') {
177 for (const block of blocksOf(row)) {
178 // The app shows Claude's thinking when the transcript keeps its text.
179 const text = block.type === 'text' ? block.text : block.type === 'thinking' ? block.thinking : undefined
180 if (text?.trim()) write(text.trim())
181 if (block.type === 'tool_use' && block.name === 'AskUserQuestion' && block.id !== undefined) {
182 askedQuestions.set(block.id, block.input?.questions ?? [])
183 }
184 }
185 } else if (opensBlock(row)) {
186 parts.push(RULE)
187 isBlockOpen = false
188 } else if (row.type === 'user') {
189 for (const block of blocksOf(row)) {
190 const asked = block.tool_use_id === undefined ? undefined : askedQuestions.get(block.tool_use_id)
191 if (asked === undefined) continue
192 for (const pair of questionPairs(asked, row.toolUseResult)) {
193 parts.push(RULE)
194 write(pair)
195 parts.push(RULE)
196 }
197 }
198 }
199 }
200
201 return { isFound: true, replies: { text: render(parts), blocks } }
202}
203
204/** Line numbers (1-based) of the sent prompts, from `grep -n -o` output. */
205export function sentPromptLines(grepOutput: string): number[] {
206 return grepOutput
207 .split('\n')
208 .map(line => Number.parseInt(line, 10))
209 .filter(line => Number.isInteger(line) && line > 0)
210}
211