Copy on select without the gutter bar and without line breaks in the middle of sentences

A Claude Code plugin that cleans the text you copy from the terminal.
In the fullscreen TUI, selecting text with the mouse copies it as it is drawn on screen: with the bar glyph of the left margin and with a line break wherever the row wrapped, in the middle of a sentence. This plugin rewrites the clipboard right after the copy, with the same text cleaned:
▎, │) and the reply marker are removed;List items, code blocks, drawn tables and text made of short lines are left as they are.
claude plugin marketplace add kbrianps/claude-clean-copy
claude plugin install clean-copy@claude-clean-copy
Then start a new session, or run /reload-plugins.
Claude Code's copy on select writes straight to the clipboard and does not raise the ui.copy plugin event, so a plugin cannot rewrite the text before it is copied (anthropics/claude-code#99758). The plugin works around that:
$.ui.selection(), an in-process call, not a clipboard read);It only writes to the clipboard when it holds the text you just selected in Claude Code, so a copy made in another app is never overwritten.
The plugin does not read the clipboard continuously.
The whole watcher is plugins/clean-copy/hooks/register.ts, under 70 lines.
"tui": "fullscreen") and copy on select on (the default).wl-paste (Wayland), xclip (X11) or pbpaste (macOS).claude plugin validate plugins/clean-copy
claude plugin test plugins/clean-copy
The cleaning rule is plugins/clean-copy/hooks/clean.ts, with its tests beside it.
MIT
hooks/register.ts 69 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { clean } from './clean'
4
5const TICK_MS = 250
6// For how many ticks, once the selection stops changing, Claude Code's own copy is still awaited:
7// two seconds, which is also the longest the clipboard is read after a selection.
8const WAIT_TICKS = 8
9// Clipboard readers, tried in order: Wayland, X11, macOS.
10const READERS = [
11 ['wl-paste', '--no-newline'],
12 ['xclip', '-selection', 'clipboard', '-o'],
13 ['pbpaste'],
14] as const
15
16let reader: readonly string[] | undefined
17
18async function readClipboard($: EngineInterface): Promise<string | undefined> {
19 for (const argv of reader ? [reader] : READERS) {
20 const ran = await $.process.run(argv, { timeoutMs: 2000 }).catch(() => undefined)
21 if (ran?.exitCode !== 0) continue
22 reader = argv
23 return ran.stdout
24 }
25 return undefined
26}
27
28export const register: Register = on => {
29 let timer: Timer | undefined
30
31 // Claude Code's copy on select writes straight to the clipboard, without raising `ui.copy`:
32 // the watcher waits for that copy to land and writes the same text over it, cleaned.
33 on('session.start', ($, e, next) => {
34 let seen: string | undefined
35 let ticks = 0
36 let isDone = false
37 let isBusy = false
38
39 const tick = async () => {
40 const raw = (await $.ui.selection())?.text
41 if (raw !== seen) {
42 seen = raw
43 ticks = 0
44 isDone = false
45 return
46 }
47 if (raw === undefined || isDone || ticks++ >= WAIT_TICKS) return
48 const text = clean(raw)
49 if (text === raw) {
50 isDone = true
51 return
52 }
53 if ((await readClipboard($))?.trim() !== raw.trim()) return
54 isDone = (await $.ui.copy({ text })).isCopied
55 }
56
57 timer?.cancel()
58 timer = $.clock.every(TICK_MS, () => {
59 if (isBusy) return
60 isBusy = true
61 void tick().finally(() => {
62 isBusy = false
63 })
64 })
65
66 return next(e)
67 })
68}
69hooks/clean.ts 62 lines1// Bars the terminal draws on the margin (blockquote, pane border) and the reply marker.
2const GUTTER = /^(\s*)[▎▏▍▌▐┃│]\s?/
3const MARKER = /^(\s*)[⏺●]\s/
4const BLOCK_START = /^\s*([-*+•]\s|\d+[.)]\s|#{1,6}\s|>|```|~~~|\||[│┃┌├└╭╰─━])/
5const HEADING = /^\s*#{1,6}\s/
6const FENCE = /^\s*(```|~~~)/
7const MIN_WRAP = 60
8const MAX_WRAP = 240
9
10function stripGutter(line: string): string {
11 const bars = line.match(/[│┃]/g)?.length ?? 0
12 // A drawn table row has more than one bar: left as it is.
13 if (bars > 1) return line
14 return line.replace(GUTTER, '$1').replace(MARKER, '$1')
15}
16
17function indentOf(line: string): number {
18 return line.length - line.trimStart().length
19}
20
21function hasGutter(line: string): boolean {
22 return stripGutter(line) !== line
23}
24
25// A selection that starts in the middle of a row has no margin on its first line, and so says
26// nothing about the indentation of the others: it is measured on the lines that follow.
27function dedent(lines: string[], startsMidRow: boolean): string[] {
28 const measured = (startsMidRow ? lines.slice(1) : lines).filter(line => line.trim() !== '')
29 if (measured.length === 0) return lines
30 const indent = Math.min(...measured.map(indentOf))
31 return lines.map(line => line.slice(Math.min(indent, indentOf(line))))
32}
33
34// The line was wrapped by the width when the next line's first word did not fit on it.
35function wrapsInto(line: string, next: string, width: number): boolean {
36 if (line.trim() === '' || next.trim() === '') return false
37 if (HEADING.test(line) || FENCE.test(line) || BLOCK_START.test(next)) return false
38 const word = next.trimStart().split(/\s/, 1)[0] ?? ''
39 return line.length + 1 + word.length > width
40}
41
42export function clean(text: string): string {
43 const rows = text.split(/\r?\n/)
44 const startsMidRow = rows.length > 1 && !hasGutter(rows[0] ?? '') && rows.slice(1).some(hasGutter)
45 const lines = dedent(rows.map(line => stripGutter(line).trimEnd()), startsMidRow)
46 const width = Math.max(0, ...lines.map(line => line.length))
47 // Joins only text that looks like screen rows: the longest line gives the wrap width.
48 if (width < MIN_WRAP || width > MAX_WRAP) return lines.join('\n')
49
50 const out: string[] = []
51 let inFence = false
52 let isJoined = false
53 lines.forEach((line, i) => {
54 if (isJoined) out[out.length - 1] += ' ' + line.trimStart()
55 else out.push(line)
56 if (FENCE.test(line)) inFence = !inFence
57 const next = lines[i + 1]
58 isJoined = !inFence && next !== undefined && wrapsInto(line, next, width)
59 })
60 return out.join('\n')
61}
62