@threadcrumbs/common 0.1.4 → 0.1.5

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.
@@ -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}`,
@@ -334,7 +336,10 @@ function statusOf({ running, state, calls, subagents, awaiting, lastAt, mark, un
334
336
  const failedTurn = failed || Boolean(mark?.failed)
335
337
  const quiet = () => (unread ? ['done', 'unread'] : ['idle', mark ? 'read' : 'quiet'])
336
338
  if (running) {
337
- if (running.status === 'waiting') return ['asking', calls.some((c) => c.questions) ? 'question' : calls.length ? 'approval' : 'waiting']
339
+ if (running.status === 'waiting') {
340
+ const asked = calls.filter((c) => (c.kind ?? nameKind(c.name)) !== 'agent')
341
+ return ['asking', asked.some((c) => c.questions) ? 'question' : asked.length ? 'approval' : 'waiting']
342
+ }
338
343
  const busy = running.status ? running.status === 'busy' : midTurn
339
344
  if (busy) return onlyAgents ? ['delegating', 'subagents'] : ['working', running.status ? 'process-busy' : 'turn-open']
340
345
  if (subagents || awaiting.length) return ['delegating', subagents ? 'subagents' : 'awaiting']
@@ -364,7 +369,8 @@ function toSession({ id, state, stat, running, subagents, workingSubagents, awai
364
369
  const unread = Boolean(mark) && (state.handedBackAt || mark.activeAt) > mark.focusedAt
365
370
  const calls = [...state.open.values()].sort((a, b) => a.startedAt - b.startedAt)
366
371
  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 || ''
372
+ const markFailed = Boolean(mark?.failed) && !turnEnding(state).failed
373
+ const errorText = (markFailed && mark.errorText) || state.detail?.errors.at(-1)?.message || mark?.errorText || ''
368
374
  const midTurn = Boolean(state.ending && !state.ending.handedBack)
369
375
  const turnOpen = midTurn && (status === 'working' || status === 'delegating' || status === 'asking')
370
376
  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/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 @@
1
+ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
package/ui/icons.mjs ADDED
@@ -0,0 +1,98 @@
1
+ import { mcpServer } from '../lib/tool-kinds.mjs'
2
+ import { esc } from './html.mjs'
3
+
4
+ export { mcpServer }
5
+
6
+ const LINE_ICONS = {
7
+ key: '<circle cx="8" cy="15.5" r="4.5"/><path d="M11.3 12.2 20 3.5M16.5 7l2.5 2.5M14 9.5l2 2"/>',
8
+ folder: '<path d="M3 7.4A1.4 1.4 0 0 1 4.4 6h4.2l2 2.5h7A1.4 1.4 0 0 1 19 9.9v7.7a1.4 1.4 0 0 1-1.4 1.4H4.4A1.4 1.4 0 0 1 3 17.6z"/>',
9
+ org: '<path d="M4.5 20V5.4A1.4 1.4 0 0 1 5.9 4h6.7A1.4 1.4 0 0 1 14 5.4V20"/><path d="M14 9h4.1a1.4 1.4 0 0 1 1.4 1.4V20"/><path d="M3 20h18"/><path d="M7.5 8h3M7.5 11.5h3M7.5 15h3M16.8 13h.01M16.8 16.5h.01"/>',
10
+ branch:
11
+ '<circle cx="7" cy="5.5" r="2.2"/><circle cx="7" cy="18.5" r="2.2"/><circle cx="17" cy="7.5" r="2.2"/><path d="M7 7.7v8.6"/><path d="M17 9.7c0 3.4-2.6 4.6-6 5.2-1.9.3-3.3.9-4 1.4"/>',
12
+ worktree: '<circle cx="6.5" cy="5" r="2.2"/><circle cx="17.5" cy="5" r="2.2"/><circle cx="12" cy="19" r="2.2"/><path d="M6.5 7.2v1.3a3 3 0 0 0 3 3h5a3 3 0 0 0 3-3V7.2"/><path d="M12 11.5v5.3"/>',
13
+ model: '<path d="M11 3.5l1.8 4.9 4.9 1.8-4.9 1.8L11 16.9l-1.8-4.9-4.9-1.8 4.9-1.8z"/><path d="M18 14.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>',
14
+ 'k-read': '<path d="M13.5 3.5H7.4A1.4 1.4 0 0 0 6 4.9v14.2a1.4 1.4 0 0 0 1.4 1.4h9.2a1.4 1.4 0 0 0 1.4-1.4V8z"/><path d="M13.5 3.5V8H18"/><path d="M9 12.5h6M9 16h4"/>',
15
+ 'k-search': '<circle cx="10.5" cy="10.5" r="6"/><path d="M15 15l5 5"/>',
16
+ 'k-edit': '<path d="M15.2 5.3a2 2 0 0 1 2.8 0l.7.7a2 2 0 0 1 0 2.8L8.5 19 4 20l1-4.5z"/><path d="M13.5 7l3.5 3.5"/>',
17
+ 'k-delete':
18
+ '<path d="M4.5 7h15"/><path d="M9.5 7V5.2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V7"/><path d="M6.5 7l.8 11.8a1.5 1.5 0 0 0 1.5 1.4h6.4a1.5 1.5 0 0 0 1.5-1.4L17.5 7"/><path d="M10.2 11v5.5M13.8 11v5.5"/>',
19
+ 'k-move': '<path d="M12 20.5H7.4A1.4 1.4 0 0 1 6 19.1V4.9a1.4 1.4 0 0 1 1.4-1.4h6.1L18 8v3"/><path d="M13.5 3.5V8H18"/><path d="M11.5 16h9M17.5 13l3 3-3 3"/>',
20
+ 'k-execute': '<path d="M5 7l5 5-5 5"/><path d="M12.5 17H19"/>',
21
+ 'k-fetch': '<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17"/><path d="M12 3.5c2.3 2.3 3.5 5.2 3.5 8.5s-1.2 6.2-3.5 8.5C9.7 18.2 8.5 15.3 8.5 12S9.7 5.8 12 3.5z"/>',
22
+ 'k-think': '<path d="M9.5 17.5h5M10.5 20.5h3"/><path d="M12 3.5a5.5 5.5 0 0 0-3.3 9.9c.6.5 1 1.2 1 2v.1h4.6v-.1c0-.8.4-1.5 1-2A5.5 5.5 0 0 0 12 3.5z"/>',
23
+ 'k-agent': '<path d="M2.5 12H9M6.5 9.2 9.3 12l-2.8 2.8"/><circle cx="15.5" cy="12" r="5.5" stroke-linecap="butt" stroke-dasharray="3.2 2.56"/><circle cx="15.5" cy="12" r="1.7" fill="currentColor" stroke="none"/>',
24
+ 'k-plan': '<path d="M3.5 7.2 5.8 9.5 10 5M3.5 15.7 5.8 18 10 13.5"/><path d="M13.5 7.5H20.5M13.5 16H20.5"/>',
25
+ 'k-communicate':
26
+ '<path d="M4 6.4A1.9 1.9 0 0 1 5.9 4.5h12.2A1.9 1.9 0 0 1 20 6.4v8.2a1.9 1.9 0 0 1-1.9 1.9H11.5L7 20v-3.5H5.9A1.9 1.9 0 0 1 4 14.6z"/><path d="M10.1 8.7a1.9 1.9 0 1 1 2.8 1.7c-.6.3-.9.7-.9 1.3"/><path d="M12 14h.01"/>',
27
+ 'k-switch_mode': '<rect x="2.5" y="7" width="19" height="10" rx="5"/><circle cx="16.5" cy="12" r="2.5"/>',
28
+ 'k-other': '<path d="M17.57 4.17A4.8 4.8 0 0 0 11.13 10.47L5.11 16.49a1.7 1.7 0 0 0 2.4 2.4L13.53 12.87A4.8 4.8 0 0 0 19.83 6.43L17.76 8.5 15.5 6.24z"/>',
29
+ 'k-mcp': '<path d="M9 3.5v4M15 3.5v4"/><path d="M6.5 7.5h11V10a5.5 5.5 0 0 1-11 0z"/><path d="M12 15.5v5"/>',
30
+ }
31
+
32
+ export const ICON_NAMES = Object.freeze(Object.keys(LINE_ICONS))
33
+
34
+ export const ICO = (name, size = 13) =>
35
+ `<svg class="ico" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${LINE_ICONS[name] || ''}</svg>`
36
+
37
+ export const EFFORT_LEVELS = { minimal: 1, low: 1, medium: 2, high: 3, xhigh: 4, max: 5 }
38
+ export const EFFORT_NAMES = { minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' }
39
+ export const effortName = (effort) => EFFORT_NAMES[String(effort).toLowerCase()] || effort || ''
40
+
41
+ export function effortBars(effort) {
42
+ if (!effort) return ''
43
+ const level = EFFORT_LEVELS[String(effort).toLowerCase()] ?? 0
44
+ const bars = [1, 2, 3, 4, 5]
45
+ .map((n) => `<rect x="${n * 4 - 1.5}" y="${(20 - n * 3.2).toFixed(1)}" width="2.6" height="${(n * 3.2).toFixed(1)}" rx="1.1" class="${n <= level ? 'eb-on' : 'eb-off'}"/>`)
46
+ .join('')
47
+ const label = esc(`${effortName(effort)} effort`)
48
+ return `<svg class="ico eff" width="14" height="14" viewBox="0 0 24 24" role="img" aria-label="${label}"><title>${label}</title>${bars}</svg>`
49
+ }
50
+
51
+ export const HARNESS_MARKS = Object.freeze({
52
+ 'claude-code': 'CC',
53
+ codex: 'Cx',
54
+ cursor: 'Cu',
55
+ antigravity: 'AG',
56
+ hermes: 'H',
57
+ opencode: 'OC',
58
+ kilocode: 'K',
59
+ 'gemini-cli': 'Gm',
60
+ 'copilot-cli': 'Cp',
61
+ })
62
+ export const harnessMark = (id, name = id) => HARNESS_MARKS[id] || String(name || '?')[0].toUpperCase()
63
+
64
+ export const KINDS = Object.freeze({
65
+ read: { name: 'Read', tip: 'Reads a file' },
66
+ search: { name: 'Search', tip: 'Searches for text or lists files' },
67
+ edit: { name: 'Edit', tip: 'Creates or changes a file' },
68
+ delete: { name: 'Delete', tip: 'Deletes files' },
69
+ move: { name: 'Move', tip: 'Moves or renames files' },
70
+ execute: { name: 'Run', tip: 'Runs a command' },
71
+ fetch: { name: 'Web', tip: 'Fetches a page or searches the web' },
72
+ think: { name: 'Think', tip: 'Thinks or takes notes, without changing anything' },
73
+ agent: { name: 'Subagent', tip: 'Hands work to a subagent' },
74
+ plan: { name: 'Plan', tip: 'Updates the plan' },
75
+ communicate: { name: 'Ask', tip: 'Asks you something' },
76
+ switch_mode: { name: 'Mode', tip: "Changes the agent's mode" },
77
+ other: { name: 'Other', tip: 'A tool that has no kind yet' },
78
+ mcp: { name: 'MCP', tip: 'A tool from an MCP server' },
79
+ })
80
+
81
+ const COMMAND_TOOLS = /bash|shell|command|exec|terminal|powershell/i
82
+ export function kindOf(item, byName = null) {
83
+ const server = item?.server || mcpServer(item?.name)
84
+ if (server) return { kind: 'mcp', server, shell: false }
85
+ const kind = KINDS[item?.kind] ? item.kind : 'other'
86
+ const nameKind = byName?.get(item?.name) ?? (COMMAND_TOOLS.test(item?.name || '') ? 'execute' : null)
87
+ return { kind, server: null, shell: kind !== 'execute' && kind !== 'other' && nameKind === 'execute' }
88
+ }
89
+
90
+ export function kindTip({ kind, server, shell }) {
91
+ if (kind === 'mcp') return server ? `A tool from the ${server} MCP server` : KINDS.mcp.tip
92
+ const tip = (KINDS[kind] || KINDS.other).tip
93
+ return shell ? `${tip}, with a shell command` : tip
94
+ }
95
+
96
+ export const kindIconHTML = (k) => `<span class="tk" data-k="${esc(k.kind)}" data-tip="${esc(kindTip(k))}">${ICO(`k-${k.kind}`)}</span>`
97
+
98
+ export const toolKinds = (tools) => new Map((tools || []).map((t) => [t.name, t.kind]))
package/ui/status.css ADDED
@@ -0,0 +1,75 @@
1
+ [data-st='asking'] {
2
+ --st: var(--lamp);
3
+ --plate: var(--lamp-wash);
4
+ }
5
+ [data-st='working'] {
6
+ --st: var(--work);
7
+ --plate: var(--work-plate);
8
+ }
9
+ [data-st='delegating'] {
10
+ --st: var(--wait);
11
+ --plate: var(--wait-plate);
12
+ }
13
+ [data-st='done'] {
14
+ --st: var(--done);
15
+ --plate: var(--done-plate);
16
+ }
17
+ [data-st='error'] {
18
+ --st: var(--err);
19
+ --plate: var(--err-plate);
20
+ }
21
+ [data-st='idle'] {
22
+ --st: var(--idle);
23
+ --plate: var(--idle-plate);
24
+ }
25
+
26
+ .lamp {
27
+ display: inline-grid;
28
+ place-items: center;
29
+ width: 16px;
30
+ height: 16px;
31
+ flex: none;
32
+ color: var(--st, var(--idle));
33
+ }
34
+ .lamp svg {
35
+ width: 16px;
36
+ height: 16px;
37
+ overflow: visible;
38
+ }
39
+ .lamp .spin {
40
+ animation: tc-spin 1.5s linear infinite;
41
+ transform-origin: 8px 8px;
42
+ }
43
+ .lamp .spin-slow {
44
+ animation: tc-spin 5s linear infinite;
45
+ transform-origin: 8px 8px;
46
+ }
47
+ .lamp .blink {
48
+ animation: tc-blink 2s ease-in-out infinite;
49
+ }
50
+
51
+ @keyframes tc-spin {
52
+ to {
53
+ transform: rotate(360deg);
54
+ }
55
+ }
56
+ @keyframes tc-blink {
57
+ 0%,
58
+ 60% {
59
+ opacity: 1;
60
+ }
61
+ 80% {
62
+ opacity: 0.6;
63
+ }
64
+ 100% {
65
+ opacity: 1;
66
+ }
67
+ }
68
+
69
+ @media (prefers-reduced-motion: reduce) {
70
+ .lamp .spin,
71
+ .lamp .spin-slow,
72
+ .lamp .blink {
73
+ animation: none;
74
+ }
75
+ }
package/ui/status.mjs ADDED
@@ -0,0 +1,36 @@
1
+ export const STATUSES = [
2
+ { key: 'asking', name: 'Needs you', color: '--lamp', plate: '--lamp-wash' },
3
+ { key: 'working', name: 'Working', color: '--work', plate: '--work-plate' },
4
+ { key: 'delegating', name: 'Waiting on agents', color: '--wait', plate: '--wait-plate' },
5
+ { key: 'done', name: 'Done', color: '--done', plate: '--done-plate' },
6
+ { key: 'error', name: 'Error', color: '--err', plate: '--err-plate' },
7
+ { key: 'idle', name: 'Idle', color: '--idle', plate: '--idle-plate' },
8
+ ]
9
+
10
+ export const STATUS_KEYS = STATUSES.map((s) => s.key)
11
+
12
+ export const URGENCY_ORDER = ['asking', 'error', 'done', 'working', 'delegating', 'idle']
13
+
14
+ export const LIVE_STATUSES = new Set(['asking', 'working', 'delegating'])
15
+
16
+ export const statusOf = (key) => STATUSES.find((s) => s.key === key) || STATUSES.at(-1)
17
+
18
+ export const LAMP_ICONS = {
19
+ asking:
20
+ '<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><g class="blink"><path d="M8 .9 15.100 8 8 15.100.9 8Z" fill="var(--sign)" stroke="var(--sign)" stroke-width="1"/><path d="M6.300 6.300a1.750 1.750 0 1 1 2.700 1.500c-.7.4-1 .8-1 1.400" fill="none" stroke="var(--sign-ink)" stroke-width="1.400" stroke-linecap="round"/><circle cx="8" cy="11.300" r=".85" fill="var(--sign-ink)"/></g></svg>',
21
+ working:
22
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="5.600" stroke="currentColor" stroke-opacity=".28" stroke-width="2"/><g class="spin"><path d="M8 2.400a5.600 5.600 0 0 1 5.600 5.600" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></g></svg>',
23
+ delegating:
24
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><g class="spin-slow"><circle cx="8" cy="8" r="5.800" stroke="currentColor" stroke-width="1.800" stroke-dasharray="2.600 2.460"/></g><circle cx="8" cy="8" r="2.100" fill="currentColor"/></svg>',
25
+ done: '<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.600" fill="currentColor"/><path d="M4.900 8.200 7.100 10.400 11.100 5.800" fill="none" stroke="var(--knock)" stroke-width="1.700" stroke-linecap="round" stroke-linejoin="round"/></svg>',
26
+ error:
27
+ '<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.500 15.200 14.200H.8Z" fill="currentColor"/><path d="M8 6v4" stroke="var(--knock)" stroke-width="1.600" stroke-linecap="round"/><circle cx="8" cy="12" r=".9" fill="var(--knock)"/></svg>',
28
+ idle: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="5.200" stroke="currentColor" stroke-width="1.600"/></svg>',
29
+ pending:
30
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="4.600" stroke="currentColor" stroke-width="1.500"/></svg>',
31
+ }
32
+
33
+ export const lampHTML = (key, cls = '') => {
34
+ const st = STATUS_KEYS.includes(key) || key === 'pending' ? key : 'idle'
35
+ return `<span class="lamp${cls ? ` ${cls}` : ''}" data-st="${st}">${LAMP_ICONS[st]}</span>`
36
+ }