@threadcrumbs/common 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -22,9 +22,14 @@ need it.
22
22
  | `@threadcrumbs/common/ui/theme.css` | The Threadcrumbs look as CSS variables: the Quieter backgrounds, lines, and text levels, Threadcrumbs orange, the six status colors and their plates, `--warn`, added and removed, the chart and gauge colors, focus, type sizes, spacing, radii, and the default `--sans`, `--cond`, and `--mono` stacks. Every element keeps one x-height (`font-size-adjust: 0.51`) |
23
23
  | `@threadcrumbs/common/ui/fonts.css` | `@font-face` rules for Inter, Inter Tight, and JetBrains Mono, Latin and Latin Extended, upright, with their `.woff2` files in `ui/fonts/`. A bundler copies the files into the app, so they load from its own origin. `tools/ui-fonts.mjs` writes both from the `@fontsource` packages |
24
24
  | `@threadcrumbs/common/ui/status` | `STATUSES` (each status's key, name, color, and plate, in the order both apps pin), `STATUS_KEYS`, `URGENCY_ORDER` (for the status row and the waffle), `LIVE_STATUSES`, `statusOf`, the status icons as SVG in `LAMP_ICONS`, and `lampHTML(status)`. `ui/status.css` colors each `[data-st]` and animates the lamps, and reduced motion stops them |
25
- | `@threadcrumbs/common/ui/icons` | `ICO(name)`, the line icons (organization, folder, worktree, branch, model, key, and one per tool kind); `effortBars` and `effortName`; `harnessMark` (CC, Cx, and the others); and, for tool calls, `KINDS`, `kindOf`, `kindTip`, `kindIconHTML`, `toolKinds`, and `mcpServer` |
25
+ | `@threadcrumbs/common/ui/icons` | `ICO(name)`, the line icons (organization, folder, worktree, branch, model, key, and one per tool kind); `IC.chev`, a group heading's chevron; `effortBars` and `effortName`; `harnessMark` (CC, Cx, and the others); and, for tool calls, `KINDS`, `kindOf`, `kindTip`, `kindIconHTML`, `toolKinds`, and `mcpServer` |
26
26
  | `@threadcrumbs/common/ui/where` | `whereHTML(thread)`, the organization and repo chips, `whereFullHTML(thread)` with the worktree and branch, and `repoName(name, org)`, which drops a package scope the organization repeats. `ui/where.css` styles the chips, the harness initials, the effort bars, and the tool kind icons |
27
27
  | `@threadcrumbs/common/ui/gauge` | `contextGauge({ pct, level }, size)`, the context gauge as SVG, with `levelOf(pct)` (amber from 70%, red from 90%) and `levelClass`. `ui/gauge.css` draws it and gives a picture with a tip a slightly larger hover area (`.i-wrap`) |
28
+ | `@threadcrumbs/common/ui/html` | `esc(text)`, `plural(n, one, many)`, `joinNames(names)` ("a, b and c"), and two attribute writers for tips: `termButton(key, label, attrs)`, a label that opens a term's text, and `instAttrs(spec, val, sub)`, the picture, value, and second line its tip shows |
29
+ | `@threadcrumbs/common/ui/format` | How the lists word numbers and times: `compactNumber` (32.7K), `duration` (4m 12s), `callDuration` (0.4s), `ago` (7m ago), `percent`, `modelName` (Opus 5.5), and `toolLabel` (MCP tools as tool, then server) |
30
+ | `@threadcrumbs/common/ui/rows` | Threadgauge's list rows: `rowHTML(thread, { sel, now, harnessName })` and `groupHeadHTML(group, first, { group, shut })`, with the pieces they use (`nowHTML`, `nowText`, `nowParts`, `rowDetail`, `askLine`, `errorShort`, `shownPlan`, `waitingOn`, `roughly`, `linesHTML`, `rowLabel`, `groupTally`, `turnOf`, `groupKey`). `ui/rows.css` styles the headings, rows, and term labels |
31
+ | `@threadcrumbs/common/ui/totals` | The status lamps and the footer: `statusCounts`, `lampsHTML(counts, total, pressed)`, `footerTotals(threads)`, `footerHTML(totals, { short, bars })`, and the output-over-time log, `emptyOutputLog`, `recordOutput`, and `outputBars`. `ui/totals.css` styles them and builds on `ui/rows.css` |
32
+ | `@threadcrumbs/common/ui/pictures` | The footer's pictures, `FOOT_DRAW` (`drawPie`, `drawWaffle`, `drawCols`) at `FOOT_SIZE`, with `footerNote`; plan ticks, `planTicks` and `planSpec`; `waffleStates` and `binTimes`; and the SVG helpers Threadgauge's other pictures use (`fx`, `clamp`, `polar`, `wideBox`, `wideSVG`, and an `esc` for SVG text). `ui/pictures.css` colors them |
28
33
 
29
34
  `launch/` is the only part of this package that starts processes, and `license/store.mjs` is the
30
35
  only part that writes a file. The readers and `orgs/` import neither. `ui/` is plain functions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadcrumbs/common",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Code the Threadcrumbs apps share, starting with the readers for each coding-agent harness.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -33,6 +33,14 @@
33
33
  "./ui/where.css": "./ui/where.css",
34
34
  "./ui/gauge": "./ui/gauge.mjs",
35
35
  "./ui/gauge.css": "./ui/gauge.css",
36
+ "./ui/format": "./ui/format.mjs",
37
+ "./ui/html": "./ui/html.mjs",
38
+ "./ui/pictures": "./ui/pictures.mjs",
39
+ "./ui/pictures.css": "./ui/pictures.css",
40
+ "./ui/rows": "./ui/rows.mjs",
41
+ "./ui/rows.css": "./ui/rows.css",
42
+ "./ui/totals": "./ui/totals.mjs",
43
+ "./ui/totals.css": "./ui/totals.css",
36
44
  "./ui/theme.css": "./ui/theme.css",
37
45
  "./ui/fonts.css": "./ui/fonts.css"
38
46
  },
@@ -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
 
@@ -334,7 +334,8 @@ function statusOf({ running, state, calls, subagents, awaiting, lastAt, mark, un
334
334
  const { failed, onAgents: onlyAgents } = turnEnding(state)
335
335
  const blocked = mark?.outcome === 'blocked'
336
336
  const failedTurn = failed || Boolean(mark?.failed)
337
- 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'])
338
339
  if (running) {
339
340
  if (running.status === 'waiting') {
340
341
  const asked = calls.filter((c) => (c.kind ?? nameKind(c.name)) !== 'agent')
@@ -345,7 +346,7 @@ function statusOf({ running, state, calls, subagents, awaiting, lastAt, mark, un
345
346
  if (subagents || awaiting.length) return ['delegating', subagents ? 'subagents' : 'awaiting']
346
347
  if (blocked) return ['asking', 'marked-blocked']
347
348
  if (failedTurn) return ['error', 'turn-failed']
348
- if (!unread && now - lastAt < ACTIVE_WINDOW_MS) return ['done', 'recent']
349
+ if (!unread && now - lastAt < ACTIVE_WINDOW_MS) return ['done', settled ? 'process-idle' : 'recent']
349
350
  return quiet()
350
351
  }
351
352
  if (midTurn && now - lastAt < STALE_TURN_MS) return onlyAgents ? ['delegating', 'subagents'] : ['working', 'turn-open']
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/html.mjs CHANGED
@@ -1 +1,12 @@
1
- export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
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)}"`
package/ui/icons.mjs CHANGED
@@ -1,98 +1,105 @@
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]))
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
+ const svg = (body, size = 16) =>
38
+ `<svg width="${size}" height="${size}" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`
39
+
40
+ export const IC = {
41
+ chev: svg('<path d="M6 4l4 4-4 4"/>', 12),
42
+ }
43
+
44
+ export const EFFORT_LEVELS = { minimal: 1, low: 1, medium: 2, high: 3, xhigh: 4, max: 5 }
45
+ export const EFFORT_NAMES = { minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' }
46
+ export const effortName = (effort) => EFFORT_NAMES[String(effort).toLowerCase()] || effort || ''
47
+
48
+ export function effortBars(effort) {
49
+ if (!effort) return ''
50
+ const level = EFFORT_LEVELS[String(effort).toLowerCase()] ?? 0
51
+ const bars = [1, 2, 3, 4, 5]
52
+ .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'}"/>`)
53
+ .join('')
54
+ const label = esc(`${effortName(effort)} effort`)
55
+ return `<svg class="ico eff" width="14" height="14" viewBox="0 0 24 24" role="img" aria-label="${label}"><title>${label}</title>${bars}</svg>`
56
+ }
57
+
58
+ export const HARNESS_MARKS = Object.freeze({
59
+ 'claude-code': 'CC',
60
+ codex: 'Cx',
61
+ cursor: 'Cu',
62
+ antigravity: 'AG',
63
+ hermes: 'H',
64
+ opencode: 'OC',
65
+ kilocode: 'K',
66
+ 'gemini-cli': 'Gm',
67
+ 'copilot-cli': 'Cp',
68
+ })
69
+ export const harnessMark = (id, name = id) => HARNESS_MARKS[id] || String(name || '?')[0].toUpperCase()
70
+
71
+ export const KINDS = Object.freeze({
72
+ read: { name: 'Read', tip: 'Reads a file' },
73
+ search: { name: 'Search', tip: 'Searches for text or lists files' },
74
+ edit: { name: 'Edit', tip: 'Creates or changes a file' },
75
+ delete: { name: 'Delete', tip: 'Deletes files' },
76
+ move: { name: 'Move', tip: 'Moves or renames files' },
77
+ execute: { name: 'Run', tip: 'Runs a command' },
78
+ fetch: { name: 'Web', tip: 'Fetches a page or searches the web' },
79
+ think: { name: 'Think', tip: 'Thinks or takes notes, without changing anything' },
80
+ agent: { name: 'Subagent', tip: 'Hands work to a subagent' },
81
+ plan: { name: 'Plan', tip: 'Updates the plan' },
82
+ communicate: { name: 'Ask', tip: 'Asks you something' },
83
+ switch_mode: { name: 'Mode', tip: "Changes the agent's mode" },
84
+ other: { name: 'Other', tip: 'A tool that has no kind yet' },
85
+ mcp: { name: 'MCP', tip: 'A tool from an MCP server' },
86
+ })
87
+
88
+ const COMMAND_TOOLS = /bash|shell|command|exec|terminal|powershell/i
89
+ export function kindOf(item, byName = null) {
90
+ const server = item?.server || mcpServer(item?.name)
91
+ if (server) return { kind: 'mcp', server, shell: false }
92
+ const kind = KINDS[item?.kind] ? item.kind : 'other'
93
+ const nameKind = byName?.get(item?.name) ?? (COMMAND_TOOLS.test(item?.name || '') ? 'execute' : null)
94
+ return { kind, server: null, shell: kind !== 'execute' && kind !== 'other' && nameKind === 'execute' }
95
+ }
96
+
97
+ export function kindTip({ kind, server, shell }) {
98
+ if (kind === 'mcp') return server ? `A tool from the ${server} MCP server` : KINDS.mcp.tip
99
+ const tip = (KINDS[kind] || KINDS.other).tip
100
+ return shell ? `${tip}, with a shell command` : tip
101
+ }
102
+
103
+ export const kindIconHTML = (k) => `<span class="tk" data-k="${esc(k.kind)}" data-tip="${esc(kindTip(k))}">${ICO(`k-${k.kind}`)}</span>`
104
+
105
+ export const toolKinds = (tools) => new Map((tools || []).map((t) => [t.name, t.kind]))
@@ -0,0 +1,100 @@
1
+ .g-inst {
2
+ display: block;
3
+ flex: none;
4
+ overflow: visible;
5
+ }
6
+
7
+ .g-inst .t {
8
+ fill: var(--dial-track);
9
+ }
10
+ .g-inst .f {
11
+ fill: var(--inst);
12
+ }
13
+ .g-inst .s {
14
+ fill: var(--inst-soft);
15
+ }
16
+ .g-inst .a {
17
+ fill: var(--add);
18
+ }
19
+ .g-inst .d {
20
+ fill: var(--del);
21
+ }
22
+ .g-inst .r {
23
+ fill: var(--work);
24
+ animation: tg-pulse 1.6s ease-in-out infinite;
25
+ }
26
+ .g-inst .m {
27
+ stroke: var(--text3);
28
+ stroke-width: 1;
29
+ opacity: 0.8;
30
+ }
31
+ .g-inst .m.base {
32
+ opacity: 0.35;
33
+ }
34
+
35
+ .g-waf rect[data-st] {
36
+ fill: var(--st);
37
+ }
38
+
39
+ .ticks {
40
+ display: flex;
41
+ gap: 2px;
42
+ width: max-content;
43
+ max-width: 100%;
44
+ }
45
+ .ticks i {
46
+ flex: 0 1 auto;
47
+ width: 14px;
48
+ min-width: 3px;
49
+ height: 3px;
50
+ border-radius: 1px;
51
+ background: rgba(255, 255, 255, 0.15);
52
+ }
53
+ .ticks i[data-s='d'] {
54
+ background: var(--done);
55
+ opacity: 0.8;
56
+ }
57
+ .ticks i[data-s='c'] {
58
+ background: var(--st);
59
+ }
60
+ .ticks[data-st='working'] i[data-s='c'] {
61
+ animation: tg-breathe 2s ease-in-out infinite;
62
+ }
63
+ .ticks.compact i {
64
+ width: 7px;
65
+ }
66
+
67
+ .ticks {
68
+ position: relative;
69
+ --hit-t: 6px;
70
+ --hit-b: 6px;
71
+ }
72
+ .ticks::after {
73
+ content: '';
74
+ position: absolute;
75
+ top: calc(-1 * var(--hit-t, 4px));
76
+ right: calc(-1 * var(--hit-r, 4px));
77
+ bottom: calc(-1 * var(--hit-b, 4px));
78
+ left: calc(-1 * var(--hit-l, 4px));
79
+ }
80
+ .tot .i-wrap {
81
+ --hit-l: 1px;
82
+ }
83
+
84
+ @keyframes tg-pulse {
85
+ 50% {
86
+ opacity: 0.45;
87
+ }
88
+ }
89
+ @keyframes tg-breathe {
90
+ 50% {
91
+ opacity: 0.45;
92
+ }
93
+ }
94
+
95
+ @media (prefers-reduced-motion: reduce) {
96
+ .g-inst .r,
97
+ .ticks[data-st='working'] i[data-s='c'] {
98
+ animation: none;
99
+ }
100
+ }
@@ -0,0 +1,158 @@
1
+ import { URGENCY_ORDER } from './status.mjs'
2
+
3
+ export const fx = (n) => n.toFixed(2)
4
+ export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
5
+ export const polar = (r, deg) => {
6
+ const a = ((deg - 90) * Math.PI) / 180
7
+ return [r * Math.cos(a), r * Math.sin(a)]
8
+ }
9
+ export const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
10
+
11
+ export const TONE_COUNT = 5
12
+
13
+ export function wideBox(size, large) {
14
+ return { W: large ? 300 : Math.round(size * 1.9), H: large ? 46 : size >= 24 ? 16 : 13 }
15
+ }
16
+
17
+ export function wideSVG(cls, W, H, body) {
18
+ return `<svg class="inst g-inst ${cls}" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" aria-hidden="true">${body}</svg>`
19
+ }
20
+
21
+ export function binTimes(times, from, to, weights, n = 14) {
22
+ const out = Array(n).fill(0)
23
+ const span = Math.max(1, to - from)
24
+ times.forEach((at, i) => {
25
+ if (!at) return
26
+ out[clamp(Math.floor(((at - from) / span) * n), 0, n - 1)] += weights ? weights[i] : 1
27
+ })
28
+ return out
29
+ }
30
+
31
+ export function waffleStates(counts, n, order = URGENCY_ORDER) {
32
+ const sum = counts.reduce((a, b) => a + b, 0) || 1
33
+ const exact = counts.map((c) => (c / sum) * n)
34
+ const alloc = exact.map(Math.floor)
35
+ counts.forEach((c, i) => {
36
+ if (c && !alloc[i] && alloc.reduce((a, b) => a + b, 0) < n) alloc[i] = 1
37
+ })
38
+ for (const [, i] of exact.map((x, i) => [x - Math.floor(x), i]).sort((a, b) => b[0] - a[0])) {
39
+ if (alloc.reduce((a, b) => a + b, 0) >= n) break
40
+ if (counts[i]) alloc[i]++
41
+ }
42
+ const out = []
43
+ alloc.forEach((k, i) => {
44
+ for (let j = 0; j < k && out.length < n; j++) out.push(order[i])
45
+ })
46
+ while (out.length < n) out.push('idle')
47
+ return out
48
+ }
49
+
50
+ export function planTicks(plan, status, cls = '') {
51
+ if (!plan?.total) return ''
52
+ let marks = ''
53
+ for (let i = 0; i < plan.total; i++) {
54
+ const s = i < plan.done ? 'd' : i === plan.done && plan.current ? 'c' : 't'
55
+ marks += `<i data-s="${s}"></i>`
56
+ }
57
+ const label = `${plan.done} of ${plan.total} steps done`
58
+ return `<span class="ticks${cls ? ` ${esc(cls)}` : ''}" data-st="${esc(status || 'idle')}" role="img" aria-label="${esc(label)}">${marks}</span>`
59
+ }
60
+
61
+ export const planSpec = (plan) =>
62
+ plan?.total ? { k: 'gauge', pct: (plan.done / plan.total) * 100, plan: { done: plan.done, total: plan.total, cur: Boolean(plan.current) } } : null
63
+
64
+ export const FOOT_SIZE = 34
65
+
66
+ function rebin(vals, n) {
67
+ if (vals.length <= n) return vals.slice()
68
+ const out = Array(n).fill(0)
69
+ vals.forEach((v, i) => {
70
+ out[Math.min(n - 1, Math.floor((i * n) / vals.length))] += v
71
+ })
72
+ return out
73
+ }
74
+
75
+ const footSVG = (cls, size, body) => `<svg class="inst g-inst g-foot ${cls}" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">${body}</svg>`
76
+
77
+ export function drawPie(spec, size) {
78
+ const a = spec.a || 0
79
+ const r = spec.r || 0
80
+ const tot = a + r
81
+ const c = size / 2
82
+ const R = c - 0.5
83
+ if (!tot) return footSVG('g-pie', size, `<circle cx="${fx(c)}" cy="${fx(c)}" r="${fx(R)}" class="t"/>`)
84
+ const pt = (f) => {
85
+ const ang = f * 2 * Math.PI - Math.PI / 2
86
+ return `${fx(c + R * Math.cos(ang))},${fx(c + R * Math.sin(ang))}`
87
+ }
88
+ const slice = (f0, f1, cls) => {
89
+ if (f1 - f0 >= 0.9999) return `<circle cx="${fx(c)}" cy="${fx(c)}" r="${fx(R)}" class="${cls}"/>`
90
+ if (f1 <= f0) return ''
91
+ return `<path d="M${fx(c)},${fx(c)}L${pt(f0)}A${fx(R)},${fx(R)} 0 ${f1 - f0 > 0.5 ? 1 : 0} 1 ${pt(f1)}Z" class="${cls}"/>`
92
+ }
93
+ const fa = a / tot
94
+ return footSVG('g-pie', size, slice(0, fa, 'a') + slice(fa, 1, 'd'))
95
+ }
96
+
97
+ export function drawWaffle(spec, size) {
98
+ const large = size >= 60
99
+ const total = Math.max(0, spec.total || 0)
100
+ const n = Math.min(total, large ? 64 : 25)
101
+ if (!n) return ''
102
+ const g = Math.max(2, Math.ceil(Math.sqrt(n)))
103
+ const gap = large ? 3 : g > 4 ? 1.5 : 2
104
+ const cell = (size - gap * (g - 1)) / g
105
+ const run = spec.run ? Math.max(1, Math.round((spec.run / total) * n)) : 0
106
+ const states = spec.sts ? waffleStates(spec.sts, n) : null
107
+ let out = ''
108
+ for (let i = 0; i < n; i++) {
109
+ const x = (i % g) * (cell + gap)
110
+ const y = Math.floor(i / g) * (cell + gap)
111
+ out += `<rect x="${fx(x)}" y="${fx(y)}" width="${fx(cell)}" height="${fx(cell)}" rx="${fx(Math.min(2, cell * 0.22))}" ${states ? `data-st="${states[i]}"` : `class="${i < run ? 'r' : 't'}"`}/>`
112
+ }
113
+ return footSVG('g-waf', size, out)
114
+ }
115
+
116
+ export function drawCols(spec, size) {
117
+ const large = size >= 60
118
+ const bars = spec.bars?.some(Boolean) ? rebin(spec.bars, large ? 14 : 7) : null
119
+ const floor = size - 1
120
+ const top = 1
121
+ let out = `<line x1="0" x2="${size}" y1="${fx(floor + 0.5)}" y2="${fx(floor + 0.5)}" class="m base"/>`
122
+ const col = (i, n, gap, h, cls) => {
123
+ const w = (size - gap * (n - 1)) / n
124
+ return `<rect x="${fx(i * (w + gap))}" y="${fx(floor - h)}" width="${fx(w)}" height="${fx(h)}" rx="${large ? 1.5 : 0.6}" class="${cls}"/>`
125
+ }
126
+ if (bars) {
127
+ const max = Math.max(1, ...bars)
128
+ let last = -1
129
+ bars.forEach((v, i) => {
130
+ if (v > 0) last = i
131
+ })
132
+ bars.forEach((v, i) => {
133
+ if (v) out += col(i, bars.length, large ? 3 : 1.5, Math.max(1.5, (v / max) * (floor - top)), i === last ? 'f' : 's')
134
+ })
135
+ } else {
136
+ const v = spec.v || 0
137
+ const lit = v >= spec.base ? clamp(Math.floor(Math.log10(v / spec.base)) + 1, 1, 5) : 0
138
+ for (let i = 0; i < 5; i++) out += col(i, 5, large ? 4 : 2, ((floor - top) * (i + 1)) / 5, i < lit ? 's' : 't')
139
+ }
140
+ return footSVG('g-cols', size, out)
141
+ }
142
+
143
+ export const FOOT_DRAW = { pie: drawPie, waffle: drawWaffle, cols: drawCols }
144
+
145
+ export function footerNote(spec) {
146
+ if (spec.k === 'pie') return 'Green is the share of lines added, and red is the share removed.'
147
+ if (spec.k === 'waffle' && spec.sts)
148
+ return `${(spec.total || 0) > 64 ? 'Each square stands for a few threads' : 'One square for each thread in the list'}, in its status color, with the ones that need you first.`
149
+ if (spec.k === 'waffle')
150
+ return (spec.total || 0) > 64
151
+ ? 'Each square stands for a few threads. Blue squares are running, and gray ones are not.'
152
+ : 'One square for each thread in the list. Blue squares are running, and gray ones are not.'
153
+ if (spec.k === 'cols')
154
+ return spec.bars?.some(Boolean)
155
+ ? 'Each column is a slice of time, oldest on the left. The brighter column is the latest.'
156
+ : 'Each bar is ten times the one before it, so this shows size, not a limit.'
157
+ return ''
158
+ }