SLOPSHOPPER

previews

Images Claude reads get a 🖼 preview line in the transcript; hover it to see the picture inline (terminals with image support, like Ghostty)

newrowsprocess
v0.1.0no licenseupdated 2026-10-09whilestevego/claude-mods/previews
A shopper browsing a rack in a slop shop
README

claude-mods

Mods for Claude Code: small plugins that add to its screen and behavior. Each folder is one mod. Turn on the ones you want (see Setup) and change their settings in /config, under the mod's name.

The mods

context-bar

Shows how full Claude's context window is, as a colored bar above the prompt.

  • Point at the bar to see what's using the space (system prompt, messages, tools…).
  • /context-bar hides or shows it for this session.

rate-limits

Shows how much of your 5-hour and weekly usage limits you've used, as two small meters under the context bar. They turn yellow at 70% and red at 90%, and you get a warning once a limit passes 90%.

  • Nothing to do: it appears on its own when you're on a subscription plan.

session-id

Shows this session's ID in the bottom-right corner of the screen.

  • Click it to copy the ID.
  • Ctrl-click it to copy the full claude --resume <id> command.

turn-receipt

Adds a receipt to the line that closes each turn ("✻ Baked for 42s"): how many tools ran, which files were edited, the tokens used and what the turn cost.

  • Nothing to do: it appears after every turn.

tldr

Adds a one-sentence summary under Claude's longer replies, written by Claude Haiku.

  • Nothing to do. TL;DR: long reply in /config sets how long a reply must be (600 characters by default).

previews

Lets you see images Claude reads, right in the conversation.

  • Under each image Claude opens, a line reads 🖼 name.png · hover to preview. Point at it to show the picture, and move away to hide it.

voice

Gives Claude a voice, through murmur. It speaks only when you're away from the session (another app or tab in front), so it never talks over you while you're watching.

  • It says what a long turn did, when Claude needs your permission or has a question, when a background task finishes, how a long task is going every few minutes, and when you're close to a usage limit.
  • /read reads Claude's last reply aloud.
  • /tldr-aloud gives a spoken 2–3 sentence summary of the session so far.
  • A soft chime plays before it speaks, so a voice never starts out of nowhere. Turn it off with Chime in /config.
  • /hush mutes this session; run it again to unmute.
  • Each open session gets its own voice. Pick one fixed voice, or the list sessions choose from, in /config under voice.

ghostty

Ties Claude into the Ghostty terminal. All its settings start with "Ghostty" in /config.

  • Tab title: a 4-word summary of what the session is about, from your last 10 messages and Claude's latest reply, updated every few minutes as it goes. /title gets a new one right away. A resumed session gets its old title back.
  • Tab progress bar: shows while Claude works, fills in as its to-do list gets done, and turns red when a command fails.
  • ❓ waiting on you: a ❓ in the tab title and a desktop notification when Claude needs a permission or an answer. If voice is on, it speaks instead of notifying.
  • /workspace saves and reopens whole windows of sessions: see Workspaces.
  • /goto (or ⌘⌃G) lists your open Claude sessions, the ones waiting on you first, and jumps to the one you pick. /goto next jumps straight to the one waiting longest; /goto <words> jumps to the one whose title matches.
  • /keybind add <keys> <text> makes a Ghostty key type something into Claude, like /keybind add super+ctrl+h /hush for ⌘⌃H. /keybind lists them, and /keybind remove <keys> removes one.
Workspaces

A workspace is a Ghostty window of Claude sessions you can put away and bring back later. It remembers every tab and split: each Claude session with its conversation, and each shell with its folder. Reopening it brings back the whole window, with every conversation picking up where it left off.

CommandWhat it does
/workspace save [name]Saves this window. With no name, it keeps the name the window already had, or Claude Haiku picks one from the tabs.
/workspace close [name]Saves this window, then exits every Claude session in it and closes their tabs. Your current tab stays open at the shell, and shell tabs stay open.
/workspace listShows your saved workspaces, newest first. Press a number or Enter to reopen one, or Tab to ✕ delete to remove it.
/workspace open <name>Reopens a saved workspace in a new window. Part of the name is enough.
  • Reopening puts each tab back in its folder. Claude tabs run claude --resume, so they continue their own conversation, with the tab title they had when you saved. A tab saved without a title gets a new one from its conversation. Shell tabs open at the prompt.
  • Put away a project for the day: /workspace close. Pick it up tomorrow: /workspace list, then press its number.
  • What doesn't come back: programs that were running (a dev server, a test watcher), and the exact split layout. Splits reopen side by side.
  • Where they're kept: ~/.claude/workspaces/, one JSON file per workspace.

Setup

  1. Clone this repo.
  2. List the mods you want in CLAUDE_CODE_PLUGIN_DIRS, separated by :, in the env block of ~/.claude/settings.json:
   "env": {
     "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/context-bar:/path/to/claude-mods/voice"
   }
  1. Start a new Claude session.

To try one mod for a single session: claude --plugin-dir /path/to/claude-mods/tldr.

Requirements

  • ghostty needs Ghostty 1.3 or newer on macOS. Set CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 in the same env block, so Claude Code doesn't overwrite the tab title.
  • voice needs murmur installed, which runs on Apple Silicon Macs.
  • previews needs a terminal that can draw images, like Ghostty or Kitty.
  • session-id's pointing-hand cursor works in Ghostty, Kitty and foot.

The first time a mod controls Ghostty, macOS asks for permission. Allow it.

Development

Editing a mod's files reloads it in running sessions.

claude plugin validate context-bar   # check a mod the way Claude Code will load it
claude plugin test context-bar       # run its tests

IDEAS.md has ideas for more mods.

Source 4 files
hooks/register.tsx 97 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderNode } from 'claude-code'
3
4import { boxFor, cacheName, imagePath, needsConversion, parseSize } from './image'
5
6const shown = atom({ plugin: 'previews', key: 'shown' } as const, null)
7
8let home = ''
9/** image path → what to draw (a PNG path and its size), worked out once. */
10const prepared = new Map<string, Promise<{ png: string; width: number; height: number } | null>>()
11
12async function prepare($: EngineInterface, path: string) {
13  const stat = await $.fs.stat(path).catch(() => null)
14  if (!stat) return null
15  let png = path
16  if (needsConversion(path)) {
17    const dir = `${home}/Library/Caches/claude-previews`
18    png = `${dir}/${cacheName(path, (stat as { mtimeMs?: number }).mtimeMs ?? 0)}`
19    await $.process.run(['mkdir', '-p', dir])
20    const r = await $.process.run(['sips', '-s', 'format', 'png', path, '--out', png])
21    if (r.exitCode !== 0) return null
22  }
23  const size = parseSize((await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png])).stdout)
24  return { png, ...size }
25}
26
27function prepared$($: EngineInterface, path: string) {
28  if (!prepared.has(path)) prepared.set(path, prepare($, path).catch(() => null))
29  return prepared.get(path)!
30}
31
32/** How tall a preview is drawn, from the setting. */
33let previewRows = 14
34
35/** A row plus a 🖼 line per image it read; a picture only while the pointer is on its line. */
36async function withPreviews($: EngineInterface, ui: Elements['terminal'], requestId: string, columns: number, row: RenderNode, paths: string[]) {
37  const { Box, Client, Image } = ui
38  const current = await read($, shown)
39  const maxColumns = Math.max(10, columns - 6)
40  const lines = await Promise.all(
41    paths.map(async (path, i) => {
42      const id = `${requestId}~${i}`
43      const name = path.split('/').pop() ?? path
44      const isShown = current === id
45      const pic = isShown ? await prepared$($, path) : null
46      const box = pic && boxFor(pic.width, pic.height, previewRows, maxColumns)
47      return (
48        <Box flexDirection="column">
49          <Client key={`preview-${id}`} module="./preview.tsx" props={{ name, shown: isShown }} />
50          {pic && box && (
51            <Box marginLeft={4}>
52              <Image source={{ file: pic.png, format: 'png' }} rows={box.rows} columns={box.columns} alt={name} />
53            </Box>
54          )}
55        </Box>
56      )
57    }),
58  )
59  return (
60    <Box flexDirection="column">
61      {row}
62      {lines}
63    </Box>
64  )
65}
66
67export const register: Register = (on, options) => {
68  previewRows = typeof options.rows === 'number' && options.rows > 0 ? Math.min(60, options.rows) : 14
69
70  on('session.start', async ($, e, next) => {
71    home = (await $.process.run(['sh', '-c', 'echo "$HOME"'])).stdout.trim()
72    return next(e)
73  })
74
75  // A single tool row (expanded transcripts, --verbose).
76  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
77    const path = imagePath(e.props.tool, e.props.input)
78    if (!path || e.surface !== 'terminal') return next(e)
79    return withPreviews($, $.ui.resolve(e), e.requestId, e.viewport?.columns ?? 80, await next(e), [path])
80  })
81
82  // The collapsed row ("Read 3 files") that groups tool calls by default.
83  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
84    if (e.surface !== 'terminal' || e.props.isExpanded) return next(e)
85    const paths = e.props.calls.map(c => imagePath(c.tool, c.input)).filter((p): p is string => p !== undefined)
86    if (paths.length === 0) return next(e)
87    return withPreviews($, $.ui.resolve(e), e.requestId, e.viewport?.columns ?? 80, await next(e), paths)
88  })
89
90  on('ui.message', async ($, e, next) => {
91    if (!e.element.startsWith('preview-')) return next(e)
92    const id = e.element.slice('preview-'.length)
93    await update($, shown, current => (e.data === 'enter' ? id : current === id ? null : current))
94    return next(e)
95  })
96}
97
hooks/image.ts 43 lines
1/** Formats the terminal draws as-is, and the ones macOS's `sips` converts to PNG first. */
2const PNG = /\.png$/i
3const CONVERTIBLE = /\.(jpe?g|gif|webp|heic|heif|tiff?|bmp)$/i
4
5export function isImage(path: string): boolean {
6  return PNG.test(path) || CONVERTIBLE.test(path)
7}
8
9export function needsConversion(path: string): boolean {
10  return !PNG.test(path) && CONVERTIBLE.test(path)
11}
12
13/** The image path a Read call opened, when it's an image. */
14export function imagePath(tool: string, input: unknown): string | undefined {
15  if (tool !== 'Read' || !input || typeof input !== 'object') return undefined
16  const path = (input as { file_path?: unknown }).file_path
17  return typeof path === 'string' && path.startsWith('/') && isImage(path) ? path : undefined
18}
19
20/**
21 * A box that keeps the picture's shape (a cell is about twice as tall as wide): `rows` tall when it fits,
22 * shorter when the screen's width caps it.
23 */
24export function boxFor(width: number, height: number, rows: number, maxColumns: number): { rows: number; columns: number } {
25  if (!(width > 0 && height > 0)) return { rows, columns: Math.min(rows * 2, maxColumns) }
26  const ratio = width / height
27  const columns = Math.max(4, Math.min(maxColumns, Math.round(rows * 2 * ratio)))
28  return { rows: Math.max(1, Math.min(rows, Math.round(columns / (2 * ratio)))), columns }
29}
30
31/** `sips -g pixelWidth -g pixelHeight` output → size. */
32export function parseSize(sips: string): { width: number; height: number } {
33  const n = (key: string) => Number(new RegExp(`${key}:\\s*(\\d+)`).exec(sips)?.[1] ?? 0)
34  return { width: n('pixelWidth'), height: n('pixelHeight') }
35}
36
37/** A stable file name for a converted copy (FNV-1a of the path and its modification time). */
38export function cacheName(path: string, mtimeMs: number): string {
39  let h = 0x811c9dc5
40  for (const c of `${path}|${mtimeMs}`) h = Math.imul(h ^ c.charCodeAt(0), 0x01000193) >>> 0
41  return `${h.toString(16)}.png`
42}
43
hooks/preview.tsx 12 lines
1import type { ClientModule } from 'claude-code'
2
3// The 🖼 line: drawn by the surface so pointer enter/leave reach it; the hooks module draws the picture.
4const PreviewLabel: ClientModule<{ name: string; shown: boolean }> = ({ name, shown }, surface) => {
5  const { Text } = surface.elements
6  surface.onPointer(ev => {
7    if (ev.type === 'enter' || ev.type === 'leave') surface.post(ev.type)
8  })
9  return <Text dimColor={!shown}>  🖼  {shown ? name : `${name} · hover to preview`}</Text>
10}
11export default PreviewLabel
12
types/index.d.ts 9 lines
1/** The ToolUse row whose preview the pointer is over, or null. */
2export type ShownPreview = string | null
3
4declare module 'claude-code' {
5  interface PluginState {
6    previews: { shown: ShownPreview }
7  }
8}
9