SLOPSHOPPER

identicon

GitHub-style identicon of the current repo in a docked pane

newpanecommandprocess
★ 1v0.2.0no licenseupdated 2026-10-15robertocaldas/identicon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · identicon
│ ┃ identicon ✕ › fix the failing auth test and add an audit log call │ ┃ Cell shape 2.0 │ ┃ Cell shape 2.0 ⏺ Read(src/auth.ts) │ ┃ Cell shape 2.0 ⎿ Read 6 lines │ ┃ Cell shape 2.0 ⏺ 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 │ │ › /identicon │ ⎿ identicon: Identicon pane closed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · identicon
Cell shape 2.0 Cell shape 2.0 Cell shape 2.0 Cell shape 2.0
README

identicon

A Claude Code mod that draws a GitHub-style identicon of the current repo in a docked pane.

The picture comes from a hash of the origin remote URL (ssh and https clones match), else the repo root's name, else the folder name. Clicking it steps through the cell shapes, then moves it to the other end of the pane. /identicon shows or hides the pane.

The pane docks beside the transcript in the fullscreen layout, from 110 columns.

Install

At the prompt of a terminal session:

/plugin install identicon --marketplace robertocaldas/identicon

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install identicon --marketplace robertocaldas/identicon

Options

Set them on the install screen or in /config.

OptionValuesMeaning
Positiontop, bottomWhere the icon sits in the pane
Cell shape2.0, 2.2, 2.4Your terminal cell's height as a multiple of its width, so the icon draws square
Source 3 files
hooks/register.tsx 94 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { HEIGHT, TILE, identicon, lastPart, normaliseRemote, widthFor } from './identicon'
5
6const PANE = 'identicon'
7const SHAPES = ['2.0', '2.2', '2.4']
8const picture = atom({ plugin: 'identicon', key: 'picture' } as const, null)
9
10export const register: Register = (on, options) => {
11  const isBottom = options.position === 'bottom'
12  const shape = SHAPES.includes(String(options.cellShape)) ? String(options.cellShape) : SHAPES[0]
13  const width = widthFor(Number(shape))
14
15  on('session.start', async ($, e, next) => {
16    await $.command.register({ name: 'identicon', description: 'Show or hide the repo identicon pane' })
17
18    const git = async (...args: string[]) => {
19      try {
20        const { exitCode, stdout } = await $.process.run(['git', ...args], { cwd: e.cwd })
21        return exitCode === 0 ? stdout.trim() : ''
22      } catch {
23        return ''
24      }
25    }
26    const remote = await git('remote', 'get-url', 'origin')
27    const root = await git('rev-parse', '--show-toplevel')
28    const seed = remote ? normaliseRemote(remote) : lastPart(root || e.cwd)
29    await update($, picture, () => identicon(seed, width))
30    void $.ui.open({ id: PANE, title: lastPart(seed), columns: width })
31
32    return next(e)
33  })
34
35  on('command.run', { command: 'identicon' }, async $ => {
36    const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
37    if (isOpen) {
38      await $.ui.close({ id: PANE })
39      return { text: 'Identicon pane closed.' }
40    }
41    const seed = (await read($, picture))?.seed ?? 'unknown'
42    await $.ui.open({ id: PANE, title: lastPart(seed), columns: width })
43
44    return { text: `Identicon pane opened (seed: ${seed}).` }
45  })
46
47  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
48    const { Box, Button, Text } = $.ui.resolve(e)
49    const pic = await read($, picture)
50    if (pic === null) {
51      return <Text dimColor>…</Text>
52    }
53
54    const lines = Array.from({ length: HEIGHT / 2 }, (_, line) => [pic.cells[line * 2], pic.cells[line * 2 + 1]])
55    // Docked, the body is the dock's full height; inline it fits the tree.
56    const height = isBottom && e.props.placement === 'dock' ? e.props.scroll.bodyRows : undefined
57
58    // A click steps through the shapes, then moves the icon to the other end
59    // and starts the shapes again. Each /config change reloads the mod; both
60    // writes go out together so a reload can't cut the second one off.
61    const step = async () => {
62      const rows = await $.config.list()
63      const keyOf = (field: string) => rows.find(r => r.key.startsWith('identicon') && r.key.endsWith(`.${field}`))?.key
64      const shapeKey = keyOf('cellShape')
65      const positionKey = keyOf('position')
66      const isLast = SHAPES.indexOf(shape) === SHAPES.length - 1
67      const writes = [shapeKey && $.config.set({ key: shapeKey, value: isLast ? SHAPES[0] : SHAPES[SHAPES.indexOf(shape) + 1] })]
68      if (isLast) {
69        writes.push(positionKey && $.config.set({ key: positionKey, value: isBottom ? 'top' : 'bottom' }))
70      }
71      await Promise.all(writes)
72    }
73
74    // Upper half block: its colour is the top cell, the background the bottom one.
75    // A Button holds one line, so each row is one, all pressing the same.
76    return (
77      <Box flexDirection="column" alignItems="center" justifyContent={isBottom ? 'flex-end' : 'flex-start'} height={height}>
78        {/* Buttons stretch to the row; a Box of the icon's width keeps it centred. */}
79        <Box flexDirection="column" width={width}>
80          {lines.map(([top, bottom], line) => (
81            <Button key={`row-${line}`} label={`Cell shape ${shape}`} plain onPress={step}>
82              {top.map((isOn, col) => (
83                <Text color={isOn ? pic.color : TILE} backgroundColor={bottom[col] ? pic.color : TILE}>
84                  ▀
85                </Text>
86              ))}
87            </Button>
88          ))}
89        </Box>
90      </Box>
91    )
92  })
93}
94
hooks/identicon.ts 70 lines
1import type { Picture } from '../types'
2
3// 8 cells tall, drawn two per character with half blocks: 4 rows.
4export const HEIGHT = 8
5
6// Columns that make the icon square for a cell `shape` times taller than wide.
7export const widthFor = (shape: number): number => Math.round((HEIGHT / 2) * shape)
8
9// The tile behind the icon: the theme's text colour, so black on a light theme
10// and white on a dark one, standing out from the pane either way.
11export const TILE = 'text'
12
13// FNV-1a, then a murmur3 finaliser so nearby seeds spread.
14const hash = (text: string, salt: number): number => {
15  let h = (0x811c9dc5 ^ salt) >>> 0
16  for (let i = 0; i < text.length; i++) {
17    h ^= text.charCodeAt(i)
18    h = Math.imul(h, 0x01000193) >>> 0
19  }
20  h ^= h >>> 16
21  h = Math.imul(h, 0x85ebca6b) >>> 0
22  h ^= h >>> 13
23  h = Math.imul(h, 0xc2b2ae35) >>> 0
24  h ^= h >>> 16
25
26  return h >>> 0
27}
28
29const hslToHex = (hue: number, sat: number, lum: number): string => {
30  const s = sat / 100
31  const l = lum / 100
32  const a = s * Math.min(l, 1 - l)
33  const channel = (n: number) => {
34    const k = (n + hue / 30) % 12
35    const v = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
36    return Math.round(v * 255).toString(16).padStart(2, '0')
37  }
38
39  return `#${channel(0)}${channel(8)}${channel(4)}`
40}
41
42// Windows paths use backslashes; git prints forward slashes everywhere.
43export const lastPart = (path: string): string => path.split(/[\\/]/).filter(Boolean).pop() ?? path
44
45// Same repo over ssh or https, with or without .git, gives the same seed.
46export const normaliseRemote = (url: string): string =>
47  url
48    .trim()
49    .replace(/^[a-z+]+:\/\//i, '')
50    .replace(/^[^@/]+@/, '')
51    .replace(/:(?!\d)/, '/')
52    .replace(/\.git$/, '')
53    .replace(/\/+$/, '')
54    .toLowerCase()
55
56// Left half (centre column included) from the hash and mirrored, one colour, like GitHub's.
57export const identicon = (seed: string, width: number): Picture => {
58  const words = [hash(seed, 0), hash(seed, 2)]
59  const bit = (i: number) => ((words[i >>> 5] >>> (i & 31)) & 1) === 1
60  const tone = hash(seed, 1)
61  const half = Math.ceil(width / 2)
62  const cells = Array.from({ length: HEIGHT }, (_, row) => {
63    const left = Array.from({ length: half }, (_, col) => bit(row * half + col))
64    return [...left, ...left.slice(0, width - half).reverse()]
65  })
66  const color = hslToHex(tone % 360, 65 - ((tone >>> 9) % 20), 62 - ((tone >>> 17) % 16))
67
68  return { seed, color, cells }
69}
70
types/index.d.ts 8 lines
1export type Picture = { seed: string; color: string; cells: boolean[][] }
2
3declare module 'claude-code' {
4  interface PluginState {
5    identicon: { picture: Picture | null }
6  }
7}
8