SLOPSHOPPER

copy-button

Ctrl+click copy link on every code block in Claude's replies (normal mode; WSL + Windows Terminal, Linux desktops, nothing to install)

newrowsprocess
v0.6.1MITupdated 2026-10-06GeckoKing9/claude-code-copy-button
A shopper browsing a rack in a slop shop
README

copy-button

Ctrl+click to copy any code block in Claude Code. One click, exact text, straight to your clipboard. Works in WSL with Windows Terminal, and on Linux desktops (X11 and Wayland) with nothing extra to install.

Ctrl+clicking the copy link on a code block, then pasting it into the prompt

Normal mode only (Claude Code's default renderer, not fullscreen). You Ctrl+click the link. Runs in WSL + Windows Terminal and on Linux desktops; anywhere else it stays out of the way. See Where it works.

Why

Copying a code block out of Claude Code in the terminal is annoying:

  • Mouse selection grabs the indent, splits long lines where they wrapped, and drags along whatever is next to the block.
  • /copy copies the last reply (or lets you pick from it), so for a block further up you have to count back with /copy N.

With this mod every code block gets a small box with a ⧉ copy link in the corner. Ctrl+click it and the block is on your clipboard, exactly as written, no matter how far up it is.

Install

In Claude Code (terminal):

/plugin install copy-button --marketplace GeckoKing9/claude-code-copy-button

Answer y to add the marketplace and pick the user scope. That's it, it is active right away. Needs Claude Code 2.1.287 or later (the version that added mods).

Nothing else to install, on Linux either. If you have wl-copy, xclip or xsel it uses that; if not, it copies with its own small helper, which only needs the Python and X11 library every Linux desktop already ships.

If Claude Code opens fullscreen for you (newer installs can), switch to normal mode with /tui default. In fullscreen the mod steps aside.

Where it works

SetupStatus
WSL 2 + Windows Terminal, normal modeYes. Tested.
Linux, X11 (tested: Xubuntu 24.04, xfce4-terminal)Yes. Tested with a real Ctrl+click, with xclip and with no clipboard tool at all.
Linux, WaylandYes. Tested in a Weston session, with wl-copy and with no clipboard tool at all. GNOME and KDE on Wayland not tested yet.
Other Linux terminals (GNOME Terminal, Konsole, kitty...)Should work if the terminal opens file: links on Ctrl+click. Only xfce4-terminal is tested.
Bare window managers (i3, sway...)Probably not: there xdg-open guesses the file type from its content, so the click opens the block in a text editor.
SSH sessions, including ssh -XNo, on purpose: the links would point at the remote machine.
Fullscreen rendererNo. The mod steps aside and the reply is drawn as usual.
VS Code terminal, other Windows terminalsNo. They open file: links their own way.
macOSNot yet. The mod draws nothing and runs nothing.

macOS would need its own click handler (a file type that runs pbcopy). Happy to take a PR from someone who can test it on a real Mac.

How it works

Normal mode has no click events. The one click you get is the terminal's own Ctrl+click on a link. So:

  1. When a reply arrives, each code block is saved to its own .ccopy file.
  2. The reply is drawn with a box around each block and a copy link to that file.
  3. The mod registers a .ccopy file type for your user (no admin, no sudo) whose handler puts the file's text on the clipboard.
WSLLinux
Files%LOCALAPPDATA%\claude-copy~/.local/share/claude-copy ($XDG_DATA_HOME)
File typeHKCU\Software\Classes\.ccopyshared-mime type application/x-claude-copy + a hidden .desktop entry, set as its default app
Handlerwindows/copy.vbs (Windows clip)linux/copy.sh: wl-copy, xclip or xsel when installed, otherwise linux/clip.py

On Linux a click fills both places Linux pastes from: the clipboard (Ctrl+V, or Ctrl+Shift+V in a terminal) and the primary selection (middle-click, Shift+Insert). Whichever way you paste, you get the block.

clip.py does what xclip does, through the X11 library directly: it holds the copied text in a small background process until you copy something else, then exits. On Wayland desktops it goes through their X11 layer (Xwayland), whose clipboard is shared with Wayland apps.

Why a custom extension instead of linking a script: Windows Terminal warns before opening anything in PATHEXT (.vbs, .cmd...). .ccopy isn't in it, so the click is silent.

Session folders are deleted two days after the session was last used.

What it touches

Read this before installing anything that runs scripts, including this.

WSL

  • Files: %LOCALAPPDATA%\claude-copy (your code blocks, one folder per session, and a copy of copy.vbs).
  • Registry: HKCU\Software\Classes\.ccopy and HKCU\Software\Classes\ClaudeCopy (current user only, no admin).
  • Commands: cmd.exe once to find %LOCALAPPDATA%, wslpath, reg.exe to check the file type each session (and add it when missing), rm to prune old session folders.

Linux

  • Files: ~/.local/share/claude-copy (code blocks, copy.sh, clip.py), ~/.local/share/mime/packages/claude-copy.xml, ~/.local/share/applications/claude-copy.desktop, and one line in ~/.config/mimeapps.list (written by xdg-mime).
  • Commands: uname, xdg-mime, update-mime-database, update-desktop-database, chmod on its own script, sh to check what can copy, rm to prune old session folders. On a click: your clipboard tool, or python3 clip.py.
  • Notice: if nothing on the system can copy (no clipboard tool, and no Python or X11 library), it says so once per session with the install command for your system. Otherwise it never prints anything.

Both

  • Checked each session and repaired if something removed it. All of it runs in the background; drawing only ever waits (up to 2 seconds) for the mod to find its folder at the start of a session.
  • Network: none.

The handler (windows/copy.vbs, linux/copy.sh) only copies .ccopy files from its own folder, so a stray .ccopy file from a download or a web page can't replace your clipboard. Errors go to the Claude Code debug log (claude --debug).

Heads up for Windows: Microsoft is phasing VBScript out. If it gets removed on your machine the click stops working until the script is replaced.

Uninstall

  1. /plugin uninstall copy-button
  2. Remove the file type and the files.

WSL:

   reg.exe delete 'HKCU\Software\Classes\.ccopy' /f
   reg.exe delete 'HKCU\Software\Classes\ClaudeCopy' /f
   rm -rf "$(wslpath "$(cmd.exe /c 'echo %LOCALAPPDATA%' 2>/dev/null | tr -d '\r')")/claude-copy"

Linux:

   d=${XDG_DATA_HOME:-$HOME/.local/share}
   rm -rf "$d/claude-copy" "$d/applications/claude-copy.desktop" "$d/mime/packages/claude-copy.xml"
   update-mime-database "$d/mime"
   sed -i '/x-claude-copy/d' "${XDG_CONFIG_HOME:-$HOME/.config}/mimeapps.list"

Known limits

  • A reply that starts with a code block shows no bullet dot in front of it.
  • Markdown that spans a code block (a reference link defined on the other side of one, a list item continued with 4+ spaces after one) can render differently than without the mod, since the reply is drawn in pieces.
  • A reply drawn in the first moments of a session waits up to 2 seconds for the mod to find its folder; if that takes longer, that one reply has no box.

Develop

git clone https://github.com/GeckoKing9/claude-code-copy-button
claude --plugin-dir ./claude-code-copy-button
claude plugin test ./claude-code-copy-button

MIT license.

Source 1 files
hooks/register.tsx 526 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// A boxed "⧉ copy" link on every fenced code block in Claude's replies, for
4// Claude Code in the classic renderer: in WSL shown in Windows Terminal, or on
5// a Linux desktop. See ../README.md for the moving parts and how to uninstall.
6//
7// The classic renderer has no click events; the one click there is the
8// terminal's own Ctrl+click on a link. Each block is saved to a .ccopy file
9// and linked, and the .ccopy file type copies the file's text:
10// - WSL: %LOCALAPPDATA%\claude-copy\<session>\<hash>.ccopy; the file type
11//   (HKCU) runs copy.vbs silently. .ccopy, unlike .vbs, is not in PATHEXT, so
12//   Windows Terminal opens it without its "unsafe link" dialog.
13// - Linux: $XDG_DATA_HOME/claude-copy/<session>/<hash>.ccopy; a shared-mime
14//   type and a hidden .desktop entry run copy.sh (wl-copy, xclip or xsel).
15//
16// The engine still draws the prose (bullet, indent, markdown); this hook only
17// cuts the reply at its code blocks and draws a box around each one. Classic
18// Claude Code prints a file: link's URL after it, so the link sits in a 6x1
19// box with overflow hidden and only "⧉ copy" shows.
20//
21// Drawing never runs anything: the engine refuses side effects there. All
22// work (finding the Windows folder, the file type, writing blocks, pruning)
23// runs in a background queue, one task at a time, each failure on its own.
24// A reply drawn while the folder is being looked up waits for it (up to
25// LOOKUP_WAIT_MS): the classic renderer cannot redraw a finished reply. Anywhere else (no Windows side and no Linux
26// display: macOS, a plain SSH session) nothing is drawn or run.
27
28const KEEP_MS = 2 * 86_400_000
29const STAMP_EVERY_MS = 10 * 60_000
30const PRUNE_EVERY_MS = 60 * 60_000
31const RETRY_MS = 5 * 60_000
32const LOOKUP_WAIT_MS = 2_000
33const STORE_KEY = 'home.v3'
34const MIME = 'application/x-claude-copy'
35const DESKTOP = 'claude-copy.desktop'
36const CLASSES = 'HKCU\\Software\\Classes'
37const LINK_WIDTH = 6
38// The link's icon: Windows Terminal's fonts have ⧉; Linux's default monospace
39// (DejaVu Sans Mono) has ❐ but not ⧉, which it draws as an empty box.
40const ICON = { wsl: '⧉', linux: '❐' } as const
41const GUTTER = 2
42
43type Host = Pick<EngineInterface, 'clock' | 'env' | 'fs' | 'plugin' | 'process' | 'session' | 'store' | 'ui'>
44
45type Code = { kind: 'code'; lang: string; body: string; raw: string }
46type Segment = { kind: 'prose'; text: string } | Code
47
48// ---- Parsing ---------------------------------------------------------------
49
50const OPEN = /^([ \t]*)(`{3,}|~{3,})(.*)$/
51
52const columns = (lead: string) => [...lead].reduce((col, c) => (c === '\t' ? col + 4 - (col % 4) : col + 1), 0)
53
54// Removes `indent` columns of leading whitespace, tabs stopping every 4
55// columns; a tab that straddles the edge leaves its extra columns as spaces,
56// as CommonMark strips a fenced block's body.
57function dedent(line: string, indent: number): string {
58  let col = 0
59  let i = 0
60  while (i < line.length && col < indent) {
61    const c = line[i]
62    if (c === ' ') col++
63    else if (c === '\t') col += 4 - (col % 4)
64    else break
65    i++
66  }
67  return ' '.repeat(Math.max(0, col - indent)) + line.slice(i)
68}
69
70// CommonMark-ish fences: any indent (list items), closed by the same char at
71// >= the opening length, indented at most 3 columns past the opening fence.
72// An unclosed fence (still streaming) stays prose.
73function split(text: string): Segment[] {
74  const lines = text.replace(/\r\n?/g, '\n').split('\n')
75  const out: Segment[] = []
76  let prose: string[] = []
77  let i = 0
78  while (i < lines.length) {
79    const line = lines[i] ?? ''
80    const m = OPEN.exec(line)
81    const [, lead = '', fence = '', info = ''] = m ?? []
82    if (!m || (fence.startsWith('`') && info.includes('`'))) {
83      prose.push(line)
84      i++
85      continue
86    }
87    const indent = columns(lead)
88    const close = new RegExp(`^([ \\t]*)${fence[0] === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`)
89    const closes = (l: string) => {
90      const c = close.exec(l)
91      return c !== null && columns(c[1] ?? '') <= indent + 3
92    }
93    let j = i + 1
94    while (j < lines.length && !closes(lines[j] ?? '')) j++
95    if (j >= lines.length) {
96      prose.push(...lines.slice(i))
97      break
98    }
99    const body = lines.slice(i + 1, j).map(l => dedent(l, indent))
100    if (prose.length) out.push({ kind: 'prose', text: prose.join('\n') })
101    prose = []
102    out.push({ kind: 'code', lang: info.trim().split(/\s+/)[0] ?? '', body: body.join('\n'), raw: [line.slice(lead.length), ...body, fence].join('\n') })
103    i = j + 1
104  }
105  if (prose.length) out.push({ kind: 'prose', text: prose.join('\n') })
106  return out
107}
108
109// A code block with something in it; an empty one is drawn as the engine draws it.
110const copyable = (s: Segment): Code | undefined => (s.kind === 'code' && s.body.trim() !== '' ? s : undefined)
111
112// 53-bit hash (cyrb53): a file per distinct block, collisions out of reach.
113function hash(s: string): string {
114  let h1 = 0xdeadbeef
115  let h2 = 0x41c6ce57
116  for (let i = 0; i < s.length; i++) {
117    const c = s.charCodeAt(i)
118    h1 = Math.imul(h1 ^ c, 2654435761)
119    h2 = Math.imul(h2 ^ c, 1597334677)
120  }
121  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
122  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
123  return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0')
124}
125
126const textOf = (content: unknown): string =>
127  Array.isArray(content)
128    ? content
129        .map(b => (b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : ''))
130        .join('\n')
131    : typeof content === 'string'
132      ? content
133      : ''
134
135// ---- Where the files live --------------------------------------------------
136
137// WSL: %LOCALAPPDATA%\claude-copy as a Linux path (to write), a Windows path
138// (for the registry) and a file: URL (to link), plus %SystemRoot%.
139// Linux: $XDG_DATA_HOME/claude-copy and its file: URL.
140type Home = { kind: 'wsl'; dir: string; win: string; url: string; systemRoot: string } | { kind: 'linux'; dir: string; url: string; dataHome: string }
141
142// A file: URL path segment that is also a valid markdown link destination.
143// Windows decodes %XX in file: URLs with the ANSI code page, so a UTF-8
144// escape of "é" names a different folder there: only ASCII that would break
145// the URL or the markdown is escaped and non-ASCII stays literal. Linux reads
146// %XX as UTF-8, so everything outside unreserved ASCII is escaped.
147const escape = (c: string) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`
148const winSegment = (s: string) => s.replace(/[\x00-\x20"#%'()<>?[\\\]^`{|}\x7f]/g, escape)
149const linuxSegment = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, escape)
150const winUrl = (win: string) => {
151  const [drive = '', ...rest] = win.split('\\')
152  return `file:///${drive}/${rest.map(winSegment).join('/')}`
153}
154const linuxUrl = (dir: string) => `file://${dir.split('/').map(linuxSegment).join('/')}`
155const urlSegment = (h: Home, s: string) => (h.kind === 'wsl' ? winSegment(s) : linuxSegment(s))
156
157async function locateWsl($: Host): Promise<Home> {
158  const kept = (await $.store.get(STORE_KEY)) as Home | undefined
159  if (kept?.kind === 'wsl' && kept.dir && kept.systemRoot) return kept
160  // chcp 65001: the paths come back as UTF-8 whatever the user's name is.
161  const r = await $.process.run(['cmd.exe', '/d', '/c', 'chcp 65001 >nul & echo %LOCALAPPDATA%& echo %SystemRoot%'], { cwd: '/mnt/c' })
162  const [local = '', systemRoot = ''] = r.stdout.split(/\r?\n/).map(l => l.trim())
163  if (!/^[A-Za-z]:\\/.test(local) || !/^[A-Za-z]:\\/.test(systemRoot)) throw new Error(`no %LOCALAPPDATA%: ${JSON.stringify(r.stdout)} ${r.stderr}`)
164  const win = `${local}\\claude-copy`
165  const w = await $.process.run(['wslpath', '-u', win])
166  const dir = w.stdout.trim()
167  if (w.exitCode !== 0 || !dir.startsWith('/')) throw new Error(`wslpath ${win}: ${w.stderr}`)
168  const home: Home = { kind: 'wsl', dir, win, url: winUrl(win), systemRoot }
169  await $.store.set(STORE_KEY, home)
170  return home
171}
172
173async function locateLinux($: Host): Promise<Home | null> {
174  const kept = (await $.store.get(STORE_KEY)) as Home | undefined
175  if (kept?.kind === 'linux' && kept.dir) return kept
176  const r = await $.process.run(['uname', '-s'])
177  if (r.stdout.trim() !== 'Linux') return null // macOS and others: not yet
178  const user = await $.env.get('HOME')
179  if (!user?.startsWith('/')) return null
180  // The XDG spec: a relative XDG_DATA_HOME is invalid and ignored.
181  const xdg = (await $.env.get('XDG_DATA_HOME'))?.replace(/\/+$/, '')
182  const dataHome = xdg?.startsWith('/') ? xdg : `${user}/.local/share`
183  const dir = `${dataHome}/claude-copy`
184  const home: Home = { kind: 'linux', dir, url: linuxUrl(dir), dataHome }
185  await $.store.set(STORE_KEY, home)
186  return home
187}
188
189// Which side this session copies through, from the environment alone, so
190// drawing may ask too. A forwarded display (ssh -X, a container) is not
191// the screen the user clicks on: the links would name the remote machine.
192async function platformOf($: Host): Promise<Home['kind'] | null> {
193  if ((await $.env.get('WSL_DISTRO_NAME')) || (await $.env.get('WSL_INTEROP'))) return 'wsl'
194  if ((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY'))) return null
195  if (!(await $.env.get('DISPLAY')) && !(await $.env.get('WAYLAND_DISPLAY'))) return null
196  return 'linux'
197}
198
199async function locate($: Host): Promise<Home | null> {
200  const platform = await platformOf($)
201  if (platform === 'wsl') return locateWsl($)
202  if (platform === 'linux') return locateLinux($)
203  return null
204}
205
206// What drawing reads: undefined until looked up, null where the mod stays out;
207// `lookup` is the lookup in flight, which drawing may wait on but never starts.
208// Before that, drawing falls back on the folder a past session kept.
209let known: Home | null | undefined
210let lookup: Promise<Home | null> | undefined
211let failedAt = 0
212
213async function getHome($: Host): Promise<Home | null> {
214  if (known !== undefined) return known
215  // On WSL a failed lookup (interop off, cmd.exe missing) is retried, not
216  // hammered: once per RETRY_MS at most.
217  if (Date.now() - failedAt < RETRY_MS) return null
218  lookup ??= locate($)
219    .then(
220      h => {
221        known = h
222        if (h) $.ui.invalidate('ui.render')
223        return h
224      },
225      err => {
226        failedAt = Date.now()
227        throw err
228      },
229    )
230    .finally(() => {
231      lookup = undefined
232    })
233  return lookup
234}
235
236// For a reply drawn before the folder is known: wait on the lookup in flight
237// (up to LOOKUP_WAIT_MS), or, before one has started (a resumed session draws
238// early), take the folder a past session kept. Reads only: drawing runs nothing.
239async function homeWhileDrawing($: Host, signal: AbortSignal): Promise<Home | null> {
240  if (lookup) return Promise.race([lookup.catch(() => null), $.clock.sleep(LOOKUP_WAIT_MS, { signal }).then(() => null, () => null)])
241  const kept = (await $.store.get(STORE_KEY).catch(() => undefined)) as Home | undefined
242  return kept && kept.kind === (await platformOf($)) ? kept : null
243}
244
245const log = ($: Host, what: string, err: unknown) => $.ui.log(`copy-button: ${what}: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
246
247// ---- Background work, one task at a time ----------------------------------
248
249let queue: Promise<void> = Promise.resolve()
250const later = ($: Host, what: string, task: (h: Home) => Promise<void>) => {
251  queue = queue
252    .then(async () => {
253      const h = await getHome($)
254      if (h) await task(h)
255    })
256    .catch(err => log($, what, err))
257}
258
259// Windows programs run from /mnt/c: a Linux working directory makes cmd.exe
260// and reg.exe warn about UNC paths.
261const run = async ($: Host, argv: string[], cwd?: string) => {
262  const r = await $.process.run(argv, cwd ? { cwd } : {})
263  if (r.exitCode !== 0) throw new Error(`${argv.slice(0, 3).join(' ')}: exit ${r.exitCode} ${r.stderr.trim()}`)
264  return r.stdout
265}
266
267// Puts copy.vbs and the .ccopy file type in place, checked against what is
268// actually there each session, so a removed or stale entry heals itself. The
269// check reads an ASCII signature of the command, never the command itself:
270// reg.exe prints in the console code page, which mangles non-ASCII paths.
271async function installWsl($: Host, h: Extract<Home, { kind: 'wsl' }>): Promise<void> {
272  const vbs = await $.fs.read(`${$.plugin.root}/windows/copy.vbs`)
273  if ((await $.fs.read(`${h.dir}/copy.vbs`).catch(() => '')) !== vbs) await $.fs.write(`${h.dir}/copy.vbs`, vbs)
274  // %L: the long path, so copy.vbs compares like with like.
275  const command = `"${h.systemRoot}\\System32\\wscript.exe" //B //Nologo "${h.win}\\copy.vbs" "%L"`
276  const signature = hash(command)
277  const query = (key: string, value: string[]) => $.process.run(['reg.exe', 'query', key, ...value], { cwd: '/mnt/c' }).then(r => r.stdout, () => '')
278  const [ext, sig] = await Promise.all([query(`${CLASSES}\\.ccopy`, ['/ve']), query(`${CLASSES}\\ClaudeCopy`, ['/v', 'Signature'])])
279  if (ext.includes('ClaudeCopy') && sig.includes(signature)) return
280  // Arguments, not a .reg file: WSL hands them to reg.exe as UTF-16, so a
281  // non-ASCII user name survives.
282  await run($, ['reg.exe', 'add', `${CLASSES}\\ClaudeCopy\\shell\\open\\command`, '/ve', '/d', command, '/f'], '/mnt/c')
283  await run($, ['reg.exe', 'add', `${CLASSES}\\ClaudeCopy`, '/ve', '/d', 'Claude Code block', '/f'], '/mnt/c')
284  await run($, ['reg.exe', 'add', `${CLASSES}\\.ccopy`, '/ve', '/d', 'ClaudeCopy', '/f'], '/mnt/c')
285  await run($, ['reg.exe', 'add', `${CLASSES}\\ClaudeCopy`, '/v', 'Signature', '/d', signature, '/f'], '/mnt/c')
286}
287
288// A path in a .desktop Exec line: double-quoted, with the characters the
289// spec reserves escaped, and % doubled.
290const execArg = (path: string) =>
291  `"${path.replace(/["`$\\]/g, c => `\\${c}`)}"`
292    // then the key-file string escape over the whole value: \ is written \\
293    .replace(/\\/g, '\\\\')
294    .replace(/%/g, '%%')
295
296// The freedesktop way: a shared-mime type for *.ccopy, a hidden .desktop entry
297// that runs copy.sh on the file, and that entry as the type's default app,
298// all under the user's own data and config folders. Checked each session
299// like the Windows side: a file that differs, or another default, is redone.
300async function installLinux($: Host, h: Extract<Home, { kind: 'linux' }>): Promise<void> {
301  const script = `${h.dir}/copy.sh`
302  const want = new Map([
303    [script, await $.fs.read(`${$.plugin.root}/linux/copy.sh`)],
304    [`${h.dir}/clip.py`, await $.fs.read(`${$.plugin.root}/linux/clip.py`)],
305    [
306      `${h.dataHome}/mime/packages/claude-copy.xml`,
307      `<?xml version="1.0" encoding="UTF-8"?>
308<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
309  <mime-type type="${MIME}">
310    <comment>Claude Code block</comment>
311    <glob pattern="*.ccopy" weight="90"/>
312  </mime-type>
313</mime-info>
314`,
315    ],
316    [
317      `${h.dataHome}/applications/${DESKTOP}`,
318      `[Desktop Entry]
319Type=Application
320Name=Claude Code copy button
321Comment=Copies a Claude Code block to the clipboard
322Exec=${execArg(script)} %f
323MimeType=${MIME};
324NoDisplay=true
325Terminal=false
326`,
327    ],
328  ])
329  const stale: string[] = []
330  for (const [path, text] of want) if ((await $.fs.read(path).catch(() => '')) !== text) stale.push(path)
331  const current = await $.process.run(['xdg-mime', 'query', 'default', MIME]).then(r => r.stdout.trim(), () => '')
332  const runnable = await $.process.run(['test', '-x', script]).then(r => r.exitCode === 0, () => false)
333  if (stale.length || current !== DESKTOP || !runnable) {
334    for (const path of stale) await $.fs.write(path, want.get(path) ?? '')
335    await run($, ['chmod', '755', script])
336    await run($, ['update-mime-database', `${h.dataHome}/mime`])
337    await $.process.run(['update-desktop-database', `${h.dataHome}/applications`]).catch(() => undefined)
338    await run($, ['xdg-mime', 'default', DESKTOP, MIME])
339  }
340}
341
342// The package that gives a click something to copy with: wl-clipboard on a
343// Wayland session with no X11 layer, xclip otherwise.
344const INSTALL: Record<string, (pkg: string) => string> = {
345  'apt-get': pkg => `sudo apt install ${pkg}`,
346  dnf: pkg => `sudo dnf install ${pkg}`,
347  pacman: pkg => `sudo pacman -S ${pkg}`,
348  zypper: pkg => `sudo zypper install ${pkg}`,
349}
350const FIRST_INSTALLER = `for t in ${Object.keys(INSTALL).join(' ')}; do command -v "$t" >/dev/null 2>&1 && { echo "$t"; break; }; done`
351const WARNED_KEY = 'warned-session'
352
353// The one notice this mod ever shows: a click would copy nothing. copy.sh
354// answers that itself (--check runs the exact choice a click makes), so the
355// notice and the click cannot disagree. Once per session, reloads included.
356async function warnIfNothingCopies($: Host, h: Extract<Home, { kind: 'linux' }>): Promise<void> {
357  if ((await $.process.run(['sh', `${h.dir}/copy.sh`, '--check'])).exitCode === 0) return
358  const session = await $.session.id()
359  if ((await $.store.get(WARNED_KEY)) === session) return
360  await $.store.set(WARNED_KEY, session)
361  const pkg = (await $.env.get('WAYLAND_DISPLAY')) && !(await $.env.get('DISPLAY')) ? 'wl-clipboard' : 'xclip'
362  const installer = (await $.process.run(['sh', '-c', FIRST_INSTALLER])).stdout.trim()
363  const how = INSTALL[installer]?.(pkg) ?? `install ${pkg}`
364  $.ui.log(`copy-button: nothing on this system can copy to the clipboard; to make the copy links work, run: ${how}`)
365}
366
367const install = ($: Host, h: Home) => (h.kind === 'wsl' ? installWsl($, h) : installLinux($, h))
368
369const written = new Set<string>()
370
371// Writes each block of a reply; a block that fails is logged and left for the
372// next save to retry, the others are still written.
373async function saveBlocks($: Host, h: Home, text: string): Promise<void> {
374  const folder = `${h.dir}/${await $.session.id()}`
375  for (const s of split(text)) {
376    const code = copyable(s)
377    if (!code) continue
378    const path = `${folder}/${hash(code.body)}.ccopy`
379    if (written.has(path)) continue
380    try {
381      await $.fs.write(path, h.kind === 'wsl' ? code.body.replace(/\n/g, '\r\n') : code.body)
382      written.add(path)
383    } catch (err) {
384      log($, `save ${path}`, err)
385    }
386  }
387}
388
389// Every reply of the conversation: at start, after /resume or /clear, and
390// when this session's folder has gone missing.
391async function saveAll($: Host, h: Home): Promise<void> {
392  for (const m of await $.session.messages()) if (m.role === 'assistant' && m.text) await saveBlocks($, h, m.text)
393}
394
395// Marks this session's folder as in use. If it is gone (pruned by another
396// session after two idle days), every block is written again.
397let lastStamp = 0
398async function stamp($: Host, h: Home): Promise<void> {
399  const now = Date.now()
400  if (now - lastStamp < STAMP_EVERY_MS) return
401  lastStamp = now
402  const folder = `${h.dir}/${await $.session.id()}`
403  if (written.size && !(await $.fs.exists(folder))) {
404    written.clear()
405    await saveAll($, h)
406  }
407  await $.fs.write(`${folder}/.last`, String(now))
408}
409
410// Deletes session folders unused for KEEP_MS, temp files copy.vbs left behind
411// after a failed click, and files older versions wrote.
412const LITTER = /^(rad[0-9a-f]+\.tmp|clip\.u16|\.installed)$/i
413let lastPrune = 0
414async function prune($: Host, h: Home): Promise<void> {
415  const now = Date.now()
416  if (now - lastPrune < PRUNE_EVERY_MS) return
417  lastPrune = now
418  const mine = await $.session.id()
419  const expired: string[] = []
420  for (const e of await $.fs.list(h.dir)) {
421    const path = `${h.dir}/${e.name}`
422    if (e.kind === 'file' && LITTER.test(e.name)) {
423      expired.push(path)
424      continue
425    }
426    if (e.kind !== 'dir' || e.name === mine) continue
427    const used = await $.fs
428      .stat(`${path}/.last`)
429      .catch(() => $.fs.stat(path))
430      .then(s => s.mtimeMs, () => now)
431    if (now - used > KEEP_MS) expired.push(path)
432  }
433  if (expired.length) await run($, ['rm', '-rf', '--', ...expired])
434}
435
436// ---- Hooks -----------------------------------------------------------------
437
438export const register: Register = on => {
439  // Once per process and per reload of this module.
440  on('session.start', async ($, e, next) => {
441    const r = await next(e)
442    later($, 'install', h => install($, h))
443    later($, 'clipboard check', h => (h.kind === 'linux' ? warnIfNothingCopies($, h) : Promise.resolve()))
444    later($, 'stamp', h => stamp($, h))
445    later($, 'prune', h => prune($, h))
446    later($, 'save', h => saveAll($, h))
447    return r
448  })
449
450  // /resume and /clear swap the conversation without a session.start.
451  on('classic.SessionStart', async ($, e, next) => {
452    const r = await next(e)
453    if (e.source === 'resume' || e.source === 'clear' || e.source === 'fork') {
454      lastStamp = 0
455      later($, 'save', h => saveAll($, h))
456      later($, 'stamp', h => stamp($, h))
457    }
458    return r
459  })
460
461  on('session.append', { door: 'response' }, async ($, e, next) => {
462    const stored = await next(e)
463    // The row as stored, after any plugin beneath rewrote it.
464    if (!e.agentId && stored.deny === undefined) {
465      const text = textOf(stored.message.content)
466      later($, 'save', h => saveBlocks($, h, text))
467    }
468    return stored
469  })
470
471  on('turn.complete', async ($, e, next) => {
472    const r = await next(e)
473    if (e.agentId === undefined) {
474      later($, 'stamp', h => stamp($, h))
475      later($, 'prune', h => prune($, h))
476    }
477    return r
478  })
479
480  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
481    // Classic terminal only: the fullscreen renderer, desktop and IDE surfaces
482    // have mouse handling of their own; a summary row is not the saved text.
483    if (e.surface !== 'terminal' || e.viewport?.isFullscreen || e.props.isSummary) return next(e)
484    const segments = split(e.props.text)
485    if (!segments.some(copyable)) return next(e)
486    const where = known ?? (await homeWhileDrawing($, next.signal)) // (not named h: JSX compiles to the global h)
487    if (!where) return next(e)
488
489    const { Box, Text, Markdown } = $.ui.resolve(e)
490    const base = `${where.url}/${urlSegment(where, await $.session.id())}`
491    // Laid out as the engine lays out a reply: the bullet on the first piece,
492    // every other piece under it (GUTTER), a blank line between pieces. The
493    // engine's own drawing of a piece brings its blank line; a box adds one.
494    const parts = []
495    let isFirst = e.props.isFirstOfReply
496    for (const [i, s] of segments.entries()) {
497      const code = copyable(s)
498      if (!code) {
499        const text = (s.kind === 'prose' ? s.text : s.raw).replace(/^\n+|\n+$/g, '')
500        if (!text.trim()) continue
501        const drawn = await next({ ...e, props: { ...e.props, text, isFirstOfReply: isFirst } })
502        parts.push(
503          <Box key={`p${i}`} marginLeft={isFirst ? 0 : GUTTER}>
504            {drawn}
505          </Box>,
506        )
507        isFirst = false
508        continue
509      }
510      parts.push(
511        <Box key={`c${i}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginTop={1} marginLeft={GUTTER}>
512          <Box flexDirection="row" justifyContent="space-between">
513            <Text dimColor>{code.lang || 'text'}</Text>
514            <Box width={LINK_WIDTH} height={1} overflow="hidden">
515              <Markdown text={`[${ICON[where.kind]} copy](${base}/${hash(code.body)}.ccopy)`} />
516            </Box>
517          </Box>
518          <Markdown text={code.raw} />
519        </Box>,
520      )
521      isFirst = false
522    }
523    return <Box flexDirection="column">{parts}</Box>
524  })
525}
526