From 63b6c77f0f7ac0e81b0182d2b77fbd8b4daf31bb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 18:39:06 -0700 Subject: [PATCH] aesel pro: the line being worked on is the animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the machine has the floor, the last thing typed runs a colour wave through its letters — each letter the next hue along, the whole run shifted one step per frame on the dance clock, in the splash's own colours — and settles the moment the answer lands. --- easel/package.json | 2 +- easel/src/render.mjs | 19 ++++++++++++++++++- easel/test/render.test.mjs | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/easel/package.json b/easel/package.json index d9fdfa241b..51bd829431 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.32", + "version": "0.7.33", "private": true, "type": "module", "scripts": { diff --git a/easel/src/render.mjs b/easel/src/render.mjs index c98ae40c5a..18ea00c38c 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -363,6 +363,16 @@ function entryLines(entry, width, useColor, gutter = "wide") { // An inbox line leads with who sent it — `↓ host:name · text` — so the // sender is read before the request, the way the model reads the stamp. const bodyTone = BODY_TONES[entry.kind] || "text"; + if (entry.kind === "typed" && entry.wave !== undefined && useColor) { + // The wave: each letter takes the next hue along, the whole run shifted + // one step per frame, the splash's own colours in the splash's own order. + const hues = aeselInk.name; + let at = entry.wave; + return wrapText(cleanText(entry.text), Math.max(1, width - prefix.length)).map((line, index) => { + const painted = Array.from(line).map((ch) => `${hues[(at++) % hues.length]}${ch}`).join(""); + return `${paint(useColor, tone, index === 0 ? prefix : continuation)}${painted}${color.reset}${color.ground}`; + }); + } const rows=[],text=cleanText(entry.kind === "inbox" && entry.from ? `${entry.from} · ${entry.text}` : entry.text),parts=text.split(/(^[ \t]*```[^\n]*$)/m); let fenced=false; for(const part of parts){ @@ -652,7 +662,14 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const contentWidth = qr ? width - qr.width - 2 : width - 2; const transcript = state.about ? aboutMap().flatMap((line) => wrapText(line, contentWidth)) - : state.entries.flatMap((entry) => entryLines(pro && entry.kind === "user" ? { ...entry, kind: "typed" } : entry, contentWidth, useColor, pro ? "narrow" : "wide")); + : (() => { + // In pro the line being worked on is the animation: while the machine + // has the floor, the last thing typed runs a colour wave through its + // letters, frame by frame on the dance clock, and settles when the + // answer lands. + const working = pro && state.busy ? state.entries.reduce((found, entry, index) => (entry.kind === "user" ? index : found), -1) : -1; + return state.entries.flatMap((entry, index) => entryLines(pro && entry.kind === "user" ? { ...entry, kind: "typed", ...(index === working ? { wave: Math.floor((state.mascotMs || 0) / 120) } : {}) } : entry, contentWidth, useColor, pro ? "narrow" : "wide")); + })(); const drawer=drawerRows(state,width,height,useColor); const availableRows=transcriptRows-drawer.length; const start = state.about ? Math.min(state.aboutScroll || 0, Math.max(0, transcript.length - transcriptRows)) diff --git a/easel/test/render.test.mjs b/easel/test/render.test.mjs index 0baf5620c8..d623f517c1 100644 --- a/easel/test/render.test.mjs +++ b/easel/test/render.test.mjs @@ -452,3 +452,20 @@ test("in pro a question takes the page with three answers, and a running tool ri assert.match(proStatus(busy, 100, false).line, /s… · \/bin\/zsh -lc "git status --short"/, "the tool shows beside the timer"); assert.doesNotMatch(renderFrame(busy, 100, 20, false), /RUN/, "and not as a line of the conversation"); }); + +test("while the machine works, the line it is working on runs a colour wave, frame by frame", () => { + const base = { workspace: "/c", mode: "remote", status: "working", input: "", account: "@t", model: "m", profile: { name: "pro" }, busy: true, requestStartedAt: Date.now(), + entries: [{ id: "u0", kind: "user", text: "earlier line" }, { id: "u1", kind: "user", text: "make it sing" }] }; + const a = renderFrame({ ...base, mascotMs: 0 }, 60, 12, true).split("\n"); + const b = renderFrame({ ...base, mascotMs: 240 }, 60, 12, true).split("\n"); + const row = (rows) => rows.find((r) => r.includes("make it sing".slice(-4)) && r.includes("\x1b[38;")); + const wave = (rows) => rows.find((r) => /m\x1b\[38;[^m]*ma/.test(r) || /make/.test(r.replace(/\x1b\[[0-9;]*m/g, "")) && (r.match(/\x1b\[38;/g) || []).length >= 6); + assert.ok(wave(a), "each letter of the working line carries its own hue"); + assert.notEqual(wave(a), wave(b), "and the hues move between frames"); + const distinctInks = (r) => new Set(r.match(/\x1b\[38;[0-9;]*m/g) || []).size; + const earlier = a.find((r) => r.replace(/\x1b\[[0-9;]*m/g, "").includes("earlier line")); + assert.ok(distinctInks(earlier) <= 3, "an earlier line keeps one ink"); + assert.ok(distinctInks(wave(a)) >= 5, "the working line wears many"); + const idle = renderFrame({ ...base, busy: false, mascotMs: 240 }, 60, 12, true).split("\n"); + assert.equal(wave(idle), undefined, "and so does the line once the answer has landed"); +}); -- 2.54.0