wisp-agents 0.24.0 → 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 +23 -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 +39 -1
- 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/server/update.js +50 -16
- 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/features/update.js +1 -2
- package/web/js/spell-schema.js +246 -0
package/server/update.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Update check: polls the npm registry for a newer wisp-agents and, when asked, installs it and
|
|
2
2
|
// relaunches wisp. How depends on how this copy was installed:
|
|
3
|
-
// global npm install -g wisp-agents@latest, then rerun bin/wisp.js
|
|
3
|
+
// global npm install -g wisp-agents@latest (also what install.sh does), then rerun bin/wisp.js
|
|
4
4
|
// npx nothing to install: relaunch through `npx -y wisp-agents@latest`
|
|
5
|
-
//
|
|
5
|
+
// brew brew upgrade m0xoo/tap/wisp-agents, then rerun it from the keg's opt/ link
|
|
6
|
+
// git a clone of the repo, i.e. working on wisp itself: never offers an update
|
|
6
7
|
// Opt out with WISP_UPDATE_CHECK=0.
|
|
7
8
|
|
|
8
9
|
import { spawn } from 'node:child_process';
|
|
@@ -16,6 +17,9 @@ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
|
|
|
16
17
|
const REGISTRY = `https://registry.npmjs.org/${PKG.name}/latest`;
|
|
17
18
|
const DAY = 24 * 3600_000;
|
|
18
19
|
const TICK = 30 * 60_000; // timers stall while the machine sleeps, so poll the wall clock instead
|
|
20
|
+
const TAP = 'm0xoo/tap/wisp-agents';
|
|
21
|
+
// <prefix>/Cellar/wisp-agents/<version>/libexec/lib/node_modules/wisp-agents
|
|
22
|
+
const CELLAR = /^(.*)[\\/]Cellar[\\/]wisp-agents[\\/]/;
|
|
19
23
|
|
|
20
24
|
export function newer(a, b) {
|
|
21
25
|
const p = (v) => String(v).replace(/^v/, '').split(/[-+]/)[0].split('.').map(Number);
|
|
@@ -25,20 +29,23 @@ export function newer(a, b) {
|
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export function installMethod(root = ROOT) {
|
|
32
|
+
if (CELLAR.test(root)) return 'brew';
|
|
28
33
|
if (fs.existsSync(path.join(root, '.git'))) return 'git';
|
|
29
34
|
if (/[\\/]_npx[\\/]/.test(root)) return 'npx';
|
|
30
35
|
return 'global';
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export class Updater {
|
|
34
|
-
constructor({ port, onChange = () => {}, env = process.env, fetch = globalThis.fetch, now = Date.now } = {}) {
|
|
39
|
+
constructor({ port, onChange = () => {}, env = process.env, fetch = globalThis.fetch, now = Date.now, root = ROOT } = {}) {
|
|
35
40
|
this.port = port;
|
|
36
41
|
this.onChange = onChange;
|
|
37
42
|
this.fetch = fetch;
|
|
38
43
|
this.now = now;
|
|
39
44
|
this.checkedAt = -Infinity;
|
|
40
|
-
this.
|
|
41
|
-
this.method = installMethod();
|
|
45
|
+
this.root = root;
|
|
46
|
+
this.method = installMethod(root);
|
|
47
|
+
this.brew = root.match(CELLAR)?.[1]; // Homebrew's prefix, e.g. /opt/homebrew
|
|
48
|
+
this.enabled = this.method !== 'git' && !/^(0|false|no|off)$/i.test(env.WISP_UPDATE_CHECK || '') && !env.CI;
|
|
42
49
|
this.state = { current: PKG.version, latest: null, available: false, method: this.method, status: 'idle', error: null };
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -60,9 +67,20 @@ export class Updater {
|
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
view() {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
return { ...this.state, command: this.command() };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// What installs the latest version; shown to copy if it fails. A clone has none.
|
|
74
|
+
command() {
|
|
75
|
+
if (this.method === 'brew') return `${JSON.stringify(path.join(this.brew, 'bin', 'brew'))} upgrade ${TAP}`;
|
|
76
|
+
return { global: `npm install -g ${PKG.name}@latest`, npx: `npx -y ${PKG.name}@latest` }[this.method];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Where the updated copy lives: the same folder, except Homebrew puts each version in its own
|
|
80
|
+
// keg and keeps opt/wisp-agents pointing at the current one.
|
|
81
|
+
home() {
|
|
82
|
+
if (this.method !== 'brew') return this.root;
|
|
83
|
+
return path.join(this.brew, 'opt', 'wisp-agents', 'libexec', 'lib', 'node_modules', PKG.name);
|
|
66
84
|
}
|
|
67
85
|
|
|
68
86
|
async check() {
|
|
@@ -71,20 +89,28 @@ export class Updater {
|
|
|
71
89
|
const r = await this.fetch(REGISTRY, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10_000) });
|
|
72
90
|
if (!r.ok) return this.view();
|
|
73
91
|
const { version } = await r.json();
|
|
74
|
-
const available = newer(version, PKG.version);
|
|
92
|
+
const available = this.method !== 'git' && newer(version, PKG.version);
|
|
75
93
|
if (version !== this.state.latest || available !== this.state.available) this.set({ latest: version, available });
|
|
76
94
|
} catch {} // offline: try again next round
|
|
77
95
|
return this.view();
|
|
78
96
|
}
|
|
79
97
|
|
|
80
|
-
// Installs (
|
|
81
|
-
// free up and starts the new version. The caller shuts this process down.
|
|
98
|
+
// Installs (npm or brew; npx fetches on relaunch) then hands off to a detached helper that waits
|
|
99
|
+
// for this server's port to free up and starts the new version. The caller shuts this process down.
|
|
82
100
|
async install() {
|
|
83
|
-
if (this.method === 'git') throw bad('This is a git
|
|
101
|
+
if (this.method === 'git') throw bad('This copy of wisp is a git clone, so it does not update itself.');
|
|
84
102
|
if (this.state.status === 'installing') throw bad('Already updating.', 409);
|
|
85
103
|
this.set({ status: 'installing', error: null });
|
|
86
104
|
try {
|
|
87
|
-
if (this.method
|
|
105
|
+
if (this.method !== 'npx') {
|
|
106
|
+
// Old kegs stay until brew's own periodic cleanup: this process still runs from one.
|
|
107
|
+
await run(this.command(), this.method === 'brew' ? { HOMEBREW_NO_INSTALL_CLEANUP: '1' } : {});
|
|
108
|
+
// Relaunching a copy that didn't change would leave the UI waiting for a version that never comes.
|
|
109
|
+
const got = installedVersion(this.home());
|
|
110
|
+
if (!newer(got, PKG.version)) {
|
|
111
|
+
throw bad(this.method === 'brew' ? `Homebrew doesn't have wisp ${this.state.latest} yet. Try again later.` : `npm installed wisp somewhere else: this copy is still ${got || PKG.version}.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
88
114
|
} catch (err) {
|
|
89
115
|
this.set({ status: 'idle', error: err.message });
|
|
90
116
|
throw err;
|
|
@@ -97,13 +123,21 @@ export class Updater {
|
|
|
97
123
|
relaunchCommand(electron = !!process.versions.electron) {
|
|
98
124
|
const args = `${electron ? '' : ' --browser --no-open'} --port ${this.port}`; // desktop: reopen the app; browser: keep the tab
|
|
99
125
|
const node = JSON.stringify(electron ? 'node' : process.execPath);
|
|
100
|
-
return this.method === 'npx' ? `npx -y ${PKG.name}@latest${args}` : `${node} ${JSON.stringify(path.join(
|
|
126
|
+
return this.method === 'npx' ? `npx -y ${PKG.name}@latest${args}` : `${node} ${JSON.stringify(path.join(this.home(), 'bin', 'wisp.js'))}${args}`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function installedVersion(dir) {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
101
135
|
}
|
|
102
136
|
}
|
|
103
137
|
|
|
104
|
-
function run(cmd) {
|
|
138
|
+
function run(cmd, env = {}) {
|
|
105
139
|
return new Promise((resolve, reject) => {
|
|
106
|
-
const child = spawn(cmd, { shell: true, stdio: ['ignore', 'ignore', 'pipe'], env: withoutElectronNode() });
|
|
140
|
+
const child = spawn(cmd, { shell: true, stdio: ['ignore', 'ignore', 'pipe'], env: { ...withoutElectronNode(), ...env } });
|
|
107
141
|
let err = '';
|
|
108
142
|
child.stderr.on('data', (d) => (err = (err + d).slice(-2000)));
|
|
109
143
|
child.on('error', reject);
|
package/web/css/feed.css
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* ---------- feed ---------- */
|
|
2
2
|
.feed-head { display: flex; align-items: center; gap: 14px; min-height: 22px; margin: 26px 0 10px; padding: 0 4px; font-size: 13px; }
|
|
3
|
-
.feed-head[hidden]
|
|
3
|
+
.feed-head[hidden] ~ .feed { margin-top: 26px; }
|
|
4
4
|
.hs { display: inline-flex; align-items: center; gap: 7px; color: var(--ink-2); }
|
|
5
5
|
.hs i { width: 7px; height: 7px; border-radius: 50%; }
|
|
6
6
|
.hs.working i { background: var(--accent); animation: pulse 1.6s infinite; }
|
|
@@ -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 =
|
|
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);
|
package/web/js/features/feed.js
CHANGED
|
@@ -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>`;
|
package/web/js/features/keys.js
CHANGED
|
@@ -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();
|