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.
Files changed (41) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +44 -4
  3. package/bin/wisp.js +55 -1
  4. package/package.json +2 -2
  5. package/server/adapters/codex.js +3 -0
  6. package/server/client.js +2 -2
  7. package/server/index.js +17 -2
  8. package/server/mcp-ask.js +44 -2
  9. package/server/openapi.js +38 -0
  10. package/server/protocol.js +32 -3
  11. package/server/render.js +16 -1
  12. package/server/routes/automations.js +8 -5
  13. package/server/routes/internal.js +61 -0
  14. package/server/routes/meta.js +5 -1
  15. package/server/routes/settings.js +17 -3
  16. package/server/routes/spells.js +147 -0
  17. package/server/runner.js +10 -3
  18. package/server/services/launch.js +7 -2
  19. package/server/services/spell-hooks.js +99 -0
  20. package/server/spell-guide.js +135 -0
  21. package/server/spells-data.js +60 -0
  22. package/server/spells.js +647 -0
  23. package/web/css/feed.css +1 -1
  24. package/web/css/spells.css +205 -0
  25. package/web/index.html +4 -0
  26. package/web/js/app.js +3 -0
  27. package/web/js/blocks.js +7 -1
  28. package/web/js/features/actions.js +3 -0
  29. package/web/js/features/commands.js +11 -0
  30. package/web/js/features/composer.js +12 -0
  31. package/web/js/features/feed.js +3 -0
  32. package/web/js/features/keys.js +15 -0
  33. package/web/js/features/launcher.js +56 -11
  34. package/web/js/features/live.js +9 -1
  35. package/web/js/features/settings.js +21 -0
  36. package/web/js/features/slash.js +2 -1
  37. package/web/js/features/spell-actions.js +64 -0
  38. package/web/js/features/spell-ui.js +398 -0
  39. package/web/js/features/spells.js +715 -0
  40. package/web/js/features/turns.js +2 -0
  41. package/web/js/spell-schema.js +246 -0
@@ -0,0 +1,205 @@
1
+ /* Spells: what spells add to wisp (slots, panels, blocks), and Settings → Spells. Plain 1px neutral
2
+ borders like the panels next to them. */
3
+
4
+ .sp-sandbox { position: absolute; width: 0; height: 0; border: 0; visibility: hidden; }
5
+
6
+ /* top bar */
7
+ .sp-top { display: contents; }
8
+ .sp-btn .sp-n, .sp-fab .sp-n, .sp-qr .sp-n { min-width: 17px; height: 17px; padding: 0 5px; border-radius: 99px; font-size: 10.5px; font-weight: 600; display: inline-grid; place-items: center; background: var(--accent-soft); color: var(--accent-ink); }
9
+ .sp-btn .i { width: 14px; height: 14px; }
10
+
11
+ /* floating buttons */
12
+ .sp-float {
13
+ position: fixed; right: 22px; bottom: 22px; z-index: 30; display: flex; flex-direction: column-reverse; align-items: flex-end; gap: 10px;
14
+ padding-bottom: env(safe-area-inset-bottom);
15
+ }
16
+ .sp-float.left { right: auto; left: 22px; align-items: flex-start; }
17
+ .sp-float[hidden] { display: none; }
18
+ .sp-fab {
19
+ display: inline-flex; align-items: center; gap: 8px; height: 42px; padding: 0 14px 0 13px; border-radius: 21px;
20
+ background: var(--surface); color: var(--ink-2); border: 1px solid var(--line-2); box-shadow: var(--shadow-2);
21
+ font-size: 13.5px; font-weight: 500; transition: translate 0.15s var(--ease), color 0.15s, border-color 0.15s;
22
+ animation: sp-in 0.25s var(--ease);
23
+ }
24
+ .sp-fab .i { width: 16px; height: 16px; }
25
+ .sp-fab:hover { color: var(--ink); border-color: var(--faint); translate: 0 -2px; }
26
+ .sp-fab.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
27
+ .sp-fab:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
28
+ @keyframes sp-in { from { opacity: 0; transform: translateY(8px) scale(0.97); } }
29
+ .sp-fan { display: flex; flex-direction: column-reverse; align-items: inherit; gap: 8px; }
30
+ .sp-fan-items { display: none; flex-direction: column-reverse; align-items: inherit; gap: 8px; }
31
+ .sp-fan.open .sp-fan-items { display: flex; }
32
+ .sp-fan.open .sp-fan-btn { color: var(--ink); border-color: var(--faint); }
33
+ body:has(.launcher.docked:is(:focus-within, :has(.open))) .sp-float { bottom: 96px; }
34
+
35
+ /* card head chips and hover buttons */
36
+ .sp-chip {
37
+ display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 7px; border-radius: 6px; font-size: 11.5px; white-space: nowrap;
38
+ color: var(--muted); background: var(--bg-2); border: 1px solid var(--line);
39
+ }
40
+ .sp-chip .i { width: 12px; height: 12px; }
41
+ .sp-chip.tone-good { color: var(--good); }
42
+ .sp-chip.tone-bad { color: var(--bad); }
43
+ .sp-chip.tone-warn { color: var(--warn); }
44
+ .sp-chip.tone-info { color: var(--info); }
45
+ button.sp-chip:hover { border-color: var(--line-2); color: var(--ink); }
46
+ .sp-quick:hover { color: var(--accent); }
47
+
48
+ /* prompt box and follow-up box */
49
+ .sp-lbar, .sp-cbar { display: inline-flex; align-items: center; gap: 6px; }
50
+ .sp-lbar:empty, .sp-cbar:empty { display: none; }
51
+ .sp-lchips, .sp-cchips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 2px 0; }
52
+ .sp-cchips { padding: 0 0 8px; }
53
+ .sp-cchips[hidden] { display: none; }
54
+ .sp-qr {
55
+ display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 13px; font-size: 12.5px;
56
+ color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); transition: border-color 0.15s, color 0.15s;
57
+ text-align: left; max-width: 100%;
58
+ }
59
+ .sp-qr .i { width: 13px; height: 13px; }
60
+ .sp-qr:hover { border-color: var(--line-2); color: var(--ink); }
61
+
62
+ /* panels: in cards, above the feed, and as dialogs */
63
+ .sp-card { display: grid; gap: 10px; padding: 12px 14px 0; }
64
+ .sp-card[hidden] { display: none; }
65
+ .sp-cpanel, .sp-feed { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
66
+ .sp-cpanel > header, .sp-feed > header { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-bottom: 1px solid var(--line); font-size: 12.5px; color: var(--ink-2); }
67
+ .sp-cpanel > header .i, .sp-feed > header .i { width: 14px; height: 14px; color: var(--muted); }
68
+ .sp-by { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--faint); }
69
+ .sp-by .i { width: 12px; height: 12px; }
70
+ .sp-cpanel-body, .sp-feed-body { padding: 12px; }
71
+ .sp-cpanel-body .presentation, .sp-feed-body .presentation, .sp-panel-body .presentation, .sp-pv-body .presentation, .sp-block .presentation { gap: 10px; }
72
+ .sp-feedtop { display: grid; gap: 12px; margin: 14px 0; }
73
+ .sp-feedtop[hidden] { display: none; }
74
+ .sp-panel { width: min(720px, 100%); }
75
+ .sp-panel header { gap: 10px; }
76
+ .sp-panel header h2 { display: flex; align-items: center; gap: 8px; margin-right: auto; }
77
+ .sp-panel header h2 .i { width: 18px; height: 18px; color: var(--muted); }
78
+ .sp-panel .sp-by { margin-left: 0; }
79
+ .sp-drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(460px, 100%); max-height: none; border-radius: 0; border-width: 0 0 0 1px; }
80
+ .sp-out { max-height: 60vh; overflow: auto; white-space: pre-wrap; }
81
+ .sp-error { display: flex; gap: 8px; align-items: flex-start; padding: 10px 12px; border-radius: 9px; font-size: 13px; color: var(--bad); background: var(--del-bg); }
82
+ .sp-error .i { flex: none; width: 15px; height: 15px; margin-top: 1px; }
83
+ .sp-empty { margin: 0; color: var(--faint); font-size: 13px; }
84
+
85
+ /* blocks spells add to reports */
86
+ .sp-off { padding: 10px 12px; }
87
+ .sp-off-head { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
88
+ .sp-off-head .i { width: 14px; height: 14px; }
89
+ .sp-off-head .link { margin-left: auto; }
90
+ .sp-off pre { margin: 10px 0 0; }
91
+
92
+ /* the spell block in a Cast a spell report */
93
+ .b-spell { padding: 14px 16px; display: grid; gap: 10px; }
94
+ .sp-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; }
95
+ .sp-head > .i { width: 17px; height: 17px; color: var(--accent); }
96
+ .sp-ver { font: 500 11.5px var(--mono); color: var(--faint); }
97
+ .sp-state { margin-left: auto; }
98
+ .sp-on { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 10px; font-size: 11.5px; font-weight: 600; color: var(--good); background: var(--add-bg); }
99
+ .sp-desc { margin: 0; color: var(--ink-2); font-size: 13.5px; }
100
+ .sp-lines { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; font-size: 13px; color: var(--ink-2); }
101
+ .sp-lines li { display: flex; gap: 8px; align-items: flex-start; }
102
+ .sp-lines li .i { flex: none; width: 13px; height: 13px; margin-top: 3px; color: var(--good); }
103
+ .sp-lines li.bad, .sp-lines li.bad .i { color: var(--bad); }
104
+ .sp-lines li.warn .i { color: var(--warn); }
105
+ .sp-preview { display: grid; gap: 10px; }
106
+ .sp-preview:empty { display: none; }
107
+ .sp-pv-btns { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); }
108
+ .sp-pv { display: grid; gap: 5px; justify-items: start; }
109
+ .sp-pv small, .sp-pv-panel small { font-size: 11px; color: var(--faint); text-transform: uppercase; letter-spacing: 0.04em; }
110
+ .sp-pv .sp-fab { animation: none; box-shadow: none; }
111
+ .sp-pv .sp-quick { opacity: 1; margin: 0; }
112
+ .sp-pv > span:not(small) { pointer-events: none; }
113
+ .sp-pv-panel { display: grid; gap: 6px; }
114
+ .sp-pv-body { padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
115
+ .sp-pv-note { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; color: var(--muted); }
116
+ .sp-pv-note .i { width: 13px; height: 13px; color: var(--accent); }
117
+ .sp-acts { display: flex; flex-wrap: wrap; gap: 8px; }
118
+ .sp-acts .i { width: 14px; height: 14px; }
119
+ .sp-json { margin: 0; max-height: 360px; overflow: auto; }
120
+
121
+ /* how risky */
122
+ .sp-kind { display: inline-flex; align-items: center; height: 19px; padding: 0 7px; border-radius: 6px; font: 600 11px var(--sans); letter-spacing: 0.01em; border: 1px solid var(--line); color: var(--muted); background: var(--surface-2); vertical-align: 1px; }
123
+ .sp-kind.k-acts { color: var(--info); }
124
+ .sp-kind.k-reads { color: var(--ink-2); }
125
+ .sp-kind.k-runs { color: var(--warn); }
126
+
127
+ /* trying a spell */
128
+ .sp-try {
129
+ position: fixed; left: 50%; top: 70px; translate: -50% 0; z-index: 60; display: flex; align-items: center; gap: 10px;
130
+ padding: 8px 8px 8px 14px; border-radius: 14px; background: var(--surface); border: 1px solid var(--line-2); box-shadow: var(--shadow-2); font-size: 13.5px;
131
+ animation: sp-in 0.25s var(--ease);
132
+ }
133
+ .sp-try > .i { width: 15px; height: 15px; color: var(--accent); }
134
+
135
+ /* cast, review and Settings → Spells */
136
+ .sp-cast { display: grid; gap: 12px; }
137
+ .sp-cast header, .sp-review header { margin-bottom: 0; }
138
+ .sp-cast h2, .sp-review h2 { display: flex; align-items: center; gap: 8px; }
139
+ .sp-cast h2 .i, .sp-review h2 .i { width: 18px; height: 18px; color: var(--accent); }
140
+ .sp-cast footer, .sp-review footer, .sp-confirm footer { display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
141
+ .sp-cast footer .hint { margin-right: auto; }
142
+ .sp-review { display: grid; gap: 12px; }
143
+ .sp-review p { margin: 0; }
144
+ .sp-review .grow { flex: 1; }
145
+ .sp-review-kind { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); font-size: 13px; color: var(--ink-2); }
146
+ .sp-review details pre { max-height: 300px; overflow: auto; }
147
+ .sp-confirm { display: grid; gap: 12px; }
148
+ .sp-confirm p { margin: 0; }
149
+ .sp-examples { display: flex; flex-wrap: wrap; gap: 6px; }
150
+ /* a long example wraps inside its chip instead of spilling over the next one */
151
+ .sp-examples .sp-qr { height: auto; min-height: 26px; padding-block: 3px; line-height: 1.4; }
152
+ .sp-castbox { display: grid; gap: 10px; padding-bottom: 14px; }
153
+ .sp-castbox > .sp-examples { padding: 0 14px; }
154
+ .sp-castrow { display: flex; gap: 8px; align-items: center; padding: 0 14px 14px; }
155
+ .sp-castrow:has(.textarea) { align-items: flex-start; }
156
+ .sp-castrow .textarea { min-height: 56px; }
157
+ /* the cast box: the launcher's prompt in small, the send button in a bar under the text */
158
+ .sp-prompt {
159
+ margin: 0 14px; padding: 8px 10px 8px; border: 1px solid var(--line-2); border-radius: 12px; background: var(--surface);
160
+ box-shadow: var(--shadow-1); cursor: text; transition: box-shadow 0.15s, border-color 0.15s;
161
+ }
162
+ .sp-prompt:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--line-2)); box-shadow: 0 0 0 3px var(--accent-soft); }
163
+ .sp-prompt textarea {
164
+ display: block; width: 100%; resize: none; border: 0; outline: 0; background: transparent; padding: 6px 4px;
165
+ font-size: 15px; line-height: 1.55; color: var(--ink); min-height: 56px;
166
+ }
167
+ .sp-prompt textarea::placeholder { color: var(--faint); }
168
+ .sp-pbar { display: flex; align-items: center; gap: 8px; padding-top: 4px; }
169
+ .sp-pbar .grow { flex: 1; }
170
+ .sp-pin { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--faint); }
171
+ .sp-pin .i { width: 13px; height: 13px; color: var(--accent); }
172
+ .sp-castrow .primary, .sp-castrow .ghost { flex: none; }
173
+ .sp-row .sx-text details { margin-top: 4px; font-size: 12.5px; color: var(--muted); }
174
+ .sp-row .sx-text summary { cursor: pointer; width: max-content; }
175
+ .sp-row .sp-lines { margin-top: 6px; }
176
+ .sp-row .sx-ctl { display: flex; align-items: center; gap: 4px; }
177
+ .sp-row.broken b { color: var(--bad); }
178
+ .sp-none { display: flex; gap: 12px; align-items: center; padding: 16px 4px; color: var(--muted); }
179
+ .sp-none > .i { width: 20px; height: 20px; color: var(--accent); }
180
+ .sp-none p { margin: 2px 0 0; font-size: 13px; }
181
+ .sp-foot .link { vertical-align: baseline; }
182
+ .sp-lock { display: flex !important; align-items: center; gap: 5px; margin-top: 3px; color: var(--accent-ink) !important; }
183
+ .sp-lock .i { width: 12px; height: 12px; }
184
+ .menu .sp-menu { display: flex; align-items: center; gap: 9px; width: 100%; }
185
+
186
+ /* card density */
187
+ body.sp-compact #feed .it-head { padding-top: 7px; padding-bottom: 7px; }
188
+ body.sp-compact #feed .it-sub { display: none; }
189
+ body.sp-compact #feed .f-list { gap: 6px; }
190
+ body.sp-comfy #feed .it-head { padding-top: 15px; padding-bottom: 15px; }
191
+
192
+ @media (max-width: 640px) {
193
+ .sp-float { right: 14px; bottom: 14px; }
194
+ .sp-fab { height: 40px; padding: 0 12px; }
195
+ .sp-fab .sp-fab-l { display: none; }
196
+ .sp-fan.open .sp-fab-l { display: inline; }
197
+ .sp-try { left: 12px; right: 12px; translate: none; top: 62px; }
198
+ .sp-castrow { flex-direction: column; align-items: stretch; }
199
+ /* bar buttons fold to their icon (a button without one keeps its label) */
200
+ .sp-lbar .sp-btn:has(.i) > span, .sp-cbar .sp-btn:has(.i) > span { display: none; }
201
+ .sp-lbar .sp-btn, .sp-cbar .sp-btn { padding: 0 7px; }
202
+ }
203
+
204
+ /* the floating stack moves up while the Collapse button is out */
205
+ body:has(.collapse-all.on) .sp-float:not(.left) { bottom: 76px; }
package/web/index.html CHANGED
@@ -23,6 +23,7 @@
23
23
  <link rel="stylesheet" href="/css/subagents.css">
24
24
  <link rel="stylesheet" href="/css/auto.css">
25
25
  <link rel="stylesheet" href="/css/transcript.css">
26
+ <link rel="stylesheet" href="/css/spells.css">
26
27
  </head>
27
28
  <body>
28
29
  <svg width="0" height="0" style="position:absolute" aria-hidden="true">
@@ -98,6 +99,7 @@
98
99
  <button class="auto-btn" id="autoBtn" title="Automations: recipes and triggers">
99
100
  <svg class="i"><use href="#i-bolt"/></svg><span>Automations</span><b class="ab-n" hidden></b>
100
101
  </button>
102
+ <div class="sp-top" id="spTop"></div>
101
103
  <button class="search-btn" id="searchBtn" title="Search">
102
104
  <svg class="i"><use href="#i-search"/></svg><span>Search</span><kbd class="mod-k"></kbd>
103
105
  </button>
@@ -117,6 +119,7 @@
117
119
  </section>
118
120
  </div>
119
121
  <div class="feed-head" id="feedHead"></div>
122
+ <div class="sp-feedtop" id="spFeedTop" hidden></div>
120
123
  <div class="feed" id="feed">
121
124
  <section class="f-group needs" id="secNeeds" hidden><h4>Needs you<span class="n"></span></h4><div class="f-list"></div></section>
122
125
  <section class="f-group working" id="secWorking" hidden><h4>Working<span class="n"></span></h4><div class="f-list"></div></section>
@@ -131,6 +134,7 @@
131
134
  <svg class="i"><use href="#i-send"/></svg><kbd>U</kbd>
132
135
  </button>
133
136
  <div class="drag-strip"></div>
137
+ <div class="sp-float" id="spFloat" hidden></div>
134
138
  <div class="overlay" id="overlay" hidden></div>
135
139
  <div class="toasts" id="toasts"></div>
136
140
 
package/web/js/app.js CHANGED
@@ -19,6 +19,8 @@ import { onKey } from './features/keys.js';
19
19
  import { backToTop, onScroll, stage } from './features/scroll.js';
20
20
  import { openSettings, telemetryNotice } from './features/settings.js';
21
21
  import { installUpdate, loadUpdate } from './features/update.js';
22
+ import { routeSpell } from './features/spell-ui.js';
23
+ import './features/spells.js';
22
24
 
23
25
  document.addEventListener('keydown', onKey);
24
26
  stage.addEventListener('scroll', onScroll, { passive: true });
@@ -58,6 +60,7 @@ if (/Electron/.test(navigator.userAgent)) document.body.classList.add('electron'
58
60
 
59
61
  // #/s/<id> still works (notifications, CLI): it reveals that item in the feed.
60
62
  function route() {
63
+ if (routeSpell()) return; // #/spell?src=…: a shared spell to review
61
64
  const m = location.hash.match(/^#\/s\/(\w+)/);
62
65
  if (!m) return;
63
66
  history.replaceState(null, '', '#/');
package/web/js/blocks.js CHANGED
@@ -930,6 +930,12 @@ R.unknown = (b) => el(`<div class="b-code card"><div class="code-head"><span cla
930
930
 
931
931
  // --- public -----------------------------------------------------------------
932
932
 
933
+ // Block types defined elsewhere: spells (features/spells.js) register the `spell` block and draw
934
+ // the types spells add through the fallback, before R.unknown.
935
+ export const registerBlock = (type, fn) => (R[type] = fn);
936
+ let fallback = () => null;
937
+ export const setBlockFallback = (fn) => (fallback = fn);
938
+
933
939
  export function renderPresentation(presentation, ctx) {
934
940
  const wrap = document.createElement('div');
935
941
  wrap.className = 'presentation';
@@ -939,7 +945,7 @@ export function renderPresentation(presentation, ctx) {
939
945
  const delay = reduced() ? 0 : Math.min(i, 10) * 110;
940
946
  let node;
941
947
  try {
942
- node = (R[b.type] || R.unknown)(b, { ...ctx, delay });
948
+ node = R[b.type] ? R[b.type](b, { ...ctx, delay }) : fallback(b, { ...ctx, delay }) || R.unknown(b);
943
949
  } catch (err) {
944
950
  console.error('block failed', b, err);
945
951
  node = R.unknown(b);
@@ -23,6 +23,7 @@ import { machineById, pathAt, placeName, projectById } from './places.js';
23
23
  import { agentById, agentName, DEFAULT_MODE, effortsFor, harnessesAt, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
24
24
  import { feedAnchor, keepInPlace } from './scroll.js';
25
25
  import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
26
+ import { slotHtml } from './spells.js';
26
27
 
27
28
  export async function toggleDone(id) {
28
29
  const s = S.sessions.get(id);
@@ -192,9 +193,11 @@ export function taskMenu(id, e) {
192
193
  <button data-a="recipe">${icon('spark')}Save as recipe…</button>
193
194
  ${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
194
195
  ${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
196
+ ${slotHtml('card.menu', s).replace(/class="ghost sm sp-btn"/g, 'class="sp-menu"').replace(/<button /g, `<button data-task="${s.id}" `)}
195
197
  <button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
196
198
  </div>`);
197
199
  menu.onclick = async (ev) => {
200
+ if (ev.target.closest('[data-sp]')) return close();
198
201
  const a = ev.target.closest('button')?.dataset.a;
199
202
  close();
200
203
  if (a === 'open') expand(id);
@@ -23,6 +23,8 @@ import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
23
23
  import { transcriptOn } from './transcript.js';
24
24
  import { replay, toggleTranscript } from './turns.js';
25
25
  import { openUsage } from './usage.js';
26
+ import { activeSpells, runAction } from './spells.js';
27
+ import { openCast } from './spell-ui.js';
26
28
 
27
29
  const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
28
30
 
@@ -96,6 +98,15 @@ function paletteItems() {
96
98
  { group: a, label: 'Settings', icon: 'settings', keywords: 'preferences lessons learn retro editor terminal notifications', run: openSettings },
97
99
  { group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
98
100
  );
101
+ items.push(
102
+ { group: a, label: 'Cast a spell…', icon: 'spark', keywords: 'spell customize change wisp add button panel block theme plugin extension mod', run: () => openCast() },
103
+ { group: a, label: 'Spells', icon: 'spark', keywords: 'spells installed manage import export share customize', run: () => openSettings({ pane: 'spells' }) },
104
+ );
105
+ // What spells add to ⌘K: their commands, and their buttons for the palette slot.
106
+ for (const spell of activeSpells()) {
107
+ const cmds = [...(spell.commands || []), ...(spell.buttons || []).filter((b) => b.slot === 'palette')];
108
+ for (const c of cmds) items.push({ group: 'Spells', label: c.label, sub: spell.name, icon: c.icon || 'spark', kbd: c.key ? c.key.replace(/^shift\+/i, '⇧').toUpperCase() : undefined, keywords: `spell ${c.keywords || ''}`, run: () => runAction(spell, c.do, { session: S.focus }) });
109
+ }
99
110
  if (feedbackOn()) items.push({ group: a, label: 'Send feedback…', icon: 'thumb-up', keywords: 'feedback bug report idea suggestion thumbs contact team', run: () => openFeedback() });
100
111
 
101
112
  for (const r of S.config.recipes || []) items.push({ group: 'Recipes', label: `Run: ${r.name}`, sub: r.prompt.replace(/\s+/g, ' ').slice(0, 80), icon: 'spark', keywords: `recipe ${r.prompt}`, run: () => runRecipe({ ...r, params: recipeParams(r.prompt) }) });
@@ -24,6 +24,8 @@ import { feedAnchor, keepInPlace, watching } from './scroll.js';
24
24
  import { shipDialog } from './ship.js';
25
25
  import { slashMenu } from './slash.js';
26
26
  import { renderBody } from './turns.js';
27
+ import { paintCard, runAction, spellProp } from './spells.js';
28
+ import { spellSlash } from './launcher.js';
27
29
 
28
30
  export function draftInto(id, text) {
29
31
  const ta = $('.reply', S.nodes.get(id)?.card);
@@ -91,6 +93,7 @@ export function renderDock(id) {
91
93
  }
92
94
  });
93
95
  $('.send', comp).onclick = () => sendReply(id, ta.value);
96
+ paintCard(id); // what spells add to the box and the card
94
97
  }
95
98
 
96
99
  // The mode the next follow-up runs in: the session's own until the user picks another here.
@@ -243,6 +246,8 @@ export function updateDock(id) {
243
246
  ? `Follow up with ${next} to reopen…`
244
247
  : 'Follow up to reopen…'
245
248
  : `Follow up with ${next}…`;
249
+ const spellPh = spellProp('composer.input', 'placeholder');
250
+ if (spellPh && !tour && !s.ask) ta.placeholder = spellPh;
246
251
  paintSide(id);
247
252
  paintReplyMode(id, $('.c-mode', n.card));
248
253
  paintReplyRunner(id, $('.c-runner', n.card));
@@ -335,6 +340,13 @@ export async function sendReply(id, text) {
335
340
  text = (text || '').trim();
336
341
  if (!s || !text) return;
337
342
  const ta = n && $('.reply', n.card);
343
+ // A spell's slash command: it does its thing on this task instead of being sent.
344
+ const slash = !isBtw(text) && spellSlash(text);
345
+ if (slash) {
346
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
347
+ S.drafts.delete(id);
348
+ return runAction(slash.spell, slash.cmd.do, { session: id, args: slash.args, input: ta });
349
+ }
338
350
  // A side question: answered next to the run, so nothing is interrupted, queued or switched.
339
351
  if (isBtw(text)) {
340
352
  const question = btwQuestion(text);
@@ -16,6 +16,7 @@ import { scrollByPx, stage, topInset } from './scroll.js';
16
16
  import { prOf, prPill, shipPill } from './ship.js';
17
17
  import { teamStack } from './team.js';
18
18
  import { renderBody } from './turns.js';
19
+ import { slotHtml } from './spells.js';
19
20
 
20
21
  function visible(list) {
21
22
  return S.filter === 'all' ? list : list.filter((s) => inProject(s, S.filter));
@@ -247,7 +248,9 @@ function renderHead(n, s) {
247
248
  ${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route?.switched ? null : s.route)}
248
249
  ${s.machine ? machinePill(s.machine) : ''}
249
250
  ${projectLabel(s)}
251
+ ${s.pending ? '' : slotHtml('card.head', s)}
250
252
  <span class="q-time">${ago(s.updatedAt)}</span>
253
+ ${s.pending ? '' : slotHtml('card.hover', s)}
251
254
  ${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
252
255
  ${icon('chevron', 'chev')}
253
256
  </span>`;
@@ -11,6 +11,10 @@ import { focusLauncher } from './launcher.js';
11
11
  import { backToTop } from './scroll.js';
12
12
  import { shipDialog, shippable } from './ship.js';
13
13
  import { replay, toggleTranscript } from './turns.js';
14
+ import { activeSpells, runAction } from './spells.js';
15
+
16
+ // Keys wisp uses itself (with or without Shift), which spells can't claim.
17
+ const BUILT_IN = new Set(['n', 'j', 'k', 'o', 'enter', 'e', 'a', 'c', 'u', 'home', 'm', 'delete', 'backspace', 't', 'shift+r', 's', 'r', 'v', '/', '?', 'shift+?', 'escape', ...'123456789'.split('')]);
14
18
 
15
19
  export function onKey(e) {
16
20
  // ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
@@ -37,6 +41,17 @@ export function onKey(e) {
37
41
  if (typing || e.metaKey || e.ctrlKey || e.altKey || !$('#overlay').hidden) return;
38
42
  const k = e.key.toLowerCase();
39
43
  const id = S.focus && S.nodes.has(S.focus) ? S.focus : null;
44
+ // A spell's key (only keys wisp doesn't use itself: spells can't take over built-in ones).
45
+ const combo = `${e.shiftKey ? 'shift+' : ''}${k}`;
46
+ if (!BUILT_IN.has(combo)) {
47
+ for (const spell of activeSpells()) {
48
+ const c = [...(spell.commands || []), ...(spell.buttons || [])].find((x) => x.key && x.key.toLowerCase() === combo);
49
+ if (c) {
50
+ e.preventDefault();
51
+ return runAction(spell, c.do, { session: id });
52
+ }
53
+ }
54
+ }
40
55
  if (k === 'n') {
41
56
  e.preventDefault();
42
57
  focusLauncher();
@@ -27,6 +27,8 @@ import { needsSetup, nudgeSetup, renderSetup } from './setup.js';
27
27
  import { agentName, agentsAt, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
28
28
  import { feedAnchor, keepInPlace, stage, topInset } from './scroll.js';
29
29
  import { canLead, paintTeamBtn, teamEditor } from './team.js';
30
+ import { activeSpells, launcherParts, runAction, spellProp } from './spells.js';
31
+ import { cast } from './spell-ui.js';
30
32
 
31
33
  // "No folder": a general ask with no project. The server gives it an empty scratch directory.
32
34
  const NO_FOLDER = 'none';
@@ -72,6 +74,19 @@ function syncWhere() {
72
74
  launch.project = storedPick() || null;
73
75
  }
74
76
 
77
+ // A spell's slash command at the start of `text`: { spell, cmd, args } or null.
78
+ export function spellSlash(text) {
79
+ const m = /^\/([a-z][a-z0-9-]*)(?:\s+([\s\S]*))?$/i.exec(String(text || '').trim());
80
+ if (!m) return null;
81
+ for (const spell of activeSpells()) {
82
+ const cmd = (spell.slash || []).find((c) => c.name === m[1].toLowerCase());
83
+ if (cmd) return { spell, cmd, args: (m[2] || '').trim() };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ bus.on('spells:launcher', () => $('#launcher:not(.skel)') && renderLauncher());
89
+
75
90
  export function renderLauncher() {
76
91
  const box = $('#launcher');
77
92
  const mode = S.config.defaults.mode || DEFAULT_MODE.id;
@@ -104,18 +119,19 @@ export function renderLauncher() {
104
119
  <div class="l-recipes"></div>`;
105
120
  renderLaunchChips();
106
121
  renderSetup();
122
+ launcherParts(box); // what spells add to the prompt box
107
123
 
108
124
  const ta = mentionBox($('#prompt', box));
109
125
  const tray = attachTray();
110
126
  ta.parentNode.before(tray.node); // before the mention box's wrapper
111
127
  acceptFiles(box, ta, tray);
112
- autosize(ta);
128
+ autosize(ta, (spellProp('launcher.input', 'maxRows') || 0) * 24 || undefined);
113
129
  ta.value = S.drafts.get('home') || '';
114
130
  ta.addEventListener('input', () => (S.drafts.set('home', ta.value), paintSaveRecipe()));
115
131
  // While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
116
- const hint = () => (ta.placeholder = document.activeElement === ta
132
+ const hint = () => (ta.placeholder = spellProp('launcher.input', 'placeholder') || (document.activeElement === ta
117
133
  ? 'What should I go do? Paste screenshots, drop logs or videos…'
118
- : `What should I go do? Press ${combo('L')} to start typing`);
134
+ : `What should I go do? Press ${combo('L')} to start typing`));
119
135
  ta.addEventListener('focus', hint);
120
136
  ta.addEventListener('blur', hint);
121
137
  hint();
@@ -247,8 +263,7 @@ export function renderLauncher() {
247
263
  });
248
264
  });
249
265
  paintCompare();
250
- runnerBtn.onclick = () =>
251
- runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launch.where, auto: true }, (id, model, effort) => {
266
+ const pickRun = (id, model, effort) => {
252
267
  agent = harnessesAt(launch.where, { auto: true }).find((a) => a.id === id);
253
268
  curModel = model;
254
269
  curEffort = effort;
@@ -260,11 +275,38 @@ export function renderLauncher() {
260
275
  const efforts = effort || effortsFor(id, model).length ? { ...d.efforts, [id]: effort } : d.efforts;
261
276
  Object.assign(d, { agent: id, model, models, effort, efforts });
262
277
  api('/config', { method: 'PATCH', body: { defaults: { agent: id, model, models, effort, efforts } } }).catch(() => {});
263
- });
278
+ };
279
+ runnerBtn.onclick = () => runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launch.where, auto: true }, pickRun);
280
+ // Spells: set the pickers (setLaunch), or launch once with other settings (launchWith).
281
+ box.spellSet = (v) => {
282
+ if (v.agent && harnessesAt(launch.where, { auto: true }).some((a) => a.id === v.agent)) pickRun(v.agent, v.model ?? savedModel(v.agent), v.effort ?? savedEffort(v.agent, v.model ?? savedModel(v.agent)));
283
+ else if (v.agent) toast(`${esc(agentName(v.agent))} isn’t installed here`, { tone: 'bad' });
284
+ if (v.mode) setMode(v.mode);
285
+ };
286
+ box.spellLaunch = (v) => go(v);
264
287
 
265
- const go = async () => {
288
+ // over: a spell's launchWith ({ agent, model, effort, mode, text }) for this one launch.
289
+ const go = async (over = null) => {
290
+ if (over && !(over instanceof Event) && over.text && !ta.value.trim().startsWith(over.text)) ta.value = `${over.text}${ta.value.trim() ? `\n\n${ta.value.trim()}` : ''}`;
291
+ if (over instanceof Event) over = null;
266
292
  const prompt = ta.value.trim();
267
293
  if (!prompt) return ta.focus();
294
+ // /spell <what to change>: cast a spell instead of starting a task.
295
+ const spellAsk = /^\/spell(?:\s+|$)([\s\S]*)$/i.exec(prompt);
296
+ if (spellAsk) {
297
+ if (!spellAsk[1].trim()) return toast('Say what to change after /spell, like “/spell a floating button that opens my PRs”');
298
+ ta.value = '';
299
+ S.drafts.delete('home');
300
+ ta.dispatchEvent(new Event('input'));
301
+ return cast(spellAsk[1]).catch((err) => (toastError(err), (ta.value = prompt), ta.dispatchEvent(new Event('input'))));
302
+ }
303
+ // A spell's slash command, typed in the prompt box.
304
+ const slash = spellSlash(prompt);
305
+ if (slash) {
306
+ ta.value = '';
307
+ ta.dispatchEvent(new Event('input'));
308
+ return runAction(slash.spell, slash.cmd.do, { args: slash.args, input: ta });
309
+ }
268
310
  if (isFeedback(prompt)) {
269
311
  ta.value = '';
270
312
  ta.dispatchEvent(new Event('input'));
@@ -277,7 +319,7 @@ export function renderLauncher() {
277
319
  const off = targets.find((t) => t.m && t.m.state !== 'online');
278
320
  if (off) return offlineToast(off.k);
279
321
  if (needsSetup()) return nudgeSetup();
280
- const wanted = [agent?.id, ...compare.map((c) => c.agent)].filter((id) => id !== 'auto'); // Auto picks from what's there
322
+ const wanted = [over?.agent || agent?.id, ...compare.map((c) => c.agent)].filter((id) => id !== 'auto'); // Auto picks from what's there
281
323
  for (const t of targets) {
282
324
  const list = agentsAt(t.k);
283
325
  const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
@@ -290,7 +332,8 @@ export function renderLauncher() {
290
332
  const attachments = await tray.ids();
291
333
  const files = tray.take();
292
334
  const cmp = compare;
293
- const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
335
+ const runner = over?.agent ? { agent: over.agent, model: over.model ?? savedModel(over.agent), effort: over.effort ?? '' } : { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
336
+ const launchMode = over?.mode || curMode;
294
337
  // A repo still cloning gets a stand-in project; its card says so until the clone lands.
295
338
  const fetching = (t) => t.k && [t.project, ...t.also].some((p) => p && !p.scratch && !pathAt(p, t.k)); // not on that machine yet
296
339
  const pending = targets.map((t) => addPending(prompt, t.project ? t : { ...t, project: { id: t.clone.id, name: t.clone.name, path: '', color: 'slate' } }, runner, t.clone ? `Cloning ${t.clone.label}` : fetching(t) ? `Cloning ${t.project.name} on ${placeName(t.k)}` : undefined));
@@ -320,7 +363,7 @@ export function renderLauncher() {
320
363
  targets.map((t) =>
321
364
  api('/sessions', {
322
365
  method: 'POST',
323
- body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, also: t.also.length ? t.also.map((p) => p.path) : undefined, scratch: t.project.scratch || undefined, machine: t.k, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
366
+ body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, also: t.also.length ? t.also.map((p) => p.path) : undefined, scratch: t.project.scratch || undefined, machine: t.k, groupId, agent: runner.agent, mode: launchMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
324
367
  }),
325
368
  ),
326
369
  );
@@ -347,7 +390,9 @@ export function renderLauncher() {
347
390
  tray.restore(files);
348
391
  }
349
392
  };
350
- $('#launch', box).onclick = go;
393
+ $('#launch', box).onclick = () => {
394
+ go();
395
+ };
351
396
  // Picking other places (or a machine coming online) changes which harnesses exist: keep the
352
397
  // pick if one of them still has it, else move to one that's installed there.
353
398
  box.placesChanged = () => {
@@ -17,6 +17,7 @@ import { openSettings } from './settings.js';
17
17
  import { renderPrStrip } from './ship.js';
18
18
  import { onChildSummary } from './team.js';
19
19
  import { onActivity } from './turns.js';
20
+ import { applySpells, spellEvent } from './spells.js';
20
21
  import { loadUpdate, renderUpdate } from './update.js';
21
22
 
22
23
  export function connect() {
@@ -37,10 +38,16 @@ export function connect() {
37
38
  keepInPlace(follow ? S.nodes.get(s.id).card : feedAnchor(s.id), renderFeed);
38
39
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
39
40
  if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state, prev?.shipping]) !== JSON.stringify([s.pr, s.toShip, s.state, s.shipping])) renderPrStrip(s.id);
41
+ if (prev?.state !== s.state || prev?.pr?.state !== s.pr?.state) spellEvent('summary');
40
42
  });
41
43
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
42
44
  es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
43
- es.addEventListener('finished', (e) => notifyFinished(JSON.parse(e.data).session));
45
+ es.addEventListener('finished', (e) => (notifyFinished(JSON.parse(e.data).session), spellEvent('finished')));
46
+ // A spell hook's notify, from the server.
47
+ es.addEventListener('spell', (e) => {
48
+ const m = JSON.parse(e.data);
49
+ if (m.toast) toast(`<b>${esc(m.spell)}</b> · ${esc(m.toast)}`);
50
+ });
44
51
  es.addEventListener('removed', (e) => {
45
52
  const { id } = JSON.parse(e.data);
46
53
  S.sessions.delete(id);
@@ -80,6 +87,7 @@ export async function loadSessions() {
80
87
 
81
88
  export async function loadConfig() {
82
89
  S.config = await api('/config');
90
+ applySpells();
83
91
  renderProjects();
84
92
  renderLaunchChips();
85
93
  paintAutoBtn();
@@ -23,6 +23,7 @@ import { copySetupCommand, INSTALLABLE, installHarness, isInstalling, setupComma
23
23
  import { clearPicks } from './transcript.js';
24
24
  import { repaintViews } from './turns.js';
25
25
  import { openUsage } from './usage.js';
26
+ import { spellsPane } from './spell-ui.js';
26
27
 
27
28
  // A sidebar of sections and one pane at a time, like every other app's settings. Big lists
28
29
  // (memory, automations, usage history) keep their own views; a pane only summarises and links.
@@ -34,6 +35,7 @@ const SETTINGS_PANES = [
34
35
  { id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
35
36
  { id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
36
37
  { id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
38
+ { id: 'spells', label: 'Spells', icon: 'spark', lede: 'Changes to wisp itself: buttons, panels, blocks, looks and behaviour. Cast one, or add one a colleague sent.' },
37
39
  { id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
38
40
  { id: 'privacy', label: 'Privacy', icon: 'info', lede: 'What wisp sends about itself. Never prompts, paths or code.' },
39
41
  ];
@@ -135,6 +137,7 @@ export function openSettings(opts) {
135
137
  ${row('Triggers', '<span class="trigger-hint"></span>', '<button class="ghost sm" data-auto="triggers">Manage</button>')}
136
138
  </div>
137
139
  </section>
140
+ <section class="sx-pane sp-pane" data-pane="spells"></section>
138
141
  <section class="sx-pane" data-pane="usage">
139
142
  <div class="us-tiles sx-usage"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
140
143
  <div class="sx-group">${row('Charts and breakdowns', 'Per day, per project and per harness · model', '<button class="ghost sm" id="openUsage">Open usage</button>')}</div>
@@ -149,6 +152,7 @@ export function openSettings(opts) {
149
152
  </div>`);
150
153
  const close = overlay(node);
151
154
  $('.close', node).onclick = close;
155
+ spellsPane($('[data-pane="spells"].sx-pane', node));
152
156
  const paintAgents = () => {
153
157
  $('.sx-agents', node).innerHTML = S.agents.length
154
158
  ? S.agents.map((a) => {
@@ -441,6 +445,23 @@ export function openSettings(opts) {
441
445
  $('#openRouter', node).onclick = () => openRouter({ host: host('auto') });
442
446
  $('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
443
447
  $$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
448
+ // Settings a spell sets are locked while it's on: the row says which spell, and links to Spells.
449
+ const LOCKABLE = { transcript: '#txDefault', worktree: '#wtDefault', fixConflicts: '#fixConflicts', learn: '#learn', editor: 'select[name="editor"]', notify: '#notif' };
450
+ const paintLocks = () => {
451
+ for (const n of $$('.sp-lock', node)) n.remove();
452
+ for (const [key, sel] of Object.entries(LOCKABLE)) {
453
+ const ctl = $(sel, node);
454
+ if (!ctl) continue;
455
+ const by = S.config.lockedBy?.[key];
456
+ ctl.disabled = !!by;
457
+ if (by) $('.sx-text', ctl.closest('.sx-row'))?.insertAdjacentHTML('beforeend', `<small class="sp-lock">${icon('spark')}Set by the spell <b>${esc(by.name)}</b> · <button class="link xs" data-pane-go="spells">Spells</button></small>`);
458
+ }
459
+ };
460
+ paintLocks();
461
+ bus.on('config', paintLocks, { node });
462
+ node.addEventListener('click', (e) => {
463
+ if (e.target.closest('[data-pane-go]')) show(e.target.closest('[data-pane-go]').dataset.paneGo);
464
+ });
444
465
  $('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
445
466
  $('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
446
467
  $('#openMachines', node).onclick = () => openMachines({ host: host('machines') });