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

Declarative macOS system configuration using Nix flakes
[]() [
]()
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.
# 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:
| # | Step | Notes |
|---|---|---|
| 1 | Xcode Command Line Tools | |
| 2 | Nix package manager | Determinate Systems installer |
| 3 | Homebrew | |
| 4 | 1Password | pauses — you retrieve SSH keys + git-crypt key from the vault |
| 5 | SSH key permissions | chmod 700 ~/.ssh, 600 on the private key |
| 6 | GitHub CLI authentication | gh auth login |
| 7 | Decrypt secrets | git-crypt unlock |
| 8 | App Store login | pauses — sign in for masApps |
| 9 | darwin-rebuild switch | the actual build |
| 10 | Switch git remote to SSH | |
| 11 | VS Code extensions | vscode-install-extensions |
| 12 | Restore app configs | Plex, 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
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.
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.
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
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
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
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.
| Check | What it enforces |
|---|---|
format-check | nixfmt --check over every tracked *.nix (find walk, no per-directory list) |
system-config | The whole alex-mbp darwin configuration actually builds |
agent-instructions | The 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-consistency | The APEX skill keeps its critical clauses, flag casing, subagent isolation, and step-file references |
apex-plan-provenance | Every 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-tier | The 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-config | Claude Code invariants: JSON parses, sandbox denies ~/.ssh and secrets, agents declare a model, haiku only on read-only agents, rules declare paths |
claude-mods | Claude 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-config | Codex 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-wiring | Claude 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-lint | ESLint (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-consistency | This 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-cli | The 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.
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 -dwarns$HOME is not owned by youand falls back to root's profile (leaving user generations behind), run the user sweep without sudo as well:nix-collect-garbage -d.
Each row points at the module that owns it — that file is the authoritative list, deliberately not duplicated here.
| Layer | Tool | Contents |
|---|---|---|
| CLI tools | Nix (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 apps | Homebrew 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 & taps | Homebrew (modules/brew.nix) | cloudflared, ffmpeg, displayplacer, mas, postgresql@16, trash, rtk-ai/tap/rtk |
| App Store | mas (modules/brew.nix) | DaisyDisk, Trello, iWork (Keynote/Numbers/Pages), Microsoft Office, Affinity Photo & Publisher |
| Fonts | Nix (modules/ui.nix) | Nerd Fonts (JetBrains Mono, Meslo LG, Hack, Fira Code, Sauce Code Pro), Cascadia Code, Inconsolata, Noto (+ CJK, emoji) |
| macOS defaults | Nix (modules/ui.nix) | Dock, Finder, trackpad, clock, screensaver, Window Manager, wallpaper |
| Services | Nix (modules/services.nix) | weekly flake update, throttled brew update, power tuning (pmset), network/TCP tuning |
| System | Nix (modules/system.nix) | Nix daemon settings, binary caches, env vars, application firewall |
| Terminal | home-manager (home/ghostty.nix) | Catppuccin Macchiato, quick terminal, keybindings |
| Editor | home-manager (home/vscode.nix) | Settings, keybindings, extension list |
| Shell | home-manager (home/zsh.nix) | zsh + fzf + zoxide + autosuggestions + syntax highlighting + aliases |
| Git | home-manager (home/git.nix) | SSH signing, rebase-on-pull, fsck |
| SSH | home-manager (home/ssh.nix) | Host configs (Tailscale) |
| Claude Code | home-manager (home/claude-code/) | settings, hooks, agents, skills, commands, rules, mods, shell aliases |
| Codex CLI | home-manager (home/codex/) | hooks.json + hook scripts (store symlinks), config.toml merge, hook-trust verification |
| Secrets | git-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 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:
sudo darwin-rebuild switch --flake .#alex-mbp — activation merges config.toml, links hooks.json, then runs codex-verify-hook-trust./hooks, review each hook and trust it.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.
~/.ssh/id_ed25519*)~/.local/bin/vscode-install-extensions)nix flake check greenAPEX 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}/.
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.
| Mode | Default flags | Notes |
|---|---|---|
| Direct | -pr | ≤ 4 files, ≤ 30 changed lines, no sensitive surface |
| Diagnosis | -x -pr -o -n | bug/crash — reproduce first, debugger agent implements, ships as a PR |
| Standard | -t -pr -o -n | full orchestration |
| High-stakes | -t -x -pr -o -n -e | irreversible / 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 research | none | analyze only, no branch |
Lowercase forces ON, uppercase forces OFF (-PR cancels an automatic -pr). Typed flags beat mode defaults.
| Enable | Disable | Description |
|---|---|---|
-q | -Q | Clarify — ambiguities become up to 3 targeted questions before planning |
-x | -X | Examine — adversarial, checklist-driven review |
-t | -T | Test — create and run tests |
-f | -F | Test-first — a separate agent writes failing tests from the ACs; read-only for the implementer |
-2 | Divergence — second independent implementation of the core logic, behavioural diff | |
-p | -P | Premises — force/forbid the independent premises pass |
| -e | -E | External verify — one cross-vendor read-only pass (Codex/GPT) over th
hooks/index.tsx 257 lines1// 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}
257hooks/format.ts 210 lines1// 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}
210types/index.d.ts 31 lines1// 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