SLOPSHOPPER

copy-list

A band above the prompt that lists the paths and //Next: lines from Claude's replies, newest 10, each with a copy button

newbandtimer
v0.4.2MITupdated 2026-10-09Crinitys/copy-list
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · copy-list
› 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 LIST 1-1 of 1/10 [ Path ] src/audit.ts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── ⧉ COPY LIST 1-1 of 1/10 [ Path ] src/audit.ts
README

copy-list

A Claude Code mod. It draws a band above the prompt that lists the paths and the //Next: lines from Claude's replies. Each row has a button that copies the row to the clipboard.

  • The mod reads the final answer of each main-loop turn. It skips subagent reports.
  • It collects absolute paths (C:\…, ~/…, /c/…), relative paths in inline code (hooks/register.tsx:12), and the body of the fenced block after //Next:.
  • A //Next: row also has a ▶ button. It copies the line, clears the prompt, and sends the line as your prompt. A line that starts with / is a slash command: ▶ only puts it in the prompt, and you press Enter.
  • The mod keeps the newest 10 items. It drops the oldest item first. A repeat moves to the top.
  • The band shows 5 rows at a time, newest first. ▲ and ▼ scroll through the rest. A new reply scrolls back to the top.
  • The band stays hidden until a reply has a path or a //Next: line. The engine's [-] mark collapses it.

Install

/plugin install copy-list --marketplace Crinitys/copy-list

Answer y to add the marketplace, then pick the user scope.

Test

claude plugin test .
Source 3 files
hooks/register.tsx 109 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { MAX, extract, merge } from './extract.ts'
5
6// Rows of clips the band shows at once; ▲ and ▼ scroll the rest.
7const ROWS = 5
8const clips = atom({ plugin: 'copy-list', key: 'clips' } as const, [])
9const offset = atom({ plugin: 'copy-list', key: 'offset' } as const, 0)
10// A short result line in the band's header, in place of a toast.
11const notice = atom({ plugin: 'copy-list', key: 'notice' } as const, '')
12
13export const register: Register = on => {
14  on('turn.complete', async ($, e, next) => {
15    // Main loop only: subagent reports are not what the person reads.
16    if (!e.agentId && e.answer) {
17      const added = extract(e.answer)
18      if (added.length > 0) {
19        await update($, clips, list => merge(list, added))
20        await update($, offset, () => 0)
21      }
22    }
23
24    return next(e)
25  })
26
27  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
28    const list = await read($, clips)
29    if (e.props.hasSurvey || list.length === 0) {
30      return next(e)
31    }
32
33    const { Box, Button, Text } = $.ui.resolve(e)
34    const newest = [...list].reverse()
35    const last = Math.max(0, newest.length - ROWS)
36    const top = Math.min(await read($, offset), last)
37    const scroll = (by: number) => update($, offset, n => Math.max(0, Math.min(last, n + by)))
38    const say = async (text: string) => {
39      await update($, notice, () => text)
40      $.clock.after(2000, () => void update($, notice, n => (n === text ? '' : n)))
41    }
42    const said = await read($, notice)
43
44    // A blank row and a rule set the band apart from the transcript above it.
45    return (
46      <Box flexDirection="column" marginTop={1}>
47        <Text dimColor>{'─'.repeat(Math.max(1, e.props.bodyColumns))}</Text>
48        <Box justifyContent="space-between">
49          <Text>
50            <Text color="claude" bold>
51              ⧉ COPY LIST
52            </Text>
53            <Text dimColor>
54              {' '}
55              {top + 1}-{Math.min(top + ROWS, newest.length)} of {newest.length}/{MAX}
56            </Text>
57          </Text>
58          <Box>
59            {said !== '' && <Text color={said.startsWith('✓') ? 'success' : 'error'}>{said} </Text>}
60            {newest.length > ROWS && (
61              <Box>
62                <Button key="up" label="▲" onPress={() => scroll(-1)} />
63                <Button key="down" label="▼" onPress={() => scroll(1)} />
64              </Box>
65            )}
66          </Box>
67        </Box>
68        {newest.slice(top, top + ROWS).map((clip, n) => {
69          const i = top + n
70          return (
71            <Box key={`row:${i}`}>
72              <Button
73                key={`copy:${i}`}
74                label={clip.kind === 'next' ? 'Next' : 'Path'}
75                variant={clip.kind === 'next' ? 'primary' : undefined}
76                onPress={async press => {
77                  const done = await $.ui.copy({ text: clip.text, surface: press.surface })
78                  await say(done.isCopied ? '✓ Copied' : `✗ Copy failed: ${done.reason}`)
79                }}
80              />
81              {clip.kind === 'next' ? (
82                <Button
83                  key={`fill:${i}`}
84                  label="▶"
85                  onPress={async press => {
86                    await $.ui.copy({ text: clip.text, surface: press.surface })
87                    // A plugin's submit does not run slash commands, so those only fill the prompt.
88                    if (clip.text.startsWith('/')) {
89                      const done = await $.prompt.fill({ text: clip.text, mode: 'replace' })
90                      await say(done.isFilled ? '✓ In the prompt; press Enter' : `✗ Prompt fill failed: ${done.refusal ?? 'refused'}`)
91                      return
92                    }
93                    await $.prompt.fill({ text: '', mode: 'replace' })
94                    void $.prompt.submit({ text: clip.text, asUser: true })
95                    await say('✓ Sent')
96                  }}
97                />
98              ) : (
99                <Text>{'     '}</Text>
100              )}
101              <Text wrap="truncate-end"> {clip.text.replace(/\s*\n\s*/g, ' ⏎ ')}</Text>
102            </Box>
103          )
104        })}
105      </Box>
106    )
107  })
108}
109
hooks/extract.ts 36 lines
1import type { Clip } from '../types'
2
3export const MAX = 10
4
5// The body of each fenced block that follows a //Next: label.
6const NEXT = /\/\/Next:\s*\n\s*```[^\n]*\n([\s\S]*?)\n\s*```/g
7// Absolute Windows paths, ~/ paths and /x/ Git Bash paths.
8const BARE = /(?:[A-Za-z]:[\\/]|~[\\/]|\/[a-z]\/)[^\s`'"<>|*?()\[\]]+/g
9// Inline code that looks like a relative path: has a slash and an extension or a trailing slash.
10const CODE = /`([^`\s]+)`/g
11const LOOKS_RELATIVE = /^\.{0,2}[\w.-]+(?:[\\/][\w.@-]+)+(?:\.\w+(?::\d+)?|[\\/])$/
12
13const tidy = (s: string) => s.replace(/[.,;:)\]]+$/, '')
14
15export function extract(answer: string): Clip[] {
16  const found: Clip[] = []
17  const rest = answer.replace(NEXT, (_, body: string) => {
18    found.push({ kind: 'next', text: body.trim() })
19    return ''
20  })
21  const paths = new Set<string>()
22  for (const m of rest.matchAll(BARE)) paths.add(tidy(m[0]))
23  for (const m of rest.matchAll(CODE)) {
24    const code = m[1]
25    if (code && LOOKS_RELATIVE.test(code)) paths.add(tidy(code))
26  }
27  // Paths first, so the //Next: line ends up newest.
28  return [...[...paths].map(text => ({ kind: 'path' as const, text })), ...found]
29}
30
31/** Appends new clips, moves repeats to the end, keeps the newest MAX. */
32export function merge(list: Clip[], added: Clip[]): Clip[] {
33  const keys = new Set(added.map(c => c.text))
34  return [...list.filter(c => !keys.has(c.text)), ...added].slice(-MAX)
35}
36
types/index.d.ts 8 lines
1export type Clip = { kind: 'path' | 'next'; text: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'copy-list': { clips: Clip[]; offset: number; notice: string }
6  }
7}
8