Hover previews for pasted images under your message, and a popup to read long pasted prompts in full

English · Español
Two mods for the Claude Code terminal.

At the prompt of a Claude Code terminal session:
/plugin install usage-meter --marketplace anthonyamaro15/claude-code-plugins
/plugin install paste-viewer --marketplace anthonyamaro15/claude-code-plugins
Answer y to add the marketplace, then pick a scope (user scope loads the mod in every session). The mod starts working right away.
From a shell:
claude plugin install usage-meter --marketplace anthonyamaro15/claude-code-plugins
claude plugin install paste-viewer --marketplace anthonyamaro15/claude-code-plugins
To update later: claude plugin update usage-meter (or paste-viewer), then /reload-plugins.
tui: fullscreen) is recommended. It docks the usage popup on the right and turns on the hover previews.python3 on your PATH, for usage-meter's 24h to 90d history only. The meter line and the Session view work without it.A line above the prompt shows this session's tokens and cost. /usage-meter opens the popup:
Commands:
| Command | What it does |
|---|---|
/usage-meter | Open or close the popup |
/usage-meter cost 30d | Open on a view: cost, tokens or limits, and session, 24h, 7d, 30d or 90d |
/usage-meter font blocky | Headline font: smooth, rounded, bold or blocky |
/usage-meter bg #0d1117 | Popup background: a hex color, none, or default |
Font and background choices are saved across sessions.
Privacy: history is read only from local files: ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects) and, if Codex is installed, ~/.codex/sessions (or $CODEX_HOME/sessions). Nothing is sent anywhere. Sums are cached in ~/.cache/usage-meter/.
Pricing: costs use the API list prices built into usage-meter/scripts/scan.py, so they show what the same use would cost on the API, not what a subscription charges. Models without a known price are counted as tokens and noted as unpriced.
/pastes opens the latest one, and the popup steps through every long prompt pasted this session.Each mod is a folder with .claude-plugin/plugin.json and a hooks module in hooks/register.tsx. To run one from a local copy:
claude --plugin-dir ./usage-meter
Checks:
claude plugin validate ./usage-meter
claude plugin test ./usage-meter
python3 -I usage-meter/scripts/scan_check.py
MIT
hooks/register.tsx 187 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Paste, PastedImage } from '../types'
5
6const VIEWER = 'paste-viewer'
7const LONG_LINES = 12
8const LONG_CHARS = 1200
9const MAX_PNG_BYTES = 2 * 1024 * 1024
10const THUMB_COLUMNS = 30
11
12const pastes = atom({ plugin: 'paste-viewer', key: 'pastes' } as const, null as Paste | null)
13const order = atom({ plugin: 'paste-viewer', key: 'order' } as const, [] as string[])
14const viewing = atom({ plugin: 'paste-viewer', key: 'viewing' } as const, '')
15
16/** Width and height from a base64 PNG's header; undefined when it is not a PNG. */
17function pngSize(data: string): { width: number; height: number } | undefined {
18 try {
19 const head = atob(data.slice(0, 32))
20 if (head.slice(1, 4) !== 'PNG') return undefined
21
22 const at = (i: number) =>
23 ((head.charCodeAt(i) << 24) | (head.charCodeAt(i + 1) << 16) | (head.charCodeAt(i + 2) << 8) | head.charCodeAt(i + 3)) >>> 0
24
25 return { width: at(16), height: at(20) }
26 } catch {
27 return undefined
28 }
29}
30
31/** What a prompt row holds worth a viewer: its images, and its text when long; null otherwise. */
32function pasteOf(content: readonly { type: string; [field: string]: unknown }[]): Paste | null {
33 const text = content
34 .filter(b => b.type === 'text')
35 .map(b => String(b.text ?? ''))
36 .join('\n')
37 const images = content.flatMap((b): PastedImage[] => {
38 const source = b.source as { type?: string; media_type?: string; data?: string } | undefined
39 if (b.type !== 'image' || source?.type !== 'base64' || !source.data) return []
40
41 const size = pngSize(source.data)
42 const bytes = (source.data.length * 3) / 4
43
44 return [{
45 mediaType: source.media_type ?? 'image',
46 data: source.data,
47 width: size?.width ?? 0,
48 height: size?.height ?? 0,
49 isDrawable: size !== undefined && bytes <= MAX_PNG_BYTES,
50 }]
51 })
52 const lines = text.split('\n').length
53 const isLong = lines >= LONG_LINES || text.length >= LONG_CHARS
54
55 return images.length > 0 || isLong ? { text, lines, images } : null
56}
57
58function isLong(paste: Paste): boolean {
59 return paste.lines >= LONG_LINES || paste.text.length >= LONG_CHARS
60}
61
62/** Rows for an image `columns` wide, keeping its aspect: a cell is about twice as tall as wide. */
63function rowsFor(image: PastedImage, columns: number, maxRows: number): number {
64 if (image.width === 0 || image.height === 0) return Math.min(maxRows, Math.round(columns / 3))
65
66 return Math.max(2, Math.min(maxRows, Math.round((columns * image.height) / image.width / 2)))
67}
68
69/**
70 * The key a prompt row's paste is kept under: its id less the last group. The engine stores a
71 * row under its uuid but draws it as `<first four groups>-<block index>`, so only that prefix matches.
72 */
73function rowKey(id: string): string {
74 return id.slice(0, id.lastIndexOf('-'))
75}
76
77async function openViewer($: EngineInterface, id: string): Promise<void> {
78 await update($, viewing, () => id)
79 await $.ui.open({ id: VIEWER, title: 'Paste', focus: true, closeOnEscape: true, rows: 40 })
80}
81
82export const register: Register = on => {
83 on('session.start', async ($, e, next) => {
84 await $.command.register({
85 name: 'pastes',
86 description: 'Read the long prompts pasted this session in full',
87 })
88
89 return next(e)
90 })
91
92 on('command.run', { command: 'pastes' }, async $ => {
93 const latest = (await read($, order)).at(-1)
94 if (latest === undefined) return { text: 'No long prompts pasted yet this session.' }
95
96 await openViewer($, latest)
97
98 return { text: 'Paste viewer opened.' }
99 })
100
101 on('session.append', async ($, e, next) => {
102 const kept = await next(e)
103 const isTyped = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
104 if (kept.message === undefined || !isTyped || e.agentId !== undefined || e.message.type !== 'user' || e.message.isMeta) {
105 return kept
106 }
107
108 const paste = pasteOf(kept.message.content)
109 if (paste) {
110 const key = rowKey(e.uuid)
111 await update($, memberOf(pastes, { requestId: key }), () => paste)
112 // The viewer reads text alone: only long prompts are stepped through there.
113 if (isLong(paste)) await update($, order, list => [...list, key])
114 }
115
116 return kept
117 })
118
119 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
120 const key = rowKey(e.requestId)
121 const paste = await read($, memberOf(pastes, { requestId: key }))
122 if (!paste || e.surface !== 'terminal') return next(e)
123
124 const { Box, Text, Button, Image } = $.ui.resolve(e)
125 const row = await next(e)
126 const drawable = paste.images.filter(image => image.isDrawable)
127 const n = paste.images.length
128 const columns = Math.max(8, Math.min(THUMB_COLUMNS, (e.viewport?.columns ?? 80) - 4))
129
130 // Thumbnails stay hidden until the pointer is on this line, then float over the rows above it.
131 return (
132 <Box flexDirection="column">
133 {row}
134 <Box key="paste" paddingLeft={2} gap={3}>
135 {n > 0 && <Text dimColor>{`🖼 ${n === 1 ? '1 image' : `${n} images`} · hover to preview`}</Text>}
136 {isLong(paste) && (
137 <Button key="view" plain onPress={() => void openViewer($, key)}>
138 <Text dimColor>{`⤢ View ${paste.lines} lines`}</Text>
139 </Button>
140 )}
141 {drawable.length > 0 && (
142 <Box position="absolute" bottom={1} left={2} gap={1} display="none" hover={{ display: 'flex' }}>
143 {drawable.map((image, i) => (
144 <Image
145 key={`thumb-${i}`}
146 source={{ png: image.data }}
147 columns={columns}
148 rows={rowsFor(image, columns, 12)}
149 alt={`Pasted image ${i + 1}`}
150 />
151 ))}
152 </Box>
153 )}
154 </Box>
155 </Box>
156 )
157 })
158
159 on('ui.render', { component: 'Pane', requestId: VIEWER }, async ($, e) => {
160 const { Box, Text, Button } = $.ui.resolve(e)
161 const list = await read($, order)
162 const id = await read($, viewing)
163 const paste = await read($, memberOf(pastes, { requestId: id }))
164 if (!paste) return <Text dimColor>Nothing to show.</Text>
165
166 const at = list.indexOf(id)
167 const step = (by: number) => () => {
168 const other = list[at + by]
169 if (other !== undefined) void update($, viewing, () => other)
170 }
171
172 return (
173 <Box flexDirection="column" gap={1}>
174 <Box justifyContent="space-between">
175 <Text dimColor>{`Paste ${at + 1} of ${list.length} · ${paste.lines} lines`}</Text>
176 <Box gap={2}>
177 {at > 0 && <Button key="prev" plain hotkey="p" onPress={step(-1)}>Prev</Button>}
178 {at < list.length - 1 && <Button key="next" plain hotkey="n" onPress={step(1)}>Next</Button>}
179 <Text dimColor>esc to close</Text>
180 </Box>
181 </Box>
182 <Text>{paste.text}</Text>
183 </Box>
184 )
185 })
186}
187types/index.d.ts 26 lines1export type PastedImage = {
2 mediaType: string
3 /** The image's bytes, base64, as the prompt row stores them. */
4 data: string
5 /** Pixel size from the PNG header; 0 when unknown. */
6 width: number
7 height: number
8 /** A PNG small enough for the terminal to draw. */
9 isDrawable: boolean
10}
11
12export type Paste = { text: string; lines: number; images: PastedImage[] }
13
14declare module 'claude-code' {
15 interface PluginState {
16 'paste-viewer': {
17 /** Per transcript row (its uuid): what was pasted into it; null when nothing worth showing. */
18 pastes: StateFamily<Paste | null>
19 /** Row uuids that hold a paste, oldest first. */
20 order: string[]
21 /** The row the viewer shows. */
22 viewing: string
23 }
24 }
25}
26