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.
Files changed (63) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +6 -5
  3. package/bin/wisp.js +4 -2
  4. package/package.json +2 -2
  5. package/server/adapters/antigravity.js +5 -1
  6. package/server/adapters/claude.js +48 -5
  7. package/server/adapters/codex.js +3 -0
  8. package/server/adapters/cursor.js +2 -0
  9. package/server/adapters/grok.js +15 -0
  10. package/server/adapters/index.js +9 -1
  11. package/server/adapters/opencode.js +31 -6
  12. package/server/client.js +1 -1
  13. package/server/git.js +114 -2
  14. package/server/handoff.js +2 -1
  15. package/server/machines.js +2 -2
  16. package/server/mcp-ask.js +113 -8
  17. package/server/mentions.js +39 -0
  18. package/server/openapi.js +21 -3
  19. package/server/protocol.js +8 -1
  20. package/server/render.js +3 -1
  21. package/server/routes/internal.js +121 -1
  22. package/server/routes/meta.js +1 -1
  23. package/server/routes/sessions.js +12 -0
  24. package/server/routes/settings.js +51 -4
  25. package/server/routes/ship.js +24 -2
  26. package/server/runner.js +142 -22
  27. package/server/services/auto.js +12 -1
  28. package/server/services/launch.js +12 -8
  29. package/server/services/phone.js +1 -1
  30. package/server/services/ship.js +83 -4
  31. package/server/store.js +23 -2
  32. package/server/triggers.js +1 -1
  33. package/server/usage.js +94 -0
  34. package/server/welcome.js +2 -2
  35. package/web/css/composer.css +22 -0
  36. package/web/css/feed.css +27 -0
  37. package/web/css/launcher.css +17 -0
  38. package/web/css/layout.css +10 -2
  39. package/web/css/modals.css +8 -0
  40. package/web/css/palette.css +22 -0
  41. package/web/css/ship.css +42 -2
  42. package/web/index.html +16 -5
  43. package/web/js/app.js +14 -3
  44. package/web/js/blocks.js +46 -0
  45. package/web/js/core/state.js +2 -1
  46. package/web/js/features/actions.js +9 -4
  47. package/web/js/features/automations.js +1 -1
  48. package/web/js/features/clones.js +79 -0
  49. package/web/js/features/compare.js +2 -1
  50. package/web/js/features/composer.js +11 -7
  51. package/web/js/features/feed.js +3 -3
  52. package/web/js/features/launcher.js +150 -28
  53. package/web/js/features/live.js +3 -3
  54. package/web/js/features/mentions.js +125 -0
  55. package/web/js/features/notify.js +1 -1
  56. package/web/js/features/project-list.js +99 -0
  57. package/web/js/features/projects.js +218 -34
  58. package/web/js/features/runners.js +3 -1
  59. package/web/js/features/settings.js +16 -2
  60. package/web/js/features/setup.js +3 -3
  61. package/web/js/features/ship.js +100 -24
  62. package/web/js/features/turns.js +44 -13
  63. 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 chip = (id, name, color) => {
29
+ const badges = (id) => {
24
30
  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>` : ''}
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
- $('#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
- );
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
- $('.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.';
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 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();
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
- box.innerHTML = projects.length
206
- ? projects
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. Shell commands that need approval are denied.' },
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