SLOPSHOPPER

commonplace-pane

A docked pane of public-domain paintings (Art Institute of Chicago) whose weather follows the repo: calm when clean, stormy when it isn't

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commonplace-pane
│ ┃ Commonplace ✕ › fix the failing auth test and add╭───────────────────────────────╮ │ ┃ Hanging a painting… │ commonplace-pane │ │ ⏺ Read(src/auth.ts) │ ☁ The weather turns overcast. │ │ ⎿ 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 │ │ › /commonplace │ ⎿ commonplace-pane: Commonplace pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Commonplace
Hanging a painting…
README

claude-mods

Small mods for Claude Code: status lines, a band above the prompt, a docked pane, and a radio. Each one is a plugin of function hooks.

radio, backlog-band, spend-meter and commit-drift in a Claude Code terminal (illustration)

Install

claude plugin marketplace add sivori/claude-mods
claude plugin install radio@claude-mods        # or any mod below

Update later with claude plugin marketplace update claude-mods.

To try one without installing, clone the repo and run claude --plugin-dir ./plugins/<mod>.

The mods

radio

/tune streams internet radio inside Claude Code: SomaFM, lo-fi, classical and choral. (The command is /tune because Claude Code has a built-in /radio.) All your sessions share one player, so music started in two sessions never plays twice. Switching stations in any session replaces the current stream, and the status line shows what's playing everywhere. While something plays, a row of buttons sits above the prompt: ◀ ■ ▶ to change or stop, − + for volume. Stopping keeps the row, with a play button that resumes the last station.

/tune              toggle (resumes the last station); /tune listen just plays
/tune groove       pick by name, genre ("jazz", "choral") or number
/tune next | prev | stop | list
/tune vol 30       also vol +10 / vol -10
/tune band         hide or show the buttons

Plays through mpv (brew install mpv), which changes volume without a gap; otherwise ffplay from ffmpeg, which restarts the stream on each volume change.

By default the music keeps playing after you close Claude Code, until /tune stop. Turn on stopWithLastSession in /config to have it stop once no session is open; a watchdog catches a crashed session too, within about a minute.

spend-meter

Session cost, context fill and 5-hour rate-limit use in the status line ($1.23 · ctx 42% · 5h 24%). It toasts once as spend passes $5, $10, $25, $50 and $100.

commit-drift

Uncommitted file count and time since the last commit in the status line, for whichever repo you're editing. After an editing turn on a tree that hasn't been committed for 30+ minutes, it nudges you (at most every 10 minutes).

backlog-band

Shows the open ## Now items of the active repo's BACKLOG.md above the prompt. /later <item> appends to ## Next, and /backlog-band hides or shows the band. It expects this format:

## Now
- [ ] Blocking or imminent work

## Next
- [ ] The normal queue

## Someday
- [ ] Ideas

## Done
- [x] 2026-08-01 Completed

day-recap

So you never start cold. While you work it records your prompts, the files edited and the commits made. Once the session has been quiet for 10 minutes, it asks the model for a two-line recap (what got done, the next step) and saves it per repo. When you next open Claude Code in that repo, the recap sits above the prompt until your first message:

↩ Last time in dash · yesterday 5:12pm
  Done: Added the spend chart and matched its colors.
  Next: Write a test for the empty-data case.

/recap writes it now, and /recap last brings back the one from last time. A session that did nothing leaves the earlier recap in place. If you quit before the recap is written, the next session writes it from the recorded activity with Haiku.

quest-board

Three daily quests in a pane, drawn from the active repo: a TODO/FIXME comment to resolve, an open GitHub issue to close (needs gh), and a test command that failed to get passing. It picks one of each kind when it can, keeps the same board all day, and draws a new one tomorrow. A quest clears by itself when the TODO line is gone, the issue is closed or the test command passes. Each cleared quest gets a toast, and clearing all three gets a bigger one. The status line shows ⚔ quests 1/3.

/quests             open the board
/quests reroll      swap the unfinished quests for new ones
/quests check       re-check the issues now (otherwise every 10 minutes)

commonplace-pane

A docked pane of public-domain paintings from the Art Institute of Chicago. Its "weather" follows your repo: calm landscapes while the tree is clean, overcast as changes pile up or commands fail, shipwrecks in a storm. The painting rotates every 20 minutes, and /commonplace next hangs a new one.

The images need a terminal with the kitty graphics protocol (kitty, Ghostty); elsewhere the pane shows the title and artist. It uses macOS sips to convert images. Paintings are cached in ~/.cache/commonplace-pane/, which keeps the newest 30 and deletes the rest after each new painting. Two optional settings live in /config:

  • contact: an email or URL sent in the museum's AIC-User-Agent header, as the Art Institute asks of API clients.
  • seedDir: a folder of seed-*.jpg paintings to fall back on when the museum is unreachable.

Developing

Each mod is plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and a hooks/register.ts(x) module. To check one:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

License

MIT

Source 2 files
hooks/register.tsx 308 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mood, Painting } from '../types'
5
6const PANE = 'commonplace'
7const ROTATE_MS = 20 * 60_000
8const MOOD_CHECK_MS = 2 * 60_000
9const AIC_SEARCH = 'https://api.artic.edu/api/v1/artworks/search'
10const AIC_IIIF = 'https://www.artic.edu/iiif/2'
11// The museum asks API clients to identify themselves; `contact` (an email or
12// URL, optional) is appended when set.
13export const agentHeader = (contact: string) =>
14  `AIC-User-Agent: commonplace-pane${contact ? ` (${contact})` : ''}`
15// Terminal cells are about twice as tall as they are wide.
16const CELL_ASPECT = 2
17// Paintings kept in the cache; older ones are deleted after each hang.
18const KEEP = 30
19
20const painting = atom({ plugin: 'commonplace-pane', key: 'painting' } as const, null)
21const mood = atom({ plugin: 'commonplace-pane', key: 'mood' } as const, 'calm')
22
23export const MOODS: Record<Mood, { query: string; glyph: string }> = {
24  calm: { query: 'pastoral landscape summer meadow', glyph: '☀' },
25  overcast: { query: 'clouds river landscape evening', glyph: '☁' },
26  storm: { query: 'storm sea shipwreck', glyph: '⛈' },
27}
28
29const SEEDS: Record<Mood, { file: string; title: string; artist: string; date: string }[]> = {
30  calm: [
31    { file: 'seed-wheatfield', title: 'Wheat Field with Cypresses', artist: 'Vincent van Gogh', date: '1889' },
32    { file: 'seed-harvesters', title: 'The Harvesters', artist: 'Pieter Bruegel the Elder', date: '1565' },
33  ],
34  overcast: [
35    { file: 'seed-waterpitcher', title: 'Young Woman with a Water Pitcher', artist: 'Johannes Vermeer', date: 'ca. 1662' },
36    { file: 'seed-rembrandt', title: 'Self-Portrait', artist: 'Rembrandt van Rijn', date: '1660' },
37  ],
38  storm: [
39    { file: 'seed-wave', title: 'Under the Wave off Kanagawa', artist: 'Katsushika Hokusai', date: 'ca. 1830–32' },
40  ],
41}
42
43/** The repo's weather: failing commands darken it fastest, then uncommitted sprawl. */
44export function moodOf(dirty: number, recentFailures: number): Mood {
45  if (recentFailures >= 3 || dirty >= 12) return 'storm'
46  if (recentFailures >= 1 || dirty >= 4) return 'overcast'
47  return 'calm'
48}
49
50/** The Image box for a picture, filling the width unless that overflows the rows. */
51export function fit(width: number, height: number, columns: number, rows: number) {
52  const tall = Math.round((columns * height) / width / CELL_ASPECT)
53  if (tall <= rows) return { columns, rows: Math.max(1, tall) }
54  return { columns: Math.max(1, Math.round((rows * CELL_ASPECT * width) / height)), rows }
55}
56
57function pick<T>(list: readonly T[]): T | undefined {
58  return list[Math.floor(Math.random() * list.length)]
59}
60
61// Set from the plugin's options in register.
62let AIC_AGENT = agentHeader('')
63// Optional offline deck (a folder of seed-*.jpg): the floor when the museum is
64// unreachable. Empty means no fallback.
65let seedDir = ''
66
67// Module state: which repo we watch, recent Bash outcomes, and a rotation guard.
68let root: string | undefined
69let bashFailures: boolean[] = []
70const shown = new Set<string>()
71
72async function home($: EngineInterface) {
73  const found = await $.process.run(['printenv', 'HOME']).catch(() => undefined)
74  return found?.exitCode === 0 && found.stdout.trim() !== '' ? found.stdout.trim() : '/tmp'
75}
76
77async function cacheDir($: EngineInterface) {
78  return `${await home($)}/.cache/commonplace-pane`
79}
80
81async function run($: EngineInterface, argv: string[], timeoutMs = 30_000) {
82  return $.process.run(argv, { timeoutMs }).catch(() => undefined)
83}
84
85async function toPng($: EngineInterface, from: string, to: string) {
86  const converted = await run($, ['sips', '-s', 'format', 'png', from, '--out', to])
87  if (converted?.exitCode !== 0) return undefined
88  const dims = await run($, ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', to])
89  const width = Number(/pixelWidth: (\d+)/.exec(dims?.stdout ?? '')?.[1])
90  const height = Number(/pixelHeight: (\d+)/.exec(dims?.stdout ?? '')?.[1])
91  return width > 0 && height > 0 ? { width, height } : undefined
92}
93
94async function fromMuseum($: EngineInterface, want: Mood): Promise<Painting | undefined> {
95  const body = JSON.stringify({
96    q: MOODS[want].query,
97    query: {
98      bool: {
99        must: [
100          { term: { is_public_domain: true } },
101          { exists: { field: 'image_id' } },
102          { exists: { field: 'artist_title' } },
103          { match: { artwork_type_title: 'Painting' } },
104        ],
105      },
106    },
107    fields: ['id', 'title', 'artist_title', 'date_display', 'image_id'],
108    limit: 40,
109  })
110  const found = await run($, [
111    'curl', '-sS', '--max-time', '15', '-X', 'POST', AIC_SEARCH,
112    '-H', 'Content-Type: application/json', '-H', AIC_AGENT, '-d', body,
113  ])
114  if (found?.exitCode !== 0) return undefined
115  type Hit = { title: string; artist_title: string; date_display: string; image_id: string }
116  const hits = ((JSON.parse(found.stdout) as { data?: Hit[] }).data ?? []).filter(
117    hit => hit.image_id && !shown.has(hit.image_id),
118  )
119  const hit = pick(hits)
120  if (hit === undefined) return undefined
121
122  const dir = await cacheDir($)
123  const jpg = `${dir}/${hit.image_id}.jpg`
124  const png = `${dir}/${hit.image_id}.png`
125  await run($, ['mkdir', '-p', dir])
126  const got = await run($, [
127    'curl', '-sS', '--max-time', '30', '-o', jpg, '-H', AIC_AGENT,
128    `${AIC_IIIF}/${hit.image_id}/full/843,/0/default.jpg`,
129  ])
130  if (got?.exitCode !== 0) return undefined
131  const size = await toPng($, jpg, png)
132  if (size === undefined) return undefined
133  shown.add(hit.image_id)
134  return {
135    file: png,
136    title: hit.title,
137    artist: hit.artist_title,
138    date: hit.date_display,
139    ...size,
140    mood: want,
141  }
142}
143
144async function fromSeed($: EngineInterface, want: Mood): Promise<Painting | undefined> {
145  const seed = pick(SEEDS[want])
146  if (seed === undefined || seedDir === '') return undefined
147  const dir = await cacheDir($)
148  await run($, ['mkdir', '-p', dir])
149  const png = `${dir}/${seed.file}.png`
150  const size = await toPng($, `${seedDir.replace(/^~(?=\/)/, await home($))}/${seed.file}.jpg`, png)
151  if (size === undefined) return undefined
152  return { file: png, title: seed.title, artist: seed.artist, date: seed.date, ...size, mood: want }
153}
154
155/**
156 * Which cache entries to delete: every leftover JPEG, and every PNG past the
157 * newest `keep`, never the one on the wall.
158 */
159export function toPrune(
160  entries: readonly { name: string; mtimeMs: number }[],
161  keep: number,
162  current: string | undefined,
163): string[] {
164  const jpgs = entries.filter(entry => entry.name.endsWith('.jpg'))
165  const pngs = entries
166    .filter(entry => entry.name.endsWith('.png') && entry.name !== current)
167    .sort((a, b) => b.mtimeMs - a.mtimeMs)
168  const room = Math.max(0, keep - (current === undefined ? 0 : 1))
169  return [...jpgs, ...pngs.slice(room)].map(entry => entry.name)
170}
171
172async function prune($: EngineInterface) {
173  const dir = await cacheDir($)
174  const entries = await $.fs.list(dir).catch(() => [])
175  const onWall = (await read($, painting))?.file
176  const current = onWall?.startsWith(`${dir}/`) ? onWall.slice(dir.length + 1) : undefined
177  const doomed = toPrune(entries.filter(entry => entry.kind === 'file'), KEEP, current)
178  if (doomed.length > 0) await run($, ['rm', '-f', ...doomed.map(name => `${dir}/${name}`)])
179}
180
181// One hanging at a time; a caller arriving mid-hang waits for that one.
182let hanging: Promise<void> | undefined
183
184async function hangOnce($: EngineInterface) {
185  const want = await read($, mood)
186  const next = (await fromMuseum($, want)) ?? (await fromSeed($, want))
187  if (next === undefined) return
188  await update($, painting, () => next)
189  await prune($)
190}
191
192async function hang($: EngineInterface) {
193  hanging ??= hangOnce($).finally(() => {
194    hanging = undefined
195  })
196  return hanging
197}
198
199async function checkWeather($: EngineInterface) {
200  let dirty = 0
201  if (root !== undefined) {
202    const status = await run($, ['git', '-C', root, 'status', '--porcelain'])
203    if (status?.exitCode === 0) dirty = status.stdout.split('\n').filter(l => l.trim()).length
204  }
205  const now = moodOf(dirty, bashFailures.filter(Boolean).length)
206  const was = await read($, mood)
207  if (now === was) return
208  await update($, mood, () => now)
209  $.ui.toast(`${MOODS[now].glyph} The weather turns ${now}.`)
210  // A hang already under way picked the old mood: let it land, then hang anew.
211  await hanging
212  await hang($)
213}
214
215async function follow($: EngineInterface, filePath: string) {
216  const cut = filePath.lastIndexOf('/')
217  const found = await run($, ['git', '-C', cut > 0 ? filePath.slice(0, cut) : '/', 'rev-parse', '--show-toplevel'])
218  if (found?.exitCode === 0) root = found.stdout.trim()
219}
220
221export const register: Register = (on, options) => {
222  AIC_AGENT = agentHeader(String(options.contact ?? '').trim())
223  seedDir = String(options.seedDir ?? '').trim().replace(/\/$/, '')
224
225  on('session.start', async ($, e, next) => {
226    const started = await next(e)
227    await $.command.register({
228      name: 'commonplace',
229      description: 'Open the painting pane; `/commonplace next` hangs a new one',
230      argumentHint: '[next]',
231    })
232    root = (await $.session.repo())?.root
233    void $.ui.open({ id: PANE, title: 'Commonplace' })
234    if ((await read($, painting)) === null) void hang($)
235    $.clock.every(ROTATE_MS, () => void hang($))
236    $.clock.every(MOOD_CHECK_MS, () => void checkWeather($))
237    return started
238  })
239
240  on('command.run', { command: 'commonplace' }, async ($, e) => {
241    await $.ui.open({ id: PANE, title: 'Commonplace' })
242    if (e.args.trim() === 'next') {
243      await hang($)
244      const now = await read($, painting)
245      return { text: now ? `Now hanging: ${now.title}, ${now.artist}` : 'The museum is closed; no new painting.' }
246    }
247    return { text: 'Commonplace pane opened.' }
248  })
249
250  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
251    const ran = await next(e)
252    await follow($, e.file_path)
253    return ran
254  })
255
256  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
257    const ran = await next(e)
258    await follow($, e.file_path)
259    return ran
260  })
261
262  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
263    const ran = await next(e)
264    bashFailures = [...bashFailures, ran.deny === undefined && ran.isError === true].slice(-10)
265    return ran
266  })
267
268  on('turn.complete', async ($, e, next) => {
269    const done = await next(e)
270    if (e.agentId === undefined) await checkWeather($)
271    return done
272  })
273
274  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
275    const now = await read($, painting)
276    const weather = await read($, mood)
277    if (now === null) {
278      const { Text } = $.ui.resolve(e)
279      return <Text dimColor>Hanging a painting…</Text>
280    }
281    const caption = `${now.artist}, ${now.date} · ${MOODS[weather].glyph} ${weather}`
282    if (e.surface === 'terminal') {
283      const { Box, Image, Text } = $.ui.resolve(e)
284      const box = fit(now.width, now.height, e.props.bodyColumns, Math.max(4, (e.viewport?.rows ?? 30) - 6))
285      return (
286        <Box flexDirection="column">
287          <Image
288            key="art"
289            source={{ file: now.file, format: 'png' }}
290            columns={box.columns}
291            rows={box.rows}
292            alt={`${now.title}, ${now.artist}`}
293          />
294          <Text bold wrap="truncate-end">{now.title}</Text>
295          <Text dimColor wrap="truncate-end">{caption}</Text>
296        </Box>
297      )
298    }
299    const { Box, Text } = $.ui.resolve(e)
300    return (
301      <Box flexDirection="column">
302        <Text bold>{now.title}</Text>
303        <Text dimColor>{caption}</Text>
304      </Box>
305    )
306  })
307}
308
types/index.d.ts 21 lines
1export type Mood = 'calm' | 'overcast' | 'storm'
2
3export type Painting = {
4  /** Absolute path of the cached PNG the terminal draws. */
5  file: string
6  title: string
7  artist: string
8  date: string
9  /** Pixel size of the PNG, for sizing the Image box. */
10  width: number
11  height: number
12  /** The mood it was chosen for. */
13  mood: Mood
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'commonplace-pane': { painting: Painting | null; mood: Mood }
19  }
20}
21