Copy buttons on code blocks and a one-click tl;dr of long replies

Three small mods for Claude Code. Install the ones you want.
claude plugin marketplace add GururOzatmaca/claude-mods
claude plugin install prompt-boost@claude-mods
claude plugin install reply-tools@claude-mods
claude plugin install jump-list@claude-mods
Needs Claude Code 2.1.287 or newer. If the install says options not yet set, ignore it; the defaults work.
Add ++ to a messy prompt. It gets rewritten in clear English and put back in the box. You check it, then press Enter.

++ at the start or end. ++o uses Opus, ++q adds open questions./ap undo brings back your original.A copy button on every code block, and a tl;dr button under long replies.

⧉ copy all copies several shell commands at once.tl;dr keeps every step and warning. ↑ original switches back./plugin configure reply-tools@claude-mods.A side list of every prompt in the session, with its time. Click one to jump back.
<img src="docs/images/jump-list-pane.png" alt="Jump list" width="232">
↓ end goes to the bottom. – folds it into one line./jumps.Mods run with your permissions. prompt-boost and tl;dr make one model call each time you use them. jump-list reads the session's transcript file. Run claude plugin validate <folder> to see exactly what a mod does.
MIT license.
hooks/register.tsx 197 lines1import type { EngineInterface, Register, RenderSurface } from 'claude-code'
2
3import { codeBlocks, isShell, splitFences } from './fences'
4import { isLong, questionFor, TLDR_MODEL, TLDR_SYSTEM, tldrPrompt } from './simplify'
5
6const INSTRUCTION = [
7 'Copyable output: the user has a copy button on every fenced code block in your replies.',
8 'Put anything the user is meant to copy and paste (shell commands, scripts, config, emails, chat messages, commit messages, text to send) inside a fenced code block.',
9 'Use a language tag for code (```bash, ```ts) and ```text for messages and prose meant to be sent.',
10 'Put commands the user runs in sequence in one ```bash block rather than splitting them, unless an explanation must sit between steps.',
11 'Do not fence text that is not meant to be copied.',
12].join(' ')
13
14const SELECTION_SETTLE_MS = 350
15const TLDR_TIMEOUT_MS = 30000
16const TLDR_MAX_TOKENS = 1000
17
18type View = 'original' | 'tldr'
19type ReplyState = { view: View; isLoading: boolean; tldr?: string }
20
21const replies = new Map<string, ReplyState>()
22
23const lineCount = (code: string) => code.split('\n').length
24
25function stateOf(id: string): ReplyState {
26 const found = replies.get(id)
27 if (found !== undefined) return found
28 const fresh: ReplyState = { view: 'original', isLoading: false }
29 replies.set(id, fresh)
30 return fresh
31}
32
33async function copyAfterClick($: EngineInterface, code: string, surface: RenderSurface) {
34 await $.ui.copy({ text: code, surface })
35 await $.clock.sleep(SELECTION_SETTLE_MS)
36 return copy($, code, surface)
37}
38
39async function copy($: EngineInterface, code: string, surface?: RenderSurface) {
40 const result = await $.ui.copy({ text: code, surface })
41 const lines = lineCount(code)
42 $.ui.toast(
43 result.isCopied
44 ? `Copied ${lines} line${lines === 1 ? '' : 's'}`
45 : `Copy failed: ${result.reason}`,
46 )
47 return result.isCopied
48}
49
50async function showView($: EngineInterface, id: string, text: string, view: View) {
51 const state = stateOf(id)
52 if (view === 'original' || state.tldr !== undefined) {
53 state.view = view
54 $.ui.invalidate('ui.render')
55 return
56 }
57 state.isLoading = true
58 $.ui.invalidate('ui.render')
59 const messages = (await $.session.messages()).map(m => ({ role: m.role, text: m.text }))
60 const result = await $.model.complete({
61 model: TLDR_MODEL,
62 system: TLDR_SYSTEM,
63 prompt: tldrPrompt(questionFor(messages, text), text),
64 maxTokens: TLDR_MAX_TOKENS,
65 timeoutMs: TLDR_TIMEOUT_MS,
66 })
67 state.isLoading = false
68 if (result.isAnswered && result.text.trim() !== '') {
69 state.tldr = result.text.trim()
70 state.view = 'tldr'
71 } else {
72 $.ui.toast(`tl;dr failed: ${result.isAnswered ? 'empty reply' : result.reason}`)
73 }
74 $.ui.invalidate('ui.render')
75}
76
77export const register: Register = (on, options) => {
78 const copyButtons = options.copy_buttons !== false
79 const copyInstruction = options.copy_instruction !== false
80 const tldrButton = options.tldr_button !== false
81
82 if (copyInstruction) {
83 on('prompt.compose', async ($, e, next) => {
84 const composed = await next(e)
85 return {
86 sections: [...composed.sections, { id: 'reply-tools:instruction', text: INSTRUCTION, scope: 'session' }],
87 }
88 })
89 }
90
91 on('session.start', async ($, e, next) => {
92 await $.command.register({
93 name: 'cb',
94 description: 'Copy code block N of the last reply; "all" joins every shell block; no N lists them',
95 argumentHint: '[n|all]',
96 immediate: true,
97 })
98 return next(e)
99 })
100
101 on('command.run', { command: 'cb' }, async ($, e) => {
102 const messages = await $.session.messages()
103 const last = [...messages]
104 .reverse()
105 .find(m => m.role === 'assistant' && codeBlocks(m.text).length > 0)
106 if (last === undefined) return { text: 'No code blocks in recent replies.' }
107
108 const blocks = codeBlocks(last.text)
109 const arg = String(e.args ?? '').trim()
110
111 if (arg === 'all') {
112 const shell = blocks.filter(b => isShell(b.language))
113 if (shell.length === 0) return { text: 'No shell blocks in the last reply.' }
114 await copy($, shell.map(b => b.code).join('\n'))
115 return { text: `Copied ${shell.length} shell block${shell.length === 1 ? '' : 's'}.` }
116 }
117
118 const n = Number.parseInt(arg, 10)
119
120 if (Number.isNaN(n)) {
121 const only = blocks.length === 1 ? blocks[0] : undefined
122 if (only !== undefined) {
123 await copy($, only.code)
124 return { text: 'Copied code block 1.' }
125 }
126 const list = blocks
127 .map(b => `${b.index}. ${b.language || 'text'}: ${(b.code.split('\n')[0] ?? '').slice(0, 60)}`)
128 .join('\n')
129 return { text: `Code blocks in the last reply:\n${list}\nRun /cb N to copy one, /cb all for every shell block.` }
130 }
131
132 const block = blocks.find(b => b.index === n)
133 if (block === undefined) return { text: `No block ${n}. The last reply has ${blocks.length}.` }
134 await copy($, block.code)
135 return { text: `Copied code block ${n}.` }
136 })
137
138 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
139 const original = e.props.text
140 const id = e.requestId
141 const canSummarize = tldrButton && id !== undefined && isLong(original)
142 const state = canSummarize ? stateOf(id) : undefined
143 const shown = state?.view === 'tldr' && state.tldr !== undefined ? state.tldr : original
144
145 const segments = copyButtons ? splitFences(shown) : [{ kind: 'prose' as const, text: shown }]
146 const hasCode = segments.some(s => s.kind === 'code')
147 if (!hasCode && state === undefined) return next(e)
148
149 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
150
151 return (
152 <Box flexDirection="column">
153 {segments.map((s, i) =>
154 s.kind === 'prose' ? (
155 <Markdown key={`p${i}`} text={s.text} />
156 ) : (
157 <Box key={`c${i}`} flexDirection="column">
158 <Box flexDirection="row" gap={2}>
159 <Button
160 key={`copy-${s.index}`}
161 label={`⧉ copy ${s.index}`}
162 plain
163 dimColor
164 onPress={press => void copyAfterClick($, s.code, press.surface)}
165 />
166 {s.group !== undefined && (
167 <Button
168 key={`copy-all-${s.index}`}
169 label={`⧉ copy all ${s.group.size}`}
170 plain
171 onPress={press => void copyAfterClick($, s.group?.code ?? s.code, press.surface)}
172 />
173 )}
174 </Box>
175 <Markdown key={`m${i}`} text={s.raw} />
176 </Box>
177 ),
178 )}
179 {state !== undefined && id !== undefined && (
180 <Box key="tldr-row" flexDirection="row" gap={2}>
181 {state.isLoading ? (
182 <Text dimColor>Summarizing...</Text>
183 ) : state.view === 'original' ? (
184 <Button key="tldr" label="↓ tl;dr" plain dimColor onPress={() => void showView($, id, original, 'tldr')} />
185 ) : (
186 <>
187 <Text dimColor>tl;dr ·</Text>
188 <Button key="original" label="↑ original" plain dimColor onPress={() => void showView($, id, original, 'original')} />
189 </>
190 )}
191 </Box>
192 )}
193 </Box>
194 )
195 })
196}
197hooks/fences.ts 84 lines1export type Group = { code: string; size: number }
2
3export type Segment =
4 | { kind: 'prose'; text: string }
5 | { kind: 'code'; raw: string; code: string; language: string; index: number; group?: Group }
6
7const OPEN = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/
8const SHELL = new Set(['bash', 'sh', 'shell', 'zsh', 'console', 'shellscript'])
9const MAX_GAP_LINES = 2
10
11export const isShell = (language: string) => SHELL.has(language.toLowerCase())
12
13export function splitFences(text: string): Segment[] {
14 const lines = text.split('\n')
15 const segments: Segment[] = []
16 let prose: string[] = []
17 let index = 0
18 let i = 0
19
20 const flushProse = () => {
21 if (prose.join('').trim() !== '') segments.push({ kind: 'prose', text: prose.join('\n') })
22 prose = []
23 }
24
25 while (i < lines.length) {
26 const line = lines[i] ?? ''
27 const open = OPEN.exec(line)
28 if (open === null) {
29 prose.push(line)
30 i += 1
31 continue
32 }
33 const fence = open[1] ?? '```'
34 const close = new RegExp(`^ {0,3}${fence.startsWith('`') ? '`' : '~'}{${fence.length},}\\s*$`)
35 let end = i + 1
36 while (end < lines.length && !close.test(lines[end] ?? '')) end += 1
37 if (end >= lines.length) {
38 prose.push(...lines.slice(i))
39 break
40 }
41 flushProse()
42 index += 1
43 segments.push({
44 kind: 'code',
45 raw: lines.slice(i, end + 1).join('\n'),
46 code: lines.slice(i + 1, end).join('\n'),
47 language: open[2] ?? '',
48 index,
49 })
50 i = end + 1
51 }
52 flushProse()
53
54 return groupShell(segments)
55}
56
57function groupShell(segments: Segment[]): Segment[] {
58 const runs: Extract<Segment, { kind: 'code' }>[][] = []
59 let run: Extract<Segment, { kind: 'code' }>[] = []
60
61 for (const s of segments) {
62 if (s.kind === 'code' && isShell(s.language)) {
63 run.push(s)
64 } else if (s.kind === 'code' || s.text.trim().split('\n').length > MAX_GAP_LINES) {
65 runs.push(run)
66 run = []
67 }
68 }
69 runs.push(run)
70
71 for (const r of runs) {
72 const first = r[0]
73 if (first !== undefined && r.length > 1) {
74 first.group = { code: r.map(b => b.code).join('\n'), size: r.length }
75 }
76 }
77
78 return segments
79}
80
81export function codeBlocks(text: string) {
82 return splitFences(text).filter(s => s.kind === 'code')
83}
84hooks/simplify.ts 56 lines1export const MIN_LINES = 15
2export const TLDR_MODEL = 'sonnet'
3export const MAX_WORDS = 100
4
5const MAX_QUESTION_CHARS = 1500
6
7export const TLDR_SYSTEM = `You summarize one reply from Claude Code, a coding assistant, for a developer who reads English as a second language.
8
9You get the developer's question in <question> (it may be empty) and Claude's reply in <reply>. Summarize only the reply.
10
11Output only the summary in markdown. No preamble, no explanation of what you did.
12
13Hard limits:
141. At most ${MAX_WORDS} words in total, not counting code blocks. Count them. This limit beats every other wish to include more.
152. No headings, no title line. Start with 1 to 3 plain sentences that answer the question: the core idea, as if explaining to a beginner. Cover the main concept even when the reply spends more space on details.
163. Then at most 5 bullets. Each bullet is one line of at most 15 words and holds one point.
174. Keep a code block only if the developer must run or copy it, and put it after the bullets.
185. Output this one summary and stop. Never add a second version or sections from the original.
19
20What to keep, in this order of priority. When the word limit forces a cut, drop examples, background and nice-to-know details first:
21- every step the developer must do, and every open point Claude left (e.g. a permission still to add),
22- every status: done, not done, not deployed, not tested, failed,
23- every warning or risk, and each option's verdict exactly as given (recommended, works but risky, do not use),
24- every condition that limits a statement (only when, after, if, unless, a pushed branch, removed rather than stopped, inside a transaction),
25- exact names of errors (e.g. ERESOLVE), environments (e.g. staging), changed files, commands and numbers.
26
27Never add facts, advice or steps that are not in the reply. Never change who did something (Claude did the work, not the developer).
28Use simple A2-level English. Keep technical terms and explain them in a few plain words; never replace a term with a vaguer word.`
29
30export function isLong(text: string): boolean {
31 return text.split('\n').length >= MIN_LINES
32}
33
34export type ConversationMessage = { role: 'user' | 'assistant'; text: string }
35
36export function questionFor(messages: readonly ConversationMessage[], reply: string): string {
37 const probe = reply.trim().slice(0, 200)
38 let at = -1
39 for (let i = messages.length - 1; i >= 0; i -= 1) {
40 const m = messages[i]
41 if (m !== undefined && m.role === 'assistant' && m.text.includes(probe)) {
42 at = i
43 break
44 }
45 }
46 for (let i = at - 1; i >= 0; i -= 1) {
47 const m = messages[i]
48 if (m !== undefined && m.role === 'user' && m.text.trim() !== '') return m.text.trim().slice(0, MAX_QUESTION_CHARS)
49 }
50 return ''
51}
52
53export function tldrPrompt(question: string, reply: string): string {
54 return `<question>\n${question}\n</question>\n\n<reply>\n${reply}\n</reply>`
55}
56