SLOPSHOPPER

copy-markdown

A small copy control on each reply that copies the whole reply as its original markdown, plus /copy-md [n]

newrowscommandtoast
v0.1.0MITupdated 2026-10-06a-bine/claude-mods/copy-markdown
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · copy-markdown
› 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-md ⎿ copy-markdown: Copied reply 1 as markdown (71 chars). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Mods for Claude Code: plugins of function hooks that change the terminal UI.

usage-bars

usage-bars: context, 5h and 7d bars, then folder, model, effort and thinking

Two rows above the prompt:

  • Row 1: bars for the context window, the 5-hour limit and the 7-day limit, with the reset times. Each bar is colored by fill: blue when it is unused, green below 50%, yellow below 80%, red from 80% up.
  • Row 2: the current folder, the model, the effort level and thinking.

The limits are read every 2 minutes, and after each turn, from the account's usage endpoint (the figures /usage shows). They count every session and device, not only this session's last response. If that call fails, the bars fall back to the session's own readings. The last reading is cached, so the bars are never empty at startup. A reading older than 10 minutes is drawn dim, with its age.

The layout follows the terminal's width. On a wide window the bars are 20 cells, with reset times and token count. Narrower, the bars shrink and the extras go. On a very narrow one only the percentages are left.

copy-markdown

copy-markdown: the md button on a reply and the toast after a copy

A small ⧉ md button on each reply copies the whole reply as its original markdown to the clipboard: tables, code blocks, diffs and mermaid come through intact. It works on replies split by tool calls too. /copy-md [n] copies the n-th last reply from the keyboard.

Clicking needs mouse support, which you get with the fullscreen TUI ("tui": "fullscreen"). Without it, use /copy-md.

Install

At a Claude Code prompt:

/plugin install usage-bars --marketplace a-bine/claude-mods
/plugin install copy-markdown --marketplace a-bine/claude-mods

Answer y to add the marketplace, then pick a scope.

usage-bars replaces a statusLine you may have in settings.json. Remove that setting so the figures are not shown twice.

Develop

claude plugin validate <mod>
claude plugin test <mod>
claude --plugin-dir ./<mod>   # run a working copy

Notes

  • The usage endpoint (api.anthropic.com/api/oauth/usage) is not a documented API and may change. If it does, the bars keep working on the session's own readings. The request goes through $.session.authorize(), so the mod never sees the credential.
  • These mods use the Claude Code mod API, which may change between releases. They were written against 2.1.291.

License

MIT

Source 2 files
hooks/register.tsx 72 lines
1import type { EngineInterface, Register, RenderSurface, UiPressArgument } from 'claude-code'
2
3import { nthLastReply, replyContaining } from './reply'
4import type { Row } from './reply'
5
6async function readRows($: EngineInterface): Promise<Row[]> {
7  const rows = await $.session.messages()
8  return 'deny' in rows ? [] : (rows as Row[])
9}
10
11async function copyMarkdown($: EngineInterface, markdown: string, surface: RenderSurface | undefined): Promise<void> {
12  const result = await $.ui.copy({ text: markdown, surface })
13  if (result.isCopied) {
14    $.ui.toast(`Copied reply as markdown (${markdown.length} chars)`)
15  } else {
16    $.ui.toast(`Copy failed: ${result.reason}`)
17  }
18}
19
20async function copyWholeReply($: EngineInterface, blockText: string, surface: RenderSurface | undefined): Promise<void> {
21  const rows = await readRows($)
22  await copyMarkdown($, replyContaining(rows, blockText), surface)
23}
24
25async function drawAssistant($: EngineInterface, e: any, next: any): Promise<any> {
26  if (!e.props.isFirstOfReply || e.props.isSummary || e.props.text.trim() === '') return next(e)
27  const drawn = await next(e)
28  const { Box, Text, Button } = $.ui.resolve(e)
29  const blockText: string = e.props.text
30  const surface: RenderSurface = e.surface
31  return (
32    <Box key="copy-md" flexDirection="column">
33      {drawn}
34      {/* Always drawn, dim: a hidden Box can never be hovered, so a reveal-only
35          control in a scope of its own would never show. It brightens while the
36          pointer is over the reply. */}
37      <Box position="absolute" top={0} right={0}>
38        <Button
39          key="copy-md-button"
40          plain
41          label="⧉ md"
42          dimColor
43          hover={{ dimColor: false, color: 'cyan' }}
44          onPress={(press: UiPressArgument) => copyWholeReply($, blockText, press.surface ?? surface)}
45        />
46      </Box>
47    </Box>
48  )
49}
50
51async function runCopyCommand($: EngineInterface, e: any): Promise<{ text: string }> {
52  const arg = String(e.args ?? '').trim()
53  const n = arg === '' ? 1 : Number.parseInt(arg, 10)
54  if (!Number.isInteger(n) || n < 1) return { text: 'Usage: /copy-md [n]  (n-th last reply, default 1)' }
55  const markdown = nthLastReply(await readRows($), n)
56  if (markdown === undefined) return { text: `No reply number ${n} found.` }
57  const [surface] = await $.session.surfaces()
58  const result = await $.ui.copy({ text: markdown, surface })
59  return { text: result.isCopied ? `Copied reply ${n} as markdown (${markdown.length} chars).` : `Copy failed: ${result.reason}` }
60}
61
62async function registerCommand($: EngineInterface, e: any, next: any): Promise<any> {
63  await $.command.register({ name: 'copy-md', description: 'Copy the n-th last reply as markdown', argumentHint: '[n]' })
64  return next(e)
65}
66
67export const register: Register = on => {
68  on('session.start', registerCommand)
69  on('command.run', { command: 'copy-md' }, runCopyCommand)
70  on('ui.render', { component: 'AssistantMessage' }, drawAssistant)
71}
72
hooks/reply.ts 72 lines
1// Pure helpers: no `$`, so they are unit-testable.
2
3export type Row = {
4  role: 'user' | 'assistant'
5  text: string
6  toolResults?: readonly unknown[]
7}
8
9/** A user row that starts a new turn: real text, not a tool result. */
10function isTurnStart(row: Row): boolean {
11  return row.role === 'user' && !(row.toolResults && row.toolResults.length > 0) && row.text.trim() !== ''
12}
13
14/** Join text blocks with blank lines; markdown (tables included) is kept verbatim. */
15export function joinBlocks(blocks: readonly string[]): string {
16  return blocks.filter(b => b.trim() !== '').join('\n\n')
17}
18
19/** Replies in order, each the list of its non-empty assistant texts. */
20export function replies(rows: readonly Row[]): string[][] {
21  const out: string[][] = []
22  let current: string[] | null = null
23  for (const row of rows) {
24    if (isTurnStart(row)) {
25      current = null
26    } else if (row.role === 'assistant') {
27      if (current === null) {
28        current = []
29        out.push(current)
30      }
31      if (row.text.trim() !== '') current.push(row.text)
32    }
33  }
34  return out.filter(r => r.length > 0)
35}
36
37function norm(s: string): string {
38  return s.replace(/\s+/g, ' ').trim()
39}
40
41function contains(haystack: string, needle: string): boolean {
42  const n = norm(needle)
43  if (n === '') return false
44  const h = norm(haystack)
45  return h.includes(n) || h.includes(n.slice(0, 80))
46}
47
48/**
49 * The whole reply a block belongs to, as markdown. The newest reply whose first
50 * text row holds the block wins, then the newest holding it anywhere; when none
51 * does (a reply still streaming) the block's own text is returned.
52 */
53export function replyContaining(rows: readonly Row[], blockText: string): string {
54  const all = replies(rows)
55  for (let i = all.length - 1; i >= 0; i--) {
56    const r = all[i]
57    if (r && r[0] !== undefined && contains(r[0], blockText)) return joinBlocks(r)
58  }
59  for (let i = all.length - 1; i >= 0; i--) {
60    const r = all[i]
61    if (r && r.some(t => contains(t, blockText))) return joinBlocks(r)
62  }
63  return blockText
64}
65
66/** The n-th last reply (1 = newest) as markdown, or undefined. */
67export function nthLastReply(rows: readonly Row[], n: number): string | undefined {
68  const all = replies(rows)
69  const r = all[all.length - n]
70  return n >= 1 && r ? joinBlocks(r) : undefined
71}
72