wisp-agents 0.24.1 → 0.25.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 +16 -1
- package/README.md +44 -4
- package/bin/wisp.js +55 -1
- package/package.json +2 -2
- package/server/adapters/codex.js +3 -0
- package/server/client.js +2 -2
- package/server/index.js +17 -2
- package/server/mcp-ask.js +44 -2
- package/server/openapi.js +38 -0
- package/server/protocol.js +32 -3
- package/server/render.js +16 -1
- package/server/routes/automations.js +8 -5
- package/server/routes/internal.js +61 -0
- package/server/routes/meta.js +5 -1
- package/server/routes/settings.js +17 -3
- package/server/routes/spells.js +147 -0
- package/server/runner.js +10 -3
- package/server/services/launch.js +7 -2
- package/server/services/spell-hooks.js +99 -0
- package/server/spell-guide.js +135 -0
- package/server/spells-data.js +60 -0
- package/server/spells.js +647 -0
- package/web/css/feed.css +1 -1
- package/web/css/spells.css +205 -0
- package/web/index.html +4 -0
- package/web/js/app.js +3 -0
- package/web/js/blocks.js +7 -1
- package/web/js/features/actions.js +3 -0
- package/web/js/features/commands.js +11 -0
- package/web/js/features/composer.js +12 -0
- package/web/js/features/feed.js +3 -0
- package/web/js/features/keys.js +15 -0
- package/web/js/features/launcher.js +56 -11
- package/web/js/features/live.js +9 -1
- package/web/js/features/settings.js +21 -0
- package/web/js/features/slash.js +2 -1
- package/web/js/features/spell-actions.js +64 -0
- package/web/js/features/spell-ui.js +398 -0
- package/web/js/features/spells.js +715 -0
- package/web/js/features/turns.js +2 -0
- package/web/js/spell-schema.js +246 -0
package/web/js/features/slash.js
CHANGED
|
@@ -5,6 +5,7 @@ import { esc } from '../md.js';
|
|
|
5
5
|
import { el } from '../core/dom.js';
|
|
6
6
|
import { SLASH, slashMatches } from '../slash-text.js';
|
|
7
7
|
import { feedbackOn } from './feedback.js';
|
|
8
|
+
import { activeSpells } from './spells.js';
|
|
8
9
|
|
|
9
10
|
// Register before the box's own Enter handler: picking a command must not send it.
|
|
10
11
|
export function slashMenu(ta, host) {
|
|
@@ -13,7 +14,7 @@ export function slashMenu(ta, host) {
|
|
|
13
14
|
let items = [];
|
|
14
15
|
let sel = 0;
|
|
15
16
|
let closed = false; // Esc: hidden until the text changes
|
|
16
|
-
const commands = () => SLASH.filter((c) => c.name !== 'feedback' || feedbackOn());
|
|
17
|
+
const commands = () => [...SLASH.filter((c) => c.name !== 'feedback' || feedbackOn()), ...activeSpells().flatMap((s) => (s.slash || []).map((c) => ({ name: c.name, args: c.args || '', hint: c.hint || `From ${s.name}` })))];
|
|
17
18
|
const paint = () => {
|
|
18
19
|
const next = !closed && document.activeElement === ta ? slashMatches(ta.value, commands()) : [];
|
|
19
20
|
if (next.map((c) => c.name).join() !== items.map((c) => c.name).join()) sel = 0;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// What spell buttons do to tasks, through the same functions the rest of the page uses. Loaded on
|
|
2
|
+
// first use, so spells.js doesn't import half the app.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { api } from '../core/api.js';
|
|
6
|
+
import { $, el, icon } from '../core/dom.js';
|
|
7
|
+
import { S } from '../core/state.js';
|
|
8
|
+
import { overlay } from '../ui/overlay.js';
|
|
9
|
+
import { toast } from '../ui/toast.js';
|
|
10
|
+
import { setArchived, toggleDone } from './actions.js';
|
|
11
|
+
import { recipeParams, runRecipe } from './automations.js';
|
|
12
|
+
import { sendReply } from './composer.js';
|
|
13
|
+
import { shipDialog } from './ship.js';
|
|
14
|
+
|
|
15
|
+
// A follow-up: through the card's box when it's open (optimistic, like typing it), else straight to the API.
|
|
16
|
+
export async function sendTo(id, text) {
|
|
17
|
+
if (S.full.has(id) && S.nodes.get(id)?.body) return sendReply(id, text);
|
|
18
|
+
await api(`/sessions/${id}/reply`, { method: 'POST', body: { text } });
|
|
19
|
+
toast('Sent');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const project = () => S.config.projects.find((p) => p.id === S.filter) || null;
|
|
23
|
+
|
|
24
|
+
export async function launchTask(v, s) {
|
|
25
|
+
const cwd = v.cwd || s?.cwd || project()?.path || '';
|
|
26
|
+
const out = await api('/sessions', { method: 'POST', body: { prompt: v.prompt, ...(cwd ? { cwd } : { scratch: true }), agent: v.agent || undefined, model: v.model || undefined, effort: v.effort || undefined, mode: v.mode || undefined } });
|
|
27
|
+
toast(`Started <b>${esc(out.title)}</b>`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Rerun a task as a new one (its first request unless the spell gives a prompt), on another harness.
|
|
31
|
+
export async function rerunOn(s, v) {
|
|
32
|
+
const full = S.full.get(s.id) || (await api(`/sessions/${s.id}`));
|
|
33
|
+
const prompt = v.prompt || full.turns?.[0]?.prompt || s.title;
|
|
34
|
+
const out = await api('/sessions', { method: 'POST', body: { prompt, cwd: s.scratch ? undefined : s.cwd, scratch: s.scratch || undefined, agent: v.agent || undefined, model: v.model ?? undefined, effort: v.effort || undefined, mode: v.mode || undefined } });
|
|
35
|
+
toast(`Started <b>${esc(out.title)}</b> on ${esc(out.agent)}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function recipe(name) {
|
|
39
|
+
const loose = (x) => String(x).toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
40
|
+
const r = (S.config.recipes || []).find((x) => x.id === name || loose(x.name) === loose(name));
|
|
41
|
+
if (!r) throw new Error(`No recipe named ${name}`);
|
|
42
|
+
runRecipe({ ...r, params: recipeParams(r.prompt) });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const ship = (id) => shipDialog(id);
|
|
46
|
+
export const done = (id) => toggleDone(id);
|
|
47
|
+
export const archive = (id) => setArchived(id, true);
|
|
48
|
+
|
|
49
|
+
export async function exec(id, command) {
|
|
50
|
+
const out = await api(`/sessions/${id}/exec`, { method: 'POST', body: { command, key: `spell:${command}` } });
|
|
51
|
+
const ok = out.exitCode === 0;
|
|
52
|
+
toast(`${icon(ok ? 'check' : 'x')} <code>${esc(command)}</code> ${ok ? 'finished' : `exited ${esc(out.exitCode)}`}`, {
|
|
53
|
+
tone: ok ? '' : 'bad',
|
|
54
|
+
action: {
|
|
55
|
+
label: 'Output',
|
|
56
|
+
run: () => {
|
|
57
|
+
const node = el(`<div class="modal sp-panel"><header><h2>${icon('terminal')} ${esc(command)}</h2><button class="icon-btn close">${icon('x')}</button></header><pre class="code sp-out"><code>${esc(out.output || '(no output)')}</code></pre></div>`);
|
|
58
|
+
const close = overlay(node);
|
|
59
|
+
$('.close', node).onclick = close;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
timeout: 8000,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// Spells you see and handle: the `spell` block a Cast a spell task ends with (preview, Try it,
|
|
2
|
+
// Keep), Settings → Spells (on/off, import, export, cast), and the Cast a spell dialog.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { api } from '../core/api.js';
|
|
6
|
+
import * as bus from '../core/bus.js';
|
|
7
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
8
|
+
import { S } from '../core/state.js';
|
|
9
|
+
import { overlay } from '../ui/overlay.js';
|
|
10
|
+
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
11
|
+
import { registerBlock } from '../blocks.js';
|
|
12
|
+
import { autosize } from './attachments.js';
|
|
13
|
+
import { callSpell, cleanBlocks, drawBlocks, keep, setOn, spellData, tryOn, tryingId, undoTry } from './spells.js';
|
|
14
|
+
|
|
15
|
+
const KIND = {
|
|
16
|
+
safe: ['Safe', 'Changes how wisp looks and what its buttons do. Runs nothing on your computer.'],
|
|
17
|
+
acts: ['Acts for you', 'Sends follow-ups, starts or files tasks, when you click (or on its own, for hooks).'],
|
|
18
|
+
reads: ['Reads GitHub', 'Runs read-only gh commands to show you things.'],
|
|
19
|
+
runs: ['Runs commands', 'Runs commands on your computer or POSTs to a URL. Read what it runs first.'],
|
|
20
|
+
};
|
|
21
|
+
const kindBadge = (k) => `<span class="sp-kind k-${esc(k)}" title="${esc(KIND[k]?.[1] || '')}">${esc(KIND[k]?.[0] || k)}</span>`;
|
|
22
|
+
|
|
23
|
+
const checks = new Map(); // JSON of a spell -> Promise of /spells/check
|
|
24
|
+
const check = (spell) => {
|
|
25
|
+
const key = JSON.stringify(spell);
|
|
26
|
+
if (!checks.has(key)) checks.set(key, api('/spells/check', { method: 'POST', body: { spell } }));
|
|
27
|
+
return checks.get(key);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const EXAMPLES = [
|
|
31
|
+
'A floating button that opens all my open PRs on GitHub, with how many there are',
|
|
32
|
+
'A top bar button that shows my wisp tool stats for the last 30 days',
|
|
33
|
+
'A "deploy" block agents can use to show where they deployed and whether it is healthy',
|
|
34
|
+
'Compact cards with bold titles, the PR first, no machine or project, and an Open PR button on hover',
|
|
35
|
+
'Quick replies above the follow-up box: Run the tests, Ship it, Explain why…',
|
|
36
|
+
'When a task finishes with failing tests, send it back once to fix them',
|
|
37
|
+
'A bigger monospace prompt box without the compare and team buttons',
|
|
38
|
+
'Codex tasks always run the linter before they report',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// --- the spell block -------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function previewButtons(spell) {
|
|
44
|
+
const list = [...(spell.buttons || []), ...(spell.chips || [])];
|
|
45
|
+
if (!list.length) return '';
|
|
46
|
+
return `<div class="sp-pv-btns">${list
|
|
47
|
+
.map((b) => {
|
|
48
|
+
const where = { topbar: 'Top bar', float: 'Floating', palette: '⌘K', 'launcher.bar': 'Prompt box', 'launcher.chips': 'Under the prompt box', 'composer.bar': 'Follow-up box', 'composer.chips': 'Above the follow-up box', 'card.head': 'Card head', 'card.hover': 'Card, on hover', 'card.menu': 'Card menu' }[b.slot] || b.slot;
|
|
49
|
+
const look = b.slot === 'float' ? 'sp-fab' : b.slot === 'topbar' ? 'auto-btn sp-btn' : b.slot === 'card.hover' ? 'it-quick sp-quick' : b.slot?.endsWith('chips') || b.text !== undefined ? 'sp-qr' : 'ghost sm sp-btn';
|
|
50
|
+
const label = b.label || b.text || '';
|
|
51
|
+
return `<span class="sp-pv"><small>${esc(where)}</small><span class="${look}">${b.icon ? icon(b.icon) : b.slot === 'float' || b.slot === 'topbar' ? icon('spark') : ''}${label ? `<span${b.slot === 'float' ? ' class="sp-fab-l"' : ''}>${esc(label)}</span>` : ''}</span></span>`;
|
|
52
|
+
})
|
|
53
|
+
.join('')}</div>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function previewPanel(host, spell) {
|
|
57
|
+
const p = (spell.panels || [])[0];
|
|
58
|
+
if (!p) return;
|
|
59
|
+
host.append(el(`<div class="sp-pv-panel"><small>${esc(p.title || p.id)} · ${p.as === 'card' ? 'in each card' : p.as === 'feed' ? 'above the feed' : 'a dialog'}</small><div class="sp-pv-body"><div class="it-loading"><span class="orb tiny"></span>Rendering with your data…</div></div></div>`));
|
|
60
|
+
const body = $('.sp-pv-body', host);
|
|
61
|
+
try {
|
|
62
|
+
const sample = p.as === 'card' ? [...S.sessions.values()].find((s) => !s.pending && s.agent !== 'wisp') : null;
|
|
63
|
+
const data = await spellData(spell, p.data || [], sample?.id || null);
|
|
64
|
+
body.replaceChildren(drawBlocks(cleanBlocks(await callSpell(spell, p.render, [data, { session: sample || null }]))));
|
|
65
|
+
} catch (err) {
|
|
66
|
+
body.replaceChildren(el(`<div class="sp-error">${icon('warn')}<span>${esc(err.message)}</span></div>`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
registerBlock('spell', (b, ctx) => {
|
|
71
|
+
const sessionId = ctx.session?.id || null;
|
|
72
|
+
// A removal: { remove: id }, or (as agents also write it) { spell: { remove: id } }.
|
|
73
|
+
if (!b.remove && typeof b.spell?.remove === 'string' && !b.spell.wispSpell) b = { ...b, remove: b.spell.remove };
|
|
74
|
+
if (b.remove) {
|
|
75
|
+
const have = (S.config.spells || []).find((s) => s.id === b.remove);
|
|
76
|
+
const node = el(`<div class="b-spell card"><div class="sp-head">${icon('spark')}<b>Remove the spell ${esc(have?.name || b.remove)}</b></div><p class="sp-desc">Turns it off and deletes it. Whatever it changed goes back to how wisp was.</p><div class="sp-acts"><button class="ghost sm danger" data-a="rm">${icon('trash')}Remove</button></div></div>`);
|
|
77
|
+
$('[data-a="rm"]', node).onclick = async (e) => {
|
|
78
|
+
const btn = e.currentTarget;
|
|
79
|
+
btn.disabled = true;
|
|
80
|
+
const r = await tryApi(() => api(`/spells/${encodeURIComponent(b.remove)}`, { method: 'DELETE' }));
|
|
81
|
+
if (r) (btn.innerHTML = `${icon('check')}Removed`), bus.emit('spells:changed');
|
|
82
|
+
else btn.disabled = false;
|
|
83
|
+
};
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
86
|
+
const spell = b.spell && typeof b.spell === 'object' ? b.spell : {};
|
|
87
|
+
const node = el(`
|
|
88
|
+
<div class="b-spell card">
|
|
89
|
+
<div class="sp-head">${icon('spark')}<b>${esc(spell.name || spell.id || 'Spell')}</b><span class="sp-ver">v${esc(spell.version || '1.0.0')}</span><span class="sp-kindslot"></span><span class="sp-state"></span></div>
|
|
90
|
+
${spell.description ? `<p class="sp-desc">${esc(spell.description)}</p>` : ''}
|
|
91
|
+
<ul class="sp-lines"><li class="it-loading"><span class="orb tiny"></span>Checking it…</li></ul>
|
|
92
|
+
<div class="sp-preview"></div>
|
|
93
|
+
<div class="sp-acts">
|
|
94
|
+
<button class="primary sm" data-a="keep" disabled>${icon('check')}Keep</button>
|
|
95
|
+
<button class="ghost sm" data-a="try" disabled>${icon('play')}Try it</button>
|
|
96
|
+
<button class="ghost sm" data-a="json">${icon('text')}JSON</button>
|
|
97
|
+
<button class="ghost sm" data-a="copy">${icon('copy')}Copy</button>
|
|
98
|
+
</div>
|
|
99
|
+
<pre class="code sp-json" hidden><code>${esc(JSON.stringify(spell, null, 2))}</code></pre>
|
|
100
|
+
</div>`);
|
|
101
|
+
const pv = $('.sp-preview', node);
|
|
102
|
+
pv.innerHTML = previewButtons(spell);
|
|
103
|
+
if ((spell.blocks || []).length) pv.insertAdjacentHTML('beforeend', `<p class="sp-pv-note">${icon('spark')}Agents can use ${spell.blocks.map((x) => `<code>${esc(x.type)}</code>`).join(', ')} in their reports once it's on.</p>`);
|
|
104
|
+
const paintState = (r) => {
|
|
105
|
+
const on = (S.config.spells || []).find((s) => s.id === r.spell.id);
|
|
106
|
+
const same = on && JSON.stringify(on) === JSON.stringify({ ...r.spell, kind: on.kind });
|
|
107
|
+
const keepBtn = $('[data-a="keep"]', node);
|
|
108
|
+
const tryBtn = $('[data-a="try"]', node);
|
|
109
|
+
keepBtn.disabled = !!same;
|
|
110
|
+
keepBtn.innerHTML = same ? `${icon('check')}On` : r.installed ? `${icon('check')}${r.installed.same ? 'Turn on' : `Keep (replaces v${esc(r.installed.version)})`}` : `${icon('check')}Keep`;
|
|
111
|
+
tryBtn.disabled = !!same;
|
|
112
|
+
tryBtn.innerHTML = tryingId() === r.spell.id ? `${icon('x')}Stop trying` : `${icon('play')}Try it`;
|
|
113
|
+
$('.sp-state', node).innerHTML = same ? '<span class="sp-on">On</span>' : '';
|
|
114
|
+
};
|
|
115
|
+
check(spell)
|
|
116
|
+
.then((r) => {
|
|
117
|
+
if (!node.isConnected && !node.parentNode) return;
|
|
118
|
+
if (!r.ok) {
|
|
119
|
+
$('.sp-lines', node).innerHTML = r.problems.map((p) => `<li class="bad">${icon('x')}${esc(p)}</li>`).join('');
|
|
120
|
+
const acts = $('.sp-acts', node);
|
|
121
|
+
$('[data-a="keep"]', node).remove();
|
|
122
|
+
$('[data-a="try"]', node).remove();
|
|
123
|
+
if (ctx.reply && ctx.isLatest) {
|
|
124
|
+
const fix = el(`<button class="primary sm" data-a="fix">${icon('pen')}Fix it</button>`);
|
|
125
|
+
fix.onclick = () => {
|
|
126
|
+
ctx.reply(`The spell has problems wisp found:\n${r.problems.map((p) => `- ${p}`).join('\n')}\n\nFix them (check with wisp_spell_check) and hand the spell back.`);
|
|
127
|
+
};
|
|
128
|
+
acts.prepend(fix);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
$('.sp-kindslot', node).innerHTML = kindBadge(r.kind);
|
|
133
|
+
$('.sp-lines', node).innerHTML = r.summary.map((l) => `<li>${icon('check')}${esc(l)}</li>`).join('') + (r.warnings || []).map((w) => `<li class="warn">${icon('warn')}${esc(w)}</li>`).join('');
|
|
134
|
+
paintState(r);
|
|
135
|
+
node.spellCheck = r;
|
|
136
|
+
previewPanel(pv, r.spell);
|
|
137
|
+
})
|
|
138
|
+
.catch((err) => ($('.sp-lines', node).innerHTML = `<li class="bad">${icon('x')}${esc(err.message)}</li>`));
|
|
139
|
+
node.addEventListener('click', async (e) => {
|
|
140
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
141
|
+
const r = node.spellCheck;
|
|
142
|
+
if (a === 'json') $('.sp-json', node).hidden = !$('.sp-json', node).hidden;
|
|
143
|
+
if (a === 'copy') navigator.clipboard.writeText(JSON.stringify(spell, null, 2)).then(() => toast('Copied the spell'));
|
|
144
|
+
if (!r) return;
|
|
145
|
+
if (a === 'try') {
|
|
146
|
+
if (tryingId() === r.spell.id) undoTry();
|
|
147
|
+
else tryOn(r.spell, sessionId);
|
|
148
|
+
paintState(r);
|
|
149
|
+
}
|
|
150
|
+
if (a === 'keep') {
|
|
151
|
+
const btn = e.target.closest('button');
|
|
152
|
+
btn.disabled = true;
|
|
153
|
+
if (await keep(r.spell, sessionId)) paintState(r);
|
|
154
|
+
else btn.disabled = false;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
bus.on('spells', () => node.spellCheck && paintState(node.spellCheck), { node });
|
|
158
|
+
return node;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// --- casting ---------------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export async function cast(prompt) {
|
|
164
|
+
const text = String(prompt || '').trim();
|
|
165
|
+
if (!text) return null;
|
|
166
|
+
const out = await api('/sessions', { method: 'POST', body: { prompt: text, kind: 'spell', agent: S.config.defaults.agent || undefined, model: S.config.defaults.model || undefined, effort: S.config.defaults.effort || undefined } });
|
|
167
|
+
S.sessions.set(out.id, out);
|
|
168
|
+
const { renderFeed, reveal } = await import('./feed.js');
|
|
169
|
+
renderFeed();
|
|
170
|
+
reveal(out.id);
|
|
171
|
+
toast(`${icon('spark')} Casting: the agent writes the spell, then you try it and keep it`);
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function openCast(prefill = '') {
|
|
176
|
+
const node = el(`
|
|
177
|
+
<form class="modal sp-cast">
|
|
178
|
+
<header><h2>${icon('spark')} Cast a spell</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
|
|
179
|
+
<p class="hint">Say what to change in wisp: a button, a panel, a block for agents, a different look, how agents behave. An agent writes it as a spell; you see a preview, try it and keep it. Nothing changes until you do.</p>
|
|
180
|
+
<textarea class="textarea" rows="3" placeholder="A floating button that opens all my PRs…" spellcheck="false"></textarea>
|
|
181
|
+
<div class="sp-examples">${EXAMPLES.slice(0, 6).map((x) => `<button type="button" class="sp-qr">${esc(x)}</button>`).join('')}</div>
|
|
182
|
+
<footer><span class="hint">Runs on ${esc(S.config.defaults.agent === 'auto' || !S.config.defaults.agent ? 'Auto' : S.config.defaults.agent)} in an empty folder of its own: it writes the spell, not your code.</span><button class="primary" type="submit">${icon('spark')}Cast</button></footer>
|
|
183
|
+
</form>`);
|
|
184
|
+
const ta = $('textarea', node);
|
|
185
|
+
ta.value = prefill;
|
|
186
|
+
const close = overlay(node);
|
|
187
|
+
$('.close', node).onclick = close;
|
|
188
|
+
$('.sp-examples', node).onclick = (e) => {
|
|
189
|
+
const b = e.target.closest('button');
|
|
190
|
+
if (!b) return;
|
|
191
|
+
ta.value = b.textContent;
|
|
192
|
+
ta.focus();
|
|
193
|
+
};
|
|
194
|
+
ta.onkeydown = (e) => {
|
|
195
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
node.requestSubmit();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
node.onsubmit = async (e) => {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
const text = ta.value.trim();
|
|
203
|
+
if (!text) return ta.focus();
|
|
204
|
+
close();
|
|
205
|
+
await cast(text).catch((err) => {
|
|
206
|
+
toastError(err);
|
|
207
|
+
openCast(text);
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
setTimeout(() => ta.focus(), 50);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Settings → Spells -----------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const sourceLabel = (src) => (!src ? '' : src.kind === 'cast' ? 'Cast here' : src.kind === 'gist' ? `From a gist${src.owner ? ` by ${src.owner}` : ''}` : src.kind === 'url' ? 'From a link' : src.kind === 'file' ? `Imported${src.name ? ` (${src.name})` : ''}` : 'Added here');
|
|
216
|
+
|
|
217
|
+
function download(name, json) {
|
|
218
|
+
const a = document.createElement('a');
|
|
219
|
+
a.href = URL.createObjectURL(new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' }));
|
|
220
|
+
a.download = name;
|
|
221
|
+
a.click();
|
|
222
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Review before installing: what it does and how risky, and Keep (on) / Add (off) / Cancel.
|
|
226
|
+
export async function reviewImport(body) {
|
|
227
|
+
const r = await api('/spells/check', { method: 'POST', body });
|
|
228
|
+
if (!r.ok) {
|
|
229
|
+
toast(`That spell has problems: ${esc(r.problems.slice(0, 2).join('; '))}${r.problems.length > 2 ? '…' : ''}`, { tone: 'bad', timeout: 8000 });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const node = el(`
|
|
233
|
+
<div class="modal sp-review">
|
|
234
|
+
<header><h2>${icon('spark')} ${esc(r.spell.name)} <span class="sp-ver">v${esc(r.spell.version)}</span></h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
235
|
+
${r.spell.author ? `<p class="hint">By ${esc(r.spell.author)}</p>` : ''}
|
|
236
|
+
${r.spell.description ? `<p>${esc(r.spell.description)}</p>` : ''}
|
|
237
|
+
<div class="sp-review-kind">${kindBadge(r.kind)}<span>${esc(KIND[r.kind]?.[1] || '')}</span></div>
|
|
238
|
+
<ul class="sp-lines">${r.summary.map((l) => `<li>${icon('check')}${esc(l)}</li>`).join('')}</ul>
|
|
239
|
+
${r.installed ? `<p class="hint">You have v${esc(r.installed.version)}${r.installed.enabled ? ' (on)' : ''}; this replaces it.</p>` : ''}
|
|
240
|
+
<details><summary>The spell's JSON</summary><pre class="code"><code>${esc(JSON.stringify(r.spell, null, 2))}</code></pre></details>
|
|
241
|
+
<footer><button class="ghost" data-a="try">${icon('play')}Try it</button><span class="grow"></span><button class="ghost" data-a="add">Add, off</button><button class="primary" data-a="keep">${icon('check')}Keep and turn on</button></footer>
|
|
242
|
+
</div>`);
|
|
243
|
+
const close = overlay(node);
|
|
244
|
+
$('.close', node).onclick = close;
|
|
245
|
+
node.onclick = async (e) => {
|
|
246
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
247
|
+
if (!a) return;
|
|
248
|
+
if (a === 'try') return close(), tryOn(r.spell);
|
|
249
|
+
close();
|
|
250
|
+
if (a === 'keep') return keep(r.spell);
|
|
251
|
+
const out = await tryApi(() => api('/spells', { method: 'POST', body: { spell: r.spell, source: body.url ? undefined : { kind: 'file' } } }));
|
|
252
|
+
if (out) toast(`Added <b>${esc(r.spell.name)}</b>, off. Turn it on in Settings → Spells`), bus.emit('spells:changed');
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function spellsPane(section) {
|
|
257
|
+
section.innerHTML = `
|
|
258
|
+
<div class="sx-group sp-castbox">
|
|
259
|
+
<div class="sx-row"><div class="sx-text"><b>Cast a spell</b><small>Ask for a change to wisp. An agent writes it; you try it and keep it.</small></div></div>
|
|
260
|
+
<div class="sp-prompt">
|
|
261
|
+
<textarea rows="2" placeholder="A floating button that opens all my PRs…" spellcheck="false"></textarea>
|
|
262
|
+
<div class="sp-pbar"><span class="sp-pin">${icon('spark')}Writes a spell, not your code</span><span class="grow"></span><button class="send" data-a="cast" title="Cast (Enter · Shift+Enter for newline)">${icon('enter')}</button></div>
|
|
263
|
+
</div>
|
|
264
|
+
<div class="sp-examples">${EXAMPLES.map((x) => `<button type="button" class="sp-qr">${esc(x)}</button>`).join('')}</div>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="sx-group sp-list"></div>
|
|
267
|
+
<div class="sx-group">
|
|
268
|
+
<div class="sx-row"><div class="sx-text"><b>Add a spell</b><small>A <code>.spell.json</code> file a colleague sent, its JSON, or a link (https or <code>gist:<id></code>). You review it before anything changes.</small></div>
|
|
269
|
+
<div class="sx-ctl"><button class="ghost sm" data-a="file">${icon('file')}File…</button><input type="file" accept=".json,application/json" hidden></div></div>
|
|
270
|
+
<div class="sp-castrow"><input class="input" placeholder="Paste JSON or a link" spellcheck="false"><button class="ghost sm" data-a="paste">Review</button></div>
|
|
271
|
+
</div>
|
|
272
|
+
<p class="hint sp-foot"></p>`;
|
|
273
|
+
const list = $('.sp-list', section);
|
|
274
|
+
const paint = async () => {
|
|
275
|
+
const r = await api('/spells').catch(() => null);
|
|
276
|
+
if (!section.isConnected || !r) return;
|
|
277
|
+
$('.sp-foot', section).innerHTML = r.safe
|
|
278
|
+
? `${icon('warn')} Safe mode: wisp started with <code>WISP_SAFE=1</code> (or <code>--safe</code>), so every spell is off.`
|
|
279
|
+
: `Spells live in <code>~/.wisp/spells</code>. Turning one off puts back exactly what it changed. <button class="link xs" data-a="alloff">Turn every spell off</button>`;
|
|
280
|
+
if (!r.spells.length && !r.broken.length) {
|
|
281
|
+
list.innerHTML = `<div class="sp-none">${icon('spark')}<div><b>No spells yet</b><p>Cast one above, or add one a colleague sent you.</p></div></div>`;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
list.innerHTML =
|
|
285
|
+
r.spells
|
|
286
|
+
.map(
|
|
287
|
+
(s) => `
|
|
288
|
+
<div class="sx-row sp-row" data-id="${esc(s.id)}">
|
|
289
|
+
<div class="sx-text"><b>${esc(s.name)} <span class="sp-ver">v${esc(s.version)}</span> ${kindBadge(s.kind)}</b>
|
|
290
|
+
<small>${esc(s.description || s.summary[0] || '')}${s.source ? ` · ${esc(sourceLabel(s.source))}` : ''}</small>
|
|
291
|
+
<details><summary>What it does</summary><ul class="sp-lines">${s.summary.map((l) => `<li>${esc(l)}</li>`).join('')}</ul></details></div>
|
|
292
|
+
<div class="sx-ctl">
|
|
293
|
+
<button class="icon-btn" data-a="export" title="Export ${esc(s.name)} as a file to share">${icon('arrow')}</button>
|
|
294
|
+
<button class="icon-btn" data-a="copy" title="Copy its JSON (to paste in a chat)">${icon('copy')}</button>
|
|
295
|
+
<button class="icon-btn" data-a="remove" title="Remove">${icon('trash')}</button>
|
|
296
|
+
<button class="switch" role="switch" aria-checked="${s.enabled}" aria-label="${s.enabled ? 'On' : 'Off'}" data-a="toggle" ${r.safe ? 'disabled' : ''}></button>
|
|
297
|
+
</div>
|
|
298
|
+
</div>`,
|
|
299
|
+
)
|
|
300
|
+
.join('') + r.broken.map((b) => `<div class="sx-row sp-row broken" data-id="${esc(b.id)}"><div class="sx-text"><b>${esc(b.id)} <span class="sp-kind k-runs">Broken</span></b><small>${esc(b.error)}</small></div><div class="sx-ctl"><button class="icon-btn" data-a="remove" title="Remove">${icon('trash')}</button></div></div>`).join('');
|
|
301
|
+
};
|
|
302
|
+
paint();
|
|
303
|
+
bus.on('config', paint, { node: section });
|
|
304
|
+
bus.on('spells:changed', paint, { node: section });
|
|
305
|
+
const castTa = $('.sp-castbox textarea', section);
|
|
306
|
+
autosize(castTa, 240);
|
|
307
|
+
// A click on the box's padding or bar lands in the textarea, as on the launcher.
|
|
308
|
+
$('.sp-prompt', section).onmousedown = (e) => {
|
|
309
|
+
if (e.target.closest('button, textarea')) return;
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
castTa.focus();
|
|
312
|
+
};
|
|
313
|
+
const doCast = async () => {
|
|
314
|
+
const text = castTa.value.trim();
|
|
315
|
+
if (!text) return castTa.focus();
|
|
316
|
+
castTa.value = '';
|
|
317
|
+
const out = await cast(text).catch(toastError);
|
|
318
|
+
if (!out) castTa.value = text;
|
|
319
|
+
else $('#overlay').close?.();
|
|
320
|
+
};
|
|
321
|
+
castTa.onkeydown = (e) => {
|
|
322
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
doCast();
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const file = $('input[type=file]', section);
|
|
328
|
+
file.onchange = async () => {
|
|
329
|
+
const f = file.files[0];
|
|
330
|
+
if (f) await reviewImport({ text: await f.text(), name: f.name }).catch(toastError);
|
|
331
|
+
file.value = '';
|
|
332
|
+
};
|
|
333
|
+
section.ondragover = (e) => e.preventDefault();
|
|
334
|
+
section.ondrop = async (e) => {
|
|
335
|
+
const f = e.dataTransfer?.files?.[0];
|
|
336
|
+
if (!f) return;
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
await reviewImport({ text: await f.text(), name: f.name }).catch(toastError);
|
|
339
|
+
};
|
|
340
|
+
section.onclick = async (e) => {
|
|
341
|
+
const b = e.target.closest('[data-a]');
|
|
342
|
+
if (!b) {
|
|
343
|
+
const ex = e.target.closest('.sp-examples .sp-qr');
|
|
344
|
+
if (ex) (castTa.value = ex.textContent), castTa.focus();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const a = b.dataset.a;
|
|
348
|
+
const id = b.closest('[data-id]')?.dataset.id;
|
|
349
|
+
if (a === 'cast') return doCast();
|
|
350
|
+
if (a === 'file') return file.click();
|
|
351
|
+
if (a === 'paste') {
|
|
352
|
+
const v = $('.sp-castrow input', section).value.trim();
|
|
353
|
+
if (!v) return;
|
|
354
|
+
await reviewImport(/^(https:\/\/|gist:)/i.test(v) ? { url: v } : { text: v }).catch(toastError);
|
|
355
|
+
$('.sp-castrow input', section).value = '';
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (a === 'alloff') {
|
|
359
|
+
await tryApi(() => api('/spells/off', { method: 'POST', body: {} }));
|
|
360
|
+
return paint();
|
|
361
|
+
}
|
|
362
|
+
if (a === 'toggle') {
|
|
363
|
+
const on = b.getAttribute('aria-checked') !== 'true';
|
|
364
|
+
b.setAttribute('aria-checked', on); // optimistic
|
|
365
|
+
if (!(await setOn(id, on))) b.setAttribute('aria-checked', !on);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (a === 'export') return download(`${id}.spell.json`, await api(`/spells/${id}/export`));
|
|
369
|
+
if (a === 'copy') {
|
|
370
|
+
await navigator.clipboard.writeText(JSON.stringify(await api(`/spells/${id}/export`), null, 2));
|
|
371
|
+
return toast('Copied. Paste it to a colleague; they add it in Settings → Spells');
|
|
372
|
+
}
|
|
373
|
+
if (a === 'remove') {
|
|
374
|
+
const row = b.closest('.sp-row');
|
|
375
|
+
row.hidden = true; // optimistic
|
|
376
|
+
const r = await tryApi(() => api(`/spells/${id}`, { method: 'DELETE' }));
|
|
377
|
+
if (!r) row.hidden = false;
|
|
378
|
+
else bus.emit('spells:changed');
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Drop a .spell.json anywhere on the window: review it.
|
|
384
|
+
document.addEventListener('drop', async (e) => {
|
|
385
|
+
const f = [...(e.dataTransfer?.files || [])].find((x) => /\.spell\.json$/i.test(x.name));
|
|
386
|
+
if (!f || e.defaultPrevented) return;
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
await reviewImport({ text: await f.text(), name: f.name }).catch(toastError);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// #/spell?src=gist:… (a share link opened in wisp): review it.
|
|
392
|
+
export function routeSpell() {
|
|
393
|
+
const m = location.hash.match(/^#\/spell\?src=(.+)$/);
|
|
394
|
+
if (!m) return false;
|
|
395
|
+
history.replaceState(null, '', '#/');
|
|
396
|
+
reviewImport({ url: decodeURIComponent(m[1]) }).catch(toastError);
|
|
397
|
+
return true;
|
|
398
|
+
}
|