wisp-agents 0.15.0 → 0.16.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 (114) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +28 -12
  3. package/bin/wisp.js +3 -2
  4. package/package.json +3 -3
  5. package/server/adapters/antigravity.js +2 -2
  6. package/server/adapters/claude.js +31 -50
  7. package/server/adapters/codex.js +2 -2
  8. package/server/adapters/common.js +14 -34
  9. package/server/adapters/cursor.js +2 -2
  10. package/server/adapters/grok.js +2 -2
  11. package/server/adapters/index.js +16 -1
  12. package/server/adapters/opencode.js +47 -6
  13. package/server/checkpoint.js +12 -68
  14. package/server/client.js +5 -3
  15. package/server/git.js +9 -4
  16. package/server/handoff.js +70 -0
  17. package/server/http/respond.js +59 -0
  18. package/server/http/router.js +64 -0
  19. package/server/http/static.js +36 -0
  20. package/server/index.js +138 -1216
  21. package/server/machines/bootstrap.js +56 -0
  22. package/server/machines/hosts.js +127 -0
  23. package/server/machines.js +7 -179
  24. package/server/mcp-wisp.js +17 -5
  25. package/server/openapi.js +33 -2
  26. package/server/paths.js +32 -0
  27. package/server/preferences.js +2 -6
  28. package/server/proc.js +54 -0
  29. package/server/recipes.js +2 -4
  30. package/server/remote.js +14 -14
  31. package/server/router.js +7 -3
  32. package/server/routes/auto.js +29 -0
  33. package/server/routes/automations.js +92 -0
  34. package/server/routes/internal.js +56 -0
  35. package/server/routes/machines.js +43 -0
  36. package/server/routes/meta.js +76 -0
  37. package/server/routes/proxy.js +47 -0
  38. package/server/routes/sessions.js +339 -0
  39. package/server/routes/settings.js +144 -0
  40. package/server/routes/ship.js +61 -0
  41. package/server/routes/uploads.js +19 -0
  42. package/server/runner.js +87 -36
  43. package/server/services/auto.js +67 -0
  44. package/server/services/feedback.js +53 -0
  45. package/server/services/install.js +69 -0
  46. package/server/services/launch.js +127 -0
  47. package/server/services/phone.js +65 -0
  48. package/server/services/ship.js +82 -0
  49. package/server/services/telemetry.js +34 -0
  50. package/server/services/uploads.js +59 -0
  51. package/server/services/webhooks.js +28 -0
  52. package/server/store.js +5 -3
  53. package/server/subagents.js +5 -4
  54. package/server/telemetry.js +1 -1
  55. package/server/terminal.js +2 -1
  56. package/server/triggers.js +4 -6
  57. package/server/update.js +4 -3
  58. package/server/util.js +42 -0
  59. package/server/welcome.js +1 -1
  60. package/web/css/auto.css +29 -0
  61. package/web/css/automations.css +71 -0
  62. package/web/css/base.css +83 -0
  63. package/web/css/blocks.css +461 -0
  64. package/web/css/composer.css +62 -0
  65. package/web/css/feed.css +208 -0
  66. package/web/css/launcher.css +204 -0
  67. package/web/css/layout.css +61 -0
  68. package/web/css/machines.css +125 -0
  69. package/web/css/modals.css +176 -0
  70. package/web/css/palette.css +79 -0
  71. package/web/css/responsive.css +35 -0
  72. package/web/css/ship.css +95 -0
  73. package/web/css/subagents.css +130 -0
  74. package/web/css/tokens.css +115 -0
  75. package/web/index.html +16 -1
  76. package/web/js/app.js +28 -5237
  77. package/web/js/core/api.js +25 -0
  78. package/web/js/core/bus.js +28 -0
  79. package/web/js/core/dom.js +7 -0
  80. package/web/js/core/format.js +27 -0
  81. package/web/js/core/state.js +43 -0
  82. package/web/js/features/actions.js +339 -0
  83. package/web/js/features/attachments.js +115 -0
  84. package/web/js/features/auto.js +54 -0
  85. package/web/js/features/automations.js +305 -0
  86. package/web/js/features/commands.js +123 -0
  87. package/web/js/features/compare.js +104 -0
  88. package/web/js/features/composer.js +311 -0
  89. package/web/js/features/feed.js +400 -0
  90. package/web/js/features/feedback.js +127 -0
  91. package/web/js/features/keys.js +83 -0
  92. package/web/js/features/launcher.js +559 -0
  93. package/web/js/features/live.js +91 -0
  94. package/web/js/features/machines.js +313 -0
  95. package/web/js/features/memory.js +154 -0
  96. package/web/js/features/notify.js +36 -0
  97. package/web/js/features/phone.js +124 -0
  98. package/web/js/features/places.js +38 -0
  99. package/web/js/features/projects.js +284 -0
  100. package/web/js/features/runner-picker.js +233 -0
  101. package/web/js/features/runners.js +161 -0
  102. package/web/js/features/scroll.js +75 -0
  103. package/web/js/features/settings.js +390 -0
  104. package/web/js/features/setup.js +94 -0
  105. package/web/js/features/ship.js +122 -0
  106. package/web/js/features/team.js +386 -0
  107. package/web/js/features/turns.js +401 -0
  108. package/web/js/features/update.js +48 -0
  109. package/web/js/features/usage.js +75 -0
  110. package/web/js/ui/kbd.js +46 -0
  111. package/web/js/ui/overlay.js +38 -0
  112. package/web/js/ui/popover.js +52 -0
  113. package/web/js/ui/toast.js +23 -0
  114. package/web/css/app.css +0 -1910
@@ -0,0 +1,401 @@
1
+ // An expanded card's body: its turns (prompt, live progress or presentation, trail), newest open
2
+ // and older ones folded.
3
+
4
+ import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
5
+ import { esc, md } from '../md.js';
6
+ import { api } from '../core/api.js';
7
+ import { $, $$, el, icon } from '../core/dom.js';
8
+ import { S } from '../core/state.js';
9
+ import { overlay } from '../ui/overlay.js';
10
+ import { toastError } from '../ui/toast.js';
11
+ import { execAction, moreMenu, openInEditor, revertFile, rollbackTurn, takeOver } from './actions.js';
12
+ import { draftInto, renderDock, sendReply, startOptimistic, updateDock } from './composer.js';
13
+ import { scheduleRefetch } from './feed.js';
14
+ import { feedbackOn, rateTurn, whyForm } from './feedback.js';
15
+ import { projectById } from './places.js';
16
+ import { agentName, effortLabel, modelBadge, MODES } from './runners.js';
17
+ import { scrollByPx, watching } from './scroll.js';
18
+ import { renderPrStrip, shipDialog } from './ship.js';
19
+ import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
20
+
21
+ const KIND_ICON = { 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
+
23
+ export function ctxFor(s, turn, isLatest) {
24
+ return {
25
+ session: s,
26
+ turn,
27
+ isLatest,
28
+ reply: (text) => sendReply(s.id, text),
29
+ draft: (text) => draftInto(s.id, text),
30
+ openFile: (p, line) => openInEditor(s, p, line),
31
+ fileUrl: (p) => `/api/sessions/${s.id}/file?path=${encodeURIComponent(p)}`,
32
+ zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
33
+ revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
34
+ exec: (command, key) => execAction(s.id, turn.id, command, key),
35
+ };
36
+ }
37
+
38
+ // Older turns render as one-line folded rows; past this many they hide behind "Show earlier".
39
+ const VISIBLE_TURNS = 8;
40
+
41
+ export function renderBody(id, { fresh }) {
42
+ const s = S.full.get(id);
43
+ const n = S.nodes.get(id);
44
+ if (!s || !n) return;
45
+ const summary = S.sessions.get(id) || {};
46
+
47
+ if (fresh || !n.body?.querySelector('.turns')) {
48
+ n.turnNodes.clear();
49
+ const body = el(`
50
+ <div class="it-body">
51
+ <div class="it-bar">
52
+ <div class="it-meta"></div>
53
+ <div class="it-actions">
54
+ <button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
55
+ <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
56
+ <button class="ghost xs" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
57
+ <button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
58
+ </div>
59
+ </div>
60
+ <div class="pr-strip" hidden></div>
61
+ <button class="link xs earlier" hidden></button>
62
+ <div class="turns"></div>
63
+ <div class="it-dock"></div>
64
+ </div>`);
65
+ n.body ? n.body.replaceWith(body) : n.card.append(body);
66
+ n.body = body;
67
+ $('.it-bar', body).addEventListener('click', (e) => {
68
+ const a = e.target.closest('[data-a]')?.dataset.a;
69
+ if (a === 'replay') replay(id);
70
+ if (a === 'takeover') takeOver(id);
71
+ if (a === 'ship') shipDialog(id);
72
+ if (a === 'more') moreMenu(id, e.target.closest('button'));
73
+ });
74
+ $('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
75
+ renderDock(id);
76
+ }
77
+
78
+ const body = n.body;
79
+ const proj = projectById(summary.projectId);
80
+ // The head already shows the harness, model and project, the task head has Compare, and Done
81
+ // sits by the composer. This bar only says how the run was set up: a mode worth noticing
82
+ // (Plan or Full auto, as on the launcher), the branch, and what launched it.
83
+ const mode = MODES.find((m) => m.id === s.mode);
84
+ const where = (proj?.path || s.cwd).replace(S.config.home || '', '~');
85
+ $('.it-meta', body).title = [agentName(s.agent), s.model, s.effort && `${effortLabel(s.effort)} thinking`, mode?.label, where].filter(Boolean).join(' · ');
86
+ $('.it-meta', body).innerHTML = `
87
+ ${mode && mode.id !== 'edits' ? `<span class="m mode m-${mode.id}" title="${esc(mode.hint)}">${icon(mode.icon)}${esc(mode.label)}</span>` : ''}
88
+ ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
89
+ ${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>` : ''}
90
+ ${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>` : ''}`;
91
+ const live = s.state === 'running' || s.state === 'waiting';
92
+ // Ship sits in the top bar and again next to the composer, so it's there after reading the result.
93
+ renderPrStrip(id);
94
+ $$('[data-a="done"]', body).forEach((b) => {
95
+ b.classList.toggle('on', s.state === 'done');
96
+ b.hidden = s.state === 'running' || s.state === 'waiting';
97
+ $('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
98
+ });
99
+ $('[data-a="takeover"]', body).hidden = !s.agentSessionId;
100
+
101
+ // Turns: keep nodes whose content hasn't changed so animations don't replay.
102
+ const turnsEl = $('.turns', body);
103
+ const turns = n.showAll ? s.turns : s.turns.slice(-VISIBLE_TURNS);
104
+ const hiddenCount = s.turns.length - turns.length;
105
+ const earlier = $('.earlier', body);
106
+ earlier.hidden = !hiddenCount;
107
+ earlier.textContent = `Show ${hiddenCount} earlier turn${hiddenCount === 1 ? '' : 's'}`;
108
+ // A new turn re-folds everything older, so the latest exchange is what's open by default.
109
+ const latestId = s.turns.at(-1)?.id;
110
+ const newTurn = n.latestTurn !== latestId;
111
+ if (newTurn) (n.unfolded.clear(), (n.latestTurn = latestId));
112
+ // Folding the last answer when a follow-up starts can take a screen or more out of the card
113
+ // above the composer. Hold the composer still, so the new exchange lands where the user was
114
+ // typing instead of the whole card sliding off the top of the screen.
115
+ const watched = watching(id);
116
+ const dock = watched && newTurn && !fresh ? $('.it-dock', body) : null;
117
+ const dockTop = dock?.getBoundingClientRect().top;
118
+ const keep = new Set();
119
+ let firstNew = null;
120
+ turns.forEach((t, i) => {
121
+ const isLatest = t.id === latestId;
122
+ const folded = !isLatest && !n.unfolded.has(t.id);
123
+ const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
124
+ const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
125
+ const prev = n.turnNodes.get(t.id);
126
+ let node = prev?.node;
127
+ if (!prev || prev.key !== key) {
128
+ const toggle = () => (n.unfolded.has(t.id) ? n.unfolded.delete(t.id) : n.unfolded.add(t.id), renderBody(id, { fresh: false }));
129
+ node = folded ? renderFoldedTurn(t, toggle) : renderTurn(s, t, isLatest, isLatest ? null : toggle);
130
+ if (prev) prev.node.replaceWith(node);
131
+ if (!prev && !fresh && !folded) firstNew ??= node;
132
+ if (prev && prev.status === 'running' && t.status !== 'running') firstNew ??= node;
133
+ n.turnNodes.set(t.id, { key, node, status: t.status });
134
+ }
135
+ keep.add(t.id);
136
+ // Place the turn without re-inserting a node that's already here: re-parenting restarts its
137
+ // CSS animations, so a refetch would replay the whole card's entrance.
138
+ if (turnsEl.children[i] !== node) turnsEl.insertBefore(node, turnsEl.children[i] || null);
139
+ });
140
+ for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
141
+
142
+ updateDock(id);
143
+ if (dock?.isConnected) scrollByPx(dock.getBoundingClientRect().top - dockTop);
144
+ if (firstNew && watched) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
145
+ }
146
+
147
+ function renderFoldedTurn(t, unfold) {
148
+ const hero = t.presentation?.blocks?.find((b) => b.type === 'hero');
149
+ const node = el(`
150
+ <article class="turn folded" data-turn="${t.id}">
151
+ <button class="t-fold" title="Show this exchange">
152
+ ${icon('chevron', 'chev')}
153
+ <span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
154
+ ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
155
+ ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
156
+ <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
157
+ </button>
158
+ </article>`);
159
+ $('.t-fold', node).onclick = unfold;
160
+ return node;
161
+ }
162
+
163
+ // A follow-up that switched the mode says so, next to the model it ran on.
164
+ function modeSwitch(s, t) {
165
+ const i = s.turns.indexOf(t);
166
+ const prev = s.turns.slice(0, i).findLast((x) => x.mode)?.mode;
167
+ const m = t.mode && prev && t.mode !== prev && MODES.find((x) => x.id === t.mode);
168
+ 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>` : '';
169
+ }
170
+
171
+ // `fold` is set for older turns the user unfolded, so they can tuck them away again.
172
+ function renderTurn(s, t, isLatest, fold) {
173
+ const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
174
+ const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
175
+ const cp = t.checkpoint;
176
+ const cpLabel = cp?.head ? `${cp.branch ? `${cp.branch}@` : ''}${cp.head.slice(0, 7)}${cp.dirty ? '*' : ''}` : '';
177
+ const ask = el(`
178
+ <div class="ask">
179
+ <div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
180
+ ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
181
+ ${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
182
+ <div class="ask-foot">
183
+ ${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
184
+ ${modeSwitch(s, t)}
185
+ <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
186
+ ${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
187
+ ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
188
+ </div>
189
+ ${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
190
+ </div>`);
191
+ if (fold) ask.querySelector('.t-unfold').onclick = fold;
192
+ ask.querySelector('.more-btn')?.addEventListener('click', (e) => {
193
+ ask.querySelector('.ask-text').classList.toggle('clamped');
194
+ e.target.textContent = ask.querySelector('.ask-text').classList.contains('clamped') ? 'Show all' : 'Show less';
195
+ });
196
+ ask.querySelectorAll('.ask-atts img').forEach((img) => img.addEventListener('click', () => overlay(el(`<img class="lightbox" src="${img.src}">`))));
197
+ node.append(ask);
198
+
199
+ if (t.status === 'running') {
200
+ node.append(renderWorking(s, t));
201
+ } else {
202
+ node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
203
+ const subs = renderSubs(s, t);
204
+ if (subs) node.append(subs);
205
+ node.append(renderTrail(s, t));
206
+ }
207
+ return node;
208
+ }
209
+
210
+ function renderWorking(s, t) {
211
+ const waiting = !!s.ask;
212
+ const node = el(`
213
+ <div class="w-panel ${waiting ? 'waiting' : ''}">
214
+ <div class="w-orb"><div class="orb live"><i></i><i></i><i></i></div></div>
215
+ <div class="w-body">
216
+ <div class="w-status">
217
+ <b>${waiting ? `${esc(agentName(s.agent))} is waiting for you` : `${esc(agentName(s.agent))} is working`}</b>
218
+ <span class="elapsed" data-start="${t.startedAt}">${fmtDuration(Date.now() - t.startedAt)}</span>
219
+ <span class="w-count">${t.activity.filter((a) => a.kind !== 'text').length} actions</span>
220
+ </div>
221
+ <div class="ask-slot"></div>
222
+ ${teamLiveHtml(s, t)}
223
+ <ul class="ticker"></ul>
224
+ <div class="w-actions">
225
+ <button class="link xs peek">Show all activity</button>
226
+ ${s.queue?.length ? `<span class="w-queued">${s.queue.length} follow-up${s.queue.length > 1 ? 's' : ''} queued</span>` : ''}
227
+ <span class="grow"></span>
228
+ <button class="ghost sm danger stop">${icon('stop')}Stop</button>
229
+ </div>
230
+ <ul class="log" hidden></ul>
231
+ </div>
232
+ </div>`);
233
+ if (waiting) $('.ask-slot', node).append(renderAskCard(s));
234
+ const ticker = $('.ticker', node);
235
+ t.activity
236
+ .filter((a) => a.kind !== 'text')
237
+ .slice(-5)
238
+ .forEach((a) => ticker.prepend(actItem(a)));
239
+ $('.stop', node).onclick = async (e) => {
240
+ // Shows at once; the run winds down and the live update replaces this panel.
241
+ const btn = e.currentTarget;
242
+ btn.disabled = true;
243
+ btn.lastChild.textContent = 'Stopping…';
244
+ $('.w-status b', node).textContent = `Stopping ${agentName(s.agent)}…`;
245
+ await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((err) => {
246
+ btn.disabled = false;
247
+ btn.lastChild.textContent = 'Stop';
248
+ $('.w-status b', node).textContent = `${agentName(s.agent)} is working`;
249
+ toastError(err);
250
+ });
251
+ };
252
+ $('.peek', node).onclick = (e) => {
253
+ const log = $('.log', node);
254
+ log.hidden = !log.hidden;
255
+ e.target.textContent = log.hidden ? 'Show all activity' : 'Hide activity';
256
+ if (!log.hidden) log.innerHTML = t.activity.map((a) => actItem(a).outerHTML).join('');
257
+ };
258
+ return node;
259
+ }
260
+
261
+ function actItem(a) {
262
+ return el(`<li class="act k-${a.kind}">${icon(KIND_ICON[a.kind] || 'tool')}<span>${esc(a.label || '')}</span></li>`);
263
+ }
264
+
265
+ function renderAskCard(s) {
266
+ const a = s.ask;
267
+ const node = el(`
268
+ <div class="ask-card">
269
+ <div class="q-head">${icon('ask')}<span>Needs your call</span></div>
270
+ <p class="q-text">${esc(a.question)}</p>
271
+ ${a.context ? `<div class="prose q-context">${md(a.context)}</div>` : ''}
272
+ ${a.options?.length ? `<div class="q-opts">${a.options.map((o, i) => `<button class="opt" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>` : ''}
273
+ <form class="q-own"><input class="input" placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
274
+ </div>`);
275
+ const answer = async (text) => {
276
+ if (!text.trim()) return;
277
+ node.classList.add('sent');
278
+ const undo = startOptimistic(s.id, text, null, { answering: true });
279
+ try {
280
+ await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } });
281
+ if (undo) scheduleRefetch(s.id);
282
+ } catch (e) {
283
+ node.classList.remove('sent');
284
+ undo?.();
285
+ toastError(e);
286
+ }
287
+ };
288
+ node.addEventListener('click', (e) => {
289
+ const b = e.target.closest('.opt');
290
+ if (b) answer(a.options[b.dataset.i]);
291
+ });
292
+ $('form', node).onsubmit = (e) => (e.preventDefault(), answer($('input', node).value));
293
+ return node;
294
+ }
295
+
296
+ function renderTrail(s, t) {
297
+ const acts = t.activity || [];
298
+ const count = (k) => acts.filter((a) => a.kind === k).length;
299
+ const parts = [
300
+ ['read', 'read', count('read')],
301
+ ['pen', 'edited', count('edit')],
302
+ ['terminal', 'ran', count('run')],
303
+ ['search', 'searched', count('search')],
304
+ ['web', 'web', count('web')],
305
+ ].filter((p) => p[2]);
306
+ const totals = (t.changes || []).reduce((acc, c) => {
307
+ const n = countChanges(c.edits);
308
+ return { add: acc.add + n.add, del: acc.del + n.del };
309
+ }, { add: 0, del: 0 });
310
+ const st = t.stats || {};
311
+ const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (t.changes?.length || t.checkpoint?.commit);
312
+ const rolledBack = !!t.rolledBackAt;
313
+ const rated = t.feedback?.rating;
314
+ const node = el(`
315
+ <div class="trail">
316
+ <div class="trail-bar">
317
+ ${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>` : ''}
318
+ ${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
319
+ ${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
320
+ <span class="grow"></span>
321
+ ${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>` : ''}
322
+ <span class="tb-tools">
323
+ ${t.presentation?.notes ? `<button class="tb-btn" data-tab="notes" title="Agent notes" aria-label="Agent notes">${icon('text')}</button>` : ''}
324
+ ${canRollback ? (rolledBack
325
+ ? `<button class="link xs cp-rollback rolled-back" data-a="rollback" title="Re-apply changes from this turn">${icon('rerun')}Re-apply</button>`
326
+ : `<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>`) : ''}
327
+ ${feedbackOn() ? `<span class="rate ${rated ? 'rated' : ''}">${['up', 'down'].map((r) => `<button class="rate-btn ${rated === r ? 'on' : ''}" data-rate="${r}" title="${r === 'up' ? 'Good result' : 'Bad result'}: tell the wisp team" aria-pressed="${rated === r}">${icon(`thumb-${r}`)}</button>`).join('')}</span>` : ''}
328
+ </span>
329
+ </div>
330
+ <div class="trail-body" hidden></div>
331
+ </div>`);
332
+ const body = $('.trail-body', node);
333
+ let open = null;
334
+ node.addEventListener('click', (e) => {
335
+ if (e.target.closest('[data-a="rollback"]')) {
336
+ return rollbackTurn(s.id, t.id, rolledBack);
337
+ }
338
+ const rb = e.target.closest('[data-rate]');
339
+ if (rb) {
340
+ const next = t.feedback?.rating === rb.dataset.rate ? null : rb.dataset.rate;
341
+ rateTurn(s, t, node, next);
342
+ if (next !== 'down' && open === 'rate') ((open = null), (body.hidden = true));
343
+ if (next === 'down') {
344
+ open = 'rate';
345
+ $$('[data-tab]', node).forEach((x) => x.classList.remove('on'));
346
+ body.hidden = false;
347
+ body.innerHTML = '';
348
+ body.append(whyForm(s, t, () => ((open = null), (body.hidden = true))));
349
+ }
350
+ return;
351
+ }
352
+ const b = e.target.closest('[data-tab]');
353
+ if (!b) return;
354
+ const tab = b.dataset.tab;
355
+ $$('[data-tab]', node).forEach((x) => x.classList.toggle('on', x === b && open !== tab));
356
+ if (open === tab) {
357
+ open = null;
358
+ body.hidden = true;
359
+ return;
360
+ }
361
+ open = tab;
362
+ body.hidden = false;
363
+ body.innerHTML = '';
364
+ if (tab === 'activity') body.append(el(`<ul class="log">${acts.map((a) => actItem(a).outerHTML).join('')}</ul>`));
365
+ if (tab === 'notes') body.append(el(`<div class="prose">${md(t.presentation.notes)}</div>`));
366
+ if (tab === 'changes') {
367
+ body.append(renderPresentation({ blocks: t.changes.map((c) => ({ type: 'diff', file: c.file })) }, ctxFor(s, t, false)));
368
+ }
369
+ });
370
+ return node;
371
+ }
372
+
373
+ // Live activity without re-rendering the whole turn.
374
+ export function onActivity({ sessionId, turnId, item }) {
375
+ const s = S.full.get(sessionId);
376
+ if (!s) return;
377
+ const turn = s.turns.find((t) => t.id === turnId);
378
+ if (turn && !turn.activity.includes(item)) turn.activity.push(item);
379
+ const node = S.nodes.get(sessionId)?.turnNodes.get(turnId)?.node;
380
+ if (!node || item.kind === 'text') return;
381
+ const ticker = $('.ticker', node);
382
+ if (ticker) {
383
+ const li = actItem(item);
384
+ li.classList.add('new');
385
+ ticker.prepend(li);
386
+ while (ticker.children.length > 5) ticker.lastElementChild.remove();
387
+ }
388
+ const log = $('.log', node);
389
+ if (log && !log.hidden) log.append(actItem(item));
390
+ const c = $('.w-count', node);
391
+ if (c && turn) c.textContent = `${turn.activity.filter((a) => a.kind !== 'text').length} actions`;
392
+ }
393
+
394
+ export function replay(id) {
395
+ const n = S.nodes.get(id);
396
+ if (!n?.body) return;
397
+ for (const v of n.turnNodes.values()) v.node.remove();
398
+ n.turnNodes.clear();
399
+ renderBody(id, { fresh: false });
400
+ $('.turns', n.body).lastElementChild?.scrollIntoView({ block: 'start', behavior: 'smooth' });
401
+ }
@@ -0,0 +1,48 @@
1
+ // Self-update: the server polls npm; when a newer wisp-agents is out, a pill appears next to Search.
2
+
3
+ import { copy } from '../blocks.js';
4
+ import { esc } from '../md.js';
5
+ import { api } from '../core/api.js';
6
+ import { $ } from '../core/dom.js';
7
+ import { S } from '../core/state.js';
8
+ import { toast } from '../ui/toast.js';
9
+
10
+ // The server polls npm; when a newer wisp-agents is out, a pill appears next to Search.
11
+ let update = null;
12
+ export const loadUpdate = () => api('/update').then(renderUpdate).catch(() => {});
13
+ export function renderUpdate(u) {
14
+ update = u;
15
+ const b = $('#updateBtn');
16
+ const busy = u.status === 'installing' || u.status === 'restarting';
17
+ b.hidden = !u.available && !busy;
18
+ b.disabled = busy;
19
+ b.classList.toggle('busy', busy);
20
+ b.querySelector('span').textContent = u.status === 'installing' ? 'Updating…' : u.status === 'restarting' ? 'Restarting…' : `Update to ${u.latest}`;
21
+ b.title = u.auto ? `wisp ${u.current} → ${u.latest}: install and restart` : `wisp ${u.latest} is out. Run \`${u.command}\` to update`;
22
+ }
23
+
24
+ export async function installUpdate() {
25
+ if (!update?.available) return;
26
+ if (!update.auto) return toast(`wisp <b>${esc(update.latest)}</b> is out. Run <code>${esc(update.command)}</code>`, { action: { label: 'Copy', run: () => copy(update.command) }, timeout: 8000 });
27
+ const post = (force) => api('/update', { method: 'POST', body: { force } });
28
+ try {
29
+ await post(false).catch(async (err) => {
30
+ const running = [...S.sessions.values()].filter((s) => s.state === 'running' || s.state === 'waiting').length;
31
+ if (err.message !== 'agents are running') throw err;
32
+ if (!confirm(`Update to wisp ${update.latest} and restart? ${running || 'Some'} running agent${running === 1 ? '' : 's'} will be stopped.`)) throw null;
33
+ return post(true);
34
+ });
35
+ } catch (err) {
36
+ if (err) toast(esc(err.message), { tone: 'bad', timeout: 10_000, action: { label: 'Copy command', run: () => copy(update.command) } });
37
+ return;
38
+ }
39
+ renderUpdate({ ...update, status: 'restarting' });
40
+ // The server exits and a new one takes the port: reload once it answers with the new version.
41
+ const from = update.current;
42
+ const t0 = Date.now();
43
+ const poll = setInterval(async () => {
44
+ const h = await api('/health').catch(() => null);
45
+ if (h?.version && h.version !== from) return clearInterval(poll), location.reload();
46
+ if (Date.now() - t0 > 120_000) clearInterval(poll), toast('wisp did not come back. Start it again from your terminal.', { tone: 'bad', timeout: 15_000 });
47
+ }, 1000);
48
+ }
@@ -0,0 +1,75 @@
1
+ // Usage: spend and outcomes.
2
+
3
+ import { fmtDuration } from '../blocks.js';
4
+ import { esc } from '../md.js';
5
+ import { api } from '../core/api.js';
6
+ import { $, $$, el, icon } from '../core/dom.js';
7
+ import { money, pct } from '../core/format.js';
8
+ import { present } from '../ui/overlay.js';
9
+ import { toastError } from '../ui/toast.js';
10
+ import { agentName, harnessLogo } from './runners.js';
11
+
12
+ export async function openUsage({ days = 30, host } = {}) {
13
+ const node = el(`<div class="modal usage-modal">
14
+ <header><h2>Usage</h2><div class="mem-tabs us-range">${[7, 30, 90].map((d) => `<button data-days="${d}">${d} days</button>`).join('')}</div><button class="icon-btn close">${icon('x')}</button></header>
15
+ <div class="us-body"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
16
+ </div>`);
17
+ const close = present(node, host);
18
+ const render = async () => {
19
+ $$('.us-range button', node).forEach((b) => b.classList.toggle('on', Number(b.dataset.days) === days));
20
+ const u = await api(`/usage?days=${days}`).catch((err) => (toastError(err), null));
21
+ if (!u || !node.isConnected) return;
22
+ const t = u.total;
23
+ const unpriced = t.turns - t.priced;
24
+ // Cost when any harness reports it, else turns: one measure per chart, never two axes.
25
+ const byCost = t.priced > 0;
26
+ const val = (d) => (byCost ? d.cost : d.turns);
27
+ const max = Math.max(...u.daily.map(val), 0) || 1;
28
+ const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString([], { month: 'short', day: 'numeric' });
29
+ const table = (rows, label) =>
30
+ rows.length
31
+ ? `<table class="us-table"><thead><tr><th></th><th>Spend</th><th>Turns</th><th>Worked</th><th>Asked</th><th>Avg time</th></tr></thead><tbody>${rows
32
+ .map((r) => `<tr><td>${label(r)}</td><td>${r.priced ? money(r.cost) : '—'}</td><td>${r.turns}</td><td>${pct(r.successRate)}</td><td>${r.questions}</td><td>${fmtDuration(r.avgMs)}</td></tr>`)
33
+ .join('')}</tbody></table>`
34
+ : '<p class="mem-empty">Nothing in this range.</p>';
35
+ $('.us-body', node).innerHTML = `
36
+ <div class="us-tiles">
37
+ <div><span>Spend</span><b>${byCost ? money(t.cost) : '—'}</b><small>${unpriced ? `${unpriced} turn${unpriced > 1 ? 's' : ''} without a price` : 'all turns priced'}</small></div>
38
+ <div><span>Turns</span><b>${t.turns}</b><small>in ${t.sessions} session${t.sessions === 1 ? '' : 's'}</small></div>
39
+ <div><span>Worked</span><b>${pct(t.successRate)}</b><small>finished, not partial or blocked</small></div>
40
+ <div><span>Questions</span><b>${t.questions}</b><small>times an agent needed you</small></div>
41
+ </div>
42
+ <h4>${byCost ? 'Spend per day' : 'Turns per day'}</h4>
43
+ <div class="us-chart" role="img" aria-label="${byCost ? 'Spend' : 'Turns'} per day">${u.daily
44
+ .map((d) => `<div class="us-bar" data-tip="${esc(fmtDay(d.date))}: ${byCost ? money(d.cost) : ''}${byCost ? ' · ' : ''}${d.turns} turn${d.turns === 1 ? '' : 's'}"><i style="height:${val(d) ? Math.max(2, (val(d) / max) * 100) : 0}%"></i></div>`)
45
+ .join('')}</div>
46
+ <div class="us-axis"><span>${esc(fmtDay(u.daily[0].date))}</span><span>${byCost ? `max ${money(max)}/day` : `max ${max}/day`}</span><span>today</span></div>
47
+ <h4>By project</h4>${table(u.projects, (r) => `<span class="us-proj c-${esc(r.color)}"><i class="p-dot"></i>${esc(r.name)}</span>`)}
48
+ <h4>By harness · model</h4>${table(u.runners, (r) => `<span class="us-runner"><span class="model-logo">${harnessLogo(r.agent)}</span>${esc(agentName(r.agent))} · ${esc(r.model || 'default')}</span>`)}
49
+ ${unpriced ? '<p class="hint">Codex, Cursor and Antigravity report tokens but not cost, so their turns count in turns and outcomes but not in spend.</p>' : ''}`;
50
+ };
51
+ // One floating tooltip for the bars (they are too thin to label).
52
+ const tip = el('<div class="us-tip" hidden></div>');
53
+ node.append(tip);
54
+ node.addEventListener('mousemove', (e) => {
55
+ const b = e.target.closest('.us-bar');
56
+ tip.hidden = !b;
57
+ if (!b) return;
58
+ tip.textContent = b.dataset.tip;
59
+ const r = b.getBoundingClientRect();
60
+ const m = node.getBoundingClientRect();
61
+ tip.style.left = `${Math.min(r.left - m.left + r.width / 2, m.width - 90)}px`;
62
+ tip.style.top = `${r.top - m.top + node.scrollTop - 6}px`;
63
+ });
64
+ node.addEventListener('mouseleave', () => (tip.hidden = true));
65
+ $('.us-range', node).onclick = (e) => {
66
+ const d = Number(e.target.closest('[data-days]')?.dataset.days);
67
+ if (d) (days = d), render();
68
+ };
69
+ render();
70
+ }
71
+
72
+ function usageSummary(u) {
73
+ if (!u) return '';
74
+ return u.total.turns ? `${u.total.priced ? `${money(u.total.cost)} · ` : ''}${u.total.turns} turns · ${pct(u.total.successRate)} worked, last 7 days` : 'Nothing in the last 7 days';
75
+ }
@@ -0,0 +1,46 @@
1
+ // Keyboard shortcuts as they read on this OS, how they're matched, and the dialog that lists them.
2
+
3
+ import { el } from '../core/dom.js';
4
+ import { overlay } from './overlay.js';
5
+
6
+ // The keyboard shortcut for a menu item, shown at its right edge. Menus act on the
7
+ // focused card, so the key does the same thing as the click.
8
+ export const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
9
+
10
+ export function shortcuts() {
11
+ const rows = [
12
+ [combo('K'), 'Search & commands'],
13
+ [`N · ${combo('L')}`, 'New wisp (focus the launcher)'],
14
+ ['J / K', 'Next / previous item'],
15
+ ['O / ↵', 'Expand / collapse item'],
16
+ ['/', 'Follow up on item'],
17
+ ['E', 'Mark done & go to next'],
18
+ ['A', 'Archive / unarchive'],
19
+ ['M', 'Rename item'],
20
+ ['⌫', 'Delete item'],
21
+ ['T', 'Take over in terminal'],
22
+ ['R', 'Replay presentation'],
23
+ ['⇧R', 'Rerun with another harness, model or thinking level'],
24
+ ['S', 'Ship it: commit, push and open a PR (worktree tasks)'],
25
+ ['1–9', 'Pick an answer when asked'],
26
+ ['↵', 'Launch / send'],
27
+ ['⇧↵', 'New line'],
28
+ ['/feedback', 'Send feedback to the wisp team (in any prompt box)'],
29
+ ['C', 'Collapse all'],
30
+ ['U', 'Back to top'],
31
+ ['Esc', 'Close / collapse'],
32
+ ];
33
+ const node = el(`<div class="modal keys-modal"><header><h2>Shortcuts</h2></header>${rows.map(([k, l]) => `<div class="krow"><kbd>${k}</kbd><span>${l}</span></div>`).join('')}</div>`);
34
+ overlay(node);
35
+ }
36
+
37
+ const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
38
+
39
+ // How a modifier shortcut reads on this OS: "⌘L" on macOS, "Ctrl+L" on Windows/Linux.
40
+ export const combo = (k) => (IS_MAC ? `⌘${k}` : `Ctrl+${k}`);
41
+
42
+ // ⌘ on macOS, Ctrl elsewhere (AltGr on Windows reports Ctrl+Alt, so Alt is excluded). Falls back to
43
+ // the physical key on non-Latin layouts, where e.key is e.g. "д" for the L key.
44
+ export const modKey = (e, k) =>
45
+ (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey &&
46
+ (e.key.toLowerCase() === k || (!/^[a-z]$/i.test(e.key) && e.code === `Key${k.toUpperCase()}`));
@@ -0,0 +1,38 @@
1
+ import { $ } from '../core/dom.js';
2
+ import { sweep } from '../core/bus.js';
3
+
4
+ // The one modal layer (#overlay). Opening a dialog replaces whatever it showed.
5
+ export function overlay(node, { onClose } = {}) {
6
+ const o = $('#overlay');
7
+ o.innerHTML = '';
8
+ o.append(node);
9
+ o.hidden = false;
10
+ requestAnimationFrame(() => o.classList.add('on'));
11
+ const close = () => {
12
+ o.classList.remove('on');
13
+ // Another overlay may have replaced this one while it faded (palette → settings): leave that alone.
14
+ setTimeout(() => {
15
+ if (o.firstChild === node) (o.hidden = true), (o.innerHTML = '');
16
+ sweep();
17
+ }, 200);
18
+ onClose?.();
19
+ };
20
+ // A block body: an on* handler that returns false cancels the click, which would stop every
21
+ // submit button inside the dialog from submitting its form.
22
+ o.onclick = (e) => {
23
+ if (e.target === o) close();
24
+ };
25
+ o.close = close;
26
+ return close;
27
+ }
28
+
29
+ export const closeOverlay = () => !$('#overlay').hidden && $('#overlay').close?.();
30
+
31
+ // A view opens as its own dialog, or inside another one (Settings) that hands it a host.
32
+ // Returns what closes it for good: the whole overlay either way.
33
+ export function present(node, host) {
34
+ if (host) return host(node);
35
+ const close = overlay(node);
36
+ $('.close', node).onclick = close;
37
+ return close;
38
+ }