wisp-agents 0.6.0 → 0.8.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/CHANGELOG.md +52 -1
- package/README.md +90 -1
- package/bin/wisp.js +52 -5
- package/package.json +2 -2
- package/server/adapters/claude.js +4 -0
- package/server/adapters/codex.js +1 -0
- package/server/adapters/common.js +1 -1
- package/server/adapters/index.js +2 -1
- package/server/adapters/opencode.js +4 -0
- package/server/checkpoint.js +348 -0
- package/server/client.js +26 -2
- package/server/git.js +199 -0
- package/server/index.js +473 -21
- package/server/mcp-ask.js +75 -2
- package/server/mcp-wisp.js +7 -1
- package/server/openapi.js +168 -4
- package/server/preferences.js +296 -0
- package/server/protocol.js +11 -1
- package/server/recipes.js +64 -0
- package/server/remote.js +279 -0
- package/server/render.js +18 -0
- package/server/runner.js +153 -13
- package/server/store.js +9 -1
- package/server/subagents.js +85 -0
- package/server/triggers.js +268 -0
- package/server/update.js +136 -0
- package/server/usage.js +79 -0
- package/server/welcome.js +9 -1
- package/skills/wisp/SKILL.md +3 -2
- package/web/css/app.css +637 -14
- package/web/index.html +15 -3
- package/web/js/app.js +2060 -87
- package/web/js/blocks.js +396 -22
package/web/js/blocks.js
CHANGED
|
@@ -121,6 +121,7 @@ R.stats = (b, ctx) => {
|
|
|
121
121
|
};
|
|
122
122
|
|
|
123
123
|
R.files = (b, ctx) => {
|
|
124
|
+
const canRevert = !!(ctx.revertFile && ctx.session?.agent && ctx.session.agent !== 'wisp');
|
|
124
125
|
const node = el(`
|
|
125
126
|
<div class="b-files card">
|
|
126
127
|
<h3 class="b-title">${esc(b.title || 'Files')}<span class="count">${(b.items || []).length}</span></h3>
|
|
@@ -129,11 +130,13 @@ R.files = (b, ctx) => {
|
|
|
129
130
|
const [ch, cls] = CHANGE[f.change] || CHANGE.modified;
|
|
130
131
|
const c = findChange(ctx.turn, f.path);
|
|
131
132
|
const n = c ? countChanges(c.edits) : null;
|
|
132
|
-
|
|
133
|
+
const rev = !!c?.reverted;
|
|
134
|
+
return `<li style="--k:${i}" data-path="${esc(f.path)}" class="${c ? 'has-diff' : ''} ${rev ? 'is-reverted' : ''}">
|
|
133
135
|
<div class="row">
|
|
134
136
|
<span class="badge ${cls}">${ch}</span>${pathHtml(f.path)}
|
|
137
|
+
${rev ? '<span class="rev-tag">Reverted</span>' : ''}
|
|
135
138
|
${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>
|
|
139
|
+
<span class="actions">${c && canRevert ? `<button class="ghost xs revert" title="${rev ? 'Re-apply changes to this file' : 'Revert file to before this turn'}">${icon(rev ? 'rerun' : 'replay')}<span>${rev ? 'Re-apply' : 'Revert'}</span></button>` : ''}${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
140
|
</div>
|
|
138
141
|
${f.note ? `<p class="note">${inlineMd(f.note)}</p>` : ''}
|
|
139
142
|
</li>`;
|
|
@@ -144,6 +147,11 @@ R.files = (b, ctx) => {
|
|
|
144
147
|
const li = e.target.closest('li');
|
|
145
148
|
if (!li) return;
|
|
146
149
|
if (e.target.closest('.open')) return ctx.openFile(li.dataset.path);
|
|
150
|
+
const revBtn = e.target.closest('.revert');
|
|
151
|
+
if (revBtn) {
|
|
152
|
+
const c = findChange(ctx.turn, li.dataset.path);
|
|
153
|
+
return ctx.revertFile?.(c?.file || li.dataset.path, !!c?.reverted);
|
|
154
|
+
}
|
|
147
155
|
if (!li.classList.contains('has-diff')) return;
|
|
148
156
|
if (e.target.closest('a')) return;
|
|
149
157
|
const existing = li.querySelector('.b-diff');
|
|
@@ -199,14 +207,12 @@ R.diff = (b, ctx) => {
|
|
|
199
207
|
let edits;
|
|
200
208
|
let op = 'edit';
|
|
201
209
|
let file = b.file || b.path || '';
|
|
210
|
+
const c = findChange(ctx.turn, file);
|
|
202
211
|
if (b.before !== undefined || b.after !== undefined) edits = [{ before: b.before || '', after: b.after || '' }];
|
|
203
|
-
else {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
op = c.op;
|
|
208
|
-
file = c.file;
|
|
209
|
-
}
|
|
212
|
+
else if (c) {
|
|
213
|
+
edits = c.edits;
|
|
214
|
+
op = c.op;
|
|
215
|
+
file = c.file;
|
|
210
216
|
}
|
|
211
217
|
if (!edits) {
|
|
212
218
|
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>`);
|
|
@@ -214,6 +220,8 @@ R.diff = (b, ctx) => {
|
|
|
214
220
|
const { add, del } = countChanges(edits);
|
|
215
221
|
const numbered = op === 'create' || op === 'write';
|
|
216
222
|
const lang = langOf(file);
|
|
223
|
+
const canRevert = !!(c && ctx.revertFile && ctx.session?.agent && ctx.session.agent !== 'wisp');
|
|
224
|
+
const reverted = !!c?.reverted;
|
|
217
225
|
const body = edits
|
|
218
226
|
.map((e, ei) => {
|
|
219
227
|
const rows = hunks(diffLines(e.before, e.after), 3)
|
|
@@ -227,12 +235,13 @@ R.diff = (b, ctx) => {
|
|
|
227
235
|
})
|
|
228
236
|
.join('');
|
|
229
237
|
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
|
|
238
|
+
<div class="b-diff ${b.bare ? 'bare' : 'card'} ${reverted ? 'is-reverted' : ''}">
|
|
239
|
+
${b.bare ? '' : `<div class="diff-head">${icon('file')}${pathHtml(file)}${reverted ? '<span class="rev-tag">Reverted</span>' : ''}<span class="nums"><span class="plus">+${add}</span><span class="minus">−${del}</span></span>${canRevert ? `<button class="ghost xs revert ${reverted ? 'reverted' : ''}" title="${reverted ? 'Re-apply changes to this file' : 'Revert this file to before this turn'}">${icon(reverted ? 'rerun' : 'replay')}<span>${reverted ? 'Re-apply' : 'Revert'}</span></button>` : ''}<button class="ghost xs open" title="Open in editor">${icon('external')}</button></div>`}
|
|
232
240
|
<div class="diff-scroll"><table><tbody>${body}</tbody></table></div>
|
|
233
241
|
${b.caption ? `<p class="caption">${inlineMd(b.caption)}</p>` : ''}
|
|
234
242
|
</div>`);
|
|
235
243
|
node.querySelector('.open')?.addEventListener('click', () => ctx.openFile(file));
|
|
244
|
+
node.querySelector('.revert')?.addEventListener('click', () => ctx.revertFile?.(file, reverted));
|
|
236
245
|
return node;
|
|
237
246
|
};
|
|
238
247
|
|
|
@@ -265,6 +274,23 @@ R.compare = (b) => {
|
|
|
265
274
|
return el(`<div class="b-compare">${side(b.before, 'before')}<div class="arrow">${icon('arrow')}</div>${side(b.after, 'after')}</div>`);
|
|
266
275
|
};
|
|
267
276
|
|
|
277
|
+
// Orthogonal polyline with rounded corners.
|
|
278
|
+
const orthPath = (pts, r = 10) =>
|
|
279
|
+
pts
|
|
280
|
+
.map(([x, y], i) => {
|
|
281
|
+
if (i === 0) return `M${x},${y}`;
|
|
282
|
+
if (i === pts.length - 1) return `L${x},${y}`;
|
|
283
|
+
const [px, py] = pts[i - 1];
|
|
284
|
+
const [nx, ny] = pts[i + 1];
|
|
285
|
+
const k1 = Math.min(r, Math.hypot(x - px, y - py) / 2);
|
|
286
|
+
const k2 = Math.min(r, Math.hypot(nx - x, ny - y) / 2);
|
|
287
|
+
const ux = Math.sign(x - px), uy = Math.sign(y - py), vx = Math.sign(nx - x), vy = Math.sign(ny - y);
|
|
288
|
+
return `L${x - ux * k1},${y - uy * k1} Q${x},${y} ${x + vx * k2},${y + vy * k2}`;
|
|
289
|
+
})
|
|
290
|
+
.join(' ');
|
|
291
|
+
const FLOW_MIN_COL = 150; // px a column needs before the row wraps
|
|
292
|
+
const FLOW_GAP = 46;
|
|
293
|
+
|
|
268
294
|
R.flow = (b) => {
|
|
269
295
|
const nodes = (b.nodes || []).map((n, i) => (typeof n === 'string' ? { id: String(i), label: n } : { id: String(n.id ?? i), ...n }));
|
|
270
296
|
const ids = nodes.map((n) => n.id);
|
|
@@ -305,8 +331,27 @@ R.flow = (b) => {
|
|
|
305
331
|
const r = stage.querySelector(`.node[data-id="${CSS.escape(id)}"]`).getBoundingClientRect();
|
|
306
332
|
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
333
|
};
|
|
308
|
-
const
|
|
334
|
+
const colsEl = stage.querySelector('.cols');
|
|
335
|
+
const vertical = getComputedStyle(colsEl).flexDirection === 'column';
|
|
336
|
+
// Too many columns for the width: wrap into rows that snake (L→R, then R→L, …).
|
|
337
|
+
const hasBack = edges.some((e) => order[e.to] <= order[e.from]);
|
|
338
|
+
const room = box.width - 4 - (hasBack ? 70 : 0); // leave the loop gutter either way so the row count is stable
|
|
339
|
+
const perRow = vertical ? cols.length : Math.max(1, Math.floor((room + FLOW_GAP) / (FLOW_MIN_COL + FLOW_GAP)));
|
|
340
|
+
const snake = !vertical && cols.length > perRow;
|
|
341
|
+
if (snake !== colsEl.classList.contains('snake') || (snake && colsEl.style.getPropertyValue('--per-row') !== String(perRow))) {
|
|
342
|
+
colsEl.classList.toggle('snake', snake);
|
|
343
|
+
colsEl.style.setProperty('--per-row', snake ? perRow : '');
|
|
344
|
+
[...colsEl.children].forEach((c, i) => {
|
|
345
|
+
const row = Math.floor(i / perRow);
|
|
346
|
+
const pos = i % perRow;
|
|
347
|
+
c.style.gridRow = snake ? row + 1 : '';
|
|
348
|
+
c.style.gridColumn = snake ? (row % 2 ? perRow - pos : pos + 1) : '';
|
|
349
|
+
});
|
|
350
|
+
return requestAnimationFrame(draw);
|
|
351
|
+
}
|
|
309
352
|
let out = '';
|
|
353
|
+
let loopTop = false;
|
|
354
|
+
let loopBottom = false;
|
|
310
355
|
edges.forEach((e, i) => {
|
|
311
356
|
const a = rect(e.from);
|
|
312
357
|
const z = rect(e.to);
|
|
@@ -315,13 +360,22 @@ R.flow = (b) => {
|
|
|
315
360
|
let lx;
|
|
316
361
|
let ly;
|
|
317
362
|
if (back) {
|
|
318
|
-
if (
|
|
363
|
+
if (snake && Math.abs(a.cy - z.cy) > 4) {
|
|
364
|
+
// Out to the right gutter, up, then over the top of the target so it never crosses a card.
|
|
365
|
+
const x = box.width - 44;
|
|
366
|
+
const y = z.t - 16;
|
|
367
|
+
d = orthPath([[a.r, a.cy], [x, a.cy], [x, y], [z.cx, y], [z.cx, z.t]]);
|
|
368
|
+
lx = x + 6;
|
|
369
|
+
ly = (a.cy + y) / 2;
|
|
370
|
+
loopTop ||= y < 0;
|
|
371
|
+
} else if (vertical) {
|
|
319
372
|
const x = Math.max(a.r, z.r) + 26;
|
|
320
373
|
d = `M${a.r},${a.cy} C${x},${a.cy} ${x},${z.cy} ${z.r},${z.cy}`;
|
|
321
374
|
lx = x + 4;
|
|
322
375
|
ly = (a.cy + z.cy) / 2;
|
|
323
376
|
} else {
|
|
324
377
|
const y = Math.max(a.b, z.b) + 34;
|
|
378
|
+
loopBottom = true;
|
|
325
379
|
d = `M${a.cx},${a.b} C${a.cx},${y} ${z.cx},${y} ${z.cx},${z.b}`;
|
|
326
380
|
lx = (a.cx + z.cx) / 2;
|
|
327
381
|
ly = y - 4;
|
|
@@ -331,6 +385,18 @@ R.flow = (b) => {
|
|
|
331
385
|
d = `M${a.cx},${a.b} C${a.cx},${m} ${z.cx},${m} ${z.cx},${z.t}`;
|
|
332
386
|
lx = (a.cx + z.cx) / 2 + 8;
|
|
333
387
|
ly = m;
|
|
388
|
+
} else if (snake && z.t >= a.b) {
|
|
389
|
+
// Row turn: drop from the end of one row into the start of the next.
|
|
390
|
+
const m = (a.b + z.t) / 2;
|
|
391
|
+
d = `M${a.cx},${a.b} C${a.cx},${m} ${z.cx},${m} ${z.cx},${z.t}`;
|
|
392
|
+
lx = (a.cx + z.cx) / 2 + 8;
|
|
393
|
+
ly = m;
|
|
394
|
+
} else if (z.cx < a.cx) {
|
|
395
|
+
// Right-to-left row.
|
|
396
|
+
const m = (a.l + z.r) / 2;
|
|
397
|
+
d = `M${a.l},${a.cy} C${m},${a.cy} ${m},${z.cy} ${z.r},${z.cy}`;
|
|
398
|
+
lx = m;
|
|
399
|
+
ly = (a.cy + z.cy) / 2 - 8;
|
|
334
400
|
} else {
|
|
335
401
|
const m = (a.r + z.l) / 2;
|
|
336
402
|
d = `M${a.r},${a.cy} C${m},${a.cy} ${m},${z.cy} ${z.l},${z.cy}`;
|
|
@@ -339,11 +405,13 @@ R.flow = (b) => {
|
|
|
339
405
|
}
|
|
340
406
|
out += `<path class="wire ${back ? 'back' : ''}" d="${d}" pathLength="1" style="--k:${i}"/>`;
|
|
341
407
|
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
|
-
|
|
408
|
+
const side = vertical || (snake && (back ? Math.abs(a.cy - z.cy) > 4 : z.t >= a.b));
|
|
409
|
+
if (e.label) out += `<text x="${lx}" y="${ly}" class="wire-label" text-anchor="${side ? 'start' : 'middle'}">${esc(e.label)}</text>`;
|
|
343
410
|
});
|
|
344
411
|
svg.innerHTML = out;
|
|
345
|
-
stage.style.paddingBottom =
|
|
346
|
-
stage.style.paddingRight = vertical
|
|
412
|
+
stage.style.paddingBottom = loopBottom ? '46px' : '';
|
|
413
|
+
stage.style.paddingRight = (vertical || snake) && hasBack ? '70px' : '';
|
|
414
|
+
stage.style.paddingTop = loopTop || (snake && stage.style.paddingTop && hasBack) ? '22px' : '';
|
|
347
415
|
};
|
|
348
416
|
new ResizeObserver(() => requestAnimationFrame(draw)).observe(node);
|
|
349
417
|
return node;
|
|
@@ -370,17 +438,20 @@ R.chart = (b, ctx) => {
|
|
|
370
438
|
};
|
|
371
439
|
|
|
372
440
|
R.terminal = (b, ctx) => {
|
|
373
|
-
const
|
|
441
|
+
const cmd = String(b.command || '');
|
|
442
|
+
const key = `term:${cmd}`;
|
|
443
|
+
const prev = cmd && ctx.turn?.actionRuns?.[key];
|
|
444
|
+
const exitCode = prev ? prev.exitCode : b.exitCode;
|
|
445
|
+
const ok = exitCode === undefined || Number(exitCode) === 0;
|
|
374
446
|
const node = el(`
|
|
375
447
|
<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>${
|
|
448
|
+
<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>${exitCode !== undefined ? `<span class="exit ${ok ? 'ok' : 'bad'}">exit ${esc(exitCode)}</span>` : ''}${cmd && ctx.exec ? `<button class="term-run" title="Run command again">${icon('rerun')}<span>Run</span></button>` : ''}</div>
|
|
377
449
|
<pre><span class="cmd"><span class="ps">$</span> <span class="typed"></span><span class="caret"></span></span>\n<span class="out"></span></pre>
|
|
378
450
|
</div>`);
|
|
379
451
|
const typed = node.querySelector('.typed');
|
|
380
452
|
const out = node.querySelector('.out');
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
if (reduced()) {
|
|
453
|
+
out.textContent = String(prev ? prev.output : b.output || '').replace(/\s+$/, '');
|
|
454
|
+
if (reduced() || prev) {
|
|
384
455
|
typed.textContent = cmd;
|
|
385
456
|
node.classList.add('ran');
|
|
386
457
|
} else {
|
|
@@ -391,6 +462,31 @@ R.terminal = (b, ctx) => {
|
|
|
391
462
|
else setTimeout(() => node.classList.add('ran'), 220);
|
|
392
463
|
}, ctx.delay + 300);
|
|
393
464
|
}
|
|
465
|
+
const runBtn = node.querySelector('.term-run');
|
|
466
|
+
if (runBtn) {
|
|
467
|
+
runBtn.addEventListener('click', async () => {
|
|
468
|
+
runBtn.disabled = true;
|
|
469
|
+
runBtn.querySelector('span').textContent = 'Running…';
|
|
470
|
+
try {
|
|
471
|
+
const res = await ctx.exec(cmd, key);
|
|
472
|
+
if (res) {
|
|
473
|
+
typed.textContent = cmd;
|
|
474
|
+
node.classList.add('ran');
|
|
475
|
+
out.textContent = String(res.output || '').replace(/\s+$/, '');
|
|
476
|
+
let badge = node.querySelector('.exit');
|
|
477
|
+
if (!badge) {
|
|
478
|
+
badge = el('<span class="exit"></span>');
|
|
479
|
+
runBtn.before(badge);
|
|
480
|
+
}
|
|
481
|
+
badge.className = `exit ${res.exitCode === 0 ? 'ok' : 'bad'}`;
|
|
482
|
+
badge.textContent = `exit ${res.exitCode}`;
|
|
483
|
+
}
|
|
484
|
+
} finally {
|
|
485
|
+
runBtn.disabled = false;
|
|
486
|
+
runBtn.querySelector('span').textContent = 'Run';
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
394
490
|
return node;
|
|
395
491
|
};
|
|
396
492
|
|
|
@@ -402,6 +498,9 @@ R.tests = (b, ctx) => {
|
|
|
402
498
|
const C = 2 * Math.PI * 34;
|
|
403
499
|
const seg = (n, off) => `stroke-dasharray="${(n / total) * C} ${C}" stroke-dashoffset="${-off}"`;
|
|
404
500
|
const ok = failed === 0;
|
|
501
|
+
const cmd = String(b.command || '').trim();
|
|
502
|
+
const runKey = `tests:${cmd}`;
|
|
503
|
+
const prevRun = cmd && ctx.turn?.actionRuns?.[runKey];
|
|
405
504
|
const node = el(`
|
|
406
505
|
<div class="b-tests card ${ok ? 'ok' : 'bad'}">
|
|
407
506
|
<div class="tests-main">
|
|
@@ -415,9 +514,10 @@ R.tests = (b, ctx) => {
|
|
|
415
514
|
<div class="tests-text">
|
|
416
515
|
<div class="verdict">${ok ? 'All green' : `${failed} failing`}</div>
|
|
417
516
|
<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
|
-
${
|
|
517
|
+
${cmd ? `<div class="tests-cmd"><code class="cmdline">$ ${esc(cmd)}</code>${ctx.exec ? `<button class="ghost xs run-tests" title="Re-run ${esc(cmd)}">${icon('rerun')}<span>Re-run</span></button>` : ''}</div>` : ''}
|
|
419
518
|
</div>
|
|
420
519
|
</div>
|
|
520
|
+
<div class="tests-rerun-out" ${prevRun ? '' : 'hidden'}></div>
|
|
421
521
|
${(b.failures || []).length ? `<ul class="failures">${b.failures
|
|
422
522
|
.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
523
|
.join('')}</ul>` : ''}
|
|
@@ -429,6 +529,28 @@ R.tests = (b, ctx) => {
|
|
|
429
529
|
ctx.openFile(p, line);
|
|
430
530
|
}),
|
|
431
531
|
);
|
|
532
|
+
const outSlot = node.querySelector('.tests-rerun-out');
|
|
533
|
+
const paintRun = (res) => {
|
|
534
|
+
if (!res || !outSlot) return;
|
|
535
|
+
outSlot.hidden = false;
|
|
536
|
+
const pass = res.exitCode === 0;
|
|
537
|
+
outSlot.innerHTML = `<div class="act-res ${pass ? 'ok' : 'bad'}"><div class="act-res-bar"><span class="exit ${pass ? 'ok' : 'bad'}">exit ${esc(res.exitCode)}</span><span class="muted xs">${fmtDuration(res.durationMs)}</span></div>${res.output ? `<pre class="act-out">${esc(res.output)}</pre>` : ''}</div>`;
|
|
538
|
+
};
|
|
539
|
+
if (prevRun) paintRun(prevRun);
|
|
540
|
+
const runBtn = node.querySelector('.run-tests');
|
|
541
|
+
if (runBtn) {
|
|
542
|
+
runBtn.addEventListener('click', async () => {
|
|
543
|
+
runBtn.disabled = true;
|
|
544
|
+
runBtn.querySelector('span').textContent = 'Running…';
|
|
545
|
+
try {
|
|
546
|
+
const res = await ctx.exec(cmd, runKey);
|
|
547
|
+
paintRun(res);
|
|
548
|
+
} finally {
|
|
549
|
+
runBtn.disabled = false;
|
|
550
|
+
runBtn.querySelector('span').textContent = 'Re-run';
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
432
554
|
return node;
|
|
433
555
|
};
|
|
434
556
|
|
|
@@ -443,6 +565,183 @@ R.commits = (b) =>
|
|
|
443
565
|
.join('')}</ol>
|
|
444
566
|
</div>`);
|
|
445
567
|
|
|
568
|
+
// A branch graph for a PR or merge: lanes per branch, the merge target on top, commits that
|
|
569
|
+
// conflict marked and numbered, and the conflicts themselves listed underneath.
|
|
570
|
+
const GRAPH_ROW = 30;
|
|
571
|
+
const GRAPH_LANE = 16;
|
|
572
|
+
const GRAPH_STATE = {
|
|
573
|
+
conflict: { label: 'conflicts', icon: 'warn' },
|
|
574
|
+
ready: { label: 'ready', icon: 'check' },
|
|
575
|
+
merged: { label: 'merged', icon: 'branch' },
|
|
576
|
+
blocked: { label: 'blocked', icon: 'x' },
|
|
577
|
+
pending: { label: 'checking', icon: 'clock' },
|
|
578
|
+
};
|
|
579
|
+
const PR_STATE = { open: 'Open', draft: 'Draft', merged: 'Merged', closed: 'Closed' };
|
|
580
|
+
|
|
581
|
+
export function layoutGraph(b) {
|
|
582
|
+
const commits = (Array.isArray(b.commits) ? b.commits : []).filter((c) => c && typeof c === 'object');
|
|
583
|
+
const into = b.into ? String(b.into) : '';
|
|
584
|
+
// Lane 0 is the base branch (what we merge into); the others in order of first appearance.
|
|
585
|
+
const lanes = [];
|
|
586
|
+
const laneOf = (name) => {
|
|
587
|
+
let i = lanes.indexOf(name);
|
|
588
|
+
if (i < 0) i = lanes.push(name) - 1;
|
|
589
|
+
return i;
|
|
590
|
+
};
|
|
591
|
+
const base = into || String(commits.find((c) => c.branch)?.branch || '');
|
|
592
|
+
laneOf(base);
|
|
593
|
+
const off = into ? 1 : 0; // the merge target takes row 0
|
|
594
|
+
const rows = commits.map((c, i) => ({ c, row: i + off, lane: laneOf(c.branch ? String(c.branch) : base) }));
|
|
595
|
+
const head = b.head ? laneOf(String(b.head)) : lanes.length > 1 ? 1 : 0;
|
|
596
|
+
const same = (a, z) => !!a && !!z && (a.startsWith(z) || z.startsWith(a));
|
|
597
|
+
const find = (h) => rows.find((r) => same(String(r.c.hash || ''), String(h)));
|
|
598
|
+
const edges = [];
|
|
599
|
+
const tails = [];
|
|
600
|
+
rows.forEach((r, i) => {
|
|
601
|
+
const below = rows.slice(i + 1);
|
|
602
|
+
let parents = Array.isArray(r.c.parents) ? r.c.parents.map(find).filter(Boolean) : [];
|
|
603
|
+
if (!parents.length) {
|
|
604
|
+
// Implied parent: the next older commit on this branch, else where the branch forked off base.
|
|
605
|
+
const p = below.find((o) => o.lane === r.lane) || (r.lane !== 0 && below.find((o) => o.lane === 0));
|
|
606
|
+
if (p) parents = [p];
|
|
607
|
+
}
|
|
608
|
+
parents.forEach((p, k) => edges.push({ from: r, to: p, lane: k === 0 ? r.lane : p.lane, bend: k === 0 ? 'late' : 'early' }));
|
|
609
|
+
if (!parents.length) tails.push(r);
|
|
610
|
+
});
|
|
611
|
+
const conflictNo = new Map();
|
|
612
|
+
rows.forEach((r) => {
|
|
613
|
+
if (r.c.conflict) conflictNo.set(r, conflictNo.size + 1);
|
|
614
|
+
});
|
|
615
|
+
const tips = new Set(lanes.map((_, l) => rows.find((r) => r.lane === l)).filter(Boolean));
|
|
616
|
+
return { rows, lanes, head, edges, tails, conflictNo, tips, into, off };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
R.graph = (b, ctx) => {
|
|
620
|
+
const g = layoutGraph(b);
|
|
621
|
+
const conflicts = (Array.isArray(b.conflicts) ? b.conflicts : []).filter(Boolean).map((c) => (typeof c === 'string' ? { file: c } : c));
|
|
622
|
+
const nConflicts = conflicts.length || g.conflictNo.size;
|
|
623
|
+
const merge = GRAPH_STATE[b.merge] ? b.merge : nConflicts ? 'conflict' : g.into ? 'ready' : '';
|
|
624
|
+
const merged = merge === 'merged';
|
|
625
|
+
const laneColor = (l) => (l === 0 ? 'var(--g-base)' : merged && l === g.head ? 'var(--g-merged)' : `var(--g-${((l - 1) % 4) + 1})`);
|
|
626
|
+
const x = (l) => 9 + l * GRAPH_LANE;
|
|
627
|
+
const y = (row) => row * GRAPH_ROW + GRAPH_ROW / 2;
|
|
628
|
+
const nRows = g.rows.length + g.off;
|
|
629
|
+
const width = x(Math.max(0, g.lanes.length - 1)) + 10;
|
|
630
|
+
const height = nRows * GRAPH_ROW + 10;
|
|
631
|
+
const path = (x1, y1, x2, y2, bend) => {
|
|
632
|
+
if (x1 === x2) return `M${x1} ${y1}V${y2}`;
|
|
633
|
+
const r = GRAPH_ROW * 0.8;
|
|
634
|
+
// 'late' stays in the child's lane and curves into the parent near it (a fork); 'early'
|
|
635
|
+
// curves out right away (a merge pulling another branch in).
|
|
636
|
+
return bend === 'early' ? `M${x1} ${y1}C${x1} ${y1 + r} ${x2} ${y1 + r * 0.2} ${x2} ${y1 + r}V${y2}` : `M${x1} ${y1}V${y2 - r}C${x1} ${y2 - r * 0.2} ${x2} ${y2 - r} ${x2} ${y2}`;
|
|
637
|
+
};
|
|
638
|
+
const wires = [];
|
|
639
|
+
g.edges.forEach((e, k) => {
|
|
640
|
+
wires.push(`<path class="g-wire" style="--k:${k};stroke:${laneColor(e.lane)}" pathLength="1" d="${path(x(e.from.lane), y(e.from.row), x(e.to.lane), y(e.to.row), e.bend)}"/>`);
|
|
641
|
+
});
|
|
642
|
+
g.tails.forEach((r) => wires.push(`<path class="g-tail" style="stroke:${laneColor(r.lane)}" d="M${x(r.lane)} ${y(r.row)}V${height}"/>`));
|
|
643
|
+
if (g.into) {
|
|
644
|
+
const headTip = g.rows.find((r) => r.lane === g.head && g.head !== 0);
|
|
645
|
+
const baseTip = g.rows.find((r) => r.lane === 0);
|
|
646
|
+
if (baseTip) wires.push(`<path class="g-tail" style="stroke:var(--g-base)" d="M${x(0)} ${y(0)}V${y(baseTip.row)}"/>`);
|
|
647
|
+
if (headTip) wires.push(`<path class="g-wire ${merged ? '' : 'pending'} st-${merge}" style="--k:0" pathLength="1" d="${path(x(0), y(0), x(headTip.lane), y(headTip.row), 'early')}"/>`);
|
|
648
|
+
}
|
|
649
|
+
const dots = g.rows.map((r) => {
|
|
650
|
+
const cls = r.c.conflict ? 'conflict' : r.c.merge || (Array.isArray(r.c.parents) && r.c.parents.length > 1) ? 'merge' : '';
|
|
651
|
+
return `<circle class="g-dot ${cls}" style="--k:${r.row};--lc:${laneColor(r.lane)}" cx="${x(r.lane)}" cy="${y(r.row)}" r="${cls === 'merge' ? 4.5 : 4}"/>`;
|
|
652
|
+
});
|
|
653
|
+
const target = g.into ? `<g class="g-target st-${merge}" transform="translate(${x(0)} ${y(0)})"><circle r="7.5"/>${merge === 'conflict' ? '<path d="M0 -3.6V0.6M0 3V3.2"/>' : merge === 'ready' ? '<path d="M-3 0.2L-0.9 2.3L3.2 -2"/>' : merge === 'blocked' ? '<path d="M-2.6 -2.6L2.6 2.6M2.6 -2.6L-2.6 2.6"/>' : ''}</g>` : '';
|
|
654
|
+
|
|
655
|
+
const meta = (c) => [c.hash ? `<code>${esc(String(c.hash).slice(0, 7))}</code>` : '', [c.author, c.when].filter(Boolean).map((s) => esc(s)).join(' · ')].filter(Boolean).join(' ');
|
|
656
|
+
const rowHtml = (r) => {
|
|
657
|
+
const n = g.conflictNo.get(r);
|
|
658
|
+
const tag = g.tips.has(r) && g.lanes[r.lane] ? `<span class="g-tag" style="--lc:${laneColor(r.lane)}">${esc(g.lanes[r.lane])}</span>` : '';
|
|
659
|
+
const tone = r.c.conflict ? 'conflict' : r.lane === g.head ? 'head' : 'other';
|
|
660
|
+
return `<li class="g-row ${tone}" style="--k:${r.row}" ${n ? `data-n="${n}"` : ''} title="${esc(r.c.note || '')}"><span class="g-msg">${inlineMd(r.c.message || r.c.hash || '')}</span>${tag}${n ? `<span class="g-num">${n}</span>` : ''}<span class="g-meta">${meta(r.c)}</span></li>`;
|
|
661
|
+
};
|
|
662
|
+
const stateNote = b.note || (merge === 'conflict' ? `${nConflicts} conflict${nConflicts === 1 ? '' : 's'}` : GRAPH_STATE[merge]?.label || '');
|
|
663
|
+
const targetRow = g.into ? `<li class="g-row target st-${merge}" style="--k:0"><span class="g-msg">${merged ? 'Merged' : 'Merge'} into ${esc(g.into)}</span><span class="g-state">${esc(stateNote)}</span></li>` : '';
|
|
664
|
+
|
|
665
|
+
const checks = (Array.isArray(b.checks) ? b.checks : []).filter(Boolean).map((c) => (typeof c === 'string' ? { label: c, status: 'pass' } : c));
|
|
666
|
+
const checkIcon = { pass: 'check', fail: 'x', pending: 'clock', skip: 'skip' };
|
|
667
|
+
const checksHtml = checks.length
|
|
668
|
+
? `<div class="g-checks">${checks.map((c, i) => `<span class="g-check st-${esc(c.status || 'pass')}" style="--k:${i}" ${c.detail ? `title="${esc(c.detail)}"` : ''}>${icon(checkIcon[c.status] || 'check')}<b>${esc(c.label || '')}</b>${c.value ? `<span>${esc(c.value)}</span>` : ''}</span>`).join('')}</div>`
|
|
669
|
+
: '';
|
|
670
|
+
|
|
671
|
+
const numFor = (c) => {
|
|
672
|
+
if (c.n) return Number(c.n);
|
|
673
|
+
const r = c.commit && g.rows.find((o) => o.c.hash && (String(o.c.hash).startsWith(String(c.commit)) || String(c.commit).startsWith(String(o.c.hash))));
|
|
674
|
+
return r ? g.conflictNo.get(r) : undefined;
|
|
675
|
+
};
|
|
676
|
+
const side = (label, text, cls) => (text ? `<div class="g-side ${cls}"><span class="g-who">${esc(label)}</span><code>${esc(text)}</code></div>` : '');
|
|
677
|
+
const headName = g.lanes[g.head] || 'ours';
|
|
678
|
+
const conflictsHtml = conflicts.length
|
|
679
|
+
? `<ol class="g-conflicts" ${b.expanded === false ? 'hidden' : ''}>${conflicts
|
|
680
|
+
.map((c, i) => {
|
|
681
|
+
const n = numFor(c);
|
|
682
|
+
const loc = c.file ? `${c.file}${c.line || c.lines ? `:${c.line || c.lines}` : ''}` : '';
|
|
683
|
+
return `<li style="--k:${i}" ${n ? `data-n="${n}"` : ''}>
|
|
684
|
+
<div class="g-c-head"><span class="g-num ${n ? '' : 'plain'}">${n || i + 1}</span>${loc ? `<button class="link g-file" data-file="${esc(loc)}">${esc(loc)}</button>` : ''}${c.detail ? `<span class="g-c-detail">${inlineMd(c.detail)}</span>` : ''}</div>
|
|
685
|
+
${c.ours || c.theirs ? `<div class="g-sides">${side(c.oursLabel || headName, c.ours, 'ours')}${side(c.theirsLabel || g.into || g.lanes[0] || 'theirs', c.theirs, 'theirs')}</div>` : ''}
|
|
686
|
+
${c.resolution ? `<div class="g-res">${icon('check')}<span>${inlineMd(c.resolution)}</span></div>` : ''}
|
|
687
|
+
</li>`;
|
|
688
|
+
})
|
|
689
|
+
.join('')}</ol>`
|
|
690
|
+
: '';
|
|
691
|
+
|
|
692
|
+
const footText = b.summary
|
|
693
|
+
? inlineMd(b.summary)
|
|
694
|
+
: merge === 'conflict'
|
|
695
|
+
? `${conflicts.length ? `<button class="g-count">${nConflicts} conflict${nConflicts === 1 ? '' : 's'}</button>` : `<b>${nConflicts} conflict${nConflicts === 1 ? '' : 's'}</b>`} with ${esc(g.into || g.lanes[0] || 'base')}`
|
|
696
|
+
: merge === 'ready'
|
|
697
|
+
? 'Ready to merge'
|
|
698
|
+
: merged
|
|
699
|
+
? `Merged into ${esc(g.into || g.lanes[0])}`
|
|
700
|
+
: merge === 'blocked'
|
|
701
|
+
? 'Blocked'
|
|
702
|
+
: merge === 'pending'
|
|
703
|
+
? 'Checks running'
|
|
704
|
+
: '';
|
|
705
|
+
const action = b.action && (typeof b.action === 'string' ? { label: b.action } : b.action);
|
|
706
|
+
const live = ctx.isLatest !== false;
|
|
707
|
+
const foot = footText || action ? `<div class="g-foot st-${merge}">${merge ? icon(GRAPH_STATE[merge].icon) : ''}<span class="g-foot-text">${footText}</span>${action ? `<button class="g-action" ${live ? '' : 'disabled'}>${esc(action.label || 'Continue')}</button>` : ''}</div>` : '';
|
|
708
|
+
const pr = PR_STATE[b.state] ? b.state : merged ? 'merged' : '';
|
|
709
|
+
|
|
710
|
+
const node = el(`
|
|
711
|
+
<div class="b-graph card">
|
|
712
|
+
${b.title || b.ref || pr ? `<div class="g-head"><h3>${inlineMd(b.title || '')}${b.ref ? ` <span class="g-ref">${esc(b.ref)}</span>` : ''}</h3>${pr ? `<span class="g-pr st-${pr}"><i></i>${PR_STATE[pr]}</span>` : ''}</div>` : ''}
|
|
713
|
+
<div class="g-body" style="--g-w:${width}px">
|
|
714
|
+
<svg class="g-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true">${wires.join('')}${dots.join('')}${target}</svg>
|
|
715
|
+
<ol class="g-rows">${targetRow}${g.rows.map(rowHtml).join('')}</ol>
|
|
716
|
+
</div>
|
|
717
|
+
${checksHtml}${conflictsHtml}${foot}
|
|
718
|
+
</div>`);
|
|
719
|
+
|
|
720
|
+
const list = node.querySelector('.g-conflicts');
|
|
721
|
+
node.querySelector('.g-count')?.addEventListener('click', () => {
|
|
722
|
+
list.hidden = !list.hidden;
|
|
723
|
+
});
|
|
724
|
+
// Hovering a numbered conflict lights up the commit it came from, and the other way round.
|
|
725
|
+
const light = (n, on) => node.querySelectorAll(`[data-n="${n}"]`).forEach((e) => e.classList.toggle('lit', on));
|
|
726
|
+
node.querySelectorAll('[data-n]').forEach((e) => {
|
|
727
|
+
e.addEventListener('mouseenter', () => light(e.dataset.n, true));
|
|
728
|
+
e.addEventListener('mouseleave', () => light(e.dataset.n, false));
|
|
729
|
+
});
|
|
730
|
+
node.querySelectorAll('.g-file').forEach((btn) =>
|
|
731
|
+
btn.addEventListener('click', () => {
|
|
732
|
+
const [p, line] = btn.dataset.file.split(':');
|
|
733
|
+
ctx.openFile?.(p, line);
|
|
734
|
+
}),
|
|
735
|
+
);
|
|
736
|
+
node.querySelector('.g-action')?.addEventListener('click', () => {
|
|
737
|
+
if (!live) return;
|
|
738
|
+
const text = action.reply || action.label;
|
|
739
|
+
if (action.draft) ctx.draft?.(text);
|
|
740
|
+
else ctx.reply?.(text);
|
|
741
|
+
});
|
|
742
|
+
return node;
|
|
743
|
+
};
|
|
744
|
+
|
|
446
745
|
R.checklist = (b) =>
|
|
447
746
|
el(`
|
|
448
747
|
<div class="b-check card">
|
|
@@ -489,6 +788,81 @@ R.question = (b, ctx) => {
|
|
|
489
788
|
return node;
|
|
490
789
|
};
|
|
491
790
|
|
|
791
|
+
R.actions = (b, ctx) => {
|
|
792
|
+
const items = (b.items || []).map((it, i) => (typeof it === 'string' ? { label: it, command: it, _i: i } : { ...it, _i: i }));
|
|
793
|
+
const node = el(`
|
|
794
|
+
<div class="b-actions card">
|
|
795
|
+
${b.title ? `<h3 class="b-title">${icon('terminal')}${esc(b.title)}</h3>` : ''}
|
|
796
|
+
<ul class="act-list">${items
|
|
797
|
+
.map((it, i) => {
|
|
798
|
+
const isCmd = !!it.command;
|
|
799
|
+
const key = `act:${i}:${it.command || it.reply || it.label}`;
|
|
800
|
+
const prev = isCmd && ctx.turn?.actionRuns?.[key];
|
|
801
|
+
return `<li class="act-row ${it.tone ? `tone-${esc(it.tone)}` : ''}" style="--k:${i}" data-i="${i}" data-key="${esc(key)}">
|
|
802
|
+
<div class="act-bar">
|
|
803
|
+
<div class="act-info">
|
|
804
|
+
<b>${esc(it.label || it.command || it.reply || 'Action')}</b>
|
|
805
|
+
${isCmd ? `<code class="act-cmd">$ ${esc(it.command)}</code>` : ''}
|
|
806
|
+
${it.detail || it.note ? `<span class="act-detail">${inlineMd(it.detail || it.note)}</span>` : ''}
|
|
807
|
+
</div>
|
|
808
|
+
<div class="act-side">
|
|
809
|
+
<span class="act-badge">${prev ? `<span class="exit ${prev.exitCode === 0 ? 'ok' : 'bad'}">exit ${esc(prev.exitCode)}</span>` : ''}</span>
|
|
810
|
+
<button class="ghost xs act-go">${icon(isCmd ? (prev ? 'rerun' : 'play') : 'send')}<span>${isCmd ? (prev ? 'Run again' : 'Run') : 'Send'}</span></button>
|
|
811
|
+
</div>
|
|
812
|
+
</div>
|
|
813
|
+
<div class="act-slot" ${prev ? '' : 'hidden'}></div>
|
|
814
|
+
</li>`;
|
|
815
|
+
})
|
|
816
|
+
.join('')}</ul>
|
|
817
|
+
</div>`);
|
|
818
|
+
|
|
819
|
+
const paintResult = (li, it, res) => {
|
|
820
|
+
if (!res) return;
|
|
821
|
+
const badge = li.querySelector('.act-badge');
|
|
822
|
+
const ok = res.exitCode === 0;
|
|
823
|
+
badge.innerHTML = `<span class="exit ${ok ? 'ok' : 'bad'}">exit ${esc(res.exitCode)}</span>${res.durationMs ? `<span class="muted xs">${fmtDuration(res.durationMs)}</span>` : ''}`;
|
|
824
|
+
const slot = li.querySelector('.act-slot');
|
|
825
|
+
slot.hidden = false;
|
|
826
|
+
slot.innerHTML = `
|
|
827
|
+
<div class="act-res ${ok ? 'ok' : 'bad'}">
|
|
828
|
+
<pre class="act-out">${esc(res.output || '(no output)')}</pre>
|
|
829
|
+
${!ok && ctx.reply ? `<button class="link xs act-fix">${icon('send')}Send failure to agent</button>` : ''}
|
|
830
|
+
</div>`;
|
|
831
|
+
slot.querySelector('.act-fix')?.addEventListener('click', () => {
|
|
832
|
+
ctx.reply(`Command \`${it.command}\` failed (exit ${res.exitCode}):\n\`\`\`\n${(res.output || '').slice(0, 1600)}\n\`\`\``);
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
items.forEach((it, i) => {
|
|
837
|
+
const key = `act:${i}:${it.command || it.reply || it.label}`;
|
|
838
|
+
const prev = it.command && ctx.turn?.actionRuns?.[key];
|
|
839
|
+
if (prev) paintResult(node.querySelector(`li[data-i="${i}"]`), it, prev);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
node.addEventListener('click', async (e) => {
|
|
843
|
+
const btn = e.target.closest('.act-go');
|
|
844
|
+
if (!btn) return;
|
|
845
|
+
const li = btn.closest('li.act-row');
|
|
846
|
+
const it = items[Number(li.dataset.i)];
|
|
847
|
+
if (!it) return;
|
|
848
|
+
if (!it.command) {
|
|
849
|
+
return ctx.reply?.(it.reply || it.label);
|
|
850
|
+
}
|
|
851
|
+
if (it.confirm && !confirm(`Run this command in ${ctx.session?.cwd || 'the project'}?\n\n$ ${it.command}`)) return;
|
|
852
|
+
if (!ctx.exec) return;
|
|
853
|
+
btn.disabled = true;
|
|
854
|
+
btn.querySelector('span').textContent = 'Running…';
|
|
855
|
+
try {
|
|
856
|
+
const res = await ctx.exec(it.command, li.dataset.key);
|
|
857
|
+
if (res) paintResult(li, it, res);
|
|
858
|
+
} finally {
|
|
859
|
+
btn.disabled = false;
|
|
860
|
+
btn.querySelector('span').textContent = 'Run again';
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
return node;
|
|
864
|
+
};
|
|
865
|
+
|
|
492
866
|
R.next = (b, ctx) => {
|
|
493
867
|
const node = el(`
|
|
494
868
|
<div class="b-next">
|