SLOPSHOPPER

paste-viewer

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

newpanerowscommand
★ 1v1.0.0MITupdated 2026-10-22anthonyamaro15/claude-code-plugins/paste-viewer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paste-viewer
│ ┃ paste-viewer ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to show. │ ⏺ 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 │ │ › /pastes │ ⎿ paste-viewer: No long prompts pasted yet this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · paste-viewer
Nothing to show.
README

Claude Code plugins

English · Español

Two mods for the Claude Code terminal.

  • usage-meter: a token and cost line above the prompt, plus a popup with this session's usage and your last 24 hours to 90 days of Claude Code and Codex use.
  • paste-viewer: hover previews for images you paste, and a popup that shows long pasted prompts in full.

usage-meter's popup docked beside a Claude Code session, switching between 30-day cost, tokens, 90-day activity and limits

Install

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.

Requirements

  • A recent Claude Code version that supports mods. These were built and tested on 2.1.304 and 2.1.305.
  • The fullscreen terminal UI (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.

usage-meter

A line above the prompt shows this session's tokens and cost. /usage-meter opens the popup:

  • Session: cost, tokens, a per-turn chart and cost by type (input, output, cache reads, cache writes).
  • 24h, 7d, 30d, 90d: history from the session logs on this computer, broken down by model or by day, split between Claude Code and Codex. 90d adds a daily activity calendar.
  • Limits: your 5-hour and weekly rate-limit use, with the pace you're on.

Commands:

CommandWhat it does
/usage-meterOpen or close the popup
/usage-meter cost 30dOpen on a view: cost, tokens or limits, and session, 24h, 7d, 30d or 90d
/usage-meter font blockyHeadline font: smooth, rounded, bold or blocky
/usage-meter bg #0d1117Popup 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.

paste-viewer

  • Pasted images show as a dim "1 image · hover to preview" line under your message. Hover it to see the image.
  • A long pasted prompt (more than 12 lines or 1,200 characters) gets a "View N lines" link under it that opens a popup with the full text. /pastes opens the latest one, and the popup steps through every long prompt pasted this session.

Development

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

License

MIT

Source 2 files
hooks/register.tsx 187 lines
1import { 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}
187
types/index.d.ts 26 lines
1export 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