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
@@ -10,16 +10,16 @@ import { S } from '../core/state.js';
10
10
  import { overlay } from '../ui/overlay.js';
11
11
  import { toastError } from '../ui/toast.js';
12
12
  import { execAction, moreMenu, openInEditor, revertFile, rollbackTurn, takeOver, toggleDone } from './actions.js';
13
+ import { btwTip, paintBtw } from './btw.js';
13
14
  import { draftInto, renderDock, sendReply, startOptimistic, updateDock } from './composer.js';
14
- import { scheduleRefetch } from './feed.js';
15
+ import { expand, fetchFull, renderFeed, scheduleRefetch, setFocus } from './feed.js';
15
16
  import { feedbackOn, rateTurn, whyForm } from './feedback.js';
16
17
  import { projectById } from './places.js';
17
18
  import { agentName, DEFAULT_MODE, effortLabel, modelBadge, MODES } from './runners.js';
18
- import { scrollByPx, watching } from './scroll.js';
19
+ import { scrollByPx, topInset, watching } from './scroll.js';
19
20
  import { renderPrStrip, shipDialog } from './ship.js';
20
21
  import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
21
-
22
- 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' };
22
+ import { appendStep, KIND_ICON, pickView, renderLiveTranscript, renderTranscript, sameStep, transcriptOn, updateStep } from './transcript.js';
23
23
 
24
24
  export function ctxFor(s, turn, isLatest) {
25
25
  return {
@@ -34,6 +34,7 @@ export function ctxFor(s, turn, isLatest) {
34
34
  revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
35
35
  exec: (command, key) => execAction(s.id, turn.id, command, key),
36
36
  approve: (allow) => approveBlocked(s, turn, allow),
37
+ refresh: () => fetchFull(s.id).then(() => S.full.get(s.id)),
37
38
  };
38
39
  }
39
40
 
@@ -67,6 +68,7 @@ export function renderBody(id, { fresh }) {
67
68
  <div class="it-bar">
68
69
  <div class="it-meta"></div>
69
70
  <div class="it-actions">
71
+ <button class="ghost xs tx-toggle" data-a="transcript" aria-pressed="false">${icon('transcript')}<span>Transcript</span></button>
70
72
  <button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
71
73
  <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
72
74
  <button class="ghost xs" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
@@ -83,11 +85,13 @@ export function renderBody(id, { fresh }) {
83
85
  n.body = body;
84
86
  $('.it-bar', body).addEventListener('click', (e) => {
85
87
  const a = e.target.closest('[data-a]')?.dataset.a;
88
+ if (a === 'transcript') toggleTranscript(id);
86
89
  if (a === 'replay') replay(id);
87
90
  if (a === 'takeover') takeOver(id);
88
91
  if (a === 'ship') shipDialog(id);
89
92
  if (a === 'done') toggleDone(id);
90
93
  if (a === 'more') moreMenu(id, e.target.closest('button'));
94
+ if (a === 'moved') openMoved(S.full.get(id)?.movedTo?.id);
91
95
  });
92
96
  $('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
93
97
  renderDock(id);
@@ -97,14 +101,17 @@ export function renderBody(id, { fresh }) {
97
101
  const proj = projectById(summary.projectId);
98
102
  // The head already shows the harness, model and project, and the task head has Compare. This bar
99
103
  // says how the run was set up: a mode worth noticing (Plan or Edits, as on the launcher), the
100
- // branch, and what launched it.
104
+ // branch, the other folders it works in, and what launched it.
101
105
  const mode = MODES.find((m) => m.id === s.mode);
102
106
  const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
103
107
  $('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
104
108
  $('.it-meta', body).innerHTML = `
105
109
  ${mode && mode !== DEFAULT_MODE ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
106
110
  ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
111
+ ${(s.also || []).map((a, i) => `<span class="m also c-${summary.also?.[i]?.color || 'slate'}" title="${esc(`Also works in ${a.cwd}${a.worktree ? `\nWorktree: ${a.worktree.path}\nBranch ${a.worktree.branch}, from ${a.worktree.baseRef}` : ''}`)}"><i class="p-dot"></i>${esc(summary.also?.[i]?.project || a.cwd.split(/[\\/]/).pop())}</span>`).join('')}
107
112
  ${s.subagents?.length ? `<span class="m team-m" title="${esc(s.subagents.map((m) => `@${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n'))}">${icon('agent')}${teamSize(s.subagents)} subagent${teamSize(s.subagents) > 1 ? 's' : ''}</span>` : ''}
113
+ ${s.moved?.from ? `<span class="m" title="${esc(`This task ran on ${s.moved.from} before it moved here, with its work and history`)}">${icon('server')}From ${esc(s.moved.from)}</span>` : ''}
114
+ ${s.movedTo?.id ? `<button class="m moved-to" data-a="moved" title="This task carries on as a new session on ${esc(s.movedTo.place)}">${icon('server')}Continues on ${esc(s.movedTo.place)}</button>` : ''}
108
115
  ${s.origin ? `<span class="m" title="${s.origin.kind === 'telegram' ? 'Started from Telegram' : `Launched by a ${esc(s.origin.kind)}`}">${icon({ trigger: 'replay', telegram: 'send' }[s.origin.kind] || 'spark')}${esc(s.origin.name)}</span>` : ''}`;
109
116
  const live = s.state === 'running' || s.state === 'waiting';
110
117
  // Ship and Done sit in the top bar and again next to the composer, so they're there on opening the
@@ -116,6 +123,11 @@ export function renderBody(id, { fresh }) {
116
123
  $('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
117
124
  });
118
125
  $('[data-a="takeover"]', body).hidden = !s.agentSessionId;
126
+ const tx = transcriptOn(id);
127
+ const txBtn = $('[data-a="transcript"]', body);
128
+ txBtn.classList.toggle('on', tx);
129
+ txBtn.setAttribute('aria-pressed', tx);
130
+ txBtn.title = tx ? 'Back to the presentation (V)' : 'Full transcript: every message and step (V)';
119
131
 
120
132
  // Turns: keep nodes whose content hasn't changed so animations don't replay.
121
133
  const turnsEl = $('.turns', body);
@@ -140,12 +152,12 @@ export function renderBody(id, { fresh }) {
140
152
  const isLatest = t.id === latestId;
141
153
  const folded = !isLatest && !n.unfolded.has(t.id);
142
154
  const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
143
- const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.approval || ''}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
155
+ const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.approval || ''}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}|${tx}`;
144
156
  const prev = n.turnNodes.get(t.id);
145
157
  let node = prev?.node;
146
158
  if (!prev || prev.key !== key) {
147
159
  const toggle = () => (n.unfolded.has(t.id) ? n.unfolded.delete(t.id) : n.unfolded.add(t.id), renderBody(id, { fresh: false }));
148
- node = folded ? renderFoldedTurn(t, toggle) : renderTurn(s, t, isLatest, isLatest ? null : toggle);
160
+ node = folded ? renderFoldedTurn(t, toggle) : renderTurn(s, t, isLatest, isLatest ? null : toggle, tx);
149
161
  if (prev) prev.node.replaceWith(node);
150
162
  if (!prev && !fresh && !folded) firstNew ??= node;
151
163
  if (prev && prev.status === 'running' && t.status !== 'running') firstNew ??= node;
@@ -157,10 +169,45 @@ export function renderBody(id, { fresh }) {
157
169
  if (turnsEl.children[i] !== node) turnsEl.insertBefore(node, turnsEl.children[i] || null);
158
170
  });
159
171
  for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
172
+ paintBtw(id); // side questions update in place, so they don't re-render their turn
160
173
 
161
174
  updateDock(id);
162
175
  if (dock?.isConnected) scrollByPx(dock.getBoundingClientRect().top - dockTop);
163
- if (firstNew && watched) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
176
+ // A transcript was being read at its tail: its result lands right there, so show that, not the prompt.
177
+ const landing = firstNew && tx ? $('.tx-end', firstNew) || firstNew : firstNew;
178
+ if (landing && watched) requestAnimationFrame(() => landing.scrollIntoView({ behavior: 'smooth', block: 'start' }));
179
+ }
180
+
181
+ // The full transcript or the presentation, for one card. The turn being read keeps its place: its
182
+ // prompt stays where it was on screen, or comes back into view if it had scrolled off the top.
183
+ export function toggleTranscript(id) {
184
+ const n = S.nodes.get(id);
185
+ pickView(id, !transcriptOn(id));
186
+ if (!S.open.has(id)) return expand(id);
187
+ if (!n?.body?.querySelector('.turns')) return;
188
+ const turns = [...n.body.querySelectorAll('.turn:not(.folded)')];
189
+ const mid = innerHeight / 2;
190
+ const reading = turns.find((x) => { const r = x.getBoundingClientRect(); return r.top <= mid && r.bottom >= mid; }) || turns.at(-1);
191
+ const tid = reading?.dataset.turn;
192
+ const before = reading && $('.ask', reading)?.getBoundingClientRect().top;
193
+ renderBody(id, { fresh: false });
194
+ const ask = tid && $(`.turn[data-turn="${tid}"] .ask`, n.body);
195
+ if (!ask || before === undefined) return;
196
+ if (before < topInset()) ask.scrollIntoView({ block: 'start', behavior: 'smooth' });
197
+ else scrollByPx(ask.getBoundingClientRect().top - before);
198
+ }
199
+
200
+ // After the setting changes: every open card follows it.
201
+ export function repaintViews() {
202
+ for (const id of S.open) if (S.full.has(id)) renderBody(id, { fresh: false });
203
+ }
204
+
205
+ // The session a moved task carries on in: shown, opened and focused (archived ones stay hidden).
206
+ function openMoved(next) {
207
+ if (!next || !S.sessions.has(next)) return;
208
+ renderFeed();
209
+ expand(next);
210
+ setFocus(next);
164
211
  }
165
212
 
166
213
  function renderFoldedTurn(t, unfold) {
@@ -171,6 +218,7 @@ function renderFoldedTurn(t, unfold) {
171
218
  ${icon('chevron', 'chev')}
172
219
  <span class="t-prompt">${t.by === 'wisp' ? '<b class="t-by">wisp</b> ' : ''}${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
173
220
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
221
+ ${t.moved ? `<span class="t-moved" title="${esc(`Ran on ${t.moved}`)}">${icon('server')}${esc(t.moved)}</span>` : ''}
174
222
  ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
175
223
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
176
224
  </button>
@@ -187,8 +235,9 @@ function modeSwitch(s, t) {
187
235
  return m ? `<span class="t-mode m-${m.id}" title="Switched from ${esc(MODES.find((x) => x.id === prev)?.label || prev)} to ${m.label} for this follow-up">${icon(m.icon)}${m.label}</span>` : '';
188
236
  }
189
237
 
190
- // `fold` is set for older turns the user unfolded, so they can tuck them away again.
191
- function renderTurn(s, t, isLatest, fold) {
238
+ // `fold` is set for older turns the user unfolded, so they can tuck them away again. `tx`: the card
239
+ // shows the full transcript.
240
+ function renderTurn(s, t, isLatest, fold, tx) {
192
241
  const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
193
242
  const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
194
243
  const cp = t.checkpoint;
@@ -202,6 +251,7 @@ function renderTurn(s, t, isLatest, fold) {
202
251
  <div class="ask-foot">
203
252
  ${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
204
253
  ${modeSwitch(s, t)}
254
+ ${t.moved ? `<span class="t-moved" title="${esc(`Ran on ${t.moved}, before the task moved`)}">${icon('server')}${esc(t.moved)}</span>` : ''}
205
255
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
206
256
  ${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
207
257
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
@@ -217,20 +267,25 @@ function renderTurn(s, t, isLatest, fold) {
217
267
  node.append(ask);
218
268
 
219
269
  if (t.status === 'running') {
220
- node.append(renderWorking(s, t));
270
+ if (tx) node.append(renderLiveTranscript(s, t, ctxFor(s, t, isLatest)));
271
+ node.append(renderWorking(s, t, tx));
221
272
  } else {
222
- node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
273
+ const ctx = ctxFor(s, t, isLatest);
274
+ node.append(tx ? renderTranscript(s, t, ctx) : renderPresentation(t.presentation, ctx));
223
275
  const subs = renderSubs(s, t);
224
276
  if (subs) node.append(subs);
225
- node.append(renderTrail(s, t));
277
+ node.append(renderTrail(s, t, tx));
226
278
  }
279
+ node.append(el('<div class="btw-list"></div>')); // side questions asked during this turn (btw.js)
227
280
  return node;
228
281
  }
229
282
 
230
- function renderWorking(s, t) {
283
+ // The live panel. With the transcript on, the steps are already on the timeline above it, so it
284
+ // keeps only the status, the question, the team and Stop.
285
+ function renderWorking(s, t, tx) {
231
286
  const waiting = !!s.ask;
232
287
  const node = el(`
233
- <div class="w-panel ${waiting ? 'waiting' : ''}">
288
+ <div class="w-panel ${waiting ? 'waiting' : ''} ${tx ? 'tx-panel' : ''}">
234
289
  <div class="w-orb"><div class="orb live"><i></i><i></i><i></i></div></div>
235
290
  <div class="w-body">
236
291
  <div class="w-status">
@@ -240,9 +295,9 @@ function renderWorking(s, t) {
240
295
  </div>
241
296
  <div class="ask-slot"></div>
242
297
  ${teamLiveHtml(s, t)}
243
- <ul class="ticker"></ul>
298
+ ${tx ? '' : '<ul class="ticker"></ul>'}
244
299
  <div class="w-actions">
245
- <button class="link xs peek">Show all activity</button>
300
+ ${tx ? '' : '<button class="link xs peek">Show all activity</button>'}
246
301
  ${s.queue?.length ? `<span class="w-queued">${s.queue.length} follow-up${s.queue.length > 1 ? 's' : ''} queued</span>` : ''}
247
302
  <span class="grow"></span>
248
303
  <button class="ghost sm danger stop">${icon('stop')}Stop</button>
@@ -251,11 +306,16 @@ function renderWorking(s, t) {
251
306
  </div>
252
307
  </div>`);
253
308
  if (waiting) $('.ask-slot', node).append(renderAskCard(s));
309
+ // The /btw tip goes after Show all activity. The transcript's panel has none: it leads the row with Stop.
310
+ const tip = btwTip(s.id);
311
+ if (tip && tx) $('.w-actions', node).prepend(tip);
312
+ else if (tip) $('.peek', node).after(tip);
254
313
  const ticker = $('.ticker', node);
255
- t.activity
256
- .filter((a) => a.kind !== 'text')
257
- .slice(-5)
258
- .forEach((a) => ticker.prepend(actItem(a)));
314
+ if (ticker)
315
+ t.activity
316
+ .filter((a) => a.kind !== 'text')
317
+ .slice(-5)
318
+ .forEach((a) => ticker.prepend(actItem(a)));
259
319
  $('.stop', node).onclick = async (e) => {
260
320
  // Shows at once; the run winds down and the live update replaces this panel.
261
321
  const btn = e.currentTarget;
@@ -269,7 +329,7 @@ function renderWorking(s, t) {
269
329
  toastError(err);
270
330
  });
271
331
  };
272
- $('.peek', node).onclick = (e) => {
332
+ if (!tx) $('.peek', node).onclick = (e) => {
273
333
  const log = $('.log', node);
274
334
  log.hidden = !log.hidden;
275
335
  e.target.textContent = log.hidden ? 'Show all activity' : 'Hide activity';
@@ -278,8 +338,10 @@ function renderWorking(s, t) {
278
338
  return node;
279
339
  }
280
340
 
341
+ // A line in the ticker and the activity log. A message (kept whole for the transcript) shows its start.
281
342
  function actItem(a) {
282
- return el(`<li class="act k-${a.kind}">${icon(KIND_ICON[a.kind] || 'tool')}<span>${esc(a.label || '')}</span></li>`);
343
+ const label = a.kind === 'text' && a.label?.length > 400 ? `${a.label.slice(0, 400)}…` : a.label || '';
344
+ return el(`<li class="act k-${a.kind}">${icon(KIND_ICON[a.kind] || 'tool')}<span>${esc(label)}</span></li>`);
283
345
  }
284
346
 
285
347
  function renderAskCard(s) {
@@ -325,7 +387,9 @@ function renderAskCard(s) {
325
387
  return node;
326
388
  }
327
389
 
328
- function renderTrail(s, t) {
390
+ // Under a finished turn. With the transcript showing, its steps and the agent's notes are already on
391
+ // the page, so the bar drops those two.
392
+ function renderTrail(s, t, tx) {
329
393
  const acts = t.activity || [];
330
394
  const count = (k) => acts.filter((a) => a.kind === k).length;
331
395
  const parts = [
@@ -346,13 +410,13 @@ function renderTrail(s, t) {
346
410
  const node = el(`
347
411
  <div class="trail">
348
412
  <div class="trail-bar">
349
- ${acts.length ? `<button class="tb-acts" data-tab="activity" title="Show activity">${parts.length ? parts.map(([ic, l, n]) => `<span class="tb" title="${n} ${l}">${icon(ic)}${n}</span>`).join('') : 'Activity'}</button>` : ''}
413
+ ${acts.length && !tx ? `<button class="tb-acts" data-tab="activity" title="Show activity">${parts.length ? parts.map(([ic, l, n]) => `<span class="tb" title="${n} ${l}">${icon(ic)}${n}</span>`).join('') : 'Activity'}</button>` : ''}
350
414
  ${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
351
415
  ${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
352
416
  <span class="grow"></span>
353
417
  ${t.changes?.length ? `<button class="link xs" data-tab="changes">${t.changes.length} file${t.changes.length > 1 ? 's' : ''} <span class="plus">+${totals.add}</span> <span class="minus">−${totals.del}</span></button>` : ''}
354
418
  <span class="tb-tools">
355
- ${t.presentation?.notes ? `<button class="tb-btn" data-tab="notes" title="Agent notes" aria-label="Agent notes">${icon('text')}</button>` : ''}
419
+ ${t.presentation?.notes && !tx ? `<button class="tb-btn" data-tab="notes" title="Agent notes" aria-label="Agent notes">${icon('text')}</button>` : ''}
356
420
  ${canRollback ? (rolledBack
357
421
  ? `<button class="link xs cp-rollback rolled-back" data-a="rollback" title="Re-apply changes from this turn">${icon('rerun')}Re-apply</button>`
358
422
  : `<button class="tb-btn cp-rollback" data-a="rollback" title="Restore working tree to before this turn${t.checkpoint?.head ? ` (${t.checkpoint.head.slice(0, 7)})` : ''}" aria-label="Restore to before this turn">${icon('replay')}</button>`) : ''}
@@ -402,13 +466,21 @@ function renderTrail(s, t) {
402
466
  return node;
403
467
  }
404
468
 
405
- // Live activity without re-rendering the whole turn.
406
- export function onActivity({ sessionId, turnId, item }) {
469
+ // Live activity without re-rendering the whole turn. `update`: a step already shown changed (its
470
+ // output came in).
471
+ export function onActivity({ sessionId, turnId, item, update }) {
407
472
  const s = S.full.get(sessionId);
408
473
  if (!s) return;
409
474
  const turn = s.turns.find((t) => t.id === turnId);
410
- if (turn && !turn.activity.includes(item)) turn.activity.push(item);
411
475
  const node = S.nodes.get(sessionId)?.turnNodes.get(turnId)?.node;
476
+ if (update) {
477
+ const known = turn?.activity.findLast((a) => sameStep(a, item));
478
+ if (known) Object.assign(known, item);
479
+ if (node) updateStep(node, item, turn);
480
+ return;
481
+ }
482
+ if (turn && !turn.activity.includes(item)) turn.activity.push(item);
483
+ if (node && turn) appendStep(node, item, turn, scrollByPx);
412
484
  if (!node || item.kind === 'text') return;
413
485
  const ticker = $('.ticker', node);
414
486
  if (ticker) {
@@ -0,0 +1,22 @@
1
+ // Slash commands in a follow-up box, DOM-free: what the / menu offers and how a message reads as
2
+ // one. The server knows /btw too (server/btw.js), so the CLI and the phone can use it.
3
+
4
+ export const SLASH = [
5
+ { name: 'btw', args: 'question', hint: 'Ask on the side. The agent keeps working' },
6
+ { name: 'feedback', args: 'note', hint: 'Tell the wisp team what works or breaks', bare: true }, // alone: opens the dialog
7
+ ];
8
+
9
+ const BTW_CMD = /^\/btw(?:\s+|$)/i;
10
+ export const isBtw = (text) => BTW_CMD.test(String(text || '').trimStart());
11
+
12
+ // The question in "/btw did you …" ('' for a bare /btw), or null when the text isn't a /btw.
13
+ export function btwQuestion(text) {
14
+ const t = String(text || '').trim();
15
+ return BTW_CMD.test(t) ? t.replace(BTW_CMD, '').trim() : null;
16
+ }
17
+
18
+ // The commands a half-typed "/b" could be. Only while the box holds nothing but the command.
19
+ export function slashMatches(text, list = SLASH) {
20
+ const m = /^\/([a-z]*)$/i.exec(String(text || ''));
21
+ return m ? list.filter((c) => c.name.startsWith(m[1].toLowerCase())) : [];
22
+ }
package/web/js/ui/kbd.js CHANGED
@@ -20,6 +20,7 @@ export function shortcuts() {
20
20
  ['⌫', 'Delete item'],
21
21
  ['T', 'Take over in terminal'],
22
22
  ['R', 'Replay presentation'],
23
+ ['V', 'Full transcript / presentation'],
23
24
  ['⇧R', 'Rerun with another harness, model or thinking level'],
24
25
  ['S', 'Ship it: commit, push and open a PR (worktree tasks)'],
25
26
  ['1–9', 'Pick an answer when asked'],