k0-board 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/README.md +1021 -119
  22. package/bin/k0-board.js +81 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +37 -6
  26. package/platform/darwin/index.js +1 -0
  27. package/platform/darwin/shell.js +4 -1
  28. package/platform/darwin/terminal.js +230 -56
  29. package/platform/index.js +3 -1
  30. package/platform/linux/index.js +14 -0
  31. package/platform/linux/shell.js +10 -1
  32. package/platform/linux/tray.py +10 -10
  33. package/platform/shared/command.js +19 -3
  34. package/platform/shared/tmux.js +32 -14
  35. package/platform/win32/index.js +6 -2
  36. package/platform/win32/shell.js +4 -1
  37. package/platform/win32/terminal.js +24 -8
  38. package/platform/win32/tray.ps1 +13 -13
  39. package/server/backlog.js +618 -0
  40. package/server/changelog.js +13 -13
  41. package/server/db.js +1800 -146
  42. package/server/files.js +156 -26
  43. package/server/git.js +53 -14
  44. package/server/idle.js +15 -13
  45. package/server/index.js +1286 -189
  46. package/server/launcher.js +122 -14
  47. package/server/machine.js +10 -1
  48. package/server/mirror.js +1048 -0
  49. package/server/prices.js +59 -0
  50. package/server/projects.js +57 -12
  51. package/server/servers.js +22 -1
  52. package/server/sessions.js +5 -5
  53. package/server/settings.js +25 -2
  54. package/server/update.js +324 -0
  55. package/server/usage.js +642 -0
  56. package/server/watcher.js +67 -13
  57. package/server/worktree.js +600 -0
  58. package/server/writer.js +98 -26
  59. package/web/awake.js +62 -0
  60. package/web/base.css +4 -0
  61. package/web/board.css +404 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/conf.js +118 -0
  65. package/web/files.css +206 -1
  66. package/web/files.html +10 -0
  67. package/web/files.js +533 -75
  68. package/web/fuzzy.js +73 -21
  69. package/web/index.html +109 -12
  70. package/web/json.js +145 -0
  71. package/web/list.css +536 -0
  72. package/web/list.js +1015 -0
  73. package/web/mentions.js +27 -9
  74. package/web/recency.js +10 -10
  75. package/web/units.js +1 -1
  76. package/web/usage.css +169 -0
  77. package/web/usage.html +78 -0
  78. package/web/usage.js +447 -0
  79. package/web/view.js +22 -3
  80. package/web/whatsnew.css +211 -0
  81. package/web/whatsnew.html +49 -0
  82. package/web/whatsnew.js +339 -0
package/web/usage.js ADDED
@@ -0,0 +1,447 @@
1
+ import { whileVisible } from '/awake.js'
2
+
3
+ // ── Usage ────────────────────────────────────────────────────────────────────
4
+ // What the Claude Code sessions consumed, a week at a time: tokens, the model and effort they ran
5
+ // on, the time Claude spent working, what it would have cost at the API price, and — when the
6
+ // statusline writes it down — how full each account's weekly limit got.
7
+ //
8
+ // The server reads all of it out of files already on this machine; this page only asks it for a
9
+ // span and draws what comes back. Nothing here, and nothing behind it, spends a token.
10
+
11
+ const $ = (s) => document.querySelector(s)
12
+
13
+ const api = async (url) => {
14
+ const r = await fetch(url)
15
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText)
16
+ return r.json()
17
+ }
18
+
19
+ const esc = (s) =>
20
+ String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
21
+
22
+ // ── Names and numbers ────────────────────────────────────────────────────────
23
+ /** `claude-sonnet-4-6` → `Sonnet 4.6`. Anything that is not shaped like that is shown as it is. */
24
+ function modelName(id) {
25
+ const m = /^claude-([a-z]+)-(\d+(?:-\d+)*)$/.exec(id || '')
26
+ if (!m) return id || 'unknown model'
27
+ return `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2].replace(/-/g, '.')}`
28
+ }
29
+
30
+ const AGENTS = { main: 'The conversation itself', sub: 'Subagents', workflow: 'Workflows (Ultracode)' }
31
+
32
+ const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
33
+ const money = (n) => (n >= 100 ? usd.format(Math.round(n)).replace(/\.00$/, '') : usd.format(n))
34
+
35
+ function tokens(n) {
36
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`
37
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
38
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`
39
+ return String(n)
40
+ }
41
+
42
+ function duration(ms) {
43
+ const m = Math.round(ms / 60000)
44
+ if (m < 60) return `${m} min`
45
+ return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}`
46
+ }
47
+
48
+ const dayFmt = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric' })
49
+ const whenFmt = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
50
+ const spanFmt = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short' })
51
+
52
+ const accountName = (email, uuid) => email || (uuid ? `account ${uuid.slice(0, 8)}` : 'Unknown account')
53
+
54
+ // ── Colours ──────────────────────────────────────────────────────────────────
55
+ // Fixed to the entity, so a model keeps its colour from one week to the next. Six slots; a
56
+ // seventh model is grey and says "other" in the legend rather than getting a made-up hue.
57
+ const MODEL_ORDER = [
58
+ 'claude-opus-5',
59
+ 'claude-sonnet-5',
60
+ 'claude-fable-5-1',
61
+ 'claude-opus-4-8',
62
+ 'claude-sonnet-4-6',
63
+ 'claude-haiku-4-5',
64
+ ]
65
+ const colourOf = (model) => {
66
+ const i = MODEL_ORDER.indexOf(model)
67
+ return i >= 0 ? `var(--s${i + 1})` : 'var(--other)'
68
+ }
69
+ // Accounts take the slots in the order they were first seen, which does not change either.
70
+ const accountColour = (uuid, known) => {
71
+ const i = known.findIndex((a) => a.uuid === uuid)
72
+ return i >= 0 && i < 6 ? `var(--s${i + 1})` : 'var(--other)'
73
+ }
74
+
75
+ // ── The span ─────────────────────────────────────────────────────────────────
76
+ const DAY = 86_400_000
77
+
78
+ /** Monday at midnight, here. The week is yours, so it is the browser that decides where it starts. */
79
+ function monday(at) {
80
+ const d = new Date(at)
81
+ d.setHours(0, 0, 0, 0)
82
+ d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
83
+ return d.getTime()
84
+ }
85
+ const plusDays = (at, n) => {
86
+ const d = new Date(at)
87
+ d.setDate(d.getDate() + n)
88
+ return d.getTime()
89
+ }
90
+
91
+ let from = monday(Date.now())
92
+ let account = null
93
+ let showAll = false
94
+ let last = null
95
+
96
+ try {
97
+ const kept = localStorage.getItem('k0-usage-account')
98
+ if (kept !== null) account = kept
99
+ } catch {
100
+ /* private window: the filter is simply not remembered */
101
+ }
102
+
103
+ async function load() {
104
+ const to = plusDays(from, 7)
105
+ const qs = new URLSearchParams({ from, to })
106
+ if (account !== null) qs.set('account', account)
107
+ try {
108
+ last = await api(`/api/usage?${qs}`)
109
+ render(last)
110
+ } catch (e) {
111
+ $('#notes').innerHTML = `<p class="note"><b>Couldn't load the usage:</b> ${esc(e.message)}</p>`
112
+ }
113
+ }
114
+
115
+ // ── Drawing ──────────────────────────────────────────────────────────────────
116
+ function render(d) {
117
+ const to = plusDays(from, 7)
118
+ $('#span').textContent = `${spanFmt.format(from)} – ${spanFmt.format(to - 1)} ${new Date(from).getFullYear()}`
119
+ $('#next').disabled = to > Date.now()
120
+ renderAccounts(d)
121
+ renderNotes(d)
122
+ renderTiles(d)
123
+ costChart(d)
124
+ limitsChart(d)
125
+ renderTables(d)
126
+ renderSessions(d)
127
+ }
128
+
129
+ function renderAccounts(d) {
130
+ const sel = $('#account')
131
+ const options = [['', 'All accounts', null]]
132
+ .concat(d.known.map((a) => [a.uuid, accountName(a.email, a.uuid), a.uuid]))
133
+ .concat([['unknown', 'Unknown account', '']])
134
+ const want = account === null ? '' : account === '' ? 'unknown' : account
135
+ sel.innerHTML = options.map(([v, label]) => `<option value="${esc(v)}">${esc(label)}</option>`).join('')
136
+ sel.value = want
137
+ }
138
+
139
+ function renderNotes(d) {
140
+ const notes = []
141
+ const r = d.tracking.reading
142
+ if (r.bytes > 0) {
143
+ notes.push(`<b>Still reading the history.</b> ${r.files} transcripts and ${tokens(r.bytes)}B to go; the numbers
144
+ grow as it catches up. It spends no tokens: it only reads files already on this machine.`)
145
+ }
146
+ if (!d.tracking.statusline) {
147
+ notes.push(`<b>No limit samples yet.</b> Claude Code only tells the statusline how full the five-hour and weekly
148
+ limits are, and keeps it nowhere else. Add the few lines from k0's README (<i>Usage → the limits</i>) to your
149
+ statusline and they are written to <code>~/.k0/usage/limits.jsonl</code> as you work.`)
150
+ }
151
+ if (d.totals.unpriced > 0) {
152
+ notes.push(`<b>${tokens(d.totals.unpriced)} tokens have no price.</b> They ran on a model k0's price list does not
153
+ know, so they count as tokens but not as dollars.`)
154
+ }
155
+ $('#notes').innerHTML = notes.map((n) => `<p class="note">${n}</p>`).join('')
156
+ }
157
+
158
+ function renderTiles(d) {
159
+ const t = d.totals
160
+ const now = latestFive(d)
161
+ const tiles = [
162
+ [money(t.cost), 'at API prices', 'what the week would have cost without a subscription'],
163
+ [tokens(t.tokens), 'tokens', `${tokens(t.output)} written by Claude, ${tokens(t.cache_read)} read back from cache`],
164
+ [duration(t.work_ms), 'Claude worked', `across ${t.sessions} session${t.sessions === 1 ? '' : 's'}`],
165
+ [String(t.requests), 'replies', `${tokens(t.thinking)} tokens of them thinking`],
166
+ [String(t.limits), 'times a limit stopped you', t.limits ? 'see the sessions below' : 'not once this week'],
167
+ ]
168
+ if (now) tiles.push([`${Math.round(now.five)}%`, 'of the five-hour limit, last seen', accountName(now.email, now.account)])
169
+ $('#tiles').innerHTML = tiles
170
+ .map(([v, k, s]) => `<div class="tile"><div class="v">${esc(v)}</div><div class="k">${esc(k)}</div><div class="s">${esc(s)}</div></div>`)
171
+ .join('')
172
+ }
173
+
174
+ function latestFive(d) {
175
+ let best = null
176
+ for (const series of d.limits) {
177
+ const s = series.samples.filter((x) => x.five != null).at(-1)
178
+ if (s && (!best || s.at > best.at)) best = { ...s, account: series.account, email: series.email }
179
+ }
180
+ return best
181
+ }
182
+
183
+ // Chart geometry, in the SVG's own units; it scales to the width of the panel.
184
+ const W = 900
185
+ const H = 240
186
+ const PAD = { l: 52, r: 12, t: 12, b: 26 }
187
+
188
+ function niceMax(v) {
189
+ if (!(v > 0)) return 1
190
+ const p = 10 ** Math.floor(Math.log10(v))
191
+ for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p
192
+ return 10 * p
193
+ }
194
+
195
+ function costChart(d) {
196
+ const models = [...new Set(d.days.flatMap((day) => Object.keys(day.models)))].sort(
197
+ (a, b) => (MODEL_ORDER.indexOf(a) + 1 || 99) - (MODEL_ORDER.indexOf(b) + 1 || 99)
198
+ )
199
+ $('#cost-legend').innerHTML =
200
+ models.length > 1
201
+ ? models.map((m) => `<span><i style="background:${colourOf(m)}"></i>${esc(modelName(m))}${MODEL_ORDER.includes(m) ? '' : ' (other)'}</span>`).join('')
202
+ : ''
203
+ const max = niceMax(Math.max(0, ...d.days.map((day) => day.cost)))
204
+ const iw = W - PAD.l - PAD.r
205
+ const ih = H - PAD.t - PAD.b
206
+ const slot = iw / d.days.length
207
+ const bw = Math.min(56, slot * 0.6)
208
+ const y = (v) => PAD.t + ih - (v / max) * ih
209
+ let svg = ''
210
+ for (let i = 0; i <= 4; i++) {
211
+ const v = (max / 4) * i
212
+ svg += `<line class="grid-line" x1="${PAD.l}" x2="${W - PAD.r}" y1="${y(v)}" y2="${y(v)}"/>`
213
+ svg += `<text class="axis" x="${PAD.l - 8}" y="${y(v) + 4}" text-anchor="end">${esc(money(v))}</text>`
214
+ }
215
+ d.days.forEach((day, i) => {
216
+ const x = PAD.l + slot * i + (slot - bw) / 2
217
+ let base = 0
218
+ const segs = models.filter((m) => day.models[m] > 0)
219
+ segs.forEach((m, j) => {
220
+ const v = day.models[m]
221
+ const top = y(base + v)
222
+ const h = Math.max(0, y(base) - top)
223
+ // A 2px gap of paper between segments, and the rounded end only on the top one.
224
+ const r = j === segs.length - 1 ? Math.min(4, h) : 0
225
+ svg += `<path class="seg" fill="${colourOf(m)}" d="${barPath(x, top, bw, Math.max(0, h - (j ? 2 : 0)), r)}"/>`
226
+ base += v
227
+ })
228
+ if (day.cost > 0) svg += `<text class="label" x="${x + bw / 2}" y="${y(day.cost) - 6}" text-anchor="middle">${esc(money(day.cost))}</text>`
229
+ svg += `<text class="axis" x="${x + bw / 2}" y="${H - 8}" text-anchor="middle">${esc(dayFmt.format(day.at))}</text>`
230
+ svg += `<rect class="hit" data-day="${i}" x="${PAD.l + slot * i}" y="${PAD.t}" width="${slot}" height="${ih}"/>`
231
+ })
232
+ if (!d.days.some((day) => day.tokens)) {
233
+ svg += `<text class="empty" x="${W / 2}" y="${H / 2}" text-anchor="middle">No session consumed anything this week.</text>`
234
+ }
235
+ const el = $('#cost-chart')
236
+ el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Cost per day by model">${svg}</svg>`
237
+ el.querySelectorAll('.hit').forEach((hit) => {
238
+ const day = d.days[Number(hit.dataset.day)]
239
+ tipOn(hit, () => {
240
+ const rows = models
241
+ .filter((m) => day.models[m] > 0)
242
+ .reverse()
243
+ .map((m) => `<div><i style="background:${colourOf(m)}"></i>${esc(modelName(m))} ${esc(money(day.models[m]))}</div>`)
244
+ return `<b>${esc(dayFmt.format(day.at))} · ${esc(money(day.cost))}</b><div>${tokens(day.tokens)} tokens, ${day.requests} replies</div>${rows.join('')}`
245
+ })
246
+ })
247
+ }
248
+
249
+ /** A bar with only its top corners rounded: the bottom sits on the baseline. */
250
+ function barPath(x, y, w, h, r) {
251
+ if (h <= 0) return ''
252
+ const b = y + h
253
+ return `M${x},${b}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${b}Z`
254
+ }
255
+
256
+ function limitsChart(d) {
257
+ const to = plusDays(from, 7)
258
+ const series = d.limits.map((s) => ({ ...s, samples: s.samples.filter((x) => x.week != null) })).filter((s) => s.samples.length)
259
+ $('#limits-caption').textContent = series.length
260
+ ? 'Per account, as the statusline saw it after each reply. It falls back to zero when the week resets.'
261
+ : 'Nothing to draw yet: the statusline has not written any samples for this week.'
262
+ $('#limits-legend').innerHTML =
263
+ series.length > 1
264
+ ? series.map((s) => `<span><i style="background:${accountColour(s.account, d.known)}"></i>${esc(accountName(s.email, s.account))}</span>`).join('')
265
+ : ''
266
+ const iw = W - PAD.l - PAD.r
267
+ const ih = H - PAD.t - PAD.b
268
+ const x = (at) => PAD.l + ((Math.min(Math.max(at, from), to) - from) / (to - from)) * iw
269
+ const y = (v) => PAD.t + ih - (Math.min(100, Math.max(0, v)) / 100) * ih
270
+ let svg = ''
271
+ for (const v of [0, 25, 50, 75, 100]) {
272
+ svg += `<line class="grid-line" x1="${PAD.l}" x2="${W - PAD.r}" y1="${y(v)}" y2="${y(v)}"/>`
273
+ svg += `<text class="axis" x="${PAD.l - 8}" y="${y(v) + 4}" text-anchor="end">${v}%</text>`
274
+ }
275
+ for (let i = 0; i < 7; i++) {
276
+ const at = plusDays(from, i)
277
+ svg += `<text class="axis" x="${x(at + DAY / 2)}" y="${H - 8}" text-anchor="middle">${esc(dayFmt.format(at))}</text>`
278
+ }
279
+ const dots = []
280
+ for (const s of series) {
281
+ const colour = accountColour(s.account, d.known)
282
+ // The sample before Monday sets where the line starts; it is drawn at the left edge.
283
+ const pts = s.samples.map((p) => [x(p.at), y(p.week), p])
284
+ svg += `<polyline class="line" stroke="${colour}" points="${pts.map(([a, b]) => `${a},${b}`).join(' ')}"/>`
285
+ const end = pts.at(-1)
286
+ svg += `<circle class="dot" cx="${end[0]}" cy="${end[1]}" r="4.5" fill="${colour}"/>`
287
+ svg += `<text class="label" x="${Math.min(end[0] + 8, W - PAD.r - 30)}" y="${end[1] - 8}">${Math.round(end[2].week)}%</text>`
288
+ for (const p of pts) if (p[2].at >= from) dots.push({ x: p[0], y: p[1], p: p[2], s, colour })
289
+ }
290
+ const el = $('#limits-chart')
291
+ el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Weekly limit used, per account">${svg}
292
+ <rect class="hit" id="limits-hit" x="${PAD.l}" y="${PAD.t}" width="${iw}" height="${ih}"/></svg>`
293
+ // A crosshair's worth of help without the crosshair: the sample nearest the pointer.
294
+ const hit = el.querySelector('#limits-hit')
295
+ if (!dots.length) return
296
+ tipOn(hit, (ev) => {
297
+ const box = hit.ownerSVGElement.getBoundingClientRect()
298
+ const px = ((ev.clientX - box.left) / box.width) * W
299
+ const near = dots.reduce((a, b) => (Math.abs(b.x - px) < Math.abs(a.x - px) ? b : a))
300
+ const five = near.p.five != null ? `, five-hour ${Math.round(near.p.five)}%` : ''
301
+ return `<b><i style="background:${near.colour}"></i>${esc(accountName(near.s.email, near.s.account))}</b><div>${esc(whenFmt.format(near.p.at))}</div><div>week ${Math.round(near.p.week)}%${five}</div>`
302
+ })
303
+ }
304
+
305
+ // ── The tooltip ──────────────────────────────────────────────────────────────
306
+ const tip = $('#tip')
307
+ function tipOn(el, html) {
308
+ el.addEventListener('mousemove', (ev) => {
309
+ tip.innerHTML = html(ev)
310
+ tip.hidden = false
311
+ const r = tip.getBoundingClientRect()
312
+ tip.style.left = `${Math.min(ev.clientX + 14, innerWidth - r.width - 8)}px`
313
+ tip.style.top = `${Math.min(ev.clientY + 14, innerHeight - r.height - 8)}px`
314
+ })
315
+ el.addEventListener('mouseleave', () => (tip.hidden = true))
316
+ }
317
+
318
+ // ── Tables ───────────────────────────────────────────────────────────────────
319
+ const head = (cols) => `<thead><tr>${cols.map(([t, n]) => `<th${n ? ' class="n"' : ''}>${esc(t)}</th>`).join('')}</tr></thead>`
320
+ const empty = (span) => `<tr><td colspan="${span}" class="muted">Nothing this week.</td></tr>`
321
+
322
+ function share(v, total) {
323
+ return total > 0 ? `${Math.round((v / total) * 100)}%` : '—'
324
+ }
325
+
326
+ function renderTables(d) {
327
+ const total = d.totals.cost
328
+ $('#t-models').innerHTML =
329
+ head([['Model'], ['Effort'], ['Replies', 1], ['Tokens', 1], ['Cost', 1], ['Share', 1]]) +
330
+ `<tbody>${
331
+ d.models
332
+ .map(
333
+ (m) => `<tr><td><span class="swatch" style="background:${colourOf(m.model)}"></span>${esc(modelName(m.model))}${
334
+ m.speed === 'fast' ? ' <span class="muted">fast</span>' : ''
335
+ }</td><td>${esc(m.effort || '—')}</td><td class="n">${m.requests}</td><td class="n">${tokens(m.tokens)}</td><td class="n">${
336
+ m.unpriced ? '?' : esc(money(m.cost))
337
+ }</td><td class="n">${share(m.cost, total)}</td></tr>`
338
+ )
339
+ .join('') || empty(6)
340
+ }</tbody>`
341
+
342
+ $('#t-agents').innerHTML =
343
+ head([['Who'], ['Replies', 1], ['Tokens', 1], ['Cost', 1], ['Share', 1]]) +
344
+ `<tbody>${
345
+ d.agents
346
+ .map(
347
+ (a) => `<tr><td>${esc(AGENTS[a.agent] ?? a.agent)}</td><td class="n">${a.requests}</td><td class="n">${tokens(a.tokens)}</td><td class="n">${esc(
348
+ money(a.cost)
349
+ )}</td><td class="n">${share(a.cost, total)}</td></tr>`
350
+ )
351
+ .join('') || empty(5)
352
+ }</tbody>`
353
+
354
+ $('#t-repos').innerHTML =
355
+ head([['Repository'], ['Tokens', 1], ['Cost', 1], ['Share', 1]]) +
356
+ `<tbody>${
357
+ d.repos
358
+ .map(
359
+ (r) => `<tr><td>${esc(r.project ?? 'outside any repository')}</td><td class="n">${tokens(r.tokens)}</td><td class="n">${esc(
360
+ money(r.cost)
361
+ )}</td><td class="n">${share(r.cost, total)}</td></tr>`
362
+ )
363
+ .join('') || empty(4)
364
+ }</tbody>`
365
+
366
+ // Always every account, whatever the filter says: this table is how you choose one.
367
+ const all = d.accounts.reduce((x, a) => x + a.cost, 0)
368
+ $('#t-accounts').innerHTML =
369
+ head([['Account'], ['Tokens', 1], ['Cost', 1], ['Share', 1]]) +
370
+ `<tbody>${
371
+ d.accounts
372
+ .map(
373
+ (a) => `<tr><td><span class="swatch" style="background:${a.uuid ? accountColour(a.uuid, d.known) : 'var(--other)'}"></span>${esc(
374
+ accountName(a.email, a.uuid)
375
+ )}${a.uuid ? '' : '<span class="sub">before k0 was watching which account was signed in</span>'}</td><td class="n">${tokens(
376
+ a.tokens
377
+ )}</td><td class="n">${esc(money(a.cost))}</td><td class="n">${share(a.cost, all)}</td></tr>`
378
+ )
379
+ .join('') || empty(4)
380
+ }</tbody>`
381
+ }
382
+
383
+ const LIMIT = 40
384
+
385
+ function renderSessions(d) {
386
+ const list = showAll ? d.sessions : d.sessions.slice(0, LIMIT)
387
+ const rows = list.map((s) => {
388
+ const name = s.title || `Session ${s.session_id.slice(0, 8)}`
389
+ const title = s.story
390
+ ? `<a href="/#story-${s.story.id}"><span class="key">${esc(s.story.key)}</span>${esc(name)}</a>`
391
+ : esc(name)
392
+ const models = Object.entries(s.models)
393
+ .sort((a, b) => b[1] - a[1])
394
+ .map(([m]) => {
395
+ const [id, ...rest] = m.split(' · ')
396
+ return esc([modelName(id), ...rest].join(' · '))
397
+ })
398
+ .join('<br>')
399
+ const helpers = (s.agents.sub ?? 0) + (s.agents.workflow ?? 0)
400
+ const via = helpers > 0 && s.cost > 0 ? `<span class="sub">${share(helpers, s.cost)} by agents</span>` : ''
401
+ return `<tr>
402
+ <td>${title}<span class="sub">${esc(s.project ?? '')}${s.accounts.length ? ` · ${esc(s.accounts.join(', '))}` : ''}</span></td>
403
+ <td>${esc(whenFmt.format(s.first_at))}</td>
404
+ <td>${models}</td>
405
+ <td class="n">${s.work_ms ? duration(s.work_ms) : '—'}</td>
406
+ <td class="n">${tokens(s.tokens)}</td>
407
+ <td class="n">${s.unpriced && !s.cost ? '?' : esc(money(s.cost))}${via}</td>
408
+ <td class="n">${s.week_pct != null ? `≈ ${s.week_pct.toFixed(1)}%` : '—'}</td>
409
+ <td class="n">${s.limits ? `<span class="warn">${s.limits}</span>` : ''}</td>
410
+ </tr>`
411
+ })
412
+ $('#t-sessions').innerHTML =
413
+ head([['Session'], ['Started'], ['Model · effort'], ['Worked', 1], ['Tokens', 1], ['Cost', 1], ['Of the week', 1], ['Stopped', 1]]) +
414
+ `<tbody>${rows.join('') || empty(8)}</tbody>`
415
+ const more = $('#more')
416
+ more.hidden = d.sessions.length <= LIMIT
417
+ more.textContent = showAll ? `Show the ${LIMIT} most expensive` : `Show all ${d.sessions.length} sessions`
418
+ }
419
+
420
+ // ── Controls ─────────────────────────────────────────────────────────────────
421
+ $('#prev').onclick = () => {
422
+ from = plusDays(from, -7)
423
+ load()
424
+ }
425
+ $('#next').onclick = () => {
426
+ from = plusDays(from, 7)
427
+ load()
428
+ }
429
+ $('#account').onchange = (e) => {
430
+ const v = e.target.value
431
+ account = v === '' ? null : v === 'unknown' ? '' : v
432
+ try {
433
+ if (account === null) localStorage.removeItem('k0-usage-account')
434
+ else localStorage.setItem('k0-usage-account', account)
435
+ } catch {
436
+ /* not remembered, still applied */
437
+ }
438
+ load()
439
+ }
440
+ $('#more').onclick = () => {
441
+ showAll = !showAll
442
+ if (last) renderSessions(last)
443
+ }
444
+
445
+ load()
446
+ // The numbers move once a minute at most: the server reads the transcripts at that pace.
447
+ whileVisible(load, 30_000)
package/web/view.js CHANGED
@@ -32,6 +32,15 @@ let held = false // a button is down: the board stands still, the click has to l
32
32
  let frame = 0 // the requestAnimationFrame handle, 0 = stopped
33
33
  let last = 0
34
34
 
35
+ /**
36
+ * Whether the board is currently a list, in which case none of this applies.
37
+ *
38
+ * The list is a page: it scrolls, it has bars, and the wheel belongs to the browser. The class is
39
+ * put on by `render` and it is the one thing this module asks about the outside world — the
40
+ * alternative was a second switch to keep in step with the one the stylesheet already reads.
41
+ */
42
+ const scrolling = () => board?.classList.contains('list')
43
+
35
44
  // ── Limits and drawing ────────────────────────────────────────────────
36
45
  const maxX = () => contentW * view.z - box.w
37
46
  const maxY = () => contentH * view.z - box.h
@@ -100,7 +109,12 @@ function load() {
100
109
  * because there neither component is zero.
101
110
  */
102
111
  function velocity() {
103
- if (held || !inside || document.querySelector('dialog[open]')) return [0, 0]
112
+ // A row being dragged into another place in the order is a button held down, and the view has to
113
+ // stand still for it exactly as it does for one — but a native drag is not a pointer gesture: the
114
+ // browser cancels the pointer when it starts and reports no move until it ends, so `held` goes
115
+ // false and the last position before the drag stands. Without this the board slides under
116
+ // whatever is in the air, on the reading of where the pointer was a second ago.
117
+ if (scrolling() || held || !inside || document.querySelector('dialog[open], .dragging')) return [0, 0]
104
118
  const ramp = (d) => {
105
119
  const t = 1 - d / EDGE
106
120
  return t > 0 ? t * t : 0
@@ -175,7 +189,9 @@ function fit() {
175
189
  // ── Wiring ────────────────────────────────────────────────────────────
176
190
  /** The content changed: remeasure, come back inside the limits, and redraw. */
177
191
  export function refit() {
178
- if (!vp) return
192
+ // Nothing to fit on a page that scrolls, and measuring one would write a view — where the board
193
+ // is and how far it is zoomed — taken off a shape the board is not currently in.
194
+ if (!vp || scrolling()) return
179
195
  measure()
180
196
  clamp()
181
197
  apply()
@@ -205,7 +221,7 @@ export function initView() {
205
221
  wake()
206
222
  })
207
223
  // Outside the page, or with a button held, the board stands still: that way a click near the
208
- // edge lands instead of chasing a card that is running away. A pointer that has left the window
224
+ // edge lands instead of chasing a post-it that is running away. A pointer that has left the window
209
225
  // stops reporting, and without this the last report would stand — "I am at the edge" — and the
210
226
  // board would scroll forever on its own.
211
227
  document.documentElement.addEventListener('pointerleave', () => (inside = false))
@@ -225,6 +241,9 @@ export function initView() {
225
241
  vp.addEventListener(
226
242
  'wheel',
227
243
  (e) => {
244
+ // On the list the wheel is the browser's: the page scrolls, and taking the event would leave
245
+ // it unable to.
246
+ if (scrolling()) return
228
247
  e.preventDefault()
229
248
  const k = e.deltaMode === 1 ? 16 : 1 // some mice count lines, not pixels
230
249
  if (e.ctrlKey) {