SLOPSHOPPER

Paste peek

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

newpanebandcommandpromptprocess
★ 1v0.2.0MITupdated 2026-10-24benjaminb10/encore-mods/plugins/paste-peek
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paste-peek
│ ┃ paste-peek ✕ › fix the failing auth test and add an audit log call │ ┃ This pasted text is gone. │ ⏺ 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 │ │ › /paste │ ⎿ paste-peek: No pasted text caught yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · paste-peek
This pasted text is gone.
README

Paste peek

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

paste-peek: a long paste folds into a placeholder, the View button above the prompt opens the whole text in a pane, Hide closes 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…”
  • Edit (or 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.
  • View (or 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.
  • Erase the placeholder from the prompt, or send the prompt, and the preview goes away.
  • /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.

Install

/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.

Try it without installing

git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/paste-peek
Source 2 files
hooks/register.tsx 193 lines
1// 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}
193
types/index.d.ts 8 lines
1export 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