Live visitors from PostHog above the prompt (last 5 minutes and today), refreshed every 2 minutes; /jrvisitors adds today's top pages.

Who is on your site right now, from PostHog, above the prompt.
visitors 3 now · 118 today
One quiet row, right-aligned, out of the way of your eye.
/jrvisitors details adds today's top pages:
jr visitors updated 14:05
● 3 118
last 5 min today
TOP PAGES TODAY
████████████ 40 /pricing
████········ 12 /
/jrvisitors details refreshes everything when you open it.In PostHog, create a personal API key (Settings → Personal API keys) with the Query Read scope. In /config under jr-visitors, set the host (us, eu, or your own), the project id (the number in /project/12345), and the key. The key is stored as a secret.
| Command | |
|---|---|
/jrvisitors | Hide or show the band (it toggles); /jrvisitors hide and show set it |
/jrvisitors details | Visitors now, today, and today's top pages |
/config)| Option | Default | |
|---|---|---|
| PostHog host | us | us, eu, or a self-hosted URL |
| Project id | Your project's number | |
| Personal API key | Query Read scope | |
| Show the band | on | Visitors above the prompt |
/plugin install jr-visitors --marketplace jrlprost/claude-code-mods
Sends three read-only HogQL queries to your PostHog host with your personal API key. Keeps the figures in memory for the session. Writes nothing, runs nothing.
hooks/register.tsx 165 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { gauge, gradientText } from './jr/draw'
5import { GRADIENTS } from './jr/gradient'
6import { body, countOf, pagesOf, QUERIES, queryUrl } from './posthog'
7
8const PANE = 'jr-visitors'
9const REFRESH_MS = 2 * 60_000
10const figures = atom({ plugin: 'jr-visitors', key: 'figures' } as const, null)
11const bandHidden = atom({ plugin: 'jr-visitors', key: 'isBandHidden' } as const, false)
12
13// The band can be hidden: /jrvisitors hide, show, or the bare command to toggle. One choice for
14// every session, kept across restarts.
15const band = { isLoaded: false }
16
17const bandVerb = (args: string) => {
18 const arg = args.trim().toLowerCase()
19 if (arg === 'hide' || arg === 'off') return 'hide' as const
20 if (arg === 'show' || arg === 'on') return 'show' as const
21 return arg === '' ? ('toggle' as const) : null
22}
23
24async function isBandHidden($: EngineInterface) {
25 if (!band.isLoaded) {
26 band.isLoaded = true
27 if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
28 }
29 return read($, bandHidden)
30}
31
32async function setBand($: EngineInterface, verb: 'hide' | 'show' | 'toggle') {
33 const hide = verb === 'toggle' ? !(await isBandHidden($)) : verb === 'hide'
34 await $.store.set('isBandHidden', hide)
35 await update($, bandHidden, () => hide)
36 return { text: hide ? 'jr visitors · band hidden (/jrvisitors to bring it back)' : 'jr visitors · band shown' }
37}
38const live = { isStarted: false, host: '', projectId: '', key: '' }
39
40async function ask($: EngineInterface, query: string) {
41 const res = await $.http.fetch(queryUrl(live.host, live.projectId), {
42 method: 'POST',
43 headers: { Authorization: `Bearer ${live.key}`, 'Content-Type': 'application/json' },
44 body: body(query),
45 })
46 if (!res.ok) throw new Error(`PostHog answered ${res.status}`)
47 return res.text
48}
49
50async function refresh($: EngineInterface, withPages: boolean) {
51 if (!live.key || !live.projectId) return
52 const at = await $.clock.now()
53 try {
54 const [now, today] = await Promise.all([ask($, QUERIES.now), ask($, QUERIES.today)])
55 const pages = withPages ? pagesOf(await ask($, QUERIES.pages)) : ((await read($, figures))?.pages ?? [])
56 await update($, figures, () => ({ now: countOf(now), today: countOf(today), pages, at, error: null }))
57 } catch (error) {
58 await update($, figures, prev => ({ ...(prev ?? { now: 0, today: 0, pages: [], at }), at, error: String(error).slice(0, 120) }))
59 }
60}
61
62async function ensureStarted($: EngineInterface) {
63 if (live.isStarted) return
64 live.isStarted = true
65 await $.command.register({ name: 'jrvisitors', description: 'Show or hide the visitors band; /jrvisitors details opens visitors now, today and top pages' })
66 if (!live.key || !live.projectId) return
67 await refresh($, false)
68 $.clock.every(REFRESH_MS, () => {
69 void refresh($, false)
70 })
71}
72
73export const register: Register = (on, options) => {
74 live.host = String(options.host ?? 'us')
75 live.projectId = String(options.projectId ?? '')
76 live.key = String(options.apiKey ?? '')
77 const showBand = options.showBand !== false
78
79 on('session.start', async ($, e, next) => {
80 const started = await next(e)
81 await ensureStarted($)
82 return started
83 })
84
85 on('prompt.submit', async ($, e, next) => {
86 await ensureStarted($)
87 return next(e)
88 })
89
90 on('command.run', { command: 'jrvisitors' }, async ($, e) => {
91 await ensureStarted($)
92 const verb = bandVerb(e.args)
93 // Not set up yet: the bare command says how, instead of toggling a band that cannot show.
94 if (verb && (live.key && live.projectId || verb !== 'toggle')) return setBand($, verb)
95 if (!live.key || !live.projectId) return { text: 'jr visitors · set the PostHog project id and a personal API key (Query Read) in /config under jr-visitors.' }
96 await refresh($, true)
97 await $.ui.open({ id: PANE, title: 'jr visitors', rows: 14 })
98 return { text: 'jr visitors opened.' }
99 })
100
101 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
102 const { Box, Text } = $.ui.resolve(e)
103 const f = await read($, figures)
104 if (!f) return <Text dimColor>Asking PostHog…</Text>
105 const most = Math.max(1, ...f.pages.map(p => p.views))
106 return (
107 <Box flexDirection="column">
108 <Text>
109 {gradientText(Text, 'jr visitors', GRADIENTS.ice)}
110 <Text dimColor>{` updated ${new Date(f.at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}`}</Text>
111 </Text>
112 {f.error && <Text color="#ef4565">{f.error}</Text>}
113 <Box columnGap={4}>
114 <Box flexDirection="column">
115 <Text bold color="#2cb67d">{`● ${f.now}`}</Text>
116 <Text dimColor>last 5 min</Text>
117 </Box>
118 <Box flexDirection="column">
119 <Text bold>{String(f.today)}</Text>
120 <Text dimColor>today</Text>
121 </Box>
122 </Box>
123 <Text dimColor>TOP PAGES TODAY</Text>
124 {f.pages.length === 0 && <Text dimColor>No page views yet today.</Text>}
125 {f.pages.map(p => (
126 <Text wrap="truncate-end">
127 {gauge(Text, p.views / most, 12, GRADIENTS.ice)}
128 <Text bold>{` ${String(p.views).padStart(5)} `}</Text>
129 <Text>{p.page}</Text>
130 </Text>
131 ))}
132 </Box>
133 )
134 })
135
136 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
137 const below = await next(e)
138 // Never write state while drawing: the saved choice is loaded just after.
139 if (!band.isLoaded) {
140 $.clock.after(0, () => {
141 void isBandHidden($)
142 })
143 }
144 if (await read($, bandHidden)) return below
145 const f = await read($, figures)
146 if (!showBand || !f || e.props.hasSurvey) return below
147 const { Box, Text } = $.ui.resolve(e)
148 return (
149 <Box flexDirection="column">
150 <Box justifyContent="flex-end">
151 <Text wrap="truncate-end">
152 <Text dimColor>{'visitors '}</Text>
153 <Text color={f.now > 0 ? '#2cb67d' : undefined}>{String(f.now)}</Text>
154 <Text dimColor>{' now · '}</Text>
155 <Text>{String(f.today)}</Text>
156 <Text dimColor>{' today'}</Text>
157 {f.error && <Text color="#ef4565">{' · PostHog unreachable'}</Text>}
158 </Text>
159 </Box>
160 {below}
161 </Box>
162 )
163 })
164}
165hooks/jr/draw.tsx 35 lines1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10 <Text bold={bold}>
11 {paint(text, stops).map(({ char, color }) => (
12 <Text color={color}>{char}</Text>
13 ))}
14 </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19 <Text>
20 {bar(ratio, width, stops).map(cell =>
21 cell.empty ? (
22 <Text color={cell.color} dimColor>
23 {cell.char}
24 </Text>
25 ) : (
26 <Text color={cell.color}>{cell.char}</Text>
27 ),
28 )}
29 </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34 gradientText(Text, `${label} `, stops)
35hooks/jr/gradient.ts 75 lines1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7 const n = Number.parseInt(hex.replace('#', ''), 16)
8 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12 `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15 // The jr signature: violet to pink to amber.
16 brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17 // Calm to alarm, for gauges where full is bad.
18 heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19 // Cool, for neutral figures.
20 ice: ['#3da9fc', '#7f5af0'],
21 // Money and growth.
22 money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29 if (stops.length === 1) return stops[0]!
30 const clamped = Math.min(1, Math.max(0, t))
31 const span = clamped * (stops.length - 1)
32 const i = Math.min(stops.length - 2, Math.floor(span))
33 const a = hexToRgb(stops[i]!)
34 const b = hexToRgb(stops[i + 1]!)
35 const local = span - i
36 return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41 const chars = [...text]
42 return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53 const filled = Math.min(1, Math.max(0, ratio)) * width
54 const full = Math.floor(filled)
55 const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56 const cells: { char: string; color: string; empty?: true }[] = []
57 for (let i = 0; i < width; i++) {
58 const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59 if (i < full) cells.push({ char: '█', color })
60 else if (i === full && partial) cells.push({ char: partial, color })
61 else cells.push({ char: '·', color, empty: true })
62 }
63 return cells
64}
65
66export const formatTokens = (n: number) =>
67 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70 const minutes = Math.max(0, Math.round(ms / 60_000))
71 const h = Math.floor(minutes / 60)
72 const m = minutes % 60
73 return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75hooks/posthog.ts 33 lines1import { printable } from './jr/text'
2// HogQL queries and how their answers become numbers.
3
4export const QUERIES = {
5 now: "SELECT count(DISTINCT person_id) FROM events WHERE event = '$pageview' AND timestamp > now() - INTERVAL 5 MINUTE",
6 today: "SELECT count(DISTINCT person_id) FROM events WHERE event = '$pageview' AND timestamp >= toStartOfDay(now())",
7 pages:
8 "SELECT properties.$pathname AS page, count() AS views FROM events WHERE event = '$pageview' AND timestamp >= toStartOfDay(now()) GROUP BY page ORDER BY views DESC LIMIT 8",
9} as const
10
11/** `https://eu.posthog.com/` → `https://eu.posthog.com`; a bare region name works too. */
12export const hostOf = (raw: string) => {
13 const v = raw.trim().toLowerCase()
14 if (v === '' || v === 'us') return 'https://us.posthog.com'
15 if (v === 'eu') return 'https://eu.posthog.com'
16 return (v.startsWith('http') ? v : `https://${v}`).replace(/\/+$/, '')
17}
18
19export const queryUrl = (host: string, projectId: string) => `${hostOf(host)}/api/projects/${encodeURIComponent(projectId.trim())}/query/`
20
21export const body = (query: string) => JSON.stringify({ query: { kind: 'HogQLQuery', query }, name: 'jr-visitors' })
22
23/** First cell of the first row, as a number. */
24export const countOf = (text: string) => {
25 const parsed = JSON.parse(text) as { results?: unknown[][] }
26 return Number(parsed.results?.[0]?.[0] ?? 0) || 0
27}
28
29export const pagesOf = (text: string) => {
30 const parsed = JSON.parse(text) as { results?: unknown[][] }
31 return (parsed.results ?? []).map(row => ({ page: printable(String(row[0] ?? '(none)') || '/', 120), views: Number(row[1] ?? 0) || 0 }))
32}
33hooks/jr/text.ts 13 lines1// Text from outside (an API, a status page, a cloned repository) is drawn only once it is
2// printable: control characters, escape sequences included, become a visible "�" so nothing
3// can move the cursor, recolour the terminal or hide what follows.
4// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh.
5
6// C0 controls (tab kept), DEL, C1 controls, and bidi overrides that reorder what is shown.
7const UNPRINTABLE = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f--]/g
8
9export const printable = (text: unknown, max = 200) => {
10 const s = String(text ?? '').replace(UNPRINTABLE, '�')
11 return s.length > max ? `${s.slice(0, max - 1)}…` : s
12}
13types/index.d.ts 16 lines1export type JrVisitorsFigures = {
2 /** Unique visitors with a page view in the last 5 minutes. */
3 now: number
4 /** Unique visitors with a page view since midnight (project time zone). */
5 today: number
6 pages: { page: string; views: number }[]
7 at: number
8 error: string | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'jr-visitors': { figures: JrVisitorsFigures | null; isBandHidden: boolean }
14 }
15}
16