SLOPSHOPPER

clean-copy

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

newprocesstimer
A shopper browsing a rack in a slop shop
README

claude-clean-copy

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:

  • the margin bar (▎, │) and the reply marker are removed;
  • the screen indentation is removed;
  • rows wrapped by the terminal width are joined back into one line.

List items, code blocks, drawn tables and text made of short lines are left as they are.

Install

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.

How it works

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:

  1. every 250 ms it asks Claude Code for its own current selection ($.ui.selection(), an in-process call, not a clipboard read);
  2. after you select something that cleaning would change, it reads the clipboard until it holds exactly that selection, for at most 2 seconds;
  3. it writes the cleaned text over it and stops.

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.

Privacy

The plugin does not read the clipboard continuously.

  • The clipboard is read only after a selection made inside Claude Code, and only when cleaning would change the selected text. With no selection, or with a selection that is already clean, it is never read.
  • Reads stop as soon as Claude Code's copy is seen, or after 2 seconds (8 reads at most per selection).
  • During those 2 seconds it reads whatever the clipboard holds, including text copied from another app. That content is compared in memory with the selection and dropped.
  • Nothing is stored, logged or sent anywhere. The plugin makes no network calls and writes no files.

The whole watcher is plugins/clean-copy/hooks/register.ts, under 70 lines.

Requirements and limits

  • Claude Code with the fullscreen TUI ("tui": "fullscreen") and copy on select on (the default).
  • A clipboard reader on the machine: wl-paste (Wayland), xclip (X11) or pbpaste (macOS).
  • Tested on Ubuntu with GNOME on Wayland and Ghostty. The X11 and macOS readers are untested. Windows is not supported.
  • Pasting less than about half a second after releasing the mouse may still get the raw text.
  • If you stop dragging and hold the mouse for more than 2 seconds before releasing, the copy is not cleaned. Select again.
  • A selection made by the terminal itself (shift + drag) never reaches Claude Code, so it is not cleaned.
  • Joining wrapped rows is a heuristic on the longest line of the selection. It can be wrong; please open an issue with the text as selected and as pasted.
  • The plugin API (function hooks) is early access and may change between Claude Code releases. Written against 2.1.289.

Development

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.

License

MIT

Source 2 files
hooks/register.ts 69 lines
1import 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}
69
hooks/clean.ts 62 lines
1// 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