SLOPSHOPPER

pacer

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

newbandprocesstimer
★ 1v0.1.0MITupdated 2026-10-24PershinIlya/pacer/mods/pacer
A shopper browsing a rack in a slop shop
README

pacer mod

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:

  1. when a reading moved (a 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);
  2. stores what it prints in $.state: one entry per window (bar kind, percent, words, reset, sentence) and the text line;
  3. draws them on the 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.

Source 2 files
hooks/register.tsx 901 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
901
types/index.d.ts 86 lines
1// 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