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

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.
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
━━━━●───┊──┊── 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.━━●─┃── 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
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./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
| Colour | On a meter or a countdown |
|---|---|
| Green | under half used |
| Yellow | from half (levels.yellow, 50%) |
| Orange | from three quarters (levels.orange, 75%) |
| Red | at 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
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/.
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.
| Command | What it does |
|---|---|
/bar-limit 300k | Sets 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. |
/remote | Turns 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-handoff | Writes a handoff and continues in a new session, like the Hand off button. |
/bar-demo | Steps through sample bars (no plan limits, light use, getting full, past the limit), then back to your live bar. |
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.
| Option | Default | What it controls |
|---|---|---|
bar.enabled | true | The whole bar above the prompt |
bar.hover | true | Every hover effect: the details, the underlines |
bar.pillBorders | true | The faint outline around the bar's panel (desktop) |
context.enabled | true | The 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 / weekly | true | The 5h and Week meters |
planLimits.resetTime | true | The reset time after each percentage |
planLimits.hoverDetails | true | The exact reset date while hovering 5h or Week |
planLimits.pace | true | The "now" mark on the 5h and Week meters, and the pace estimate in their details |
turn.enabled | true | The turn on row 2 (the cache and Hand off stay) |
turn.whileWorking / lastTurn | true | The live turn (beat, time, sparkline), and the last turn between turns |
turn.tokens | true | The token figures on row 2 |
turn.toolCalls / toolCallsPanel | true | The tool-call count, and the panel it opens |
answer.frame / footer / footerTokens | true | The answer frame, the line under it, and its token figures |
spinnerTimer | true | The live time beside the app's working indicator |
cache.enabled | true | The Cache countdown |
cache.handoff | true | The Hand off button beside it (/bar-handoff works either way) |
remote.enabled | true | The Remote Control toggle and /remote |
remote.autoStart | "newProjects" | Start Remote Control by itself: "never", "newProjects" (a project's first session) or "always" |
remote.notifications | true | The on/off notices |
remote.sessionsButton | true | The Remote sessions button in the footer |
levels.yellow / orange / red | 0.5 / 0.75 / 1.0 | The share of a limit where colors change |
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
hooks/register.tsx 20 lines1import 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}
20hooks/band.tsx 940 lines1import { 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}
940hooks/cache.tsx 304 lines1import { 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}
304hooks/one-click.ts 57 lines1import 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}
57hooks/remote.tsx 636 lines1import { 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}
636hooks/tools.tsx 88 lines1import { 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}
88hooks/turns.tsx 330 lines1import { 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}
330hooks/format.ts 86 lines1// 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}
86hooks/meter.ts 141 lines1// 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"/>')
141hooks/settings.ts 233 lines1import 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}
233types/index.d.ts 146 lines1// 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