Copy each quote or code block of Claude's last answer with one click, from buttons above the prompt.

Mods for Claude Code by y-hirakaw. Each installs and turns on or off on its own.
| Mod | What it does |
|---|---|
| touch-map | Shows which files Claude read, edited or never opened, as a tree and an activity map in a pane. |
| copy-blocks | Adds copy buttons to the quotes and code blocks in Claude's answers, so you copy them without the terminal's line breaks. |
See how much of your codebase Claude actually looked at.

<sub>Claude and two subagents survey a Unity project, sped up. With the conversation beside it</sub>
You ask Claude something and get an answer. Did it read the file, or only see a few grep matches? Which parts did it never open? touch-map marks every file Claude touches in this session, down to the lines it read, in a pane beside the conversation.
Needs Claude Code 2.1.287 or later. On Ghostty, use 1.3.1 or later: older versions may not pass clicks to the pane. From a shell:
claude plugin marketplace add y-hirakaw/claude-code-mods
claude plugin install touch-map@y-hirakaw-mods
Or inside Claude Code: run /plugin marketplace add y-hirakaw/claude-code-mods, then open /plugin, pick the marketplace, select touch-map and install it.
Then start a new session and run /touch-map once. From then on the pane opens by itself when the terminal is wide enough; otherwise run /touch-map.
git clone https://github.com/y-hirakaw/claude-code-mods
claude --plugin-dir ./claude-code-mods/touch-map
Each file shows the deepest state it reached.
| State | Meaning | Detected from |
|---|---|---|
<img src="docs/colors/created.svg" width="12" height="12"> created | Claude made the file | Write, > redirects, mv, scripts |
<img src="docs/colors/edited.svg" width="12" height="12"> edited | Claude changed the file | Edit, >>, sed -i, scripts |
<img src="docs/colors/read.svg" width="12" height="12"> read | Claude saw the whole file | Read, cat, @file mentions |
<img src="docs/colors/partial.svg" width="12" height="12"> partial | Claude saw only part of it | Read with a line range, head / tail / sed -n, grep matches |
<img src="docs/colors/listed.svg" width="12" height="12"> listed | Claude only saw the name | ls, find, grep -l, git status and similar output |
<img src="docs/colors/deleted.svg" width="12" height="12"> deleted | Claude removed the file | rm, git rm, mv |
<img src="docs/colors/auto.svg" width="12" height="12"> auto | Loaded into context automatically | CLAUDE.md and rules files |

■■■■■■■■■■ is the share of its files in each state, then touched/total.━━━─── marks the lines it read, ┄┄┄ means only grep matches. A file read in full says so with the color of its name.▾ / ▸ to open or close a directory. Untouched files fold into one N untouched: … line.[ save ] writes the record to ~/.claude/touch-map-logs/ and starts over. It is also saved on /clear, compaction and exit.× shrinks it to one line above the prompt.| Command | ||
|---|---|---|
/touch-map | Open the pane | |
/touch-map clear | Save the record and start over | |
/touch-map discard | Start over without saving | |
/touch-map min | Shrink to one line above the prompt | |
| `/touch-map map [on\ | off]` | Show or hide the activity map |
| `/touch-map debug [on\ | off]` | Write how each event was judged, and the pane as text, to ~/.claude/touch-map-logs/debug/ |
@file attachments and instruction-file loads. Nothing is sent anywhere.printenv HOME, git ls-files, git worktree list and git status, and nothing else, never through a shell. git status runs only after a Bash command that may have written files.~/.claude/touch-map-logs/<time>.json when a record is saved, and debug files only while debug output is on.git status and modification times, so files ignored by git are not seen, and a file another process writes while the command runs is counted as Claude's.git status adds about 0.1 s per writing command.Copy a quote or code block from Claude's answer without the terminal's line breaks.

<sub>Copying from the answer and pasting into the prompt. With the row above the prompt</sub>
Selecting text in the terminal copies the wrapped lines and the │ gutter with it. copy-blocks puts a copy button on each > quote and code block, right above the block in the answer, and in a row above the prompt for Claude's last answer:
copy 1 ❝ Thanks for the update… · 2 sh git switch -c featu… · 3 tsx export const regist… 7L ×
+N, which opens a pane listing every block, and below 50 columns only the numbers remain.✓ copied for a moment; in the row it keeps a ✓.×./config → copy-blocks buttons: both (default), inline (in the answer only) or band (the row above the prompt only). With band, answers are drawn exactly as Claude Code draws them.claude plugin marketplace add y-hirakaw/claude-code-mods
claude plugin install copy-blocks@y-hirakaw-mods
Needs Claude Code 2.1.287 or later. Nothing to run afterwards: the buttons appear after Claude's next answer.
band, the text of each answer as it is drawn, to place the buttons. Your stored conversation is not changed./copy does. It writes no files.Developing a mod: see CLAUDE.md.
| mod | できること |
|---|---|
| touch-map | Claude がどのファイルを読んだか、変えたか、開かなかったかを、ペインにツリーとマップで表示する |
| copy-blocks | Claude の返事にある引用やコードブロックにコピーボタンを付け、余計な改行なしにコピーできるようにする |
touch-map は、Claude がこのセッションで触ったファイルを created・edited・read・partial・listed・deleted・auto に分けて、会話の横のペインにツリーとアクティビティマップで表示する Claude Code の mod です。触っていないところも畳んで見せるので、「どこまで見て答えたのか」がわかります。
claude plugin marketplace add y-hirakaw/claude-code-mods
claude plugin install touch-map@y-hirakaw-mods
Claude Code 2.1.287 以上が必要です。Ghostty は 1.3.1 以上を推奨します(古い版ではペインのボタンがクリックできないことがあります)。
インストールしたら、新しいセッションで一度 /touch-map を実行してください。外部に何かを送ることはなく、実行するのは printenv HOME・git ls-files・git worktree list・git status だけです。
copy-blocks は、Claude の返事にある引用(>)とコードブロックにコピーボタンを付ける mod です。ボタンは返事の中のブロックの直上と、入力欄の上の帯に出ます。ターミナルで選択したときのような余計な改行や │ は入りません。出す場所は /config の copy-blocks buttons で both(初期値)・inline(返事の中だけ)・band(帯だけ)から選べます。band なら返事の表示には手を加えません。
claude plugin install copy-blocks@y-hirakaw-mods
copy-blocks はコマンドを実行せず、外部に何も送らず、書き込むのはボタンを押したときのクリップボードだけです。
hooks/register.tsx 337 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Block } from '../types'
5
6const blocks = atom({ plugin: 'copy-blocks', key: 'blocks' } as const, [] as Block[])
7const copied = atom({ plugin: 'copy-blocks', key: 'copied' } as const, [] as number[])
8const flash = atom({ plugin: 'copy-blocks', key: 'flash' } as const, null as number | null)
9const inline = atom({ plugin: 'copy-blocks', key: 'inline' } as const, null as string | null)
10
11const PANE = 'copy-blocks'
12// 抜き出すのは 9 個まで
13const MAX = 9
14// ラベルの長さ。入りきらないときは SHORT まで縮める
15const LABEL = 20
16const SHORT = 12
17// この幅より狭い帯では、番号と種類の印だけにする
18const NARROW = 50
19// 5 行以上のブロックには行数を添える
20const MANY_LINES = 5
21// 「✓ copied」を出しておく時間
22const FLASH_MS = 1500
23
24// lines[i] から始まる引用(> で始まる行の続き)かコードブロック(``` で囲んだ部分)の終わりと中身。どちらでもなければ null
25// 中身が空のブロックは block を持たない
26function blockAt(lines: string[], i: number): { end: number; block?: Block } | null {
27 const line = lines[i] ?? ''
28 const fence = line.match(/^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/)
29 if (fence) {
30 const close = fence[1] ?? '```'
31 const lang = fence[2] ?? ''
32 let end = i + 1
33 while (end < lines.length && !(lines[end] ?? '').trim().startsWith(close)) end += 1
34 const body = lines.slice(i + 1, end)
35 end += 1
36 if (!body.some(l => l.trim() !== '')) return { end }
37 const text = body.join('\n')
38 return { end, block: lang ? { kind: 'code', text, lang } : { kind: 'code', text } }
39 }
40 if (/^\s*>/.test(line)) {
41 let end = i
42 while (end < lines.length && /^\s*>/.test(lines[end] ?? '')) end += 1
43 const body = lines.slice(i, end).map(l => l.replace(/^\s*> ?/, ''))
44 if (!body.some(l => l.trim() !== '')) return { end }
45 return { end, block: { kind: 'quote', text: body.join('\n').trim() } }
46 }
47 return null
48}
49
50// 回答を、地の文と、引用・コードブロックに切り分ける。raw は描くための元の markdown、block はコピーする中身
51export type Part = { raw: string; block?: Block }
52export function split(text: string): Part[] {
53 const out: Part[] = []
54 const lines = text.split('\n')
55 let prose: string[] = []
56 const flush = () => {
57 if (prose.join('').trim() !== '') out.push({ raw: prose.join('\n') })
58 prose = []
59 }
60 let i = 0
61 while (i < lines.length) {
62 const found = blockAt(lines, i)
63 if (!found) {
64 prose.push(lines[i] ?? '')
65 i += 1
66 continue
67 }
68 const raw = lines.slice(i, found.end).join('\n')
69 if (found.block) {
70 flush()
71 out.push({ raw, block: found.block })
72 } else prose.push(raw)
73 i = found.end
74 }
75 flush()
76 return out
77}
78
79// 引用とコードブロックを、出てきた順に抜き出す(帯とペインに並べる分)
80export function extract(answer: string): Block[] {
81 return split(answer)
82 .flatMap(p => (p.block ? [p.block] : []))
83 .slice(0, MAX)
84}
85
86// 端末で何セル使うか。全角は 2 セル
87export function cellsOf(s: string): number {
88 let n = 0
89 for (const ch of s) {
90 const c = ch.codePointAt(0) ?? 0
91 // ❝ は端末によって 2 セルで描かれるので、広いほうで数える
92 n += c === 0x275d || (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || c >= 0x1f300 ? 2 : 1
93 }
94 return n
95}
96
97const cut = (s: string, width: number): string => {
98 if (cellsOf(s) <= width) return s
99 let out = ''
100 for (const ch of s) {
101 if (cellsOf(out + ch) > width - 1) break
102 out += ch
103 }
104 return out + '…'
105}
106
107// import や括弧だけの行、シバンを飛ばした、最初の意味のある行
108const NOISE = /^(import\b|from\b|#!|[\s()[\]{};,]*$)/
109export function labelOf(b: Block, width = LABEL): string {
110 const lines = b.text.split('\n').map(l => l.trim())
111 const first = lines.find(l => l !== '' && !(b.kind === 'code' && NOISE.test(l))) ?? lines.find(l => l !== '') ?? ''
112 return cut(first, width)
113}
114
115const tagOf = (b: Block): string => (b.kind === 'quote' ? '❝' : b.lang ?? '‹›')
116const linesOf = (b: Block): number => b.text.split('\n').length
117
118type Item = { index: number; tag: string; label: string; meta: string }
119
120// 帯に並べるものを、幅に合わせて 3 段階で決める。入りきらない分は rest に回して「+N」にする
121export function fit(list: Block[], columns: number): { items: Item[]; rest: number } {
122 const item = (b: Block, i: number, width: number | null): Item => ({
123 index: i,
124 tag: tagOf(b),
125 label: width === null ? '' : labelOf(b, width),
126 meta: width !== null && linesOf(b) >= MANY_LINES ? `${linesOf(b)}L` : '',
127 })
128 // 「copy 」と「 ×」、項目の間の「 · 」。端末ごとの幅の数え違いに備えて 2 セル余らせる
129 const widthOf = (items: Item[], rest: number) =>
130 5 + 2 + 2 + items.reduce((n, it) => n + cellsOf(`${it.index + 1} ${it.tag}${it.label ? ' ' + it.label : ''}${it.meta ? ' ' + it.meta : ''}`) + 3, 0) + (rest > 0 ? cellsOf(`+${rest}`) + 1 : 0)
131 if (columns >= NARROW) {
132 const full = list.map((b, i) => item(b, i, LABEL))
133 if (widthOf(full, 0) <= columns) return { items: full, rest: 0 }
134 const short = list.map((b, i) => item(b, i, SHORT))
135 for (let n = short.length; n >= 1; n--) if (widthOf(short.slice(0, n), short.length - n) <= columns) return { items: short.slice(0, n), rest: short.length - n }
136 }
137 const bare = list.map((b, i) => item(b, i, null))
138 for (let n = bare.length; n >= 1; n--) if (widthOf(bare.slice(0, n), bare.length - n) <= columns) return { items: bare.slice(0, n), rest: bare.length - n }
139 return { items: bare.slice(0, 1), rest: bare.length - 1 }
140}
141
142// クリップボードに書く。手応えは ✓ で返すので、トーストはうまくいかなかったときだけ
143async function toClipboard($: any, text: string, surface: any): Promise<boolean> {
144 const r = await $.ui.copy({ text, surface })
145 if (!r.isCopied) $.ui.toast(`Not copied: ${r.reason}`)
146 return r.isCopied
147}
148
149async function copy($: any, list: Block[], i: number, surface: any): Promise<void> {
150 const b = list[i]
151 if (!b) return
152 if (!(await toClipboard($, b.text, surface))) return
153 await update($, copied, (xs: number[]) => (xs.includes(i) ? xs : [...xs, i]))
154 await update($, flash, () => i)
155 await $.clock.sleep(FLASH_MS)
156 await update($, flash, (f: number | null) => (f === i ? null : f))
157}
158
159// 番号と本文を、同じホバーのグループで光る 2 つのボタンにする。番号は明るく、本文は薄く
160// site は帯とペインでキーが重ならないようにする接頭辞
161function entry($: any, e: any, list: Block[], it: Item, done: number[], lit: number | null, site = 'copy') {
162 const { Box, Button, Text } = $.ui.resolve(e)
163 const i = it.index
164 const scope = `copy-${i}`
165 const press = (p: { surface: unknown }) => void copy($, list, i, p.surface)
166 const hover = { scope, inverse: true }
167 const body = [it.tag, it.label, it.meta].filter(Boolean).join(' ')
168 return (
169 // 帯が詰まっても番号は縮めない。縮むのは本文のほうだけ
170 <Box key={`${site}-entry-${i}`} flexDirection="row" columnGap={1} flexShrink={0}>
171 <Box flexShrink={0}>
172 <Button key={`${site}-${i}-n`} label={String(i + 1)} plain hover={hover} onPress={press} />
173 </Box>
174 {lit === i ? (
175 <Text color="green">{`✓ copied${linesOf(list[i] as Block) > 1 ? ' ' + linesOf(list[i] as Block) + 'L' : ''}`}</Text>
176 ) : (
177 <Button key={`${site}-${i}`} label={done.includes(i) ? `✓ ${body}` : body} plain dimColor hover={hover} onPress={press} />
178 )}
179 </Box>
180 )
181}
182
183// 回答の中のボタンで押したブロックをコピーする。中身はボタンを描いたときの本文から取るので、次のターンのあとも押せる
184async function copyInline($: any, key: string, text: string, surface: any): Promise<void> {
185 if (!(await toClipboard($, text, surface))) return
186 await update($, inline, () => key)
187 await $.clock.sleep(FLASH_MS)
188 await update($, inline, (k: string | null) => (k === key ? null : k))
189}
190
191// ヘッダーの見出し。「bash · 2 lines」「quote」
192const headOf = (b: Block): string => [b.kind === 'quote' ? 'quote' : b.lang ?? 'code', linesOf(b) > 1 ? `${linesOf(b)} lines` : ''].filter(Boolean).join(' · ')
193
194// 見出しを挟む罫線。画面の端までは伸ばさず、⧉ copy と合わせて RULE セルほどで止める
195const RULE = 40
196export const ruleOf = (b: Block): string => {
197 const head = `── ${headOf(b)} `
198 return head + '─'.repeat(Math.max(2, RULE - cellsOf(head) - cellsOf(' ⧉ copy')))
199}
200
201// 本文の文字列から短い印を作る。同じ回答を描き直しても同じ key になるように
202export const hashOf = (s: string): string => {
203 let h = 0
204 for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
205 return (h >>> 0).toString(36)
206}
207
208export const register: Register = (on, options) => {
209 // /config の「copy-blocks buttons」。回答の中(inline)、プロンプトの上の帯(band)、両方(both)
210 const placement = options?.placement ?? 'both'
211 const inlineOn = placement !== 'band'
212 const bandOn = placement !== 'inline'
213
214 // 本体の返事だけを見る。サブエージェントの返事と、止めたターンは飛ばす
215 on('turn.complete', async ($, e, next) => {
216 if (e.agentId === undefined && !e.isAborted) {
217 await update($, blocks, () => extract(e.answer))
218 await update($, copied, () => [])
219 await update($, flash, () => null)
220 }
221 return next(e)
222 })
223
224 // 次のプロンプトを送ったら片付ける
225 on('prompt.submit', ($, e, next) => {
226 void update($, blocks, () => []).catch(() => {})
227 void $.ui.close({ id: PANE }).catch(() => {})
228 return next(e)
229 })
230
231 // 「+N」から一覧のペインを開く。人の操作として数えられるよう、ボタンの onPress ではなくここで開く
232 on('ui.press', { element: 'more' }, async ($, e, next) => {
233 await $.ui.open({ id: PANE, title: 'Copy blocks', closeOnEscape: true })
234 return next(e)
235 })
236
237 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
238 if (!bandOn) return next(e)
239 const list = await read($, blocks)
240 if (list.length === 0 || e.props.hasSurvey || e.props.isWorking) return next(e)
241 const done = await read($, copied)
242 const lit = await read($, flash)
243 const { Box, Button, Text } = $.ui.resolve(e)
244 const { items, rest } = fit(list, e.props.bodyColumns)
245 // 帯はほかの mod と分け合う。下の mod が描くもの(touch-map の最小化した行など)を消さないよう、ボタンの行の下に並べる
246 const below = await next(e)
247 const row: any[] = []
248 items.forEach((it, n) => {
249 if (n > 0) row.push(<Text key={`sep-${n}`} dimColor>·</Text>)
250 row.push(entry($, e, list, it, done, lit))
251 })
252 const mine = (
253 <Box flexDirection="row" columnGap={1}>
254 <Box flexShrink={0}>
255 <Text dimColor>copy</Text>
256 </Box>
257 {row}
258 {rest > 0 && <Button key="more" label={`+${rest}`} plain onPress={() => {}} />}
259 <Button key="dismiss" label="×" plain dimColor role="dismiss" onPress={() => update($, blocks, () => [])} />
260 </Box>
261 )
262 return below ? (
263 <Box flexDirection="column">
264 {mine}
265 {below}
266 </Box>
267 ) : (
268 mine
269 )
270 })
271
272 // 回答の本文の中で、引用とコードブロックの直上にコピーボタンを置く。ブロックがない回答は本体がそのまま描く
273 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
274 if (!inlineOn) return next(e)
275 const parts = split(e.props.text)
276 if (!parts.some(p => p.block)) return next(e)
277 const lit = await read($, inline)
278 const { Box, Button, Markdown, Text } = $.ui.resolve(e) as any
279 const id = hashOf(e.props.text)
280 return (
281 // 本体の見た目に合わせる:左に 2 セルの列をとって返事の最初のブロックにだけ ● を置き、部分の間は 1 行あける
282 <Box flexDirection="row">
283 <Box width={2} flexShrink={0}>
284 <Text>{e.props.isFirstOfReply ? '●' : ' '}</Text>
285 </Box>
286 <Box flexDirection="column" rowGap={1} flexGrow={1} flexShrink={1}>
287 {parts.map((p, n) => {
288 const body = <Markdown key={`md-${id}-${n}`} text={p.raw} />
289 if (!p.block) return body
290 const key = `inline-${id}-${n}`
291 const text = p.block.text
292 const press = (x: { surface: unknown }) => void copyInline($, key, text, x.surface)
293 const hover = { scope: key, inverse: true }
294 return (
295 // ブロックの直上に、罫線の形のヘッダーを置く。罫線は薄く、⧉ copy だけ普通の明るさ(Button は色を持てない)
296 // 2 つは同じホバーのグループなので、行のどこを押してもコピーでき、乗せると行全体が反転する
297 <Box key={`blk-${id}-${n}`} flexDirection="column">
298 {lit === key ? (
299 <Text color="green" bold>{`── ${headOf(p.block)} ── ✓ copied`}</Text>
300 ) : (
301 <Box flexDirection="row" columnGap={1}>
302 <Button key={`${key}-rule`} label={ruleOf(p.block)} plain dimColor hover={hover} onPress={press} />
303 <Box flexShrink={0}>
304 <Button key={key} label="⧉ copy" plain hover={hover} onPress={press} />
305 </Box>
306 </Box>
307 )}
308 {body}
309 </Box>
310 )
311 })}
312 </Box>
313 </Box>
314 )
315 })
316
317 // 一覧のペイン。帯に入りきらなかった分も含めて、全部を 1 行ずつ並べる
318 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
319 const list = await read($, blocks)
320 const done = await read($, copied)
321 const lit = await read($, flash)
322 const { Box, Text } = $.ui.resolve(e)
323 if (list.length === 0) return <Text dimColor>Nothing to copy</Text>
324 return (
325 <Box flexDirection="column">
326 <Text dimColor>Click a row to copy it</Text>
327 {list.map((b, i) => {
328 // 折り返すと崩れることがあるので、ペインの幅に収まるよう 1 行に切る。番号・印・行数・間の空白の分を引く
329 const meta = linesOf(b) >= MANY_LINES ? `${linesOf(b)}L` : ''
330 const room = e.props.bodyColumns - cellsOf(`${i + 1} ✓ ${tagOf(b)} ${meta}`) - 1
331 return entry($, e, list, { index: i, tag: tagOf(b), label: labelOf(b, Math.max(4, room)), meta }, done, lit, 'list')
332 })}
333 </Box>
334 )
335 })
336}
337types/index.d.ts 17 lines1// 最後の返事から抜き出した、コピーできるブロック。lang はコードブロックの開始行に書かれた言語名
2export type Block = { kind: 'quote' | 'code'; text: string; lang?: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'copy-blocks': {
7 blocks: Block[]
8 // コピーしたブロックの番号。✓ を残して、どれを貼ったか分かるようにする
9 copied: number[]
10 // 「✓ copied」を出しているブロックの番号
11 flash: number | null
12 // 回答の中のボタンで直前にコピーしたもの(ボタンの key)
13 inline: string | null
14 }
15 }
16}
17