Show 5h / 1w rate limits and this session's task progress above the prompt

Reproducible macOS dotfiles for Apple Silicon.
Built with Nix, nix-darwin, Home Manager, Homebrew, and Fish.
| Area | Location |
|---|---|
| System and packages | nix/ |
| User and application configuration | config/ |
| Menu bar helpers | menubar-script/ |
| Small utilities | script/ |
Most managed files use Home Manager out-of-store symlinks into the checkout, so edits take effect immediately. Run just switch after changing Nix packages, Homebrew applications, macOS defaults, or the set of managed files.
Install anything missing:
xcode-select --install
curl -sSfL https://artifacts.nixos.org/nix-installer | sh -s -- install --enable-flakes
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Restart the shell after installing Nix so that nix is available.
Clone the repository and point $HOME/.dotfiles at the checkout:
git clone https://github.com/luvpame/dotfiles.git ~/src/dotfiles
cd ~/src/dotfiles
if [ -e "$HOME/.dotfiles" ] && [ ! -L "$HOME/.dotfiles" ]; then
echo "$HOME/.dotfiles is not a symlink; refusing to overwrite it." >&2
exit 1
fi
ln -shf "$(pwd -P)" "$HOME/.dotfiles"
Set dotfiles.user.name in nix/nix-darwin/users.nix. This is the single user name used by the configuration.
Create the local Git configuration from config/git/config.local.example, then fill in your identity:
test -f config/git/config.local || cp config/git/config.local.example config/git/config.local
$EDITOR config/git/config.local
Set user.name, user.email, and ghq.root. Home Manager exposes this file as ~/.config/git/config.local, which the managed Git config includes. Keep it out of version control. The shared Git configuration uses Secure Enclave for commit signing, so complete the Git commit signing setup below before committing. Keep the 1Password SSH Agent enabled for push and pull.
The repository includes a repeatable setup wizard for moving commit signing to a non-exportable Secure Enclave key. Run it from a normal interactive Terminal session on each Mac. sc_auth and CryptoTokenKit may not be available from an agent or another non-interactive process.
The wizard creates or validates a p-256-ne CTK identity with -t none, creates the SSH key handle pair at ~/.ssh/id_git_sign and ~/.ssh/id_git_sign.pub when needed, and runs a temporary signed commit verification. The private key never leaves the Secure Enclave, while the reference files remain device-specific.
OpenSSH may show a generic authenticator PIN prompt even for this key. The wizard answers it with an empty input; if a PIN or password is requested interactively, enter nothing and stop the wizard.
The default command performs the local setup and self-test, then prints the exact GitHub command without changing the GitHub account:
./script/setup-git-signing.sh
To let the wizard check the GitHub signing-key list and register the public key after the self-test and an explicit confirmation, use:
gh auth login
# Run this once before the first registration (or when the wizard reports a missing scope).
gh auth refresh -h github.com -s admin:ssh_signing_key
./script/setup-git-signing.sh --register-github
The wizard checks that scope before it changes GitHub. If the check reports a network or other API error, it stops without treating that error as a scope problem.
The GitHub key title uses git-sign@<short-hostname> so multiple Macs can be distinguished. If the hostname is unavailable, the title falls back to git-sign; repeated-run detection compares the public-key contents instead of the title.
The shared Git configuration now uses Secure Enclave for commit signing. On an existing Mac, run the wizard before relying on the new configuration. With --register-github, it checks GitHub API access during preflight, completes the local self-test, and then registers the key after confirmation. After the new key produces a Verified commit on GitHub, remove only the old 1Password Signing key from GitHub. Keep the 1Password SSH Agent and its authentication key for push and pull.
This migration changes commit signing only. Tag signing is unchanged. -t none deliberately avoids Touch ID or passcode prompts so an unattended Coding Agent can finish a commit; any process running as the logged-in user can therefore request a signature. Secure Enclave storage protects the private key from export, but it does not protect against malware already running in that account.
The wizard does not create a permanent allowedSignersFile. Its verification file and temporary Git repository are removed after the self-test; GitHub's Verified status is the ongoing verification record.
Sign in to the Mac App Store before switching; mas installs App Store applications as part of the configuration.
Apply the initial system configuration. just is installed by Home Manager during this step, so it is not required yet:
cd nix
sudo -H nix --extra-experimental-features "nix-command flakes" \
run github:LnL7/nix-darwin -- switch --flake "path:.#default"
cd ..
| Command | Purpose |
|---|---|
just check | Validate the flake |
just build | Build the Darwin system |
just switch | Apply the configuration |
just clean | Remove old Nix generations |
just switch updates $HOME/.dotfiles to the physical path of the current checkout before applying changes, so clone locations and Git worktrees are supported.
Set Fish as the default shell after the first switch:
./script/set-fish-default.sh
Log out and back in, or start a new Fish session, then update plugins:
fish
fisher update
GitHub Actions checks and prebuilds darwinConfigurations.default.system for aarch64-darwin on macos-15, then publishes the result to the public luvpame Cachix cache. Add a repository secret named CACHIX_AUTH_TOKEN with permission to push to Cachix. The flake and nix-darwin configuration already declare luvpame.cachix.org, so local builds can use the same cache.
Pushes to main that change nix/** or this workflow build the current flake.lock. A schedule runs at 17 minutes past every hour (UTC), updates flake inputs, and builds only when flake.lock changes. A successful update commits the new lock file to main. Manual runs on main can enable force_build to rebuild the current lock even when it is unchanged.
Pull CI's updated nix/flake.lock before running just switch to use the prebuilt inputs. just us updates inputs locally and switches immediately, so its result may differ from the CI build.
After switching, sign in to services that need authentication:
gh auth loginhooks/register.tsx 178 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Jobs, Limit } from '../types'
5
6const limits = atom({ plugin: 'usage-band', key: 'limits' } as const, {})
7const jobs = atom({ plugin: 'usage-band', key: 'jobs' } as const, { running: 0, done: 0 })
8// サブエージェントは $.agent.list() で数え、それ以外のバックグラウンド作業はツール呼び出しから拾う
9const BG_TOOLS = ['Bash', 'Monitor', 'Workflow']
10const isBackground = (tool: string, input: Record<string, unknown>) =>
11 tool === 'Bash' ? input.run_in_background === true : BG_TOOLS.includes(tool)
12
13async function refreshJobs($: EngineInterface) {
14 const started: string[] = []
15 const ended = new Set<string>()
16 for (const m of await $.session.messages()) {
17 for (const u of m.toolUses) if (isBackground(u.tool, u.input) && !u.isError) started.push(u.tool_use_id)
18 // 終了したバックグラウンド作業は <task-notification> の <tool-use-id> で届く
19 if (m.role === 'user') for (const x of m.text.matchAll(/<tool-use-id>([^<]+)<\/tool-use-id>/g)) ended.add(x[1]!)
20 }
21 const agents = (await $.agent.list()).filter(a => !a.parentId)
22 const agentsRunning = agents.filter(a => a.status === 'running').length
23 const shellsRunning = started.filter(id => !ended.has(id)).length
24 await update($, jobs, () => ({
25 running: shellsRunning + agentsRunning,
26 done: started.length - shellsRunning + agents.length - agentsRunning,
27 }))
28}
29
30const setLimits = ($: EngineInterface, rl: SessionRateLimit[]) =>
31 update($, limits, () => Object.fromEntries(rl.map(l => [l.kind, { percentUsed: l.percentUsed, resetsAt: l.resetsAt }])))
32
33const pad = (n: number) => String(n).padStart(2, '0')
34const resetLabel = (iso: string | undefined, withDate: boolean) => {
35 if (!iso) return ''
36 const d = new Date(iso)
37 const time = `${d.getHours()}:${pad(d.getMinutes())}`
38 return ` ↻${withDate ? `${d.getMonth() + 1}/${d.getDate()} ` : ''}${time}`
39}
40const colorOf = (p: number) => (p >= 90 ? 'red' : p >= 70 ? 'yellow' : undefined)
41
42// ゲージは前回値から今回値へ伸び縮みさせる。同じ値の再描画では source を変えず、アニメーションを再生し直さない
43const anim = new Map<string, { from: number; to: number }>()
44const sweep = (kind: string, to: number) => {
45 const a = anim.get(kind)
46 if (a?.to !== to) anim.set(kind, { from: a?.to ?? 0, to })
47 return anim.get(kind)!
48}
49
50const BAR = 132
51const GAUGE = 156
52const fill = (p: number) => (p >= 90 ? 'url(#hot)' : p >= 70 ? 'url(#warm)' : 'url(#cool)')
53
54function gauge(x: number, label: string, kind: string, w: Limit, withDate: boolean) {
55 const p = Math.min(100, w.percentUsed)
56 const { from, to } = sweep(kind, p)
57 const px = (v: number) => Math.max(6, (BAR * v) / 100).toFixed(1)
58 const pulse = p >= 90 ? '<animate attributeName="opacity" values="1;.45;1" dur="1.6s" repeatCount="indefinite"/>' : ''
59 return `<g transform="translate(${x},0)">
60 <text x="0" y="17" class="tiny">${label}</text>
61 <text x="20" y="17" class="big">${Math.round(p)}<tspan class="unit">%</tspan></text>
62 <text x="${BAR}" y="17" class="dim" text-anchor="end">${resetLabel(w.resetsAt, withDate).trim()}</text>
63 <rect x="0" y="26" width="${BAR}" height="7" rx="3.5" class="bar"/>
64 <rect x="0" y="26" width="${px(to)}" height="7" rx="3.5" fill="${fill(p)}">
65 <animate attributeName="width" from="${px(from)}" to="${px(to)}" dur="1.1s" calcMode="spline" keySplines=".22 1 .36 1" keyTimes="0;1" fill="freeze"/>${pulse}
66 </rect>
67</g>`
68}
69
70function jobsChip(x: number, j: Jobs) {
71 const live = j.running > 0
72 const icon = live
73 ? `<circle cx="14" cy="22" r="8" class="track" stroke-width="2.5"/>
74 <circle cx="14" cy="22" r="8" fill="none" stroke="url(#cool)" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="14 50">
75 <animateTransform attributeName="transform" type="rotate" from="0 14 22" to="360 14 22" dur=".9s" repeatCount="indefinite"/>
76 </circle>`
77 : `<path d="M8 22.5l4 4 8-9" fill="none" stroke="url(#cool)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
78 stroke-dasharray="20" stroke-dashoffset="20"><animate attributeName="stroke-dashoffset" to="0" dur=".5s" fill="freeze"/></path>`
79 return `<g transform="translate(${x},0)">
80 <rect x="0" y="6" width="150" height="32" rx="16" class="chip"/>
81 ${icon}
82 <text x="30" y="20" class="big small">${j.running}<tspan class="unit"> 実行中</tspan></text>
83 <text x="30" y="33" class="dim">✓ ${j.done} 完了</text>
84</g>`
85}
86
87function bandSvg(shown: [string, string, Limit, boolean][], j: Jobs) {
88 const parts = shown.map(([label, kind, w, withDate], i) => gauge(i * GAUGE, label, kind, w, withDate))
89 const width = shown.length * GAUGE + (j.running + j.done > 0 ? 154 : 0)
90 if (j.running + j.done > 0) parts.push(jobsChip(shown.length * GAUGE, j))
91 return {
92 width,
93 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="44" viewBox="0 0 ${width} 44">
94<style>
95 text { font-family: -apple-system, system-ui, sans-serif; fill: #1f1e1d }
96 .big { font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums }
97 .small { font-size: 13px }
98 .unit { font-size: 10px; font-weight: 500; opacity: .6 }
99 .dim { font-size: 10px; opacity: .55 }
100 .tiny { font-size: 11px; font-weight: 600; opacity: .6 }
101 .track { fill: none; stroke: #00000014; stroke-width: 4 }
102 .bar { fill: #00000012 }
103 .chip { fill: #0000000a; stroke: #00000012 }
104 @media (prefers-color-scheme: dark) {
105 text { fill: #f5f4ef } .bar { fill: #ffffff1a } .track { stroke: #ffffff1c } .chip { fill: #ffffff0d; stroke: #ffffff17 }
106 }
107</style>
108<defs>
109 <linearGradient id="cool" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5eb3ff"/><stop offset="1" stop-color="#8b6cff"/></linearGradient>
110 <linearGradient id="warm" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffc24b"/><stop offset="1" stop-color="#ff8a3d"/></linearGradient>
111 <linearGradient id="hot" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ff6b5e"/><stop offset="1" stop-color="#e5307a"/></linearGradient>
112</defs>
113${parts.join('\n')}
114</svg>`,
115 }
116}
117
118export const register: Register = on => {
119 on('session.start', async ($, e, next) => {
120 await setLimits($, (await $.session.usage()).rateLimits)
121 await refreshJobs($)
122 return next(e)
123 })
124
125 on('session.measure', async ($, e, next) => {
126 if (e.changed.includes('rateLimits')) await setLimits($, e.rateLimits)
127 return next(e)
128 })
129
130 on('tool.call', async ($, e, next) => {
131 const done = await next(e)
132 if (e.tool === 'Agent' || e.tool === 'TaskStop' || isBackground(e.tool, e)) await refreshJobs($)
133 return done
134 })
135
136 on('turn.complete', async ($, e, next) => {
137 const done = await next(e)
138 await refreshJobs($)
139 return done
140 })
141
142 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143 if (e.props.hasSurvey) return next(e)
144 const l = await read($, limits)
145 const j = await read($, jobs)
146 const windows: [string, Limit | undefined, boolean][] = [['5h', l.five_hour, false], ['1w', l.seven_day, true]]
147 const shown = windows.filter((w): w is [string, Limit, boolean] => !!w[1])
148 if (shown.length === 0 && j.running + j.done === 0) return next(e)
149
150 const els = $.ui.resolve(e)
151 // Svg を持つ面(Desktop)ではアニメーション付きの帯、持たない面(ターミナル)では文字の帯
152 if ('Svg' in els) {
153 const { Svg } = els
154 const kinds: Record<string, string> = { '5h': 'five_hour', '1w': 'seven_day' }
155 const { width, source } = bandSvg(shown.map(([label, w, d]) => [label, kinds[label]!, w, d]), j)
156 const alt = [...shown.map(([label, w]) => `${label} ${Math.round(w.percentUsed)}%`), `${j.running} 実行中 ${j.done} 完了`].join(', ')
157 return <Svg source={source} alt={alt} width={width} height={44} isInteractive />
158 }
159
160 const { Box, Text } = els
161
162 return (
163 <Box flexDirection="row" gap={2}>
164 {shown.map(([label, w, withDate]) => (
165 <Text key={label} color={colorOf(w.percentUsed)} dimColor={!colorOf(w.percentUsed)}>
166 {label} {Math.round(w.percentUsed)}%{resetLabel(w.resetsAt, withDate)}
167 </Text>
168 ))}
169 {j.running + j.done > 0 && (
170 <Text dimColor={j.running === 0} color={j.running > 0 ? 'cyan' : undefined}>
171 ▶ {j.running} 実行中 · ✓ {j.done} 完了
172 </Text>
173 )}
174 </Box>
175 )
176 })
177}
178types/index.d.ts 9 lines1export type Limit = { percentUsed: number; resetsAt?: string }
2export type Jobs = { running: number; done: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'usage-band': { limits: Record<string, Limit>; jobs: Jobs }
7 }
8}
9