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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The launcher: the box on top where tasks start. It picks where they run (this computer and/or
|
|
2
|
-
// machines,
|
|
2
|
+
// machines), in which project (any of them runs anywhere), on what, and shows a placeholder card
|
|
3
|
+
// until the server answers.
|
|
3
4
|
|
|
4
5
|
import { esc } from '../md.js';
|
|
5
6
|
import { plain } from '../mention-text.js';
|
|
@@ -18,7 +19,7 @@ import { feedbackCommand, isFeedback } from './feedback.js';
|
|
|
18
19
|
import { connectMachine, offlineToast, openMachines } from './machines.js';
|
|
19
20
|
import { mentionBox } from './mentions.js';
|
|
20
21
|
import { askNotify } from './notify.js';
|
|
21
|
-
import { MACHINE_STATE, machineById, placeName, placeOf, projectById,
|
|
22
|
+
import { MACHINE_STATE, machineById, pathAt, placeName, placeOf, placesOf, projectById, projects, projectTitle } from './places.js';
|
|
22
23
|
import { projectList } from './project-list.js';
|
|
23
24
|
import { openProjects } from './projects.js';
|
|
24
25
|
import { runnerPicker } from './runner-picker.js';
|
|
@@ -30,7 +31,17 @@ import { canLead, paintTeamBtn, teamEditor } from './team.js';
|
|
|
30
31
|
// "No folder": a general ask with no project. The server gives it an empty scratch directory.
|
|
31
32
|
const NO_FOLDER = 'none';
|
|
32
33
|
const GENERAL = { id: null, name: 'General', path: '', color: 'slate', scratch: true };
|
|
33
|
-
|
|
34
|
+
// One pick for every place the task runs on (launch.picks[''], see core/state.js): the project,
|
|
35
|
+
// or No folder, or a repo still cloning; and the task's other folders (launch.also['']).
|
|
36
|
+
// Nothing to pick (no projects yet) runs in No folder; the stored pick stays null, so the first
|
|
37
|
+
// project to show up takes over (syncWhere).
|
|
38
|
+
const storedPick = () => launch.picks[''];
|
|
39
|
+
const pickId = () => storedPick() || NO_FOLDER;
|
|
40
|
+
const projectOf = () => (pickId() === NO_FOLDER ? GENERAL : projectById(pickId()));
|
|
41
|
+
const alsoIds = () => launch.also[''] || [];
|
|
42
|
+
const chosen = (id) => id === pickId() || alsoIds().includes(id);
|
|
43
|
+
const ownPick = () => !!projectById(pickId());
|
|
44
|
+
const MOD = combo('').replace(/\+$/, ''); // ⌘ or Ctrl, for "⌘-click"
|
|
34
45
|
// Project chips in the "in" row, at most, before the rest fold into "+N".
|
|
35
46
|
const CHIPS = 5;
|
|
36
47
|
|
|
@@ -40,15 +51,25 @@ let filterSeen = null;
|
|
|
40
51
|
function syncWhere() {
|
|
41
52
|
launch.where = launch.where.filter((k, i) => (!k || machineById(k)) && launch.where.indexOf(k) === i);
|
|
42
53
|
if (!launch.where.length) launch.where = [''];
|
|
43
|
-
//
|
|
54
|
+
// Picks saved per place, from before every project ran everywhere: the first place's carries on.
|
|
55
|
+
if (!('' in launch.picks) && launch.picks[launch.where[0]]) launch.picks[''] = launch.picks[launch.where[0]];
|
|
56
|
+
if (!launch.also[''] && launch.also[launch.where[0]]) launch.also[''] = launch.also[launch.where[0]];
|
|
57
|
+
for (const k of Object.keys(launch.picks)) if (k) delete launch.picks[k];
|
|
58
|
+
for (const k of Object.keys(launch.also)) if (k) delete launch.also[k];
|
|
59
|
+
// Filtering the feed to a project points the launcher at it. One that's already in the task
|
|
60
|
+
// (as its first folder or another) leaves the selection as it is.
|
|
44
61
|
const f = S.filter !== 'all' && S.filter !== filterSeen && projectById(S.filter);
|
|
45
62
|
filterSeen = S.filter;
|
|
46
|
-
if (f) {
|
|
47
|
-
|
|
48
|
-
launch.
|
|
49
|
-
} else if (
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
if (f && !chosen(f.id)) {
|
|
64
|
+
launch.picks[''] = f.id;
|
|
65
|
+
delete launch.also[''];
|
|
66
|
+
} else if (!storedPick() && projectById(launch.project)) launch.picks[''] = launch.project;
|
|
67
|
+
if (storedPick() !== NO_FOLDER && !cloneById(storedPick()) && !projectById(storedPick())) launch.picks[''] = projects()[0]?.id || null;
|
|
68
|
+
// Other folders only go with a project (not No folder or a clone).
|
|
69
|
+
const ids = ownPick() ? alsoIds().filter((id, i, a) => id !== pickId() && a.indexOf(id) === i && projectById(id)) : [];
|
|
70
|
+
if (ids.length) launch.also[''] = ids;
|
|
71
|
+
else delete launch.also[''];
|
|
72
|
+
launch.project = storedPick() || null;
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
export function renderLauncher() {
|
|
@@ -249,10 +270,10 @@ export function renderLauncher() {
|
|
|
249
270
|
ta.dispatchEvent(new Event('input'));
|
|
250
271
|
return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
|
|
251
272
|
}
|
|
252
|
-
// One run per place picked under "Run on",
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
273
|
+
// One run per place picked under "Run on", all in the picked project: a place that has no
|
|
274
|
+
// checkout of it yet gets one first (the server clones it there).
|
|
275
|
+
const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(), clone: cloneById(pickId()), also: ownPick() ? alsoIds().map(projectById).filter(Boolean) : [] }));
|
|
276
|
+
if (!targets[0].project && !targets[0].clone) return openProjects({ focusAdd: true });
|
|
256
277
|
const off = targets.find((t) => t.m && t.m.state !== 'online');
|
|
257
278
|
if (off) return offlineToast(off.k);
|
|
258
279
|
if (needsSetup()) return nudgeSetup();
|
|
@@ -271,12 +292,13 @@ export function renderLauncher() {
|
|
|
271
292
|
const cmp = compare;
|
|
272
293
|
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
273
294
|
// A repo still cloning gets a stand-in project; its card says so until the clone lands.
|
|
274
|
-
const
|
|
295
|
+
const fetching = (t) => t.k && [t.project, ...t.also].some((p) => p && !p.scratch && !pathAt(p, t.k)); // not on that machine yet
|
|
296
|
+
const pending = targets.map((t) => addPending(prompt, t.project ? t : { ...t, project: { id: t.clone.id, name: t.clone.name, path: '', color: 'slate' } }, runner, t.clone ? `Cloning ${t.clone.label}` : fetching(t) ? `Cloning ${t.project.name} on ${placeName(t.k)}` : undefined));
|
|
275
297
|
if (cmp.length) (compare = []), paintCompare();
|
|
276
298
|
ta.value = '';
|
|
277
299
|
S.drafts.delete('home');
|
|
278
300
|
ta.dispatchEvent(new Event('input'));
|
|
279
|
-
if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || t.project?.id !== S.filter)) {
|
|
301
|
+
if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || (t.project?.id !== S.filter && !t.also.some((p) => p.id === S.filter)))) {
|
|
280
302
|
S.filter = 'all';
|
|
281
303
|
save('wisp.filter', S.filter);
|
|
282
304
|
}
|
|
@@ -298,7 +320,7 @@ export function renderLauncher() {
|
|
|
298
320
|
targets.map((t) =>
|
|
299
321
|
api('/sessions', {
|
|
300
322
|
method: 'POST',
|
|
301
|
-
body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, scratch: t.project.scratch || undefined, machine: t.k
|
|
323
|
+
body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, also: t.also.length ? t.also.map((p) => p.path) : undefined, scratch: t.project.scratch || undefined, machine: t.k, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
302
324
|
}),
|
|
303
325
|
),
|
|
304
326
|
);
|
|
@@ -352,46 +374,56 @@ export function renderLauncher() {
|
|
|
352
374
|
});
|
|
353
375
|
}
|
|
354
376
|
|
|
355
|
-
// No folder
|
|
377
|
+
// No folder: no git repo, so no worktree either; the toggle would only mislead.
|
|
356
378
|
function paintScratch() {
|
|
357
379
|
const wt = $('#launcher #wtBtn');
|
|
358
|
-
if (wt) wt.hidden =
|
|
380
|
+
if (wt) wt.hidden = pickId() === NO_FOLDER;
|
|
359
381
|
}
|
|
360
382
|
|
|
361
383
|
export function renderLaunchChips() {
|
|
362
384
|
const box = $('#launcher .l-projects');
|
|
363
385
|
if (!box) return;
|
|
364
386
|
syncWhere();
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
? `<
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
387
|
+
const list = projects();
|
|
388
|
+
const pick = pickId();
|
|
389
|
+
// A machine picked under "Run on" but not connected: it says so, with a way to connect.
|
|
390
|
+
const offline = launch.where
|
|
391
|
+
.map(machineById)
|
|
392
|
+
.filter((m) => m && m.state !== 'online')
|
|
393
|
+
.map((m) => `<span class="lp-gl off" title="${esc(`${m.target} · ${MACHINE_STATE[m.state] || ''}`)}">${icon('server')}${esc(m.name)}<button class="link xs" data-connect="${m.id}">${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : 'Connect'}</button></span>`)
|
|
394
|
+
.join('');
|
|
395
|
+
// A repo still cloning: picked, with a spinner, until the real project replaces it.
|
|
396
|
+
const cloning = clones
|
|
397
|
+
.map((c) => `<button class="lp-chip cloning ${c.id === pick ? 'on' : ''}" data-id="${c.id}" title="Cloning ${esc(c.label)}${c.where ? ` on ${esc(placeName(c.where))}` : ''}…"><span class="orb live"></span>${esc(c.name)}<span class="lp-note">cloning…</span></button>`)
|
|
398
|
+
.join('');
|
|
399
|
+
// Tens of projects: the most recent few as chips (the picks always among them), the rest one
|
|
400
|
+
// search away behind "+N". fitChips() then folds more into it if the row would wrap.
|
|
401
|
+
const picked = list.filter((p) => chosen(p.id));
|
|
402
|
+
const rest = list.filter((p) => !chosen(p.id)).slice(0, Math.max(0, CHIPS - picked.length));
|
|
403
|
+
const shown = list.filter((p) => picked.includes(p) || rest.includes(p));
|
|
404
|
+
// A task can span several projects: ⌘-click a chip, or its + badge, to add it; the × on a
|
|
405
|
+
// picked one leaves it out again.
|
|
406
|
+
const own = ownPick();
|
|
407
|
+
const several = own && alsoIds().length > 0;
|
|
408
|
+
const badge = (p) =>
|
|
409
|
+
chosen(p.id)
|
|
410
|
+
? several ? `<span class="lp-also x" data-also="${p.id}" title="Leave ${esc(p.name)} out of this task">${icon('x')}</span>` : ''
|
|
411
|
+
: own ? `<span class="lp-also" data-also="${p.id}" title="Also work in ${esc(p.name)} (${MOD}-click)">${icon('plus')}</span>` : '';
|
|
412
|
+
// Machines picked that have no checkout of it yet: the first run there clones it.
|
|
413
|
+
const missing = (p) => launch.where.filter((k) => k && !pathAt(p, k)).map(placeName);
|
|
414
|
+
const tip = (p) => {
|
|
415
|
+
const m = missing(p);
|
|
416
|
+
return `${projectTitle(p)}${m.length ? `\nwisp clones it on ${m.join(', ')} for this task` : ''}${several && p.id === pick ? '\nThe agent starts here' : ''}${own && !chosen(p.id) ? `\n${MOD}-click to work in it too` : ''}`;
|
|
393
417
|
};
|
|
394
|
-
|
|
418
|
+
const chips = cloning + shown.map((p) => `<button class="lp-chip c-${p.color} ${chosen(p.id) ? 'on' : ''}" data-id="${p.id}" title="${esc(tip(p))}"><i class="p-dot"></i>${esc(p.name)}${badge(p)}</button>`).join('');
|
|
419
|
+
const more = list.length > 1 ? `<button class="lp-chip more" data-more="1" data-total="${list.length}" title="All ${list.length} projects" aria-haspopup="dialog"><span class="lp-more-n"></span>${icon('search')}</button>` : '';
|
|
420
|
+
// Another folder goes on the one machine picked, if that's all it runs on.
|
|
421
|
+
const addOn = launch.where.length === 1 ? launch.where[0] : '';
|
|
422
|
+
const add = list.length || cloning
|
|
423
|
+
? `<button class="lp-chip add" data-add="${addOn}" title="Another folder${addOn ? ` on ${esc(placeName(addOn))}` : ''}">${icon('folder')}${list.length > CHIPS ? '' : 'Other folder…'}</button>`
|
|
424
|
+
: `<button class="lp-chip add" data-add="${addOn}">${icon('folder')}${addOn ? `Pick a project on ${esc(placeName(addOn))}…` : 'Choose a folder…'}</button>`;
|
|
425
|
+
const none = `<button class="lp-chip none ${pick === NO_FOLDER ? 'on' : ''}" data-id="${NO_FOLDER}" title="No folder: a general ask, with no project and no worktree. The agent gets an empty scratch directory.">${icon('spark')}<span>No folder</span></button>`;
|
|
426
|
+
box.innerHTML = `<span class="l-in">in</span><span class="lp-group">${chips}${more}${none}${add}${offline}</span>`;
|
|
395
427
|
fitChips(box);
|
|
396
428
|
fitRow ??= new ResizeObserver(([e]) => {
|
|
397
429
|
if (e.contentRect.width === fitRow.w) return; // its own folding only changes the height
|
|
@@ -400,9 +432,21 @@ export function renderLaunchChips() {
|
|
|
400
432
|
});
|
|
401
433
|
fitRow.observe(box);
|
|
402
434
|
paintScratch();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
435
|
+
// A plain pick makes it the task's only folder; `add` (⌘-click, the + badge) adds it to the
|
|
436
|
+
// folders or takes it out. Leaving out the first folder hands its place to the next one.
|
|
437
|
+
const choose = (id, { add = false } = {}) => {
|
|
438
|
+
const also = alsoIds();
|
|
439
|
+
if (add && ownPick() && projectById(id)) {
|
|
440
|
+
if (id !== pickId()) launch.also[''] = also.includes(id) ? also.filter((x) => x !== id) : [...also, id];
|
|
441
|
+
else if (also.length) {
|
|
442
|
+
launch.picks[''] = also[0];
|
|
443
|
+
launch.also[''] = also.slice(1);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
launch.picks[''] = id;
|
|
447
|
+
delete launch.also[''];
|
|
448
|
+
}
|
|
449
|
+
launch.project = storedPick();
|
|
406
450
|
saveLaunch();
|
|
407
451
|
paintScratch();
|
|
408
452
|
renderLaunchRecipes();
|
|
@@ -410,24 +454,31 @@ export function renderLaunchChips() {
|
|
|
410
454
|
box.onclick = (e) => {
|
|
411
455
|
const chip = e.target.closest('.lp-chip[data-id]');
|
|
412
456
|
if (chip) {
|
|
413
|
-
|
|
414
|
-
|
|
457
|
+
choose(chip.dataset.id, { add: !!e.target.closest('[data-also]') || e.metaKey || e.ctrlKey || e.shiftKey });
|
|
458
|
+
renderLaunchChips(); // the + and × badges follow the picks
|
|
415
459
|
return;
|
|
416
460
|
}
|
|
417
461
|
const more = e.target.closest('[data-more]');
|
|
418
462
|
if (more) {
|
|
419
|
-
const k = more.dataset.more;
|
|
420
463
|
return projectList(more, {
|
|
421
|
-
projects:
|
|
422
|
-
current:
|
|
423
|
-
|
|
464
|
+
projects: projects(),
|
|
465
|
+
current: pickId(),
|
|
466
|
+
// Each row's + adds it to the task (or takes it out) and leaves the list open.
|
|
467
|
+
also: ownPick() && {
|
|
468
|
+
has: (id) => chosen(id),
|
|
469
|
+
toggle: (id) => {
|
|
470
|
+
choose(id, { add: true });
|
|
471
|
+
renderLaunchChips();
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
label: 'Projects',
|
|
424
475
|
extras: [
|
|
425
476
|
{ id: NO_FOLDER, label: 'No folder', icon: 'spark' },
|
|
426
|
-
{ id: '+add', label:
|
|
477
|
+
{ id: '+add', label: addOn ? `Another folder on ${placeName(addOn)}…` : 'Other folder…', icon: 'folder' },
|
|
427
478
|
],
|
|
428
479
|
onPick: (id) => {
|
|
429
|
-
if (id === '+add') return openProjects({ focusAdd: true, machine:
|
|
430
|
-
|
|
480
|
+
if (id === '+add') return openProjects({ focusAdd: true, machine: addOn || null });
|
|
481
|
+
choose(id);
|
|
431
482
|
renderLaunchChips(); // the pick joins the chips
|
|
432
483
|
$('#prompt')?.focus();
|
|
433
484
|
},
|
|
@@ -457,9 +508,9 @@ function fitChips(box) {
|
|
|
457
508
|
$('.lp-more-n', more).textContent = `+${n}`;
|
|
458
509
|
};
|
|
459
510
|
paint();
|
|
460
|
-
if (launch.where.length > 1) continue;
|
|
461
511
|
const wraps = () => {
|
|
462
512
|
const kids = [...g.children].filter((x) => !x.hidden && x.offsetParent);
|
|
513
|
+
if (!kids.length) return false; // not laid out (a launcher painted over, or folded into the dock)
|
|
463
514
|
const lin = $('.l-in', box); // "in" left alone on a line counts too
|
|
464
515
|
return kids.some((x) => x.offsetTop >= kids[0].offsetTop + kids[0].offsetHeight) || (lin && kids[0].offsetTop >= lin.offsetTop + lin.offsetHeight);
|
|
465
516
|
};
|
|
@@ -500,12 +551,12 @@ function whereMenu(anchor) {
|
|
|
500
551
|
return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemcheckbox" aria-checked="${on}">
|
|
501
552
|
<span class="wm-check">${icon('check')}</span>
|
|
502
553
|
<span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
|
|
503
|
-
<span class="wm-name"><b>${esc(placeName(k))}</b><small>${m ? esc(st === 'online' ? m.target : `${m.target} · ${MACHINE_STATE[st]}`) :
|
|
554
|
+
<span class="wm-name"><b>${esc(placeName(k))}</b><small>${m ? esc(st === 'online' ? m.target : `${m.target} · ${MACHINE_STATE[st]}`) : 'Where wisp runs'}</small></span>
|
|
504
555
|
${m && st === 'online' && ready.length ? `<span class="wm-agents">${ready.map((a) => `<span class="model-logo">${harnessLogo(a.id)}</span>`).join('')}</span>` : ''}
|
|
505
556
|
<span class="wm-only" data-only="${k}" title="Only here">only</span>
|
|
506
557
|
</button>`;
|
|
507
558
|
};
|
|
508
|
-
box.innerHTML = `<div class="wm-h">Run on <small>pick several to run in parallel</small></div>
|
|
559
|
+
box.innerHTML = `<div class="wm-h">Run on <small>pick several to run in parallel · any project runs anywhere</small></div>
|
|
509
560
|
${row('', null)}${S.machines.map((m) => row(m.id, m)).join('')}
|
|
510
561
|
<div class="wm-sep"></div>
|
|
511
562
|
<button data-a="add">${icon('plus')}${S.machines.length ? 'Add a machine…' : 'Add a machine to run on another computer…'}</button>
|
|
@@ -605,7 +656,7 @@ function launchedFx(from, to) {
|
|
|
605
656
|
// A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
|
|
606
657
|
// "Starting up" card. It can't be opened; the real card takes its place.
|
|
607
658
|
let pendingSeq = 0;
|
|
608
|
-
function addPending(prompt, { project: p, m }, { agent, model, effort }, live = 'Starting up') {
|
|
659
|
+
function addPending(prompt, { project: p, m, also = [] }, { agent, model, effort }, live = 'Starting up') {
|
|
609
660
|
const id = `pending-${++pendingSeq}`;
|
|
610
661
|
const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
|
|
611
662
|
const now = Date.now();
|
|
@@ -621,6 +672,7 @@ function addPending(prompt, { project: p, m }, { agent, model, effort }, live =
|
|
|
621
672
|
project: p.name,
|
|
622
673
|
projectId: p.id,
|
|
623
674
|
color: p.color || 'slate',
|
|
675
|
+
...(also.length && { also: also.map((x) => ({ cwd: x.path, project: x.name, projectId: x.id, color: x.color, branch: '', pr: null })) }),
|
|
624
676
|
state: 'running',
|
|
625
677
|
live,
|
|
626
678
|
createdAt: now,
|
|
@@ -632,10 +684,12 @@ function addPending(prompt, { project: p, m }, { agent, model, effort }, live =
|
|
|
632
684
|
|
|
633
685
|
// A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
|
|
634
686
|
// at once; the real session replaces it, or it disappears and the error is thrown on.
|
|
635
|
-
|
|
636
|
-
|
|
687
|
+
// machine: where it runs ('' = this computer), else where the folder is.
|
|
688
|
+
export async function launchBehind({ prompt, cwd, runner, machine }, request) {
|
|
689
|
+
const sum = S.config.projects.find((p) => placesOf(p).some((k) => pathAt(p, k) === cwd));
|
|
637
690
|
const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
|
|
638
|
-
const
|
|
691
|
+
const k = machine ?? placeOf(project);
|
|
692
|
+
const ghost = addPending(prompt, { project, m: k ? machineById(k) : null }, runner, k && sum && !pathAt(sum, k) ? `Cloning ${sum.name} on ${placeName(k)}` : undefined);
|
|
639
693
|
if (S.filter !== 'all' && S.filter !== project.id) {
|
|
640
694
|
S.filter = 'all';
|
|
641
695
|
save('wisp.filter', S.filter);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// "Runs on" for one task: pick this computer or a machine for a follow-up or a rerun. The
|
|
2
|
+
// launcher's own menu (several places at once) is whereMenu in launcher.js.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import * as bus from '../core/bus.js';
|
|
6
|
+
import { $, el, icon } from '../core/dom.js';
|
|
7
|
+
import { S } from '../core/state.js';
|
|
8
|
+
import { popover } from '../ui/popover.js';
|
|
9
|
+
import { MACHINE_STATE, machineById, placeName } from './places.js';
|
|
10
|
+
|
|
11
|
+
// One place for a follow-up or a rerun: this computer or a machine. from: where the task is now.
|
|
12
|
+
// locked: why it can't move right now (a running task), shown instead of a choice.
|
|
13
|
+
export function placeMenu(anchor, { title, current, from, note = '', locked = '' }, pick) {
|
|
14
|
+
if ($('.menu.place-menu')) return $('.menu.place-menu').close();
|
|
15
|
+
const box = el(`<div class="menu where-menu place-menu" role="menu"></div>`);
|
|
16
|
+
const paint = () => {
|
|
17
|
+
const row = (k) => {
|
|
18
|
+
const m = k ? machineById(k) : null;
|
|
19
|
+
const st = m ? m.state : 'online';
|
|
20
|
+
const on = k === current;
|
|
21
|
+
const sub = k === from ? 'Where it is now' : m ? (st === 'online' ? m.target : `${m.target} · ${MACHINE_STATE[st]}`) : 'Where wisp runs';
|
|
22
|
+
return `<button class="wm-row ${on ? 'on' : ''}" data-k="${k}" role="menuitemradio" aria-checked="${on}" ${locked && k !== from ? 'disabled' : ''}>
|
|
23
|
+
<span class="wm-check">${icon('check')}</span>
|
|
24
|
+
<span class="wm-icon">${icon(k ? 'server' : 'laptop')}${m ? `<i class="dot ${st === 'online' ? 'ok' : st === 'error' ? 'bad' : ''}"></i>` : ''}</span>
|
|
25
|
+
<span class="wm-name"><b>${esc(placeName(k))}</b><small>${esc(sub)}</small></span>
|
|
26
|
+
</button>`;
|
|
27
|
+
};
|
|
28
|
+
box.innerHTML = `<div class="wm-h">${esc(title)}</div>${locked || note ? `<p class="wm-note ${locked ? 'warn' : ''}">${esc(locked || note)}</p>` : ''}${row('')}${S.machines.map((m) => row(m.id)).join('')}`;
|
|
29
|
+
};
|
|
30
|
+
box.onclick = (e) => {
|
|
31
|
+
const r = e.target.closest('.wm-row:not([disabled])');
|
|
32
|
+
if (!r) return;
|
|
33
|
+
shut();
|
|
34
|
+
pick(r.dataset.k);
|
|
35
|
+
};
|
|
36
|
+
paint();
|
|
37
|
+
const shut = popover(box, { anchor, onClose: () => anchor.setAttribute('aria-expanded', 'false') });
|
|
38
|
+
bus.on('machines', paint, { node: box });
|
|
39
|
+
anchor.setAttribute('aria-expanded', 'true');
|
|
40
|
+
const r = anchor.getBoundingClientRect();
|
|
41
|
+
const h = box.offsetHeight;
|
|
42
|
+
box.style.left = `${Math.max(8, Math.min(r.left, innerWidth - box.offsetWidth - 8))}px`;
|
|
43
|
+
box.style.top = `${r.bottom + 6 + h > innerHeight - 8 ? Math.max(8, r.top - h - 6) : r.bottom + 6}px`;
|
|
44
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Projects and machines: the places a task can run. A project is
|
|
2
|
-
//
|
|
1
|
+
// Projects and machines: the places a task can run. A project is one repo, and runs on every place:
|
|
2
|
+
// it was added on one (path, machine), and wisp clones it onto the others the first time a task
|
|
3
|
+
// runs there (copies: { [machine id | 'here']: path }; server/services/places.js).
|
|
3
4
|
|
|
4
5
|
import { esc } from '../md.js';
|
|
5
6
|
import { icon } from '../core/dom.js';
|
|
@@ -8,19 +9,38 @@ import { S } from '../core/state.js';
|
|
|
8
9
|
export const projectById = (id) => S.config.projects.find((p) => p.id === id);
|
|
9
10
|
|
|
10
11
|
export const placeOf = (p) => p?.machine || '';
|
|
12
|
+
// A task's projects: its own, then those of the other folders it works in (also).
|
|
13
|
+
export const inProject = (s, id) => s.projectId === id || !!s.also?.some((a) => a.projectId === id);
|
|
14
|
+
|
|
15
|
+
// The project on a card: "api + web", or "api +2" past that, each one in the tooltip.
|
|
16
|
+
export function projectLabel(s) {
|
|
17
|
+
const also = s.also || [];
|
|
18
|
+
if (!also.length) return `<span class="q-proj">${esc(s.project)}</span>`;
|
|
19
|
+
const names = [s.project, ...also.map((a) => a.project)];
|
|
20
|
+
return `<span class="q-proj multi" title="${esc(`One task in ${names.length} folders: ${names.join(', ')}`)}">${esc(s.project)}<span class="q-also ${also.length === 1 ? `c-${also[0].color}` : ''}">${also.length === 1 ? `+ ${esc(also[0].project)}` : `+${also.length}`}</span></span>`;
|
|
21
|
+
}
|
|
11
22
|
export const placeName = (k) => (k ? machineById(k)?.name || 'machine' : 'This computer');
|
|
12
23
|
export const recentFirst = (a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt);
|
|
13
|
-
|
|
24
|
+
// Every project, on every place: one that isn't on a place yet gets cloned there on first use.
|
|
25
|
+
export const projects = () => [...S.config.projects].sort(recentFirst);
|
|
26
|
+
// The project's folder on place k, or '' when wisp still has to put it there.
|
|
27
|
+
export const pathAt = (p, k) => (placeOf(p) === k ? p.path : p?.copies?.[k || 'here'] || '');
|
|
28
|
+
// The places that have a checkout of it: where it was added first.
|
|
29
|
+
export const placesOf = (p) => (p ? [placeOf(p), ...Object.keys(p.copies || {}).map((k) => (k === 'here' ? '' : k))].filter((k, i, a) => a.indexOf(k) === i && (!k || machineById(k))) : []);
|
|
14
30
|
|
|
15
31
|
// A machine runs its own wisp; this one signs in over SSH, tunnels to it and shows its sessions
|
|
16
|
-
// as <id>@<machine>.
|
|
32
|
+
// as <id>@<machine>. Any project runs there; the launch clones it there first if it has to.
|
|
17
33
|
export const machineById = (id) => S.machines.find((m) => m.id === id);
|
|
18
34
|
const machineOnline = (id) => machineById(id)?.state === 'online';
|
|
19
35
|
export const MACHINE_STATE = { online: 'Online', connecting: 'Connecting…', reconnecting: 'Reconnecting…', error: 'Couldn’t connect', off: 'Not connected' };
|
|
36
|
+
// Where it is: "~/code/app", "on devbox: /srv/app", plus the other places that have it.
|
|
20
37
|
export const projectTitle = (p) => {
|
|
21
38
|
if (!p) return '';
|
|
39
|
+
const tilde = (x) => (S.config.home && x.startsWith(S.config.home) ? `~${x.slice(S.config.home.length)}` : x);
|
|
22
40
|
const m = p.machine && machineById(p.machine);
|
|
23
|
-
|
|
41
|
+
const first = m ? `${p.path} on ${m.name}${m.state === 'online' ? '' : ' (not connected)'}` : tilde(p.path);
|
|
42
|
+
const also = placesOf(p).slice(1).map(placeName);
|
|
43
|
+
return also.length ? `${first}\nAlso checked out on ${also.join(', ')}` : first;
|
|
24
44
|
};
|
|
25
45
|
|
|
26
46
|
export function machinePill(mt) {
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// meta(p) extra html after a row's name (status badges)
|
|
7
7
|
// extras [{ id, label, icon }] fixed rows under the list (No folder, Other folder…)
|
|
8
8
|
// onPick (id) => …, a project's id or an extra's
|
|
9
|
+
// also { has(id), toggle(id) }: each row gets a + that adds the project to the task (✓ once
|
|
10
|
+
// it's in) without closing the list; ⌘/Ctrl/Shift+Enter does the same from the keyboard
|
|
9
11
|
|
|
10
12
|
import { esc } from '../md.js';
|
|
11
13
|
import { $, el, icon } from '../core/dom.js';
|
|
@@ -13,7 +15,7 @@ import { fuzzy, highlight } from '../fuzzy.js';
|
|
|
13
15
|
import { popover } from '../ui/popover.js';
|
|
14
16
|
import { projectTitle } from './places.js';
|
|
15
17
|
|
|
16
|
-
export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, label = 'Projects' }) {
|
|
18
|
+
export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, also = null, label = 'Projects' }) {
|
|
17
19
|
if ($('.menu.pm')) return $('.menu.pm').close();
|
|
18
20
|
const node = el(`
|
|
19
21
|
<div class="menu pm" role="dialog" aria-label="${esc(label)}">
|
|
@@ -43,8 +45,10 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
|
|
|
43
45
|
list.innerHTML = hits.length
|
|
44
46
|
? hits
|
|
45
47
|
.map(
|
|
46
|
-
({ p, m }) => `<button class="pm-row c-${p.color} ${p.id === current ? 'on' : ''}" data-id="${esc(p.id)}" role="option" aria-selected="${p.id === current}" title="${esc(projectTitle(p))}">
|
|
47
|
-
<i class="p-dot"></i><span class="pm-name">${highlight(p.name, m?.idx, esc)}</span>${p.machine ? icon('server', 'pm-where') : ''}${meta(p)}<small class="pm-path">${esc(p.path.replace(/^\/(Users|home)\/[^/]+/, '~'))}</small>${
|
|
48
|
+
({ p, m }) => `<button class="pm-row c-${p.color} ${p.id === current ? 'on' : ''} ${also?.has(p.id) ? 'in' : ''}" data-id="${esc(p.id)}" role="option" aria-selected="${p.id === current}" title="${esc(projectTitle(p))}">
|
|
49
|
+
<i class="p-dot"></i><span class="pm-name">${highlight(p.name, m?.idx, esc)}</span>${p.machine ? icon('server', 'pm-where') : ''}${meta(p)}<small class="pm-path">${esc(p.path.replace(/^\/(Users|home)\/[^/]+/, '~'))}</small>${
|
|
50
|
+
also ? `<span class="pm-also" data-also title="${also.has(p.id) ? `Leave ${esc(p.name)} out of this task` : `Also work in ${esc(p.name)}`}">${icon(also.has(p.id) ? 'check' : 'plus')}</span>` : icon('check', 'pm-check')
|
|
51
|
+
}
|
|
48
52
|
</button>`,
|
|
49
53
|
)
|
|
50
54
|
.join('')
|
|
@@ -57,9 +61,19 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
|
|
|
57
61
|
close();
|
|
58
62
|
onPick(id);
|
|
59
63
|
};
|
|
64
|
+
// Adding to the task keeps the list open, the row where it was and the search as typed.
|
|
65
|
+
const toggle = (r) => {
|
|
66
|
+
also.toggle(r.dataset.id);
|
|
67
|
+
const at = act;
|
|
68
|
+
paint();
|
|
69
|
+
mark(at, false);
|
|
70
|
+
input.focus({ preventScroll: true });
|
|
71
|
+
};
|
|
60
72
|
node.onclick = (e) => {
|
|
61
73
|
const r = e.target.closest('.pm-row');
|
|
62
|
-
if (r)
|
|
74
|
+
if (!r) return;
|
|
75
|
+
if (also && !r.classList.contains('pm-x') && (e.target.closest('[data-also]') || e.metaKey || e.ctrlKey || e.shiftKey)) return toggle(r);
|
|
76
|
+
pick(r.dataset.id);
|
|
63
77
|
};
|
|
64
78
|
node.onmousemove = (e) => {
|
|
65
79
|
const r = e.target.closest('.pm-row');
|
|
@@ -73,7 +87,8 @@ export function projectList(anchor, { projects, current, meta = () => '', extras
|
|
|
73
87
|
} else if (e.key === 'Enter' && !e.isComposing) {
|
|
74
88
|
e.preventDefault();
|
|
75
89
|
const r = rows()[act];
|
|
76
|
-
if (r)
|
|
90
|
+
if (r && also && !r.classList.contains('pm-x') && (e.metaKey || e.ctrlKey || e.shiftKey)) toggle(r);
|
|
91
|
+
else if (r) pick(r.dataset.id);
|
|
77
92
|
} else if (e.key === 'Tab') close();
|
|
78
93
|
};
|
|
79
94
|
|
|
@@ -13,7 +13,7 @@ import { feedToTop, renderFeed } from './feed.js';
|
|
|
13
13
|
import { renderLaunchChips } from './launcher.js';
|
|
14
14
|
import { loadConfig } from './live.js';
|
|
15
15
|
import { connectMachine, openMachines } from './machines.js';
|
|
16
|
-
import { MACHINE_STATE, machineById,
|
|
16
|
+
import { inProject, MACHINE_STATE, machineById, pathAt, placeName, placesOf, projectById, projectTitle, recentFirst } from './places.js';
|
|
17
17
|
import { projectList } from './project-list.js';
|
|
18
18
|
|
|
19
19
|
// Project filter chips in the top bar before the rest fold into "+N".
|
|
@@ -23,7 +23,7 @@ let fitBar; // refits the chips when the bar's width changes
|
|
|
23
23
|
export function renderProjects() {
|
|
24
24
|
const all = [...S.sessions.values()];
|
|
25
25
|
const stats = (pid) => {
|
|
26
|
-
const l = pid === 'all' ? all : all.filter((s) => s
|
|
26
|
+
const l = pid === 'all' ? all : all.filter((s) => inProject(s, pid));
|
|
27
27
|
return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
|
|
28
28
|
};
|
|
29
29
|
const badges = (id) => {
|
|
@@ -203,8 +203,8 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
203
203
|
: `Clones <b>${esc(repoLabel(v))}</b> into <button class="link" data-a="clonedir" title="Change where repos are cloned">${esc(tilde(S.config.cloneDir || '~/code'))}/${esc(repoName(v))}</button>. You can write the task while it clones.`;
|
|
204
204
|
else
|
|
205
205
|
hint.innerHTML = m
|
|
206
|
-
? `Folders on <b>${esc(m.name)}</b>, or a repo URL to clone there.
|
|
207
|
-
:
|
|
206
|
+
? `Folders on <b>${esc(m.name)}</b>, or a repo URL to clone there. You don’t need to add a project here that you already have: every project runs on ${esc(m.name)}, and wisp clones it there the first time.`
|
|
207
|
+
: `Paste a GitHub URL and wisp clones it for you. Or run <code>wisp add</code> inside any folder: it is added automatically.${S.machines.length ? ' Projects run on your machines too: wisp clones them there when a task needs it.' : ''}`;
|
|
208
208
|
};
|
|
209
209
|
// Change the clone folder in place: the hint turns into a small path field.
|
|
210
210
|
$('.hint', node).addEventListener('click', (e) => {
|
|
@@ -255,12 +255,13 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
255
255
|
input.focus();
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
-
// A new project becomes the launcher's pick
|
|
258
|
+
// A new project becomes the launcher's pick; added on a machine, the task runs there.
|
|
259
259
|
const picked = async (proj) => {
|
|
260
260
|
await loadConfig();
|
|
261
|
-
launch.picks[
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
launch.picks[''] = proj.id;
|
|
262
|
+
delete launch.also[''];
|
|
263
|
+
launch.project = proj.id;
|
|
264
|
+
if (!launch.where.includes(where || '')) launch.where = [where || ''];
|
|
264
265
|
saveLaunch();
|
|
265
266
|
renderLaunchChips();
|
|
266
267
|
};
|
|
@@ -331,9 +332,9 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
331
332
|
|
|
332
333
|
const list = () => {
|
|
333
334
|
const box = $('#plist', node);
|
|
334
|
-
|
|
335
|
+
// Every project, wherever it was added: each runs on every place. Its row says where it's checked out.
|
|
336
|
+
const projects = [...S.config.projects].sort(recentFirst);
|
|
335
337
|
const pending = clones
|
|
336
|
-
.filter((j) => j.where === where)
|
|
337
338
|
.map(
|
|
338
339
|
(j) => `<div class="prow cloning"><span class="swatch"><span class="orb live"></span></span>
|
|
339
340
|
<div class="prow-main"><b class="pname">${esc(repoName(j.url))}</b><span class="ppath">Cloning ${esc(repoLabel(j.url))}…</span></div></div>`,
|
|
@@ -343,16 +344,18 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
343
344
|
? pending +
|
|
344
345
|
projects
|
|
345
346
|
.map((p) => {
|
|
346
|
-
const n = [...S.sessions.values()].filter((s) => s
|
|
347
|
+
const n = [...S.sessions.values()].filter((s) => inProject(s, p.id)).length;
|
|
348
|
+
const at = placesOf(p);
|
|
349
|
+
const spots = at.map((k) => `<span class="pplace" title="${esc(k ? `${pathAt(p, k)} on ${placeName(k)}` : pathAt(p, k))}">${icon(k ? 'server' : 'laptop')}${esc(placeName(k))}</span>`).join('');
|
|
347
350
|
return `<div class="prow c-${p.color}" data-id="${p.id}">
|
|
348
351
|
<button class="swatch" title="Change colour"><i class="p-dot"></i></button>
|
|
349
|
-
<div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(tilde(p.path))}</span></div>
|
|
352
|
+
<div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${S.machines.length ? spots : esc(tilde(p.path))}</span></div>
|
|
350
353
|
<span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
|
|
351
354
|
<button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
|
|
352
355
|
</div>`;
|
|
353
356
|
})
|
|
354
357
|
.join('')
|
|
355
|
-
: `<p class="muted">No projects
|
|
358
|
+
: `<p class="muted">No projects yet. Add a folder above, or pick one below.</p>`;
|
|
356
359
|
$$('.prow[data-id]', box).forEach((row) => {
|
|
357
360
|
const id = row.dataset.id;
|
|
358
361
|
const p = projectById(id);
|