Know if your Claude Code usage will last until the reset: the 5-hour and weekly pace above the prompt

Draws pacer's band above the prompt, for the Claude Desktop Code tab where a custom statusLine does not render: one row with a small bar, the percent and, its pace in a few words per window; Details (d) adds a card per window with a chart and a few numbers. All the math and all the words are in the Python CLI (../../pacer); this mod only:
session.measure whose rateLimits changed, or its 5 s poll seeing new limits in this session or a new changed stamp from another chat's run) and every 60 s, turns $.session.usage().rateLimits into the statusLine payload shape and runs python -I -S bin/pacer statusline --source mod --format json through $.process.run (the interpreter, its flags and the entry come from runtime.json in the pacer home, ~/.pacer or PACER_HOME, written by pacer install: -I -S -X utf8 on Windows, -I -S when the file lists none; a ~ or relative PACER_HOME is taken from your home directory, HOME, or USERPROFILE on Windows);$.state: one entry per window (bar kind, percent, words, reset, sentence) and the text line;AbovePrompt band for the surfaces listed in the CLI config (mod.surfaces, default ["desktop"]) as SVG bars sized to the band's width, keeping other mods' rows below its own. The terminal surface, or a CLI too old to print windows, gets the text line.Each call also records a sample, so the Desktop app is a collector too.
On Windows the mod finds your home directory as Python does: USERPROFILE, else HOMEDRIVE plus HOMEPATH, never HOME (Git Bash sets one that Python ignores). PACER_HOME may be C:\..., C:/..., ~\..., ~/... or Git Bash's /c/..., and \pacer is on your home's drive. C:pacer (relative to a drive's current folder) is refused, and so is any other path that starts with a single / (/srv/pacer): Git Bash, which runs the terminal status line, would rewrite it to a folder under its own install. The CLI refuses both with the same messages, and the one for /srv/pacer names the same path on your home's drive, where an older version kept it. Keep the home on a local drive: the mod usually cannot read a network (\\server\share) folder, and says so. When the pinned python.exe is gone, the band says "Python not found at <path>: run pacer install --apply", and the mod reads runtime.json again at its next refresh. The band draws in the Desktop app's local sessions, not in WSL sessions, where plugins are not available.
Install it with the rest of pacer (pacer install --apply), or by hand:
claude plugin marketplace add /path/to/pacer
claude plugin install pacer@pacer
On Windows: claude plugin marketplace add C:/path/to/pacer; if claude rejects the path, run claude plugin marketplace add ./ from the repo folder.
Then /reload-plugins in an open session, or start a new one.
pacer uninstall --apply removes it again, including when pacer install only printed these commands because claude was not on PATH. It also removes a mod added without pacer install (it finds it in your user settings); or remove that with claude plugin uninstall pacer@pacer and claude plugin marketplace remove pacer.
Develop: claude plugin validate . and claude plugin test . from this folder.
hooks/register.tsx 901 lines1// pacer band: one quiet row above the prompt, for the Desktop Code tab where
2// no statusLine renders. Each window is a dim name, a small bar (used, then a
3// forecast to the reset), the percent and, only when there is something to
4// say, a few words; a reset when the band is wide. Details (d) adds a
5// sentence per window.
6//
7// Thin on purpose: all the math and all the words come from the Python CLI.
8// The mod hands it the session's rate limits in the statusLine payload shape
9// and draws what it prints. It is also the collector where no statusLine
10// runs: each call records a sample (source "mod").
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
14
15import type { Chart, Detail, Phrase, View, Win } from '../types'
16
17const view = atom({ plugin: 'pacer', key: 'view' } as const, null)
18const details = atom({ plugin: 'pacer', key: 'details' } as const, false)
19
20const RUN_TIMEOUT_MS = 10_000
21const REFRESH_MS = 60_000
22// How often the mod looks for a new reading: this session's own, or another
23// chat's (the CLI touches PACER_HOME/changed). Both are cheap; Python runs
24// only when one moved.
25const POLL_MS = 5_000
26// The interpreter flags of a runtime.json written before it listed them.
27const DEFAULT_ARGS: readonly string[] = ['-I', '-S']
28
29type Runtime = { python: string; args: readonly string[]; entry: string; home: string; windows: boolean }
30
31// Desktop paints theme keys; the terminal maps them to its palette. Tones of
32// the classic line, then the bars line's words and bar parts.
33const TONE: Record<string, { dimColor?: boolean; color?: string }> = {
34 dim: { dimColor: true },
35 plain: {},
36 opportunity: { color: 'success' },
37 warn: { color: 'warning' },
38 alert: { color: 'error' },
39 text: {},
40 name: { dimColor: true },
41 inactive: { dimColor: true },
42 success: { color: 'success' },
43 warning: { color: 'warning' },
44 error: { color: 'error' },
45 used: { color: 'claude' },
46 forecast: { dimColor: true },
47 track: { dimColor: true },
48 idle: { dimColor: true },
49 over: { color: 'error' },
50 spare: { color: 'success' },
51}
52
53// The statusLine stdin shape: windows keyed by kind, resets_at in epoch seconds.
54export function toPayload(sessionId: string, limits: readonly SessionRateLimit[]) {
55 const rateLimits: Record<string, { used_percentage: number; resets_at?: number }> = {}
56 for (const limit of limits) {
57 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
58 rateLimits[limit.kind] = {
59 used_percentage: limit.percentUsed,
60 ...(Number.isFinite(resetsAt) ? { resets_at: Math.floor(resetsAt / 1000) } : {}),
61 }
62 }
63 return {
64 session_id: sessionId,
65 ...(limits.length > 0 ? { rate_limits: rateLimits } : {}),
66 kinds: limits.map((limit) => limit.kind),
67 }
68}
69
70// What a refresh compares: the readings, not the objects that carry them.
71function limitsKey(limits: readonly SessionRateLimit[]): string {
72 return JSON.stringify(limits.map((limit) => [limit.kind, limit.percentUsed, limit.resetsAt ?? null]))
73}
74
75// Module state: a reload starts it over, which is what we want.
76let runtime: Runtime | null = null
77let running = false
78let timer: Timer | null = null
79let periodMs = REFRESH_MS
80// A refresh asked for while one runs: the newest limits win, null = re-read.
81let queued: readonly SessionRateLimit[] | null | undefined
82// What the last run saw: this session's limits, and the stamp's time.
83let seenLimits: string | null = null
84let seenStamp: number | null = null
85
86// A short log for `pacer doctor` (PACER_HOME/mod.log): which surfaces the
87// session draws on, and whether one asked for the band. Written on those
88// events only, never on a timer; the last 40 lines.
89let logged: string[] = []
90const seenRenders = new Set<string>()
91async function surfacesOf($: EngineInterface): Promise<string> {
92 const list = await $.session.surfaces().catch(() => null)
93 return list === null ? '?' : `[${list.join(', ')}]`
94}
95
96async function trace($: EngineInterface, line: string) {
97 try {
98 const rt = await loadRuntime($)
99 const id = (await $.session.id()).slice(0, 8)
100 const at = new Date(await $.clock.now()).toISOString()
101 logged = [...logged.slice(-39), `${at} ${id} ${line}`]
102 await $.fs.write(join(rt.home, 'mod.log', rt.windows), `${logged.join('\n')}\n`)
103 } catch {
104 // no runtime yet: the band says why
105 }
106}
107
108// The user's home as Python's os.path.expanduser finds it, and whether
109// Windows path rules apply.
110export type UserHome = { windows: boolean; home: string | undefined }
111
112// No $ API names the OS; Windows sets OS=Windows_NT system-wide. There Python
113// ignores HOME (Git Bash sets one of its own) and takes USERPROFILE, else
114// HOMEDRIVE+HOMEPATH; an empty value counts as unset.
115async function userHome($: EngineInterface): Promise<UserHome> {
116 if ((await $.env.get('OS')) !== 'Windows_NT') return { windows: false, home: await $.env.get('HOME') }
117 const profile = await $.env.get('USERPROFILE')
118 if (profile) return { windows: true, home: profile }
119 const homePath = await $.env.get('HOMEPATH')
120 return { windows: true, home: homePath ? `${(await $.env.get('HOMEDRIVE')) ?? ''}${homePath}` : undefined }
121}
122
123// One separator between the two, as os.path.join puts it.
124function join(base: string, name: string, windows: boolean): string {
125 const ends = windows ? /[\\/]$/ : /\/$/
126 return ends.test(base) ? `${base}${name}` : `${base}${windows ? '\\' : '/'}${name}`
127}
128
129// A UNC path (\\server\share): $.fs.read refuses most of them.
130function isNetwork(path: string, windows: boolean): boolean {
131 return windows && /^[\\/]{2}/.test(path)
132}
133
134// The drive of a Windows path as ntpath.splitdrive reads it: `C:`, or
135// `\\server\share`; '' when it has none.
136function driveOf(path: string | undefined): string {
137 return path?.match(/^(?:[A-Za-z]:|[\\/]{2}[^\\/]+[\\/][^\\/]+)/)?.[0] ?? ''
138}
139
140// Git Bash's spelling of a drive path: `/c/Users/x` is `C:/Users/x`, and `/d`
141// is `D:/` (a slash, one ASCII letter, then a slash or the end), as the CLI's
142// config.windows_home reads it. Anything else is returned as given.
143export function fromGitBash(path: string): string {
144 const drive = /^\/([A-Za-z])(?:\/|$)/.exec(path)?.[1]
145 return drive ? `${drive.toUpperCase()}:/${path.slice(3)}` : path
146}
147
148// The same rules as the CLI: an empty PACER_HOME counts as unset, a leading
149// `~` is the user's home, and a relative path is taken from it, never from the
150// session's cwd (Claude Code copies settings.json env values as written). On
151// Windows `~\` works like `~/`, Git Bash's `/c/Users/x` is `C:/Users/x`,
152// `C:\`, `C:/` and `\\server\share` are absolute, a path rooted with a
153// backslash and no drive (`\x`) takes the drive of the user's home, as
154// Python's ntpath.join does, and `C:x` (relative to that drive's current
155// folder) is refused. So is any other path rooted with a slash and no drive
156// (`/x`): Git Bash, which runs the statusLine, rewrites it to a folder under
157// its own install before Python sees it, so the band and the line would use
158// different folders. Its message names `/x` on the home's drive, the folder
159// older versions used. The CLI refuses both with the same messages
160// (config.windows_home).
161export function resolveHome(value: string | undefined, user: UserHome): string {
162 const { windows, home } = user
163 const given = windows && value ? fromGitBash(value) : value
164 if (given && (windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2})/ : /^\//).test(given)) return given
165 if (windows && given && /^[A-Za-z]:/.test(given)) {
166 throw new Error(`PACER_HOME ${given} is relative to a drive's current folder; use an absolute path`)
167 }
168 if (windows && given?.startsWith('/')) {
169 throw new Error(
170 `PACER_HOME ${given} has no drive; Git Bash rewrites such a path to a folder under its own install. ` +
171 `Write it with a drive, e.g. ${driveOf(home) || 'C:'}${given}, or as ~/...`,
172 )
173 }
174 if (windows && given && /^\\/.test(given)) return `${driveOf(home)}${given}`
175 const tilde = windows ? /^~(?:[\\/]|$)/ : /^~(?:\/|$)/
176 if (given?.startsWith('~') && !tilde.test(given)) {
177 const forms = windows ? '~, ~/ and ~\\' : '~ and ~/'
178 throw new Error(`PACER_HOME ${given}: only ${forms} are expanded; use an absolute path`)
179 }
180 if (!home) {
181 const name = windows ? 'USERPROFILE' : 'HOME'
182 throw new Error(`${name} is unset: set PACER_HOME to an absolute path, or run \`pacer install --apply\``)
183 }
184 if (!given) return join(home, '.pacer', windows)
185 const rest = given.replace(windows ? /^~[\\/]?/ : /^~\/?/, '')
186 return rest ? join(home, rest, windows) : home
187}
188
189async function loadRuntime($: EngineInterface): Promise<Runtime> {
190 if (runtime !== null) return runtime
191 const user = await userHome($)
192 const home = resolveHome(await $.env.get('PACER_HOME'), user)
193 let text: string
194 try {
195 text = await $.fs.read(join(home, 'runtime.json', user.windows))
196 } catch (error) {
197 if (!isNetwork(home, user.windows)) throw error
198 const why = error instanceof Error ? error.message : String(error)
199 throw new Error(`cannot read runtime.json from network path ${home}: set PACER_HOME to a local folder (${why})`)
200 }
201 // A byte order mark (a Windows editor's) would make JSON.parse throw.
202 const parsed = JSON.parse(text.replace(/^/, '')) as { python?: unknown; args?: unknown; entry?: unknown }
203 if (typeof parsed.python !== 'string' || typeof parsed.entry !== 'string') {
204 throw new Error('runtime.json has no python/entry; run `pacer install --apply`')
205 }
206 const args = parsed.args === undefined ? DEFAULT_ARGS : parsed.args
207 if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) {
208 throw new Error('runtime.json args is not a list of strings; run `pacer install --apply`')
209 }
210 runtime = { python: parsed.python, args, entry: parsed.entry, home, windows: user.windows }
211 return runtime
212}
213
214// The stamp the CLI touches when a reading changes what is shown
215// (config.stamp_path): its time, and the run time written in it. Absent, 0.
216async function stamp($: EngineInterface, rt: Runtime): Promise<{ mtime: number; ts: number }> {
217 const path = join(rt.home, 'changed', rt.windows)
218 const mtime = await $.fs
219 .stat(path)
220 .then((s) => s.mtimeMs)
221 .catch(() => 0)
222 if (mtime === 0) return { mtime, ts: Number.NaN }
223 const ts = await $.fs
224 .read(path)
225 .then((text) => Number.parseFloat(text))
226 .catch(() => Number.NaN)
227 return { mtime, ts }
228}
229
230// The CLI's mod.refresh_s, clamped; re-arms the timer when it changes.
231function rearm($: EngineInterface, seconds: unknown) {
232 const s = typeof seconds === 'number' && Number.isFinite(seconds) ? seconds : 60
233 const ms = Math.min(3600, Math.max(15, s)) * 1000
234 if (ms === periodMs || timer === null) return
235 timer.cancel()
236 periodMs = ms
237 timer = $.clock.every(periodMs, () => {
238 void refresh($)
239 })
240}
241
242const isNum = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x)
243const isStr = (x: unknown): x is string => typeof x === 'string'
244
245function isPhrase(x: unknown): x is Phrase {
246 const p = x as Phrase
247 return (
248 typeof p === 'object' && p !== null && isStr(p.pre) && isStr(p.num) && isStr(p.post) && isStr(p.narrow) &&
249 (p.narrow_pre === undefined || isStr(p.narrow_pre)) && isStr(p.tone) && typeof p.attention === 'boolean' &&
250 typeof p.calm === 'boolean'
251 )
252}
253
254// The order of Details' rows (band.details); a label the mod does not know goes last.
255const ORDER = ['Used', 'Pace', 'Limit', 'At reset', 'Will go unused', 'Runs out', 'Opens', 'Resets', 'Rate']
256// Rows a card shows in its head: the percent and the pace.
257const HEAD = ['Used', 'Pace', 'Limit']
258const rank = (label: string) => (ORDER.indexOf(label) + ORDER.length + 1) % (ORDER.length + 1)
259
260const isPoints = (x: unknown): x is [number, number][] =>
261 Array.isArray(x) && x.every((p) => Array.isArray(p) && p.length === 2 && isNum(p[0]) && isNum(p[1]))
262
263// A chart from the CLI that draws; anything else, no chart (an older CLI has none).
264export function chartOf(x: unknown): Chart | null {
265 const c = x as Chart
266 const ok =
267 typeof c === 'object' && c !== null && isNum(c.t0) && isNum(c.t1) && c.t1 > c.t0 && isNum(c.now) &&
268 isPoints(c.actual) && c.actual.length > 0 && isPoints(c.forecast) &&
269 (c.fan === null || (Array.isArray(c.fan) && c.fan.length === 2 && c.fan.every((p) => isPoints(p) && p.length > 0))) &&
270 Array.isArray(c.ghosts) && c.ghosts.every(isPoints) && Array.isArray(c.grid) && c.grid.every(isNum) &&
271 (c.nights === undefined || isPoints(c.nights)) &&
272 (c.out == null || (isNum(c.out) && c.out > c.t0 && c.out < c.t1)) && (c.out_label == null || isStr(c.out_label)) &&
273 Array.isArray(c.labels) && c.labels.every((l) => Array.isArray(l) && isNum(l[0]) && isStr(l[1]))
274 return ok ? c : null
275}
276
277const isRow = (r: unknown): r is Detail =>
278 typeof r === 'object' && r !== null && isStr((r as Detail).label) && isStr((r as Detail).value) &&
279 isStr((r as Detail).tone) && ((r as Detail).note === undefined || isStr((r as Detail).note)) &&
280 ((r as Detail).note_tone === undefined || isStr((r as Detail).note_tone))
281
282// The CLI's windows, when every one is whole; else the text line is drawn.
283function windowsOf(x: unknown): Win[] | null {
284 if (!Array.isArray(x) || x.length !== 2) return null
285 const ok = x.every((w: Win) => {
286 return (
287 typeof w === 'object' && w !== null && (w.id === 'h5' || w.id === 'wk') && isStr(w.name) && isStr(w.short) &&
288 isStr(w.kind) && (w.pct === null || isNum(w.pct)) && isNum(w.used) &&
289 (w.projected === null || isNum(w.projected)) && (w.reset === null || isStr(w.reset)) &&
290 (w.phrase === null || isPhrase(w.phrase)) && isStr(w.sentence)
291 )
292 })
293 if (!ok) return null
294 // Details rows from an older CLI, or broken ones, are left out, not the band.
295 return (x as Win[]).map((w) => ({
296 ...w,
297 details: Array.isArray(w.details) ? w.details.filter(isRow) : [],
298 chart: chartOf(w.chart),
299 }))
300}
301
302async function runOnce($: EngineInterface, limits: readonly SessionRateLimit[] | null) {
303 const rt = await loadRuntime($)
304 const current = limits ?? (await $.session.usage()).rateLimits
305 const payload = toPayload(await $.session.id(), current)
306 const before = await stamp($, rt)
307 // Seen even when the run fails: the poll must not retry a failing run every
308 // 5 s; the refresh timer does, at its own pace.
309 seenLimits = limitsKey(current)
310 seenStamp = before.mtime
311 const result = await $.process.run(
312 [rt.python, ...rt.args, rt.entry, 'statusline', '--source', 'mod', '--format', 'json'],
313 { stdin: JSON.stringify(payload), timeoutMs: RUN_TIMEOUT_MS, env: { PACER_HOME: rt.home } },
314 )
315 if (result.exitCode !== 0 || result.stdout.trim() === '') {
316 // On Windows a missing interpreter does not fail to start: the spawn falls
317 // back to cmd.exe, which says only "...operable program or batch file."
318 // (exit 9009) or "The system cannot find the path specified.". Asked only
319 // after a failed run, so a Store alias exists() cannot see is not
320 // misreported while it works.
321 if (result.exitCode !== 0 && !(await $.fs.exists(rt.python).catch(() => true))) {
322 runtime = null // read runtime.json again on the next refresh, after a reinstall
323 throw new Error(`Python not found at ${rt.python}: run \`pacer install --apply\``)
324 }
325 const lines = result.stderr.trim().split(/\r?\n/)
326 // cmd.exe's own message spans 2 lines.
327 const why = result.exitCode === 9009 ? lines.join(' ') : lines.pop()
328 throw new Error(why || `pacer exited ${result.exitCode}`)
329 }
330 const parsed = JSON.parse(result.stdout) as Partial<View>
331 if (!Array.isArray(parsed.segments)) {
332 throw new Error(typeof parsed.line === 'string' ? parsed.line : 'unexpected output from pacer')
333 }
334 // This run's own touch of the stamp is no news; another chat's since is.
335 const after = await stamp($, rt)
336 if (isNum(parsed.now) && Math.abs(after.ts - parsed.now) < 0.002) seenStamp = after.mtime
337 const segments = parsed.segments
338 await update($, view, () => ({
339 line: typeof parsed.line === 'string' ? parsed.line : '',
340 segments,
341 surfaces: Array.isArray(parsed.surfaces) ? parsed.surfaces : ['desktop'],
342 refresh_s: typeof parsed.refresh_s === 'number' ? parsed.refresh_s : 60,
343 windows: windowsOf(parsed.windows),
344 updated_ts: isNum(parsed.updated_ts) ? parsed.updated_ts : null,
345 now: isNum(parsed.now) ? parsed.now : undefined,
346 }))
347 rearm($, parsed.refresh_s)
348}
349
350async function refresh($: EngineInterface, limits: readonly SessionRateLimit[] | null = null) {
351 if (running) {
352 queued = limits ?? queued ?? null
353 return
354 }
355 running = true
356 let pending: readonly SessionRateLimit[] | null | undefined = limits
357 try {
358 while (pending !== undefined) {
359 queued = undefined
360 try {
361 await runOnce($, pending)
362 } catch (error) {
363 const message = error instanceof Error ? error.message : String(error)
364 await update($, view, (old) => ({
365 ...(old ?? {}),
366 line: old?.line ?? '',
367 segments: old?.segments ?? [],
368 surfaces: old?.surfaces ?? ['desktop'],
369 refresh_s: old?.refresh_s ?? 60,
370 error: message.slice(0, 200),
371 }))
372 }
373 pending = queued
374 }
375 } finally {
376 running = false
377 }
378}
379
380// Runs Python only when a reading moved: this session's limits (any change,
381// where session.measure waits for a whole point), or the stamp another
382// chat's run touched.
383async function poll($: EngineInterface) {
384 // No runtime yet, or it is being read again after a failure: the refresh
385 // timer does that, and reports why.
386 const rt = runtime
387 if (running || seenLimits === null || rt === null) return
388 const limits = (await $.session.usage()).rateLimits
389 const now = await stamp($, rt)
390 if (limitsKey(limits) !== seenLimits || now.mtime !== seenStamp) await refresh($, limits)
391}
392
393// -- drawing ----------------------------------------------------------------
394
395// Widths in the band's cells (bodyColumns), checked in Chromium at 7 px a
396// cell from 40 to 140 columns: halves with resets from 112, 64 px bars from
397// 72, "Wk" and only the words that need you below 52, where a calm window
398// beside one that needs you keeps only its name and percent.
399export const layout = (body: number) => ({
400 halves: body >= 112,
401 W: body >= 72 ? 64 : 44,
402 tiny: body < 52,
403 bare: body < 44,
404})
405
406const SVG_H = 14
407const Y = 4.5
408const BH = 5
409const R = 2.5
410const GAP = 1.5
411// One style per bar, scoped to its root: Claude's orange in both themes, the
412// rest lifted for a dark background. Track, forecast, idle fill and pill are
413// color plus opacity, so they tint whatever is behind them.
414const STYLE =
415 '<style>.ctb-s{--t:#8a867c;--a:#d97757;--r:#d0564c;--g:#5b9c64}' +
416 '@media(prefers-color-scheme:dark){.ctb-s{--t:#b4afa4;--r:#e5695e;--g:#74b97c}}' +
417 '.ctb-s .ctb-bt{fill:var(--t);opacity:.22}.ctb-s .ctb-bu{fill:var(--a)}.ctb-s .ctb-bf{fill:var(--a);opacity:.32}' +
418 '.ctb-s .ctb-bx{fill:var(--r);opacity:.38}.ctb-s .ctb-bm{fill:var(--r)}.ctb-s .ctb-bi{fill:var(--t);opacity:.6}' +
419 '.ctb-s .ctb-bp{fill:var(--t);opacity:.45}.ctb-s line{stroke-width:2;stroke-linecap:round}' +
420 '.ctb-s .ctb-bw{stroke:var(--r)}.ctb-s .ctb-bd{stroke:var(--g);stroke-dasharray:0 3.5}</style>'
421
422const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
423
424// The chart's style, scoped to its root like the bars': the text, the grid
425// and now grey, Claude's orange, the forecast red or yellow in the pace's
426// color. Mid-tones only, as the bars': an image follows the system's scheme,
427// not the app's, so each mark reads on a light card and a dark one (D-30).
428const CHART_STYLE =
429 '<style>.ctc{--t:#8a867c;--a:#d97757;--r:#d0564c;--y:#c08a1e}' +
430 '@media(prefers-color-scheme:dark){.ctc{--t:#a3a19a;--r:#e5695e;--y:#e0b252}}' +
431 '.ctc .nt{fill:var(--t);opacity:.06}.ctc .ns{fill:var(--t);opacity:.45}' +
432 '.ctc .g{stroke:var(--t);stroke-width:1;opacity:.2}.ctc .lim{stroke:var(--t);stroke-width:.8;stroke-dasharray:2 3;opacity:.6}' +
433 '.ctc .ar{fill:var(--a);opacity:.13}.ctc .ac{fill:none;stroke:var(--a);stroke-width:1.8;stroke-linejoin:round}' +
434 '.ctc .dt{fill:var(--a)}.ctc .fn{fill:var(--a);opacity:.1}' +
435 '.ctc .fc{fill:none;stroke:var(--a);stroke-width:1.6;stroke-dasharray:4 3;opacity:.75}' +
436 '.ctc .fx{fill:none;stroke:var(--r);stroke-width:1.6;stroke-dasharray:4 3}' +
437 '.ctc .fy{fill:none;stroke:var(--y);stroke-width:1.6;stroke-dasharray:4 3}' +
438 '.ctc .gh{fill:none;stroke:var(--t);stroke-width:1;stroke-dasharray:3 3;opacity:.6}' +
439 '.ctc .nw{stroke:var(--t);stroke-width:.8;opacity:.75}.ctc .end{fill:none;stroke:var(--a);stroke-width:1.2}' +
440 '.ctc .ex{stroke:var(--r)}.ctc .ey{stroke:var(--y)}' +
441 '.ctc .rx{fill:var(--r);stroke:var(--r)}.ctc .ry{fill:var(--y);stroke:var(--y)}' +
442 '.ctc .oz{opacity:.09;stroke:none}.ctc .ol{stroke-width:1;stroke-dasharray:2.5 2}.ctc .ot{font-weight:600;stroke:none}' +
443 '.ctc text{font:9px -apple-system,"Segoe UI",system-ui,sans-serif;fill:var(--t)}</style>'
444
445export const CHART_H = 64
446// A label's character at 9 px, with some to spare (the system font's widest
447// day names, "Wed", run about 5.5 px a character).
448const LABEL_PX = 6
449
450// A window's chart, W px wide (chart.py): the week's nights as pale bands,
451// what was used (a line over a pale
452// area, a dot per reading while there are few), the forecast to the reset
453// (dashed, in the pace's color when it needs you: red when it runs out or
454// is blocked, yellow when it may or passes the target), an early 5-hour
455// estimate's fan, the forecasts made earlier (grey, dashed), the grid and
456// its hours or days (in the CLI's order, one that would touch one placed
457// before is left out, as in the menu bar), the limit (dotted) and now (a
458// hairline).
459export function chartSvg(c: Chart, W: number, alt: string, tone?: string): string {
460 const H = CHART_H
461 const pad = { l: 1, r: 4, t: 6, b: 14 }
462 const sx = (t: number) => r1(pad.l + ((t - c.t0) / (c.t1 - c.t0)) * (W - pad.l - pad.r))
463 const sy = (u: number) => r1(pad.t + (1 - Math.min(100, Math.max(0, u)) / 100) * (H - pad.t - pad.b))
464 const d = (pts: [number, number][]) => pts.map((p, i) => `${i ? 'L' : 'M'}${sx(p[0])},${sy(p[1])}`).join('')
465 const base = sy(0)
466 const out = [
467 `<svg xmlns='http://www.w3.org/2000/svg' class='ctc' width='${W}' height='${H}' viewBox='0 0 ${W} ${H}'>`,
468 CHART_STYLE,
469 `<title>${esc(alt)}</title>`,
470 ]
471 // The week's nights: a faint tint, and a thin rounded strip under the axis
472 // that reads on any screen.
473 for (const [a, b] of c.nights ?? []) {
474 const x = sx(a)
475 const w = r1(sx(b) - x)
476 out.push(`<rect class='nt' x='${x}' y='${pad.t}' width='${w}' height='${r1(base - pad.t)}'/>`)
477 out.push(`<rect class='ns' x='${x}' y='${base + 2}' width='${w}' height='2' rx='1'/>`)
478 }
479 for (const t of c.grid) out.push(`<line class='g' x1='${sx(t)}' y1='${pad.t}' x2='${sx(t)}' y2='${base}'/>`)
480 out.push(`<line class='lim' x1='${pad.l}' y1='${sy(100)}' x2='${W - pad.r}' y2='${sy(100)}'/>`)
481 for (const g of c.ghosts) out.push(`<path class='gh' d='${d(g)}'/>`)
482 const a = c.actual
483 const last = a[a.length - 1] as [number, number]
484 out.push(`<path class='ar' d='${d(a)}L${sx(last[0])},${base}L${sx(a[0]![0])},${base}Z'/>`)
485 out.push(`<path class='ac' d='${d(a)}'/>`)
486 if (a.length <= 30) for (const p of a.slice(1)) out.push(`<circle class='dt' cx='${sx(p[0])}' cy='${sy(p[1])}' r='1.4'/>`)
487 const f = c.forecast
488 if (c.fan) {
489 // Between the slower line and the faster one, back along it.
490 const [lo, hi] = c.fan
491 out.push(`<path class='fn' d='${d(lo)}${d([...hi].reverse()).replace('M', 'L')}Z'/>`)
492 }
493 if (f.length) {
494 const end = f[f.length - 1] as [number, number]
495 const k = tone === 'error' ? 'x' : tone === 'warning' ? 'y' : ''
496 out.push(`<path class='${k ? `f${k}` : 'fc'}' d='${d(f)}'/>`)
497 out.push(`<circle class='end${k ? ` e${k}` : ''}' cx='${r1(sx(end[0]) - 1.5)}' cy='${sy(end[1])}' r='2.2'/>`)
498 }
499 if (c.out != null) {
500 // It runs out before the reset: the rest of the window tinted, a dashed
501 // mark down from a dot on the limit, and its time beside it.
502 const h = tone === 'warning' ? 'ry' : 'rx'
503 const x = sx(c.out)
504 out.push(`<rect class='oz ${h}' x='${x}' y='${pad.t}' width='${r1(Math.max(0, W - pad.r - x))}' height='${r1(base - pad.t)}'/>`)
505 out.push(`<line class='ol ${h}' x1='${x}' y1='${pad.t}' x2='${x}' y2='${base}'/>`)
506 out.push(`<circle class='${h}' cx='${x}' cy='${pad.t}' r='2.4'/>`)
507 if (c.out_label) {
508 const room = W - pad.r - x > c.out_label.length * LABEL_PX + 6
509 out.push(`<text class='ot ${h}' x='${r1(room ? x + 4 : x - 4)}' y='${pad.t + 10}' text-anchor='${room ? 'start' : 'end'}'>${esc(c.out_label)}</text>`)
510 }
511 }
512 out.push(`<line class='nw' x1='${sx(c.now)}' y1='${pad.t}' x2='${sx(c.now)}' y2='${base}'/>`)
513 out.push(`<circle class='dt' cx='${sx(last[0])}' cy='${sy(last[1])}' r='2.4'/>`)
514 // In the CLI's order (whole days before the week's edge days): a label
515 // that would touch one placed before it is left out, as in the menu bar.
516 const placed: [number, number][] = []
517 for (const [t, label] of c.labels) {
518 const x = sx(t)
519 const w = label.length * LABEL_PX
520 const anchor = x - w / 2 < pad.l ? 'start' : x + w / 2 > W - 1 ? 'end' : 'middle'
521 const left = anchor === 'start' ? pad.l : anchor === 'end' ? W - 1 - w : x - w / 2
522 if (placed.some(([a, b]) => left < b + 3 && a < left + w + 3)) continue
523 placed.push([left, left + w])
524 out.push(`<text x='${anchor === 'start' ? pad.l : anchor === 'end' ? W - 1 : x}' y='${H - 2}' text-anchor='${anchor}'>${esc(label)}</text>`)
525 }
526 out.push('</svg>')
527 return out.join('')
528}
529const r1 = (n: number) => Math.round(n * 10) / 10
530
531// 5 hex digits of FNV-1a: a gradient id of its own per bar.
532function hash(s: string): string {
533 let h = 0x811c9dc5
534 for (let i = 0; i < s.length; i++) {
535 h ^= s.charCodeAt(i)
536 h = Math.imul(h, 0x01000193) >>> 0
537 }
538 return h.toString(16).padStart(8, '0').slice(0, 5)
539}
540
541// The bar of one window, W px wide: used in orange, then after a hairline gap
542// a pale forecast to where it lands at the reset (fading while the estimate
543// is rough); red into a wall when it runs out (fading when it only may);
544// green dots over what will go unused; all red when maxed; grey when idle; a
545// pill while waiting. Drawn as an image: an interactive frame paints an
546// opaque white box where the app's theme and the system's differ (Chromium's
547// color-scheme rule), so the tooltip's words are in the row and in Details.
548export function barSvg(w: Win, W: number): string {
549 const X = (p: number) => (Math.min(100, Math.max(0, p)) / 100) * W
550 const pill = (x0: number, x1: number, c: string) =>
551 `<rect x='${r1(x0)}' y='${Y}' width='${r1(Math.max(x1 - x0, BH))}' height='${BH}' rx='${R}' ` +
552 (c.startsWith('url') ? `fill='${c}'/>` : `class='ctb-b${c}'/>`)
553 const out = [
554 `<svg xmlns='http://www.w3.org/2000/svg' class='ctb-s' width='${W}' height='${SVG_H}' viewBox='0 0 ${W} ${SVG_H}'>`,
555 STYLE,
556 `<title>${esc(w.sentence)}</title>`,
557 pill(0, W, 't'),
558 ]
559 if (w.kind === 'wait') {
560 out.push(
561 `<rect y='${Y}' width='16' height='${BH}' rx='${R}' class='ctb-bp'>` +
562 `<animate attributeName='x' values='0;${W - 16};0' dur='2.4s' repeatCount='indefinite'/></rect></svg>`,
563 )
564 return out.join('')
565 }
566 const u = X(w.used)
567 const p = X(w.projected ?? w.used)
568 const g0 = u > 0 ? Math.max(u, BH) + GAP : 0
569 if (w.kind === 'maxed') out.push(pill(0, W, 'm'))
570 else {
571 if (w.kind === 'dry') {
572 // Red into a wall; a milder run-out (soft) fades, and so does its wall.
573 if (W - g0 >= BH && w.soft) {
574 const id = `ctb${hash(`${w.id}|soft|${w.used}|${W}`)}`
575 out.push(
576 `<linearGradient id='${id}' gradientUnits='userSpaceOnUse' x1='${r1(g0)}' x2='${W}'>` +
577 `<stop offset='.2' style='stop-color:var(--r);stop-opacity:.45'/>` +
578 `<stop offset='1' style='stop-color:var(--r);stop-opacity:.08'/></linearGradient>`,
579 pill(g0, W, `url(#${id})`),
580 )
581 } else if (W - g0 >= BH) out.push(pill(g0, W, 'x'))
582 out.push(
583 `<line x1='${W - 1}' y1='1.5' x2='${W - 1}' y2='12.5' class='ctb-bw'${w.soft ? " style='opacity:.45'" : ''}/>`,
584 )
585 } else if (w.kind !== 'idle' && w.projected !== null && p - g0 >= BH) {
586 if (w.kind === 'rough') {
587 const id = `ctb${hash(`${w.id}|${w.kind}|${w.used}|${w.projected}|${W}`)}`
588 out.push(
589 `<linearGradient id='${id}' gradientUnits='userSpaceOnUse' x1='${r1(g0)}' x2='${r1(p)}'>` +
590 `<stop offset='.2' style='stop-color:var(--a);stop-opacity:.45'/>` +
591 `<stop offset='1' style='stop-color:var(--a);stop-opacity:0'/></linearGradient>`,
592 pill(g0, p, `url(#${id})`),
593 )
594 } else out.push(pill(g0, p, 'f'))
595 }
596 if (w.kind === 'expire' && W - 2.5 > p + 4) {
597 out.push(`<line x1='${r1(p + 4)}' y1='7' x2='${r1(W - 2.5)}' y2='7' class='ctb-bd'/>`)
598 }
599 if (u > 0) out.push(pill(0, u, w.kind === 'idle' ? 'i' : 'u'))
600 }
601 out.push('</svg>')
602 return out.join('')
603}
604
605// A reading 5 min old or more: Details says how old.
606export const stale = (updated: number | null | undefined, now: number | undefined): boolean =>
607 typeof updated === 'number' && typeof now === 'number' && now - updated >= 300
608
609// "Updated 3 min ago." from the CLI's own clock, so a stale view says so.
610export function age(updated: number | null | undefined, now: number | undefined): string {
611 if (!isNum(updated) || !isNum(now)) return ''
612 const min = Math.max(0, Math.floor((now - updated) / 60))
613 if (min < 1) return 'Updated just now.'
614 if (min < 60) return `Updated ${min} min ago.`
615 const h = Math.round(min / 60)
616 return h < 48 ? `Updated ${h} h ago.` : `Updated ${Math.round(h / 24)} days ago.`
617}
618
619export const register: Register = (on) => {
620 on('session.start', async ($, e, next) => {
621 const started = await next(e)
622 $.clock.after(0, () => {
623 void (async () => trace($, `start: surfaces ${await surfacesOf($)}`))()
624 })
625 // Off the start path: the first prompt never waits for Python.
626 $.clock.after(0, () => {
627 void refresh($)
628 })
629 timer = $.clock.every(periodMs, () => {
630 void refresh($)
631 })
632 $.clock.every(POLL_MS, () => {
633 void poll($).catch(() => undefined)
634 })
635 return started
636 })
637
638 on('session.measure', async ($, e, next) => {
639 const measured = await next(e)
640 if (e.changed.includes('rateLimits') && limitsKey(e.rateLimits) !== seenLimits) {
641 void refresh($, e.rateLimits)
642 }
643 return measured
644 })
645
646 on('session.attach', async ($, e, next) => {
647 const attached = await next(e)
648 void (async () => trace($, `attach: ${e.surface}, surfaces ${await surfacesOf($)}`))()
649 return attached
650 })
651
652 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
653 const current = await read($, view)
654 const why =
655 current === null ? 'no reading yet' : e.props.hasSurvey ? 'a survey' :
656 !current.surfaces.includes(e.surface) ? `not in mod.surfaces [${current.surfaces.join(', ')}]` : ''
657 const key = `${e.surface}|${why}`
658 if (!seenRenders.has(key)) {
659 seenRenders.add(key)
660 void trace($, `band asked on ${e.surface}: ${why ? `not drawn, ${why}` : 'drawn'}`)
661 }
662 if (why) return next(e)
663 if (current === null) return next(e)
664 const { Box, Text, Button } = $.ui.resolve(e)
665 const wins = current.windows
666 let ours
667 if (current.error) {
668 ours = (
669 <Text dimColor wrap="truncate-end">
670 pacer: {current.error}
671 </Text>
672 )
673 } else if (wins && e.surface === 'desktop') {
674 const { Svg } = $.ui.resolve(e)
675 const L = layout(e.props.bodyColumns)
676 const waiting = wins.every((w) => w.pct === null)
677 const focus = wins.some((w) => w.phrase?.attention)
678 const both = wins.filter((w) => w.phrase?.attention).length > 1
679 // Every window says its pace ("1.4x faster", "On pace", "2x slower").
680 // With the small bars, below 72 cells, only one window that does not
681 // need you keeps its words (green before grey, the 5 hours first), and
682 // none beside one that does; below 52 only the words that need you.
683 const quiet = wins.filter((w) => w.phrase && !w.phrase.attention)
684 const kept = quiet.find((w) => !w.phrase?.calm) ?? quiet[0]
685 const said = (w: Win) => {
686 const p = w.phrase
687 if (!p || p.attention || L.W === 64) return p
688 return !focus && !L.tiny && w === kept ? p : null
689 }
690 // The short lead ("Back 16:30", "Over 80% target") below the halves.
691 const words = (p: Phrase) => (L.halves ? [p.pre, p.num, p.post] : [p.narrow_pre ?? p.pre, p.num, p.narrow])
692 // Below 60 cells, 2 windows that need you fit only without the bars
693 // (measured in Chromium at 7 px a cell, 40 to 140).
694 const squeezed = e.props.bodyColumns < 56
695 const nobars = both && e.props.bodyColumns < 60
696 const button = !waiting && !(L.bare && focus)
697 // A short label below 56 cells, or when 2 windows need you with the small bars.
698 const short = squeezed || (both && L.W === 44)
699 const open = (await read($, details)) && button
700 const toggle = () => update($, details, (v) => !v)
701 // Details: a card per window, side by side from 72 cells, one under the
702 // other below: its name, its percent large and its pace in color, then
703 // the CLI's other rows (band.details, the menu bar's too), the label on
704 // the left and the value on the right, so each number stays with its row.
705 const rows = (w: Win): Detail[] =>
706 w.details?.length ? w.details : [{ label: 'Summary', value: w.sentence, tone: 'inactive' }]
707 const shown = (w: Win) =>
708 rows(w)
709 .filter((r) => !HEAD.includes(r.label))
710 .sort((a, b) => rank(a.label) - rank(b.label))
711 const title = (w: Win) => (w.id === 'h5' ? '5 hours' : 'Week')
712 // The pace as the band says it; without words (a maxed week blocks the
713 // 5 hours), the CLI's Pace row.
714 const pace = (w: Win) => {
715 if (w.phrase) return { text: w.phrase.pre + w.phrase.num + w.phrase.post, tone: w.phrase.tone }
716 const r = rows(w).find((d) => d.label === 'Pace')
717 return r ? { text: r.value, tone: r.tone } : null
718 }
719 const side = e.props.bodyColumns >= 72
720 // The chart fills the card's inside (7 px a cell, less the border and
721 // the padding); drawn wider than its slot, the surface scales it down.
722 const cw = Math.max(120, (side ? Math.floor((e.props.bodyColumns * 7 - 12) / 2) : e.props.bodyColumns * 7) - 26)
723 const card = (w: Win) => {
724 const pc = pace(w)
725 const about = `${title(w)}: what was used over the window and the forecast to its reset`
726 return (
727 <Box
728 key={`card-${w.id}`}
729 flexDirection="column"
730 flexGrow={1}
731 flexShrink={1}
732 width={side ? '50%' : undefined}
733 borderStyle="round"
734 borderDimColor
735 paddingX="md"
736 paddingY="sm"
737 rowGap="xs"
738 >
739 <Box key={`ch-${w.id}`} flexDirection="row" justifyContent="space-between" alignItems="center" columnGap="md">
740 <Box flexDirection="row" alignItems="center" columnGap="sm">
741 <Text size="footnote" color="inactive" weight="medium">
742 {title(w)}
743 </Text>
744 <Text size="heading" weight="semibold" tabular>
745 {w.pct === null ? 'waiting' : `${w.pct}%`}
746 </Text>
747 </Box>
748 {pc ? (
749 <Text size="footnote" weight="medium" color={pc.tone} wrap="truncate-end">
750 {pc.text}
751 </Text>
752 ) : null}
753 </Box>
754 {w.chart ? <Svg key={`cc-${w.id}`} source={chartSvg(w.chart, cw, about, pc?.tone)} alt={about} /> : null}
755 {shown(w).map((r) => (
756 <Box key={`cr-${w.id}-${r.label}`} flexDirection="row" justifyContent="space-between" columnGap="md">
757 <Text size="footnote" color="inactive">
758 {r.label}
759 </Text>
760 <Box flexDirection="row" flexShrink={1} columnGap="xs">
761 <Text size="footnote" tabular wrap="truncate-end" color={r.tone === 'text' ? 'text' : r.tone}>
762 {r.value}
763 </Text>
764 {r.note ? (
765 <Text size="footnote" tabular wrap="truncate-end" color={r.note_tone ?? 'inactive'}>
766 {`· ${r.note}`}
767 </Text>
768 ) : null}
769 </Box>
770 </Box>
771 ))}
772 </Box>
773 )
774 }
775 const table = (
776 <Box flexDirection={side ? 'row' : 'column'} columnGap="md" rowGap="sm">
777 {wins.map(card)}
778 </Box>
779 )
780 const row = (
781 <Box flexDirection="row" alignItems="center" columnGap="sm">
782 <Box
783 flexDirection="row"
784 alignItems="center"
785 flexGrow={1}
786 flexShrink={1}
787 columnGap={L.halves && !waiting ? 0 : 'md'}
788 >
789 {wins.map((w) => {
790 const att = Boolean(w.phrase?.attention)
791 const dim = focus && !att
792 const p = said(w)
793 const [pre, num, post] = p ? words(p) : ['', '', '']
794 const plain = p?.tone === 'inactive'
795 return (
796 <Box
797 key={`ctb-${w.id}`}
798 width={L.halves && !waiting ? '50%' : undefined}
799 flexShrink={1}
800 flexDirection="row"
801 alignItems="center"
802 columnGap="xs"
803 >
804 <Text size="footnote" color="inactive">
805 {L.tiny && w.id === 'wk' ? w.short : w.name}
806 </Text>
807 {(L.tiny && focus && !att) || nobars ? null : (
808 <Svg source={barSvg(w, L.W)} alt={w.sentence} width={L.W} height={SVG_H} />
809 )}
810 {w.pct === null ? (
811 waiting ? null : (
812 <Text size="footnote" color="inactive">
813 waiting
814 </Text>
815 )
816 ) : (
817 <Text
818 size="footnote"
819 tabular
820 weight={dim ? 'regular' : 'medium'}
821 color={dim ? 'inactive' : 'text'}
822 >
823 {`${w.pct}%`}
824 </Text>
825 )}
826 {p ? (
827 // A little more air between the percent and the words than inside them.
828 <Box marginLeft={squeezed ? undefined : 'xs'} flexShrink={1}>
829 <Text size="footnote" wrap="truncate-end" color={p.tone} weight={plain ? 'regular' : 'medium'}>
830 {pre}
831 {num ? (
832 <Text weight="semibold" tabular color={p.tone}>
833 {num}
834 </Text>
835 ) : null}
836 {post}
837 </Text>
838 </Box>
839 ) : null}
840 {L.halves && w.reset && w.pct !== null && w.kind !== 'maxed' ? (
841 <Box marginLeft="sm">
842 <Text size="footnote" color="inactive" tabular>
843 {`resets ${w.reset}`}
844 </Text>
845 </Box>
846 ) : null}
847 </Box>
848 )
849 })}
850 {waiting ? (
851 <Text size="footnote" color="inactive">
852 {L.halves ? 'Waiting for the first reading' : 'Waiting for data'}
853 </Text>
854 ) : null}
855 </Box>
856 {button ? (
857 // One label in both states, so the row never shifts when it toggles;
858 // below 56 cells a short one, so the words that need you keep their room.
859 <Button key="details" plain dimColor hotkey="d" label={short ? '⋯' : 'Details'} onPress={toggle} />
860 ) : null}
861 </Box>
862 )
863 ours = open ? (
864 <Box flexDirection="column" rowGap="sm">
865 {row}
866 {table}
867 {/* How old the reading is, once that is worth a line (5 min). */}
868 {stale(current.updated_ts, current.now) ? (
869 <Text size="footnote" color="inactive">
870 {age(current.updated_ts, current.now)}
871 </Text>
872 ) : null}
873 </Box>
874 ) : (
875 row
876 )
877 } else {
878 // The terminal (when asked for), or an older CLI: the text line.
879 ours = (
880 <Box flexDirection="row">
881 {current.segments.map((segment, index) => (
882 <Text key={`s${index}`} wrap="truncate-end" {...(TONE[segment.tone] ?? {})}>
883 {segment.text}
884 </Text>
885 ))}
886 </Box>
887 )
888 }
889 // Keep what other mods draw in the band below ours.
890 const below = await next(e)
891 return below ? (
892 <Box flexDirection="column">
893 {ours}
894 {below}
895 </Box>
896 ) : (
897 ours
898 )
899 })
900}
901types/index.d.ts 86 lines1// Segment tones of the text line: the classic line's, and the bars line's
2// words and bar parts (pacer/render.py ANSI). An unknown one draws plain.
3export type Tone = string
4
5export type Segment = { text: string; tone: Tone }
6
7// What a window's bar draws (pacer/band.py).
8export type BarKind = 'run' | 'rough' | 'dry' | 'expire' | 'maxed' | 'idle' | 'wait'
9
10// The words after the percent: at most one number, drawn bold.
11export type Phrase = {
12 pre: string
13 num: string
14 post: string
15 // `pre` and `post` in a narrow band ("Out 15:40" for "Runs out 15:40").
16 narrow_pre?: string
17 narrow: string
18 tone: 'inactive' | 'success' | 'warning' | 'error'
19 // The window needs you (slower, reached, over a target): its words always
20 // show, the other percent dims.
21 attention: boolean
22 // Grey words ("On pace", "Idle"): the first to go when room is short.
23 calm: boolean
24}
25
26// A row of Details, the same as the menu bar's menu (band.details).
27// `note`: a second, quieter fact on the same row (in `note_tone`, grey by default).
28export type Detail = { label: string; value: string; tone: string; note?: string; note_tone?: string }
29
30// A window's chart for its Details card (chart.py): epoch seconds, percents.
31export type Chart = {
32 t0: number
33 t1: number
34 now: number
35 actual: [number, number][]
36 forecast: [number, number][]
37 // An early 5-hour estimate: the lines at its rate 40% lower and higher.
38 fan: [[number, number][], [number, number][]] | null
39 ghosts: [number, number][][]
40 // The week's night hours, as pale bands (an older CLI has none).
41 nights?: [number, number][]
42 // When it runs out before the reset, and its time (an older CLI has none).
43 out?: number | null
44 out_label?: string | null
45 grid: number[]
46 labels: [number, string][]
47}
48
49// One window of the band (band.window).
50export type Win = {
51 id: 'h5' | 'wk'
52 name: string
53 short: string
54 kind: BarKind
55 pct: number | null
56 // A milder run-out (yellow): the red forecast fades.
57 soft?: boolean
58 used: number
59 projected: number | null
60 reset: string | null
61 reset_ts: number | null
62 phrase: Phrase | null
63 sentence: string
64 details?: Detail[]
65 chart?: Chart | null
66}
67
68// What `pacer statusline --format json` prints, plus the mod's own error.
69export type View = {
70 line: string
71 segments: Segment[]
72 surfaces: string[]
73 refresh_s: number
74 windows?: Win[] | null
75 // When a reading last changed, and when the CLI ran: epoch seconds.
76 updated_ts?: number | null
77 now?: number
78 error?: string
79}
80
81declare module 'claude-code' {
82 interface PluginState {
83 pacer: { view: View | null; details: boolean }
84 }
85}
86