wisp-agents 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/wisp.js +113 -0
- package/electron/icon.png +0 -0
- package/electron/icon.svg +13 -0
- package/electron/main.js +147 -0
- package/electron/preload.cjs +6 -0
- package/package.json +47 -4
- package/server/adapters/claude.js +230 -0
- package/server/adapters/index.js +36 -0
- package/server/index.js +364 -0
- package/server/mcp-ask.js +82 -0
- package/server/protocol.js +182 -0
- package/server/runner.js +283 -0
- package/server/store.js +132 -0
- package/server/terminal.js +73 -0
- package/server/welcome.js +168 -0
- package/web/css/app.css +893 -0
- package/web/favicon.svg +5 -0
- package/web/index.html +85 -0
- package/web/js/app.js +1452 -0
- package/web/js/blocks.js +557 -0
- package/web/js/diff.js +68 -0
- package/web/js/md.js +139 -0
package/web/js/blocks.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// Presentation blocks. Each renderer gets (block, ctx) and returns an element.
|
|
2
|
+
// ctx: { session, turn, isLatest, reply(text), draft(text), openFile(path, line), fileUrl(path) }
|
|
3
|
+
|
|
4
|
+
import { md, esc, inlineMd, highlight, langOf } from './md.js';
|
|
5
|
+
import { diffLines, hunks, countChanges } from './diff.js';
|
|
6
|
+
|
|
7
|
+
export const icon = (name, cls = '') => `<svg class="i ${cls}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
|
|
8
|
+
|
|
9
|
+
const el = (html) => {
|
|
10
|
+
const t = document.createElement('template');
|
|
11
|
+
t.innerHTML = html.trim();
|
|
12
|
+
return t.content.firstElementChild;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
16
|
+
|
|
17
|
+
function countUp(node, to, delay = 0, decimals = null) {
|
|
18
|
+
const n = Number(to);
|
|
19
|
+
if (!Number.isFinite(n)) {
|
|
20
|
+
node.textContent = to;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const dec = decimals ?? (String(to).split('.')[1]?.length || 0);
|
|
24
|
+
const fmt = (v) => v.toLocaleString(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
|
25
|
+
if (reduced()) {
|
|
26
|
+
node.textContent = fmt(n);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
node.textContent = fmt(0);
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
const dur = 900 + Math.min(700, Math.log10(Math.abs(n) + 1) * 220);
|
|
32
|
+
const t0 = performance.now();
|
|
33
|
+
const tick = (t) => {
|
|
34
|
+
const p = Math.min(1, (t - t0) / dur);
|
|
35
|
+
const e = 1 - Math.pow(1 - p, 4);
|
|
36
|
+
node.textContent = fmt(n * e);
|
|
37
|
+
if (p < 1) requestAnimationFrame(tick);
|
|
38
|
+
};
|
|
39
|
+
requestAnimationFrame(tick);
|
|
40
|
+
}, delay);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function splitPath(p = '') {
|
|
44
|
+
const i = p.lastIndexOf('/');
|
|
45
|
+
return i < 0 ? ['', p] : [p.slice(0, i + 1), p.slice(i + 1)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pathHtml(p) {
|
|
49
|
+
const [dir, base] = splitPath(p);
|
|
50
|
+
return `<span class="path"><span class="dir">${esc(dir)}</span><span class="base">${esc(base)}</span></span>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CHANGE = {
|
|
54
|
+
added: ['A', 'add'],
|
|
55
|
+
create: ['A', 'add'],
|
|
56
|
+
modified: ['M', 'mod'],
|
|
57
|
+
edit: ['M', 'mod'],
|
|
58
|
+
write: ['M', 'mod'],
|
|
59
|
+
deleted: ['D', 'del'],
|
|
60
|
+
renamed: ['R', 'ren'],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function findChange(turn, file) {
|
|
64
|
+
if (!file || !turn?.changes) return null;
|
|
65
|
+
return turn.changes.find((c) => c.file === file) || turn.changes.find((c) => c.file.endsWith('/' + file) || file.endsWith('/' + c.file));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- renderers --------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const R = {};
|
|
71
|
+
|
|
72
|
+
R.hero = (b, ctx) => {
|
|
73
|
+
const status = ['done', 'partial', 'blocked', 'info'].includes(b.status) ? b.status : 'done';
|
|
74
|
+
const label = { done: 'Done', partial: 'Partly done', blocked: 'Blocked', info: 'Report' }[status];
|
|
75
|
+
const st = ctx.turn?.stats || {};
|
|
76
|
+
const meta = [
|
|
77
|
+
ctx.turn?.endedAt && ctx.turn?.startedAt ? fmtDuration(ctx.turn.endedAt - ctx.turn.startedAt) : '',
|
|
78
|
+
st.costUsd ? `$${st.costUsd.toFixed(2)}` : '',
|
|
79
|
+
ctx.turn?.changes?.length ? `${ctx.turn.changes.length} file${ctx.turn.changes.length > 1 ? 's' : ''} touched` : '',
|
|
80
|
+
].filter(Boolean);
|
|
81
|
+
return el(`
|
|
82
|
+
<div class="b-hero st-${status}">
|
|
83
|
+
<div class="aura"></div>
|
|
84
|
+
<div class="hero-top"><span class="pill st-${status}"><i></i>${label}</span>${meta.map((m) => `<span class="meta">${esc(m)}</span>`).join('')}</div>
|
|
85
|
+
<h2>${esc(b.title || 'Finished')}</h2>
|
|
86
|
+
${b.summary ? `<p>${inlineMd(b.summary)}</p>` : ''}
|
|
87
|
+
</div>`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
R.markdown = (b) => el(`<div class="b-md prose">${md(b.text || '')}</div>`);
|
|
91
|
+
|
|
92
|
+
R.steps = (b) => {
|
|
93
|
+
const items = b.items || [];
|
|
94
|
+
const node = el(`
|
|
95
|
+
<div class="b-steps card">
|
|
96
|
+
${b.title ? `<h3 class="b-title">${esc(b.title)}</h3>` : ''}
|
|
97
|
+
<ol>${items
|
|
98
|
+
.map((s, i) => {
|
|
99
|
+
const st = s.status || 'done';
|
|
100
|
+
const ic = st === 'failed' ? 'x' : st === 'skipped' ? 'skip' : 'check';
|
|
101
|
+
return `<li class="st-${st}" style="--k:${i}"><span class="dot">${icon(ic)}</span><div><b>${inlineMd(s.title || '')}</b>${s.detail ? `<p>${inlineMd(s.detail)}</p>` : ''}</div></li>`;
|
|
102
|
+
})
|
|
103
|
+
.join('')}</ol>
|
|
104
|
+
</div>`);
|
|
105
|
+
return node;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
R.stats = (b, ctx) => {
|
|
109
|
+
const node = el(`<div class="b-stats">${(b.items || [])
|
|
110
|
+
.map(
|
|
111
|
+
(s, i) => `
|
|
112
|
+
<div class="stat tone-${s.tone || 'neutral'}" style="--k:${i}">
|
|
113
|
+
<div class="num"><span class="v" data-v="${esc(s.value)}"></span>${s.unit ? `<span class="u">${esc(s.unit)}</span>` : ''}</div>
|
|
114
|
+
<div class="lbl">${esc(s.label || '')}</div>
|
|
115
|
+
${s.delta !== undefined && s.delta !== '' ? `<span class="delta">${esc(s.delta)}</span>` : ''}
|
|
116
|
+
</div>`,
|
|
117
|
+
)
|
|
118
|
+
.join('')}</div>`);
|
|
119
|
+
node.querySelectorAll('.v').forEach((v, i) => countUp(v, v.dataset.v, ctx.delay + 250 + i * 120));
|
|
120
|
+
return node;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
R.files = (b, ctx) => {
|
|
124
|
+
const node = el(`
|
|
125
|
+
<div class="b-files card">
|
|
126
|
+
<h3 class="b-title">${esc(b.title || 'Files')}<span class="count">${(b.items || []).length}</span></h3>
|
|
127
|
+
<ul>${(b.items || [])
|
|
128
|
+
.map((f, i) => {
|
|
129
|
+
const [ch, cls] = CHANGE[f.change] || CHANGE.modified;
|
|
130
|
+
const c = findChange(ctx.turn, f.path);
|
|
131
|
+
const n = c ? countChanges(c.edits) : null;
|
|
132
|
+
return `<li style="--k:${i}" data-path="${esc(f.path)}" class="${c ? 'has-diff' : ''}">
|
|
133
|
+
<div class="row">
|
|
134
|
+
<span class="badge ${cls}">${ch}</span>${pathHtml(f.path)}
|
|
135
|
+
${n ? `<span class="nums"><span class="plus">+${n.add}</span><span class="minus">−${n.del}</span></span>` : ''}
|
|
136
|
+
<span class="actions">${c ? `<button class="ghost xs toggle" title="Show diff">${icon('chevron')}</button>` : ''}<button class="ghost xs open" title="Open in editor">${icon('external')}</button></span>
|
|
137
|
+
</div>
|
|
138
|
+
${f.note ? `<p class="note">${inlineMd(f.note)}</p>` : ''}
|
|
139
|
+
</li>`;
|
|
140
|
+
})
|
|
141
|
+
.join('')}</ul>
|
|
142
|
+
</div>`);
|
|
143
|
+
node.addEventListener('click', (e) => {
|
|
144
|
+
const li = e.target.closest('li');
|
|
145
|
+
if (!li) return;
|
|
146
|
+
if (e.target.closest('.open')) return ctx.openFile(li.dataset.path);
|
|
147
|
+
if (!li.classList.contains('has-diff')) return;
|
|
148
|
+
if (e.target.closest('a')) return;
|
|
149
|
+
const existing = li.querySelector('.b-diff');
|
|
150
|
+
if (existing) {
|
|
151
|
+
existing.remove();
|
|
152
|
+
li.classList.remove('open');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
li.classList.add('open');
|
|
156
|
+
li.append(R.diff({ file: li.dataset.path, bare: true }, ctx));
|
|
157
|
+
});
|
|
158
|
+
return node;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
R.tree = (b, ctx) => {
|
|
162
|
+
const root = { children: new Map() };
|
|
163
|
+
for (const f of b.items || []) {
|
|
164
|
+
const parts = f.path.split('/').filter(Boolean);
|
|
165
|
+
let cur = root;
|
|
166
|
+
parts.forEach((p, i) => {
|
|
167
|
+
if (!cur.children.has(p)) cur.children.set(p, { name: p, children: new Map(), item: null, path: parts.slice(0, i + 1).join('/') });
|
|
168
|
+
cur = cur.children.get(p);
|
|
169
|
+
});
|
|
170
|
+
cur.item = f;
|
|
171
|
+
}
|
|
172
|
+
let k = 0;
|
|
173
|
+
const render = (node, depth) =>
|
|
174
|
+
[...node.children.values()]
|
|
175
|
+
.sort((a, b) => (b.children.size > 0) - (a.children.size > 0) || a.name.localeCompare(b.name))
|
|
176
|
+
.map((n) => {
|
|
177
|
+
// collapse single-child directory chains: src/lib/util
|
|
178
|
+
let name = n.name;
|
|
179
|
+
while (!n.item && n.children.size === 1 && [...n.children.values()][0].children.size > 0) {
|
|
180
|
+
n = [...n.children.values()][0];
|
|
181
|
+
name += '/' + n.name;
|
|
182
|
+
}
|
|
183
|
+
const isDir = n.children.size > 0 && !n.item;
|
|
184
|
+
const [ch, cls] = n.item ? CHANGE[n.item.change] || CHANGE.added : ['', ''];
|
|
185
|
+
return `<li style="--k:${k++};--depth:${depth}" class="${isDir ? 'dir' : 'file'}" ${isDir ? '' : `data-path="${esc(n.path)}"`}>
|
|
186
|
+
<span class="row">${icon(isDir ? 'folder' : 'file')}<span class="name">${esc(name)}${isDir ? '/' : ''}</span>${ch ? `<span class="badge ${cls}">${ch}</span>` : ''}${n.item?.note ? `<span class="note">${inlineMd(n.item.note)}</span>` : ''}</span>
|
|
187
|
+
</li>${isDir ? render(n, depth + 1) : ''}`;
|
|
188
|
+
})
|
|
189
|
+
.join('');
|
|
190
|
+
const node = el(`<div class="b-tree card">${b.title ? `<h3 class="b-title">${esc(b.title)}</h3>` : ''}<ul>${render(root, 0)}</ul></div>`);
|
|
191
|
+
node.addEventListener('click', (e) => {
|
|
192
|
+
const li = e.target.closest('li.file');
|
|
193
|
+
if (li) ctx.openFile(li.dataset.path);
|
|
194
|
+
});
|
|
195
|
+
return node;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
R.diff = (b, ctx) => {
|
|
199
|
+
let edits;
|
|
200
|
+
let op = 'edit';
|
|
201
|
+
let file = b.file || b.path || '';
|
|
202
|
+
if (b.before !== undefined || b.after !== undefined) edits = [{ before: b.before || '', after: b.after || '' }];
|
|
203
|
+
else {
|
|
204
|
+
const c = findChange(ctx.turn, file);
|
|
205
|
+
if (c) {
|
|
206
|
+
edits = c.edits;
|
|
207
|
+
op = c.op;
|
|
208
|
+
file = c.file;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!edits) {
|
|
212
|
+
return el(`<div class="b-diff card missing"><div class="diff-head">${icon('file')}${pathHtml(file)}</div><p class="muted">No captured edits for this file.</p></div>`);
|
|
213
|
+
}
|
|
214
|
+
const { add, del } = countChanges(edits);
|
|
215
|
+
const numbered = op === 'create' || op === 'write';
|
|
216
|
+
const lang = langOf(file);
|
|
217
|
+
const body = edits
|
|
218
|
+
.map((e, ei) => {
|
|
219
|
+
const rows = hunks(diffLines(e.before, e.after), 3)
|
|
220
|
+
.map((o) =>
|
|
221
|
+
o.op === '…'
|
|
222
|
+
? `<tr class="gap"><td colspan="3">${icon('dots')} ${o.count} unchanged line${o.count > 1 ? 's' : ''}</td></tr>`
|
|
223
|
+
: `<tr class="ln ${o.op === '+' ? 'add' : o.op === '-' ? 'del' : ''}"><td class="no">${numbered ? (o.op === '+' ? o.b : o.op === '-' ? o.a : o.b) || '' : ''}</td><td class="sign">${o.op === ' ' ? '' : o.op === '-' ? '−' : '+'}</td><td class="src">${highlight(o.text, lang) || ' '}</td></tr>`,
|
|
224
|
+
)
|
|
225
|
+
.join('');
|
|
226
|
+
return `${ei > 0 ? '<tr class="sep"><td colspan="3"></td></tr>' : ''}${rows}`;
|
|
227
|
+
})
|
|
228
|
+
.join('');
|
|
229
|
+
const node = el(`
|
|
230
|
+
<div class="b-diff ${b.bare ? 'bare' : 'card'}">
|
|
231
|
+
${b.bare ? '' : `<div class="diff-head">${icon('file')}${pathHtml(file)}<span class="nums"><span class="plus">+${add}</span><span class="minus">−${del}</span></span><button class="ghost xs open" title="Open in editor">${icon('external')}</button></div>`}
|
|
232
|
+
<div class="diff-scroll"><table><tbody>${body}</tbody></table></div>
|
|
233
|
+
${b.caption ? `<p class="caption">${inlineMd(b.caption)}</p>` : ''}
|
|
234
|
+
</div>`);
|
|
235
|
+
node.querySelector('.open')?.addEventListener('click', () => ctx.openFile(file));
|
|
236
|
+
return node;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
R.code = (b, ctx) => {
|
|
240
|
+
const lines = String(b.code || '').replace(/\n$/, '').split('\n');
|
|
241
|
+
const hl = new Set(b.highlight || []);
|
|
242
|
+
const start = Number(b.line) || 1;
|
|
243
|
+
const lang = b.language || langOf(b.file || '');
|
|
244
|
+
const node = el(`
|
|
245
|
+
<div class="b-code card">
|
|
246
|
+
<div class="code-head">${b.file ? `<button class="link file">${pathHtml(b.file)}${b.line ? `<span class="lineno">:${b.line}</span>` : ''}</button>` : `<span class="lang">${esc(lang)}</span>`}<button class="ghost xs copy" title="Copy">${icon('copy')}</button></div>
|
|
247
|
+
<div class="code-scroll"><table><tbody>${lines
|
|
248
|
+
.map((l, i) => `<tr class="${hl.has(i + 1) ? 'hl' : ''}"><td class="no">${start + i}</td><td class="src">${highlight(l, lang) || ' '}</td></tr>`)
|
|
249
|
+
.join('')}</tbody></table></div>
|
|
250
|
+
${b.caption ? `<p class="caption">${inlineMd(b.caption)}</p>` : ''}
|
|
251
|
+
</div>`);
|
|
252
|
+
node.querySelector('.copy').addEventListener('click', (e) => copy(b.code, e.currentTarget));
|
|
253
|
+
node.querySelector('.file')?.addEventListener('click', () => ctx.openFile(b.file, b.line));
|
|
254
|
+
return node;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
R.compare = (b) => {
|
|
258
|
+
const side = (s = {}, kind) => `
|
|
259
|
+
<div class="side ${kind}">
|
|
260
|
+
<div class="side-label">${esc(s.label || (kind === 'before' ? 'Before' : 'After'))}</div>
|
|
261
|
+
${s.items ? `<ul>${s.items.map((t, i) => `<li style="--k:${i}">${icon(kind === 'before' ? 'minus' : 'plus')}<span>${inlineMd(t)}</span></li>`).join('')}</ul>` : ''}
|
|
262
|
+
${s.text ? `<div class="prose">${md(s.text)}</div>` : ''}
|
|
263
|
+
${s.code ? `<pre class="code"><code>${highlight(s.code, s.language || '')}</code></pre>` : ''}
|
|
264
|
+
</div>`;
|
|
265
|
+
return el(`<div class="b-compare">${side(b.before, 'before')}<div class="arrow">${icon('arrow')}</div>${side(b.after, 'after')}</div>`);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
R.flow = (b) => {
|
|
269
|
+
const nodes = (b.nodes || []).map((n, i) => (typeof n === 'string' ? { id: String(i), label: n } : { id: String(n.id ?? i), ...n }));
|
|
270
|
+
const ids = nodes.map((n) => n.id);
|
|
271
|
+
let edges = (b.edges || []).map((e) => (Array.isArray(e) ? { from: String(e[0]), to: String(e[1]), label: e[2] } : { from: String(e.from), to: String(e.to), label: e.label }));
|
|
272
|
+
if (!b.edges) edges = nodes.slice(1).map((n, i) => ({ from: nodes[i].id, to: n.id }));
|
|
273
|
+
edges = edges.filter((e) => ids.includes(e.from) && ids.includes(e.to));
|
|
274
|
+
|
|
275
|
+
// Longest-path layering; edges pointing backwards in declaration order are "loops".
|
|
276
|
+
const level = Object.fromEntries(ids.map((id) => [id, 0]));
|
|
277
|
+
const order = Object.fromEntries(ids.map((id, i) => [id, i]));
|
|
278
|
+
const forward = edges.filter((e) => order[e.to] > order[e.from]);
|
|
279
|
+
for (const id of ids) for (const e of forward) if (e.from === id) level[e.to] = Math.max(level[e.to], level[id] + 1);
|
|
280
|
+
const cols = [];
|
|
281
|
+
for (const n of nodes) (cols[level[n.id]] ||= []).push(n);
|
|
282
|
+
|
|
283
|
+
const node = el(`
|
|
284
|
+
<div class="b-flow card">
|
|
285
|
+
${b.title ? `<h3 class="b-title">${esc(b.title)}</h3>` : ''}
|
|
286
|
+
<div class="flow-stage">
|
|
287
|
+
<svg class="wires"></svg>
|
|
288
|
+
<div class="cols">${cols
|
|
289
|
+
.map(
|
|
290
|
+
(col, ci) => `<div class="col">${col
|
|
291
|
+
.map((n) => `<div class="node" data-id="${esc(n.id)}" style="--k:${ci}"><b>${inlineMd(n.label || n.id)}</b>${n.note ? `<span>${inlineMd(n.note)}</span>` : ''}</div>`)
|
|
292
|
+
.join('')}</div>`,
|
|
293
|
+
)
|
|
294
|
+
.join('')}</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>`);
|
|
297
|
+
|
|
298
|
+
const draw = () => {
|
|
299
|
+
const stage = node.querySelector('.flow-stage');
|
|
300
|
+
const svg = node.querySelector('.wires');
|
|
301
|
+
const box = stage.getBoundingClientRect();
|
|
302
|
+
if (!box.width) return;
|
|
303
|
+
svg.setAttribute('viewBox', `0 0 ${box.width} ${box.height}`);
|
|
304
|
+
const rect = (id) => {
|
|
305
|
+
const r = stage.querySelector(`.node[data-id="${CSS.escape(id)}"]`).getBoundingClientRect();
|
|
306
|
+
return { l: r.left - box.left, t: r.top - box.top, r: r.right - box.left, b: r.bottom - box.top, cx: r.left - box.left + r.width / 2, cy: r.top - box.top + r.height / 2 };
|
|
307
|
+
};
|
|
308
|
+
const vertical = getComputedStyle(stage.querySelector('.cols')).flexDirection === 'column';
|
|
309
|
+
let out = '';
|
|
310
|
+
edges.forEach((e, i) => {
|
|
311
|
+
const a = rect(e.from);
|
|
312
|
+
const z = rect(e.to);
|
|
313
|
+
const back = order[e.to] <= order[e.from];
|
|
314
|
+
let d;
|
|
315
|
+
let lx;
|
|
316
|
+
let ly;
|
|
317
|
+
if (back) {
|
|
318
|
+
if (vertical) {
|
|
319
|
+
const x = Math.max(a.r, z.r) + 26;
|
|
320
|
+
d = `M${a.r},${a.cy} C${x},${a.cy} ${x},${z.cy} ${z.r},${z.cy}`;
|
|
321
|
+
lx = x + 4;
|
|
322
|
+
ly = (a.cy + z.cy) / 2;
|
|
323
|
+
} else {
|
|
324
|
+
const y = Math.max(a.b, z.b) + 34;
|
|
325
|
+
d = `M${a.cx},${a.b} C${a.cx},${y} ${z.cx},${y} ${z.cx},${z.b}`;
|
|
326
|
+
lx = (a.cx + z.cx) / 2;
|
|
327
|
+
ly = y - 4;
|
|
328
|
+
}
|
|
329
|
+
} else if (vertical) {
|
|
330
|
+
const m = (a.b + z.t) / 2;
|
|
331
|
+
d = `M${a.cx},${a.b} C${a.cx},${m} ${z.cx},${m} ${z.cx},${z.t}`;
|
|
332
|
+
lx = (a.cx + z.cx) / 2 + 8;
|
|
333
|
+
ly = m;
|
|
334
|
+
} else {
|
|
335
|
+
const m = (a.r + z.l) / 2;
|
|
336
|
+
d = `M${a.r},${a.cy} C${m},${a.cy} ${m},${z.cy} ${z.l},${z.cy}`;
|
|
337
|
+
lx = m;
|
|
338
|
+
ly = (a.cy + z.cy) / 2 - 8;
|
|
339
|
+
}
|
|
340
|
+
out += `<path class="wire ${back ? 'back' : ''}" d="${d}" pathLength="1" style="--k:${i}"/>`;
|
|
341
|
+
if (!reduced()) out += `<circle r="3" class="spark ${back ? 'back' : ''}"><animateMotion dur="${back ? 3.2 : 2.2}s" begin="${0.8 + i * 0.35}s" repeatCount="indefinite" path="${d}"/></circle>`;
|
|
342
|
+
if (e.label) out += `<text x="${lx}" y="${ly}" class="wire-label" text-anchor="${vertical ? 'start' : 'middle'}">${esc(e.label)}</text>`;
|
|
343
|
+
});
|
|
344
|
+
svg.innerHTML = out;
|
|
345
|
+
stage.style.paddingBottom = !vertical && edges.some((e) => order[e.to] <= order[e.from]) ? '46px' : '';
|
|
346
|
+
stage.style.paddingRight = vertical && edges.some((e) => order[e.to] <= order[e.from]) ? '70px' : '';
|
|
347
|
+
};
|
|
348
|
+
new ResizeObserver(() => requestAnimationFrame(draw)).observe(node);
|
|
349
|
+
return node;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
R.chart = (b, ctx) => {
|
|
353
|
+
const items = b.items || [];
|
|
354
|
+
const max = Math.max(...items.map((i) => Math.abs(Number(i.value) || 0)), 1);
|
|
355
|
+
const node = el(`
|
|
356
|
+
<div class="b-chart card">
|
|
357
|
+
${b.title ? `<h3 class="b-title">${esc(b.title)}</h3>` : ''}
|
|
358
|
+
<div class="bars">${items
|
|
359
|
+
.map(
|
|
360
|
+
(it, i) => `<div class="bar-row" style="--k:${i}">
|
|
361
|
+
<span class="bar-label">${esc(it.label)}</span>
|
|
362
|
+
<span class="bar-track"><span class="bar" style="--w:${(Math.abs(Number(it.value) || 0) / max) * 100}%"></span></span>
|
|
363
|
+
<span class="bar-val"><span class="v" data-v="${esc(it.value)}"></span>${b.unit ? `<span class="u">${esc(b.unit)}</span>` : ''}</span>
|
|
364
|
+
</div>`,
|
|
365
|
+
)
|
|
366
|
+
.join('')}</div>
|
|
367
|
+
</div>`);
|
|
368
|
+
node.querySelectorAll('.v').forEach((v, i) => countUp(v, v.dataset.v, ctx.delay + 300 + i * 140));
|
|
369
|
+
return node;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
R.terminal = (b, ctx) => {
|
|
373
|
+
const ok = b.exitCode === undefined || Number(b.exitCode) === 0;
|
|
374
|
+
const node = el(`
|
|
375
|
+
<div class="b-term">
|
|
376
|
+
<div class="term-head"><span class="lights"><i></i><i></i><i></i></span><span class="title">${esc(b.title || ctx.session?.cwd?.split('/').pop() || 'terminal')}</span>${b.exitCode !== undefined ? `<span class="exit ${ok ? 'ok' : 'bad'}">exit ${esc(b.exitCode)}</span>` : ''}</div>
|
|
377
|
+
<pre><span class="cmd"><span class="ps">$</span> <span class="typed"></span><span class="caret"></span></span>\n<span class="out"></span></pre>
|
|
378
|
+
</div>`);
|
|
379
|
+
const typed = node.querySelector('.typed');
|
|
380
|
+
const out = node.querySelector('.out');
|
|
381
|
+
const cmd = String(b.command || '');
|
|
382
|
+
out.textContent = String(b.output || '').replace(/\s+$/, '');
|
|
383
|
+
if (reduced()) {
|
|
384
|
+
typed.textContent = cmd;
|
|
385
|
+
node.classList.add('ran');
|
|
386
|
+
} else {
|
|
387
|
+
let i = 0;
|
|
388
|
+
setTimeout(function type() {
|
|
389
|
+
typed.textContent = cmd.slice(0, ++i);
|
|
390
|
+
if (i < cmd.length) setTimeout(type, 14 + Math.random() * 30);
|
|
391
|
+
else setTimeout(() => node.classList.add('ran'), 220);
|
|
392
|
+
}, ctx.delay + 300);
|
|
393
|
+
}
|
|
394
|
+
return node;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
R.tests = (b, ctx) => {
|
|
398
|
+
const passed = Number(b.passed) || 0;
|
|
399
|
+
const failed = Number(b.failed) || 0;
|
|
400
|
+
const skipped = Number(b.skipped) || 0;
|
|
401
|
+
const total = passed + failed + skipped || 1;
|
|
402
|
+
const C = 2 * Math.PI * 34;
|
|
403
|
+
const seg = (n, off) => `stroke-dasharray="${(n / total) * C} ${C}" stroke-dashoffset="${-off}"`;
|
|
404
|
+
const ok = failed === 0;
|
|
405
|
+
const node = el(`
|
|
406
|
+
<div class="b-tests card ${ok ? 'ok' : 'bad'}">
|
|
407
|
+
<div class="tests-main">
|
|
408
|
+
<svg class="ring" viewBox="0 0 80 80">
|
|
409
|
+
<circle cx="40" cy="40" r="34" class="track"/>
|
|
410
|
+
<circle cx="40" cy="40" r="34" class="seg pass" ${seg(passed, 0)}/>
|
|
411
|
+
<circle cx="40" cy="40" r="34" class="seg fail" ${seg(failed, (passed / total) * C)}/>
|
|
412
|
+
<circle cx="40" cy="40" r="34" class="seg skip" ${seg(skipped, ((passed + failed) / total) * C)}/>
|
|
413
|
+
</svg>
|
|
414
|
+
<div class="ring-label">${icon(ok ? 'check' : 'x')}</div>
|
|
415
|
+
<div class="tests-text">
|
|
416
|
+
<div class="verdict">${ok ? 'All green' : `${failed} failing`}</div>
|
|
417
|
+
<div class="counts"><span class="pass"><b class="v" data-v="${passed}"></b> passed</span>${failed ? `<span class="fail"><b class="v" data-v="${failed}"></b> failed</span>` : ''}${skipped ? `<span class="skip"><b class="v" data-v="${skipped}"></b> skipped</span>` : ''}${b.durationMs ? `<span class="dur">${fmtDuration(b.durationMs)}</span>` : ''}</div>
|
|
418
|
+
${b.command ? `<code class="cmdline">$ ${esc(b.command)}</code>` : ''}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
${(b.failures || []).length ? `<ul class="failures">${b.failures
|
|
422
|
+
.map((f) => `<li>${icon('x')}<div><b>${esc(f.name)}</b>${f.message ? `<pre>${esc(f.message)}</pre>` : ''}${f.file ? `<button class="link file" data-file="${esc(f.file)}">${esc(f.file)}</button>` : ''}</div></li>`)
|
|
423
|
+
.join('')}</ul>` : ''}
|
|
424
|
+
</div>`);
|
|
425
|
+
node.querySelectorAll('.v').forEach((v, i) => countUp(v, v.dataset.v, ctx.delay + 300 + i * 100));
|
|
426
|
+
node.querySelectorAll('.file').forEach((btn) =>
|
|
427
|
+
btn.addEventListener('click', () => {
|
|
428
|
+
const [p, line] = btn.dataset.file.split(':');
|
|
429
|
+
ctx.openFile(p, line);
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
return node;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
R.commits = (b) =>
|
|
436
|
+
el(`
|
|
437
|
+
<div class="b-commits card">
|
|
438
|
+
<h3 class="b-title">${icon('branch')}${esc(b.branch || 'Commits')}<span class="count">${(b.items || []).length}</span></h3>
|
|
439
|
+
<ol>${(b.items || [])
|
|
440
|
+
.map(
|
|
441
|
+
(c, i) => `<li style="--k:${i}"><span class="node"></span><div><div class="msg">${inlineMd(c.message || '')}</div><div class="sub"><code>${esc(String(c.hash || '').slice(0, 7))}</code>${c.files ? `<span>${c.files} file${c.files > 1 ? 's' : ''}</span>` : ''}${c.additions !== undefined ? `<span class="plus">+${c.additions}</span>` : ''}${c.deletions !== undefined ? `<span class="minus">−${c.deletions}</span>` : ''}</div></div></li>`,
|
|
442
|
+
)
|
|
443
|
+
.join('')}</ol>
|
|
444
|
+
</div>`);
|
|
445
|
+
|
|
446
|
+
R.checklist = (b) =>
|
|
447
|
+
el(`
|
|
448
|
+
<div class="b-check card">
|
|
449
|
+
${b.title ? `<h3 class="b-title">${esc(b.title)}</h3>` : ''}
|
|
450
|
+
<ul>${(b.items || []).map((it, i) => `<li class="${it.done ? 'done' : ''}" style="--k:${i}"><span class="box">${icon('check')}</span><span>${inlineMd(it.text || '')}</span></li>`).join('')}</ul>
|
|
451
|
+
</div>`);
|
|
452
|
+
|
|
453
|
+
R.callout = (b) => {
|
|
454
|
+
const tone = ['info', 'success', 'warn', 'danger'].includes(b.tone) ? b.tone : 'info';
|
|
455
|
+
const ic = { info: 'info', success: 'check', warn: 'warn', danger: 'x' }[tone];
|
|
456
|
+
return el(`<div class="b-callout tone-${tone}"><span class="ic">${icon(ic)}</span><div>${b.title ? `<b>${esc(b.title)}</b>` : ''}<div class="prose">${md(b.text || '')}</div></div></div>`);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
R.image = (b, ctx) => {
|
|
460
|
+
const src = /^https?:/.test(b.src || '') ? b.src : ctx.fileUrl(b.path || b.src || '');
|
|
461
|
+
const node = el(`<figure class="b-image card"><img loading="lazy" alt="${esc(b.caption || b.path || '')}">${b.caption ? `<figcaption>${inlineMd(b.caption)}</figcaption>` : ''}</figure>`);
|
|
462
|
+
const img = node.querySelector('img');
|
|
463
|
+
img.src = src;
|
|
464
|
+
img.addEventListener('click', () => ctx.zoom(src));
|
|
465
|
+
img.addEventListener('error', () => node.classList.add('broken'));
|
|
466
|
+
return node;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
R.question = (b, ctx) => {
|
|
470
|
+
const answered = !ctx.isLatest;
|
|
471
|
+
const node = el(`
|
|
472
|
+
<div class="b-question ${answered ? 'answered' : ''}">
|
|
473
|
+
<div class="q-head">${icon('ask')}<span>${answered ? 'Agent asked' : 'Needs your call'}</span></div>
|
|
474
|
+
<p class="q-text">${inlineMd(b.question || '')}</p>
|
|
475
|
+
<div class="q-opts">${(b.options || [])
|
|
476
|
+
.map((o, i) => {
|
|
477
|
+
const opt = typeof o === 'string' ? { label: o } : o;
|
|
478
|
+
return `<button class="opt" data-i="${i}" ${answered ? 'disabled' : ''}><span class="k">${i + 1}</span>${esc(opt.label)}${opt.detail ? `<small>${esc(opt.detail)}</small>` : ''}</button>`;
|
|
479
|
+
})
|
|
480
|
+
.join('')}${answered ? '' : `<button class="opt own">${icon('pen')}Answer in my own words</button>`}</div>
|
|
481
|
+
</div>`);
|
|
482
|
+
node.addEventListener('click', (e) => {
|
|
483
|
+
const btn = e.target.closest('.opt');
|
|
484
|
+
if (!btn || answered) return;
|
|
485
|
+
if (btn.classList.contains('own')) return ctx.draft('');
|
|
486
|
+
const o = b.options[btn.dataset.i];
|
|
487
|
+
ctx.reply(typeof o === 'string' ? o : o.reply || o.label);
|
|
488
|
+
});
|
|
489
|
+
return node;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
R.next = (b, ctx) => {
|
|
493
|
+
const node = el(`
|
|
494
|
+
<div class="b-next">
|
|
495
|
+
<div class="next-label">${icon('spark')}Follow up</div>
|
|
496
|
+
<div class="chips">${(b.items || []).map((t, i) => `<button class="chip" style="--k:${i}" data-i="${i}">${esc(typeof t === 'string' ? t : t.label)}</button>`).join('')}</div>
|
|
497
|
+
</div>`);
|
|
498
|
+
node.addEventListener('click', (e) => {
|
|
499
|
+
const chip = e.target.closest('.chip');
|
|
500
|
+
if (!chip) return;
|
|
501
|
+
const t = b.items[chip.dataset.i];
|
|
502
|
+
const text = typeof t === 'string' ? t : t.reply || t.label;
|
|
503
|
+
if (e.metaKey || e.ctrlKey) ctx.reply(text);
|
|
504
|
+
else ctx.draft(text);
|
|
505
|
+
});
|
|
506
|
+
return node;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
R.unknown = (b) => el(`<div class="b-code card"><div class="code-head"><span class="lang">unknown block · ${esc(b.type)}</span></div><pre class="code"><code>${esc(JSON.stringify(b, null, 2))}</code></pre></div>`);
|
|
510
|
+
|
|
511
|
+
// --- public -----------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
export function renderPresentation(presentation, ctx) {
|
|
514
|
+
const wrap = document.createElement('div');
|
|
515
|
+
wrap.className = 'presentation';
|
|
516
|
+
const blocks = presentation?.blocks || [];
|
|
517
|
+
// Group consecutive half-width-friendly blocks? Keep it a single calm column.
|
|
518
|
+
blocks.forEach((b, i) => {
|
|
519
|
+
const delay = reduced() ? 0 : Math.min(i, 10) * 110;
|
|
520
|
+
let node;
|
|
521
|
+
try {
|
|
522
|
+
node = (R[b.type] || R.unknown)(b, { ...ctx, delay });
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.error('block failed', b, err);
|
|
525
|
+
node = R.unknown(b);
|
|
526
|
+
}
|
|
527
|
+
const holder = document.createElement('div');
|
|
528
|
+
holder.className = `blk blk-${b.type}`;
|
|
529
|
+
holder.style.setProperty('--d', `${delay}ms`);
|
|
530
|
+
holder.append(node);
|
|
531
|
+
wrap.append(holder);
|
|
532
|
+
});
|
|
533
|
+
if (presentation?.fallback) {
|
|
534
|
+
wrap.prepend(el(`<div class="blk"><div class="fallback-note">${icon('info')} The agent answered in plain text instead of a wisp presentation.</div></div>`));
|
|
535
|
+
}
|
|
536
|
+
return wrap;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function fmtDuration(ms) {
|
|
540
|
+
if (!ms && ms !== 0) return '';
|
|
541
|
+
const s = Math.round(ms / 1000);
|
|
542
|
+
if (s < 1) return `${ms}ms`;
|
|
543
|
+
if (s < 60) return `${s}s`;
|
|
544
|
+
const m = Math.floor(s / 60);
|
|
545
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`;
|
|
546
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function copy(text, btn) {
|
|
550
|
+
try {
|
|
551
|
+
await navigator.clipboard.writeText(text);
|
|
552
|
+
btn?.classList.add('copied');
|
|
553
|
+
setTimeout(() => btn?.classList.remove('copied'), 1200);
|
|
554
|
+
} catch {}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export { el, pathHtml, countChanges };
|
package/web/js/diff.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Line diff (LCS) for rendering captured edits as unified hunks.
|
|
2
|
+
|
|
3
|
+
export function diffLines(before = '', after = '') {
|
|
4
|
+
const a = before === '' ? [] : before.split('\n');
|
|
5
|
+
const b = after === '' ? [] : after.split('\n');
|
|
6
|
+
|
|
7
|
+
// Trim common prefix/suffix so the LCS table stays small.
|
|
8
|
+
let start = 0;
|
|
9
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
10
|
+
let endA = a.length;
|
|
11
|
+
let endB = b.length;
|
|
12
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) endA--, endB--;
|
|
13
|
+
|
|
14
|
+
const midA = a.slice(start, endA);
|
|
15
|
+
const midB = b.slice(start, endB);
|
|
16
|
+
const ops = [];
|
|
17
|
+
for (let i = 0; i < start; i++) ops.push({ op: ' ', text: a[i], a: i + 1, b: i + 1 });
|
|
18
|
+
|
|
19
|
+
if (midA.length * midB.length > 4_000_000) {
|
|
20
|
+
midA.forEach((t, i) => ops.push({ op: '-', text: t, a: start + i + 1 }));
|
|
21
|
+
midB.forEach((t, i) => ops.push({ op: '+', text: t, b: start + i + 1 }));
|
|
22
|
+
} else {
|
|
23
|
+
const n = midA.length;
|
|
24
|
+
const m = midB.length;
|
|
25
|
+
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
26
|
+
for (let i = n - 1; i >= 0; i--)
|
|
27
|
+
for (let j = m - 1; j >= 0; j--) dp[i][j] = midA[i] === midB[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
28
|
+
let i = 0;
|
|
29
|
+
let j = 0;
|
|
30
|
+
while (i < n || j < m) {
|
|
31
|
+
if (i < n && j < m && midA[i] === midB[j]) ops.push({ op: ' ', text: midA[i], a: start + ++i, b: start + ++j });
|
|
32
|
+
else if (j < m && (i >= n || dp[i][j + 1] > dp[i + 1][j])) ops.push({ op: '+', text: midB[j], b: start + ++j });
|
|
33
|
+
else ops.push({ op: '-', text: midA[i], a: start + ++i });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (let k = 0; k < a.length - endA; k++) ops.push({ op: ' ', text: a[endA + k], a: endA + k + 1, b: endB + k + 1 });
|
|
37
|
+
return ops;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Collapse long unchanged runs, keeping `context` lines around changes.
|
|
41
|
+
export function hunks(ops, context = 3) {
|
|
42
|
+
const keep = new Array(ops.length).fill(false);
|
|
43
|
+
ops.forEach((o, i) => {
|
|
44
|
+
if (o.op !== ' ') for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) keep[k] = true;
|
|
45
|
+
});
|
|
46
|
+
const out = [];
|
|
47
|
+
let skipped = 0;
|
|
48
|
+
ops.forEach((o, i) => {
|
|
49
|
+
if (keep[i]) {
|
|
50
|
+
if (skipped) out.push({ op: '…', count: skipped });
|
|
51
|
+
skipped = 0;
|
|
52
|
+
out.push(o);
|
|
53
|
+
} else skipped++;
|
|
54
|
+
});
|
|
55
|
+
if (skipped && out.length) out.push({ op: '…', count: skipped });
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function countChanges(edits = []) {
|
|
60
|
+
let add = 0;
|
|
61
|
+
let del = 0;
|
|
62
|
+
for (const e of edits)
|
|
63
|
+
for (const o of diffLines(e.before, e.after)) {
|
|
64
|
+
if (o.op === '+') add++;
|
|
65
|
+
else if (o.op === '-') del++;
|
|
66
|
+
}
|
|
67
|
+
return { add, del };
|
|
68
|
+
}
|