@threadcrumbs/common 0.1.4 → 0.1.6

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.
@@ -98,8 +98,10 @@ adapter in `../harnesses/`, so a session has the same id in both apps. Add the h
98
98
  | `error` | `turn-failed` | The last turn ended in an error | All but Hermes |
99
99
  | `done` | `unread` | The harness records that you haven't read the reply | Claude Code (the app's record), Codex, Hermes, OpenCode 2.x |
100
100
  | | `recent` | No record of reading, and the turn ended in the last 30 minutes | All |
101
+ | | `process-idle` | The live process says it's idle, with no record of reading, and the turn ended in the last 30 minutes | Claude Code |
101
102
  | `idle` | `read` | The harness records that you've read the reply | Claude Code (the app's record), Codex, Hermes, OpenCode 2.x |
102
103
  | | `quiet` | No record of reading, and nothing for 30 minutes | All |
104
+ | | `process-idle` | The live process says it's idle, with no record of reading, and nothing for 30 minutes | Claude Code |
103
105
 
104
106
  ### What a thread needs
105
107
 
@@ -158,6 +160,15 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
158
160
  (`startCall`, `endCall`, `toolTarget`, `keepNewest`, `fileList`, and the limits) are in
159
161
  `../lib/telemetry-fields.mjs`.
160
162
 
163
+ `sessionDetail(id, { filesOnly: true })` returns only `{ id, sessionId, files }`, with each file
164
+ as `{ path }`, from what the session scan already holds. It's for finding other threads that
165
+ changed the same files, so it never opens the readers that serve an open thread.
166
+
167
+ The readers that serve an open thread (thoughts and replies for Claude Code, Codex, Cursor, and
168
+ Antigravity) are kept for the last three sessions opened. Claude Code's and Codex's read the
169
+ transcript from its end first, in growing steps, until they have the newest 50 replies, and then
170
+ follow it forwards.
171
+
161
172
  ### Context breakdown
162
173
 
163
174
  `contextBreakdown` splits the context into parts, so an app can show what fills it.
@@ -170,8 +181,8 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
170
181
  them aren't read, so an app should call it the default. Free space is `limit - total -
171
182
  reserved`.
172
183
  - **`parts`** are `[{ id, tokens, items }]` in this order: `messages`, `system`,
173
- `builtInTools`, `mcpTools`, `skills`, `memory`. A part a harness doesn't let us size is left
174
- out, and its tokens stay in `system`.
184
+ `builtInTools`, `mcpTools`, `skills`, `memory`. A part the harness records is sent even at 0
185
+ tokens; one it doesn't record is left out, and its tokens stay in `system`.
175
186
  - `messages` items: `{ id: 'prompts' }`, `{ id: 'replies' }`, `{ id: 'summary' }` (from the
176
187
  last compaction), and `{ id: 'tool', name }` for each tool's calls and results.
177
188
  - `mcpTools` items: `{ name, tools }` per server, `tools` a count or `null`.
@@ -183,6 +194,11 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
183
194
  - That gives a characters-per-token ratio for the text of each fixed part (system prompt,
184
195
  tool definitions, MCP servers, skills, memory files), so each part is its text length at
185
196
  that ratio. A part added later counts at the same ratio.
197
+ - The ratio is used only when the system prompt and tool definitions were recorded, as in
198
+ Claude Code's `api-request-shape`. Otherwise it would spread their unrecorded text over the
199
+ parts that were, so each recorded part is its text at 4 characters a token, and `system` is
200
+ the rest of the fixed count. That's always the case for Codex, whose built-in tool
201
+ definitions aren't written.
186
202
  - `messages` is the rest of the total. Each request's growth over the one before is split
187
203
  among the prompts, replies, and tool calls and results since then, by text length. After a
188
204
  compaction, the next request's growth over the fixed parts is the summary.
@@ -200,8 +216,8 @@ Again, a `null` field means the harness doesn't record it. The shared helpers fo
200
216
  - Codex: the rollout's base instructions and developer messages, its AGENTS.md messages, and
201
217
  its `additional_tools` items. Its built-in tool definitions aren't written, so they're in
202
218
  `system`.
203
- - **Read only for an open thread,** by the same second reader as thoughts and replies. It keeps
204
- numbers and names, never the text.
219
+ - **Its sizes are kept by the session scan,** which already reads every record, as numbers and
220
+ names only, never the text, so opening a thread doesn't read its transcript again.
205
221
 
206
222
  ### Tool kinds
207
223
 
@@ -252,9 +252,10 @@ export function antigravity({
252
252
  return sessions
253
253
  }
254
254
 
255
- async function sessionDetail(key) {
255
+ async function sessionDetail(key, { filesOnly = false } = {}) {
256
256
  const id = String(key).replace(/^antigravity:/, '')
257
257
  if (!known.has(id)) return null
258
+ if (filesOnly) return { id: `antigravity:${id}`, sessionId: id, files: null }
258
259
  const sessionDir = path.join(brainDir, id)
259
260
  const found = await transcriptOf(sessionDir)
260
261
  const state = (found && (await transcripts.read(found.file, found.stat).catch(() => null))) || emptyTranscript()
@@ -6,7 +6,8 @@ import { awaitedPeers, readLiveSessions, transcriptDetail, transcriptReader, tur
6
6
  import { exists, listDirs, listFiles, num } from '../lib/fsutil.mjs'
7
7
  import { contextBreakdown } from '../lib/context-breakdown.mjs'
8
8
  import { recentReaders } from '../lib/jsonl-follower.mjs'
9
- import { DETAIL_LIMITS, RECENT_MS, askOf, number, planSummary, sampleList, text, titleFromPrompt, toolNow } from '../lib/telemetry-fields.mjs'
9
+ import { nameKind } from '../lib/tool-kinds.mjs'
10
+ import { DETAIL_LIMITS, RECENT_MS, askOf, filePaths, number, planSummary, sampleList, text, titleFromPrompt, toolNow } from '../lib/telemetry-fields.mjs'
10
11
 
11
12
  export { nextPlan } from '../lib/telemetry-fields.mjs'
12
13
 
@@ -27,10 +28,10 @@ export function claudeCode({
27
28
  const projectsDir = path.join(home, 'projects')
28
29
  const liveDir = path.join(home, 'sessions')
29
30
  const catalogDir = path.join(home, 'cache', 'model-catalog')
30
- const transcripts = transcriptReader({ detail: true })
31
+ const transcripts = transcriptReader({ detail: true, sizes: true })
31
32
  const subagentFiles = transcriptReader()
32
33
  const subagentDetails = transcriptReader({ detail: true, words: true })
33
- const openedFor = recentReaders(() => transcriptReader({ words: true, sizes: true }), OPENED_KEPT)
34
+ const openedFor = recentReaders(() => transcriptReader({ words: true, tail: true }), OPENED_KEPT)
34
35
  const desktopRecords = desktopRecordReader()
35
36
  const olderReaders = new Map()
36
37
  const metas = new Map()
@@ -219,11 +220,11 @@ export function claudeCode({
219
220
  const read = await openedFor(file).read(file, stat).catch(() => null)
220
221
  const limit = contextLimit(limits, state.model)
221
222
  const reserved = limit ? Math.min(RESERVED_TOKENS, limit) : RESERVED_TOKENS
222
- return { words: read?.words ?? null, contextBreakdown: contextBreakdown(read?.sizes, { total: state.context, limit, reserved }) }
223
+ return { words: read?.words ?? null, contextBreakdown: contextBreakdown(state.sizes, { total: state.context, limit, reserved }) }
223
224
  }
224
225
 
225
226
  function olderReader(file) {
226
- const reader = olderReaders.get(file) || transcriptReader({ detail: true })
227
+ const reader = olderReaders.get(file) || transcriptReader({ detail: true, sizes: true })
227
228
  olderReaders.delete(file)
228
229
  olderReaders.set(file, reader)
229
230
  while (olderReaders.size > OLDER_KEPT) olderReaders.delete(olderReaders.keys().next().value)
@@ -252,13 +253,14 @@ export function claudeCode({
252
253
  }
253
254
  }
254
255
 
255
- async function sessionDetail(sessionKey, { subagent = null } = {}) {
256
+ async function sessionDetail(sessionKey, { subagent = null, filesOnly = false } = {}) {
256
257
  const id = String(sessionKey).replace(/^claude-code:/, '')
257
258
  const file = known.get(id)
258
259
  const stat = file ? await fsp.stat(file).catch(() => null) : null
259
260
  if (!stat) return null
260
261
  if (subagent != null) return subagentDetail(file, id, String(subagent))
261
262
  const state = await transcripts.read(file, stat)
263
+ if (filesOnly) return { id: `claude-code:${id}`, sessionId: id, files: filePaths(state.detail.files, state.cwd) }
262
264
  const said = await opened(file, stat, state, await contextLimits())
263
265
  return {
264
266
  id: `claude-code:${id}`,
@@ -332,15 +334,19 @@ function statusOf({ running, state, calls, subagents, awaiting, lastAt, mark, un
332
334
  const { failed, onAgents: onlyAgents } = turnEnding(state)
333
335
  const blocked = mark?.outcome === 'blocked'
334
336
  const failedTurn = failed || Boolean(mark?.failed)
335
- const quiet = () => (unread ? ['done', 'unread'] : ['idle', mark ? 'read' : 'quiet'])
337
+ const settled = running?.status === 'idle'
338
+ const quiet = () => (unread ? ['done', 'unread'] : ['idle', mark ? 'read' : settled ? 'process-idle' : 'quiet'])
336
339
  if (running) {
337
- if (running.status === 'waiting') return ['asking', calls.some((c) => c.questions) ? 'question' : calls.length ? 'approval' : 'waiting']
340
+ if (running.status === 'waiting') {
341
+ const asked = calls.filter((c) => (c.kind ?? nameKind(c.name)) !== 'agent')
342
+ return ['asking', asked.some((c) => c.questions) ? 'question' : asked.length ? 'approval' : 'waiting']
343
+ }
338
344
  const busy = running.status ? running.status === 'busy' : midTurn
339
345
  if (busy) return onlyAgents ? ['delegating', 'subagents'] : ['working', running.status ? 'process-busy' : 'turn-open']
340
346
  if (subagents || awaiting.length) return ['delegating', subagents ? 'subagents' : 'awaiting']
341
347
  if (blocked) return ['asking', 'marked-blocked']
342
348
  if (failedTurn) return ['error', 'turn-failed']
343
- if (!unread && now - lastAt < ACTIVE_WINDOW_MS) return ['done', 'recent']
349
+ if (!unread && now - lastAt < ACTIVE_WINDOW_MS) return ['done', settled ? 'process-idle' : 'recent']
344
350
  return quiet()
345
351
  }
346
352
  if (midTurn && now - lastAt < STALE_TURN_MS) return onlyAgents ? ['delegating', 'subagents'] : ['working', 'turn-open']
@@ -364,7 +370,8 @@ function toSession({ id, state, stat, running, subagents, workingSubagents, awai
364
370
  const unread = Boolean(mark) && (state.handedBackAt || mark.activeAt) > mark.focusedAt
365
371
  const calls = [...state.open.values()].sort((a, b) => a.startedAt - b.startedAt)
366
372
  const [status, statusReason] = statusOf({ running, state, calls, subagents, awaiting, lastAt: lastActivityAt, mark, unread, now })
367
- const errorText = state.detail?.errors.at(-1)?.message || mark?.errorText || ''
373
+ const markFailed = Boolean(mark?.failed) && !turnEnding(state).failed
374
+ const errorText = (markFailed && mark.errorText) || state.detail?.errors.at(-1)?.message || mark?.errorText || ''
368
375
  const midTurn = Boolean(state.ending && !state.ending.handedBack)
369
376
  const turnOpen = midTurn && (status === 'working' || status === 'delegating' || status === 'asking')
370
377
  const { projectPath, project, worktree } = projectOf(mark?.cwd || state.firstCwd || state.cwd, mark?.originCwd)
@@ -19,6 +19,7 @@ import {
19
19
  emptySample,
20
20
  endCall,
21
21
  fileList,
22
+ filePaths,
22
23
  inputKind,
23
24
  joinedText,
24
25
  keepNewest,
@@ -34,6 +35,7 @@ import {
34
35
  startCall,
35
36
  text,
36
37
  textList,
38
+ wordsFull,
37
39
  titleFromPrompt,
38
40
  toolInput,
39
41
  toolNow,
@@ -43,6 +45,7 @@ import {
43
45
  const ACTIVE_WINDOW_MS = 30 * 60 * 1000
44
46
  const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
45
47
  const OPENED_KEPT = 3
48
+ const TAIL_BYTES = 512 * 1024
46
49
  const COMPACT_SHARE = 0.9
47
50
  const REPORTED_SHARE = 0.95
48
51
  const ROLLOUT_FILE = /^rollout-.{19}-([0-9a-f-]{36})(?:_[0-9a-f-]{36})?\.jsonl$/i
@@ -143,6 +146,7 @@ function emptyRollout() {
143
146
  fallbackPrompts: 0,
144
147
  open: false,
145
148
  turnStartedAt: 0,
149
+ turnId: '',
146
150
  usageLimits: null,
147
151
  failed: false,
148
152
  calls: new Map(),
@@ -151,6 +155,8 @@ function emptyRollout() {
151
155
  tokens: null,
152
156
  context: null,
153
157
  contextSample: emptySample(),
158
+ sizes: emptySizes(),
159
+ sent: emptySent(),
154
160
  plan: null,
155
161
  history: [],
156
162
  contextParts: null,
@@ -166,13 +172,19 @@ function emptyRollout() {
166
172
 
167
173
  const AGENTS_FILE = /^# AGENTS\.md instructions for ([^\n]+)/
168
174
 
169
- const emptyOpened = () => ({
170
- lastAt: 0,
171
- thoughts: [],
172
- replies: [],
173
- sizes: emptySizes(),
174
- sent: { base: 0, developer: [], other: 0, memory: new Map(), tools: new Map() },
175
- })
175
+ const emptyWords = () => ({ lastAt: 0, thoughts: [], replies: [] })
176
+
177
+ function applyWords(state, record) {
178
+ const at = Date.parse(record.timestamp) || state.lastAt
179
+ state.lastAt = at
180
+ const item = record.payload
181
+ if (record.type !== 'response_item' || !item || typeof item !== 'object') return
182
+ const id = text(item.id) || null
183
+ if (item.type === 'reasoning') keepText(state.thoughts, 'thoughts', { id, at, text: joinedText(item.summary, 'summary_text', 'text') })
184
+ else if (item.type === 'message' && item.role === 'assistant') keepText(state.replies, 'replies', { id, at, text: joinedText(item.content, 'output_text', 'text') })
185
+ }
186
+
187
+ const emptySent = () => ({ base: 0, developer: [], other: 0, memory: new Map(), tools: new Map() })
176
188
 
177
189
  function callName(item) {
178
190
  if (item.type === 'local_shell_call') return 'shell'
@@ -186,14 +198,11 @@ function codexFixed(sent) {
186
198
  fixed.system = developer + (sent.developer.includes(sent.base) ? 0 : sent.base) + sent.other
187
199
  fixed.mcp = new Map(sent.tools)
188
200
  fixed.memory = new Map(sent.memory)
201
+ fixed.recorded = new Set(['system', 'mcpTools', 'memory'])
189
202
  return fixed
190
203
  }
191
204
 
192
- function applyOpened(state, record) {
193
- const at = Date.parse(record.timestamp) || state.lastAt
194
- state.lastAt = at
195
- const item = record.payload
196
- if (!item || typeof item !== 'object') return
205
+ function applySizes(state, record, item, at) {
197
206
  const { sizes, sent } = state
198
207
  const before = sizes.first == null
199
208
  if (record.type === 'session_meta') {
@@ -207,14 +216,10 @@ function applyOpened(state, record) {
207
216
  } else if (record.type !== 'response_item') {
208
217
  return
209
218
  } else if (item.type === 'reasoning') {
210
- const said = joinedText(item.summary, 'summary_text', 'text')
211
- keepText(state.thoughts, 'thoughts', { id: text(item.id) || null, at, text: said })
212
- sizeReply(sizes, said.length)
219
+ sizeReply(sizes, joinedText(item.summary, 'summary_text', 'text').length)
213
220
  return
214
221
  } else if (item.type === 'message' && item.role === 'assistant') {
215
- const said = joinedText(item.content, 'output_text', 'text')
216
- keepText(state.replies, 'replies', { id: text(item.id) || null, at, text: said })
217
- sizeReply(sizes, said.length)
222
+ sizeReply(sizes, joinedText(item.content, 'output_text', 'text').length)
218
223
  return
219
224
  } else if (CALLS.has(item.type)) {
220
225
  sizeCall(sizes, text(item.call_id), callName(item), (text(item.arguments) || text(item.input) || JSON.stringify(item.action ?? '')).length)
@@ -230,7 +235,7 @@ function applyOpened(state, record) {
230
235
  } else if (item.type === 'message') {
231
236
  const raw = contentText(item.content)
232
237
  const agents = item.role === 'user' ? AGENTS_FILE.exec(raw) : null
233
- if (agents) sent.memory.set(`${agents[1].trim().replace(/[\/]+$/, '')}/AGENTS.md`, raw.length)
238
+ if (agents) sent.memory.set(`${agents[1].trim().replace(/[\\/]+$/, '')}/AGENTS.md`, raw.length)
234
239
  else if (item.role === 'user' && promptText(raw)) {
235
240
  sizePrompt(sizes, raw.length)
236
241
  return
@@ -253,6 +258,7 @@ function applyRecord(state, record) {
253
258
  }
254
259
  const payload = record.payload
255
260
  if (!payload || typeof payload !== 'object') return
261
+ applySizes(state, record, payload, at || state.lastAt)
256
262
  if (record.type === 'session_meta') applyMeta(state, payload)
257
263
  else if (record.type === 'turn_context') applySettings(state, payload.model, payload.effort, payload.cwd)
258
264
  else if (record.type === 'response_item') applyItem(state, payload, at || state.lastAt)
@@ -367,13 +373,16 @@ function addLines(state, patch) {
367
373
  function applyEvent(state, event, at) {
368
374
  switch (event.type) {
369
375
  case 'task_started':
370
- case 'turn_started':
371
- if (!state.open) state.turnStartedAt = at
376
+ case 'turn_started': {
377
+ const turn = text(event.turn_id)
378
+ if (!state.open || (turn && turn !== state.turnId)) state.turnStartedAt = at
379
+ if (turn) state.turnId = turn
372
380
  state.open = true
373
381
  state.failed = false
374
382
  state.calls.clear()
375
383
  settleCalls(state.activity)
376
384
  break
385
+ }
377
386
  case 'task_complete':
378
387
  case 'turn_complete':
379
388
  state.open = false
@@ -468,7 +477,7 @@ export function codex({
468
477
  const locksDir = path.join(home, 'thread-writer-locks')
469
478
  const indexFile = path.join(home, 'session_index.jsonl')
470
479
  const rollouts = jsonlFollower({ empty: emptyRollout, apply: applyRecord })
471
- const openedFor = recentReaders(() => jsonlFollower({ empty: emptyOpened, apply: applyOpened }), OPENED_KEPT)
480
+ const openedFor = recentReaders(() => jsonlFollower({ empty: emptyWords, apply: applyWords, tail: { bytes: TAIL_BYTES, enough: wordsFull } }), OPENED_KEPT)
472
481
  let index = { mtime: -1, names: new Map() }
473
482
  let known = new Map()
474
483
  let childrenOf = new Map()
@@ -576,11 +585,12 @@ export function codex({
576
585
  return state ? { file, stat, state } : null
577
586
  }
578
587
 
579
- async function sessionDetail(key) {
588
+ async function sessionDetail(key, { filesOnly = false } = {}) {
580
589
  const id = String(key).replace(/^codex:/, '').toLowerCase()
581
590
  const found = await readKnown(id)
582
591
  if (!found || found.state.subagent || found.state.internal) return null
583
592
  const { file, stat, state } = found
593
+ if (filesOnly) return { id: `codex:${id}`, sessionId: id, files: filePaths(state.files, state.cwd) }
584
594
  const said = await openedFor(file).read(file, stat).catch(() => null)
585
595
  const limit = state.context?.limit || null
586
596
  const subagents = []
@@ -617,7 +627,7 @@ export function codex({
617
627
  prompts: (state.typed ? state.promptList : state.fallbackPromptList).map((p) => ({ ...p })),
618
628
  thoughts: textList(said?.thoughts),
619
629
  replies: textList(said?.replies),
620
- contextBreakdown: contextBreakdown(said?.sizes, {
630
+ contextBreakdown: contextBreakdown(state.sizes, {
621
631
  total: state.context?.used,
622
632
  limit,
623
633
  reserved: limit ? Math.round(limit * (1 - COMPACT_SHARE / REPORTED_SHARE)) : null,
@@ -198,9 +198,10 @@ export function cursor({
198
198
  return list.sort((a, b) => a.startedAt - b.startedAt).slice(-DETAIL_LIMITS.subagents)
199
199
  }
200
200
 
201
- async function sessionDetail(key) {
201
+ async function sessionDetail(key, { filesOnly = false } = {}) {
202
202
  const id = String(key).replace(/^cursor:/, '')
203
203
  const entry = known.get(id)
204
+ if (filesOnly) return entry ? { id: `cursor:${id}`, sessionId: id, files: null } : null
204
205
  const stat = entry ? await fsp.stat(entry.file).catch(() => null) : null
205
206
  if (!stat) return null
206
207
  const state = await follow(entry.file, stat)
@@ -122,10 +122,11 @@ export function hermes({ home = resolveHermesHome(), now = Date.now } = {}) {
122
122
  return sessions
123
123
  }
124
124
 
125
- async function sessionDetail(key) {
125
+ async function sessionDetail(key, { filesOnly = false } = {}) {
126
126
  const match = /^hermes:([^:]+):(.+)$/.exec(String(key))
127
127
  if (!match) return null
128
128
  const [, profile, rootId] = match
129
+ if (filesOnly) return { id: `hermes:${profile}:${rootId}`, sessionId: rootId, files: null }
129
130
  const store = (await storeFiles()).find((s) => s.profile === profile)
130
131
  if (!store) return null
131
132
  const detail = await withReadOnly(store.file, (db) => detailFrom(db, rootId, now()), null)
package/ui/fonts.css ADDED
@@ -0,0 +1,48 @@
1
+ @font-face {
2
+ font-family: 'Inter Variable';
3
+ font-style: normal;
4
+ font-display: swap;
5
+ font-weight: 100 900;
6
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
7
+ src: url('./fonts/inter-latin-ext-wght-normal.woff2') format('woff2-variations');
8
+ }
9
+ @font-face {
10
+ font-family: 'Inter Variable';
11
+ font-style: normal;
12
+ font-display: swap;
13
+ font-weight: 100 900;
14
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
15
+ src: url('./fonts/inter-latin-wght-normal.woff2') format('woff2-variations');
16
+ }
17
+ @font-face {
18
+ font-family: 'Inter Tight Variable';
19
+ font-style: normal;
20
+ font-display: swap;
21
+ font-weight: 100 900;
22
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
23
+ src: url('./fonts/inter-tight-latin-ext-wght-normal.woff2') format('woff2-variations');
24
+ }
25
+ @font-face {
26
+ font-family: 'Inter Tight Variable';
27
+ font-style: normal;
28
+ font-display: swap;
29
+ font-weight: 100 900;
30
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
31
+ src: url('./fonts/inter-tight-latin-wght-normal.woff2') format('woff2-variations');
32
+ }
33
+ @font-face {
34
+ font-family: 'JetBrains Mono Variable';
35
+ font-style: normal;
36
+ font-display: swap;
37
+ font-weight: 100 800;
38
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
39
+ src: url('./fonts/jetbrains-mono-latin-ext-wght-normal.woff2') format('woff2-variations');
40
+ }
41
+ @font-face {
42
+ font-family: 'JetBrains Mono Variable';
43
+ font-style: normal;
44
+ font-display: swap;
45
+ font-weight: 100 800;
46
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
47
+ src: url('./fonts/jetbrains-mono-latin-wght-normal.woff2') format('woff2-variations');
48
+ }
package/ui/format.mjs ADDED
@@ -0,0 +1,60 @@
1
+ const UNITS = [
2
+ ['B', 1e9],
3
+ ['M', 1e6],
4
+ ['K', 1e3],
5
+ ]
6
+
7
+ export function compactNumber(n) {
8
+ if (!Number.isFinite(n)) return '—'
9
+ const abs = Math.abs(n)
10
+ for (const [suffix, size] of UNITS) {
11
+ if (abs < size * 0.9995) continue
12
+ const value = n / size
13
+ const shown = Math.abs(value) >= 99.95 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '')
14
+ return `${shown}${suffix}`
15
+ }
16
+ return String(Math.round(n))
17
+ }
18
+
19
+ export function duration(ms) {
20
+ const seconds = Math.max(0, Math.floor(ms / 1000))
21
+ if (seconds < 60) return `${seconds}s`
22
+ const minutes = Math.floor(seconds / 60)
23
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
24
+ const hours = Math.floor(minutes / 60)
25
+ if (hours < 24) return `${hours}h ${minutes % 60}m`
26
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`
27
+ }
28
+
29
+ export function callDuration(ms) {
30
+ if (!Number.isFinite(ms) || ms < 0) return '—'
31
+ const tenths = Math.round(ms / 100)
32
+ if (tenths < 10) return `${(tenths / 10).toFixed(1)}s`
33
+ return duration(Math.max(ms, 1000))
34
+ }
35
+
36
+ export function ago(ms) {
37
+ if (ms < 45 * 1000) return 'just now'
38
+ const minutes = Math.round(ms / 60000)
39
+ if (minutes < 60) return `${minutes}m ago`
40
+ const hours = Math.round(minutes / 60)
41
+ if (hours < 24) return `${hours}h ago`
42
+ return `${Math.round(hours / 24)}d ago`
43
+ }
44
+
45
+ export function percent(used, limit) {
46
+ if (!limit) return null
47
+ return Math.min(100, Math.max(0, (used / limit) * 100))
48
+ }
49
+
50
+ export function modelName(id) {
51
+ const match = /^(?:[\w.-]+\/)?claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[.*\])?$/.exec(id || '')
52
+ if (!match) return id || ''
53
+ const [, family, major, minor] = match
54
+ return `${family[0].toUpperCase()}${family.slice(1)} ${minor ? `${major}.${minor}` : major}`
55
+ }
56
+
57
+ export function toolLabel(name) {
58
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(name || '')
59
+ return mcp ? `${mcp[2]} (${mcp[1]})` : name || ''
60
+ }
package/ui/gauge.css ADDED
@@ -0,0 +1,75 @@
1
+ .inst {
2
+ display: block;
3
+ flex: none;
4
+ overflow: visible;
5
+ }
6
+ .i-dial {
7
+ fill: var(--dial);
8
+ stroke: var(--line);
9
+ stroke-width: 1;
10
+ }
11
+ .i-mini .i-dial {
12
+ stroke-width: 2.4;
13
+ }
14
+ .i-hub {
15
+ fill: var(--hub);
16
+ }
17
+ .i-track,
18
+ .i-fill,
19
+ .i-red {
20
+ fill: none;
21
+ stroke-width: 6;
22
+ stroke-linecap: round;
23
+ }
24
+ .i-track {
25
+ stroke: var(--dial-track);
26
+ }
27
+ .i-fill {
28
+ stroke: var(--inst);
29
+ }
30
+ .i-red {
31
+ stroke: var(--err);
32
+ opacity: 0.8;
33
+ }
34
+ .i-mini .i-track,
35
+ .i-mini .i-fill,
36
+ .i-mini .i-red {
37
+ stroke-width: 10;
38
+ }
39
+ .inst[data-level='high'] .i-fill {
40
+ stroke: var(--warn);
41
+ }
42
+ .inst[data-level='full'] .i-fill {
43
+ stroke: var(--err);
44
+ }
45
+ .i-needle {
46
+ stroke: var(--needle);
47
+ stroke-width: 3.2;
48
+ stroke-linecap: round;
49
+ filter: drop-shadow(0 0 3px var(--needle-glow));
50
+ }
51
+ .i-mini .i-needle {
52
+ stroke-width: 6;
53
+ filter: none;
54
+ }
55
+ .i-label {
56
+ fill: var(--text);
57
+ font: 600 12px var(--cond);
58
+ }
59
+ .i-wrap {
60
+ position: relative;
61
+ }
62
+ .i-wrap::after {
63
+ content: '';
64
+ position: absolute;
65
+ top: calc(-1 * var(--hit-t, 4px));
66
+ right: calc(-1 * var(--hit-r, 4px));
67
+ bottom: calc(-1 * var(--hit-b, 4px));
68
+ left: calc(-1 * var(--hit-l, 4px));
69
+ }
70
+ .i-wrap:has(+ .i-wrap) {
71
+ --hit-r: 2px;
72
+ }
73
+ .i-wrap + .i-wrap {
74
+ --hit-l: 2px;
75
+ }
package/ui/gauge.mjs ADDED
@@ -0,0 +1,14 @@
1
+ const ARC = 'M-19.8,19.8 A28,28 0 1 1 19.8,19.8'
2
+ const fx = (n) => n.toFixed(2)
3
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
4
+
5
+ export const levelOf = (pct) => (pct == null ? 'unknown' : pct >= 90 ? 'full' : pct >= 70 ? 'high' : 'ok')
6
+ export const levelClass = (spec) => (spec.level === 'full' ? 'x' : spec.level === 'high' ? 'h' : 'f')
7
+
8
+ export function contextGauge(spec, size = 18) {
9
+ const mini = size <= 30
10
+ const p = clamp(spec.pct ?? 0, 0, 100)
11
+ const level = spec.level || levelOf(spec.pct ?? null)
12
+ const body = `<path d="${ARC}" class="i-track" pathLength="100"/><path d="${ARC}" class="i-red" pathLength="100" stroke-dasharray="10 100" stroke-dashoffset="-90"/><path d="${ARC}" class="i-fill" pathLength="100" stroke-dasharray="${fx(p)} 100"/><line x1="0" y1="0" x2="${mini ? 19 : 21}" y2="0" class="i-needle" transform="rotate(${fx(135 + (270 * p) / 100)})"/>${mini ? '' : `<text x="0" y="28" class="i-label" text-anchor="middle">${Math.round(p)}%</text>`}`
13
+ return `<svg class="inst${mini ? ' i-mini' : ''}" data-k="gauge" data-level="${level}" width="${size}" height="${size}" viewBox="-36 -36 72 72" aria-hidden="true"><circle r="35" class="i-dial"/>${body}<circle r="${mini ? 6 : 4.5}" class="i-hub"/></svg>`
14
+ }
package/ui/html.mjs ADDED
@@ -0,0 +1,12 @@
1
+ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
2
+
3
+ export const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
4
+
5
+ export function joinNames(names) {
6
+ if (names.length <= 1) return names[0] || ''
7
+ return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`
8
+ }
9
+
10
+ export const termButton = (key, label, attrs = '') => `<button type="button" class="term" data-term="${esc(key)}" aria-expanded="false"${attrs ? ` ${attrs}` : ''}>${esc(label)}</button>`
11
+
12
+ export const instAttrs = (spec, val = '', sub = '') => `data-inst="${esc(JSON.stringify(spec))}" data-val="${esc(val)}" data-sub="${esc(sub)}"`