wisp-agents 0.20.0 → 0.22.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +17 -8
  3. package/bin/wisp.js +14 -6
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +12 -7
  6. package/server/adapters/codex.js +13 -7
  7. package/server/adapters/cursor.js +4 -1
  8. package/server/adapters/grok.js +2 -1
  9. package/server/adapters/index.js +7 -2
  10. package/server/adapters/opencode.js +5 -2
  11. package/server/btw.js +76 -0
  12. package/server/client.js +22 -2
  13. package/server/git.js +80 -4
  14. package/server/handoff.js +13 -7
  15. package/server/index.js +4 -0
  16. package/server/machines.js +15 -4
  17. package/server/mcp-wisp.js +2 -1
  18. package/server/openapi.js +28 -2
  19. package/server/remote.js +4 -2
  20. package/server/routes/machines.js +1 -1
  21. package/server/routes/proxy.js +6 -0
  22. package/server/routes/sessions.js +47 -10
  23. package/server/routes/settings.js +24 -38
  24. package/server/routes/ship.js +19 -24
  25. package/server/routes/uploads.js +2 -0
  26. package/server/runner.js +178 -12
  27. package/server/services/auto.js +10 -2
  28. package/server/services/launch.js +67 -7
  29. package/server/services/move.js +130 -0
  30. package/server/services/phone.js +20 -1
  31. package/server/services/places.js +150 -0
  32. package/server/services/ship.js +97 -10
  33. package/server/services/uploads.js +23 -4
  34. package/server/store.js +39 -6
  35. package/skills/wisp/SKILL.md +3 -1
  36. package/web/css/blocks.css +2 -0
  37. package/web/css/composer.css +23 -1
  38. package/web/css/feed.css +52 -1
  39. package/web/css/launcher.css +12 -0
  40. package/web/css/machines.css +4 -0
  41. package/web/css/modals.css +19 -0
  42. package/web/css/palette.css +6 -0
  43. package/web/css/ship.css +14 -0
  44. package/web/css/transcript.css +115 -0
  45. package/web/index.html +2 -0
  46. package/web/js/core/state.js +5 -2
  47. package/web/js/features/actions.js +42 -10
  48. package/web/js/features/btw.js +145 -0
  49. package/web/js/features/clones.js +7 -6
  50. package/web/js/features/commands.js +3 -1
  51. package/web/js/features/composer.js +162 -18
  52. package/web/js/features/feed.js +10 -10
  53. package/web/js/features/keys.js +2 -1
  54. package/web/js/features/launcher.js +121 -67
  55. package/web/js/features/place-menu.js +44 -0
  56. package/web/js/features/places.js +25 -5
  57. package/web/js/features/project-list.js +20 -5
  58. package/web/js/features/projects.js +16 -13
  59. package/web/js/features/settings.js +24 -3
  60. package/web/js/features/ship.js +66 -29
  61. package/web/js/features/slash.js +63 -0
  62. package/web/js/features/transcript.js +290 -0
  63. package/web/js/features/turns.js +101 -29
  64. package/web/js/slash-text.js +22 -0
  65. package/web/js/ui/kbd.js +1 -0
@@ -20,6 +20,8 @@ import { openPhone, phoneSummary } from './phone.js';
20
20
  import { machinesSummary } from './places.js';
21
21
  import { AUTO_GOALS, harnessLogo } from './runners.js';
22
22
  import { INSTALLABLE, installHarness, isInstalling } from './setup.js';
23
+ import { clearPicks } from './transcript.js';
24
+ import { repaintViews } from './turns.js';
23
25
  import { openUsage } from './usage.js';
24
26
 
25
27
  // A sidebar of sections and one pane at a time, like every other app's settings. Big lists
@@ -80,6 +82,9 @@ export function openSettings(opts) {
80
82
  ${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
81
83
  ${row('Auto-fix PR conflicts', 'Without waiting for a click on Fix conflicts: when a shipped PR conflicts with its base, the task merges the base in, resolves it and pushes', '<button class="switch" id="fixConflicts" role="switch"></button>')}
82
84
  </div>
85
+ <div class="sx-group">
86
+ ${row('Full transcript', 'Every message and step the agent took, with its presentation at the end. Any one card switches with its Transcript button or <kbd>V</kbd>', '<button class="switch" id="txDefault" role="switch"></button>')}
87
+ </div>
83
88
  </section>
84
89
  <section class="sx-pane" data-pane="notifications">
85
90
  <div class="sx-group">
@@ -116,7 +121,7 @@ export function openSettings(opts) {
116
121
  <div class="sx-group">
117
122
  ${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
118
123
  </div>
119
- <p class="hint">Also in the top bar, and in Projects: add folders on a machine, then launch there like anywhere else.</p>
124
+ <p class="hint">Also in the top bar. Every project runs on every machine: pick one under Run on and wisp clones the project there the first time. A follow-up or a rerun can move a task to another one.</p>
120
125
  </section>
121
126
  <section class="sx-pane" data-pane="memory">
122
127
  <div class="sx-group">
@@ -308,6 +313,9 @@ export function openSettings(opts) {
308
313
  });
309
314
  if (!saved) return;
310
315
  paintWt();
316
+ renderLauncher();
317
+ };
318
+ paintWt();
311
319
  const fc = $('#fixConflicts', node);
312
320
  const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts === 'on');
313
321
  fc.onclick = async () => {
@@ -321,9 +329,22 @@ export function openSettings(opts) {
321
329
  }
322
330
  };
323
331
  paintFc();
324
- renderLauncher();
332
+ // The transcript switch flips every open card at once; a refused save flips them back.
333
+ const tx = $('#txDefault', node);
334
+ const paintTx = () => tx.setAttribute('aria-checked', S.config.defaults.transcript === 'on');
335
+ const setTx = (v) => {
336
+ S.config.defaults.transcript = v;
337
+ clearPicks();
338
+ paintTx();
339
+ repaintViews();
325
340
  };
326
- paintWt();
341
+ tx.onclick = async () => {
342
+ const was = S.config.defaults.transcript || 'off';
343
+ setTx(was === 'on' ? 'off' : 'on');
344
+ const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { transcript: S.config.defaults.transcript } } }));
345
+ if (!saved) setTx(was);
346
+ };
347
+ paintTx();
327
348
  // Auto: goal, exploring and allowed harnesses here; the full record in its own view.
328
349
  const paintAuto = async () => {
329
350
  const r = await api('/router').catch(() => null);
@@ -31,16 +31,25 @@ const STEP_LABEL = { commit: 'Commit', push: 'Push', pr: 'Open PR' };
31
31
  const STEP_DOING = { commit: 'Committing', push: 'Pushing', pr: 'Opening the PR' };
32
32
  const shipping = (s) => !!(s?.shipping || (ships.has(s?.id) && !ships.get(s.id).error));
33
33
 
34
+ // A task in several repos (also) has a PR per repo: its own first, then the others', each { repo, pr }.
35
+ export const prsOf = (s) => [...(s?.pr ? [{ repo: s.project, pr: s.pr }] : []), ...(s?.also || []).filter((a) => a.pr).map((a) => ({ repo: a.project, pr: a.pr }))];
36
+ export const prOf = (s) => prsOf(s)[0]?.pr || null; // the one the card's pill shows
37
+ const branches = (s) => [s?.branch, ...(s?.also || []).map((a) => a.branch)].filter(Boolean);
38
+ const severalRepos = (s) => branches(s).length > 1;
39
+
34
40
  // Ship it only makes sense on a finished worktree task with something to ship: uncommitted
35
41
  // changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
42
+ // Several repos: the server counts the ones with something new (toShip.repos).
36
43
  export function shippable(s) {
37
- if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED' || shipping(s)) return false;
44
+ if (!branches(s).length || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || shipping(s)) return false;
38
45
  const t = s.toShip;
46
+ if (t?.repos !== undefined) return t.repos > 0;
47
+ if (s.pr?.state === 'MERGED') return false;
39
48
  return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
40
49
  }
41
- export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
50
+ export const shipLabel = (s) => (severalRepos(s) ? (prsOf(s).some((x) => x.pr.state === 'OPEN') ? 'Push to the PRs…' : 'Ship it…') : s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
42
51
 
43
- export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}…"><i></i>Shipping</span>` : '');
52
+ export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}${s.shipping.repo ? ` ${esc(s.shipping.repo)}` : ''}…"><i></i>Shipping</span>` : '');
44
53
 
45
54
  export const prPill = (pr, fix) => `<span class="pill pr pr-${prTone(pr, fix)}" title="PR #${pr.number}: ${esc(prLine(pr, fix))}"><i></i>#${pr.number}</span>`;
46
55
 
@@ -50,29 +59,41 @@ export function renderPrStrip(id) {
50
59
  const node = n?.body && $('.pr-strip', n.body);
51
60
  const s = S.sessions.get(id);
52
61
  if (!node || !s) return;
53
- const pr = s.pr;
62
+ const prs = prsOf(s);
63
+ const pr = prs[0]?.pr;
64
+ const several = prs.length > 1;
54
65
  $$('[data-a="ship"]', n.body).forEach((b) => {
55
66
  b.hidden = !shippable(s);
56
- $('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
67
+ $('span', b).textContent = severalRepos(s) ? (prs.some((x) => x.pr.state === 'OPEN') ? 'Push to PRs' : prs.length ? 'Ship again' : 'Ship it') : pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
57
68
  });
58
69
  // Started here, or elsewhere (another tab, the API): the server's stage moves the track along.
59
70
  const run = ships.get(id) || (s.shipping && { steps: s.shipping.update ? ['push', 'pr'] : ['commit', 'push', 'pr'], step: 'push' });
60
- if (run && STEP_LABEL[s.shipping?.step] && !run.error) run.step = s.shipping.step;
71
+ if (run && STEP_LABEL[s.shipping?.step] && !run.error) Object.assign(run, { step: s.shipping.step, repo: s.shipping.repo, at: s.shipping.at, of: s.shipping.of });
61
72
  if (run) return renderShipRun(node, id, run);
62
73
  const landed = node.classList.contains('ship-run') && !node.classList.contains('failed');
63
74
  node.hidden = !pr;
64
75
  if (!pr) return;
65
- const tone = prTone(pr, s.conflictFix);
76
+ // Several repos: the first PR leads the row and the others follow as small links, each in its
77
+ // own tone; the row takes the worst of them, and Merge merges them all.
78
+ const fix = s.pr ? s.conflictFix : null; // wisp fixes conflicts on the task's own PR only
79
+ const TONES = ['fail', 'pending', 'pass', 'merged', 'closed'];
80
+ const tone = prs.map((x, i) => prTone(x.pr, i ? null : fix)).sort((a, b) => TONES.indexOf(a) - TONES.indexOf(b))[0];
81
+ const open = prs.filter((x) => x.pr.state === 'OPEN');
82
+ const conflict = open.some((x) => x.pr.conflict);
66
83
  node.className = `pr-strip pr-${tone}${landed ? ' landed' : ''}`;
67
84
  node.innerHTML = `
68
85
  <span class="pr-dot"></span>
69
- <a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
70
- <span class="pr-line">${esc(prLine(pr, s.conflictFix))}</span>
86
+ <a href="${esc(pr.url)}" target="_blank" class="pr-num">${several ? `${esc(prs[0].repo)} #${pr.number}` : `PR #${pr.number}`}</a>
87
+ ${prs
88
+ .slice(1)
89
+ .map((x) => `<a href="${esc(x.pr.url)}" target="_blank" class="pr-also pr-${prTone(x.pr)}" title="${esc(`${x.repo}: PR #${x.pr.number} · ${prLine(x.pr)}`)}"><i></i>${esc(x.repo)} #${x.pr.number}</a>`)
90
+ .join('')}
91
+ <span class="pr-line">${esc(prLine(pr, fix))}</span>
71
92
  <span class="grow"></span>
72
- ${pr.state === 'OPEN' && pr.conflict && !fixing(s.conflictFix) && s.state !== 'running' ? `<button class="ghost xs pr-fix" data-pr="fix" title="Send the task a follow-up to merge the base in and resolve the conflicts; wisp pushes the fix to the PR">${icon('merge')}<span>Fix conflicts</span></button>` : ''}
73
- ${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
74
- <a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
75
- ${pr.state === 'OPEN' && !pr.conflict ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
93
+ ${pr.state === 'OPEN' && pr.conflict && s.pr && !fixing(fix) && s.state !== 'running' ? `<button class="ghost xs pr-fix" data-pr="fix" title="Send the task a follow-up to merge the base in and resolve the conflicts; wisp pushes the fix to the PR">${icon('merge')}<span>Fix conflicts</span></button>` : ''}
94
+ ${open.length ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
95
+ ${several ? '' : `<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>`}
96
+ ${open.length && !conflict ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge${open.length > 1 ? ` all ${open.length} PRs` : ''}, then fast-forward your local base branch${open.length > 1 ? 'es' : ''}">${icon('check')}<span>Merge${open.length > 1 ? ' all' : ''}</span></button>` : ''}`;
76
97
  node.onclick = (e) => {
77
98
  const a = e.target.closest('[data-pr]')?.dataset.pr;
78
99
  if (a === 'refresh') refreshPr(id, e.target.closest('[data-pr]'));
@@ -135,7 +156,8 @@ function renderShipRun(node, id, run) {
135
156
  node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
136
157
  node.title = failed ? run.error : '';
137
158
  $$('.ship-track li', node).forEach((li, i) => (li.className = i < at ? 'done' : i === at ? (failed ? 'bad' : 'now') : ''));
138
- const what = failed ? `Ship failed at ${STEP_LABEL[run.steps[at]] || 'start'}: <span class="ship-err">${esc(run.error)}</span>` : `${STEP_DOING[run.steps[at]]}<span class="dots"></span>`;
159
+ const repo = !failed && run.repo ? ` ${esc(run.repo)}${run.of > 1 ? ` <span class="muted">(${run.at} of ${run.of})</span>` : ''}` : '';
160
+ const what = failed ? `Ship failed at ${STEP_LABEL[run.steps[at]] || 'start'}: <span class="ship-err">${esc(run.error)}</span>` : `${STEP_DOING[run.steps[at]]}${repo}<span class="dots"></span>`;
139
161
  if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
140
162
  const acts = failed ? `<button class="ghost xs" data-ship="retry">${icon('replay')}<span>Retry</span></button><button class="ghost xs" data-ship="dismiss" title="Dismiss">${icon('x')}</button>` : '';
141
163
  if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
@@ -151,19 +173,23 @@ export async function shipDialog(id) {
151
173
  } catch (err) {
152
174
  return toastError(err);
153
175
  }
154
- const open = info.pr?.state === 'OPEN';
176
+ // Several repos: a route line each, and the PRs open or update together.
177
+ const repos = info.repos || [info];
178
+ const open = repos.every((r) => r.pr?.state === 'OPEN');
155
179
  const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
156
180
  const nothing = !info.dirty && !info.ahead;
181
+ const noRemote = repos.every((r) => !r.remote);
182
+ const route = (r) => `<p class="ship-route">${icon('branch')}${info.repos ? `<b class="ship-repo">${esc(r.repo)}</b>` : ''}<code>${esc(r.branch)}</code>${icon('arrow')}<code>${esc(r.remote || 'no remote')}/${esc(r.base)}</code>
183
+ <span class="muted">${r.dirty ? `${r.dirty} uncommitted change${r.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${r.ahead} commit${r.ahead === 1 ? '' : 's'} on the branch${info.repos && r.pr ? ` · PR #${r.pr.number}` : ''}</span></p>`;
157
184
  const node = el(`
158
185
  <div class="modal ship-modal">
159
- <header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
160
- <p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
161
- <span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
186
+ <header><h2>${open ? (info.repos ? `Push to ${repos.length} PRs` : `Push to PR #${info.pr.number}`) : info.repos ? `Ship it to ${repos.length} repos` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
187
+ ${repos.map(route).join('')}
162
188
  ${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
163
189
  ${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
164
190
  <label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
165
- ${!info.remote ? '<p class="hint bad">This repo has no <code>origin</code> remote, so there is nowhere to push.</p>' : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
166
- <div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing || !info.remote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : 'Commit, push & open PR'}</button></div>
191
+ ${noRemote ? `<p class="hint bad">${info.repos ? 'These repos have' : 'This repo has'} no <code>origin</code> remote, so there is nowhere to push.</p>` : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
192
+ <div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in ${info.repos ? 'each worktree with changes, one PR per repo' : 'the worktree'}.</span><button class="primary sm" data-a="go" ${nothing || noRemote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : info.repos ? 'Commit, push & open PRs' : 'Commit, push & open PR'}</button></div>
167
193
  </div>`);
168
194
  const close = overlay(node);
169
195
  $('.close', node).onclick = close;
@@ -188,10 +214,12 @@ export async function shipDialog(id) {
188
214
  (pr) => {
189
215
  const s = S.sessions.get(id);
190
216
  // Everything just went up: no Push to #n until the server's recheck finds something new.
191
- if (s) S.sessions.set(id, { ...s, pr: { ...s.pr, ...pr }, shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0 } });
217
+ // Several repos: their PRs come with the server's next summary.
218
+ if (s) S.sessions.set(id, { ...s, ...(!pr.prs && { pr: { ...s.pr, ...pr } }), shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0, ...(s.toShip.repos !== undefined && { repos: 0 }) } });
192
219
  ships.delete(id);
193
220
  renderPrStrip(id);
194
- toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
221
+ const what = pr.prs ? pr.prs.map((x) => `<b>${esc(x.repo)} #${x.number}</b>`).join(', ') : `<b>PR #${pr.number}</b>`;
222
+ toast(`${icon('branch')} ${open ? 'Pushed to' : pr.prs ? 'PRs:' : 'Opened'} ${what}`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
195
223
  },
196
224
  (err) => {
197
225
  if (ships.has(id)) ships.get(id).error = err.message;
@@ -210,15 +238,24 @@ export async function shipDialog(id) {
210
238
 
211
239
  export async function mergePr(id) {
212
240
  const s = S.sessions.get(id);
213
- const pr = s?.pr;
214
- if (!pr || pr.state !== 'OPEN') return;
215
- const c = pr.checks || {};
216
- const warn = c.fail ? `\n\n⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing.` : c.pending ? `\n\n⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running.` : '';
217
- if (!confirm(`Squash-merge PR #${pr.number}${pr.draft ? ' (marks it ready first)' : ''}, then fast-forward your local base branch and remove the worktree?${warn}`)) return;
218
- toast(`Merging PR #${pr.number}…`);
241
+ const open = prsOf(s).filter((x) => x.pr.state === 'OPEN');
242
+ if (!open.length) return;
243
+ const several = open.length > 1;
244
+ const warn = open
245
+ .map(({ repo, pr }) => {
246
+ const c = pr.checks || {};
247
+ const where = several ? ` in ${repo}` : '';
248
+ return c.fail ? `⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing${where}.` : c.pending ? `⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running${where}.` : '';
249
+ })
250
+ .filter(Boolean)
251
+ .join('\n');
252
+ const what = several ? `these ${open.length} PRs (${open.map((x) => `${x.repo} #${x.pr.number}`).join(', ')})` : `PR #${open[0].pr.number}${open[0].pr.draft ? ' (marks it ready first)' : ''}`;
253
+ if (!confirm(`Squash-merge ${what}, then fast-forward your local base branch${several ? 'es' : ''} and remove the worktree${several ? 's' : ''}?${warn ? `\n\n${warn}` : ''}`)) return;
254
+ toast(several ? `Merging ${open.length} PRs…` : `Merging PR #${open[0].pr.number}…`);
219
255
  try {
220
256
  const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
221
- toast(`${icon('check')} Merged <b>PR #${r.pr.number}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
257
+ const merged = r.prs ? r.prs.filter((x) => x.state === 'MERGED').map((x) => `${esc(x.repo)} #${x.number}`).join(', ') : `PR #${r.pr.number}`;
258
+ toast(`${icon('check')} Merged <b>${merged}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
222
259
  } catch (err) {
223
260
  toastError(err, { timeout: 12_000 });
224
261
  }
@@ -0,0 +1,63 @@
1
+ // The / menu in a follow-up box: type "/" to see the commands, pick one with ↑↓ and Tab (or Enter,
2
+ // or a click) and the box reads "/btw " ready for the rest. Esc closes it.
3
+
4
+ import { esc } from '../md.js';
5
+ import { el } from '../core/dom.js';
6
+ import { SLASH, slashMatches } from '../slash-text.js';
7
+ import { feedbackOn } from './feedback.js';
8
+
9
+ // Register before the box's own Enter handler: picking a command must not send it.
10
+ export function slashMenu(ta, host) {
11
+ const menu = el('<div class="slash-menu" role="listbox" aria-label="Commands" hidden></div>');
12
+ host.append(menu);
13
+ let items = [];
14
+ let sel = 0;
15
+ let closed = false; // Esc: hidden until the text changes
16
+ const commands = () => SLASH.filter((c) => c.name !== 'feedback' || feedbackOn());
17
+ const paint = () => {
18
+ const next = !closed && document.activeElement === ta ? slashMatches(ta.value, commands()) : [];
19
+ if (next.map((c) => c.name).join() !== items.map((c) => c.name).join()) sel = 0;
20
+ items = next;
21
+ menu.hidden = !items.length;
22
+ menu.innerHTML = items
23
+ .map((c, i) => `<button type="button" class="slash-item${i === sel ? ' on' : ''}" role="option" aria-selected="${i === sel}" data-i="${i}"><b>/${c.name}</b><span class="slash-args">${esc(c.args)}</span><span class="slash-hint">${esc(c.hint)}</span></button>`)
24
+ .join('');
25
+ };
26
+ const pick = (c) => {
27
+ ta.value = `/${c.name} `;
28
+ ta.dispatchEvent(new Event('input'));
29
+ ta.focus();
30
+ ta.setSelectionRange(ta.value.length, ta.value.length);
31
+ };
32
+ ta.addEventListener('input', () => {
33
+ closed = false;
34
+ paint();
35
+ });
36
+ ta.addEventListener('focus', paint);
37
+ ta.addEventListener('blur', paint);
38
+ menu.addEventListener('mousedown', (e) => e.preventDefault()); // keep the focus in the box
39
+ menu.addEventListener('click', (e) => {
40
+ const b = e.target.closest('[data-i]');
41
+ if (b) pick(items[b.dataset.i]);
42
+ });
43
+ ta.addEventListener('keydown', (e) => {
44
+ if (menu.hidden || e.isComposing) return;
45
+ const c = items[sel];
46
+ // Enter on a command typed out in full that works on its own (/feedback opens the dialog) sends it.
47
+ const complete = (e.key === 'Tab' && !e.shiftKey) || (e.key === 'Enter' && !e.shiftKey && !(c.bare && ta.value.slice(1).toLowerCase() === c.name));
48
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
49
+ e.preventDefault();
50
+ sel = (sel + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
51
+ paint();
52
+ } else if (complete) {
53
+ e.preventDefault();
54
+ e.stopImmediatePropagation();
55
+ pick(c);
56
+ } else if (e.key === 'Escape') {
57
+ e.preventDefault();
58
+ e.stopPropagation();
59
+ closed = true;
60
+ paint();
61
+ }
62
+ });
63
+ }
@@ -0,0 +1,290 @@
1
+ // The full transcript: everything the agent said and did in a turn, oldest first, on a timeline that
2
+ // ends with its presentation, rendered as blocks. Off by default (Settings → General → Full
3
+ // transcript); any card switches with its Transcript button or V.
4
+
5
+ import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
6
+ import { esc, md } from '../md.js';
7
+ import { $, el, icon } from '../core/dom.js';
8
+ import { load, S, save } from '../core/state.js';
9
+
10
+ export const KIND_ICON = { check: 'check', read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask', background: 'clock' };
11
+
12
+ // --- which view a card shows ---------------------------------------------------
13
+
14
+ // Cards switched away from the setting: session id → true (transcript) or false (presentation).
15
+ const picked = new Map(Object.entries(load('wisp.transcript', {})));
16
+ const savePicks = () => save('wisp.transcript', Object.fromEntries(picked));
17
+
18
+ export const transcriptByDefault = () => S.config.defaults?.transcript === 'on';
19
+ export const transcriptOn = (id) => picked.get(id) ?? transcriptByDefault();
20
+
21
+ export function pickView(id, on) {
22
+ if (on === transcriptByDefault()) picked.delete(id);
23
+ else picked.set(id, on);
24
+ if (S.sessions.size) for (const k of picked.keys()) if (!S.sessions.has(k)) picked.delete(k);
25
+ savePicks();
26
+ }
27
+
28
+ // A new setting applies to every card, including the ones switched by hand before.
29
+ export function clearPicks() {
30
+ picked.clear();
31
+ savePicks();
32
+ }
33
+
34
+ // --- what the transcript shows -----------------------------------------------
35
+
36
+ // The agent's final answer is its presentation, shown as blocks at the end rather than as a message.
37
+ // It's the turn's last message (or last few: some harnesses stream it in parts); wisp's own notes
38
+ // after it (Auto's test check) don't break the run. Returns their indexes in turn.activity.
39
+ export function finalAnswer(t) {
40
+ const out = new Set();
41
+ const raw = typeof t.raw === 'string' ? t.raw : '';
42
+ const acts = t.activity || [];
43
+ if (!raw.trim()) return out;
44
+ for (let i = acts.length - 1; i >= 0; i--) {
45
+ const a = acts[i];
46
+ if (a.by === 'wisp') continue;
47
+ const head = a.kind === 'text' ? String(a.label || '').trim().slice(0, 200) : '';
48
+ if (!head || !raw.includes(head)) break;
49
+ out.add(i);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ // A message as it reads mid-run: the presentation it ends with is on its way, not prose to show.
55
+ export const messageText = (text) => {
56
+ const s = String(text || '');
57
+ const open = s.search(/```wisp/);
58
+ return (open < 0 ? s : s.slice(0, open)).trim();
59
+ };
60
+
61
+ // Steps between two messages, in runs. A run longer than FOLD_AT shows KEEP at each end and folds the
62
+ // middle behind one row.
63
+ const FOLD_AT = 12;
64
+ const KEEP = 4;
65
+ export function layout(items) {
66
+ const out = [];
67
+ let run = [];
68
+ const flush = () => {
69
+ if (run.length > FOLD_AT) out.push(...run.slice(0, KEEP), { fold: run.slice(KEEP, -KEEP) }, ...run.slice(-KEEP));
70
+ else out.push(...run);
71
+ run = [];
72
+ };
73
+ for (const it of items) {
74
+ if (it.kind === 'text') {
75
+ flush();
76
+ out.push(it);
77
+ } else run.push(it);
78
+ }
79
+ flush();
80
+ return out;
81
+ }
82
+
83
+ const OPENS = new Set(['run', 'search', 'web', 'tool', 'agent']);
84
+ const findChange = (t, file) => t?.changes?.find((c) => c.file === file);
85
+ // Edits a step made: its own slice of the file's edits when the server linked them, else the file's.
86
+ const editsOf = (a, t) => {
87
+ const c = findChange(t, a.file);
88
+ if (!c) return null;
89
+ return Array.isArray(a.edits) ? c.edits.slice(a.edits[0], a.edits[1]) : c.edits;
90
+ };
91
+
92
+ // Whether a step opens up to more: a command and what it printed, an edit's diff, an error in full.
93
+ // Mid-run an edit's diff isn't loaded yet, so it opens anyway and fetches it.
94
+ function opens(a, t, live) {
95
+ if (a.kind === 'error') return true;
96
+ if (a.kind === 'edit') return !!a.file && (live || !!findChange(t, a.file));
97
+ return OPENS.has(a.kind) && !!(a.output || a.detail);
98
+ }
99
+
100
+ // How far into the turn a step came, shown on hover.
101
+ const since = (a, t) => (a.t && t.startedAt ? `<time class="tx-t" title="Since the turn started">${fmtDuration(Math.max(0, a.t - t.startedAt))}</time>` : '');
102
+
103
+ function stepHtml(a, k, t, live) {
104
+ const more = opens(a, t, live);
105
+ // The command itself, unless the label already is it ("Running ls; …" when there was no description).
106
+ const flat = a.kind === 'run' && a.detail ? a.detail.replace(/\s+/g, ' ').trim() : '';
107
+ const cmd = flat && !String(a.label || '').replace(/\s+/g, ' ').includes(flat.slice(0, 24)) ? flat : '';
108
+ const edits = a.kind === 'edit' && editsOf(a, t);
109
+ const n = edits ? countChanges(edits) : null;
110
+ return `<li class="tx-step k-${esc(a.kind)}${a.by === 'wisp' ? ' by-wisp' : ''}" data-k="${k}">
111
+ <div class="tx-row" ${more ? 'role="button" tabindex="0" aria-expanded="false"' : ''}>
112
+ <span class="tx-ic">${icon(a.by === 'wisp' ? 'spark' : KIND_ICON[a.kind] || 'tool')}</span>
113
+ <span class="tx-label">${esc(a.label || '')}</span>
114
+ ${cmd ? `<code class="tx-cmd">${esc(cmd)}</code>` : ''}
115
+ ${n ? `<span class="nums"><span class="plus">+${n.add}</span><span class="minus">−${n.del}</span></span>` : ''}
116
+ ${since(a, t)}
117
+ ${a.file && (a.kind === 'read' || a.kind === 'edit') ? `<button class="tx-open" title="Open in editor" aria-label="Open in editor">${icon('external')}</button>` : ''}
118
+ ${more ? icon('chevron', 'chev') : ''}
119
+ </div>
120
+ </li>`;
121
+ }
122
+
123
+ function msgHtml(a, k, t) {
124
+ const text = messageText(a.label);
125
+ return text ? `<li class="tx-msg" data-k="${k}"><span class="tx-dot"></span><div class="prose">${md(text)}</div>${since(a, t)}</li>` : '';
126
+ }
127
+
128
+ const foldHtml = (n, k) => `<li class="tx-fold" data-k="${k}"><button class="tx-row"><span class="tx-ic">${icon('dots')}</span><span class="tx-label">${n} more step${n === 1 ? '' : 's'}</span></button></li>`;
129
+
130
+ // The steps rendered into a list, by data-k: the row's item, or a fold's hidden items.
131
+ const registry = new WeakMap();
132
+
133
+ function listHtml(list, items, t, live, { fold = true } = {}) {
134
+ const reg = registry.get(list);
135
+ return (fold ? layout(items) : items)
136
+ .map((it) => {
137
+ const k = reg.push(it) - 1;
138
+ if (it.fold) return foldHtml(it.fold.length, k);
139
+ return it.kind === 'text' ? msgHtml(it, k, t) : stepHtml(it, k, t, live);
140
+ })
141
+ .join('');
142
+ }
143
+
144
+ // --- rendering ---------------------------------------------------------------
145
+
146
+ // Where the timeline ends. How long it took is on the hero right under it.
147
+ const endLabel = (t) => {
148
+ if (t.status === 'error') return ['Ended with an error', 'warn'];
149
+ if (t.status === 'stopped') return [t.presentation?.blocks?.[0]?.title === 'Interrupted' ? 'Interrupted' : 'Stopped', 'stop'];
150
+ return ['Finished', 'done'];
151
+ };
152
+
153
+ // A finished turn: the timeline, then the presentation. ctx is the block context (turns.js ctxFor).
154
+ export function renderTranscript(s, t, ctx) {
155
+ const skip = finalAnswer(t);
156
+ const items = (t.activity || []).filter((_, i) => !skip.has(i));
157
+ const notes = t.presentation?.notes;
158
+ if (notes) items.push({ kind: 'text', label: notes, t: t.endedAt });
159
+ const msgs = items.filter((a) => a.kind === 'text').length;
160
+ const steps = items.length - msgs;
161
+ const [end, endIcon] = endLabel(t);
162
+ const node = el(`
163
+ <div class="tx">
164
+ <div class="tx-head">
165
+ <span class="tx-title">${icon('transcript')}Transcript</span>
166
+ <span class="tx-count">${[msgs && `${msgs} message${msgs === 1 ? '' : 's'}`, steps && `${steps} step${steps === 1 ? '' : 's'}`].filter(Boolean).join(' · ') || 'Nothing recorded'}</span>
167
+ ${t.trimmed ? `<span class="tx-count" title="A turn keeps its latest steps">· ${t.trimmed} earlier not kept</span>` : ''}
168
+ <span class="grow"></span>
169
+ ${items.length > 10 ? `<button class="link xs tx-jump">Jump to the result${icon('arrow')}</button>` : ''}
170
+ </div>
171
+ <ol class="tx-list"></ol>
172
+ </div>`);
173
+ const list = $('.tx-list', node);
174
+ registry.set(list, []);
175
+ list.innerHTML = listHtml(list, items, t, false) + `<li class="tx-end st-${esc(t.status)}"><span class="tx-ic">${icon(endIcon)}</span><span class="tx-label">${esc(end)}</span></li>`;
176
+ node.append(renderPresentation(t.presentation, ctx));
177
+ wire(node, s, t, ctx);
178
+ return node;
179
+ }
180
+
181
+ // A running turn: the timeline so far, which onActivity grows (appendStep). The working panel with
182
+ // its Stop button and any question sits under it (turns.js).
183
+ export function renderLiveTranscript(s, t, ctx) {
184
+ const node = el(`<div class="tx live"><ol class="tx-list"></ol></div>`);
185
+ const list = $('.tx-list', node);
186
+ registry.set(list, []);
187
+ list.innerHTML = listHtml(list, t.activity || [], t, true);
188
+ wire(node, s, t, ctx);
189
+ return node;
190
+ }
191
+
192
+ // A new step. In a running turn it goes at the end, and the working panel under the timeline holds
193
+ // still on screen while the timeline grows above it, so watching a run reads like a chat; scrolled
194
+ // away, nothing moves. A finished turn still gets wisp's own steps after the agent's answer (Auto
195
+ // checking the tests): they go above the end marker, and what you're reading below doesn't move.
196
+ export function appendStep(turnNode, item, t, scrollBy) {
197
+ const list = $('.tx-list', turnNode);
198
+ const reg = list && registry.get(list);
199
+ if (!reg) return;
200
+ const live = !!list.closest('.live');
201
+ const html = item.kind === 'text' ? msgHtml(item, reg.length, t) : stepHtml(item, reg.length, t, live);
202
+ if (!html) return;
203
+ reg.push(item);
204
+ const end = $(':scope > .tx-end', list);
205
+ const anchor = live ? $('.w-panel', turnNode) : end;
206
+ const r = anchor?.getBoundingClientRect();
207
+ const held = !r ? null : live ? (r.bottom > 0 && r.top < innerHeight ? r.top : null) : r.bottom <= 0 ? r.top : null;
208
+ const li = el(html);
209
+ li.classList.add('new');
210
+ list.insertBefore(li, end);
211
+ if (held !== null) scrollBy(anchor.getBoundingClientRect().top - held);
212
+ }
213
+
214
+ // A step that changed after it was shown (its output came in).
215
+ export const sameStep = (a, b) => (a.call || b.call ? a.call === b.call : a.t === b.t && a.kind === b.kind && a.label === b.label);
216
+ export function updateStep(turnNode, item, t) {
217
+ for (const list of turnNode.querySelectorAll('.tx-list')) {
218
+ const reg = registry.get(list) || [];
219
+ const k = reg.findLastIndex((x) => !x.fold && sameStep(x, item));
220
+ if (k < 0) continue;
221
+ Object.assign(reg[k], item);
222
+ const li = $(`.tx-step[data-k="${k}"]`, list);
223
+ if (!li) continue;
224
+ const open = li.classList.contains('open');
225
+ const next = el(stepHtml(reg[k], k, t, list.closest('.live') !== null));
226
+ li.replaceWith(next);
227
+ if (open) toggleStep(next, reg[k], t, null);
228
+ }
229
+ }
230
+
231
+ // What a step opens to.
232
+ function moreEl(a, t, ctx) {
233
+ if (a.kind === 'edit') {
234
+ const edits = editsOf(a, t);
235
+ if (!edits) return el(`<div class="tx-more"><p class="tx-none">No captured edits for this file.</p></div>`);
236
+ const blocks = edits.map((e) => ({ type: 'diff', file: a.file, before: e.before, after: e.after, bare: true }));
237
+ const box = el(`<div class="tx-more tx-diff"></div>`);
238
+ box.append(renderPresentation({ blocks }, { ...ctx, turn: t }));
239
+ return box;
240
+ }
241
+ if (a.kind === 'error') return el(`<div class="tx-more"><pre class="tx-out err">${esc(a.label)}</pre></div>`);
242
+ const cmd = a.detail ? `<span class="tx-cmdline">${a.kind === 'run' ? '<b>$</b> ' : ''}${esc(a.detail)}</span>` : '';
243
+ const out = a.output ? `<span class="tx-res">${esc(a.output)}</span>` : '';
244
+ return el(`<div class="tx-more"><pre class="tx-out">${cmd}${out}</pre></div>`);
245
+ }
246
+
247
+ async function toggleStep(li, a, t, ctx) {
248
+ const row = $('.tx-row', li);
249
+ const open = !li.classList.contains('open');
250
+ li.classList.toggle('open', open);
251
+ row.setAttribute('aria-expanded', open);
252
+ $('.tx-more', li)?.remove();
253
+ if (!open) return;
254
+ // Mid-run, the card's copy of the turn predates this edit: load it first.
255
+ if (a.kind === 'edit' && !findChange(t, a.file) && ctx?.refresh) {
256
+ li.classList.add('loading');
257
+ t = (await ctx.refresh())?.turns?.find((x) => x.id === t.id) || t;
258
+ a.edits ??= t.activity?.findLast((x) => sameStep(x, a))?.edits; // linked to its edit after it was shown
259
+ li.classList.remove('loading');
260
+ if (!li.classList.contains('open')) return;
261
+ }
262
+ li.append(moreEl(a, t, ctx || {}));
263
+ }
264
+
265
+ function wire(node, s, t, ctx) {
266
+ // The latest copy of the turn: a refetch replaces it while this node stays.
267
+ const turn = () => S.full.get(s.id)?.turns?.find((x) => x.id === t.id) || t;
268
+ const itemOf = (li) => registry.get(li.closest('.tx-list'))?.[li.dataset.k];
269
+ const act = (e) => {
270
+ if (e.target.closest('.tx-jump')) return $('.tx-end', node)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
271
+ const li = e.target.closest('.tx-step, .tx-fold');
272
+ const a = li && itemOf(li);
273
+ if (!a) return;
274
+ if (e.target.closest('.tx-open')) return ctx.openFile(a.file);
275
+ if (a.fold) {
276
+ const list = li.closest('.tx-list');
277
+ li.outerHTML = listHtml(list, a.fold, turn(), node.classList.contains('live'), { fold: false });
278
+ return;
279
+ }
280
+ if (e.target.closest('.tx-row[role="button"]') && !e.target.closest('.tx-more')) toggleStep(li, a, turn(), ctx);
281
+ };
282
+ node.addEventListener('click', act);
283
+ node.addEventListener('keydown', (e) => {
284
+ if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('.tx-row[role="button"]')) {
285
+ e.preventDefault();
286
+ e.stopPropagation(); // Enter on the page would fold the whole card (keys.js)
287
+ act(e);
288
+ }
289
+ });
290
+ }