SLOPSHOPPER

turn-copy

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

newrowstoasttimer
v0.1.0no licenseupdated 2026-10-15olaugh/claude-mods/turn-copy
A shopper browsing a rack in a slop shop
README

claude-mods

My personal mods for Claude Code. The repository is a plugin marketplace: each folder is one mod.

Install

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.

Mods

turn-copy

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.

  • Terminal: the icons are Nerd Font glyphs (Material 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.
  • Desktop app: shows ⧉ Copy / ✓ Copied.

magpie-sim

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.

  • Installs MAGPIE itself. Installing the mod asks the language you play in (English, Français, Deutsch, Nederlands, Català, Polski) and your lexicon, the one a position is simmed in when it names none. The first session without MAGPIE opens a setup pane with the same choices and an Install button, which gets MAGPIE 0.1.3 (on Windows the published .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.
  • Languages: every set MAGPIE plays. Tiles with accents are tiles of their own (Ä, Ł, Ç), drawn with their own set's scores; Catalan's two-letter tiles are written as MAGPIE writes them, [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 lives in ~/.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.
  • Terminal: the board is an image, drawn by kitty graphics (kitty, Ghostty). Desktop and VS Code get SVG.
  • Phone: the Claude app following a session over Remote Control draws no pane, so a /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.
  • The tiles are Fira Code (the MAGPIE TUI's font, OFL, in assets/), rasterized by the mod itself.

Agents (Claude sessions) installing or writing mods: see AGENTS.md.

Developing

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

Source 1 files
hooks/register.tsx 162 lines
1import 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