k0-board 0.4.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.
- package/.claude/skills/k0-changelog/SKILL.md +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +361 -1
- package/README.md +931 -111
- package/bin/k0-board.js +79 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +30 -5
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +2 -1
- package/platform/linux/index.js +3 -0
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +5 -2
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1251 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +396 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/files.js +73 -16
- package/web/index.html +109 -12
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|