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/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
- return `<li style="--k:${i}" data-path="${esc(f.path)}" class="${c ? 'has-diff' : ''}">
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
- const c = findChange(ctx.turn, file);
205
- if (c) {
206
- edits = c.edits;
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><button class="ghost xs open" title="Open in editor">${icon('external')}</button></div>`}
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 vertical = getComputedStyle(stage.querySelector('.cols')).flexDirection === 'column';
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 (vertical) {
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
- if (e.label) out += `<text x="${lx}" y="${ly}" class="wire-label" text-anchor="${vertical ? 'start' : 'middle'}">${esc(e.label)}</text>`;
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 = !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' : '';
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 ok = b.exitCode === undefined || Number(b.exitCode) === 0;
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>${b.exitCode !== undefined ? `<span class="exit ${ok ? 'ok' : 'bad'}">exit ${esc(b.exitCode)}</span>` : ''}</div>
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
- const cmd = String(b.command || '');
382
- out.textContent = String(b.output || '').replace(/\s+$/, '');
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
- ${b.command ? `<code class="cmdline">$ ${esc(b.command)}</code>` : ''}
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">