A copy button under each finished reply: copies the whole turn's text and flashes a check mark

My personal mods for Claude Code. The repository is a plugin marketplace: each folder is one mod.
In a terminal Claude Code session, type:
/plugin install turn-copy --marketplace olaugh/claude-mods
Answer y to add the marketplace, then pick the user scope (Enter). The mod is active right away and loads in every session you start on that machine afterwards. Repeat once per machine.
To pick up changes later:
claude plugin update turn-copy
then /reload-plugins in a running session.
A copy button under each finished assistant reply. It copies the whole turn's text (every text block of the reply, joined) and turns into a check mark for 1.5 s, like the Claude desktop app. Beside it, the reply's size: 1,234 chars · 18 lines, which also copies when clicked.
content_copy U+F018F and check U+F012C), so the terminal needs a Nerd Font (kitty and Ghostty draw them built in). Clicks work in fullscreen terminal mode; otherwise use /copy.⧉ Copy / ✓ Copied.Sims a crossword position in MAGPIE and shows the results in a pane: a picture of the board with the selected play on it, beside a table of the sim's top 20 plays (win%, equity, static equity, iterations, the opponent's mean reply and bingo rate), each play spelled in tiles: bright green for the tiles it plays, darker green for the board's own it plays through, blanks' letters grey. Click a row to put its play on the board; click a heading to sort those 20 by it (again to flip); the wheel scrolls the rows.
Say what to sim after /magpie in your own words. A model (Haiku) reads them into a request, knowing what the pane shows and which lexicons MAGPIE has; the rest is MAGPIE's:
/magpie ADOPQRR an opening rack (in your lexicon unless you name another)
/magpie opening rack rucksex nwl NWL23, the newest NWL installed
/magpie AEINRST after 8g vav a rack after the moves before it (all lowercase is plain tiles; VaV has a blank)
/magpie 15/15/.../15 AEINRST/ 0/0 0 lex CSW24; a CGP
/magpie ~/games/round3.gcg turn 12 a GCG (path, URL, woogles.io or cross-tables.com link) at a turn
/magpie <game> the turn where josh played brisant or a rack held, or "after VODKA" (that player's next rack)
/magpie https://www.cross-tables.com/annotated.php?u=62161#13# the turn the viewer link's move 13 was played on
/magpie next turn back, first turn, end of game: through the game the pane shows
/magpie csw the same position again in another lexicon (it says if that is the one in use)
Play on from what the pane shows by saying what happened, as a game goes on:
/magpie inertias was played. opp played zag through the a in inertias. next rack for me is polish?
The model turns that into moves and a rack (polish? is ?HILOPS); each move is then found among the legal plays MAGPIE generates on the board so far (in milliseconds), narrowed by what you said: square, direction, score, the tiles it went through, "to the TWS". Your own move is the sim's best-ranked placement of the word, the one the pane showed; the opponent's, when it fits more than one place, is asked about, and a move that fits nowhere says so and where the word does fit. With no rack given, /magpie asks for it; then the new position is simmed.
Tiles are counted from MAGPIE's letter distribution less the board. A word needing more of a letter than is left takes blanks, and when the blank could be more than one of its tiles /magpie asks which ("the first Z (JAzZ) or the second Z (JAZz)?"; say a blank in lowercase to skip the question). A word the tiles cannot make ("PIZZAZZ needs 4 Zs, and only 1 Z and 2 blanks are left") and a rack the bag cannot hold are refused before anything runs. "Keeping ION" commits your move from the whole rack, and your next rack must hold ION. A short answer to a question ("the second z", "14G") is read with what you typed before it.
A game's position left out is its latest with a player to move. When a turn, rack or word fits more than one position, /magpie lists them and asks which. Asking Claude ("sim AEINRST after 8G VAV") works too: the mod gives the model a simulate tool that takes the same request as fields and reports the top plays back.
The table fills in while a sim runs, its numbers and order updating about twice a second. Each sim is 50 plays × 5,000 iterations at 4 plies, with WMP, and RIT and WIT where the lexicon has them. With the bag empty (seven or fewer tiles unseen) MAGPIE solves the endgame instead, and the table shows its 50 best first moves: Result the final spread (W+20, L-10, T=0), Net what the line nets, Then the rest of the line.
.exe, its SHA-256 checked; on macOS and Linux, where none is published yet, built from its source in about a minute, which needs Apple's command-line tools or make and a C compiler), then has MAGPIE fetch its data (about 195 MB, every language) and build your lexicon's word map. About a minute on a Mac, all told. /magpie setup opens the pane again, to change the language or lexicon; until MAGPIE is installed, /magpie and the simulate tool open it too.[L·L], [NY], [QU], and drawn on one tile. Write /magpie in your own language; with a language other than English chosen, its replies come back in that language (the quick model translates them, the plays and racks left as they are). Polish has no word map, so its sims are slower.~/.magpie (bin/magpie and data/; %LOCALAPPDATA%\MAGPIE on Windows, where MAGPIE itself looks), or wherever magpieDir says (claude plugin configure magpie-sim@olaugh-mods). To use a MAGPIE checkout's own build instead, after make release: mkdir -p ~/magpie/bin && cp bin/magpie ~/magpie/bin/ && cp -cR data ~/magpie/ (-c clones on APFS, so the 5 GB of data costs no space until it changes), and set magpieDir to ~/magpie./magpie sent from it gets a picture instead: a PNG of the board and the top 10 plays with their leaves, which the mod draws once the sim finishes and asks Claude to send (a short turn: a file card reaches the phone only from a SendUserFile call in the conversation). Asking Claude from the phone sends one too. /magpie picture (or "send it to my phone") sends one of what the pane shows.assets/), rasterized by the mod itself.Agents (Claude sessions) installing or writing mods: see AGENTS.md.
Edit a mod's hooks/register.tsx, then check it:
claude plugin validate turn-copy
claude plugin test turn-copy
To run a working copy without installing, start Claude Code with claude --plugin-dir ./turn-copy (or name the folder in CLAUDE_CODE_PLUGIN_DIRS).
hooks/register.tsx 162 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const DONE_MS = 1500
4
5// The icons are Nerd Font glyphs, which kitty and Ghostty carry built in:
6// Material's content_copy (the back square an L at the upper left, the front
7// one lower right) and its check. Text, so the button takes them as its
8// label and a click lands on them. A space after a glyph lets kitty draw it
9// two cells wide, nearer the desktop app's icon size.
10const COPY_GLYPH = '\u{F018F}'
11const DONE_GLYPH = '\u{F012C}'
12// The pressed-and-hovered highlight covers the label's cells, and trailing
13// spaces are trimmed (it lit one cell under a two-cell glyph). A braille
14// blank draws as an empty cell and is not whitespace, so it holds a one-cell
15// margin each side: margin, glyph, the space kitty widens it into, margin.
16const MARGIN = '\u2800'
17export const iconLabel = (glyph: string) => MARGIN + glyph + ' ' + MARGIN
18
19const thousands = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
20
21// What the copy holds: "1,234 chars · 18 lines".
22export function stats(text: string): string {
23 const chars = [...text].length
24 const lines = text.split('\n').length
25 return `${thousands(chars)} ${chars === 1 ? 'char' : 'chars'} · ${thousands(lines)} ${lines === 1 ? 'line' : 'lines'}`
26}
27
28// Each finished reply as its text blocks in order, rebuilt from the session's
29// messages at load and around each turn. A block not found here (the reply
30// still being written, a summary row) gets no button.
31let replies: string[][] = []
32let copied: string | null = null
33// Turns under way; while one is, the newest reply is still being written.
34const running = new Set<string>()
35let timer: { cancel: () => void } | null = null
36
37const norm = (s: string) => s.trim()
38
39async function rebuild($: EngineInterface) {
40 const out: string[][] = []
41 let cur: string[] | null = null
42 for (const m of await $.session.messages()) {
43 if (m.role === 'user') {
44 // A typed prompt opens a reply; a tool result continues the current one.
45 if (norm(m.text) && !m.toolResults?.length) {
46 if (cur?.length) out.push(cur)
47 cur = []
48 }
49 } else if (norm(m.text)) {
50 ;(cur ??= []).push(m.text)
51 }
52 }
53 if (cur?.length) out.push(cur)
54 replies = out
55 $.ui.invalidate('ui.render')
56}
57
58// The whole reply's text when `block` is that reply's last text block, and
59// the reply is finished (`lastOpen`: the newest reply is still being written).
60export function replyText(
61 all: readonly (readonly string[])[],
62 block: string,
63 lastOpen = false,
64): string | null {
65 const b = norm(block)
66 if (!b) return null
67 for (let r = all.length - 1; r >= 0; r--) {
68 const texts = all[r]
69 const last = texts[texts.length - 1]
70 if (texts.some(t => t.includes(b))) {
71 if (lastOpen && r === all.length - 1) return null
72 return norm(last).endsWith(b) ? texts.map(norm).join('\n\n') : null
73 }
74 }
75 return null
76}
77
78export const register: Register = on => {
79 on('session.start', async ($, e, next) => {
80 await rebuild($)
81 return next(e)
82 })
83
84 // /clear, /resume, /branch and compaction replace the conversation.
85 on('classic.SessionStart', async ($, e, next) => {
86 await rebuild($)
87 return next(e)
88 }).catch(($, e, next) => next(e)) // a failed rebuild must not hold up the session
89
90 // The turn that starts has no blocks in `replies`, so it shows no button
91 // until it completes.
92 on('turn.start', async ($, e, next) => {
93 running.add(e.turnId)
94 await rebuild($)
95 return next(e)
96 })
97
98 on('turn.complete', async ($, e, next) => {
99 const done = await next(e)
100 running.delete(e.turnId)
101 if (e.agentId === undefined) {
102 await rebuild($)
103 // The last block can land just after the event; look again shortly.
104 $.clock.after(400, () => {
105 void rebuild($)
106 })
107 }
108 return done
109 })
110
111 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
112 const theirs = await next(e)
113 if (e.props.isSummary) return theirs
114 const text = replyText(replies, e.props.text, running.size > 0)
115 if (text === null) return theirs
116
117 const { Box, Button, Text } = $.ui.resolve(e)
118 const id = e.requestId
119 const isCopied = copied === id
120
121 const onPress = async (press: { surface: typeof e.surface }) => {
122 const result = await $.ui.copy({ text, surface: press.surface })
123 if (!result.isCopied) {
124 $.ui.toast('Copy failed: ' + result.reason)
125 return
126 }
127 copied = id
128 timer?.cancel()
129 timer = $.clock.after(DONE_MS, () => {
130 copied = null
131 $.ui.invalidate('ui.render')
132 })
133 $.ui.invalidate('ui.render')
134 }
135
136 // The terminal draws the glyphs; surfaces in a proportional font without
137 // the Nerd Font symbols (the desktop app) draw words.
138 const label = stats(text)
139 const icon =
140 e.surface === 'terminal'
141 ? isCopied
142 ? <Text color="success">{iconLabel(DONE_GLYPH)}</Text>
143 : <Text color="inactive">{iconLabel(COPY_GLYPH)}</Text>
144 : isCopied
145 ? <Text color="success">✓ Copied</Text>
146 : <Text color="inactive">⧉ Copy</Text>
147 return (
148 <Box flexDirection="column">
149 {theirs}
150 <Box paddingLeft={e.surface === 'terminal' ? 1 : 2} flexDirection="row" columnGap={1}>
151 <Button key={'copy:' + id} label={isCopied ? 'Copied' : 'Copy'} plain onPress={onPress}>
152 {icon}
153 </Button>
154 <Button key={'count:' + id} label={label} plain onPress={onPress}>
155 <Text dimColor>{label}</Text>
156 </Button>
157 </Box>
158 </Box>
159 )
160 })
161}
162