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

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.
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
Set them on the install screen or in /config.
| Option | Values | Meaning |
|---|---|---|
| Position | top, bottom | Where the icon sits in the pane |
| Cell shape | 2.0, 2.2, 2.4 | Your terminal cell's height as a multiple of its width, so the icon draws square |
hooks/register.tsx 94 lines1import { 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}
94hooks/identicon.ts 70 lines1import 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}
70types/index.d.ts 8 lines1export type Picture = { seed: string; color: string; cells: boolean[][] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 identicon: { picture: Picture | null }
6 }
7}
8