SLOPSHOPPER

Copy latest replies

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…

newrowscommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · copy-latest-replies
› 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 › /copy-latest-replies ⎿ copy-latest-replies: ✗ Nothing copied ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

Why

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.

What it copies

Everything Claude shows you since the last message you sent from the prompt box:

  • the text of every reply block, in order;
  • the thinking Claude shows, when the transcript keeps it;
  • the questions Claude asked you, with the answer you gave.

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.

Install

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.

How it works

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.

  • Where it stops. The engine marks a prompt sent from the prompt box with the origin {"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.
  • Which conversation. It follows each row's parent from the newest one, so a branch you rewound is left out, and it crosses a compaction through its logical parent.
  • Where blocks split. Any incoming message opens a new reply block, except a task notification, which the app shows inside the current block. Checked against twenty native copy buttons on a real session: the same 359 paragraphs, in the same order.
  • Large transcripts. A transcript can weigh tens of megabytes. The mod finds the last prompt with grep and reads from there in chunks.

Privacy and permissions

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.

ProgramCommandWhy
findfind <config dir>/projects -maxdepth 2 -name <session id>.jsonlLocate the transcript, when no hook event has named it yet
grepgrep -n -o -F '"origin":{"kind":"human"}' <transcript>Find the lines of the prompts you sent
wcwc -l <transcript>Count the transcript's lines
sedsed -n '<first>,<last>p' <transcript>Read the transcript from your last prompt on, in chunks
pbcopy, powershell, wl-copy, xclippbcopy · powershell -NoProfile -Command "Set-Clipboard -Value ([Console]::In.ReadToEnd())" · wl-copy · xclip -selection clipboardClipboard 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.

EventWhat the mod does
session.startRegisters the /copy-latest-replies command
classic.SessionStart, classic.UserPromptSubmitKeeps the transcript path the event carries, and passes the event on unchanged
turn.start, turn.completeHides 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 onlyCopies the replies and answers ✓ Copied or ✗ Nothing copied
ui.render, for AssistantMessageDraws 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.

Limits

  • The button shows in the terminal and in the Desktop app's Code tab. Remote surfaces, such as Remote Control from claude.ai or the mobile app, draw what a mod adds only partly, if at all.
  • Over Remote Control, the session runs on your computer, so the mod has to be installed there.
  • It copies Markdown as Claude wrote it, not the rendered text.

Development

claude plugin validate --strict .
claude plugin test .

To try a change, start Claude Code with claude --plugin-dir . from a clone.

License

MIT © 2026 Tomy Spagnoletti Duval

Source 5 files
hooks/register.tsx 202 lines
1import 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}
202
hooks/clipboard.ts 19 lines
1import 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}
19
hooks/copy-button.tsx 83 lines
1import 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}
83
hooks/feedback.ts 52 lines
1export 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}
52
hooks/transcript.ts 211 lines
1// 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