SLOPSHOPPER

session-hud

One band above the prompt: rate limits, context, PR checks and Datadog auth

bandguardtoastprocesstimer
v0.2.0no licenseupdated 2026-10-16chroju/dotfiles/dotfiles/claude-mods/session-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-hud
› fix the failing auth test and add an audit log call ⏺ 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 5h ◔ 31% 09:53 ctx ◑ 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ◔ 31% 09:53 ctx ◑ 49%
README

dotfiles

The setting files for my development PC.

Usage

Work with bash and setup_*.sh scripts.

$ mkdir -p ~/dev/src/github/com/chroju
$ cd ~/dev/src/github/com/chroju
$ git clone https://github.com/chroju/dotfiles
$ cd dotfiles

# must execute setup_default.sh with sudo
$ sudo ./setup_default.sh
$ ./setup_symlinks.sh
$ ./setup_homebrew.sh
$ ./setup_gh_extensions.sh

Devcontainer

Other repositories can reuse this dotfiles setup via the devcontainer dotfiles feature.

Prerequisites

Run setup_symlinks.sh on the host first. This installs devcontainer-init to /usr/local/bin/.

Setup

Copy .devcontainer/devcontainer.json from this repository to your project. Change name and add project-specific features as needed. Add "dotfiles.repository": "chroju/dotfiles" to enable the dotfiles feature.

How it works

  • devcontainer-init (host, initializeCommand): Sets up SSH agent forwarding via Podman VM and writes GH_TOKEN / SSH_AUTH_SOCK to .devcontainer/.env.devcontainer.
  • install.sh (container, dotfiles feature): Creates symlinks from dotfiles/HOME/ to $HOME, installs Claude Code and mise, configures SSH agent socket.
  • Mounts share host credentials (SSH, AWS, Claude Code sessions/settings) into the container. Claude Code credentials and config use devcontainer-specific files to keep authentication separate.
Source 2 files
hooks/register.tsx 191 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Checks, HudView, Limit, Pr } from '../types'
5
6type CheckItem = { status?: string; conclusion?: string; state?: string }
7
8const PR_POLL_MS = 60_000
9const DATADOG_CHECK_MS = 5 * 60_000
10
11const view = atom({ plugin: 'session-hud', key: 'view' } as const, { limits: [], isDatadogOut: false } as HudView)
12
13const pad = (n: number) => String(n).padStart(2, '0')
14
15export const pie = (percent: number): string =>
16  percent < 12.5 ? '○' : percent < 37.5 ? '◔' : percent < 62.5 ? '◑' : percent < 87.5 ? '◕' : '●'
17
18export const colorOf = (percent: number) => (percent >= 90 ? 'red' : percent >= 70 ? 'yellow' : 'green')
19
20export const summarizeChecks = (items: readonly CheckItem[]): Checks => {
21  const checks = { pass: 0, fail: 0, pending: 0 }
22  for (const item of items) {
23    const verdict = item.conclusion || item.state || ''
24    if (item.status !== undefined && item.status !== 'COMPLETED') checks.pending += 1
25    else if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(verdict)) checks.pass += 1
26    else if (['PENDING', 'EXPECTED', ''].includes(verdict)) checks.pending += 1
27    else checks.fail += 1
28  }
29  return checks
30}
31
32export const describePr = ({ number, checks }: Pr): { text: string; color?: string } => {
33  const total = checks.pass + checks.fail + checks.pending
34  if (total === 0) return { text: `PR #${number}` }
35  if (checks.fail > 0) return { text: `PR #${number} ✗ ${checks.fail} failed`, color: 'red' }
36  if (checks.pending > 0) return { text: `PR #${number} ⏳ ${checks.pass}/${total}`, color: 'yellow' }
37  return { text: `PR #${number} ✓`, color: 'green' }
38}
39
40export const resetTime = (l: Limit): string | undefined => {
41  if (l.resetsAt === undefined) return undefined
42  const d = new Date(l.resetsAt)
43  if (Number.isNaN(d.getTime())) return undefined
44  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
45  return l.kind === 'five_hour' ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`
46}
47
48const SVG_COLORS: Record<string, string> = { green: '#3fb950', yellow: '#d29922', red: '#f85149' }
49
50// A pie drawn as SVG, for surfaces whose fonts draw ○◔◑◕● at different sizes
51export const pieSvg = (percent: number): string => {
52  const p = Math.min(Math.max(percent, 0), 100) / 100
53  const fill = SVG_COLORS[colorOf(percent)]
54  const r = 6
55  const c = 7
56  const track = `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${fill}" stroke-width="1.5"/>`
57  let slice = ''
58  if (p >= 1) {
59    slice = `<circle cx="${c}" cy="${c}" r="${r}" fill="${fill}"/>`
60  } else if (p > 0) {
61    const angle = 2 * Math.PI * p
62    const x = (c + r * Math.sin(angle)).toFixed(2)
63    const y = (c - r * Math.cos(angle)).toFixed(2)
64    slice = `<path d="M${c} ${c} L${c} ${c - r} A${r} ${r} 0 ${p > 0.5 ? 1 : 0} 1 ${x} ${y} Z" fill="${fill}"/>`
65  }
66  return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">${track}${slice}</svg>`
67}
68
69const label = (kind: string) => (kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind)
70
71// Work that is not drawn: kept in the module, started over on a reload
72type Runtime = { poll?: { cancel: () => void }; datadogCheckedAt?: number }
73
74async function setUsage($: EngineInterface, limits: readonly SessionRateLimit[], percent?: number) {
75  await update($, view, v => ({ ...v, limits: [...limits], contextPercent: percent }))
76}
77
78async function refreshPr($: EngineInterface, rt: Runtime) {
79  const before = (await read($, view)).pr
80  let pr: Pr | undefined
81  try {
82    const r = await $.process.run(['gh', 'pr', 'view', '--json', 'number,url,statusCheckRollup'], { timeoutMs: 15_000 })
83    if (r.exitCode === 0) {
84      const json = JSON.parse(r.stdout) as { number: number; url: string; statusCheckRollup?: CheckItem[] }
85      pr = { number: json.number, url: json.url, checks: summarizeChecks(json.statusCheckRollup ?? []) }
86    }
87  } catch {
88    pr = undefined
89  }
90  await update($, view, v => ({ ...v, pr }))
91
92  const wasPending = before !== undefined && before.number === pr?.number && before.checks.pending > 0
93  if (wasPending && pr !== undefined && pr.checks.pending === 0) {
94    $.ui.toast(pr.checks.fail > 0 ? `PR #${pr.number}: CI failed (${pr.checks.fail})` : `PR #${pr.number}: CI passed`)
95  }
96
97  const shouldPoll = pr !== undefined && pr.checks.pending > 0
98  if (shouldPoll && rt.poll === undefined) {
99    rt.poll = $.clock.every(PR_POLL_MS, () => refreshPr($, rt))
100  } else if (!shouldPoll && rt.poll !== undefined) {
101    rt.poll.cancel()
102    rt.poll = undefined
103  }
104}
105
106async function checkDatadog($: EngineInterface, rt: Runtime) {
107  const now = await $.clock.now()
108  const isOut = (await read($, view)).isDatadogOut
109  if (rt.datadogCheckedAt !== undefined && now - rt.datadogCheckedAt < DATADOG_CHECK_MS && !isOut) return
110  rt.datadogCheckedAt = now
111  try {
112    const r = await $.process.run(['pup', 'auth', 'status', '--site', 'datadoghq.com'], { timeoutMs: 15_000 })
113    const json = JSON.parse(r.stdout.slice(r.stdout.indexOf('{'))) as { authenticated?: boolean }
114    const isNowOut = json.authenticated !== true
115    if (isNowOut && !isOut) $.ui.toast('Datadog: not authenticated (pup auth login)')
116    await update($, view, v => ({ ...v, isDatadogOut: isNowOut }))
117  } catch {
118    // pup missing or unreadable output: say nothing rather than guess
119  }
120}
121
122export const register: Register = on => {
123  const rt: Runtime = {}
124
125  on('session.start', async ($, e, next) => {
126    const result = await next(e)
127    const usage = await $.session.usage()
128    await setUsage($, usage.rateLimits, usage.context.percent)
129    void refreshPr($, rt)
130    return result
131  })
132
133  on('session.measure', async ($, e, next) => {
134    await setUsage($, e.rateLimits, e.context.percent)
135    return next(e)
136  })
137
138  on('turn.complete', async ($, e, next) => {
139    const result = await next(e)
140    void refreshPr($, rt)
141    return result
142  })
143
144  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
145    if (/^\s*pup\s/.test(e.command) && !/^\s*pup\s+auth\b/.test(e.command)) await checkDatadog($, rt)
146    return next(e)
147  }).catch(($, e, next) => next(e))
148
149  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
150    const v = await read($, view)
151    const hasAny = v.limits.length > 0 || v.contextPercent !== undefined || v.pr || v.isDatadogOut
152    if (e.props.hasSurvey || !hasAny) return next(e)
153
154    const elements = $.ui.resolve(e)
155    const { Box, Text, Link } = elements
156    const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined
157    const meter = (key: string, name: string, percent: number, extra?: string) => (
158      <Box key={key} flexDirection="row" columnGap={1} alignItems="center">
159        <Text bold>{name}</Text>
160        {Svg ? (
161          <Svg source={pieSvg(percent)} alt={`${Math.round(percent)}%`} width={14} height={14} />
162        ) : (
163          <Text color={colorOf(percent)}>{pie(percent)}</Text>
164        )}
165        <Text color={colorOf(percent)}>{`${Math.round(percent)}%`}</Text>
166        {extra ? <Text dimColor>{extra}</Text> : null}
167      </Box>
168    )
169    const pr = v.pr ? describePr(v.pr) : undefined
170
171    return (
172      <Box key="session-hud" flexDirection="row" flexWrap="wrap" columnGap={3}>
173        {v.limits.map(l => meter(`limit-${l.kind}`, label(l.kind), l.percentUsed, resetTime(l)))}
174        {v.contextPercent !== undefined ? meter('context', 'ctx', v.contextPercent) : null}
175        {v.pr && pr ? (
176          <Box key="pr">
177            <Link href={v.pr.url}>
178              <Text color={pr.color}>{pr.text}</Text>
179            </Link>
180          </Box>
181        ) : null}
182        {v.isDatadogOut ? (
183          <Box key="datadog">
184            <Text color="red">dd ✗ pup auth login</Text>
185          </Box>
186        ) : null}
187      </Box>
188    )
189  })
190}
191
types/index.d.ts 16 lines
1export type Checks = { pass: number; fail: number; pending: number }
2export type Pr = { number: number; url: string; checks: Checks }
3export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
4export type HudView = {
5  limits: Limit[]
6  contextPercent?: number
7  pr?: Pr
8  isDatadogOut: boolean
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'session-hud': { view: HudView }
14  }
15}
16