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

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

Two rows above the prompt:
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.

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.
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.
claude plugin validate <mod>
claude plugin test <mod>
claude --plugin-dir ./<mod> # run a working copy
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.MIT
hooks/register.tsx 72 lines1import 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}
72hooks/reply.ts 72 lines1// 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