wisp-agents 0.14.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.
- package/CHANGELOG.md +50 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -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-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- 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 +56 -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 +339 -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 +87 -36
- 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 +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- 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 +62 -0
- package/web/css/feed.css +208 -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 +16 -1
- package/web/js/app.js +28 -5232
- 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 +339 -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 +104 -0
- package/web/js/features/composer.js +311 -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 +559 -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/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 +161 -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 +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -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 -1901
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Projects and machines: the places a task can run. A project is a folder, on this computer or on a
|
|
2
|
+
// machine (another computer running its own wisp, reached over SSH).
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { icon } from '../core/dom.js';
|
|
6
|
+
import { S } from '../core/state.js';
|
|
7
|
+
|
|
8
|
+
export const projectById = (id) => S.config.projects.find((p) => p.id === id);
|
|
9
|
+
|
|
10
|
+
export const placeOf = (p) => p?.machine || '';
|
|
11
|
+
export const placeName = (k) => (k ? machineById(k)?.name || 'machine' : 'This computer');
|
|
12
|
+
export const recentFirst = (a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt);
|
|
13
|
+
export const projectsAt = (k) => S.config.projects.filter((p) => placeOf(p) === k).sort(recentFirst);
|
|
14
|
+
|
|
15
|
+
// A machine runs its own wisp; this one signs in over SSH, tunnels to it and shows its sessions
|
|
16
|
+
// as <id>@<machine>. Projects can live on a machine; launching in one runs the agent there.
|
|
17
|
+
export const machineById = (id) => S.machines.find((m) => m.id === id);
|
|
18
|
+
const machineOnline = (id) => machineById(id)?.state === 'online';
|
|
19
|
+
export const MACHINE_STATE = { online: 'Online', connecting: 'Connecting…', reconnecting: 'Reconnecting…', error: 'Couldn’t connect', off: 'Not connected' };
|
|
20
|
+
export const projectTitle = (p) => {
|
|
21
|
+
if (!p) return '';
|
|
22
|
+
const m = p.machine && machineById(p.machine);
|
|
23
|
+
return m ? `${p.path} on ${m.name}${m.state === 'online' ? '' : ' (not connected)'}` : p.path;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function machinePill(mt) {
|
|
27
|
+
const on = machineOnline(mt.id);
|
|
28
|
+
return `<span class="q-machine ${on ? '' : 'off'}" title="Runs on ${esc(mt.name)} (${esc(mt.target)})${on ? '' : ' · not connected'}">${icon('server')}<span>${esc(mt.name)}</span></span>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function machinesSummary() {
|
|
32
|
+
const n = S.machines.length;
|
|
33
|
+
if (!n) return 'None yet · run agents on another computer over SSH';
|
|
34
|
+
const on = S.machines.filter((m) => m.state === 'online').length;
|
|
35
|
+
return `${n} machine${n === 1 ? '' : 's'} · ${on ? `${on} online` : 'none online'}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const projectName = (p) => S.config.projects.find((x) => x.path === p)?.name || String(p || '').split(/[\\/]/).pop();
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Projects: the filter chips in the top bar and the dialog that adds, renames and removes them.
|
|
2
|
+
|
|
3
|
+
import { esc } from '../md.js';
|
|
4
|
+
import { api } from '../core/api.js';
|
|
5
|
+
import * as bus from '../core/bus.js';
|
|
6
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
7
|
+
import { launch, S, save, saveLaunch } from '../core/state.js';
|
|
8
|
+
import { present } from '../ui/overlay.js';
|
|
9
|
+
import { popover } from '../ui/popover.js';
|
|
10
|
+
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
11
|
+
import { feedToTop, renderFeed } from './feed.js';
|
|
12
|
+
import { renderLaunchChips } from './launcher.js';
|
|
13
|
+
import { loadConfig } from './live.js';
|
|
14
|
+
import { connectMachine, openMachines } from './machines.js';
|
|
15
|
+
import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle } from './places.js';
|
|
16
|
+
|
|
17
|
+
export function renderProjects() {
|
|
18
|
+
const all = [...S.sessions.values()];
|
|
19
|
+
const stats = (pid) => {
|
|
20
|
+
const l = pid === 'all' ? all : all.filter((s) => s.projectId === pid);
|
|
21
|
+
return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
|
|
22
|
+
};
|
|
23
|
+
const chip = (id, name, color) => {
|
|
24
|
+
const st = stats(id);
|
|
25
|
+
return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
|
|
26
|
+
${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
|
|
27
|
+
</button>`;
|
|
28
|
+
};
|
|
29
|
+
$('#projects').innerHTML = `${chip('all', 'All', 'slate')}${S.config.projects
|
|
30
|
+
.slice()
|
|
31
|
+
.sort((a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt))
|
|
32
|
+
.map((p) => chip(p.id, p.name, p.color))
|
|
33
|
+
.join('')}<button class="p-chip add" id="manageProjects" title="Manage projects">${icon('plus')}</button>`;
|
|
34
|
+
$$('.p-chip[data-id]').forEach((b) =>
|
|
35
|
+
b.addEventListener('click', () => {
|
|
36
|
+
S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
|
|
37
|
+
save('wisp.filter', S.filter);
|
|
38
|
+
renderFeed();
|
|
39
|
+
feedToTop();
|
|
40
|
+
renderLaunchChips();
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
$$('.p-chip[data-id]:not([data-id="all"])').forEach((b) =>
|
|
44
|
+
b.addEventListener('contextmenu', (e) => {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
projectMenu(b.dataset.id, e);
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
$('#manageProjects').onclick = () => openProjects();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function projectMenu(id, e) {
|
|
53
|
+
const p = projectById(id);
|
|
54
|
+
if (!p) return;
|
|
55
|
+
$('.menu.project')?.close();
|
|
56
|
+
const menu = el(`
|
|
57
|
+
<div class="menu project" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
58
|
+
<button data-a="manage">${icon('pen')}Manage projects…</button>
|
|
59
|
+
<button data-a="delete" class="danger">${icon('trash')}Remove project</button>
|
|
60
|
+
</div>`);
|
|
61
|
+
menu.onclick = async (ev) => {
|
|
62
|
+
const a = ev.target.closest('button')?.dataset.a;
|
|
63
|
+
close();
|
|
64
|
+
if (a === 'manage') openProjects();
|
|
65
|
+
if (a === 'delete' && confirm(`Remove “${p.name}” from wisp? Sessions and files are kept.`)) {
|
|
66
|
+
try {
|
|
67
|
+
await api(`/projects/${id}`, { method: 'DELETE' });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return toastError(err);
|
|
70
|
+
}
|
|
71
|
+
if (S.filter === id) {
|
|
72
|
+
S.filter = 'all';
|
|
73
|
+
save('wisp.filter', S.filter);
|
|
74
|
+
}
|
|
75
|
+
await loadConfig();
|
|
76
|
+
renderFeed();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const close = popover(menu);
|
|
80
|
+
// Keep the menu on-screen when right-clicking near the window edge.
|
|
81
|
+
const r = menu.getBoundingClientRect();
|
|
82
|
+
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
83
|
+
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
|
|
87
|
+
|
|
88
|
+
// where: a machine id to add folders on that computer (server/machines.js), else this one.
|
|
89
|
+
export async function openProjects({ focusAdd = false, machine = null, host } = {}) {
|
|
90
|
+
const node = el(`
|
|
91
|
+
<div class="modal projects-modal">
|
|
92
|
+
<header><h2>Projects</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
93
|
+
<div class="mem-tabs where"></div>
|
|
94
|
+
<div class="add-row">
|
|
95
|
+
<div class="field lg mono path-input">
|
|
96
|
+
${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
|
|
97
|
+
<input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
|
|
98
|
+
<div class="suggest" hidden></div>
|
|
99
|
+
</div>
|
|
100
|
+
${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
|
|
101
|
+
<button class="primary" id="padd">Add</button>
|
|
102
|
+
</div>
|
|
103
|
+
<p class="hint"></p>
|
|
104
|
+
<div class="plist" id="plist"></div>
|
|
105
|
+
<div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
|
|
106
|
+
</div>`);
|
|
107
|
+
present(node, host);
|
|
108
|
+
const input = $('#ppath', node);
|
|
109
|
+
const sug = $('.suggest', node);
|
|
110
|
+
let where = machine && machineById(machine) ? machine : null;
|
|
111
|
+
const here = () => machineById(where);
|
|
112
|
+
const home = () => (where ? here()?.remote?.home : S.config.home) || '';
|
|
113
|
+
const tilde = (p) => (home() && p.startsWith(home()) ? `~${p.slice(home().length)}` : p);
|
|
114
|
+
|
|
115
|
+
const paintWhere = () => {
|
|
116
|
+
const tabs = $('.where', node);
|
|
117
|
+
tabs.innerHTML = `<button data-w="" class="${where ? '' : 'on'}">${icon('folder')}This computer</button>${S.machines
|
|
118
|
+
.map((m) => `<button data-w="${m.id}" class="${where === m.id ? 'on' : ''}" title="${esc(m.target)} · ${esc(MACHINE_STATE[m.state] || m.state)}">${icon('server')}${esc(m.name)}<i class="dot ${m.state === 'online' ? 'ok' : m.state === 'error' ? 'bad' : ''}"></i></button>`)
|
|
119
|
+
.join('')}<i></i>${host ? '' : `<button data-w="+" title="Run agents on another computer over SSH">${icon('plus')}Machine</button>`}`;
|
|
120
|
+
const m = here();
|
|
121
|
+
input.placeholder = m ? `~/code/my-app on ${m.name}` : '~/code/my-app';
|
|
122
|
+
$$('#pbrowse, .browse', node).forEach((b) => (b.hidden = !!m));
|
|
123
|
+
$('.hint', node).innerHTML = m
|
|
124
|
+
? `Folders on <b>${esc(m.name)}</b>. Agents run there, in its own worktrees; you follow along here.`
|
|
125
|
+
: 'Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.';
|
|
126
|
+
};
|
|
127
|
+
$('.where', node).addEventListener('click', (e) => {
|
|
128
|
+
const b = e.target.closest('[data-w]');
|
|
129
|
+
if (!b) return;
|
|
130
|
+
if (b.dataset.w === '+') {
|
|
131
|
+
openMachines({ focusAdd: true });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
where = b.dataset.w || null;
|
|
135
|
+
sug.hidden = true;
|
|
136
|
+
paintWhere();
|
|
137
|
+
list();
|
|
138
|
+
discover();
|
|
139
|
+
input.focus();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const add = async (p) => {
|
|
143
|
+
try {
|
|
144
|
+
const proj = await api('/projects', { method: 'POST', body: { path: p, ...(where && { machine: where }) } });
|
|
145
|
+
await loadConfig();
|
|
146
|
+
launch.picks[placeOf(proj)] = proj.id;
|
|
147
|
+
if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
|
|
148
|
+
if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
|
|
149
|
+
saveLaunch();
|
|
150
|
+
renderLaunchChips();
|
|
151
|
+
toast(`Added <b>${esc(proj.name)}</b>${where ? ` on ${esc(here()?.name || '')}` : ''}`);
|
|
152
|
+
input.value = '';
|
|
153
|
+
sug.hidden = true;
|
|
154
|
+
list();
|
|
155
|
+
discover();
|
|
156
|
+
} catch (err) {
|
|
157
|
+
toastError(err);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
$('#padd', node).onclick = () => {
|
|
161
|
+
if (input.value.trim()) add(input.value.trim());
|
|
162
|
+
};
|
|
163
|
+
const browse = async () => {
|
|
164
|
+
const dir = await window.wispDesktop.pickFolder();
|
|
165
|
+
if (dir) add(dir);
|
|
166
|
+
};
|
|
167
|
+
$$('#pbrowse, .browse', node).forEach((b) => (b.onclick = browse));
|
|
168
|
+
|
|
169
|
+
let sel = -1;
|
|
170
|
+
let t;
|
|
171
|
+
input.addEventListener('input', () => {
|
|
172
|
+
clearTimeout(t);
|
|
173
|
+
t = setTimeout(async () => {
|
|
174
|
+
const q = input.value;
|
|
175
|
+
if (!q) return (sug.hidden = true);
|
|
176
|
+
const dirs = await api(`${where ? `/machines/${where}/fs` : '/fs'}?q=${encodeURIComponent(q)}`).catch(() => []);
|
|
177
|
+
sel = -1;
|
|
178
|
+
sug.hidden = !dirs.length;
|
|
179
|
+
sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(tilde(d))}</button>`).join('');
|
|
180
|
+
}, 120);
|
|
181
|
+
});
|
|
182
|
+
input.addEventListener('keydown', (e) => {
|
|
183
|
+
const opts = $$('button', sug);
|
|
184
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + opts.length) % opts.length;
|
|
187
|
+
opts.forEach((o, i) => o.classList.toggle('on', i === sel));
|
|
188
|
+
} else if (e.key === 'Tab' && opts.length) {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
input.value = tilde(opts[Math.max(0, sel)].dataset.p + '/');
|
|
191
|
+
input.dispatchEvent(new Event('input'));
|
|
192
|
+
} else if (e.key === 'Enter') {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
add(sel >= 0 ? opts[sel].dataset.p : input.value.trim());
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
sug.addEventListener('click', (e) => {
|
|
198
|
+
const b = e.target.closest('button');
|
|
199
|
+
if (b) add(b.dataset.p);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const list = () => {
|
|
203
|
+
const box = $('#plist', node);
|
|
204
|
+
const projects = S.config.projects.filter((p) => (p.machine || null) === where);
|
|
205
|
+
box.innerHTML = projects.length
|
|
206
|
+
? projects
|
|
207
|
+
.map((p) => {
|
|
208
|
+
const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
|
|
209
|
+
return `<div class="prow c-${p.color}" data-id="${p.id}">
|
|
210
|
+
<button class="swatch" title="Change colour"><i class="p-dot"></i></button>
|
|
211
|
+
<div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
|
|
212
|
+
<span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
|
|
213
|
+
<button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
|
|
214
|
+
</div>`;
|
|
215
|
+
})
|
|
216
|
+
.join('')
|
|
217
|
+
: `<p class="muted">No projects ${where ? `on ${esc(here()?.name || '')} ` : ''}yet. Add a folder above, or pick one below.</p>`;
|
|
218
|
+
$$('.prow', box).forEach((row) => {
|
|
219
|
+
const id = row.dataset.id;
|
|
220
|
+
const p = projectById(id);
|
|
221
|
+
$('.pname', row).onchange = (e) => {
|
|
222
|
+
tryApi(async () => {
|
|
223
|
+
await api(`/projects/${id}`, { method: 'PATCH', body: { name: e.target.value } });
|
|
224
|
+
await loadConfig();
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
$('.swatch', row).onclick = () => {
|
|
228
|
+
const color = COLORS[(COLORS.indexOf(p.color) + 1) % COLORS.length];
|
|
229
|
+
tryApi(async () => {
|
|
230
|
+
await api(`/projects/${id}`, { method: 'PATCH', body: { color } });
|
|
231
|
+
await loadConfig();
|
|
232
|
+
list();
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
$('.premove', row).onclick = async () => {
|
|
236
|
+
if (!confirm(`Remove “${p.name}” from wisp? Sessions and files are kept.`)) return;
|
|
237
|
+
await tryApi(async () => {
|
|
238
|
+
await api(`/projects/${id}`, { method: 'DELETE' });
|
|
239
|
+
await loadConfig();
|
|
240
|
+
list();
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const discover = async () => {
|
|
247
|
+
const box = $('#discover', node);
|
|
248
|
+
const m = here();
|
|
249
|
+
const at = where;
|
|
250
|
+
box.onclick = (e) => {
|
|
251
|
+
const b = e.target.closest('.found-item');
|
|
252
|
+
if (b) add(b.dataset.p);
|
|
253
|
+
if (e.target.closest('[data-a="connect"]')) connectMachine(at);
|
|
254
|
+
};
|
|
255
|
+
if (m && m.state !== 'online') {
|
|
256
|
+
box.innerHTML = `<p class="muted">${esc(m.name)} is ${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : `offline. <button class="link" data-a="connect">Connect</button>`}</p>`;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
box.innerHTML = `<h4>Found on ${m ? esc(m.name) : 'this machine'}</h4><p class="muted">Looking for git repos…</p>`;
|
|
260
|
+
const found = await api(m ? `/machines/${m.id}/discover` : '/projects/discover').catch(() => []);
|
|
261
|
+
if (where !== at) return; // switched tabs meanwhile
|
|
262
|
+
box.innerHTML = found.length
|
|
263
|
+
? `<h4>Found on ${m ? esc(m.name) : 'this machine'} <span class="muted">· git repos</span></h4><div class="found">${found
|
|
264
|
+
.map((f) => `<button class="found-item" data-p="${esc(f.path)}">${icon('plus')}<b>${esc(f.name)}</b><span>${esc(tilde(f.path).replace(/\/[^/]+$/, ''))}</span></button>`)
|
|
265
|
+
.join('')}</div>`
|
|
266
|
+
: '';
|
|
267
|
+
};
|
|
268
|
+
let wasOnline = here()?.state === 'online';
|
|
269
|
+
bus.on(
|
|
270
|
+
'machines',
|
|
271
|
+
() => {
|
|
272
|
+
paintWhere();
|
|
273
|
+
const online = here()?.state === 'online';
|
|
274
|
+
if (online !== wasOnline) discover();
|
|
275
|
+
wasOnline = online;
|
|
276
|
+
},
|
|
277
|
+
{ node },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
paintWhere();
|
|
281
|
+
list();
|
|
282
|
+
discover();
|
|
283
|
+
if (focusAdd) setTimeout(() => input.focus(), 60);
|
|
284
|
+
}
|
|
@@ -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
|
+
}
|