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

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.

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.
Copying a code block out of Claude Code in the terminal is annoying:
/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.
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.
| Setup | Status |
|---|---|
| WSL 2 + Windows Terminal, normal mode | Yes. 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, Wayland | Yes. 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 -X | No, on purpose: the links would point at the remote machine. |
| Fullscreen renderer | No. The mod steps aside and the reply is drawn as usual. |
| VS Code terminal, other Windows terminals | No. They open file: links their own way. |
| macOS | Not 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.
Normal mode has no click events. The one click you get is the terminal's own Ctrl+click on a link. So:
.ccopy file..ccopy file type for your user (no admin, no sudo) whose handler puts the file's text on the clipboard.| WSL | Linux | |
|---|---|---|
| Files | %LOCALAPPDATA%\claude-copy | ~/.local/share/claude-copy ($XDG_DATA_HOME) |
| File type | HKCU\Software\Classes\.ccopy | shared-mime type application/x-claude-copy + a hidden .desktop entry, set as its default app |
| Handler | windows/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.
Read this before installing anything that runs scripts, including this.
WSL
%LOCALAPPDATA%\claude-copy (your code blocks, one folder per session, and a copy of copy.vbs).HKCU\Software\Classes\.ccopy and HKCU\Software\Classes\ClaudeCopy (current user only, no admin).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
~/.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).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.Both
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.
/plugin uninstall copy-buttonWSL:
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"
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.
hooks/register.tsx 526 lines1import 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