SLOPSHOPPER

statusbar

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

newbandguardcommandtoastprompt
★ 4v0.2.0MITupdated 2026-10-06ThisaruGuruge/dotfiles/claude-mods/statusbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusbar
› 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 › /statusbar ⎿ statusbar: Status band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Thisaru's Dotfiles

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.

Highlights

  • Fast Zsh environment – zinit-managed plugins, fzf-tab completion, syntax highlighting, autosuggestions, zoxide (via z command), Atuin history (Ctrl+R and up-arrow), and Ghostty compatible key bindings
  • Powerlevel10k prompt – pure-Zsh rendering (no binary subprocess per draw), instant prompt on startup, contextual Git state, Go/Java/Python/Ballerina indicators, transient prompt for clean scrollback, full Catppuccin Mocha theme matching the tmux status bar
  • Modern CLI stack – eza, bat, dust, ripgrep, fd, yazi (with inline image previews in Ghostty/iTerm2, chafa fallback for other terminals), jless, lazygit, lazydocker, tmux, direnv, atuin, gh, git-delta, gng (gw), and curated helper aliases/functions (take, kill_by_port, show_tools, etc.)
  • Debugging in Neovim – nvim-dap with a DAP UI, inline variable values while stepping, and one keymap set (<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
  • Go tooling in Neovim – gopls with inlay hints and codelenses, <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 mode
  • Smart aliases – Single-letter shortcuts for modern tools (v for nvim, g for ripgrep, f for fd, z for zoxide) while keeping original commands for scripts
  • Suffix aliases – Automatically open files with the right tool based on extension (.md → mdcat, .json/.yaml → jless, .py/.sh/.bal → $EDITOR)
  • Language runtimes – pyenv, rbenv, nvm, SDKMAN, and Ballerina with lazy-loading shell glue so heavy managers don't slow startup
  • Touch ID for sudo – Use your fingerprint instead of typing passwords in the terminal (works inside tmux too via pam-reattach)
  • Caps Lock remap – Hammerspoon + ControlEscape.spoon: tap Caps Lock for Escape, hold for Control — a Karabiner alternative that needs only Accessibility/Input Monitoring permissions, no blockable system extension
  • Secrets handled correctly – SOPS + age encryption, edit_secrets workflow, and automatic .env handling inside init.sh
  • Brewfile-driven – Curated Brewfile with optional category files in packages/ for modular installation
  • Validation + profiling – test-zsh integration tests, profile_startup quick timing, and bin/profile-zsh-startup for deep dives

Prerequisites

  1. macOS – Tested on Sonoma/Ventura (Apple Silicon friendly)
  2. Git – Already on macOS, confirm with git --version
  3. Homebrew – Install if missing:
   /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  1. Terminal – Ghostty (recommended and configured), iTerm2, or Terminal.app all work
  2. Nerd Font – Needed for icons in Powerlevel10k/lazygit:
   brew install --cask font-fira-code-nerd-font

Set the font once inside your terminal profile (Appearance/Text settings).

Installation

Option 1 – Automated (init.sh)

git clone https://github.com/ThisaruGuruge/dotfiles.git ~/dotfiles
cd ~/dotfiles
./init.sh

The script is interactive; it will:

  • Validate macOS + Xcode CLT, install Homebrew, jq, Go (needed to install bestow)
  • Install core packages from Brewfile with Brew
  • Offer opt-in categories (development, terminals, editors, etc.)
  • Install SDKMAN + Java 21 (optional) and brew-based Ballerina
  • Enable Touch ID for sudo (with tmux support via pam-reattach)
  • Configure Atuin, direnv, tmux, git delta, lazygit, lazydocker, aliases, and helper functions
  • Generate/restore encrypted .env with SOPS + age (keys stored at ~/.config/sops/age/keys.txt)
  • Create Nerd Font + terminal integrations
  • Back up existing dotfiles and symlink everything via bestow
  • Test the install with test-zsh and show next steps

Option 2 – Manual setup

git 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.

Option 3 – Brewfile only

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.

Package Management

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

Installing optional categories

brew bundle --file=Brewfile                      # Core packages
brew bundle --file=packages/development.brewfile # Add dev tools
brew bundle --file=packages/editors.brewfile     # Add editors

Categories available

  • 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 helpers
  • editors – Cursor, VS Code
  • terminals – iTerm2 (casks)
  • containers – colima (container runtime), Docker CLI + Compose + credential helper
  • productivity – Raycast, Rectangle, TablePlus, Alfred, Postman

Comment out what you do not need in the Brewfile, then rerun brew bundle.

Repository Layout & Symlink Packages

PathNotes
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.zshPowerlevel10k 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 Configuration

Ghostty is configured with:

  • Option key for word navigation (Option+Left/Right)
  • Catppuccin Mocha theme
  • FiraCode Nerd Font with ligatures
  • Tmux-aware keybindings for window/pane navigation

Containers (colima)

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):

AliasCommand
colima-startcolima start
colima-stopcolima stop
colima-restartcolima restart
colima-statuscolima status
colima-listcolima list
colima-sshcolima 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.

Symlink Management (bestow)

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.

Quick Start Commands

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)

Suffix Aliases

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:

ExtensionToolPurpose
.mdmdcat -pView rendered Markdown with paging
.jsonjlessInteractive JSON browsing with folding
.yaml, .ymljlessInteractive YAML browsing
.py$EDITOREdit Python files
.sh, .bash, .zsh$EDITOREdit shell scripts
.bal$EDITOREdit Ballerina files
.conf, .config, .ini$EDITOREdit configuration files

The default editor is set to nvim via the $EDITOR environment variable in .zshenv.

Secret Management (SOPS + age)

  • Keys live at ~/.config/sops/age/keys.txt and are created by init.sh. Backup that file somewhere safe.
  • Secrets live in ~/.env (ignored by git). They remain encrypted on disk and are transparently decrypted by the shell when sourced.
  • Use edit_secrets (wrapper defined in .functions.d/06-dotfiles.zsh) to decrypt, open your $EDITOR, and re-encrypt on save.
  • Manual commands:
  sops -d ~/.env | less            # View decrypted env
  sops ~/.env                      # Edit directly
  export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
  • The template at zsh/.env.example is copied when you first run init.sh; extend it if you need new keys for future machines.

Touch ID for sudo

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.so with /usr/local/lib/pam/pam_reattach.so.

Powerlevel10k Prompt

Sample prompt (top line + prompt character on line 2):

 ~/dotfiles  main !+   Go 1.23.4
❯

What you see:

  • Directory – truncated to last component, lock icon for read-only dirs
  • Git branch/status – always blue; dirty state shown by icons (! modified, + staged, ? untracked, * stash, ⇣⇡ ahead/behind) — no colour change on dirty, no blocking git subprocess
  • Runtime indicators – Go, Java, Python, and Ballerina only appear inside matching projects; all read versions via async gitstatus-style checks, never blocking the prompt
  • Command duration – shown on the right only when the previous command took > 2 seconds
  • Prompt character – ❯ green on success, red on failure; ❮ in Vim normal mode
  • Transient prompt – after a command runs, the previous prompt collapses to just ❯ in scrollback

Design 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.

Validation, Performance & Troubleshooting

  • 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 config
  • zsh -n ~/.p10k.zsh – Validate prompt config syntax without sourcing it
  • brew bundle check – Confirm Brew dependencies match Brewfile before running Bundle again

Common 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.

Keeping Dotfiles Updated

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.

Contributing

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!

Source 2 files
hooks/register.tsx 412 lines
1import { 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}
412
types/index.d.ts 41 lines
1export 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