SLOPSHOPPER

usage-band

Context and rate-limit usage, coloured by pace, above the prompt

newbandguardprocesstimer
v0.1.0no licenseupdated 2026-10-090inp/dotfiles/claude-mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 /work/app claude-opus-5-5 ▓▓▓▓░░░░░░ 49% 5h 31% ↻NaNj ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
/work/app claude-opus-5-5 ▓▓▓▓░░░░░░ 49% 5h 31% ↻NaNj ⟨Claude Code's own drawing⟩
README

Dotfiles

My dotfiles for macOS — supports both Apple Silicon (/opt/homebrew) and Intel (/usr/local) Macs.

Installation

Make sure you have git and stow installed.

brew install git stow
git clone https://github.com/0inp/dotfiles.git ~/dotfiles
cd ~/dotfiles
chmod +x ./scripts/install.sh && ./scripts/install.sh

install.sh handles Homebrew, the Brewfile, macOS defaults and stowing. If you ever need to run the pieces by hand:

brew bundle --global   # HOMEBREW_BUNDLE_FILE_GLOBAL points at this repo
stow -t ~ */           # one package per module directory

Never run stow -t ~ . — that names the repo root as a single package and would symlink aerospace/, brew/, CLAUDE.md and .github/ straight into $HOME, bypassing every .stow-local-ignore.

Quick Start

  1. Run scripts/install.sh to set up dotfiles.
  2. Update with scripts/update.sh.

Update

cd ~/dotfiles
./scripts/update.sh

Directory Structure

Each top-level directory is a GNU Stow package whose contents are symlinked into ~. See CONTEXT_MAP.md for the full module-to-target table, and the CONTEXT.md inside each module for tool-specific detail.

Shell & terminal

  • zsh/: Zsh configuration (.zshenv, .zprofile, .zshrc, .config/zsh/).
  • ghostty/: Ghostty terminal emulator.
  • herdr/: Agent-aware terminal multiplexer (replaced tmux).
  • aerospace/: Tiling window manager.

Development

  • git/, lazygit/, gh-dash/: Git config, TUI, and PR dashboard.
  • nvim/: Neovim, using the built-in vim.pack manager.
  • mise/: Runtime version pinning (Node, Python, Go).
  • worktrunk/: Worktree/workspace manager for parallel agent sessions.
  • ripgrep/, pgcli/, python/, btop/: Per-tool configuration.

AI agents

  • claude/: Claude Code settings.
  • agents/: Cross-tool agent skills, shared via ~/.agents.
  • vibe/: Mistral Vibe CLI.
  • docs/agents/: Agent skills and domain documentation.

System

  • brew/: The Brewfile (packages and casks).
  • fnox/: Secret references — values live in the macOS keychain.
  • gnupg/: GPG configuration.
  • scripts/: Custom scripts, symlinked into ~/.local/bin.
  • resources/: Helper scripts run by the installer (not stowed).

Misc

Command Line Tools (macOS)

Reinstall them by running following command:

sudo rm -rf $(xcode-select -print-path)
xcode-select --install

Brew

Fix it with following command when it's broken.

sudo chown -R "$(whoami)" "$(brew --prefix)"/*
brew doctor

Apple Silicon vs Intel

Homebrew installs to different prefixes depending on architecture:

  • Apple Silicon: /opt/homebrew
  • Intel: /usr/local

Scripts and shell configs use $(brew --prefix) or branch on [[ -d /opt/homebrew ]] to stay portable across both. PATH is built in zsh/.config/zsh/lib/path.zsh, which sets HOMEBREW_PREFIX by branching on the directory rather than shelling out to brew --prefix on every shell start.

Source 4 files
hooks/register.ts 129 lines
1// usage-band: the status line, redrawn as a mod in the band above the prompt.
2// Usage figures are pushed by session.measure; git is polled with a 5 s cache,
3// like the script this replaces; a 60 s clock keeps pace colours and
4// countdowns moving between requests, since the budget grows with time alone.
5
6import type { EngineInterface as Api, On } from 'claude-code'
7import { contextLevel, paceLevel, type WindowKind } from './pace.ts'
8import { composeRow1, composeRow2, formatCountdown, type Limit, type Tone } from './layout.ts'
9import { parseStatus, parseWorktree, REVPARSE_ARGV, STATUS_ARGV, type GitInfo } from './git.ts'
10
11// ANSI-16 theme slots only, so the band follows the terminal palette.
12const TONE_COLOR: Record<Tone, string | undefined> = {
13  ok: 'green', warning: 'yellow', danger: 'red', none: 'gray',
14  path: 'blue', worktree: 'magenta', branch: 'yellow', model: 'cyan',
15  dim: 'gray', staged: 'green', modified: 'red', plain: undefined,
16}
17const LABEL: Record<WindowKind, '5h' | '7d'> = { five_hour: '5h', seven_day: '7d' }
18const GIT_TTL_MS = 5_000
19// git must never hold up a prompt: a stuck lock or a huge repo gives up fast.
20const GIT_TIMEOUT = { timeoutMs: 2_000 }
21
22type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
23
24let contextPct: number | undefined
25let limits: RateLimit[] = []
26let model = ''
27let effort: string | undefined
28let cwd = ''
29let home = ''
30let git: GitInfo = { staged: 0, modified: 0 }
31let gitAt = 0
32
33// Top level, not inside register: the validator follows $ only into functions
34// declared at the top of this file.
35const redraw = ($: Api) => $.ui.invalidate('ui.render')
36
37async function refreshGit($: Api, force = false): Promise<void> {
38  const now = await $.clock.now()
39  if (!force && now - gitAt < GIT_TTL_MS) return
40  gitAt = now
41  try {
42    cwd = await $.session.cwd()
43    const [st, rp] = await Promise.all([$.process.run(STATUS_ARGV, GIT_TIMEOUT), $.process.run(REVPARSE_ARGV, GIT_TIMEOUT)])
44    git =
45      st.exitCode === 0
46        ? { ...parseStatus(st.stdout), worktree: rp.exitCode === 0 ? parseWorktree(rp.stdout) : undefined }
47        : { staged: 0, modified: 0 }
48  } catch {
49    git = { staged: 0, modified: 0 }
50  }
51  redraw($)
52}
53
54export function register(on: On): void {
55  on('session.start', async ($, e, next) => {
56    home = (await $.env.get('HOME')) ?? ''
57    cwd = await $.session.cwd()
58    model = await $.session.model()
59    const u = await $.session.usage()
60    contextPct = u.context.percent
61    limits = [...u.rateLimits]
62    void refreshGit($, true)
63    $.clock.every(60_000, () => redraw($))
64    return next(e)
65  })
66
67  on('session.measure', async ($, e, next) => {
68    contextPct = e.context.percent
69    limits = [...e.rateLimits]
70    model = await $.session.model()
71    if (e.changed.includes('context') || e.changed.includes('rateLimits')) redraw($)
72    return next(e)
73  })
74
75  // Main loop only: a subagent's step may run at another effort.
76  on('turn.step', async function* ($, e, next) {
77    if (e.agentId === undefined) {
78      const next_ = e.effort === undefined ? undefined : String(e.effort)
79      if (next_ !== effort) {
80        effort = next_
81        redraw($)
82      }
83    }
84    return yield* next(e)
85  })
86
87  on('turn.complete', async ($, e, next) => {
88    void refreshGit($, true)
89    return next(e)
90  })
91
92  on('tool.call', { tool: ['Bash', 'Edit', 'Write'] }, async ($, e, next) => {
93    const result = await next(e)
94    void refreshGit($)
95    return result
96  })
97
98  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
99    const { Box, Text } = $.ui.resolve(e)
100    const cols = e.props.bodyColumns
101    const now = await $.clock.now()
102
103    const shown: Limit[] = limits
104      .filter((l): l is RateLimit & { kind: WindowKind } => l.kind in LABEL)
105      .map((l) => ({
106        label: LABEL[l.kind],
107        percent: l.percentUsed,
108        level: paceLevel(l.kind, l.percentUsed, l.resetsAt, now),
109        countdown: l.resetsAt ? formatCountdown(Date.parse(l.resetsAt) - now) : undefined,
110      }))
111
112    const rows = [
113      composeRow1({ cwd, home, ...git }, cols),
114      composeRow2({ model, effort, contextPct, contextLevel: contextLevel(contextPct), limits: shown }, cols),
115    ]
116    const ours = rows.map((row) =>
117      Text({
118        wrap: 'truncate-end',
119        children: row.map((s) => {
120          const color = TONE_COLOR[s.tone]
121          return color ? Text({ color, children: [s.text] }) : s.text
122        }),
123      }),
124    )
125    const theirs = await next(e)
126    return Box({ flexDirection: 'column', children: theirs ? [...ours, theirs] : ours })
127  })
128}
129
hooks/pace.ts 41 lines
1// The two colour rules of the usage band. Pure: no mods API, so the specs run
2// under plain node.
3
4export type Level = 'ok' | 'warning' | 'danger' | 'none'
5export type WindowKind = 'five_hour' | 'seven_day'
6
7export const WINDOW_MS: Record<WindowKind, number> = {
8  five_hour: 5 * 3_600_000,
9  seven_day: 7 * 86_400_000,
10}
11
12// Below this much usage a window is never coloured: early in a window the
13// budget is tiny, and one request would otherwise turn it red.
14export const FLOOR: Record<WindowKind, number> = { five_hour: 10, seven_day: 5 }
15
16export function contextLevel(percent: number | undefined): Level {
17  if (percent === undefined) return 'none'
18  if (percent >= 80) return 'danger'
19  if (percent >= 40) return 'warning'
20  return 'ok'
21}
22
23// Compares usage with the share of the window that has already elapsed.
24export function paceLevel(
25  kind: WindowKind,
26  used: number,
27  resetsAt: string | undefined,
28  now: number,
29): Level {
30  if (resetsAt === undefined) return 'none'
31  const reset = Date.parse(resetsAt)
32  if (Number.isNaN(reset)) return 'none'
33  if (used < FLOOR[kind]) return 'ok'
34  const windowMs = WINDOW_MS[kind]
35  const elapsed = Math.min(windowMs, Math.max(0, windowMs - (reset - now)))
36  const budget = (elapsed / windowMs) * 100
37  if (used >= budget) return 'danger'
38  if (used >= 0.8 * budget) return 'warning'
39  return 'ok'
40}
41
hooks/layout.ts 142 lines
1// Builds the band's two rows as tone-tagged segments. A port of the
2// row-building half of claude/.claude/statusline-command.sh; register.ts maps
3// tones to theme colours. Widths count code points, which the glyphs here
4// (… ⑂ ⌂ ▓ ░ ↻) need.
5
6import type { Level } from './pace.ts'
7
8export type Tone =
9  | Level | 'path' | 'worktree' | 'branch' | 'model' | 'dim' | 'staged' | 'modified' | 'plain'
10export type Segment = { text: string; tone: Tone }
11export type Row = Segment[]
12
13const SEP = '  '
14const len = (s: string) => [...s].length
15
16export const width = (row: Row): number => row.reduce((n, s) => n + len(s.text), 0)
17
18// Fit a path into max columns, escalating only as far as needed.
19export function shortenPath(p: string, max: number): string {
20  const m = Math.max(max, 8)
21  if (len(p) <= m) return p
22  const parts = p.split('/')
23  const n = parts.length
24  // 1. Elide the middle, keeping the head and as many trailing parts as fit.
25  for (let keep = n - 1; keep >= 1; keep--) {
26    const cand = [parts[0], '…', ...parts.slice(n - keep)].join('/')
27    if (len(cand) <= m) return cand
28  }
29  // 2. Abbreviate every intermediate component to one character.
30  const abbrev = [parts[0], ...parts.slice(1, n - 1).map((s) => [...s][0] ?? ''), parts[n - 1]].join('/')
31  if (len(abbrev) <= m) return abbrev
32  // 3. Give up and clip the head.
33  return '…' + [...p].slice(-(m - 1)).join('')
34}
35
36function joinParts(parts: Row[]): Row {
37  const out: Row = []
38  parts.forEach((p, k) => {
39    if (k > 0) out.push({ text: SEP, tone: 'plain' })
40    out.push(...p)
41  })
42  return out
43}
44
45export type Row1Input = {
46  cwd: string
47  home: string
48  worktree?: string
49  branch?: string
50  staged: number
51  modified: number
52}
53
54// The path is the only elastic part: under 10 columns it is dropped outright.
55// When chip and branch cannot both fit, the chip goes; the branch never does.
56export function composeRow1(i: Row1Input, cols: number): Row {
57  const shortCwd =
58    i.cwd === i.home ? '~' : i.cwd.startsWith(i.home + '/') ? '~' + i.cwd.slice(i.home.length) : i.cwd
59  const dirty: Row = []
60  if (i.staged > 0) dirty.push({ text: ` +${i.staged}`, tone: 'staged' })
61  if (i.modified > 0) dirty.push({ text: ` ~${i.modified}`, tone: 'modified' })
62
63  const build = (withWorktree: boolean): Row => {
64    const parts: Row[] = []
65    if (withWorktree && i.worktree) parts.push([{ text: `⌂ ${i.worktree}`, tone: 'worktree' }])
66    if (i.branch) parts.push([{ text: `⑂ ${i.branch}`, tone: 'branch' }, ...dirty])
67    const avail = cols - parts.reduce((n, p) => n + SEP.length + width(p), 0)
68    if (avail >= 10 || parts.length === 0) {
69      parts.unshift([{ text: shortenPath(shortCwd, Math.max(avail, 1)), tone: 'path' }])
70    }
71    return joinParts(parts)
72  }
73
74  const full = build(true)
75  return width(full) > cols && i.worktree ? build(false) : full
76}
77
78export function bar(pct: number): string {
79  const w = 10
80  const f = Math.max(0, Math.min(w, Math.floor((pct * w) / 100)))
81  return '▓'.repeat(f) + '░'.repeat(w - f)
82}
83
84export function formatCountdown(ms: number): string {
85  const min = Math.max(0, Math.floor(ms / 60_000))
86  if (min < 60) return `${min}m`
87  const h = Math.floor(min / 60)
88  if (h < 24) return `${h}h${String(min % 60).padStart(2, '0')}`
89  return `${Math.floor(h / 24)}j`
90}
91
92export type Limit = { label: '5h' | '7d'; percent: number; level: Level; countdown?: string }
93
94export type Row2Input = {
95  model: string
96  effort?: string
97  contextPct?: number
98  contextLevel: Level
99  limits: Limit[]
100}
101
102type Want = { countdown: boolean; d7: boolean; h5: boolean; effort: boolean; bar: boolean }
103
104// Shed right-to-left until it fits: countdowns, 7d, 5h, effort, then the bar.
105const SHED: Want[] = [
106  { countdown: true, d7: true, h5: true, effort: true, bar: true },
107  { countdown: false, d7: true, h5: true, effort: true, bar: true },
108  { countdown: false, d7: false, h5: true, effort: true, bar: true },
109  { countdown: false, d7: false, h5: false, effort: true, bar: true },
110  { countdown: false, d7: false, h5: false, effort: false, bar: true },
111  { countdown: false, d7: false, h5: false, effort: false, bar: false },
112]
113
114function compose(i: Row2Input, w: Want): Row {
115  const row: Row = [{ text: i.model, tone: 'model' }]
116  if (w.effort && i.effort) row.push({ text: ` · ${i.effort}`, tone: 'dim' })
117  if (i.contextPct !== undefined) {
118    const pct = Math.round(i.contextPct)
119    row.push({ text: SEP, tone: 'plain' })
120    if (w.bar) row.push({ text: bar(pct), tone: i.contextLevel }, { text: ' ', tone: 'plain' })
121    row.push({ text: `${pct}%`, tone: i.contextLevel })
122  }
123  for (const l of i.limits) {
124    if (!(l.label === '5h' ? w.h5 : w.d7)) continue
125    row.push(
126      { text: SEP, tone: 'plain' },
127      { text: `${l.label} `, tone: 'dim' },
128      { text: `${Math.round(l.percent)}%`, tone: l.level },
129    )
130    if (w.countdown && l.countdown) row.push({ text: ` ↻${l.countdown}`, tone: 'dim' })
131  }
132  return row
133}
134
135export function composeRow2(i: Row2Input, cols: number): Row {
136  for (const w of SHED) {
137    const row = compose(i, w)
138    if (width(row) <= cols) return row
139  }
140  return [{ text: [...i.model].slice(0, Math.max(0, cols)).join(''), tone: 'model' }]
141}
142
hooks/git.ts 35 lines
1// Parses the two git calls the band makes. The mod runs them through
2// $.process.run; parsing stays here so it is testable without a session.
3
4export type GitInfo = { branch?: string; worktree?: string; staged: number; modified: number }
5
6// One `status --porcelain=v2 --branch` yields branch *and* dirty counts.
7export const STATUS_ARGV = ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'] as const
8export const REVPARSE_ARGV = [
9  'git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir',
10] as const
11
12export function parseStatus(stdout: string): Omit<GitInfo, 'worktree'> {
13  let oid = ''
14  let head: string | undefined
15  let staged = 0
16  let modified = 0
17  for (const line of stdout.split('\n')) {
18    if (line.startsWith('# branch.oid ')) oid = line.slice(13).slice(0, 7)
19    else if (line.startsWith('# branch.head ')) head = line.slice(14)
20    else if (line.startsWith('1 ') || line.startsWith('2 ')) {
21      const xy = line.slice(2, 4)
22      if (xy[0] !== '.') staged++
23      if (xy[1] !== '.') modified++
24    }
25  }
26  return { branch: head === '(detached)' ? oid : head, staged, modified }
27}
28
29// In a linked worktree, --git-dir points into the main repo's .git/worktrees/.
30export function parseWorktree(stdout: string): string | undefined {
31  const [top, gitDir, commonDir] = stdout.trim().split('\n')
32  if (!top || !gitDir || !commonDir || gitDir === commonDir) return undefined
33  return top.split('/').pop()
34}
35