From ad5c891967dadf478e6920224e3def515a32c38e Mon Sep 17 00:00:00 2001 From: Samarth Rayar Date: Sat, 3 Oct 2026 20:11:38 +0000 Subject: [PATCH 1/6] pulse 0.1.0: live usage cockpit with expandable tabs and a spend ledger A one-line glance above the prompt (heartbeat, context sparkline, plan limit meters with an even-pace mark, spend for session, today and month) that expands into Context, Limits, Cost and Turns tabs, plus a /pulse dashboard pane. Spend by day, week and month comes from a per-session, per-day ledger in the plugin's store. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 7 + README.md | 1 + plugins/pulse/.claude-plugin/plugin.json | 22 ++ plugins/pulse/CHANGELOG.md | 14 + plugins/pulse/README.md | 93 +++++ plugins/pulse/hooks/calc.test.ts | 106 ++++++ plugins/pulse/hooks/calc.ts | 262 ++++++++++++++ plugins/pulse/hooks/draw.tsx | 418 +++++++++++++++++++++++ plugins/pulse/hooks/hooks.json | 3 + plugins/pulse/hooks/register.test.tsx | 264 ++++++++++++++ plugins/pulse/hooks/register.tsx | 313 +++++++++++++++++ plugins/pulse/tsconfig.json | 1 + plugins/pulse/types/index.d.ts | 58 ++++ 13 files changed, 1562 insertions(+) create mode 100644 plugins/pulse/.claude-plugin/plugin.json create mode 100644 plugins/pulse/CHANGELOG.md create mode 100644 plugins/pulse/README.md create mode 100644 plugins/pulse/hooks/calc.test.ts create mode 100644 plugins/pulse/hooks/calc.ts create mode 100644 plugins/pulse/hooks/draw.tsx create mode 100644 plugins/pulse/hooks/hooks.json create mode 100644 plugins/pulse/hooks/register.test.tsx create mode 100644 plugins/pulse/hooks/register.tsx create mode 100644 plugins/pulse/tsconfig.json create mode 100644 plugins/pulse/types/index.d.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8f7cce2..e53d9c9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,6 +9,13 @@ "description": "A pixel-art companion above the prompt that acts out what Claude is doing, with a sprout that wilts as context fills, a context sparkline, usage limits and cost", "category": "mods", "tags": ["mod", "companion", "pixel-art", "status"] + }, + { + "name": "pulse", + "source": "./plugins/pulse", + "description": "A live usage cockpit above the prompt: context, plan limits with pace forecasts, and cost by day, week and month, in a one-line glance that expands into tabs and a dashboard pane", + "category": "mods", + "tags": ["mod", "usage", "cost", "limits", "dashboard"] } ] } diff --git a/README.md b/README.md index 4fc85a9..48eacc6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Installed plugins also load in the Claude desktop app (Code tab, local sessions) | Plugin | Install id | Type | What it does | | --- | --- | --- | --- | | [Buddy](plugins/buddy) | `buddy` | mods | A pixel-art companion above the prompt that acts out what Claude is doing, with a sprout that wilts as context fills, a context sparkline, usage limits and cost | +| [Pulse](plugins/pulse) | `pulse` | mods | A live usage cockpit above the prompt: context, plan limits with pace forecasts, and cost by day, week and month, in a one-line glance that expands into tabs and a dashboard pane | Each plugin has its own README covering what it adds, how to use and configure it, and its changelog. This table is generated from [`marketplace.json`](.claude-plugin/marketplace.json) by `scripts/catalog.mts`. diff --git a/plugins/pulse/.claude-plugin/plugin.json b/plugins/pulse/.claude-plugin/plugin.json new file mode 100644 index 0000000..528be80 --- /dev/null +++ b/plugins/pulse/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "pulse", + "displayName": "Pulse", + "version": "0.1.0", + "description": "A live usage cockpit above the prompt: context, plan limits with pace forecasts, and cost by day, week and month, in a one-line glance that expands into tabs and a dashboard pane", + "author": { + "name": "Samarth Rayar", + "url": "https://github.com/Samarth2001" + }, + "homepage": "https://github.com/Samarth2001/claude-plugins/tree/main/plugins/pulse", + "repository": "https://github.com/Samarth2001/claude-plugins", + "license": "MIT", + "keywords": [ + "mod", + "usage", + "cost", + "limits", + "context", + "dashboard" + ], + "types": "./types/index.d.ts" +} diff --git a/plugins/pulse/CHANGELOG.md b/plugins/pulse/CHANGELOG.md new file mode 100644 index 0000000..b95fa0b --- /dev/null +++ b/plugins/pulse/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to pulse. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow [Semantic Versioning](https://semver.org/). Each release is tagged `pulse--v`. + +## [0.1.0] - 2026-10-03 + +### Added + +- A one-line glance above the prompt: a heartbeat trace that scrolls while Claude works, the turn's clock and tool count, a context sparkline, plan limit meters with an even-pace mark and the 5-hour reset countdown, and spend for this session, today and this month. +- `more` expands the band into four tabs (hotkeys `1` to `4`): Context (fill, tokens, last turn's jump, average growth, turns left, cache hit), Limits (meters, reset times, pace ratio, where each window lands at reset or when it fills), Cost (a bar chart over 7 days, 30 days or 12 months, today, 7 days, this month, a month-end forecast, last month, daily average, session tokens) and Turns (recent turns with duration, tools, context growth, cost and cache hit, plus the session's tool mix). +- `/pulse` opens a dashboard pane with every section at once. +- A spend ledger in the plugin's store, kept per session and per local day so sessions running side by side don't overwrite each other; days older than 400 days are pruned. +- A value that just changed glows for a moment; a toast when a plan limit crosses 80% and 95%. +- `/pulse glance`, `/pulse detail`, `/pulse context|limits|cost|turns`, `/pulse hide`, `/pulse show`. The band's mode, tab and range are remembered across sessions. diff --git a/plugins/pulse/README.md b/plugins/pulse/README.md new file mode 100644 index 0000000..06473ef --- /dev/null +++ b/plugins/pulse/README.md @@ -0,0 +1,93 @@ +# Pulse + +A live usage cockpit above the Claude Code prompt. One quiet line tells you how full the context is, how fast you are burning your plan limits, and what you have spent today and this month. Press `more` and it opens into tabs with the detail behind each number. + +``` +⡠⠊⠑⢄⣀⣀ 12s · 3 tools │ ctx ▂▃▃▄ 42% │ 5h ━━─┃── 23% ↻2h00m │ 7d ━━┃━━─ 81% │ $2.07 · today $4.47 · Oct $24.37 ▾ more +``` + +Expanded, on the Limits tab: + +``` + Context ▸Limits Cost Turns dashboard +5h ━━━━━━━───────────┃─────────── 23% resets in 2h00m · 0.4x pace → 38% at reset +7d ━━━━━━━━━━━━┃━━━━━━━━━━━────── 81% resets in 4d0h · 1.9x pace · full in ~16h53m +┃ marks an even pace through the window +``` + +On the Cost tab: + +``` + ██ ▃▃ session $2.07 today $4.47 7d $24.37 + ▂▂ ██ ▅▅ ██ ██ Oct $24.37 → ~$50.36 by month end last month $40.00 +Fr Sa Su Mo Tu We Th avg $3.48/day · tracked here since Sep 2 +tokens in 1.2M · out 84k · cache hit 91% · subagents 22% +``` + +## Install + +```bash +claude plugin marketplace add Samarth2001/claude-plugins +claude plugin install pulse@samarth +``` + +Then run `/reload-plugins` in an open session, or start a new one. It draws with plain text, so it works in the terminal, the desktop app's Code tab, VS Code and mobile. + +## What it shows + +### The glance row + +| Part | What | +| --- | --- | +| Heartbeat | Scrolls while a turn runs, flat when idle. Beside it, the turn's clock and tool count | +| `ctx` | A sparkline with one bar per turn, then the context fill now | +| `5h`, `7d` | Each plan limit as a meter with `┃` where an even pace would be by now, the percent used, and the time until the 5-hour window resets. The color follows where the window is heading, not only where it is | +| Money | This session, today, and this month across every session on this machine | + +The row drops the meters and the sparkline below 120 columns, and keeps only the numbers below 84. + +### The tabs + +| Tab | Shows | +| --- | --- | +| Context | Fill, tokens of the window, the last turn's jump, average growth per turn, turns left at that rate, the last turn's cache hit, the model | +| Limits | A wide meter per window, reset time, pace (`1.9x` is burning almost twice as fast as even), and where it lands at reset or when it fills | +| Cost | Bars over 7 days, 30 days or 12 months; session, today, 7 days, this month, a month-end forecast, last month, average per day; the session's tokens in and out, cache hit and the subagents' share | +| Turns | Recent turns: duration as a bar, tools, context growth, cost and cache hit; then the session's tool mix | + +A number that just changed glows for a moment. When a plan limit crosses 80% or 95%, a toast says so once. + +## Controls + +| Control | Does | +| --- | --- | +| `▾ more` / `▴ less` (`e`) | Expand or collapse the band | +| `Context`, `Limits`, `Cost`, `Turns` (`1` to `4`) | Pick a tab | +| `7d`, `30d`, `12mo` (`w`, `m`, `y`) | Pick the Cost chart's range | +| `dashboard` (`p`) or `/pulse` | Open every section in a pane | +| `/pulse glance`, `/pulse detail` | Set the band's size | +| `/pulse context` (or `limits`, `cost`, `turns`) | Expand on that tab | +| `/pulse hide`, `/pulse show` | Hide or bring back the band. On desktop the expanded band also has `hide`; the terminal draws its own `[-]` | + +Hotkeys work while the band has focus (ctrl+x tab, or a click). The mode, tab and range are remembered across sessions. + +## Where the numbers come from + +- Context, plan limits and session cost are Claude Code's own figures, the ones `/cost` and the status line use: `$.session.usage()` and the `session.measure` event. +- Pace and forecasts are straight-line estimates: used over elapsed for the 5-hour and 7-day windows, average growth over the last few turns for context, and this month's spend so far for the month end. +- Spend by day, week and month is pulse's own ledger. Each time a session's cost grows, pulse adds the difference to that day in its store, under a key per session. It counts only sessions that ran with pulse installed, on this machine, from the day shown as "tracked here since". It is not your billing statement. +- Token totals and cache hit come from each turn's usage on `turn.complete`, subagents included. + +## Develop + +From the repository root: + +```bash +claude plugin validate plugins/pulse +claude plugin test plugins/pulse +claude --plugin-dir plugins/pulse # try it, hot-reloads on save +``` + +`hooks/calc.ts` holds the pure math (pace, ledger sums, charts), `hooks/draw.tsx` the rows, and `hooks/register.tsx` the events and state. + +See [CHANGELOG.md](CHANGELOG.md) for releases. diff --git a/plugins/pulse/hooks/calc.test.ts b/plugins/pulse/hooks/calc.test.ts new file mode 100644 index 0000000..04e0b9f --- /dev/null +++ b/plugins/pulse/hooks/calc.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { addDays, columns, ekg, forecast, formatTokens, formatUsd, meter, pace, series, summarize } from './calc' + +const HOUR = 3_600_000 +const NOW = Date.UTC(2026, 9, 15, 12) + +describe('pace', () => { + test('reads ahead of an even burn as a ratio and a forecast', async () => { + // 3h into a 5h window, 90% used: 1.5x pace, full before the reset. + const p = pace({ kind: 'five_hour', percentUsed: 90, resetsAt: new Date(NOW + 2 * HOUR).toISOString() }, NOW) + expect(p?.expected).toBe(60) + expect(p?.ratio).toBe(1.5) + expect(p?.atReset).toBe(150) + expect(p?.fullInMs).toBe(20 * 60_000) + }) + + test('behind pace has no full-by time', async () => { + const p = pace({ kind: 'five_hour', percentUsed: 30, resetsAt: new Date(NOW + 2 * HOUR).toISOString() }, NOW) + expect(p?.atReset).toBe(50) + expect(p?.fullInMs).toBeUndefined() + }) + + test('says nothing for a window of unknown length or with no reset', async () => { + expect(pace({ kind: 'spend_limit', percentUsed: 40, resetsAt: new Date(NOW + HOUR).toISOString() }, NOW)).toBeUndefined() + expect(pace({ kind: 'five_hour', percentUsed: 40 }, NOW)).toBeUndefined() + }) +}) + +describe('ledger', () => { + const ledger = { + since: '2026-09-20', + days: { '2026-09-20': 10, '2026-09-30': 5, '2026-10-01': 2, '2026-10-09': 3, '2026-10-15': 4 }, + } + + test('adds up today, the last 7 days, this month and last month', async () => { + const s = summarize(ledger, '2026-10-15') + expect(s.today).toBe(4) + expect(s.week).toBe(7) // Oct 9 to Oct 15 + expect(s.month).toBe(9) + expect(s.monthLabel).toBe('Oct') + expect(s.lastMonth).toBe(15) + expect(s.all).toBe(24) + // 9 over 15 days carried to 31. + expect(s.monthForecast.toFixed(6)).toBe((9 + (9 / 15) * 16).toFixed(6)) + }) + + test('a month tracked from mid-month forecasts from the days it saw', async () => { + const s = summarize({ since: '2026-10-11', days: { '2026-10-11': 5, '2026-10-15': 5 } }, '2026-10-15') + expect(s.monthForecast.toFixed(6)).toBe((10 + (10 / 5) * 16).toFixed(6)) + }) + + test('series gives 7 days, 30 days or 12 months, today last', async () => { + const week = series(ledger, '2026-10-15', 'week') + expect(week.length).toBe(7) + expect(week.at(-1)).toMatchObject({ usd: 4, isNow: true }) + expect(week[0]?.usd).toBe(3) + expect(series(ledger, '2026-10-15', 'month').length).toBe(30) + const year = series(ledger, '2026-10-15', 'year') + expect(year.length).toBe(12) + expect(year.at(-1)).toMatchObject({ label: 'O', usd: 9 }) + expect(year.at(-2)).toMatchObject({ label: 'S', usd: 15 }) + }) + + test('addDays crosses months', async () => { + expect(addDays('2026-10-01', -1)).toBe('2026-09-30') + expect(addDays('2026-12-31', 1)).toBe('2027-01-01') + }) +}) + +describe('drawing helpers', () => { + test('meter marks the even-pace cell', async () => { + expect(meter(50, 10, 75)).toEqual({ filled: '━━━━━', empty: '─────', markAt: 7 }) + expect(meter(100, 4).markAt).toBeUndefined() + }) + + test('columns stack eighth blocks two rows tall', async () => { + const [top, bottom] = columns([0, 0.5, 1, 0.01], 2) + expect(top).toEqual([' ', ' ', '█', ' ']) + expect(bottom).toEqual([' ', '█', '█', '▁']) + }) + + test('the heartbeat scrolls while working and lies flat at rest', async () => { + expect(ekg(0, 6, true)).not.toBe(ekg(3, 6, true)) + expect(ekg(0, 4, false)).toBe('⣀⣀⣀⣀') + }) + + test('forecast skips compactions and counts turns left', async () => { + const f = forecast([100_000, 150_000, 40_000, 90_000], 1_000_000) + expect(f?.perTurn).toBe(50_000) + expect(f?.turnsLeft).toBe(18) + expect(forecast([100_000], 1_000_000)).toBeUndefined() + }) + + test('tokens drop a trailing .0', async () => { + expect(formatTokens(50_000)).toBe('50k') + expect(formatTokens(12_345)).toBe('12.3k') + expect(formatTokens(1_500_000)).toBe('1.5M') + }) + + test('money prints tighter as it grows', async () => { + expect(formatUsd(2.414)).toBe('$2.41') + expect(formatUsd(155.4)).toBe('$155') + expect(formatUsd(1520)).toBe('$1.5k') + }) +}) diff --git a/plugins/pulse/hooks/calc.ts b/plugins/pulse/hooks/calc.ts new file mode 100644 index 0000000..72d6c59 --- /dev/null +++ b/plugins/pulse/hooks/calc.ts @@ -0,0 +1,262 @@ +// Pure helpers: how numbers print, how fast a limit is burning, and what the +// cost ledger adds up to. Nothing here touches $, so it tests on its own. + +import type { Ledger, Limit, Range } from '../types' + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +// How long each rate-limit window lasts; a gateway's spend limit has none. +export const WINDOW_MS: Record = { five_hour: 5 * HOUR, seven_day: 7 * DAY } +export const LIMIT_LABELS: Record = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' } + +export function formatTokens(n: number): string { + const trim = (x: string) => x.replace(/\.0$/, '') + if (n >= 1_000_000) return `${trim((n / 1_000_000).toFixed(1))}M` + if (n >= 1_000) return `${trim((n / 1_000).toFixed(n >= 100_000 ? 0 : 1))}k` + return `${Math.round(n)}` +} + +export function formatDuration(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)) + if (s >= 3600) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m` + return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s` +} + +export function formatSpan(ms: number): string { + const minutes = Math.max(0, Math.round(ms / MINUTE)) + if (minutes >= 24 * 60) return `${Math.floor(minutes / 1440)}d${Math.floor((minutes % 1440) / 60)}h` + if (minutes >= 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m` + return `${minutes}m` +} + +export function formatUsd(usd: number): string { + if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}k` + if (usd >= 100) return `$${Math.round(usd)}` + return `$${usd.toFixed(2)}` +} + +export function shorten(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text +} + +// Green, yellow from 50, orange from 75, red from 90. +export function levelColor(percent: number): string { + if (percent >= 90) return '#E5534B' + if (percent >= 75) return '#E8964A' + if (percent >= 50) return '#D6C35A' + return '#7BC96F' +} + +const BARS = ' ▁▂▃▄▅▆▇█' + +// One bar per value, 0-100. +export function spark(percent: number): string { + const i = Math.round((clamp(percent) / 100) * 7) + 1 + return BARS[i] ?? '▁' +} + +// A meter `cells` wide. `mark` is where an even pace would be by now, drawn +// as its own cell so the bar reads "ahead of pace" or "behind" at a glance. +export function meter(percent: number, cells: number, mark?: number): { filled: string; empty: string; markAt?: number } { + const n = Math.round((clamp(percent) / 100) * cells) + const markAt = mark === undefined ? undefined : Math.min(cells - 1, Math.floor((clamp(mark) / 100) * cells)) + return { filled: '━'.repeat(n), empty: '─'.repeat(cells - n), markAt } +} + +// Columns `rows` tall from eighth blocks, top row first; each value 0-1. +export function columns(values: number[], rows: number): string[][] { + const out: string[][] = Array.from({ length: rows }, () => []) + for (const v of values) { + let level = Math.round(Math.max(0, Math.min(1, v)) * rows * 8) + if (v > 0 && level === 0) level = 1 // a cent still shows + for (let r = rows - 1; r >= 0; r--) { + const here = Math.min(8, level) + out[r]?.push(BARS[here] ?? ' ') + level -= here + } + } + return out +} + +// A heartbeat trace that scrolls while Claude works and lies flat at rest. +const BEAT = '⣀⣀⣀⡠⠊⠑⢄⣀⣀⣀⣀⣀⡠⠔⠁⠈⠢⣀⣀⣀' +export function ekg(frame: number, width: number, isWorking: boolean): string { + if (!isWorking) return '⣀'.repeat(width) + let out = '' + for (let i = 0; i < width; i++) out += BEAT[(frame + i) % BEAT.length] + return out +} + +export type Pace = { + // Where an even burn would put the window by now, 0-100. + expected: number + // Used over expected: 1 is on pace, 2 is burning twice as fast. + ratio: number + // Where the window ends at this pace, 0-100+. + atReset: number + // Milliseconds until 100% at this pace, when that comes before the reset. + fullInMs?: number + resetInMs: number +} + +// Linear pace over the window so far. Undefined for a window of unknown +// length, or too early in one to say anything. +export function pace(limit: Limit, now: number): Pace | undefined { + const length = WINDOW_MS[limit.kind] + if (!length || !limit.resetsAt) return undefined + const resetInMs = Date.parse(limit.resetsAt) - now + if (!Number.isFinite(resetInMs) || resetInMs <= 0) return undefined + const elapsed = Math.max(0, length - resetInMs) + if (elapsed < Math.min(length * 0.02, 10 * MINUTE)) return undefined + const expected = (elapsed / length) * 100 + const ratio = limit.percentUsed / expected + const atReset = limit.percentUsed / (elapsed / length) + const perMs = limit.percentUsed / elapsed + const left = perMs > 0 ? (100 - limit.percentUsed) / perMs : Infinity + return { expected, ratio, atReset, resetInMs, fullInMs: left < resetInMs ? Math.max(0, left) : undefined } +} + +// Color by where the window is heading, not only where it is. +export function paceColor(limit: Limit, p: Pace | undefined): string { + if (!p) return levelColor(limit.percentUsed) + if (p.fullInMs !== undefined) return '#E5534B' + return levelColor(Math.max(limit.percentUsed, Math.min(89, p.atReset))) +} + +// Local calendar day as YYYY-MM-DD. +export function dayKey(ms: number): string { + const d = new Date(ms) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function dayStart(day: string): Date { + const [y, m, d] = day.split('-').map(Number) + return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1) +} + +export function addDays(day: string, n: number): string { + const d = dayStart(day) + d.setDate(d.getDate() + n) + return dayKey(d.getTime()) +} + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] +const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] + +export function monthName(day: string): string { + return MONTHS[dayStart(day).getMonth()] ?? '' +} + +export function shortDate(day: string): string { + const d = dayStart(day) + return `${MONTHS[d.getMonth()]} ${d.getDate()}` +} + +export type Spend = { + today: number + week: number + month: number + monthLabel: string + // This month's spend carried to its last day at the pace so far. + monthForecast: number + lastMonth: number + all: number + perDay: number + since?: string +} + +export function summarize(ledger: Ledger, today: string): Spend { + const weekStart = addDays(today, -6) + const month = today.slice(0, 7) + const start = dayStart(today) + const lastMonthKey = dayKey(new Date(start.getFullYear(), start.getMonth() - 1, 1).getTime()).slice(0, 7) + let week = 0 + let monthTotal = 0 + let lastMonth = 0 + let all = 0 + for (const [day, usd] of Object.entries(ledger.days)) { + all += usd + if (day >= weekStart && day <= today) week += usd + if (day.startsWith(month)) monthTotal += usd + if (day.startsWith(lastMonthKey)) lastMonth += usd + } + const dayOfMonth = start.getDate() + const daysInMonth = new Date(start.getFullYear(), start.getMonth() + 1, 0).getDate() + const firstDay = ledger.since && ledger.since > `${month}-01` ? dayStart(ledger.since).getDate() : 1 + const daysTracked = Math.max(1, dayOfMonth - firstDay + 1) + const weekDays = Math.max(1, Math.min(7, ledger.since ? daysBetween(ledger.since, today) + 1 : 7)) + return { + today: ledger.days[today] ?? 0, + week, + month: monthTotal, + monthLabel: MONTHS[start.getMonth()] ?? '', + monthForecast: monthTotal + (monthTotal / daysTracked) * (daysInMonth - dayOfMonth), + lastMonth, + all, + perDay: week / weekDays, + since: ledger.since, + } +} + +function daysBetween(from: string, to: string): number { + return Math.round((dayStart(to).getTime() - dayStart(from).getTime()) / DAY) +} + +export type Bucket = { label: string; usd: number; isNow: boolean } + +// Spend per bar for a range: 7 days, 30 days, or 12 months, oldest first. +export function series(ledger: Ledger, today: string, range: Range): Bucket[] { + if (range === 'year') { + const start = dayStart(today) + const out: Bucket[] = [] + for (let i = 11; i >= 0; i--) { + const key = dayKey(new Date(start.getFullYear(), start.getMonth() - i, 1).getTime()).slice(0, 7) + let usd = 0 + for (const [day, v] of Object.entries(ledger.days)) if (day.startsWith(key)) usd += v + out.push({ label: MONTHS[Number(key.slice(5)) - 1]?.slice(0, 1) ?? '', usd, isNow: i === 0 }) + } + return out + } + const count = range === 'week' ? 7 : 30 + const out: Bucket[] = [] + for (let i = count - 1; i >= 0; i--) { + const day = addDays(today, -i) + const label = range === 'week' ? (WEEKDAYS[dayStart(day).getDay()] ?? '') : String(dayStart(day).getDate()) + out.push({ label, usd: ledger.days[day] ?? 0, isNow: i === 0 }) + } + return out +} + +// What a tool call counts as in the tool mix. +export function toolGroup(tool: string): string { + if (tool.startsWith('mcp__')) return 'MCP' + if (tool === 'Task') return 'Agent' + if (tool === 'MultiEdit' || tool === 'NotebookEdit') return 'Edit' + return tool +} + +// Share of the input the prompt cache served, 0-100. +export function cacheHit(u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): number | undefined { + const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + return total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : undefined +} + +// Average context growth per turn over the last few, and turns left at it. +export function forecast(tokens: number[], window: number): { perTurn: number; turnsLeft?: number } | undefined { + const recent = tokens.slice(-6) + const steps: number[] = [] + for (let i = 1; i < recent.length; i++) { + const step = (recent[i] ?? 0) - (recent[i - 1] ?? 0) + if (step > 0) steps.push(step) // a compaction is a drop, not a turn's growth + } + if (steps.length === 0) return undefined + const perTurn = steps.reduce((a, b) => a + b, 0) / steps.length + const last = recent.at(-1) ?? 0 + return { perTurn, turnsLeft: perTurn > 0 ? Math.max(0, Math.floor((window - last) / perTurn)) : undefined } +} + +function clamp(percent: number): number { + return Math.min(100, Math.max(0, percent)) +} diff --git a/plugins/pulse/hooks/draw.tsx b/plugins/pulse/hooks/draw.tsx new file mode 100644 index 0000000..3aa20a7 --- /dev/null +++ b/plugins/pulse/hooks/draw.tsx @@ -0,0 +1,418 @@ +// What pulse draws: the glance row, the tab bodies and the dashboard pane. +// Every function takes the surface's element table and plain data, so the +// band and the pane share one set of rows. + +import type { ElementTable, RenderElement } from 'claude-code' + +import type { Ledger, Limit, Live, Range, Snapshot, Tab, Turn, View } from '../types' +import { + LIMIT_LABELS, + addDays, + cacheHit, + columns, + ekg, + forecast, + formatDuration, + formatSpan, + formatTokens, + formatUsd, + levelColor, + meter, + pace, + paceColor, + series, + shortDate, + shorten, + spark, + summarize, +} from './calc' + +export type Ui = Pick + +export type Data = { + snap: Snapshot + turns: Turn[] + live: Live + ledger: Ledger + view: View + now: number + today: string +} + +export type Actions = { + setView: (change: Partial) => void + openPane: () => void +} + +export const ACCENT = '#B4A7F5' +const BAR = '#7FB2F0' +const GOOD = '#7BC96F' +const FRAME_MS = 140 + +export const TABS: { tab: Tab; label: string; hotkey: string }[] = [ + { tab: 'context', label: 'Context', hotkey: '1' }, + { tab: 'limits', label: 'Limits', hotkey: '2' }, + { tab: 'cost', label: 'Cost', hotkey: '3' }, + { tab: 'turns', label: 'Turns', hotkey: '4' }, +] + +const RANGES: { range: Range; label: string; hotkey: string }[] = [ + { range: 'week', label: '7d', hotkey: 'w' }, + { range: 'month', label: '30d', hotkey: 'm' }, + { range: 'year', label: '12mo', hotkey: 'y' }, +] + +// A little text toolkit over one element table. +function kit(ui: Ui) { + const { Box, Text } = ui + return { + dim: (text: string) => {text}, + plain: (text: string) => {text}, + paint: (text: string, color: string, bold = false) => {text}, + // A value that just changed glows for a moment: the live-update cue. + live: (text: string, color: string | undefined, isFlashing: boolean) => ( + {text} + ), + row: (key: string, parts: RenderElement[]) => {parts}, + } +} + +function meterParts(ui: Ui, limit: Limit, cells: number, now: number): RenderElement[] { + const { Text } = ui + const p = pace(limit, now) + const color = paceColor(limit, p) + const { filled, empty, markAt } = meter(limit.percentUsed, cells, p?.expected) + const bar = filled + empty + if (markAt === undefined) return [{filled}, {empty}] + // Split the bar around the even-pace mark. + const before = bar.slice(0, markAt) + const after = bar.slice(markAt + 1) + const split = (text: string, offset: number) => { + const n = Math.max(0, Math.min(text.length, filled.length - offset)) + return [{text.slice(0, n)}, {text.slice(n)}] + } + return [...split(before, 0), ┃, ...split(after, markAt + 1)] +} + +// One line, always: what is happening and the four numbers that matter. +export function glance(ui: Ui, d: Data, width: number, actions: Actions, isPane = false): RenderElement { + const { Box, Button } = ui + const t = kit(ui) + const { snap, live, now } = d + const isWide = width >= 120 + const isMid = width >= 84 + const sep = t.dim(' │ ') + const parts: RenderElement[] = [] + + // The heartbeat, then the turn's clock while working. + const frame = Math.floor(now / FRAME_MS) + parts.push({ekg(frame, isMid ? 6 : 3, live.isWorking)}) + if (live.isWorking) { + parts.push(t.plain(` ${formatDuration(now - live.turnStartedAt)}`)) + if (isWide && live.toolsThisTurn > 0) parts.push(t.dim(` · ${live.toolsThisTurn} tool${live.toolsThisTurn === 1 ? '' : 's'}`)) + } else { + parts.push(t.dim(' idle')) + } + + // Context. + parts.push(sep, t.dim('ctx ')) + const percent = snap.percent + if (percent === undefined) { + parts.push(t.dim('after the first turn')) + } else { + if (isMid) { + const history = d.turns.slice(isWide ? -8 : -4).map(turn => turn.percent) + for (const p of history) parts.push(t.paint(spark(p), levelColor(p))) + if (history.length > 0) parts.push(t.plain(' ')) + } + parts.push(t.live(`${percent}%`, levelColor(percent), live.flash.context > now)) + } + + // Plan limits, each with a meter on wide screens. + const flashLimits = live.flash.limits > now + for (const limit of snap.limits) { + parts.push(sep, t.dim(`${LIMIT_LABELS[limit.kind] ?? limit.kind} `)) + if (isWide) parts.push(...meterParts(ui, limit, 6, now), t.plain(' ')) + parts.push(t.live(`${Math.round(limit.percentUsed)}%`, paceColor(limit, pace(limit, now)), flashLimits)) + if (isMid && limit.kind === 'five_hour' && limit.resetsAt) { + parts.push(t.dim(` ↻${formatSpan(Date.parse(limit.resetsAt) - now)}`)) + } + } + + // Money: this session, then today and this month across sessions. + if (snap.usd !== undefined) { + const spend = summarize(d.ledger, d.today) + parts.push(sep, t.live(formatUsd(snap.usd), undefined, live.flash.cost > now)) + if (isMid) parts.push(t.dim(' · today '), t.plain(formatUsd(spend.today))) + if (isWide) parts.push(t.dim(` · ${spend.monthLabel} `), t.plain(formatUsd(spend.month))) + } + + const isDetail = d.view.mode === 'detail' + return ( + + {parts} + {isPane ? null : ( +