Copy what Claude wrote as raw text: reply markdown, code blocks, tables, files Claude read or wrote, plans and tool output, by hover chip or /copy-that

Claude Code renders Claude's markdown in the terminal, so a mouse selection gives you the rendered text: no fences, no **, tables redrawn as boxes, link targets gone. copy-that copies the raw text instead. Hover the ⧉ at the end of a reply or a tool row and click a chip, a row naming what to copy (⧉ reply, ⧉ bash, ⧉ table). The ⧉ needs fullscreen mode. /copy-that works without it.

Drawn with lucid loaded. At rest a single ⧉ sits at the bottom right of the reply. Hovering it opened the list, which hides the ⧉ while it is open.
/plugin marketplace add vvojciech/claude-kit
/plugin install copy-that@vvojciech
/tui fullscreen) for the ⧉ markers. /copy-that works without it.pbcopy on macOS, wl-copy (from wl-clipboard), xclip or xsel on Linux, or a terminal that takes OSC 52.copy-that works alone, with the same list on rows Claude Code draws itself. With lucid or another mod that draws replies, copy-that has to load first. See load order for how to set it.
⧉ reply copies the whole reply as markdown, including text written before and after tool calls.⧉ python. A block with no language is ⧉ code, a table is ⧉ table.⧉ quote, which copies what's inside without the > markers, so a drafted Slack or PR message pastes as written.> [!NOTE]) is named by its type: ⧉ note, ⧉ tip, ⧉ important, ⧉ warning or ⧉ caution. It copies without the [!NOTE] line.⧉ bash 1, ⧉ bash 2, ⧉ quote 1, ⧉ quote 2.partial at the top of its list./copy-that N for each whole reply.⧉ file on Write, Edit and Read. Write copies what was written. Edit copies the whole file after the edit. Read copies what was read. An image read gives no chip.⧉ plan on ExitPlanMode copies the plan.⧉ output on Bash, Grep, Glob, WebFetch, Agent and MCP tools copies the output with colour codes stripped. Bash gives stdout then stderr, Grep gives matches or file names.⧉ a.md, ⧉ bash #1, ⧉ bash #2. A group with one copyable call shows ⧉ output or ⧉ file, like a single row.⧉ output once its report arrives.pbcopy.wl-copy on Wayland, then xclip, then xsel on X11.copied (pbcopy) or copy failed. Rows are wide enough for that text, so the list doesn't shift./copy-that answers name what was copied, its line count and the route, such as "Copied python block (4 lines, pbcopy)".+N. Click it for a toast naming them and the /copy-that commands for them.+N.COLORFGBG, and falls back to dark./copy-that help prints the full usage. R counts replies back from the last, so 1 is the last. N picks the Nth item of a kind.
| Command | Copies |
|---|---|
/copy-that | the last reply as markdown |
/copy-that R | reply R back, so /copy-that 2 is the reply before the last |
/copy-that <name> | one block by its chip name, such as /copy-that ruby 2 |
/copy-that code [N] | the block with no language. If every block has a language, the Nth code block |
/copy-that table | a table |
/copy-that quote [N] | the Nth quote without its > markers |
/copy-that file [N] | the Nth last file Claude wrote, edited or read |
/copy-that plan | the last plan |
/copy-that output [N] | the Nth last Bash, Grep, Glob, WebFetch, Agent or MCP output |
/copy-that <call> | a call by its group chip name, such as /copy-that notes.md, /copy-that bash or /copy-that bash #2 |
/copy-that selection | the markdown behind what you last selected with the mouse |
/copy-that 2 ruby, /copy-that 2 quote./copy-that note.⧉ 123, needs R first: /copy-that 1 123./copy-that bash 2 is the second bash block in the reply. /copy-that bash #2 is the second bash call in the latest group that has one./copy-that selection copies the markdown source of what you selected, even when the selection starts or ends inside bold text or a link. When the selected text appears in more than one place, it copies nothing and asks you to select more.When copy-that is working, the first reply that gets a ⧉ in a session also shows the toast Hover ⧉ to copy; /copy-that help.
⧉ needs fullscreen; use /copy-that. Turn it on with /tui fullscreen./copy-that answer repeats it: Put copy-that first in CLAUDE_CODE_PLUGIN_DIRS (a plugin listed earlier draws replies).set -g set-clipboard on.#141311 (dark) or #f5f4f0 (light), a one-row box shows behind the hovered chip./copy-that output says so.table, code, reply, quote, an alert type, file, plan, output, selection, help) is renamed, for example ⧉ table-code.hooks/register.tsx 112 lines1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2
3import { runCommand } from './answer'
4import { ARGUMENT_HINT } from './commands'
5import { checkWrappedLater, hintOnce } from './hints'
6import { forget, markLive, refresh, settleTurn } from './history'
7import { settled, withGroupMarker, withMarker, withToolMarker } from './markers'
8import { NO_FULLSCREEN, failure } from './notices'
9import { forgetTone } from './palette'
10import { freshSession, session } from './session'
11import { writtenText } from './turns'
12
13const COMMAND = 'copy-that'
14
15const DESCRIPTION = [
16 'Copy a reply as markdown, one of its blocks,',
17 'a file, plan or tool output Claude produced, or the markdown behind a selection',
18].join(' ')
19
20async function drawnOr($: EngineInterface, drawn: RenderElement, mark: () => Promise<RenderElement>): Promise<RenderElement> {
21 try {
22 return await mark()
23 } catch (error) {
24 $.ui.log(failure(error), { to: 'debug' })
25 return drawn
26 }
27}
28
29function timedOut(args: string): string {
30 return `Copy failed: ${args === '' ? 'the reply' : `"${args}"`} could not be copied in time`
31}
32
33export const register: Register = on => {
34 on('session.start', async ($, e, next) => {
35 freshSession().interactive = e.isInteractive === true
36 await $.command.register({ name: COMMAND, description: DESCRIPTION, argumentHint: ARGUMENT_HINT })
37 await forget($)
38 return next(e)
39 })
40
41 on('session.end', async ($, e, next) => {
42 freshSession()
43 await forget($)
44 return next(e)
45 })
46
47 on('config.set', { key: 'theme' }, async ($, e, next) => {
48 const result = await next(e)
49 forgetTone()
50 if (result.deny === undefined) $.ui.invalidate('ui.render')
51 return result
52 }).catch(($, e, next) => next(e))
53
54 on('session.append', { door: 'response' }, async ($, e, next) => {
55 const stored = await next(e)
56 if (e.agentId !== undefined || stored.uuid === undefined) return stored
57 session.turn.push({ uuid: stored.uuid, text: writtenText(stored.message.content) })
58 await markLive($)
59 return stored
60 }).catch(($, e, next) => next(e))
61
62 on('turn.start', async ($, e, next) => {
63 if (e.agentId === undefined) session.openTurn()
64 return next(e)
65 })
66
67 on('turn.complete', async ($, e, next) => {
68 const result = await next(e)
69 if (e.agentId !== undefined) return result
70 await settleTurn($, e.isAborted)
71 await refresh($)
72 checkWrappedLater($)
73 return result
74 })
75
76 on('command.run', { command: COMMAND }, async ($, e) => {
77 try {
78 return { text: await runCommand($, e) }
79 } catch (error) {
80 $.ui.log(failure(error), { to: 'debug' })
81 return { text: failure(error) }
82 }
83 }).catch(($, e) => ({ text: timedOut(e.args) }))
84
85 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
86 session.reached = true
87 const drawn = await next(e)
88 if (e.surface !== 'terminal') return drawn
89 session.drawnInTurn(e.requestId)
90 session.remember(e.requestId, e.props.text)
91 if (e.props.isSummary) return drawn
92 if (e.viewport?.isFullscreen !== true) {
93 hintOnce($, 'noFullscreen', NO_FULLSCREEN)
94 return drawn
95 }
96 return drawnOr($, drawn, () => withMarker($, e, drawn))
97 }).catch(($, e, next) => next(e))
98
99 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
100 const drawn = await next(e)
101 if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || !settled(e.props)) return drawn
102 return drawnOr($, drawn, () => withToolMarker($, e, drawn))
103 }).catch(($, e, next) => next(e))
104
105 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
106 const drawn = await next(e)
107 const folded = !e.props.isExpanded && !e.props.isActive
108 if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || !folded) return drawn
109 return drawnOr($, drawn, () => withGroupMarker($, e, drawn))
110 }).catch(($, e, next) => next(e))
111}
112hooks/answer.ts 84 lines1import type { CommandRunInput, EngineInterface, RenderSurface } from 'claude-code'
2
3import { commandSurface, copyText } from './clipboard'
4import { USAGE, chooseBlock, chooseGroupCall, chooseNamedCall, chooseTool, missingReply, parse } from './commands'
5import type { Choice } from './commands'
6import type { BlockRequest, CallRequest, ToolRequest } from './commands'
7import { wrappedWarning } from './hints'
8import { agentReport, refresh, resolve } from './history'
9import { NO_SELECTION, SELECTION_ELSEWHERE, confirmation, selectionRepeats, withWarning } from './notices'
10import { replyText } from './replies'
11import { behindAny } from './selection'
12import { session } from './session'
13import { toolTargets } from './tools'
14import type { Reply } from '../types'
15
16type CommandRun = Pick<CommandRunInput, 'args' | 'origin'>
17
18type Surface = RenderSurface | undefined
19
20function selectionSources(requestId: string | undefined, all: Reply[]): string[] | undefined {
21 if (requestId !== undefined) {
22 const row = session.drawn.get(requestId)
23 return row === undefined ? undefined : [row]
24 }
25 const rows = [...session.drawn.values()].reverse()
26 return [...rows, ...all.map(replyText).reverse()]
27}
28
29async function copySelection($: EngineInterface, surface: Surface): Promise<string> {
30 const selected = await $.ui.selection()
31 if (selected === undefined || selected.text.trim() === '') return NO_SELECTION
32 const sources = selectionSources(selected.requestId, await refresh($))
33 const found = sources && behindAny(sources, selected.text)
34 if (found === undefined) return SELECTION_ELSEWHERE
35 if ('places' in found) return selectionRepeats(found.places)
36 if (found.text === '') return SELECTION_ELSEWHERE
37 return confirmation(await copyText($, found.text, surface), 'selection as markdown', found.text)
38}
39
40async function copyBlock($: EngineInterface, request: BlockRequest, surface: Surface): Promise<string> {
41 const all = await refresh($)
42 const reply = all[all.length - request.reply]
43 if (reply === undefined) return missingReply(all.length)
44 const block = chooseBlock(await resolve($, reply), request)
45 const picked = 'error' in block ? ((await namedCall($, request)) ?? block) : block
46 if ('error' in picked) return picked.error
47 return confirmation(await copyText($, picked.text, surface), picked.what, picked.text)
48}
49
50async function namedCall($: EngineInterface, request: BlockRequest): Promise<Choice | undefined> {
51 if (request.reply !== 1) return undefined
52 const messages = await $.session.messages()
53 const lookup = (agentId: string) => agentReport($, agentId)
54 const [files, outputs] = await Promise.all([toolTargets(messages, 'file', lookup), toolTargets(messages, 'output', lookup)])
55 return chooseNamedCall([...files, ...outputs], request.name)
56}
57
58async function copyTool($: EngineInterface, request: ToolRequest, surface: Surface): Promise<string> {
59 const targets = await toolTargets(await $.session.messages(), request.tool, agentId => agentReport($, agentId))
60 const picked = chooseTool(targets, request)
61 if ('error' in picked) return picked.error
62 return confirmation(await copyText($, picked.text, surface), picked.what, picked.text)
63}
64
65async function copyGroupCall($: EngineInterface, request: CallRequest, surface: Surface): Promise<string> {
66 const order = (await $.session.messages()).flatMap(message => message.toolUses.map(use => use.tool_use_id))
67 const picked = chooseGroupCall([...session.groupCalls.values()], order, request.name)
68 if ('error' in picked) return picked.error
69 return confirmation(await copyText($, picked.text, surface), picked.what, picked.text)
70}
71
72async function answer($: EngineInterface, args: string, surface: Surface): Promise<string> {
73 const request = parse(args)
74 if ('error' in request) return request.error
75 if (request.kind === 'help') return USAGE
76 if (request.kind === 'selection') return copySelection($, surface)
77 if (request.kind === 'call') return copyGroupCall($, request, surface)
78 return request.kind === 'tool' ? copyTool($, request, surface) : copyBlock($, request, surface)
79}
80
81export async function runCommand($: EngineInterface, e: CommandRun): Promise<string> {
82 return withWarning(await answer($, e.args, commandSurface(e.origin)), wrappedWarning())
83}
84hooks/commands.ts 203 lines1import { CODE, OUTPUT, REPLY, TOOL_KINDS, TOOL_WHAT, replyTargets } from './targets'
2import type { Resolved, Target, ToolKind } from './targets'
3
4export type Choice = { text: string; what: string } | { error: string }
5
6export type BlockRequest = { kind: 'block'; reply: number; name: string }
7
8export type ToolRequest = { kind: 'tool'; tool: ToolKind; back: number }
9
10export type CallRequest = { kind: 'call'; name: string }
11
12export type Request = BlockRequest | ToolRequest | CallRequest | { kind: 'selection' } | { kind: 'help' } | { error: string }
13
14const HELP = new Set(['help', '-h', '--help', '?'])
15
16export const SELECTION = 'selection'
17
18export const ARGUMENT_HINT = `[R] [reply | <language> | code | table | quote] [N] | file [N] | plan | output [N] | ${SELECTION} | help`
19
20const PARAGRAPHS = [
21 [`Usage: /copy-that ${ARGUMENT_HINT}`],
22 [
23 '/copy-that copies the last reply as markdown.',
24 'R picks the reply, 1 = the last: /copy-that 2 is the reply before.',
25 ],
26 [
27 'A name copies one block, as its ⧉ chip shows it: /copy-that ruby 2 is the chip "⧉ ruby 2";',
28 'blocks of one name count from the top of the reply.',
29 'code is a block with no language, or any code block when none lacks one.',
30 'A chip named by digits, as ⧉ 123, needs R first: /copy-that 1 123.',
31 ],
32 [
33 '/copy-that quote [N] copies the Nth quote in the reply without its > markers, ready to paste as a draft;',
34 '/copy-that 2 quote takes it from the reply before.',
35 'A GitHub alert is named by its type, as /copy-that note or /copy-that warning, and copies without its [!NOTE] line.',
36 ],
37 [
38 '/copy-that file [N] copies the Nth last file Claude wrote, edited (the whole file after the edit) or read.',
39 '/copy-that plan copies the last plan.',
40 '/copy-that output [N] copies the Nth last Bash, Grep, Glob, WebFetch, Agent or MCP output, colours stripped.',
41 ],
42 [
43 'N counts back from the last, 1 = the last.',
44 'A tool row with one copyable call says ⧉ output or ⧉ file like a single call.',
45 'Chips on a group with more name each call instead, and /copy-that copies the last call of that name:',
46 '/copy-that notes.md for ⧉ notes.md, /copy-that bash for ⧉ bash.',
47 'A repeated name, as ⧉ bash #2, numbers a call from the top of its group: /copy-that bash #2 copies it from the last group that has it.',
48 ],
49 [`/copy-that ${SELECTION} copies the markdown behind what you last selected with the mouse.`],
50 ['The ⧉ markers need fullscreen; a reply still being written gets its marker when it ends.'],
51]
52
53export const USAGE = PARAGRAPHS.map(lines => lines.join(' ')).join('\n')
54
55export const SELECTION_HAS_NO_REPLY = `${SELECTION} takes no reply number: run /copy-that ${SELECTION}`
56
57export const BAD_REPLY = 'R counts back from 1, the last reply'
58
59export function badToolCount(tool: ToolKind): string {
60 return `N counts back from 1, the last ${tool}: /copy-that ${tool} 1`
61}
62
63const COUNT = /^[1-9]\d*$/
64
65const CALL_ORDINAL = /^#\d+$/
66
67const COUNTS_BACK = '/copy-that output N or /copy-that file N counts calls back from the last'
68
69export function callNameAlone(given: string): string {
70 const name = given.split(' ').slice(-2).join(' ')
71 return `A call name takes nothing else: /copy-that ${name}`
72}
73
74export function noGroupCall(name: string): string {
75 return `No tool group shown so far has a call ${name}; ${COUNTS_BACK}`
76}
77
78const DIGITS = /^\d+$/
79
80export function numberFirst(index: string, name: string): string {
81 return name === SELECTION ? SELECTION_HAS_NO_REPLY : `The reply number goes first: /copy-that ${index} ${name}`
82}
83
84function words(args: string): string[] {
85 return args.trim().toLowerCase().split(/\s+/).filter(Boolean)
86}
87
88function toolRequest(given: string[]): Request | undefined {
89 const [name, back = '1', ...rest] = given
90 const tool = TOOL_KINDS.find(kind => kind === name)
91 if (tool === undefined) return undefined
92 if (rest.length > 0 || !DIGITS.test(back)) return { error: USAGE }
93 return COUNT.test(back) ? { kind: 'tool', tool, back: Number(back) } : { error: badToolCount(tool) }
94}
95
96function callRequest(given: string[]): Request | undefined {
97 if (!given.some(word => CALL_ORDINAL.test(word))) return undefined
98 const [name, ordinal, ...rest] = given
99 const alone = rest.length === 0 && name !== undefined && !CALL_ORDINAL.test(name) && CALL_ORDINAL.test(ordinal ?? '')
100 return alone ? { kind: 'call', name: `${name} ${ordinal}` } : { error: callNameAlone(given.join(' ')) }
101}
102
103export function parse(args: string): Request {
104 const given = words(args)
105 if (given.length === 1 && HELP.has(given[0] ?? '')) return { kind: 'help' }
106 const tool = toolRequest(given)
107 if (tool !== undefined) return tool
108 const call = callRequest(given)
109 if (call !== undefined) return call
110 if (given.length === 1 && given[0] === SELECTION) return { kind: 'selection' }
111 if (given[0] === '0') return { error: BAD_REPLY }
112 const explicitReply = COUNT.test(given[0] ?? '')
113 const reply = explicitReply ? Number(given.shift()) : 1
114 const [name = REPLY, index, ...rest] = given
115 if (explicitReply && name === SELECTION && index === undefined) return { error: SELECTION_HAS_NO_REPLY }
116 if (rest.length > 0 || (!explicitReply && DIGITS.test(name))) return { error: USAGE }
117 if (index === undefined) return { kind: 'block', reply, name }
118 const misplaced = !explicitReply && (name === REPLY || name === SELECTION) && COUNT.test(index)
119 if (misplaced) return { error: numberFirst(index, name) }
120 return COUNT.test(index) ? { kind: 'block', reply, name: `${name} ${index}` } : { error: USAGE }
121}
122
123export function whichReply(reply: number): string {
124 return reply === 1 ? 'the last reply' : `reply ${reply} back`
125}
126
127function replyCount(count: number): string {
128 return count === 1 ? '1 reply' : `${count} replies`
129}
130
131export function missingReply(count: number): string {
132 return count === 0 ? 'No reply to copy yet' : `Only ${replyCount(count)} so far`
133}
134
135function split(name: string): { base: string; index: number } {
136 const match = /^(.*) ([1-9]\d*)$/.exec(name)
137 return match ? { base: match[1] ?? name, index: Number(match[2]) } : { base: name, index: 1 }
138}
139
140function named(targets: Target[], name: string): Target | undefined {
141 const exact = targets.find(t => t.name === name)
142 if (exact) return exact
143 const { base, index } = split(name)
144 return index === 1 ? targets.find(t => t.name === base || t.name === `${base} 1`) : undefined
145}
146
147function anyCode(targets: Target[], name: string): Target | undefined {
148 const { base, index } = split(name)
149 if (base !== CODE || targets.some(t => t.kind === 'code' && t.what === 'code block')) return undefined
150 return targets.filter(t => t.kind === 'code')[index - 1]
151}
152
153export function chooseBlock(reply: Resolved, request: BlockRequest): Choice {
154 if (request.name === REPLY) {
155 const what = whichReply(request.reply).replace(/^the /, '')
156 return { text: reply.text, what: reply.partial ? `${what} (partial)` : what }
157 }
158 const targets = replyTargets(reply)
159 const found = named(targets, request.name) ?? anyCode(targets, request.name)
160 if (found) return { text: found.text, what: found.what }
161 const names = targets.map(t => t.name).join(', ')
162 return { error: `No "${request.name}" in ${whichReply(request.reply)}; it has ${names}` }
163}
164
165export function missingTool(tool: ToolKind, back: number, count: number): string {
166 if (count === 0) return `No ${TOOL_WHAT[tool]} to copy in this session yet`
167 return `Only ${count} ${TOOL_WHAT[tool]}${count === 1 ? '' : 's'} so far, not ${back}`
168}
169
170function namedAs(call: Target, name: string): boolean {
171 const what = call.what.toLowerCase()
172 return what === `${TOOL_WHAT.file} ${name}` || what === `${name} ${OUTPUT}`
173}
174
175export function chooseNamedCall(calls: Target[], name: string): Choice | undefined {
176 const found = calls.filter(call => namedAs(call, name)).at(-1)
177 if (found === undefined) return undefined
178 const what = `last ${found.what}`
179 return found.unavailable === undefined ? { text: found.text, what } : { error: `Cannot copy the ${what}: ${found.unavailable}` }
180}
181
182function latestFirst(order: string[]): (call: Target) => number {
183 const at = new Map(order.map((id, i) => [id, i]))
184 return call => at.get(call.callId ?? '') ?? -1
185}
186
187export function chooseGroupCall(calls: Target[], order: string[], name: string): Choice {
188 const rank = latestFirst(order)
189 const named = calls.filter(call => call.name.toLowerCase() === name)
190 const found = named.reduce<Target | undefined>((best, call) => (best === undefined || rank(call) >= rank(best) ? call : best), undefined)
191 if (found === undefined) return { error: noGroupCall(name) }
192 const what = `${found.what} ${found.name.split(' ').at(-1)}`
193 return found.unavailable === undefined ? { text: found.text, what } : { error: `Cannot copy the ${what}: ${found.unavailable}` }
194}
195
196export function chooseTool(targets: Target[], request: ToolRequest): Choice {
197 const found = targets[targets.length - request.back]
198 if (found === undefined) return { error: missingTool(request.tool, request.back, targets.length) }
199 const what = request.back === 1 ? `last ${found.what}` : `${found.what}, ${request.back} back`
200 if (found.unavailable !== undefined) return { error: `Cannot copy the ${what}: ${found.unavailable}` }
201 return { text: found.text, what }
202}
203hooks/hints.ts 30 lines1import type { EngineInterface } from 'claude-code'
2
3import { NOT_OUTERMOST } from './notices'
4import { hinted, session } from './session'
5
6export const NOTICE_MS = 4000
7const WRAPPED_CHECK_MS = 1500
8
9export function wrappedWarning(): string | undefined {
10 return session.interactive && session.settledOnce && !session.reached ? NOT_OUTERMOST : undefined
11}
12
13function warnIfWrapped($: EngineInterface): void {
14 const warning = wrappedWarning()
15 if (warning === undefined || session.warned) return
16 session.warned = true
17 void $.ui.status(warning)
18}
19
20export function checkWrappedLater($: EngineInterface): void {
21 session.settledOnce = true
22 setTimeout(() => warnIfWrapped($), WRAPPED_CHECK_MS)
23}
24
25export function hintOnce($: EngineInterface, hint: 'firstRun' | 'noFullscreen', text: string): void {
26 if (!session.interactive || hinted.has(hint)) return
27 hinted.add(hint)
28 $.ui.toast(text)
29}
30hooks/history.ts 99 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderInputOf } from 'claude-code'
3
4import { replyBlocks } from './model'
5import { failure } from './notices'
6import { repliesEndingWith, repliesFrom, replyText } from './replies'
7import { session } from './session'
8import type { Resolved } from './targets'
9import { handbackReport } from './tools'
10import { ownersOf } from './turns'
11import type { Owners, Replies, Reply } from '../types'
12
13export type AssistantRender = RenderInputOf<'AssistantMessage', 'terminal'>
14
15export type Owned = { reply: Reply; shared?: number[] }
16
17const replies = atom({ plugin: 'copy-that', key: 'replies' } as const, null as Replies)
18const owners = atom({ plugin: 'copy-that', key: 'owners' } as const, {} as Owners)
19const live = atom({ plugin: 'copy-that', key: 'live' } as const, false)
20
21async function transcriptReplies($: EngineInterface): Promise<Reply[]> {
22 return repliesFrom(await $.session.messages())
23}
24
25async function readTranscript($: EngineInterface): Promise<Reply[]> {
26 const found = await transcriptReplies($)
27 await update($, replies, () => found)
28 session.seeded = undefined
29 return found
30}
31
32export function refresh($: EngineInterface): Promise<Reply[]> {
33 const current = session
34 current.reading ??= readTranscript($).finally(() => {
35 current.reading = undefined
36 })
37 return current.reading
38}
39
40export async function forget($: EngineInterface): Promise<void> {
41 await update($, replies, () => null)
42 await update($, owners, () => ({}))
43 await update($, live, () => false)
44}
45
46export async function markLive($: EngineInterface): Promise<void> {
47 if (!(await read($, live))) await update($, live, () => true)
48}
49
50function unreadable($: EngineInterface, error: unknown): Reply[] {
51 $.ui.log(`copy-that could not read the transcript, so resumed replies get no ⧉: ${failure(error)}`, { to: 'debug' })
52 return []
53}
54
55async function history($: EngineInterface): Promise<Reply[]> {
56 const stored = await read($, replies)
57 if (stored !== null) return stored
58 session.seeded ??= transcriptReplies($).catch(error => unreadable($, error))
59 return session.seeded
60}
61
62async function stillWriting($: EngineInterface, requestId: string): Promise<boolean> {
63 return session.writing(requestId) || session.liveRows.has(requestId) || (await read($, live))
64}
65
66function sharedBy(all: Reply[], matches: number[], text: string): Owned {
67 const only = matches.length === 1 ? all[matches[0] ?? 0] : undefined
68 if (only) return { reply: only }
69 return { reply: { texts: [text], partial: false }, shared: matches.map(i => all.length - i).reverse() }
70}
71
72export async function ownedReply($: EngineInterface, e: AssistantRender): Promise<Owned | undefined> {
73 const known = await read($, owners)
74 if (e.requestId in known) {
75 const reply = known[e.requestId]
76 return reply ? { reply } : undefined
77 }
78 if (await stillWriting($, e.requestId)) return undefined
79 const all = await history($)
80 const matches = repliesEndingWith(all, e.props.text)
81 return matches.length === 0 ? undefined : sharedBy(all, matches, e.props.text)
82}
83
84export async function resolve($: EngineInterface, reply: Reply): Promise<Resolved> {
85 return { text: replyText(reply), partial: reply.partial, blocks: await replyBlocks($, reply.texts) }
86}
87
88export async function settleTurn($: EngineInterface, isAborted: boolean): Promise<void> {
89 const settled = ownersOf(session.closeTurn(isAborted), isAborted)
90 await update($, live, () => false)
91 if (Object.keys(settled).length === 0) return
92 await update($, owners, current => ({ ...current, ...settled }))
93}
94
95export async function agentReport($: EngineInterface, agentId: string): Promise<string | undefined> {
96 const found = await $.session.messages({ agentId })
97 return Array.isArray(found) ? handbackReport(found) : undefined
98}
99hooks/markers.ts 151 lines1import type { EngineInterface, RenderElement, RenderInputOf, RenderViewport, SessionMessage, UiPressArgument } from 'claude-code'
2
3import { copyText } from './clipboard'
4import { NOTICE_MS, hintOnce } from './hints'
5import { agentReport, ownedReply, resolve } from './history'
6import type { AssistantRender } from './history'
7import { holdsCall, holdsText, menuCap, fitMenu, menuRows, menuWidth, opening, rowsBefore, wrappedRows } from './menu'
8import { FIRST_RUN, LONGEST_FLASH, confirmation, failure, flashOf, flashSuffices, callsInGroup, moreTargets, sharedTail, sharedTailNote, toastWidth } from './notices'
9import { drawnTone } from './palette'
10import { headerRow, keyOf, menuLabels, replyMarker, toolMarker } from './row'
11import type { Flash, Marker, Note } from './row'
12import { session } from './session'
13import { blockTargets, replyTargets } from './targets'
14import type { Target } from './targets'
15import { groupTargets, launchedInBackground, reportedTarget, toolTarget } from './tools'
16import type { ToolCall } from './tools'
17
18export type ToolRender = RenderInputOf<'ToolUse', 'terminal'>
19
20export type GroupRender = RenderInputOf<'ToolGroup', 'terminal'>
21
22type Settled = Pick<ToolCall, 'tool' | 'input' | 'output'> & { isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
23
24type Copied = { notice?: string; flash: string; ok: boolean }
25
26type Overflow = (hidden: Target[], width: number) => string
27
28type MarkerSpec = Pick<Marker, 'scope' | 'row' | 'partial' | 'note' | 'flashOnGlyph' | 'opening' | 'width'> & { overflow: Overflow; columns: number | undefined }
29
30type Placed = Pick<Marker, 'row' | 'opening' | 'width'>
31
32type Menu = { targets: Target[]; scope: string; partial: boolean; note?: Note; above: number; viewport: RenderViewport | undefined }
33
34const PANE_EDGE = 2
35
36function flash($: EngineInterface, scope: string, shown: Flash): void {
37 session.flashes.set(scope, shown)
38 $.ui.invalidate('ui.render')
39 setTimeout(() => {
40 if (session.flashes.get(scope) !== shown) return
41 session.flashes.delete(scope)
42 $.ui.invalidate('ui.render')
43 }, NOTICE_MS)
44}
45
46async function copied($: EngineInterface, target: Target, press: UiPressArgument): Promise<Copied> {
47 try {
48 const outcome = await copyText($, target.text, press.surface)
49 const notice = flashSuffices(outcome) ? undefined : confirmation(outcome, target.what, target.text)
50 return { notice, flash: flashOf(outcome), ok: outcome.ok }
51 } catch (error) {
52 $.ui.log(failure(error), { to: 'debug' })
53 return { notice: failure(error), flash: flashOf({ ok: false, error: '' }), ok: false }
54 }
55}
56
57function noteFor($: EngineInterface, shared: number[] | undefined): Note | undefined {
58 if (shared === undefined) return undefined
59 return { label: sharedTailNote(shared), press: () => $.ui.toast(sharedTail(shared)) }
60}
61
62async function markerFor($: EngineInterface, spec: MarkerSpec): Promise<Marker> {
63 const { overflow, columns, ...shown } = spec
64 return {
65 ...shown,
66 tone: await drawnTone($),
67 flash: session.flashes.get(spec.scope),
68 press: target => async press => {
69 const done = await copied($, target, press)
70 flash($, spec.scope, { key: keyOf(target), text: done.flash, ok: done.ok })
71 if (done.notice !== undefined) $.ui.toast(done.notice)
72 },
73 more: hidden => () => $.ui.toast(overflow(hidden, toastWidth(columns))),
74 }
75}
76
77export function withFlash(targets: Target[], shown: Flash | undefined): Target[] {
78 return shown === undefined ? targets : targets.map(t => (keyOf(t) === shown.key ? { ...t, label: shown.text } : t))
79}
80
81function placed(menu: Menu): Placed {
82 const { partial, note, above, viewport } = menu
83 const extras = (partial ? 1 : 0) + (note === undefined ? 0 : 1)
84 const row = fitMenu(withFlash(menu.targets, session.flashes.get(menu.scope)), menuCap(above, viewport?.rows ?? 0), extras)
85 const width = menuWidth([...menuLabels({ row, partial, note }), LONGEST_FLASH], (viewport?.columns ?? 0) - PANE_EDGE)
86 return { row, opening: opening(menuRows(row, extras), above), width }
87}
88
89async function transcript($: EngineInterface): Promise<SessionMessage[]> {
90 const found = await $.session.messages()
91 return Array.isArray(found) ? found : []
92}
93
94async function replyRoom($: EngineInterface, e: AssistantRender): Promise<number> {
95 const columns = e.viewport?.columns ?? 0
96 return wrappedRows(e.props.text, columns) + rowsBefore(await transcript($), holdsText(e.props.text), columns)
97}
98
99async function toolRoom($: EngineInterface, ids: (string | undefined)[], drawn: RenderElement, columns: number | undefined): Promise<number> {
100 return headerRow(drawn) + rowsBefore(await transcript($), holdsCall(ids), columns ?? 0)
101}
102
103export async function withMarker($: EngineInterface, e: AssistantRender, drawn: RenderElement): Promise<RenderElement> {
104 const owned = await ownedReply($, e)
105 if (owned === undefined) return drawn
106 const resolved = await resolve($, owned.reply)
107 const targets = owned.shared ? blockTargets(resolved.blocks) : replyTargets(resolved)
108 const note = noteFor($, owned.shared)
109 const scope = `copy-that-${e.requestId}`
110 if (targets.length === 0 && note === undefined) return drawn
111 hintOnce($, 'firstRun', FIRST_RUN)
112 const partial = owned.reply.partial
113 const menu = placed({ targets, scope, partial, note, above: await replyRoom($, e), viewport: e.viewport })
114 const spec = { scope, ...menu, partial, flashOnGlyph: false, note, overflow: moreTargets, columns: e.viewport?.columns }
115 return replyMarker($.ui.resolve(e), drawn, await markerFor($, spec))
116}
117
118async function rowTarget($: EngineInterface, call: ToolCall): Promise<Target | undefined> {
119 if (!launchedInBackground(call)) return toolTarget(call)
120 return reportedTarget(call, await $.session.messages(), agentId => agentReport($, agentId))
121}
122
123export async function withToolMarker($: EngineInterface, e: ToolRender, drawn: RenderElement): Promise<RenderElement> {
124 const found = await rowTarget($, e.props)
125 if (found === undefined) return drawn
126 const scope = `copy-that-tool-${e.requestId}`
127 const above = await toolRoom($, [e.props.tool_use_id], drawn, e.viewport?.columns)
128 const menu = placed({ targets: [found], scope, partial: false, above, viewport: e.viewport })
129 const spec = { scope, ...menu, partial: false, flashOnGlyph: true, overflow: callsInGroup, columns: e.viewport?.columns }
130 return toolMarker($.ui.resolve(e), drawn, await markerFor($, spec))
131}
132
133export function settled(call: Settled): boolean {
134 return !call.isRunning && !call.isErrored && !call.isInterrupted
135}
136
137function groupScope(e: GroupRender): string {
138 return e.requestId ?? e.props.calls.map(call => call.tool_use_id ?? call.tool).join('-')
139}
140
141export async function withGroupMarker($: EngineInterface, e: GroupRender, drawn: RenderElement): Promise<RenderElement> {
142 const scope = `copy-that-group-${groupScope(e)}`
143 const targets = groupTargets(e.props.calls.filter(settled))
144 session.rememberGroup(targets)
145 if (targets.length === 0) return drawn
146 const above = await toolRoom($, e.props.calls.map(call => call.tool_use_id), drawn, e.viewport?.columns)
147 const menu = placed({ targets, scope, partial: false, above, viewport: e.viewport })
148 const spec = { scope, ...menu, partial: false, flashOnGlyph: true, overflow: callsInGroup, columns: e.viewport?.columns }
149 return toolMarker($.ui.resolve(e), drawn, await markerFor($, spec))
150}
151hooks/notices.ts 131 lines1import { TERMINAL_ROUTE } from './clipboard'
2import type { CopyOutcome } from './clipboard'
3import type { Target } from './targets'
4import { cellWidth } from './width'
5
6export const NOT_OUTERMOST = 'Put copy-that first in CLAUDE_CODE_PLUGIN_DIRS (a plugin listed earlier draws replies)'
7
8export const FIRST_RUN = 'Hover ⧉ to copy; /copy-that help'
9
10export const NO_FULLSCREEN = '⧉ needs fullscreen; use /copy-that'
11
12export const LARGE_FOR_TERMINAL = 100_000
13
14export const NO_SELECTION = 'Nothing selected: drag over part of a reply in fullscreen, then run /copy-that selection'
15
16export const SELECTION_ELSEWHERE = 'The selection is not in a reply copy-that can read: select inside one reply Claude wrote'
17
18export function selectionRepeats(places: number): string {
19 return `copy-that copied nothing: the selected text appears in ${places} places, so select more of it to say which`
20}
21
22export function lineCount(text: string): number {
23 const body = text.endsWith('\n') ? text.slice(0, -1) : text
24 return text === '' ? 0 : body.split('\n').length
25}
26
27export function confirmation(outcome: CopyOutcome, what: string, text: string): string {
28 if (!outcome.ok) return `Copy failed: ${outcome.error}`
29 const count = lineCount(text)
30 return `Copied ${what} (${count} ${count === 1 ? 'line' : 'lines'}, ${route(outcome.via, text)})`
31}
32
33export function flashSuffices(outcome: CopyOutcome): boolean {
34 return outcome.ok && outcome.via !== TERMINAL_ROUTE
35}
36
37export function flashOf(outcome: CopyOutcome): string {
38 return outcome.ok ? `copied (${outcome.via})` : 'copy failed'
39}
40
41const ROUTES = ['pbcopy', 'wl-copy', 'xclip', 'xsel', TERMINAL_ROUTE]
42
43const FLASHES = [...ROUTES.map(via => flashOf({ ok: true, via })), flashOf({ ok: false, error: '' })]
44
45export const LONGEST_FLASH = FLASHES.reduce((longest, text) => (cellWidth(text) > cellWidth(longest) ? text : longest))
46
47function route(via: string, text: string): string {
48 if (via !== TERMINAL_ROUTE) return via
49 return text.length > LARGE_FOR_TERMINAL ? `${via}, paste to check: terminals often cut copies this large` : `${via}, paste to check`
50}
51
52export function failure(error: unknown): string {
53 return `Copy failed: ${error instanceof Error ? error.message : String(error)}`
54}
55
56export function moreTargets(hidden: Target[]): string {
57 return `Also in this reply: ${hidden.map(t => t.name).join(', ')}. Copy one with /copy-that <name>`
58}
59
60export const TOAST_WIDTH = 37
61
62const TOAST_FRAME = 6
63
64export function toastWidth(columns: number | undefined): number {
65 return columns === undefined ? TOAST_WIDTH : Math.max(1, Math.min(TOAST_WIDTH, columns - TOAST_FRAME))
66}
67
68export const TOAST_LINES = 3
69
70const BY_NAME = '/copy-that <name>'
71
72export function toastLines(text: string, width = TOAST_WIDTH): string[] {
73 const lines: string[] = []
74 for (const word of text.split(' ').filter(Boolean)) {
75 const last = lines.at(-1)
76 if (last !== undefined && cellWidth(`${last} ${word}`) <= width) lines[lines.length - 1] = `${last} ${word}`
77 else lines.push(...brokenWord(word, width))
78 }
79 return lines
80}
81
82function brokenWord(word: string, width: number): string[] {
83 const pieces: string[] = []
84 let piece = ''
85 for (const char of word) {
86 if (piece !== '' && cellWidth(piece + char) > width) {
87 pieces.push(piece)
88 piece = ''
89 }
90 piece += char
91 }
92 return [...pieces, piece]
93}
94
95function fitsToast(text: string, width: number): boolean {
96 return toastLines(text, width).length <= TOAST_LINES
97}
98
99function listing(lead: string, names: string[], kept: number): string {
100 const rest = names.length - kept
101 const shown = names.slice(0, kept).join(', ')
102 const more = rest === 0 ? '' : `${kept === 0 ? '' : ' '}+${rest}`
103 return `${lead} ${shown}${more}`
104}
105
106function fittedList(lead: string, calls: Target[], width: number): string {
107 const names = calls.map(call => call.name)
108 for (let kept = names.length; kept > 0; kept--) {
109 const text = listing(lead, names, kept)
110 if (fitsToast(text, width)) return text
111 }
112 return listing(lead, names, 0)
113}
114
115export function callsInGroup(calls: Target[], width = TOAST_WIDTH): string {
116 return fittedList(`Copy with ${BY_NAME}:`, calls, width)
117}
118
119export function sharedTailNote(replies: number[]): string {
120 return `reply: /copy-that ${replies.join(' or ')}`
121}
122
123export function sharedTail(replies: number[]): string {
124 const each = `/copy-that ${replies.join(' or /copy-that ')} copies each whole`
125 return `${replies.length} replies end with this block, so ⧉ reply cannot tell which; ${each}`
126}
127
128export function withWarning(text: string, warning: string | undefined): string {
129 return warning === undefined ? text : `${text}\n${warning}`
130}
131hooks/palette.ts 105 lines1import type { EngineInterface } from 'claude-code'
2
3export type Ground = 'dark' | 'light'
4
5export type Tone = { ground: Ground; background: string; muted: string; text: string; accent: string; pointed: string }
6
7export type LucidTone = { ground: Ground; palette: { text: string; strong: string; muted: string; accent: string } }
8
9export const dark: Tone = { ground: 'dark', background: '#141311', muted: '#a39e94', text: '#d8d5cc', accent: '#d4734f', pointed: '#d4734f' }
10
11export const light: Tone = { ground: 'light', background: '#f5f4f0', muted: '#625e56', text: '#2e2c27', accent: '#c96442', pointed: '#ab4f2d' }
12
13const LIGHT_NAME = /(?:^|[-_ ])light(?:$|[-_ ])/i
14
15const DARK_NAME = /(?:^|[-_ ])dark(?:$|[-_ ])/i
16
17const LIGHT_BACKGROUND_INDEX = new Set([7, 15])
18
19export function groundOfTerminal(colorFgBg: string | undefined): Ground | undefined {
20 if (colorFgBg === undefined || colorFgBg.trim() === '') return undefined
21 const background = Number(colorFgBg.split(';').at(-1))
22 if (Number.isNaN(background)) return undefined
23 return LIGHT_BACKGROUND_INDEX.has(background) ? 'light' : 'dark'
24}
25
26export function groundOf(theme: unknown, terminal?: Ground): Ground {
27 if (typeof theme !== 'string') return terminal ?? 'dark'
28 if (LIGHT_NAME.test(theme)) return 'light'
29 if (DARK_NAME.test(theme)) return 'dark'
30 return terminal ?? 'dark'
31}
32
33export function toneFor(theme: unknown, terminal?: Ground): Tone {
34 return groundOf(theme, terminal) === 'light' ? light : dark
35}
36
37async function themeName($: EngineInterface): Promise<unknown> {
38 const rows = await $.config.list().catch(() => [])
39 return rows.find(row => row.key === 'theme')?.value
40}
41
42async function terminalGround($: EngineInterface): Promise<Ground | undefined> {
43 return groundOfTerminal(await $.env.get('COLORFGBG').catch(() => undefined))
44}
45
46let current: Promise<Tone> | undefined
47
48export function themeTone($: EngineInterface): Promise<Tone> {
49 current ??= Promise.all([themeName($), terminalGround($)]).then(([theme, terminal]) => toneFor(theme, terminal))
50 return current
51}
52
53export function forgetTone(): void {
54 current = undefined
55}
56
57const HEX = /^#[0-9a-f]{6}$/i
58
59function isHex(value: unknown): value is string {
60 return typeof value === 'string' && HEX.test(value)
61}
62
63export function isLucidTone(value: unknown): value is LucidTone {
64 if (typeof value !== 'object' || value === null) return false
65 const { ground, palette } = value as Partial<LucidTone>
66 if (ground !== 'dark' && ground !== 'light') return false
67 return typeof palette === 'object' && palette !== null && [palette.text, palette.strong, palette.muted, palette.accent].every(isHex)
68}
69
70const TEXT_CONTRAST = 4.5
71
72function luminance(hex: string): number {
73 const channels = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
74 const [r = 0, g = 0, b = 0] = channels.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
75 return 0.2126 * r + 0.7152 * g + 0.0722 * b
76}
77
78export function contrast(a: string, b: string): number {
79 const [hi = 0, lo = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x)
80 return (hi + 0.05) / (lo + 0.05)
81}
82
83function pointedOn(base: Tone, accent: string): string {
84 return contrast(accent, base.background) >= TEXT_CONTRAST ? accent : base.pointed
85}
86
87export function fromLucid(tone: LucidTone): Tone {
88 const { ground, palette } = tone
89 const base = ground === 'light' ? light : dark
90 return { ...base, muted: palette.muted, text: palette.text, accent: palette.accent, pointed: pointedOn(base, palette.accent) }
91}
92
93async function lucidTone($: EngineInterface): Promise<unknown> {
94 try {
95 return await $.lucid.tone()
96 } catch {
97 return undefined
98 }
99}
100
101export async function drawnTone($: EngineInterface): Promise<Tone> {
102 const theirs = await lucidTone($)
103 return isLucidTone(theirs) ? fromLucid(theirs) : themeTone($)
104}
105hooks/session.ts 82 lines1import type { Reply } from '../types'
2import type { Flash } from './row'
3import type { Target } from './targets'
4import type { Written } from './turns'
5
6const DRAWN_ROWS = 1000
7
8export class Session {
9 interactive = false
10 reached = false
11 warned = false
12 settledOnce = false
13 turn: Written[] = []
14 turnOpen = false
15 turnRows: string[] = []
16 shownBeforeTurn = new Set<string>()
17 readonly liveRows = new Set<string>()
18 seeded: Promise<Reply[]> | undefined
19 reading: Promise<Reply[]> | undefined
20 readonly drawn = new Map<string, string>()
21 readonly flashes = new Map<string, Flash>()
22 readonly groupCalls = new Map<string, Target>()
23
24 rememberGroup(targets: Target[]): void {
25 for (const call of targets) {
26 if (call.callId === undefined) continue
27 this.groupCalls.delete(call.callId)
28 this.groupCalls.set(call.callId, call)
29 }
30 while (this.groupCalls.size > DRAWN_ROWS) this.groupCalls.delete(this.groupCalls.keys().next().value ?? '')
31 }
32
33 remember(requestId: string, text: string): void {
34 this.drawn.delete(requestId)
35 this.drawn.set(requestId, text)
36 if (this.drawn.size > DRAWN_ROWS) this.drawn.delete(this.drawn.keys().next().value ?? '')
37 }
38
39 writing(requestId: string): boolean {
40 return this.turn.some(block => block.uuid === requestId) || this.turnRows.includes(requestId)
41 }
42
43 openTurn(): void {
44 this.turnOpen = true
45 this.turnRows = []
46 this.shownBeforeTurn = new Set(this.drawn.keys())
47 }
48
49 mayBelongToTurn(requestId: string): boolean {
50 return this.turnOpen && !this.liveRows.has(requestId) && (this.writing(requestId) || !this.shownBeforeTurn.has(requestId))
51 }
52
53 drawnInTurn(requestId: string): void {
54 if (!this.mayBelongToTurn(requestId)) return
55 this.liveRows.add(requestId)
56 this.turnRows.push(requestId)
57 }
58
59 closeTurn(isAborted: boolean): Written[] {
60 const written = this.turn
61 const unwritten = isAborted ? this.turnRows.filter(id => !written.some(block => block.uuid === id)) : []
62 const drawnOnly = unwritten.flatMap(uuid => {
63 const text = this.drawn.get(uuid)
64 return text === undefined ? [] : [{ uuid, text }]
65 })
66 this.turn = []
67 this.turnRows = []
68 this.turnOpen = false
69 this.shownBeforeTurn = new Set()
70 return [...written, ...drawnOnly]
71 }
72}
73
74export const hinted = new Set<string>()
75
76export let session = new Session()
77
78export function freshSession(): Session {
79 session = new Session()
80 return session
81}
82hooks/turns.ts 19 lines1import type { Owners } from '../types'
2
3export type Written = { uuid: string; text: string }
4
5type Content = { type: string; [field: string]: unknown }
6
7export function writtenText(content: Content[]): string {
8 return content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])).join('\n\n')
9}
10
11export function ownersOf(blocks: Written[], partial: boolean): Owners {
12 const written = blocks.filter(b => b.text.trim() !== '')
13 const tail = written.at(-1)
14 if (tail === undefined) return {}
15 const owners: Owners = Object.fromEntries(written.map(b => [b.uuid, null]))
16 owners[tail.uuid] = { texts: written.map(b => b.text), partial }
17 return owners
18}
19hooks/clipboard.ts 90 lines1import type { EngineInterface, PromptOrigin, RenderSurface } from 'claude-code'
2
3export type Host = { os: string; wayland: boolean; x11: boolean; remote: boolean }
4export type ClipCommand = { argv: string[]; env?: Record<string, string> }
5export type CopyOutcome = { ok: true; via: string } | { ok: false; error: string }
6
7export const TERMINAL_ROUTE = 'OSC 52'
8
9const UTF8 = { LANG: 'en_US.UTF-8', LC_CTYPE: 'UTF-8' }
10
11const TERMINAL_REFUSED = 'the terminal did not take it either'
12
13export function clipboardCommands(host: Host): ClipCommand[] {
14 if (host.remote) return []
15 if (host.os === 'Darwin') return [{ argv: ['/usr/bin/pbcopy'], env: UTF8 }]
16 if (host.os !== 'Linux') return []
17 return [
18 ...(host.wayland ? [{ argv: ['wl-copy'] }] : []),
19 ...(host.x11 ? [{ argv: ['xclip', '-selection', 'clipboard', '-in'] }, { argv: ['xsel', '--clipboard', '--input'] }] : []),
20 ]
21}
22
23export function missingToolHint(host: Host): string {
24 if (host.remote) return 'over SSH copy-that copies through your terminal (OSC 52) only, so the remote clipboard is never mistaken for yours'
25 if (host.os === 'Linux') return 'no clipboard tool found: install wl-clipboard (Wayland) or xclip (X11)'
26 return `no clipboard command known for ${host.os === '' ? 'this system' : host.os}`
27}
28
29export function commandSurface(origin: PromptOrigin): RenderSurface | undefined {
30 return origin.kind === 'composer' ? 'terminal' : undefined
31}
32
33let hostOf: Promise<Host> | undefined
34
35async function detectHost($: EngineInterface): Promise<Host> {
36 const uname = await $.process.run(['uname', '-s'], { timeoutMs: 2000 }).catch(() => undefined)
37 return {
38 os: uname?.stdout.trim() ?? '',
39 wayland: Boolean(await $.env.get('WAYLAND_DISPLAY')),
40 x11: Boolean(await $.env.get('DISPLAY')),
41 remote: Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY'))),
42 }
43}
44
45export function host($: EngineInterface): Promise<Host> {
46 hostOf ??= detectHost($).catch((error: unknown) => {
47 hostOf = undefined
48 throw error
49 })
50 return hostOf
51}
52
53function toolName(command: ClipCommand): string {
54 return (command.argv[0] ?? '').split('/').at(-1) ?? ''
55}
56
57function messageOf(error: unknown): string {
58 return error instanceof Error ? error.message : String(error)
59}
60
61async function runCopy($: EngineInterface, command: ClipCommand, text: string): Promise<string | undefined> {
62 const name = command.argv[0] ?? ''
63 try {
64 const result = await $.process.run(command.argv, { stdin: text, env: command.env, timeoutMs: 5000 })
65 if (result.exitCode === 0) return undefined
66 const detail = result.stderr.trim()
67 return `${name} exited ${result.exitCode}${detail === '' ? '' : `: ${detail}`}`
68 } catch (error) {
69 return `${name} did not run: ${messageOf(error)}`
70 }
71}
72
73async function terminalCopy($: EngineInterface, text: string, surface?: RenderSurface): Promise<boolean> {
74 const result = await $.ui.copy({ text, surface }).catch(() => undefined)
75 return result?.isCopied === true
76}
77
78export async function copyText($: EngineInterface, text: string, surface?: RenderSurface): Promise<CopyOutcome> {
79 const machine = await host($)
80 const failures: string[] = []
81 for (const command of clipboardCommands(machine)) {
82 const failure = await runCopy($, command, text)
83 if (failure === undefined) return { ok: true, via: toolName(command) }
84 failures.push(failure)
85 }
86 if (await terminalCopy($, text, surface)) return { ok: true, via: TERMINAL_ROUTE }
87 const reasons = failures.length > 0 ? failures : [missingToolHint(machine)]
88 return { ok: false, error: `${reasons.join('; ')}, and ${TERMINAL_REFUSED}` }
89}
90hooks/replies.ts 46 lines1import type { SessionMessage } from 'claude-code'
2
3import type { Reply } from '../types'
4
5const BLOCK_SEPARATOR = '\n\n'
6
7const INTERRUPTED = /^\[Request interrupted by user/
8
9export function trimBlankLines(text: string): string {
10 return text.replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, '')
11}
12
13export function joinBlocks(blocks: string[]): string {
14 return blocks.map(trimBlankLines).filter(b => b !== '').join(BLOCK_SEPARATOR)
15}
16
17export function replyText(reply: Reply): string {
18 return joinBlocks(reply.texts)
19}
20
21function isPrompt(message: SessionMessage): boolean {
22 return message.role === 'user' && (message.toolResults?.length ?? 0) === 0 && message.text.trim() !== ''
23}
24
25function isInterruption(message: SessionMessage): boolean {
26 return message.role === 'user' && INTERRUPTED.test(message.text.trim())
27}
28
29export function repliesFrom(messages: SessionMessage[]): Reply[] {
30 const replies: Reply[] = [{ texts: [], partial: false }]
31 for (const message of messages) {
32 const current = replies[replies.length - 1]
33 if (current === undefined) continue
34 if (isInterruption(message)) current.partial = true
35 else if (isPrompt(message)) replies.push({ texts: [], partial: false })
36 else if (message.role === 'assistant' && message.text.trim() !== '') current.texts.push(message.text)
37 }
38 return replies.filter(reply => reply.texts.length > 0)
39}
40
41export function repliesEndingWith(replies: Reply[], block: string): number[] {
42 const wanted = block.trim()
43 if (wanted === '') return []
44 return replies.flatMap((reply, index) => ((reply.texts.at(-1) ?? '').trim() === wanted ? [index] : []))
45}
46