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

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.
C:\…, ~/…, /c/…), relative paths in inline code (hooks/register.tsx:12), and the body of the fenced block after //Next:.//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.▲ and ▼ scroll through the rest. A new reply scrolls back to the top.//Next: line. The engine's [-] mark collapses it./plugin install copy-list --marketplace Crinitys/copy-list
Answer y to add the marketplace, then pick the user scope.
claude plugin test .hooks/register.tsx 109 lines1import { 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}
109hooks/extract.ts 36 lines1import 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}
36types/index.d.ts 8 lines1export 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