@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.
package/ui/icons.mjs ADDED
@@ -0,0 +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
+ 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
+ }