SLOPSHOPPER

status-bar

Ligne d'état sous le prompt : modèle, dossier, branche, tokens, contexte et quotas 5 h / 7 j (barres colorées, reset).

newspinnerprompttimer
★ 1v0.1.0no licenseupdated 2026-10-09AlxWrtl/NixConfig/home/claude-code/mods/status-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-bar
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › 🤖 Opus 5.5 | 📁 app | 📊 97,400/0 | 🧠 ████░░░░░░ 49% | ⏳ ███░░░░░░░ 31% ⟨Claude Code's own drawing⟩

Draws

Prompt hint
🤖 Opus 5.5 | 📁 app | 📊 97,400/0 | 🧠 ████░░░░░░ 49% | ⏳ ███░░░░░░░ 31% ⟨Claude Code's own drawing⟩
README

Nix-Darwin Configuration

Declarative macOS system configuration using Nix flakes

[Nix]() [macOS]()

Single host: alex-mbp (aarch64-darwin). Everything below is declared in this repo — system settings, CLI tools, GUI apps, fonts, shell, editor, and the Claude Code setup.

Clean Install

# 1. Prerequisites (git + clone access)
xcode-select --install
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install gh
gh auth login                    # authenticate with GitHub

# 2. Clone and run bootstrap (handles everything else)
git clone https://github.com/AlxWrtl/NixConfig.git ~/.config/nix-darwin
cd ~/.config/nix-darwin
./bootstrap.sh

bootstrap.sh runs twelve checkpointed steps and skips whatever is already done, so it is safe to re-run after an interruption. Once a run completes, later runs skip the app configs restore unless you pass ./bootstrap.sh --restore:

#StepNotes
1Xcode Command Line Tools
2Nix package managerDeterminate Systems installer
3Homebrew
41Passwordpauses — you retrieve SSH keys + git-crypt key from the vault
5SSH key permissionschmod 700 ~/.ssh, 600 on the private key
6GitHub CLI authenticationgh auth login
7Decrypt secretsgit-crypt unlock
8App Store loginpauses — sign in for masApps
9darwin-rebuild switchthe actual build
10Switch git remote to SSH
11VS Code extensionsvscode-install-extensions
12Restore app configsPlex, Logitech, Raycast, Ice, Finder sidebar, Wi-Fi/BT

Steps 4 and 8 block on a manual action; the rest are unattended. App logins (Discord, Figma, Teams, …) stay manual — see the checklist below.

# 1. Xcode Command Line Tools
xcode-select --install

# 2. Install Homebrew + GitHub CLI
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install gh
gh auth login

# 3. Install Nix
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh

# 4. Clone
git clone https://github.com/AlxWrtl/NixConfig.git ~/.config/nix-darwin
cd ~/.config/nix-darwin

# 5. Install 1Password and retrieve keys
brew install --cask 1password
# Open 1Password → login → save SSH keys to ~/.ssh/ and git-crypt key to ~/git-crypt-key

# 6. Decrypt secrets
nix-shell -p git-crypt --run "git-crypt unlock ~/git-crypt-key"
rm ~/git-crypt-key

# 7. Login to App Store (for masApps)

# 8. Build & apply
sudo darwin-rebuild switch --flake .#alex-mbp

# 9. Post-install
git remote set-url origin git@github.com:AlxWrtl/NixConfig.git
~/.local/bin/vscode-install-extensions

Structure

flake.nix                        # inputs, checks, darwinConfigurations, devShell
├── hosts/alex-mbp/              # Host identity
│   ├── default.nix
│   └── configuration.nix
├── modules/                     # System modules
│   ├── system.nix               # Nix settings, env, firewall, shell
│   ├── packages.nix             # CLI tools + shell aliases
│   ├── services.nix             # launchd agents & daemons
│   ├── ui.nix                   # Fonts, Dock, Finder, macOS defaults, wallpaper
│   └── brew.nix                 # taps, brews, casks, masApps
├── home/                        # User config (home-manager)
│   ├── default.nix              # User packages & imports
│   ├── git.nix                  # Git + SSH signing
│   ├── ssh.nix                  # SSH hosts
│   ├── zsh.nix                  # Shell (zsh + fzf + zoxide + autosuggest + highlighting)
│   ├── starship.nix             # Prompt
│   ├── direnv.nix               # Directory environments
│   ├── ghostty.nix              # Terminal (Catppuccin, quick terminal)
│   ├── vscode.nix               # VS Code settings, keybindings, extensions
│   ├── claude-code.nix          # Claude Code entrypoint — imports claude-code/
│   ├── claude-code/             # settings, hooks, agents, skills, commands, rules…
│   │                            #   incl. skills-manifest.nix (Claude skills)
│   │                            #   and mods/ (task-board, apex-band, status-bar) via mods.nix
│   ├── codex.nix                # Codex CLI entrypoint — imports codex/
│   └── codex/                   # hooks.json generator, activation, hook & merge scripts
├── checks/                      # Flake checks (see Quality Gates)
│   ├── agent-instructions.nix
│   ├── apex-consistency.nix
│   ├── apex-plan-provenance.nix
│   ├── apex-tier.nix
│   ├── audit-apex-needles.py    # Advisory, not a flake check — needle shapes
│   ├── claude-config.nix
│   ├── claude-mods.nix
│   ├── codex-config.nix
│   ├── hook-wiring.nix
│   ├── js-lint.nix
│   ├── readme-consistency.nix
│   └── trello-cli.nix
├── backups/                     # 🔒 Encrypted app config exports (backup-apps.sh)
├── wallpapers/                  # Desktop wallpaper
├── secrets.nix                  # 🔒 Encrypted (git-crypt) — emails, IPs, usernames
├── bootstrap.sh                 # Fresh-machine install
├── backup-apps.sh               # Export app configs into backups/
└── .gitattributes               # git-crypt filter rules

flake.nix inputs: nixpkgs (unstable), nix-darwin, home-manager (master), and determinate for the Nix daemon.

Secrets

Sensitive values live in secrets.nix, encrypted by git-crypt. The backups/ tree is encrypted by the same filter — app exports contain Wi-Fi networks and the Bluetooth device list.

  • Locally: readable, transparent workflow
  • On GitHub: encrypted binary
  • Key backup: 1Password → "git-crypt nix-darwin key"
git-crypt status                 # Check encryption status
git-crypt lock                   # Re-encrypt (rarely needed)
git-crypt unlock <key-file>      # Decrypt after clone

Filter rules live in .gitattributes:

secrets.nix  filter=git-crypt diff=git-crypt
backups/**   filter=git-crypt diff=git-crypt

Commands

rebuild                          # alias: sudo darwin-rebuild switch --flake .#alex-mbp
nix flake check                  # Run all quality gates (see below)
nix flake update                 # Update inputs
nix develop                      # Dev shell: vulnix, nix-tree, nixfmt, nil
darwin-rebuild rollback          # Rollback to previous generation
darwin-rebuild switch --flake .#alex-mbp --show-trace -v  # Debug

Option Docs

nix-options (home/claude-code/scripts/nix-options.sh, packaged by home/claude-code/nix-options.nix) evaluates the options of the flake.lock-pinned nix-darwin, home-manager and determinate modules. "Declared in" maps to GitHub at the lock rev. Inside the Claude sandbox it falls back to a read-only store. Override with NIX_OPTIONS_FLAKE, --flake or --host.

nix-options show system.defaults.dock.autohide
nix-options show programs.git.settings
nix-options search dock
nix-options --json show system.defaults.dock.autohide

Quality Gates

nix flake check runs every check below. They are the reason a broken module, a drifted Claude Code config or a stale README fails before it reaches the system.

CheckWhat it enforces
format-checknixfmt --check over every tracked *.nix (find walk, no per-directory list)
system-configThe whole alex-mbp darwin configuration actually builds
agent-instructionsThe shared instruction trunk actually reaches both rendered outputs: every shared section body present in CLAUDE.md and AGENTS.md, each heading exactly once, headings equal the declared trunk-plus-delta list in order, no mechanism Codex lacks named to Codex or smuggled through the trunk, the nix Docs Gate divergence pinned as Codex-inline only, each output under 100 lines, Project Map gone from both
apex-consistencyThe APEX skill keeps its critical clauses, flag casing, subagent isolation, and step-file references
apex-plan-provenanceEvery premise in an APEX plan carries [M] or [I] as its first token: the clause still stands in step-02-plan, and the line detector is run against two inline fixtures — one correctly tagged, one identical but for a stripped tag — so a detector that stopped detecting fails instead of passing. Presence is not truth: it proves the tag is THERE, never that it is earned; falsifying a tag is the examine reviewer's job and the Fable premises pass
apex-tierThe built apex-tier classifier against throwaway git repos: a matcher added under hooks.Notification is direct, an entry added inside a deny = [ ... ] list is high, a permissionDecision branch in hooks/x.js is high, a 40-line README change is standard, a new .env.example is high. Canary M1 (the permission regex replaced by one that never matches) must turn the deny-list case away from high, proving that assertion rests on the permission class
claude-configClaude Code invariants: JSON parses, sandbox denies ~/.ssh and secrets, agents declare a model, haiku only on read-only agents, rules declare paths
claude-modsClaude Code mods (home/claude-code/mods.nix), offline structure only: names equal the folders under home/claude-code/mods/ both ways, each plugin.json parses and names its folder, each hooks.json names one existing module, no .js there, no sound / host process / network / file write / dynamic import / toast in any source and no deny in a hooks module, settings env.CLAUDE_CODE_PLUGIN_DIRS equal to the ~/.claude/mods/<name> folders, activation copying them with the DRY_RUN skip and the engine's types excluded. Canaries: the scan must flag $.audio.speak, the filter a .js name, the comparison an extra name. claude plugin validate --strict and claude plugin test on each mod folder are the code gate and run in the session (claude is not in the build sandbox)
codex-configCodex hook invariants: every command in the generated hooks.json names a script the module installs, both scripts pass node --check, hook order and matcher, registered timeouts above each script's own watchdog
hook-wiringClaude Code hook wiring, from the evaluated module rather than from text: every hook file home/claude-code.nix installs is named by a command in home/claude-code/settings.nix and every such command names a file that exists, both senses reported apart; additionalContext emitted only inside hookSpecificOutput, the one shape the reference documents; the hookEventName a hook writes equal to the event registering it. Each direction is guarded by a corpus-non-empty assertion first, because an extractor that stops matching would otherwise be green forever. The branch guards are also RUN against fixture repos, each under the timeout its registration gives the host: protect-main.js and block-main-bash.js must deny on malformed input and on a missing or hung git and stay silent off a protected branch, format-typescript.js must hand a $(…) file path to prettier unexpanded and must not call prettier --write when --find-config-path finds no prettier config, research-model.js must deny an Explore or codebase-navigator spawn whose model is not haiku (absent included) and stay silent on haiku, on other agent types and on malformed input, correction-budget.js must deny a correction brief whose model is not opus (absent included) before counting it, allow one more round per user grant, and deny a CronCreate or ScheduleWakeup prompt carrying an apex: +1 tour line while letting any other scheduler call through uncounted, correction-grant.js must grant one round only when the first non-empty prompt line is apex: +1 tour and the prompt carries no harness wrapper (task notification, agent hand-back), never on an agent_id of any value, malformed input or a token further down, never twice for one prompt nor over an existing grant file, and only to the project's live run (newest round, run dir under the cwd) once it is spent; the two hooks carry the same token line, asserted at eval. Canary mutants, which must turn their case red by a missed deny or a PWNED file and not by a crash, cover these branches only: malformed JSON, a missing git and the time budget in protect-main.js; malformed JSON, a broken git in the cwd or in a cd target, and the linear executor scan on a newline flood in block-main-bash.js; each of the two argv calls and the prettier-config gate in format-typescript.js; the absent-model branch and the agent-type set in research-model.js; the grant count, the opus check and the scheduler token check in correction-budget.js; the whole-line token, the first-line rule, the wrapper filter and the spent-budget test in correction-grant.js. The other cases are graded without a mutant
js-lintESLint (pkgs.eslint, eslint:recommended rebuilt from builtinRules, node globals) over every tracked .js: the Claude hooks in home/claude-code/hooks/ and the Codex scripts; asserts the file count and that a canary with an unused variable turns it red
readme-consistencyThis file against the repo: the APEX flag table vs the skill, /apex examples typing only live flags, every .nix in modules/ home/ checks/ hosts/ present in the Structure tree, every check listed above, no dangling path, no alias documented that no attrset declares, no hard count
trello-cliThe trello wrapper (home/claude-code/trello.nix). Text: skillTrello and cmdCard call only bare trello …, with no curl, no command substitution, no AUTH= and no secret cat; both keep the Tech & Pit test-write rule, the skill its contract/scope/handoffs sections, /card its stop/no-write branches and its comment-before-move order. Settings: Bash(trello ) allowed, no Bash(curl ), trello absent from excludedCommands, allowRead keeps both secret files, autoMode.environment has $defaults first plus the Trello line. Runtime: the built wrapper runs against a stub curl in 17 cases — key and token never in argv, URL or output (inherited SHELLOPTS=xtrace and secret-echoing success bodies included), the OAuth header arrives on stdin, ids validated before any call, secrets with embedded whitespace rejected, 401 and network errors exit 1. Canaries C1 (secret moved into the URL), C2 (id validation disabled) and C3 (set +o xtrace dropped) must each be killed by their target case

Run them before every commit that touches .nix files — format-check in particular fails on formatting alone.

readme-consistency compares this document against live sources rather than against a copy of its own expectations: a check holding its own copy of the truth rots at the same rate as the thing it checks. It deliberately does not require the modules inside home/claude-code/ to be listed individually — that directory is documented as one unit.

Maintenance / Cleanup

Reclaim disk space by garbage-collecting old generations and deduplicating the Nix store, then prune Homebrew caches.

sudo nix-collect-garbage -d      # Delete all old generations (system + user)
nix-store --optimise             # Hard-link identical files in the store
brew autoremove                  # Remove unused brew dependencies
brew cleanup -s --prune=all      # Purge all download caches and old versions

Note: if sudo nix-collect-garbage -d warns $HOME is not owned by you and falls back to root's profile (leaving user generations behind), run the user sweep without sudo as well: nix-collect-garbage -d.

What's Managed

Each row points at the module that owns it — that file is the authoritative list, deliberately not duplicated here.

LayerToolContents
CLI toolsNix (modules/packages.nix)eza, bat, fd, ripgrep, fzf, atuin, zoxide, btop, jq/yq, git-crypt, gh, nodejs, pnpm, bun, python3, uv, ruff, sqlite, postgresql, redis, nixd/nil/nixfmt …
GUI appsHomebrew casks (modules/brew.nix)1Password, Arc, Ghostty, VS Code, Zed, Docker, Raycast, Obsidian, Figma, Jellyfin, Tailscale … apps without an auto-updater are marked greedy
Formulae & tapsHomebrew (modules/brew.nix)cloudflared, ffmpeg, displayplacer, mas, postgresql@16, trash, rtk-ai/tap/rtk
App Storemas (modules/brew.nix)DaisyDisk, Trello, iWork (Keynote/Numbers/Pages), Microsoft Office, Affinity Photo & Publisher
FontsNix (modules/ui.nix)Nerd Fonts (JetBrains Mono, Meslo LG, Hack, Fira Code, Sauce Code Pro), Cascadia Code, Inconsolata, Noto (+ CJK, emoji)
macOS defaultsNix (modules/ui.nix)Dock, Finder, trackpad, clock, screensaver, Window Manager, wallpaper
ServicesNix (modules/services.nix)weekly flake update, throttled brew update, power tuning (pmset), network/TCP tuning
SystemNix (modules/system.nix)Nix daemon settings, binary caches, env vars, application firewall
Terminalhome-manager (home/ghostty.nix)Catppuccin Macchiato, quick terminal, keybindings
Editorhome-manager (home/vscode.nix)Settings, keybindings, extension list
Shellhome-manager (home/zsh.nix)zsh + fzf + zoxide + autosuggestions + syntax highlighting + aliases
Githome-manager (home/git.nix)SSH signing, rebase-on-pull, fsck
SSHhome-manager (home/ssh.nix)Host configs (Tailscale)
Claude Codehome-manager (home/claude-code/)settings, hooks, agents, skills, commands, rules, mods, shell aliases
Codex CLIhome-manager (home/codex/)hooks.json + hook scripts (store symlinks), config.toml merge, hook-trust verification
Secretsgit-crypt (secrets.nix, backups/)Git email, SSH hosts, app config exports

Claude Code mods. home/claude-code/mods.nix lists the mods under home/claude-code/mods/ (TypeScript plugins of function hooks, no dependency, no sound). Activation copies them into ~/.claude/mods as real writable files — that folder is nix-owned, a hand-placed mod there is deleted on the next rebuild — and env.CLAUDE_CODE_PLUGIN_DIRS loads them. task-board: /task-board opens a "Tâches" pane listing background shells and subagents with their duration and state (en cours / fini / échoué); a subagent's still running shells turn échoué when it is killed, fails, or leaves the agent list (a teammate's: only when it leaves the list), since their notification would never reach the main loop; a shell also closes on its subagent's own notification row and on a TaskStop. apex-band: a band above the prompt shows the live APEX run of the working directory (title, mode, current step, branch, baseline) and nothing otherwise. status-bar: replaces the command status line. Drawn on the hint line under the prompt, it shows the model, folder, git branch, the last response's tokens in/out, the context bar with its percentage, and the 5h and 7d quota bars with their percentage and reset countdown: one row when it fits the width, else two, with the engine's own hint line still beneath. Read-only: the branch is read from .git/HEAD, no process is spawned. Run yourself after a rebuild, in a new session: /task-board, a background sleep 5 going from en cours to fini, a failing background command shown échoué, the band present during an APEX run and absent elsewhere, the status bar under the prompt on one row and on two in a narrow window.

Codex Hooks — Trusting Them After a Rebuild

Codex records hook trust per hook and by position, inside ~/.codex/config.toml, as a [hooks.state."<file>:<event>:<group>:<hook>"] table carrying a hash of the hook's content. A hook it has not been told to trust is skipped in silence — no error, no log, no protection, while the configuration still looks correct. That is why ~/.codex/config.toml is merged in place and never regenerated, and why home/codex/hooks.nix requires new hooks to be appended only at the END of an event's list.

Nothing outside Codex can grant that trust, and no Codex command reports it (codex doctor has no hook check). So the sequence after any change to the hooks is manual, and short:

  1. sudo darwin-rebuild switch --flake .#alex-mbp — activation merges config.toml, links hooks.json, then runs codex-verify-hook-trust.
  2. The verifier warns on both hooks. That is expected on a first rebuild and after any hook edit; a silent pass there would mean the check failed to notice, not that you are protected.
  3. Open Codex, run /hooks, review each hook and trust it.
  4. Record the reviewed baseline by hand: codex-verify-hook-trust -a. It writes the hash under ~/.local/state/, outside the agent's writable set, so nothing running inside a session can forge its own clean bill of health. Activation never passes -a: the review it records is a human act.

After that, rebuilds are silent until the hooks' content changes — then the warning returns and step 3 has to happen again.

The verifier never claims a hook IS trusted at runtime; it can only report that one is un-approved or stale. Its silence is not proof of protection.

Post Clean Install Checklist

  • ☐ SSH keys restored (~/.ssh/id_ed25519*)
  • ☐ VS Code extensions installed (~/.local/bin/vscode-install-extensions)
  • ☐ Default browser set (Arc)
  • ☐ 1Password logged in + browser extension
  • ☐ iCloud signed in (Desktop & Documents sync)
  • ☐ Arc signed in (sync spaces)
  • ☐ App logins: Discord, WhatsApp, Spark, Teams, Figma
  • ☐ Raycast settings imported (if backed up)
  • ☐ nix flake check green

APEX

APEX is the implementation workflow for Claude Code, declared in home/claude-code/skills.nix and guarded by checks/apex-consistency.nix. Every task that modifies files goes through it; a pure question does not.

Each phase runs as a fresh subagent and returns only a bounded summary, so the coordinator never accumulates raw context. Phase summaries are persisted to .claude/output/apex/{task-id}/.

Mode Gate

The gate picks the depth, never whether to run. Each mode carries a default flag set, applied to every flag you did not type. The tier (Direct, Standard, High-stakes) is decided on the diff, not on the brief: apex-tier (home/claude-code/apex-tier.nix) reads its size, its paths and the indentation ancestry of each changed line.

ModeDefault flagsNotes
Direct-pr≤ 4 files, ≤ 30 changed lines, no sensitive surface
Diagnosis-x -pr -o -nbug/crash — reproduce first, debugger agent implements, ships as a PR
Standard-t -pr -o -nfull orchestration
High-stakes-t -x -pr -o -n -eirreversible / security / architecture / prod — one examine reviewer, then Codex -e as read-only detector whose findings are triaged by evidence; Fable only as fallback when no usable external verdict (BLOCKED or -E), or under -p
Pure researchnoneanalyze only, no branch

Flags

Lowercase forces ON, uppercase forces OFF (-PR cancels an automatic -pr). Typed flags beat mode defaults.

EnableDisableDescription
-q-QClarify — ambiguities become up to 3 targeted questions before planning
-x-XExamine — adversarial, checklist-driven review
-t-TTest — create and run tests
-f-FTest-first — a separate agent writes failing tests from the ACs; read-only for the implementer
-2Divergence — second independent implementation of the core logic, behavioural diff
-p-PPremises — force/forbid the independent premises pass

| -e | -E | External verify — one cross-vendor read-only pass (Codex/GPT) over th

Source 3 files
hooks/index.tsx 257 lines
1// status-bar: the session's status under the prompt, in place of the
2// command status line: model, folder, git branch, the last response's
3// tokens in/out, the context bar and the 5h / 7d quota bars with their
4// reset countdowns. Drawn on the PromptHint line, one row when it fits the
5// width, else two; the engine's own hint line (`? for shortcuts`, `esc to
6// interrupt`, its pills) stays drawn beneath, live.
7// Observes only: turn.step, turn.complete and session.measure hooks return
8// next(e)'s result unchanged. Read-only: the branch is read from .git/HEAD.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register, TextProps, Timer } from 'claude-code'
12
13import type { StatusBarLimit, StatusBarStep, StatusBarUsage } from '../types'
14import { layout, parentDir, parseHead, pickModel, resolveGitdir } from './format.ts'
15import type { Seg } from './format.ts'
16
17// Reset countdowns move by the minute.
18const TICK_MS = 60_000
19// Folders walked up from the working directory to find `.git`.
20const MAX_DEPTH = 64
21
22const NO_USAGE: StatusBarUsage = { contextPercent: null, contextTokens: null, limits: [] }
23
24const usage = atom({ plugin: 'status-bar', key: 'usage' } as const, NO_USAGE)
25const step = atom({ plugin: 'status-bar', key: 'step' } as const, null)
26const model = atom({ plugin: 'status-bar', key: 'model' } as const, '')
27const cwd = atom({ plugin: 'status-bar', key: 'cwd' } as const, '')
28const branch = atom({ plugin: 'status-bar', key: 'branch' } as const, null)
29const now = atom({ plugin: 'status-bar', key: 'now' } as const, 0)
30
31// Module-level: a hot reload drops the environment and its timers with it.
32let timer: Timer | undefined
33
34// The engine's usage figures as the state keeps them (JSON values only:
35// no absent field, null instead).
36function toUsage(
37  context: { percent?: number; tokens?: number },
38  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[],
39): StatusBarUsage {
40  const limits: StatusBarLimit[] = rateLimits.map(l => ({
41    kind: l.kind,
42    percentUsed: l.percentUsed,
43    resetsAt: l.resetsAt ?? null,
44  }))
45  return { contextPercent: context.percent ?? null, contextTokens: context.tokens ?? null, limits }
46}
47
48// Writes only on change, so readers redraw only when a figure moved (the
49// state library reads and writes named atoms only: one helper per atom).
50async function putUsage($: EngineInterface, value: StatusBarUsage): Promise<void> {
51  if (JSON.stringify(await read($, usage)) === JSON.stringify(value)) return
52  await update($, usage, () => value)
53}
54
55async function putStep($: EngineInterface, value: StatusBarStep | null): Promise<void> {
56  if (JSON.stringify(await read($, step)) === JSON.stringify(value)) return
57  await update($, step, () => value)
58}
59
60async function putModel($: EngineInterface, value: string): Promise<void> {
61  if ((await read($, model)) === value) return
62  await update($, model, () => value)
63}
64
65async function putCwd($: EngineInterface, value: string): Promise<void> {
66  if ((await read($, cwd)) === value) return
67  await update($, cwd, () => value)
68}
69
70async function putBranch($: EngineInterface, value: string | null): Promise<void> {
71  if ((await read($, branch)) === value) return
72  await update($, branch, () => value)
73}
74
75async function putNow($: EngineInterface, value: number): Promise<void> {
76  if ((await read($, now)) === value) return
77  await update($, now, () => value)
78}
79
80// The branch HEAD names at `path`; null when unreadable or detached.
81async function readHead($: EngineInterface, path: string): Promise<string | null> {
82  try {
83    return parseHead(await $.fs.read(path)) ?? null
84  } catch {
85    // No HEAD there (a broken worktree link): no branch shown.
86    return null
87  }
88}
89
90// The checked-out branch of the repository holding `dir`: the nearest
91// `.git` above it, a folder, or a worktree's file naming its git folder.
92async function findBranch($: EngineInterface, dir: string): Promise<string | null> {
93  let at = dir
94  for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
95    const dotGit = `${at === '/' ? '' : at}/.git`
96    let kind: string | undefined
97    try {
98      kind = (await $.fs.stat(dotGit)).kind
99    } catch {
100      // No .git here: look one folder up.
101      kind = undefined
102    }
103    if (kind === 'dir') return readHead($, `${dotGit}/HEAD`)
104    if (kind === 'file') {
105      let text: string
106      try {
107        text = await $.fs.read(dotGit)
108      } catch {
109        // Unreadable .git file: branch unknown.
110        return null
111      }
112      const gitdir = resolveGitdir(text, at)
113      return gitdir === undefined ? null : readHead($, `${gitdir}/HEAD`)
114    }
115    const up = parentDir(at)
116    if (up === at) return null
117    at = up
118  }
119  return null
120}
121
122// Each figure on its own: one refused read leaves the others current.
123async function refresh($: EngineInterface): Promise<void> {
124  const jobs: Promise<void>[] = [
125    $.clock.now().then(at => putNow($, at)),
126    $.session.usage().then(u => putUsage($, toUsage(u.context, u.rateLimits))),
127    $.session.model().then(m => putModel($, m)),
128    $.session.cwd().then(async dir => {
129      await putCwd($, dir)
130      await putBranch($, await findBranch($, dir))
131    }),
132  ]
133  const results = await Promise.allSettled(jobs)
134  const failed = results.find(r => r.status === 'rejected')
135  if (failed !== undefined) throw failed.reason
136}
137
138function onTick($: EngineInterface): void {
139  refresh($).catch(() => {
140    // A refused read or write leaves that figure as drawn; the next tick
141    // (TICK_MS later), turn or measurement refreshes it again.
142  })
143}
144
145// Started from session.start and lazily from prompt.submit: session.start
146// does not fire again after a hot reload.
147function ensurePolling($: EngineInterface): void {
148  if (timer !== undefined) return
149  timer = $.clock.every(TICK_MS, () => onTick($))
150  $.clock.after(0, () => onTick($))
151}
152
153// Runs `write`; a refused state write leaves the bar as it was (an observer
154// never fails the event it watches).
155async function record(write: () => Promise<void>): Promise<void> {
156  try {
157    await write()
158  } catch {
159    // Display only: the next step, turn or tick writes again.
160  }
161}
162
163// A segment's Text props: only the styles it sets, never an undefined prop.
164function segProps(seg: Seg): TextProps {
165  return {
166    ...(seg.color === undefined ? {} : { color: seg.color }),
167    ...(seg.dim === true ? { dimColor: true } : {}),
168  }
169}
170
171export const register: Register = on => {
172  on('session.start', async ($, e, next) => {
173    ensurePolling($)
174    return next(e)
175  })
176
177  on('prompt.submit', ($, e, next) => {
178    ensurePolling($)
179    return next(e)
180  }).catch(($, e, next) => next(e))
181
182  // Pushed when the context fill or a quota window moves.
183  on('session.measure', async ($, e, next) => {
184    await record(() => putUsage($, toUsage(e.context, e.rateLimits)))
185    return next(e)
186  }).catch(($, e, next) => next(e))
187
188  // Each main-loop response: its tokens in/out (the status line's
189  // total_input_tokens / total_output_tokens) and the model that answered.
190  on('turn.step', async function* ($, e, next) {
191    const result = yield* next(e)
192    const used = result.usage
193    if (e.agentId === undefined && used !== null) {
194      const tokensIn = used.input_tokens + used.cache_creation_input_tokens + used.cache_read_input_tokens
195      await record(() => putStep($, { tokensIn, tokensOut: used.output_tokens, model: used.model }))
196    }
197    return result
198  }).catch(async function* ($, e, next) {
199    return yield* next(e)
200  })
201
202  // A main-loop turn ended: the branch may have moved, the model changed.
203  on('turn.complete', async ($, e, next) => {
204    const done = await next(e)
205    if (e.agentId === undefined) await record(() => refresh($))
206    return done
207  }).catch(($, e, next) => next(e))
208
209  on('session.end', async ($, e, next) => {
210    if (e.reason === 'clear') {
211      // No session.start follows a /clear: the timer is kept, counts reset.
212      await record(() => putStep($, null))
213      await record(() => refresh($))
214    } else {
215      timer?.cancel()
216      timer = undefined
217    }
218    return next(e)
219  })
220
221  // Our rows, then the engine's own hint line (what next(e) answers: its
222  // drawing, pills live). Reads state only.
223  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
224    const { Box, Text } = $.ui.resolve(e)
225    const last = await read($, step)
226    const figures = await read($, usage)
227    const lines = layout(
228      {
229        model: pickModel(await read($, model), last?.model),
230        cwd: await read($, cwd),
231        branch: await read($, branch),
232        tokensIn: last?.tokensIn ?? figures.contextTokens ?? 0,
233        tokensOut: last?.tokensOut ?? 0,
234        contextPercent: figures.contextPercent ?? 0,
235        limits: figures.limits,
236        now: await read($, now),
237      },
238      e.viewport?.columns,
239    )
240    const theirs = await next(e)
241    return (
242      <Box flexDirection="column">
243        {lines.map(line => (
244          <Box flexDirection="row">
245            {line.map(seg => (
246              <Text {...segProps(seg)} wrap="truncate-end">
247                {seg.text}
248              </Text>
249            ))}
250          </Box>
251        ))}
252        {theirs}
253      </Box>
254    )
255  })
256}
257
hooks/format.ts 210 lines
1// Pure logic of the status bar: model name, bars, reset countdowns,
2// thousands separators, git HEAD parsing and the one-or-two-line layout.
3// Same figures, colours, thresholds and formats as the former command
4// status line (statusline.sh). Widths are counted in terminal cells by an
5// approximation (cellWidth): the bar's emoji count 2, the rest 1.
6
7import type { Color } from 'claude-code'
8
9import type { StatusBarLimit } from '../types'
10
11// One run of text drawn with one style; index.tsx maps it to a Text.
12export type Seg = { text: string; color?: Color; dim?: boolean }
13
14// The script's ANSI colours as their xterm-256 hex, the same on every surface.
15export const PALETTE = {
16  red: '#ff5f5f',
17  orange: '#ff8700',
18  yellow: '#ffd75f',
19  green: '#5fd75f',
20  cyan: '#5fd7ff',
21  grey: '#6c6c6c',
22} as const
23
24export const BAR_CELLS = 10
25const FULL = '█'
26const EMPTY = '░'
27const SEP = ' | '
28// Cells left free at the row's end before the bar breaks onto two lines.
29const MARGIN = 4
30
31// `claude-opus-5-5[1m]` → `Opus 5.5`, `claude-3-5-sonnet-20241022` →
32// `Sonnet 3.5`, `opus` → `Opus`; a display name (`Opus 5.5 (1M context)`)
33// loses its parenthesis. The family is the first word that is no number.
34export function modelName(id: string): string {
35  const bare = id
36    .replace(/\[[^\]]*\]/g, '')
37    .replace(/\s*\([^)]*\)/g, '')
38    .trim()
39  if (bare === '') return ''
40  if (/\s/.test(bare)) return bare
41  const words = bare
42    .replace(/^claude-/i, '')
43    .replace(/-\d{8}$/, '')
44    .replace(/-latest$/i, '')
45    .split('-')
46    .filter(w => w !== '')
47  const family = words.find(w => !/^\d+$/.test(w))
48  const version = words.filter(w => /^\d+$/.test(w)).join('.')
49  if (family === undefined) return bare
50  const name = family.charAt(0).toUpperCase() + family.slice(1)
51  return version === '' ? name : `${name} ${version}`
52}
53
54// The session's model as /model has it; an alias without a version
55// (`opus[1m]`) takes the version of the API's model of the same family.
56export function pickModel(session: string, step: string | undefined): string {
57  const fromSession = modelName(session)
58  if (step === undefined) return fromSession
59  const fromStep = modelName(step)
60  if (fromSession === '') return fromStep
61  const isBare = !/\d/.test(fromSession)
62  const sameFamily = fromStep.split(' ')[0]?.toLowerCase() === fromSession.toLowerCase()
63  return isBare && sameFamily ? fromStep : fromSession
64}
65
66// A percentage as the script rounds it (jq round: half away from zero).
67export function roundPct(value: number): number {
68  return value < 0 ? -Math.round(-value) : Math.round(value)
69}
70
71// A 10-cell bar: filled cells from the whole tens of `pct` (clamped 0-100),
72// green under 60, orange under 85, red from 85; empty cells grey.
73export function bar(pct: number): Seg[] {
74  const clamped = Math.min(100, Math.max(0, Math.trunc(pct)))
75  const filled = Math.floor(clamped / 10)
76  const color = clamped >= 85 ? PALETTE.red : clamped >= 60 ? PALETTE.orange : PALETTE.green
77  const segs: Seg[] = []
78  if (filled > 0) segs.push({ text: FULL.repeat(filled), color })
79  if (filled < BAR_CELLS) segs.push({ text: EMPTY.repeat(BAR_CELLS - filled), color: PALETTE.grey })
80  return segs
81}
82
83// Time left until `resetMs`: under an hour `42min`, under a day `5.07h`
84// (the digits after the dot are minutes, 00-59), else `5j 0h`.
85export function fmtReset(resetMs: number, nowMs: number): string {
86  const seconds = Math.max(0, Math.floor((resetMs - nowMs) / 1000))
87  const mins = Math.floor(seconds / 60)
88  if (mins < 60) return `${mins}min`
89  if (mins < 1440) return `${Math.floor(mins / 60)}.${String(mins % 60).padStart(2, '0')}h`
90  return `${Math.floor(mins / 1440)}j ${Math.floor((mins % 1440) / 60)}h`
91}
92
93// 1234567 → `1,234,567` (the en_US grouping the script's printf used).
94export function thousands(value: number): string {
95  const whole = Math.trunc(value)
96  const sign = whole < 0 ? '-' : ''
97  return sign + String(Math.abs(whole)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
98}
99
100// The branch `.git/HEAD` names (`ref: refs/heads/feat/x` → `feat/x`); a
101// detached HEAD names none, as `git branch --show-current` prints nothing.
102export function parseHead(text: string): string | undefined {
103  const name = /^ref:\s*refs\/heads\/(\S+)\s*$/.exec(text.trim())?.[1]
104  return name === undefined || name === '' ? undefined : name
105}
106
107// The git directory a `.git` file points at (`gitdir: ../.git/worktrees/x`),
108// made absolute against the folder holding that file.
109export function resolveGitdir(text: string, dir: string): string | undefined {
110  const target = /^gitdir:\s*(.+?)\s*$/m.exec(text)?.[1]
111  if (target === undefined || target === '') return undefined
112  if (target.startsWith('/')) return target
113  return `${dir === '/' ? '' : dir}/${target}`
114}
115
116// The folder above `dir`; the root is its own parent.
117export function parentDir(dir: string): string {
118  const trimmed = dir.replace(/\/+$/, '')
119  const cut = trimmed.lastIndexOf('/')
120  return cut <= 0 ? '/' : trimmed.slice(0, cut)
121}
122
123// The last path component, trailing slashes ignored; the root reads `/`.
124export function basename(path: string): string {
125  const trimmed = path.replace(/\/+$/, '')
126  const name = trimmed.slice(trimmed.lastIndexOf('/') + 1)
127  return name === '' ? '/' : name
128}
129
130// Cells a code point takes: 2 for emoji and CJK, 0 for joiners, variation
131// selectors and combining marks, 1 otherwise.
132function pointWidth(code: number): number {
133  if (code === 0x200d || (code >= 0xfe00 && code <= 0xfe0f) || (code >= 0x0300 && code <= 0x036f)) return 0
134  if (
135    code === 0x23f3 ||
136    (code >= 0x1100 && code <= 0x115f) ||
137    (code >= 0x2e80 && code <= 0xa4cf) ||
138    (code >= 0xac00 && code <= 0xd7a3) ||
139    (code >= 0xf900 && code <= 0xfaff) ||
140    (code >= 0xff00 && code <= 0xff60) ||
141    (code >= 0x1f300 && code <= 0x1faff) ||
142    (code >= 0x20000 && code <= 0x3fffd)
143  ) {
144    return 2
145  }
146  return 1
147}
148
149export function cellWidth(text: string): number {
150  let width = 0
151  for (const point of text) width += pointWidth(point.codePointAt(0) ?? 0)
152  return width
153}
154
155const lineWidth = (line: readonly Seg[]): number => line.reduce((sum, seg) => sum + cellWidth(seg.text), 0)
156
157export type StatusData = {
158  model: string
159  cwd: string
160  branch: string | null
161  tokensIn: number
162  tokensOut: number
163  contextPercent: number
164  limits: readonly StatusBarLimit[]
165  now: number
166}
167
168const sep = (): Seg => ({ text: SEP, dim: true })
169
170// Group 1: model, folder, branch (when known), tokens in/out.
171export function sessionGroup(data: StatusData): Seg[] {
172  const segs: Seg[] = [
173    { text: `🤖 ${data.model === '' ? '?' : data.model}`, color: PALETTE.red },
174    sep(),
175    { text: `📁 ${basename(data.cwd)}`, color: PALETTE.orange },
176  ]
177  if (data.branch !== null && data.branch !== '') segs.push(sep(), { text: `⎇ ${data.branch}`, color: PALETTE.yellow })
178  segs.push(sep(), { text: `📊 ${thousands(data.tokensIn)}/${thousands(data.tokensOut)}`, color: PALETTE.green })
179  return segs
180}
181
182// One quota window: glyph, bar, percentage and the time to its reset.
183function limitSegs(glyph: string, limit: StatusBarLimit, now: number): Seg[] {
184  const pct = roundPct(limit.percentUsed)
185  const resetMs = limit.resetsAt === null ? Number.NaN : Date.parse(limit.resetsAt)
186  const reset = Number.isFinite(resetMs) ? ` · ${fmtReset(resetMs, now)}` : ''
187  return [sep(), { text: `${glyph} ` }, ...bar(pct), { text: ` ${pct}%${reset}`, color: PALETTE.cyan }]
188}
189
190// Group 2: context bar, then the 5h and 7d windows that have a reading.
191export function usageGroup(data: StatusData): Seg[] {
192  const ctx = roundPct(data.contextPercent)
193  const segs: Seg[] = [{ text: '🧠 ' }, ...bar(ctx), { text: ` ${ctx}%`, color: PALETTE.cyan }]
194  const five = data.limits.find(l => l.kind === 'five_hour')
195  const seven = data.limits.find(l => l.kind === 'seven_day')
196  if (five !== undefined) segs.push(...limitSegs('⏳', five, data.now))
197  if (seven !== undefined) segs.push(...limitSegs('📆', seven, data.now))
198  return segs
199}
200
201// One line when it fits `cols` (less a margin), else group 1 over group 2;
202// one line too when the width is unknown.
203export function layout(data: StatusData, cols: number | undefined): Seg[][] {
204  const first = sessionGroup(data)
205  const second = usageGroup(data)
206  const one = [...first, sep(), ...second]
207  if (cols === undefined || lineWidth(one) <= cols - MARGIN) return [one]
208  return [first, second]
209}
210
types/index.d.ts 31 lines
1// status-bar state contract: what the hooks module keeps in $.state.
2// Self-contained (no import), as the plugin-authoring reference asks.
3
4// One rate-limit window as $.session.usage() reports it (`five_hour`,
5// `seven_day`); `resetsAt` is an ISO 8601 timestamp, null when unknown.
6export type StatusBarLimit = { kind: string; percentUsed: number; resetsAt: string | null }
7
8// The figures of $.session.usage(); null where the engine has none yet.
9export type StatusBarUsage = {
10  contextPercent: number | null
11  contextTokens: number | null
12  limits: StatusBarLimit[]
13}
14
15// The main loop's last model response: input (uncached + cache write + cache
16// read) and output tokens, and the model id the API reported.
17export type StatusBarStep = { tokensIn: number; tokensOut: number; model: string }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'status-bar': {
22      usage: StatusBarUsage
23      step: StatusBarStep | null
24      model: string
25      cwd: string
26      branch: string | null
27      now: number
28    }
29  }
30}
31