copy-band (yash-gadodia) restyled: a copy icon at the top-right of every block (code, table, list, quote); click to copy.

三個 Claude Code mod(需要 Claude Code 2.1.291 以上;終端字型用 Nerd Font,膠囊圓角和複製圖示會用到):
| mod | 效果 |
|---|---|
| pill-steps | 每輪結束後,輸入框上方一列一個繁中「下一步」膠囊。空白輸入框按 1~3 帶入、0 略過,也可以用滑鼠點;hover 會變亮。每輪多一次 Haiku 呼叫(上限 200 token) |
| pill-copy | 回答裡每個區塊(code、表格、清單、引用)右上角一個複製圖示,點了複製並顯示 ✓ Copied! |
| image-peek | 貼圖後在輸入框上方顯示縮圖(修正原版 image-view 在新版 Claude Code 找不到圖的問題) |
claude plugin marketplace add youngOman/pill-mods
claude plugin install pill-steps@pill-mods --scope user
claude plugin install pill-copy@pill-mods --scope user
claude plugin install image-peek@pill-mods --scope user
裝完重開 Claude Code session(/exit,再照畫面印出的 claude --resume … 接回)。
有裝原版 next-steps、copy-band、image-view 的話先停用,免得重複顯示:
claude plugin disable next-steps@claude-code-mods
claude plugin disable copy-band@claude-mods
claude plugin disable image-view@claude-image-view
claude plugin marketplace update pill-mods
claude plugin update pill-steps@pill-mods
claude plugin update pill-copy@pill-mods
claude plugin update image-peek@pill-mods
改自 hamzafer/claude-code-mods 的 next-steps、yash-gadodia/claude-mods 的 copy-band、jarrodwatts/claude-image-view(MIT)。
hooks/register.tsx 391 lines1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3
4// Every code block and quoted draft in the last answer, as a pressable button above the prompt.
5//
6// A press runs pbcopy in the plugin's own environment: no model turn, no tokens, no waiting for a
7// reply that says "copied". Digits press from an empty composer, the mouse presses anywhere.
8//
9// The band only ever shows the last answer, so each block is also appended to a stash file that
10// outlives the session; /stash lists it and /stash 7 puts an old one back on the clipboard, which is
11// the part a clipboard manager cannot do (it holds what was copied, not what was offered).
12//
13// 0 toggles WhatsApp mode: the copy is wrapped in a triple-backtick fence, which Telegram renders
14// as a native click-to-copy block and WhatsApp as monospace.
15
16const MAX_BLOCKS = 9
17const STASH_KEEP = 60
18const LABEL_WIDTH = 20
19const WA_KEY = 'pill-copy:wa'
20
21type Block = { label: string; text: string }
22
23let blocks: Block[] = []
24let wa = false
25let stashPath: string | undefined
26let stashed = 0
27
28const clean = (text: string) => text.replace(/\s+$/, '').replace(/^\n+/, '')
29
30// The first line says more than the language does, so the language is dropped unless the block had
31// no fence to announce it (a quoted draft) or no first line worth reading.
32const label = (text: string, lang: string) => {
33 const first = (text.split('\n').find((l) => l.trim()) ?? '').trim()
34 const shown = lang === '表格' ? first.replace(/^\|\s*/, '').split('|')[0]?.trim() ?? '' : lang === '清單' ? first.replace(/^\s*([-*+]|\d+[.)])\s+/, '').replace(/\*\*/g, '') : first
35 const head = lang === 'draft' || lang === '表格' || lang === '清單' || !first ? `${lang} ${shown}`.trim() : first
36 return head.length > LABEL_WIDTH ? `${head.slice(0, LABEL_WIDTH - 1)}…` : head
37}
38
39// Fenced blocks of any language, plus runs of quoted lines, which is how a draft message arrives.
40export const parse = (answer: string): Block[] => {
41 const found: Block[] = []
42 const add = (raw: string, lang: string) => {
43 const text = clean(raw)
44 if (text.length < 3) return
45 if (found.some((b) => b.text === text)) return
46 found.push({ label: label(text, lang), text })
47 }
48
49 let fence: string | undefined
50 let lang = ''
51 let body: string[] = []
52 let quote: string[] = []
53 // 表格與條列清單也各算一個區塊,讓每段都能複製
54 let table: string[] = []
55 let list: string[] = []
56 const flushQuote = () => {
57 if (quote.length) add(quote.join('\n'), 'draft')
58 quote = []
59 if (table.length >= 2) add(table.join('\n'), '表格')
60 table = []
61 if (list.length) add(list.join('\n'), '清單')
62 list = []
63 }
64
65 for (const line of answer.split('\n')) {
66 const open = /^\s*(`{3,}|~{3,})(.*)$/.exec(line)
67 if (fence !== undefined) {
68 const mark = open?.[1]
69 if (mark && mark[0] === fence[0] && mark.length >= fence.length && !(open?.[2] ?? '').trim()) {
70 add(body.join('\n'), lang)
71 fence = undefined
72 body = []
73 continue
74 }
75 body.push(line)
76 continue
77 }
78 if (open?.[1]) {
79 flushQuote()
80 fence = open[1]
81 lang = (open[2] ?? '').trim()
82 body = []
83 continue
84 }
85 if (/^\s*>\s?/.test(line)) {
86 quote.push(line.replace(/^\s*>\s?/, ''))
87 continue
88 }
89 if (/^\s*\|.*\|\s*$/.test(line)) {
90 if (!table.length) flushQuote()
91 table.push(line)
92 continue
93 }
94 if (/^\s*([-*+]|\d+[.)])\s+\S/.test(line) || (list.length && /^\s{2,}\S/.test(line))) {
95 if (!list.length) flushQuote()
96 list.push(line)
97 continue
98 }
99 flushQuote()
100 }
101 if (fence !== undefined) add(body.join('\n'), lang)
102 flushQuote()
103
104 return found.slice(0, MAX_BLOCKS)
105}
106
107const wrap = (text: string) => (wa ? `\`\`\`\n${text}\n\`\`\`` : text)
108
109const copy = async ($: EngineInterface, block: Block): Promise<void> => {
110 const payload = wrap(block.text)
111 const r = await $.process
112 .run(['pbcopy'], { stdin: payload, timeoutMs: 5000 })
113 .catch((err) => {
114 $.ui.toast(`copy failed: ${err}`, { timeoutMs: 6000 })
115 return undefined
116 })
117 if (!r || r.exitCode !== 0) return
118 $.ui.toast(`✓ Copied! ${block.label}${wa ? '(WhatsApp 格式)' : ''}`, { timeoutMs: 2500 })
119}
120
121// tee appends without a shell, so nothing here is quoted into one. One JSON object per line.
122const stash = async ($: EngineInterface, entries: Block[]): Promise<void> => {
123 if (!stashPath || !entries.length) return
124 const at = await $.clock.now().catch(() => Date.now())
125 const lines = entries.map((b) => JSON.stringify({ at, label: b.label, text: b.text })).join('\n')
126 await $.process.run(['tee', '-a', stashPath], { stdin: `${lines}\n`, timeoutMs: 5000 }).catch((err) =>
127 $.ui.log(`copy-band: stash write failed: ${err}`),
128 )
129}
130
131const readStash = async ($: EngineInterface): Promise<Block[]> => {
132 if (!stashPath) return []
133 const r = await $.process.run(['tail', '-n', String(STASH_KEEP), stashPath], { timeoutMs: 5000 }).catch(() => undefined)
134 if (!r || r.exitCode !== 0) return []
135 const out: Block[] = []
136 for (const line of r.stdout.split('\n')) {
137 if (!line.trim()) continue
138 try {
139 const entry = JSON.parse(line) as { label?: unknown; text?: unknown }
140 if (typeof entry.text === 'string' && typeof entry.label === 'string') out.push({ label: entry.label, text: entry.text })
141 } catch {
142 // A truncated line from a killed write is skipped rather than costing the whole stash.
143 }
144 }
145 return out.reverse()
146}
147
148// The blocks of one transcript message, memoised: a render hook runs on every frame the message is
149// drawn in, and parsing the same markdown each time would cost the scrollback its speed.
150const inlineCache = new Map<string, Block[]>()
151const inlineById = new Map<string, Block[]>()
152const blocksFor = (text: string): Block[] => {
153 const hit = inlineCache.get(text)
154 if (hit) return hit
155 const found = parse(text)
156 if (inlineCache.size > 200) {
157 inlineCache.clear()
158 inlineById.clear()
159 }
160 inlineCache.set(text, found)
161 inlineById.set(digest(text), found)
162 return found
163}
164
165// A stable address for a Button drawn in the transcript, where many messages draw a row each and a
166// repeated key would collide between them.
167const digest = (text: string) => {
168 let h = 0
169 for (let i = 0; i < text.length; i++) h = (Math.imul(31, h) + text.charCodeAt(i)) | 0
170 return (h >>> 0).toString(36)
171}
172
173// The Button a press names, by its key: the band's `copy:N`, the transcript's `inline:<digest>:N`.
174const blockAt = (element: string): Block | undefined => {
175 const band = /^copy:(\d+)$/.exec(element)
176 if (band) return blocks[Number(band[1])]
177 const inline = /^inline:([^:]+):(\d+)$/.exec(element)
178 if (inline) return inlineById.get(inline[1] ?? '')?.[Number(inline[2])]
179 return undefined
180}
181
182// The status line under the prompt outlives a collapsed band, so the count of what is on offer
183// stays on screen when the buttons do not.
184// 底部只留給下一步:不顯示狀態列提示,也清掉舊版留下的
185const status = ($: EngineInterface): void => {
186 $.ui.status(undefined)
187}
188
189// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
190// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
191const MOD = 'pill-copy'
192let disabled = false
193const readDisabled = async ($: EngineInterface): Promise<boolean> => {
194 const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
195 disabled = raw
196 .split(',')
197 .map(v => v.trim())
198 .some(v => v === 'all' || v === MOD)
199 return disabled
200}
201
202// 把回答切成依序的段落:一般文字,以及 parse() 會認成區塊的段(code、引用、表格、清單)。
203// copy 是該區塊複製出去的內容,與 parse() 的 text 相同,用來對回按鈕的編號。
204type Segment = { md: string; copy?: string }
205export const segments = (answer: string): Segment[] => {
206 const out: Segment[] = []
207 let prose: string[] = []
208 let cur: { kind: 'fence' | 'quote' | 'table' | 'list'; lines: string[]; body: string[]; mark?: string } | undefined
209 const flushProse = () => {
210 if (prose.join('').trim()) out.push({ md: prose.join('\n') })
211 prose = []
212 }
213 const flushCur = () => {
214 if (!cur) return
215 const md = cur.lines.join('\n')
216 const copy = clean(cur.body.join('\n'))
217 const isBlock = copy.length >= 3 && (cur.kind !== 'table' || cur.lines.length >= 2)
218 if (isBlock) out.push({ md, copy })
219 else prose.push(...cur.lines)
220 cur = undefined
221 }
222 for (const line of answer.split('\n')) {
223 const open = /^\s*(`{3,}|~{3,})(.*)$/.exec(line)
224 if (cur?.kind === 'fence') {
225 cur.lines.push(line)
226 const m = open?.[1]
227 if (m && cur.mark && m[0] === cur.mark[0] && m.length >= cur.mark.length && !(open?.[2] ?? '').trim()) flushCur()
228 else cur.body.push(line)
229 continue
230 }
231 const kind = open?.[1] ? 'fence'
232 : /^\s*>\s?/.test(line) ? 'quote'
233 : /^\s*\|.*\|\s*$/.test(line) ? 'table'
234 : /^\s*([-*+]|\d+[.)])\s+\S/.test(line) || (cur?.kind === 'list' && /^\s{2,}\S/.test(line)) ? 'list'
235 : undefined
236 if (kind && cur?.kind === kind) {
237 cur.lines.push(line)
238 cur.body.push(kind === 'quote' ? line.replace(/^\s*>\s?/, '') : line)
239 continue
240 }
241 flushCur()
242 if (!kind) { prose.push(line); continue }
243 flushProse()
244 cur = kind === 'fence'
245 ? { kind, lines: [line], body: [], mark: open![1] }
246 : { kind, lines: [line], body: [kind === 'quote' ? line.replace(/^\s*>\s?/, '') : line] }
247 }
248 flushCur()
249 flushProse()
250 return out
251}
252
253// 圓角膠囊(Nerd Font 的 ):Claude 橘底、深色字
254const ORANGE = '#D97757'
255const INK = '#1B1B1F'
256const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
257
258export const register: Register = (on) => {
259 on('session.start', async ($, e, next) => {
260 const r = await next(e)
261 if (await readDisabled($)) return r
262 if ((await $.store.get(WA_KEY).catch(() => undefined)) === true) wa = true
263 const home = await $.env.get('HOME').catch(() => undefined)
264 if (home) {
265 const dir = `${home}/.claude/copy-stash`
266 const made = await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5000 }).catch(() => undefined)
267 if (made?.exitCode === 0) {
268 stashPath = `${dir}/stash.jsonl`
269 stashed = (await readStash($)).length
270 }
271 }
272 await $.command
273 .register({
274 name: 'stash',
275 description: 'Copy a block from the stash: /stash lists, /stash 7 copies, /stash wa toggles the WhatsApp wrap (copy-band)',
276 argumentHint: '[n | wa [n]]',
277 immediate: true,
278 })
279 .catch((err) => $.ui.log(`copy-band: /stash not registered: ${err}`))
280 return r
281 })
282
283 on('command.run', { command: 'stash' }, async ($, e) => {
284 const args = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
285 const toggle = args[0] === 'wa'
286 const rest = toggle ? args.slice(1) : args
287
288 if (toggle && !rest.length) {
289 wa = !wa
290 await $.store.set(WA_KEY, wa).catch(() => undefined)
291 $.ui.invalidate('ui.render')
292 return { text: `whatsapp wrap ${wa ? 'on' : 'off'} — copies are fenced with \`\`\`` }
293 }
294
295 const entries = await readStash($)
296 if (!entries.length) return { text: 'copy stash is empty — it fills as answers come in' }
297
298 const pick = rest[0]
299 if (pick === undefined) {
300 const list = entries
301 .slice(0, 20)
302 .map((b, i) => `${String(i + 1).padStart(2)}. ${b.label}${b.text.includes('\n') ? ` (${b.text.split('\n').length} lines)` : ''}`)
303 .join('\n')
304 return { text: `copy stash, newest first — /stash <n>${wa ? '' : ', /stash wa <n> to fence it'}\n${list}` }
305 }
306
307 const n = Number(pick)
308 const block = Number.isInteger(n) ? entries[n - 1] : undefined
309 if (!block) return { text: `copy-band: no stash entry ${pick} — /stash lists what there is` }
310
311 const was = wa
312 if (toggle) wa = true
313 await copy($, block)
314 wa = was
315 return { text: `copied: ${block.label}` }
316 })
317
318 on('turn.complete', async ($, e, next) => {
319 if (disabled) return next(e)
320 const r = await next(e)
321 // A subagent's turn carries an agentId and never reaches the person's screen.
322 if (e.agentId) return r
323 const found = parse(e.answer)
324 if (!found.length) {
325 if (blocks.length) {
326 blocks = []
327 status($)
328 $.ui.invalidate('ui.render')
329 }
330 return r
331 }
332 blocks = found
333 await stash($, found)
334 stashed += found.length
335 status($)
336 $.ui.invalidate('ui.render')
337 return r
338 })
339
340 // A press is answered here rather than in the Button closures: a closure handle belongs to one
341 // drawing, and a press that lands during a redraw is dropped by core, while the hook still sees
342 // the event and its key.
343 on('ui.press', { plugin: MOD }, async ($, e, next) => {
344 if (disabled) return next(e)
345 if (e.element === 'copy:wa') {
346 wa = !wa
347 await $.store.set(WA_KEY, wa).catch(() => undefined)
348 $.ui.invalidate('ui.render')
349 return { element: e.element }
350 }
351 const block = blockAt(e.element)
352 if (!block) return next(e)
353 await copy($, block)
354 return { element: e.element }
355 })
356
357 // 每個區塊在它自己的右上角放一顆複製膠囊:把回答切成段落,區塊段上方靠右畫膠囊,再用 Markdown 畫本體。
358 // 只用滑鼠點(hotkey 在 band 以外會被拒)。畫失敗就退回引擎原本的畫法。
359 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
360 if (disabled) return next(e)
361 if (e.surface !== 'terminal') return next(e)
362 const found = blocksFor(e.props.text)
363 if (!found.length) return next(e)
364 try {
365 const { Box, Text, Button, Markdown } = await $.ui.resolve(e)
366 const id = digest(e.props.text)
367 const segs = segments(e.props.text)
368 return (
369 <Box flexDirection="column">
370 {segs.map((s, k) => {
371 const i = s.copy === undefined ? -1 : found.findIndex((b) => b.text === s.copy)
372 if (i < 0) return <Markdown key={`seg:${id}:${k}`} text={s.md} />
373 return (
374 <Box key={`seg:${id}:${k}`} flexDirection="column">
375 <Box flexDirection="row" justifyContent="flex-end">
376 {/* 不要膠囊:只放圖示;primary 會用 Claude 的強調色畫,滑鼠移上去加粗 */}
377 <Button key={`inline:${id}:${i}`} variant="primary" label={'\u{F018F}'} hover={{ bold: true }} onPress={() => undefined} />
378 </Box>
379 <Markdown text={s.md} />
380 </Box>
381 )
382 })}
383 </Box>
384 )
385 } catch (err) {
386 $.ui.log(`pill-copy: inline render failed: ${err}`)
387 return next(e)
388 }
389 })
390}
391