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

My dotfiles for macOS — supports both Apple Silicon (/opt/homebrew) and Intel (/usr/local) Macs.
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 symlinkaerospace/,brew/,CLAUDE.mdand.github/straight into$HOME, bypassing every.stow-local-ignore.
scripts/install.sh to set up dotfiles.scripts/update.sh.cd ~/dotfiles
./scripts/update.sh
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).Reinstall them by running following command:
sudo rm -rf $(xcode-select -print-path)
xcode-select --install
Fix it with following command when it's broken.
sudo chown -R "$(whoami)" "$(brew --prefix)"/*
brew doctor
Homebrew installs to different prefixes depending on architecture:
/opt/homebrew/usr/localScripts 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.
hooks/register.ts 129 lines1// 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}
129hooks/pace.ts 41 lines1// 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}
41hooks/layout.ts 142 lines1// 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}
142hooks/git.ts 35 lines1// 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