SLOPSHOPPER

bar

A status bar above the prompt (context, plan limits, the running turn's time and tokens), and a frame around each final answer

newpanebandspinnerrowsguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bar
│ ┃ bar-tools ✕ › fix the failing auth test and a╭─────────────────────────────────╮ │ ┃ 9 calls · click one for its input and output │ bar │ │ ┃ ⏺ Read(src/auth.ts) │ Starting Remote Control for app │ │ ┃ ● ▸ Read /work/app/src/auth.ts 0s ⎿ Read 6 lines ╰─────────────────────────────────╯ │ ┃ ● ▸ Grep /work/app/src 0s ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ │ ┃ ● ▸ Edit /work/app/src/auth.ts 0s ⎿ Added 2 lines, re│ bar │ │ ┃ ● ▸ Write /work/app/src/audit.ts 0s ⏺ Bash(bun test) │ Hand off needs /mattpocock-skills:handoff: │ │ ┃ ● ▸ Write /work/app/src/cache.ts 0s ⎿ 3 pass, 1 fail │ install mattpocock-skills@mattpocock │ │ ┃ ● ▸ Bash Run the test suite 0s ╰────────────────────────────────────────────╯ │ ┃ ● ▸ Bash Show working tree status 0s ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ● ▸ Bash Clean build output and push 0s │ ┃ ● ▸ Bash Show environment file 0s ✻ Worked for 42s · done 4:20 PM │ │ › /bar-limit │ ⎿ bar: The context meter fills at 200k tokens, the model's full wi │ │ ⟨Claude Code's own drawing⟩ Context ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ○ Last turn 0s 1.5k out 91k read 4.3k write │ 9 tools › ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ Sessions ○ Remote

Draws

Band
⟨Claude Code's own drawing⟩ Context ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━ ━
Pane · bar-tools
9 calls · click one for its input and output ● ▸ Read /work/app/src/auth.ts 0s ● ▸ Grep /work/app/src 0s ● ▸ Edit /work/app/src/auth.ts 0s ● ▸ Write /work/app/src/audit.ts 0s ● ▸ Write /work/app/src/cache.ts 0s ● ▸ Bash Run the test suite 0s ● ▸ Bash Show working tree status 0s ● ▸ Bash Clean build output and push 0s ● ▸ Bash Show environment file 0s
Pane · bar-remote
No Remote Control yet. Turn it on with the Remote toggle in the footer.
README

Bar for Claude Code

A Claude Code mod that adds a status bar above the prompt, a live timer on every turn, and a frame that sets each final answer apart from the work before it. It works in the Claude desktop app's Code tab and in the terminal.

What it shows

The bar, above the prompt

One panel with two rows. Row 1 is capacity: how full things are. Row 2 is right now: the turn, its tools, the prompt cache and Hand off. Every gauge is the same drawing: a thin track, a fill that brightens toward its end, and a bead (a dot with a white centre) marking where you are. Hover any item and its full detail appears laid over the other row.

Row 1: capacity

  • Context ━━━━●───┊──┊── 216k / 1.0M 22%: how full this chat's context window is. The bold number is the tokens in the conversation, / 400k is the limit (your auto-compact window when you've set one, else the model's window, or your own from /bar-limit), then the share. The faint ticks on the track mark where the next colours start; only the ones still ahead are drawn. Click Context to run /usage.
  • 5h and Week ━━●─┃── 30% 07:20: your plan's 5-hour window and weekly limit. The bold percentage is how much you've used; the dim time or day is when it resets. The upright mark on the line is now: how much of the window has passed. Bead left of the mark: you're using it slower than the clock, fine. Bead right of the mark: you're ahead of pace and may run out before it resets. Hover for the exact reset date and where you'll land at this pace.

Row 2: right now

  • The beat: an orange bead with a ring swelling once a second means Claude is working; the beat keeps time with the timer. A hollow grey ring means idle, and the row shows the last turn.
  • Working 1m 04s (orange) or Last turn 16s: how long the turn has run, or took.
  • The sparkline (while working): output tokens per second over the last 24 seconds, the bead being now. High means Claude is writing fast; flat means it's thinking, or waiting on a tool.
  • The figures: out is the tokens Claude wrote (a + means more are still streaming in), read the tokens served from the prompt cache, write the tokens newly written to it. Hover the turn for all four, with in (input outside the cache) and the tool calls.
  • 3 tools ›: how many tools Claude called this turn. Click it for a panel listing every call, each opening to its input and output.
  • Cache 59:27: how long the conversation stays in the prompt cache, counted down from Claude's last response (1 hour or 5 minutes, whichever the session uses). Once it expires, the next message has to write the whole conversation to the cache again. Hover for the exact expiry time and how many tokens that would be.
  • Hand off → (or /bar-handoff): runs /mattpocock-skills:handoff to write a handoff document, then opens a new Code session in the same folder that continues from it. The current chat stays exactly as it is: it is never cleared or compacted. The button is outlined most of the time and turns solid once the cache is running out (orange or red), the moment it's worth pressing. While it works it reads Writing…, Opening…, then Opened ✓ (or Failed, with the reason on hover). The desktop app asks you to trust the folder for the new session. When that session runs Bar, it sends the continue prompt by itself; otherwise the prompt is waiting in its message box. Requires the mattpocock-skills plugin.

The colours

ColourOn a meter or a countdown
Greenunder half used
Yellowfrom half (levels.yellow, 50%)
Orangefrom three quarters (levels.orange, 75%)
Redat or past the limit (levels.red, 100%); the cache has expired

Orange in the turn (the beat, the timer, the sparkline) is Claude's own colour: it only means Claude is working. A bold number is the value to read; dim text is a label or a unit.

In the terminal the same panel is drawn in characters: ━ used, ─ the track, ┃ the now mark, ● working, ○ idle, │ between items, and Hand off as [ Hand off → ].

The app already shows the repo, branch, uncommitted changes and running tasks above the prompt, so Bar leaves those out.

Every turn

  • The final answer in an orange frame. Under the frame: how long the turn took, plus its input, output, cache-read and cache-write tokens.

Remote Control, in the prompt footer

A ○ Remote toggle sits at the right of the footer under the message box. Click it, or run /remote, to start Remote Control for the project folder, and again to stop it. Its mark shows the state:

  • ○ Remote: off
  • 🟡 Remote…: starting
  • 🟢 Remote: connected
  • 🔴 Remote: the process stopped (the reason is in Remote sessions)

Beside it, Sessions opens a list of every folder that has had a Remote Control, running ones first, each with its folder and how long it has run (a stopped one shows when it stopped and how long it ran). Each row offers Stop while running, and Start and Remove once stopped. Stop all ends every running one. Remote Control processes you started yourself with claude remote-control show up too, marked "started outside Bar", and can be stopped.

Anything another mod adds to the footer (a button of its own, say) stays there, before Sessions.

There's one Remote Control per folder, shared by every session in it. It keeps running after the session that started it ends, and any session opened in that folder later shows it and can turn it off. Its process ID and log are kept in ~/.claude/bar/remote/.

Install

In Claude Code:

/plugin marketplace add AltamimiYasser/claude-code-bar
/plugin install bar@claude-code-bar

Or from a shell:

claude plugin marketplace add AltamimiYasser/claude-code-bar
claude plugin install bar@claude-code-bar

Start a new session afterwards.

Requirement: Bar is a mod, a plugin made of function hooks, and function hooks are in early access. If the bar doesn't appear, set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in your environment, or in the env block of ~/.claude/settings.json.

Commands

CommandWhat it does
/bar-limit 300kSets where the context meter is full and turns red. It accepts 300k, 1.5m or 250000, and is capped at the model's own window. /bar-limit model uses the model's whole window; /bar-limit off goes back to the default, your auto-compact window; /bar-limit on its own shows the current value.
/remoteTurns Remote Control on or off for this folder, like the footer toggle. /remote list opens Remote sessions; /remote stop-all stops every running one.
/bar-handoffWrites a handoff and continues in a new session, like the Hand off button.
/bar-demoSteps through sample bars (no plan limits, light use, getting full, past the limit), then back to your live bar.

Settings

Everything Bar shows can be turned off, in ~/.claude/bar/settings.jsonc. Bar writes the file the first time it runs, with every option set to its default and a comment explaining it. Changes apply within a few seconds, with no restart. A wrong value falls back to its default, and a file that doesn't parse keeps the last good settings and shows a notice.

OptionDefaultWhat it controls
bar.enabledtrueThe whole bar above the prompt
bar.hovertrueEvery hover effect: the details, the underlines
bar.pillBorderstrueThe faint outline around the bar's panel (desktop)
context.enabledtrueThe Context meter
context.limit"autoCompact"Where the meter is full: "autoCompact" for your auto-compact window (autoCompactWindow in Claude Code's settings; the model's window when none is set), "model" for the model's own window, or a token count. An older file's null reads as "autoCompact"
context.click"usage"Clicking "Context": "usage" runs /usage, "none" does nothing
planLimits.fiveHour / weeklytrueThe 5h and Week meters
planLimits.resetTimetrueThe reset time after each percentage
planLimits.hoverDetailstrueThe exact reset date while hovering 5h or Week
planLimits.pacetrueThe "now" mark on the 5h and Week meters, and the pace estimate in their details
turn.enabledtrueThe turn on row 2 (the cache and Hand off stay)
turn.whileWorking / lastTurntrueThe live turn (beat, time, sparkline), and the last turn between turns
turn.tokenstrueThe token figures on row 2
turn.toolCalls / toolCallsPaneltrueThe tool-call count, and the panel it opens
answer.frame / footer / footerTokenstrueThe answer frame, the line under it, and its token figures
spinnerTimertrueThe live time beside the app's working indicator
cache.enabledtrueThe Cache countdown
cache.handofftrueThe Hand off button beside it (/bar-handoff works either way)
remote.enabledtrueThe Remote Control toggle and /remote
remote.autoStart"newProjects"Start Remote Control by itself: "never", "newProjects" (a project's first session) or "always"
remote.notificationstrueThe on/off notices
remote.sessionsButtontrueThe Remote sessions button in the footer
levels.yellow / orange / red0.5 / 0.75 / 1.0The share of a limit where colors change

Development

The plugin lives in plugins/bar. Check it with:

claude plugin validate plugins/bar

For type checking, open Claude Code in plugins/bar, run /plugin-types (it writes the API declarations to .claude/types), then run:

npx -p typescript tsc -p plugins/bar

Run the tests (the plugin test kit needs function hooks switched on):

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/bar

To load your working copy instead of the installed one:

claude --plugin-dir plugins/bar

License

MIT

Source 11 files
hooks/register.tsx 20 lines
1import type { Register } from 'claude-code'
2
3import { registerBand } from './band'
4import { registerCache } from './cache'
5import { registerOneClick } from './one-click'
6import { PANEL as REMOTE_PANEL, registerRemote } from './remote'
7import { registerTools, TOOLS_PANEL } from './tools'
8import { registerTurns } from './turns'
9
10// The band above the prompt, its Tool calls panel, the per-turn timer and
11// the Remote Control toggle in the prompt footer share one mod.
12export const register: Register = (on, options) => {
13  registerBand(on, options)
14  registerCache(on, options)
15  registerTurns(on, options)
16  registerTools(on, options)
17  registerRemote(on, options)
18  registerOneClick(on, [REMOTE_PANEL, TOOLS_PANEL])
19}
20
hooks/band.tsx 940 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  EngineInterface,
4  Register,
5  RenderChildren,
6  SessionContextUsage,
7  SessionRateLimit,
8} from 'claude-code'
9
10import type { BarData, BarSettings, CompactWindow, ContextLimit, Fill, Limit, Tokens, Turn } from '../types'
11import { cacheExpiry } from './cache'
12import { clockTime, colorFor, countdown, duration, longDate, resetTime, shortCount, until } from './format'
13import { hruleSvg, meterSvg, pulseSvg, restingSvg, ruleSvg, sparkSvg } from './meter'
14import { DEFAULTS, parseJsonc, SETTINGS_FILE, TEMPLATE, toSettings, withLimit } from './settings'
15import { liveSnapshot } from './turns'
16
17// Whether a tree drawn beneath this hook shows anything: the engine's own
18// answer for the band is an empty Box.
19const hasContent = (element: unknown) => {
20  const tree = element as { type?: string; children?: unknown[] } | null | undefined
21
22  return Boolean(tree) && !(tree?.type === 'Box' && (tree.children ?? []).length === 0)
23}
24
25// The smallest limit /bar-limit takes; the largest is the model's window.
26const MIN_LIMIT = 10_000
27// How often the settings file is checked for changes.
28const SETTINGS_POLL_MS = 3_000
29// How often the auto-compact window is read again between responses, so an
30// edit to Claude Code's settings shows while idle.
31const COMPACT_POLL_MS = 60_000
32// In the terminal the context meter is two runs of the line glyph sharing
33// whatever room the pill has left, in proportion: this many steps, each run
34// long enough to fill the widest band, and cut at its box's edge.
35const METER_STEPS = 1_000
36const METER_GLYPHS = 400
37// The desktop's meters, in CSS pixels: tall enough for the bead's glow.
38const METER_HEIGHT = 14
39const WINDOW_METER_WIDTH = 52
40const SPARK_WIDTH = 72
41// The terminal's window meters, in cells.
42const WINDOW_METER_CELLS = 6
43// The running turn's time never needs more than `59m 59s`.
44const TIMER_CELLS = 7
45// The terminal's `[-]` at the band's top right, and a space before it.
46const COLLAPSE_CELLS = 4
47const WINDOW_MS: Record<string, number> = {
48  five_hour: 5 * 3_600_000,
49  seven_day: 7 * 24 * 3_600_000,
50}
51const WINDOW_NAMES: Record<string, string> = {
52  five_hour: '5-hour window',
53  seven_day: 'Weekly limit',
54}
55
56const fill = atom({ plugin: 'bar', key: 'fill' } as const, null)
57const compactAtom = atom({ plugin: 'bar', key: 'compactWindow' } as const, null)
58const limits = atom({ plugin: 'bar', key: 'limits' } as const, [])
59// The turns (written by the turn hooks): the bar shows the running turn's
60// time and tokens while Claude works.
61const turnList = atom({ plugin: 'bar', key: 'turns' } as const, [])
62const settingsAtom = atom({ plugin: 'bar', key: 'settings' } as const, null)
63const cacheAtom = atom({ plugin: 'bar', key: 'cache' } as const, null)
64const handoffAtom = atom({
65  plugin: 'bar',
66  key: 'handoff',
67} as const, { status: 'idle', path: null, clickedAt: null, turnId: null, detail: null })
68// What the Hand off button says as the handoff goes along.
69const HANDOFF_LABELS = {
70  idle: 'Hand off →',
71  requested: 'Writing…',
72  writing: 'Writing…',
73  opening: 'Opening…',
74  opened: 'Opened ✓',
75  error: 'Failed',
76} as const
77// The countdown never needs more than `60:00`.
78const COUNTDOWN_CELLS = 5
79// Which sample /bar-demo shows in place of the live bar; -1 is the live bar.
80// Starts on the fullest sample, the one with every element in it.
81const DEMO_START = 2
82const demo = atom({ plugin: 'bar', key: 'demo' } as const, DEMO_START)
83
84// The session may still hold the earlier on/off value; read it as the start.
85const demoIndex = (value: unknown) => (typeof value === 'number' ? value : DEMO_START)
86
87const HOUR = 3_600_000
88
89// Sample states for /bar-demo, from a fresh session to one past its limits.
90const samples = (now: number): { label: string; data: BarData }[] => [
91  {
92    label: 'API key (no plan limits), fresh session',
93    data: { tokens: 12_400, limit: 400_000, limits: [] },
94  },
95  {
96    label: 'Light use',
97    data: {
98      tokens: 148_000,
99      limit: 400_000,
100      limits: [
101        { kind: 'five_hour', percent: 18, resetsAt: new Date(now + 3 * HOUR).toISOString() },
102        { kind: 'seven_day', percent: 34, resetsAt: new Date(now + 80 * HOUR).toISOString() },
103      ],
104    },
105  },
106  {
107    label: 'Getting full',
108    data: {
109      tokens: 286_000,
110      limit: 400_000,
111      limits: [
112        { kind: 'five_hour', percent: 62, resetsAt: new Date(now + 2 * HOUR).toISOString() },
113        { kind: 'seven_day', percent: 71, resetsAt: new Date(now + 50 * HOUR).toISOString() },
114      ],
115    },
116  },
117  {
118    label: 'Past the limit, plan nearly used',
119    data: {
120      tokens: 431_000,
121      limit: 400_000,
122      limits: [
123        { kind: 'five_hour', percent: 96, resetsAt: new Date(now + 0.4 * HOUR).toISOString() },
124        { kind: 'seven_day', percent: 88, resetsAt: new Date(now + 20 * HOUR).toISOString() },
125      ],
126    },
127  },
128]
129
130const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'Week' }
131
132// The panel's outline: a mid grey, faint, which reads on a light and a dark
133// background alike.
134const PANEL_BORDER = 'rgba(128, 128, 128, 0.32)'
135
136// `300k`, `1.5m`, `250000`: a token count as a person types one.
137const parseCount = (text: string) => {
138  const match = /^([\d.]+)\s*([km]?)$/i.exec(text.trim().replaceAll(',', ''))
139
140  if (!match) {
141    return null
142  }
143
144  const [, amount = '', unit = ''] = match
145  const scale = { '': 1, k: 1_000, m: 1_000_000 }[unit.toLowerCase() as '' | 'k' | 'm']
146  const value = Math.round(Number(amount) * scale)
147
148  return Number.isFinite(value) && value > 0 ? value : null
149}
150
151// Before the first response there are no tokens yet, but the window is known.
152const toFill = (context: SessionContextUsage): Fill => ({ tokens: context.tokens ?? 0, window: context.window })
153
154// The window auto-compact measures against, from the context breakdown
155// (estimated locally; no request is sent). Null where the session has none.
156async function readCompactWindow($: EngineInterface): Promise<CompactWindow | null> {
157  try {
158    const { breakdown } = (await $.session.usage({ breakdown: 'summary' })).context
159
160    return breakdown && breakdown.rawMaxTokens > 0
161      ? { tokens: breakdown.rawMaxTokens, source: breakdown.autocompactSource }
162      : null
163  } catch {
164    return null
165  }
166}
167
168const refreshCompactWindow = async ($: EngineInterface) => {
169  const next = await readCompactWindow($)
170  if (next) {
171    await update($, compactAtom, previous =>
172      previous?.tokens === next.tokens && previous.source === next.source ? previous : next,
173    )
174  }
175}
176
177// The token count where the context meter is full: the auto-compact window
178// (else the model's), the model's own window, or the person's number. Never
179// past the model's window.
180export const contextLimit = (setting: ContextLimit, window: number | null, compact: CompactWindow | null) => {
181  const model = window ?? compact?.tokens ?? 200_000
182  const wanted = setting === 'model' ? model : setting === 'autoCompact' ? (compact?.tokens ?? model) : setting
183
184  return Math.min(wanted, model)
185}
186
187// What the limit is, in words, for the hover detail and /bar-limit.
188const limitWords = (setting: ContextLimit, compact: CompactWindow | null) =>
189  setting === 'model'
190    ? "the model's full window"
191    : setting === 'autoCompact'
192      ? compact && compact.source !== 'auto'
193        ? 'your auto-compact window'
194        : "the model's full window (no auto-compact window set)"
195      : 'your limit, set with /bar-limit'
196
197const toLimits = (windows: SessionRateLimit[]): Limit[] =>
198  windows
199    .filter(window => window.kind in LIMIT_LABELS)
200    .map(window => ({
201      kind: window.kind,
202      percent: window.percentUsed,
203      resetsAt: window.resetsAt ?? null,
204    }))
205
206// The settings file's path, and its last-read modification time, so a check
207// reads it again only when it changed.
208let settingsPath: string | null = null
209let settingsMtime = -1
210
211// Reads ~/.claude/bar/settings.jsonc into the shared `settings` value,
212// writing it with every default and its comments when it is missing. A file
213// that does not parse keeps the last good settings and says why, once per
214// change.
215async function loadSettings($: EngineInterface) {
216  if (!settingsPath) {
217    const home = (await $.process.run(['sh', '-c', 'printf %s "$HOME"'])).stdout
218    settingsPath = `${home}/${SETTINGS_FILE}`
219  }
220
221  if (!(await $.fs.exists(settingsPath))) {
222    await $.fs.write(settingsPath, TEMPLATE)
223  }
224
225  const { mtimeMs } = await $.fs.stat(settingsPath)
226
227  if (mtimeMs === settingsMtime) {
228    return
229  }
230
231  settingsMtime = mtimeMs
232
233  try {
234    const next = toSettings(parseJsonc(await $.fs.read(settingsPath)))
235    await update($, settingsAtom, () => next)
236  } catch (error) {
237    $.ui.toast(
238      `Bar settings: ${error instanceof Error ? error.message : 'the file has an error'}; keeping the last good settings`,
239    )
240  }
241}
242
243export const registerBand: Register = on => {
244  on('session.start', async ($, e, next) => {
245    // Each load starts on the live bar; /bar-demo brings the samples back.
246    await update($, demo, () => -1)
247    // Panels earlier versions opened and this one no longer draws.
248    await $.ui.close({ id: 'bar-context' })
249    await $.ui.close({ id: 'bar-file' })
250    await $.ui.close({ id: 'bar-changes' })
251    await $.ui.close({ id: 'bar-agents' })
252    await $.command.register({
253      name: 'bar-limit',
254      description: 'Set where the context meter turns red: /bar-limit 300k, model, or off (your auto-compact window)',
255    })
256    await $.command.register({
257      name: 'bar-demo',
258      description: 'Step through sample bars, then back to the live one',
259    })
260
261    await loadSettings($)
262    $.clock.every(SETTINGS_POLL_MS, () => void loadSettings($))
263
264    const usage = await $.session.usage()
265    await update($, fill, () => toFill(usage.context))
266    await update($, limits, () => toLimits(usage.rateLimits))
267    await refreshCompactWindow($)
268    $.clock.every(COMPACT_POLL_MS, () => void refreshCompactWindow($))
269
270    return next(e)
271  })
272
273  on('command.run', { command: 'bar-limit' }, async ($, e) => {
274    const settings = (await read($, settingsAtom)) ?? DEFAULTS
275    const window = (await read($, fill))?.window ?? null
276    await refreshCompactWindow($)
277    const compact = await read($, compactAtom)
278    const word = e.args.trim().toLowerCase()
279    const describe = (setting: ContextLimit) =>
280      `${shortCount(contextLimit(setting, window, compact))} tokens, ${limitWords(setting, compact)}`
281
282    if (!word) {
283      return {
284        text: `The context meter fills at ${describe(settings.context.limit)}. Change it with /bar-limit 300k, /bar-limit model, or /bar-limit off for your auto-compact window.`,
285      }
286    }
287
288    // "off" (or "default", "compact", "auto", "none") follows the auto-compact window.
289    const isAuto = ['off', 'default', 'compact', 'autocompact', 'auto', 'none'].includes(word)
290    const isModel = ['model', 'window', 'full'].includes(word)
291    const wanted = isAuto || isModel ? null : parseCount(word)
292
293    if (!isAuto && !isModel && wanted === null) {
294      return {
295        text: 'Give a token count such as 300k, 1.5m or 250000, "model" for the model\'s window, or "off" for your auto-compact window.',
296      }
297    }
298
299    const limit: ContextLimit = isAuto
300      ? 'autoCompact'
301      : isModel
302        ? 'model'
303        : Math.max(MIN_LIMIT, window ? Math.min(wanted ?? 0, window) : (wanted ?? 0))
304    const text = settingsPath && (await $.fs.exists(settingsPath)) ? await $.fs.read(settingsPath) : TEMPLATE
305    const updated = withLimit(text, limit)
306
307    if (!updated || !settingsPath) {
308      return { text: `Couldn't find "limit" in ${SETTINGS_FILE}; set it there by hand.` }
309    }
310
311    await $.fs.write(settingsPath, updated)
312    await loadSettings($)
313
314    return {
315      text: `The context meter now fills at ${describe(limit)}${typeof limit === 'number' && wanted !== null && limit < wanted ? ` (capped at the model's window)` : ''}.`,
316    }
317  })
318
319  on('command.run', { command: 'bar-demo' }, async $ => {
320    const count = samples(0).length
321    const shown = demoIndex(await read($, demo))
322    // Past the last sample comes the live bar, then the first sample again.
323    const nextIndex = shown + 1 >= count ? -1 : shown + 1
324    await update($, demo, () => nextIndex)
325
326    return {
327      text:
328        nextIndex === -1
329          ? 'Showing the live bar.'
330          : `Sample ${nextIndex + 1} of ${count}: ${samples(0)[nextIndex]?.label}. /bar-demo again for the next.`,
331    }
332  })
333
334  on('session.measure', async ($, e, next) => {
335    if (e.changed.includes('context')) {
336      const before = (await read($, fill))?.window
337      await update($, fill, () => toFill(e.context))
338      // A model switch moves the auto-compact window too.
339      if (before !== e.context.window) {
340        await refreshCompactWindow($)
341      }
342    }
343    if (e.changed.includes('rateLimits')) {
344      await update($, limits, () => toLimits(e.rateLimits))
345    }
346
347    return next(e)
348  })
349
350  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
351    const s: BarSettings = (await read($, settingsAtom)) ?? DEFAULTS
352
353    if (e.props.hasSurvey || !s.bar.enabled) {
354      return next(e)
355    }
356
357    // What other mods draw here (cards, notices) stays, above the bar.
358    const below = await next(e)
359    const { Box, Text, Button } = $.ui.resolve(e)
360    // The app surfaces draw SVG; the terminal has none and draws text instead.
361    const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e).Svg
362    const level = (ratio: number) => colorFor(ratio, s.levels)
363    const current = await read($, fill)
364    const allTurns = await read($, turnList)
365    const running = allTurns.findLast(turn => turn.endedAt === null)
366    const lastDone = running ? undefined : allTurns.findLast(turn => turn.endedAt !== null)
367    // While a turn runs the turn hooks ask for a redraw each second; the time
368    // and tally are read here, at drawing, so they are current whenever it lands.
369    const now = await $.clock.now()
370    const elapsed = running ? now - running.startedAt : 0
371    const liveTurn = running ? liveSnapshot() : null
372    const window = current?.window ?? null
373    const compact = await read($, compactAtom)
374    const cache = s.cache.enabled ? await read($, cacheAtom) : null
375    const handoff = await read($, handoffAtom)
376
377    const live: BarData = {
378      tokens: current?.tokens ?? 0,
379      limit: contextLimit(s.context.limit, window, compact),
380      limits: await read($, limits),
381    }
382
383    // One panel, two rows: row 1 is capacity (context, the 5-hour and weekly
384    // windows), row 2 is right now (the turn, its tools, the prompt cache and
385    // Hand off). Every gauge is the same line meter. Hovering an item shows
386    // its detail laid over the other row, so nothing moves and the row under
387    // the pointer stays usable. A terminal draws a border as three rows, so
388    // there the panel goes borderless and the drawings become line glyphs.
389    const isFramed = e.surface !== 'terminal'
390    const hasHover = s.bar.hover
391
392    // A detail, laid over a row while its item is hovered: the same height,
393    // on a solid background. (The band clips anything drawn outside it, and
394    // growing it makes it jump.)
395    const overlay = (scope: string, content: ReturnType<typeof Text>) => (
396      <Box
397        key={`${scope}-detail`}
398        position="absolute"
399        top={0}
400        bottom={0}
401        left={0}
402        right={0}
403        overflow="hidden"
404        display="none"
405        hover={{ scope, display: 'flex' }}
406        flexDirection="row"
407        alignItems="center"
408        backgroundColor="userMessageBackground"
409      >
410        {content}
411      </Box>
412    )
413
414    const link = (key: string, label: string, onPress: () => unknown, dim = false) => (
415      <Button
416        key={key}
417        label={label}
418        plain
419        {...(dim ? { dimColor: true } : {})}
420        {...(hasHover ? { hover: { scope: `bar-link-${key}`, underline: true } } : {})}
421        onPress={onPress}
422      />
423    )
424
425    // An item of a row; its key is the hover scope its detail answers to.
426    const segment = (scope: string, grow: boolean, content: RenderChildren[]) => (
427      <Box
428        key={scope}
429        flexDirection="row"
430        alignItems="center"
431        columnGap={1}
432        flexGrow={grow ? 1 : 0}
433        flexShrink={grow ? 1 : 0}
434        // A growing item gives up its room to the fixed ones, down to nothing,
435        // instead of pushing the row past the edge.
436        {...(grow ? { minWidth: 0, overflow: 'hidden' as const } : {})}
437        {...(hasHover ? { hover: { scope } } : {})}
438      >
439        {content}
440      </Box>
441    )
442
443    // The hairline between items. It never shrinks: when the row is short of
444    // room the context meter gives way, and a shrinking hairline rounded
445    // down to nothing.
446    const rule = (key: string) => (
447      <Box key={key} flexShrink={0}>
448        {Svg ? (
449          <Svg source={ruleSvg()} alt="│" width={1} height={16} />
450        ) : (
451          <Text dimColor>│</Text>
452        )}
453      </Box>
454    )
455
456    // The context meter fills the room between its label and its figures, so
457    // the figures always show. The app draws it as one stretching SVG line
458    // (meter.ts). The terminal splits its room between two glyph runs by
459    // ratio, the used part in the level's colour and the rest a dim track, so
460    // the meter stays true at any width.
461    const contextMeter = (ratio: number) => {
462      if (Svg) {
463        return (
464          <Box
465            key="context-meter"
466            flexDirection="column"
467            justifyContent="center"
468            alignItems="stretch"
469            flexGrow={1}
470            flexShrink={1}
471            minWidth={0}
472            height={1}
473            marginX={1}
474          >
475            <Svg
476              source={meterSvg(ratio, level(ratio), { ticks: [s.levels.yellow, s.levels.orange, s.levels.red] })}
477              alt={`${Math.round(ratio * 100)}% of the context used`}
478              height={METER_HEIGHT}
479            />
480          </Box>
481        )
482      }
483
484      const used = Math.round(Math.min(Math.max(ratio, 0), 1) * METER_STEPS)
485      const run = '━'.repeat(METER_GLYPHS)
486      const part = (key: string, grow: number, dim: boolean) => (
487        <Box key={key} flexGrow={grow} flexShrink={1} width={0} minWidth={0} height={1} overflow="hidden">
488          <Text color={level(ratio)} dimColor={dim}>
489            {run}
490          </Text>
491        </Box>
492      )
493
494      return (
495        <Box key="context-meter" flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden" marginX={1}>
496          {used > 0 && part('context-used', used, false)}
497          {used < METER_STEPS && part('context-rest', METER_STEPS - used, true)}
498        </Box>
499      )
500    }
501
502    // A plan window's short meter, with a "now" mark at how much of the
503    // window has passed: fill beyond it means usage is ahead of the clock.
504    const windowMeter = (key: string, ratio: number, cursor: number | null) => {
505      if (Svg) {
506        return (
507          <Box key={key} flexShrink={0}>
508            <Svg
509              source={meterSvg(ratio, level(ratio), { cursor, pad: 5 })}
510              alt={`${Math.round(ratio * 100)}% used${cursor === null ? '' : `, ${Math.round(cursor * 100)}% of the window passed`}`}
511              width={WINDOW_METER_WIDTH}
512              height={METER_HEIGHT}
513            />
514          </Box>
515        )
516      }
517
518      const used = Math.round(Math.min(Math.max(ratio, 0), 1) * WINDOW_METER_CELLS)
519      const mark = cursor === null ? -1 : Math.min(WINDOW_METER_CELLS - 1, Math.floor(cursor * WINDOW_METER_CELLS))
520
521      return (
522        <Text key={key}>
523          {Array.from({ length: WINDOW_METER_CELLS }, (_, cell) =>
524            cell === mark ? (
525              <Text key={String(cell)}>┃</Text>
526            ) : cell < used ? (
527              <Text key={String(cell)} color={level(ratio)}>
528                ━
529              </Text>
530            ) : (
531              <Text key={String(cell)} dimColor>
532                ─
533              </Text>
534            ),
535          )}
536        </Text>
537      )
538    }
539
540    // How much of a window has passed, or null when unknown or turned off.
541    const windowShare = (window: Limit) => {
542      const span = WINDOW_MS[window.kind] ?? 0
543      const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : null
544
545      return resetsAt && span ? Math.min(Math.max((now - (resetsAt - span)) / span, 0), 1) : null
546    }
547
548    const windowDetail = (window: Limit) => {
549      const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : null
550      const share = windowShare(window) ?? 0
551      // Where usage lands at reset if it keeps the pace it has had so far;
552      // too early in the window to say anything useful before a tenth of it.
553      const pace = s.planLimits.pace && share >= 0.1 ? Math.round(window.percent / share) : null
554      const tint = level(window.percent / 100)
555
556      return overlay(
557        `bar-${window.kind}`,
558        <Text wrap="truncate-end">
559          <Text bold>{WINDOW_NAMES[window.kind]}</Text>
560          <Text color={tint} dimColor>
561            {'  ·  '}
562          </Text>
563          <Text color={tint}>{Math.round(window.percent)}% used</Text>
564          {resetsAt && (
565            <Text>
566              <Text color={tint} dimColor>
567                {'  ·  '}
568                {Math.round(share * 100)}% of the window passed{'  ·  '}resets{' '}
569              </Text>
570              {longDate(resetsAt)}
571              <Text color={tint} dimColor>
572                {' '}(in {until(resetsAt - now)})
573              </Text>
574            </Text>
575          )}
576          {pace !== null && (
577            <Text color={pace >= 100 ? 'error' : tint} dimColor={pace < 100}>
578              {'  ·  '}
579              {pace >= 100 ? 'on pace to run out first' : `on pace for ~${pace}%`}
580            </Text>
581          )}
582        </Text>,
583      )
584    }
585
586    const contextDetail = (data: BarData) => {
587      const ratio = data.tokens / data.limit
588      const tint = level(ratio)
589
590      return overlay(
591        'bar-context',
592        <Text wrap="truncate-end">
593          <Text bold>Context window</Text>
594          <Text color={tint} dimColor>
595            {'  ·  '}
596          </Text>
597          <Text color={tint}>
598            {shortCount(data.tokens)} of {shortCount(data.limit)} tokens ({Math.round(ratio * 100)}%)
599          </Text>
600          <Text dimColor>
601            {'  ·  '}
602            {limitWords(s.context.limit, compact)}
603            {s.context.click === 'usage' ? '  ·  click Context for /usage' : ''}
604          </Text>
605        </Text>,
606      )
607    }
608
609    // The turn's figures, the number in the text colour and its label quiet.
610    const figures = (tokens: Tokens, streaming = 0) =>
611      s.turn.tokens && (
612        <Text key="turn-figures">
613          {(
614            [
615              [`${shortCount(tokens.output + streaming)}${streaming > 0 ? '+' : ''}`, 'out'],
616              [shortCount(tokens.cacheRead), 'read'],
617              [shortCount(tokens.cacheWrite), 'write'],
618            ] as const
619          ).map(([value, label], index) => (
620            <Text key={label}>
621              {index > 0 ? '   ' : ''}
622              {value}
623              <Text dimColor> {label}</Text>
624            </Text>
625          ))}
626        </Text>
627      )
628
629    // The turn at the left of row 2, where it stays in view as replies
630    // scroll: live while Claude works (a beat each second, the time, the
631    // output rate), else the last finished one.
632    const turnContent = (): RenderChildren[] => {
633      if (running && s.turn.whileWorking) {
634        const spark = Svg && liveTurn ? sparkSvg(liveTurn.rate, 'claude') : null
635
636        return [
637          Svg ? (
638            <Svg key="beat" source={pulseSvg('claude')} alt="Working" width={14} height={14} />
639          ) : (
640            <Text key="beat" color="claude">
641              ●
642            </Text>
643          ),
644          <Text key="state" bold>
645            Working
646          </Text>,
647          // Fixed width: the time changes every second, and the app's font
648          // gives digits different widths.
649          <Box key="time" width={TIMER_CELLS} flexShrink={0}>
650            <Text color="claude" bold>
651              {duration(elapsed)}
652            </Text>
653          </Box>,
654          Svg && spark && (
655            <Box key="spark" flexShrink={0}>
656              <Svg source={spark} alt="Output tokens each second" width={SPARK_WIDTH} height={16} />
657            </Box>
658          ),
659          liveTurn && figures(liveTurn.tokens, liveTurn.streaming),
660        ]
661      }
662
663      if (!running && lastDone?.endedAt && s.turn.lastTurn) {
664        return [
665          Svg ? (
666            <Svg key="beat" source={restingSvg()} alt="Idle" width={14} height={14} />
667          ) : (
668            <Text key="beat" dimColor>
669              ○
670            </Text>
671          ),
672          <Text key="state" dimColor>
673            Last turn
674          </Text>,
675          <Text key="time" bold>
676            {duration(lastDone.endedAt - lastDone.startedAt)}
677          </Text>,
678          lastDone.tokens && figures(lastDone.tokens),
679        ]
680      }
681
682      return []
683    }
684
685    // Every figure of the turn, by its full name.
686    const turnDetail = () => {
687      const tokens = running ? liveTurn?.tokens : lastDone?.tokens
688      const tools = running ? liveTurn?.tools : lastDone?.tools
689
690      return (
691        s.turn.tokens &&
692        tokens &&
693        overlay(
694          'bar-turn',
695          <Text wrap="truncate-end">
696            <Text bold>{running ? 'This turn so far' : 'Last turn'}</Text>
697            <Text dimColor>{'  ·  '}</Text>
698            {shortCount(tokens.input)}
699            <Text dimColor> in{'  ·  '}</Text>
700            {shortCount(tokens.output)}
701            <Text dimColor> out{'  ·  '}</Text>
702            {shortCount(tokens.cacheRead)}
703            <Text dimColor> cache read{'  ·  '}</Text>
704            {shortCount(tokens.cacheWrite)}
705            <Text dimColor> cache write</Text>
706            {s.turn.toolCalls && typeof tools === 'number' && (
707              <Text dimColor>
708                {'  ·  '}
709                {tools} tool {tools === 1 ? 'call' : 'calls'}
710              </Text>
711            )}
712          </Text>,
713        )
714      )
715    }
716
717    const toolsItem = (tools: number) => {
718      if (!s.turn.toolCalls) {
719        return null
720      }
721
722      const label = `${tools} ${tools === 1 ? 'tool' : 'tools'}`
723
724      return (
725        <Box key="tools-slot" flexShrink={0}>
726          {s.turn.toolCallsPanel ? (
727            link('tools', `${label} ›`, () => $.ui.open({ id: 'bar-tools', title: 'Tool calls', focus: true }))
728          ) : (
729            <Text>{label}</Text>
730          )}
731        </Box>
732      )
733    }
734
735    // The prompt cache: time left before the conversation drops out of it,
736    // counted from the last response, and the Hand off button beside it,
737    // drawn as the main action once the time is running out.
738    const expiry = cacheExpiry(cache)
739    const cacheSpan = cache ? expiry! - cache.at : 1
740    const cacheLeft = expiry === null ? 0 : expiry - now
741    const cacheRatio = 1 - Math.max(cacheLeft, 0) / cacheSpan
742    const cacheTint = cacheLeft > 0 ? level(cacheRatio) : 'error'
743    const isCacheUrgent = cacheTint === 'claude' || cacheTint === 'error'
744
745    const cacheItem = () =>
746      segment('bar-cache', false, [
747        <Text key="label" dimColor>
748          Cache
749        </Text>,
750        // Fixed width: the digits change every second.
751        <Box key="left" width={Math.max(COUNTDOWN_CELLS, 'expired'.length)} flexShrink={0}>
752          <Text color={cacheTint} bold>
753            {cacheLeft > 0 ? countdown(cacheLeft) : 'expired'}
754          </Text>
755        </Box>,
756      ])
757
758    const handoffButton = () => (
759      <Button
760        key="handoff"
761        label={HANDOFF_LABELS[handoff.status]}
762        variant={isCacheUrgent ? 'primary' : 'secondary'}
763        // The cache hooks start it on their next tick.
764        onPress={() =>
765          update($, handoffAtom, state =>
766            state.status === 'idle' || state.status === 'error' || state.status === 'opened'
767              ? { ...state, status: 'requested' as const }
768              : state,
769          )
770        }
771      />
772    )
773
774    const cacheDetail = () =>
775      cache &&
776      expiry !== null &&
777      overlay(
778        'bar-cache',
779        <Text wrap="truncate-end">
780          <Text bold>Prompt cache</Text>
781          <Text color={cacheTint} dimColor>
782            {'  ·  '}
783            {cache.ttl === '1h' ? '1 hour' : '5 minutes'} from the last response{'  ·  '}
784          </Text>
785          {handoff.status === 'error' ? (
786            <Text color="error">Hand off failed: {handoff.detail}</Text>
787          ) : handoff.status === 'writing' ? (
788            <Text>Writing the handoff, then opening a new session with it</Text>
789          ) : (
790            <Text>
791              <Text color={cacheTint}>
792                {cacheLeft > 0 ? `expires at ${clockTime(expiry, false)}` : `expired at ${clockTime(expiry, false)}`}
793              </Text>
794              {current && (
795                <Text color={cacheTint} dimColor>
796                  {'  ·  '}
797                  {cacheLeft > 0 ? 'then' : 'so'} the next message writes {shortCount(current.tokens)} tokens again
798                </Text>
799              )}
800            </Text>
801          )}
802        </Text>,
803      )
804
805    const bar = (data: BarData) => {
806      const ratio = data.tokens / data.limit
807      const windows = data.limits.filter(window =>
808        window.kind === 'five_hour' ? s.planLimits.fiveHour : s.planLimits.weekly,
809      )
810      const contextLabel =
811        s.context.click === 'usage' ? (
812          link('context', 'Context', () => $.command.run({ command: 'usage', args: '' }), true)
813        ) : (
814          <Text key="context-label" dimColor>
815            Context
816          </Text>
817        )
818
819      const capacity: RenderChildren[] = []
820
821      if (s.context.enabled) {
822        capacity.push(
823          segment('bar-context', true, [
824            contextLabel,
825            contextMeter(ratio),
826            // Never shrinks: the meter gives up its room first.
827            <Box key="context-figures" flexShrink={0}>
828              <Text>
829                <Text color={level(ratio)} bold>
830                  {shortCount(data.tokens)}
831                </Text>
832                <Text dimColor> / {shortCount(data.limit)}  </Text>
833                <Text color={level(ratio)}>{Math.round(ratio * 100)}%</Text>
834              </Text>
835            </Box>,
836          ]),
837        )
838      }
839
840      for (const window of windows) {
841        const tint = level(window.percent / 100)
842
843        capacity.push(
844          segment(`bar-${window.kind}`, false, [
845            <Text key="label" dimColor>
846              {LIMIT_LABELS[window.kind]}
847            </Text>,
848            windowMeter('meter', window.percent / 100, s.planLimits.pace ? windowShare(window) : null),
849            <Text key="percent" color={tint} bold>
850              {Math.round(window.percent)}%
851            </Text>,
852            s.planLimits.resetTime && window.resetsAt && (
853              <Text key={`bar-${window.kind}-reset`} dimColor>
854                {resetTime(window.resetsAt, now)}
855              </Text>
856            ),
857          ]),
858        )
859      }
860
861      const tools = running ? (liveTurn?.tools ?? 0) : lastDone?.tools
862      const nowItems: RenderChildren[] = []
863
864      const turn = s.turn.enabled ? turnContent() : []
865
866      if (turn.length > 0) {
867        nowItems.push(segment('turn', true, turn))
868        if (typeof tools === 'number') {
869          nowItems.push(toolsItem(tools))
870        }
871      } else if (cache) {
872        // Without the turn, the cache keeps to the right.
873        nowItems.push(<Box key="turn-space" flexGrow={1} />)
874      }
875
876      if (cache) {
877        nowItems.push(cacheItem())
878      }
879
880      const hasCapacity = capacity.length > 0
881      const hasNow = nowItems.length > 0
882      // Items with a hairline between each.
883      const withRules = (row: string, items: RenderChildren[]) =>
884        items.flatMap((item, index) => (index > 0 ? [rule(`${row}-rule-${index}`), item] : [item]))
885
886      return (
887        <Box
888          key="bar-panel"
889          flexDirection="column"
890          {...(isFramed && s.bar.pillBorders ? { borderStyle: 'round', borderColor: PANEL_BORDER, paddingX: 1 } : {})}
891        >
892          {hasCapacity && (
893            // The terminal draws the band's collapse control, `[-]`, over the
894            // right end of the first row: keep those cells clear.
895            <Box
896              key="row-capacity"
897              flexDirection="row"
898              alignItems="center"
899              columnGap={1}
900              minHeight={1}
901              paddingRight={isFramed ? 0 : COLLAPSE_CELLS}
902            >
903              {withRules('capacity', capacity)}
904              {/* Last, so they paint over the row when shown: row 2's details. */}
905              {hasHover && hasNow && cacheDetail()}
906              {hasHover && hasNow && s.turn.enabled && turnDetail()}
907            </Box>
908          )}
909          {hasCapacity && hasNow && Svg && (
910            <Box key="row-rule" flexDirection="column" alignItems="stretch" marginY={0}>
911              <Svg source={hruleSvg()} alt="—" height={1} />
912            </Box>
913          )}
914          {hasNow && (
915            <Box key="row-now" flexDirection="row" alignItems="center" columnGap={1} minHeight={1}>
916              {withRules('now', nowItems.filter(item => item !== null))}
917              {cache && s.cache.handoff && handoffButton()}
918              {/* Row 1's details. */}
919              {hasHover && hasCapacity && s.context.enabled && contextDetail(data)}
920              {hasHover && hasCapacity && s.planLimits.hoverDetails && windows.map(window => windowDetail(window))}
921            </Box>
922          )}
923        </Box>
924      )
925    }
926
927    const sample = samples(now)[demoIndex(await read($, demo))]
928    const own = bar(sample ? sample.data : live)
929
930    return hasContent(below) ? (
931      <Box flexDirection="column" rowGap={1}>
932        {below}
933        {own}
934      </Box>
935    ) : (
936      own
937    )
938  })
939}
940
hooks/cache.tsx 304 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CacheState, CacheTtl, HandoffState } from '../types'
5
6// The prompt cache: how long the conversation so far stays cached, counted
7// from the main loop's last model response, and the Hand off button that
8// carries the work into a new session before (or after) it lapses.
9
10const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
11// How much of the transcript's end is read to find the last response.
12const TAIL_BYTES = 400_000
13// The skill the button runs, from Matt Pocock's skills plugin.
14const HANDOFF_SKILL = 'mattpocock-skills:handoff'
15// A new session picks up a handoff only this soon after it was written.
16const CLAIM_MS = 5 * 60_000
17const CLAIM_FILE = '.claude/bar/handoff/pending.json'
18
19const IDLE: HandoffState = { status: 'idle', path: null, clickedAt: null, turnId: null, detail: null }
20
21const cacheAtom = atom({ plugin: 'bar', key: 'cache' } as const, null)
22const handoffAtom = atom({ plugin: 'bar', key: 'handoff' } as const, IDLE)
23const turnList = atom({ plugin: 'bar', key: 'turns' } as const, [])
24
25let home: string | null = null
26
27const homeDir = async ($: EngineInterface) => {
28  home ??= (await $.process.run(['sh', '-c', 'printf %s "$HOME"'])).stdout
29
30  return home
31}
32
33// Claude Code keeps a session's transcript under its folder's path with
34// every other character turned into a dash.
35const transcriptPath = async ($: EngineInterface) => {
36  const cwd = await $.session.cwd()
37
38  return `${await homeDir($)}/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${await $.session.id()}.jsonl`
39}
40
41type UsageLine = {
42  type?: string
43  isSidechain?: boolean
44  timestamp?: string
45  message?: { usage?: { cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } } }
46}
47
48// The cache as the transcript has it: when the main loop's last response
49// came, and the lifetime its cache writes asked for (a response that only
50// read the cache says nothing, so an earlier one decides).
51const fromTranscript = async ($: EngineInterface): Promise<CacheState | null> => {
52  const path = await transcriptPath($)
53
54  if (!(await $.fs.exists(path))) {
55    return null
56  }
57
58  const { stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
59  const lines = stdout.split('\n').reverse()
60  let at: number | null = null
61  let ttl: CacheTtl | null = null
62
63  for (const text of lines) {
64    if (!text.includes('"assistant"') || !text.includes('"usage"')) {
65      continue
66    }
67
68    let row: UsageLine
69    try {
70      row = JSON.parse(text) as UsageLine
71    } catch {
72      continue
73    }
74
75    if (row.type !== 'assistant' || row.isSidechain || !row.message?.usage) {
76      continue
77    }
78
79    at ??= row.timestamp ? Date.parse(row.timestamp) : null
80    const written = row.message.usage.cache_creation
81    if ((written?.ephemeral_1h_input_tokens ?? 0) > 0) {
82      ttl = '1h'
83    } else if ((written?.ephemeral_5m_input_tokens ?? 0) > 0) {
84      ttl = '5m'
85    }
86
87    if (at !== null && ttl !== null) {
88      break
89    }
90  }
91
92  return at === null ? null : { at, ttl: ttl ?? '5m' }
93}
94
95const refresh = async ($: EngineInterface) => {
96  const found = await fromTranscript($)
97
98  if (found) {
99    await update($, cacheAtom, () => found)
100  }
101}
102
103// When the cache lapses, in ms; null before the first response.
104export const cacheExpiry = (cache: CacheState | null) => (cache ? cache.at + TTL_MS[cache.ttl] : null)
105
106const stamp = (ms: number) => {
107  const d = new Date(ms)
108  const pad = (n: number) => String(n).padStart(2, '0')
109
110  return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
111}
112
113const continuePrompt = (path: string) =>
114  `Continue from the handoff document at ${path}. Read it first, call the skills it suggests, then pick up the work where it left off.`
115
116// Runs the handoff skill, telling it where to save the document; the turn
117// hooks below open the new session once that turn ends with the file there.
118const startHandoff = async ($: EngineInterface) => {
119  const state = await read($, handoffAtom)
120
121  if (state.status === 'writing' || state.status === 'opening') {
122    return
123  }
124
125  await update($, handoffAtom, (): HandoffState => ({ ...IDLE, status: 'writing' }))
126
127  const commands = await $.command.list()
128
129  if (!commands.some(command => command.name === HANDOFF_SKILL)) {
130    await update($, handoffAtom, (): HandoffState => ({ ...IDLE, status: 'error', detail: `/${HANDOFF_SKILL} isn't installed` }))
131    $.ui.toast(`Hand off needs /${HANDOFF_SKILL}: install mattpocock-skills@mattpocock`)
132    return
133  }
134
135  const tmp = (await $.process.run(['sh', '-c', 'printf %s "${TMPDIR:-/tmp}"'])).stdout.replace(/\/$/, '')
136  const cwd = await $.session.cwd()
137  const folder = cwd.split('/').filter(Boolean).pop() ?? 'session'
138  const now = await $.clock.now()
139  const path = `${tmp}/claude-handoffs/${folder.replace(/[^a-zA-Z0-9._-]/g, '-')}-${stamp(now)}.md`
140  await $.process.run(['mkdir', '-p', `${tmp}/claude-handoffs`])
141
142  await update($, handoffAtom, (): HandoffState => ({ status: 'writing', path, clickedAt: now, turnId: null, detail: null }))
143
144  try {
145    await $.command.run({
146      command: HANDOFF_SKILL,
147      args: `The next session continues this work. Save the handoff document to exactly this path: ${path}`,
148    })
149  } catch (error) {
150    await update($, handoffAtom, (): HandoffState => ({
151      ...IDLE,
152      status: 'error',
153      detail: error instanceof Error ? error.message : 'the handoff skill did not run',
154    }))
155  }
156}
157
158// Opens a new Code session in this folder with the continue prompt in its
159// box, and leaves a note so that session's Bar sends the prompt itself.
160const openSession = async ($: EngineInterface, path: string) => {
161  const cwd = await $.session.cwd()
162  const prompt = continuePrompt(path)
163  const claim = `${await homeDir($)}/${CLAIM_FILE}`
164  await $.process.run(['mkdir', '-p', claim.replace(/\/[^/]+$/, '')])
165  await $.fs.write(claim, JSON.stringify({ cwd, path, prompt, at: await $.clock.now() }))
166
167  const url = `claude://code/new?folder=${encodeURIComponent(cwd)}&q=${encodeURIComponent(prompt)}`
168  const { exitCode, stderr } = await $.process.run(['open', url])
169
170  if (exitCode !== 0) {
171    await update($, handoffAtom, (): HandoffState => ({ ...IDLE, status: 'error', path, detail: stderr.trim() || 'could not open a new session' }))
172    return
173  }
174
175  await update($, handoffAtom, (): HandoffState => ({ ...IDLE, status: 'opened', path }))
176  $.ui.toast('Handoff written; continuing in a new session')
177  $.clock.after(15_000, () => void update($, handoffAtom, (state): HandoffState => (state.status === 'opened' ? IDLE : state)))
178}
179
180type Claim = { cwd: string; path: string; prompt: string; at: number }
181
182// A session opened by Hand off: take the note (once, so no other session
183// does), send the continue prompt, and empty the box the link filled.
184const pickUp = async ($: EngineInterface) => {
185  const claim = `${await homeDir($)}/${CLAIM_FILE}`
186
187  if (!(await $.fs.exists(claim))) {
188    return
189  }
190
191  let note: Claim
192  try {
193    note = JSON.parse(await $.fs.read(claim)) as Claim
194  } catch {
195    return
196  }
197
198  const now = await $.clock.now()
199
200  if (note.cwd !== (await $.session.cwd()) || now - note.at > CLAIM_MS || (await $.session.turns()) > 0) {
201    return
202  }
203
204  await $.process.run(['rm', '-f', claim])
205  await $.prompt.submit({ text: note.prompt, asUser: true })
206
207  // The app fills the box from the link around when the session starts:
208  // clear it whenever it still holds the prompt that was just sent.
209  for (const delay of [500, 1_500, 3_000, 6_000]) {
210    $.clock.after(delay, async () => {
211      const box = await $.prompt.read()
212      if (box.text.trim() === note.prompt) {
213        await $.prompt.fill({ text: '' })
214      }
215    })
216  }
217}
218
219let tick: { cancel: () => void } | null = null
220// The last finished turn the ticker has handled.
221let seenTurn: string | null = null
222
223// The handoff's turn (the turn hooks mark it) has ended: open the new
224// session when the document is there.
225const afterHandoffTurn = async ($: EngineInterface) => {
226  const state = await read($, handoffAtom)
227  const turn = (await read($, turnList)).find(t => t.id === state.turnId)
228
229  if (state.status !== 'writing' || !turn || turn.endedAt === null || !state.path) {
230    return
231  }
232
233  if (turn.isAborted) {
234    await update($, handoffAtom, () => IDLE)
235  } else if (await $.fs.exists(state.path)) {
236    await update($, handoffAtom, (s): HandoffState => ({ ...s, status: 'opening' }))
237    await openSession($, state.path)
238  } else {
239    await update($, handoffAtom, (): HandoffState => ({ ...IDLE, status: 'error', detail: `the handoff wasn't saved to ${state.path}` }))
240  }
241}
242
243// At session start: the cache as the transcript left it, a redraw a second
244// for the countdown between turns (the turn hooks redraw while a turn runs),
245// /bar-handoff, and a handoff to pick up.
246const begin = async ($: EngineInterface) => {
247  await $.command.register({
248    name: 'bar-handoff',
249    description: 'Write a handoff and continue in a new session (this one stays as it is)',
250  })
251  await refresh($)
252  seenTurn = (await read($, turnList)).findLast(turn => turn.endedAt !== null)?.id ?? null
253
254  tick?.cancel()
255  tick = $.clock.every(1000, async () => {
256    if ((await read($, handoffAtom)).status === 'requested') {
257      void startHandoff($)
258    }
259
260    const turns = await read($, turnList)
261    const running = turns.some(turn => turn.endedAt === null)
262    const lastEnded = turns.findLast(turn => turn.endedAt !== null)
263
264    // A turn ended: the transcript has its responses with their cache
265    // lifetime, and it may be the handoff's.
266    if (lastEnded && lastEnded.id !== seenTurn) {
267      seenTurn = lastEnded.id
268      await refresh($)
269      await afterHandoffTurn($)
270    }
271
272    const expiry = cacheExpiry(await read($, cacheAtom))
273    if (!running && expiry !== null && expiry + 2_000 > (await $.clock.now())) {
274      $.ui.invalidate('ui.render')
275    }
276  })
277
278  void pickUp($)
279}
280
281export const registerCache: Register = on => {
282  // Every session: the desktop app's sessions are SDK-hosted, not
283  // interactive, so one hook per kind (the band has the unmatched one).
284  on('session.start', { isInteractive: true }, async ($, e, next) => {
285    const started = await next(e)
286    await begin($)
287
288    return started
289  })
290
291  on('session.start', { isInteractive: false }, async ($, e, next) => {
292    const started = await next(e)
293    await begin($)
294
295    return started
296  })
297
298  on('command.run', { command: 'bar-handoff' }, async $ => {
299    void startHandoff($)
300
301    return { text: 'Writing the handoff; a new session opens with it when it is done.' }
302  })
303}
304
hooks/one-click.ts 57 lines
1import type { Register } from 'claude-code'
2
3// One click for a panel's buttons. The desktop app spends the first click on
4// a pane Button that doesn't hold the focus ring moving the ring onto it, and
5// only the second presses it. Here the ring landing on a button by the
6// person presses it at once; a press of the same button straight after (the
7// click the person makes out of habit) is taken without running it again.
8// The engine can't tell a click from Tab or the arrows, so moving the ring
9// onto a button by keyboard presses it too.
10
11// How long after a press on focus a press of the same button is the same click.
12const SAME_CLICK_MS = 1_500
13
14// Each panel's buttons, by key, as its latest drawing bound them.
15const handlers = new Map<string, Map<string, () => unknown>>()
16// The button each panel last pressed on focus, and when.
17const pressedOnFocus = new Map<string, { element: string; at: number }>()
18
19// Records what a button does for the focus hook, and returns it for onPress.
20// Called while drawing: a drawing's buttons replace the last drawing's.
21export const oneClick = (requestId: string) => {
22  const own = new Map<string, () => unknown>()
23  handlers.set(requestId, own)
24
25  return (key: string, handler: () => unknown) => {
26    own.set(key, handler)
27    return handler
28  }
29}
30
31export const registerOneClick = (on: Parameters<Register>[0], requestIds: readonly string[]) => {
32  for (const requestId of requestIds) {
33    on('ui.focus', { requestId }, async ($, e, next) => {
34      const result = await next(e)
35      const handler = e.origin.kind === 'person' && e.element ? handlers.get(requestId)?.get(e.element) : undefined
36
37      if (handler && e.element) {
38        pressedOnFocus.set(requestId, { element: e.element, at: await $.clock.now() })
39        void handler()
40      }
41
42      return result
43    })
44
45    on('ui.press', { requestId }, async ($, e, next) => {
46      const last = pressedOnFocus.get(requestId)
47
48      if (last?.element === e.element && (await $.clock.now()) - last.at < SAME_CLICK_MS) {
49        pressedOnFocus.delete(requestId)
50        return { element: e.element }
51      }
52
53      return next(e)
54    })
55  }
56}
57
hooks/remote.tsx 636 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BarSettings, RemoteInstance, RemoteState, RemoteStatus } from '../types'
5import { clockTime, duration, longDate } from './format'
6import { oneClick } from './one-click'
7import { DEFAULTS, parseJsonc, SETTINGS_FILE, toSettings } from './settings'
8
9// Remote Control in the prompt footer: a toggle for this session's folder,
10// a "Remote sessions" button that lists every folder's instance (running or
11// not) with Stop / Start / Remove, and `/remote`. One instance per folder,
12// shared by every session in it: the process runs detached, so it outlives
13// the session that started it, and a record per folder in
14// ~/.claude/bar/remote (its folder, pid and log) lets every session see it,
15// follow it, stop it and start it again.
16
17// This session's folder, as the footer toggle shows it.
18const remote = atom({ plugin: 'bar', key: 'remote' } as const, {
19  status: 'off',
20  url: null,
21  detail: null,
22})
23// Every folder's instance, and those started outside Bar, for the panel.
24const remoteAll = atom({ plugin: 'bar', key: 'remoteAll' } as const, [])
25const settingsAtom = atom({ plugin: 'bar', key: 'settings' } as const, null)
26
27export const PANEL = 'bar-remote'
28const POLL_MS = 2_000
29// How often the panel's "running 4m 12s" moves while it is shown.
30const TICK_MS = 1_000
31// How much of the log a check reads: the output redraws one block over and
32// over, so its end holds the current state.
33const TAIL_BYTES = 4_000
34// remote-control redraws its status block over and over, so its log grows
35// without end; past this size a check empties it (the process appends, so it
36// carries on at the start), and the next redraw fills it again.
37const MAX_LOG_BYTES = 512_000
38const URL_PATTERN = /https:\/\/claude\.ai\/code\?environment=[\w-]+/
39const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g
40
41// A folder's place in ~/.claude/bar/remote: its record and its log.
42type Folder = { key: string; cwd: string; name: string; record: string; log: string }
43
44// What a folder's record file holds. `pid` is null once stopped; the record
45// stays so the folder can be started again from the panel. Records written
46// by Bar 1.2's first build lack cwd and name.
47type RemoteRecord = {
48  cwd?: string
49  name?: string
50  pid: number | null
51  log: string
52  startedAt: number | null
53  stoppedAt?: number | null
54  // The pid a Stop ended: remote-control takes seconds to exit, and until it
55  // has, the folder shows "stopping…" rather than the process showing up as
56  // one started outside Bar.
57  stoppedPid?: number | null
58}
59
60// How long a stopped remote-control gets to exit before it is killed outright.
61const STOP_GRACE_MS = 15_000
62
63
64
65let home: string | null = null
66// This session's folder; set at session start.
67let own: Folder | null = null
68// The status this session last showed, to toast only on a change.
69let shown: RemoteStatus = 'off'
70
71const basename = (path: string) => path.split('/').filter(Boolean).pop() ?? path
72
73const clean = (text: string) => text.replace(ANSI, '').replace(/\r/g, '\n')
74
75const lastLine = (text: string) =>
76  clean(text)
77    .split('\n')
78    .map(line => line.trim())
79    .filter(Boolean)
80    .pop() ?? null
81
82const recordsFolder = () => `${home ?? ''}/.claude/bar/remote`
83
84// One record per folder, named after its path.
85const folderOf = (cwd: string, name = basename(cwd)): Folder => {
86  const key = cwd.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')
87
88  return {
89    key,
90    cwd,
91    name,
92    record: `${recordsFolder()}/${key}.json`,
93    log: `${recordsFolder()}/${key}.log`,
94  }
95}
96
97async function locate($: EngineInterface) {
98  home = (await $.process.run(['sh', '-c', 'printf %s "$HOME"'])).stdout
99  own = folderOf(await $.session.cwd())
100}
101
102async function readRecord($: EngineInterface, path: string): Promise<RemoteRecord | null> {
103  try {
104    return JSON.parse(await $.fs.read(path)) as RemoteRecord
105  } catch {
106    return null
107  }
108}
109
110// The settings as the band last read them; at session start, before the
111// band's first read may have landed, straight from the file.
112async function settingsOf($: EngineInterface): Promise<BarSettings> {
113  const loaded = await read($, settingsAtom)
114
115  if (loaded) {
116    return loaded
117  }
118
119  try {
120    return toSettings(parseJsonc(await $.fs.read(`${home ?? ''}/${SETTINGS_FILE}`)))
121  } catch {
122    return DEFAULTS
123  }
124}
125
126// This session's folder, as the footer shows it, with a notice on a change.
127async function show($: EngineInterface, next: RemoteState) {
128  const current = await read($, remote)
129
130  if (JSON.stringify(current) !== JSON.stringify(next)) {
131    await update($, remote, () => next)
132  }
133
134  if (next.status !== shown && (await settingsOf($)).remote.notifications) {
135    if (next.status === 'on' && own) {
136      $.ui.toast(`Remote Control on for ${own.name}`)
137    } else if (next.status === 'error' && shown !== 'off') {
138      $.ui.toast(`Remote Control stopped: ${next.detail ?? 'the process exited'}`)
139    }
140  }
141  shown = next.status
142}
143
144// The running remote-control processes, by pid, from one `ps`.
145async function runningProcesses($: EngineInterface) {
146  const { stdout } = await $.process.run(['ps', '-A', '-o', 'pid=,command='])
147  const found = new Map<number, string>()
148
149  for (const line of stdout.split('\n')) {
150    const match = /^\s*(\d+)\s+(.*)$/.exec(line)
151    if (match?.[1] && match[2]?.includes('claude remote-control')) {
152      found.set(Number(match[1]), match[2])
153    }
154  }
155
156  return found
157}
158
159// Reads every folder's record and the process behind it: the panel's list,
160// and this session's own state. Every session runs it, so all of them agree.
161async function poll($: EngineInterface) {
162  if (!own) {
163    await locate($)
164  }
165
166  const processes = await runningProcesses($)
167  const folder = recordsFolder()
168  const entries = (await $.fs.exists(folder)) ? await $.fs.list(folder) : []
169  const instances: RemoteInstance[] = []
170  const known = new Set<number>()
171
172  for (const entry of entries.filter(item => item.name.endsWith('.json'))) {
173    const key = entry.name.slice(0, -'.json'.length)
174    const record = await readRecord($, `${folder}/${entry.name}`)
175
176    if (!record) {
177      continue
178    }
179
180    // A record from before cwd was kept: this session's own folder fills it in.
181    const cwd = record.cwd ?? (own && key === own.key ? own.cwd : null)
182    const name = record.name ?? (cwd ? basename(cwd) : (key.split('_').pop() ?? key))
183    const isAlive = record.pid !== null && processes.has(record.pid)
184    let status: RemoteInstance['status'] = record.pid === null ? 'stopped' : 'error'
185    let url: string | null = null
186    let detail: string | null = null
187
188    // A stopped one still exiting: shown as stopping, not as a stray process;
189    // past the grace period it is killed outright.
190    if (record.pid === null && record.stoppedPid && processes.has(record.stoppedPid)) {
191      known.add(record.stoppedPid)
192      status = 'stopping'
193      if (record.stoppedAt && (await $.clock.now()) - record.stoppedAt > STOP_GRACE_MS) {
194        await $.process.run(['kill', '-9', String(record.stoppedPid)])
195      }
196    }
197
198    if (record.pid !== null) {
199      known.add(record.pid)
200      const { stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), record.log])
201      const output = clean(stdout)
202
203      if (isAlive) {
204        // "Connecting" arrives first; only "Connected" means it is up.
205        status = output.includes('Connected') ? 'on' : 'starting'
206        url = output.match(URL_PATTERN)?.[0] ?? null
207        if ((await $.fs.stat(record.log)).size > MAX_LOG_BYTES) {
208          await $.fs.write(record.log, '')
209        }
210      } else {
211        detail = lastLine(stdout)
212      }
213    }
214
215    instances.push({
216      key,
217      cwd,
218      name,
219      status,
220      url,
221      detail,
222      pid: isAlive ? record.pid : null,
223      startedAt: record.startedAt,
224      stoppedAt: record.stoppedAt ?? null,
225      isExternal: false,
226    })
227  }
228
229  // remote-control processes Bar did not start: listed, and stoppable.
230  for (const [pid, command] of processes) {
231    if (!known.has(pid)) {
232      const name = /--name\s+(\S+)/.exec(command)?.[1] ?? 'remote-control'
233      instances.push({
234        key: `pid-${pid}`,
235        cwd: null,
236        name,
237        status: 'on',
238        url: null,
239        detail: null,
240        pid,
241        startedAt: null,
242        stoppedAt: null,
243        isExternal: true,
244      })
245    }
246  }
247
248  // Running first, then the most recently used.
249  const order = { on: 0, starting: 1, stopping: 2, error: 3, stopped: 4, off: 5 }
250  instances.sort(
251    (a, b) =>
252      order[a.status] - order[b.status] ||
253      (b.stoppedAt ?? b.startedAt ?? 0) - (a.stoppedAt ?? a.startedAt ?? 0),
254  )
255
256  if (JSON.stringify(await read($, remoteAll)) !== JSON.stringify(instances)) {
257    await update($, remoteAll, () => instances)
258  }
259  const mine = own ? instances.find(instance => instance.key === own?.key) : undefined
260  await show(
261    $,
262    !mine || mine.status === 'stopped' || mine.status === 'stopping' || mine.status === 'off'
263      ? { status: 'off', url: null, detail: null }
264      : { status: mine.status, url: mine.url, detail: mine.detail },
265  )
266}
267
268async function startFolder($: EngineInterface, target: Folder) {
269  // Detached so it outlives this session: in the background, stdin closed,
270  // its log opened for appending (so emptying it later is safe),
271  // hung-up signals ignored, and in a process session of its own (perl's
272  // setsid, which macOS lacks as a command), so nothing that cleans up this
273  // shell's group reaches it. exec keeps one pid all the way to claude, the
274  // pid the shell prints. The app's PATH may lack Homebrew's and the usual
275  // install folders, so they are added.
276  const { stdout } = await $.process.run([
277    'sh',
278    '-c',
279    [
280      'export PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:$HOME/.claude/local"',
281      'mkdir -p "$1" && cd "$2" || exit 1',
282      ': >"$4"',
283      'nohup perl -MPOSIX -e \'POSIX::setsid(); exec @ARGV\' claude remote-control --name "$3" >>"$4" 2>&1 </dev/null &',
284      'echo $!',
285    ].join('\n'),
286    'sh',
287    recordsFolder(),
288    target.cwd,
289    target.name,
290    target.log,
291  ])
292  const pid = Number(stdout.trim())
293  const record: RemoteRecord = {
294    cwd: target.cwd,
295    name: target.name,
296    pid: pid || null,
297    log: target.log,
298    startedAt: await $.clock.now(),
299    stoppedAt: null,
300  }
301
302  await $.fs.write(target.record, JSON.stringify(record))
303  await poll($)
304}
305
306// Ends the process and marks the record stopped; the record stays, so the
307// folder can be started again from the panel.
308async function stopInstance($: EngineInterface, instance: RemoteInstance) {
309  if (instance.pid !== null) {
310    await $.process.run(['kill', String(instance.pid)])
311  }
312
313  if (!instance.isExternal) {
314    const path = `${recordsFolder()}/${instance.key}.json`
315    const record = await readRecord($, path)
316    if (record) {
317      await $.fs.write(
318        path,
319        JSON.stringify({ ...record, pid: null, stoppedPid: instance.pid, stoppedAt: await $.clock.now() }),
320      )
321    }
322  }
323
324  await poll($)
325}
326
327// Takes a stopped folder off the list: its record and log go.
328async function forgetInstance($: EngineInterface, instance: RemoteInstance) {
329  await $.process.run(['rm', '-f', `${recordsFolder()}/${instance.key}.json`, `${recordsFolder()}/${instance.key}.log`])
330  await poll($)
331}
332
333async function startInstance($: EngineInterface, instance: RemoteInstance) {
334  if (instance.cwd) {
335    await startFolder($, folderOf(instance.cwd, instance.name))
336  }
337}
338
339async function stopAll($: EngineInterface) {
340  for (const instance of await read($, remoteAll)) {
341    if (instance.pid !== null) {
342      await stopInstance($, instance)
343    }
344  }
345}
346
347// Off or failed starts this folder's; starting or on stops it. Shows the new
348// state at once, before the process has done anything, so a click is never
349// silent.
350async function toggle($: EngineInterface) {
351  await poll($)
352  const { status } = await read($, remote)
353  const { remote: options } = await settingsOf($)
354  const mine = (await read($, remoteAll)).find(instance => instance.key === own?.key)
355
356  if ((status === 'on' || status === 'starting') && mine) {
357    if (options.notifications) {
358      $.ui.toast('Stopping Remote Control')
359    }
360    await stopInstance($, mine)
361  } else if (own) {
362    await show($, { status: 'starting', url: null, detail: null })
363    if (options.notifications) {
364      $.ui.toast(`Starting Remote Control for ${own.name}`)
365    }
366    await startFolder($, own)
367  }
368}
369
370// Sets this session up: where its folder's record lives, `/remote`, and the
371// check every 2 seconds that keeps it in step with the other sessions.
372async function begin($: EngineInterface, isInteractive: boolean) {
373  await locate($)
374  shown = 'off'
375  await $.command.register({
376    name: 'remote',
377    description: 'Remote Control for this folder: on or off; "list" shows every folder, "stop-all" stops them',
378  })
379  // A record from another session (or an earlier run) is picked up here.
380  await poll($)
381  await autoStart($, isInteractive)
382  $.clock.every(POLL_MS, () => void poll($))
383  $.clock.every(TICK_MS, () => void tick($))
384}
385
386// A redraw a second while the panel is shown and something in it runs, so its
387// running times move second by second; a stopped one shows fixed times and
388// needs none.
389async function tick($: EngineInterface) {
390  const isShown = (await $.ui.panes()).some(pane => pane.id === PANEL && pane.isShown)
391
392  if (isShown && (await read($, remoteAll)).some(instance => instance.pid !== null && instance.startedAt)) {
393    $.ui.invalidate('ui.render')
394  }
395}
396
397// When a folder's Remote Control stopped, fixed: `at 22:31` today, the full
398// date before, and how long it ran when its start is known.
399const stoppedLine = (instance: RemoteInstance, now: number) => {
400  const at = instance.stoppedAt ?? 0
401  const isToday = new Date(at).toDateString() === new Date(now).toDateString()
402  const ran = instance.startedAt && instance.startedAt <= at ? ` · ran ${duration(at - instance.startedAt)}` : ''
403
404  return `stopped ${isToday ? `at ${clockTime(at, false)}` : longDate(at)}${ran}`
405}
406
407// Whether this folder has had a session before this one: Claude Code keeps
408// one `<session id>.jsonl` per session under ~/.claude/projects/<folder>,
409// the folder's path with every other character turned into "-".
410async function isNewProject($: EngineInterface) {
411  if (!own) {
412    return false
413  }
414
415  const folder = `${home ?? ''}/.claude/projects/${own.cwd.replace(/[^A-Za-z0-9-]/g, '-')}`
416
417  if (!(await $.fs.exists(folder))) {
418    return true
419  }
420
421  const mine = `${await $.session.id()}.jsonl`
422  const others = (await $.fs.list(folder)).filter(entry => entry.name.endsWith('.jsonl') && entry.name !== mine)
423
424  return others.length === 0
425}
426
427// Starts Remote Control on its own, as `remote.autoStart` says: in a
428// project's first session, or in any session while it isn't running. Never
429// over a failure, so a broken setup doesn't retry on every session. Only in
430// a session a person works in: the terminal's, or the desktop app's (which
431// reports itself as SDK-hosted, so it is told apart by its entrypoint); a
432// one-off `claude -p` run never starts one.
433async function autoStart($: EngineInterface, isInteractive: boolean) {
434  const { remote: options } = await settingsOf($)
435  const { status } = await read($, remote)
436  const isPersonal = isInteractive || (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-desktop'
437
438  if (!isPersonal || !options.enabled || options.autoStart === 'never' || status !== 'off') {
439    return
440  }
441
442  if (options.autoStart === 'always' || (await isNewProject($))) {
443    await toggle($)
444  }
445}
446
447// The footer's marks: emoji, which keep their colour inside a button's label.
448const FOOTER_MARKS: Record<RemoteStatus, string> = {
449  off: '○',
450  starting: '🟡',
451  on: '🟢',
452  error: '🔴',
453}
454
455// The panel's marks and state words, in the theme's colours.
456const LOOKS: Record<RemoteInstance['status'], { mark: string; color: string; word: string | null }> = {
457  off: { mark: '○', color: 'inactive', word: null },
458  stopped: { mark: '○', color: 'inactive', word: 'stopped' },
459  stopping: { mark: '◌', color: 'inactive', word: 'stopping…' },
460  starting: { mark: '◐', color: 'warning', word: 'starting…' },
461  on: { mark: '●', color: 'success', word: 'on' },
462  error: { mark: '✕', color: 'error', word: 'failed' },
463}
464
465export const registerRemote: Register = on => {
466  // Every session: the desktop app's sessions are SDK-hosted, not
467  // interactive, so one hook per kind (a mod takes one unmatched
468  // session.start, and the band has it).
469  on('session.start', { isInteractive: true }, async ($, e, next) => {
470    await begin($, true)
471
472    return next(e)
473  })
474
475  on('session.start', { isInteractive: false }, async ($, e, next) => {
476    await begin($, false)
477
478    return next(e)
479  })
480
481  on('command.run', { command: 'remote' }, async ($, e) => {
482    if (!(await settingsOf($)).remote.enabled) {
483      return { text: 'Remote Control is turned off in Bar settings (remote.enabled).' }
484    }
485
486    const word = e.args.trim().toLowerCase()
487
488    if (word === 'list') {
489      await poll($)
490      await $.ui.open({ id: PANEL, title: 'Remote sessions', focus: true })
491      return { text: 'Opened Remote sessions.' }
492    }
493
494    if (word === 'stop-all') {
495      await stopAll($)
496      return { text: 'Stopped every Remote Control.' }
497    }
498
499    await toggle($)
500    const { status } = await read($, remote)
501
502    return {
503      text:
504        status === 'off'
505          ? 'Remote Control is off.'
506          : 'Starting Remote Control for this folder. The footer turns green once it connects.',
507    }
508  })
509
510  // The footer's right side: what the app and other mods draw there (the mode
511  // labels, another mod's button), then Sessions and the Remote toggle. The
512  // slot is narrow and clips, so Bar adds nothing more: a failure message is in
513  // the Remote sessions panel, where there is room.
514  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
515    const s = await settingsOf($)
516
517    if (!s.remote.enabled) {
518      return next(e)
519    }
520
521    const { Box, Button } = $.ui.resolve(e)
522    const state = await read($, remote)
523    const beneath = await next(e)
524    const hover = (scope: string) => (s.bar.hover ? { hover: { scope, underline: true, dimColor: false } } : {})
525
526    return (
527      <Box key="remote-footer" flexDirection="row" columnGap={1}>
528        {beneath}
529        {s.remote.sessionsButton && (
530          <Button
531            key="remote-sessions"
532            label="Sessions"
533            plain
534            dimColor
535            {...hover('bar-remote-sessions')}
536            onPress={async () => {
537              await poll($)
538              await $.ui.open({ id: PANEL, title: 'Remote sessions', focus: true })
539            }}
540          />
541        )}
542        {/* One button, mark and all, so the whole of it toggles. A label
543            can't take a colour, but an emoji keeps its own: the mark is one
544            while it means something, and a plain circle while off. */}
545        <Button
546          key="remote-toggle"
547          label={`${FOOTER_MARKS[state.status]} ${state.status === 'starting' ? 'Remote…' : 'Remote'}`}
548          plain
549          dimColor={state.status === 'off'}
550          {...hover('bar-remote')}
551          onPress={() => toggle($)}
552        />
553      </Box>
554    )
555  })
556
557  // Every folder's Remote Control: running ones first, each with what it can
558  // do now (Stop while running, nothing while stopping, Start and Remove once
559// stopped).
560  on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e) => {
561    const { Box, Text, Button } = $.ui.resolve(e)
562    const s = await settingsOf($)
563    const list = await read($, remoteAll)
564    const now = await $.clock.now()
565    const running = list.filter(instance => instance.pid !== null)
566    const bind = oneClick(PANEL)
567
568    const action = (key: string, label: string, onPress: () => unknown) => (
569      <Button
570        key={key}
571        label={label}
572        plain
573        {...(s.bar.hover ? { hover: { scope: `bar-${key}`.slice(0, 64), underline: true } } : {})}
574        onPress={bind(key, onPress)}
575      />
576    )
577
578    if (list.length === 0) {
579      return <Text dimColor>No Remote Control yet. Turn it on with the Remote toggle in the footer.</Text>
580    }
581
582    return (
583      <Box flexDirection="column">
584        <Box flexDirection="row" columnGap={3} marginBottom={1}>
585          <Text>
586            <Text bold>{running.length}</Text>
587            <Text dimColor> running · {list.length} in all</Text>
588          </Text>
589          {running.length > 1 && action('remote-stop-all', 'Stop all', () => stopAll($))}
590        </Box>
591        {list.map(instance => {
592          const look = LOOKS[instance.status]
593          const isOwn = instance.key === own?.key
594          const since =
595            instance.pid !== null && instance.startedAt
596              ? `running ${duration(now - instance.startedAt)}`
597              : instance.stoppedAt
598                ? stoppedLine(instance, now)
599                : instance.isExternal
600                  ? 'started outside Bar'
601                  : ''
602
603          return (
604            <Box key={instance.key} flexDirection="column" marginBottom={1}>
605              <Box flexDirection="row" columnGap={1}>
606                <Text color={look.color}>{look.mark}</Text>
607                <Text bold>{instance.name}</Text>
608                {isOwn && <Text dimColor>(this folder)</Text>}
609                {look.word && <Text color={look.color}>{look.word}</Text>}
610                <Box flexGrow={1} />
611                {instance.pid !== null && action(`remote-stop-${instance.key}`, 'Stop', () => stopInstance($, instance))}
612                {instance.status === 'stopped' &&
613                  instance.cwd &&
614                  action(`remote-start-${instance.key}`, 'Start', () => startInstance($, instance))}
615                {instance.status === 'stopped' &&
616                  !instance.isExternal &&
617                  action(`remote-forget-${instance.key}`, 'Remove', () => forgetInstance($, instance))}
618              </Box>
619              <Text dimColor wrap="truncate-start">
620                {[instance.cwd ?? (instance.isExternal ? `pid ${instance.pid}` : ''), since]
621                  .filter(Boolean)
622                  .join('  ·  ')}
623              </Text>
624              {instance.status === 'error' && instance.detail && (
625                <Text color="error" dimColor wrap="truncate-end">
626                  {instance.detail}
627                </Text>
628              )}
629            </Box>
630          )
631        })}
632      </Box>
633    )
634  })
635}
636
hooks/tools.tsx 88 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ToolCallRecord } from '../types'
5import { clockTime, duration } from './format'
6import { oneClick } from './one-click'
7
8// The Tool calls panel, opened from the count on the bar: one line per call
9// of the running (or last) turn, each opening to its input and output.
10
11const calls = atom({ plugin: 'bar', key: 'calls' } as const, [])
12const openCalls = atom({ plugin: 'bar', key: 'openCalls' } as const, [])
13
14export const TOOLS_PANEL = 'bar-tools'
15
16const STATUS_COLORS: Record<ToolCallRecord['status'], string> = {
17  running: 'suggestion',
18  done: 'success',
19  error: 'error',
20  denied: 'warning',
21}
22
23export const registerTools: Register = on => {
24  on('ui.render', { component: 'Pane', requestId: TOOLS_PANEL }, async ($, e) => {
25    const { Box, Text, Button, Code } = $.ui.resolve(e)
26    const list = await read($, calls)
27    const open = new Set(await read($, openCalls))
28
29    if (list.length === 0) {
30      return <Text dimColor>No tool calls in this turn yet.</Text>
31    }
32
33    const bind = oneClick(TOOLS_PANEL)
34    const toggle = (id: string) =>
35      update($, openCalls, ids => (ids.includes(id) ? ids.filter(other => other !== id) : [...ids, id]))
36
37    return (
38      <Box flexDirection="column">
39        <Text dimColor>
40          {list.length} {list.length === 1 ? 'call' : 'calls'} · click one for its input and output
41        </Text>
42        {list.map((call, index) => {
43          const isOpen = open.has(call.id)
44          const scope = `bar-call-${index}`
45
46          return (
47            <Box key={call.id} flexDirection="column" marginTop={index === 0 ? 1 : 0}>
48              <Box flexDirection="row" columnGap={1} height={1} overflow="hidden">
49                <Text color={STATUS_COLORS[call.status]}>●</Text>
50                <Button
51                  key={`call-${call.id}`}
52                  label={`${isOpen ? '▾' : '▸'} ${call.tool}`}
53                  plain
54                  hover={{ scope, underline: true }}
55                  onPress={bind(`call-${call.id}`, () => toggle(call.id))}
56                />
57                <Box flexGrow={1} flexShrink={1} overflow="hidden">
58                  <Text dimColor wrap="truncate-end">
59                    {call.summary}
60                  </Text>
61                </Box>
62                <Box flexShrink={0}>
63                  <Text dimColor>{call.ms === null ? 'running' : duration(call.ms)}</Text>
64                </Box>
65              </Box>
66              {isOpen && (
67                <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
68                  <Text dimColor>
69                    {clockTime(call.startedAt)} · {call.status}
70                  </Text>
71                  <Text bold>Input</Text>
72                  <Code source={call.input} language="json" />
73                  <Text bold>Output</Text>
74                  {call.output === null ? (
75                    <Text dimColor>Still running</Text>
76                  ) : (
77                    <Code source={call.output || '(empty)'} />
78                  )}
79                </Box>
80              )}
81            </Box>
82          )
83        })}
84      </Box>
85    )
86  })
87}
88
hooks/turns.tsx 330 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LiveTurn, ToolCallRecord, Tokens, Turn } from '../types'
5import { clockTime, duration, shortCount } from './format'
6import { DEFAULTS } from './settings'
7
8const KEPT_TURNS = 50
9// Markdown draws at most 10,000 characters; a longer answer keeps the
10// engine's own drawing.
11const MAX_FRAMED = 9_500
12// The one colour the timer lines use, on the value worth a glance; the rest
13// is dim, like the app's own metadata rows.
14const ACCENT = 'suggestion'
15
16const turns = atom({ plugin: 'bar', key: 'turns' } as const, [])
17const calls = atom({ plugin: 'bar', key: 'calls' } as const, [])
18const settingsAtom = atom({ plugin: 'bar', key: 'settings' } as const, null)
19const openCalls = atom({ plugin: 'bar', key: 'openCalls' } as const, [])
20// The prompt cache and the Hand off button (hooks/cache.tsx): each response
21// restarts the cache's countdown, and the first turn after a Hand off click
22// is the one writing the handoff.
23const cacheAtom = atom({ plugin: 'bar', key: 'cache' } as const, null)
24const handoffAtom = atom({
25  plugin: 'bar',
26  key: 'handoff',
27} as const, { status: 'idle', path: null, clickedAt: null, turnId: null, detail: null })
28
29// What the Tool calls panel keeps of each input and output.
30const MAX_DETAIL = 4_000
31// The keys tool.call adds beside a tool's own arguments.
32const ENVELOPE = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'origin'])
33
34const clip = (text: string) =>
35  text.length > MAX_DETAIL ? `${text.slice(0, MAX_DETAIL)}\n… cut at ${MAX_DETAIL} characters` : text
36
37// The line a call shows in the list: the argument that says the most about
38// it (a command, a path, a pattern), on one line.
39const summarize = (args: Record<string, unknown>) => {
40  for (const key of ['description', 'command', 'file_path', 'path', 'pattern', 'url', 'query', 'prompt', 'skill']) {
41    const value = args[key]
42    if (typeof value === 'string' && value.trim()) {
43      return value.replace(/\s+/g, ' ').trim().slice(0, 160)
44    }
45  }
46
47  return ''
48}
49
50// About four characters to a token: enough for a count that moves while a
51// response streams, replaced by the exact figure when the request ends.
52const CHARS_PER_TOKEN = 4
53
54const noTokens = (): Tokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
55
56// The running turn's tally, kept here; the bar reads it when it redraws, so
57// a fast stream does not redraw the bar on every piece.
58let tally = noTokens()
59let streamingChars = 0
60let toolCalls = 0
61// Output tokens each second of the running turn, for the bar's sparkline:
62// the newest last, at most `RATE_SAMPLES` of them.
63const RATE_SAMPLES = 24
64let rate: number[] = []
65let lastOutput = 0
66
67const outputSoFar = () => tally.output + Math.round(streamingChars / CHARS_PER_TOKEN)
68
69// One sample a second. A finished request swaps its estimate for the exact
70// count, which can land a little under it: never a negative rate.
71const sampleRate = () => {
72  const output = outputSoFar()
73  rate = [...rate, Math.max(0, output - lastOutput)].slice(-RATE_SAMPLES)
74  lastOutput = output
75}
76
77const normalize = (text: string) => text.replace(/\s+/g, ' ').trim()
78
79// The finished turn whose final answer is (or ends with) this text block.
80const turnForAnswer = (list: Turn[], text: string) => {
81  const block = normalize(text)
82
83  return block
84    ? list.findLast(
85        turn =>
86          turn.answer !== null &&
87          turn.endedAt !== null &&
88          normalize(turn.answer).endsWith(block),
89      )
90    : undefined
91}
92
93
94
95let tick: { cancel: () => void } | null = null
96
97// The running turn's tally as the bar draws it: read straight from this
98// module when the bar redraws, so nothing is written each second.
99export const liveSnapshot = (): LiveTurn => ({
100  tokens: { ...tally },
101  streaming: Math.round(streamingChars / CHARS_PER_TOKEN),
102  tools: toolCalls,
103  rate: [...rate],
104})
105
106export const registerTurns: Register = on => {
107  on('turn.start', async ($, e, next) => {
108    const startedAt = await $.clock.now()
109    const turn: Turn = {
110      id: e.turnId,
111      prompt: e.text,
112      startedAt,
113      endedAt: null,
114      answer: null,
115      isAborted: false,
116      tokens: null,
117      tools: 0,
118    }
119    await update($, turns, list => [...list, turn].slice(-KEPT_TURNS))
120
121    await update($, handoffAtom, state =>
122      state.status === 'writing' && state.turnId === null ? { ...state, turnId: e.turnId } : state,
123    )
124
125    tally = noTokens()
126    streamingChars = 0
127    toolCalls = 0
128    rate = []
129    lastOutput = 0
130    await update($, calls, () => [])
131    await update($, openCalls, () => [])
132
133    tick?.cancel()
134    // A redraw a second while the turn runs, and nothing else: the bar and
135    // the spinner read the clock and the tally themselves when they draw.
136    // (Writing them to state instead made every tick wait for a redraw to
137    // land, a second or more on the desktop, so ticks were skipped and the
138    // timer moved in 2-second steps.)
139    tick = $.clock.every(1000, () => {
140      sampleRate()
141      $.ui.invalidate('ui.render')
142    })
143
144    return next(e)
145  })
146
147  on('turn.complete', async ($, e, next) => {
148    // Subagents finish turns too; only the main loop's ends the timer.
149    if (e.agentId === undefined) {
150      tick?.cancel()
151      tick = null
152      const endedAt = await $.clock.now()
153      const tokens = e.usage
154        ? {
155            input: e.usage.input_tokens,
156            output: e.usage.output_tokens,
157            cacheRead: e.usage.cache_read_input_tokens,
158            cacheWrite: e.usage.cache_creation_input_tokens,
159          }
160        : null
161      await update($, turns, list =>
162        list.map(turn =>
163          turn.id === e.turnId
164            ? { ...turn, endedAt, answer: e.answer, isAborted: e.isAborted, tokens, tools: toolCalls }
165            : turn,
166        ),
167      )
168    }
169
170    return next(e)
171  })
172
173  // Counts the main loop's tool calls (a subagent's are its own)
174  // and records each one for the Tool calls panel.
175  on('tool.call', async ($, e, next) => {
176    if (e.agentId !== undefined) {
177      return next(e)
178    }
179
180    toolCalls += 1
181    const args = Object.fromEntries(Object.entries(e).filter(([key]) => !ENVELOPE.has(key)))
182    const startedAt = await $.clock.now()
183    const id = e.tool_use_id ?? `${e.tool}-${startedAt}`
184    const record: ToolCallRecord = {
185      id,
186      tool: e.tool,
187      summary: summarize(args),
188      input: clip(JSON.stringify(args, null, 2)),
189      output: null,
190      status: 'running',
191      startedAt,
192      ms: null,
193    }
194    await update($, calls, list => [...list, record])
195
196    const result = await next(e)
197    const ms = (await $.clock.now()) - startedAt
198    const finished: ToolCallRecord =
199      'deny' in result && result.deny
200        ? { ...record, status: 'denied', output: result.deny, ms }
201        : {
202            ...record,
203            status: result.isError ? 'error' : 'done',
204            output: clip(result.text ?? JSON.stringify(result.result ?? null, null, 2)),
205            ms,
206          }
207    await update($, calls, list => list.map(call => (call.id === id ? finished : call)))
208
209    return result
210  })
211
212  // Each model request of the main loop: its streamed pieces feed the
213  // estimate, and its stop brings the exact usage. Passes every chunk on.
214  on('turn.step', async function* ($, e, next) {
215    const stream = next(e)
216
217    if (e.agentId !== undefined) {
218      return yield* stream
219    }
220
221    let step = await stream.next()
222
223    while (!step.done) {
224      const chunk = step.value
225
226      if (chunk.kind === 'text' || chunk.kind === 'thinking') {
227        streamingChars += chunk.text.length
228      } else if (chunk.kind === 'input') {
229        streamingChars += chunk.json.length
230      } else if (chunk.kind === 'stop') {
231        streamingChars = 0
232        const at = await $.clock.now()
233        await update($, cacheAtom, cache => ({ at, ttl: cache?.ttl ?? '5m' }))
234        if (chunk.usage) {
235          tally = {
236            input: tally.input + chunk.usage.input_tokens,
237            output: tally.output + chunk.usage.output_tokens,
238            cacheRead: tally.cacheRead + chunk.usage.cache_read_input_tokens,
239            cacheWrite: tally.cacheWrite + chunk.usage.cache_creation_input_tokens,
240          }
241        }
242      }
243
244      yield chunk
245      step = await stream.next()
246    }
247
248    return step.value
249  })
250
251  // While the turn runs, the same live count beside the spinner.
252  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
253    const running = (await read($, turns)).findLast(turn => turn.endedAt === null)
254    const { spinnerTimer } = (await read($, settingsAtom)) ?? DEFAULTS
255
256    if (!running || !spinnerTimer) {
257      return next(e)
258    }
259
260    const at = await $.clock.now()
261
262    return next({
263      ...e,
264      props: { ...e.props, suffix: `… ${duration(at - running.startedAt)}` },
265    })
266  })
267
268  // The final answer, framed apart from the work before it; the turn's end
269  // line sits under the frame, closing the turn. Each part is a setting
270  // (`answer`): without the frame the answer draws plain, footer and all.
271  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
272    const { answer } = (await read($, settingsAtom)) ?? DEFAULTS
273
274    if (!answer.frame && !answer.footer) {
275      return next(e)
276    }
277
278    const turn = turnForAnswer(await read($, turns), e.props.text)
279
280    if (!turn || turn.endedAt === null || e.props.text.length > MAX_FRAMED) {
281      return next(e)
282    }
283
284    const { Box, Text, Markdown } = $.ui.resolve(e)
285
286    const { tokens } = turn
287
288    return (
289      <Box flexDirection="column" marginTop={1}>
290        {answer.frame ? (
291          <Box
292            flexDirection="column"
293            borderStyle="round"
294            borderColor="claude"
295            paddingX={1}
296          >
297            <Text color="claude">✻ Answer</Text>
298            <Markdown text={e.props.text} />
299          </Box>
300        ) : (
301          <Markdown text={e.props.text} />
302        )}
303        {answer.footer && (
304          <Box marginTop={1} paddingX={answer.frame ? 1 : 0} flexDirection="row" justifyContent="space-between">
305            <Text dimColor>
306              {turn.isAborted ? (
307                <Text color="warning">Interrupted after {duration(turn.endedAt - turn.startedAt)}</Text>
308              ) : (
309                <Text>
310                  Done in <Text color={ACCENT}>{duration(turn.endedAt - turn.startedAt)}</Text>
311                </Text>
312              )}
313              {'  ·  '}
314              {clockTime(turn.endedAt)}
315            </Text>
316            {answer.footerTokens && tokens && (
317              <Text dimColor>
318                {shortCount(tokens.input)} in{'  ·  '}
319                {shortCount(tokens.output)} out{'  ·  '}
320                {shortCount(tokens.cacheRead)} cache read{'  ·  '}
321                {shortCount(tokens.cacheWrite)} cache write
322              </Text>
323            )}
324          </Box>
325        )}
326      </Box>
327    )
328  })
329}
330
hooks/format.ts 86 lines
1// Theme keys, so the engine picks the light or dark shade of each colour
2// from the active theme, including when the theme follows the system. The
3// share of a limit where each colour starts is a setting (`levels`).
4type Levels = { yellow: number; orange: number; red: number }
5
6export const colorFor = (ratio: number, levels: Levels = { yellow: 0.5, orange: 0.75, red: 1 }) =>
7  ratio >= levels.red
8    ? 'error'
9    : ratio >= levels.orange
10      ? 'claude'
11      : ratio >= levels.yellow
12        ? 'warning'
13        : 'success'
14
15const pad = (n: number) => String(n).padStart(2, '0')
16
17export const clockTime = (ms: number, withSeconds = true) => {
18  const date = new Date(ms)
19  const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`
20
21  return withSeconds ? `${hm}:${pad(date.getSeconds())}` : hm
22}
23
24const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
25const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
26
27// The full reset moment: `Sun 5 Oct, 21:34`.
28export const longDate = (ms: number) => {
29  const date = new Date(ms)
30
31  return `${DAYS[date.getDay()]} ${date.getDate()} ${MONTHS[date.getMonth()]}, ${clockTime(ms, false)}`
32}
33
34// How long until a moment, coarse: `2d 3h`, `1h 59m`, `12m`.
35export const until = (ms: number) => {
36  const minutes = Math.max(0, Math.round(ms / 60_000))
37  const hours = Math.floor(minutes / 60)
38  const days = Math.floor(hours / 24)
39
40  return days > 0
41    ? `${days}d ${hours % 24}h`
42    : hours > 0
43      ? `${hours}h ${minutes % 60}m`
44      : `${minutes}m`
45}
46
47// When a window resets: a clock time today, else the weekday.
48export const resetTime = (resetsAt: string, now: number) => {
49  const at = Date.parse(resetsAt)
50  const isToday = new Date(at).toDateString() === new Date(now).toDateString()
51
52  return isToday
53    ? clockTime(at, false)
54    : (DAYS[new Date(at).getDay()] ?? '')
55}
56
57
58export const shortCount = (n: number) =>
59  n >= 1_000_000
60    ? `${(n / 1_000_000).toFixed(1)}M`
61    : n >= 10_000
62      ? `${Math.round(n / 1_000)}k`
63      : n >= 1_000
64        ? `${(n / 1_000).toFixed(1)}k`
65        : `${n}`
66
67// A duration the way Claude Code writes one: `8s`, `1m 4s`, `1h 2m`.
68export const duration = (ms: number) => {
69  const seconds = Math.max(0, Math.round(ms / 1000))
70  const minutes = Math.floor(seconds / 60)
71  const hours = Math.floor(minutes / 60)
72
73  return hours > 0
74    ? `${hours}h ${minutes % 60}m`
75    : minutes > 0
76      ? `${minutes}m ${seconds % 60}s`
77      : `${seconds}s`
78}
79
80// Time left on a countdown: `59:12`, `4:05`, `0:09`.
81export const countdown = (ms: number) => {
82  const seconds = Math.max(0, Math.ceil(ms / 1000))
83
84  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
85}
86
hooks/meter.ts 141 lines
1// The bar's drawings on the desktop: SVG the app draws as images. The app
2// sets each SVG's `color` to its text colour, so `currentColor` is a light
3// line on a dark theme and a dark one on a light theme. Plain functions with
4// no `$`, so a test can call them.
5//
6// One language throughout: a hairline track, a fill that brightens toward
7// its end, and a bead (the level's colour with a white heart) at the tip.
8
9// The level colours, as mid-tones that read on a light and a dark background
10// alike; the panel and the pills use the same ones.
11export const LEVEL_RGB: Record<string, string> = {
12  success: '34, 160, 90',
13  warning: '202, 138, 4',
14  claude: '217, 119, 87',
15  error: '220, 38, 38',
16}
17
18const rgbOf = (level: string) => `rgb(${LEVEL_RGB[level] ?? LEVEL_RGB.success})`
19
20const clamp = (n: number) => Math.min(Math.max(n, 0), 1)
21
22const svg = (width: string, height: string, body: string) =>
23  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${body}</svg>`
24
25// The fill's gradient and the bead's glow.
26const defs = (rgb: string) =>
27  '<defs>' +
28  `<linearGradient id="fill"><stop offset="0" stop-color="${rgb}" stop-opacity="0.15"/><stop offset="1" stop-color="${rgb}"/></linearGradient>` +
29  '<filter id="glow" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="2"/></filter>' +
30  '</defs>'
31
32// The bead at a point: glow, body, white heart. Positions go in `style`,
33// where the browser resolves `calc()`, which a stretched width needs.
34const bead = (cx: string, cy: string, rgb: string) =>
35  `<circle style="cx:${cx};cy:${cy}" r="5" fill="${rgb}" fill-opacity="0.5" filter="url(#glow)"/>` +
36  `<circle style="cx:${cx};cy:${cy}" r="3" fill="${rgb}"/>` +
37  `<circle style="cx:${cx};cy:${cy}" r="1.2" fill="#fff" fill-opacity="0.9"/>`
38
39export type MeterOptions = {
40  // Shares where a faint tick marks the next colour; only those still ahead
41  // of the fill are drawn, since a passed one would cut through it.
42  ticks?: number[]
43  // How much of a usage window has passed: a "now" mark on the track. Fill
44  // past it means usage is running ahead of the clock.
45  cursor?: number | null
46  // Room kept at either end for the bead and its glow, in CSS pixels.
47  pad?: number
48}
49
50// The line meter: stretches to the room it is given.
51export function meterSvg(ratio: number, level: string, options: MeterOptions | number[] = {}) {
52  const { ticks = [], cursor = null, pad = 6 } = Array.isArray(options) ? { ticks: options } : options
53  const share = clamp(ratio)
54  const rgb = rgbOf(level)
55  const along = (at: number) => `calc(${pad}px + (100% - ${2 * pad}px) * ${at})`
56  const tickMarks = ticks
57    .filter(tick => tick > share && tick < 1)
58    .map(
59      tick =>
60        `<rect style="x:calc(${along(tick)} - 0.5px);y:calc(50% - 3px);width:1px;height:6px" rx="0.5" fill="currentColor" fill-opacity="0.28"/>`,
61    )
62    .join('')
63  const now =
64    cursor === null
65      ? ''
66      : `<rect style="x:calc(${along(clamp(cursor))} - 0.75px);y:calc(50% - 5px);width:1.5px;height:10px" rx="0.75" fill="currentColor" fill-opacity="0.6"/>`
67  const fill =
68    share > 0
69      ? `<rect style="x:${pad}px;y:calc(50% - 1.5px);width:calc((100% - ${2 * pad}px) * ${share});height:3px" rx="1.5" fill="url(#fill)"/>` +
70        bead(along(share), '50%', rgb)
71      : ''
72
73  return svg(
74    '100%',
75    '100%',
76    defs(rgb) +
77      `<rect style="x:${pad}px;y:calc(50% - 0.5px);width:calc(100% - ${2 * pad}px);height:1px" rx="0.5" fill="currentColor" fill-opacity="0.2"/>` +
78      tickMarks +
79      now +
80      fill,
81  )
82}
83
84// While Claude works: the bead with a ring that swells once a second. The
85// bar redraws each second while a turn runs, so the beat keeps time with
86// the timer beside it.
87export const pulseSvg = (level: string) => {
88  const rgb = rgbOf(level)
89
90  return svg(
91    '14',
92    '14',
93    `<circle cx="7" cy="7" r="3" fill="none" stroke="${rgb}" stroke-width="1.5">` +
94      '<animate attributeName="r" values="3;6.5" dur="1s" repeatCount="indefinite"/>' +
95      '<animate attributeName="stroke-opacity" values="0.7;0" dur="1s" repeatCount="indefinite"/>' +
96      '</circle>' +
97      `<circle cx="7" cy="7" r="4" fill="${rgb}" fill-opacity="0.35"/>` +
98      `<circle cx="7" cy="7" r="3" fill="${rgb}"/>` +
99      '<circle cx="7" cy="7" r="1.2" fill="#fff" fill-opacity="0.9"/>',
100  )
101}
102
103// Between turns: an empty ring, the bead at rest.
104export const restingSvg = () =>
105  svg('14', '14', '<circle cx="7" cy="7" r="3" fill="none" stroke="currentColor" stroke-opacity="0.45" stroke-width="1.5"/>')
106
107// Output tokens a second across the running turn, oldest first, ending in
108// the bead. Fewer than two samples draw nothing.
109export function sparkSvg(values: number[], level: string, width = 72, height = 16) {
110  if (values.length < 2) {
111    return null
112  }
113
114  const rgb = rgbOf(level)
115  const max = Math.max(...values, 1)
116  const points = values.map(
117    (value, index) => [(index / (values.length - 1)) * (width - 6) + 1, height - 2 - (value / max) * (height - 5)] as const,
118  )
119  const path = points.map(([x, y], index) => `${index ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ')
120  const [lastX, lastY] = points[points.length - 1] ?? [0, 0]
121
122  return svg(
123    String(width),
124    String(height),
125    '<defs>' +
126      `<linearGradient id="area" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${rgb}" stop-opacity="0.35"/><stop offset="1" stop-color="${rgb}" stop-opacity="0"/></linearGradient>` +
127      '<filter id="glow" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="1.5"/></filter>' +
128      '</defs>' +
129      `<path d="${path} L${lastX.toFixed(1)} ${height} L1 ${height} Z" fill="url(#area)"/>` +
130      `<path d="${path}" fill="none" stroke="${rgb}" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round"/>` +
131      `<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="4" fill="${rgb}" fill-opacity="0.5" filter="url(#glow)"/>` +
132      `<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="2.2" fill="${rgb}"/>`,
133  )
134}
135
136// The hairline between segments.
137export const ruleSvg = () => svg('1', '16', '<rect width="1" height="16" fill="currentColor" fill-opacity="0.16"/>')
138
139// The hairline between the rows.
140export const hruleSvg = () => svg('100%', '1', '<rect width="100%" height="1" fill="currentColor" fill-opacity="0.1"/>')
141
hooks/settings.ts 233 lines
1import type { BarSettings, ContextLimit } from '../types'
2
3// The settings file, `~/.claude/bar/settings.jsonc`: what each option is,
4// its default, and how a file's text becomes BarSettings. Plain functions with
5// no `$`, so every module imports them.
6
7export const SETTINGS_FILE = '.claude/bar/settings.jsonc'
8
9export const DEFAULTS: BarSettings = {
10  bar: { enabled: true, hover: true, pillBorders: true },
11  context: { enabled: true, limit: 'autoCompact', click: 'usage' },
12  planLimits: { fiveHour: true, weekly: true, resetTime: true, hoverDetails: true, pace: true },
13  turn: {
14    enabled: true,
15    whileWorking: true,
16    lastTurn: true,
17    tokens: true,
18    toolCalls: true,
19    toolCallsPanel: true,
20  },
21  answer: { frame: true, footer: true, footerTokens: true },
22  spinnerTimer: true,
23  cache: { enabled: true, handoff: true },
24  remote: { enabled: true, autoStart: 'newProjects', notifications: true, sessionsButton: true },
25  levels: { yellow: 0.5, orange: 0.75, red: 1 },
26}
27
28// Written once, when the file does not exist yet: every option with its
29// default and what it does, so the file documents itself.
30export const TEMPLATE = `// Bar settings. Changes apply within a few seconds; no restart needed.
31// Delete an option (or the whole file) to get its default back; this file is
32// written again with every default if it goes missing.
33{
34  // ── Bar above the prompt ──────────────────────────────────────────────
35  "bar": {
36    "enabled": true,              // false hides the whole bar (the turn timer, answer frame and Remote toggle keep working)
37    "hover": true,                // master switch for every hover effect in the bar: the details, the underlines
38    "pillBorders": true           // the faint outline around the bar's panel (desktop)
39  },
40
41  // ── Context (row 1) ───────────────────────────────────────────────────
42  "context": {
43    "enabled": true,
44    "limit": "autoCompact",       // where the meter is full and red:
45                                  // "autoCompact": your auto-compact window (autoCompactWindow in Claude Code's
46                                  //   settings), or the model's window when none is set
47                                  // "model": the model's own context window
48                                  // a number (e.g. 300000): your own limit, capped at the model's window
49                                  // /bar-limit 300k, /bar-limit model and /bar-limit off (autoCompact) write it.
50    "click": "usage"              // what clicking "Context" does: "usage" (runs /usage) or "none"
51  },
52
53  // ── Plan limits (5h and weekly, row 1) ────────────────────────────────
54  "planLimits": {
55    "fiveHour": true,             // show the 5h meter
56    "weekly": true,               // show the Week meter
57    "resetTime": true,            // the "21:20" / "Sat" after the percentage
58    "hoverDetails": true,         // exact reset date over row 2 while hovering 5h or Week (needs bar.hover)
59    "pace": true                  // the "now" mark on those meters, and "on pace for ~84%" in their details
60  },
61
62  // ── The turn (row 2) ──────────────────────────────────────────────────
63  "turn": {
64    "enabled": true,              // false hides the turn; the cache and Hand off keep row 2
65    "whileWorking": true,         // live "Working 1m 12s", its beat and output sparkline, while Claude works
66    "lastTurn": true,             // "Last turn 37s" between turns
67    "tokens": true,               // the out / read / write figures (hover the turn for all four)
68    "toolCalls": true,            // the "9 tools" count
69    "toolCallsPanel": true        // clicking that count opens the Tool calls panel
70  },
71
72  // ── In the conversation ───────────────────────────────────────────────
73  "answer": {
74    "frame": true,                // the orange frame around each turn's final answer
75    "footer": true,               // "Done in 37s · 20:38:33" under the frame
76    "footerTokens": true          // the token figures on the right of that footer
77  },
78  "spinnerTimer": true,           // live time beside the app's "working" indicator
79
80  // ── Prompt cache (row 2) ──────────────────────────────────────────────
81  "cache": {
82    "enabled": true,              // the Cache countdown: time left before the conversation drops out of the prompt cache
83    "handoff": true               // the "Hand off" button beside it: writes a handoff and continues in a new session
84  },
85
86  // ── Remote Control (prompt footer) ────────────────────────────────────
87  "remote": {
88    "enabled": true,              // show the ○ Remote toggle and the /remote command
89    "autoStart": "newProjects",   // "never"
90                                  // "newProjects": start it in a project's first session
91                                  // "always": start it whenever a session opens and it isn't running
92    "notifications": true,        // the "Remote Control on for <project>" notices
93    "sessionsButton": true        // the "Remote sessions" button: every folder's Remote Control, to stop or start again
94  },
95
96  // ── Colors ────────────────────────────────────────────────────────────
97  // The share of a limit where the meters, numbers and borders change color.
98  "levels": {
99    "yellow": 0.5,
100    "orange": 0.75,
101    "red": 1.0
102  }
103}
104`
105
106// JSON with comments and trailing commas, as people write it: comments go
107// (never inside a string), then commas before a closing bracket.
108export const parseJsonc = (text: string): unknown => {
109  let out = ''
110  let inString = false
111
112  for (let i = 0; i < text.length; i += 1) {
113    const char = text[i]
114    const nextChar = text[i + 1]
115
116    if (inString) {
117      out += char
118      if (char === '\\') {
119        out += nextChar ?? ''
120        i += 1
121      } else if (char === '"') {
122        inString = false
123      }
124    } else if (char === '"') {
125      inString = true
126      out += char
127    } else if (char === '/' && nextChar === '/') {
128      while (i < text.length && text[i] !== '\n') i += 1
129      out += '\n'
130    } else if (char === '/' && nextChar === '*') {
131      i += 2
132      while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1
133      i += 1
134    } else {
135      out += char
136    }
137  }
138
139  return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'))
140}
141
142const isObject = (value: unknown): value is Record<string, unknown> =>
143  typeof value === 'object' && value !== null && !Array.isArray(value)
144
145// Each option from the file when it has the default's type (or is one of a
146// choice's words), else the default: a typo never breaks the bar.
147const pick = <T,>(value: unknown, fallback: T, choices?: readonly string[]): T => {
148  if (choices) {
149    return typeof value === 'string' && choices.includes(value) ? (value as T) : fallback
150  }
151  if (typeof fallback === 'boolean') {
152    return (typeof value === 'boolean' ? value : fallback) as T
153  }
154
155  return (typeof value === typeof fallback ? value : fallback) as T
156}
157
158const level = (value: unknown, fallback: number) =>
159  typeof value === 'number' && value > 0 && value <= 10 ? value : fallback
160
161export const toSettings = (raw: unknown): BarSettings => {
162  const file = isObject(raw) ? raw : {}
163  const section = (name: string) => (isObject(file[name]) ? (file[name] as Record<string, unknown>) : {})
164  const bar = section('bar')
165  const context = section('context')
166  const plan = section('planLimits')
167  const turn = section('turn')
168  const answer = section('answer')
169  const remote = section('remote')
170  const cache = section('cache')
171  const levels = section('levels')
172  const d = DEFAULTS
173  const limit = context.limit
174
175  return {
176    bar: {
177      enabled: pick(bar.enabled, d.bar.enabled),
178      hover: pick(bar.hover, d.bar.hover),
179      pillBorders: pick(bar.pillBorders, d.bar.pillBorders),
180    },
181    context: {
182      enabled: pick(context.enabled, d.context.enabled),
183      limit: typeof limit === 'number' && limit > 0 ? Math.round(limit) : limit === 'model' ? 'model' : 'autoCompact',
184      click: pick(context.click, d.context.click, ['usage', 'none']),
185    },
186    planLimits: {
187      fiveHour: pick(plan.fiveHour, d.planLimits.fiveHour),
188      weekly: pick(plan.weekly, d.planLimits.weekly),
189      resetTime: pick(plan.resetTime, d.planLimits.resetTime),
190      hoverDetails: pick(plan.hoverDetails, d.planLimits.hoverDetails),
191      pace: pick(plan.pace, d.planLimits.pace),
192    },
193    turn: {
194      enabled: pick(turn.enabled, d.turn.enabled),
195      whileWorking: pick(turn.whileWorking, d.turn.whileWorking),
196      lastTurn: pick(turn.lastTurn, d.turn.lastTurn),
197      tokens: pick(turn.tokens, d.turn.tokens),
198      toolCalls: pick(turn.toolCalls, d.turn.toolCalls),
199      toolCallsPanel: pick(turn.toolCallsPanel, d.turn.toolCallsPanel),
200    },
201    answer: {
202      frame: pick(answer.frame, d.answer.frame),
203      footer: pick(answer.footer, d.answer.footer),
204      footerTokens: pick(answer.footerTokens, d.answer.footerTokens),
205    },
206    spinnerTimer: pick(file.spinnerTimer, d.spinnerTimer),
207    cache: {
208      enabled: pick(cache.enabled, d.cache.enabled),
209      handoff: pick(cache.handoff, d.cache.handoff),
210    },
211    remote: {
212      enabled: pick(remote.enabled, d.remote.enabled),
213      autoStart: pick(remote.autoStart, d.remote.autoStart, ['never', 'newProjects', 'always']),
214      notifications: pick(remote.notifications, d.remote.notifications),
215      sessionsButton: pick(remote.sessionsButton, d.remote.sessionsButton),
216    },
217    levels: {
218      yellow: level(levels.yellow, d.levels.yellow),
219      orange: level(levels.orange, d.levels.orange),
220      red: level(levels.red, d.levels.red),
221    },
222  }
223}
224
225// The file's text with `context.limit` set to a new value, its comments and
226// layout kept: what /bar-limit writes.
227export const withLimit = (text: string, limit: ContextLimit) => {
228  const value = typeof limit === 'number' ? String(limit) : `"${limit}"`
229  const pattern = /("limit"\s*:\s*)(null|\d+(?:\.\d+)?|"[^"\n]*")/
230
231  return pattern.test(text) ? text.replace(pattern, `$1${value}`) : null
232}
233
types/index.d.ts 146 lines
1// The context window's fill as the last response reported it.
2export type Fill = { tokens: number; window: number }
3
4// A plan usage window (`five_hour`, `seven_day`) as the last response had it.
5export type Limit = { kind: string; percent: number; resetsAt: string | null }
6
7// What a turn's requests cost in tokens, summed, as the API bills them.
8export type Tokens = {
9  input: number
10  output: number
11  cacheRead: number
12  cacheWrite: number
13}
14
15// Everything one drawing of the bar shows.
16export type BarData = {
17  tokens: number
18  limit: number
19  limits: Limit[]
20}
21
22// The running turn's tokens so far: exact for each finished model request,
23// plus an estimate of the output still streaming in; and its output tokens
24// each second, oldest first, for the sparkline.
25export type LiveTurn = { tokens: Tokens; streaming: number; tools: number; rate: number[] }
26
27// One tool call of the main loop, for the Tool calls panel: what it was,
28// a one-line summary, and its input and output cut to a readable length.
29export type ToolCallRecord = {
30  id: string
31  tool: string
32  summary: string
33  input: string
34  output: string | null
35  status: 'running' | 'done' | 'error' | 'denied'
36  startedAt: number
37  ms: number | null
38}
39
40// Where the context meter is full: the auto-compact window (the model's
41// window when none is set), the model's own window, or a token count.
42export type ContextLimit = 'autoCompact' | 'model' | number
43
44// The window auto-compact measures against, and who set it (`settings` for
45// autoCompactWindow, `auto` when it is the model's own window).
46export type CompactWindow = { tokens: number; source: string }
47
48// Bar's settings, from ~/.claude/bar/settings.jsonc (hooks/settings.ts
49// documents each one).
50export type BarSettings = {
51  bar: { enabled: boolean; hover: boolean; pillBorders: boolean }
52  context: { enabled: boolean; limit: ContextLimit; click: 'usage' | 'none' }
53  planLimits: { fiveHour: boolean; weekly: boolean; resetTime: boolean; hoverDetails: boolean; pace: boolean }
54  turn: {
55    enabled: boolean
56    whileWorking: boolean
57    lastTurn: boolean
58    tokens: boolean
59    toolCalls: boolean
60    toolCallsPanel: boolean
61  }
62  answer: { frame: boolean; footer: boolean; footerTokens: boolean }
63  spinnerTimer: boolean
64  cache: { enabled: boolean; handoff: boolean }
65  remote: {
66    enabled: boolean
67    autoStart: 'never' | 'newProjects' | 'always'
68    notifications: boolean
69    sessionsButton: boolean
70  }
71  levels: { yellow: number; orange: number; red: number }
72}
73
74// The prompt cache's lifetime, as the API reports the writes.
75export type CacheTtl = '5m' | '1h'
76
77// When the main loop's last response came and how long its cache lives.
78export type CacheState = { at: number; ttl: CacheTtl }
79
80// The Hand off button: pressed, writing the handoff (in the turn `turnId`),
81// opening the new session with it, done, or what went wrong.
82export type HandoffState = {
83  status: 'idle' | 'requested' | 'writing' | 'opening' | 'opened' | 'error'
84  path: string | null
85  clickedAt: number | null
86  turnId: string | null
87  detail: string | null
88}
89
90// Remote Control for this session's folder, as the footer shows it.
91export type RemoteStatus = 'off' | 'starting' | 'on' | 'error'
92export type RemoteState = { status: RemoteStatus; url: string | null; detail: string | null }
93
94// One folder's Remote Control in the Remote sessions panel (or a
95// remote-control process Bar did not start, `isExternal`).
96export type RemoteInstance = {
97  key: string
98  cwd: string | null
99  name: string
100  status: RemoteStatus | 'stopped' | 'stopping'
101  url: string | null
102  detail: string | null
103  pid: number | null
104  startedAt: number | null
105  stoppedAt: number | null
106  isExternal: boolean
107}
108
109// One main-loop turn: what started it, when, and how it ended.
110export type Turn = {
111  id: string
112  prompt: string
113  startedAt: number
114  endedAt: number | null
115  answer: string | null
116  isAborted: boolean
117  tokens: Tokens | null
118  // How many tools the main loop called in the turn.
119  tools: number
120}
121
122declare module 'claude-code' {
123  interface PluginState {
124    bar: {
125      fill: Fill | null
126      // The auto-compact window; null until the first reading.
127      compactWindow: CompactWindow | null
128      limits: Limit[]
129      turns: Turn[]
130      // The tool calls of the running turn, or of the last one.
131      calls: ToolCallRecord[]
132      // Which rows of the Tool calls panel are open, by call id.
133      openCalls: string[]
134      remote: RemoteState
135      remoteAll: RemoteInstance[]
136      // The settings file as last read; absent until the first read.
137      settings: BarSettings | null
138      // The prompt cache's countdown; null before the first response.
139      cache: CacheState | null
140      handoff: HandoffState
141      // The sample /bar-demo shows instead of the live bar; -1 shows the live one.
142      demo: number
143    }
144  }
145}
146