diff --git a/easel/README.md b/easel/README.md --- a/easel/README.md +++ b/easel/README.md @@ -44,18 +44,46 @@ `ctrl-c` to interrupt a running turn or exit while idle. ## Engine bridges -Two bridges ship, and either can drive a session: +Three bridges can drive a session: ```sh ac # claude, on claude-opus-5 ac --backend codex # codex app-server +ac --backend ac # AC hosted, using your handle's budget ac --model claude-opus-5 # a different model on the same bridge +ac --piece path/to/fogozo.mjs # reopen an existing piece and its versions ``` -`/backend` and `/model` do the same thing mid-session — both restart the -conversation on the new engine and leave the piece, the channel and the QR code -exactly where they were. `/backend` with no argument says which engine and -model are running. +`/backend` and `/model` switch mid-session, preserving the visible conversation, +piece, channel and QR. A new provider thread receives recent user/assistant +context (up to 24,000 characters) and the current piece; provider thread IDs and +tool history are not portable. A failed connection returns to the prior engine. +`/new` explicitly starts a fresh conversation. `/backend` lists account options; +`/model` lists hosted choices or accepts a model name for your own vendor CLI. + +AC hosted keeps GLM as its default. `/model sonnet` and `/model gpt` select +premium models and consume the same handle allowance. Model IDs were checked +against the [OpenRouter catalog](https://openrouter.ai/compare/openai/gpt-5.4/anthropic/claude-sonnet-4.6). +The allowance measures weighted tokens, not dollars, and is not an atomic spend +reservation. Unavailable budget checks refuse inference. New hosted choices +require the matching Lith endpoint deployment. + +`/about`, or clicking **EASEL**, opens the feature map. Click **@handle** to open +your profile in a browser. Header targets highlight on hover in terminals that +support mouse reporting. `/mouse off` restores terminal selection; `/mouse on` +enables interaction again. `EASEL_MOUSE=0` disables it at launch. + +Wheel and Page Up/Page Down scroll the transcript internally, keeping the input +and footer fixed. Incoming output preserves your reading position. End with an +empty input, or `/latest`, returns to the live end. The about map scrolls too; +Esc returns to the conversation. + +`/performance [frames]` measures the current JavaScript piece's headless logic +with seeded randomness and drawing-call counts. The default is 600 measured +frames at 800×600 after warmup. It runs in a restricted child with a timeout; +Ctrl-C cancels it. Browser rendering, rasterization and display latency are +excluded. Unsupported APIs/imports report an error. It requires Node permission +support (Node 24 or newer recommended). The Claude bridge runs `claude --print --input-format stream-json --output-format stream-json`, the same headless protocol the Claude Agent SDK @@ -71,6 +99,19 @@ configuration — Codex is pinned to `on-request` approvals and a `workspace-write` sandbox, and Claude is launched with `--setting-sources ""` and `--strict-mcp-config` — so nothing but the person watching can approve a command in a session, and an `a` is never written to a settings file. + +On the Claude bridge the session also carries Easel's own tools, served by +`src/tools.mjs` as the one MCP server the strict config admits: `ac_api` (the +piece API — runtime signatures, docs and real call sites, read off +`lib/disk.mjs` and `lib/graph.mjs` by `bin/build-api-map.mjs` into +`context/api.json`), `ac_examples` (pieces that call a symbol), `ac_outline` +(a piece's top-level symbols with line spans) and `ac_symbol` (one symbol's +source). They exist because the first ten sessions each spent six to twelve +shell calls — `grep function circle( graph.mjs`, `sed -n 6590,6650p disk.mjs`, +`grep -rn "synth({" disks/` — rebuilding the same picture before the first +edit. The guides are inlined into the first turn for the same reason. All four +tools are read-only and pre-allowed; `npm run context` rebuilds the map and +`npm test` fails when it is stale. The two are not equivalent on containment. Codex runs commands inside an operating-system sandbox with the network off; Claude Code has no such sandbox, @@ -192,3 +233,17 @@ installer preserves them as `ac-repo` and `aesthetic-platform`. The product boundary is recorded in [`docs/local-contract.md`](docs/local-contract.md). + +Each complete piece update gets a local version (`v1`, `v2`, …). `/versions` +lists snapshots; `/rollback vN` restores one as a new version and sends it through +the usual live/publish path. Finish or interrupt the current turn and let uploads +finish first. History persists in `~/.local/share/easel/history/`, keyed by the +piece's absolute file path; it is not yet shared between machines or accounts. + +The AC backend streams text and completed `write_piece` checkpoints as they +arrive. It shows connecting, waiting, generating, composing, and writing states; +received kilobytes count stream bytes, not billed tokens. JavaScript checkpoints +are syntax-checked without executing them, so unfinished fragments keep the last +working preview. Other runtimes retain their own loader validation. This uses +ordered HTTPS streaming (SSE); a socket or UDP transport is not required for each +token to arrive immediately. Disconnects cancel an active response upstream. diff --git a/easel/bin/build-api-map.mjs b/easel/bin/build-api-map.mjs new file mode 100644 --- /dev/null +++ b/easel/bin/build-api-map.mjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node +// build-api-map — the static map of the piece API, read off the runtime source. +// +// Every Easel session so far has opened with the same hunt: grep graph.mjs for +// `function circle(`, sed a window of disk.mjs to see what `$paintApiUnwrapped` +// exposes, grep the disks for one piece that already calls `synth(`. Ten +// sessions, the same eight commands, a minute or two each before the first +// edit. The answers do not change between sessions; only the model's memory +// does. So the answers are computed once, here, and shipped as +// `easel/context/api.json` for `ac_api` to serve in a single call. +// +// What it records, per API name a piece can call: +// - where it lives on `$api` (`circle`, `sound.synth`, `ui.Button`) +// - the runtime signature, read from the defining `function` line +// - the comment immediately above the definition, when there is one +// - up to three one-line uses from real pieces under disks/, as file:line +// +// Regex over source, not a parser: the runtime is one 600 KB file with a +// house style regular enough that `^ name: graph.name,` is a grammar. Where +// the pattern misses, the entry is simply absent — a hole in the map, never a +// wrong signature. +// +// node bin/build-api-map.mjs # write context/api.json +// node bin/build-api-map.mjs --check # exit 1 if it is stale +import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EASEL = join(HERE, ".."); +const REPO = join(EASEL, ".."); +const AC = join(REPO, "system", "public", "aesthetic.computer"); +const OUT = join(EASEL, "context", "api.json"); + +const read = (file) => readFileSync(join(AC, file), "utf8").split("\n"); + +// The comment block sitting directly above `line` (0-based), joined, trimmed. +function commentAbove(lines, line, max = 6) { + const out = []; + for (let i = line - 1; i >= 0 && out.length < max; i--) { + const text = lines[i].trim(); + if (!text.startsWith("//")) break; + out.unshift(text.replace(/^\/\/\s?/, "")); + } + return out.join(" ").replace(/\s+/g, " ").trim(); +} + +// `function name(a, b = 1, ...rest) {` → "name(a, b = 1, ...rest)". A signature +// split across lines (oval, synth) is joined until its parenthesis closes. +function signatureAt(lines, line, name) { + let text = ""; + for (let i = line; i < Math.min(lines.length, line + 24); i++) { + text += lines[i].replace(/\/\/.*$/, "").trim() + " "; + if (/\)\s*(=>\s*)?\{?\s*$/.test(text) || text.includes(") {")) break; + } + const match = text.match(/\(([\s\S]*)\)\s*(?:=>\s*)?\{?\s*$/); + if (!match) return `${name}(…)`; + let params = match[1].replace(/\s+/g, " ").trim(); + // A destructured options object reads as its keys. + params = params.replace(/\{\s*([^}]*)\}\s*=\s*\{\}/, (_, keys) => `{ ${keys.trim()} }`); + return `${name}(${params})`; +} + +// Find `function name(` / `name = function(` / `name(...) {` / `name: function` +// anywhere in `lines`, preferring a top-level `function`. +function definitionOf(lines, name) { + const patterns = [ + new RegExp(`^(?:export\\s+)?(?:async\\s+)?function\\s+${name}\\s*\\(`), + new RegExp(`^\\s*(?:const|let)\\s+${name}\\s*=\\s*(?:async\\s*)?(?:function\\s*\\(|\\()`), + new RegExp(`^\\s*[$\\w.]*\\.?${name}\\s*=\\s*(?:async\\s+)?function\\s*(?:\\w+\\s*)?\\(`), + new RegExp(`^\\s*${name}\\s*:\\s*(?:async\\s+)?function\\s*(?:\\w+\\s*)?\\(`), + new RegExp(`^\\s*(?:async\\s+)?${name}\\s*\\([^)]*\\)\\s*\\{\\s*$`), + new RegExp(`^\\s*${name}\\s*:\\s*(?:async\\s*)?\\(`), + ]; + for (const pattern of patterns) { + const at = lines.findIndex((text) => pattern.test(text)); + if (at >= 0) return at; + } + return -1; +} + +const disk = read("lib/disk.mjs"); +const graph = read("lib/graph.mjs"); + +// The object the paint API is built from. `circle: graph.circle,` is the shape +// of most of it; the rest are inline functions wrapping a graph call. +function paintApi() { + const start = disk.findIndex((text) => /^const \$paintApiUnwrapped = \{$/.test(text)); + if (start < 0) throw new Error("disk.mjs: $paintApiUnwrapped not found"); + let end = start + 1; + while (end < disk.length && !/^\};?$/.test(disk[end])) end++; + const entries = []; + for (let i = start + 1; i < end; i++) { + const text = disk[i]; + let match = text.match(/^ (\w+): graph\.(\w+),?\s*(\/\/\s*(.*))?$/); + if (match) { + const [, name, target, , note] = match; + const at = definitionOf(graph, target); + entries.push({ + name, + path: name, + signature: at >= 0 ? signatureAt(graph, at, name) : `${name}(…)`, + doc: note?.trim() || (at >= 0 ? commentAbove(graph, at) : "") || commentAbove(disk, i), + source: at >= 0 ? `lib/graph.mjs:${at + 1}` : `lib/disk.mjs:${i + 1}`, + }); + continue; + } + match = text.match(/^ (\w+)(?::\s*(?:async\s+)?function\s*\w*\s*\(|\s*\()/); + if (match) { + const name = match[1]; + // An inline wrapper usually forwards to graph.; take the + // graph signature when it exists, since that is what the arguments are. + const at = definitionOf(graph, name); + entries.push({ + name, + path: name, + signature: at >= 0 ? signatureAt(graph, at, name) : signatureAt(disk, i, name), + doc: commentAbove(disk, i) || (at >= 0 ? commentAbove(graph, at) : ""), + source: at >= 0 ? `lib/graph.mjs:${at + 1}` : `lib/disk.mjs:${i + 1}`, + }); + } + } + return entries; +} + +// Things a piece reaches through a namespace. Named by hand: this is the short +// list the sessions actually hunted for, and each one is checked against the +// source so the signature is the runtime's, not a memory of it. +const NAMESPACED = [ + ["sound.synth", disk, "synth", "Play a synthesized tone. `tone` is Hz or a note name like \"c4\"; returns a voice with .kill() and .update()."], + ["sound.play", disk, "play", "Play a loaded sample or sfx by id."], + ["ui.Button", null, "Button", "A rectangular button: new ui.Button(x, y, w, h) or ({x,y,w,h}); btn.paint(callback) inside paint, btn.act(e, { push, down, up, cancel }) inside act."], + ["ui.TextButton", null, "TextButton", "A labelled button sized to its text."], + ["hud.label", disk, "label", "Take over the system's corner label — the only sanctioned way to draw in the top-left."], + ["write", disk, "write", "Draw text with the current ink: write(text, { x, y, size, center: \"x\" }) or write(text, x, y). Chain from ink(): ink(\"white\").write(...)."], + ["num.randInt", null, "randInt", "Random integer in [0, n]."], + ["num.randIntRange", null, "randIntRange", "Random integer in [low, high]."], + ["num.lerp", null, "lerp", "Linear interpolation a→b by t."], + ["num.clamp", null, "clamp", "Clamp a value between min and max."], + ["num.dist", null, "dist", "Distance between two points."], + ["num.map", null, "map", "Map a value from one range to another."], + ["num.radians", null, "radians", "Degrees to radians."], + ["geo.Box", null, "Box", "An axis-aligned rectangle with .contains(point) and .crop()."], + ["geo.Circle", null, "Circle", "A circle with .contains(point)."], +]; + +function namespaced() { + const libs = { + ui: read("lib/ui.mjs"), + num: read("lib/num.mjs"), + geo: read("lib/geo.mjs"), + }; + const out = []; + for (const [path, where, name, doc] of NAMESPACED) { + const [ns] = path.split("."); + const lines = where || libs[ns]; + if (!lines) continue; + let at = definitionOf(lines, name); + let signature = `${path}(…)`; + let source = ""; + if (at >= 0) { + signature = signatureAt(lines, at, path); + source = `lib/${where ? "disk" : ns}.mjs:${at + 1}`; + } else { + // A class: the signature is its constructor's. + const cls = lines.findIndex((text) => new RegExp(`^(?:export\\s+)?class\\s+${name}\\b`).test(text)); + if (cls < 0) continue; + const ctor = lines.slice(cls).findIndex((text) => /^\s*constructor\s*\(/.test(text)); + signature = ctor >= 0 ? signatureAt(lines, cls + ctor, `new ${path}`) : `new ${path}(…)`; + source = `lib/${ns}.mjs:${cls + 1}`; + at = cls; + } + out.push({ name: path.split(".").pop(), path, signature, doc: doc || commentAbove(lines, at), source }); + } + return out; +} + +// Up to `limit` short lines from pieces that call `name(`. Long lines and the +// definition of a same-named helper inside a piece are skipped; what is wanted +// is a call site a model can copy the shape of. +function examplesFor(entries, limit = 3) { + const disksDir = join(AC, "disks"); + const files = readdirSync(disksDir).filter((f) => f.endsWith(".mjs")).sort(); + const sources = files.map((f) => [f, readFileSync(join(disksDir, f), "utf8").split("\n")]); + for (const entry of entries) { + const leaf = entry.path.split(".").pop(); + const needle = entry.path.includes(".") + ? new RegExp(`\\b${entry.path.replace(".", "\\.")}\\s*\\(|new ${entry.path.replace(".", "\\.")}\\s*\\(`) + : new RegExp(`(? 140) continue; + found.push(`disks/${file}:${i + 1} ${text.trim()}`); + } + if (found.length >= limit) break; + } + entry.examples = found; + } +} + +// Definitions that read `arguments` show up as `name()`. The real shapes, by +// hand, for the handful a piece cannot do without — checked against +// graph.mjs's own header comments, which this generator otherwise trusts. +const OVERRIDES = { + ink: { signature: "ink(r, g, b, a) | ink(gray, a) | ink(\"red\") | ink([r, g, b]) | ink() (random)", doc: "Set the paint color for everything drawn next. Returns the API so calls chain: ink(255, 0, 0).line(0, 0, 10, 10)." }, + ink2: { signature: "ink2(...color)", doc: "Secondary color, used by gradient-aware primitives." }, + wipe: { signature: "wipe(...color)", doc: "Fill the whole screen with a color; the usual first line of paint()." }, + line: { signature: "line(x1, y1, x2, y2) | line({x, y}, {x, y}) | line(x1, y1, x2, y2, thickness)", doc: "Draw a 1px line between two points in the current ink." }, + box: { signature: "box(x, y, size) | box(x, y, w, h) | box(x, y, w, h, mode) | box({x, y, w, h}, mode)", doc: "Rectangle. `mode` is \"fill\" (default), \"outline\", \"inline\", or \"fill*center\" / \"outline*center\" to draw from the center." }, + shape: { signature: "shape(x1, y1, x2, y2, ...) | shape([[x, y], [x, y], ...], filled = true)", doc: "Rasterize a filled or outlined polygon from point pairs." }, + tri: { signature: "tri(x1, y1, x2, y2, x3, y3, mode = \"fill\")", doc: "Triangle from three points; mode \"fill\" or \"outline\"." }, + clear: { signature: "clear()", doc: "Clear the buffer to transparent (unlike wipe, which paints a color)." }, + page: { signature: "page(buffer)", doc: "Point subsequent drawing at another painting buffer; page(screen) comes back." }, + draw: { signature: "draw(drawing, x, y, scale = 1, angle = 0, thickness = 1)", doc: "Draw a stored vector drawing (from `drawing`/store) at a position." }, + unpan: { signature: "unpan()", doc: "Undo pan(x, y)." }, + mask: { signature: "mask({ x, y, width, height })", doc: "Clip drawing to a rectangle until unmask()." }, + unmask: { signature: "unmask()", doc: "Lift the clip set by mask()." }, + flip: { signature: "flip(horizontal = false, vertical = false)", doc: "Mirror the screen." }, + sort: { signature: "sort()", doc: "Pixel-sort the screen — a glitch effect." }, + invert: { signature: "invert()", doc: "Invert every pixel's color." }, + "num.dist": { signature: "num.dist(x1, y1, x2, y2)", doc: "Distance between two points." }, + "ui.Button": { signature: "new ui.Button(x, y, w, h) | new ui.Button({ x, y, w, h })", doc: "A button. In paint: btn.paint((b) => { ink(b.down ? \"yellow\" : \"gray\").box(b.box) }). In act: btn.act(e, { push: () => {}, down: () => {}, up: () => {}, cancel: () => {} }). Pass pens() as the 3rd arg to act for multitouch. Rebuild buttons in `reframed`." }, + "geo.Box": { signature: "new geo.Box(x, y, w, h)", doc: "An axis-aligned rectangle with .x .y .w .h and .contains({x, y})." }, + write: { signature: "write(text, { x, y, size, center: \"x\" | \"xy\" }) | write(text, x, y)", doc: "Draw text in the current ink. Chains from ink(): ink(\"white\").write(\"hi\", { x: 10, y: 40 })." }, + "hud.label": { signature: "hud.label(text, color, offset)", doc: "Take over the system's corner label — the only sanctioned way to draw in the top-left." }, +}; + +// Not functions, so nothing to read a signature from — but the sessions hunted +// for these as often as for any primitive. +const STATIC = [ + { name: "screen", path: "screen", signature: "screen.width, screen.height, screen.pixels, screen.center", doc: "The canvas. Read width/height in paint(); never write screen.pixels directly (writes can silently drop) — draw into your own painting buffer and paste() it.", source: "lib/disk.mjs" }, + { name: "pen", path: "pen", signature: "pen.x, pen.y, pen.drawing, pen.delta", doc: "The single primary pointer; null when there is none. Read in paint()/sim().", source: "lib/disk.mjs" }, + { name: "pens", path: "pens", signature: "pens() → [{ x, y, id, drawing }]", doc: "Every active pointer, for multitouch. Pass to btn.act(e, callbacks, pens()).", source: "lib/disk.mjs" }, + { name: "event", path: "act(e)", signature: "e.is(\"touch\") | e.is(\"draw\") | e.is(\"lift\") | e.is(\"keyboard:down:space\") | e.is(\"reframed\") ; e.x, e.y, e.delta, e.key", doc: "Events arrive in act({ event: e, ... }). Pointer: touch → draw → lift. Keys: keyboard:down:, keyboard:up:.", source: "lib/disk.mjs" }, + { name: "sim", path: "sim", signature: "function sim({ ... }) — runs 120 times per second", doc: "Physics and timers go here, not in paint(); paint() runs at display rate and only when something needs painting.", source: "lib/disk.mjs" }, + { name: "needsPaint", path: "needsPaint", signature: "needsPaint()", doc: "Ask for another paint() when the piece is static and something changed.", source: "lib/disk.mjs" }, + { name: "painting", path: "painting", signature: "painting(w, h, (api) => { ... }) → buffer", doc: "Make an offscreen buffer by drawing into it; show it later with paste(buffer, x, y).", source: "lib/disk.mjs" }, + { name: "help.choose", path: "help.choose", signature: "help.choose(...items)", doc: "Pick one item at random.", source: "lib/help.mjs" }, + { name: "help.repeat", path: "help.repeat", signature: "help.repeat(n, (i) => { ... })", doc: "Call a function n times.", source: "lib/help.mjs" }, +]; + +export function build() { + const entries = [...paintApi(), ...namespaced()]; + for (const entry of entries) { + const fix = OVERRIDES[entry.path]; + if (!fix) continue; + if (fix.signature) entry.signature = fix.signature; + if (fix.doc) entry.doc = fix.doc; + } + // graph.mjs's TODO notes are not documentation. + for (const entry of entries) if (/^TODO/i.test(entry.doc)) entry.doc = ""; + examplesFor(entries); + entries.push(...STATIC.map((entry) => ({ ...entry, examples: [] }))); + const body = JSON.stringify( + { + about: + "The Aesthetic Computer piece API, read from the runtime. `path` is where it sits on the $api object a piece destructures in paint/act/sim. Signatures are the runtime's own.", + built_from: "system/public/aesthetic.computer/lib/{disk,graph,ui,num,geo}.mjs", + entries, + }, + null, + 1, + ); + return body + "\n"; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const check = process.argv.includes("--check"); + const body = build(); + const current = existsSync(OUT) ? readFileSync(OUT, "utf8") : ""; + if (current === body) { + console.log("context/api.json is current."); + } else if (check) { + console.error("stale: context/api.json no longer matches the runtime — run `npm run context`."); + process.exit(1); + } else { + writeFileSync(OUT, body); + const count = JSON.parse(body).entries.length; + console.log(`wrote context/api.json (${count} entries, ${(body.length / 1024).toFixed(1)} KB)`); + } +} diff --git a/easel/bin/easel b/easel/bin/easel --- a/easel/bin/easel +++ b/easel/bin/easel @@ -25,6 +25,7 @@ usage() { cat <<'EOF' Usage: ac [directory] [--runtime mjs|lisp|processing] + [--piece FILE] [--resume THREAD_ID] [--backend claude|codex|ac] [--model NAME] [--autopublish | --no-autopublish] aesthetic [directory] @@ -122,6 +123,7 @@ target_directory="" resume_thread="" initial_prompt="" +initial_piece="" runtime="" backend="" model="" @@ -144,6 +146,11 @@ [[ "$2" =~ ^[0-9A-Fa-f-]{36}$ ]] || fail "invalid thread id" resume_thread="$2" shift 2 ;; + --piece) + [[ $# -ge 2 ]] || fail "--piece requires a file" + initial_piece="$2" + shift 2 + ;; --prompt) [[ $# -ge 2 ]] || fail "--prompt requires text" [[ ${#2} -le 4000 ]] || fail "prompt exceeds 4000 characters" @@ -237,6 +244,7 @@ export EASEL_VERSION="$VERSION" arguments=("--cwd" "$target_directory") if [[ -n "$resume_thread" ]]; then arguments+=("--resume" "$resume_thread"); fi if [[ -n "$initial_prompt" ]]; then arguments+=("--prompt" "$initial_prompt"); fi +if [[ -n "$initial_piece" ]]; then arguments+=("--piece" "$initial_piece"); fi if [[ -n "$runtime" ]]; then arguments+=("--runtime" "$runtime"); fi if [[ "$autopublish" == "on" ]]; then arguments+=("--autopublish"); fi if [[ "$autopublish" == "off" ]]; then arguments+=("--no-autopublish"); fi diff --git a/easel/bin/sync-context.mjs b/easel/bin/sync-context.mjs --- a/easel/bin/sync-context.mjs +++ b/easel/bin/sync-context.mjs @@ -20,6 +20,7 @@ // node bin/sync-context.mjs --check # exit 1 if it is stale import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { build as buildApiMap } from "./build-api-map.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const EASEL = join(HERE, ".."); @@ -41,10 +42,20 @@ `\n\n`; export function build() { mkdirSync(OUT, { recursive: true }); - return BUNDLE.map(([from, to, subject]) => { + const guides = BUNDLE.map(([from, to, subject]) => { const body = header(from, subject) + readFileSync(join(REPO, from), "utf8"); return { path: join(OUT, to), body, from, to, subject }; }); + // The API map travels the same way, for the same reason: it is read off the + // runtime source, which an installed Easel does not have. + guides.push({ + path: join(OUT, "api.json"), + body: buildApiMap(), + from: "system/public/aesthetic.computer/lib/{disk,graph,ui,num,geo}.mjs", + to: "api.json", + subject: "the piece API map", + }); + return guides; } const check = process.argv.includes("--check"); diff --git a/easel/context/api.json b/easel/context/api.json new file mode 100644 --- /dev/null +++ b/easel/context/api.json @@ -0,0 +1,905 @@ +{ + "about": "The Aesthetic Computer piece API, read from the runtime. `path` is where it sits on the $api object a piece destructures in paint/act/sim. Signatures are the runtime's own.", + "built_from": "system/public/aesthetic.computer/lib/{disk,graph,ui,num,geo}.mjs", + "entries": [ + { + "name": "blend", + "path": "blend", + "signature": "blend(mode = \"blend\")", + "doc": "Shortcuts l: graph.line, i: ink, Defaults", + "source": "lib/graph.mjs:2851", + "examples": [] + }, + { + "name": "setEraseTarget", + "path": "setEraseTarget", + "signature": "setEraseTarget(target, targetWidth)", + "doc": "", + "source": "lib/graph.mjs:2859", + "examples": [] + }, + { + "name": "page", + "path": "page", + "signature": "page(buffer)", + "doc": "Point subsequent drawing at another painting buffer; page(screen) comes back.", + "source": "lib/disk.mjs:6391", + "examples": [ + "disks/bits.mjs:33 page(sys.painting)", + "disks/breathe.mjs:29 page(screen);", + "disks/cal.mjs:34 ← / → — page (month pages months; week pages weeks; day pages days)" + ] + }, + { + "name": "edit", + "path": "edit", + "signature": "edit(changer)", + "doc": "Edit pixels by pasing a callback.", + "source": "lib/graph.mjs:752", + "examples": [] + }, + { + "name": "ink", + "path": "ink", + "signature": "ink(r, g, b, a) | ink(gray, a) | ink(\"red\") | ink([r, g, b]) | ink() (random)", + "doc": "Set the paint color for everything drawn next. Returns the API so calls chain: ink(255, 0, 0).line(0, 0, 10, 10).", + "source": "lib/disk.mjs:6421", + "examples": [ + "disks/$.mjs:168 ink([160, 160, 160]).write(compressedText, { x, y, size: scale });", + "disks/$.mjs:195 ink([160, 160, 160]).write(compressedText, { x: startX, y, size: scale });", + "disks/$.mjs:204 ink([160, 160, 160]).write(compressedText, { x: loopX, y, size: scale });" + ] + }, + { + "name": "ink2", + "path": "ink2", + "signature": "ink2(...color)", + "doc": "Secondary color, used by gradient-aware primitives.", + "source": "lib/disk.mjs:6425", + "examples": [] + }, + { + "name": "wipe", + "path": "wipe", + "signature": "wipe(...color)", + "doc": "Fill the whole screen with a color; the usual first line of paint().", + "source": "lib/disk.mjs:6431", + "examples": [ + "disks/$.mjs:67 wipe(0);", + "disks/$.mjs:324 wipe(0);", + "disks/$.mjs:705 wipe(\"black\").ink(\"cyan\").write(\"$\", { center: \"xy\", size: 4 });" + ] + }, + { + "name": "backgroundFill", + "path": "backgroundFill", + "signature": "backgroundFill(color)", + "doc": "Set background fill color for reframe operations (especially for KidLisp pieces)", + "source": "lib/disk.mjs:6483", + "examples": [] + }, + { + "name": "clear", + "path": "clear", + "signature": "clear()", + "doc": "Clear the buffer to transparent (unlike wipe, which paints a color).", + "source": "lib/graph.mjs:1407", + "examples": [ + "disks/doodle.mjs:53 clear(); // Always clear if the line is changing.", + "disks/doodle.mjs:61 clear();", + "disks/oldwand.mjs:996 clear(color, segments, segmentMarkers, totalLength, remote = false) {" + ] + }, + { + "name": "copy", + "path": "copy", + "signature": "copy(destX, destY, srcX, srcY, src, alpha = 1.0)", + "doc": "", + "source": "lib/graph.mjs:1835", + "examples": [ + "disks/oldpull.mjs:121 copy(x, y, selection.x + x, selection.y + y, sketch);" + ] + }, + { + "name": "paste", + "path": "paste", + "signature": "paste(from, destX = 0, destY = 0, scale = 1, blit = false)", + "doc": "", + "source": "lib/graph.mjs:2212", + "examples": [ + "disks/25.4.13.19.24.mjs:52 paste(drawing, x, y, scale);", + "disks/angel.mjs:65 paste(painting, xposition, screen.height - painting.height);", + "disks/arena.mjs:3262 paste(buffer, centerX, centerY);" + ] + }, + { + "name": "stamp", + "path": "stamp", + "signature": "stamp(from, x, y, scale, angle)", + "doc": "Similar to paste, but always draws from the center of x, y. Has partial support for {center, bottom}. 24.02.15.12.19", + "source": "lib/graph.mjs:2797", + "examples": [ + "disks/flap.mjs:50 if (store[`flap~${num}`]) stamp(store[`flap~${num}`], screen.width / 2, screen.height / 2);", + "disks/graphics.mjs:28 stamp(", + "disks/kokazo.mjs:243 stamp(ink, color, x, y, jx, jy, r);" + ] + }, + { + "name": "pixel", + "path": "pixel", + "signature": "pixel(x, y, painting = { width, height, pixels })", + "doc": "Return a pixel from the main buffer or from a specified buffer.", + "source": "lib/graph.mjs:757", + "examples": [ + "disks/blur.mjs:36 const srcColor = pixel(...xy, system.painting);", + "disks/blur.mjs:50 const sampleCol = pixel(...sampleXY, system.painting);", + "disks/colplay.mjs:100 const color = pixel(x, y, system.painting);" + ] + }, + { + "name": "plot", + "path": "plot", + "signature": "plot(x, y)", + "doc": "Where a pixel is a region in which we draw from the upper left corner. (2D)", + "source": "lib/graph.mjs:1639", + "examples": [ + "disks/bootpics.mjs:184 plot(ox + x, oy + y);", + "disks/doodle.mjs:78 plot(x, y);", + "disks/jas.mjs:557 plot(ox + fx * scale, oy + fy * scale);" + ] + }, + { + "name": "flood", + "path": "flood", + "signature": "flood(x, y, fillColor = c)", + "doc": "Fill pixels with a color using a flood fill technique.", + "source": "lib/graph.mjs:778", + "examples": [ + "disks/colplay.mjs:116 flood(x, y, [255, 255, 255, 127]);", + "disks/fill.mjs:30 flood(pen.x, pen.y, floodColor);" + ] + }, + { + "name": "compositeLayers", + "path": "compositeLayers", + "signature": "compositeLayers(layers)", + "doc": "GPU-accelerated multi-layer compositing", + "source": "lib/graph.mjs:6087", + "examples": [] + }, + { + "name": "batchedEffects", + "path": "batchedEffects", + "signature": "batchedEffects(options = {})", + "doc": "GPU-accelerated batched effects (zoom+scroll+contrast+brightness in one pass)", + "source": "lib/graph.mjs:6182", + "examples": [] + }, + { + "name": "point", + "path": "point", + "signature": "point(...args)", + "doc": "Plots a single pixel within the panned coordinate space. Basically a wrapper over plot, which should ultimately be renamed to set? Accepts x, y or {x, y}", + "source": "lib/graph.mjs:1725", + "examples": [ + "disks/i.mjs:102 point(...g.point).line(...g.line[0], ...g.line[1]);", + "disks/nail.mjs:166 point(p) {", + "disks/pline.mjs:137 const p = point(e);" + ] + }, + { + "name": "line", + "path": "line", + "signature": "line(x1, y1, x2, y2) | line({x, y}, {x, y}) | line(x1, y1, x2, y2, thickness)", + "doc": "Draw a 1px line between two points in the current ink.", + "source": "lib/graph.mjs:3205", + "examples": [ + "disks/ableton.mjs:124 line(x, y + v, x + 1, y + v);", + "disks/ableton.mjs:527 const blurbY = labelY + 12; // label line (10 tall) + 2 gap", + "disks/ableton.mjs:528 const buttonsY = blurbY + 14; // blurb line (10 tall) + 4 gap" + ] + }, + { + "name": "lineAngle", + "path": "lineAngle", + "signature": "lineAngle(x1, y1, dist, degrees)", + "doc": "Draws a line from a point at a distance... with an angle in degrees.", + "source": "lib/graph.mjs:3407", + "examples": [ + "disks/ucla-5.mjs:32 8. - [] Learning `lineAngle(x1, y1, dist, degrees)`", + "disks/ucla-6-turtle.mjs:14 8. - [] Learning `lineAngle(x1, y1, dist, degrees)`" + ] + }, + { + "name": "pline", + "path": "pline", + "signature": "pline(coords, thickness, shader)", + "doc": "", + "source": "lib/graph.mjs:3596", + "examples": [] + }, + { + "name": "setBufferAlpha", + "path": "setBufferAlpha", + "signature": "setBufferAlpha(buffer, alpha)", + "doc": "Set the alpha of every non-transparent pixel in a buffer to a uniform value. Useful for per-stroke alpha — must be called after drawing on the buffer.", + "source": "lib/disk.mjs:6601", + "examples": [ + "disks/line.mjs:215 setBufferAlpha(nopaint.buffer, strokeAlpha);" + ] + }, + { + "name": "pppline", + "path": "pppline", + "signature": "pppline(points, shader)", + "doc": "Takes an array of pixel coords `{x, y}` and filters out L shapes. Note: It checks the previous, current, and next pixel and requires a minimum set of 3 before it removes anything. Draws a regular `line` if only two pixels are provided. Transcribed from: https://rickyhan.com/jekyll/update/2018/11/22/pixel-art-algorithm-pixel-perfect.html", + "source": "lib/graph.mjs:3346", + "examples": [] + }, + { + "name": "oval", + "path": "oval", + "signature": "oval(x0, y0, radiusX, radiusY, filled = false, thickness = 1, precision,)", + "doc": "", + "source": "lib/graph.mjs:3542", + "examples": [ + "disks/flower-eater.mjs:255 oval(x, y + 2, rx, max(1, floor(rx * 0.3)), true);", + "disks/lmn-flower.mjs:32 oval(screen.width / 2, screen.height / 2, 20, 10, true )", + "disks/lmn-petal.mjs:29 oval(screen.width / 2, screen.height / 2, 80, 17, true);" + ] + }, + { + "name": "circle", + "path": "circle", + "signature": "circle(x0, y0, radius, filled = false, thickness, precision)", + "doc": "", + "source": "lib/graph.mjs:3477", + "examples": [ + "disks/butterflies.mjs:535 circle(voice.pointerX, voice.pointerY, 11);", + "disks/butterflies.mjs:537 circle(voice.pointerX, voice.pointerY, 7);", + "disks/butterflies.mjs:540 circle(reader.x, reader.y, 8);" + ] + }, + { + "name": "pie", + "path": "pie", + "signature": "pie(x0, y0, radius, startAngle, endAngle, precision = 3)", + "doc": "Draw a filled pie slice / wedge (for pie charts, progress indicators, etc.) startAngle and endAngle are in radians, 0 = right, PI/2 = down, etc.", + "source": "lib/graph.mjs:3521", + "examples": [] + }, + { + "name": "tri", + "path": "tri", + "signature": "tri(x1, y1, x2, y2, x3, y3, mode = \"fill\")", + "doc": "Triangle from three points; mode \"fill\" or \"outline\".", + "source": "lib/graph.mjs:4566", + "examples": [ + "disks/blank.mjs:480 tri(proj[a][0], proj[a][1], proj[b][0], proj[b][1], proj[c][0], proj[c][1]);", + "disks/blank.mjs:481 tri(proj[a][0], proj[a][1], proj[c][0], proj[c][1], proj[d][0], proj[d][1]);", + "disks/blank.mjs:496 tri(x0, y0, x1, y1, x2, y2);" + ] + }, + { + "name": "poly", + "path": "poly", + "signature": "poly(coords)", + "doc": "Draws a series of 1px lines without overlapping / overdrawing points. TODO: Add closed mode? Example: ink(handPalette.w).poly([...w, w[0]]);", + "source": "lib/graph.mjs:3571", + "examples": [ + "disks/toss.mjs:496 poly(points);", + "disks/toss.mjs:514 poly(points);", + "disks/visualizer.mjs:1614 poly(waveformPoints);" + ] + }, + { + "name": "box", + "path": "box", + "signature": "box(x, y, size) | box(x, y, w, h) | box(x, y, w, h, mode) | box({x, y, w, h}, mode)", + "doc": "Rectangle. `mode` is \"fill\" (default), \"outline\", \"inline\", or \"fill*center\" / \"outline*center\" to draw from the center.", + "source": "lib/graph.mjs:3815", + "examples": [ + "disks/1v1.mjs:1490 box(x * squareSize, y * squareSize, squareSize, squareSize);", + "disks/1v1.mjs:1516 box(x * squareSize, y * squareSize, squareSize, squareSize);", + "disks/a-star.mjs:819 box(x * CELL_WIDTH, y * CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT);" + ] + }, + { + "name": "shape", + "path": "shape", + "signature": "shape(x1, y1, x2, y2, ...) | shape([[x, y], [x, y], ...], filled = true)", + "doc": "Rasterize a filled or outlined polygon from point pairs.", + "source": "lib/graph.mjs:4132", + "examples": [ + "disks/flower-eater.mjs:314 shape([[girlX - 7, headY + 16], [girlX + 6, headY + 16],", + "disks/oldwipppps.mjs:369 shape([", + "disks/oldwipppps.mjs:377 shape([" + ] + }, + { + "name": "grid", + "path": "grid", + "signature": "grid({ box: { x, y, w: cols, h: rows }, transform: { scale, angle, width: twidth, height: theight, anchor }, centers = [], }, buffer,)", + "doc": "", + "source": "lib/graph.mjs:4690", + "examples": [ + "disks/plot.mjs:190 grid(" + ] + }, + { + "name": "draw", + "path": "draw", + "signature": "draw(drawing, x, y, scale = 1, angle = 0, thickness = 1)", + "doc": "Draw a stored vector drawing (from `drawing`/store) at a position.", + "source": "lib/graph.mjs:5053", + "examples": [ + "disks/icon.mjs:6 - [] Use angle: `draw(drawing, x, y, scale = 1, angle = 0)`", + "disks/wgr.mjs:540 draw({ x, y, pressure }) {" + ] + }, + { + "name": "setShowClippedWireframes", + "path": "setShowClippedWireframes", + "signature": "setShowClippedWireframes(enabled)", + "doc": "Function to toggle wireframe rendering", + "source": "lib/graph.mjs:8174", + "examples": [ + "disks/1v1.mjs:1464 setShowClippedWireframes(showWireframes);", + "disks/1v1.mjs:2023 setShowClippedWireframes(showWireframes);", + "disks/fps.mjs:326 setShowClippedWireframes(showWireframes);" + ] + }, + { + "name": "clearWireframeBuffer", + "path": "clearWireframeBuffer", + "signature": "clearWireframeBuffer()", + "doc": "Function to clear wireframe buffer (called at start of frame)", + "source": "lib/graph.mjs:8179", + "examples": [ + "disks/1v1.mjs:1469 clearWireframeBuffer();", + "disks/fps.mjs:331 clearWireframeBuffer();" + ] + }, + { + "name": "drawBufferedWireframes", + "path": "drawBufferedWireframes", + "signature": "drawBufferedWireframes()", + "doc": "Function to draw all buffered wireframes (called at end of frame)", + "source": "lib/graph.mjs:8357", + "examples": [ + "disks/1v1.mjs:1603 drawBufferedWireframes();", + "disks/fps.mjs:402 drawBufferedWireframes();" + ] + }, + { + "name": "getRenderStats", + "path": "getRenderStats", + "signature": "getRenderStats()", + "doc": "Function to get current render stats", + "source": "lib/graph.mjs:8197", + "examples": [] + }, + { + "name": "printLine", + "path": "printLine", + "signature": "printLine(text, font, startX, startY, blockWidth = 6, scale = 1, xOffset = 0, thickness = 1, rotation = 0, fontMetadata = null, fallbackFont = null,)", + "doc": "", + "source": "lib/graph.mjs:5316", + "examples": [] + }, + { + "name": "pan", + "path": "pan", + "signature": "pan(x, y)", + "doc": "", + "source": "lib/graph.mjs:1795", + "examples": [ + "disks/baktok.mjs:133 pan(noshake ? 0 : choose(-1, 0, 1), noshake ? 0 : choose(-1, 0, 1));", + "disks/baktok.mjs:136 pan(noshake ? 0 : choose(-1, 0, 1), noshake ? 0 : choose(-1, 0, 1));", + "disks/clock.mjs:3757 const pan = 0; // Centered pan (could be enhanced later)" + ] + }, + { + "name": "unpan", + "path": "unpan", + "signature": "unpan()", + "doc": "Undo pan(x, y).", + "source": "lib/graph.mjs:1805", + "examples": [ + "disks/baktok.mjs:135 unpan();", + "disks/baktok.mjs:138 unpan();", + "disks/lmn-petal.mjs:32 unpan();" + ] + }, + { + "name": "savepan", + "path": "savepan", + "signature": "savepan()", + "doc": "Save the local transform.", + "source": "lib/graph.mjs:1813", + "examples": [ + "disks/field.mjs:58 savepan();" + ] + }, + { + "name": "loadpan", + "path": "loadpan", + "signature": "loadpan()", + "doc": "Restore it.", + "source": "lib/graph.mjs:1818", + "examples": [ + "disks/field.mjs:69 loadpan();" + ] + }, + { + "name": "mask", + "path": "mask", + "signature": "mask({ x, y, width, height })", + "doc": "Clip drawing to a rectangle until unmask().", + "source": "lib/graph.mjs:1826", + "examples": [ + "disks/chat.mjs:1278 mask({", + "disks/clocks.mjs:152 mask({", + "disks/commits.mjs:326 mask({ x: 0, y: topMargin, width: w, height: chatHeight });" + ] + }, + { + "name": "unmask", + "path": "unmask", + "signature": "unmask()", + "doc": "Lift the clip set by mask().", + "source": "lib/graph.mjs:1831", + "examples": [ + "disks/chat.mjs:2154 unmask();", + "disks/clocks.mjs:205 unmask(); // End masking", + "disks/commits.mjs:485 unmask();" + ] + }, + { + "name": "steal", + "path": "steal", + "signature": "steal(x, y, width, height)", + "doc": "", + "source": "lib/graph.mjs:7733", + "examples": [] + }, + { + "name": "scroll", + "path": "scroll", + "signature": "scroll(dx = 0, dy = 0)", + "doc": "Scroll the entire pixel buffer by x and/or y pixels with wrapping", + "source": "lib/graph.mjs:5599", + "examples": [ + "disks/gulmo.mjs:36 let scroll = 0; // continuous forward scroll (fractional rows)", + "disks/mibo.mjs:54 let scrollPhase = 0; // accumulated depth-scroll (fly-forward), advances in onSim", + "disks/mugs.mjs:35 let scroll = 0; // Negative scroll (like colors.mjs)" + ] + }, + { + "name": "flip", + "path": "flip", + "signature": "flip(horizontal = false, vertical = false)", + "doc": "Mirror the screen.", + "source": "lib/graph.mjs:5725", + "examples": [ + "disks/textfence.mjs:229 const completed = flip();", + "disks/textfence.mjs:243 if (flip()) {" + ] + }, + { + "name": "spin", + "path": "spin", + "signature": "spin(steps = 0, anchorX = null, anchorY = null)", + "doc": "Each ring rotates by exactly 'steps' pixels, preserving all data", + "source": "lib/graph.mjs:6274", + "examples": [] + }, + { + "name": "sort", + "path": "sort", + "signature": "sort()", + "doc": "Pixel-sort the screen — a glitch effect.", + "source": "lib/graph.mjs:7623", + "examples": [] + }, + { + "name": "zoom", + "path": "zoom", + "signature": "zoom(level = 1, anchorX = 0.5, anchorY = 0.5)", + "doc": "Zoom the entire pixel buffer with 1.0 as neutral (no change) level < 1.0 zooms out, level > 1.0 zooms in, level = 1.0 does nothing anchorX, anchorY: 0.0 = top/left, 0.5 = center, 1.0 = bottom/right Uses bilinear sampling with hard-edge thresholding for smooth scaling with crisp output", + "source": "lib/graph.mjs:6706", + "examples": [ + "disks/wgr.mjs:482 zoom(amt) {" + ] + }, + { + "name": "suck", + "path": "suck", + "signature": "suck(strength = 1, centerX, centerY)", + "doc": "Radial displacement transformation with pixel-perfect nearest neighbor sampling Creates discrete, lossless pixel movement without blur or center holes", + "source": "lib/graph.mjs:7121", + "examples": [] + }, + { + "name": "blur", + "path": "blur", + "signature": "blur(strength = 1, quality = \"medium\")", + "doc": "Efficient Gaussian blur using separable filtering with linear sampling optimization Creates smooth blur effect by applying horizontal then vertical Gaussian convolution", + "source": "lib/graph.mjs:7273", + "examples": [ + "disks/notepat.mjs:8583 blur(0.5);" + ] + }, + { + "name": "sharpen", + "path": "sharpen", + "signature": "sharpen(strength = 1)", + "doc": "Apply sharpening filter to enhance edges and details strength: 0 = no sharpening, 1 = normal sharpening, >1 = aggressive sharpening", + "source": "lib/graph.mjs:7514", + "examples": [ + "disks/notepat.mjs:5872 sharpen(sharpenAmount);" + ] + }, + { + "name": "invert", + "path": "invert", + "signature": "invert()", + "doc": "Invert every pixel's color.", + "source": "lib/graph.mjs:2094", + "examples": [] + }, + { + "name": "contrast", + "path": "contrast", + "signature": "contrast(level = 1.0)", + "doc": "Adjust the contrast of the pixel buffer level: 1.0 = no change, >1.0 = more contrast, <1.0 = less contrast", + "source": "lib/graph.mjs:1939", + "examples": [ + "disks/oldwipppps.mjs:55 filter: contrast(1.2) brightness(1.1) saturate(1.1)" + ] + }, + { + "name": "shear", + "path": "shear", + "signature": "shear(shearX = 0, shearY = 0)", + "doc": "KidPix-style shear function shearX: horizontal shear factor (positive = right lean, negative = left lean) shearY: vertical shear factor (positive = down lean, negative = up lean)", + "source": "lib/graph.mjs:7748", + "examples": [] + }, + { + "name": "resetScrollState", + "path": "resetScrollState", + "signature": "resetScrollState()", + "doc": "Reset scroll accumulators - called when pieces change", + "source": "lib/graph.mjs:5582", + "examples": [] + }, + { + "name": "noise16", + "path": "noise16", + "signature": "noise16()", + "doc": "", + "source": "lib/graph.mjs:5446", + "examples": [ + "disks/graphics.mjs:29 painting(6, 6, ({ noise16 }) => noise16()),", + "disks/ordfish.mjs:213 ready <= GO ? noise16(0) : wipe();", + "disks/wgr.mjs:185 noise16();" + ] + }, + { + "name": "noise16DIGITPAIN", + "path": "noise16DIGITPAIN", + "signature": "noise16DIGITPAIN()", + "doc": "", + "source": "lib/graph.mjs:5471", + "examples": [ + "disks/hell_-world.mjs:944 noise16DIGITPAIN();", + "disks/images.mjs:20 imgToExport = painting(256, 256, ({noise16DIGITPAIN}) => noise16DIGITPAIN());", + "disks/noise.mjs:34 noise16DIGITPAIN();" + ] + }, + { + "name": "noise16Aesthetic", + "path": "noise16Aesthetic", + "signature": "noise16Aesthetic()", + "doc": "", + "source": "lib/graph.mjs:5496", + "examples": [ + "disks/login-pattern.mjs:15 noise16Aesthetic().ink(0, 100).box(0, 0, width, height);" + ] + }, + { + "name": "noise16Sotce", + "path": "noise16Sotce", + "signature": "noise16Sotce()", + "doc": "", + "source": "lib/graph.mjs:5521", + "examples": [] + }, + { + "name": "noiseTinted", + "path": "noiseTinted", + "signature": "noiseTinted(tint, amount, saturation)", + "doc": "", + "source": "lib/graph.mjs:5546", + "examples": [ + "disks/aframe.mjs:83 noiseTinted([0, 0, 0], 0.9, 0.1);", + "disks/decode.mjs:57 noiseTinted([189, 164, 166], 0.8, 0.6);", + "disks/noise.mjs:23 noiseTinted(hud.currentStatusColor(), 0.15, 0.1);" + ] + }, + { + "name": "pasteWithAlpha", + "path": "pasteWithAlpha", + "signature": "pasteWithAlpha(source, x, y, alpha)", + "doc": "🎨 Alpha-blended paste for crossfade compositing", + "source": "lib/disk.mjs:6669", + "examples": [ + "disks/merry-fade.mjs:85 if (outAlpha > 0) pasteWithAlpha(outgoing, 0, 0, outAlpha);" + ] + }, + { + "name": "kidlisp", + "path": "kidlisp", + "signature": "kidlisp(x = 0, y = 0, width, height, source, options = {})", + "doc": "🎯 Simplified KidLisp integration using global singleton instance", + "source": "lib/disk.mjs:6705", + "examples": [ + "disks/$.mjs:369 kidlisp(", + "disks/cross-tab-test.mjs:76 kidlisp(", + "disks/kidlisp-in-js.mjs:64 kidlisp(0, 0, hw, h, \"(wipe green) (ink blue) (box 20 20 60 60)\");" + ] + }, + { + "name": "synth", + "path": "sound.synth", + "signature": "sound.synth({ tone = 440, type = \"square\", duration = 0.1, beats = undefined, attack = 0.01, decay = 0.9, volume, pan = 0, immediate = false, probe = null, generator = null, })", + "doc": "Play a synthesized tone. `tone` is Hz or a note name like \"c4\"; returns a voice with .kill() and .update().", + "source": "lib/disk.mjs:13124", + "examples": [ + "disks/$.mjs:646 sound.synth({", + "disks/1but.mjs:66 sound.synth({ type: \"triangle\", tone: 1047, attack: 0, decay: 0.1, duration: 0.1, volume: 0.25 });", + "disks/1but.mjs:67 sound.synth({ type: \"triangle\", tone: 1319, attack: 0.06, decay: 0.1, duration: 0.1, volume: 0.2 });" + ] + }, + { + "name": "play", + "path": "sound.play", + "signature": "sound.play(sfx, options, callbacks)", + "doc": "Play a loaded sample or sfx by id.", + "source": "lib/disk.mjs:13039", + "examples": [ + "disks/1v1.mjs:695 bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 });", + "disks/1v1.mjs:763 bgmPlaying = sound.play(bgmSfx, { loop: true, volume: 0.4 });", + "disks/booted-by.mjs:211 sound.play(startupSfx); // Play startup sound..." + ] + }, + { + "name": "Button", + "path": "ui.Button", + "signature": "new ui.Button(x, y, w, h) | new ui.Button({ x, y, w, h })", + "doc": "A button. In paint: btn.paint((b) => { ink(b.down ? \"yellow\" : \"gray\").box(b.box) }). In act: btn.act(e, { push: () => {}, down: () => {}, up: () => {}, cancel: () => {} }). Pass pens() as the 3rd arg to act for multitouch. Rebuild buttons in `reframed`.", + "source": "lib/ui.mjs:264", + "examples": [ + "disks/arena.mjs:3554 up: { btn: new ui.Button(moveX + btnSize + gap, moveY, btnSize, btnSize), key: \"forward\", label: \"↑\", isArrow: true },", + "disks/arena.mjs:3556 left: { btn: new ui.Button(moveX, moveY + btnSize + gap, btnSize, btnSize), key: \"left\", label: \"←\", isArrow: true },", + "disks/arena.mjs:3559 view: { btn: new ui.Button(actionX, actionY, btnSizeWide, btnSize), key: \"view\", label: \"VIEW\", color: [150, 110, 200], isView: true }," + ] + }, + { + "name": "TextButton", + "path": "ui.TextButton", + "signature": "new ui.TextButton(text = \"Button\", pos = { x: 0, y: 0 }, typeface = TYPEFACE_UI, gap = null)", + "doc": "A labelled button sized to its text.", + "source": "lib/ui.mjs:875", + "examples": [ + "disks/ads.mjs:35 chatBtn = new ui.TextButton(\"CHAT\", { center: \"x\", y: Math.floor(screen.height * 0.55), screen });", + "disks/amail.mjs:100 inboxBtn = new ui.TextButton(\"inbox\", { screen });", + "disks/amail.mjs:101 sentBtn = new ui.TextButton(\"sent\", { screen });" + ] + }, + { + "name": "label", + "path": "hud.label", + "signature": "hud.label(text, color, offset)", + "doc": "Take over the system's corner label — the only sanctioned way to draw in the top-left.", + "source": "lib/disk.mjs:3642", + "examples": [ + "disks/$.mjs:642 hud.label(`Previewing ${entry.codeText}`, \"cyan\");", + "disks/amail.mjs:82 hud.label(\"amail\");", + "disks/audio.mjs:81 hud.label(\"audio\");" + ] + }, + { + "name": "write", + "path": "write", + "signature": "write(text, { x, y, size, center: \"x\" | \"xy\" }) | write(text, x, y)", + "doc": "Draw text in the current ink. Chains from ink(): ink(\"white\").write(\"hi\", { x: 10, y: 40 }).", + "source": "lib/disk.mjs:5582", + "examples": [ + "disks/arena.mjs:2908 write(txt, { x: rX - txt.length * 4, y }, undefined, undefined, false, font);", + "disks/arena.mjs:2918 write(t, { x, y }, undefined, undefined, false, font);", + "disks/arena.mjs:3094 write(msg1, { x: Math.floor(screen.width / 2 - msg1.length * 2), y: bannerY }, undefined, undefined, false, \"MatrixChunky8\");" + ] + }, + { + "name": "randInt", + "path": "num.randInt", + "signature": "num.randInt(n)", + "doc": "Random integer in [0, n].", + "source": "lib/num.mjs:225", + "examples": [ + "disks/bgm.mjs:26 if (params.length === 0) params[0] = num.randInt(trackCount - 1);", + "disks/crayon.mjs:86 let numDots = num.randInt(minNumDots, maxNumDots);", + "disks/dafu.mjs:101 const speed = num.randInt(20) / 10 + 0.4;" + ] + }, + { + "name": "randIntRange", + "path": "num.randIntRange", + "signature": "num.randIntRange(low, high)", + "doc": "Random integer in [low, high].", + "source": "lib/num.mjs:248", + "examples": [ + "disks/ant.mjs:56 num.randIntRange(margin, gridW - margin),", + "disks/ant.mjs:57 num.randIntRange(margin, gridH - margin),", + "disks/ant.mjs:249 angle: num.randIntRange(0, 360) * (Math.PI / 180)," + ] + }, + { + "name": "lerp", + "path": "num.lerp", + "signature": "num.lerp(a, b, amount)", + "doc": "Linear interpolation a→b by t.", + "source": "lib/num.mjs:342", + "examples": [ + "disks/blur.mjs:62 avgCol[i] = num.lerp(srcColor[i], avgCol[i], lerpAmt);", + "disks/bubble.mjs:116 radius = num.lerp(radius, currentParams.radius, 0.15);", + "disks/bubble.mjs:125 parameterDisplay.fadeAlpha = num.lerp(parameterDisplay.fadeAlpha, 0, 0.02);" + ] + }, + { + "name": "clamp", + "path": "num.clamp", + "signature": "num.clamp(value, low, high)", + "doc": "Clamp a value between min and max.", + "source": "lib/num.mjs:328", + "examples": [ + "disks/bubble.mjs:80 const normalizedDistance = num.clamp(", + "disks/bubble.mjs:104 const volume = num.clamp(normalizedEdgeDistance + 0.3, 0.3, 1.0);", + "disks/bubble.mjs:291 const normalizedDistance = num.clamp(" + ] + }, + { + "name": "dist", + "path": "num.dist", + "signature": "num.dist(x1, y1, x2, y2)", + "doc": "Distance between two points.", + "source": "lib/num.mjs:280", + "examples": [ + "disks/bubble.mjs:79 let distanceFromCenter = num.dist(pointer.x, pointer.y, centerX, centerY);", + "disks/bubble.mjs:290 let distanceFromCenter = num.dist(e.x, e.y, centerX, centerY);", + "disks/bubble.mjs:356 let distanceFromCenter = num.dist(e.x, e.y, centerX, centerY);" + ] + }, + { + "name": "map", + "path": "num.map", + "signature": "num.map(num, inMin, inMax, outMin, outMax)", + "doc": "Map a value from one range to another.", + "source": "lib/num.mjs:348", + "examples": [ + "disks/bubble.mjs:87 const pan = num.map(pointer.x, 0, screen.width, -1, 1);", + "disks/bubble.mjs:90 const rise = num.map(pointer.y, 0, screen.height, 4.0, 0.2);", + "disks/bubble.mjs:93 const bubbleRadius = num.map(normalizedDistance, 0, 1, 3, 25);" + ] + }, + { + "name": "radians", + "path": "num.radians", + "signature": "num.radians(deg = 0)", + "doc": "Degrees to radians.", + "source": "lib/num.mjs:317", + "examples": [ + "disks/moods.mjs:595 const osc = (sin(num.radians(bounceCount % 360)) + 1) / 2;", + "disks/staka.mjs:105 const radians = $.num.radians(ball.angle);", + "disks/staka.mjs:141 const radians = $.num.radians(ball.angle);" + ] + }, + { + "name": "Box", + "path": "geo.Box", + "signature": "new geo.Box(x, y, w, h)", + "doc": "An axis-aligned rectangle with .x .y .w .h and .contains({x, y}).", + "source": "lib/geo.mjs:41", + "examples": [ + "disks/hell_-world.mjs:873 prevBtn.box = new geo.Box(", + "disks/hell_-world.mjs:901 nextBtn.box = new geo.Box(", + "disks/notepat.mjs:8806 buttons[label].box = new geo.Box(...geometry);" + ] + }, + { + "name": "Circle", + "path": "geo.Circle", + "signature": "new geo.Circle(x, y, radius = 8)", + "doc": "A circle with .contains(point).", + "source": "lib/geo.mjs:8", + "examples": [ + "disks/balls.mjs:17 ball.circle = new geo.Circle(screen.width / 2, screen.height / 2, 8);", + "disks/staka.mjs:53 ball.circle = new geo.Circle(screen.width / 2, screen.height / 2 - 100, radius);" + ] + }, + { + "name": "screen", + "path": "screen", + "signature": "screen.width, screen.height, screen.pixels, screen.center", + "doc": "The canvas. Read width/height in paint(); never write screen.pixels directly (writes can silently drop) — draw into your own painting buffer and paste() it.", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "pen", + "path": "pen", + "signature": "pen.x, pen.y, pen.drawing, pen.delta", + "doc": "The single primary pointer; null when there is none. Read in paint()/sim().", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "pens", + "path": "pens", + "signature": "pens() → [{ x, y, id, drawing }]", + "doc": "Every active pointer, for multitouch. Pass to btn.act(e, callbacks, pens()).", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "event", + "path": "act(e)", + "signature": "e.is(\"touch\") | e.is(\"draw\") | e.is(\"lift\") | e.is(\"keyboard:down:space\") | e.is(\"reframed\") ; e.x, e.y, e.delta, e.key", + "doc": "Events arrive in act({ event: e, ... }). Pointer: touch → draw → lift. Keys: keyboard:down:, keyboard:up:.", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "sim", + "path": "sim", + "signature": "function sim({ ... }) — runs 120 times per second", + "doc": "Physics and timers go here, not in paint(); paint() runs at display rate and only when something needs painting.", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "needsPaint", + "path": "needsPaint", + "signature": "needsPaint()", + "doc": "Ask for another paint() when the piece is static and something changed.", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "painting", + "path": "painting", + "signature": "painting(w, h, (api) => { ... }) → buffer", + "doc": "Make an offscreen buffer by drawing into it; show it later with paste(buffer, x, y).", + "source": "lib/disk.mjs", + "examples": [] + }, + { + "name": "help.choose", + "path": "help.choose", + "signature": "help.choose(...items)", + "doc": "Pick one item at random.", + "source": "lib/help.mjs", + "examples": [] + }, + { + "name": "help.repeat", + "path": "help.repeat", + "signature": "help.repeat(n, (i) => { ... })", + "doc": "Call a function n times.", + "source": "lib/help.mjs", + "examples": [] + } + ] +} diff --git a/easel/package.json b/easel/package.json --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "easel", - "version": "0.5.1", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/easel/src/about.mjs b/easel/src/about.mjs new file mode 100644 --- /dev/null +++ b/easel/src/about.mjs @@ -0,0 +1,39 @@ +export function aboutMap() { + return [ + "EASEL — make a piece by talking to it", + "", + "You → model → working piece → live preview → URL / QR", + " │ │", + " │ └─ v1 → v2 → v3 · /versions · /rollback vN", + " ├─ AC hosted · /backend ac · /model", + " ├─ Your Claude account · /backend claude", + " └─ Your Codex account · /backend codex", + "", + "MAKE /piece · /runtime · /ask on|off", + "MEASURE /performance · headless logic and drawing-call counts", + "SHARE /publish · /autopublish on|off · /open · /qr", + "ACCOUNT /login · /profile · /logout", + "THREAD /model NAME · /backend NAME · /new", + "", + "Switch engines with recent conversation and the current piece.", + "Versions are saved on this computer; rollback makes a new version.", + "AC hosted uses your handle's daily budget. Claude/Codex use your own CLI sign-in.", + "", + "Esc returns · ↑/↓ scroll · /mouse off restores terminal selection", + ]; +} + +// Recent conversation is portable even when provider thread IDs are not. +export function conversationHandoff(entries, limit = 24000) { + const turns = entries.filter(({ kind }) => kind === "user" || kind === "assistant") + .map(({ kind, text }) => JSON.stringify({ role: kind, content: text })); + const kept = []; + let size = 0; + for (const turn of turns.reverse()) { + const part = turn.length > limit ? turn.slice(0, limit) : turn; + if (size + part.length > limit) break; + kept.unshift(part); + size += part.length; + } + return kept.length ? "Conversation before the engine switch (reference context):\n" + kept.join("\n") : ""; +} diff --git a/easel/src/ac-server.mjs b/easel/src/ac-server.mjs --- a/easel/src/ac-server.mjs +++ b/easel/src/ac-server.mjs @@ -32,6 +32,7 @@ import { EventEmitter } from "node:events"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { validatePieceSource } from "./revisions.mjs"; import { randomUUID } from "node:crypto"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -45,6 +46,8 @@ export const AC_MODELS = { glm: "z-ai/glm-4.6", qwen: "qwen/qwen3-coder", deepseek: "deepseek/deepseek-chat-v3.1", + sonnet: "anthropic/claude-sonnet-4.6", + gpt: "openai/gpt-5.4", }; // The guides, in the order a model should meet them: what a piece is, then how @@ -73,7 +76,7 @@ const WRITE_PIECE = { name: "write_piece", description: - "Write the complete new source of the session's piece. Always send the whole file, never a patch or a fragment — what you send replaces the file exactly. Saving pushes it live to anyone watching, so prefer several small writes over one large one.", + "Write the complete new source of the session's piece. Always send the whole file, never a patch or a fragment — what you send replaces the file exactly. Saving pushes it live to anyone watching. Build the request in several small, complete working checkpoints: send each checkpoint as a separate write_piece call as soon as it is ready, then continue improving it. Never send unfinished syntax.", input_schema: { type: "object", properties: { @@ -99,7 +102,7 @@ site = SITE, } = {}) { super(); this.cwd = cwd; - this.model = AC_MODELS[model] || model || DEFAULT_AC_MODEL; + this.model = (Object.hasOwn(AC_MODELS, model) ? AC_MODELS[model] : model) || DEFAULT_AC_MODEL; this.developerInstructions = developerInstructions; this.piece = piece; this.token = token; @@ -139,6 +142,9 @@ } if (this.developerInstructions) { blocks.push({ type: "text", text: this.developerInstructions }); } + if (this.piece?.file && existsSync(this.piece.file)) { + blocks.push({ type: "text", text: `Current piece (${this.piece.file}); preserve the user's existing work unless asked to change it:\n\n${readFileSync(this.piece.file, "utf8")}` }); + } return blocks; } @@ -216,7 +222,8 @@ } // One request, streamed. Returns why the model stopped. async #round() { - this.controller = new AbortController(); + const controller = this.controller = new AbortController(); + this.emit("notification", { method: "turn/progress", params: { phase: "connecting" } }); const token = await this.token?.(); if (!token) { throw new Error("Hosted inference needs an Aesthetic Computer handle — run /login."); @@ -224,7 +231,7 @@ } const response = await this.fetch(`${this.site}/api/easel-inference`, { method: "POST", - signal: this.controller.signal, + signal: controller.signal, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ model: this.model, @@ -244,8 +251,12 @@ } catch {} throw new Error(message); } + this.emit("notification", { method: "turn/progress", params: { phase: "waiting" } }); const messageId = `msg-${this.turns}-${Date.now()}`; const blocks = []; + const results = []; + let received = 0; + let finished = false; let stop = "end_turn"; let text = ""; // Tool arguments arrive as a JSON string in fragments, so they are gathered @@ -256,58 +267,78 @@ const reader = response.body.getReader(); const decoder = new TextDecoder(); let tail = ""; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - tail += decoder.decode(value, { stream: true }); - let cut = tail.indexOf("\n"); - while (cut !== -1) { - const line = tail.slice(0, cut).trim(); - tail = tail.slice(cut + 1); - cut = tail.indexOf("\n"); - if (!line.startsWith("data: ")) continue; - const payload = line.slice(6); - if (payload === "[DONE]") continue; - let event; - try { - event = JSON.parse(payload); - } catch { - continue; - } + try { + for (;;) { + controller.signal.throwIfAborted(); + const { done, value } = await reader.read(); + controller.signal.throwIfAborted(); + if (done) break; + received += value.byteLength; + tail += decoder.decode(value, { stream: true }); + let cut = tail.indexOf("\n"); + while (cut !== -1) { + const line = tail.slice(0, cut).trim(); + tail = tail.slice(cut + 1); + cut = tail.indexOf("\n"); + if (!line.startsWith("data:")) continue; + const payload = line.slice(5).trimStart(); + if (payload === "[DONE]") continue; + let event; + try { + event = JSON.parse(payload); + } catch { + continue; + } - if (event.type === "content_block_start") { - const block = event.content_block; - if (block?.type === "tool_use") { - partials.set(event.index, { id: block.id, name: block.name, json: "" }); + if (event.type === "content_block_start" || event.type === "content_block_delta") { + this.emit("notification", { method: "turn/progress", params: { + phase: event.delta?.type === "input_json_delta" || event.content_block?.type === "tool_use" ? "composing" : "generating", + bytes: received, + } }); } - } else if (event.type === "content_block_delta") { - const delta = event.delta; - if (delta?.type === "text_delta" && delta.text) { - text += delta.text; - this.emit("notification", { - method: "item/agentMessage/delta", - params: { itemId: messageId, delta: delta.text }, - }); - } else if (delta?.type === "input_json_delta") { + if (event.type === "content_block_start") { + const block = event.content_block; + if (block?.type === "tool_use") { + partials.set(event.index, { id: block.id, name: block.name, json: "" }); + } + } else if (event.type === "content_block_delta") { + const delta = event.delta; + if (delta?.type === "text_delta" && delta.text) { + text += delta.text; + this.emit("notification", { + method: "item/agentMessage/delta", + params: { itemId: messageId, delta: delta.text }, + }); + } else if (delta?.type === "input_json_delta") { + const partial = partials.get(event.index); + if (partial) partial.json += delta.partial_json || ""; + } + } else if (event.type === "content_block_stop") { const partial = partials.get(event.index); - if (partial) partial.json += delta.partial_json || ""; - } - } else if (event.type === "content_block_stop") { - const partial = partials.get(event.index); - if (partial) { - let input = {}; - try { - input = JSON.parse(partial.json || "{}"); - } catch {} - blocks.push({ type: "tool_use", id: partial.id, name: partial.name, input }); - partials.delete(event.index); + if (partial) { + let input = {}; + try { + input = JSON.parse(partial.json || "{}"); + } catch {} + const block = { type: "tool_use", id: partial.id, name: partial.name, input }; + blocks.push(block); + // A complete tool block is a checkpoint; do not wait for the next + // explanation or the end of this response before showing it. + results.push(await this.#runTool(block)); + partials.delete(event.index); + } + } else if (event.type === "message_delta") { + if (event.delta?.stop_reason) { stop = event.delta.stop_reason; finished = true; } + } else if (event.type === "error") { + throw new Error(event.error?.message || "inference error"); } - } else if (event.type === "message_delta") { - if (event.delta?.stop_reason) stop = event.delta.stop_reason; - } else if (event.type === "error") { - throw new Error(event.error?.message || "inference error"); } } + + if (!finished || partials.size) throw new Error("Inference stream ended before the response completed. Saved checkpoints are preserved."); + } finally { + await reader.cancel?.().catch(() => {}); + reader.releaseLock?.(); } if (text) { @@ -324,15 +355,12 @@ if (assistant.length) this.messages.push({ role: "assistant", content: assistant }); if (stop !== "tool_use" || !blocks.length) return { stop: "end_turn" }; - const results = []; - for (const block of blocks) { - results.push(await this.#runTool(block)); - } this.messages.push({ role: "user", content: results }); return { stop: "tool_use" }; } async #runTool(block) { + const signal = this.controller?.signal; const itemId = `tool-${block.id}`; const note = String(block.input?.note || "").trim(); this.emit("notification", { @@ -366,7 +394,11 @@ try { const file = this.piece?.file; if (!file) throw new Error("no piece is open in this session"); + this.emit("notification", { method: "turn/progress", params: { phase: "writing" } }); + await validatePieceSource(source, file); + signal?.throwIfAborted(); writeFileSync(file, source.endsWith("\n") ? source : `${source}\n`); + await this.piece?.checkpoint?.(); this.emit("notification", { method: "item/completed", params: { item: { id: itemId, type: "fileChange", path: file, status: note || "written" } }, diff --git a/easel/src/claude-server.mjs b/easel/src/claude-server.mjs --- a/easel/src/claude-server.mjs +++ b/easel/src/claude-server.mjs @@ -30,6 +30,7 @@ // workspace, WebFetch and WebSearch are removed, and network commands do // prompt — but the prompt, not the kernel, is the boundary. See // docs/local-contract.md. import { spawn } from "node:child_process"; +import { mcpConfig, SERVER_NAME } from "./tools.mjs"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { createInterface } from "node:readline"; @@ -77,9 +78,12 @@ args = [], environment = {}, developerInstructions = "", model = DEFAULT_CLAUDE_MODEL, + // Easel's native tools (ac_api, ac_examples, ac_outline, ac_symbol). + tools = true, }) { super(); this.cwd = cwd; + this.tools = tools; this.command = command; this.args = args; this.environment = environment; @@ -241,6 +245,16 @@ ...WITHHELD_TOOLS, "--add-dir", this.cwd, ]; + // Easel's own tools ride in as the one MCP server the strict config + // admits: the API map, call-site search, and the outline/symbol pair that + // replaces `sed -n` over a 9,000-line piece. They read local files and + // nothing else, so they are allowed up front — an approval prompt for + // "what does circle take?" would cost the round trip the tool exists to + // save. + if (this.tools) { + args.push("--mcp-config", JSON.stringify(mcpConfig(this.cwd))); + args.push("--allowedTools", `mcp__${SERVER_NAME}`); + } if (this.developerInstructions) { args.push("--append-system-prompt", this.developerInstructions); } diff --git a/easel/src/live.mjs b/easel/src/live.mjs --- a/easel/src/live.mjs +++ b/easel/src/live.mjs @@ -18,6 +18,7 @@ // the prompt sees the string `channel~` and splits its own arguments // on spaces — a tilde-separated argument arrives glued to the command name and // the channel is silently dropped, leaving the phone on an empty prompt. An // encoded space is what actually reaches `halt` as two tokens. +import { PieceRevisions, validatePieceSource } from "./revisions.mjs"; import { EventEmitter } from "node:events"; import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; import { basename, dirname, extname, join, resolve } from "node:path"; @@ -194,12 +195,53 @@ if (previous !== this.file) this.#discard(previous, previousBlank); return this.file; } + get history() { return new PieceRevisions(this.file); } + + async checkpoint(source = this.source()) { + const file = this.file; + await validatePieceSource(source, file); + if (file !== this.file || source !== this.source()) return null; + const revision = this.history.capture(source); + if (this.revision?.revision !== revision.revision || this.revisionFile !== file) { + this.revision = revision; + this.revisionFile = file; + this.emit("revision", revision); + } + return revision; + } + + async rollback(version) { + // Preserve a complete unobserved edit; a broken edit must still be recoverable. + const current = this.source(); + let valid = false; + try { await validatePieceSource(current, this.file); valid = true; } catch {} + if (valid) this.history.capture(current); + const revision = await this.history.restore(version); + this.revision = revision; + this.revisionFile = this.file; + this.emit("revision", revision); + return revision; + } + // Push the current source onto the code channel. async push() { + // Serialize uploads so a slow older save cannot arrive after a newer one. + if (this.pendingPush) { + await this.pendingPush.catch(() => {}); + return this.push(); + } + this.sending = true; + const pending = this.#push(); + this.pendingPush = pending; + try { return await pending; } + finally { this.pendingPush = null; this.sending = false; } + } + + async #push() { const source = this.source(); if (!source.trim()) return false; - this.sending = true; - try { + if (!await this.checkpoint(source)) return false; + { // `/run` takes no anonymous pushes: ownership of a channel is the token, // not the name. A session with no token can still watch its own piece in // a browser, it just cannot put source on anyone else's screen. @@ -215,12 +257,10 @@ headers, body: JSON.stringify({ piece: this.slug, source, codeChannel: this.channel }), }); if (!response.ok) throw new Error(`live push failed (HTTP ${response.status})`); - } finally { - this.sending = false; } this.pushes += 1; - this.ahead = false; - this.emit("push", this.pushes); + this.ahead = source !== this.source(); + this.emit("push", this.pushes, source); return true; } diff --git a/easel/src/mouse.mjs b/easel/src/mouse.mjs new file mode 100644 --- /dev/null +++ b/easel/src/mouse.mjs @@ -0,0 +1,41 @@ +export const MOUSE_ON = "\x1b[?1003h\x1b[?1006h"; +export const MOUSE_OFF = "\x1b[?1003l\x1b[?1006l"; + +export function mouseEvent(token) { + const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(token); + if (!match) return null; + const [, code, x, y, end] = match; + const button = Number(code); + return { x: Number(x), y: Number(y), motion: Boolean(button & 32), + wheel: button & 64 ? (button & 1 ? 1 : -1) : 0, + click: end === "M" && button === 0 }; +} + +// Keep split terminal escape sequences intact between stdin chunks. +export class InputDecoder { + pending = ""; + push(chunk) { + this.pending += chunk; + const tokens = []; + while (this.pending) { + if (this.pending.startsWith("\x1b[")) { + const match = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(this.pending); + if (!match) break; + tokens.push(match[0]); + this.pending = this.pending.slice(match[0].length); + } else if (this.pending === "\x1b") { + break; + } else { + const token = String.fromCodePoint(this.pending.codePointAt(0)); + tokens.push(token); + this.pending = this.pending.slice(token.length); + } + } + return tokens; + } + escape() { + if (this.pending !== "\x1b") return []; + this.pending = ""; + return ["\x1b"]; + } +} diff --git a/easel/src/perf-worker.mjs b/easel/src/perf-worker.mjs new file mode 100644 --- /dev/null +++ b/easel/src/perf-worker.mjs @@ -0,0 +1,58 @@ +// Executed by perf.mjs in a bounded, permission-restricted child process. +import vm from "node:vm"; +import { performance } from "node:perf_hooks"; + +let input = ""; +for await (const chunk of process.stdin) input += chunk; +try { + const { source, frames, warmup, width, height, seed, timeoutMs } = JSON.parse(input); + const context = vm.createContext(Object.create(null), { + codeGeneration: { strings: false, wasm: false }, + }); + vm.runInContext(` + let randomState = ${seed} || 1; + Math.random = () => { + randomState ^= randomState << 13; + randomState ^= randomState >>> 17; + randomState ^= randomState << 5; + return (randomState >>> 0) / 4294967296; + }; + `, context, { timeout: 100 }); + const module = new vm.SourceTextModule(source, { + context, + identifier: "piece.mjs", + importModuleDynamically: () => { throw new Error("Imports are unavailable in the headless logic benchmark."); }, + }); + await module.link(() => { throw new Error("Imports are unavailable in the headless logic benchmark."); }); + await module.evaluate({ timeout: timeoutMs }); + context.__piece = module.namespace; + // All callbacks are created inside the guest realm; no host function, fs, + // process, network client, or constructor is passed through the piece API. + vm.runInContext(` + const counts = Object.create(null); + const drawing = ["wipe", "ink", "line", "circle", "box", "rect", "point", "plot", "polygon", "triangle", "write", "print", "paste"]; + const api = { screen: { width: ${width}, height: ${height} } }; + for (const name of drawing) api[name] = (..._args) => { counts[name] = (counts[name] || 0) + 1; return api; }; + Object.freeze(api.screen); + Object.freeze(api); + if (typeof __piece.paint !== "function" && typeof __piece.sim !== "function") throw new Error("This piece has no paint or sim export to benchmark."); + function call(name) { + const result = __piece[name]?.(api); + if (result && typeof result.then === "function") throw new Error("Async lifecycle functions are unavailable in the headless benchmark."); + } + call("boot"); + for (let frame = 0; frame < ${warmup}; frame++) { call("sim"); call("paint"); } + for (const name of Object.keys(counts)) counts[name] = 0; + `, context, { timeout: timeoutMs }); + const started = performance.now(); + vm.runInContext(` + for (let frame = 0; frame < ${frames}; frame++) { call("sim"); call("paint"); } + `, context, { timeout: timeoutMs }); + const elapsedMs = performance.now() - started; + const totalCalls = JSON.parse(vm.runInContext("JSON.stringify(counts)", context, { timeout: 100 })); + const drawCalls = Object.fromEntries(Object.entries(totalCalls).map(([name, count]) => [name, count / frames])); + process.stdout.write(JSON.stringify({ measurement: "headless-logic", frames, warmup, width, height, seed, elapsedMs, msPerFrame: elapsedMs / frames, drawCalls, totalCalls })); +} catch (error) { + process.stderr.write(String(error.message).slice(0, 4096)); + process.exitCode = 1; +} diff --git a/easel/src/perf.mjs b/easel/src/perf.mjs new file mode 100644 --- /dev/null +++ b/easel/src/perf.mjs @@ -0,0 +1,57 @@ +// Headless logic timings with counted drawing stubs; never actual render FPS. +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; + +function bounded(value, fallback, min, max, name) { + const number = value === undefined ? fallback : value; + if (!Number.isSafeInteger(number) || number < min || number > max) throw new Error(`${name} must be ${min}–${max}.`); + return number; +} + +export async function benchmarkPiece({ file, frames, warmup, width, height, seed, timeoutMs, signal } = {}) { + if (!file || extname(file) !== ".mjs") throw new Error("Headless logic benchmarks currently support .mjs pieces only."); + // The child needs stable Node permissions. Never silently fall back to an + // unrestricted process on an older installed runtime. + if (Number(process.versions.node.split(".")[0]) < 22 || !process.allowedNodeEnvironmentFlags.has("--permission")) throw new Error("Headless benchmarks require Node with --permission support; use Node 24 or newer."); + const options = { + frames: bounded(frames, 600, 1, 1200, "frames"), + warmup: bounded(warmup, 60, 0, 120, "warmup"), + width: bounded(width, 800, 1, 4096, "width"), + height: bounded(height, 600, 1, 4096, "height"), + seed: bounded(seed, 1, 0, 4294967295, "seed"), + timeoutMs: bounded(timeoutMs, 3000, 100, 10000, "timeoutMs"), + }; + signal?.throwIfAborted(); + const [source, worker] = await Promise.all([ + readFile(file, "utf8"), readFile(new URL("./perf-worker.mjs", import.meta.url), "utf8"), + ]); + if (Buffer.byteLength(source) > 1_048_576) throw new Error("The piece exceeds the benchmark's 1 MB source limit."); + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--permission", "--no-addons", "--max-old-space-size=64", "--experimental-vm-modules", "--input-type=module", "-e", worker], { + env: { NODE_NO_WARNINGS: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + let output = "", errors = "", failure; + const stop = (error) => { failure ||= error; child.kill("SIGKILL"); }; + const timer = setTimeout(() => stop(new Error(`Headless benchmark exceeded ${options.timeoutMs} ms.`)), options.timeoutMs); + const abort = () => stop(signal.reason || new Error("Benchmark cancelled.")); + signal?.addEventListener("abort", abort, { once: true }); + child.stdin.on("error", () => {}); + child.stdout.on("data", (chunk) => { + output += chunk; + if (output.length > 65536) stop(new Error("Benchmark output exceeded its limit.")); + }); + child.stderr.on("data", (chunk) => { if (errors.length < 8192) errors += chunk; }); + child.on("error", (error) => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(error); }); + child.on("close", (code) => { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + if (failure) return reject(failure); + if (code !== 0) return reject(new Error(`Headless benchmark: ${errors.trim() || `process exited ${code}`}`)); + try { resolve(JSON.parse(output)); } catch { reject(new Error("Invalid benchmark result.")); } + }); + child.stdin.end(JSON.stringify({ ...options, source })); + }); +} diff --git a/easel/src/publish.mjs b/easel/src/publish.mjs --- a/easel/src/publish.mjs +++ b/easel/src/publish.mjs @@ -1,3 +1,4 @@ +import { validatePieceSource } from "./revisions.mjs"; // publish.mjs — put a piece live under the signed-in user's @handle. // // This mirrors the web prompt's `publish` command exactly: ask the site for a @@ -72,6 +73,7 @@ if (!looksLikePiece(source, plan.extension)) { throw new Error("this file does not export a piece (boot, paint, sim, act, or default)"); } + await validatePieceSource(source, plan.path); const token = await session.token(); onStep("requesting upload grant"); const presign = await fetch(plan.grantUrl, { diff --git a/easel/src/render.mjs b/easel/src/render.mjs --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -7,6 +7,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { MASCOT_HEIGHT, mascotAt, mascotRow } from "./mascot.mjs"; +import { aboutMap } from "./about.mjs"; const ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; @@ -393,7 +394,7 @@ const rockGutter = 0; const room = Math.max(0, width - 3 - rightWidth - rockGutter); const title = "EASEL"; let account = state.account || "not signed in"; - let piece = state.piece ? clipText(state.piece, 24) : ""; + let piece = state.piece ? `${clipText(state.piece, 24)}${state.pieceVersion ? ` v${state.pieceVersion}` : ""}` : ""; if (textWidth(`${title} ${account} ${piece}`) > room) piece = ""; if (textWidth(`${title} ${account}`) > room) account = ""; const leftPlain = clipText( @@ -402,9 +403,9 @@ room, ); const left = leftPlain === title || !account - ? paint(useColor, "bold text", leftPlain) - : `${paint(useColor, "bold text", title)} ` + - `${paint(useColor, account.startsWith("@") ? "handle" : "muted", account)}` + + ? paint(useColor, state.hover === "about" ? "block bold" : "bold text", leftPlain) + : `${paint(useColor, state.hover === "about" ? "block bold" : "bold text", title)} ` + + `${paint(useColor, state.hover === "profile" ? "block" : account.startsWith("@") ? "handle" : "muted", account)}` + `${piece ? ` ${paint(useColor, "soft", piece)}` : ""}`; const gap = " ".repeat( Math.max(1, width - 2 - textWidth(leftPlain) - rightWidth - rockGutter), @@ -437,13 +438,17 @@ // The code needs the rows it occupies and not one more. An earlier `+ 2` // asked for breathing room it never used, which put the cliff at 24 rows and // hid the code from a 23-row window for no reason a reader could see. const qr = - useColor && state.qr && width >= state.qr.width + 24 && transcriptRows >= state.qr.height + !state.about && useColor && state.qr && width >= state.qr.width + 24 && transcriptRows >= state.qr.height ? state.qr : null; const contentWidth = qr ? width - qr.width - 2 : width - 2; - const transcript = state.entries.flatMap((entry) => entryLines(entry, contentWidth, useColor)); - const visible = transcript.slice(Math.max(0, transcript.length - transcriptRows)); - while (visible.length < transcriptRows) visible.unshift(""); + const transcript = state.about + ? aboutMap().flatMap((line) => wrapText(line, contentWidth)) + : state.entries.flatMap((entry) => entryLines(entry, contentWidth, useColor)); + const start = state.about ? Math.min(state.aboutScroll || 0, Math.max(0, transcript.length - transcriptRows)) + : Math.max(0, transcript.length - transcriptRows - (state.scrollOffset || 0)); + const visible = transcript.slice(start, start + transcriptRows); + while (visible.length < transcriptRows) state.about ? visible.push("") : visible.unshift(""); const body = visible.map((line, index) => { const row = ` ${fit(line, contentWidth)}`; @@ -486,8 +491,12 @@ const guy = `${paint(useColor, "soft", pose[0])}` + `${paint(useColor, "handle", pose[1])}` + `${paint(useColor, "soft", pose[2])}`; - const helpText = state.busy - ? " ctrl-c interrupt" + const helpText = state.about ? " Esc back · ↑/↓ scroll" + : state.scrollOffset ? ` ${state.scrollOffset} lines above · End latest` + : state.hover === "about" ? " About Easel · click" + : state.hover === "profile" ? " Open profile in browser · click" + : state.busy + ? ` ${state.progressBytes ? `${(state.progressBytes / 1024).toFixed(1)} KB received · ` : ""}ctrl-c interrupt` : " /help \u00b7 /login \u00b7 /publish \u00b7 /open \u00b7 /qr \u00b7 ctrl-c quit"; const help = width >= 23 @@ -503,3 +512,22 @@ .slice(0, height) .map((line) => `${ground}${fit(line, width)}${reset}`) .join("\n"); } + +export function transcriptLineCount(state, columns = 80, rows = 24, useColor = true) { + const width = Math.max(32, columns), height = Math.max(10, rows); + const qr = useColor && state.qr && width >= state.qr.width + 24 && height - 5 >= state.qr.height ? state.qr : null; + return state.entries.reduce((count, entry) => count + entryLines(entry, qr ? width - qr.width - 2 : width - 2, false).length, 0); +} + +// Terminal mouse coordinates are one-based, like the displayed header row. +export function headerAction(state, columns, rows, x, y) { + if (columns < 32 || rows < 10 || y !== rows - 3) return ""; + const mode = state.mode === "local" ? "LOCAL" : "REMOTE"; + const rightWidth = textWidth(`${mode} · ${String(state.status || "ready").toUpperCase()}`); + const room = Math.max(0, columns - 3 - rightWidth); + if (room >= 5 && x >= 2 && x <= 6) return "about"; + const account = state.account || ""; + if (account.startsWith("@") && textWidth(`EASEL ${account}`) <= room + && x >= 9 && x < 9 + textWidth(account)) return "profile"; + return ""; +} diff --git a/easel/src/revisions.mjs b/easel/src/revisions.mjs new file mode 100644 --- /dev/null +++ b/easel/src/revisions.mjs @@ -0,0 +1,58 @@ +// Complete piece snapshots stay on this machine; rollback appends, never erases. +import { createHash, randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { extname, join, resolve } from "node:path"; + +const digest = (source) => createHash("sha256").update(source).digest("hex"); + +// Parse JavaScript without importing it: user code must never execute in Easel. +export async function validatePieceSource(source, file) { + if (typeof source !== "string" || !source.trim()) throw new Error("The piece is empty."); + if (extname(file) !== ".mjs") return; // Other runtimes retain their own loader validation. + await new Promise((resolveCheck, reject) => { + const child = spawn(process.execPath, ["--input-type=module", "--check"], { stdio: ["pipe", "ignore", "pipe"] }); + let detail = ""; + child.stderr.on("data", (chunk) => { if (detail.length < 4096) detail += chunk; }); + child.on("error", reject); + child.stdin.on("error", () => {}); + child.on("close", (code) => code === 0 ? resolveCheck() : reject(new Error(`Incomplete or invalid JavaScript; previous preview kept. ${detail.trim()}`))); + child.stdin.end(source); + }); +} + +export class PieceRevisions { + constructor(file, { root = process.env.EASEL_HISTORY_DIR || join(homedir(), ".local", "share", "easel", "history") } = {}) { + this.file = resolve(file); + this.directory = join(root, digest(this.file)); + } + list() { + let names; + try { names = readdirSync(this.directory); } catch (error) { if (error.code === "ENOENT") return []; throw error; } + return names.filter((name) => /^v\d+\.json$/.test(name)).map((name) => JSON.parse(readFileSync(join(this.directory, name), "utf8"))) + .sort((a, b) => a.version - b.version); + } + capture(source, { restoredFrom } = {}) { + const entries = this.list(); + const revision = digest(source); + const previous = entries.at(-1); + if (previous?.revision === revision) return previous; + const entry = { version: (previous?.version || 0) + 1, revision, updatedAt: new Date().toISOString(), source, ...(restoredFrom ? { restoredFrom } : {}) }; + mkdirSync(this.directory, { recursive: true, mode: 0o700 }); + // Exclusive final creation prevents two sessions silently overwriting a version. + writeFileSync(join(this.directory, `v${entry.version}.json`), `${JSON.stringify(entry)}\n`, { flag: "wx", mode: 0o600 }); + return entry; + } + async restore(version) { + const entry = this.list().find((item) => item.version === Number(version)); + if (!entry) throw new Error(`No saved v${version} for this piece.`); + const before = readFileSync(this.file, "utf8"); + await validatePieceSource(entry.source, this.file); + if (readFileSync(this.file, "utf8") !== before) throw new Error("The piece changed while preparing rollback. Try again when editing stops."); + const temporary = `${this.file}.${randomUUID()}.tmp`; + writeFileSync(temporary, entry.source); + renameSync(temporary, this.file); + return this.capture(entry.source, { restoredFrom: entry.version }); + } +} diff --git a/easel/src/slab-session.mjs b/easel/src/slab-session.mjs --- a/easel/src/slab-session.mjs +++ b/easel/src/slab-session.mjs @@ -110,6 +110,10 @@ scan_url: String(scanUrl || ""), }); } + revision(revision) { + this.#update({ piece_version: revision.version, piece_revision: revision.revision, piece_updated_at: revision.updatedAt }); + } + // Where the file stands against what the address is serving: // live — the channel has the current save // ahead — saved, not pushed yet diff --git a/easel/src/tools.mjs b/easel/src/tools.mjs new file mode 100644 --- /dev/null +++ b/easel/src/tools.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +// tools.mjs — the native tools Easel hands the engine, as an MCP server on stdio. +// +// Read the transcripts of the first ten Easel sessions and they open the same +// way: the model reads the guides, then spends six to twelve shell calls — +// `grep -n "function circle(" graph.mjs`, `sed -n 6590,6650p disk.mjs`, +// `grep -rn "synth({" disks/*.mjs | head` — rebuilding a picture of the API +// that the previous session had already built and thrown away. A minute or two +// per session before the first edit, on a surface that does not change. +// +// So the picture is built once (`bin/build-api-map.mjs` → `context/api.json`) +// and served here, alongside the two things a large piece needs that `sed -n` +// gives badly: an outline of its symbols, and one symbol's source by name. +// Four tools, all read-only, all answered from local files: +// +// ac_api what does `circle` / `sound.synth` / `ui.Button` take? +// ac_examples show me pieces that call it +// ac_outline what is in notepat.mjs, and where? +// ac_symbol give me `setupButtons` from notepat.mjs +// +// This is an MCP server without a dependency: the protocol is JSON-RPC over +// newline-delimited stdio, and a server that only lists and calls tools needs +// four methods. Claude Code is pointed at it with `--mcp-config`, which is the +// one hole `--strict-mcp-config` leaves open on purpose. +// +// node src/tools.mjs --cwd /path/to/workspace +import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { dirname, join, resolve, relative, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EASEL = join(HERE, ".."); + +export const SERVER_NAME = "ac"; +export const TOOL_PREFIX = `mcp__${SERVER_NAME}__`; + +// Where the pieces are: the repo's disks folder when the workspace is the +// Aesthetic Computer repository, the workspace itself anywhere else. +export function disksDir(cwd) { + const inRepo = join(cwd, "system", "public", "aesthetic.computer", "disks"); + return existsSync(inRepo) ? inRepo : cwd; +} + +export function loadMap() { + try { + return JSON.parse(readFileSync(join(EASEL, "context", "api.json"), "utf8")); + } catch { + return { entries: [] }; + } +} + +// ---------------------------------------------------------------- ac_api ---- + +function scoreEntry(entry, terms) { + const path = entry.path.toLowerCase(); + const name = entry.name.toLowerCase(); + const hay = `${path} ${entry.signature} ${entry.doc}`.toLowerCase(); + let score = 0; + for (const term of terms) { + if (name === term || path === term) score += 100; + else if (name.startsWith(term) || path.endsWith(`.${term}`)) score += 40; + else if (path.includes(term)) score += 20; + else if (hay.includes(term)) score += 5; + } + return score; +} + +export function apiLookup(map, query, { limit = 6 } = {}) { + const terms = String(query || "") + .toLowerCase() + .split(/[^a-z0-9_.$]+/) + .filter(Boolean); + if (!terms.length) { + return map.entries.map((entry) => `${entry.path} — ${entry.signature}`).join("\n"); + } + const ranked = map.entries + .map((entry) => [scoreEntry(entry, terms), entry]) + .filter(([score]) => score > 0) + .sort((a, b) => b[0] - a[0]) + .slice(0, limit) + .map(([, entry]) => entry); + if (!ranked.length) return `Nothing in the API map matches "${query}". Call ac_api with no query for the full list.`; + return ranked.map(describe).join("\n\n"); +} + +function describe(entry) { + const lines = [`${entry.path}`, ` ${entry.signature}`]; + if (entry.doc) lines.push(` ${entry.doc}`); + if (entry.source) lines.push(` source: ${entry.source}`); + for (const example of entry.examples || []) lines.push(` e.g. ${example}`); + return lines.join("\n"); +} + +// ----------------------------------------------------------- ac_examples ---- + +export function examples(cwd, symbol, { limit = 12 } = {}) { + const dir = disksDir(cwd); + const leaf = String(symbol || "").trim(); + if (!leaf) return "Name a symbol, e.g. synth or ui.Button."; + const escaped = leaf.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const needle = new RegExp(leaf.includes(".") ? `\\b${escaped}\\b` : `(? /\.(mjs|lisp)$/.test(f)) + .sort(); + const found = []; + for (const file of files) { + let text; + try { + text = readFileSync(join(dir, file), "utf8"); + } catch { + continue; + } + const lines = text.split("\n"); + let perFile = 0; + for (let i = 0; i < lines.length && found.length < limit && perFile < 3; i++) { + if (!needle.test(lines[i])) continue; + if (/^\s*\/\//.test(lines[i])) continue; + found.push(`${file}:${i + 1} ${lines[i].trim().slice(0, 160)}`); + perFile++; + } + if (found.length >= limit) break; + } + if (!found.length) return `No piece in ${relative(cwd, dir) || "."} calls ${leaf}.`; + return found.join("\n"); +} + +// ------------------------------------------------- ac_outline / ac_symbol ---- + +// Resolve a piece name or path to a file, never outside the workspace. +export function resolvePiece(cwd, file) { + const raw = String(file || "").trim(); + if (!raw) throw new Error("name a file, e.g. notepat.mjs"); + const candidates = []; + if (isAbsolute(raw)) candidates.push(raw); + else { + candidates.push(resolve(cwd, raw)); + const dir = disksDir(cwd); + candidates.push(resolve(dir, raw)); + if (!/\.\w+$/.test(raw)) { + candidates.push(resolve(dir, `${raw}.mjs`), resolve(dir, `${raw}.lisp`)); + } + } + for (const path of candidates) { + const inside = !relative(cwd, path).startsWith(".."); + if (inside && existsSync(path) && statSync(path).isFile()) return path; + } + throw new Error(`no such piece: ${raw}`); +} + +const SYMBOL_LINE = [ + // export function paint({ ... }) { + [/^(?:export\s+)?(?:async\s+)?function\s*\*?\s*([\w$]+)\s*\(/, "function"], + // const foo = (a, b) => { / const foo = function + [/^(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>/, "function"], + [/^(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s+)?function\b/, "function"], + [/^(?:export\s+)?class\s+([\w$]+)/, "class"], + // top-level data: const buttons = { / let x = 0 + [/^(?:export\s+)?(?:const|let|var)\s+([\w$]+)\s*=/, "value"], + [/^export\s*\{([^}]*)\}/, "exports"], + [/^import\b.*from\s+["']([^"']+)["']/, "import"], +]; + +// The top-level shape of a JavaScript piece: every symbol declared at column +// zero, with the line where it starts and where the next one begins. Column +// zero is the whole heuristic — pieces are written flat, one function after +// another, and nesting inside a symbol is exactly what the outline is meant to +// skip over. +export function outline(source) { + const lines = source.split("\n"); + const items = []; + for (let i = 0; i < lines.length; i++) { + const text = lines[i]; + if (!text || /^\s/.test(text)) continue; + for (const [pattern, kind] of SYMBOL_LINE) { + const match = text.match(pattern); + if (!match) continue; + items.push({ name: match[1].trim(), kind, line: i + 1 }); + break; + } + } + for (let i = 0; i < items.length; i++) { + const next = items[i + 1]; + let end = next ? next.line - 1 : lines.length; + // Trim trailing blank lines and comments off the span so a symbol's source + // ends where its brace does, not where the next one's header comment begins. + while (end > items[i].line && /^\s*(\/\/.*)?$/.test(lines[end - 1])) end--; + items[i].end = end; + } + return { lines: lines.length, items }; +} + +export function outlineText(cwd, file) { + const path = resolvePiece(cwd, file); + const source = readFileSync(path, "utf8"); + if (path.endsWith(".lisp")) { + const heads = source + .split("\n") + .map((text, i) => [text, i + 1]) + .filter(([text]) => /^\(/.test(text)) + .map(([text, line]) => `${String(line).padStart(5)} ${text.slice(0, 80)}`); + return [`${relative(cwd, path)} — ${source.split("\n").length} lines, ${heads.length} top-level forms`, ...heads].join("\n"); + } + const { lines, items } = outline(source); + const rows = items + .filter((item) => item.kind !== "import") + .map((item) => `${String(item.line).padStart(5)}-${String(item.end).padEnd(5)} ${item.kind.padEnd(8)} ${item.name}`); + const imports = items.filter((item) => item.kind === "import").map((item) => item.name); + return [ + `${relative(cwd, path)} — ${lines} lines, ${rows.length} top-level symbols${imports.length ? `, imports: ${imports.join(", ")}` : ""}`, + "lines kind name", + ...rows, + ].join("\n"); +} + +export function symbolText(cwd, file, name, { maxLines = 220 } = {}) { + const path = resolvePiece(cwd, file); + const source = readFileSync(path, "utf8"); + const wanted = String(name || "").trim(); + const { items } = outline(source); + const item = items.find((entry) => entry.name === wanted) || items.find((entry) => entry.name.startsWith(wanted)); + if (!item) { + const near = items.filter((entry) => entry.name.toLowerCase().includes(wanted.toLowerCase())).map((entry) => entry.name); + return `No top-level symbol "${wanted}" in ${relative(cwd, path)}.${near.length ? ` Close: ${near.join(", ")}.` : " Call ac_outline to see what is there."}`; + } + const lines = source.split("\n"); + const span = lines.slice(item.line - 1, item.end); + const clipped = span.length > maxLines; + const shown = clipped ? span.slice(0, maxLines) : span; + const numbered = shown.map((text, i) => `${String(item.line + i).padStart(5)} ${text}`); + const head = `${relative(cwd, path)}:${item.line}-${item.end} ${item.kind} ${item.name}`; + const tail = clipped ? `… ${span.length - maxLines} more lines; read ${relative(cwd, path)} from line ${item.line + maxLines} for the rest.` : ""; + return [head, ...numbered, tail].filter(Boolean).join("\n"); +} + +// ------------------------------------------------------------- the server ---- + +export const TOOLS = [ + { + name: "ac_api", + description: + "Look up the Aesthetic Computer piece API: what a drawing primitive, sound call, ui class or event takes, with its runtime signature and real call sites from existing pieces. Use this before grepping graph.mjs or disk.mjs. No query lists every name.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "A name or words: circle, synth, button, text, multitouch." }, + }, + }, + }, + { + name: "ac_examples", + description: + "Lines from existing pieces that call a symbol (e.g. synth, pline, ui.Button, hud.label), as file:line. Use instead of grep -rn over disks/.", + inputSchema: { + type: "object", + properties: { + symbol: { type: "string", description: "The function or dotted name to find call sites for." }, + limit: { type: "integer", description: "Max lines (default 12)." }, + }, + required: ["symbol"], + }, + }, + { + name: "ac_outline", + description: + "The top-level symbols of a piece with their line spans — functions, classes, values, exports. Use before reading a large piece so you can fetch one symbol with ac_symbol instead of paging through it.", + inputSchema: { + type: "object", + properties: { + file: { type: "string", description: "A piece name (notepat), file (notepat.mjs) or path." }, + }, + required: ["file"], + }, + }, + { + name: "ac_symbol", + description: "The full source of one top-level symbol from a piece, numbered by line. Pairs with ac_outline.", + inputSchema: { + type: "object", + properties: { + file: { type: "string", description: "A piece name, file or path." }, + name: { type: "string", description: "The symbol to fetch, as ac_outline listed it." }, + }, + required: ["file", "name"], + }, + }, +]; + +export function callTool(name, args, { cwd, map }) { + switch (name) { + case "ac_api": + return apiLookup(map, args?.query); + case "ac_examples": + return examples(cwd, args?.symbol, { limit: Number(args?.limit) || 12 }); + case "ac_outline": + return outlineText(cwd, args?.file); + case "ac_symbol": + return symbolText(cwd, args?.file, args?.name); + default: + throw new Error(`unknown tool: ${name}`); + } +} + +// One JSON-RPC message in, at most one out. Notifications get nothing back. +export function handle(message, context) { + const { id, method, params } = message; + const reply = (result) => (id === undefined ? null : { jsonrpc: "2.0", id, result }); + const fail = (code, text) => (id === undefined ? null : { jsonrpc: "2.0", id, error: { code, message: text } }); + switch (method) { + case "initialize": + return reply({ + protocolVersion: params?.protocolVersion || "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: `easel-${SERVER_NAME}`, version: "1" }, + }); + case "notifications/initialized": + case "notifications/cancelled": + return null; + case "ping": + return reply({}); + case "tools/list": + return reply({ tools: TOOLS }); + case "tools/call": { + try { + const text = callTool(params?.name, params?.arguments || {}, context); + return reply({ content: [{ type: "text", text }] }); + } catch (error) { + return reply({ content: [{ type: "text", text: String(error?.message || error) }], isError: true }); + } + } + default: + return fail(-32601, `method not found: ${method}`); + } +} + +export function serve({ cwd = process.cwd(), input = process.stdin, output = process.stdout } = {}) { + const context = { cwd: resolve(cwd), map: loadMap() }; + const lines = createInterface({ input, crlfDelay: Infinity }); + lines.on("line", (line) => { + if (!line.trim()) return; + let message; + try { + message = JSON.parse(line); + } catch { + output.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })}\n`); + return; + } + const response = handle(message, context); + if (response) output.write(`${JSON.stringify(response)}\n`); + }); + return lines; +} + +// The MCP configuration the Claude bridge passes with --mcp-config: this file, +// run by the same node that is running Easel, pointed at the workspace. +export function mcpConfig(cwd) { + return { + mcpServers: { + [SERVER_NAME]: { + command: process.execPath, + args: [fileURLToPath(import.meta.url), "--cwd", cwd], + }, + }, + }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const at = process.argv.indexOf("--cwd"); + serve({ cwd: at >= 0 ? process.argv[at + 1] : process.cwd() }); +} diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -1,10 +1,13 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import process from "node:process"; +import { StringDecoder } from "node:string_decoder"; +import { aboutMap, conversationHandoff } from "./about.mjs"; +import { InputDecoder, mouseEvent, MOUSE_ON, MOUSE_OFF } from "./mouse.mjs"; import { ACSession } from "./ac-session.mjs"; import { Audience } from "./audience.mjs"; import { AutoPublisher } from "./autopublish.mjs"; @@ -15,7 +18,7 @@ import { LivePiece } from "./live.mjs"; import { applyUpdate, checkForUpdate, currentVersion, installed } from "./updates.mjs"; import { publishPiece } from "./publish.mjs"; import { qrBlock } from "./qr.mjs"; -import { cleanText, color, easelInk, renderBoot, renderFrame } from "./render.mjs"; +import { cleanText, color, easelInk, renderBoot, renderFrame, headerAction, wrapText, transcriptLineCount } from "./render.mjs"; import { mascotNextFrameIn, mascotRowNextFrameIn } from "./mascot.mjs"; import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; @@ -29,10 +32,14 @@ const flag = (name) => arguments_.includes(name); const cwd = path.resolve(option("--cwd") || process.cwd()); const resumeThreadId = option("--resume"); const initialPrompt = option("--prompt"); +const initialPiece = option("--piece"); // Which engine bridge drives the conversation, and on which model. The bridge // can be swapped mid-session with /backend, so neither is a constant. let backend = backendFor(option("--backend") || process.env.EASEL_BACKEND || DEFAULT_BACKEND); let model = option("--model") || backend.defaultModel; +let handoff = ""; +let archivedConversation = []; +let mouseEnabled = process.env.EASEL_MOUSE !== "0"; const session = new ACSession(); // Every session opens on a new blank piece with a random name. It is a real @@ -52,6 +59,10 @@ return null; } }, }); +if (initialPiece) { + const file = path.resolve(cwd, initialPiece); + if (!existsSync(file) || !live.retarget(file)) throw new Error("--piece must name an existing supported piece file"); +} const state = { workspace: cwd, mode: "remote", @@ -132,6 +143,7 @@ // The repo's style guides, named only when the session is actually running in // the repository that holds them. Naming a path that isn't there teaches the // model to ignore the whole instruction. const STYLE_GUIDES = [ + ["system/public/aesthetic.computer/disks/CLAUDE.md", "the piece authoring guide"], ["SCREEN.md", "how a piece draws on the AC canvas"], ["HAND.md", "how the code reads"], ]; @@ -161,11 +173,22 @@ : BUNDLED_CONTEXT.map(([file, subject]) => [path.join(easelRoot, file), subject]).filter( ([file]) => existsSync(file), ); if (source.length === 0) return []; - const named = source - .map(([file, subject]) => `${file} (${subject})`) - .join(" and "); + // Inlined rather than named. Every session so far opened by reading these + // three files — three tool calls and ten seconds before the first thought + // about the piece — and the bytes cost the same either way. Here they arrive + // with the first turn and are cached for every turn after it. + const inlined = source + .map(([file, subject]) => { + try { + return `## ${subject} (${path.relative(cwd, file) || file})\n\n${readFileSync(file, "utf8").trim()}`; + } catch { + return ""; + } + }) + .filter(Boolean); const lines = [ - `Style: the Aesthetic Computer guides are ${named} — read them before writing a piece, and follow them over your own defaults.`, + "Style: the Aesthetic Computer guides follow. They are the house rules for a piece and win over your own defaults. Do not re-read them from disk; they are already here.", + ...inlined, ]; // The one rule that gets broken on a first draft, inlined because a model // that skips the read still has to know it. Lua pieces draw through @@ -178,6 +201,17 @@ } return lines; } +// The native tools, named so the model reaches for them instead of the shell. +// The pattern being replaced is specific: grep graph.mjs for a signature, sed a +// window of disk.mjs, grep disks/ for a call site, page a 9,000-line piece in +// 80-line slices. Each of those is one call here. +function toolInstructions() { + if (!backend.Engine || backend.id !== "claude") return []; + return [ + "Tools: you have ac_api (the piece API — signatures, docs and real call sites for circle, line, box, write, sound.synth, ui.Button, pens, events…), ac_examples (pieces that call a symbol), ac_outline (a piece's top-level symbols with line spans) and ac_symbol (one symbol's source). Use them instead of grep/sed/head over lib/ and disks/: ask ac_api before opening graph.mjs or disk.mjs, and outline a large piece before reading any of it. Start writing the piece as soon as the request is clear — the guides above are already the context.", + ]; +} + function developerInstructions() { const account = session.handle ? `The user is signed in to Aesthetic Computer as @${session.handle}.` @@ -207,11 +241,12 @@ ]; return [ "You are running inside Easel, a terminal interface for Aesthetic Computer (AC) work.", account, - `This session's piece is ${live.file} (${live.runtime.label}). It already exists as a blank piece that paints a flat color and nothing else. Edit that file unless the user asks for something else.`, + `This session's piece is ${live.file} (${live.runtime.label}). Its current source is the source of truth; read it before editing and preserve existing work. Edit that file unless the user asks for something else.`, "Do not write the piece's name onto the screen: the system already shows it in the corner label. If the file still carries a placeholder that writes its own name, remove it in your first edit.", ...dialect, ...styleInstructions(), "Every save of that file is pushed live to a phone that scanned the interface's QR code, so small frequent edits are better than one big rewrite.", + ...toolInstructions(), ...publishing, "Dev servers: do not stop a dev server you were asked to start; say that it is still running.", ].join("\n"); @@ -228,7 +263,7 @@ const opened = new backend.Engine({ cwd, resumeThreadId: resume, model, - developerInstructions: developerInstructions(), + developerInstructions: [developerInstructions(), handoff].filter(Boolean).join("\n\n"), // The hosted bridge has no subprocess and no file tools, so it needs the // two things a CLI would have found for itself: which file is the piece, // and a token to pay for the turn. The other bridges ignore both. @@ -247,9 +282,10 @@ SLAB_TERMINAL_TTY: slabSession.tty, SLAB_AGENT_TYPE: "easel", }, }); - opened.on("notification", handleNotification); - opened.on("request", handleRequest); + opened.on("notification", (...args) => { if (!closing && opened === engine) handleNotification(...args); }); + opened.on("request", (...args) => { if (!closing && opened === engine) handleRequest(...args); }); opened.on("protocolError", (error) => { + if (closing || opened !== engine) return; addEntry("error", errorText(error)); redraw(); }); @@ -265,6 +301,9 @@ } let engine = openEngine({ resume: resumeThreadId }); let drawing = false; +let redrawTimer = null; +let lastDrawAt = 0; +let lastTranscriptLines = 0; let closing = false; // The startup easel owns the screen until it is done or dismissed. Declared // here rather than beside the splash itself because redraw() reads it, and @@ -273,6 +312,7 @@ let splashing = false; let splashTimer = null; let streamedMessageId = null; let pasteBuffer = null; +let performanceAbort = null; function addEntry(kind, text, id = `entry-${Date.now()}-${Math.random()}`) { state.entries.push({ id, kind, text: cleanText(text) }); @@ -314,8 +354,18 @@ } function redraw() { if (closing || drawing || splashing) return; + // Token bursts coalesce into at most 30 terminal frames/second. + const remaining = 33 - (Date.now() - lastDrawAt); + if (remaining > 0) { + if (!redrawTimer) redrawTimer = setTimeout(() => { redrawTimer = null; redraw(); }, remaining); + return; + } + lastDrawAt = Date.now(); drawing = true; try { + const count = transcriptLineCount(state, process.stdout.columns || 80, process.stdout.rows || 24, process.env.NO_COLOR !== "1"); + if (state.scrollOffset) state.scrollOffset = Math.max(0, state.scrollOffset + count - lastTranscriptLines); + lastTranscriptLines = count; const frame = renderFrame(state, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); process.stdout.write(`\x1b[H\x1b[2J${frame}`); } finally { @@ -326,6 +376,7 @@ async function finish(code = 0) { if (closing) return; closing = true; + performanceAbort?.abort(); session.unwatch(); const pending = autopublish.pending || autopublish.running; live.unwatch(); @@ -334,7 +385,7 @@ slabSession.close(); engine.close(); process.stdin.setRawMode(false); process.stdin.pause(); - process.stdout.write("\x1b[?2004l\x1b[?25h\x1b[?1049l"); + process.stdout.write(MOUSE_OFF + "\x1b[?2004l\x1b[?25h\x1b[?1049l"); process.exitCode = code; // The last save has to land. Quitting a second after an edit would otherwise // drop it — auto-publish coalesces, and the timer it was waiting on dies with @@ -461,6 +512,7 @@ if (!item) return null; if (item.type === "commandExecution") return { kind: "command", text: item.command }; if (item.type === "fileChange") { const paths = (item.changes || []).map((change) => change.path).filter(Boolean); + if (item.path) paths.push(item.path); for (const file of paths) notePiece(file); return { kind: "change", text: paths.join(", ") || "workspace files" }; } @@ -495,11 +547,17 @@ switch (method) { case "turn/started": state.busy = true; startDance(); - state.status = "working"; + state.status = "waiting"; + state.progressBytes = 0; engine.turnId = params.turn?.id || engine.turnId; slabSession.working(); break; + case "turn/progress": + state.status = params.phase || "working"; + state.progressBytes = params.bytes || state.progressBytes || 0; + break; case "item/agentMessage/delta": + state.status = "generating"; if (!streamedMessageId || streamedMessageId !== params.itemId) { streamedMessageId = params.itemId; addEntry("assistant", "", params.itemId); @@ -510,6 +568,7 @@ if (entry) entry.text += cleanText(params.delta); } break; case "item/started": { + if (params.item?.type === "fileChange") state.status = "writing"; const summary = itemSummary(params.item); if (summary) updateEntry(params.item.id, summary.kind, summary.text); break; @@ -752,12 +811,18 @@ addEntry("notice", "The model is told when a thread opens · /new to tell it now"); return redraw(); } +let manualPublishInFlight = false; async function commandPublish(argumentText) { + if (manualPublishInFlight || autopublish.running || state.status === "restoring") { + addEntry("notice", "Wait for the current upload or rollback to finish before publishing."); + return redraw(); + } const [file = live.file, slug = ""] = argumentText.split(/\s+/).filter(Boolean); if (!file) { addEntry("error", "Usage: /publish [slug] — no piece has been touched yet."); return redraw(); } + manualPublishInFlight = true; const id = addEntry("publish", `Publishing ${path.basename(file)}…`); redraw(); try { @@ -775,6 +840,8 @@ notePiece(result.path); updateEntry(id, "publish", `${result.route}${result.verified ? "" : " · uploaded, not yet readable"}`); } catch (error) { updateEntry(id, "error", `Publish failed: ${errorText(error)}`); + } finally { + manualPublishInFlight = false; } redraw(); } @@ -785,31 +852,50 @@ function engineLabel() { return `${backend.label} · ${state.model || model || backend.modelSource}`; } -// Open a thread on the current bridge, replacing whatever is running. This is -// what /new, /backend and /model all come down to: the conversation restarts, -// the piece and the QR code do not. -async function restartEngine(note) { +// Provider thread IDs cannot cross engines; carry recent conversation and +// keep the old connection available until the replacement connects. +async function restartEngine(note, nextBackend = backend, nextModel = model) { + if (nextBackend.models && !Object.hasOwn(nextBackend.models, nextModel) + && !Object.values(nextBackend.models).includes(nextModel)) { + addEntry("error", "Unknown hosted model. Use /model to see available choices."); + return redraw(); + } + const previousBackend = backend, previousModel = model, previousLabel = state.model; + const previousHandoff = handoff; + handoff = conversationHandoff([...archivedConversation, ...state.entries]); + backend = nextBackend; + model = nextModel; state.status = "starting"; + state.busy = true; redraw(); const previous = engine; - engine = openEngine(); - previous.close(); try { + engine = openEngine(); const connection = await engine.connect(); + previous.close(); slabSession.connected(engine.threadId); state.model = connection?.model || model; - state.entries = [{ kind: "notice", text: `${note} · ${engineLabel()}`, id: `thread-${Date.now()}` }]; + addEntry("notice", `${note} · ${engineLabel()} · current piece and recent conversation carried over`); state.status = "ready"; } catch (error) { + const failed = engine; + engine = previous; + if (failed !== previous) failed.close(); + backend = previousBackend; + model = previousModel; + state.model = previousLabel; + handoff = previousHandoff; addEntry("error", errorText(error)); - state.status = "failed"; + state.status = "ready"; } + state.busy = false; redraw(); + drainQueue(); } async function commandBackend(rest) { if (!rest) { - addEntry("notice", `${engineLabel()} · backends: ${backendMenu()}`); + addEntry("notice", `${engineLabel()}\n/backend ac — AC hosted, handle budget\n/backend claude — your Claude CLI sign-in\n/backend codex — your Codex CLI sign-in\n/model — models on the selected engine`); return redraw(); } if (state.busy) { @@ -824,24 +910,39 @@ } catch (error) { addEntry("error", errorText(error)); return redraw(); } - backend = next; - model = wantedModel || next.defaultModel; - state.model = ""; - return restartEngine("Engine"); + return restartEngine("Engine", next, wantedModel || next.defaultModel); } async function commandModel(rest) { if (!rest) { - addEntry("notice", engineLabel()); + const choices = backend.models ? Object.entries(backend.models).map(([alias, id]) => `/model ${alias} — ${id}${["sonnet", "gpt"].includes(alias) ? " · premium, uses budget faster" : ""}`).join("\n") + : "/model NAME — a model supported by your signed-in CLI"; + addEntry("notice", `${engineLabel()}\n${choices}\n/backend — switch between AC hosted and your own Claude/Codex`); return redraw(); } if (state.busy) { addEntry("error", "Interrupt the current turn before switching models."); return redraw(); } - model = rest.split(/\s+/)[0]; - state.model = ""; - return restartEngine("Model"); + return restartEngine("Model", backend, rest.split(/\s+/)[0]); +} + +async function commandPerformance(rest) { + if (state.busy) { addEntry("notice", "Wait for the current turn before benchmarking."); return redraw(); } + performanceAbort = new AbortController(); + state.busy = true; + state.status = "benchmarking"; + const id = addEntry("notice", `Measuring ${state.piece} · headless logic…`); + redraw(); + try { + const { benchmarkPiece } = await import("./perf.mjs"); + const result = await benchmarkPiece({ file: live.file, frames: rest ? Number(rest) : 600, signal: performanceAbort.signal }); + const calls = Object.entries(result.drawCalls).map(([name, count]) => `${Number(count).toFixed(1)} ${name}`).join(" · "); + updateEntry(id, "notice", `Headless logic · ${result.msPerFrame.toFixed(3)} ms/frame · ${result.frames} frames at ${result.width}×${result.height}\nPer frame: ${calls}\nExcludes browser rendering, rasterization and display latency.`); + } catch (error) { updateEntry(id, "error", errorText(error)); } + finally { performanceAbort = null; state.busy = false; state.status = "ready"; } + redraw(); + drainQueue(); } // Start the next queued line, if the turn that just ended left one. Routed back @@ -869,8 +970,25 @@ if (text.startsWith("/")) { const [command, ...restWords] = text.split(/\s+/); const rest = restWords.join(" "); if (command === "/quit" || command === "/exit") return finish(); + if (command === "/about") { + state.about = !state.about; + state.aboutScroll = 0; + return redraw(); + } + if (command === "/mouse") { + mouseEnabled = rest !== "off"; + process.stdout.write(mouseEnabled ? MOUSE_ON : MOUSE_OFF); + state.hover = ""; + addEntry("notice", `Mouse ${mouseEnabled ? "on · shift-drag selects in supporting terminals" : "off · terminal selection restored"}`); + return redraw(); + } + if (command === "/profile") return openProfile(); + if (command === "/performance" || command === "/perf") return commandPerformance(rest); + if (command === "/latest") { state.scrollOffset = 0; return redraw(); } if (command === "/clear") { + archivedConversation.push(...state.entries.filter(({ kind }) => kind === "user" || kind === "assistant")); state.entries = []; + state.scrollOffset = 0; return redraw(); } if (command === "/handle") { @@ -920,10 +1038,35 @@ addEntry("error", `Update failed: ${errorText(error)}`); } return redraw(); } + if (command === "/versions") { + const versions = live.history.list(); + addEntry("notice", versions.length ? versions.map((entry) => `v${entry.version} · ${entry.updatedAt}${entry.restoredFrom ? ` · restored v${entry.restoredFrom}` : ""}`).join("\n") : "No saved versions yet."); + return redraw(); + } + if (command === "/rollback") { + if (state.busy || manualPublishInFlight || autopublish.running || live.sending) { + addEntry("notice", "Wait for the current turn and uploads to finish before rolling back."); + return redraw(); + } + const version = /^v?([1-9]\d*)$/.exec(rest.trim())?.[1]; + if (!version) { addEntry("notice", "Use /rollback v1 · /versions lists saved versions."); return redraw(); } + state.busy = true; + state.status = "restoring"; + autopublish.cancel(); + try { + const revision = await live.rollback(Number(version)); + addEntry("notice", `Restored v${version} as v${revision.version}.`); + await live.push(); + publishTurn(); + } catch (error) { addEntry("error", errorText(error)); } + finally { state.busy = false; state.status = "ready"; } + drainQueue(); + return redraw(); + } if (command === "/help") { addEntry( "notice", - "/login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /runtime [id] · /backend [id] · /model [name] · /handle [name] · /update · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", + "/about · /profile · /mouse [on|off] · /performance [frames] · /latest · /login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /versions · /rollback vN · /runtime [id] · /backend [id] · /model [name] · /handle [name] · /update · /open · /qr · /live · /new · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -936,8 +1079,8 @@ return redraw(); } if (command === "/publish") return commandPublish(rest); if (command === "/autopublish" || command === "/auto") return commandAutopublish(rest); - if (command === "/backend" || command === "/engine") return commandBackend(rest); - if (command === "/model") return commandModel(rest); + if (["/backend", "/engine", "/mode"].includes(command)) return commandBackend(rest); + if (command === "/model" || command === "/models") return commandModel(rest); if (command === "/piece") { if (rest) { try { @@ -1036,6 +1179,8 @@ } else { state.status = "starting"; redraw(); try { + handoff = ""; + archivedConversation = []; engine.developerInstructions = developerInstructions(); await engine.newThread(); slabSession.connected(engine.threadId); @@ -1087,6 +1232,36 @@ state.input = value; state.cursor = Array.from(value).length; } +function openProfile() { + if (!session.handle) { + addEntry("notice", "Sign in with /login to open your profile."); + return redraw(); + } + const url = `https://aesthetic.computer/@${encodeURIComponent(session.handle)}`; + const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open"; + const child = spawn(opener, [url], { stdio: "ignore", detached: true }); + child.on("error", error => { addEntry("error", `Could not open profile: ${errorText(error)}`); redraw(); }); + child.unref(); +} + +function scrollAbout(delta) { + const height = Math.max(10, process.stdout.rows || 24); + const width = Math.max(32, process.stdout.columns || 80) - 2; + const count = aboutMap().flatMap(line => wrapText(line, width)).length; + state.aboutScroll = Math.max(0, Math.min(Math.max(0, count - (height - 5)), (state.aboutScroll || 0) + delta)); + redraw(); +} + +function scrollTranscript(delta) { + const height = Math.max(10, process.stdout.rows || 24); + const count = transcriptLineCount(state, process.stdout.columns || 80, height, process.env.NO_COLOR !== "1"); + const offset = state.scrollOffset || 0; + state.scrollOffset = Math.max(0, Math.min(Math.max(0, count - (height - 5)), + offset + (offset ? count - lastTranscriptLines : 0) + delta)); + lastTranscriptLines = count; + redraw(); +} + function insertText(value) { const characters = Array.from(state.input); const inserted = Array.from(cleanText(value.replace(/\x1b\[200~|\x1b\[201~/g, ""))); @@ -1096,6 +1271,16 @@ state.cursor += inserted.length; } function handleKey(input) { + if (state.about && ["\x1b", "\x1b[A", "\x1b[B", "\x1b[5~", "\x1b[6~"].includes(input)) { + if (input === "\x1b") { state.about = false; return redraw(); } + return scrollAbout(input === "\x1b[A" ? -1 : input === "\x1b[B" ? 1 : input === "\x1b[5~" ? -8 : 8); + } + if (input === "\x1b[5~") return scrollTranscript(Math.max(1, (process.stdout.rows || 24) - 7)); + if (input === "\x1b[6~") return scrollTranscript(-Math.max(1, (process.stdout.rows || 24) - 7)); + if (["\x1b[F", "\x1b[4~", "\x1b[1;2F"].includes(input) && !state.input) { + state.scrollOffset = 0; + return redraw(); + } // The easel is a greeting, not a gate. Any key puts it away. if (splashing) { splashing = false; @@ -1106,6 +1291,7 @@ } if (answerApproval(input)) return; if (input === "\u0003") { + if (performanceAbort) { performanceAbort.abort(new Error("Benchmark cancelled.")); return; } if (state.busy) { state.status = "interrupting"; redraw(); @@ -1148,8 +1334,13 @@ } redraw(); } +const inputDecoder = new InputDecoder(); +const utf8Decoder = new StringDecoder("utf8"); +let escapeTimer; function handleKeys(buffer) { - const tokens = buffer.toString("utf8").match(/\x1b\[[0-9;]*[~A-Za-z]|./gsu) || []; + clearTimeout(escapeTimer); + const tokens = inputDecoder.push(utf8Decoder.write(buffer)); + escapeTimer = setTimeout(() => inputDecoder.escape().forEach(handleKey), 35); for (const token of tokens) { if (token === "\x1b[200~") { pasteBuffer = ""; @@ -1160,12 +1351,23 @@ redraw(); } else if (pasteBuffer !== null) { pasteBuffer += token; } else { + const mouse = mouseEvent(token); + if (mouse) { + if (!mouseEnabled || splashing) continue; + if (state.about && mouse.wheel) { scrollAbout(mouse.wheel * 3); continue; } + if (mouse.wheel) { scrollTranscript(-mouse.wheel * 3); continue; } + const action = headerAction(state, process.stdout.columns || 80, process.stdout.rows || 24, mouse.x, mouse.y); + if (state.hover !== action) { state.hover = action; redraw(); } + if (mouse.click && action === "about") { state.about = !state.about; state.aboutScroll = 0; redraw(); } + if (mouse.click && action === "profile") openProfile(); + continue; + } handleKey(token); } } } -process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[?2004h"); +process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[?2004h" + (mouseEnabled ? MOUSE_ON : "")); // 🎨 Stand the easel up. Each frame reads the live values rather than a // snapshot, so the address is written onto the canvas at whatever moment the @@ -1215,7 +1417,7 @@ redraw(); }); // Mint this session's blank piece and the QR code that opens it on a phone. -live.create(); +if (!initialPiece) live.create(); live.watch(liveError); publishBlankOnce(); @@ -1238,15 +1440,21 @@ // leave nothing behind, out there or in the workspace — because the address on // the rock is now the published one, and a code that resolves to a 404 until // someone types is worse than a published blank. The local file is still // discarded on exit if it was never edited; the published copy stays. -live.on("push", () => { - slabSession.flow("live"); +live.on("push", (_count, source) => { + slabSession.flow(live.ahead ? "ahead" : "live"); if (live.pristine || autopublishBlocker()) return; - autopublish.note(live.source()); + autopublish.note(source); }); // A save has landed and the channel has not heard about it yet. The rock's // neighbour — the preview of the very address the rock encodes — says so, so // that an old frame never passes for the current one. live.on("dirty", () => slabSession.flow("ahead")); +live.on("revision", (revision) => { + state.pieceVersion = revision.version; + slabSession.revision(revision); + redraw(); +}); +live.checkpoint().catch(liveError); state.piece = `${live.slug}${live.runtime.extension}`; refreshQr(); audience.start(); diff --git a/easel/test/about.test.mjs b/easel/test/about.test.mjs new file mode 100644 --- /dev/null +++ b/easel/test/about.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { conversationHandoff } from "../src/about.mjs"; + +test("engine handoff carries recent user/assistant context, excluding tools and UI", () => { + const handoff = conversationHandoff([ + { kind: "user", text: "keep the dots purple" }, + { kind: "command", text: "PRIVATE TOOL OUTPUT" }, + { kind: "assistant", text: "the dots are purple" }, + { kind: "change", text: "TOOL PATH" }, + { kind: "error", text: "INTERNAL ERROR" }, + { kind: "notice", text: "SIGN IN URL" }, + ]); + assert.match(handoff, /keep the dots purple/); + assert.match(handoff, /the dots are purple/); + assert.doesNotMatch(handoff, /PRIVATE|TOOL|INTERNAL|SIGN IN/); + assert.ok(handoff.indexOf('"role":"user"') < handoff.indexOf('"role":"assistant"')); +}); + +test("engine handoff bounds context and favors the latest turns", () => { + const entries = Array.from({ length: 30 }, (_, i) => ({ kind: i % 2 ? "assistant" : "user", text: `turn ${i} ${"x".repeat(50)}` })); + const handoff = conversationHandoff(entries, 300); + assert.ok(handoff.length < 400, "context budget plus heading and separators"); + assert.match(handoff, /turn 29/); + assert.match(handoff, /turn 28/); + assert.doesNotMatch(handoff, /turn 0 /); + assert.equal(conversationHandoff([{ kind: "command", text: "tool" }]), ""); +}); diff --git a/easel/test/ac-server.test.mjs b/easel/test/ac-server.test.mjs --- a/easel/test/ac-server.test.mjs +++ b/easel/test/ac-server.test.mjs @@ -136,3 +136,85 @@ sent.system.indexOf(guides) < sent.system.indexOf(instructions), "the stable prefix comes first, or the cache breaks on every session", ); }); + +test("a completed piece checkpoint saves before the response ends", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "ac-checkpoint-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "piece.mjs"); + await writeFile(file, "// before\n"); + let stream; + const body = new ReadableStream({ start(c) { stream = c; } }); + let call = 0; + const engine = new AcServer({ piece: { file }, token: async () => "tok", fetch: async () => call++ === 0 ? { ok: true, body } : serving(say("done"))() }); + await engine.connect(); + const saved = new Promise((resolve) => engine.on("notification", ({ method, params }) => { + if (method === "item/completed" && params.item?.type === "fileChange") resolve(); + })); + const turn = engine.startTurn("make a piece in steps"); + const source = "export function paint({wipe}) { wipe(40); }\n"; + for (const event of writes(source).slice(0, -1)) stream.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)); + await saved; + assert.equal(await readFile(file, "utf8"), source, "saved while the network response is still open"); + stream.enqueue(new TextEncoder().encode('data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}\n\n')); + stream.close(); + await turn; +}); + +test("incomplete tool source is rejected and previous working file is preserved", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "ac-invalid-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "piece.mjs"); + await writeFile(file, "// working\n"); + const engine = new AcServer({ piece: { file }, token: async () => "tok", fetch: serving(writes("export function paint("), say("I will fix that")) }); + await engine.connect(); + await engine.startTurn("edit"); + assert.equal(await readFile(file, "utf8"), "// working\n"); + const result = engine.messages.find((m) => Array.isArray(m.content) && m.content[0]?.type === "tool_result"); + assert.equal(result.content[0].is_error, true); +}); + +test("a disconnected response reports failure instead of successful completion", async () => { + const engine = new AcServer({ token: async () => "tok", fetch: serving(say("unfinished").slice(0, 1)) }); + let completed; + engine.on("notification", ({ method, params }) => { if (method === "turn/completed") completed = params.turn; }); + await engine.connect(); + await engine.startTurn("hi"); + assert.equal(completed.status, "failed"); + assert.match(completed.error.message, /stream ended/); +}); + +test("hosted engine reads the current piece on every round, including after rollback", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "ac-context-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "piece.mjs"); + let sent; + const engine = new AcServer({ piece: { file }, developerInstructions: "Recent conversation: keep the dots purple", token: async () => "tok", fetch: async (_url, options) => { sent = JSON.parse(options.body); return serving(say("ok"))(); } }); + await writeFile(file, "// source from another engine\n"); + await engine.connect(); + await engine.startTurn("continue"); + assert.ok(sent.system.some((block) => block.text.includes("source from another engine"))); + assert.ok(sent.system.some((block) => block.text.includes("keep the dots purple"))); + await writeFile(file, "// restored old source\n"); + await engine.startTurn("continue from rollback"); + assert.ok(sent.system.some((block) => block.text.includes("restored old source"))); + assert.ok(!sent.system.some((block) => block.text.includes("source from another engine"))); +}); + +test("interrupting a checkpoint during validation cannot write or start another round", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "ac-interrupt-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "piece.mjs"); + await writeFile(file, "// working\n"); + let calls = 0, completed; + const serve = serving(writes("export function paint() {}"), say("done")); + const engine = new AcServer({ piece: { file }, token: async () => "tok", fetch: (...args) => { calls++; return serve(...args); } }); + engine.on("notification", ({ method, params }) => { + if (method === "turn/progress" && params.phase === "writing") engine.interrupt(); + if (method === "turn/completed") completed = params.turn; + }); + await engine.connect(); + await engine.startTurn("edit"); + assert.equal(await readFile(file, "utf8"), "// working\n"); + assert.equal(calls, 1); + assert.equal(completed.status, "interrupted"); +}); diff --git a/easel/test/claude-server.test.mjs b/easel/test/claude-server.test.mjs --- a/easel/test/claude-server.test.mjs +++ b/easel/test/claude-server.test.mjs @@ -139,6 +139,12 @@ // The user's own allow-lists, hooks and MCP servers stay out of a session. assert.equal(flag("--setting-sources"), ""); assert.ok(argv.includes("--strict-mcp-config")); for (const tool of ["WebFetch", "WebSearch", "Task"]) assert.ok(argv.includes(tool)); + // Easel's own read-only tools are the one MCP server let through, pre-allowed. + const mcp = JSON.parse(flag("--mcp-config")); + assert.equal(mcp.mcpServers.ac.command, process.execPath); + assert.ok(mcp.mcpServers.ac.args[0].endsWith("tools.mjs")); + assert.deepEqual(mcp.mcpServers.ac.args.slice(1), ["--cwd", directory]); + assert.equal(flag("--allowedTools"), "mcp__ac"); assert.equal(flag("--add-dir"), directory); assert.equal(flag("--append-system-prompt"), "piece rules"); assert.ok(argv.includes("--session-id")); diff --git a/easel/test/inference-policy.test.mjs b/easel/test/inference-policy.test.mjs new file mode 100644 --- /dev/null +++ b/easel/test/inference-policy.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AC_MODELS } from "../src/ac-server.mjs"; +import { EASEL_MODELS, inferenceRequest, inferenceBudgetFailure } from "../../system/backend/easel-policy.mjs"; +const messages = [{ role: "user", content: "hello" }]; + +test("hosted defaults remain inexpensive and each advertised model is explicitly allowed", () => { + assert.equal(inferenceRequest({ messages }).model, "z-ai/glm-4.6"); + for (const model of Object.values(AC_MODELS)) { + assert.ok(Object.hasOwn(EASEL_MODELS, model)); + assert.equal(inferenceRequest({ model, messages }).model, model); + } + assert.equal(inferenceRequest({ model: "anthropic/claude-sonnet-4.6", messages }).model, "anthropic/claude-sonnet-4.6"); + assert.equal(inferenceRequest({ model: "openai/gpt-5.4", messages }).model, "openai/gpt-5.4"); +}); + +test("unsupported models and malformed requests refuse rather than silently falling back", () => { + for (const model of ["unknown", "", null, [], ["openai/gpt-5.4"], "toString", "__proto__"]) { + assert.throws(() => inferenceRequest({ model, messages }), /Unsupported model/); + } + for (const body of [null, [], "request"]) assert.throws(() => inferenceRequest(body), /request object/); + assert.throws(() => inferenceRequest({ messages: [] }), /message/); + for (const max_tokens of [-1, 0, 1.5, "100", null, Infinity]) assert.throws(() => inferenceRequest({ messages, max_tokens }), /positive integer/); + assert.equal(inferenceRequest({ messages, max_tokens: 99999 }).maxTokens, 8192); + assert.equal(inferenceRequest({ messages, max_tokens: 100 }).maxTokens, 100); +}); + +test("unknown or failed budget checks never permit paid hosted inference", () => { + const valid = { used: 0, budget: 200000, remaining: 200000, exhausted: false }; + assert.equal(inferenceBudgetFailure(valid, "test"), null); + for (const budget of [null, undefined, { ...valid, unknown: true }, {}, { ...valid, remaining: NaN }]) { + assert.equal(inferenceBudgetFailure(budget, "test").statusCode, 503); + } + assert.equal(inferenceBudgetFailure({ ...valid, remaining: 0 }, "test").statusCode, 429); + assert.equal(inferenceBudgetFailure({ ...valid, exhausted: true }, "test").statusCode, 429); +}); diff --git a/easel/test/mouse-about.test.mjs b/easel/test/mouse-about.test.mjs new file mode 100644 --- /dev/null +++ b/easel/test/mouse-about.test.mjs @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { InputDecoder, mouseEvent } from "../src/mouse.mjs"; +import { headerAction, renderFrame, textWidth, cleanText } from "../src/render.mjs"; + +test("mouse reports split across reads never become prompt characters", () => { + const decoder = new InputDecoder(); + assert.deepEqual(decoder.push("\x1b[<35;4"), []); + assert.deepEqual(decoder.push(";21Mhi"), ["\x1b[<35;4;21M", "h", "i"]); + assert.deepEqual(mouseEvent("\x1b[<35;4;21M"), { x: 4, y: 21, motion: true, wheel: 0, click: false }); + assert.equal(mouseEvent("\x1b[<0;4;21M").click, true); + assert.equal(mouseEvent("\x1b[<0;4;21m").click, false); + assert.equal(mouseEvent("\x1b[<65;4;21M").wheel, 1); +}); + +test("standalone escape and split arrow/paste sequences are distinct", () => { + const decoder = new InputDecoder(); + assert.deepEqual(decoder.push("\x1b"), []); + assert.deepEqual(decoder.escape(), ["\x1b"]); + assert.deepEqual(decoder.push("\x1b["), []); + assert.deepEqual(decoder.escape(), []); + assert.deepEqual(decoder.push("A\x1b[200~hello\x1b[201~"), ["\x1b[A", "\x1b[200~", "h", "e", "l", "l", "o", "\x1b[201~"]); +}); + +test("only visible header labels are clickable across terminal sizes", () => { + const state = { account: "@jeffrey", status: "ready", entries: [] }; + for (const width of [32, 40, 80, 120]) { + const header = cleanText(renderFrame(state, width, 24, false)).split("\n")[20]; + assert.equal(headerAction(state, width, 24, 3, 21), "about"); + assert.equal(headerAction(state, width, 24, 10, 21), header.includes("@jeffrey") ? "profile" : ""); + assert.equal(headerAction(state, width, 24, 3, 20), ""); + assert.equal(headerAction(state, width, 24, 8, 21), ""); + } +}); + +test("about is a scrollable map that preserves transcript and fits small windows", () => { + const state = { account: "@jeffrey", entries: [{ kind: "user", text: "keep my drawing" }], about: true }; + const top = renderFrame(state, 80, 24, false); + assert.match(top, /You → model → working piece/); + assert.doesNotMatch(top, /keep my drawing/); + assert.equal(state.entries[0].text, "keep my drawing"); + const bottom = renderFrame({ ...state, aboutScroll: 1000 }, 40, 12, false); + assert.match(bottom, /Esc returns/); + for (const width of [32, 40, 80]) { + for (const row of renderFrame(state, width, 24, false).split("\n")) assert.ok(textWidth(row) <= width); + } +}); diff --git a/easel/test/perf.test.mjs b/easel/test/perf.test.mjs new file mode 100644 --- /dev/null +++ b/easel/test/perf.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { benchmarkPiece } from "../src/perf.mjs"; + +async function piece(t, source) { + const root = await mkdtemp(join(tmpdir(), "easel-perf-")); + t.after(() => rm(root, { recursive: true, force: true })); + const file = join(root, "piece.mjs"); + await writeFile(file, source); + return file; +} + +test("counts measured drawing work, excluding warmup, with seeded top-level randomness", async (t) => { + const file = await piece(t, ` + const count = 1 + Math.floor(Math.random() * 100); + export function boot({ circle }) { circle(); } + export function sim({ line }) { line(); } + export function paint({ ink, circle }) { ink(); for (let i=0; i= 0); + assert.equal(first.fps, undefined, "logic timing is not rendering FPS"); +}); + +test("a looping piece times out without blocking the parent", async (t) => { + const file = await piece(t, "export function paint() { while(true) {} }"); + let ticked = false; + const tick = setTimeout(() => { ticked = true; }, 30); + await assert.rejects(benchmarkPiece({ file, timeoutMs: 150 }), /exceeded|timed out/); + clearTimeout(tick); + assert.ok(ticked); +}); + +test("filesystem imports and host globals are unavailable", async (t) => { + const imported = await piece(t, 'import fs from "node:fs"; export function paint() { fs.readFileSync("/etc/passwd"); }'); + await assert.rejects(benchmarkPiece({ file: imported }), /Imports are unavailable/); + const processPiece = await piece(t, 'export function paint() { process.env; }'); + await assert.rejects(benchmarkPiece({ file: processPiece }), /process is not defined/); + const constructorEscape = await piece(t, 'export function paint({wipe}) { wipe.constructor("return process")(); }'); + await assert.rejects(benchmarkPiece({ file: constructorEscape }), /Code generation from strings disallowed/); +}); + +test("bounded options and cancellation refuse work cleanly", async (t) => { + const file = await piece(t, "export function paint() {}"); + await assert.rejects(benchmarkPiece({ file, frames: 100000 }), /frames must be/); + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + await assert.rejects(benchmarkPiece({ file, signal: controller.signal }), /cancelled/); +}); diff --git a/easel/test/revisions.test.mjs b/easel/test/revisions.test.mjs new file mode 100644 --- /dev/null +++ b/easel/test/revisions.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { PieceRevisions, validatePieceSource } from "../src/revisions.mjs"; +import { LivePiece } from "../src/live.mjs"; + +async function setup(t) { + const root = await mkdtemp(join(tmpdir(), "easel-revision-")); + t.after(() => rm(root, { recursive: true, force: true })); + const file = join(root, "piece.mjs"); + return { root, file, history: new PieceRevisions(file, { root: join(root, "history") }) }; +} + +test("revisions survive restart, deduplicate saves, and rollback appends", async (t) => { + const { root, file, history } = await setup(t); + const first = "export function paint() {}\n"; + const second = "export function paint({ wipe }) { wipe(0); }\n"; + await writeFile(file, first); + assert.equal(history.capture(first).version, 1); + assert.equal(history.capture(first).version, 1); + history.capture(second); + await writeFile(file, second); + const reopened = new PieceRevisions(file, { root: join(root, "history") }); + const restored = await reopened.restore(1); + assert.equal(restored.version, 3); + assert.equal(restored.restoredFrom, 1); + assert.equal(await readFile(file, "utf8"), first); + assert.deepEqual(reopened.list().map((v) => v.source), [first, second, first]); + await assert.rejects(reopened.restore(99), /No saved/); +}); + +test("validation parses without executing and rejects incomplete JavaScript", async () => { + await validatePieceSource('throw new Error("must not execute"); export const x = 1;', "piece.mjs"); + await assert.rejects(validatePieceSource("export function paint( {", "piece.mjs"), /invalid JavaScript/); +}); + +test("file watcher versions external edits and never pushes unfinished JavaScript", async (t) => { + const { root, history } = await setup(t); + let pushes = 0; + const live = new LivePiece({ directory: root, slug: "piece", fetch: async () => { pushes++; return new Response("ok"); } }); + Object.defineProperty(live, "history", { get: () => history }); + live.create(); + await live.checkpoint(); + t.after(() => live.unwatch()); + const errors = []; + live.watch((e) => errors.push(e)); + await writeFile(live.file, "export function paint( {"); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.equal(pushes, 0); + assert.equal(history.list().length, 1); + assert.equal(errors.length, 1); + const landed = new Promise((resolve) => live.once("push", resolve)); + await writeFile(live.file, "export function paint() {}\n"); + await landed; + assert.equal(history.list().length, 2); + await writeFile(live.file, "export function broken("); + const restored = await live.rollback(1); + assert.equal(restored.version, 3, "a broken current edit does not prevent recovery"); +}); + +test("live uploads serialize so old saves cannot overtake newer versions", async (t) => { + const { root, history } = await setup(t); + let releaseFirst; + const gate = new Promise((resolve) => { releaseFirst = resolve; }); + const seen = []; + let firstStarted; + const started = new Promise((resolve) => { firstStarted = resolve; }); + const live = new LivePiece({ directory: root, slug: "piece", fetch: async (_url, options) => { + seen.push(JSON.parse(options.body).source); + if (seen.length === 1) { firstStarted(); await gate; } + return new Response("ok"); + } }); + Object.defineProperty(live, "history", { get: () => history }); + await writeFile(live.file, "// first\n"); + const first = live.push(); + await started; + await writeFile(live.file, "// second\n"); + const second = live.push(); + assert.equal(seen.length, 1); + assert.equal(live.sending, true); + releaseFirst(); + await Promise.all([first, second]); + assert.deepEqual(seen, ["// first\n", "// second\n"]); + assert.equal(live.ahead, false); + assert.equal(live.sending, false); +}); + +test("a failed old upload does not discard a queued newer save", async (t) => { + const { root, history } = await setup(t); + let failFirst, firstStarted; + const started = new Promise((resolve) => { firstStarted = resolve; }); + const blocked = new Promise((_resolve, reject) => { failFirst = reject; }); + const seen = []; + const live = new LivePiece({ directory: root, slug: "piece", fetch: async (_url, options) => { + seen.push(JSON.parse(options.body).source); + if (seen.length === 1) { firstStarted(); await blocked; } + return new Response("ok"); + } }); + Object.defineProperty(live, "history", { get: () => history }); + await writeFile(live.file, "// old\n"); + const old = live.push(); + const failure = assert.rejects(old, /offline/); + await started; + await writeFile(live.file, "// latest\n"); + const latest = live.push(); + failFirst(new Error("offline")); + await failure; + assert.equal(await latest, true); + assert.deepEqual(seen, ["// old\n", "// latest\n"]); +}); diff --git a/easel/test/stream-transport.test.mjs b/easel/test/stream-transport.test.mjs new file mode 100644 --- /dev/null +++ b/easel/test/stream-transport.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import test from "node:test"; +import { relayInference } from "../../system/backend/easel-stream.mjs"; +import { sendStream } from "../../lith/stream-response.mjs"; + +const bytes = (text) => new TextEncoder().encode(text); + +test("relay forwards first chunk before EOF and cancellation stops the provider", async () => { + let source, cancelled = false, aborted = false; + const body = new ReadableStream({ start(c) { source = c; }, cancel() { cancelled = true; } }); + const reader = relayInference(body, { abort: () => { aborted = true; } }).getReader(); + source.enqueue(bytes("data: first\n\n")); + assert.equal(new TextDecoder().decode((await reader.read()).value), "data: first\n\n"); + await reader.cancel(); + assert.ok(cancelled && aborted); +}); + +test("usage survives chunk boundaries and final output-only usage updates", async () => { + let charge = 0; + const data = 'data: {"message":{"usage":{"input_tokens":100}}}\n\ndata: {"usage":{"output_tokens":8}}\n\n'; + const body = new ReadableStream({ start(c) { for (const char of data) c.enqueue(bytes(char)); c.close(); } }); + await new Response(relayInference(body, { onUsage: (n) => { charge = n; } })).text(); + await Promise.resolve(); + assert.equal(charge, 108); +}); + +test("HTTP adapter delivers data before generation ends and cancels on disconnect", async (t) => { + let provider, resolveCancel; + const cancelled = new Promise((resolve) => { resolveCancel = resolve; }); + const server = createServer((_req, res) => { + res.setHeader("Content-Type", "text/event-stream"); + const stream = new ReadableStream({ start(c) { provider = c; c.enqueue(bytes("data: token\n\n")); }, cancel() { resolveCancel(); } }); + sendStream(res, stream).catch(() => {}); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => { server.closeAllConnections(); server.close(); }); + const abort = new AbortController(); + const response = await fetch(`http://127.0.0.1:${server.address().port}`, { signal: abort.signal }); + const reader = response.body.getReader(); + assert.match(new TextDecoder().decode((await reader.read()).value), /token/); + assert.ok(provider, "provider is still open"); + abort.abort(); + await cancelled; +}); diff --git a/easel/test/tools.test.mjs b/easel/test/tools.test.mjs new file mode 100644 --- /dev/null +++ b/easel/test/tools.test.mjs @@ -0,0 +1,133 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { apiLookup, loadMap, outline, symbolText, outlineText, examples, handle, mcpConfig, TOOLS } from "../src/tools.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "..", ".."); +const server = path.join(here, "..", "src", "tools.mjs"); + +const PIECE = `// smiley, 2026 +import { thing } from "./lib/thing.mjs"; + +const RADIUS = 20; + +// Draw the face. +function paint({ wipe, ink, circle, screen }) { + wipe("blue"); + ink("yellow").circle(screen.width / 2, screen.height / 2, RADIUS, true); +} + +function act({ event: e }) { + if (e.is("touch")) grow(); +} + +const grow = () => { + // nothing yet +}; + +export { paint, act }; +`; + +function workspace() { + const root = mkdtempSync(path.join(tmpdir(), "easel-tools-")); + mkdirSync(path.join(root, "disks")); + writeFileSync(path.join(root, "smiley.mjs"), PIECE); + writeFileSync(path.join(root, "other.mjs"), `function paint({ circle }) { circle(1, 2, 3); }\nexport { paint };\n`); + return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +test("the API map is built and answers the questions sessions actually asked", () => { + const map = loadMap(); + assert.ok(map.entries.length > 60, `map has ${map.entries.length} entries`); + const circle = apiLookup(map, "circle"); + assert.match(circle, /^circle\n\s+circle\(x0, y0, radius, filled/m); + assert.match(circle, /e\.g\. disks\//); + const synth = apiLookup(map, "synth"); + assert.match(synth, /sound\.synth\n\s+sound\.synth\(\{ tone = 440/); + const button = apiLookup(map, "button multitouch"); + assert.match(button, /ui\.Button/); + assert.match(apiLookup(map, "zzzznotathing"), /Nothing in the API map/); + // No query lists everything, one per line. + assert.ok(apiLookup(map, "").split("\n").length === map.entries.length); +}); + +test("outline reads a flat piece as symbols with spans", () => { + const { items, lines } = outline(PIECE); + assert.equal(lines, PIECE.split("\n").length); + const names = items.map((item) => `${item.kind}:${item.name}`); + assert.deepEqual(names, [ + "import:./lib/thing.mjs", + "value:RADIUS", + "function:paint", + "function:act", + "function:grow", + "exports:paint, act", + ]); + const paint = items.find((item) => item.name === "paint"); + assert.equal(paint.line, 7); + // The span ends at paint's closing brace, not at act's opening line. + assert.equal(paint.end, 10); +}); + +test("symbol and outline resolve a piece by bare name inside the workspace", () => { + const { root, cleanup } = workspace(); + try { + const text = symbolText(root, "smiley", "paint"); + assert.match(text, /^smiley\.mjs:7-10 {2}function paint/); + assert.match(text, /circle\(screen\.width/); + assert.match(symbolText(root, "smiley.mjs", "nope"), /No top-level symbol "nope"/); + assert.match(outlineText(root, "smiley"), /5 top-level symbols/); + assert.throws(() => outlineText(root, "../../etc/passwd"), /no such piece/); + const hits = examples(root, "circle"); + assert.match(hits, /smiley\.mjs:9/); + assert.match(hits, /other\.mjs:1/); + } finally { + cleanup(); + } +}); + +test("outline of a real large piece is a page, not a file", () => { + const text = outlineText(repo, "notepat"); + const rows = text.split("\n"); + assert.match(rows[0], /notepat\.mjs — \d+ lines, \d+ top-level symbols/); + assert.ok(rows.length > 20 && rows.length < 400, `${rows.length} rows`); +}); + +test("the JSON-RPC surface: initialize, list, call, unknown", () => { + const context = { cwd: repo, map: loadMap() }; + const init = handle({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" } }, context); + assert.equal(init.result.protocolVersion, "2025-06-18"); + assert.deepEqual(init.result.capabilities, { tools: {} }); + assert.equal(handle({ jsonrpc: "2.0", method: "notifications/initialized" }, context), null); + const list = handle({ jsonrpc: "2.0", id: 2, method: "tools/list" }, context); + assert.deepEqual(list.result.tools.map((tool) => tool.name), ["ac_api", "ac_examples", "ac_outline", "ac_symbol"]); + assert.equal(list.result.tools, TOOLS); + const call = handle({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "ac_api", arguments: { query: "wipe" } } }, context); + assert.match(call.result.content[0].text, /^wipe\n/); + const bad = handle({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "ac_symbol", arguments: { file: "missing", name: "x" } } }, context); + assert.equal(bad.result.isError, true); + const unknown = handle({ jsonrpc: "2.0", id: 5, method: "resources/list" }, context); + assert.equal(unknown.error.code, -32601); +}); + +test("the server runs on stdio and the config points the CLI at it", async () => { + const config = mcpConfig(repo); + assert.equal(config.mcpServers.ac.command, process.execPath); + assert.deepEqual(config.mcpServers.ac.args.slice(1), ["--cwd", repo]); + const child = spawn(config.mcpServers.ac.command, config.mcpServers.ac.args, { stdio: ["pipe", "pipe", "inherit"] }); + const out = []; + child.stdout.on("data", (chunk) => out.push(chunk)); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "ac_outline", arguments: { file: "notepat" } } })}\n`); + child.stdin.end(); + await new Promise((done) => child.on("close", done)); + const replies = Buffer.concat(out).toString().trim().split("\n").map((line) => JSON.parse(line)); + assert.equal(replies.length, 2); + assert.equal(replies[0].result.serverInfo.name, "easel-ac"); + assert.match(replies[1].result.content[0].text, /notepat\.mjs — \d+ lines/); +}); diff --git a/easel/test/tui-fixture.mjs b/easel/test/tui-fixture.mjs new file mode 100644 --- /dev/null +++ b/easel/test/tui-fixture.mjs @@ -0,0 +1,31 @@ +// Imported only by the PTY integration test: no accounts, network or vendor CLIs. +import { EventEmitter } from "node:events"; +import { appendFileSync } from "node:fs"; +import { ACSession } from "../src/ac-session.mjs"; +import { BACKENDS } from "../src/backends.mjs"; +import { Audience } from "../src/audience.mjs"; +import { Diagnostics } from "../src/diagnostics.mjs"; + +ACSession.prototype.read = () => ({ access_token: "fixture", user: { handle: "tester" } }); +ACSession.prototype.token = async () => "fixture"; +ACSession.prototype.watch = function () { return this; }; +ACSession.prototype.unwatch = () => {}; +Audience.prototype.watch = () => {}; +Diagnostics.prototype.watch = async () => {}; +globalThis.fetch = async () => { throw new Error("Network disabled in PTY fixture"); }; +class FixtureEngine extends EventEmitter { + constructor(options) { super(); Object.assign(this, options); this.threadId = "fixture"; } + async connect() { + appendFileSync(process.env.EASEL_TEST_LOG, JSON.stringify({ model: this.model, context: this.developerInstructions }) + "\n"); + if (this.model === "broken") throw new Error("Fixture switch failed"); + return { model: this.model || "fixture-default" }; + } + close() { this.emit("notification", { method: "item/agentMessage/delta", params: { itemId: "stale", delta: "STALE_CALLBACK_BUG" } }); } + async startTurn(text) { + this.emit("notification", { method: "turn/started", params: { turn: { id: "turn" } } }); + this.emit("notification", { method: "item/agentMessage/delta", params: { itemId: `answer-${Date.now()}`, delta: `I remember ${text}` } }); + this.emit("notification", { method: "turn/completed", params: { turn: { status: "completed" } } }); + } + interrupt() {} +} +for (const backend of Object.values(BACKENDS)) backend.Engine = FixtureEngine; diff --git a/easel/test/tui-pty.py b/easel/test/tui-pty.py new file mode 100644 --- /dev/null +++ b/easel/test/tui-pty.py @@ -0,0 +1,98 @@ +"""Real PTY checks with offline engine fixtures; run with python3 test/tui-pty.py.""" +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import shutil +import struct +import subprocess +import tempfile +import termios +import time + +ROOT = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory(prefix="easel-pty-") as temporary: + folder = Path(temporary) + piece = folder / "existing.mjs" + piece_source = "export function paint({wipe}) { wipe(70,50,100); }\n" + piece.write_text(piece_source) + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 100, 0, 0)) + fake_bin = folder / "bin" + fake_bin.mkdir() + opener = fake_bin / "open" + opener.write_text('#!/bin/sh\nprintf "%s" "$1" > "$EASEL_BROWSER_LOG"\n') + opener.chmod(0o755) + env = dict(os.environ, TERM="xterm-256color", NO_COLOR="1", + SLAB_HOME=str(folder / "slab"), EASEL_TEST_LOG=str(folder / "engines.jsonl"), + EASEL_HISTORY_DIR=str(folder / "history"), + EASEL_BROWSER_LOG=str(folder / "browser.txt"), PATH=str(fake_bin) + ":" + os.environ["PATH"]) + child = subprocess.Popen([shutil.which("node"), "--import", str(ROOT / "test/tui-fixture.mjs"), + str(ROOT / "src/tui.mjs"), "--cwd", str(folder), "--piece", str(piece), "--backend", "ac", "--no-autopublish"], + stdin=slave, stdout=slave, stderr=slave, env=env) + os.close(slave) + output = bytearray() + def read_for(seconds=0.15): + until = time.monotonic() + seconds + while time.monotonic() < until: + if select.select([master], [], [], max(0, until-time.monotonic()))[0]: + try: + data = os.read(master, 65536) + except OSError: + break + if not data: + break + output.extend(data) + return output.decode("utf-8", errors="replace") + def send(text): + offset = len(output) + os.write(master, text.encode()) + read_for(0.3) + return output[offset:].decode("utf-8", errors="replace") + try: + read_for(0.8) + send("\x1b") # dismiss splash + assert "make a piece" in send("/about\r") + send("\x1b") + assert "make a piece" in send("\x1b[<0;3;21M") # click EASEL + send("\x1b") + profile_result = send("\x1b[<0;10;21M") # click @tester + deadline = time.monotonic() + 2 + while not (folder / "browser.txt").exists() and time.monotonic() < deadline: + read_for(0.05) + assert (folder / "browser.txt").exists(), profile_result[-5000:] + assert (folder / "browser.txt").read_text() == "https://aesthetic.computer/@tester" + send("remember cobalt dots\r") + result = send("/backend codex\r") + assert "current piece and recent conversation carried over" in result + assert "STALE_CALLBACK_BUG" not in result + records = [json.loads(line) for line in (folder / "engines.jsonl").read_text().splitlines()] + assert "remember cobalt dots" in records[-1]["context"] + result = send("/model broken\r") + assert "Fixture switch failed" in result + assert "STALE_CALLBACK_BUG" not in result + assert "I remember still here" in send("still here\r") + offset = len(output) + send("/performance 10\r") + read_for(0.8) + assert "Excludes browser rendering" in output[offset:].decode("utf-8", errors="replace") + for index in range(24): + send(f"line {index}\r") + result = send("\x1b[5~") + assert "lines above" in result and "EASEL" in result + result = send("\x1b[F") + assert "line 23" in result + send("/mouse off\r") + assert b"\x1b[?1003l" in output + send("/quit\r") + child.wait(timeout=5) + assert child.returncode == 0, child.returncode + assert piece.read_text() == piece_source + print("PTY passed: about/profile clicks, engine handoff/recovery, stale callbacks, internal scroll, benchmark, existing piece preservation, mouse cleanup") + finally: + if child.poll() is None: + child.terminate() + child.wait(timeout=5) + os.close(master) diff --git a/lith/server.mjs b/lith/server.mjs --- a/lith/server.mjs +++ b/lith/server.mjs @@ -47,6 +47,7 @@ }; } import express from "express"; +import { sendStream } from "./stream-response.mjs"; import { userMediaTarget } from "./media-path.mjs"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, statSync } from "fs"; import { join, dirname } from "path"; @@ -483,17 +484,10 @@ // Handle ReadableStream bodies (from streaming functions like ask, keep-mint) if (result.body && typeof result.body === "object" && typeof result.body.getReader === "function") { res.status(statusCode); - const reader = result.body.getReader(); - const pump = async () => { - while (true) { - const { done, value } = await reader.read(); - if (done) { res.end(); return; } - res.write(value); - } - }; - return pump().catch((err) => { + return sendStream(res, result.body).catch((err) => { + if (res.destroyed) return; console.error(`fn/${name} stream error:`, err); - res.end(); + res.destroy(err); }); } diff --git a/lith/stream-response.mjs b/lith/stream-response.mjs new file mode 100644 --- /dev/null +++ b/lith/stream-response.mjs @@ -0,0 +1,28 @@ +import { once } from "node:events"; + +// Preserve streaming through the Express adapter, including slow/disconnected +// clients. Header flush exposes connection establishment before token one. +export async function sendStream(res, body) { + const reader = body.getReader(); + const cancelled = new AbortController(); + const close = () => { + cancelled.abort(); + reader.cancel("client disconnected").catch(() => {}); + }; + res.once("close", close); + res.socket?.setNoDelay(true); + res.flushHeaders(); + try { + while (!res.destroyed) { + const { done, value } = await reader.read(); + if (done) break; + if (!res.write(value)) await once(res, "drain", { signal: cancelled.signal }); + res.flush?.(); + } + if (!res.destroyed) res.end(); + } finally { + res.off("close", close); + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -110,6 +110,7 @@ /// Only Easel sets it, and it names the rock: a session that is /// holding a piece should be addressable by that piece's name rather than /// by a second unrelated word drawn from its session id. var piece: String = "" + var pieceVersion: Int = 0 /// How the file on disk stands against what `scanURL` is serving — /// `live`, `ahead` (saved, not pushed yet) or `pushing`. The preview @@ -384,6 +385,7 @@ session.loopboyResponse = (obj["loopboy_response"] as? String) ?? "" session.nudgeScreen = (obj["nudge_screen"] as? String) ?? "" session.scanURL = (obj["scan_url"] as? String) ?? "" session.piece = (obj["piece"] as? String) ?? "" + session.pieceVersion = (obj["piece_version"] as? Int) ?? 0 session.flow = (obj["flow"] as? String) ?? "live" return session } diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift --- a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift @@ -13,14 +13,25 @@ // costs one decision fewer every time you want to know what you just made. // // What it shows is `scan_url` — the same address the rock encodes — so the two // surfaces can never disagree about which piece this session is about. It runs -// capped (`maxfps`) because a wall of nine panes each animating at display rate -// is a warm laptop for no one's benefit. +// at display rate: a piece is judged by how it moves, and a card that shows +// four frames a second answers "is it moving?" but never "is it right?". // // The piece runs at the pane's own viewport the whole time. The resting card is -// a small window onto it — a slow Ken Burns crop at 1:1, the way chat.mjs shows -// a `#painting` — and pointing at the card opens that window up to the whole -// viewport. Nothing is resized on the way: a live resize of a web view costs a -// reframe and a black frame or two, and the old card paid both on every hover. +// that whole viewport composited down to thumbnail size, and pointing at the +// card scales it back up to 1:1 over the pane. Nothing is resized on the way: a +// live resize of a web view costs a reframe and a black frame or two, and the +// old card paid both on every hover. +// +// The pane itself resizing is the one time the web view must follow, and it +// follows late: while the terminal is being dragged the stage only rescales, +// and the real reframe happens once, after the drag settles, under a snapshot +// of the last frame that stays up until the piece has painted at its new size. +// A WKWebView mid-resize shows its window's backing colour — here, the card's +// black — and that flash was what a resize used to look like. +// +// While the card is open it is the piece, not a picture of it: the pointer +// reaches the web view and so does the keyboard. Pointing at your own work and +// having it ignore you is the wrong kind of preview. // // The chrome over it is not decoration. A web view that is covered, throttled // or simply one save behind shows a frame that looks exactly like a live one, @@ -52,6 +63,7 @@ /// screen are however old the last paint was. Never inferred from a /// timer — read from the same window-stack test that hides the rock. var paused = false var piece: String = "" + var version = 0 /// Nothing to report: the card is showing the current piece, painting, and /// the file agrees with it. The overwhelmingly common case, and the one the @@ -95,7 +107,8 @@ /// The name earns its place only in the grown card; the state earns its /// place only when there is one. Both absent is the ordinary case, and the /// view draws nothing at all. private var text: String { - let name = expanded ? state.piece : "" + let name = [expanded ? state.piece : "", state.version > 0 ? "v\(state.version)" : ""] + .filter { !$0.isEmpty }.joined(separator: " · ") if state.quiet { return name } return name.isEmpty ? state.label : "\(name) · \(state.label)" } @@ -135,10 +148,13 @@ /// Room left between the open card and its pane's bottom-right, so the /// terminal never looks completely papered over. private static let hoverMargin: CGFloat = 12 - /// Frames per second while nobody is looking. Four is enough to see that a - /// piece is moving — which is the only question a glance asks — and cheap - /// enough to leave running on every pane at once. - private static let restFPS = 4 + /// How long a pane has to hold still before the web view is reframed to + /// it. Live-resize ticks arrive every frame; this collapses a drag into one + /// reframe at the end of it. + private static let resizeSettle: TimeInterval = 0.3 + /// How long the snapshot stays over the reframed web view. The runtime's own + /// resize handler debounces, then repaints; this covers both. + private static let coverHold: TimeInterval = 0.6 /// Inset from the pane's left edge, and drop below its title bar. The card /// parks *inside* the pane rather than over the title: the top-left of a @@ -163,18 +179,21 @@ /// A radius this small reads as a cut corner rather than a rounded one, /// which is what a screen on a desk looks like. private static let cardRadius: CGFloat = 3 - /// One slow lap of the resting crop around the piece. Matches the - /// `#painting` embeds in chat.mjs, which this card is a cousin of. - private static let kenBurnsCycle: TimeInterval = 8 - /// How often the crop moves. A pan of a few points a second at 24 steps - /// reads as continuous; the web view is not repainted by it, only - /// re-composited. - private static let kenBurnsInterval: TimeInterval = 1.0 / 24 /// How long the card takes to open or close. private static let openDuration: TimeInterval = 0.16 - private let window: NSWindow + private let window: PromptPreviewWindow private let webView: WKWebView + private let refreshBridge = PromptPreviewRefreshBridge() + private var lastRefresh = Date.distantPast + /// Scale through AppKit's frame/bounds mapping, not a layer transform. + /// WebKit consults the NSView visible rect when deciding which tiles to + /// paint; a layer-only scale leaves that rect clipped to the thumbnail. + private let stage = PromptPreviewStage() + /// The last frame, held over the stage while the web view is reframed. + private let cover = NSImageView() + private var settleTimer: Timer? + private var coverTimer: Timer? private let badgeHost: NSHostingView private let border = CALayer() /// The card proper — the part of the viewport the eye is shown. The @@ -212,10 +231,10 @@ /// less the card's insets. The web view is only ever this size, so opening /// the card reframes nothing: what was cropped is simply shown. private var viewport = PromptPreview.restSize - /// Where in the lap this card's crop is; a random phase so a wall of cards - /// does not drift in lockstep. - private let kenBurnsSeed = Double.random(in: 0..<1) - private var kenBurnsTimer: Timer? + /// Whoever was frontmost when the card took the keyboard, so closing the + /// card hands focus back to the terminal it was over rather than leaving + /// a menubar app as the active one. + private var yieldTo: NSRunningApplication? init() { let config = WKWebViewConfiguration() @@ -223,25 +242,33 @@ // A wall of previews must stay silent. AC's audio needs a gesture // anyway and this window takes none, but say it rather than rely on it. config.mediaTypesRequiringUserActionForPlayback = .all config.suppressesIncrementalRendering = false + config.userContentController.add(refreshBridge, name: "previewReady") + config.userContentController.addUserScript(WKUserScript(source: """ + window.addEventListener('message', event => { + if (event.source === window && event.data?.type === 'ready') { + window.webkit.messageHandlers.previewReady.postMessage('ready'); + } + }); + """, injectionTime: .atDocumentStart, forMainFrameOnly: true)) webView = WKWebView(frame: .zero, configuration: config) webView.setValue(false, forKey: "drawsBackground") - // Positioned by hand: the crop is an offset, never a resize. webView.autoresizingMask = [] badgeHost = NSHostingView(rootView: PromptPreviewBadge(state: PromptPreviewState(), expanded: false)) - window = NSWindow(contentRect: NSRect(origin: .zero, size: Self.restSize), - styleMask: .borderless, backing: .buffered, defer: false) + window = PromptPreviewWindow(contentRect: NSRect(origin: .zero, size: Self.restSize), + styleMask: .borderless, backing: .buffered, defer: false) window.isOpaque = false window.backgroundColor = .clear // AppKit's window shadow is a soft, untunable bloom. Ours is drawn. window.hasShadow = false window.level = NSWindow.Level(Int(CGWindowLevelForKey(.normalWindow)) + 1) - // Click-through, like the rock's render surface: hover is discovered by - // the controller's pointer monitor, never by taking events away from - // the terminal. + // Click-through at rest, like the rock's render surface: hover is + // discovered by the controller's pointer monitor. Open, the card takes + // the pointer — see `setHovered`. window.ignoresMouseEvents = true + window.acceptsMouseMovedEvents = true window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] // The window is the whole viewport plus the room its shadow falls @@ -258,8 +285,18 @@ card.wantsLayer = true card.layer?.masksToBounds = true card.layer?.cornerRadius = Self.cardRadius card.layer?.backgroundColor = NSColor.black.cgColor - webView.frame = card.bounds - card.addSubview(webView) + stage.wantsLayer = true + stage.autoresizesSubviews = false + stage.frame = card.bounds + webView.frame = stage.bounds + stage.addSubview(webView) + card.addSubview(stage) + cover.imageScaling = .scaleAxesIndependently + cover.isHidden = true + cover.wantsLayer = true + cover.frame = card.bounds + cover.autoresizingMask = [.width, .height] + card.addSubview(cover) border.borderWidth = 1 border.cornerRadius = Self.cardRadius @@ -274,6 +311,7 @@ card.addSubview(badgeHost) content.addSubview(card) window.contentView = content + refreshBridge.onReady = { [weak self] in self?.celebrateRefresh() } layoutWindow() layoutCard(animated: false) redrawChrome() @@ -301,13 +339,57 @@ // `autoreload` because this card has nobody to tap the update badge: a // green arrow in the corner of a 128-point window is a control out of // reach, sitting on the piece it came to announce. The card takes the // deploy silently instead. - let url = "\(base)\(separator)nogap=true&nolabel=true&autoreload=true&maxfps=\(Self.restFPS)" + let url = "\(base)\(separator)nogap=true&nolabel=true&autoreload=true" guard let target = URL(string: url) else { return } webView.load(URLRequest(url: target)) } func setState(_ next: PromptPreviewState) { state = next } + /// Triggered by the runtime's boot completion, not by token arrivals or + /// upload progress. All motion is composited; no permanent animation loop. + private func celebrateRefresh() { + guard isOnScreen, Date().timeIntervalSince(lastRefresh) > 0.5 else { return } + lastRefresh = Date() + let blink = CABasicAnimation(keyPath: "borderColor") + blink.fromValue = NSColor.white.cgColor + blink.toValue = border.borderColor + blink.duration = 0.45 + border.add(blink, forKey: "pieceReady") + guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return } + let shake = CAKeyframeAnimation(keyPath: "transform.translation.x") + shake.values = [0, -2, 2, -1, 1, 0] + shake.duration = 0.28 + card.layer?.add(shake, forKey: "pieceReady") + guard let root = window.contentView?.layer else { return } + let rect = card.frame + for i in 0..<8 { + let particle = CALayer() + particle.frame = CGRect(x: rect.minX + rect.width * CGFloat(i + 1) / 9, + y: rect.minY + 2, width: 3, height: 3) + particle.backgroundColor = NSColor(calibratedHue: CGFloat(i) / 8, + saturation: 0.65, brightness: 1, alpha: 1).cgColor + root.addSublayer(particle) + let fall = CABasicAnimation(keyPath: "position") + fall.fromValue = NSValue(point: particle.position) + fall.toValue = NSValue(point: CGPoint(x: particle.position.x + CGFloat(i % 3 - 1) * 10, + y: particle.position.y - 24 - CGFloat(i % 3) * 7)) + fall.timingFunction = CAMediaTimingFunction(name: .easeIn) + let fade = CABasicAnimation(keyPath: "opacity") + fade.fromValue = 1 + fade.toValue = 0 + let group = CAAnimationGroup() + group.animations = [fall, fade] + group.duration = 0.6 + group.fillMode = .forwards + group.isRemovedOnCompletion = false + particle.add(group, forKey: "fall") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { + particle.removeFromSuperlayer() + } + } + } + /// Set by the controller from the window-stack test, never from a timer. func setPaused(_ paused: Bool) { guard state.paused != paused else { return } @@ -336,12 +418,28 @@ /// Open under the pointer and close when it leaves. The card keeps its /// top-left corner, so it opens *into* the pane rather than walking across /// the screen — and it opens onto the piece already running at the pane's /// own size, so nothing reframes, reloads or goes black on the way. + /// + /// Open, the card is live: it takes the pointer, and it takes the keyboard + /// too, so the piece under the pointer is the one being played. Closing + /// gives both back to whoever had them. func setHovered(_ hovering: Bool) { guard hovering != expanded else { return } expanded = hovering layoutCard(animated: true) redrawChrome() - syncKenBurns() + window.ignoresMouseEvents = !hovering + if hovering { + if !window.isVisible { window.orderFrontRegardless() } + let front = NSWorkspace.shared.frontmostApplication + if front?.processIdentifier != ProcessInfo.processInfo.processIdentifier { yieldTo = front } + NSApp.activate(ignoringOtherApps: true) + window.makeKey() + window.makeFirstResponder(webView) + } else { + if window.isKeyWindow { window.resignKey() } + if let back = yieldTo, !back.isTerminated { back.activate() } + yieldTo = nil + } } /// Park the card in the pane's top-left, under the title bar. `bounds` is @@ -370,9 +468,10 @@ let height = max(restSize.height, (pane.height - dropBelowTitle - hoverMargin).rounded(.down)) return CGSize(width: width, height: height) } - /// Size the window and the web view to the viewport. This is the only - /// place the web view changes size, and it happens only when the pane - /// does — which is when the piece would have reframed anyway. + /// Size the window to the viewport, and ask for the web view to follow — + /// later. Before anything is loaded the stage takes the size at once, since + /// there is no frame to protect; after that the reframe waits for the pane + /// to hold still (`settleResize`), and until then the stage is only scaled. private func layoutWindow() { viewport = Self.viewport(in: paneSize) let frame = NSRect(x: paneOrigin.x, @@ -380,31 +479,82 @@ y: paneOrigin.y - viewport.height - Self.shadowDrop, width: viewport.width + Self.shadowDrop, height: viewport.height + Self.shadowDrop) if window.frame != frame { window.setFrame(frame, display: false) } - if webView.frame.size != viewport { - webView.frame = NSRect(origin: webView.frame.origin, size: viewport) + guard stageSize != viewport else { settleTimer?.invalidate(); settleTimer = nil; return } + if loadedURL.isEmpty { + webView.frame = NSRect(origin: .zero, size: viewport) + stage.viewportSize = viewport + return + } + settleTimer?.invalidate() + settleTimer = Timer.scheduledTimer(withTimeInterval: Self.resizeSettle, repeats: false) { [weak self] _ in + self?.settleResize() } } - /// The card's size right now: the whole viewport when open, the resting - /// card otherwise. Its top-left never moves. + /// The size the web view is actually rendering at. Equal to `viewport` + /// except during and just after a pane resize. + private var stageSize: CGSize { webView.frame.size } + + /// The one reframe a resize costs, taken under a snapshot of the frame the + /// card is showing right now. The snapshot is what the eye sees until the + /// piece has painted at the new size; the black the web view shows in + /// between happens underneath it. + private func settleResize() { + settleTimer = nil + let target = viewport + guard stageSize != target else { return } + let reframe = { [weak self] in + guard let self else { return } + self.webView.frame = NSRect(origin: .zero, size: target) + self.stage.viewportSize = target + self.layoutCard(animated: false) + self.coverTimer?.invalidate() + self.coverTimer = Timer.scheduledTimer(withTimeInterval: Self.coverHold, repeats: false) { [weak self] _ in + self?.cover.isHidden = true + self?.cover.image = nil + self?.coverTimer = nil + } + } + guard window.isVisible else { reframe(); return } + webView.takeSnapshot(with: nil) { [weak self] image, _ in + guard let self else { return } + if let image { + self.cover.image = image + self.cover.isHidden = false + } + reframe() + } + } + + /// How far the stage is scaled: to fill the viewport when open (one, once + /// the web view has caught up with the pane), and otherwise the largest + /// scale at which the whole of it fits the resting card. + private var scale: CGFloat { + let s = stageSize + guard s.width > 0, s.height > 0 else { return 1 } + return expanded + ? min(viewport.width / s.width, viewport.height / s.height) + : min(1, Self.restSize.width / s.width, Self.restSize.height / s.height) + } + + /// The card's size right now: the stage at `scale` — the whole viewport + /// when open, a thumbnail with the piece's own proportions at rest, rather + /// than letterboxing it. Its top-left never moves. private var cardSize: CGSize { - expanded ? viewport - : CGSize(width: min(Self.restSize.width, viewport.width), - height: min(Self.restSize.height, viewport.height)) + let s = scale, size = stageSize + return CGSize(width: (size.width * s).rounded(), height: (size.height * s).rounded()) } - /// Fit the card, its shadow and its border to `cardSize`, and slide the - /// web view so the card shows the right part of it — everything when - /// open, the current crop when closed. Animated, the card unfolds over the - /// piece; the piece itself never changes size, which is what keeps the + /// Fit the card, its shadow and its border to `cardSize`, and scale the + /// stage so the whole viewport fills the card — at 1:1 when open, composited + /// down when closed. Animated, the card unfolds and the piece grows with + /// it; the web view itself never changes size, which is what keeps the /// unfolding free of the black frames a live resize costs. private func layoutCard(animated: Bool) { guard let content = window.contentView else { return } let size = cardSize let rect = NSRect(x: 0, y: content.bounds.height - size.height, width: size.width, height: size.height) - let crop = expanded ? CGPoint.zero : kenBurnsCrop(at: Date()) - let webOrigin = webOrigin(cardHeight: size.height, crop: crop) // The card is what everything else means by "the preview" — the pointer // test, the ownership test. Both read its final rect, not the frame // mid-animation, so a pointer that opened the card is inside it at once. @@ -415,12 +565,13 @@ width: hitRect.width, height: hitRect.height) let shadowRect = NSRect(x: rect.minX + Self.shadowDrop, y: rect.minY - Self.shadowDrop, width: size.width, height: size.height) let borderRect = NSRect(origin: .zero, size: size) + let stageFrame = NSRect(origin: .zero, size: size) if animated { NSAnimationContext.runAnimationGroup { context in context.duration = Self.openDuration context.timingFunction = CAMediaTimingFunction(name: .easeOut) card.animator().frame = rect - webView.animator().frame = NSRect(origin: webOrigin, size: viewport) + stage.animator().frame = stageFrame } CATransaction.begin() CATransaction.setAnimationDuration(Self.openDuration) @@ -430,7 +581,7 @@ border.frame = borderRect CATransaction.commit() } else { card.frame = rect - webView.frame = NSRect(origin: webOrigin, size: viewport) + stage.frame = stageFrame CATransaction.begin() CATransaction.setDisableActions(true) shadow.frame = shadowRect @@ -440,57 +591,6 @@ } layoutBadge() } - /// Where the web view sits inside a card `cardHeight` tall so that the - /// crop's top-left (measured from the piece's top-left, the way a picture - /// is cropped) lands in the card's top-left. AppKit's origin is bottom-left, - /// so the top edges are aligned by lifting the view by the height it - /// overhangs, less the crop. - private func webOrigin(cardHeight: CGFloat, crop: CGPoint) -> NSPoint { - NSPoint(x: -crop.x, y: cardHeight - viewport.height + crop.y) - } - - /// The resting crop's position at `time`: a slow circle around the piece - /// at 1:1, the same lap the `#painting` embeds in chat.mjs take. Nothing - /// is scaled — the card is a window onto the piece, not a thumbnail of it. - private func kenBurnsCrop(at time: Date) -> CGPoint { - let size = cardSize - let maxX = max(0, viewport.width - size.width) - let maxY = max(0, viewport.height - size.height) - guard maxX > 0 || maxY > 0 else { return .zero } - let progress = (time.timeIntervalSince1970 / Self.kenBurnsCycle + kenBurnsSeed) - .truncatingRemainder(dividingBy: 1) - let panX = (cos((progress + 0.25) * .pi * 2) + 1) / 2 - let panY = (sin((progress + 0.65) * .pi * 2) + 1) / 2 - return CGPoint(x: (maxX * panX).rounded(), y: (maxY * panY).rounded()) - } - - /// The crop moves only while there is something to move over and someone - /// might see it: a closed card on screen. Open, hidden or too small to - /// crop, the timer is off. - private func syncKenBurns() { - let size = cardSize - let wants = window.isVisible && !expanded - && (viewport.width > size.width || viewport.height > size.height) - if wants { - guard kenBurnsTimer == nil else { return } - let timer = Timer(timeInterval: Self.kenBurnsInterval, repeats: true) { [weak self] _ in - self?.stepKenBurns() - } - timer.tolerance = Self.kenBurnsInterval / 4 - RunLoop.main.add(timer, forMode: .common) - kenBurnsTimer = timer - } else { - kenBurnsTimer?.invalidate() - kenBurnsTimer = nil - } - } - - private func stepKenBurns() { - guard !expanded else { return } - let origin = webOrigin(cardHeight: cardSize.height, crop: kenBurnsCrop(at: Date())) - if webView.frame.origin != origin { webView.setFrameOrigin(origin) } - } - private func layoutBadge() { badgeHost.layoutSubtreeIfNeeded() let size = badgeHost.fittingSize @@ -518,18 +618,20 @@ func setVisible(_ visible: Bool) { if visible { if !window.isVisible { window.orderFrontRegardless() } } else if window.isVisible { - window.orderOut(nil) setHovered(false) + window.orderOut(nil) } - syncKenBurns() } var isOnScreen: Bool { window.isVisible } func close() { - kenBurnsTimer?.invalidate() - kenBurnsTimer = nil + setHovered(false) + settleTimer?.invalidate() + coverTimer?.invalidate() webView.stopLoading() + refreshBridge.onReady = nil + webView.configuration.userContentController.removeScriptMessageHandler(forName: "previewReady") // Point the view at nothing before tearing down: a WKWebView left // holding a running page keeps its content process alive past the // window that owned it. @@ -537,3 +639,32 @@ webView.loadHTMLString("", baseURL: nil) window.orderOut(nil) } } + +private final class PromptPreviewRefreshBridge: NSObject, WKScriptMessageHandler { + var onReady: (() -> Void)? + func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + guard message.frameInfo.isMainFrame, message.body as? String == "ready" else { return } + onReady?() + } +} + +/// Keep WebKit's logical viewport fixed as the visible card changes size. +private final class PromptPreviewStage: NSView { + var viewportSize = CGSize(width: 128, height: 96) { + didSet { super.setBoundsSize(viewportSize) } + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + // Frame animation otherwise scales the bounds too, changing the + // logical viewport while WebKit's frame remains fixed. + super.setBoundsSize(viewportSize) + } +} + +/// A borderless window that can take the keyboard when the piece is played. +final class PromptPreviewWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -441,10 +441,8 @@ // points the code draws at 58 — the stones are 56 — with four device pixels // to a module, above the three-pixel floor. So it keeps the wall's rhythm // instead of being the one tile that shouts. static let scanSurfaceSize: CGFloat = 64 - /// How far the scan card's shadow falls, right and down — the same three - /// points the preview card uses, so the two surfaces on one pane are lit - /// the same way. - private static let scanShadowDrop: CGFloat = 3 + /// A small hard shadow fitted to the actual QR card. + private static let scanShadowDrop: CGFloat = 2 /// True when this rock shows a scannable code instead of the tumbling /// sigil. Fixed at construction, because it decides the surface's @@ -937,6 +935,8 @@ func setHovered(_ h: Bool) { guard hovered != h else { return } hovered = h springScale(nameLayer, to: h ? 1.5 : 1.0) + // Keep the QR and its backing aligned while its label responds. + guard !isScanSurface else { return } springScale(rockLayer, to: h ? 1.12 : 1.0) springScale(shadowLayer, to: h ? 1.12 : 1.0) retime(nameLayer, speed: h ? 2.2 : 1.0) @@ -1182,6 +1182,8 @@ x: pad + (size - side) / 2, y: pad + labelH + (size - side) / 2, width: side, height: side) } + shadowLayer.frame = scanLayer.frame.offsetBy( + dx: Self.scanShadowDrop, dy: -Self.scanShadowDrop) CATransaction.commit() } @@ -2156,6 +2158,14 @@ guard mouseMonitors.isEmpty else { return } if let move = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved, handler: { [weak self] _ in self?.handleMouseMoved() }) { mouseMonitors.append(move) } + // An open preview card takes the pointer, and a global monitor is not + // told about moves over Slab's own windows — so without this twin the + // card would never learn the pointer had left it. + if let localMove = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved, handler: { + [weak self] event in + self?.handleMouseMoved() + return event + }) { mouseMonitors.append(localMove) } // Clicks received by Slab's own non-activating card panel do not reach // a global monitor. Keep a local twin so card → native share is // reliable regardless of which side of macOS's event routing wins. @@ -2239,6 +2249,7 @@ var next = PromptPreviewState() next.flow = PromptFlow(s.flow) next.working = (s.state == .working || s.state == .rendering) next.piece = s.piece + next.version = s.pieceVersion next.paused = !pv.isOnScreen pv.setState(next) } diff --git a/slab/menubar-swift/Sources/SlabMenubar/WindowNav.swift b/slab/menubar-swift/Sources/SlabMenubar/WindowNav.swift --- a/slab/menubar-swift/Sources/SlabMenubar/WindowNav.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/WindowNav.swift @@ -224,13 +224,17 @@ return AXTiler.center(window) } static func focusedWindow() -> AXUIElement? { - let sys = AXUIElementCreateSystemWide() - var appRef: CFTypeRef? - guard AXUIElementCopyAttributeValue(sys, kAXFocusedApplicationAttribute as CFString, - &appRef) == .success, - let appRef else { return nil } + // A hovered preview makes Slab frontmost. Asking system-wide AX for + // focus then re-enters our own accessibility implementation while the + // main thread holds its lock, freezing previews and the whole daemon. + // Resolve the external PID without AX and never message ourselves. + guard let front = NSWorkspace.shared.frontmostApplication, + front.processIdentifier != ProcessInfo.processInfo.processIdentifier + else { return nil } + let app = AXUIElementCreateApplication(front.processIdentifier) + AXUIElementSetMessagingTimeout(app, 0.2) var winRef: CFTypeRef? - guard AXUIElementCopyAttributeValue(appRef as! AXUIElement, + guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &winRef) == .success, let winRef else { return nil } diff --git a/slab/menubar-swift/build-dev.sh b/slab/menubar-swift/build-dev.sh --- a/slab/menubar-swift/build-dev.sh +++ b/slab/menubar-swift/build-dev.sh @@ -5,4 +5,4 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/../bin/build-lock.sh" acquire_build_lock slab-menubar cd "${SCRIPT_DIR}" -swift build -c debug +swift build -c debug "$@" diff --git a/system/backend/easel-policy.mjs b/system/backend/easel-policy.mjs new file mode 100644 --- /dev/null +++ b/system/backend/easel-policy.mjs @@ -0,0 +1,31 @@ +// Explicit hosted selections. The account budget is token-based, not a dollar +// ceiling; premium models are never substituted for the inexpensive default. +export const EASEL_MODELS = { + "z-ai/glm-4.6": { label: "glm" }, + "qwen/qwen3-coder": { label: "qwen" }, + "deepseek/deepseek-chat-v3.1": { label: "deepseek" }, + "anthropic/claude-sonnet-4.6": { label: "sonnet (premium)" }, + "openai/gpt-5.4": { label: "gpt (premium)" }, +}; +export const DEFAULT_EASEL_MODEL = "z-ai/glm-4.6"; +const MAX_TOKENS = 8192; + +export function inferenceRequest(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Expected an inference request object."); + const model = body.model === undefined ? DEFAULT_EASEL_MODEL : body.model; + if (typeof model !== "string" || !Object.hasOwn(EASEL_MODELS, model)) throw new Error("Unsupported model. Select a model from /model."); + const wanted = body.max_tokens === undefined ? MAX_TOKENS : body.max_tokens; + if (!Number.isSafeInteger(wanted) || wanted < 1) throw new Error("max_tokens must be a positive integer."); + if (!Array.isArray(body.messages) || body.messages.length === 0) throw new Error("At least one message is required."); + return { model, maxTokens: Math.min(wanted, MAX_TOKENS) }; +} + +export function inferenceBudgetFailure(budget, handle) { + if (!budget || budget.unknown || !Number.isFinite(budget.remaining) || !Number.isFinite(budget.budget) || budget.budget <= 0) { + return { statusCode: 503, message: "Hosted inference cannot verify your remaining allowance. Try again shortly." }; + } + if (budget.exhausted || budget.remaining <= 0) { + return { statusCode: 429, message: `@${handle} has used today's allowance (${budget.used}/${budget.budget} tokens). It resets at midnight UTC.` }; + } + return null; +} diff --git a/system/backend/easel-stream.mjs b/system/backend/easel-stream.mjs new file mode 100644 --- /dev/null +++ b/system/backend/easel-stream.mjs @@ -0,0 +1,54 @@ +// Relay each provider chunk as soon as the consumer can accept it. Cancellation +// travels back to fetch instead of leaving a paid generation running unseen. +export function relayInference(body, { onUsage = () => {}, abort = () => {} } = {}) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let tail = ""; + let spent = 0; + let accumulated = {}; + let finished = false; + function finish() { + if (finished) return; + finished = true; + if (spent) Promise.resolve().then(() => onUsage(spent)).catch(() => {}); + reader.releaseLock(); + } + function meter(bytes) { + tail += decoder.decode(bytes, { stream: true }); + let cut; + while ((cut = tail.indexOf("\n")) !== -1) { + const line = tail.slice(0, cut).trim(); + tail = tail.slice(cut + 1); + if (!line.startsWith("data:")) continue; + try { + const json = JSON.parse(line.slice(5).trimStart()); + const usage = json?.usage || json?.message?.usage; + if (usage) { + accumulated = { ...accumulated, ...usage }; + spent = (accumulated.input_tokens || 0) + (accumulated.output_tokens || 0) + + Math.round((accumulated.cache_read_input_tokens || 0) * 0.1) + + Math.round((accumulated.cache_creation_input_tokens || 0) * 1.25); + } + } catch {} + } + // A malformed provider must not accumulate an unbounded unterminated line. + if (tail.length > 1_048_576) tail = ""; + } + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (finished) return; + if (done) { controller.close(); finish(); return; } + meter(value); + controller.enqueue(value); + } catch (error) { + if (!finished) { controller.error(error); finish(); } + } + }, + async cancel(reason) { + abort(reason); + try { await reader.cancel(reason); } finally { finish(); } + }, + }); +} diff --git a/system/netlify/functions/easel-inference.mjs b/system/netlify/functions/easel-inference.mjs --- a/system/netlify/functions/easel-inference.mjs +++ b/system/netlify/functions/easel-inference.mjs @@ -23,26 +23,16 @@ // 3. The same daily budget /api/ask meters against, in the same collection, // so a handle has one allowance across everything AC buys for them rather // than one per endpoint. // -// Cost is read from OpenRouter's own usage block rather than estimated: their -// response carries dollars per call, so the meter records what was actually -// spent instead of a token count that drifts from the bill as prices move. +// Provider usage feeds the shared token allowance, with discounts for cached +// input. This is not a dollar limit or an atomic reservation; premium models +// cost more per allowance, and concurrent requests can overshoot the balance. import { stream } from "@netlify/functions"; +import { relayInference } from "../../backend/easel-stream.mjs"; +import { EASEL_MODELS as MODELS, inferenceRequest, inferenceBudgetFailure } from "../../backend/easel-policy.mjs"; const OPENROUTER = "https://openrouter.ai/api/v1/messages"; -// What AC is willing to buy. Cheap models only: this is a free tier attached to -// a handle, not a blank cheque, and the whole argument for it is that a -// GLM-class model writes a small piece perfectly well. Adding a frontier model -// here multiplies the cost of the free tier by about thirty. -const MODELS = { - "z-ai/glm-4.6": { label: "glm" }, - "qwen/qwen3-coder": { label: "qwen" }, - "deepseek/deepseek-chat-v3.1": { label: "deepseek" }, -}; -const DEFAULT_MODEL = "z-ai/glm-4.6"; - -const MAX_TOKENS = 8192; const AUTH_TIMEOUT_MS = 3000; function fail(statusCode, message) { @@ -104,7 +94,9 @@ } catch (error) { return fail(400, `Malformed request: ${error.message}`); } - const model = MODELS[body.model] ? body.model : DEFAULT_MODEL; + let model, maxTokens; + try { ({ model, maxTokens } = inferenceRequest(body)); } + catch (error) { return fail(400, error.message); } // Has this handle spent its day? Over budget is a refusal here rather than a // downgrade, because there is nothing cheaper to downgrade to — and a clear @@ -113,19 +105,17 @@ let budget = null; try { const { checkBudget } = await import("../../backend/ai-budget.mjs"); budget = await checkBudget(handle); - if (budget?.exhausted) { - return fail( - 429, - `@${handle} has used today's allowance (${budget.used}/${budget.budget} tokens). It resets at midnight UTC.`, - ); - } } catch (error) { console.log("🪙 easel: budget unavailable —", error.message); } + const budgetFailure = inferenceBudgetFailure(budget, handle); + if (budgetFailure) return fail(budgetFailure.statusCode, budgetFailure.message); console.log(`🎨 easel @${handle} — ${MODELS[model].label}${budget ? ` · ${budget.remaining} left` : ""}`); + const controller = new AbortController(); const upstream = await fetch(OPENROUTER, { + signal: controller.signal, method: "POST", headers: { Authorization: `Bearer ${key}`, @@ -138,7 +128,7 @@ "X-Title": "Easel", }, body: JSON.stringify({ model, - max_tokens: Math.min(Number(body.max_tokens) || MAX_TOKENS, MAX_TOKENS), + max_tokens: maxTokens, system: body.system, messages: body.messages, tools: body.tools, @@ -155,57 +145,11 @@ // Pass the SSE through untouched, watching for the usage block on the way so // the meter records real spend. Tapping the stream rather than buffering it // keeps the first token as fast as the provider makes it. - const decoder = new TextDecoder(); - let tail = ""; - let spent = 0; - - const passthrough = new ReadableStream({ - async start(controller) { - const reader = upstream.body.getReader(); - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - controller.enqueue(value); - - tail += decoder.decode(value, { stream: true }); - let cut = tail.indexOf("\n"); - while (cut !== -1) { - const line = tail.slice(0, cut).trim(); - tail = tail.slice(cut + 1); - if (line.startsWith("data: ")) { - try { - const json = JSON.parse(line.slice(6)); - const usage = json?.usage || json?.message?.usage; - if (usage) { - // Charge what it costs, not what it counts. A cached prefix - // reads at about a tenth of the price of fresh input, and - // billing it at par undoes the caching entirely: the guides - // are six thousand tokens re-sent every round, so counting - // them at full rate spends a day's allowance in three - // questions whether or not the provider charged for them. - spent = - (usage.input_tokens || 0) + - (usage.output_tokens || 0) + - Math.round((usage.cache_read_input_tokens || 0) * 0.1) + - // Writing the cache costs slightly more than fresh input, - // once, and then pays for itself. - Math.round((usage.cache_creation_input_tokens || 0) * 1.25); - } - } catch {} - } - cut = tail.indexOf("\n"); - } - } - } finally { - controller.close(); - if (spent) { - // After the answer is delivered, never in front of it. - import("../../backend/ai-budget.mjs") - .then(({ recordUsage }) => recordUsage(handle, spent, { model })) - .catch(() => {}); - } - } + const passthrough = relayInference(upstream.body, { + abort: () => controller.abort(), + onUsage: async (spent) => { + const { recordUsage } = await import("../../backend/ai-budget.mjs"); + await recordUsage(handle, spent, { model }); }, }); @@ -213,7 +157,8 @@ return { statusCode: 200, headers: { "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", "Access-Control-Allow-Origin": "*", }, body: passthrough, diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -987,6 +987,9 @@ // browser's real preference would leave it changed behind it. updateAutoReload = true; } if (resolution.shellhtml === true) preservedParams.shellhtml = "true"; + // The frame-rate cap is the embedding's too: a preview card that reloads + // without it runs at display rate, which is the one thing it was told not to do. + if (resolution.maxfps) preservedParams.maxfps = String(resolution.maxfps); if (resolution.tv === true) preservedParams.tv = "true"; if (resolution.device === true) preservedParams.device = "true"; if (resolution.solo === true) preservedParams.solo = "true"; @@ -14518,6 +14521,15 @@ for (const param of ['daw', 'density', 'nogap', 'width', 'height']) { if (currentParams.has(param)) { dawParams.set(param, currentParams.get(param)); } + } + // The embedding's own flags ride along too — what boot handed over + // as `preservedParams` (nolabel, autoreload, maxfps, …). This is the + // URL the update auto-reload comes back to, so a flag dropped here + // is a flag the next load never sees: a preview card that asked + // to be rid of the corner label got it back on its first reload + // exactly this way, while `nogap`, listed above, survived. + for (const [name, value] of Object.entries(preservedParams || {})) { + if (value && !dawParams.has(name)) dawParams.set(name, value); } const queryString = dawParams.toString(); // Keep caret "bag" URLs literal (^pads): ^ is legal in a URL path per