wisp-agents 0.15.0 → 0.16.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 +39 -1
- package/README.md +28 -12
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +56 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +339 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +87 -36
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +62 -0
- package/web/css/feed.css +208 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +16 -1
- package/web/js/app.js +28 -5237
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +339 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +104 -0
- package/web/js/features/composer.js +311 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +559 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +161 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1910
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Ship it: whether a worktree session has something to ship, the draft commit message and PR
|
|
2
|
+
// text, and following open PRs (checks, reviews, merges) so their cards stay current.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { worktreeStatus, prView } from '../git.js';
|
|
6
|
+
import { blocksToMarkdown } from '../render.js';
|
|
7
|
+
import { summarize } from '../runner.js';
|
|
8
|
+
|
|
9
|
+
const PR_POLL_MS = 60_000;
|
|
10
|
+
const SHIP_RECHECK_MS = 5_000;
|
|
11
|
+
|
|
12
|
+
export function createShip(app) {
|
|
13
|
+
const { store, runner } = app;
|
|
14
|
+
|
|
15
|
+
// Ship it shows only when the worktree has something to ship: uncommitted changes, or commits
|
|
16
|
+
// not yet pushed. Rechecked whenever wisp might have changed the worktree.
|
|
17
|
+
const check = async (s) => {
|
|
18
|
+
if (!s?.worktree || s.parentId) return;
|
|
19
|
+
const { dirty, ahead, unpushed } = await worktreeStatus(s.worktree);
|
|
20
|
+
const next = { dirty, ahead, unpushed };
|
|
21
|
+
if (JSON.stringify(next) === JSON.stringify(s.toShip)) return;
|
|
22
|
+
s.toShip = next;
|
|
23
|
+
app.sessionChanged(s);
|
|
24
|
+
};
|
|
25
|
+
// One check at a time per session (one asked for meanwhile runs right after); `lazy` (a card
|
|
26
|
+
// fetching its session) skips running sessions and runs at most every few seconds.
|
|
27
|
+
const checks = new Map(); // id → true while checking, 'again' if another check is due
|
|
28
|
+
const checkedAt = new Map();
|
|
29
|
+
const recheck = (s, { lazy = false } = {}) => {
|
|
30
|
+
if (!s?.worktree || s.parentId) return;
|
|
31
|
+
if (lazy && (runner.live.has(s.id) || checks.has(s.id) || Date.now() - (checkedAt.get(s.id) || 0) < SHIP_RECHECK_MS)) return;
|
|
32
|
+
if (checks.has(s.id)) return void checks.set(s.id, 'again');
|
|
33
|
+
checks.set(s.id, true);
|
|
34
|
+
checkedAt.set(s.id, Date.now());
|
|
35
|
+
check(s)
|
|
36
|
+
.catch(() => {})
|
|
37
|
+
.finally(() => {
|
|
38
|
+
const again = checks.get(s.id) === 'again';
|
|
39
|
+
checks.delete(s.id);
|
|
40
|
+
if (again) recheck(s);
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const refreshPr = async (s) => {
|
|
45
|
+
if (!s.pr || !s.worktree) return s.pr;
|
|
46
|
+
const prev = s.pr;
|
|
47
|
+
const dir = fs.existsSync(s.worktree.path) ? s.worktree.path : s.worktree.root;
|
|
48
|
+
const pr = await prView(dir, prev.number);
|
|
49
|
+
const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
|
|
50
|
+
s.pr = pr;
|
|
51
|
+
if (pr.state !== prev.state) app.auto.signals(summarize(s, store));
|
|
52
|
+
// Things worth coming back for: CI finished either way, a review landed, it merged or closed.
|
|
53
|
+
const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
|
|
54
|
+
if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
|
|
55
|
+
app.sessionChanged(s);
|
|
56
|
+
return pr;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Default commit message and PR text, from the session's latest presentation.
|
|
60
|
+
const draft = (s) => {
|
|
61
|
+
const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
|
|
62
|
+
const blocks = turn?.presentation?.blocks || [];
|
|
63
|
+
const hero = blocks.find((b) => b?.type === 'hero');
|
|
64
|
+
const text = `${hero?.title || s.title}`.trim();
|
|
65
|
+
const conventional = /^[a-z]+(\([\w./-]+\))?!?: /.test(text);
|
|
66
|
+
const type = /\b(fix|bug|broken|crash|flaky|regression)/i.test(`${text} ${s.turns[0]?.prompt || ''}`) ? 'fix' : /\b(docs?|readme|changelog)\b/i.test(text) ? 'docs' : /\b(refactor|clean ?up|simplif)/i.test(text) ? 'refactor' : /\btests?\b/i.test(text) ? 'test' : 'feat';
|
|
67
|
+
const title = conventional ? text : `${type}: ${text.charAt(0).toLowerCase()}${text.slice(1)}`;
|
|
68
|
+
const keep = blocks.filter((b) => !['hero', 'next', 'question', 'diff', 'code', 'image', 'terminal'].includes(b?.type));
|
|
69
|
+
const body = [hero?.summary, blocksToMarkdown(keep, turn)].filter(Boolean).join('\n\n').trim();
|
|
70
|
+
return { title: title.slice(0, 100), body: `${body}\n\n_Shipped from [wisp](https://github.com/M0xoo/wisp)._`.trim() };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
runner.on('ended', ({ session }) => recheck(store.get(session.id)));
|
|
74
|
+
for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
|
|
75
|
+
|
|
76
|
+
const timer = setInterval(async () => {
|
|
77
|
+
for (const s of store.all()) if (s.pr?.state === 'OPEN') await refreshPr(s).catch(() => {});
|
|
78
|
+
}, PR_POLL_MS);
|
|
79
|
+
timer.unref();
|
|
80
|
+
|
|
81
|
+
return { recheck, refreshPr, draft, stop: () => clearInterval(timer) };
|
|
82
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// What the runner, the router and the retro report to telemetry: fixed values about how a turn
|
|
2
|
+
// ran and ended, never the prompt or the result.
|
|
3
|
+
|
|
4
|
+
import { envDisabled } from '../telemetry.js';
|
|
5
|
+
|
|
6
|
+
export const runProps = (s, t) => ({ agent: s.agent, model: s.model || 'default', mode: s.mode, effort: s.effort || 'default', followUp: s.turns.indexOf(t) > 0 });
|
|
7
|
+
|
|
8
|
+
// Auto's choice, as fixed values only: never the prompt.
|
|
9
|
+
const routeProps = (s) =>
|
|
10
|
+
s.route ? { routed: true, routeGoal: s.route.goal, routeType: s.route.type, routeSize: s.route.size, routeExplored: !!s.route.explored, routeTrying: !!s.route.trying, routePropensity: s.route.propensity } : {};
|
|
11
|
+
|
|
12
|
+
export function trackTurns({ runner, telemetry }) {
|
|
13
|
+
runner.on('started', ({ session, turn }) => telemetry.track('turn_started', { ...runProps(session, turn), attachments: turn.attachments.length }));
|
|
14
|
+
runner.on('ended', ({ session, turn, interrupted, errorText }) => {
|
|
15
|
+
const blocks = turn.presentation?.blocks || [];
|
|
16
|
+
const st = turn.stats || {};
|
|
17
|
+
telemetry.track('turn_ended', {
|
|
18
|
+
...runProps(session, turn),
|
|
19
|
+
...routeProps(session),
|
|
20
|
+
status: interrupted ? 'interrupted' : turn.status, // done | error | stopped | interrupted
|
|
21
|
+
heroStatus: blocks.find((b) => b.type === 'hero')?.status,
|
|
22
|
+
durationMs: turn.endedAt - turn.startedAt,
|
|
23
|
+
blockCount: blocks.length,
|
|
24
|
+
blockTypes: [...new Set(blocks.map((b) => b.type))],
|
|
25
|
+
presentationFallback: !!turn.presentation?.fallback, // agent ignored or broke the wisp protocol
|
|
26
|
+
changedFiles: turn.changes.length,
|
|
27
|
+
inputTokens: st.inputTokens,
|
|
28
|
+
outputTokens: st.outputTokens,
|
|
29
|
+
costUsd: st.costUsd,
|
|
30
|
+
});
|
|
31
|
+
if (errorText) telemetry.captureError(new Error(errorText), { type: 'AgentError', ...runProps(session, turn) });
|
|
32
|
+
});
|
|
33
|
+
if (!envDisabled()) process.on('uncaughtExceptionMonitor', (err) => telemetry.crashed(err));
|
|
34
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Attachments: raw bodies streamed to disk, referenced by id from launches and replies.
|
|
2
|
+
// Each upload is <id>-<name> next to an <id>.json with its metadata.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { MIME } from '../http/static.js';
|
|
7
|
+
import { bad, newId } from '../util.js';
|
|
8
|
+
|
|
9
|
+
export const MAX_UPLOAD = 500 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export class Uploads {
|
|
12
|
+
constructor(dir) {
|
|
13
|
+
this.dir = dir;
|
|
14
|
+
this.byId = new Map(); // id -> { id, name, mime, size, path }
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
16
|
+
for (const f of fs.readdirSync(dir)) {
|
|
17
|
+
if (!f.endsWith('.json')) continue;
|
|
18
|
+
try {
|
|
19
|
+
const u = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
20
|
+
this.byId.set(u.id, u);
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(id) {
|
|
26
|
+
return this.byId.get(id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Upload ids from a request body → their records (unknown ids are dropped).
|
|
30
|
+
resolve(ids) {
|
|
31
|
+
return Array.isArray(ids) ? ids.map((i) => this.byId.get(i)).filter(Boolean) : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async receive(req, { name, type }) {
|
|
35
|
+
name = path.basename(name || 'file').replace(/[^\w.\- ]+/g, '_').slice(-120) || 'file';
|
|
36
|
+
const id = newId();
|
|
37
|
+
const file = path.join(this.dir, `${id}-${name}`);
|
|
38
|
+
let size = 0;
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
const out = fs.createWriteStream(file);
|
|
41
|
+
req.on('data', (c) => {
|
|
42
|
+
size += c.length;
|
|
43
|
+
if (size > MAX_UPLOAD) {
|
|
44
|
+
req.destroy();
|
|
45
|
+
out.destroy();
|
|
46
|
+
fs.rmSync(file, { force: true });
|
|
47
|
+
reject(bad('file too large (500 MB max)', 413));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
req.pipe(out);
|
|
51
|
+
out.on('finish', resolve);
|
|
52
|
+
out.on('error', reject);
|
|
53
|
+
});
|
|
54
|
+
const meta = { id, name, size, mime: type || MIME[path.extname(name).toLowerCase()] || 'application/octet-stream', path: file };
|
|
55
|
+
this.byId.set(id, meta);
|
|
56
|
+
fs.writeFileSync(path.join(this.dir, `${id}.json`), JSON.stringify(meta));
|
|
57
|
+
return meta;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// POST { event, session, markdown } to each configured webhook (Slack bridges, CI, notifiers).
|
|
2
|
+
// event: session.waiting (blocked on ask_user) | session.finished. Fire and forget.
|
|
3
|
+
|
|
4
|
+
import { sessionToMarkdown } from '../render.js';
|
|
5
|
+
import { VERSION } from '../util.js';
|
|
6
|
+
|
|
7
|
+
export function notifyWebhooks(store, summary) {
|
|
8
|
+
const hooks = store.config.webhooks || [];
|
|
9
|
+
if (!hooks.length) return;
|
|
10
|
+
const s = store.get(summary.id);
|
|
11
|
+
if (!s) return;
|
|
12
|
+
const event = s.state === 'waiting' ? 'session.waiting' : 'session.finished';
|
|
13
|
+
const body = JSON.stringify({ event, session: summary, markdown: sessionToMarkdown(s), at: Date.now() });
|
|
14
|
+
for (const h of hooks) {
|
|
15
|
+
if (h.events?.length && !h.events.includes(event)) continue;
|
|
16
|
+
fetch(h.url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `wisp/${VERSION}` }, body, signal: AbortSignal.timeout(10_000) }).catch((err) =>
|
|
17
|
+
console.warn(`wisp: webhook ${h.url} failed: ${err.message}`),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A webhook entry from the config API: a URL string or { url, events }.
|
|
23
|
+
export function normalizeWebhooks(list) {
|
|
24
|
+
const hooks = Array.isArray(list) ? list : [];
|
|
25
|
+
const wrong = hooks.find((h) => !/^https?:\/\/\S+$/.test(String(h?.url ?? h)));
|
|
26
|
+
if (wrong !== undefined) return { error: `Not an http(s) URL: ${wrong?.url ?? wrong}` };
|
|
27
|
+
return { hooks: hooks.map((h) => (typeof h === 'string' ? { url: h } : { url: h.url, events: Array.isArray(h.events) ? h.events : undefined })) };
|
|
28
|
+
}
|
package/server/store.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
7
|
import { parsePresentation } from './protocol.js';
|
|
8
|
+
import { writeJsonAtomic } from './util.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_CONFIG = {
|
|
10
11
|
projects: [], // [{ id, name, path, color, addedAt, machine? }]; machine: the server/machines.js id it lives on
|
|
@@ -72,7 +73,9 @@ export class Store {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
saveConfig() {
|
|
75
|
-
|
|
76
|
+
// Atomic: a crash mid-write used to leave a cut-off config.json, which loads as the defaults
|
|
77
|
+
// (no projects, recipes, triggers or machines) and is then saved over.
|
|
78
|
+
writeJsonAtomic(path.join(this.home, 'config.json'), this.config, 2);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
// --- projects: plain folders, matched to sessions by path prefix ---
|
|
@@ -130,8 +133,7 @@ export class Store {
|
|
|
130
133
|
this.timers.delete(session.id);
|
|
131
134
|
if (!this.sessions.has(session.id)) return;
|
|
132
135
|
const file = path.join(this.dir, `${session.id}.json`);
|
|
133
|
-
|
|
134
|
-
fs.renameSync(`${file}.tmp`, file);
|
|
136
|
+
writeJsonAtomic(file, session);
|
|
135
137
|
};
|
|
136
138
|
if (now) write();
|
|
137
139
|
else this.timers.set(session.id, setTimeout(write, 400));
|
package/server/subagents.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// hidden child session working in the lead's folder; delegating to it again resumes it.
|
|
4
4
|
|
|
5
5
|
import { blocksToMarkdown } from './render.js';
|
|
6
|
+
import { bad } from './util.js';
|
|
6
7
|
|
|
7
8
|
export const MAX_SUBAGENTS = 6; // rows in the team
|
|
8
9
|
export const MAX_COPIES = 5; // parallel copies of one row
|
|
@@ -20,7 +21,7 @@ export function normalizeTeam(list, adapters) {
|
|
|
20
21
|
const team = list.slice(0, MAX_SUBAGENTS).map((m) => {
|
|
21
22
|
const agent = String(m?.agent || '');
|
|
22
23
|
// auto: wisp picks the harness and model when this member gets its first task.
|
|
23
|
-
if ((!adapters[agent] && agent !== 'auto') || agent === 'wisp') throw
|
|
24
|
+
if ((!adapters[agent] && agent !== 'auto') || agent === 'wisp') throw bad(`unknown subagent harness: ${agent}`);
|
|
24
25
|
let base = slug(m.name) || agent;
|
|
25
26
|
let name = base;
|
|
26
27
|
for (let i = 2; taken.has(name); i++) name = `${base}-${i}`;
|
|
@@ -30,7 +31,7 @@ export function normalizeTeam(list, adapters) {
|
|
|
30
31
|
return { name, agent, model: typeof m.model === 'string' ? m.model.slice(0, 120) : '', effort: EFFORT.test(m.effort || '') ? m.effort : '', use, count };
|
|
31
32
|
});
|
|
32
33
|
const total = team.reduce((n, m) => n + m.count, 0);
|
|
33
|
-
if (total > MAX_INSTANCES) throw
|
|
34
|
+
if (total > MAX_INSTANCES) throw bad(`At most ${MAX_INSTANCES} subagents in all (this team has ${total}).`);
|
|
34
35
|
return team;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -40,9 +41,9 @@ export const copies = (m) => ((m.count || 1) > 1 ? Array.from({ length: m.count
|
|
|
40
41
|
// Saved teams the user can reuse from the launcher: { id, name, members }.
|
|
41
42
|
export function normalizePreset(body, adapters, prev = {}) {
|
|
42
43
|
const name = String(body?.name ?? prev.name ?? '').trim().slice(0, 60);
|
|
43
|
-
if (!name) throw
|
|
44
|
+
if (!name) throw bad('A preset needs a name.');
|
|
44
45
|
const members = body?.members !== undefined ? normalizeTeam(body.members, adapters) : prev.members || [];
|
|
45
|
-
if (!members.length) throw
|
|
46
|
+
if (!members.length) throw bad('A preset needs at least one subagent.');
|
|
46
47
|
return { id: prev.id || slug(name) + '-' + Math.random().toString(36).slice(2, 6), name, members, updatedAt: Date.now() };
|
|
47
48
|
}
|
|
48
49
|
|
package/server/telemetry.js
CHANGED
|
@@ -15,6 +15,7 @@ import os from 'node:os';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { VERSION } from './util.js';
|
|
18
19
|
|
|
19
20
|
// PostHog project API key: write-only, safe to ship in the client.
|
|
20
21
|
const KEY = process.env.WISP_POSTHOG_KEY ?? 'phc_wowwZkbP8e4zYuwT67JtX6oxdPwkMWL5kKnrUmYYXhcD';
|
|
@@ -23,7 +24,6 @@ const FLUSH_MS = 15_000;
|
|
|
23
24
|
const MAX_QUEUE = 50;
|
|
24
25
|
const TIMEOUT_MS = 3000;
|
|
25
26
|
|
|
26
|
-
const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
27
27
|
const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v);
|
|
28
28
|
|
|
29
29
|
const falsy = (v) => /^(0|false|no|off)$/i.test(v || '');
|
package/server/terminal.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { escapeCmdArgument, escapeCmdCommand, onPath } from './proc.js';
|
|
9
|
+
import { shq } from './util.js';
|
|
9
10
|
|
|
10
11
|
// os: which platforms list the entry (all if omitted).
|
|
11
12
|
const ALL_TERMINALS = [
|
|
@@ -31,7 +32,7 @@ const ALL_TERMINALS = [
|
|
|
31
32
|
export const terminalsFor = (platform = process.platform) => ALL_TERMINALS.filter((t) => !t.os || t.os.includes(platform === 'darwin' || platform === 'win32' ? platform : 'linux')).map(({ id, label }) => ({ id, label }));
|
|
32
33
|
export const TERMINALS = terminalsFor();
|
|
33
34
|
|
|
34
|
-
export
|
|
35
|
+
export { shq };
|
|
35
36
|
const psq = (s) => `'${String(s).replace(/'/g, "''")}'`; // PowerShell single-quoted string
|
|
36
37
|
const asStr = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; // AppleScript string
|
|
37
38
|
|
package/server/triggers.js
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { newId } from './
|
|
10
|
+
import { bad, MODES, newId } from './util.js';
|
|
11
11
|
import { gh as ghCli } from './git.js';
|
|
12
12
|
import { fill, findRecipe } from './recipes.js';
|
|
13
13
|
|
|
14
14
|
const KINDS = new Set(['schedule', 'ci', 'issues']);
|
|
15
|
-
const MODES = new Set(['plan', 'edits', 'auto']);
|
|
16
15
|
const TICK_MS = 20_000;
|
|
17
16
|
const MAX_SEEN = 200;
|
|
18
17
|
const MAX_PER_POLL = 3; // a burst of failures shouldn't start a dozen agents
|
|
@@ -37,20 +36,20 @@ const ALIASES = { '@hourly': '0 * * * *', '@daily': '0 0 * * *', '@weekly': '0 0
|
|
|
37
36
|
export function parseCron(expr) {
|
|
38
37
|
const src = ALIASES[String(expr || '').trim()] || String(expr || '').trim();
|
|
39
38
|
const parts = src.split(/\s+/);
|
|
40
|
-
if (parts.length !== 5) throw
|
|
39
|
+
if (parts.length !== 5) throw bad('A schedule needs 5 fields: minute hour day-of-month month day-of-week');
|
|
41
40
|
const out = {};
|
|
42
41
|
parts.forEach((part, i) => {
|
|
43
42
|
const [name, lo, hi] = FIELDS[i];
|
|
44
43
|
const set = new Set();
|
|
45
44
|
for (const piece of part.split(',')) {
|
|
46
45
|
const m = piece.match(/^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/);
|
|
47
|
-
if (!m) throw
|
|
46
|
+
if (!m) throw bad(`Bad ${name} field: ${piece}`);
|
|
48
47
|
let a = m[1] === '*' ? lo : Number(m[2]);
|
|
49
48
|
let b = m[1] === '*' ? hi : m[3] !== undefined ? Number(m[3]) : m[4] ? hi : a;
|
|
50
49
|
const step = m[4] ? Number(m[4]) : 1;
|
|
51
50
|
if (name === 'dow' && b === 7) b = 6, set.add(0); // 7 is Sunday too
|
|
52
51
|
if (name === 'dow' && a === 7) a = 0;
|
|
53
|
-
if (a < lo || b > hi || a > b || step < 1) throw
|
|
52
|
+
if (a < lo || b > hi || a > b || step < 1) throw bad(`Bad ${name} field: ${piece}`);
|
|
54
53
|
for (let v = a; v <= b; v += step) set.add(v);
|
|
55
54
|
}
|
|
56
55
|
out[name] = set;
|
|
@@ -86,7 +85,6 @@ export function nextRun(c, from = Date.now()) {
|
|
|
86
85
|
|
|
87
86
|
// --- config ----------------------------------------------------------------------------------
|
|
88
87
|
|
|
89
|
-
const bad = (msg) => Object.assign(new Error(msg), { status: 400 });
|
|
90
88
|
|
|
91
89
|
export function normalizeTrigger(body, prev = null, { recipes = [] } = {}) {
|
|
92
90
|
const v = (k, d = '') => (body[k] !== undefined ? body[k] : prev?.[k] !== undefined ? prev[k] : d);
|
package/server/update.js
CHANGED
|
@@ -9,6 +9,7 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { bad } from './util.js';
|
|
12
13
|
|
|
13
14
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
14
15
|
const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
@@ -79,8 +80,8 @@ export class Updater {
|
|
|
79
80
|
// Installs (global) then hands off to a detached helper that waits for this server's port to
|
|
80
81
|
// free up and starts the new version. The caller shuts this process down.
|
|
81
82
|
async install() {
|
|
82
|
-
if (this.method === 'git') throw
|
|
83
|
-
if (this.state.status === 'installing') throw
|
|
83
|
+
if (this.method === 'git') throw bad('This is a git checkout: run `git pull` to update.');
|
|
84
|
+
if (this.state.status === 'installing') throw bad('Already updating.', 409);
|
|
84
85
|
this.set({ status: 'installing', error: null });
|
|
85
86
|
try {
|
|
86
87
|
if (this.method === 'global') await run(`npm install -g ${PKG.name}@latest`);
|
|
@@ -109,7 +110,7 @@ function run(cmd) {
|
|
|
109
110
|
child.on('exit', (code) => {
|
|
110
111
|
if (code === 0) return resolve();
|
|
111
112
|
const hint = /EACCES|permission denied/i.test(err) ? ' (permission denied — run it yourself, maybe with sudo)' : '';
|
|
112
|
-
reject(
|
|
113
|
+
reject(bad(`\`${cmd}\` failed${hint}: ${err.trim().split('\n').slice(-3).join(' ')}`));
|
|
113
114
|
});
|
|
114
115
|
});
|
|
115
116
|
}
|
package/server/util.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Small helpers shared across the server. Nothing here imports another wisp module, so any
|
|
2
|
+
// file can use it without dragging in the runner, the store or an adapter.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
10
|
+
export const DEFAULT_PORT = 4477;
|
|
11
|
+
// The modes a session can run in: read-only planning, edits with approval rules, or full auto.
|
|
12
|
+
export const MODES = new Set(['plan', 'edits', 'auto']);
|
|
13
|
+
|
|
14
|
+
export const newId = () => randomUUID().replace(/-/g, '').slice(0, 12);
|
|
15
|
+
|
|
16
|
+
// An error the HTTP layer answers with `status` (400 unless said otherwise) and its message.
|
|
17
|
+
export const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
|
18
|
+
|
|
19
|
+
// ~ and ~/x → the home folder. `expandHome` keeps the string as typed otherwise; `expand` also resolves it.
|
|
20
|
+
export const expandHome = (p) => String(p).replace(/^~(?=$|[\\/])/, os.homedir());
|
|
21
|
+
export const expand = (p) => path.resolve(expandHome(p));
|
|
22
|
+
|
|
23
|
+
// wisp's own copies of harness CLIs, installed from the app when there's none (~/.wisp/tools).
|
|
24
|
+
export const toolsDir = () => path.join(process.env.WISP_HOME || path.join(os.homedir(), '.wisp'), 'tools');
|
|
25
|
+
|
|
26
|
+
// Single-quote for a POSIX shell.
|
|
27
|
+
export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
28
|
+
|
|
29
|
+
// One line, at most n characters (whitespace collapsed).
|
|
30
|
+
export function clip(s, n = 80) {
|
|
31
|
+
s = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
32
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Write JSON so a crash mid-write never leaves a half file behind: tmp file, then rename.
|
|
36
|
+
export function writeJsonAtomic(file, data, space) {
|
|
37
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(data, null, space));
|
|
38
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// At most n characters, line breaks kept (messages, not one-liners).
|
|
42
|
+
export const truncate = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : String(s || ''));
|
package/server/welcome.js
CHANGED
package/web/css/auto.css
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Auto: wisp's router as a harness (picker, card badge, Settings → Auto) */
|
|
2
|
+
.auto-mark { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
|
|
3
|
+
.auto-mark .i { width: 100%; height: 100%; }
|
|
4
|
+
.model-logo.routed::before { content: ''; position: absolute; right: -3px; bottom: -3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 1.5px var(--surface); }
|
|
5
|
+
.pk-note { margin: 8px 10px 4px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
|
|
6
|
+
.pk-note a { color: var(--accent-ink); }
|
|
7
|
+
.ro-goal { flex: none; }
|
|
8
|
+
.ro-allowed { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; max-width: 360px; }
|
|
9
|
+
.chip-tog { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--line); font-size: 12px; color: var(--muted); }
|
|
10
|
+
.chip-tog:hover { color: var(--ink); }
|
|
11
|
+
.chip-tog.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-ink); }
|
|
12
|
+
.router-modal { width: min(720px, 100%); position: relative; padding-bottom: 20px; }
|
|
13
|
+
.router-modal .hint { margin: 0 0 12px; }
|
|
14
|
+
.router-modal h4 { margin: 16px 0 6px; }
|
|
15
|
+
.ro-table td small.ro-types { display: block; margin: 2px 0 0 19px; font-size: 11px; color: var(--faint); }
|
|
16
|
+
.ro-table .sx-state { justify-content: flex-end; }
|
|
17
|
+
.sx-state.ok { color: var(--good); }
|
|
18
|
+
.sx-state.warn { color: var(--warn); }
|
|
19
|
+
.dot.warn { background: var(--warn); }
|
|
20
|
+
.ro-recent { list-style: none; margin: 0; padding: 0; }
|
|
21
|
+
.ro-recent li { display: flex; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
|
|
22
|
+
.ro-recent .ro-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
|
23
|
+
.ro-recent small { flex: none; color: var(--faint); }
|
|
24
|
+
.ro-recent .ok { color: var(--good); }
|
|
25
|
+
.ro-recent .warn { color: var(--warn); }
|
|
26
|
+
.ro-recent .bad { color: var(--bad); }
|
|
27
|
+
.ro-recent .dim { color: var(--faint); }
|
|
28
|
+
.ro-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
|
29
|
+
.ro-foot .hint { margin: 0; }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* ---------- automations, usage, phone ---------- */
|
|
2
|
+
.auto-modal, .usage-modal, .phone-modal { width: min(720px, 100%); position: relative; }
|
|
3
|
+
.usage-modal { padding-bottom: 24px; }
|
|
4
|
+
.mem-lede code { font-size: 11.5px; padding: 1px 5px; border-radius: 5px; background: var(--bg-2); }
|
|
5
|
+
.auto-body { padding-top: 10px; }
|
|
6
|
+
.au-list { display: flex; flex-direction: column; gap: 2px; }
|
|
7
|
+
.au-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 8px; border-radius: 10px; }
|
|
8
|
+
.au-item:hover { background: var(--surface-2); }
|
|
9
|
+
.au-item.off .au-main { opacity: 0.55; }
|
|
10
|
+
.au-main { flex: 1; min-width: 0; }
|
|
11
|
+
.au-main b { font-size: 13.5px; }
|
|
12
|
+
.au-main p { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
|
|
13
|
+
.au-main code { font-size: 11.5px; padding: 0 4px; border-radius: 4px; background: var(--bg-2); }
|
|
14
|
+
.au-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
|
15
|
+
.au-tags i { font-style: normal; font-size: 11px; padding: 1px 6px; border-radius: 5px; background: var(--bg-2); color: var(--ink-2); }
|
|
16
|
+
.au-tags a { color: var(--accent-ink); }
|
|
17
|
+
.au-err { display: flex; align-items: center; gap: 5px; color: var(--bad) !important; }
|
|
18
|
+
.au-err .i { width: 12px; height: 12px; }
|
|
19
|
+
.au-acts { display: flex; align-items: center; gap: 4px; flex: none; }
|
|
20
|
+
.au-form { display: flex; flex-direction: column; gap: 0; }
|
|
21
|
+
.au-form > label, .au-form > div > label, .au-row { display: flex; align-items: center; gap: 14px; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
|
22
|
+
.au-form label > span, .au-row > span { width: 92px; flex: none; font-size: 12.5px; font-weight: 500; color: var(--ink-2); }
|
|
23
|
+
.au-form label.stack { flex-direction: column; align-items: stretch; gap: 6px; }
|
|
24
|
+
.au-form label.stack > span { width: auto; }
|
|
25
|
+
.au-form :is(.input, .textarea, select) { flex: 1; }
|
|
26
|
+
.au-form [hidden] { display: none !important; }
|
|
27
|
+
.au-params { margin: 6px 0 2px; }
|
|
28
|
+
.au-params code, .k-vars code { font-size: 11px; }
|
|
29
|
+
.au-runner { display: flex; align-items: center; gap: 10px; }
|
|
30
|
+
.au-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 6px 0 4px 106px; }
|
|
31
|
+
.au-kinds { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 6px; }
|
|
32
|
+
.au-kinds button { display: grid; grid-template-columns: 16px 1fr; column-gap: 8px; text-align: left; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--line); }
|
|
33
|
+
.au-kinds button .i { grid-row: span 2; margin-top: 2px; color: var(--muted); }
|
|
34
|
+
.au-kinds button b { font-size: 13px; }
|
|
35
|
+
.au-kinds button small { font-size: 11px; color: var(--muted); }
|
|
36
|
+
.au-kinds button.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
37
|
+
.au-kinds button.on .i { color: var(--accent); }
|
|
38
|
+
.au-warn { display: flex; gap: 8px; margin: 10px 0 0; padding: 8px 10px; border-radius: 9px; font-size: 12.5px; color: var(--warn); background: color-mix(in srgb, var(--warn) 8%, transparent); }
|
|
39
|
+
.au-warn .i { flex: none; margin-top: 1px; }
|
|
40
|
+
.au-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
|
41
|
+
|
|
42
|
+
.usage-modal header { gap: 12px; }
|
|
43
|
+
.usage-modal header h2 { margin-right: auto; }
|
|
44
|
+
.us-tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
|
45
|
+
.us-tiles div { display: flex; flex-direction: column; padding: 10px 12px; border-radius: 11px; border: 1px solid var(--line); background: var(--surface-2); }
|
|
46
|
+
.us-tiles span { font-size: 11.5px; color: var(--muted); }
|
|
47
|
+
.us-tiles b { font: 600 22px/1.3 var(--display); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
|
|
48
|
+
.us-tiles small { font-size: 11px; color: var(--faint); }
|
|
49
|
+
.us-chart { display: flex; align-items: flex-end; gap: 2px; height: 120px; padding-bottom: 1px; border-bottom: 1px solid var(--line-2); }
|
|
50
|
+
.us-bar { flex: 1; height: 100%; display: flex; align-items: flex-end; cursor: default; }
|
|
51
|
+
.us-bar i { display: block; width: 100%; border-radius: 4px 4px 0 0; background: var(--accent); opacity: 0.85; }
|
|
52
|
+
.us-bar:hover i { opacity: 1; }
|
|
53
|
+
.us-axis { display: flex; justify-content: space-between; margin-top: 4px; font-size: 11px; color: var(--faint); }
|
|
54
|
+
.us-tip { position: absolute; z-index: 5; transform: translate(-50%, -100%); padding: 3px 7px; border-radius: 6px; background: var(--ink); color: var(--bg); font-size: 11px; white-space: nowrap; pointer-events: none; }
|
|
55
|
+
.us-table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
|
56
|
+
.us-table th { text-align: right; font-weight: 500; font-size: 11px; color: var(--faint); padding: 4px 6px; border-bottom: 1px solid var(--line); }
|
|
57
|
+
.us-table td { text-align: right; padding: 6px; border-bottom: 1px solid var(--line); color: var(--ink-2); }
|
|
58
|
+
.us-table :is(th, td):first-child { text-align: left; color: var(--ink); }
|
|
59
|
+
.us-proj, .us-runner { display: inline-flex; align-items: center; gap: 6px; }
|
|
60
|
+
|
|
61
|
+
.phone-modal .ph-sec h4 { display: flex; align-items: center; gap: 8px; }
|
|
62
|
+
.phone-modal .ph-sec h4 .switch { margin-left: auto; }
|
|
63
|
+
.phone-modal .settings-modal { width: auto; padding: 0; border: 0; box-shadow: none; background: none; }
|
|
64
|
+
.settings-modal label.check { padding: 0; border: 0; gap: 6px; }
|
|
65
|
+
.ph-ok, .ph-bad { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 500; }
|
|
66
|
+
.ph-ok { color: var(--good); }
|
|
67
|
+
.ph-bad { color: var(--bad); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
68
|
+
.ph-ok .i, .ph-bad .i { width: 12px; height: 12px; }
|
|
69
|
+
.ph-start { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
|
|
70
|
+
.ph-row { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 6px; }
|
|
71
|
+
|
package/web/css/base.css
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* ---------- buttons ---------- */
|
|
2
|
+
.primary {
|
|
3
|
+
display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px 0 16px;
|
|
4
|
+
border-radius: 8px; background: var(--ink); color: var(--bg); font-weight: 500;
|
|
5
|
+
transition: opacity 0.15s;
|
|
6
|
+
}
|
|
7
|
+
.primary:hover { opacity: 0.86; }
|
|
8
|
+
.primary:disabled { opacity: 0.5; }
|
|
9
|
+
.primary kbd { background: transparent; color: inherit; border-color: color-mix(in srgb, var(--bg) 35%, transparent); opacity: 0.7; }
|
|
10
|
+
.primary.sm { height: 30px; padding: 0 12px; font-size: 13px; border-radius: 9px; }
|
|
11
|
+
.ghost {
|
|
12
|
+
display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; border-radius: 9px;
|
|
13
|
+
color: var(--ink-2); transition: background 0.15s, color 0.15s;
|
|
14
|
+
}
|
|
15
|
+
.ghost:hover { background: var(--bg-2); color: var(--ink); }
|
|
16
|
+
.ghost.on { color: var(--good); }
|
|
17
|
+
.ghost.sm { height: 28px; font-size: 13px; }
|
|
18
|
+
.ghost.xs { height: 24px; padding: 0 6px; border-radius: 7px; }
|
|
19
|
+
.ghost.danger:hover { color: var(--bad); background: var(--del-bg); }
|
|
20
|
+
.icon-btn {
|
|
21
|
+
display: inline-grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; color: var(--muted);
|
|
22
|
+
transition: background 0.15s, color 0.15s;
|
|
23
|
+
}
|
|
24
|
+
.icon-btn:hover { background: var(--bg-2); color: var(--ink); }
|
|
25
|
+
.link { color: var(--accent-ink); font-weight: 500; }
|
|
26
|
+
.link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
27
|
+
.link.on { color: var(--ink); }
|
|
28
|
+
/* ---------- form kit ----------
|
|
29
|
+
One look for every standalone control. Use these classes, don't restyle controls per component:
|
|
30
|
+
<input class="input"> <textarea class="textarea"> <select> (styled by default)
|
|
31
|
+
.mono code-ish values: paths, cron, tokens, commands
|
|
32
|
+
.sm / .lg 30px rows in dense panels / the 38px add-project row
|
|
33
|
+
.field a box holding an icon / prefix + a bare <input>: <label class="field">icon<input></label>
|
|
34
|
+
.check a label wrapping a checkbox and its text
|
|
35
|
+
Components set only layout (width, flex) on these; a container already tinted surface-2 sets
|
|
36
|
+
--field-bg: var(--surface) so its fields don't melt into it. Prompt boxes (launcher, composer, rerun) and
|
|
37
|
+
inline renames (project name, memory edit) are their own, deliberately borderless pattern. */
|
|
38
|
+
:is(.input, .textarea, select, .field) {
|
|
39
|
+
width: 100%; min-width: 0; height: 32px; padding: 0 10px; border-radius: 9px; border: 1px solid var(--line-2);
|
|
40
|
+
background-color: var(--field-bg, var(--surface-2)); color: var(--ink); font: 13px/1.4 var(--sans); outline: 0;
|
|
41
|
+
transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
|
|
42
|
+
}
|
|
43
|
+
:is(.input, .textarea, select, .field):hover { border-color: color-mix(in srgb, var(--ink) 14%, var(--line-2)); }
|
|
44
|
+
:is(.input, .textarea, select):focus, .field:focus-within {
|
|
45
|
+
border-color: var(--accent); background-color: var(--surface); box-shadow: 0 0 0 3px var(--accent-soft);
|
|
46
|
+
}
|
|
47
|
+
:is(.input, .textarea, .field input)::placeholder { color: var(--faint); }
|
|
48
|
+
:is(.input, .textarea, select):disabled { opacity: 0.55; cursor: not-allowed; }
|
|
49
|
+
:is(.input, .textarea, select):is([aria-invalid='true'], :user-invalid) { border-color: var(--bad); }
|
|
50
|
+
:is(.input, .textarea, select):is([aria-invalid='true'], :user-invalid):focus { box-shadow: 0 0 0 3px var(--del-bg); }
|
|
51
|
+
.textarea { display: block; height: auto; min-height: 64px; padding: 7px 10px; line-height: 1.5; resize: vertical; }
|
|
52
|
+
:is(.input, .textarea, .field).mono { font-family: var(--mono); font-size: 12.5px; }
|
|
53
|
+
:is(.input, .field).sm { height: 30px; padding: 0 8px; }
|
|
54
|
+
:is(.input, .field).lg { height: 38px; padding: 0 12px; border-radius: 11px; }
|
|
55
|
+
select:not([multiple]) {
|
|
56
|
+
width: auto; padding-right: 28px; appearance: none; cursor: pointer; text-overflow: ellipsis;
|
|
57
|
+
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
|
|
58
|
+
background-position: calc(100% - 14px) 50%, calc(100% - 10px) 50%; background-size: 4px 4px; background-repeat: no-repeat;
|
|
59
|
+
}
|
|
60
|
+
.field { display: flex; align-items: center; gap: 8px; color: var(--muted); }
|
|
61
|
+
.field .i { width: 14px; height: 14px; flex: none; }
|
|
62
|
+
.field > input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: 0; background: none; font: inherit; color: var(--ink); }
|
|
63
|
+
input[type='checkbox'] { width: 15px; height: 15px; margin: 0; flex: none; accent-color: var(--accent); cursor: pointer; }
|
|
64
|
+
.check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); cursor: pointer; }
|
|
65
|
+
|
|
66
|
+
/* ---------- the mark ---------- */
|
|
67
|
+
/* A plain dot. Brand: ink dot with an offset accent dot. Live: a thin spinner ring. */
|
|
68
|
+
.orb { --s: 14px; position: relative; display: inline-block; width: var(--s); height: var(--s); border-radius: 50%; background: var(--ink); flex: none; }
|
|
69
|
+
.orb i { display: none; }
|
|
70
|
+
.orb.tiny { --s: 12px; }
|
|
71
|
+
.orb.tiny::after {
|
|
72
|
+
content: ''; position: absolute; width: 6px; height: 6px; right: -4px; bottom: -2px; border-radius: 50%;
|
|
73
|
+
background: var(--accent); box-shadow: 0 0 0 2px var(--bg);
|
|
74
|
+
}
|
|
75
|
+
.orb.big { display: none; }
|
|
76
|
+
.orb.live {
|
|
77
|
+
--s: 18px; background: none; border: 2px solid var(--line-2); border-top-color: var(--accent);
|
|
78
|
+
animation: orb-spin 0.9s linear infinite;
|
|
79
|
+
}
|
|
80
|
+
.waiting .orb.live { border: 0; background: var(--warn); --s: 10px; margin: 4px; animation: pulse 1.6s ease-in-out infinite; }
|
|
81
|
+
|
|
82
|
+
@keyframes orb-spin { to { rotate: 360deg; } }
|
|
83
|
+
|