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

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.
Shows how full Claude's context window is, as a colored bar above the prompt.
/context-bar hides or shows it for this session.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%.
Shows this session's ID in the bottom-right corner of the screen.
claude --resume <id> command.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.
Adds a one-sentence summary under Claude's longer replies, written by Claude Haiku.
/config sets how long a reply must be (600 characters by default).Lets you see images Claude reads, right in the conversation.
🖼 name.png · hover to preview. Point at it to show the picture, and move away to hide it.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.
/read reads Claude's last reply aloud./tldr-aloud gives a spoken 2–3 sentence summary of the session so far./config./hush mutes this session; run it again to unmute./config under voice.Ties Claude into the Ghostty terminal. All its settings start with "Ghostty" in /config.
/title gets a new one right away. A resumed session gets its old title back./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.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.
| Command | What 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 list | Shows 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. |
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./workspace close. Pick it up tomorrow: /workspace list, then press its number.~/.claude/workspaces/, one JSON file per workspace.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"
}
To try one mod for a single session: claude --plugin-dir /path/to/claude-mods/tldr.
CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 in the same env block, so Claude Code doesn't overwrite the tab title.The first time a mod controls Ghostty, macOS asks for permission. Allow it.
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.
hooks/register.tsx 97 lines1import { 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}
97hooks/image.ts 43 lines1/** 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}
43hooks/preview.tsx 12 lines1import 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
12types/index.d.ts 9 lines1/** 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