Keep pasted and dictated text editable in the Claude Code prompt instead of the opaque [Pasted text #N +X lines] tag.

A Claude Code mod that keeps pasted and dictated text editable in the prompt. Claude Code folds every multi-line or long paste into an opaque [Pasted text #1 +30 lines] tag, so you cannot review or fix what you pasted. This hurts most with voice-dictation tools that insert through the clipboard (Wispr Flow, Typeless, BetterWispr) and snippet tools, where "paste again to expand" does not work (claude-code#23134).
paste-inline watches the prompt draft; when a new paste tag appears and the clipboard holds a text with the same line count, it replaces the tag with the text.
/plugin marketplace add rafal-one/paste-inline
/plugin install paste-inline@paste-inline
Needs a Claude Code build with mods (function hooks) and a clipboard tool: pbpaste (macOS), wl-paste, xclip or xsel (Linux), powershell.exe (Windows / WSL). Over SSH there is usually no clipboard, and the tag stays.
Claude Code raises no hook for a collapsed paste, so the mod polls the draft every 250 ms. For each new [Pasted text #N +X lines] tag it reads the clipboard once and expands the tag only if the clipboard's line count matches X (one line of difference is tolerated for a trailing newline). A mismatch leaves the tag alone and is not retried. Text you typed before and after the paste is kept.
maxLines: pastes longer than this keep the tag (default 0 = expand everything).clipboardCommand: a command that prints the clipboard, split on spaces (for example wl-paste --no-newline). Default: auto-detect./paste-inline on|off: toggle for the session.claude plugin validate .
claude plugin test . # 6 unit tests
Live check (2026-10-19, tmux, Claude Code 2.1.303): typed fix this: , bracketed-pasted 30 lines, typed please. The draft showed fix this: line 0 ... line 29 please expanded, not re-collapsed.
If it saves you time, you can support development: PayPal.
MIT licensed.
hooks/register.ts 83 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 CLIPBOARD_CANDIDATES,
5 clipboardMatches,
6 expand,
7 findTags,
8 splitCommand,
9 tooLong,
10} from './paste'
11
12const POLL_MS = 250
13
14async function readClipboard(
15 $: EngineInterface,
16 argv: readonly string[],
17): Promise<string | undefined> {
18 const r = await $.process.run(argv, { timeoutMs: 2000 }).catch(() => undefined)
19 return r !== undefined && r.exitCode === 0 ? r.stdout : undefined
20}
21
22async function findClipboard(
23 $: EngineInterface,
24 custom: string[],
25): Promise<readonly string[] | undefined> {
26 const list = custom.length > 0 ? [custom] : CLIPBOARD_CANDIDATES
27 for (const argv of list) {
28 if ((await readClipboard($, argv)) !== undefined) return argv
29 }
30 return undefined
31}
32
33export const register: Register = (on, options) => {
34 const custom = splitCommand(options?.clipboardCommand)
35 const maxLines = typeof options?.maxLines === 'number' ? options.maxLines : 0
36 const seen = new Set<number>() // tag numbers already tried: never retry a mismatch
37 let argv: readonly string[] | undefined
38 let isOn = true
39 let isBusy = false
40
41 on('session.start', async ($, e, next) => {
42 await $.command.register({
43 name: 'paste-inline',
44 description: 'Turn inline paste expansion on or off (/paste-inline on|off)',
45 })
46
47 $.clock.every(POLL_MS, async () => {
48 if (!isOn || isBusy) return
49 isBusy = true
50 try {
51 const box = await $.prompt.read()
52 const fresh = findTags(box.text).filter(t => !seen.has(t.id))
53 if (fresh.length === 0) return
54 argv = argv ?? (await findClipboard($, custom))
55 if (argv === undefined) {
56 for (const t of fresh) seen.add(t.id)
57 return
58 }
59 // Only the newest tag can be what the clipboard holds now.
60 const tag = fresh[fresh.length - 1]
61 for (const t of fresh) seen.add(t.id)
62 const clip = await readClipboard($, argv)
63 if (clip === undefined || !clipboardMatches(tag, clip) || tooLong(clip, maxLines)) return
64 const now = await $.prompt.read()
65 if (!now.text.includes(tag.raw)) return
66 await $.prompt.fill({ text: expand(now.text, tag, clip), mode: 'replace' })
67 } finally {
68 isBusy = false
69 }
70 })
71
72 return next(e)
73 })
74
75 on('command.run', { command: 'paste-inline' }, async (_$, e) => {
76 const arg = e.args.trim().toLowerCase()
77 if (arg === 'off') isOn = false
78 else if (arg === 'on') isOn = true
79 else isOn = !isOn
80 return { text: `paste-inline is ${isOn ? 'on' : 'off'}.` }
81 })
82}
83hooks/paste.ts 54 lines1// Pure helpers for finding Claude Code's paste tags and matching them to the clipboard.
2
3export type Tag = { raw: string; id: number; lines: number }
4
5const TAG = /\[Pasted text #(\d+)(?: \+(\d+) lines)?\]/g
6
7export function findTags(draft: string): Tag[] {
8 const out: Tag[] = []
9 for (const m of draft.matchAll(TAG)) {
10 out.push({ raw: m[0], id: Number(m[1]), lines: m[2] === undefined ? 0 : Number(m[2]) })
11 }
12 return out
13}
14
15const newlines = (s: string): number => (s.match(/\n/g) ?? []).length
16
17/** Strips what clipboard tools add: CRLF and one trailing newline. */
18export function normalize(clip: string): string {
19 return clip.replace(/\r\n/g, '\n').replace(/\n$/, '')
20}
21
22/**
23 * The tag counts the pasted text's lines; clipboards differ by a trailing newline,
24 * so a difference of one is tolerated. An empty clipboard never matches.
25 */
26export function clipboardMatches(tag: Tag, clip: string): boolean {
27 const text = normalize(clip)
28 if (text.trim() === '') return false
29 const n = newlines(text)
30 return Math.abs(n - tag.lines) <= 1
31}
32
33export function tooLong(clip: string, maxLines: number): boolean {
34 return maxLines > 0 && newlines(normalize(clip)) + 1 > maxLines
35}
36
37export function expand(draft: string, tag: Tag, clip: string): string {
38 const at = draft.indexOf(tag.raw)
39 if (at < 0) return draft
40 return draft.slice(0, at) + normalize(clip) + draft.slice(at + tag.raw.length)
41}
42
43export function splitCommand(s: unknown): string[] {
44 return typeof s === 'string' ? s.split(/\s+/).filter(x => x !== '') : []
45}
46
47export const CLIPBOARD_CANDIDATES: readonly string[][] = [
48 ['pbpaste'],
49 ['wl-paste', '--no-newline'],
50 ['xclip', '-selection', 'clipboard', '-o'],
51 ['xsel', '--clipboard', '--output'],
52 ['powershell.exe', '-NoProfile', '-Command', 'Get-Clipboard -Raw'],
53]
54