SLOPSHOPPER

pill-copy

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

newrowscommandtoaststatusprocess
v0.1.2no licenseupdated 2026-10-10youngOman/pill-mods/mods/pill-copy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pill-copy
› 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 › /stash ⎿ pill-copy: copy stash is empty — it fills as answers come in ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pill-mods

三個 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)。

Source 1 files
hooks/register.tsx 391 lines
1/* @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