Read and fix a long paste before you send it: Edit unfolds a [Pasted text #n] placeholder back into the prompt, View opens it in a pane

A Claude Code mod from Encore Mods. Read, and fix, what you pasted before you send it.

Claude Code folds a long paste into [Pasted text #12 +26 lines], and there's no way to see it again before sending. This mod adds a quiet row above the prompt for each placeholder still in the box:
[ Edit ] [ View ] Pasted text #12 · 27 lines · “Here is the spec to review…”
e) puts the whole text back in the prompt box in place of the placeholder, so you can fix a dictation slip or trim it before sending.v once the row has the focus, after ctrl+x Tab) opens the whole text in a pane; give it focus with Tab and scroll with the arrows, however long it is. The button turns into Hide to close it./paste opens the last pasted text.How it works: plugins can't read what's behind a placeholder before the prompt is sent (a paste doesn't even raise an edit event). So when a new placeholder shows up, the mod reads the clipboard, which still holds what you just pasted (pbpaste on macOS, wl-paste or xclip on Linux), and keeps it only if its line count matches the placeholder's. Nothing is stored on disk or sent anywhere. If you copy something else within a second of pasting, the row simply doesn't show.
/plugin marketplace add benjaminb10/encore-mods
/plugin install paste-peek@encore-mods
Then /reload-plugins or restart Claude Code. Needs Claude Code 2.1.287 or later.
git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/paste-peekhooks/register.tsx 193 lines1// paste-peek: a long paste folds into "[Pasted text #139 +26 lines]" in the prompt box, and Claude Code hands
2// plugins no way to read what's behind it before the prompt is sent (the paste doesn't even raise `prompt.edit`).
3// So when a new placeholder shows up in the box, this reads the clipboard (pbpaste, wl-paste or xclip), which still holds what was
4// just pasted, and keeps it only if its line count matches the placeholder's. A quiet row above the prompt then
5// offers Edit and View for each placeholder still in the box: Edit puts the whole text back in the prompt box in
6// place of its placeholder, to fix before sending; View opens it in a pane (the arrows scroll it).
7// Sending the prompt clears it all.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Paste } from '../types'
12
13const PANE = 'paste-peek'
14const pastes = atom({ plugin: 'paste-peek', key: 'pastes' } as const, [])
15const box = atom({ plugin: 'paste-peek', key: 'box' } as const, '')
16const viewing = atom({ plugin: 'paste-peek', key: 'viewing' } as const, null)
17
18/** The placeholders in a prompt: "[Pasted text #139 +26 lines]" -> { id: 139, extra: 26 }. */
19export function placeholders(text: string): { id: number; extra: number | null }[] {
20 return [...text.matchAll(/\[Pasted text #(\d+)(?: \+(\d+) lines?)?\]/g)].map(m => ({ id: Number(m[1]), extra: m[2] ? Number(m[2]) : null }))
21}
22
23/** Whether a text fits a placeholder's "+N lines" (Claude Code counts the lines past the first, give or take one). */
24export function fits(text: string, extra: number | null): boolean {
25 if (extra === null) return text.length > 0
26 const breaks = text.split('\n').length - 1
27 return Math.abs(breaks - extra) <= 1
28}
29
30/** The first non-empty line, cut to `max` characters. */
31export function preview(text: string, max = 48): string {
32 const line = text.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
33 return line.length > max ? line.slice(0, max - 1) + '…' : line
34}
35
36/** The clipboard's text: pbpaste on macOS, wl-paste or xclip on Linux; '' when none answers. */
37async function clipboard($: EngineInterface): Promise<string> {
38 for (const argv of [['pbpaste'], ['wl-paste', '--no-newline'], ['xclip', '-o', '-selection', 'clipboard']]) {
39 const r = await $.process.run(argv).catch(() => null)
40 if (r && r.exitCode === 0) return String(r.stdout)
41 }
42 return ''
43}
44
45/** Notes the box, and for each placeholder not seen yet, takes the clipboard if it matches. */
46async function watch($: EngineInterface, text: string) {
47 if (text !== (await read($, box))) await update($, box, () => text)
48 // The placeholder was erased from the box: its preview goes too
49 const open = await read($, viewing)
50 if (open !== null && !placeholders(text).some(ph => ph.id === open)) {
51 await update($, viewing, () => null)
52 await $.ui.close({ id: PANE }).catch(() => undefined)
53 }
54 const known = new Set((await read($, pastes)).map(p => p.id))
55 for (const ph of placeholders(text)) {
56 if (known.has(ph.id)) continue
57 const pasted = await clipboard($)
58 if (!fits(pasted, ph.extra)) continue
59 const paste: Paste = { id: ph.id, text: pasted, lines: pasted.split('\n').length }
60 await update($, pastes, list => [...list.filter(p => p.id !== paste.id), paste].slice(-10))
61 }
62}
63
64/** Opens the pane on one pasted text. */
65async function show($: EngineInterface, id: number) {
66 await update($, viewing, () => id)
67 await $.ui.open({ id: PANE, title: `Pasted text #${id}`, closeOnEscape: true })
68}
69
70/** The prompt with one placeholder swapped for its whole text. */
71export function unfold(prompt: string, id: number, text: string): string {
72 return prompt.replace(new RegExp(`\\[Pasted text #${id}(?: \\+\\d+ lines?)?\\]`), () => text)
73}
74
75/** Edit: the whole pasted text goes back in the prompt box in place of its placeholder, to fix before sending. */
76async function edit($: EngineInterface, id: number) {
77 const paste = (await read($, pastes)).find(p => p.id === id)
78 if (!paste) return
79 const now = await $.prompt.read()
80 const text = unfold(now.text, id, paste.text)
81 if (text === now.text) return
82 if ((await read($, viewing)) === id) {
83 await update($, viewing, () => null)
84 await $.ui.close({ id: PANE }).catch(() => undefined)
85 }
86 await $.prompt.fill({ text, mode: 'replace' })
87 await update($, box, () => text)
88}
89
90/** View or Hide: the same button opens the text and closes it again. */
91async function toggle($: EngineInterface, id: number) {
92 if ((await read($, viewing)) === id) {
93 await update($, viewing, () => null)
94 await $.ui.close({ id: PANE }).catch(() => undefined)
95 } else {
96 await show($, id)
97 }
98}
99
100export const register: Register = on => {
101 let alive = true
102
103 on('session.start', async ($, e, next) => {
104 await $.command.register({ name: 'paste', description: 'Show the last pasted text in full' })
105 // A paste lands without an edit event: look at the box every second
106 void (async () => {
107 while (alive) {
108 const now = await $.prompt.read().catch(() => null)
109 if (now) await watch($, now.text).catch(() => undefined)
110 await $.clock.sleep(1000)
111 }
112 })()
113 return next(e)
114 })
115
116 on('session.end', async ($, e, next) => {
117 alive = false
118 return next(e)
119 })
120
121 on('prompt.edit', async ($, e, next) => {
122 const r = await next(e)
123 await watch($, r.text)
124 return r
125 })
126
127 on('prompt.submit', async ($, e, next) => {
128 await update($, box, () => '')
129 if ((await read($, viewing)) !== null) {
130 await update($, viewing, () => null)
131 await $.ui.close({ id: PANE }).catch(() => undefined)
132 }
133 return next(e)
134 })
135
136 // Closed with its cross or Esc: the button reads View again
137 on('ui.close', async ($, e, next) => {
138 if (e.id === PANE) await update($, viewing, () => null)
139 return next(e)
140 })
141
142 on('command.run', { command: 'paste' }, async $ => {
143 const list = await read($, pastes)
144 const last = list[list.length - 1]
145 if (!last) return { text: 'No pasted text caught yet.' }
146 await show($, last.id)
147 return { text: `Pasted text #${last.id} opened.` }
148 })
149
150 // One quiet row per placeholder still in the box, above whatever else sits there
151 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152 const ids = new Set(placeholders(await read($, box)).map(p => p.id))
153 const shown = (await read($, pastes)).filter(p => ids.has(p.id)).slice(-3)
154 if (shown.length === 0 || e.props.hasSurvey) return next(e)
155 const { Box, Button, Text } = $.ui.resolve(e)
156 const open = await read($, viewing)
157 const theirs = await next(e)
158 // The button first, then one line of text cut at the edge, so nothing pushes the button away
159 return (
160 <Box flexDirection="column">
161 {shown.map(p => (
162 <Box key={`paste-${p.id}`}>
163 <Button key={`edit-${p.id}`} label="Edit" hotkey="e" onPress={() => edit($, p.id)} />
164 <Text> </Text>
165 <Button key={`view-${p.id}`} label={open === p.id ? 'Hide' : 'View'} hotkey="v" onPress={() => toggle($, p.id)} />
166 <Text dimColor wrap="truncate-end">
167 {' '}Pasted text #{p.id} · {p.lines} lines · “{preview(p.text, 60)}”
168 </Text>
169 </Box>
170 ))}
171 {theirs}
172 </Box>
173 )
174 })
175
176 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
177 const { Box, Text } = $.ui.resolve(e)
178 const id = await read($, viewing)
179 const paste = (await read($, pastes)).find(p => p.id === id)
180 if (!paste) return <Text dimColor>This pasted text is gone.</Text>
181 return (
182 <Box flexDirection="column">
183 <Text dimColor>
184 {paste.lines} lines · {paste.text.length} characters · Tab here, then the arrows to scroll
185 </Text>
186 {paste.text.split('\n').map((line, i) => (
187 <Text key={`l${i}`}>{line.length > 0 ? line : ' '}</Text>
188 ))}
189 </Box>
190 )
191 })
192}
193types/index.d.ts 8 lines1export type Paste = { id: number; text: string; lines: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'paste-peek': { pastes: Paste[]; box: string; viewing: number | null }
6 }
7}
8