See what Claude did for you: tasks, code, commits, tests and tokens, and an estimate of the time and money it saved. Open it with /payback.

Payback is a Claude Code mod that counts what Claude finished for you, sorts it, reads your git and test activity, and estimates the hours and money it saved.
Install · What you get · How it counts · Import history · Commands
/plugin install payback --marketplace michaelSelbertinger/payback
Then type /payback. Want your past sessions in the report too? Run /payback import once. A mod installs like any plugin, through the normal marketplace flow; if you install from a shell while a session is open, run /reload-plugins there to load it.
From a shell, with Claude Code 2.1.292 or later:
claude plugin install payback --marketplace michaelSelbertinger/payback
On an older version, add the marketplace first and then install:
claude plugin marketplace add michaelSelbertinger/payback
claude plugin install payback@payback
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with michaelSelbertinger/payback, then Add plugin.
[!NOTE] Payback is a mod: a plugin that changes how Claude Code looks and behaves. Mods need Claude Code 2.1.287 or later in the terminal (2.1.286 in the desktop app). Payback was built and tested on 2.1.299, and the mods API is young enough to change between releases.
Three things to type once it is installed:
/payback opens the pane. Pick Today, Week, Month or Year, or switch between $ and €./payback settings currency eur shows money in euro; /payback settings rate 75 sets your hourly rate./payback import 30d previews the last 30 days of your past sessions, and imports them when you press Import.The pane on the Month tab, in euro, with invented sample data. Above it: Today · Week · Month · Year, a $ / € switch and an All · You · SDK filter. The tiles, chart and cards are drawn by the mod itself.
| 📈 Tasks, counted honestly | One request you typed and Claude finished is one task. Slash commands, helper agents and interrupted requests are not. Tabs for today, the week, the month and the year, with streaks and your best day. |
| 🧑💻 Real developer stats | Lines added and removed, files touched, languages, commits, pushes, merges, rebases, branches, pull requests and issues, read straight from what Claude ran. |
| ✅ Tests and builds | Runs, pass rate, total and average runtime, and passed and failed counts for pytest, jest, vitest, go, cargo, dotnet, npm and more. |
| 🪙 Tokens per model | Input, output and the share served from cache, for every model that did the work. |
| ⏱️ Time and money | Minutes saved per kind of task, times your hourly rate, in dollars or euro. Always labelled as an estimate. |
| 🕰️ Your past, imported | Replays the session transcripts Claude Code already keeps. Preview first, import on one button, undo any time. |
| 🤖 SDK-aware | Work done by an assistant or script you run through the Agent SDK is kept apart, with an All · You · SDK filter. |
flowchart LR
A["You type a request"] --> B["Claude works<br/>edits · shell · web · tools"]
B --> C{"Finished with<br/>an answer?"}
C -- "interrupted" --> N["Not counted"]
C -- "yes" --> D["Sorted by the tools it used<br/>Coding · DevOps · Research · Docs …"]
D --> F["Coding tasks sized<br/>by lines changed"]
F -. "optional" .-> G["Haiku 5.5 judge:<br/>type and size"]
F --> H[("Daily record in<br/>the mod's store")]
G --> H
H --> I["/payback pane"]
Each task gets one kind, from the tools it used:
| Kind | When | Minutes saved (default) |
|---|---|---|
| Coding | It edited code, ran git, gh, tests or a build, or used a code tool such as Unity or GitHub | by size: 15 / 45 / 120 |
| DevOps & shell | It ran shell commands without editing code, or used Docker, Kubernetes or cloud tools | 20 |
| Emails | It used a mail tool | 10 |
| Images and video | It used an image or video tool, or wrote an image or video file | 20 |
| Documents | It used a document, slide or spreadsheet tool, or wrote a document file | 30 |
| Research | It searched or read the web | 25 |
| Questions & planning | It used no tools, or only read and searched files | 10 |
| Other | Anything else | 10 |
The size of a coding task comes from the lines it changed (under 40 small, under 250 medium, larger is large) until the Haiku judge says otherwise.
git -C, chained commands and PowerShell's if ($?) work; quoted messages and here-strings are ignored, as are dry runs and aborted merges.gh pr …, gh issue …).pip install pytest is not a test run.A helper agent's lines, commits, test runs and tokens count, but it never makes a task of its own. Payback counts what Claude did through its own tools, not commits you make yourself in a terminal.
Sessions started through the Claude Agent SDK (an assistant of your own, a script) are counted apart. The pane shows a Started by row with an All / You / SDK filter. SDK sessions run from a temp folder are treated as tests and left out, as are requests that only ask Claude to summarise an earlier session.
[!NOTE] Off by default. It is the only part of Payback that calls a model. Turn it on with
/payback settings judge on, and off again with/payback settings judge off.
When the judge is on, each finished coding task gets one small call to Claude Haiku 5.5. It labels the task's type (feature, bugfix, refactor, tests, debug, docs, devops, review) and its size. The call is sent the first 1,500 characters of your request, the names and counts of the tools it used and the number of lines changed, and it uses your own Claude account or API key. Its tokens are shown in the pane under Haiku judge. With the judge off, which is how Payback starts, sizes come from the lines changed alone and the pane's Task types row says the judge is off.
/payback import reads the session transcripts Claude Code keeps in ~/.claude/projects/ and replays them by the same rules as live tracking.
[!TIP] Nothing is saved until you press Import. The pane first shows what it found: tasks, days, sessions, kinds and the estimate.
/payback import undo.[!IMPORTANT] Claude Code deletes transcripts after 30 days by default, so that is as far back as an import can go. Raise
cleanupPeriodDaysin your settings to keep more from now on.
| Where | What you get |
|---|---|
| Desktop app, Code tab | The pane, the hint-line button and every command. This is where Payback was developed and used. |
| Terminal | The same pane in a text layout. Covered by automated tests, not yet by daily use. |
| VS Code extension | Mods draw there from extension version 2.1.299. Payback has not been tried in it. |
claude -p and the Agent SDK | Counting runs, nothing is drawn. |
| WSL sessions in the desktop app | Mods do not run there. |
| Command | What it does | ||
|---|---|---|---|
/payback | Opens the pane. There is also a Payback button in the hint line under the prompt. | ||
/payback settings | Lists the hourly rate, currency, minutes per kind of task and whether the Haiku judge is on. | ||
/payback settings rate 75 | Sets the hourly rate. | ||
/payback settings currency eur | Shows money in euro (usd for dollars). Nothing is converted: the rate is read in the chosen currency. The pane has a $ / € switch too. | ||
/payback settings <kind> 15 | Minutes saved per task of a kind: devops, emails, images, documents, research, questions, other. | ||
| `/payback settings small\ | medium\ | large 45` | Minutes saved per coding task of that size. |
| `/payback settings judge on\ | off` | Turns the Haiku judge on or off. | |
/payback import | Reads your past sessions into the report. Also /payback import 30d or /payback import 2026-09-20 2026-10-10. | ||
/payback import undo | Removes everything imported. Live data is untouched. |
A mod runs inside Claude Code with your permissions, so here is exactly what Payback touches:
~/.claude/projects, read only when you run /payback import, and only to count. Large ones are read with cmd /c type (Windows) or cat; on Windows, a path containing & | < > ^ % ! or a quote is skipped rather than handed to cmd.CLAUDE_CODE_ENTRYPOINT, CLAUDE_CONFIG_DIR, HOME, USERPROFILE and OS.What Payback sends, and where. One thing only, and only if you turn it on: the Haiku judge's request to Anthropic through your own Claude Code session (described above). The files and transcripts Payback reads are never sent anywhere.
Programs it runs. At most one, and only during /payback import: cmd /c type <file> on Windows or cat <file> elsewhere, to print a session transcript larger than 4 MB, because the file API reads at most 4 MiB at a time. It runs on files under ~/.claude/projects only, takes no other arguments, and prints to Payback and nowhere else.
What it changes. Nothing outside its own store. Its hooks watch tool calls, prompts and turns and pass them on unchanged: they never block, rewrite or approve anything, and Payback does not touch permissions, agents, settings or files. It answers its own /payback command, and it draws its pane and one button in the hint line under the prompt.
Run claude plugin validate on a clone of this repository to see for yourself: its hooks: and calls: lines list the events Payback handles and what it asks Claude Code to do.
[!NOTE] All counts stay on your machine. Out of the box nothing is sent anywhere. If you turn the Haiku judge on, its calls go to Anthropic through your own Claude Code session. Payback sees your prompts and tool calls in order to count them, and keeps none of their text. Read the privacy policy.
Bug reports, questions and ideas: open an issue. For security concerns, write to michael.selbertinger@web.de. The full privacy policy is a short read.
claude plugin validate .
claude plugin test .
The logic lives in hooks/stats.ts (counting, sorting, dates) and hooks/history.ts (the transcript replay), both free of Claude Code's runtime so the tests reach them directly. hooks/register.tsx holds the hooks, the commands and the pane.
To run your working copy in every session, add its folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, and set CLAUDE_CODE_PLUGIN_DIR_WATCH to 1 there so desktop sessions reload it when you save.
The pictures in this README are generated, with no hosted services involved, from the mod's own drawing code (hooks/dashboard.ts) and invented sample data. The generator scripts are kept outside this repository, so that everything in it is the mod itself.
Copyright (C) 2026 Michael Selbertinger.
Payback is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See LICENSE for the full text.
hooks/register.tsx 940 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Counter, Currency, Day, DevKind, ImportView, Kind, Settings, Size, Source, Tab } from '../types'
5import {
6 DEV_KINDS, JUDGE_MODEL, JUDGE_SYSTEM, KINDS, KIND_LABEL, SIZES, TITLES,
7 addDays, barCell, bigText, bySource, dayKey, isSummaryJob, isTempDir, daysBack, finishTask, fit, fmtCompact, fmtDuration, fmtMoney,
8 fmtHours, fmtInt, isEmptyDay, judgePrompt, minutesSaved, modelName, newTask, parseJudge,
9 shortDay, taskCount, tilesPerRow, toolDelta, view, withDefaults,
10} from './stats'
11import type { Task, View } from './stats'
12import { createImporter } from './history'
13import { dashboardSvg } from './dashboard'
14import { C, KIND_COLOR } from './theme'
15import type { ImportRange, ImportResult } from './history'
16
17const PANE = 'payback'
18const tab = atom({ plugin: 'payback', key: 'tab' } as const, 'today' as Tab)
19const rev = atom({ plugin: 'payback', key: 'rev' } as const, 0)
20const IDLE: ImportView = { phase: 'idle', fromDay: '', untilDay: '', done: 0, total: 0 }
21const importView = atom({ plugin: 'payback', key: 'import' } as const, IDLE)
22const sourceView = atom({ plugin: 'payback', key: 'source' } as const, 'all' as Source)
23
24// Who started this session, read at its start: the SDK's work is stored apart, a harness test not at all.
25let isFromSdk = false
26let isTestSession = false
27
28const TYPED_ORIGINS = new Set(['composer', 'sdk', 'bridge'])
29
30type Host = EngineInterface
31
32// ---------------------------------------------------------------- store
33
34const yearKey = (day: string) => `days:${day.slice(0, 4)}`
35
36async function loadSettings($: Host): Promise<Settings> {
37 return withDefaults(await $.store.get('settings'))
38}
39
40/** This year's days and last year's, so a week or a streak may reach back over January 1: live and imported added up. */
41async function loadDays($: Host, nowMs: number): Promise<Record<string, Day>> {
42 const y = new Date(nowMs).getFullYear()
43 const layers = await Promise.all([`days:${y - 1}`, `days:${y}`, `imported:${y - 1}`, `imported:${y}`].map(k => $.store.get(k)))
44 const days: Record<string, Day> = {}
45 for (const layer of layers) for (const [k, d] of Object.entries((layer ?? {}) as Record<string, Day>)) days[k] = addDays(days[k] ?? {}, d)
46 return days
47}
48
49let chain: Promise<unknown> = Promise.resolve()
50let hasStarted = false
51
52/** Adds `delta` to the stored day, read fresh so another session's writes are kept. */
53function record($: Host, day: string, delta: Day): Promise<unknown> {
54 if (isEmptyDay(delta) || isTestSession) return Promise.resolve()
55 const stored: Day = isFromSdk ? { sdk: delta } : delta
56 // ponytail: one key per year, read-modify-write; two sessions writing in the same millisecond can drop one delta.
57 const run = chain.then(async () => {
58 const year = ((await $.store.get(yearKey(day))) ?? {}) as Record<string, Day>
59 year[day] = addDays(year[day] ?? {}, stored)
60 await $.store.set(yearKey(day), year)
61 if (!hasStarted) {
62 hasStarted = true
63 if ((await $.store.get('tracked-since-ms')) === undefined) await $.store.set('tracked-since-ms', await $.clock.now())
64 }
65 await update($, rev, n => n + 1)
66 })
67 chain = run.catch(() => {})
68 return run
69}
70
71// ---------------------------------------------------------------- the task in flight
72
73type LiveTask = Task & { turnId: string; text: string }
74
75let typed: string | null = null
76let cur: LiveTask | null = null
77type Job = { day: string; size: Size; prompt: string }
78// ponytail: the judge queue lives in the module; a reload drops jobs not yet sent, and those keep the lines-based size.
79const queue: Job[] = []
80let isJudging = false
81
82async function drain($: Host): Promise<void> {
83 if (isJudging) return
84 isJudging = true
85 try {
86 for (let job = queue.shift(); job; job = queue.shift()) {
87 const n: Partial<Record<Counter, number>> = { judgeCalls: 1 }
88 const delta: Day = { n }
89 try {
90 const r = await $.model.complete({ model: JUDGE_MODEL, system: JUDGE_SYSTEM, prompt: job.prompt, maxTokens: 16, timeoutMs: 30_000 })
91 n.judgeIn = r.usage.input_tokens
92 n.judgeOut = r.usage.output_tokens
93 n.judgeCacheRead = r.usage.cache_read_input_tokens
94 n.judgeCacheWrite = r.usage.cache_creation_input_tokens
95 const verdict = r.isAnswered ? parseJudge(r.text) : null
96 if (verdict) {
97 delta.dev = { [verdict.kind]: 1 }
98 if (verdict.size !== job.size) delta.sizes = { [job.size]: -1, [verdict.size]: 1 }
99 } else n.judgeFails = 1
100 } catch {
101 n.judgeFails = 1
102 }
103 await record($, job.day, delta)
104 }
105 } finally {
106 isJudging = false
107 }
108}
109
110// ---------------------------------------------------------------- history import
111//
112// Reads Claude Code's own transcripts into a layer of its own (`imported:YYYY`), which the pane adds to the
113// live days. It reads only days before live tracking began, and an import replaces its whole range, so running
114// it again never counts twice; undo deletes the layer. Nothing is saved until the person presses Import.
115
116const ALL_HISTORY = '0000-01-01'
117const STREAM_ABOVE = 4_000_000 // $.fs.read stops at 4 MiB; bigger transcripts are streamed through the shell
118/** `cmd` reads these as commands (& | < > ^ % ! and quotes), not as part of a file name: a Windows path with one is skipped, never handed to it. */
119const CMD_SAFE_PATH = /^[\p{L}\p{N} _.:\\/()[\]@+,'~-]+$/u
120
121type Transcript = { path: string; size: number; isSubagent: boolean }
122
123let scan: { isCancelled: boolean } | null = null
124let scanned: { range: ImportRange; result: ImportResult } | null = null
125
126const setImport = ($: Host, next: ImportView) => update($, importView, () => next)
127
128/** The moment the live tracker first saved anything: the import reads only what came before it. */
129async function trackedSince($: Host, nowMs: number): Promise<number> {
130 const saved = await $.store.get('tracked-since-ms')
131 return typeof saved === 'number' ? saved : nowMs
132}
133
134/** Every transcript, main sessions and their subagents', changed on or after `sinceMs`. */
135async function listTranscripts($: Host, sinceMs: number): Promise<{ files: Transcript[]; isWindows: boolean }> {
136 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
137 const config = await $.env.get('CLAUDE_CONFIG_DIR')
138 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
139 const base = config ?? (home ? `${home}/.claude` : undefined)
140 if (!base) throw new Error('Could not find your home folder, so the transcripts could not be found.')
141 const root = `${base}/projects`
142 if (!(await $.fs.exists(root))) throw new Error(`There are no transcripts in ${root}.`)
143 const files: Transcript[] = []
144 const take = (path: string, e: { name: string; kind: string; size: number; mtimeMs: number }, isSubagent: boolean) => {
145 if (e.kind === 'file' && e.name.endsWith('.jsonl') && e.mtimeMs >= sinceMs) files.push({ path, size: e.size, isSubagent })
146 }
147 for (const project of await $.fs.list(root)) {
148 if (project.kind !== 'dir') continue
149 const dir = `${root}/${project.name}`
150 for (const e of await $.fs.list(dir).catch(() => [])) {
151 if (e.kind !== 'dir') take(`${dir}/${e.name}`, e, false)
152 else {
153 const sub = `${dir}/${e.name}/subagents`
154 for (const s of await $.fs.list(sub).catch(() => [])) take(`${sub}/${s.name}`, s, true)
155 }
156 }
157 }
158 return { files, isWindows }
159}
160
161/** The lines of one transcript: read whole when small, streamed through `type`/`cat` when large. */
162async function* transcriptLines($: Host, file: Transcript, isWindows: boolean): AsyncGenerator<string> {
163 if (file.size <= STREAM_ABOVE) {
164 yield* (await $.fs.read(file.path)).split('\n')
165 return
166 }
167 if (isWindows && !CMD_SAFE_PATH.test(file.path)) throw new Error('its path has a character cmd would read as a command')
168 const argv = isWindows ? ['cmd', '/c', 'type', file.path.replaceAll('/', '\\')] : ['cat', file.path]
169 let buffer = ''
170 for await (const chunk of $.process.spawn({ argv })) {
171 if (chunk.stream !== 'stdout') continue
172 buffer += chunk.text
173 for (let i = buffer.indexOf('\n'); i >= 0; i = buffer.indexOf('\n')) {
174 yield buffer.slice(0, i)
175 buffer = buffer.slice(i + 1)
176 }
177 }
178 if (buffer) yield buffer
179}
180
181async function runScan($: Host, range: ImportRange): Promise<void> {
182 const mine = { isCancelled: false }
183 scan = mine
184 scanned = null
185 const base: ImportView = { ...IDLE, phase: 'scanning', fromDay: range.fromDay, untilDay: range.untilDay }
186 try {
187 await setImport($, base)
188 const since = range.fromDay === ALL_HISTORY ? 0 : daysBack(dayMs(range.fromDay) ?? 0, 1)
189 const { files, isWindows } = await listTranscripts($, since)
190 const importer = createImporter(range)
191 let skipped = 0
192 let shownAt = 0
193 for (const [i, file] of files.entries()) {
194 if (mine.isCancelled) return
195 const now = await $.clock.now()
196 if (now - shownAt > 400) {
197 shownAt = now
198 await setImport($, { ...base, done: i, total: files.length })
199 }
200 const reader = importer.file(file.isSubagent)
201 try {
202 let n = 0
203 for await (const text of transcriptLines($, file, isWindows)) {
204 reader.line(text)
205 if (++n % 5000 === 0 && mine.isCancelled) return
206 }
207 reader.end()
208 } catch {
209 skipped++ // unreadable or gone mid-read: left out and counted, the rest goes on
210 }
211 }
212 const result = importer.result()
213 const split = Object.values(result.days).reduce((a, d) => addDays(a, d), {} as Day)
214 const total = bySource(split, 'all')
215 const settings = await loadSettings($)
216 scanned = { range, result }
217 await setImport($, {
218 ...base,
219 phase: 'preview',
220 done: files.length,
221 total: files.length,
222 preview: {
223 tasks: taskCount(total),
224 kinds: total.tasks ?? {},
225 minutes: minutesSaved(total, settings),
226 sessions: result.sessions,
227 sdkSessions: result.sdkSessions,
228 sdkTasks: taskCount(split.sdk ?? {}),
229 testSessions: result.testSessions,
230 skipped: skipped + result.badLines,
231 activeDays: Object.values(result.days).filter(d => taskCount(bySource(d, 'all')) > 0).length,
232 },
233 })
234 } catch (err) {
235 if (!mine.isCancelled) await setImport($, { ...base, phase: 'error', message: err instanceof Error ? err.message : 'The history could not be read.' })
236 } finally {
237 if (scan === mine) scan = null
238 }
239}
240
241/** Saves what the scan found, replacing the imported days of its range, so an import run twice counts once. */
242async function applyImport($: Host): Promise<void> {
243 const shown = await read($, importView)
244 if (!scanned) {
245 await setImport($, { ...shown, phase: 'error', message: 'The scan was lost when the mod reloaded. Run /payback import again.' })
246 return
247 }
248 const { range, result } = scanned
249 const inRange = (day: string) => day >= range.fromDay && day < range.untilDay
250 const years = new Set(Object.keys(result.days).map(d => d.slice(0, 4)))
251 for (const key of await $.store.keys()) if (key.startsWith('imported:')) years.add(key.slice(9))
252 for (const year of years) {
253 const key = `imported:${year}`
254 const kept = Object.entries(((await $.store.get(key)) ?? {}) as Record<string, Day>).filter(([d]) => !inRange(d))
255 const merged = Object.fromEntries([...kept, ...Object.entries(result.days).filter(([d]) => d.startsWith(year))])
256 if (Object.keys(merged).length > 0) await $.store.set(key, merged)
257 else await $.store.delete(key)
258 }
259 scanned = null
260 await setImport($, { ...shown, phase: 'done' })
261 await update($, rev, r => r + 1)
262}
263
264async function undoImport($: Host): Promise<string> {
265 const keys = (await $.store.keys()).filter(k => k.startsWith('imported:'))
266 for (const key of keys) await $.store.delete(key)
267 await setImport($, IDLE)
268 await update($, rev, r => r + 1)
269 return keys.length > 0 ? 'Removed the imported history. Live tracking is untouched.' : 'There was no imported history to remove.'
270}
271
272/** `YYYY-MM-DD` at local noon, or null when it is no real day. */
273function dayMs(key: string): number | null {
274 const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key)
275 if (!m) return null
276 const ms = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12).getTime()
277 return dayKey(ms) === key ? ms : null
278}
279
280const rangeText = (r: { fromDay: string; untilDay: string }) =>
281 `${r.fromDay === ALL_HISTORY ? 'all kept history' : shortDay(r.fromDay)} to ${shortDay(dayKey(daysBack(dayMs(r.untilDay) ?? 0, 1)))}`
282
283async function importCommand($: Host, args: string[]): Promise<string> {
284 const sub = args[0]?.toLowerCase()
285 if (sub === 'undo') return undoImport($)
286 if (sub === 'cancel') return cancelImport($)
287 if (scan) return 'The history is already being read; Payback shows how far it got.'
288 const now = await $.clock.now()
289 const since = await trackedSince($, now)
290 let fromDay = ALL_HISTORY
291 let untilDay = dayKey(daysBack(since, -1))
292 if (sub && /^\d+d$/.test(sub)) fromDay = dayKey(daysBack(now, Number(sub.slice(0, -1))))
293 else if (sub) {
294 const from = dayMs(sub)
295 const to = args[1] === undefined ? null : dayMs(args[1])
296 if (from === null || (args[1] !== undefined && to === null))
297 return 'Give the days as YYYY-MM-DD, for example /payback import 2026-09-20 2026-10-10, or a span such as /payback import 30d.'
298 fromDay = sub
299 if (to !== null) untilDay = [dayKey(daysBack(to, -1)), untilDay].sort()[0]!
300 }
301 if (fromDay >= untilDay) return `Nothing to import: Payback has counted everything since ${shortDay(dayKey(since))} itself.`
302 const range = { fromDay, untilDay, untilMs: since }
303 $.clock.after(0, () => void runScan($, range))
304 void openPane($)
305 return `Reading your Claude Code history, ${rangeText(range)}. Payback shows a preview when it is done; nothing is saved until you press Import.`
306}
307
308async function cancelImport($: Host): Promise<string> {
309 if (scan) scan.isCancelled = true
310 scanned = null
311 await setImport($, IDLE)
312 return 'Import cancelled. Nothing was saved.'
313}
314
315// ---------------------------------------------------------------- commands
316
317const SETTING_NAMES: Record<string, (s: Settings, v: number) => void> = {
318 rate: (s, v) => (s.rate = v),
319 emails: (s, v) => (s.minutes.emails = v),
320 documents: (s, v) => (s.minutes.documents = v),
321 images: (s, v) => (s.minutes.images = v),
322 research: (s, v) => (s.minutes.research = v),
323 devops: (s, v) => (s.minutes.devops = v),
324 questions: (s, v) => (s.minutes.questions = v),
325 other: (s, v) => (s.minutes.other = v),
326 small: (s, v) => (s.sizes.s = v),
327 medium: (s, v) => (s.sizes.m = v),
328 large: (s, v) => (s.sizes.l = v),
329}
330
331function listSettings(s: Settings): string {
332 return [
333 'Payback settings (hours and money are estimates):',
334 ` currency ${s.currency === 'eur' ? 'euro' : 'dollar'}`,
335 ` rate ${fmtMoney(s.rate, s.currency)} per hour`,
336 ` emails ${s.minutes.emails} min per task`,
337 ` documents ${s.minutes.documents} min per task`,
338 ` images ${s.minutes.images} min per task`,
339 ` research ${s.minutes.research} min per task`,
340 ` devops ${s.minutes.devops} min per task`,
341 ` questions ${s.minutes.questions} min per task`,
342 ` other ${s.minutes.other} min per task`,
343 ` small ${s.sizes.s} min per small coding task`,
344 ` medium ${s.sizes.m} min per medium coding task`,
345 ` large ${s.sizes.l} min per large coding task`,
346 ` judge ${s.judge ? 'on' : 'off'} (off by default; when on, Haiku 5.5 labels each coding task's type and size)`,
347 'Change one with /payback settings <name> <value>, for example /payback settings rate 75.',
348 ].join('\n')
349}
350
351async function settingsCommand($: Host, args: string[]): Promise<string> {
352 const s = await loadSettings($)
353 if (args.length === 0) return listSettings(s)
354 const [name, value] = [args[0]!.toLowerCase(), args[1]]
355 if (name === 'judge') {
356 if (value !== 'on' && value !== 'off') return 'Use /payback settings judge on or /payback settings judge off.'
357 s.judge = value === 'on'
358 await $.store.set('settings', s)
359 await update($, rev, r => r + 1)
360 return `The Haiku judge is ${value}.`
361 }
362 if (name === 'currency') {
363 const currency = CURRENCY_NAMES[(value ?? '').toLowerCase()]
364 if (!currency) return 'Use /payback settings currency eur or /payback settings currency usd.'
365 await setCurrency($, currency)
366 return `Money is now shown in ${currency === 'eur' ? 'euro' : 'dollars'}. The hourly rate stays ${fmtMoney(s.rate, currency)}; nothing is converted.`
367 }
368 const set = SETTING_NAMES[name]
369 if (!set) return `There is no setting called "${name}". The settings are: ${[...Object.keys(SETTING_NAMES), 'currency', 'judge'].join(', ')}.`
370 const n = Number(value)
371 if (!Number.isFinite(n) || n <= 0 || n > 100_000) return `Give ${name} a number above 0, for example /payback settings ${name} ${name === 'rate' ? 75 : 15}.`
372 set(s, n)
373 await $.store.set('settings', s)
374 await update($, rev, r => r + 1)
375 return name === 'rate' ? `Hourly rate is now ${fmtMoney(n, s.currency)}.` : `${name} is now ${n} minutes per task.`
376}
377
378const CURRENCY_NAMES: Record<string, Currency> = { eur: 'eur', euro: 'eur', euros: 'eur', '€': 'eur', usd: 'usd', dollar: 'usd', dollars: 'usd', '$': 'usd' }
379
380/** Saves the currency read fresh, so a change made elsewhere meanwhile is kept, and redraws the pane. */
381async function setCurrency($: Host, currency: Currency) {
382 const s = await loadSettings($)
383 await $.store.set('settings', { ...s, currency })
384 await update($, rev, r => r + 1)
385}
386
387const openPane = ($: Host) => $.ui.open({ id: PANE, title: 'Payback' }).catch(() => undefined)
388
389// ---------------------------------------------------------------- drawing
390//
391// Every Text is `mono`: the desktop then measures in the same cells as `bodyColumns`, as the terminal always does.
392// Rows hold fixed-width boxes that never shrink, so a long value is cut by us, never squeezed by the layout.
393
394type Els = ReturnType<EngineInterface['ui']['resolve']>
395type Seg = [string, string?, boolean?]
396// `mono` and the type scale are refused on the terminal (always monospaced there), so they ride along only elsewhere.
397type Ctx = { ui: Els; isTerminal: boolean; mono: { mono?: true } }
398
399const len = (text: string) => [...text].length
400
401/** The segments cut so together they fill at most `width` cells. */
402function cut(segs: Seg[], width: number): Seg[] {
403 let room = width
404 return segs
405 .map(([text, color, bold]): Seg => {
406 const t = len(text) <= room ? text : fit(text, Math.max(0, room))
407 room -= len(t)
408 return [t, color, bold]
409 })
410 .filter(([text]) => text !== '')
411}
412
413/** One Text holding coloured parts, so the parts are laid out as one run of text. */
414function spans(c: Ctx, key: string, segs: Seg[]) {
415 const { Text } = c.ui
416 return (
417 <Text key={key} {...c.mono} wrap="truncate-end">
418 {segs.map(([text, color, bold], i) => (
419 <Text key={`${key}-${i}`} {...c.mono} color={color ?? C.text} bold={bold}>{text}</Text>
420 ))}
421 </Text>
422 )
423}
424
425/** A row exactly `width` cells wide. */
426function line(c: Ctx, key: string, width: number, segs: Seg[]) {
427 const { Box } = c.ui
428 return (
429 <Box key={key} width={width} flexDirection="row" overflow="hidden">
430 {spans(c, `${key}-t`, cut(segs, width))}
431 </Box>
432 )
433}
434
435/** A fixed cell of `width` that never shrinks, its text cut to fit, left or right aligned. */
436function cell(c: Ctx, key: string, width: number, segs: Seg[], isRight = false) {
437 const { Box } = c.ui
438 return (
439 <Box key={key} width={width} flexShrink={0} flexDirection="row" justifyContent={isRight ? 'flex-end' : 'flex-start'} overflow="hidden">
440 {spans(c, `${key}-t`, cut(segs, width))}
441 </Box>
442 )
443}
444
445/** A section heading in small muted capitals, then a hairline to the edge. */
446function heading(c: Ctx, key: string, width: number, title: string) {
447 const { Box } = c.ui
448 const label = fit(title.toUpperCase(), width)
449 return (
450 <Box key={key} width={width} flexDirection="row" overflow="hidden" marginTop={1}>
451 {cell(c, `${key}-l`, len(label), [[label, C.muted, true]])}
452 {width - len(label) > 1 && cell(c, `${key}-r`, width - len(label), [[' ' + '─'.repeat(width - len(label) - 1), C.hair]])}
453 </Box>
454 )
455}
456
457/** A label, a bar of coloured cells for `value` against `max`, and the numbers on the right. */
458function barRow(c: Ctx, key: string, width: number, label: string, color: string, value: number, max: number, right: string, hasBar: boolean) {
459 const { Box } = c.ui
460 const labelW = width >= 56 ? 21 : Math.min(16, Math.max(8, Math.floor(width * 0.32)))
461 const rightW = Math.min(len(right), Math.max(0, width - labelW))
462 const barW = width - labelW - rightW - 2
463 const fill = value > 0 ? Math.max(1, Math.round((value / Math.max(1, max)) * barW)) : 0
464 return (
465 <Box key={key} width={width} flexDirection="row" overflow="hidden">
466 {cell(c, `${key}-l`, labelW, [[label, C.text]])}
467 {hasBar && barW >= 4
468 ? cell(c, `${key}-b`, barW + 2, [[' '], ['█'.repeat(fill), color], ['█'.repeat(barW - fill), C.hair], [' ']])
469 : cell(c, `${key}-b`, Math.max(0, width - labelW - rightW), [['']])}
470 {cell(c, `${key}-r`, rightW, [[right, C.text]], true)}
471 </Box>
472 )
473}
474
475/** A muted label column, then the value. */
476function kv(c: Ctx, key: string, width: number, label: string, value: Seg[]) {
477 const { Box } = c.ui
478 const labelW = width >= 36 ? 11 : 9
479 return (
480 <Box key={key} width={width} flexDirection="row" overflow="hidden">
481 {cell(c, `${key}-l`, labelW, [[label, C.muted]])}
482 {cell(c, `${key}-v`, width - labelW, value)}
483 </Box>
484 )
485}
486
487/** A rounded card with a muted title; `inner` is the width its rows draw in. */
488function card(c: Ctx, key: string, width: number, title: string, rows: (inner: number) => RenderChildren[]) {
489 const { Box } = c.ui
490 const inner = Math.max(8, width - 4)
491 return (
492 <Box key={key} width={width} flexDirection="column" borderStyle="round" borderColor={C.hair} paddingX={1}>
493 {line(c, `${key}-title`, inner, [[title.toUpperCase(), C.muted, true]])}
494 {rows(inner)}
495 </Box>
496 )
497}
498
499function tile(c: Ctx, key: string, width: number, label: string, digits: string, shown: string, foot: string) {
500 const { Box, Text } = c.ui
501 const inner = Math.max(1, width - 4)
502 const big = c.isTerminal ? bigText(digits, inner) : null
503 return (
504 <Box key={key} width={width} flexDirection="column" borderStyle="round" borderColor={C.hair} paddingX={1}>
505 {line(c, `${key}-label`, inner, [[label, C.muted]])}
506 {big
507 ? big.map((row, i) => line(c, `${key}-big-${i}`, inner, [[row, C.text]]))
508 : <Text key={`${key}-value`} {...c.mono} {...(c.isTerminal ? {} : { size: 'title' as const, weight: 'bold' as const })} bold color={C.text} wrap="truncate-end">{fit(shown, inner)}</Text>}
509 {line(c, `${key}-foot`, inner, [[foot, C.muted]])}
510 </Box>
511 )
512}
513
514/** The chart: on the terminal three rows of eighth blocks, elsewhere one SVG (text rows there leave gaps). */
515function chart(c: Ctx, width: number, v: View) {
516 const { Box, Text } = c.ui
517 const all = v.chart
518 const els = c.ui
519 if (!c.isTerminal && 'Svg' in els) {
520 const { Svg } = els
521 return [<Svg key="chart" source={chartSvg(all, width)} alt={`Tasks: ${all.map(b => `${b.label} ${b.isFuture ? '-' : b.value}`).join(', ')}`} height={132} />]
522 }
523 const gap = all.length < 30 || width >= all.length * 2 - 1 ? 1 : 0
524 const cap = all.length === 7 ? 6 : all.length === 12 ? 4 : 1
525 const bw = Math.max(1, Math.min(cap, Math.floor((width - gap * (all.length - 1)) / all.length)))
526 const shown = all.slice(-Math.max(1, Math.floor((width + gap) / (bw + gap))))
527 const max = Math.max(...shown.map(b => b.value))
528 const sep = (i: number) => (i < shown.length - 1 ? ' '.repeat(gap) : '')
529 const rows = [0, 1, 2].map(row =>
530 line(c, `chart-${row}`, width, shown.map((b, i): Seg => [(b.isFuture ? ' ' : barCell(b.value, max, row)).repeat(bw) + sep(i), b.isFuture || b.value === 0 ? C.hair : b.isCurrent ? C.gold : C.text])),
531 )
532 const labels =
533 shown.length === 30 || shown.length < all.length
534 ? (
535 <Box key="chart-labels" width={width} flexDirection="row" justifyContent="space-between">
536 {cell(c, 'cl-a', len(`${shown.length} days ago`), [[`${shown.length} days ago`, C.muted]])}
537 {cell(c, 'cl-b', 5, [['today', C.gold]])}
538 </Box>
539 )
540 : line(c, 'chart-labels', width, shown.map((b, i): Seg => [fit(b.label, bw).padEnd(bw) + sep(i), b.isCurrent ? C.gold : C.muted]))
541 return [...rows, labels]
542}
543
544function chartSvg(buckets: View['chart'], width: number): string {
545 const vw = Math.max(240, width * 8)
546 const vh = 132
547 const base = vh - 22
548 const slot = vw / buckets.length
549 const barW = Math.min(slot * 0.62, 40)
550 const max = Math.max(1, ...buckets.map(b => b.value))
551 const every = buckets.length > 12 ? 5 : 1
552 const parts = buckets.map((b, i) => {
553 const x = i * slot + (slot - barW) / 2
554 const cx = i * slot + slot / 2
555 const fill = b.isCurrent ? C.gold : C.text
556 const barH = b.isFuture ? 0 : b.value > 0 ? Math.max(3, (b.value / max) * (base - 18)) : 2
557 const bar = b.isFuture ? '' : `<rect x="${x.toFixed(1)}" y="${(base - barH).toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" rx="3" fill="${b.value > 0 ? fill : C.hair}"/>`
558 const count = b.value > 0 && slot >= 16 ? `<text x="${cx.toFixed(1)}" y="${(base - barH - 5).toFixed(1)}" text-anchor="middle" font-size="11" fill="${C.muted}">${b.value}</text>` : ''
559 const showLabel = (buckets.length - 1 - i) % every === 0
560 const label = showLabel ? `<text x="${cx.toFixed(1)}" y="${vh - 5}" text-anchor="middle" font-size="11" fill="${b.isCurrent ? C.gold : C.muted}">${b.label}</text>` : ''
561 return bar + count + label
562 })
563 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${vw} ${vh}" width="${vw}" height="${vh}" font-family="ui-monospace, Menlo, Consolas, monospace"><line x1="0" y1="${base + 0.5}" x2="${vw}" y2="${base + 0.5}" stroke="${C.hair}"/>${parts.join('')}</svg>`
564}
565
566const EMPTY: Record<Tab, string> = {
567 today: 'Nothing finished yet today.',
568 week: 'Nothing finished in the last 7 days.',
569 month: 'Nothing finished in the last 30 days.',
570 year: 'Nothing finished yet this year.',
571}
572
573const plural = (x: number, one: string, many = `${one}s`) => `${fmtInt(x)} ${x === 1 ? one : many}`
574
575/** Columns from which the desktop draws the body as a picture, sized to the pane; below it the text layout is kinder. */
576const WIDE = 62
577
578type ImportActions = { importNow: () => void; cancel: () => void; close: () => void }
579type Who = { source: Source; you: number; sdk: number; hasSdk: boolean; set: (s: Source) => void }
580
581function importCard(c: Ctx, width: number, imp: ImportView, s: Settings, act: ImportActions) {
582 const { Box, Button } = c.ui
583 const buttons = (main: { label: string; onPress: () => void } | null, other: { key: string; label: string; onPress: () => void }) => (
584 <Box key="imp-buttons" flexDirection="row" columnGap={2} marginTop={1}>
585 {main && <Button key="imp-go" label={main.label} variant="primary" onPress={main.onPress} />}
586 <Button key={other.key} label={other.label} plain dimColor onPress={other.onPress} />
587 </Box>
588 )
589 return card(c, 'card-import', width, 'Import history', inner => {
590 const rows: RenderChildren[] = []
591 const range = rangeText(imp)
592 if (imp.phase === 'scanning') {
593 const fill = imp.total > 0 ? Math.round((imp.done / imp.total) * inner) : 0
594 rows.push(line(c, 'imp-status', inner, [[imp.total > 0 ? `Reading transcripts: ${fmtInt(imp.done)} of ${fmtInt(imp.total)}` : 'Finding transcripts…'], [` · ${range}`, C.muted]]))
595 rows.push(line(c, 'imp-bar', inner, [['█'.repeat(fill), C.gold], ['█'.repeat(inner - fill), C.hair]]))
596 rows.push(buttons(null, { key: 'imp-cancel', label: 'Cancel', onPress: act.cancel }))
597 } else if (imp.phase === 'preview' && imp.preview) {
598 const p = imp.preview
599 rows.push(line(c, 'imp-found', inner, [[`Found ${plural(p.tasks, 'task')} on ${plural(p.activeDays, 'day')} in ${plural(p.sessions + p.sdkSessions, 'session')}`, C.text, true]]))
600 if (p.sdkTasks > 0) rows.push(line(c, 'imp-split', inner, [[`Started by you ${fmtInt(p.tasks - p.sdkTasks)} · SDK ${fmtInt(p.sdkTasks)}`]]))
601 rows.push(line(c, 'imp-range', inner, [[range, C.muted]]))
602 const kinds = KINDS.filter(k => (p.kinds[k] ?? 0) > 0).map(k => `${KIND_LABEL[k]} ${fmtInt(p.kinds[k] ?? 0)}`).join(' · ')
603 if (kinds) rows.push(line(c, 'imp-kinds', inner, [[kinds]]))
604 if (p.tasks > 0) rows.push(line(c, 'imp-worth', inner, [[`≈ ${fmtHours(p.minutes)} h · ${fmtMoney((p.minutes / 60) * s.rate, s.currency)}`, C.green], [' estimate', C.muted]]))
605 rows.push(line(c, 'imp-note', inner, [['Coding tasks are sized by lines changed, not judged by Haiku.', C.muted]]))
606 if (p.testSessions > 0) rows.push(line(c, 'imp-tests', inner, [[`Left out: ${plural(p.testSessions, 'SDK test session')} run from a temp folder.`, C.muted]]))
607 if (p.skipped > 0) rows.push(line(c, 'imp-skipped', inner, [[`${plural(p.skipped, 'unreadable file or line', 'unreadable files or lines')} skipped.`, C.amber]]))
608 rows.push(
609 p.tasks > 0
610 ? buttons({ label: 'Import', onPress: act.importNow }, { key: 'imp-cancel', label: 'Cancel', onPress: act.cancel })
611 : buttons(null, { key: 'imp-close', label: 'Close', onPress: act.close }),
612 )
613 } else if (imp.phase === 'done') {
614 rows.push(line(c, 'imp-done', inner, [[`Imported ${plural(imp.preview?.tasks ?? 0, 'task')}, ${range}.`, C.green]]))
615 rows.push(line(c, 'imp-undo', inner, [['Undo it any time with /payback import undo.', C.muted]]))
616 rows.push(buttons(null, { key: 'imp-close', label: 'Close', onPress: act.close }))
617 } else {
618 rows.push(line(c, 'imp-error', inner, [[imp.message ?? 'The history could not be read.', C.amber]]))
619 rows.push(buttons(null, { key: 'imp-close', label: 'Close', onPress: act.close }))
620 }
621 return rows
622 })
623}
624
625function drawPane(ui: Els, isTerminal: boolean, v: View, s: Settings, width: number, setTab: (t: Tab) => void, setMoney: (c: Currency) => void, imp: ImportView, act: ImportActions, who: Who) {
626 const { Box, Text, Button } = ui
627 const c: Ctx = { ui, isTerminal, mono: isTerminal ? {} : { mono: true } }
628 const W = Math.max(16, width)
629 const d = v.total
630 const n = (k: Counter) => d.n?.[k] ?? 0
631 const out: RenderChildren[] = []
632
633 // Title bar: the stretch on the left, its dates on the right, a gold rule under the title.
634 const dates = fit(v.dates, Math.max(0, W - v.title.length - 1))
635 out.push(
636 <Box key="title" width={W} flexDirection="row" justifyContent="space-between">
637 {cell(c, 'title-l', v.title.length, [[v.title, C.text, true]])}
638 {cell(c, 'title-r', len(dates), [[dates, C.muted]])}
639 </Box>,
640 )
641 out.push(
642 <Box key="rule" width={W} flexDirection="row">
643 {cell(c, 'rule-gold', v.title.length, [['━'.repeat(v.title.length), C.gold]])}
644 {cell(c, 'rule-hair', W - v.title.length, [['─'.repeat(W - v.title.length), C.hair]])}
645 </Box>,
646 )
647
648 // Tabs on the left, the currency on the right: the chosen chip gold with dark text, the others slate.
649 const chip = (key: string, label: string, isOn: boolean, onPress: () => void) =>
650 isOn ? (
651 <Text key={`${key}-on`} {...c.mono} backgroundColor={C.gold} color={C.dark} bold>{` ${label} `}</Text>
652 ) : (
653 <Box key={`${key}-chip`} backgroundColor={C.hair} paddingX={1}>
654 <Button key={key} label={label} plain onPress={onPress} />
655 </Box>
656 )
657 const tabs: Tab[] = ['today', 'week', 'month', 'year']
658 const isTight = W < 40
659 out.push(
660 <Box key="tabs" width={W} flexDirection="row" justifyContent="space-between" marginTop={1}>
661 <Box key="tabs-left" flexDirection="row" columnGap={1}>
662 {tabs.map(t => chip(`tab-${t}`, isTight ? TITLES[t][0]! : TITLES[t][0] + TITLES[t].slice(1).toLowerCase(), t === v.tab, () => setTab(t)))}
663 </Box>
664 <Box key="tabs-money" flexDirection="row" columnGap={1}>
665 {chip('cur-usd', '$', s.currency === 'usd', () => setMoney('usd'))}
666 {chip('cur-eur', '€', s.currency === 'eur', () => setMoney('eur'))}
667 </Box>
668 </Box>,
669 )
670
671 // Who started the work, and a filter on it: shown once the SDK has done any work in this stretch.
672 if (who.hasSdk || who.source !== 'all') {
673 const lead = W >= 60 ? 'Started by' : ''
674 const gaps = lead ? 4 : 3
675 const counts = `You ${fmtInt(who.you)} · SDK ${fmtInt(who.sdk)}`
676 out.push(
677 <Box key="sources" width={W} flexDirection="row" columnGap={1} marginTop={1}>
678 {lead && cell(c, 'src-lead', len(lead), [[lead, C.muted]])}
679 {chip('src-all', 'All', who.source === 'all', () => who.set('all'))}
680 {chip('src-you', 'You', who.source === 'you', () => who.set('you'))}
681 {chip('src-sdk', 'SDK', who.source === 'sdk', () => who.set('sdk'))}
682 {cell(c, 'src-counts', Math.max(0, W - len(lead) - 15 - gaps), [[counts, C.muted]])}
683 </Box>,
684 )
685 }
686
687 if (imp.phase !== 'idle') {
688 out.push(<Box key="gap-import" height={1} />)
689 out.push(importCard(c, W, imp, s, act))
690 }
691
692 if (taskCount(d) === 0 && Object.keys(d.n ?? {}).length === 0) {
693 out.push(<Box key="gap-empty" height={1} />)
694 out.push(line(c, 'empty', W, [[EMPTY[v.tab], C.text, true]]))
695 out.push(line(c, 'empty-hint', W, [['Your first finished task shows up here.', C.muted]]))
696 return <Box key="report" flexDirection="column" width={W}>{out}</Box>
697 }
698
699 // A wide desktop pane draws everything below as one picture; the terminal and narrow panes keep the text layout below.
700 if (!isTerminal && W >= WIDE && 'Svg' in ui) {
701 const { source, alt } = dashboardSvg(v, s, W)
702 const { Svg } = ui
703 out.push(<Box key="gap-dashboard" height={1} />, <Svg key="dashboard" source={source} alt={alt} />)
704 return <Box key="report" flexDirection="column" width={W}>{out}</Box>
705 }
706
707 // Tiles: three, then two, then one per row as the pane narrows.
708 const minutes = minutesSaved(d, s)
709 const hours = fmtHours(minutes)
710 const amount = (minutes / 60) * s.rate
711 const worth = s.currency === 'eur' ? Math.round(amount).toLocaleString('de-DE') : fmtInt(amount)
712 const tiles = [
713 ['tile-hours', 'HOURS SAVED', hours, `${hours} h`, 'estimate'],
714 ['tile-tasks', 'TASKS DONE', fmtInt(taskCount(d)), fmtInt(taskCount(d)), `${plural(v.activeDays, 'active day')}`],
715 ['tile-worth', s.currency === 'eur' ? 'WORTH IN EUROS' : "DOLLARS' WORTH", worth, fmtMoney(amount, s.currency), `estimate at ${fmtMoney(s.rate, s.currency)}/h`],
716 ] as const
717 const per = tilesPerRow(W)
718 const tileW = Math.floor((W - (per - 1)) / per)
719 for (let r = 0; r * per < tiles.length; r++)
720 out.push(
721 <Box key={`tilerow-${r}`} width={W} flexDirection="row" columnGap={1} marginTop={r === 0 ? 1 : 0}>
722 {tiles.slice(r * per, r * per + per).map(([key, label, digits, shown, foot]) => tile(c, key, tileW, label, digits, shown, foot))}
723 </Box>,
724 )
725
726 // Activity: the chart and its one summary line.
727 out.push(heading(c, 'h-chart', W, v.tab === 'year' ? 'Tasks by month' : 'Tasks by day'))
728 out.push(...chart(c, W, v))
729 out.push(line(c, 'summary', W, [[v.summary, C.green]]))
730
731 // Cards: where the time went and models on the left, code on the right; stacked when narrow.
732 const isTwo = W >= 92
733 const leftW = isTwo ? Math.floor((W - 1) / 2) : W
734 const rightW = isTwo ? W - 1 - leftW : W
735
736 const kindMinutes = (k: Kind) =>
737 k === 'coding' ? SIZES.reduce((a, z) => a + (d.sizes?.[z] ?? 0) * s.sizes[z], 0) : (d.tasks?.[k] ?? 0) * s.minutes[k]
738 const kinds = KINDS.filter(k => (d.tasks?.[k] ?? 0) > 0)
739 const maxKind = Math.max(0, ...kinds.map(k => d.tasks?.[k] ?? 0))
740 const timeCard = card(c, 'card-time', leftW, 'Where the time went', inner =>
741 kinds.length === 0
742 ? [line(c, 'kinds-none', inner, [['No finished tasks in this stretch.', C.muted]])]
743 : kinds.map(k => {
744 const count = d.tasks?.[k] ?? 0
745 return barRow(c, `kind-${k}`, inner, KIND_LABEL[k], KIND_COLOR[k], count, maxKind, `${plural(count, 'task')} · ${fmtHours(kindMinutes(k))} h`, kinds.length > 1)
746 }),
747 )
748
749 const models = [...new Set([...Object.keys(d.models ?? {}), ...Object.keys(d.tokens ?? {})])].sort(
750 (a, b) => (d.models?.[b] ?? 0) - (d.models?.[a] ?? 0) || (d.tokens?.[b]?.out ?? 0) - (d.tokens?.[a]?.out ?? 0),
751 )
752 const modelCard = card(c, 'card-models', leftW, 'Models and tokens', inner => {
753 const wide = inner >= 44
754 const colW = inner >= 56 ? 9 : 7
755 const cols = wide ? ['Tasks', 'In', 'Out', 'Cached'] : ['Tasks', 'Out']
756 const nameW = inner - cols.length * colW
757 const row = (key: string, name: string, vals: string[], color = C.text) => (
758 <Box key={key} width={inner} flexDirection="row" overflow="hidden">
759 {cell(c, `${key}-n`, nameW, [[name, color]])}
760 {vals.map((val, i) => cell(c, `${key}-${i}`, colW, [[val, color]], true))}
761 </Box>
762 )
763 const rows: RenderChildren[] = [row('models-head', '', cols, C.muted)]
764 for (const id of models) {
765 const t = d.tokens?.[id] ?? {}
766 const input = (t.in ?? 0) + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0)
767 const cached = input > 0 ? `${Math.round(((t.cacheRead ?? 0) / input) * 100)}%` : '-'
768 const tasks = String(d.models?.[id] ?? 0)
769 rows.push(row(`model-${id}`, modelName(id), wide ? [tasks, fmtCompact(input), fmtCompact(t.out ?? 0), cached] : [tasks, fmtCompact(t.out ?? 0)]))
770 }
771 if (n('judgeCalls') > 0) {
772 const input = n('judgeIn') + n('judgeCacheRead') + n('judgeCacheWrite')
773 const calls = plural(n('judgeCalls'), 'call')
774 rows.push(row('tok-judge', 'Haiku judge', wide ? [calls.split(' ')[0]!, fmtCompact(input), fmtCompact(n('judgeOut')), '-'] : [calls.split(' ')[0]!, fmtCompact(n('judgeOut'))], C.muted))
775 if (n('judgeFails') > 0) rows.push(line(c, 'tok-judge-fails', inner, [[`${plural(n('judgeFails'), 'judge call')} failed`, C.amber]]))
776 }
777 if (models.length === 0 && n('judgeCalls') === 0) return [line(c, 'models-none', inner, [['No model use in this stretch.', C.muted]])]
778 return rows
779 })
780
781 const hasCode = (d.tasks?.coding ?? 0) > 0 || Object.keys(d.n ?? {}).some(k => !k.startsWith('judge'))
782 const codeCard = card(c, 'card-code', rightW, 'Code', inner => {
783 const rows: RenderChildren[] = []
784 if (n('linesAdded') + n('linesRemoved') > 0 || n('files') > 0)
785 rows.push(kv(c, 'code-lines', inner, 'Lines', [[`+${fmtInt(n('linesAdded'))}`, C.green], [' '], [`−${fmtInt(n('linesRemoved'))}`, C.muted], [` · ${plural(n('files'), 'file')}`, C.muted]]))
786 const langs = Object.entries(d.langs ?? {}).sort((a, b) => b[1] - a[1])
787 const langTotal = langs.reduce((a, [, x]) => a + x, 0)
788 if (langTotal > 0) rows.push(kv(c, 'code-langs', inner, 'Languages', [[langs.slice(0, 3).map(([l, x]) => `${l} ${Math.round((x / langTotal) * 100)}%`).join(' · ')]]))
789 const items = (pairs: [number, string, string?][]) => pairs.filter(([x]) => x > 0).map(([x, one, many]) => plural(x, one, many))
790 const wrapped = (key: string, label: string, parts: string[]) => {
791 const room = Math.max(10, inner - (inner >= 36 ? 11 : 9))
792 const lines: string[] = []
793 for (const part of parts) {
794 const last = lines[lines.length - 1]
795 if (last !== undefined && last.length + 3 + part.length <= room) lines[lines.length - 1] = `${last} · ${part}`
796 else lines.push(part)
797 }
798 lines.forEach((text, i) => rows.push(kv(c, `${key}-${i}`, inner, i === 0 ? label : '', [[text]])))
799 }
800 wrapped('code-git', 'Git', items([[n('commits'), 'commit'], [n('pushes'), 'push', 'pushes'], [n('merges'), 'merge'], [n('rebases'), 'rebase'], [n('branches'), 'branch', 'branches']]))
801 wrapped('code-github', 'GitHub', items([[n('prsOpened'), 'PR opened', 'PRs opened'], [n('prsMerged'), 'PR merged', 'PRs merged'], [n('issuesOpened'), 'issue opened', 'issues opened'], [n('issuesClosed'), 'issue closed', 'issues closed']]))
802 const runs = n('testRuns')
803 if (runs > 0) {
804 const pass = Math.round(((runs - n('testFails')) / runs) * 100)
805 rows.push(kv(c, 'code-tests', inner, 'Tests', [[`${plural(runs, 'run')} · `], [`${pass}% pass`, pass >= 90 ? C.green : C.amber], [` · ${fmtDuration(n('testMs'))}`, C.muted]]))
806 if (n('testsPassed') + n('testsFailed') > 0)
807 rows.push(kv(c, 'code-results', inner, 'Results', [[`${fmtInt(n('testsPassed'))} passed`, C.green], [' '], [`${fmtInt(n('testsFailed'))} failed`, n('testsFailed') > 0 ? C.amber : C.muted]]))
808 }
809 if (n('builds') > 0) rows.push(kv(c, 'code-builds', inner, 'Builds', [[`${plural(n('builds'), 'run')} · `], [`${fmtInt(n('buildFails'))} failed`, n('buildFails') > 0 ? C.amber : C.muted]]))
810 if ((d.tasks?.coding ?? 0) > 0) {
811 const types = DEV_KINDS.filter(k => (d.dev?.[k] ?? 0) > 0).sort((a, b) => (d.dev?.[b] ?? 0) - (d.dev?.[a] ?? 0))
812 const typeText = types.map(k => `${k[0]!.toUpperCase()}${k.slice(1)} ${d.dev?.[k]}`).join(' · ')
813 rows.push(kv(c, 'code-types', inner, 'Task types', [[typeText || (s.judge ? 'not judged yet' : 'judge is off (/payback settings judge on)'), typeText ? C.text : C.muted]]))
814 rows.push(kv(c, 'code-sizes', inner, 'Sizes', [[SIZES.map(z => `${z.toUpperCase()} ${d.sizes?.[z] ?? 0}`).join(' · '), C.muted]]))
815 }
816 return rows.length > 0 ? rows : [line(c, 'code-none', inner, [['No code work in this stretch.', C.muted]])]
817 })
818
819 out.push(<Box key="gap-cards" height={1} />)
820 if (isTwo)
821 out.push(
822 <Box key="grid" width={W} flexDirection="row" columnGap={1}>
823 <Box key="col-left" width={leftW} flexDirection="column" rowGap={1}>{[timeCard, modelCard]}</Box>
824 <Box key="col-right" width={rightW} flexDirection="column">{hasCode ? [codeCard] : []}</Box>
825 </Box>,
826 )
827 else out.push(<Box key="stack" width={W} flexDirection="column" rowGap={1}>{[timeCard, ...(hasCode ? [codeCard] : []), modelCard]}</Box>)
828
829 out.push(line(c, 'foot', W, [['Hours and money are estimates. /payback settings changes them.', C.muted]]))
830 return <Box key="report" flexDirection="column" width={W}>{out}</Box>
831}
832
833// ---------------------------------------------------------------- hooks
834
835export const register: Register = on => {
836 on('session.start', async ($, e, next) => {
837 try {
838 isFromSdk = ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? '').startsWith('sdk')
839 isTestSession = isFromSdk && isTempDir(e.cwd)
840 await $.command.register({
841 name: 'payback',
842 description: 'Payback: what Claude did for you (tasks, code, git, tests, tokens) and the time and money it saved',
843 argumentHint: '[settings [name value] | import [30d | from to | undo | cancel]]',
844 })
845 } catch {}
846 return next(e)
847 })
848
849 on('command.run', { command: 'payback' }, async ($, e) => {
850 const args = e.args.trim().split(/\s+/).filter(Boolean)
851 if (args[0]?.toLowerCase() === 'settings') return { text: await settingsCommand($, args.slice(1)) }
852 if (args[0]?.toLowerCase() === 'import') return { text: await importCommand($, args.slice(1)) }
853 void openPane($)
854 return { text: 'Opened Payback. Hours and money in it are estimates.' }
855 }).catch(() => ({ text: 'Payback could not run that command. Try /payback again.' }))
856
857 on('prompt.submit', ($, e, next) => {
858 if (!e.command && !e.isMeta && !e.turnId && TYPED_ORIGINS.has(e.origin.kind) && !isSummaryJob(e.text)) typed = e.text
859 return next(e)
860 }).catch(($, e, next) => next(e))
861
862 on('turn.start', ($, e, next) => {
863 if (!e.agentId && typed !== null) {
864 cur = { ...newTask(), turnId: e.turnId, text: typed }
865 typed = null
866 }
867 return next(e)
868 }).catch(($, e, next) => next(e))
869
870 on('tool.call', async ($, e, next) => {
871 const startedAt = await $.clock.now()
872 const ran = await next(e)
873 try {
874 if (ran.deny !== undefined) return ran
875 const ms = (await $.clock.now()) - startedAt
876 const run = { tool: String(e.tool), input: e as unknown as Record<string, unknown>, result: ran.result, isOk: ran.isError !== true, output: ran.text ?? '', ms }
877 const delta = toolDelta(run, cur)
878 await record($, dayKey(await $.clock.now()), delta)
879 } catch {}
880 return ran
881 }).catch(($, e, next) => next(e))
882
883 on('turn.complete', async ($, e, next) => {
884 try {
885 const day = dayKey(await $.clock.now())
886 const delta: Day = {}
887 if (e.usage)
888 delta.tokens = {
889 [e.usage.model]: { in: e.usage.input_tokens, out: e.usage.output_tokens, cacheRead: e.usage.cache_read_input_tokens, cacheWrite: e.usage.cache_creation_input_tokens },
890 }
891 const task = cur
892 if (!e.agentId && task && task.turnId === e.turnId) {
893 cur = null
894 if (e.reason === 'answer') {
895 const done = finishTask(task, e.usage?.model ?? 'unknown')
896 Object.assign(delta, done.delta)
897 const size = done.size
898 if (size) {
899 if ((await loadSettings($)).judge) {
900 queue.push({ day, size, prompt: judgePrompt(task.text, task, task.files.size) })
901 $.clock.after(50, () => void drain($))
902 }
903 }
904 }
905 }
906 await record($, day, delta)
907 } catch {}
908 return next(e)
909 })
910
911 // The status bar button: shown in the hint line under the prompt while it is idle.
912 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
913 if (e.props.isDraft || e.props.isWorking) return next(e)
914 const { Box, Text, Button } = $.ui.resolve(e)
915 return (
916 <Box key="hint" flexDirection="row">
917 <Text dimColor wrap="truncate-end">{e.props.hint ? `${e.props.hint} ` : ''}</Text>
918 <Button key="open-payback" label="Payback" plain onPress={() => void openPane($)} />
919 </Box>
920 )
921 })
922
923 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
924 const t = await read($, tab)
925 const imp = await read($, importView)
926 const source = await read($, sourceView)
927 await read($, rev)
928 const now = await $.clock.now()
929 const [settings, days] = await Promise.all([loadSettings($), loadDays($, now)])
930 const both = view(t, now, days).total
931 const shown = Object.fromEntries(Object.entries(days).map(([k, d]) => [k, bySource(d, source)]))
932 const who: Who = { source, you: taskCount(both), sdk: taskCount(both.sdk ?? {}), hasSdk: !isEmptyDay(both.sdk ?? {}), set: s => void update($, sourceView, () => s) }
933 return drawPane($.ui.resolve(e), e.surface === 'terminal', view(t, now, shown), settings, e.props.bodyColumns, next => void update($, tab, () => next), money => void setCurrency($, money), imp, {
934 importNow: () => void applyImport($),
935 cancel: () => void cancelImport($),
936 close: () => void setImport($, IDLE),
937 }, who)
938 })
939}
940hooks/stats.ts 529 lines1// Pure logic of Payback: no `$`, so the tests reach it directly.
2import type { Counter, Currency, Day, DevKind, Kind, Settings, Size, Source, Tab } from '../types'
3
4export const KINDS: Kind[] = ['coding', 'devops', 'emails', 'images', 'documents', 'research', 'questions', 'other']
5export const KIND_LABEL: Record<Kind, string> = {
6 coding: 'Coding',
7 devops: 'DevOps & shell',
8 emails: 'Emails',
9 images: 'Images and video',
10 documents: 'Documents',
11 research: 'Research',
12 questions: 'Questions & planning',
13 other: 'Other',
14}
15export const DEV_KINDS: DevKind[] = ['feature', 'bugfix', 'refactor', 'tests', 'debug', 'docs', 'devops', 'review']
16export const SIZES: Size[] = ['s', 'm', 'l']
17
18export const DEFAULTS: Settings = {
19 rate: 50,
20 currency: 'usd',
21 minutes: { devops: 20, emails: 10, documents: 30, images: 20, research: 25, questions: 10, other: 10 },
22 sizes: { s: 15, m: 45, l: 120 },
23 judge: false, // off until the person turns it on: it sends part of each coding request to a model
24}
25
26export function withDefaults(stored: unknown): Settings {
27 const s = (stored ?? {}) as Partial<Settings>
28 return {
29 rate: s.rate ?? DEFAULTS.rate,
30 currency: s.currency === 'eur' ? 'eur' : 'usd',
31 minutes: { ...DEFAULTS.minutes, ...s.minutes },
32 sizes: { ...DEFAULTS.sizes, ...s.sizes },
33 judge: s.judge ?? DEFAULTS.judge,
34 }
35}
36
37// ---------------------------------------------------------------- days
38
39const DAY_MS = 86_400_000
40
41/** Local noon of the day `ms` falls on: stepping whole days from noon never crosses a date when clocks change. */
42export function noon(ms: number): Date {
43 const d = new Date(ms)
44 return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12)
45}
46
47export function dayKey(ms: number): string {
48 const d = new Date(ms)
49 const pad = (n: number) => String(n).padStart(2, '0')
50 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
51}
52
53/** The day `n` days before the day `ms` falls on, at local noon. */
54export function daysBack(ms: number, n: number): number {
55 return noon(ms).getTime() - n * DAY_MS
56}
57
58export function dayOfYear(ms: number): number {
59 const d = noon(ms)
60 return Math.round((d.getTime() - new Date(d.getFullYear(), 0, 1, 12).getTime()) / DAY_MS) + 1
61}
62
63type Tree = { [k: string]: number | Tree | undefined }
64
65function addTree(a: Tree, b: Tree): Tree {
66 const out: Tree = { ...a }
67 for (const [k, v] of Object.entries(b)) {
68 if (v === undefined) continue
69 const cur = out[k]
70 out[k] = typeof v === 'number' ? (typeof cur === 'number' ? cur : 0) + v : addTree((cur as Tree) ?? {}, v)
71 }
72 return out
73}
74
75/** Adds two days field by field; nested counts add, missing ones count as zero. */
76export function addDays(a: Day, b: Day): Day {
77 return addTree(a as Tree, b as Tree) as Day
78}
79
80/** A day as one source sees it: `you` the person's own sessions, `sdk` the ones the SDK started, `all` both. */
81export function bySource(day: Day, source: Source): Day {
82 const { sdk, ...own } = day
83 return source === 'you' ? own : source === 'sdk' ? (sdk ?? {}) : addDays(own, sdk ?? {})
84}
85
86/** A request that only asks Claude to summarise an earlier session: housekeeping, no task. */
87export const isSummaryJob = (text: string) => /^\s*Below is a conversation log from a Claude Code/.test(text)
88
89/** A session run from a temp folder: where scripts test the harness, not real work. */
90export const isTempDir = (cwd: string) =>
91 /[\\/]AppData[\\/]Local[\\/]Temp([\\/]|$)|^\/tmp(\/|$)|^(\/private)?\/var\/folders\//i.test(cwd)
92
93export function isEmptyDay(d: Day): boolean {
94 const walk = (t: Tree): boolean => Object.values(t).every(v => (typeof v === 'number' ? v === 0 : v === undefined || walk(v)))
95 return walk(d as Tree)
96}
97
98const sum = (r: Record<string, number | undefined> | undefined) => Object.values(r ?? {}).reduce<number>((a, v) => a + (v ?? 0), 0)
99
100export const taskCount = (d: Day) => sum(d.tasks)
101
102/** Days in a row with a finished task, counted back from today (or from yesterday while today has none yet). */
103export function streak(days: Record<string, Day>, nowMs: number): number {
104 let n = 0
105 let i = taskCount(days[dayKey(nowMs)] ?? {}) > 0 ? 0 : 1
106 while (taskCount(days[dayKey(daysBack(nowMs, i))] ?? {}) > 0) {
107 n++
108 i++
109 }
110 return n
111}
112
113export function minutesSaved(d: Day, s: Settings): number {
114 let m = 0
115 for (const k of KINDS) if (k !== 'coding') m += (d.tasks?.[k] ?? 0) * s.minutes[k]
116 for (const z of SIZES) m += (d.sizes?.[z] ?? 0) * s.sizes[z]
117 return m
118}
119
120// ---------------------------------------------------------------- tasks
121
122export type TaskSignals = {
123 tools: string[]
124 codeEdits: number
125 docWrites: number
126 mediaWrites: number
127 devShell: number
128 added: number
129 removed: number
130}
131
132const MAIL = /mail|gmail|outlook|inbox|imap/i
133const MEDIA_TOOL = /image|video|sprite|animat|portrait|screencast|gif_creator|3d_model|asset_preview/i
134const DOC_TOOL = /(^|_)(docs?|document|slides?|sheets?|spreadsheet|pptx|xlsx|docx|pdf|word|excel|powerpoint)(_|$)/i
135const RESEARCH_TOOL = /search|fetch|transcript|get_page_text|read_page|navigate/i
136/** Connected tools that are building software: a game engine, a code host, an error tracker, a database. */
137const CODE_TOOL = /unity|godot|github|gitlab|sentry|supabase|postgres|sqlite|mysql|prisma/i
138/** Connected tools that run infrastructure. */
139const OPS_TOOL = /docker|kube|k8s|aws|gcloud|azure|vercel|netlify|cloudflare|terraform|ssh/i
140/** Tools that only look, ask or keep notes: a task of these alone is a question answered or a plan made. */
141const LOOKING = new Set(['Read', 'Grep', 'Glob', 'LSP', 'ToolSearch', 'AskUserQuestion', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'Skill', 'EnterPlanMode', 'ExitPlanMode'])
142const SHELL = new Set(['Bash', 'PowerShell', 'Monitor'])
143
144/** One kind per task, by the tools it used; coding first, then the order of the original report. */
145export function classify(t: TaskSignals): Kind {
146 // Names are read only off connected (MCP) tools: the engine's own ToolSearch or FetchInboxMessage say nothing of the task.
147 const mcp = t.tools.filter(n => n.startsWith('mcp__'))
148 if (t.codeEdits > 0 || t.devShell > 0 || mcp.some(n => CODE_TOOL.test(n))) return 'coding'
149 if (mcp.some(n => MAIL.test(n))) return 'emails'
150 if (t.mediaWrites > 0 || mcp.some(n => MEDIA_TOOL.test(n))) return 'images'
151 if (t.docWrites > 0 || mcp.some(n => DOC_TOOL.test(n))) return 'documents'
152 if (t.tools.some(n => n === 'WebSearch' || n === 'WebFetch') || mcp.some(n => RESEARCH_TOOL.test(n))) return 'research'
153 if (t.tools.some(n => SHELL.has(n)) || mcp.some(n => OPS_TOOL.test(n))) return 'devops'
154 if (t.tools.every(n => LOOKING.has(n))) return 'questions'
155 return 'other'
156}
157
158/** Size of a coding task from the lines it changed, until the judge says otherwise. */
159export function sizeByLines(lines: number): Size {
160 return lines < 40 ? 's' : lines < 250 ? 'm' : 'l'
161}
162
163/** A typed request in flight: what its tools did so far. */
164export type Task = TaskSignals & { files: Set<string> }
165
166export const newTask = (): Task => ({
167 files: new Set(), tools: [], codeEdits: 0, docWrites: 0, mediaWrites: 0, devShell: 0, added: 0, removed: 0,
168})
169
170/** One finished tool call, as the live hook sees it and as a transcript replays it. */
171export type ToolRun = {
172 tool: string
173 input: Record<string, unknown>
174 /** The tool's own record (an Edit's `structuredPatch`), when it has one. */
175 result: unknown
176 isOk: boolean
177 /** What the tool printed, for a test runner's pass and fail counts. */
178 output: string
179 ms: number
180}
181
182/** What one finished tool call adds to its day, also noting it on the task in flight. */
183export function toolDelta(r: ToolRun, task: Task | null): Day {
184 const delta: Day = {}
185 const counts: Partial<Record<Counter, number>> = {}
186 const add = (k: Counter, x: number) => x !== 0 && (counts[k] = (counts[k] ?? 0) + x)
187 task?.tools.push(r.tool)
188
189 const path = r.tool === 'NotebookEdit' ? r.input.notebook_path : r.input.file_path
190 if (r.isOk && (r.tool === 'Edit' || r.tool === 'Write' || r.tool === 'NotebookEdit') && typeof path === 'string') {
191 const lines =
192 r.tool === 'NotebookEdit'
193 ? { added: String(r.input.new_source ?? '').split('\n').length, removed: 0 }
194 : countPatch(r.result as PatchResult | undefined)
195 const fk = fileKind(path)
196 add('linesAdded', lines.added)
197 add('linesRemoved', lines.removed)
198 if (!task || !task.files.has(path)) add('files', 1)
199 if (fk.lang && lines.added + lines.removed > 0) delta.langs = { [fk.lang]: lines.added + lines.removed }
200 if (task) {
201 task.files.add(path)
202 task.added += lines.added
203 task.removed += lines.removed
204 if (fk.kind === 'code') task.codeEdits++
205 if (fk.kind === 'doc') task.docWrites++
206 if (fk.kind === 'media') task.mediaWrites++
207 }
208 }
209
210 if ((r.tool === 'Bash' || r.tool === 'PowerShell') && typeof r.input.command === 'string') {
211 const facts = parseShell(r.input.command)
212 const isForeground = r.input.run_in_background !== true
213 if (r.isOk) for (const [k, x] of Object.entries(facts.n)) add(k as Counter, x ?? 0)
214 if (isForeground && facts.isTest) {
215 add('testRuns', 1)
216 add('testFails', r.isOk ? 0 : 1)
217 add('testMs', r.ms)
218 const t = parseTestCounts(r.output)
219 add('testsPassed', t.passed)
220 // A run that exited cleanly failed nothing: its output's "failed" is a test's own name or log line.
221 add('testsFailed', r.isOk ? 0 : t.failed)
222 }
223 if (isForeground && facts.isBuild) {
224 add('builds', 1)
225 add('buildFails', r.isOk ? 0 : 1)
226 }
227 if (task && facts.isDev) task.devShell++
228 }
229
230 if (Object.keys(counts).length > 0) delta.n = counts
231 return delta
232}
233
234/** What a task that finished with an answer adds: one task of its kind, its model, and a size when it is coding. */
235export function finishTask(task: Task, model: string): { delta: Day; kind: Kind; size?: Size } {
236 const kind = classify(task)
237 const delta: Day = { tasks: { [kind]: 1 }, models: { [model]: 1 } }
238 if (kind !== 'coding') return { delta, kind }
239 const size = sizeByLines(task.added + task.removed)
240 delta.sizes = { [size]: 1 }
241 return { delta, kind, size }
242}
243
244// ---------------------------------------------------------------- files
245
246const LANG: Record<string, string> = {
247 ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript',
248 js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
249 py: 'Python', ipynb: 'Python', cs: 'C#', java: 'Java', kt: 'Kotlin', go: 'Go', rs: 'Rust',
250 rb: 'Ruby', php: 'PHP', c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++', swift: 'Swift',
251 dart: 'Dart', scala: 'Scala', lua: 'Lua', r: 'R', ex: 'Elixir', exs: 'Elixir',
252 html: 'HTML', htm: 'HTML', css: 'CSS', scss: 'CSS', sass: 'CSS', less: 'CSS',
253 vue: 'Vue', svelte: 'Svelte', sql: 'SQL', sh: 'Shell', bash: 'Shell', zsh: 'Shell', ps1: 'PowerShell',
254 json: 'Config', yaml: 'Config', yml: 'Config', toml: 'Config', xml: 'Config', ini: 'Config',
255 shader: 'Shader', hlsl: 'Shader', glsl: 'Shader', gradle: 'Config', dockerfile: 'Config',
256}
257const DOC_EXT = new Set(['md', 'mdx', 'txt', 'rst', 'docx', 'doc', 'pptx', 'xlsx', 'csv', 'pdf', 'odt'])
258const MEDIA_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'mov', 'webm', 'mp3', 'wav'])
259
260export type FileKind = { lang: string | null; kind: 'code' | 'doc' | 'media' | 'other' }
261
262export function fileKind(path: string): FileKind {
263 const name = path.split(/[\\/]/).pop()!.toLowerCase()
264 const ext = name === 'dockerfile' || name === 'makefile' ? name : (name.includes('.') ? name.split('.').pop()! : '')
265 if (LANG[ext]) return { lang: LANG[ext], kind: 'code' }
266 if (ext === 'makefile') return { lang: 'Config', kind: 'code' }
267 if (DOC_EXT.has(ext)) return { lang: ext === 'md' || ext === 'mdx' ? 'Markdown' : null, kind: 'doc' }
268 if (MEDIA_EXT.has(ext)) return { lang: null, kind: 'media' }
269 return { lang: null, kind: 'other' }
270}
271
272type Hunk = { lines: string[] }
273type PatchResult = {
274 type?: string
275 content?: string
276 structuredPatch?: readonly Hunk[]
277 gitDiff?: { additions: number; deletions: number }
278}
279
280/** Lines an Edit or Write changed, from its result. */
281export function countPatch(r: PatchResult | undefined): { added: number; removed: number } {
282 if (!r) return { added: 0, removed: 0 }
283 if (r.gitDiff) return { added: r.gitDiff.additions, removed: r.gitDiff.deletions }
284 let added = 0
285 let removed = 0
286 for (const hunk of r.structuredPatch ?? [])
287 for (const line of hunk.lines) {
288 if (line.startsWith('+')) added++
289 else if (line.startsWith('-')) removed++
290 }
291 if (added + removed === 0 && r.type === 'create' && r.content) added = r.content.split('\n').length
292 return { added, removed }
293}
294
295// ---------------------------------------------------------------- shell
296
297export type ShellFacts = { n: Partial<Record<Counter, number>>; isTest: boolean; isBuild: boolean; isDev: boolean }
298
299// Runners match at the head of a command, so `pip install pytest` or `cat jest.config.js` is no test run.
300const TEST = /^(pytest|py\.test|jest|vitest|mocha|rspec|phpunit|ctest|go\s+test|cargo\s+(nextest|test)|dotnet\s+test|mvnw?\s+(\S+\s+)*test|(\.[\\/])?gradlew?(\.bat)?\s+(\S+\s+)*test|(python3?|py)\s+-m\s+(pytest|unittest)|(npm|pnpm|yarn|bun)\s+(run\s+)?test|claude\s+plugin\s+test|flutter\s+test|deno\s+test)\b/i
301const BUILD = /^(tsc|cargo\s+build|go\s+build|dotnet\s+build|mvnw?\s+(package|install|compile)|(\.[\\/])?gradlew?(\.bat)?\s+(assemble|build)|make|cmake\s+--build|vite\s+build|next\s+build|webpack|ng\s+build|(npm|pnpm|yarn|bun)\s+(run\s+)?build|msbuild)\b/i
302// Env assignments, PowerShell's `&` and runner wrappers in front of the command itself.
303const WRAPPER = /^(?:&\s*)?(?:[A-Z_][A-Z0-9_]*=\S*\s+)*(?:(?:npx|bunx|pnpm\s+(?:exec|dlx)|uv\s+run|poetry\s+run|pipenv\s+run)\s+)?/i
304const GIT = /^(?:&\s*)?git(?:\.exe)?((?:\s+-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+))*)\s+([a-z-]+)(.*)$/i
305const GH = /^(?:&\s*)?gh(?:\.exe)?\s+(pr|issue)\s+(create|merge|close)\b/i
306
307/** Quoted strings and here-docs removed, so a commit message holding `; git push` counts nothing. */
308function stripQuoted(cmd: string): string {
309 return cmd
310 .replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*?\n\s*\1\b/g, '')
311 .replace(/@'[\s\S]*?'@|@"[\s\S]*?"@/g, '""')
312 .replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '""')
313}
314
315/** What one Bash or PowerShell command did for the developer: git, gh, test and build runs. */
316export function parseShell(cmd: string): ShellFacts {
317 const n: Partial<Record<Counter, number>> = {}
318 const inc = (k: Counter) => (n[k] = (n[k] ?? 0) + 1)
319 let isTest = false
320 let isBuild = false
321 let isDev = false
322 const clean = stripQuoted(cmd)
323 for (const raw of clean.split(/&&|\|\||;|\||\r?\n|\bif\s*\(\s*\$\?\s*\)\s*\{|[{}]/)) {
324 const seg = raw.trim()
325 const head = seg.replace(WRAPPER, '')
326 if (TEST.test(head)) isTest = isDev = true
327 else if (BUILD.test(head)) isBuild = isDev = true
328 if (/^(?:&\s*)?(git|gh)(\.exe)?\s/i.test(seg)) isDev = true
329 const git = GIT.exec(seg)
330 if (git) {
331 const sub = git[2]!.toLowerCase()
332 const rest = git[3] ?? ""
333 if (/--dry-run|(^|\s)-n(\s|$)/.test(rest) && (sub === 'commit' || sub === 'push')) continue
334 if (sub === 'commit') inc('commits')
335 else if (sub === 'push') inc('pushes')
336 else if (sub === 'merge' && !/--abort/.test(rest)) inc('merges')
337 else if (sub === 'rebase' && !/--abort|--continue|--skip|--quit/.test(rest)) inc('rebases')
338 else if ((sub === 'checkout' && /(^|\s)-[bB]\s/.test(rest)) || (sub === 'switch' && /(^|\s)(-[cC]|--create)\s/.test(rest))) inc('branches')
339 else if (sub === 'branch' && /^\s+[^-\s]\S*\s*\S*\s*$/.test(rest)) inc('branches')
340 continue
341 }
342 const gh = GH.exec(seg)
343 if (gh) {
344 const what = gh[1]!.toLowerCase()
345 const act = gh[2]!.toLowerCase()
346 if (what === 'pr' && act === 'create') inc('prsOpened')
347 if (what === 'pr' && act === 'merge') inc('prsMerged')
348 if (what === 'issue' && act === 'create') inc('issuesOpened')
349 if (what === 'issue' && act === 'close') inc('issuesClosed')
350 }
351 }
352 return { n, isTest, isBuild: isBuild && !isTest, isDev }
353}
354
355const lastNum = (text: string, re: RegExp) => {
356 let found: number | null = null
357 for (const m of text.matchAll(re)) found = Number(m[1])
358 return found
359}
360
361/** Passed and failed test counts from a runner's summary (pytest, jest, vitest, cargo, dotnet, go's own words). */
362export function parseTestCounts(output: string): { passed: number; failed: number } {
363 // ponytail: last summary line wins; a runner printing per-suite totals after the grand total reads low.
364 const passed = lastNum(output, /(\d+)\s+passed/gi) ?? lastNum(output, /Passed:\s*(\d+)/g) ?? 0
365 const failed = lastNum(output, /(\d+)\s+failed/gi) ?? lastNum(output, /Failed:\s*(\d+)/g) ?? 0
366 return { passed, failed }
367}
368
369// ---------------------------------------------------------------- judge
370
371export const JUDGE_MODEL = 'claude-haiku-5-5'
372
373export const JUDGE_SYSTEM =
374 'You label one finished software task. Reply with two words and nothing else: TYPE SIZE. ' +
375 'TYPE is one of feature, bugfix, refactor, tests, debug, docs, devops, review. ' +
376 'SIZE is S, M or L: the time a senior developer would need by hand, S under 30 minutes, M 30 to 90 minutes, L over 90 minutes.'
377
378export function judgePrompt(text: string, t: TaskSignals, files: number): string {
379 const tools = Object.entries(t.tools.reduce<Record<string, number>>((a, n) => ((a[n] = (a[n] ?? 0) + 1), a), {}))
380 .map(([n, c]) => `${n} x${c}`)
381 .join(', ')
382 return `Request: ${text.slice(0, 1500)}\nTools: ${tools || 'none'}\nLines: +${t.added} -${t.removed} across ${files} files`
383}
384
385export function parseJudge(reply: string): { kind: DevKind; size: Size } | null {
386 const m = /\b(feature|bugfix|refactor|tests|debug|docs|devops|review)\b\W+\b([sml])\b/i.exec(reply)
387 return m ? { kind: m[1]!.toLowerCase() as DevKind, size: m[2]!.toLowerCase() as Size } : null
388}
389
390// ---------------------------------------------------------------- views
391
392export type Bucket = { label: string; value: number; isCurrent: boolean; isFuture: boolean }
393export type View = {
394 tab: Tab
395 title: string
396 dates: string
397 total: Day
398 chart: Bucket[]
399 summary: string
400 activeDays: number
401}
402
403const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
404const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
405const short = (ms: number) => {
406 const d = new Date(ms)
407 return `${d.getDate()} ${MONTHS[d.getMonth()]}`
408}
409/** `2026-09-19` reads `19 Sep`. */
410export const shortDay = (key: string) => {
411 const [y, m, d] = key.split('-').map(Number)
412 return short(new Date(y ?? 0, (m ?? 1) - 1, d ?? 1, 12).getTime())
413}
414const long = (ms: number) => `${WEEKDAYS[new Date(ms).getDay()]} ${short(ms)} ${new Date(ms).getFullYear()}`
415
416export const TITLES: Record<Tab, string> = { today: 'TODAY', week: 'WEEK', month: 'MONTH', year: 'YEAR' }
417
418/** Everything a tab draws, from the stored days: totals, the chart's buckets and the summary line. */
419export function view(tab: Tab, nowMs: number, days: Record<string, Day>): View {
420 const span = tab === 'today' ? 1 : tab === 'week' ? 7 : tab === 'month' ? 30 : dayOfYear(nowMs)
421 const keys = Array.from({ length: span }, (_, i) => daysBack(nowMs, span - 1 - i))
422 const dayOf = (ms: number) => days[dayKey(ms)] ?? {}
423 const total = keys.reduce((a, ms) => addDays(a, dayOf(ms)), {} as Day)
424 const activeDays = keys.filter(ms => taskCount(dayOf(ms)) > 0).length
425
426 let chart: Bucket[]
427 if (tab === 'year') {
428 const now = new Date(nowMs)
429 chart = MONTHS.map((label, m) => ({
430 label: label[0]!,
431 value: keys.filter(ms => new Date(ms).getMonth() === m).reduce((a, ms) => a + taskCount(dayOf(ms)), 0),
432 isCurrent: m === now.getMonth(),
433 isFuture: m > now.getMonth(),
434 }))
435 } else {
436 const n = tab === 'month' ? 30 : 7
437 chart = Array.from({ length: n }, (_, i) => {
438 const ms = daysBack(nowMs, n - 1 - i)
439 return { label: n === 7 ? WEEKDAYS[new Date(ms).getDay()]! : String(new Date(ms).getDate()), value: taskCount(dayOf(ms)), isCurrent: i === n - 1, isFuture: false }
440 })
441 }
442
443 let summary: string
444 if (tab === 'today' || tab === 'week') {
445 const s = streak(days, nowMs)
446 summary = `Streak: ${s} ${s === 1 ? 'day' : 'days'} in a row`
447 } else {
448 const best = tab === 'year' ? chart.reduce((a, b) => (b.value > a.value ? b : a), chart[0]!) : null
449 const bestDay = keys.reduce((a, ms) => (taskCount(dayOf(ms)) > taskCount(dayOf(a)) ? ms : a), keys[0]!)
450 const bestText =
451 tab === 'year'
452 ? `Best month: ${MONTHS[chart.indexOf(best!)]} (${best!.value} tasks)`
453 : `Best day: ${short(bestDay)} (${taskCount(dayOf(bestDay))} tasks)`
454 summary = `${bestText} · ${activeDays} active ${activeDays === 1 ? 'day' : 'days'} of ${span}`
455 }
456
457 const first = keys[0]!
458 const dates = tab === 'today' ? long(nowMs) : `${short(first)} – ${short(nowMs)} ${new Date(nowMs).getFullYear()}`
459 return { tab, title: TITLES[tab], dates, total, chart, summary, activeDays }
460}
461
462// ---------------------------------------------------------------- drawing helpers
463
464/** Big numerals, three rows tall, in heavy box-drawing characters. */
465const GLYPHS: Record<string, [string, string, string]> = {
466 '0': ['┏━┓', '┃ ┃', '┗━┛'],
467 '1': ['╺┓ ', ' ┃ ', '╺┻╸'],
468 '2': ['┏━┓', '┏━┛', '┗━╸'],
469 '3': ['╺━┓', ' ━┫', '╺━┛'],
470 '4': ['╻ ╻', '┗━┫', ' ╹'],
471 '5': ['┏━╸', '┗━┓', '╺━┛'],
472 '6': ['┏━╸', '┣━┓', '┗━┛'],
473 '7': ['╺━┓', ' ┃', ' ╹'],
474 '8': ['┏━┓', '┣━┫', '┗━┛'],
475 '9': ['┏━┓', '┗━┫', '╺━┛'],
476 '.': [' ', ' ', '╹'],
477 ',': [' ', ' ', '┛'],
478}
479
480/** The three rows of `text` in big numerals, or null when it holds a character the font lacks or is wider than `width`. */
481export function bigText(text: string, width: number): [string, string, string] | null {
482 const rows: [string, string, string] = ['', '', '']
483 for (const ch of text) {
484 const g = GLYPHS[ch]
485 if (!g) return null
486 for (let r = 0; r < 3; r++) rows[r] += (rows[r] ? ' ' : '') + g[r]
487 }
488 return [...rows[0]].length <= width ? rows : null
489}
490
491export const fmtInt = (n: number) => Math.round(n).toLocaleString('en-US')
492/** `$1,234` or `1.234 €`: each currency written the way its readers write it. */
493export const fmtMoney = (amount: number, currency: Currency) =>
494 currency === 'eur' ? `${Math.round(amount).toLocaleString('de-DE')} €` : `$${fmtInt(amount)}`
495
496export const fmtHours = (minutes: number) => {
497 const h = minutes / 60
498 return h < 10 ? (Math.round(h * 10) / 10).toFixed(1) : fmtInt(h)
499}
500export const fmtCompact = (n: number) =>
501 n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n))
502export const fmtDuration = (ms: number) => {
503 const s = Math.round(ms / 1000)
504 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
505}
506
507/** `claude-opus-5-5` reads `Opus 5.5`; an id it cannot read stays as it is. */
508export function modelName(id: string): string {
509 const m = /claude-([a-z]+)-(\d+)(?:-(\d+))?/i.exec(id)
510 return m ? `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}${m[3] && m[3].length < 3 ? `.${m[3]}` : ''}` : id
511}
512
513/** Tiles per row for a body `width` cells wide. */
514export const tilesPerRow = (width: number) => (width >= 66 ? 3 : width >= 44 ? 2 : 1)
515
516const EIGHTHS = ' ▁▂▃▄▅▆▇█'
517
518/** Row `row` (0 top, 2 bottom) of a three-row bar for `value` against `max`. */
519export function barCell(value: number, max: number, row: number): string {
520 if (max <= 0 || value <= 0) return row === 2 ? '▁' : ' '
521 const eighths = Math.max(1, Math.round((value / max) * 24))
522 return EIGHTHS[Math.min(8, Math.max(0, eighths - (2 - row) * 8))] ?? " "
523}
524
525export function fit(text: string, width: number): string {
526 const chars = [...text]
527 return chars.length <= width ? text : width <= 1 ? chars.slice(0, width).join('') : chars.slice(0, width - 1).join('') + '…'
528}
529hooks/history.ts 176 lines1// Replays Claude Code's own transcripts (~/.claude/projects/**/*.jsonl) into days, by the live tracker's rules.
2// Pure: it takes lines; reading the files is the caller's.
3import type { Day } from '../types'
4import { addDays, dayKey, finishTask, isEmptyDay, isTempDir, newTask, toolDelta } from './stats'
5import type { Task } from './stats'
6
7/** Tools whose results the replay reads; any other result line (a Read's whole file) is skipped unparsed. */
8const TRACKED = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell'])
9
10/** Text that is no typed request: slash commands, their output, and what the engine injects. */
11const NOT_TYPED = /^\s*(<command-name>|<command-message>|<command-args>|<local-command|<system-reminder>|<bash-input>|<bash-stdout>|<bash-stderr>|<task-notification>|<user-prompt-submit-hook>|Caveat:|This session is being continued from a previous conversation|Below is a conversation log from a Claude Code)/
12
13export type ImportRange = {
14 /** First day counted, `YYYY-MM-DD`. */
15 fromDay: string
16 /** First day not counted: the day after live tracking began, or after the end of the range asked for. */
17 untilDay: string
18 /** The moment live tracking began: nothing from then on is read, so a day it shares with the import counts once. */
19 untilMs: number
20}
21
22export type ImportResult = {
23 days: Record<string, Day>
24 /** Interactive sessions read. */
25 sessions: number
26 /** Sessions the SDK started (an assistant, a script): counted apart, under `sdk`. */
27 sdkSessions: number
28 /** SDK sessions run from a temp folder: harness tests, left out. */
29 testSessions: number
30 /** Lines that were not JSON. */
31 badLines: number
32}
33
34type Use = { tool: string; input: Record<string, unknown>; ms: number }
35type Open = Task & { ms: number; model: string; isAnswered: boolean }
36type Rec = {
37 type?: string
38 uuid?: string
39 timestamp?: string
40 entrypoint?: string
41 cwd?: string
42 isSidechain?: boolean
43 isMeta?: boolean
44 isCompactSummary?: boolean
45 toolUseResult?: unknown
46 message?: { id?: string; model?: string; content?: unknown; usage?: Record<string, number> }
47}
48type Block = { type?: string; text?: string; id?: string; name?: string; input?: Record<string, unknown>; tool_use_id?: string; is_error?: boolean; content?: unknown }
49
50const textOf = (content: unknown): string =>
51 typeof content === 'string'
52 ? content
53 : Array.isArray(content)
54 ? (content as Block[]).filter(b => b?.type === 'text').map(b => b.text ?? '').join('\n')
55 : ''
56
57/**
58 * One import run. Ids are remembered across every file it reads, so history a fork or a resume
59 * copied into another file, and a response split over several lines, count once.
60 */
61export function createImporter(range: ImportRange) {
62 const days: Record<string, Day> = {}
63 const seenPrompts = new Set<string>()
64 const seenMessages = new Set<string>()
65 const seenResults = new Set<string>()
66 let sessions = 0
67 let sdkSessions = 0
68 let testSessions = 0
69 let badLines = 0
70
71 const add = (ms: number, delta: Day) => {
72 const day = dayKey(ms)
73 if (day < range.fromDay || day >= range.untilDay || ms >= range.untilMs || isEmptyDay(delta)) return
74 days[day] = addDays(days[day] ?? {}, delta)
75 }
76
77 /** Reads one transcript; `isSubagent` for a file under `subagents/`, whose work adds counts but no tasks. */
78 function file(isSubagent: boolean) {
79 const uses = new Map<string, Use>()
80 let task: Open | null = null
81 let entry: string | null = null
82 let isSdk = false
83 let isTest = false
84 const put = (ms: number, delta: Day) => add(ms, isSdk ? { sdk: delta } : delta)
85
86 const close = () => {
87 if (task?.isAnswered) put(task.ms, finishTask(task, task.model).delta)
88 task = null
89 }
90
91 function line(raw: string) {
92 if (isTest) return
93 const isUser = raw.includes('"type":"user"')
94 if (!isUser && !raw.includes('"type":"assistant"')) return
95 // A result of a tool the replay does not count (a Read holding a whole file) is skipped before parsing.
96 const resultId = isUser ? /"tool_use_id":"([^"]+)"/.exec(raw.slice(0, 2000))?.[1] : undefined
97 if (resultId !== undefined && !TRACKED.has(uses.get(resultId)?.tool ?? '')) return
98
99 let o: Rec
100 try {
101 o = JSON.parse(raw) as Rec
102 } catch {
103 badLines++
104 return
105 }
106 if (entry === null && typeof o.entrypoint === 'string') {
107 entry = o.entrypoint
108 isSdk = entry.startsWith('sdk')
109 isTest = isSdk && isTempDir(o.cwd ?? '')
110 if (!isSubagent) isTest ? testSessions++ : isSdk ? sdkSessions++ : sessions++
111 if (isTest) return
112 }
113 const ms = Date.parse(o.timestamp ?? '')
114 if (Number.isNaN(ms)) return
115 const isSide = isSubagent || o.isSidechain === true
116 const content = o.message?.content
117
118 if (o.type === 'assistant') {
119 const model = o.message?.model ?? 'unknown'
120 if (model === '<synthetic>') return
121 const id = o.message?.id
122 const u = o.message?.usage
123 if (u && id && !seenMessages.has(id)) {
124 seenMessages.add(id)
125 put(ms, { tokens: { [model]: { in: u.input_tokens ?? 0, out: u.output_tokens ?? 0, cacheRead: u.cache_read_input_tokens ?? 0, cacheWrite: u.cache_creation_input_tokens ?? 0 } } })
126 }
127 for (const b of Array.isArray(content) ? (content as Block[]) : []) {
128 if (b?.type === 'tool_use' && b.id && b.name) {
129 uses.set(b.id, { tool: b.name, input: b.input ?? {}, ms })
130 // Every tool sorts the task (WebSearch makes it research); the tracked ones are noted when their result comes.
131 if (task && !isSubagent && !TRACKED.has(b.name)) task.tools.push(b.name)
132 }
133 if (b?.type === 'text' && (b.text ?? '').trim() !== '' && !isSide && task) {
134 task.isAnswered = true
135 task.model = model
136 }
137 }
138 return
139 }
140
141 if (o.type !== 'user') return
142 const results = Array.isArray(content) ? (content as Block[]).filter(b => b?.type === 'tool_result') : []
143 if (results.length > 0) {
144 for (const b of results) {
145 const id = b.tool_use_id
146 const use = id ? uses.get(id) : undefined
147 if (!id || !use || seenResults.has(id)) continue
148 seenResults.add(id)
149 const record = o.toolUseResult as { stdout?: string } | undefined
150 put(ms, toolDelta({ tool: use.tool, input: use.input, result: o.toolUseResult, isOk: b.is_error !== true, output: record?.stdout ?? textOf(b.content), ms: ms - use.ms }, isSubagent ? null : task))
151 }
152 return
153 }
154 if (isSide || o.isMeta || o.isCompactSummary) return
155 const text = textOf(content)
156 if (text.startsWith('[Request interrupted')) {
157 task = null // an interrupted request is no finished task
158 return
159 }
160 if (NOT_TYPED.test(text)) {
161 close()
162 return
163 }
164 close()
165 if (o.uuid && seenPrompts.has(o.uuid)) return // copied history: counted where it was first read
166 if (o.uuid) seenPrompts.add(o.uuid)
167 task = { ...newTask(), ms, model: 'unknown', isAnswered: false }
168 }
169
170 return { line, end: close }
171 }
172
173 const result = (): ImportResult => ({ days, sessions, sdkSessions, testSessions, badLines })
174 return { file, result }
175}
176hooks/dashboard.ts 253 lines1// The pane's body (tiles, chart, cards) drawn as one SVG, for wide desktop panes.
2// Text rows in the desktop's type leave uneven gaps and cannot round a corner or print a big numeral cleanly;
3// a picture lays out exactly as written. It carries its own dark background, so it reads in a light app theme too.
4import type { Counter, Kind, Settings } from '../types'
5import {
6 DEV_KINDS, KINDS, KIND_LABEL, SIZES, fit, fmtCompact, fmtDuration, fmtHours, fmtInt, fmtMoney, minutesSaved, modelName, taskCount,
7} from './stats'
8import type { View } from './stats'
9import { C, KIND_COLOR } from './theme'
10
11const MONO = "ui-monospace, SFMono-Regular, 'Cascadia Code', Menlo, Consolas, 'Liberation Mono', monospace"
12const SANS = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
13/** Pixels one character takes at 13px, with room for whichever monospace font the machine falls back to. */
14const CHAR = 8
15
16/** Widest the picture is drawn; a wider pane leaves the rest empty rather than stretching the type. */
17export const DASHBOARD_MAX = 1200
18/** Pixels one pane column takes, as the desktop lays out its monospace type. */
19const COLUMN_PX = 7.8
20/** Narrowest picture that still lays out cleanly (tiles, the model table), about 62 pane columns. */
21export const MIN_WIDTH = 484
22const PAD = 28
23const GAP = 14
24const ROW = 24
25
26const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
27
28type Style = { fill?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; font?: string }
29const text = (x: number, y: number, s: string, { fill = C.text, size = 13, weight = 400, anchor = 'start', font = MONO }: Style = {}) =>
30 `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" fill="${fill}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-family="${font}">${esc(s)}</text>`
31const box = (x: number, y: number, w: number, h: number, fill: string, rx = 0, stroke?: string) =>
32 `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${rx}" fill="${fill}"${stroke ? ` stroke="${stroke}"` : ''}/>`
33
34type Span = [text: string, color?: string]
35/** Coloured parts of one line, laid out as one run of text. */
36const spans = (x: number, y: number, parts: Span[], size = 13) =>
37 `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="${size}" font-family="${MONO}" xml:space="preserve">${parts.map(([t, c]) => `<tspan fill="${c ?? C.text}">${esc(t)}</tspan>`).join('')}</text>`
38
39/** Items joined with " · ", broken into lines no wider than `chars`. */
40function wrapItems(items: string[], chars: number): string[] {
41 const lines: string[] = []
42 let line = ''
43 for (const item of items) {
44 const next = line ? `${line} · ${item}` : item
45 if (line && next.length > chars) {
46 lines.push(line)
47 line = item
48 } else line = next
49 }
50 if (line) lines.push(line)
51 return lines
52}
53
54const plural = (x: number, one: string, many = `${one}s`) => `${fmtInt(x)} ${x === 1 ? one : many}`
55
56type Card = { h: number; svg: (x: number, y: number, w: number) => string }
57
58/** A rounded card with a muted title and `lines` rows below it; `draw` paints them given the card's left edge, first baseline and width. */
59function card(title: string, lines: number, draw: (x: number, y: number, w: number) => string): Card {
60 const h = 52 + lines * ROW
61 return {
62 h,
63 svg: (x, y, w) => box(x + 0.5, y + 0.5, w - 1, h - 1, C.dark, 12, C.hair) + text(x + 18, y + 31, title.toUpperCase(), { fill: C.muted, size: 12, weight: 700 }) + draw(x, y + 58, w),
64 }
65}
66
67/** The body of the pane as an SVG document, and a description of it for a reader that cannot see it. */
68export function dashboardSvg(v: View, s: Settings, columns = 160): { source: string; alt: string; width: number; height: number; body: string } {
69 // Drawn at about the pane's own pixel width, so type keeps its size; two columns of cards once there is room for them.
70 const W = Math.min(DASHBOARD_MAX, Math.max(MIN_WIDTH, Math.round(columns * COLUMN_PX)))
71 const isTwo = W >= 1000
72 const CW = W - 2 * PAD
73 const d = v.total
74 const n = (k: Counter) => d.n?.[k] ?? 0
75 const out: string[] = []
76 let y = PAD
77
78 // Tiles
79 const minutes = minutesSaved(d, s)
80 const hours = fmtHours(minutes)
81 const amount = (minutes / 60) * s.rate
82 const tw = (CW - 2 * GAP) / 3
83 const tiles: [string, string, string][] = [
84 ['HOURS SAVED', `${hours} h`, 'estimate'],
85 ['TASKS DONE', fmtInt(taskCount(d)), plural(v.activeDays, 'active day')],
86 [s.currency === 'eur' ? 'WORTH IN EUROS' : "DOLLARS' WORTH", fmtMoney(amount, s.currency), `estimate at ${fmtMoney(s.rate, s.currency)}/h`],
87 ]
88 const rate = `at ${fmtMoney(s.rate, s.currency)}/h` // the shorter caption, when the full one will not fit a narrow tile
89 const pad = tw > 260 ? 22 : 14
90 tiles.forEach(([label, value, foot], i) => {
91 const x = PAD + i * (tw + GAP)
92 const room = tw - 2 * pad
93 const size = Math.max(16, Math.min(44, Math.floor(room / (value.length * 0.6)))) // a long number shrinks rather than overflows
94 out.push(box(x + 0.5, y + 0.5, tw - 1, 115, C.dark, 12, C.hair), text(x + pad, y + 32, fit(label, Math.floor(room / 7.3)), { fill: C.muted, size: 12, weight: 600 }))
95 out.push(text(x + pad, y + 46 + size * 0.76, value, { size, weight: 800, font: SANS }), text(x + pad, y + 102, foot.length > Math.floor(room / 7.4) && i === 2 ? rate : fit(foot, Math.floor(room / 7.4)), { fill: C.muted, size: 12 }))
96 })
97 y += 116 + 30
98
99 // Chart: a bar per day (or month), its count above it, its label below
100 out.push(text(PAD, y, v.tab === 'year' ? 'TASKS BY MONTH' : 'TASKS BY DAY', { fill: C.muted, size: 12, weight: 700 }))
101 const headW = (v.tab === 'year' ? 14 : 12) * 8 + 14
102 out.push(box(PAD + headW, y - 4, CW - headW, 1, C.hair))
103 const plotH = 112
104 const base = y + 36 + plotH
105 const buckets = v.chart
106 const slot = CW / buckets.length
107 const barW = Math.min(46, slot * 0.62)
108 const top = Math.max(1, ...buckets.map(b => b.value))
109 const every = buckets.length > 12 ? 5 : 1
110 buckets.forEach((b, i) => {
111 const cx = PAD + slot * i + slot / 2
112 const h = b.isFuture ? 0 : b.value > 0 ? Math.max(4, (b.value / top) * plotH) : 2
113 if (!b.isFuture) out.push(box(cx - barW / 2, base - h, barW, h, b.isCurrent ? C.gold : b.value > 0 ? C.text : C.hair, 4))
114 if (!b.isFuture && b.value > 0 && slot >= 24) out.push(text(cx, base - h - 7, String(b.value), { fill: b.isCurrent ? C.gold : C.muted, size: 12, anchor: 'middle' }))
115 if ((buckets.length - 1 - i) % every === 0) out.push(text(cx, base + 22, b.label, { fill: b.isCurrent ? C.gold : C.muted, size: 12, weight: b.isCurrent ? 700 : 400, anchor: 'middle' }))
116 })
117 y = base + 22 + 30
118 out.push(text(PAD, y, v.summary, { fill: C.green }))
119 y += 38
120
121 // Cards
122 const kindMinutes = (k: Kind) =>
123 k === 'coding' ? SIZES.reduce((a, z) => a + (d.sizes?.[z] ?? 0) * s.sizes[z], 0) : (d.tasks?.[k] ?? 0) * s.minutes[k]
124 const kinds = KINDS.filter(k => (d.tasks?.[k] ?? 0) > 0)
125 const maxKind = Math.max(1, ...kinds.map(k => d.tasks?.[k] ?? 0))
126 const timeCard = card('Where the time went', Math.max(1, kinds.length), (x, ry, w) => {
127 if (kinds.length === 0) return text(x + 18, ry, 'No finished tasks in this stretch.', { fill: C.muted })
128 const labelW = 188
129 const numbers = 178
130 const barLen = w - 36 - labelW - numbers
131 return kinds
132 .map((k, i) => {
133 const count = d.tasks?.[k] ?? 0
134 const at = ry + i * ROW + 2
135 const bar = kinds.length > 1 && barLen >= 40 ? box(x + 18 + labelW, at - 10, barLen, 10, C.hair, 3) + box(x + 18 + labelW, at - 10, Math.max(4, (count / maxKind) * barLen), 10, KIND_COLOR[k], 3) : ''
136 return text(x + 18, at, KIND_LABEL[k]) + bar + text(x + w - 18, at, `${plural(count, 'task')} · ${fmtHours(kindMinutes(k))} h`, { anchor: 'end' })
137 })
138 .join('')
139 })
140
141 const models = [...new Set([...Object.keys(d.models ?? {}), ...Object.keys(d.tokens ?? {})])].sort(
142 (a, b) => (d.models?.[b] ?? 0) - (d.models?.[a] ?? 0) || (d.tokens?.[b]?.out ?? 0) - (d.tokens?.[a]?.out ?? 0),
143 )
144 const judge = n('judgeCalls') > 0
145 const modelRows = models.length + (judge ? 1 : 0) + (judge && n('judgeFails') > 0 ? 1 : 0)
146 const modelCard = card('Models and tokens', Math.max(1, modelRows + 1), (x, ry, w) => {
147 if (modelRows === 0) return text(x + 18, ry, 'No model use in this stretch.', { fill: C.muted })
148 const col = 80
149 const cachedX = x + w - 18
150 const outX = cachedX - col
151 const inX = outX - col
152 const tasksX = inX - col
153 const nameChars = Math.floor((tasksX - 40 - (x + 18)) / CHAR)
154 const head = [['Tasks', tasksX], ['In', inX], ['Out', outX], ['Cached', cachedX]] as const
155 const parts: string[] = head.map(([label, px]) => text(px, ry, label, { fill: C.muted, size: 12, anchor: 'end' }))
156 const rowAt = (i: number) => ry + 26 + i * ROW
157 let i = 0
158 for (const id of models) {
159 const t = d.tokens?.[id] ?? {}
160 const input = (t.in ?? 0) + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0)
161 const cached = input > 0 ? `${Math.round(((t.cacheRead ?? 0) / input) * 100)}%` : '-'
162 parts.push(text(x + 18, rowAt(i), fit(modelName(id), nameChars)))
163 for (const [val, px] of [[String(d.models?.[id] ?? 0), tasksX], [fmtCompact(input), inX], [fmtCompact(t.out ?? 0), outX], [cached, cachedX]] as const) parts.push(text(px, rowAt(i), val, { anchor: 'end' }))
164 i++
165 }
166 if (judge) {
167 const input = n('judgeIn') + n('judgeCacheRead') + n('judgeCacheWrite')
168 parts.push(text(x + 18, rowAt(i), 'Haiku judge', { fill: C.muted }))
169 for (const [val, px] of [[fmtInt(n('judgeCalls')), tasksX], [fmtCompact(input), inX], [fmtCompact(n('judgeOut')), outX], ['-', cachedX]] as const) parts.push(text(px, rowAt(i), val, { fill: C.muted, anchor: 'end' }))
170 i++
171 if (n('judgeFails') > 0) parts.push(text(x + 18, rowAt(i), `${plural(n('judgeFails'), 'judge call')} failed`, { fill: C.amber }))
172 }
173 return parts.join('')
174 })
175
176 // Code rows: a label, then the value (several lines when it is long)
177 type Row = { label: string; lines?: Span[][]; items?: string[] }
178 const rows: Row[] = []
179 const list = (pairs: [number, string, string?][]) => pairs.filter(([x]) => x > 0).map(([x, one, many]) => plural(x, one, many))
180 if (n('linesAdded') + n('linesRemoved') > 0 || n('files') > 0)
181 rows.push({ label: 'Lines', lines: [[[`+${fmtInt(n('linesAdded'))}`, C.green], [' '], [`−${fmtInt(n('linesRemoved'))}`, C.muted], [` · ${plural(n('files'), 'file')}`, C.muted]]] })
182 const langs = Object.entries(d.langs ?? {}).sort((a, b) => b[1] - a[1])
183 const langTotal = langs.reduce((a, [, x]) => a + x, 0)
184 const textRow = (label: string, items: string[]) => {
185 if (items.length > 0) rows.push({ label, items })
186 }
187 textRow('Languages', langs.slice(0, 4).map(([l, x]) => `${l} ${Math.round((x / Math.max(1, langTotal)) * 100)}%`))
188 textRow('Git', list([[n('commits'), 'commit'], [n('pushes'), 'push', 'pushes'], [n('merges'), 'merge'], [n('rebases'), 'rebase'], [n('branches'), 'branch', 'branches']]))
189 textRow('GitHub', list([[n('prsOpened'), 'PR opened', 'PRs opened'], [n('prsMerged'), 'PR merged', 'PRs merged'], [n('issuesOpened'), 'issue opened', 'issues opened'], [n('issuesClosed'), 'issue closed', 'issues closed']]))
190 if (n('testRuns') > 0) {
191 const pass = Math.round(((n('testRuns') - n('testFails')) / n('testRuns')) * 100)
192 rows.push({ label: 'Tests', lines: [[[`${plural(n('testRuns'), 'run')} · `], [`${pass}% pass`, pass >= 90 ? C.green : C.amber], [` · ${fmtDuration(n('testMs'))}`, C.muted]]] })
193 if (n('testsPassed') + n('testsFailed') > 0)
194 rows.push({ label: 'Results', lines: [[[`${fmtInt(n('testsPassed'))} passed`, C.green], [' '], [`${fmtInt(n('testsFailed'))} failed`, n('testsFailed') > 0 ? C.amber : C.muted]]] })
195 }
196 if (n('builds') > 0) rows.push({ label: 'Builds', lines: [[[`${plural(n('builds'), 'run')} · `], [`${fmtInt(n('buildFails'))} failed`, n('buildFails') > 0 ? C.amber : C.muted]]] })
197 if ((d.tasks?.coding ?? 0) > 0) {
198 const types = DEV_KINDS.filter(k => (d.dev?.[k] ?? 0) > 0).sort((a, b) => (d.dev?.[b] ?? 0) - (d.dev?.[a] ?? 0))
199 if (types.length > 0) textRow('Task types', types.map(k => `${k[0]!.toUpperCase()}${k.slice(1)} ${d.dev?.[k]}`))
200 else rows.push({ label: 'Task types', lines: [[[s.judge ? 'not judged yet' : 'judge is off (/payback settings judge on)', C.muted]]] })
201 rows.push({ label: 'Sizes', lines: [[[SIZES.map(z => `${z.toUpperCase()} ${d.sizes?.[z] ?? 0}`).join(' · '), C.muted]]] })
202 }
203 const hasCode = rows.length > 0 || (d.tasks?.coding ?? 0) > 0
204
205 const LEFT = isTwo ? Math.round(CW * 0.472) : CW
206 const RIGHT = isTwo ? CW - GAP - LEFT : CW
207 const codeChars = Math.floor((RIGHT - 36 - 112) / CHAR)
208 const laid = rows.map(r => ({ label: r.label, lines: r.items ? wrapItems(r.items, codeChars).map((t): Span[] => [[t]]) : (r.lines ?? []) }))
209 const codeLines = laid.reduce((a, r) => a + r.lines.length, 0)
210 const codeCard = card('Code', Math.max(1, codeLines), (x, ry, w) => {
211 if (laid.length === 0) return text(x + 18, ry, 'No code work in this stretch.', { fill: C.muted })
212 let line = 0
213 return laid
214 .map(r =>
215 r.lines
216 .map((parts, i) => {
217 const at = ry + line++ * ROW
218 return (i === 0 ? text(x + 18, at, r.label, { fill: C.muted }) : '') + spans(x + 130, at, parts)
219 })
220 .join(''),
221 )
222 .join('')
223 })
224
225 const colLeft = !isTwo ? [timeCard, ...(hasCode ? [codeCard] : []), modelCard] : hasCode ? [timeCard, modelCard] : [timeCard]
226 const colRight = !isTwo ? [] : hasCode ? [codeCard] : [modelCard]
227 const stack = (cards: Card[], x: number, w: number) => {
228 let cy = y
229 for (const c of cards) {
230 out.push(c.svg(x, cy, w))
231 cy += c.h + GAP
232 }
233 return cy - GAP
234 }
235 const bottom = Math.max(stack(colLeft, PAD, LEFT), colRight.length > 0 ? stack(colRight, PAD + LEFT + GAP, RIGHT) : 0)
236 y = bottom + 30
237 out.push(text(PAD, y, W < 600 ? 'All estimates. /payback settings changes them.' : 'Hours and money are estimates. /payback settings changes them.', { fill: C.muted, size: 12 }))
238 const H = y + PAD - 6
239
240 const alt =
241 `Payback, ${v.title.toLowerCase()}: ${fmtInt(taskCount(d))} tasks, ${hours} hours saved, ${fmtMoney(amount, s.currency)} (estimates). ` +
242 (kinds.length > 0 ? kinds.map(k => `${KIND_LABEL[k]} ${d.tasks?.[k]}`).join(', ') + '. ' : '') +
243 v.summary
244 const body = out.join('')
245 const source =
246 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
247 box(0.5, 0.5, W - 1, H - 1, C.dark, 16, C.hair) +
248 body +
249 '</svg>'
250 // `body` is the drawing without its frame, in the picture's own coordinates: docs/make-pane.cjs sets it under a header for the README.
251 return { source, alt, width: W, height: H, body }
252}
253hooks/theme.ts 12 lines1import type { Kind } from '../types'
2
3export const C = {
4 gold: '#F2C46D', hair: '#262B33', muted: '#7D8590', text: '#E6EDF3',
5 green: '#3DD68C', amber: '#E3B341', dark: '#0D1117', panel: '#161B22',
6}
7
8export const KIND_COLOR: Record<Kind, string> = {
9 coding: '#58A6FF', devops: '#9DA5FF', emails: '#BC8CFF', images: '#F778BA', documents: '#56D4DD', research: '#FFA657',
10 questions: '#C9D1D9', other: '#7D8590',
11}
12types/index.d.ts 73 lines1export type Tab = 'today' | 'week' | 'month' | 'year'
2
3export type Kind = 'coding' | 'devops' | 'emails' | 'images' | 'documents' | 'research' | 'questions' | 'other'
4
5export type DevKind = 'feature' | 'bugfix' | 'refactor' | 'tests' | 'debug' | 'docs' | 'devops' | 'review'
6
7export type Size = 's' | 'm' | 'l'
8
9export type Counter =
10 | 'linesAdded' | 'linesRemoved' | 'files'
11 | 'commits' | 'pushes' | 'merges' | 'rebases' | 'branches'
12 | 'prsOpened' | 'prsMerged' | 'issuesOpened' | 'issuesClosed'
13 | 'testRuns' | 'testFails' | 'testMs' | 'testsPassed' | 'testsFailed'
14 | 'builds' | 'buildFails'
15 | 'judgeCalls' | 'judgeFails' | 'judgeIn' | 'judgeOut' | 'judgeCacheRead' | 'judgeCacheWrite'
16
17export type Tokens = { in?: number; out?: number; cacheRead?: number; cacheWrite?: number }
18
19/** One calendar day, every field optional so a stored day holds only what happened. */
20export type Day = {
21 tasks?: Partial<Record<Kind, number>>
22 models?: Record<string, number>
23 sizes?: Partial<Record<Size, number>>
24 dev?: Partial<Record<DevKind, number>>
25 langs?: Record<string, number>
26 n?: Partial<Record<Counter, number>>
27 tokens?: Record<string, Tokens>
28 /** Work in sessions the SDK started (an assistant, a script): the same fields, kept apart from the person's own. */
29 sdk?: Day
30}
31
32/** Whose work the pane shows: the person's own sessions, the SDK's, or both. */
33export type Source = 'all' | 'you' | 'sdk'
34
35export type Currency = 'usd' | 'eur'
36
37export type Settings = {
38 rate: number
39 currency: Currency
40 minutes: Record<Exclude<Kind, 'coding'>, number>
41 sizes: Record<Size, number>
42 judge: boolean
43}
44
45/** What the history import shows in the pane. */
46export type ImportView = {
47 phase: 'idle' | 'scanning' | 'preview' | 'done' | 'error'
48 /** First day read, `YYYY-MM-DD`; `0000-01-01` for all history kept. */
49 fromDay: string
50 /** First day not read: where live tracking began, or the end asked for. */
51 untilDay: string
52 done: number
53 total: number
54 message?: string
55 preview?: {
56 tasks: number
57 kinds: Partial<Record<Kind, number>>
58 minutes: number
59 sessions: number
60 sdkSessions: number
61 sdkTasks: number
62 testSessions: number
63 skipped: number
64 activeDays: number
65 }
66}
67
68declare module 'claude-code' {
69 interface PluginState {
70 payback: { tab: Tab; rev: number; import: ImportView; source: Source }
71 }
72}
73