Band above the prompt: stacked context bar, rate limits, last-turn stats, cache countdown and threshold toasts.

A macOS-focused developer workstation built around Zsh, Powerlevel10k, modern CLI tools, and battle-tested automation. Everything is wired together with bestow (a Go-based GNU Stow successor), encrypted secrets, and a centralized package manifest so each new machine behaves exactly like the last one.
z command), Atuin history (Ctrl+R and up-arrow), and Ghostty compatible key bindingsgw), and curated helper aliases/functions (take, kill_by_port, show_tools, etc.)<leader>d… plus F5/F10/F11) shared by every language. Go (delve) supports debugging the test under the cursor, launching the program with arguments, and attaching to a remote dlv --headless; Python, Java, and Ballerina are wired in too. Adding another debugger is a single drop-in file — see Adding a Debugger<leader>G… for code generation (if err != nil guards, struct tags, interface stubs, test and doc-comment generation), and <leader>T… to run tests from the buffer with a summary tree and watch modev for nvim, g for ripgrep, f for fd, z for zoxide) while keeping original commands for scripts.md → mdcat, .json/.yaml → jless, .py/.sh/.bal → $EDITOR)pam-reattach)ControlEscape.spoon: tap Caps Lock for Escape, hold for Control — a Karabiner alternative that needs only Accessibility/Input Monitoring permissions, no blockable system extensionedit_secrets workflow, and automatic .env handling inside init.shBrewfile with optional category files in packages/ for modular installationtest-zsh integration tests, profile_startup quick timing, and bin/profile-zsh-startup for deep divesgit --version /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install --cask font-fira-code-nerd-font
Set the font once inside your terminal profile (Appearance/Text settings).
init.sh)git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
./init.sh
The script is interactive; it will:
bestow)Brewfile with Brewpam-reattach).env with SOPS + age (keys stored at ~/.config/sops/age/keys.txt)bestowtest-zsh and show next stepsgit clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
# Install everything that is currently enabled in Brewfile
brew bundle --file=Brewfile
# Install bestow (requires Go)
go install github.com/redpierrot/bestow@latest
# Bootstrap bestow's own config (source: this repo, destination: $HOME)
bestow init --source ~/dotfiles --destination ~/
# Symlink the packages you need (add/remove as desired)
bestow stow zsh git tmux direnv bash
# Copy the env template (will be encrypted later by init/edit_secrets)
cp zsh/.env.example zsh/.env
# Reload configuration
source ~/.zshrc
Edit the Brewfile directly or use category Brewfiles in packages/ before running brew bundle if you want to customize packages.
Already have a preferred dotfiles strategy but want the curated tools? Run brew bundle --file=Brewfile and manually pick pieces (aliases, functions, etc.). Edit the Brewfile directly or use the category files in packages/ to customize your installation.
The main Brewfile contains core packages that are always installed. Optional packages are organized into category files in packages/:
packages/
├── cloud.brewfile # AWS, GCP CLIs
├── containers.brewfile # colima, Docker CLI + Compose + credential helper
├── development.brewfile # pyenv, rbenv, nvm, flutter
├── editors.brewfile # Cursor, VS Code
├── productivity.brewfile # Raycast, Rectangle, etc.
└── terminals.brewfile # iTerm2
brew bundle --file=Brewfile # Core packages
brew bundle --file=packages/development.brewfile # Add dev tools
brew bundle --file=packages/editors.brewfile # Add editors
core – powerlevel10k, zoxide, eza, bat, ripgrep, lazygit, lazydocker, tmux, direnv, atuin, gh, etc.security – sops + age for encrypted secrets (always enabled)development – pyenv, rbenv, nvm, flutter (optional)aws, gcp – cloud CLIs and helperseditors – Cursor, VS Codeterminals – iTerm2 (casks)containers – colima (container runtime), Docker CLI + Compose + credential helperproductivity – Raycast, Rectangle, TablePlus, Alfred, PostmanComment out what you do not need in the Brewfile, then rerun brew bundle.
| Path | Notes |
|---|---|
zsh/ | .zshrc, .zshrc.d/ (modular shell config), .functions.d/ (modular functions), aliases, paths |
zsh/.zshrc.d/ | 7 modules: plugins, completion, keybindings, history, integrations, environment, tmux |
zsh/.functions.d/ | 9 modules: colors, core, navigation, archives, git, system, dotfiles, docs, packages |
<tool>/.config/<tool>/ | XDG configs, one stow package per tool (ghostty, lazygit, nvim, yazi, ripgrep, typos) |
zsh/.p10k.zsh | Powerlevel10k prompt config (Catppuccin Mocha, stowed to ~/.p10k.zsh) |
git/ | .gitconfig, ignore rules, delta settings |
tmux/ | Modern tmux config + keybinds |
direnv/ | Project-specific environment automation |
packages/ | Optional category Brewfiles (cloud, containers, development, etc.) |
bin/ | Helper scripts (test-zsh-config, profile-zsh-startup, audit-configs, adopt-config) |
docs/ | Additional documentation (prompt guide, tmux keybindings, config management) |
Ghostty is configured with:
The optional containers category (packages/containers.brewfile) installs colima as the local container runtime, paired with the plain docker, docker-compose, and docker-credential-helper CLI formulae — no Docker Desktop app/VM required.
brew bundle --file=packages/containers.brewfile # colima + docker CLI + compose + credential helper
colima-start # boots the VM, sets the `colima` docker context
docker ps # talks to colima automatically
lzd # lazydocker also works unchanged — it just follows the active docker context
Colima lifecycle aliases (zsh/.aliases.sh, only defined if colima is installed):
| Alias | Command |
|---|---|
colima-start | colima start |
colima-stop | colima stop |
colima-restart | colima restart |
colima-status | colima status |
colima-list | colima list |
colima-ssh | colima ssh |
Homebrew's docker-compose formula installs as a CLI plugin, and docker-credential-helper installs docker-credential-osxkeychain to store registry credentials in the macOS Keychain instead of plaintext — but Docker only picks either up if ~/.docker/config.json says so:
{
"cliPluginsExtraDirs": ["/opt/homebrew/lib/docker/cli-plugins"],
"credsStore": "osxkeychain"
}
init.sh does not currently write these keys automatically — add them by hand (or merge into an existing ~/.docker/config.json) after installing docker-compose / docker-credential-helper.
Day-to-day package management (adding/removing symlinks after the initial install) uses bestow — a Go-based successor to GNU Stow — instead of stow directly. It replicates stow --no-folding behavior (always on, no flag needed) but is idempotent and conflict-safe with pre-existing absolute symlinks.
bestow stow zsh # Shell config (includes .p10k.zsh)
bestow stow nvim ghostty lazygit ripgrep yazi typos # Per-tool packages
bestow stow git tmux direnv # Git/Tmux/Direnv packages
# Remove a package
bestow unstow zsh
init.sh (the fresh-machine installer) still bootstraps with GNU Stow, since bestow is a personal Go build not yet packaged for distribution.
Alternative: For cross-platform dotfile management, consider Chezmoi.
test-zsh # Full validation (tools, PATH, runtimes)
./bin/profile-zsh-startup # Deep component timing (zinit plugins, SDKMAN, p10k, etc.)
help # Alias documentation entry point
docs # Interactive alias browser (alias_docs)
show_tools # Overview of installed CLI upgrades
alias_search git # Search for aliases by keyword
edit_secrets # Safely edit encrypted ~/.env via SOPS
take my-service && code . # Smart project bootstrap / clone helper
kill_by_port 3000 # Kill whatever binds to port 3000
lg # Launch lazygit with our config
lzd # Launch lazydocker for Docker management
gw build # Run Gradle build from any subdirectory
gwt # Alias for: gw test
gwc # Alias for: gw clean
gwcb # Alias for: gw clean build
Ctrl+R # Atuin search UI (fuzzy search all history)
Up arrow # Atuin prefix search (as you type)
Zsh suffix aliases automatically open files based on their extension. Just type the filename and press Enter:
# Viewing files (rendered markdown)
README.md # Opens in mdless with rendered Markdown
data.json # Opens in jless (interactive JSON viewer)
config.yaml # Opens in jless (interactive YAML viewer)
# Editing files (opens in $EDITOR/nvim)
script.py # Opens Python files in nvim
setup.sh # Opens shell scripts in nvim
service.bal # Opens Ballerina files in nvim
app.conf # Opens config files in nvim
Supported extensions:
| Extension | Tool | Purpose |
|---|---|---|
.md | mdcat -p | View rendered Markdown with paging |
.json | jless | Interactive JSON browsing with folding |
.yaml, .yml | jless | Interactive YAML browsing |
.py | $EDITOR | Edit Python files |
.sh, .bash, .zsh | $EDITOR | Edit shell scripts |
.bal | $EDITOR | Edit Ballerina files |
.conf, .config, .ini | $EDITOR | Edit configuration files |
The default editor is set to nvim via the $EDITOR environment variable in .zshenv.
~/.config/sops/age/keys.txt and are created by init.sh. Backup that file somewhere safe.~/.env (ignored by git). They remain encrypted on disk and are transparently decrypted by the shell when sourced.edit_secrets (wrapper defined in .functions.d/06-dotfiles.zsh) to decrypt, open your $EDITOR, and re-encrypt on save. sops -d ~/.env | less # View decrypted env
sops ~/.env # Edit directly
export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
zsh/.env.example is copied when you first run init.sh; extend it if you need new keys for future machines.The installer configures macOS to accept Touch ID (fingerprint) for sudo prompts in the terminal. This uses /etc/pam.d/sudo_local, which persists across macOS system updates (unlike editing /etc/pam.d/sudo directly).
tmux support: The pam-reattach brew package is included so Touch ID also works inside tmux sessions — without it, macOS cannot reach the biometric sensor from a reattached session.
After running init.sh, any sudo command will show the Touch ID prompt first and fall back to password if dismissed.
To enable manually (without init.sh):
brew install pam-reattach
# Create /etc/pam.d/sudo_local (requires sudo)
sudo tee /etc/pam.d/sudo_local <<'EOF'
# sudo_local: local config for sudo (persists across macOS updates)
auth optional /opt/homebrew/lib/pam/pam_reattach.so
auth sufficient pam_tid.so
EOF
Note: On Intel Macs, replace
/opt/homebrew/lib/pam/pam_reattach.sowith/usr/local/lib/pam/pam_reattach.so.
Sample prompt (top line + prompt character on line 2):
~/dotfiles main !+ Go 1.23.4
❯
What you see:
! modified, + staged, ? untracked, * stash, ⇣⇡ ahead/behind) — no colour change on dirty, no blocking git subprocess❯ green on success, red on failure; ❮ in Vim normal mode❯ in scrollbackDesign principle: The prompt shows command context (where you are, git state, language). The tmux status bar shows session context (session name, sysinfo, battery, time). Nothing is duplicated between the two layers.
Customize the prompt at ~/.p10k.zsh (stowed from zsh/.p10k.zsh):
nvim ~/dotfiles/zsh/.p10k.zsh # Edit directly — hot-reloads automatically
p10k configure # Interactive wizard (overwrites the file)
The Catppuccin Mocha palette used by the prompt matches the tmux status bar exactly — same background (#1e1e2e), same blue (#89b4fa), same green (#a6e3a1), same accent colors throughout.
test-zsh – Runs syntax checks, ensures required tools exist, inspects PATH/env vars, and prints a summary with pass/warn/fail counts./bin/profile-zsh-startup – Detailed profiler that times individual components (Homebrew shellenv, zinit plugins, SDKMAN, pyenv, compinit, sourcing files)zsh -n ~/.zshrc – Quick syntax validation if you edit the configzsh -n ~/.p10k.zsh – Validate prompt config syntax without sourcing itbrew bundle check – Confirm Brew dependencies match Brewfile before running Bundle againCommon fixes:
source ~/.zshrc # Reload everything after edits
rm -rf ~/.local/share/zinit && bash -c "$(curl -fsSL https://raw.githubusercontent.com/zdharma-continuum/zinit/HEAD/scripts/install.sh)" # Reinstall zinit if plugins fail
which fzf zoxide atuin direnv # Confirm core binaries are on PATH
p10k configure # Re-run the interactive prompt wizard
Fonts missing? Re-open the terminal and ensure your profile uses a Nerd Font. Icons rendering as boxes means the font doesn't include Nerd Font glyphs — install a patched font (e.g. FiraCode Nerd Font) and set it in your terminal profile.
cd ~/dotfiles
git pull origin main
brew bundle --file=Brewfile # Install any new packages
test-zsh # Sanity check after upgrades
source ~/.zshrc # Reload shell config
Remember to bestow unstow packages you no longer want and re-run bestow stow after pulling to ensure new configs are linked.
Bug reports and PRs are welcome! Please run test-zsh plus any relevant profilers before opening a pull request. See CONTRIBUTING.md for commit conventions, scopes (including prompt for p10k changes), and validation expectations.
Happy hacking!
hooks/register.tsx 412 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Last, Limit, Row, Snap } from '../types'
5
6const snap = atom({ plugin: 'statusbar', key: 'snap' } as const, null)
7const rows = atom({ plugin: 'statusbar', key: 'rows' } as const, null)
8const history = atom({ plugin: 'statusbar', key: 'history' } as const, [])
9const last = atom({ plugin: 'statusbar', key: 'last' } as const, null)
10const isHidden = atom({ plugin: 'statusbar', key: 'isHidden' } as const, false)
11const isLegend = atom({ plugin: 'statusbar', key: 'isLegend' } as const, true)
12const warned = atom({ plugin: 'statusbar', key: 'warned' } as const, [])
13const now = atom({ plugin: 'statusbar', key: 'now' } as const, 0)
14
15const AMBER = '#c98500'
16const RED = '#e66767'
17const FREE = '#45475a'
18const RESERVE = '#6c7086'
19const OTHER = '#9399b2'
20
21// Category colors validated for a dark surface (lightness band, CVD separation)
22const PALETTE: [RegExp, string, string, string][] = [
23 [/system prompt/i, '#e66767', 'system', 'sys'],
24 [/tools/i, '#9085e9', 'tools', 'tools'],
25 [/mcp/i, '#d55181', 'MCP', 'mcp'],
26 [/agent/i, '#c98500', 'agents', 'agents'],
27 [/memory/i, '#199e70', 'memory', 'mem'],
28 [/skill/i, '#d95926', 'skills', 'skills'],
29 [/messages/i, '#3987e5', 'messages', 'msg'],
30]
31
32export const fmt = (n: number): string => {
33 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
34 if (n >= 1e4) return `${Math.round(n / 1e3)}k`
35 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
36 return String(n)
37}
38
39export const eta = (resetsAt: string | undefined, at: number): string => {
40 if (!resetsAt) return ''
41 const s = Math.floor((Date.parse(resetsAt) - at) / 1000)
42 if (!Number.isFinite(s)) return ''
43 if (s <= 0) return 'now'
44 const h = Math.floor(s / 3600)
45 const m = Math.floor((s % 3600) / 60)
46 if (h >= 24) return `${Math.floor(h / 24)}d${h % 24}h`
47 if (h > 0) return `${h}h${m}m`
48 return `${m}m`
49}
50
51export const paint = (r: Row): { color: string; name: string; short: string } => {
52 if (r.kind === 'free') return { color: FREE, name: 'free', short: 'free' }
53 if (r.kind === 'buffer') return { color: RESERVE, name: 'reserve', short: 'reserve' }
54 for (const [re, color, name, short] of PALETTE) {
55 if (re.test(r.name)) return { color, name, short }
56 }
57 return { color: OTHER, name: r.name.toLowerCase(), short: r.name.toLowerCase() }
58}
59
60type Cell = { ch: string; color: string; dim: boolean }
61
62export const barCells = (list: Row[], width: number, markerAt: number): Cell[] => {
63 const shown = list.filter(r => r.kind !== 'deferred')
64 const total = shown.reduce((a, r) => a + r.tokens, 0) || 1
65 const cells: Cell[] = []
66 const counts = new Map<Row, number>()
67 for (const r of shown.filter(x => x.kind !== 'free')) {
68 counts.set(r, r.tokens > 0 ? Math.max(1, Math.round((r.tokens / total) * width)) : 0)
69 }
70 const used = [...counts.values()].reduce((a, n) => a + n, 0)
71 for (const r of shown.filter(x => x.kind === 'free')) counts.set(r, Math.max(0, width - used))
72 for (const r of shown) {
73 const n = counts.get(r) ?? 0
74 const ch = r.kind === 'free' ? '░' : r.kind === 'buffer' ? '▒' : '█'
75 for (let i = 0; i < n && cells.length < width; i++) {
76 cells.push({ ch, color: paint(r).color, dim: false })
77 }
78 }
79 while (cells.length < width) cells.push({ ch: '░', color: FREE, dim: false })
80 if (markerAt >= 0 && markerAt < width) cells[markerAt] = { ch: '┃', color: '#cdd6f4', dim: false }
81 return cells
82}
83
84const runs = (cells: Cell[]): { text: string; color: string }[] => {
85 const out: { text: string; color: string }[] = []
86 for (const c of cells) {
87 const p = out[out.length - 1]
88 if (p && p.color === c.color && !(c.ch === '┃')) p.text += c.ch
89 else out.push({ text: c.ch, color: c.color })
90 }
91 return out
92}
93
94export type LegendItem = { key: string; swatch: string; color: string; text: string }
95
96export const legendItems = (
97 list: Row[],
98 window: number,
99 isNarrow: boolean,
100 minShare = 0.005,
101): LegendItem[] => {
102 const used = list.filter(r => r.kind === 'used' && r.tokens > 0).sort((a, b) => b.tokens - a.tokens)
103 const big = used.filter(r => r.tokens / window >= minShare)
104 const small = used.filter(r => r.tokens / window < minShare)
105 const out: LegendItem[] = big.map(r => {
106 const p = paint(r)
107 const label = isNarrow ? p.short : p.name
108 return { key: p.name, swatch: '■', color: p.color, text: `${label} ${fmt(r.tokens)}` }
109 })
110 if (small.length > 0) {
111 const t = small.reduce((a, r) => a + r.tokens, 0)
112 out.push({ key: 'other', swatch: '■', color: OTHER, text: `other ${fmt(t)}` })
113 }
114 for (const r of list) {
115 if (r.kind === 'buffer' || r.kind === 'free') {
116 const p = paint(r)
117 out.push({
118 key: p.name,
119 swatch: r.kind === 'free' ? '░' : '▒',
120 color: p.color,
121 text: `${isNarrow ? p.short : p.name} ${fmt(r.tokens)}`,
122 })
123 }
124 }
125 return out
126}
127
128// One ladder for context and limits, always on what is left
129export const leftColor = (leftPercent: number): string =>
130 leftPercent >= 50 ? 'text' : leftPercent >= 20 ? AMBER : RED
131
132export const hitColor = (hit: number): string => (hit >= 90 ? 'text' : hit >= 50 ? AMBER : RED)
133
134export const limitBar = (left: number, cells: number): string => {
135 const filled = Math.max(0, Math.min(cells, Math.round((left / 100) * cells)))
136 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
137}
138
139export const cacheState = (
140 left: number | null,
141): { glyph: string; color: string; text: string } | null => {
142 if (left === null) return null
143 if (left <= 0) return { glyph: '○', color: RED, text: 'cold' }
144 const t = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`
145 return left < 60 ? { glyph: '◐', color: AMBER, text: t } : { glyph: '●', color: '#199e70', text: t }
146}
147
148// Turns until the context reaches the compact point, from the last 6 turns' growth.
149// Null when there is too little data, the growth is noise (under 200 tokens a turn),
150// or the answer is too far off to mean anything (over 60 turns).
151export const turnsLeft = (used: number[], threshold: number): number | null => {
152 const pts = used.slice(-6)
153 if (pts.length < 3) return null
154 const growth = (pts[pts.length - 1] - pts[0]) / (pts.length - 1)
155 if (growth < 200) return null
156 const remaining = threshold - pts[pts.length - 1]
157 if (remaining <= 0) return 0
158 const n = Math.ceil(remaining / growth)
159 return n > 60 ? null : n
160}
161
162export const tier = (cols: number): 'wide' | 'mid' | 'narrow' =>
163 cols >= 150 ? 'wide' : cols >= 130 ? 'mid' : 'narrow'
164
165async function refreshUsage($: EngineInterface): Promise<Snap> {
166 const u = await $.session.usage({ breakdown: 'summary' })
167 const s: Snap = {
168 tokens: u.context.tokens,
169 window: u.context.window,
170 percent: u.context.percent,
171 limits: u.rateLimits,
172 usd: u.cost?.usd,
173 }
174 await update($, snap, () => s)
175 const cats = u.context.breakdown?.categories
176 await update($, rows, () =>
177 cats
178 ? cats.map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }))
179 : null,
180 )
181 return s
182}
183
184export const register: Register = (on, options) => {
185 const autocompact = Number(options.autocompactPercent ?? 75)
186 const ttl = Number(options.cacheTtlSeconds ?? 3600)
187 let tools = 0
188 let prevUsd = 0
189
190 on('session.start', async ($, e, next) => {
191 await $.command.register({
192 name: 'statusbar',
193 description: 'Show or hide the status band above the prompt',
194 })
195 await $.command.register({
196 name: 'context-bar',
197 description: 'Toggle the legend under the context bar',
198 })
199 const started = await next(e)
200 await refreshUsage($)
201 $.clock.every(10000, async () => {
202 const l = await read($, last)
203 if (l) {
204 const t = await $.clock.now()
205 await update($, now, () => t)
206 }
207 })
208 return started
209 })
210
211 on('command.run', { command: 'statusbar' }, async $ => {
212 const hidden = await read($, isHidden)
213 await update($, isHidden, () => !hidden)
214 return { text: hidden ? 'Status band shown.' : 'Status band hidden.' }
215 })
216
217 on('command.run', { command: 'context-bar' }, async $ => {
218 const shown = await read($, isLegend)
219 await update($, isLegend, () => !shown)
220 return { text: shown ? 'Legend hidden.' : 'Legend shown.' }
221 })
222
223 on('prompt.submit', async ($, e, next) => {
224 tools = 0
225 return next(e)
226 })
227
228 on('tool.call', async ($, e, next) => {
229 tools += 1
230 return next(e)
231 })
232
233 on('turn.complete', async ($, e, next) => {
234 if (e.agentId) return next(e)
235 const s = await refreshUsage($)
236 const at = await $.clock.now()
237 const usd = s.usd ?? 0
238 const u = e.usage
239 const inTok = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
240 const entry: Last = {
241 seconds: Math.round(e.durationMs / 1000),
242 tools,
243 usd: Math.max(0, usd - prevUsd),
244 outTokens: u?.output_tokens ?? 0,
245 cacheHitPercent: inTok > 0 && u ? Math.round((u.cache_read_input_tokens / inTok) * 100) : 0,
246 endedAt: at,
247 }
248 prevUsd = usd
249 await update($, last, () => entry)
250 if (s.tokens !== undefined) {
251 const tokensNow = s.tokens
252 await update($, history, h => [...h, tokensNow].slice(-6))
253 }
254 await update($, now, () => at)
255 return next(e)
256 })
257
258 on('session.measure', async ($, e, next) => {
259 const active: Record<string, string> = {}
260 const pct = e.context.percent ?? 0
261 if (pct >= 90) active.ctx90 = `Context at ${Math.round(pct)}%`
262 else if (pct >= autocompact) active.ctx75 = `Context at ${Math.round(pct)}%: compaction is close`
263 for (const l of e.rateLimits as Limit[]) {
264 const name = l.kind === 'five_hour' ? '5h' : l.kind === 'seven_day' ? '7d' : l.kind
265 if (l.percentUsed >= 95) active[`${name}95`] = `${name} limit ${Math.round(l.percentUsed)}% used`
266 else if (l.percentUsed >= 80) active[`${name}80`] = `${name} limit ${Math.round(l.percentUsed)}% used`
267 }
268 const before = await read($, warned)
269 for (const k of Object.keys(active).filter(k => !before.includes(k))) $.ui.toast(active[k])
270 await update($, warned, () => Object.keys(active))
271 await update($, snap, s => ({
272 tokens: e.context.tokens,
273 window: e.context.window,
274 percent: e.context.percent,
275 limits: e.rateLimits as Limit[],
276 usd: e.cost?.usd ?? s?.usd,
277 }))
278 return next(e)
279 })
280
281 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
283 const s = await read($, snap)
284 if (!s) return next(e)
285
286 const { Box, Text } = $.ui.resolve(e)
287 const [list, l, legend, hist] = await Promise.all([
288 read($, rows),
289 read($, last),
290 read($, isLegend),
291 read($, history),
292 ])
293 await read($, now)
294 const wall = await $.clock.now()
295 const cols = e.props.bodyColumns
296 const t = tier(cols)
297 const isNarrow = t === 'narrow'
298 const isTiny = cols < 120
299 const pct = Math.round(s.percent ?? 0)
300 const shown = (list ?? []).filter(r => r.kind !== 'deferred')
301 const total = shown.reduce((a, r) => a + r.tokens, 0)
302 const threshold = (total * autocompact) / 100
303 const left = list ? turnsLeft(hist, threshold) : null
304 const leftText =
305 left === null ? '' : left === 0 ? 'compact due' : left < 3 ? `${left} turns left` : `~${left} turns left`
306 const width = Math.max(10, cols - 3 - leftText.length - 6)
307 const markerAt = Math.floor((autocompact / 100) * width)
308 const cells = list
309 ? barCells(list, width, markerAt)
310 : barCells(
311 [
312 { name: 'messages', tokens: pct, color: '', kind: 'used' },
313 { name: 'free', tokens: 100 - pct, color: '', kind: 'free' },
314 ],
315 width,
316 markerAt,
317 )
318 const items = list ? legendItems(list, total || 1, isTiny, isTiny ? 0.01 : 0.005) : []
319 if (list) items.push({ key: 'compact', swatch: '┃', color: '#cdd6f4', text: `compact ${fmt(Math.round(threshold))}` })
320 const cacheLeft = l ? Math.round(ttl - (wall - l.endedAt) / 1000) : null
321 const cache = cacheState(cacheLeft)
322 const limitCells = cols < 100 ? 6 : 10
323 const gap = isNarrow ? 2 : 3
324 const rowGap = isNarrow ? 3 : 4
325 const blank = 0
326
327 const limitNodes = s.limits.map(x => {
328 const name = x.kind === 'five_hour' ? '5h' : x.kind === 'seven_day' ? '7d' : x.kind
329 const left = Math.max(0, 100 - Math.round(x.percentUsed))
330 const color = leftColor(left)
331 return (
332 <Text key={x.kind}>
333 {name} <Text color={color}>{limitBar(left, limitCells)}</Text>{' '}
334 <Text color={color}>{left}%</Text> <Text dimColor>{eta(x.resetsAt, wall)}</Text>
335 </Text>
336 )
337 })
338
339 const turnNodes = l
340 ? [
341 <Text key="s">{l.seconds}s</Text>,
342 <Text key="t">{l.tools} {l.tools === 1 ? 'tool' : 'tools'}</Text>,
343 <Text key="c">${l.usd.toFixed(2)}</Text>,
344 <Text key="o">↑{fmt(l.outTokens)}</Text>,
345 <Text key="h" color={l.cacheHitPercent >= 90 ? undefined : hitColor(l.cacheHitPercent)} dimColor={l.cacheHitPercent >= 90}>⚡ {l.cacheHitPercent}% cache</Text>,
346 cache ? (
347 <Text key="k">
348 <Text color={cache.color}>{cache.glyph}</Text>{' '}
349 <Text color={cache.glyph === '○' ? RED : undefined} dimColor={cache.glyph === '●'}>{cache.text}</Text>
350 {cache.glyph === '○' && !isNarrow && <Text color={RED}> ⚠ next re-reads full</Text>}
351 </Text>
352 ) : (
353 <Text key="k"> </Text>
354 ),
355 ]
356 : []
357
358 const lane = (nodes: unknown[]) =>
359 nodes.map((n, i) => <Box key={String(i)}>{n as never}</Box>)
360
361 return (
362 <Box flexDirection="column" marginTop={1}>
363 <Box flexDirection="row">
364 <Box width={3}><Text dimColor>◔</Text></Box>
365 <Box flexDirection="row" columnGap={2}>
366 <Box flexDirection="row">
367 {runs(cells).map((r, i) => (
368 <Text key={String(i)} color={r.color}>{r.text}</Text>
369 ))}
370 </Box>
371 {leftText !== '' && (
372 <Text color={left !== null && left < 3 ? RED : undefined} dimColor={left !== null && left >= 3}>
373 {leftText}
374 </Text>
375 )}
376 </Box>
377 </Box>
378 {legend && cols >= 80 && items.length > 0 && (
379 <Box flexDirection="row">
380 <Box width={3}><Text> </Text></Box>
381 <Box flexDirection="row" flexWrap="wrap" columnGap={gap}>
382 {lane(
383 items.map(it => (
384 <Text key={it.key}>
385 <Text color={it.color}>{it.swatch}</Text> <Text dimColor>{it.text}</Text>
386 </Text>
387 )),
388 )}
389 </Box>
390 </Box>
391 )}
392 <Box flexDirection="row" marginTop={blank}>
393 <Box width={3}><Text dimColor>⏱</Text></Box>
394 <Box flexDirection="row" columnGap={rowGap}>
395 {lane(
396 limitNodes.flatMap((n, i) => (i === 0 ? [n] : [<Text key={`d${i}`} dimColor>·</Text>, n])),
397 )}
398 </Box>
399 </Box>
400 {l && (
401 <Box flexDirection="row" marginTop={blank}>
402 <Box width={3}><Text dimColor>↻</Text></Box>
403 <Box flexDirection="row" columnGap={rowGap}>
404 {lane(turnNodes)}
405 </Box>
406 </Box>
407 )}
408 </Box>
409 )
410 })
411}
412types/index.d.ts 41 lines1export type Row = {
2 name: string
3 tokens: number
4 color: string
5 kind: 'used' | 'free' | 'buffer' | 'deferred'
6}
7
8export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
9
10export type Snap = {
11 tokens?: number
12 window: number
13 percent?: number
14 limits: Limit[]
15 usd?: number
16}
17
18export type Last = {
19 seconds: number
20 tools: number
21 usd: number
22 outTokens: number
23 cacheHitPercent: number
24 endedAt: number
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 statusbar: {
30 snap: Snap | null
31 rows: Row[] | null
32 history: number[]
33 last: Last | null
34 isHidden: boolean
35 isLegend: boolean
36 warned: string[]
37 now: number
38 }
39 }
40}
41