wisp-agents 0.16.0 → 0.18.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 +38 -1
- package/README.md +6 -5
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/git.js +114 -2
- package/server/handoff.js +2 -1
- package/server/machines.js +2 -2
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +21 -3
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/sessions.js +12 -0
- package/server/routes/settings.js +51 -4
- package/server/routes/ship.js +24 -2
- package/server/runner.js +142 -22
- package/server/services/auto.js +12 -1
- package/server/services/launch.js +12 -8
- package/server/services/phone.js +1 -1
- package/server/services/ship.js +83 -4
- package/server/store.js +23 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +42 -2
- package/web/index.html +16 -5
- package/web/js/app.js +14 -3
- package/web/js/blocks.js +46 -0
- package/web/js/core/state.js +2 -1
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/clones.js +79 -0
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/feed.js +3 -3
- package/web/js/features/launcher.js +150 -28
- package/web/js/features/live.js +3 -3
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +218 -34
- package/web/js/features/runners.js +3 -1
- package/web/js/features/settings.js +16 -2
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +100 -24
- package/web/js/features/turns.js +44 -13
- package/web/js/mention-text.js +59 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Task mentions in a prompt box, the way Linear does issue links: copy a task's id (right-click a
|
|
2
|
+
// card → Copy task ID), paste it into a prompt, and it shows as the task's title. The agent gets
|
|
3
|
+
// the id and reads that task with wisp_session.
|
|
4
|
+
//
|
|
5
|
+
// A textarea can't hold chips, so the box shows `@Title` as plain text and a mirror behind it,
|
|
6
|
+
// laid out exactly like the textarea, paints a chip under each mention. The ids stay on the box
|
|
7
|
+
// (ta.mentions). Its `value` reads and writes the stored form, `@[Title](wisp:<id>)`, so drafts,
|
|
8
|
+
// sends and restores elsewhere keep the mentions without knowing about them.
|
|
9
|
+
|
|
10
|
+
import { esc } from '../md.js';
|
|
11
|
+
import { el } from '../core/dom.js';
|
|
12
|
+
import { S } from '../core/state.js';
|
|
13
|
+
import { toast } from '../ui/toast.js';
|
|
14
|
+
import { cleanTitle, decode, encode, pastedTasks, spans } from '../mention-text.js';
|
|
15
|
+
|
|
16
|
+
const NATIVE = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
|
|
17
|
+
// What the mirror copies from the textarea, so its text wraps on the same lines.
|
|
18
|
+
const METRICS = ['font', 'letterSpacing', 'wordSpacing', 'lineHeight', 'textTransform', 'textIndent', 'tabSize', 'whiteSpace', 'overflowWrap', 'wordBreak', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'boxSizing'];
|
|
19
|
+
|
|
20
|
+
export function mentionBox(ta) {
|
|
21
|
+
if (ta.mentions) return ta;
|
|
22
|
+
ta.mentions = [];
|
|
23
|
+
const wrap = el('<div class="mn-wrap"></div>');
|
|
24
|
+
const mirror = el('<div class="mn-mirror" aria-hidden="true"></div>');
|
|
25
|
+
ta.replaceWith(wrap);
|
|
26
|
+
wrap.append(mirror, ta);
|
|
27
|
+
const shown = () => NATIVE.get.call(ta);
|
|
28
|
+
|
|
29
|
+
Object.defineProperty(ta, 'value', {
|
|
30
|
+
configurable: true,
|
|
31
|
+
get: () => encode(shown(), ta.mentions),
|
|
32
|
+
set: (v) => {
|
|
33
|
+
const d = decode(v);
|
|
34
|
+
ta.mentions = d.mentions;
|
|
35
|
+
NATIVE.set.call(ta, d.text);
|
|
36
|
+
paint();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function paint() {
|
|
41
|
+
const text = shown();
|
|
42
|
+
const found = ta.mentions.length ? spans(text, ta.mentions) : [];
|
|
43
|
+
wrap.classList.toggle('has-mn', found.length > 0);
|
|
44
|
+
if (!found.length) return (mirror.textContent = '');
|
|
45
|
+
const cs = getComputedStyle(ta);
|
|
46
|
+
for (const k of METRICS) mirror.style[k] = cs[k];
|
|
47
|
+
// A scrollbar narrows the textarea's text; narrow the mirror's the same.
|
|
48
|
+
const bar = ta.offsetWidth - ta.clientWidth - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth);
|
|
49
|
+
mirror.style.paddingRight = `${parseFloat(cs.paddingRight) + Math.max(0, bar)}px`;
|
|
50
|
+
let html = '';
|
|
51
|
+
let at = 0;
|
|
52
|
+
for (const s of found) {
|
|
53
|
+
html += `${esc(text.slice(at, s.start))}<mark class="mn">${esc(text.slice(s.start, s.end))}</mark>`;
|
|
54
|
+
at = s.end;
|
|
55
|
+
}
|
|
56
|
+
mirror.innerHTML = `${html}${esc(text.slice(at))}`;
|
|
57
|
+
sync();
|
|
58
|
+
}
|
|
59
|
+
const sync = () => {
|
|
60
|
+
mirror.scrollTop = ta.scrollTop;
|
|
61
|
+
mirror.scrollLeft = ta.scrollLeft;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
ta.addEventListener('input', paint);
|
|
65
|
+
ta.addEventListener('scroll', sync);
|
|
66
|
+
new ResizeObserver(paint).observe(ta);
|
|
67
|
+
|
|
68
|
+
ta.addEventListener('paste', (e) => {
|
|
69
|
+
if (e.clipboardData?.files?.length) return; // attachments take those
|
|
70
|
+
const ids = pastedTasks(e.clipboardData?.getData('text/plain'), (id) => S.sessions.has(id) && !S.sessions.get(id).pending);
|
|
71
|
+
if (!ids) return;
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const title = cleanTitle(S.sessions.get(id).title);
|
|
75
|
+
if (!ta.mentions.some((m) => m.id === id)) ta.mentions.push({ id, title });
|
|
76
|
+
}
|
|
77
|
+
const before = shown().slice(0, ta.selectionStart);
|
|
78
|
+
const lead = before && !/\s$/.test(before) ? ' ' : '';
|
|
79
|
+
insert(lead + ids.map((id) => `@${ta.mentions.find((m) => m.id === id).title}`).join(' ') + ' ');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Backspace right after a mention (or Delete right before one) takes the whole mention.
|
|
83
|
+
ta.addEventListener('keydown', (e) => {
|
|
84
|
+
if ((e.key !== 'Backspace' && e.key !== 'Delete') || e.isComposing || e.altKey || e.metaKey || e.ctrlKey) return;
|
|
85
|
+
const { selectionStart: a, selectionEnd: b } = ta;
|
|
86
|
+
if (a !== b || !ta.mentions.length) return;
|
|
87
|
+
const hit = spans(shown(), ta.mentions).find((s) => (e.key === 'Backspace' ? s.end === a : s.start === a));
|
|
88
|
+
if (!hit) return;
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
ta.setSelectionRange(hit.start, hit.end);
|
|
91
|
+
insert('');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Through the editing commands, so Cmd+Z undoes it like typing.
|
|
95
|
+
function insert(text) {
|
|
96
|
+
if (!document.execCommand('insertText', false, text)) {
|
|
97
|
+
ta.setRangeText(text, ta.selectionStart, ta.selectionEnd, 'end');
|
|
98
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
paint();
|
|
103
|
+
return ta;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function copyTaskId(id) {
|
|
107
|
+
const s = S.sessions.get(id);
|
|
108
|
+
if (!s || s.pending) return;
|
|
109
|
+
try {
|
|
110
|
+
await navigator.clipboard.writeText(id);
|
|
111
|
+
toast(`Copied the ID of <b>${esc(cleanTitle(s.title))}</b>. Paste it into a prompt to point an agent at this task.`);
|
|
112
|
+
} catch {
|
|
113
|
+
toast(`Couldn’t copy. The task ID is <code>${id}</code>`, { tone: 'bad', timeout: 8000 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Clicking a mention chip in a turn's prompt opens that task, or says it's gone.
|
|
118
|
+
document.addEventListener('click', (e) => {
|
|
119
|
+
const a = e.target.closest?.('a.mn[href^="#/s/"]');
|
|
120
|
+
if (!a) return;
|
|
121
|
+
const id = a.getAttribute('href').slice(4);
|
|
122
|
+
if (S.sessions.has(id)) return; // the #/s/ route reveals it
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
toast('That task is no longer in wisp.', { tone: 'bad' });
|
|
125
|
+
});
|
|
@@ -20,7 +20,7 @@ export function askNotify() {
|
|
|
20
20
|
export function notifyFinished(s) {
|
|
21
21
|
const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
|
|
22
22
|
if (viewing) return;
|
|
23
|
-
let verb = s.state === 'waiting' ? 'has a question' : 'is ready';
|
|
23
|
+
let verb = s.state === 'waiting' ? (s.approval ? 'needs your approval' : 'has a question') : 'is ready';
|
|
24
24
|
// A task run in several places (or compared) says so once, when its last run finishes.
|
|
25
25
|
const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
|
|
26
26
|
if (runs.length > 1 && s.state !== 'waiting') {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The project list: every project in a searchable list, for when there are too many to show as
|
|
2
|
+
// chips. The launcher's "in" row and the top bar's filter both open it from a "+N" chip.
|
|
3
|
+
//
|
|
4
|
+
// projects the list, in the order to show it (recent first)
|
|
5
|
+
// current the picked id, checked and scrolled to
|
|
6
|
+
// meta(p) extra html after a row's name (status badges)
|
|
7
|
+
// extras [{ id, label, icon }] fixed rows under the list (No folder, Other folder…)
|
|
8
|
+
// onPick (id) => …, a project's id or an extra's
|
|
9
|
+
|
|
10
|
+
import { esc } from '../md.js';
|
|
11
|
+
import { $, el, icon } from '../core/dom.js';
|
|
12
|
+
import { fuzzy, highlight } from '../fuzzy.js';
|
|
13
|
+
import { popover } from '../ui/popover.js';
|
|
14
|
+
import { projectTitle } from './places.js';
|
|
15
|
+
|
|
16
|
+
export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, label = 'Projects' }) {
|
|
17
|
+
if ($('.menu.pm')) return $('.menu.pm').close();
|
|
18
|
+
const node = el(`
|
|
19
|
+
<div class="menu pm" role="dialog" aria-label="${esc(label)}">
|
|
20
|
+
<label class="field pm-search">${icon('search')}<input spellcheck="false" autocomplete="off" placeholder="Search ${projects.length} projects" aria-label="Search projects"></label>
|
|
21
|
+
<div class="pm-list" role="listbox"></div>
|
|
22
|
+
${extras.length ? `<div class="pm-extras">${extras.map((x) => `<button class="pm-row pm-x" data-id="${esc(x.id)}">${icon(x.icon)}<span class="pm-name">${esc(x.label)}</span></button>`).join('')}</div>` : ''}
|
|
23
|
+
</div>`);
|
|
24
|
+
const input = $('input', node);
|
|
25
|
+
const list = $('.pm-list', node);
|
|
26
|
+
let act = 0;
|
|
27
|
+
const rows = () => [...node.querySelectorAll('.pm-row')];
|
|
28
|
+
const mark = (i, scroll = true) => {
|
|
29
|
+
const r = rows();
|
|
30
|
+
if (!r.length) return;
|
|
31
|
+
act = (i + r.length) % r.length;
|
|
32
|
+
r.forEach((x, k) => x.classList.toggle('act', k === act));
|
|
33
|
+
if (scroll) r[act].scrollIntoView({ block: 'nearest' });
|
|
34
|
+
};
|
|
35
|
+
const paint = () => {
|
|
36
|
+
const q = input.value.trim();
|
|
37
|
+
const hits = q
|
|
38
|
+
? projects
|
|
39
|
+
.map((p) => ({ p, m: fuzzy(q, [p.name, p.path]) }))
|
|
40
|
+
.filter((x) => x.m)
|
|
41
|
+
.sort((a, b) => b.m.score - a.m.score)
|
|
42
|
+
: projects.map((p) => ({ p, m: null }));
|
|
43
|
+
list.innerHTML = hits.length
|
|
44
|
+
? hits
|
|
45
|
+
.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>${icon('check', 'pm-check')}
|
|
48
|
+
</button>`,
|
|
49
|
+
)
|
|
50
|
+
.join('')
|
|
51
|
+
: `<div class="pm-empty">No project matches “${esc(q)}”</div>`;
|
|
52
|
+
// Searching starts on the best hit; the full list opens on the current pick.
|
|
53
|
+
const at = q ? 0 : Math.max(0, hits.findIndex((h) => h.p.id === current));
|
|
54
|
+
mark(at, !q);
|
|
55
|
+
};
|
|
56
|
+
const pick = (id) => {
|
|
57
|
+
close();
|
|
58
|
+
onPick(id);
|
|
59
|
+
};
|
|
60
|
+
node.onclick = (e) => {
|
|
61
|
+
const r = e.target.closest('.pm-row');
|
|
62
|
+
if (r) pick(r.dataset.id);
|
|
63
|
+
};
|
|
64
|
+
node.onmousemove = (e) => {
|
|
65
|
+
const r = e.target.closest('.pm-row');
|
|
66
|
+
if (r && !r.classList.contains('act')) mark(rows().indexOf(r), false);
|
|
67
|
+
};
|
|
68
|
+
input.oninput = paint;
|
|
69
|
+
input.onkeydown = (e) => {
|
|
70
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
mark(act + (e.key === 'ArrowDown' ? 1 : -1));
|
|
73
|
+
} else if (e.key === 'Enter' && !e.isComposing) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
const r = rows()[act];
|
|
76
|
+
if (r) pick(r.dataset.id);
|
|
77
|
+
} else if (e.key === 'Tab') close();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const close = popover(node, {
|
|
81
|
+
anchor,
|
|
82
|
+
onClose: () => {
|
|
83
|
+
anchor.classList.remove('open');
|
|
84
|
+
anchor.setAttribute('aria-expanded', 'false');
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
anchor.classList.add('open');
|
|
88
|
+
anchor.setAttribute('aria-expanded', 'true');
|
|
89
|
+
paint();
|
|
90
|
+
// Under the chip, or over it when the page has no room below; never off the side.
|
|
91
|
+
const r = anchor.getBoundingClientRect();
|
|
92
|
+
const w = node.offsetWidth;
|
|
93
|
+
const h = node.offsetHeight;
|
|
94
|
+
node.style.left = `${Math.max(8, Math.min(r.left, innerWidth - w - 8))}px`;
|
|
95
|
+
node.style.top = `${r.bottom + 6 + h > innerHeight - 8 && r.top - h - 6 > 8 ? r.top - h - 6 : r.bottom + 6}px`;
|
|
96
|
+
rows()[act]?.scrollIntoView({ block: 'nearest' }); // placed: now bring the current pick into view
|
|
97
|
+
input.focus({ preventScroll: true });
|
|
98
|
+
return close;
|
|
99
|
+
}
|
|
@@ -8,11 +8,17 @@ import { launch, S, save, saveLaunch } from '../core/state.js';
|
|
|
8
8
|
import { present } from '../ui/overlay.js';
|
|
9
9
|
import { popover } from '../ui/popover.js';
|
|
10
10
|
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
11
|
+
import { clones, isRepo, repoLabel, repoName, startClone } from './clones.js';
|
|
11
12
|
import { feedToTop, renderFeed } from './feed.js';
|
|
12
13
|
import { renderLaunchChips } from './launcher.js';
|
|
13
14
|
import { loadConfig } from './live.js';
|
|
14
15
|
import { connectMachine, openMachines } from './machines.js';
|
|
15
|
-
import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle } from './places.js';
|
|
16
|
+
import { MACHINE_STATE, machineById, placeOf, projectById, projectTitle, recentFirst } from './places.js';
|
|
17
|
+
import { projectList } from './project-list.js';
|
|
18
|
+
|
|
19
|
+
// Project filter chips in the top bar before the rest fold into "+N".
|
|
20
|
+
const TOP_CHIPS = 6;
|
|
21
|
+
let fitBar; // refits the chips when the bar's width changes
|
|
16
22
|
|
|
17
23
|
export function renderProjects() {
|
|
18
24
|
const all = [...S.sessions.values()];
|
|
@@ -20,26 +26,89 @@ export function renderProjects() {
|
|
|
20
26
|
const l = pid === 'all' ? all : all.filter((s) => s.projectId === pid);
|
|
21
27
|
return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
|
|
22
28
|
};
|
|
23
|
-
const
|
|
29
|
+
const badges = (id) => {
|
|
24
30
|
const st = stats(id);
|
|
25
|
-
return `<
|
|
26
|
-
|
|
31
|
+
return `${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}`;
|
|
32
|
+
};
|
|
33
|
+
const chip = (id, name, color) => `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectTitle(projectById(id)))}">
|
|
34
|
+
${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${projectById(id)?.machine ? icon('server', 'p-where') : ''}${badges(id)}
|
|
27
35
|
</button>`;
|
|
36
|
+
const list = S.config.projects.slice().sort(recentFirst);
|
|
37
|
+
// Tens of projects: the filtered one and those that need you first, then the most recent, up to
|
|
38
|
+
// TOP_CHIPS, kept in recent order so chips don't shuffle; the rest sit behind "+N".
|
|
39
|
+
const keep = new Set([
|
|
40
|
+
...list.filter((p) => p.id === S.filter),
|
|
41
|
+
...list.filter((p) => stats(p.id).need),
|
|
42
|
+
...list,
|
|
43
|
+
].slice(0, TOP_CHIPS));
|
|
44
|
+
const shown = list.filter((p) => keep.has(p));
|
|
45
|
+
$('#projects').innerHTML = `<div class="p-scroll">${chip('all', 'All', 'slate')}${shown.map((p) => chip(p.id, p.name, p.color)).join('')}</div><button class="p-chip more" id="moreProjects" title="All ${list.length} projects" aria-haspopup="dialog"><i class="p-dot" hidden></i><span class="p-more-n"></span><span class="p-need" hidden></span>${icon('chevron', 'chev')}</button><button class="p-chip add" id="manageProjects" title="Manage projects">${icon('plus')}</button>`;
|
|
46
|
+
// Keep chips whole: drop the least important ones (recent last, the filtered one never) until
|
|
47
|
+
// the rest fit, and count what's left out on "+N" with what needs you in there.
|
|
48
|
+
const fit = () => {
|
|
49
|
+
const box = $('#projects .p-scroll');
|
|
50
|
+
if (!box) return;
|
|
51
|
+
const chips = $$('.p-chip[data-id]:not([data-id="all"])', box);
|
|
52
|
+
chips.forEach((c) => (c.hidden = false));
|
|
53
|
+
const drop = [...chips].sort((a, b) => (a.dataset.id === S.filter) - (b.dataset.id === S.filter) || stats(a.dataset.id).need - stats(b.dataset.id).need || shown.findIndex((p) => p.id === b.dataset.id) - shown.findIndex((p) => p.id === a.dataset.id));
|
|
54
|
+
const more = $('#moreProjects');
|
|
55
|
+
box.hidden = false;
|
|
56
|
+
$('#manageProjects').hidden = false;
|
|
57
|
+
$('.p-dot', more).hidden = true;
|
|
58
|
+
more.classList.remove('solo', ...[...more.classList].filter((c) => c.startsWith('c-')));
|
|
59
|
+
const paint = () => {
|
|
60
|
+
const out = list.filter((p) => !chips.some((c) => !c.hidden && c.dataset.id === p.id));
|
|
61
|
+
more.hidden = !out.length;
|
|
62
|
+
$('.p-more-n', more).textContent = `+${out.length}`;
|
|
63
|
+
const need = out.reduce((n, p) => n + stats(p.id).need, 0);
|
|
64
|
+
$('.p-need', more).hidden = !need;
|
|
65
|
+
$('.p-need', more).textContent = need;
|
|
66
|
+
};
|
|
67
|
+
paint();
|
|
68
|
+
for (const c of drop) {
|
|
69
|
+
if (box.scrollWidth <= box.clientWidth) break;
|
|
70
|
+
c.hidden = true;
|
|
71
|
+
paint();
|
|
72
|
+
}
|
|
73
|
+
// Too narrow for even "All" (a phone): one chip naming the filter opens the whole list, which
|
|
74
|
+
// also has Manage projects, so + steps aside for it.
|
|
75
|
+
if (box.scrollWidth > box.clientWidth) {
|
|
76
|
+
const p = projectById(S.filter);
|
|
77
|
+
box.hidden = true;
|
|
78
|
+
$('#manageProjects').hidden = true;
|
|
79
|
+
more.hidden = false;
|
|
80
|
+
more.classList.add('solo', ...(p ? [`c-${p.color}`] : []));
|
|
81
|
+
$('.p-dot', more).hidden = !p;
|
|
82
|
+
$('.p-more-n', more).textContent = p ? p.name : 'All';
|
|
83
|
+
const need = stats(p ? p.id : 'all').need;
|
|
84
|
+
$('.p-need', more).hidden = !need;
|
|
85
|
+
$('.p-need', more).textContent = need;
|
|
86
|
+
}
|
|
28
87
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
88
|
+
fit();
|
|
89
|
+
fitBar ??= new ResizeObserver(() => fitBar.fit?.());
|
|
90
|
+
fitBar.fit = fit;
|
|
91
|
+
fitBar.observe($('#projects'));
|
|
92
|
+
const setFilter = (id) => {
|
|
93
|
+
S.filter = id;
|
|
94
|
+
save('wisp.filter', S.filter);
|
|
95
|
+
renderFeed(); // repaints these chips too
|
|
96
|
+
feedToTop();
|
|
97
|
+
renderLaunchChips();
|
|
98
|
+
};
|
|
99
|
+
$$('.p-chip[data-id]').forEach((b) => b.addEventListener('click', () => setFilter(S.filter === b.dataset.id ? 'all' : b.dataset.id)));
|
|
100
|
+
$('#moreProjects').onclick = (e) =>
|
|
101
|
+
projectList(e.currentTarget, {
|
|
102
|
+
projects: list,
|
|
103
|
+
current: S.filter,
|
|
104
|
+
label: 'Filter by project',
|
|
105
|
+
meta: (p) => badges(p.id),
|
|
106
|
+
extras: [
|
|
107
|
+
...(S.filter === 'all' ? [] : [{ id: 'all', label: 'All projects', icon: 'x' }]),
|
|
108
|
+
{ id: '+manage', label: 'Manage projects…', icon: 'settings' },
|
|
109
|
+
],
|
|
110
|
+
onPick: (id) => (id === '+manage' ? openProjects() : setFilter(id)),
|
|
111
|
+
});
|
|
43
112
|
$$('.p-chip[data-id]:not([data-id="all"])').forEach((b) =>
|
|
44
113
|
b.addEventListener('contextmenu', (e) => {
|
|
45
114
|
e.preventDefault();
|
|
@@ -94,7 +163,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
94
163
|
<div class="add-row">
|
|
95
164
|
<div class="field lg mono path-input">
|
|
96
165
|
${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
|
|
97
|
-
<input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
|
|
166
|
+
<input id="ppath" placeholder="~/code/my-app or a GitHub URL" spellcheck="false" autocomplete="off">
|
|
98
167
|
<div class="suggest" hidden></div>
|
|
99
168
|
</div>
|
|
100
169
|
${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
|
|
@@ -102,15 +171,17 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
102
171
|
</div>
|
|
103
172
|
<p class="hint"></p>
|
|
104
173
|
<div class="plist" id="plist"></div>
|
|
174
|
+
<div class="discover" id="ghrepos"></div>
|
|
105
175
|
<div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
|
|
106
176
|
</div>`);
|
|
107
|
-
present(node, host);
|
|
177
|
+
const close = present(node, host);
|
|
108
178
|
const input = $('#ppath', node);
|
|
109
179
|
const sug = $('.suggest', node);
|
|
110
180
|
let where = machine && machineById(machine) ? machine : null;
|
|
111
181
|
const here = () => machineById(where);
|
|
112
182
|
const home = () => (where ? here()?.remote?.home : S.config.home) || '';
|
|
113
183
|
const tilde = (p) => (home() && p.startsWith(home()) ? `~${p.slice(home().length)}` : p);
|
|
184
|
+
let editingCloneDir = false;
|
|
114
185
|
|
|
115
186
|
const paintWhere = () => {
|
|
116
187
|
const tabs = $('.where', node);
|
|
@@ -118,12 +189,59 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
118
189
|
.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
190
|
.join('')}<i></i>${host ? '' : `<button data-w="+" title="Run agents on another computer over SSH">${icon('plus')}Machine</button>`}`;
|
|
120
191
|
const m = here();
|
|
121
|
-
input.placeholder = m ? `~/code/my-app on ${m.name}` : '~/code/my-app';
|
|
192
|
+
input.placeholder = m ? `~/code/my-app or a GitHub URL, on ${m.name}` : '~/code/my-app or a GitHub URL';
|
|
122
193
|
$$('#pbrowse, .browse', node).forEach((b) => (b.hidden = !!m));
|
|
123
|
-
|
|
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.';
|
|
194
|
+
paintHint();
|
|
126
195
|
};
|
|
196
|
+
// The hint under the input follows what's typed: a repo URL says where the clone goes.
|
|
197
|
+
const paintHint = () => {
|
|
198
|
+
const m = here();
|
|
199
|
+
const hint = $('.hint', node);
|
|
200
|
+
const v = input.value.trim();
|
|
201
|
+
$('#padd', node).textContent = isRepo(v) ? 'Clone' : 'Add';
|
|
202
|
+
if (editingCloneDir) return;
|
|
203
|
+
if (isRepo(v))
|
|
204
|
+
hint.innerHTML = m
|
|
205
|
+
? `Clones <b>${esc(repoLabel(v))}</b> on <b>${esc(m.name)}</b>. You can write the task while it clones.`
|
|
206
|
+
: `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.`;
|
|
207
|
+
else
|
|
208
|
+
hint.innerHTML = m
|
|
209
|
+
? `Folders on <b>${esc(m.name)}</b>, or a repo URL to clone there. Agents run there, in its own worktrees; you follow along here.`
|
|
210
|
+
: 'Paste a GitHub URL and wisp clones it for you. Or run <code>wisp add</code> inside any folder: it is added automatically.';
|
|
211
|
+
};
|
|
212
|
+
// Change the clone folder in place: the hint turns into a small path field.
|
|
213
|
+
$('.hint', node).addEventListener('click', (e) => {
|
|
214
|
+
if (!e.target.closest('[data-a="clonedir"]')) return;
|
|
215
|
+
editingCloneDir = true;
|
|
216
|
+
const hint = $('.hint', node);
|
|
217
|
+
hint.innerHTML = `Clone repos into <input class="clonedir mono" spellcheck="false" value="${esc(tilde(S.config.cloneDir || '~/code'))}"> <button class="link" data-a="savedir">Save</button>`;
|
|
218
|
+
const f = $('.clonedir', hint);
|
|
219
|
+
const done = async (keep) => {
|
|
220
|
+
if (!editingCloneDir) return;
|
|
221
|
+
editingCloneDir = false;
|
|
222
|
+
if (keep && f.value.trim() !== tilde(S.config.cloneDir || '')) {
|
|
223
|
+
S.config.cloneDir = f.value.trim(); // optimistic; the server answers with the resolved folder
|
|
224
|
+
await tryApi(async () => {
|
|
225
|
+
await api('/config', { method: 'PATCH', body: { defaults: { cloneDir: f.value.trim() } } });
|
|
226
|
+
await loadConfig();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
paintHint();
|
|
230
|
+
input.focus();
|
|
231
|
+
};
|
|
232
|
+
f.onkeydown = (ev) => {
|
|
233
|
+
if (ev.key === 'Enter' || ev.key === 'Escape') {
|
|
234
|
+
ev.preventDefault();
|
|
235
|
+
ev.stopPropagation(); // Escape cancels the edit, not the dialog
|
|
236
|
+
done(ev.key === 'Enter');
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
$('[data-a="savedir"]', hint).onclick = () => {
|
|
240
|
+
done(true);
|
|
241
|
+
};
|
|
242
|
+
f.focus();
|
|
243
|
+
f.select();
|
|
244
|
+
});
|
|
127
245
|
$('.where', node).addEventListener('click', (e) => {
|
|
128
246
|
const b = e.target.closest('[data-w]');
|
|
129
247
|
if (!b) return;
|
|
@@ -135,28 +253,42 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
135
253
|
sug.hidden = true;
|
|
136
254
|
paintWhere();
|
|
137
255
|
list();
|
|
256
|
+
github();
|
|
138
257
|
discover();
|
|
139
258
|
input.focus();
|
|
140
259
|
});
|
|
141
260
|
|
|
261
|
+
// A new project becomes the launcher's pick for its place.
|
|
262
|
+
const picked = async (proj) => {
|
|
263
|
+
await loadConfig();
|
|
264
|
+
launch.picks[placeOf(proj)] = proj.id;
|
|
265
|
+
if (!launch.where.includes(placeOf(proj))) launch.where = [placeOf(proj)];
|
|
266
|
+
if (placeOf(proj) === launch.where[0]) launch.project = proj.id;
|
|
267
|
+
saveLaunch();
|
|
268
|
+
renderLaunchChips();
|
|
269
|
+
};
|
|
142
270
|
const add = async (p) => {
|
|
271
|
+
if (isRepo(p)) return clone(p);
|
|
143
272
|
try {
|
|
144
273
|
const proj = await api('/projects', { method: 'POST', body: { path: p, ...(where && { machine: where }) } });
|
|
145
|
-
await
|
|
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();
|
|
274
|
+
await picked(proj);
|
|
151
275
|
toast(`Added <b>${esc(proj.name)}</b>${where ? ` on ${esc(here()?.name || '')}` : ''}`);
|
|
152
276
|
input.value = '';
|
|
153
277
|
sug.hidden = true;
|
|
278
|
+
paintHint();
|
|
154
279
|
list();
|
|
155
280
|
discover();
|
|
156
281
|
} catch (err) {
|
|
157
282
|
toastError(err);
|
|
158
283
|
}
|
|
159
284
|
};
|
|
285
|
+
// Clone a repo: the launcher picks it right away (cloning in the background) and the dialog
|
|
286
|
+
// closes, so the next thing is writing the task. A launch into it waits for the clone.
|
|
287
|
+
const clone = (url) => {
|
|
288
|
+
startClone(url, where);
|
|
289
|
+
close();
|
|
290
|
+
setTimeout(() => $('#prompt')?.focus(), 60);
|
|
291
|
+
};
|
|
160
292
|
$('#padd', node).onclick = () => {
|
|
161
293
|
if (input.value.trim()) add(input.value.trim());
|
|
162
294
|
};
|
|
@@ -169,10 +301,11 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
169
301
|
let sel = -1;
|
|
170
302
|
let t;
|
|
171
303
|
input.addEventListener('input', () => {
|
|
304
|
+
paintHint();
|
|
172
305
|
clearTimeout(t);
|
|
173
306
|
t = setTimeout(async () => {
|
|
174
307
|
const q = input.value;
|
|
175
|
-
if (!q) return (sug.hidden = true);
|
|
308
|
+
if (!q || isRepo(q)) return (sug.hidden = true);
|
|
176
309
|
const dirs = await api(`${where ? `/machines/${where}/fs` : '/fs'}?q=${encodeURIComponent(q)}`).catch(() => []);
|
|
177
310
|
sel = -1;
|
|
178
311
|
sug.hidden = !dirs.length;
|
|
@@ -202,8 +335,16 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
202
335
|
const list = () => {
|
|
203
336
|
const box = $('#plist', node);
|
|
204
337
|
const projects = S.config.projects.filter((p) => (p.machine || null) === where);
|
|
205
|
-
|
|
206
|
-
|
|
338
|
+
const pending = clones
|
|
339
|
+
.filter((j) => j.where === where)
|
|
340
|
+
.map(
|
|
341
|
+
(j) => `<div class="prow cloning"><span class="swatch"><span class="orb live"></span></span>
|
|
342
|
+
<div class="prow-main"><b class="pname">${esc(repoName(j.url))}</b><span class="ppath">Cloning ${esc(repoLabel(j.url))}…</span></div></div>`,
|
|
343
|
+
)
|
|
344
|
+
.join('');
|
|
345
|
+
box.innerHTML = projects.length || pending
|
|
346
|
+
? pending +
|
|
347
|
+
projects
|
|
207
348
|
.map((p) => {
|
|
208
349
|
const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
|
|
209
350
|
return `<div class="prow c-${p.color}" data-id="${p.id}">
|
|
@@ -215,7 +356,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
215
356
|
})
|
|
216
357
|
.join('')
|
|
217
358
|
: `<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) => {
|
|
359
|
+
$$('.prow[data-id]', box).forEach((row) => {
|
|
219
360
|
const id = row.dataset.id;
|
|
220
361
|
const p = projectById(id);
|
|
221
362
|
$('.pname', row).onchange = (e) => {
|
|
@@ -243,6 +384,40 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
243
384
|
});
|
|
244
385
|
};
|
|
245
386
|
|
|
387
|
+
// The user's own GitHub repos (through gh), one click to clone. Only on this computer, and
|
|
388
|
+
// hidden when gh isn't installed or signed in.
|
|
389
|
+
let ghRepos = null;
|
|
390
|
+
let ghAll = false;
|
|
391
|
+
const github = async () => {
|
|
392
|
+
const box = $('#ghrepos', node);
|
|
393
|
+
if (where) return (box.innerHTML = '');
|
|
394
|
+
box.onclick = (e) => {
|
|
395
|
+
const b = e.target.closest('[data-repo]');
|
|
396
|
+
if (b) clone(b.dataset.repo);
|
|
397
|
+
if (e.target.closest('[data-a="ghall"]')) {
|
|
398
|
+
ghAll = !ghAll;
|
|
399
|
+
github();
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
if (!ghRepos) {
|
|
403
|
+
box.innerHTML = `<h4>${icon('github')}Your GitHub repos</h4><p class="muted">Asking gh…</p>`;
|
|
404
|
+
ghRepos = await api('/projects/github').catch(() => []);
|
|
405
|
+
if (where) return (box.innerHTML = '');
|
|
406
|
+
}
|
|
407
|
+
const busy = new Set(clones.map((j) => j.label.toLowerCase()));
|
|
408
|
+
const repos = ghRepos.filter((r) => !busy.has(r.repo.toLowerCase()));
|
|
409
|
+
const more = repos.length > 6 ? `<button class="link xs" data-a="ghall">${ghAll ? 'Show fewer' : `Show all ${repos.length}`}</button>` : '';
|
|
410
|
+
box.innerHTML = repos.length
|
|
411
|
+
? `<h4>${icon('github')}Your GitHub repos <span class="muted">· click to clone</span>${more}</h4><div class="found">${repos
|
|
412
|
+
.slice(0, ghAll ? undefined : 6)
|
|
413
|
+
.map(
|
|
414
|
+
(r) =>
|
|
415
|
+
`<button class="found-item" data-repo="${esc(r.url)}" title="${esc(r.description || r.repo)}">${icon('plus')}<b>${esc(r.repo.split('/')[1])}</b><span>${esc(r.repo.split('/')[0])}${r.private ? ' · private' : ''}${r.fork ? ' · fork' : ''}</span></button>`,
|
|
416
|
+
)
|
|
417
|
+
.join('')}</div>`
|
|
418
|
+
: '';
|
|
419
|
+
};
|
|
420
|
+
|
|
246
421
|
const discover = async () => {
|
|
247
422
|
const box = $('#discover', node);
|
|
248
423
|
const m = here();
|
|
@@ -265,6 +440,14 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
265
440
|
.join('')}</div>`
|
|
266
441
|
: '';
|
|
267
442
|
};
|
|
443
|
+
bus.on(
|
|
444
|
+
'clones',
|
|
445
|
+
() => {
|
|
446
|
+
list();
|
|
447
|
+
github();
|
|
448
|
+
},
|
|
449
|
+
{ node },
|
|
450
|
+
);
|
|
268
451
|
let wasOnline = here()?.state === 'online';
|
|
269
452
|
bus.on(
|
|
270
453
|
'machines',
|
|
@@ -279,6 +462,7 @@ export async function openProjects({ focusAdd = false, machine = null, host } =
|
|
|
279
462
|
|
|
280
463
|
paintWhere();
|
|
281
464
|
list();
|
|
465
|
+
github();
|
|
282
466
|
discover();
|
|
283
467
|
if (focusAdd) setTimeout(() => input.focus(), 60);
|
|
284
468
|
}
|
|
@@ -9,9 +9,11 @@ import { machineById } from './places.js';
|
|
|
9
9
|
|
|
10
10
|
export const MODES = [
|
|
11
11
|
{ id: 'plan', label: 'Plan', icon: 'plan', hint: 'Read-only. The agent investigates and proposes.' },
|
|
12
|
-
{ id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files.
|
|
12
|
+
{ id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files. Anything else that needs approval comes to you to allow.' },
|
|
13
13
|
{ id: 'auto', label: 'Full auto', icon: 'spark', hint: 'No permission prompts at all. Trusted repos only.' },
|
|
14
14
|
];
|
|
15
|
+
// The default: just an icon on mode buttons; the others are spelled out so you can't miss them.
|
|
16
|
+
export const DEFAULT_MODE = MODES[2];
|
|
15
17
|
|
|
16
18
|
export const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'auto' ? 'Auto' : id === 'wisp' ? 'wisp' : id);
|
|
17
19
|
|