wisp-agents 0.15.0 → 0.17.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 +55 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +7 -3
- package/server/adapters/claude.js +79 -55
- package/server/adapters/codex.js +5 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +4 -2
- package/server/adapters/grok.js +17 -2
- package/server/adapters/index.js +25 -2
- package/server/adapters/opencode.js +78 -12
- package/server/checkpoint.js +12 -68
- package/server/client.js +6 -4
- package/server/git.js +9 -4
- package/server/handoff.js +71 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-ask.js +113 -8
- package/server/mcp-wisp.js +17 -5
- package/server/mentions.js +39 -0
- package/server/openapi.js +36 -4
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/protocol.js +8 -1
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/render.js +3 -1
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +176 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +351 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +216 -49
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +13 -5
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +5 -7
- package/server/update.js +4 -3
- package/server/usage.js +94 -0
- package/server/util.js +42 -0
- package/server/welcome.js +3 -3
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +84 -0
- package/web/css/feed.css +235 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +17 -1
- package/web/js/app.js +28 -5237
- package/web/js/blocks.js +46 -0
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +344 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +105 -0
- package/web/js/features/composer.js +315 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +561 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +163 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +432 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/mention-text.js +59 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1910
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { esc } from '../md.js';
|
|
2
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
3
|
+
import { S } from '../core/state.js';
|
|
4
|
+
import { popover } from '../ui/popover.js';
|
|
5
|
+
import { placeName } from './places.js';
|
|
6
|
+
import { AUTO, AUTO_GOALS, effortLabel, effortsFor, harnessesAt, harnessLogo } from './runners.js';
|
|
7
|
+
import { openSettings } from './settings.js';
|
|
8
|
+
|
|
9
|
+
// One popover for all three choices: harnesses on the left, the focused harness's models on the
|
|
10
|
+
// right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
|
|
11
|
+
// models; picking a model commits harness + model, picking a thinking level commits it in place.
|
|
12
|
+
// places: the "Run on" picks, so the list is what's installed there rather than only on this computer.
|
|
13
|
+
export function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places, auto = false }, pick) {
|
|
14
|
+
const open = $('.picker');
|
|
15
|
+
if (open) return open.close();
|
|
16
|
+
const agents = places ? harnessesAt(places, { auto }) : auto ? [AUTO, ...S.agents] : S.agents;
|
|
17
|
+
const sub = (a) => {
|
|
18
|
+
if (a.id === 'auto') return 'Picks harness and model for you';
|
|
19
|
+
if (!a.available) return places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
|
|
20
|
+
const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
|
|
21
|
+
return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
|
|
22
|
+
};
|
|
23
|
+
let focusAgent = agents.find((a) => a.id === agent && a.available) ? agent : agents.find((a) => a.available)?.id;
|
|
24
|
+
|
|
25
|
+
const r = anchor.getBoundingClientRect();
|
|
26
|
+
const width = Math.min(580, innerWidth - 24);
|
|
27
|
+
// Stay inside the launcher card when there's room, else inside the window.
|
|
28
|
+
const box = anchor.closest('.launcher')?.getBoundingClientRect();
|
|
29
|
+
const right = Math.min(innerWidth - 12, box && box.width >= width ? box.right : innerWidth - 12);
|
|
30
|
+
const left = Math.max(12, Math.min(r.left, right - width));
|
|
31
|
+
const below = r.bottom + 440 < innerHeight;
|
|
32
|
+
const node = el(`
|
|
33
|
+
<div class="picker" role="dialog" aria-label="Harness, model and thinking level" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
|
|
34
|
+
<div class="pk-side" role="listbox" aria-label="Harness">
|
|
35
|
+
<div class="pk-head">Harness</div>
|
|
36
|
+
${agents
|
|
37
|
+
.map(
|
|
38
|
+
(a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : 'disabled'}>
|
|
39
|
+
<span class="pk-logo">${harnessLogo(a.id)}</span>
|
|
40
|
+
<span class="pk-text"><b>${esc(a.name)}</b><small${a.missing?.length ? ' class="warn"' : ''}>${esc(sub(a))}</small></span>
|
|
41
|
+
${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
|
|
42
|
+
</button>`,
|
|
43
|
+
)
|
|
44
|
+
.join('')}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="pk-main">
|
|
47
|
+
<label class="field pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
|
|
48
|
+
<div class="pk-list" role="listbox" aria-label="Model"></div>
|
|
49
|
+
<div class="pk-effort" role="radiogroup" aria-label="Thinking level"></div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>`);
|
|
52
|
+
const input = $('.pk-search input', node);
|
|
53
|
+
const list = $('.pk-list', node);
|
|
54
|
+
const sideRows = $$('.pk-h', node);
|
|
55
|
+
const seg = $('.pk-effort', node);
|
|
56
|
+
const modelOf = (id) => (id === agent ? model : savedModel(id));
|
|
57
|
+
const effortOf = (id) => (id === agent ? effort : savedEffort(id, modelOf(id)));
|
|
58
|
+
|
|
59
|
+
const paintEffort = () => {
|
|
60
|
+
const levels = effortsFor(focusAgent, modelOf(focusAgent));
|
|
61
|
+
const current = effortOf(focusAgent);
|
|
62
|
+
seg.hidden = !levels.length;
|
|
63
|
+
seg.innerHTML = `<span class="pk-ehead">Thinking</span><span class="pk-seg">${['', ...levels]
|
|
64
|
+
.map((e) => `<button role="radio" aria-checked="${e === current}" class="${e === current ? 'on' : ''}" data-e="${esc(e)}" title="${e ? `${esc(effortLabel(e))} reasoning effort` : 'Whatever the harness is set to'}">${esc(e ? effortLabel(e) : 'Default')}</button>`)
|
|
65
|
+
.join('')}</span>`;
|
|
66
|
+
};
|
|
67
|
+
const markInUse = (id) => {
|
|
68
|
+
$$('.pk-dot', node).forEach((d) => d.remove());
|
|
69
|
+
sideRows.find((b) => b.dataset.id === id)?.insertAdjacentHTML('beforeend', '<span class="pk-dot" title="In use"></span>');
|
|
70
|
+
};
|
|
71
|
+
const chooseEffort = (e) => {
|
|
72
|
+
const m = modelOf(focusAgent);
|
|
73
|
+
if (focusAgent === agent && e === effort) return;
|
|
74
|
+
if (focusAgent !== agent) markInUse(focusAgent);
|
|
75
|
+
agent = focusAgent;
|
|
76
|
+
model = m;
|
|
77
|
+
effort = e;
|
|
78
|
+
pick(agent, model, effort);
|
|
79
|
+
paintEffort();
|
|
80
|
+
$(`button[data-e="${CSS.escape(e)}"]`, seg)?.focus();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const paintModels = () => {
|
|
84
|
+
const a = agents.find((x) => x.id === focusAgent);
|
|
85
|
+
sideRows.forEach((b) => b.classList.toggle('on', b.dataset.id === focusAgent));
|
|
86
|
+
const models = a?.models || [{ id: '', label: 'Default' }];
|
|
87
|
+
const current = focusAgent === agent ? model : savedModel(focusAgent);
|
|
88
|
+
input.placeholder = `Search ${a?.name || ''} models`;
|
|
89
|
+
if (a?.id === 'auto') {
|
|
90
|
+
list.innerHTML = `<div class="pk-group"><div class="pk-ghead">Goal</div>${AUTO_GOALS.map((g) => `<button class="pk-m${g.id === current ? ' on' : ''}" role="option" aria-selected="${g.id === current}" data-id="${g.id}"><span class="pk-text"><b>${g.label}</b><small>${g.hint}</small></span>${icon('check', 'pk-check')}</button>`).join('')}</div><p class="pk-note">wisp picks the harness and model for each task from what worked before, and learns from how it went. ${'<a href="#" class="pk-auto-more">How Auto picks</a>'}</p><div class="pk-empty" hidden>No models match</div>`;
|
|
91
|
+
$('.pk-search', node).hidden = true;
|
|
92
|
+
$('.pk-auto-more', list).onclick = (e) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
close();
|
|
95
|
+
openSettings({ pane: 'auto' });
|
|
96
|
+
};
|
|
97
|
+
paintEffort();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
$('.pk-search', node).hidden = models.length <= 6;
|
|
101
|
+
// opencode-style ids (provider/model) group by provider.
|
|
102
|
+
const groups = new Map();
|
|
103
|
+
for (const m of models) {
|
|
104
|
+
const g = m.id && m.id.includes('/') ? m.hint || '' : '';
|
|
105
|
+
if (!groups.has(g)) groups.set(g, []);
|
|
106
|
+
groups.get(g).push(m);
|
|
107
|
+
}
|
|
108
|
+
list.innerHTML = [...groups]
|
|
109
|
+
.map(
|
|
110
|
+
([g, ms]) =>
|
|
111
|
+
`<div class="pk-group">${g ? `<div class="pk-ghead">${esc(g)}</div>` : ''}${ms
|
|
112
|
+
.map(
|
|
113
|
+
(m) => `<button class="pk-m${m.id === current ? ' on' : ''}" role="option" aria-selected="${m.id === current}" data-id="${esc(m.id)}">
|
|
114
|
+
<span class="pk-text"><b>${esc(m.id ? m.label : 'Default')}</b><small>${esc(m.id ? (m.hint && m.hint !== g ? m.hint : '') : m.hint || `Whatever ${a?.name} is set to`)}</small></span>${icon('check', 'pk-check')}
|
|
115
|
+
</button>`,
|
|
116
|
+
)
|
|
117
|
+
.join('')}</div>`,
|
|
118
|
+
)
|
|
119
|
+
.join('') + '<div class="pk-empty" hidden>No models match</div>';
|
|
120
|
+
filter();
|
|
121
|
+
paintEffort();
|
|
122
|
+
};
|
|
123
|
+
const rows = () => $$('.pk-m', list).filter((b) => !b.hidden);
|
|
124
|
+
const filter = () => {
|
|
125
|
+
const q = input.value.trim().toLowerCase();
|
|
126
|
+
for (const b of $$('.pk-m', list)) b.hidden = !!q && !`${b.dataset.id} ${b.textContent}`.toLowerCase().includes(q);
|
|
127
|
+
for (const g of $$('.pk-group', list)) g.hidden = !$$('.pk-m', g).some((b) => !b.hidden);
|
|
128
|
+
$('.pk-empty', list).hidden = rows().length > 0;
|
|
129
|
+
};
|
|
130
|
+
const focusModels = () => {
|
|
131
|
+
const rs = rows();
|
|
132
|
+
const target = rs.find((b) => b.classList.contains('on')) || rs[0];
|
|
133
|
+
target?.focus();
|
|
134
|
+
target?.scrollIntoView({ block: 'nearest' });
|
|
135
|
+
};
|
|
136
|
+
const preview = (id, { focus } = {}) => {
|
|
137
|
+
if (id !== focusAgent) {
|
|
138
|
+
focusAgent = id;
|
|
139
|
+
input.value = '';
|
|
140
|
+
paintModels();
|
|
141
|
+
}
|
|
142
|
+
if (focus) sideRows.find((b) => b.dataset.id === id)?.focus();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Picking a harness applies it straight away with the model and level it shows as selected.
|
|
146
|
+
const useAgent = (id) => {
|
|
147
|
+
preview(id);
|
|
148
|
+
if (id === agent) return;
|
|
149
|
+
markInUse(id);
|
|
150
|
+
agent = id;
|
|
151
|
+
model = savedModel(id);
|
|
152
|
+
effort = savedEffort(id, model);
|
|
153
|
+
pick(agent, model, effort);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const choose = (m) => {
|
|
157
|
+
close();
|
|
158
|
+
anchor.focus();
|
|
159
|
+
// Carry the thinking level over when the new model accepts it, else fall back to the saved one.
|
|
160
|
+
const cur = effortOf(focusAgent);
|
|
161
|
+
const e = effortsFor(focusAgent, m).includes(cur) ? cur : savedEffort(focusAgent, m);
|
|
162
|
+
if (focusAgent !== agent || m !== model || e !== effort) pick(focusAgent, m, e);
|
|
163
|
+
};
|
|
164
|
+
const step = (items, d) => {
|
|
165
|
+
if (!items.length) return;
|
|
166
|
+
const i = items.indexOf(document.activeElement);
|
|
167
|
+
const next = items[i < 0 ? (d > 0 ? 0 : items.length - 1) : (i + d + items.length) % items.length];
|
|
168
|
+
next.focus();
|
|
169
|
+
next.scrollIntoView({ block: 'nearest' });
|
|
170
|
+
return next;
|
|
171
|
+
};
|
|
172
|
+
const keys = (e) => {
|
|
173
|
+
const inSide = sideRows.includes(document.activeElement);
|
|
174
|
+
const live = sideRows.filter((b) => !b.disabled);
|
|
175
|
+
const segBtns = $$('button', seg);
|
|
176
|
+
const inSeg = segBtns.includes(document.activeElement);
|
|
177
|
+
// Tab hops from the model list to the thinking levels and back; anywhere else it leaves.
|
|
178
|
+
if (e.key === 'Tab' && !seg.hidden && (inSeg ? e.shiftKey : !e.shiftKey && !inSide)) {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
return inSeg ? focusModels() : (segBtns.find((b) => b.classList.contains('on')) || segBtns[0]).focus();
|
|
181
|
+
}
|
|
182
|
+
if (e.key === 'Tab') return close();
|
|
183
|
+
if (inSeg && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
return step(segBtns, e.key === 'ArrowRight' ? 1 : -1);
|
|
187
|
+
}
|
|
188
|
+
if (inSeg && e.key === 'ArrowUp') return e.preventDefault(), focusModels();
|
|
189
|
+
if (inSeg && (e.key === 'Enter' || e.key === ' ')) return e.preventDefault(), chooseEffort(document.activeElement.dataset.e);
|
|
190
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
e.stopPropagation();
|
|
193
|
+
const d = e.key === 'ArrowDown' ? 1 : -1;
|
|
194
|
+
if (inSide) preview(step(live, d)?.dataset.id);
|
|
195
|
+
else step(rows(), d);
|
|
196
|
+
} else if (e.key === 'ArrowLeft' && document.activeElement !== input) {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
preview(focusAgent, { focus: true });
|
|
199
|
+
} else if ((e.key === 'ArrowRight' || e.key === 'Enter') && inSide) {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
if (e.key === 'Enter') useAgent(focusAgent);
|
|
202
|
+
focusModels();
|
|
203
|
+
} else if (e.key === 'Enter' && document.activeElement === input && rows()[0]) {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
choose(rows()[0].dataset.id);
|
|
206
|
+
} else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !$('.pk-search', node).hidden && document.activeElement !== input) {
|
|
207
|
+
input.focus(); // type-to-search from anywhere in the picker
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
sideRows.forEach((b) => (b.onclick = () => useAgent(b.dataset.id)));
|
|
211
|
+
list.onclick = (e) => {
|
|
212
|
+
const b = e.target.closest('.pk-m');
|
|
213
|
+
if (b) choose(b.dataset.id);
|
|
214
|
+
};
|
|
215
|
+
input.oninput = filter;
|
|
216
|
+
seg.onclick = (e) => {
|
|
217
|
+
const b = e.target.closest('button[data-e]');
|
|
218
|
+
if (b) chooseEffort(b.dataset.e);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
paintModels();
|
|
222
|
+
const close = popover(node, {
|
|
223
|
+
anchor,
|
|
224
|
+
onClose: () => {
|
|
225
|
+
anchor.classList.remove('open');
|
|
226
|
+
document.removeEventListener('keydown', keys, true);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
document.addEventListener('keydown', keys, true);
|
|
230
|
+
anchor.classList.add('open');
|
|
231
|
+
focusModels(); // scrolls the current model into view…
|
|
232
|
+
if (!$('.pk-search', node).hidden) input.focus({ preventScroll: true }); // …then search is ready to type
|
|
233
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// How a task runs: the harness (a coding-agent CLI, or Auto), its model and thinking level, and
|
|
2
|
+
// the permission mode. Shared by the launcher, follow-ups, reruns, recipes and subagents.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
6
|
+
import { S } from '../core/state.js';
|
|
7
|
+
import { popover } from '../ui/popover.js';
|
|
8
|
+
import { machineById } from './places.js';
|
|
9
|
+
|
|
10
|
+
export const MODES = [
|
|
11
|
+
{ id: 'plan', label: 'Plan', icon: 'plan', hint: 'Read-only. The agent investigates and proposes.' },
|
|
12
|
+
{ id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files. Anything else that needs approval comes to you to allow.' },
|
|
13
|
+
{ id: 'auto', label: 'Full auto', icon: 'spark', hint: 'No permission prompts at all. Trusted repos only.' },
|
|
14
|
+
];
|
|
15
|
+
// The default: just an icon on mode buttons; the others are spelled out so you can't miss them.
|
|
16
|
+
export const DEFAULT_MODE = MODES[2];
|
|
17
|
+
|
|
18
|
+
export const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'auto' ? 'Auto' : id === 'wisp' ? 'wisp' : id);
|
|
19
|
+
|
|
20
|
+
// Auto: not a CLI but wisp's router (server/router.js), which picks harness and model per task.
|
|
21
|
+
// Its "models" are the goal it optimises for; there are no thinking levels to pick.
|
|
22
|
+
export const AUTO_GOALS = [
|
|
23
|
+
{ id: 'balanced', label: 'Balanced', hint: 'Best result per dollar' },
|
|
24
|
+
{ id: 'cheap', label: 'Cheap', hint: 'Lowest cost that still gets it done' },
|
|
25
|
+
{ id: 'best', label: 'Best', hint: 'Best result; cost only breaks ties' },
|
|
26
|
+
];
|
|
27
|
+
export const AUTO = { id: 'auto', name: 'Auto', available: true, delegates: true, models: AUTO_GOALS, efforts: [], hint: 'wisp picks the harness and model from what worked before' };
|
|
28
|
+
|
|
29
|
+
// Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
|
|
30
|
+
export const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
|
|
31
|
+
|
|
32
|
+
// A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
|
|
33
|
+
export function agentById(id) {
|
|
34
|
+
if (id === 'auto') return AUTO;
|
|
35
|
+
const local = S.agents.find((a) => a.id === id);
|
|
36
|
+
if (local?.available) return local;
|
|
37
|
+
return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
|
|
41
|
+
// `missing` names the picked places that don't have it. Machines not online yet don't count.
|
|
42
|
+
export function harnessesAt(places, { auto = false } = {}) {
|
|
43
|
+
if (auto) return [AUTO, ...harnessesAt(places)];
|
|
44
|
+
const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
|
|
45
|
+
if (!known.length) known.push(['', S.agents]);
|
|
46
|
+
const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
|
|
47
|
+
return ids
|
|
48
|
+
.map((id) => {
|
|
49
|
+
const hits = known.filter(([, l]) => l.some((a) => a.id === id && a.available));
|
|
50
|
+
const base = hits.length ? hits[0][1].find((a) => a.id === id) : S.agents.find((a) => a.id === id) || known.flatMap(([, l]) => l).find((a) => a.id === id);
|
|
51
|
+
return { ...base, available: hits.length > 0, missing: hits.length ? known.filter((x) => !hits.includes(x)).map(([k]) => k) : [] };
|
|
52
|
+
})
|
|
53
|
+
.filter((a) => a.available || S.agents.some((x) => x.id === a.id));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Harness logos for the request bubble, vendored from @lobehub/icons into web/logos/.
|
|
57
|
+
// `mono` marks are drawn in the text colour so they work in both themes.
|
|
58
|
+
const HARNESS_LOGO = {
|
|
59
|
+
claude: { file: 'claudecode-color' },
|
|
60
|
+
codex: { file: 'codex-color' },
|
|
61
|
+
opencode: { file: 'opencode', mono: true },
|
|
62
|
+
cursor: { file: 'cursor', mono: true },
|
|
63
|
+
antigravity: { file: 'antigravity-color' },
|
|
64
|
+
grok: { file: 'grok', mono: true },
|
|
65
|
+
};
|
|
66
|
+
export function harnessLogo(agent) {
|
|
67
|
+
if (agent === 'auto') return `<i class="auto-mark">${icon('spark')}</i>`;
|
|
68
|
+
const l = HARNESS_LOGO[agent];
|
|
69
|
+
const src = l && `/logos/${l.file}.svg`;
|
|
70
|
+
return !l ? icon('spark') : l.mono ? `<i class="mono" style="--logo:url(${src})"></i>` : `<img src="${src}" alt="">`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
|
|
74
|
+
// else the harness-wide one. Empty means the harness has no knob for it.
|
|
75
|
+
export function effortsFor(agent, model) {
|
|
76
|
+
const a = agentById(agent);
|
|
77
|
+
return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
|
|
78
|
+
}
|
|
79
|
+
export const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
|
|
80
|
+
|
|
81
|
+
export function modelBadge(agent, model, effort, route) {
|
|
82
|
+
if (agent === 'wisp') return '';
|
|
83
|
+
const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
|
|
84
|
+
const tip = route ? routeTip(route) : `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
|
|
85
|
+
const logo = harnessLogo(agent);
|
|
86
|
+
return `<span class="model-logo${route ? ' routed' : ''}" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Why Auto picked what it picked, in one line.
|
|
90
|
+
function routeTip(r) {
|
|
91
|
+
const goal = AUTO_GOALS.find((g) => g.id === r.goal)?.label || 'Balanced';
|
|
92
|
+
const record = r.n ? `${Math.round(r.chance * 100)}% on your ${r.type} tasks (${r.n})` : `${Math.round(r.chance * 100)}% expected on ${r.type} tasks`;
|
|
93
|
+
const why = r.trying ? 'new model, trying it out' : r.explored ? 'trying an alternative to learn' : record;
|
|
94
|
+
return `Auto · ${goal} → ${r.label} · ${why}${r.usd ? ` · ~$${r.usd.toFixed(2)}` : ''}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The model is remembered per harness (ids aren't portable between them).
|
|
98
|
+
export function savedModel(id) {
|
|
99
|
+
const d = S.config.defaults;
|
|
100
|
+
if (id === 'auto') return AUTO_GOALS.some((g) => g.id === d.models?.auto) ? d.models.auto : 'balanced';
|
|
101
|
+
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
102
|
+
const models = agentById(id)?.models || [];
|
|
103
|
+
return models.some((m) => m.id === want) ? want : '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Thinking level too, and only if the chosen model still accepts it.
|
|
107
|
+
export function savedEffort(id, model) {
|
|
108
|
+
const d = S.config.defaults;
|
|
109
|
+
const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
|
|
110
|
+
return effortsFor(id, model).includes(want) ? want : '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// One launch runs on at most this many harnesses side by side (the server's MAX_COMPARE).
|
|
114
|
+
export const MAX_RUNNERS = 4;
|
|
115
|
+
|
|
116
|
+
// A runner button's tooltip, for when the pill is too full to show names.
|
|
117
|
+
export function runnerTip(agentId, model, effort) {
|
|
118
|
+
const a = agentById(agentId);
|
|
119
|
+
const m = (a?.models || []).find((x) => x.id === model);
|
|
120
|
+
return [a?.name || agentName(agentId), m?.label || model || 'Default model', effort && effortLabel(effort)].filter(Boolean).join(' · ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
|
|
124
|
+
export function paintRunnerBtn(btn, agentId, model, effort) {
|
|
125
|
+
const a = agentById(agentId);
|
|
126
|
+
const m = (a?.models || []).find((x) => x.id === model);
|
|
127
|
+
btn.innerHTML = agentId
|
|
128
|
+
? `<span class="rb-logo">${harnessLogo(agentId)}</span><span class="rb-h">${esc(a?.name || agentName(agentId))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`
|
|
129
|
+
: `<span class="rb-logo">${icon('spark')}</span><span class="rb-h">Launcher default</span>${icon('chevron', 'chev')}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function modeMenu(anchor, current, pick) {
|
|
133
|
+
const open = $('.menu.mode');
|
|
134
|
+
if (open) return open.close();
|
|
135
|
+
const menu = el(`
|
|
136
|
+
<div class="menu mode" role="menu">
|
|
137
|
+
${MODES.map((m) => `<button role="menuitemradio" aria-checked="${m.id === current}" data-mode="${m.id}" class="m-${m.id}${m.id === current ? ' on' : ''}">
|
|
138
|
+
${icon(m.icon)}<span class="mm-text"><b>${m.label}</b><small>${esc(m.hint)}</small></span>${icon('check', 'mm-check')}
|
|
139
|
+
</button>`).join('')}
|
|
140
|
+
</div>`);
|
|
141
|
+
menu.onclick = (ev) => {
|
|
142
|
+
const b = ev.target.closest('button');
|
|
143
|
+
if (!b) return;
|
|
144
|
+
close();
|
|
145
|
+
pick(b.dataset.mode);
|
|
146
|
+
if (anchor.id === 'mode') $('#prompt')?.focus();
|
|
147
|
+
};
|
|
148
|
+
menu.addEventListener('keydown', (ev) => {
|
|
149
|
+
const items = $$('button', menu);
|
|
150
|
+
const i = items.indexOf(document.activeElement);
|
|
151
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
152
|
+
ev.preventDefault();
|
|
153
|
+
items[(i + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length].focus();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
const close = popover(menu, { anchor, onClose: () => anchor.classList.remove('open') });
|
|
157
|
+
anchor.classList.add('open');
|
|
158
|
+
const r = anchor.getBoundingClientRect();
|
|
159
|
+
const h = menu.offsetHeight;
|
|
160
|
+
menu.style.left = `${Math.max(8, Math.min(r.left, innerWidth - menu.offsetWidth - 8))}px`;
|
|
161
|
+
menu.style.top = `${r.bottom + 6 + h < innerHeight - 8 ? r.bottom + 6 : r.top - 6 - h}px`;
|
|
162
|
+
$('button.on', menu)?.focus();
|
|
163
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The page scrolls inside #stage, under a sticky top bar and the launcher, which docks there once
|
|
2
|
+
// it scrolls away. Everything that moves the page on a card's behalf goes through here.
|
|
3
|
+
|
|
4
|
+
import { $, $$ } from '../core/dom.js';
|
|
5
|
+
import { S } from '../core/state.js';
|
|
6
|
+
|
|
7
|
+
export const stage = $('#stage');
|
|
8
|
+
|
|
9
|
+
// How much of the feed the sticky top bar and docked launcher cover: the stage's scroll-padding-top,
|
|
10
|
+
// which scrollIntoView already honours, so hand-placed cards line up with scrolled-to ones.
|
|
11
|
+
export const topInset = () => parseFloat(getComputedStyle(stage).scrollPaddingTop) || 0;
|
|
12
|
+
|
|
13
|
+
// Whether the user is reading a card, not just catching its edge at the top or bottom of the
|
|
14
|
+
// screen: it crosses the middle of the viewport, or it's focused and a fair share of it shows.
|
|
15
|
+
// Anything that moves the page on a card's behalf asks this first.
|
|
16
|
+
export function watching(id) {
|
|
17
|
+
const card = S.nodes.get(id)?.card;
|
|
18
|
+
if (!card?.isConnected) return false;
|
|
19
|
+
const r = card.getBoundingClientRect();
|
|
20
|
+
const mid = innerHeight / 2;
|
|
21
|
+
if (r.top <= mid && r.bottom >= mid) return true;
|
|
22
|
+
const shown = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
|
|
23
|
+
return S.focus === id && shown >= Math.min(r.height, innerHeight) / 3;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Run a DOM change without moving `card` on screen. The browser's own scroll anchoring misses
|
|
27
|
+
// cards that are moved between lists, so feed re-renders do this explicitly.
|
|
28
|
+
export function keepInPlace(card, change) {
|
|
29
|
+
const top = card?.isConnected ? card.getBoundingClientRect().top : null;
|
|
30
|
+
change();
|
|
31
|
+
if (top !== null && card.isConnected) scrollByPx(card.getBoundingClientRect().top - top);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Any write to scrollTop cancels a smooth scroll in flight (j/k, Open, a launch), so only
|
|
35
|
+
// write when there's an actual shift to undo.
|
|
36
|
+
export function scrollByPx(dy) {
|
|
37
|
+
if (Math.abs(dy) >= 1) stage.scrollTop += dy;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The card to hold still while the feed changes around it: the first one showing under the top
|
|
41
|
+
// bar and docked launcher, skipping `id` (the card that's changing). None at the very top of the page, where new
|
|
42
|
+
// cards should push the list down, or when `id` itself is the top card on screen: then the next
|
|
43
|
+
// one should slide up into its place.
|
|
44
|
+
export function feedAnchor(id) {
|
|
45
|
+
if (stage.scrollTop < 1) return null;
|
|
46
|
+
const moving = id && S.nodes.get(id)?.card;
|
|
47
|
+
const inset = topInset();
|
|
48
|
+
for (const card of $$('#feed .item')) {
|
|
49
|
+
const r = card.getBoundingClientRect();
|
|
50
|
+
if (r.bottom > inset) return card === moving ? null : card;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const toTop = $('#toTop');
|
|
56
|
+
|
|
57
|
+
// Once the launcher scrolls under the top bar it docks there, folded to one line. The slot keeps
|
|
58
|
+
// its full height in the feed so docking never shifts what you're reading.
|
|
59
|
+
const launchSlot = $('#launchSlot');
|
|
60
|
+
const dockLauncher = () => {
|
|
61
|
+
const box = $('#launcher');
|
|
62
|
+
const gone = launchSlot.getBoundingClientRect().bottom < $('#topbar').offsetHeight;
|
|
63
|
+
if (gone === box.classList.contains('docked')) return;
|
|
64
|
+
launchSlot.style.height = gone ? `${box.offsetHeight}px` : '';
|
|
65
|
+
box.classList.toggle('docked', gone);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const backToTop = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
69
|
+
|
|
70
|
+
// Sticky top bar gets a hairline once the page scrolls; "back to top" shows after a screen's worth.
|
|
71
|
+
export function onScroll() {
|
|
72
|
+
$('#topbar').classList.toggle('scrolled', stage.scrollTop > 4);
|
|
73
|
+
dockLauncher();
|
|
74
|
+
toTop.hidden = stage.scrollTop < stage.clientHeight * 0.75;
|
|
75
|
+
}
|