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,124 @@
|
|
|
1
|
+
// Phone: questions and finished tasks over ntfy or Telegram, answered from there.
|
|
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 { S } from '../core/state.js';
|
|
8
|
+
import { present } from '../ui/overlay.js';
|
|
9
|
+
import { toast, toastError } from '../ui/toast.js';
|
|
10
|
+
import { placeName, recentFirst } from './places.js';
|
|
11
|
+
|
|
12
|
+
export function phoneSummary(r) {
|
|
13
|
+
if (!r) return '';
|
|
14
|
+
const on = [r.ntfy.enabled && r.ntfy.topic && 'ntfy', r.telegram.enabled && r.telegram.linked && 'Telegram'].filter(Boolean);
|
|
15
|
+
return on.length ? `${on.join(' + ')} · ${r.events.map((e) => (e === 'waiting' ? 'questions' : 'finished')).join(' & ')}` : 'Off';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function openPhone({ host } = {}) {
|
|
19
|
+
const node = el(`<div class="modal phone-modal">
|
|
20
|
+
<header><h2>Phone</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
21
|
+
<p class="mem-lede">Get questions and finished tasks on your phone, and answer from there. wisp polls both services, so nothing needs to reach this machine. They receive titles, summaries and questions (with their context), never code or diffs.</p>
|
|
22
|
+
<div class="ph-body"></div>
|
|
23
|
+
</div>`);
|
|
24
|
+
const close = present(node, host);
|
|
25
|
+
let r;
|
|
26
|
+
const put = async (patch) => {
|
|
27
|
+
try {
|
|
28
|
+
r = await api('/remote', { method: 'PUT', body: patch });
|
|
29
|
+
paint();
|
|
30
|
+
} catch (err) {
|
|
31
|
+
toastError(err);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const status = (st) => (!st ? '' : st.ok ? `<span class="ph-ok">${icon('check')}Working</span>` : `<span class="ph-bad">${icon('warn')}${esc(st.error || 'Failed')}</span>`);
|
|
35
|
+
const paint = () => {
|
|
36
|
+
const tg = r.telegram;
|
|
37
|
+
const projects = S.config.projects || [];
|
|
38
|
+
const lastUsed = [...projects].sort(recentFirst)[0];
|
|
39
|
+
$('.ph-body', node).innerHTML = `
|
|
40
|
+
<div class="settings-modal ph-sec">
|
|
41
|
+
<div class="setting"><span>Send me</span>
|
|
42
|
+
<label class="check"><input type="checkbox" data-ev="waiting" ${r.events.includes('waiting') ? 'checked' : ''}> Questions</label>
|
|
43
|
+
<label class="check"><input type="checkbox" data-ev="finished" ${r.events.includes('finished') ? 'checked' : ''}> Finished tasks</label></div>
|
|
44
|
+
</div>
|
|
45
|
+
<section class="ph-sec">
|
|
46
|
+
<h4>ntfy <span class="muted xs">· tap a button on the notification to answer</span>${status(r.status.ntfy)}<button class="switch" data-sw="ntfy" role="switch" aria-checked="${r.ntfy.enabled}"></button></h4>
|
|
47
|
+
<div class="settings-modal">
|
|
48
|
+
<label><span>Topic</span><input class="input mono" data-ntfy="topic" value="${esc(r.ntfy.topic)}" placeholder="wisp-…" spellcheck="false"></label>
|
|
49
|
+
<label><span>Server</span><input class="input mono" data-ntfy="server" value="${esc(r.ntfy.server)}" spellcheck="false"></label>
|
|
50
|
+
<label><span>Access token</span><input class="input mono" data-ntfy="token" value="${esc(r.ntfy.token)}" placeholder="Only for protected topics" spellcheck="false" type="password"></label>
|
|
51
|
+
</div>
|
|
52
|
+
<p class="hint">Install the ntfy app and subscribe to the topic. Anyone who knows the topic name can read it, so keep it long and random; answers are signed, so nobody else can send them.</p>
|
|
53
|
+
</section>
|
|
54
|
+
<section class="ph-sec">
|
|
55
|
+
<h4>Telegram <span class="muted xs">· buttons, or reply to a message in your own words</span>${status(r.status.telegram)}<button class="switch" data-sw="telegram" role="switch" aria-checked="${tg.enabled}"></button></h4>
|
|
56
|
+
<div class="settings-modal">
|
|
57
|
+
<label><span>Bot token</span><input class="input mono" data-tg="token" value="${esc(tg.token)}" placeholder="123456:ABC… from @BotFather" spellcheck="false" type="password"></label>
|
|
58
|
+
</div>
|
|
59
|
+
<p class="hint">${
|
|
60
|
+
tg.linked
|
|
61
|
+
? `${icon('check')} Linked to <b>${esc(tg.chatName || 'your chat')}</b>. <button class="link xs" data-act="unlink">Unlink</button>`
|
|
62
|
+
: tg.token && tg.enabled
|
|
63
|
+
? `Now send <code>/link ${esc(tg.linkCode)}</code> to your bot. Only the chat that sends this code gets linked.`
|
|
64
|
+
: 'Create a bot with @BotFather, paste its token here and switch Telegram on.'
|
|
65
|
+
}</p>
|
|
66
|
+
${
|
|
67
|
+
tg.linked
|
|
68
|
+
? `<div class="ph-start">
|
|
69
|
+
<div class="ph-row"><span class="grow">Start tasks from Telegram</span><button class="switch" data-sw="tgStart" role="switch" aria-checked="${tg.allowStart}"></button></div>
|
|
70
|
+
${
|
|
71
|
+
tg.allowStart
|
|
72
|
+
? `<div class="settings-modal"><label><span>Default project</span><select class="input" data-tg="project">
|
|
73
|
+
<option value="">Last used${lastUsed ? ` (${esc(lastUsed.name)})` : ''}</option>
|
|
74
|
+
${projects.map((p) => `<option value="${esc(p.id)}" ${p.id === tg.project ? 'selected' : ''}>${esc(p.name)}${p.machine ? ` · ${esc(placeName(p.machine))}` : ''}</option>`).join('')}
|
|
75
|
+
</select></label></div>
|
|
76
|
+
<p class="hint">Send <code>/new fix the flaky login test</code> to start a task in the default project, or <code>/new @${esc(projects[0]?.name || 'project')} …</code> to pick one; <code>/projects</code> lists them. Tasks use the launcher's harness, model and mode. Only your linked chat can start them.</p>`
|
|
77
|
+
: '<p class="hint">Off: the bot only answers and follows up on tasks you started here.</p>'
|
|
78
|
+
}
|
|
79
|
+
</div>`
|
|
80
|
+
: ''
|
|
81
|
+
}
|
|
82
|
+
</section>
|
|
83
|
+
<div class="ship-foot"><span class="grow"></span><button class="ghost sm" data-act="test">${icon('send')}Send a test</button></div>`;
|
|
84
|
+
};
|
|
85
|
+
node.addEventListener('change', (e) => {
|
|
86
|
+
const t = e.target;
|
|
87
|
+
if (t.dataset.ev) put({ events: $$('[data-ev]', node).filter((x) => x.checked).map((x) => x.dataset.ev) });
|
|
88
|
+
if (t.dataset.ntfy) put({ ntfy: { ...r.ntfy, [t.dataset.ntfy]: t.value.trim() } });
|
|
89
|
+
if (t.dataset.tg === 'token') put({ telegram: { enabled: r.telegram.enabled, token: t.value.trim() } });
|
|
90
|
+
if (t.dataset.tg === 'project') put({ telegram: { project: t.value } });
|
|
91
|
+
});
|
|
92
|
+
node.addEventListener('click', async (e) => {
|
|
93
|
+
const sw = e.target.closest('[data-sw]')?.dataset.sw;
|
|
94
|
+
if (sw === 'ntfy') {
|
|
95
|
+
// A fresh random topic the first time, so it isn't guessable.
|
|
96
|
+
const topic = r.ntfy.topic || `wisp-${crypto.getRandomValues(new Uint32Array(2)).join('').slice(0, 14)}`;
|
|
97
|
+
return put({ ntfy: { ...r.ntfy, enabled: !r.ntfy.enabled, topic } });
|
|
98
|
+
}
|
|
99
|
+
if (sw === 'telegram') return put({ telegram: { enabled: !r.telegram.enabled, token: r.telegram.token } });
|
|
100
|
+
if (sw === 'tgStart') return put({ telegram: { allowStart: !r.telegram.allowStart } });
|
|
101
|
+
const act = e.target.closest('[data-act]')?.dataset.act;
|
|
102
|
+
if (act === 'unlink') return put({ telegram: { enabled: r.telegram.enabled, token: r.telegram.token, unlink: true } });
|
|
103
|
+
if (act === 'test') {
|
|
104
|
+
try {
|
|
105
|
+
r = await api('/remote/test', { method: 'POST', body: {} });
|
|
106
|
+
paint();
|
|
107
|
+
toast('Test sent');
|
|
108
|
+
} catch (err) {
|
|
109
|
+
toastError(err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
bus.on(
|
|
114
|
+
'remote',
|
|
115
|
+
async () => {
|
|
116
|
+
if (node.contains(document.activeElement) && document.activeElement.matches('input')) return; // don't clobber typing
|
|
117
|
+
r = await api('/remote').catch(() => r);
|
|
118
|
+
if (r && node.isConnected) paint();
|
|
119
|
+
},
|
|
120
|
+
{ node },
|
|
121
|
+
);
|
|
122
|
+
r = await api('/remote').catch((err) => (toastError(err), null));
|
|
123
|
+
if (r) paint();
|
|
124
|
+
}
|
|
@@ -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
|
+
}
|