SLOPSHOPPER

copy-that

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

newrowscommandtoaststatusprocess
v0.6.0no licenseupdated 2026-10-23vvojciech/claude-kit/plugins/copy-that
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · copy-that
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /copy-that ⎿ copy-that: Copied last reply (1 line, OSC 52, paste to check) ⎿ copy-that: Put copy-that first in CLAUDE_CODE_PLUGIN_DIRS (a plugin listed earlier draws replies) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ copy-that: Put copy-that first in CLAUDE_CODE_PLUGIN_DIRS (a plugin listed earlier draws replies)
README

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.

A reply with a bash block, a table and a ts block. The chip list is open upward from the end of the reply, with ⧉ ts under the pointer. A tool group row above has its own ⧉ at the right edge.

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.

Install

/plugin marketplace add vvojciech/claude-kit
/plugin install copy-that@vvojciech

Requirements

  • Claude Code 2.1.287 or later.
  • Fullscreen rendering (/tui fullscreen) for the ⧉ markers. /copy-that works without it.
  • A clipboard route: 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.

Features

Reply chips

  • A muted ⧉ sits on its own row under the end of each finished reply, at the right edge.
  • Hover it to open the chips as a list, one per row, right-aligned to the ⧉. Click one to copy.
  • The list opens upward over the reply. It opens downward only when the rows above can't hold it, such as on a short reply at the top of the transcript.
  • Every row is the same width and the whole row is the target. A margin of 2 cells either side lets the pointer stray a little without closing the list.
  • ⧉ reply copies the whole reply as markdown, including text written before and after tool calls.
  • Each code block gets a chip named by its language, such as ⧉ python. A block with no language is ⧉ code, a table is ⧉ table.
  • Each blockquote gets ⧉ quote, which copies what's inside without the > markers, so a drafted Slack or PR message pastes as written.
  • A GitHub alert (> [!NOTE]) is named by its type: ⧉ note, ⧉ tip, ⧉ important, ⧉ warning or ⧉ caution. It copies without the [!NOTE] line.
  • Repeated names are numbered from the top of the reply: ⧉ bash 1, ⧉ bash 2, ⧉ quote 1, ⧉ quote 2.
  • Code blocks copy without their fences. Tables copy as markdown rows.
  • Blocks inside quotes, lists and alerts, and indented code, get chips too.
  • A reply that was interrupted shows partial at the top of its list.
  • A reply still being written gets its marker when it ends.
  • On a resumed session, when two replies end with the same block, the marker offers that block and a note naming /copy-that N for each whole reply.

Tool chips

  • A settled tool row gets a ⧉ at the right end of its first line. Its list opens like a reply's and never covers the row the ⧉ is on.
  • ⧉ 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 folded group of calls gets one chip per call, named by file or tool: ⧉ a.md, ⧉ bash #1, ⧉ bash #2. A group with one copyable call shows ⧉ output or ⧉ file, like a single row.
  • A background Agent row gets ⧉ output once its report arrives.
  • On a single call the ⧉ itself copies that call. On a group it does nothing, so a click never falls through to expand the row.
  • After a copy the ⧉ turns into ✓ or ✗ in place.
  • No chip is drawn while a call runs, when it failed or was interrupted, on an expanded live group, or when there is nothing to copy.

Clipboard

  • macOS uses pbcopy.
  • Linux tries wl-copy on Wayland, then xclip, then xsel on X11.
  • Otherwise, and always over SSH, it copies through the terminal with OSC 52. Over SSH it never runs a clipboard tool, so the remote machine's clipboard is never mistaken for yours.
  • A chip copy confirms on the clicked row with copied (pbcopy) or copy failed. Rows are wide enough for that text, so the list doesn't shift.
  • An OSC 52 copy also shows a toast saying "paste to check", because the terminal never reports back. Above 100,000 characters it warns that terminals often cut copies that large.
  • /copy-that answers name what was copied, its line count and the route, such as "Copied python block (4 lines, pbcopy)".
  • A failed copy says why, including which Linux tool to install.
  • A partial reply copies as such and says "(partial)".

Narrow panes

  • The list needs only its widest row, so it fits a 40-column pane. A name wider than the pane is cut at the edge.
  • The list holds as many rows as there are above the ⧉, or half the screen, whichever is more. Chips past that fold into +N. Click it for a toast naming them and the /copy-that commands for them.
  • Toasts wrap to the pane, folding names that don't fit into +N.

Themes

  • When lucid is loaded, copy-that takes the tone lucid last drew with, so the marker and chips match it.
  • Without lucid, it picks light or dark from the theme name. A theme name with neither "light" nor "dark" in it reads the terminal background from COLORFGBG, and falls back to dark.
  • The marker is muted and chips use the text colour. A hovered chip is bold, underlined and orange on the terminal background, at 4.5:1 contrast or better.

Commands

/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.

CommandCopies
/copy-thatthe last reply as markdown
/copy-that Rreply 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 tablea 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 planthe 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 selectionthe markdown behind what you last selected with the mouse
  • Names are case-insensitive.
  • Put R first to reach an older reply: /copy-that 2 ruby, /copy-that 2 quote.
  • An alert goes by its type, such as /copy-that note.
  • A chip named by digits, such as ⧉ 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.

Troubleshooting

When copy-that is working, the first reply that gets a ⧉ in a session also shows the toast Hover ⧉ to copy; /copy-that help.

No ⧉ under replies

  • Fullscreen is off. copy-that says so once with the toast ⧉ needs fullscreen; use /copy-that. Turn it on with /tui fullscreen.
  • Another mod draws replies before copy-that. See load order. In that case, after the first settled reply copy-that leaves this status line, and every /copy-that answer repeats it: Put copy-that first in CLAUDE_CODE_PLUGIN_DIRS (a plugin listed earlier draws replies).
  • The reply is still being written. The marker comes when it ends.
  • The ⧉ is near the top of the screen and the list is cut off. Scroll down a little.

OSC 52 copies that never arrive

  • iTerm2: turn on "Applications in terminal may access clipboard" in Settings, General, Selection.
  • tmux: set -g set-clipboard on.
  • OSC 52 can't confirm a copy. Paste to check.

Known limits

  • The engine inverts a button under the pointer. copy-that swaps its hover colours to undo that, so a hovered chip sits on the background. On a terminal whose background isn't #141311 (dark) or #f5f4f0 (light), a one-row box shows behind the hovered chip.
  • Whether the list fits above the ⧉ is judged from the transcript, not the screen, since a mod can't see where its row sits after scrolling.
  • While the pointer is on the list, the ⧉ under it isn't drawn. The engine does this.
  • The list closes as soon as the pointer leaves its margin. The hover API has no close delay.
  • Some background agent reports reach the session in a form copy-that can't read. Those get no chip, and /copy-that output says so.
  • A block whose language clashes with a command word (table, code, reply, quote, an alert type, file, plan, output, selection, help) is renamed, for example ⧉ table-code.
  • A quote inside another quote has no chip of its own. A nested quote copies one level shallower.
Source 24 files
hooks/register.tsx 112 lines
1import 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}
112
hooks/answer.ts 84 lines
1import 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}
84
hooks/commands.ts 203 lines
1import { 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}
203
hooks/hints.ts 30 lines
1import 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}
30
hooks/history.ts 99 lines
1import { 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}
99
hooks/markers.ts 151 lines
1import 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}
151
hooks/notices.ts 131 lines
1import { 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}
131
hooks/palette.ts 105 lines
1import 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}
105
hooks/session.ts 82 lines
1import 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}
82
hooks/turns.ts 19 lines
1import 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}
19
hooks/clipboard.ts 90 lines
1import 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}
90
hooks/replies.ts 46 lines
1import 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