SLOPSHOPPER

jr-visitors

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

newpanebandcommandpromptnetwork
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-visitors
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-visitors
│ ┃ jr-visitors ✕ › fix the failing auth test and add an audit log call │ ┃ Asking PostHog… │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /jrvisitors │ ⎿ jr-visitors: jr visitors · set the PostHog project id and a pers │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr-visitors
Asking PostHog…
README

jr-visitors

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 /
  • Now counts unique people with a page view in the last 5 minutes; today since midnight in your PostHog project's time zone.
  • The band refreshes every 2 minutes; /jrvisitors details refreshes everything when you open it.

Setup

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.

Commands

Command
/jrvisitorsHide or show the band (it toggles); /jrvisitors hide and show set it
/jrvisitors detailsVisitors now, today, and today's top pages

Settings (/config)

OptionDefault
PostHog hostusus, eu, or a self-hosted URL
Project idYour project's number
Personal API keyQuery Read scope
Show the bandonVisitors above the prompt

Install

/plugin install jr-visitors --marketplace jrlprost/claude-code-mods

Access

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.

Source 6 files
hooks/register.tsx 165 lines
1import { 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}
165
hooks/jr/draw.tsx 35 lines
1// 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)
35
hooks/jr/gradient.ts 75 lines
1// 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}
75
hooks/posthog.ts 33 lines
1import { 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}
33
hooks/jr/text.ts 13 lines
1// 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}
13
types/index.d.ts 16 lines
1export 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