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.
- package/CHANGELOG.md +35 -1
- package/README.md +17 -8
- package/bin/wisp.js +14 -6
- package/package.json +4 -3
- package/server/adapters/claude.js +12 -7
- package/server/adapters/codex.js +13 -7
- package/server/adapters/cursor.js +4 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/index.js +7 -2
- package/server/adapters/opencode.js +5 -2
- package/server/btw.js +76 -0
- package/server/client.js +22 -2
- package/server/git.js +80 -4
- package/server/handoff.js +13 -7
- package/server/index.js +4 -0
- package/server/machines.js +15 -4
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +28 -2
- package/server/remote.js +4 -2
- package/server/routes/machines.js +1 -1
- package/server/routes/proxy.js +6 -0
- package/server/routes/sessions.js +47 -10
- package/server/routes/settings.js +24 -38
- package/server/routes/ship.js +19 -24
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +178 -12
- package/server/services/auto.js +10 -2
- package/server/services/launch.js +67 -7
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/ship.js +97 -10
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +3 -1
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +52 -1
- package/web/css/launcher.css +12 -0
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +14 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +5 -2
- package/web/js/features/actions.js +42 -10
- package/web/js/features/btw.js +145 -0
- package/web/js/features/clones.js +7 -6
- package/web/js/features/commands.js +3 -1
- package/web/js/features/composer.js +162 -18
- package/web/js/features/feed.js +10 -10
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +121 -67
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +25 -5
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +16 -13
- package/web/js/features/settings.js +24 -3
- package/web/js/features/ship.js +66 -29
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +101 -29
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
package/web/js/features/turns.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
270
|
+
if (tx) node.append(renderLiveTranscript(s, t, ctxFor(s, t, isLatest)));
|
|
271
|
+
node.append(renderWorking(s, t, tx));
|
|
221
272
|
} else {
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
.
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'],
|