From fa3f58aafeb1f5c290810fc8984e266c4f123347 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 11 Sep 2026 10:38:33 -0400 Subject: [PATCH] Easel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool has been deciding what it is called since it was born three days ago. `0f8fef6404` said so out loud while naming the terminal palette: "It suffixes the palette, not the tool. The tool is still deciding what it is called and these names travel to other machines." It is Easel. The name was already in use — @jeffrey has called it the easel in conversation all along — and it is the better one. Aesthetic Code described where it came from; Easel describes what it is. You stand a piece on it and work on the piece. Renamed through, including the wire value: `agent_type` is `easel`, the directory is `easel/`, the binary is `bin/easel`, the environment is `EASEL_*`, the config lives in `~/.config/easel`. The Swift side moved with it in the same commit, because a marker saying `easel` and a menubar looking for `aesthetic-code` produce a rock that renders as nothing at all. Both are installed together here; neo follows in the same pass. Two things deliberately keep their names. The `-aesthetic` Terminal profile family, because it was named for the palette and not the tool, on purpose, for exactly this day. And `ac`, because it is what gets typed. One name is given back. The tool claimed `aesthetic` while it was called Aesthetic Code, which displaced the platform helper of that name into `aesthetic-platform`. It has no business holding it now, so the installer drops the symlink if it is still ours and the Fish shim stops erasing the function. The installer also removes the old `# aesthetic-code:start` block, which is inert once its path is gone but sits in the config of every machine that ever installed the old name. And the thing now stands an easel up when it opens. The canvas carries the two facts a session starts with that are worth knowing — the name of the piece and the address it already answers at — with the name written on a character at a time, because that is what an easel is for. Each frame reads the live values rather than a snapshot, so the address appears at whatever moment the sign-in resolves, which is exactly when it starts being true. Any key puts it away; a screen too short to hold it never sees it. 56 tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01A1xZ1BonXHyds6EzF5H9Hb --- CLAUDE.md | 2 +- aesthetic-code/install.sh | 41 ------- aesthetic-code/shell/aesthetic-code.fish | 20 --- {aesthetic-code => easel}/.gitignore | 0 {aesthetic-code => easel}/LICENSE | 0 {aesthetic-code => easel}/README.md | 18 +-- .../bin/aesthetic => easel/bin/easel | 20 +-- .../docs/local-contract.md | 14 +-- .../docs/openrouter-backend.md | 14 +-- easel/install.sh | 55 +++++++++ {aesthetic-code => easel}/package.json | 2 +- easel/shell/easel.fish | 18 +++ {aesthetic-code => easel}/src/ac-session.mjs | 12 +- {aesthetic-code => easel}/src/app-server.mjs | 8 +- {aesthetic-code => easel}/src/autopublish.mjs | 2 +- {aesthetic-code => easel}/src/backends.mjs | 2 +- .../src/claude-server.mjs | 10 +- {aesthetic-code => easel}/src/cli.mjs | 2 +- easel/src/easel.mjs | 116 ++++++++++++++++++ {aesthetic-code => easel}/src/live.mjs | 2 +- {aesthetic-code => easel}/src/mascot.mjs | 0 {aesthetic-code => easel}/src/names.mjs | 0 {aesthetic-code => easel}/src/publish.mjs | 0 {aesthetic-code => easel}/src/qr.mjs | 0 {aesthetic-code => easel}/src/render.mjs | 4 +- {aesthetic-code => easel}/src/runtimes.mjs | 0 .../src/slab-session.mjs | 2 +- {aesthetic-code => easel}/src/tui.mjs | 62 ++++++++-- {aesthetic-code => easel}/src/vendor/qr.mjs | 0 .../test/ac-session.test.mjs | 2 +- .../test/app-server.test.mjs | 0 .../test/autopublish.test.mjs | 0 .../test/claude-server.test.mjs | 0 {aesthetic-code => easel}/test/cli.sh | 46 +++---- easel/test/easel.test.mjs | 41 +++++++ .../test/fake-app-server.mjs | 0 .../test/fake-claude-cli.mjs | 0 {aesthetic-code => easel}/test/live.test.mjs | 2 +- .../test/mascot.test.mjs | 0 .../test/publish.test.mjs | 2 +- .../test/render.test.mjs | 0 .../test/slab-session.test.mjs | 4 +- slab/bin/prox-mcp.mjs | 16 +-- slab/bin/slab-aesthetic-profiles.swift | 2 +- .../Sources/SlabMenubar/AppDelegate.swift | 10 +- .../Sources/SlabMenubar/ClaudeSession.swift | 26 ++-- .../Sources/SlabMenubar/Ledger.swift | 14 +-- .../Sources/SlabMenubar/PromptScanCode.swift | 2 +- .../SlabMenubar/PromptSigilOverlay.swift | 6 +- .../Sources/SlabMenubar/ProxMemoirs.swift | 2 +- .../Sources/SlabMenubar/SigilRenderer.swift | 4 +- 51 files changed, 410 insertions(+), 195 deletions(-) delete mode 100755 aesthetic-code/install.sh delete mode 100644 aesthetic-code/shell/aesthetic-code.fish rename {aesthetic-code => easel}/.gitignore (100%) rename {aesthetic-code => easel}/LICENSE (100%) rename {aesthetic-code => easel}/README.md (93%) rename aesthetic-code/bin/aesthetic => easel/bin/easel (91%) rename {aesthetic-code => easel}/docs/local-contract.md (91%) rename {aesthetic-code => easel}/docs/openrouter-backend.md (97%) create mode 100755 easel/install.sh rename {aesthetic-code => easel}/package.json (83%) create mode 100644 easel/shell/easel.fish rename {aesthetic-code => easel}/src/ac-session.mjs (96%) rename {aesthetic-code => easel}/src/app-server.mjs (97%) rename {aesthetic-code => easel}/src/autopublish.mjs (98%) rename {aesthetic-code => easel}/src/backends.mjs (96%) rename {aesthetic-code => easel}/src/claude-server.mjs (98%) rename {aesthetic-code => easel}/src/cli.mjs (96%) create mode 100644 easel/src/easel.mjs rename {aesthetic-code => easel}/src/live.mjs (99%) rename {aesthetic-code => easel}/src/mascot.mjs (100%) rename {aesthetic-code => easel}/src/names.mjs (100%) rename {aesthetic-code => easel}/src/publish.mjs (100%) rename {aesthetic-code => easel}/src/qr.mjs (100%) rename {aesthetic-code => easel}/src/render.mjs (99%) rename {aesthetic-code => easel}/src/runtimes.mjs (100%) rename {aesthetic-code => easel}/src/slab-session.mjs (99%) rename {aesthetic-code => easel}/src/tui.mjs (93%) rename {aesthetic-code => easel}/src/vendor/qr.mjs (100%) rename {aesthetic-code => easel}/test/ac-session.test.mjs (98%) rename {aesthetic-code => easel}/test/app-server.test.mjs (100%) rename {aesthetic-code => easel}/test/autopublish.test.mjs (100%) rename {aesthetic-code => easel}/test/claude-server.test.mjs (100%) rename {aesthetic-code => easel}/test/cli.sh (63%) create mode 100644 easel/test/easel.test.mjs rename {aesthetic-code => easel}/test/fake-app-server.mjs (100%) rename {aesthetic-code => easel}/test/fake-claude-cli.mjs (100%) rename {aesthetic-code => easel}/test/live.test.mjs (99%) rename {aesthetic-code => easel}/test/mascot.test.mjs (100%) rename {aesthetic-code => easel}/test/publish.test.mjs (98%) rename {aesthetic-code => easel}/test/render.test.mjs (100%) rename {aesthetic-code => easel}/test/slab-session.test.mjs (94%) diff --git a/CLAUDE.md b/CLAUDE.md index 586dd7f4bd..1d5891bcbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ Pieces are URL-addressable: `aesthetic.computer/piece-name`, params `piece-name: - `kidlisp/` - KidLisp docs and tools - `spec/` - Jasmine tests for KidLisp - `ants/` - AestheticAnts automated maintenance -- `aesthetic-code/` - The terminal coding interface (`ac`); `cd aesthetic-code && ./install.sh` +- `easel/` - Easel, the terminal coding interface (`ac`); `cd easel && ./install.sh` ## Notes diff --git a/aesthetic-code/install.sh b/aesthetic-code/install.sh deleted file mode 100755 index bcd7d9c17d..0000000000 --- a/aesthetic-code/install.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BIN_DIR="${AESTHETIC_CODE_BIN_DIR:-${HOME}/.local/bin}" -CONFIG_DIR="${AESTHETIC_CODE_CONFIG_DIR:-${HOME}/.config/aesthetic-code}" -FISH_CONFIG="${XDG_CONFIG_HOME:-${HOME}/.config}/fish/config.fish" -ZSH_PROFILE="${ZDOTDIR:-${HOME}}/.zprofile" - -mkdir -p "$BIN_DIR" "$CONFIG_DIR" -chmod +x "$PROJECT_DIR/bin/aesthetic" -ln -sfn "$PROJECT_DIR/bin/aesthetic" "$BIN_DIR/aesthetic" -ln -sfn "$PROJECT_DIR/bin/aesthetic" "$BIN_DIR/ac" - -if [[ -f "$FISH_CONFIG" ]] && ! grep -Fq '# aesthetic-code:start' "$FISH_CONFIG"; then - { - printf '\n# aesthetic-code:start\n' - printf 'if test -f %q\n' "$PROJECT_DIR/shell/aesthetic-code.fish" - printf ' source %q\n' "$PROJECT_DIR/shell/aesthetic-code.fish" - printf 'end\n' - printf '# aesthetic-code:end\n' - } >> "$FISH_CONFIG" -fi - -if [[ -f "$ZSH_PROFILE" ]] && ! grep -Fq '# aesthetic-code:path:start' "$ZSH_PROFILE"; then - { - printf '\n# aesthetic-code:path:start\n' - printf 'export PATH="$HOME/.local/bin:$PATH"\n' - printf '# aesthetic-code:path:end\n' - } >> "$ZSH_PROFILE" -fi - -case ":${PATH}:" in - *":${BIN_DIR}:"*) ;; - *) printf 'Add %s to PATH to use the commands.\n' "$BIN_DIR" >&2 ;; -esac - -printf 'Installed Aesthetic Code:\n' -printf ' %s\n' "$BIN_DIR/ac" -printf ' %s\n' "$BIN_DIR/aesthetic" diff --git a/aesthetic-code/shell/aesthetic-code.fish b/aesthetic-code/shell/aesthetic-code.fish deleted file mode 100644 index ffeb2eeced..0000000000 --- a/aesthetic-code/shell/aesthetic-code.fish +++ /dev/null @@ -1,20 +0,0 @@ -# Aesthetic Code command integration for Fish. -# Preserve older Aesthetic Computer helpers under explicit names before the -# product claims `ac` and `aesthetic`. -if functions -q ac; and not functions -q ac-repo - functions --copy ac ac-repo -end - -if functions -q aesthetic; and not functions -q aesthetic-platform - functions --copy aesthetic aesthetic-platform -end - -functions --erase ac aesthetic - -function ac --description 'Open Aesthetic Code' - command $HOME/.local/bin/aesthetic $argv -end - -function aesthetic --description 'Open Aesthetic Code' - command $HOME/.local/bin/aesthetic $argv -end diff --git a/aesthetic-code/.gitignore b/easel/.gitignore similarity index 100% rename from aesthetic-code/.gitignore rename to easel/.gitignore diff --git a/aesthetic-code/LICENSE b/easel/LICENSE similarity index 100% rename from aesthetic-code/LICENSE rename to easel/LICENSE diff --git a/aesthetic-code/README.md b/easel/README.md similarity index 93% rename from aesthetic-code/README.md rename to easel/README.md index d76249595a..85cd6ecc57 100644 --- a/aesthetic-code/README.md +++ b/easel/README.md @@ -1,10 +1,10 @@ -# Aesthetic Code +# Easel -A coding interface for the terminal. Lives at `aesthetic-code/` in the +A coding interface for the terminal. Lives at `easel/` in the Aesthetic Computer repository. ```sh -cd aesthetic-code && ./install.sh +cd easel && ./install.sh ac ``` @@ -21,7 +21,7 @@ engine bridge: a subprocess on stdio that the interface drives. A native local-inference engine can replace the bridge without changing the interface. Each live TUI publishes its own Slab marker, so the menubar and prox ledger can -name, focus, wake, close, and track it as `aesthetic-code`, including which +name, focus, wake, close, and track it as `easel`, including which @handle it acts as. The marker also carries the piece and its address, which the menubar draws as a scannable code on the session's prompt rock. @@ -59,7 +59,7 @@ model are running. The Claude bridge runs `claude --print --input-format stream-json --output-format stream-json`, the same headless protocol the Claude Agent SDK -speaks, driven directly over a pipe. That is why Aesthetic Code still has no +speaks, driven directly over a pipe. That is why Easel still has no dependencies: a subprocess on stdio is the same shape as `codex app-server --stdio`, and it carries streaming, tool calls and approvals without a package tree behind it. Each bridge signs in with the vendor CLI's own credentials @@ -79,7 +79,7 @@ written down in [`docs/local-contract.md`](docs/local-contract.md). ## The session's piece, live on a phone -Opening Aesthetic Code opens a new blank piece. It gets a random pronounceable +Opening Easel opens a new blank piece. It gets a random pronounceable name, it is a real file in the workspace, and a QR code for it sits in the bottom right of the interface. Scan the code and the piece runs on your phone; every edit the agent makes reaches it a moment later. @@ -122,7 +122,7 @@ comment and declares `setup` or `draw` is taken as Lua at all. ## Account and publishing -Aesthetic Code reads the shared Aesthetic Computer sign-in at `~/.ac-token`, +Easel reads the shared Aesthetic Computer sign-in at `~/.ac-token`, the same file `ac-login` and the AC desktop apps use. `/login` runs the Authorization-Code + PKCE flow in your browser with a loopback callback and writes that file; a sign-in or sign-out anywhere in the suite updates the @@ -142,7 +142,7 @@ camera at. It coalesces — a publish runs once the saves stop, never more than one at a time, and never twice for the same bytes — and it flushes the pending save on exit. Off by default, since it writes to a public route under your own handle: turn it on per session with the flag or -`/autopublish`, or for every session with `AESTHETIC_CODE_AUTOPUBLISH=1`, which +`/autopublish`, or for every session with `EASEL_AUTOPUBLISH=1`, which `--no-autopublish` overrides. With it on the engine is told the piece is already live and told not to ask you to publish. @@ -160,7 +160,7 @@ aesthetic doctor npm test ``` -Aesthetic Code is proprietary. See `LICENSE`. +Easel is proprietary. See `LICENSE`. On Fish installations with existing `ac` or `aesthetic` functions, the installer preserves them as `ac-repo` and `aesthetic-platform`. diff --git a/aesthetic-code/bin/aesthetic b/easel/bin/easel similarity index 91% rename from aesthetic-code/bin/aesthetic rename to easel/bin/easel index cdf6f9f6dd..0198215fd2 100755 --- a/aesthetic-code/bin/aesthetic +++ b/easel/bin/easel @@ -3,7 +3,7 @@ set -euo pipefail VERSION="0.4.0" -DEFAULT_BACKEND="${AESTHETIC_CODE_BACKEND:-claude}" +DEFAULT_BACKEND="${EASEL_BACKEND:-claude}" DEFAULT_CLAUDE_MODEL="claude-opus-5" script_path="${BASH_SOURCE[0]}" while [[ -L "$script_path" ]]; do @@ -27,7 +27,7 @@ Usage: ac [directory] [--runtime mjs|lisp|processing] aesthetic login | logout | whoami aesthetic publish [slug] -Open the Aesthetic Code terminal interface in a workspace, or manage the +Open the Easel terminal interface in a workspace, or manage the shared Aesthetic Computer sign-in (~/.ac-token) and publish a piece under your @handle at https://aesthetic.computer/@handle/slug. @@ -40,7 +40,7 @@ claude-opus-5) or codex. --model names the model on that bridge, and --autopublish publishes the piece under your @handle a couple of seconds after every save, so the session's URL is live the whole time it is being -worked on. Off by default; AESTHETIC_CODE_AUTOPUBLISH=1 turns it on for +worked on. Off by default; EASEL_AUTOPUBLISH=1 turns it on for every session, --no-autopublish overrides that, and /autopublish toggles it mid-session. EOF @@ -56,7 +56,7 @@ has_command() { } doctor() { - printf 'Aesthetic Code %s\n' "$VERSION" + printf 'Easel %s\n' "$VERSION" printf 'interface: %s\n' "$PROJECT_DIR/src/tui.mjs" printf 'control plane: local\n' printf 'telemetry: off\n' @@ -87,7 +87,7 @@ case "${1:-}" in exit 0 ;; --version|-V|version) - printf 'Aesthetic Code %s\n' "$VERSION" + printf 'Easel %s\n' "$VERSION" exit 0 ;; doctor) @@ -108,7 +108,7 @@ runtime="" backend="" model="" # Empty means "whatever the environment says"; the interface reads -# AESTHETIC_CODE_AUTOPUBLISH itself, and these two flags override it. +# EASEL_AUTOPUBLISH itself, and these two flags override it. autopublish="" while [[ $# -gt 0 ]]; do case "$1" in @@ -189,13 +189,13 @@ esac has_command "$selected_backend" || fail "the $selected_backend engine bridge is not installed" -if [[ "${AESTHETIC_CODE_DRY_RUN:-0}" == "1" ]]; then - printf 'interface=aesthetic-code\n' +if [[ "${EASEL_DRY_RUN:-0}" == "1" ]]; then + printf 'interface=easel\n' printf 'directory=%s\n' "$target_directory" printf 'resume=%s\n' "$([[ -n "$resume_thread" ]] && printf yes || printf no)" printf 'initial_prompt=%s\n' "$([[ -n "$initial_prompt" ]] && printf yes || printf no)" printf 'runtime=%s\n' "${runtime:-mjs}" - printf 'autopublish=%s\n' "${autopublish:-$([[ "${AESTHETIC_CODE_AUTOPUBLISH:-0}" =~ ^(1|on|true|yes)$ ]] && printf on || printf off)}" + printf 'autopublish=%s\n' "${autopublish:-$([[ "${EASEL_AUTOPUBLISH:-0}" =~ ^(1|on|true|yes)$ ]] && printf on || printf off)}" printf 'backend=%s\n' "$selected_backend" printf 'model=%s\n' "${model:-$([[ "$selected_backend" == claude ]] && printf '%s' "$DEFAULT_CLAUDE_MODEL")}" exit 0 @@ -204,7 +204,7 @@ fi [[ -t 0 && -t 1 ]] || fail "an interactive terminal is required" export AESTHETIC_CODE=1 -export AESTHETIC_CODE_VERSION="$VERSION" +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 diff --git a/aesthetic-code/docs/local-contract.md b/easel/docs/local-contract.md similarity index 91% rename from aesthetic-code/docs/local-contract.md rename to easel/docs/local-contract.md index 5da2479d68..fd5de31e41 100644 --- a/aesthetic-code/docs/local-contract.md +++ b/easel/docs/local-contract.md @@ -1,6 +1,6 @@ # Local contract -Aesthetic Code requires no Aesthetic Code server. +Easel requires no Easel server. - No runtime account, telemetry, analytics, cloud sync, or hosted control plane. - Configuration, session records, memory, and credentials remain on machines @@ -21,7 +21,7 @@ and the session token to `aesthetic.computer` only when the user runs `/publish`. The engine bridge never receives the token; publishing is an interface action, not an agent tool. -Auto-publish (`--autopublish`, `AESTHETIC_CODE_AUTOPUBLISH=1`, `/autopublish`) +Auto-publish (`--autopublish`, `EASEL_AUTOPUBLISH=1`, `/autopublish`) is the one way that becomes repeated rather than per-command: with it on, the interface publishes the session's piece a couple of seconds after every save, and the last save is flushed on the way out. It is off by default and has to be @@ -42,13 +42,13 @@ workspace paths, no conversation. The channel token is random per session and is never reused. Pushing is the interface's own action, on a file the user can see, and stops -when the session ends. It is the one thing Aesthetic Code sends without being +when the session ends. It is the one thing Easel sends without being asked each time, so it is worth stating plainly: while the interface is open, the piece on screen is repeatedly leaving the machine. ## Inference boundary -The terminal interface is always Aesthetic Code. Engines are internal bridges, +The terminal interface is always Easel. Engines are internal bridges, not alternate client interfaces or command shortcuts. Two bridges exist, both remote: Claude Code in headless stream-json mode (the @@ -56,7 +56,7 @@ default, on `claude-opus-5`) and Codex app-server. `--backend` and `/backend` choose between them and `--model` and `/model` name the model. The interface labels remote inference before a prompt is sent. Provider terms govern that traffic, and each bridge signs in with its own vendor's existing -credentials on this machine; Aesthetic Code stores no key of its own. +credentials on this machine; Easel stores no key of its own. Neither bridge inherits the user's own agent configuration. Codex is started with `on-request` approvals and a `workspace-write` sandbox regardless of what @@ -89,7 +89,7 @@ Codex runs commands under an operating-system sandbox: writes are confined to the workspace and `networkAccess` is false, so an approved command still cannot reach the network without a second, explicit escalation. -Claude Code has no equivalent sandbox. On that bridge Aesthetic Code confines +Claude Code has no equivalent sandbox. On that bridge Easel confines the file tools to the workspace, removes WebFetch and WebSearch, and routes every prompt to this terminal — and Claude does prompt before a command that touches the network — but the prompt is the whole boundary. A shell command the @@ -97,7 +97,7 @@ user approves runs with the user's own privileges and can reach the network. Read-only commands are auto-approved by Claude's own classifier, as reads inside the sandbox are on the Codex bridge. -Aesthetic Code therefore does not claim that agent tools are network-isolated +Easel therefore does not claim that agent tools are network-isolated on the Claude bridge. Where that matters, `--backend codex` is the bridge with a kernel behind its approvals. diff --git a/aesthetic-code/docs/openrouter-backend.md b/easel/docs/openrouter-backend.md similarity index 97% rename from aesthetic-code/docs/openrouter-backend.md rename to easel/docs/openrouter-backend.md index 803f5716d9..1eb6d9ae7a 100644 --- a/aesthetic-code/docs/openrouter-backend.md +++ b/easel/docs/openrouter-backend.md @@ -3,7 +3,7 @@ Written 2026-09-11. Prices quoted are from `https://openrouter.ai/api/v1/models`, pulled that morning. Nothing here is implemented. -The goal is to stop requiring every person who opens Aesthetic Code to already +The goal is to stop requiring every person who opens Easel to already hold a Claude or Codex subscription. Today both bridges sign in with a vendor CLI's own credentials, which is elegant — no key, no server, no account — and also a wall: the interface is unusable to anyone who has not already paid @@ -62,7 +62,7 @@ Worth noting, because it changes how bad this option feels: OpenRouter has an OAuth PKCE flow at `https://openrouter.ai/auth` that exchanges a code at `POST /api/v1/auth/keys` for a user-scoped key billed to that user's credits, and it documents a headless mode with no `callback_url` where the user copies -the code across. Aesthetic Code already runs an Authorization-Code + PKCE flow +the code across. Easel already runs an Authorization-Code + PKCE flow with a loopback callback in `src/ac-session.mjs`; a second one against a different issuer is the same ninety lines. So "bring your own key" can be `/login`-shaped rather than paste-shaped. @@ -120,7 +120,7 @@ The trust boundary in the recommended option is worth stating flatly: Aesthetic Computer never sees the prompts, but it does mint a bearer token that spends its money, and a user who extracts that token from their own machine — trivially, it is a file they own — can spend the cap however they like, including on -something that is not Aesthetic Code. The cap is therefore the entire control. +something that is not Easel. The cap is therefore the entire control. It should be small, it should reset, and it should be revocable per handle. ## Whether this needs a second kind of engine @@ -251,7 +251,7 @@ product, and writes the answer back to Redis. The invalidation half is in `ticket.js`, which on `customer.subscription.updated` reads `customer.metadata.sub` and deletes the cache entry. That pair — a Redis-cached entitlement keyed on the Auth0 sub, invalidated by a webhook — is precisely what -a paid Aesthetic Code tier needs, and it can be ported by changing the product +a paid Easel tier needs, and it can be ported by changing the product id. `system/netlify/functions/news-toll.mjs` is the cleanest single-file reference @@ -318,7 +318,7 @@ of magnitude. These are @jeffrey's own sessions in the full Aesthetic Computer tree, on a million-token context, and they are an upper bound rather than a typical -Aesthetic Code session — the interface opens on one small piece file in a +Easel session — the interface opens on one small piece file in a workspace the user chose. So, bottom-up: thirty turns, context growing from about 15k tokens to about 60k, averaging 35k. That is roughly 1.05M cache-read tokens, 300k cache-write, 20k output. Priced across the shelf at today's @@ -390,7 +390,7 @@ may be issued or re-issued, plus a short `expires_at` so an abandoned key stops mattering — and it is a partial one. Worth testing before promising a tier. Runaway spend inside a single session is the most likely everyday failure, and -it is not malice. Aesthetic Code auto-approves tool calls by default: +it is not malice. Easel auto-approves tool calls by default: `handleRequest` in `src/tui.mjs` answers `accept` unless `/ask on` is set, because the first real session spent two of its two hours and nineteen minutes parked on prompts with nobody watching. That default is right for a piece @@ -452,7 +452,7 @@ from unauthenticated route probes, not from a working call. cannot answer. The free tier's viability is a quality question, not a price one. - **The realistic monthly-active handle count.** 2,814 handles exist. How many - would open Aesthetic Code in a month is a guess, and the $300 figure moves + would open Easel in a month is a guess, and the $300 figure moves linearly with it. ## What this changes in the contract diff --git a/easel/install.sh b/easel/install.sh new file mode 100755 index 0000000000..71f8889c54 --- /dev/null +++ b/easel/install.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN_DIR="${EASEL_BIN_DIR:-${HOME}/.local/bin}" +CONFIG_DIR="${EASEL_CONFIG_DIR:-${HOME}/.config/easel}" +FISH_CONFIG="${XDG_CONFIG_HOME:-${HOME}/.config}/fish/config.fish" +ZSH_PROFILE="${ZDOTDIR:-${HOME}}/.zprofile" + +mkdir -p "$BIN_DIR" "$CONFIG_DIR" +chmod +x "$PROJECT_DIR/bin/easel" +ln -sfn "$PROJECT_DIR/bin/easel" "$BIN_DIR/easel" +ln -sfn "$PROJECT_DIR/bin/easel" "$BIN_DIR/ac" + +# The tool was called `aesthetic` before it was called Easel, and that name +# belongs to the platform helper it displaced. Take it back only if it is still +# our own symlink — never touch a real file someone else put there. +if [[ -L "$BIN_DIR/aesthetic" ]] && [[ "$(readlink "$BIN_DIR/aesthetic")" == *"/bin/aesthetic" || "$(readlink "$BIN_DIR/aesthetic")" == *"/bin/easel" ]]; then + rm -f "$BIN_DIR/aesthetic" +fi + +# The tool shipped as Aesthetic Code first, and its Fish block named that path. +# The block is inert once the path is gone — `test -f` simply fails — but it +# lingers on every machine that ever installed the old name, so take it out. +if [[ -f "$FISH_CONFIG" ]] && grep -Fq '# aesthetic-code:start' "$FISH_CONFIG"; then + /usr/bin/sed -i '' '/# aesthetic-code:start/,/# aesthetic-code:end/d' "$FISH_CONFIG" +fi + +if [[ -f "$FISH_CONFIG" ]] && ! grep -Fq '# easel:start' "$FISH_CONFIG"; then + { + printf '\n# easel:start\n' + printf 'if test -f %q\n' "$PROJECT_DIR/shell/easel.fish" + printf ' source %q\n' "$PROJECT_DIR/shell/easel.fish" + printf 'end\n' + printf '# easel:end\n' + } >> "$FISH_CONFIG" +fi + +if [[ -f "$ZSH_PROFILE" ]] && ! grep -Fq '# easel:path:start' "$ZSH_PROFILE"; then + { + printf '\n# easel:path:start\n' + printf 'export PATH="$HOME/.local/bin:$PATH"\n' + printf '# easel:path:end\n' + } >> "$ZSH_PROFILE" +fi + +case ":${PATH}:" in + *":${BIN_DIR}:"*) ;; + *) printf 'Add %s to PATH to use the commands.\n' "$BIN_DIR" >&2 ;; +esac + +printf 'Installed Easel:\n' +printf ' %s\n' "$BIN_DIR/ac" +printf ' %s\n' "$BIN_DIR/easel" diff --git a/aesthetic-code/package.json b/easel/package.json similarity index 83% rename from aesthetic-code/package.json rename to easel/package.json index 24274b0ffe..2e890b9143 100644 --- a/aesthetic-code/package.json +++ b/easel/package.json @@ -1,5 +1,5 @@ { - "name": "aesthetic-code", + "name": "easel", "version": "0.4.0", "private": true, "type": "module", diff --git a/easel/shell/easel.fish b/easel/shell/easel.fish new file mode 100644 index 0000000000..bbdcb1b0cc --- /dev/null +++ b/easel/shell/easel.fish @@ -0,0 +1,18 @@ +# Easel command integration for Fish. +# +# Easel claims `ac` and `easel`. It deliberately does not claim `aesthetic` +# any more: that name belongs to the Aesthetic Computer platform helper, and +# the tool only held it while it was still called Aesthetic Code. +if functions -q ac; and not functions -q ac-repo + functions --copy ac ac-repo +end + +functions --erase ac easel + +function ac --description 'Open Easel' + command $HOME/.local/bin/easel $argv +end + +function easel --description 'Open Easel' + command $HOME/.local/bin/easel $argv +end diff --git a/aesthetic-code/src/ac-session.mjs b/easel/src/ac-session.mjs similarity index 96% rename from aesthetic-code/src/ac-session.mjs rename to easel/src/ac-session.mjs index 5feb25b90e..8928d4d58e 100644 --- a/aesthetic-code/src/ac-session.mjs +++ b/easel/src/ac-session.mjs @@ -3,7 +3,7 @@ // Every AC desktop app reads one file, ~/.ac-token, minted by `ac-login` with // Auth0 Authorization-Code + PKCE and a loopback callback. This module reads // and watches that file, refreshes the access token, and can run the same -// sign-in flow itself so Aesthetic Code needs no other checkout. Only the +// sign-in flow itself so Easel needs no other checkout. Only the // handle is ever displayed; email and name stay in the file. import { EventEmitter } from "node:events"; import { createHash, randomBytes } from "node:crypto"; @@ -20,17 +20,17 @@ export const SITE = "https://aesthetic.computer"; // Cloudflare fronts aesthetic.computer and rejects a bare user agent (1010). export const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/125.0 Safari/537.36 aesthetic-code"; + "(KHTML, like Gecko) Chrome/125.0 Safari/537.36 easel"; const base64url = (buffer) => buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); const LANDING = ` -Signed in · Aesthetic Code +Signed in · Easel -

Signed in

Return to Aesthetic Code. You can close this tab.

`; +

Signed in

Return to Easel. You can close this tab.

`; export function openInBrowser(url) { const command = @@ -210,13 +210,13 @@ export class ACSession extends EventEmitter { const failure = url.searchParams.get("error"); if (failure) { response.writeHead(400, { "content-type": "text/plain" }); - response.end("Sign-in failed. Return to Aesthetic Code."); + response.end("Sign-in failed. Return to Easel."); settle(reject, new Error(url.searchParams.get("error_description") || failure)); return; } if (url.searchParams.get("state") !== state) { response.writeHead(400, { "content-type": "text/plain" }); - response.end("State mismatch. Return to Aesthetic Code and retry."); + response.end("State mismatch. Return to Easel and retry."); settle(reject, new Error("sign-in state mismatch — retry /login")); return; } diff --git a/aesthetic-code/src/app-server.mjs b/easel/src/app-server.mjs similarity index 97% rename from aesthetic-code/src/app-server.mjs rename to easel/src/app-server.mjs index 6451144fc4..0cbfff4abd 100644 --- a/aesthetic-code/src/app-server.mjs +++ b/easel/src/app-server.mjs @@ -38,8 +38,8 @@ export class AppServer extends EventEmitter { env: { ...process.env, ...this.environment, - AESTHETIC_CODE: "1", - AESTHETIC_CODE_VERSION: VERSION, + EASEL: "1", + EASEL_VERSION: VERSION, }, stdio: ["pipe", "pipe", "pipe"], }); @@ -66,8 +66,8 @@ export class AppServer extends EventEmitter { await this.request("initialize", { clientInfo: { - name: "aesthetic_code", - title: "Aesthetic Code", + name: "easel", + title: "Easel", version: VERSION, }, capabilities: { experimentalApi: true }, diff --git a/aesthetic-code/src/autopublish.mjs b/easel/src/autopublish.mjs similarity index 98% rename from aesthetic-code/src/autopublish.mjs rename to easel/src/autopublish.mjs index 66c7e168ce..7cbec370cf 100644 --- a/aesthetic-code/src/autopublish.mjs +++ b/easel/src/autopublish.mjs @@ -9,7 +9,7 @@ // // It is off unless asked for. Publishing is outward-facing — the bytes land on // a public route under the user's own name — so it stays an opt-in for the -// session (`/autopublish on`, `--autopublish`, AESTHETIC_CODE_AUTOPUBLISH=1) +// session (`/autopublish on`, `--autopublish`, EASEL_AUTOPUBLISH=1) // rather than something the interface starts doing on its own. // // Saves arrive far faster than a publish should: an agent turn can write a file diff --git a/aesthetic-code/src/backends.mjs b/easel/src/backends.mjs similarity index 96% rename from aesthetic-code/src/backends.mjs rename to easel/src/backends.mjs index 97bcc259a5..e6b3d02aee 100644 --- a/aesthetic-code/src/backends.mjs +++ b/easel/src/backends.mjs @@ -1,4 +1,4 @@ -// backends.mjs — the engine bridges Aesthetic Code can drive. +// backends.mjs — the engine bridges Easel can drive. // // A bridge is a subprocess speaking a line protocol over stdio. The interface // holds the same conversation over either of them — a thread, turns inside it, diff --git a/aesthetic-code/src/claude-server.mjs b/easel/src/claude-server.mjs similarity index 98% rename from aesthetic-code/src/claude-server.mjs rename to easel/src/claude-server.mjs index 52aa4cff5e..5a5f5b2504 100644 --- a/aesthetic-code/src/claude-server.mjs +++ b/easel/src/claude-server.mjs @@ -163,8 +163,8 @@ export class ClaudeServer extends EventEmitter { behavior: "deny", message: decision === "cancel" - ? "Cancelled in Aesthetic Code." - : "Denied in Aesthetic Code.", + ? "Cancelled in Easel." + : "Denied in Easel.", }; } this.#send({ @@ -269,8 +269,8 @@ export class ClaudeServer extends EventEmitter { env: { ...process.env, ...this.environment, - AESTHETIC_CODE: "1", - AESTHETIC_CODE_VERSION: VERSION, + EASEL: "1", + EASEL_VERSION: VERSION, }, stdio: ["pipe", "pipe", "pipe"], }); @@ -468,7 +468,7 @@ export class ClaudeServer extends EventEmitter { response: { subtype: "error", request_id: message.request_id, - error: `Aesthetic Code does not support ${request.subtype} yet`, + error: `Easel does not support ${request.subtype} yet`, }, }); return; diff --git a/aesthetic-code/src/cli.mjs b/easel/src/cli.mjs similarity index 96% rename from aesthetic-code/src/cli.mjs rename to easel/src/cli.mjs index 100081c762..606bf0a394 100644 --- a/aesthetic-code/src/cli.mjs +++ b/easel/src/cli.mjs @@ -28,7 +28,7 @@ try { } else if (command === "publish") { const [file, slug = ""] = rest.filter((argument) => !argument.startsWith("--")); if (!file) fail("usage: aesthetic publish [slug]"); - if (process.env.AESTHETIC_CODE_DRY_RUN === "1") { + if (process.env.EASEL_DRY_RUN === "1") { const plan = planPublish({ file, slug, handle: session.handle || "handle" }); out(`would publish ${plan.path} as ${plan.route}`); } else { diff --git a/easel/src/easel.mjs b/easel/src/easel.mjs new file mode 100644 index 0000000000..cdd98a91bc --- /dev/null +++ b/easel/src/easel.mjs @@ -0,0 +1,116 @@ +// The easel. +// +// The tool is named after the thing a painter stands a canvas on, so a session +// opens by standing one up. What goes on the canvas is the only two facts a +// session starts with that are worth knowing: the name of the piece, and the +// address where it can already be reached. Both are true from the first second +// now — the blank publishes as soon as the handle resolves — so the splash is +// reporting rather than promising. +// +// The name is written on rather than printed, one character at a time, because +// that is what an easel is for. The address follows once the name is finished. +// Nothing else moves: the frame and the legs are standing from the first frame, +// since an easel that assembles itself is a cartoon and this one has to sit +// above a transcript someone is about to read. +// +// Pure, like the mascot. A caller asks what the easel looks like at a given +// number of milliseconds and gets lines back. Nothing schedules, nothing draws, +// nothing reads a clock. + +// Milliseconds per character of the piece name, and the pause before the +// address starts. Slow enough to read as handwriting, short enough that the +// whole thing is over before anyone has finished their first prompt. +const WRITE_MS = 55; +const PAUSE_MS = 260; +const ADDRESS_MS = 16; +// How long the finished easel stands before the caller may stop asking. +const HOLD_MS = 900; + +const MIN_INNER = 13; +const MAX_INNER = 46; + +function innerWidth(piece, address) { + const longest = Math.max(piece.length, address.length); + return Math.min(MAX_INNER, Math.max(MIN_INNER, longest + 2)); +} + +function centre(text, width) { + if (text.length >= width) return text.slice(0, width); + const left = Math.floor((width - text.length) / 2); + return " ".repeat(left) + text + " ".repeat(width - text.length - left); +} + +// The whole reveal, in milliseconds: name written, pause, address written, hold. +export function easelDuration(piece = "", address = "") { + return ( + String(piece).length * WRITE_MS + + PAUSE_MS + + String(address).length * ADDRESS_MS + + HOLD_MS + ); +} + +// How many characters of each line are showing at `elapsed`. +function revealed(elapsed, piece, address) { + const ms = Math.max(0, Number(elapsed) || 0); + const written = Math.min(piece.length, Math.floor(ms / WRITE_MS)); + if (written < piece.length) return { piece: written, address: 0 }; + const after = ms - piece.length * WRITE_MS - PAUSE_MS; + if (after <= 0) return { piece: piece.length, address: 0 }; + return { + piece: piece.length, + address: Math.min(address.length, Math.floor(after / ADDRESS_MS)), + }; +} + +// One frame. `piece` is the slug as it appears on disk; `address` is the bare +// host+path the rock is showing. Either may be empty — a session that is not +// signed in has no address yet, and the canvas simply stays blank there rather +// than apologising for it. +export function easelFrame(elapsed = 0, { piece = "", address = "" } = {}) { + const name = String(piece || ""); + const where = String(address || ""); + const inner = innerWidth(name, where); + const shown = revealed(elapsed, name, where); + + // A cursor while the name is still being written, so the pause between the + // name landing and the address starting does not read as a hang. + const writing = shown.piece < name.length; + const nameText = name.slice(0, shown.piece) + (writing ? "▌" : ""); + + const top = "┌" + "─".repeat(inner) + "┐"; + const blank = "│" + " ".repeat(inner) + "│"; + const nameRow = "│" + centre(nameText, inner) + "│"; + const addressRow = "│" + centre(where.slice(0, shown.address), inner) + "│"; + + // The bottom edge carries the mast the legs hang from, so the easel reads as + // one object rather than a box resting on two sticks. + const mastAt = Math.floor(inner / 2); + const bottom = + "└" + "─".repeat(mastAt) + "┬" + "─".repeat(inner - mastAt - 1) + "┘"; + + const pad = " ".repeat(mastAt); + return [ + top, + blank, + nameRow, + blank, + addressRow, + bottom, + pad + "╱ ╲", + pad.slice(0, Math.max(0, mastAt - 1)) + "╱ ╲", + ]; +} + +// When the easel next needs repainting, or null once it is finished standing +// there and the caller can stop asking. +export function easelNextFrame(elapsed = 0, { piece = "", address = "" } = {}) { + const ms = Math.max(0, Number(elapsed) || 0); + if (ms >= easelDuration(piece, address)) return null; + const shown = revealed(ms, String(piece), String(address)); + if (shown.piece < String(piece).length) return WRITE_MS; + if (shown.address < String(address).length) return ADDRESS_MS; + return HOLD_MS; +} + +export const EASEL_HEIGHT = 8; diff --git a/aesthetic-code/src/live.mjs b/easel/src/live.mjs similarity index 99% rename from aesthetic-code/src/live.mjs rename to easel/src/live.mjs index 286ac2e150..c0b0c84e68 100644 --- a/aesthetic-code/src/live.mjs +++ b/easel/src/live.mjs @@ -1,6 +1,6 @@ // live.mjs — the session's piece, and the channel that carries it to a phone. // -// Every Aesthetic Code session opens on a new blank piece with a random name. +// Every Easel session opens on a new blank piece with a random name. // The piece is a real file in the workspace, so the agent edits it like any // other file, and every save is pushed to Aesthetic Computer's `/run` endpoint // on a private code channel. Anything watching that channel — a phone that diff --git a/aesthetic-code/src/mascot.mjs b/easel/src/mascot.mjs similarity index 100% rename from aesthetic-code/src/mascot.mjs rename to easel/src/mascot.mjs diff --git a/aesthetic-code/src/names.mjs b/easel/src/names.mjs similarity index 100% rename from aesthetic-code/src/names.mjs rename to easel/src/names.mjs diff --git a/aesthetic-code/src/publish.mjs b/easel/src/publish.mjs similarity index 100% rename from aesthetic-code/src/publish.mjs rename to easel/src/publish.mjs diff --git a/aesthetic-code/src/qr.mjs b/easel/src/qr.mjs similarity index 100% rename from aesthetic-code/src/qr.mjs rename to easel/src/qr.mjs diff --git a/aesthetic-code/src/render.mjs b/easel/src/render.mjs similarity index 99% rename from aesthetic-code/src/render.mjs rename to easel/src/render.mjs index 490a0aa334..0a5d6c712f 100644 --- a/aesthetic-code/src/render.mjs +++ b/easel/src/render.mjs @@ -1,4 +1,4 @@ -// render.mjs — one frame of the Aesthetic Code interface. +// render.mjs — one frame of the Easel interface. // // The palette is the Aesthetic Computer prompt's dark scheme (disks/prompt.mjs // `scheme.dark`): purple ground, pink prompt block, orange highlight, magenta @@ -59,7 +59,7 @@ const slabState = join( process.env.SLAB_HOME || join(homedir(), ".local", "share", "slab"), "state", ); -const groundMode = (process.env.AESTHETIC_CODE_GROUND || "").toLowerCase(); +const groundMode = (process.env.EASEL_GROUND || "").toLowerCase(); const slabManagesWindow = process.env.TERM_PROGRAM === "Apple_Terminal" && existsSync(slabState); export const paintsGround = diff --git a/aesthetic-code/src/runtimes.mjs b/easel/src/runtimes.mjs similarity index 100% rename from aesthetic-code/src/runtimes.mjs rename to easel/src/runtimes.mjs diff --git a/aesthetic-code/src/slab-session.mjs b/easel/src/slab-session.mjs similarity index 99% rename from aesthetic-code/src/slab-session.mjs rename to easel/src/slab-session.mjs index 420a795c2b..4a3558a39c 100644 --- a/aesthetic-code/src/slab-session.mjs +++ b/easel/src/slab-session.mjs @@ -57,7 +57,7 @@ export class SlabSession { summary: "aesthetic code", tty, agent_pid: pid, - agent_type: "aesthetic-code", + agent_type: "easel", handle: "", // The piece this session is writing, and the address a phone reaches it // at. The menubar draws these as a scannable code on the rock, which is diff --git a/aesthetic-code/src/tui.mjs b/easel/src/tui.mjs similarity index 93% rename from aesthetic-code/src/tui.mjs rename to easel/src/tui.mjs index f2979d3c54..5eefbb4f42 100755 --- a/aesthetic-code/src/tui.mjs +++ b/easel/src/tui.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import process from "node:process"; import { ACSession } from "./ac-session.mjs"; import { AutoPublisher } from "./autopublish.mjs"; +import { EASEL_HEIGHT, easelFrame, easelNextFrame } from "./easel.mjs"; import { backendFor, backendMenu, DEFAULT_BACKEND } from "./backends.mjs"; import { LivePiece } from "./live.mjs"; import { publishPiece } from "./publish.mjs"; @@ -25,7 +26,7 @@ const resumeThreadId = option("--resume"); const initialPrompt = option("--prompt"); // 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.AESTHETIC_CODE_BACKEND || DEFAULT_BACKEND); +let backend = backendFor(option("--backend") || process.env.EASEL_BACKEND || DEFAULT_BACKEND); let model = option("--model") || backend.defaultModel; const session = new ACSession(); @@ -93,11 +94,11 @@ const state = { const autopublish = new AutoPublisher({ // On by default. The scanned address is the published one, so a session // that does not publish has nothing to point a camera at; `--no-autopublish` - // and `AESTHETIC_CODE_AUTOPUBLISH=0` both opt out, and a signed-out session + // and `EASEL_AUTOPUBLISH=0` both opt out, and a signed-out session // never reaches the attempt. enabled: !flag("--no-autopublish") && - !/^(0|off|false|no)$/i.test(process.env.AESTHETIC_CODE_AUTOPUBLISH || ""), + !/^(0|off|false|no)$/i.test(process.env.EASEL_AUTOPUBLISH || ""), publish: () => publishPiece({ file: live.file, slug: live.slug, session, cwd }), }); @@ -139,11 +140,11 @@ function developerInstructions() { ] : [ "Publishing: writing a file under system/public/aesthetic.computer/disks/ or anywhere else does NOT make a piece live.", - "A piece is live only after the user runs the Aesthetic Code command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", + "A piece is live only after the user runs the Easel command `/publish [slug]`, which uploads it under their @handle at https://aesthetic.computer/@handle/slug.", "When you finish a piece, end with the exact /publish command for the user to run. Never tell the user to visit a route that has not been published.", ]; return [ - "You are running inside Aesthetic Code, a terminal interface for Aesthetic Computer (AC) work.", + "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. Edit that file unless the user asks for something else.`, ...dialect, @@ -168,7 +169,7 @@ function openEngine({ resume = "" } = {}) { environment: { SLAB_PROMPT_SESSION_ID: slabSession.sessionId, SLAB_TERMINAL_TTY: slabSession.tty, - SLAB_AGENT_TYPE: "aesthetic-code", + SLAB_AGENT_TYPE: "easel", }, }); opened.on("notification", handleNotification); @@ -190,6 +191,11 @@ function openEngine({ resume = "" } = {}) { let engine = openEngine({ resume: resumeThreadId }); let drawing = false; 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 +// redraw() can be called before that block is reached. +let splashing = false; +let splashTimer = null; let streamedMessageId = null; let pasteBuffer = null; @@ -232,7 +238,7 @@ function startDance() { } function redraw() { - if (closing || drawing) return; + if (closing || drawing || splashing) return; drawing = true; try { const frame = renderFrame(state, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); @@ -474,7 +480,7 @@ function handleRequest(request) { redraw(); return; } - engine.reject(request.id, -32601, `Aesthetic Code does not support ${request.method} yet`); + engine.reject(request.id, -32601, `Easel does not support ${request.method} yet`); } function answerApproval(character) { @@ -894,6 +900,13 @@ function insertText(value) { } function handleKey(input) { + // The easel is a greeting, not a gate. Any key puts it away. + if (splashing) { + splashing = false; + clearTimeout(splashTimer); + splashTimer = null; + redraw(); + } if (answerApproval(input)) return; if (input === "\u0003") { @@ -957,6 +970,39 @@ function handleKeys(buffer) { } process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[?2004h"); + +// 🎨 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 +// sign-in resolves — which is the honest thing to show, since that is exactly +// when the piece's address starts answering. +function splashTick() { + const elapsed = Date.now() - splashStartedAt; + const canvas = { piece: live.slug, address: live.scanUrl }; + const columns = process.stdout.columns || 80; + const lines = easelFrame(elapsed, canvas); + const pad = Math.max(0, Math.floor((columns - lines[0].length) / 2)); + const gap = Math.max(0, Math.floor(((process.stdout.rows || 24) - EASEL_HEIGHT) / 2)); + const body = lines.map((line) => " ".repeat(pad) + line).join("\n"); + process.stdout.write(`\x1b[H\x1b[2J${color.ground}${"\n".repeat(gap)}${body}`); + + const next = easelNextFrame(elapsed, canvas); + if (next === null) { + splashing = false; + splashTimer = null; + return redraw(); + } + splashTimer = setTimeout(splashTick, next); + splashTimer.unref?.(); +} + +// Skip it on a screen too small to hold it — a clipped easel is worse than +// none — and whenever output is not a terminal at all. +const splashStartedAt = Date.now(); +if (process.stdout.isTTY && (process.stdout.rows || 0) >= EASEL_HEIGHT + 4) { + splashing = true; + splashTick(); +} + process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on("data", handleKeys); diff --git a/aesthetic-code/src/vendor/qr.mjs b/easel/src/vendor/qr.mjs similarity index 100% rename from aesthetic-code/src/vendor/qr.mjs rename to easel/src/vendor/qr.mjs diff --git a/aesthetic-code/test/ac-session.test.mjs b/easel/test/ac-session.test.mjs similarity index 98% rename from aesthetic-code/test/ac-session.test.mjs rename to easel/test/ac-session.test.mjs index 4b27724725..7fdcf8eab0 100644 --- a/aesthetic-code/test/ac-session.test.mjs +++ b/easel/test/ac-session.test.mjs @@ -9,7 +9,7 @@ const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); async function scratch(context) { - const root = await mkdtemp(join(tmpdir(), "aesthetic-code-session-")); + const root = await mkdtemp(join(tmpdir(), "easel-session-")); context.after(() => rm(root, { recursive: true, force: true })); return join(root, ".ac-token"); } diff --git a/aesthetic-code/test/app-server.test.mjs b/easel/test/app-server.test.mjs similarity index 100% rename from aesthetic-code/test/app-server.test.mjs rename to easel/test/app-server.test.mjs diff --git a/aesthetic-code/test/autopublish.test.mjs b/easel/test/autopublish.test.mjs similarity index 100% rename from aesthetic-code/test/autopublish.test.mjs rename to easel/test/autopublish.test.mjs diff --git a/aesthetic-code/test/claude-server.test.mjs b/easel/test/claude-server.test.mjs similarity index 100% rename from aesthetic-code/test/claude-server.test.mjs rename to easel/test/claude-server.test.mjs diff --git a/aesthetic-code/test/cli.sh b/easel/test/cli.sh similarity index 63% rename from aesthetic-code/test/cli.sh rename to easel/test/cli.sh index a9816a1408..d951013d94 100755 --- a/aesthetic-code/test/cli.sh +++ b/easel/test/cli.sh @@ -3,14 +3,14 @@ set -euo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CLI="$PROJECT_DIR/bin/aesthetic" -TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/aesthetic-code-test.XXXXXX")" +CLI="$PROJECT_DIR/bin/easel" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/easel-test.XXXXXX")" WORK_DIR="$TEST_ROOT/work space" TEST_BIN="$TEST_ROOT/bin" cleanup() { case "$TEST_ROOT" in - "${TMPDIR:-/tmp}"/aesthetic-code-test.*|/private/tmp/aesthetic-code-test.*) + "${TMPDIR:-/tmp}"/easel-test.*|/private/tmp/easel-test.*) rm -rf "$TEST_ROOT" ;; esac @@ -31,17 +31,17 @@ assert_contains() { } output="$($CLI --version)" -assert_contains "$output" 'Aesthetic Code 0.4.0' +assert_contains "$output" 'Easel 0.4.0' -output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" -assert_contains "$output" 'interface=aesthetic-code' +output="$(EASEL_DRY_RUN=1 "$CLI" "$WORK_DIR")" +assert_contains "$output" 'interface=easel' assert_contains "$output" "directory=$WORK_DIR" -output="$(AESTHETIC_CODE_DRY_RUN=1 "$TEST_BIN/ac" "$WORK_DIR")" -assert_contains "$output" 'interface=aesthetic-code' +output="$(EASEL_DRY_RUN=1 "$TEST_BIN/ac" "$WORK_DIR")" +assert_contains "$output" 'interface=easel' assert_contains "$output" "directory=$WORK_DIR" -output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --resume 00000000-0000-0000-0000-000000000001 --prompt continue "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 "$CLI" --resume 00000000-0000-0000-0000-000000000001 --prompt continue "$WORK_DIR")" assert_contains "$output" 'resume=yes' assert_contains "$output" 'initial_prompt=yes' @@ -64,46 +64,46 @@ output="$(HOME="$FAKE_HOME" "$CLI" whoami)" assert_contains "$output" '@tester' printf 'export function paint() {}\n' > "$WORK_DIR/smiley.mjs" -output="$(HOME="$FAKE_HOME" AESTHETIC_CODE_DRY_RUN=1 "$CLI" publish "$WORK_DIR/smiley.mjs")" +output="$(HOME="$FAKE_HOME" EASEL_DRY_RUN=1 "$CLI" publish "$WORK_DIR/smiley.mjs")" assert_contains "$output" 'https://aesthetic.computer/@tester/smiley' -if HOME="$FAKE_HOME" AESTHETIC_CODE_DRY_RUN=1 "$CLI" publish >/dev/null 2>&1; then +if HOME="$FAKE_HOME" EASEL_DRY_RUN=1 "$CLI" publish >/dev/null 2>&1; then printf 'Expected publish without a file to fail.\n' >&2 exit 1 fi -output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --runtime lisp "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 "$CLI" --runtime lisp "$WORK_DIR")" assert_contains "$output" 'runtime=lisp' -output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'runtime=mjs' # Publishing is outward-facing, so it is off unless this session asked for it — # by flag or by environment, with the flag able to say no to the environment. assert_contains "$output" 'autopublish=off' -output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --autopublish "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 "$CLI" --autopublish "$WORK_DIR")" assert_contains "$output" 'autopublish=on' -output="$(AESTHETIC_CODE_DRY_RUN=1 AESTHETIC_CODE_AUTOPUBLISH=1 "$CLI" "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 EASEL_AUTOPUBLISH=1 "$CLI" "$WORK_DIR")" assert_contains "$output" 'autopublish=on' -output="$(AESTHETIC_CODE_DRY_RUN=1 AESTHETIC_CODE_AUTOPUBLISH=1 "$CLI" --no-autopublish "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 EASEL_AUTOPUBLISH=1 "$CLI" --no-autopublish "$WORK_DIR")" assert_contains "$output" 'autopublish=off' -output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 "$CLI" "$WORK_DIR")" # Claude is the default engine bridge, on Fable; Codex stays selectable. assert_contains "$output" 'backend=claude' assert_contains "$output" 'model=claude-opus-5' -output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --backend codex "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 "$CLI" --backend codex "$WORK_DIR")" assert_contains "$output" 'backend=codex' -output="$(AESTHETIC_CODE_DRY_RUN=1 "$CLI" --backend claude --model claude-opus-5 "$WORK_DIR")" +output="$(EASEL_DRY_RUN=1 "$CLI" --backend claude --model claude-opus-5 "$WORK_DIR")" assert_contains "$output" 'model=claude-opus-5' -if AESTHETIC_CODE_DRY_RUN=1 "$CLI" --backend gemini "$WORK_DIR" >/dev/null 2>&1; then +if EASEL_DRY_RUN=1 "$CLI" --backend gemini "$WORK_DIR" >/dev/null 2>&1; then printf 'Expected an unknown backend to fail.\n' >&2 exit 1 fi @@ -113,17 +113,17 @@ assert_contains "$output" 'engine bridge claude:' assert_contains "$output" 'engine bridge codex:' assert_contains "$output" 'default engine bridge: claude (claude-opus-5)' -if AESTHETIC_CODE_DRY_RUN=1 "$CLI" --runtime rust "$WORK_DIR" >/dev/null 2>&1; then +if EASEL_DRY_RUN=1 "$CLI" --runtime rust "$WORK_DIR" >/dev/null 2>&1; then printf 'Expected an unknown runtime to fail.\n' >&2 exit 1 fi -if AESTHETIC_CODE_DRY_RUN=1 "$CLI" claude >/dev/null 2>&1; then +if EASEL_DRY_RUN=1 "$CLI" claude >/dev/null 2>&1; then printf 'Expected the removed provider shortcut to fail.\n' >&2 exit 1 fi -if AESTHETIC_CODE_DRY_RUN=1 "$CLI" "$WORK_DIR" extra >/dev/null 2>&1; then +if EASEL_DRY_RUN=1 "$CLI" "$WORK_DIR" extra >/dev/null 2>&1; then printf 'Expected extra launcher arguments to fail.\n' >&2 exit 1 fi diff --git a/easel/test/easel.test.mjs b/easel/test/easel.test.mjs new file mode 100644 index 0000000000..62416198b5 --- /dev/null +++ b/easel/test/easel.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { EASEL_HEIGHT, easelDuration, easelFrame, easelNextFrame } from "../src/easel.mjs"; + +const canvas = { piece: "balozo", address: "prompt.ac/@jeffrey/balozo" }; + +test("the easel is always the same size, whatever is on it", () => { + const widths = new Set(); + for (const ms of [0, 100, 500, 1200, 5000]) { + const lines = easelFrame(ms, canvas); + assert.equal(lines.length, EASEL_HEIGHT, "a splash that changes height jumps"); + widths.add(lines[0].length); + } + assert.equal(widths.size, 1, "the frame must not resize as the name is written"); +}); + +test("the name is written on, then the address follows", () => { + const early = easelFrame(60, canvas).join("\n"); + assert.match(early, /b▌/, "mid-write shows a cursor"); + assert.ok(!early.includes("prompt.ac"), "the address waits its turn"); + + const done = easelFrame(easelDuration(canvas.piece, canvas.address), canvas).join("\n"); + assert.ok(done.includes("balozo"), "the piece name lands"); + assert.ok(done.includes(canvas.address), "and so does the address"); + assert.ok(!done.includes("▌"), "the cursor goes away when writing stops"); +}); + +test("it eventually stops asking to be repainted", () => { + assert.equal(easelNextFrame(easelDuration(canvas.piece, canvas.address), canvas), null); + assert.ok(easelNextFrame(0, canvas) > 0); +}); + +// A session that is not signed in has no address yet, and that is ordinary +// rather than an error state — the canvas just stays empty there. +test("an empty address leaves the canvas blank rather than breaking", () => { + const lines = easelFrame(5000, { piece: "balozo", address: "" }); + assert.equal(lines.length, EASEL_HEIGHT); + assert.ok(lines.join("\n").includes("balozo")); + const lines2 = easelFrame(0, {}); + assert.equal(lines2.length, EASEL_HEIGHT, "and neither does having nothing at all"); +}); diff --git a/aesthetic-code/test/fake-app-server.mjs b/easel/test/fake-app-server.mjs similarity index 100% rename from aesthetic-code/test/fake-app-server.mjs rename to easel/test/fake-app-server.mjs diff --git a/aesthetic-code/test/fake-claude-cli.mjs b/easel/test/fake-claude-cli.mjs similarity index 100% rename from aesthetic-code/test/fake-claude-cli.mjs rename to easel/test/fake-claude-cli.mjs diff --git a/aesthetic-code/test/live.test.mjs b/easel/test/live.test.mjs similarity index 99% rename from aesthetic-code/test/live.test.mjs rename to easel/test/live.test.mjs index bd8b310857..ebf17a7744 100644 --- a/aesthetic-code/test/live.test.mjs +++ b/easel/test/live.test.mjs @@ -10,7 +10,7 @@ import { qrBlock } from "../src/qr.mjs"; import { runtimeFor, runtimeForExtension, runtimeIds, runtimeMenu } from "../src/runtimes.mjs"; async function workspace(context) { - const root = await mkdtemp(join(tmpdir(), "aesthetic-code-live-")); + const root = await mkdtemp(join(tmpdir(), "easel-live-")); context.after(() => rm(root, { recursive: true, force: true })); return root; } diff --git a/aesthetic-code/test/mascot.test.mjs b/easel/test/mascot.test.mjs similarity index 100% rename from aesthetic-code/test/mascot.test.mjs rename to easel/test/mascot.test.mjs diff --git a/aesthetic-code/test/publish.test.mjs b/easel/test/publish.test.mjs similarity index 98% rename from aesthetic-code/test/publish.test.mjs rename to easel/test/publish.test.mjs index dfa630a0ab..a4af2b4205 100644 --- a/aesthetic-code/test/publish.test.mjs +++ b/easel/test/publish.test.mjs @@ -8,7 +8,7 @@ import { looksLikePiece, planPublish, publishPiece, slugFor } from "../src/publi const SOURCE = "export function paint({ wipe }) { wipe(70, 50, 100); }\n"; async function piece(context, name = "smiley.mjs", source = SOURCE) { - const root = await mkdtemp(join(tmpdir(), "aesthetic-code-publish-")); + const root = await mkdtemp(join(tmpdir(), "easel-publish-")); context.after(() => rm(root, { recursive: true, force: true })); const file = join(root, name); await writeFile(file, source); diff --git a/aesthetic-code/test/render.test.mjs b/easel/test/render.test.mjs similarity index 100% rename from aesthetic-code/test/render.test.mjs rename to easel/test/render.test.mjs diff --git a/aesthetic-code/test/slab-session.test.mjs b/easel/test/slab-session.test.mjs similarity index 94% rename from aesthetic-code/test/slab-session.test.mjs rename to easel/test/slab-session.test.mjs index 0764365a95..ff301aea89 100644 --- a/aesthetic-code/test/slab-session.test.mjs +++ b/easel/test/slab-session.test.mjs @@ -9,7 +9,7 @@ const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); const exists = async (path) => stat(path).then(() => true, () => false); test("publishes the full Slab prompt lifecycle", async (context) => { - const root = await mkdtemp(join(tmpdir(), "aesthetic-code-slab-")); + const root = await mkdtemp(join(tmpdir(), "easel-slab-")); context.after(() => rm(root, { recursive: true, force: true })); const session = new SlabSession({ cwd: "/project", @@ -24,7 +24,7 @@ test("publishes the full Slab prompt lifecycle", async (context) => { session.start(); let marker = await readJson(active); - assert.equal(marker.agent_type, "aesthetic-code"); + assert.equal(marker.agent_type, "easel"); assert.equal(marker.state, "blank"); session.connected("00000000-0000-0000-0000-000000000001"); diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs index 849d35118d..bf23534f48 100755 --- a/slab/bin/prox-mcp.mjs +++ b/slab/bin/prox-mcp.mjs @@ -43,7 +43,7 @@ const SLAB_HOME = process.env.SLAB_HOME || join(homedir(), ".local", "share", "s const MARKER_DIRS = [join(SLAB_HOME, "state", "active-prompts"), join(SLAB_HOME, "state", "awaiting-prompts")]; const shellQuote = (s) => `'${String(s).replaceAll("'", `'"'"'`)}'`; -const isCodexBacked = (agent) => agent === "codex" || agent === "aesthetic-code"; +const isCodexBacked = (agent) => agent === "codex" || agent === "easel"; async function findFile(root, suffix) { let entries; @@ -304,7 +304,7 @@ async function toolDump({ handle, destination } = {}) { await writeFile(join(out, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); let resume; - if (agent === "aesthetic-code") { + if (agent === "easel") { resume = `#!/bin/sh set -eu bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) @@ -351,8 +351,8 @@ async function toolLaunch({ host, agent, cwd, prompt = "", by, loopboyContact = const wanted = String(host || "").trim().toLowerCase().replace(/\.local$/, ""); if (!wanted) throw new Error("`host` is required (for example, poorslice)."); const agentName = String(agent || "").trim().toLowerCase(); - if (!new Set(["claude", "codex", "aesthetic-code"]).has(agentName)) { - throw new Error("`agent` must be `claude`, `codex`, or `aesthetic-code`."); + if (!new Set(["claude", "codex", "easel"]).has(agentName)) { + throw new Error("`agent` must be `claude`, `codex`, or `easel`."); } if (String(prompt).length > 4000) throw new Error("`prompt` exceeds 4000 characters."); const contactKey = String(loopboyContact || "").trim().toLowerCase(); @@ -542,14 +542,14 @@ const TOOLS = [ { name: "prox_list", description: - "List the prompt rocks across the Slab fleet — every live Claude, Codex, or Aesthetic Code session and headless agent the menubar advertises, as host:name with its status, kind, owning interface, age, and subject. Reads the local fleet ledger cache (no SSH).", + "List the prompt rocks across the Slab fleet — every live Claude, Codex, or Easel session and headless agent the menubar advertises, as host:name with its status, kind, owning interface, age, and subject. Reads the local fleet ledger cache (no SSH).", inputSchema: { type: "object", properties: { host: { type: "string", description: "Only rocks on this machine (e.g. neo, blueberry, panda)." }, status: { type: "string", description: "Filter by status: working | awaiting | complete | rendering | blank | interrupted." }, kind: { type: "string", description: "Filter by kind: session | agent." }, - agent: { type: "string", description: "Filter by owning interface: claude | codex | aesthetic-code." }, + agent: { type: "string", description: "Filter by owning interface: claude | codex | easel." }, }, }, }, @@ -606,12 +606,12 @@ const TOOLS = [ { name: "prox_launch", description: - "Launch a new interactive Claude, Codex, or Aesthetic Code prompt in Terminal.app on a Slab fleet host. SIDE EFFECT: opens a live agent session and may consume account usage. The target accepts only fixed allowlisted launchers, limits cwd to that user's home folder, and binds the endpoint to its tailnet IP; no arbitrary command is accepted.", + "Launch a new interactive Claude, Codex, or Easel prompt in Terminal.app on a Slab fleet host. SIDE EFFECT: opens a live agent session and may consume account usage. The target accepts only fixed allowlisted launchers, limits cwd to that user's home folder, and binds the endpoint to its tailnet IP; no arbitrary command is accepted.", inputSchema: { type: "object", properties: { host: { type: "string", description: "Target Slab hostname, for example poorslice." }, - agent: { type: "string", enum: ["claude", "codex", "aesthetic-code"], description: "Interface to launch." }, + agent: { type: "string", enum: ["claude", "codex", "easel"], description: "Interface to launch." }, cwd: { type: "string", description: "Optional absolute directory on the target. Defaults to its aesthetic-computer checkout and must stay under its home folder." }, prompt: { type: "string", description: "Optional initial prompt, at most 4000 characters. Omit to open an idle TUI." }, by: { type: "string", description: "Optional caller label recorded by the target." }, diff --git a/slab/bin/slab-aesthetic-profiles.swift b/slab/bin/slab-aesthetic-profiles.swift index 2d3fb52dd2..fec043bd07 100644 --- a/slab/bin/slab-aesthetic-profiles.swift +++ b/slab/bin/slab-aesthetic-profiles.swift @@ -3,7 +3,7 @@ // The menubar already switches a tab's `current settings` per session status, // picking a settings set by name (see AppDelegate.profileName). Claude sessions // get the plain `Slab--` family and Codex ones a cooler -// `-codex` twin. Aesthetic Code sessions had no family of their own and were +// `-codex` twin. Easel sessions had no family of their own and were // borrowing Codex's, so the one interface that IS Aesthetic Computer was the // one wearing somebody else's colours. // diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index e5f1a2887a..cc1833e879 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -2310,7 +2310,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ) -> (palette: Palette, glyph: String) { // Codex-backed completion is a stronger attention cue than Claude's // calm slate: coral/red, distinct from approval/elicitation amber. - if (agentType == "codex" || agentType == "aesthetic-code"), state == .complete { + if (agentType == "codex" || agentType == "easel"), state == .complete { if dark { return blink ? (Palette(bg: (23500, 3200, 4200), text: (65535, 48000, 46000), @@ -2325,7 +2325,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { bold: (21000, 300, 800), cursor: (62000, 5000, 5000)), "✓ complete") } let base = baseStatusDecor(for: state, dark: dark, blink: blink) - guard agentType == "codex" || agentType == "aesthetic-code" else { return base } + guard agentType == "codex" || agentType == "easel" else { return base } return (palette: codexTint(base.palette, dark: dark), glyph: base.glyph) } @@ -2491,14 +2491,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } // Codex-backed interfaces share a settings-set family so their // slightly-cooler palette is provisioned independently of Claude's. - // Aesthetic Code has its own: it borrowed Codex's until now, which put + // Easel has its own: it borrowed Codex's until now, which put // the one interface that IS Aesthetic Computer in somebody else's // colours. `-aesthetic` is the prompt's own scheme, the same values // the harness paints itself with — and it names the palette rather // than the tool, which is still deciding what it is called. let agentSuffix: String switch agentType { - case "aesthetic-code": agentSuffix = "-aesthetic" + case "easel": agentSuffix = "-aesthetic" case "codex": agentSuffix = "-codex" default: agentSuffix = "" } @@ -3080,7 +3080,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { _ = fontSize let safeCwd = cwd.replacingOccurrences(of: "'", with: "'\\''") let shellCmd: String - if agentType == "aesthetic-code" { + if agentType == "easel" { let safeSid = providerSessionId.replacingOccurrences(of: "'", with: "'\\''") shellCmd = providerSessionId.isEmpty ? "cd '\(safeCwd)' && aesthetic" diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift index 26cdd08390..5786c99906 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -1,7 +1,7 @@ import Foundation /// Generic alias — a tracked agent session is no longer Claude-specific -/// (Codex and Aesthetic Code sessions flow through the same markers + reducer). +/// (Codex and Easel sessions flow through the same markers + reducer). /// New code should /// prefer `AgentSession`; the old name stays valid so existing call sites /// (overlay, snapshot, menu) keep compiling. @@ -79,17 +79,17 @@ struct ClaudeSession { var remoteHost: String = "" /// Which interface owns this session — "claude" (default), "codex", or - /// "aesthetic-code". + /// "easel". /// Read from the marker's `agent_type`; drives per-agent labels/tooltips /// in the menu. The state/color engine is agent-agnostic, so this is /// display-only. var agentType: String = "claude" - /// Aesthetic Code owns its UI while using Codex app-server as its current + /// Easel owns its UI while using Codex app-server as its current /// provider bridge. Provider-thread operations therefore share Codex's /// transcript and resume path without presenting the Codex UI. var isCodexBacked: Bool { - agentType == "codex" || agentType == "aesthetic-code" + agentType == "codex" || agentType == "easel" } /// Optional hardware/platform destination for this prompt (for example @@ -100,13 +100,13 @@ struct ClaudeSession { /// Where a phone can reach the piece this session is holding, as a bare /// host+path (`aesthetic.computer/prompt~…`) with no scheme. Only - /// Aesthetic Code mints one, and only once its piece exists, so an empty + /// Easel mints one, and only once its piece exists, so an empty /// string is the ordinary case and means "no scannable destination yet". /// The prompt rock turns this into a QR surface; everything else ignores it. var scanURL: String = "" /// The piece this session is writing, with its extension — `balozo.mjs`. - /// Only Aesthetic Code sets it, and it names the rock: a session that is + /// 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 = "" @@ -124,7 +124,7 @@ struct ClaudeSession { /// Human label for the owning interface. var agentLabel: String { - if agentType == "aesthetic-code" { return "Aesthetic Code" } + if agentType == "easel" { return "Easel" } return agentType.isEmpty ? "Claude" : agentType.prefix(1).uppercased() + agentType.dropFirst() @@ -215,9 +215,9 @@ enum ClaudeSessionReader { // has overwritten it yet) — preserve so applyTerminalDecor // paints the appearance-matched bg. // (no-op: keep s.state == .blank) - } else if s.agentType == "aesthetic-code", + } else if s.agentType == "easel", s.state == .complete || s.state == .awaiting || s.state == .interrupted { - // Aesthetic Code receives app-server lifecycle events + // Easel receives app-server lifecycle events // directly, so its marker can state this transition without // waiting for hook-derived side channels. } else if isInterrupted(sessionId: s.sessionId, markerPath: path) { @@ -340,15 +340,15 @@ enum ClaudeSessionReader { }() // Hook-backed clients still derive most state from the side-channel - // markers. Aesthetic Code receives direct app-server lifecycle events + // markers. Easel receives direct app-server lifecycle events // and can publish exact idle/interrupted state itself. let agentType = (obj["agent_type"] as? String) ?? "claude" let parsedState: ClaudeSession.State = { switch obj["state"] as? String { case "blank": return .blank - case "complete" where agentType == "aesthetic-code": return .complete - case "awaiting" where agentType == "aesthetic-code": return .awaiting - case "interrupted" where agentType == "aesthetic-code": return .interrupted + case "complete" where agentType == "easel": return .complete + case "awaiting" where agentType == "easel": return .awaiting + case "interrupted" where agentType == "easel": return .interrupted default: return .working } }() diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift index f1049af999..6b228398fa 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -49,7 +49,7 @@ struct LedgerEntry: Codable, Equatable { /// unlike the route registry, this originates in the live marker. var loopboyContact: String? /// Bare host+path a phone can scan to reach the piece this prompt is - /// holding. Only Aesthetic Code mints one. Optional both because older + /// holding. Only Easel mints one. Optional both because older /// fleet ledgers predate it and because most prompts never have one. var scanURL: String? } @@ -159,9 +159,9 @@ final class LedgerStore { /// home folder. private static func launchPrompt(_ body: [String: Any]) -> [String: Any] { let requestedAgent = ((body["agent"] as? String) ?? "").lowercased() - let agent = requestedAgent == "aesthetic" ? "aesthetic-code" : requestedAgent - guard agent == "claude" || agent == "codex" || agent == "aesthetic-code" else { - return ["ok": false, "error": "agent must be claude, codex, or aesthetic-code"] + let agent = requestedAgent == "aesthetic" ? "easel" : requestedAgent + guard agent == "claude" || agent == "codex" || agent == "easel" else { + return ["ok": false, "error": "agent must be claude, codex, or easel"] } let prompt = (body["prompt"] as? String) ?? "" @@ -203,8 +203,8 @@ final class LedgerStore { let binary: String if agent == "codex" { binary = "\(Paths.slabBin)/codex-slab" - } else if agent == "aesthetic-code" { - binary = "\(Paths.home)/.local/bin/aesthetic" + } else if agent == "easel" { + binary = "\(Paths.home)/.local/bin/easel" } else { binary = "\(Paths.home)/.local/bin/claude" } @@ -228,7 +228,7 @@ final class LedgerStore { command = "cd \(shellQuote(cwd)) && exec \(shellQuote(binary))" } if !prompt.isEmpty { - command += agent == "aesthetic-code" + command += agent == "easel" ? " --prompt \(shellQuote(prompt))" : " \(shellQuote(prompt))" } diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptScanCode.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptScanCode.swift index ca46193768..c84a16c9d5 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptScanCode.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptScanCode.swift @@ -1,6 +1,6 @@ // PROMPT SCAN CODES. // -// An Aesthetic Code session is holding a live piece, and the way you reach that +// An Easel session is holding a live piece, and the way you reach that // piece from a phone is a QR. Printed into the terminal it costs seventeen rows // of half-blocks and shoves the actual conversation off the top of the pane; // carried by the session's prompt rock it costs nothing but the stone's own diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index 630108e999..1d0ee76af1 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -2634,12 +2634,12 @@ final class PromptSigilOverlayController { /// Reconcile the badge set with the live sessions. Off when `enabled` is /// false. Only sessions with a real local tty get a badge. /// The code this session's rock should be showing, or nil for the stone. - /// Every gate lives here: only Aesthetic Code mints scan URLs, the URL has + /// Every gate lives here: only Easel mints scan URLs, the URL has /// to exist, and it has to survive the generator — a payload too long for a /// QR, or one that would only fit at a module size no camera can resolve, /// comes back nil and the session keeps an ordinary rock. private func scanCode(for s: ClaudeSession) -> CGImage? { - guard s.agentType == "aesthetic-code" else { return nil } + guard s.agentType == "easel" else { return nil } let url = s.scanURL guard !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !unscannableURLs.contains(url) else { return nil } @@ -2691,7 +2691,7 @@ final class PromptSigilOverlayController { for s in live { let bare = (s.tty as NSString).lastPathComponent liveParticleTtys.insert(bare) - // An Aesthetic Code session is holding a piece, and the client for + // An Easel session is holding a piece, and the client for // that piece is a phone. When the marker says where to send it, the // rock trades its stone for a code the camera can take. Nil — an // older session, a piece that hasn't minted yet, another agent diff --git a/slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift b/slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift index 0679aac96a..5f4c2b581e 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ProxMemoirs.swift @@ -325,7 +325,7 @@ final class ProxMemoirs { else { continue } var role = "" var content: Any? - if agentType == "codex" || agentType == "aesthetic-code" { + if agentType == "codex" || agentType == "easel" { guard (obj["type"] as? String) == "response_item", let payload = obj["payload"] as? [String: Any], (payload["type"] as? String) == "message" else { continue } diff --git a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift index 80ef4ad44b..fa85f4d0d3 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift @@ -70,13 +70,13 @@ enum SigilRenderer { return routeName } } - // An Aesthetic Code session is holding one named piece, and that name is + // An Easel session is holding one named piece, and that name is // already the session's public identity: it is the slug in the address on // the rock and the channel the piece is pushed on. Drawing a second, // unrelated word from the session id meant the rock and the piece // disagreed about what the session was called — you would open `balozo` // and be handed `muzad`. The piece wins, and follows a rename. - if session.agentType == "aesthetic-code" { + if session.agentType == "easel" { let slug = (session.piece as NSString).deletingPathExtension let clean = slug.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } if !clean.isEmpty { return String(clean.prefix(16)) } -- 2.51.2