From 08bfa3272c7e27f2b1c171bb9303c09702586fcc Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 19:32:01 -0700 Subject: [PATCH] aesel pro: slab themes keep their own ink, a spark on the line being read, no profile notice --- easel/package.json | 2 +- easel/src/render.mjs | 52 +++++++++++++++++++++++++++-------- easel/src/tui.mjs | 12 ++++---- easel/test/own-ink.mjs | 4 +++ easel/test/render.test.mjs | 11 ++++---- easel/test/selection.test.mjs | 1 + 6 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 easel/test/own-ink.mjs diff --git a/easel/package.json b/easel/package.json index b1a409a483..bf44fd55fc 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.38", + "version": "0.7.39", "private": true, "type": "module", "scripts": { diff --git a/easel/src/render.mjs b/easel/src/render.mjs index dcf1aa9144..f94cb8bef9 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -73,8 +73,27 @@ export function coloredHandle(account,colors,useColor=true,hover=false){ // whatever the window's ground is, they need something to stand on. return '\x1b[48;5;233m'+(hover?'\x1b[4m':'')+Array.from(account).map((ch,i)=>`${truecolor?'\x1b[38;2;'+rgb[i].join(';')+'m':'\x1b[38;5;'+cube(rgb[i])+'m'}${ch}`).join('')+'\x1b[0m'+color.ground; } -const fg = (rgb) => followsSlab ? `\x1b[38;5;${themeSlots.get(rgb.join(",")) ?? 7}m` : (truecolor ? `\x1b[38;2;${rgb.join(";")}m` : `\x1b[38;5;${cube(rgb)}m`); -const bg = (rgb) => followsSlab ? (rgb === palette.background ? "\x1b[49m" : "\x1b[48;5;13m") : (truecolor ? `\x1b[48;2;${rgb.join(";")}m` : `\x1b[48;5;${cube(rgb)}m`); +// Ink, by role. In a window Slab dresses the page's own roles follow the +// window: plain text is the profile's text colour, the muted and soft roles +// the same dimmed, the highlight its bold colour, and the coloured roles the +// standard ANSI slots. Everything else — the cloud's inks, the tray's, the +// outline's — is the interface's own and keeps its exact colour anywhere. +const exact = (rgb) => (truecolor ? `\x1b[38;2;${rgb.join(";")}m` : `\x1b[38;5;${cube(rgb)}m`); +const SLAB_INK = new Map([ + [palette.text.join(","), "\x1b[39m"], + [palette.soft.join(","), "\x1b[39m\x1b[2m"], + [palette.muted.join(","), "\x1b[39m\x1b[2m"], + [palette.highlight.join(","), "\x1b[39m\x1b[1m"], +]); +const fg = (rgb) => { + if (!followsSlab) return exact(rgb); + const key = rgb.join(","); + if (SLAB_INK.has(key)) return SLAB_INK.get(key); + return themeSlots.has(key) ? `\x1b[38;5;${themeSlots.get(key)}m` : exact(rgb); +}; +// The ground is the window's own in a Slab window; every other ground the +// interface paints — the bar, the tray, the cloud — is exact. +const bg = (rgb) => (followsSlab && rgb === palette.background ? "\x1b[49m" : truecolor ? `\x1b[48;2;${rgb.join(";")}m` : `\x1b[48;5;${cube(rgb)}m`); // Slab tints the whole Terminal window by session status — lifted while the // machine works, pulled toward the prompt's pink when it wants you, settled @@ -379,13 +398,18 @@ function entryLines(entry, width, useColor, gutter = "wide") { // 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; + // The shimmer: the words in the page's ink with a spark of the prompt's + // pink, three letters wide, travelling through them one step per frame — + // alive, but quiet enough to sit on any ground Slab gives the window. + const spark = [fg(palette.prompt), fg(palette.highlight), fg(palette.prompt)]; + const hues = { length: 1 }; + let at = 0; + const total = Array.from(cleanText(entry.text)).length + 3; + const head = entry.wave % Math.max(1, total); + const inkFor = () => { const d = at - head; at += 1; return d >= -2 && d <= 0 ? spark[d + 2] : color.text; }; const inner = Math.max(1, width - prefix.length - (TYPED_STYLE === "lines" ? 0 : 4)); - const painted = wrapText(cleanText(entry.text), inner).map((line) => Array.from(line).map((ch) => `${TYPED_STYLE === "bubble" ? bg(TYPED_TINT.bg) : ""}${hues[(at++) % hues.length]}${ch}`).join("")); - return TYPED_STYLE === "bubble" ? cloudRows(painted, width - prefix.length, TYPED_TINT) : TYPED_STYLE === "outline" ? outlineRows(painted, width - prefix.length) : painted.map((row) => `${row}${color.reset}${color.ground}`); + const painted = wrapText(cleanText(entry.text), inner).map((line) => Array.from(line).map((ch) => `${TYPED_STYLE === "bubble" ? bg(TYPED_TINT.bg) : ""}${inkFor()}${ch}`).join("")); + return TYPED_STYLE === "bubble" ? cloudRows(painted, width - prefix.length, TYPED_TINT) : TYPED_STYLE === "outline" ? outlineRows(painted, width - prefix.length, true) : painted.map((row) => `${row}${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; @@ -946,7 +970,7 @@ export function setTypedStyle(style) { TYPED_STYLE = ["outline", "bubble", "line // Your words in an outline: rounded box-drawing corners in the prompt's ink, // the words inside in the same ink, the ground showing through — the // machine's cloud is filled, yours is drawn, and that is the difference. -function outlineRows(rows, width) { +function outlineRows(rows, width, painted = false) { while (rows.length > 1 && !paintedWidth(rows[rows.length - 1])) rows.pop(); while (rows.length > 1 && !paintedWidth(rows[0])) rows.shift(); const inner = Math.min(width - 4, Math.max(4, ...rows.map((row) => paintedWidth(row)))); @@ -954,10 +978,16 @@ function outlineRows(rows, width) { const line = (text) => `${ink}${text}${color.reset}${color.ground}`; // The words inside are set in the page's own ink, white on the ground — // the pink is the frame around them, not the words themselves. - const words = (row) => `${color.text}${fit(row.replace(new RegExp(ink.replace(/[[\]\\]/g, "\\$&"), "g"), color.text), inner)}`; + // Painted rows (the shimmer) keep their own inks; plain rows take the page's. + const words = (row) => `${color.text}${fit(painted ? row : row.replace(new RegExp(ink.replace(/[[\]\\]/g, "\\$&"), "g"), color.text), inner)}`; return [line(`╭${"─".repeat(inner + 2)}╮`), ...rows.map((row) => `${line("│ ")}${words(row)}${line(" │")}`), line(`╰${"─".repeat(inner + 2)}╯`)]; } -const CLOUD_TINT = { bg: palette.cloud, ink: palette.cloudInk, soft: palette.cloudSoft, highlight: palette.cloudHighlight }; +let LIGHT = false; +// On a light page the cloud is a light gray, not a white that would vanish. +export function setAppearance(mode) { LIGHT = mode === "light"; } +const CLOUD_TINT_DARK = { bg: palette.cloud, ink: palette.cloudInk, soft: palette.cloudSoft, highlight: palette.cloudHighlight }; +const CLOUD_TINT_LIGHT = { bg: [226, 220, 238], ink: palette.cloudInk, soft: palette.cloudSoft, highlight: palette.cloudHighlight }; +const CLOUD_TINT = new Proxy({}, { get: (_, key) => (LIGHT ? CLOUD_TINT_LIGHT : CLOUD_TINT_DARK)[key] }); const TYPED_TINT = { bg: palette.typedCloud, ink: palette.typedInk, soft: [170, 80, 130], highlight: palette.cloudHighlight }; function cloudInk(role, value, tint = CLOUD_TINT) { const inks = { text: tint.ink, soft: tint.soft, muted: tint.soft, highlight: tint.highlight, error: palette.error, inbox: tint.soft, prompt: tint.ink }; diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index e499767c24..3b41a5efe0 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -54,7 +54,7 @@ import { publishPiece } from "./publish.mjs"; import { syncPictureWip, pictureWipAddress } from "./picture-wip.mjs"; import { publishPicture, publishedPicture } from "./publish-picture.mjs"; import { qrBlock } from "./qr.mjs"; -import { cleanText, clipText, color, aeselInk, renderBoot, renderFrame, renderGenrePicker, frameLayout, headerAction, wrapText, transcriptLineCount, windowTitle, setCorners, setTypedStyle } from "./render.mjs"; +import { cleanText, clipText, color, aeselInk, renderBoot, renderFrame, renderGenrePicker, frameLayout, headerAction, wrapText, transcriptLineCount, windowTitle, setCorners, setTypedStyle, setAppearance } from "./render.mjs"; import { mascotNextFrameIn, mascotRowNextFrameIn } from "./mascot.mjs"; import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; @@ -305,7 +305,7 @@ const state = { // Not in pro. There the engine carries the user's own settings and servers, // so the prompt is the whole boundary again and it starts closed. autoAllow: !pro, - tray: pro ? appearance() : "", + tray: pro ? (() => { const mode = appearance(); setAppearance(mode); return mode; })() : "", entries: [ // Pro opens onto nothing but the bar: the mode and the model sit under // it, and the agreement was the disclosure. @@ -316,9 +316,9 @@ const state = { }]), // Say why the session is not the default one, once. A piece session // opened by default has nothing to explain. - // Pro says nothing about itself here; the status line under the bar - // already does. Private is worth one line, because it is a promise. - ...(profile.private ? [{ id: "profile", kind: "notice", text: `Profile · ${profile.reason}` }] : []), + // Pro says nothing about itself here, private or not; /mode says it when + // asked, and the status line under the bar carries the rest. + ...(profile.private && !pro ? [{ id: "profile", kind: "notice", text: `Profile · ${profile.reason}` }] : []), ], }; @@ -2723,7 +2723,7 @@ process.stdin.setRawMode(true); process.stdin.resume(); startNativeGamepad(); process.stdin.on("data", handleKeys); -process.stdout.on("resize", () => { if (pro) state.tray = appearance(); redraw(); }); +process.stdout.on("resize", () => { if (pro) { state.tray = appearance(); setAppearance(state.tray); } redraw(); }); process.on("SIGWINCH", () => { frameDiff.reset(); lastLayout = ""; lastProvider = ""; lastConversation = ""; lastPrompt = ""; redraw(); }); if (desktopSessionPath) process.on("SIGUSR2", () => { readDesktopIntent(process.env.EASEL_DESKTOP_INTENT).then(requestDesktop).catch((error) => { addEntry("error", errorText(error)); redraw(); }); diff --git a/easel/test/own-ink.mjs b/easel/test/own-ink.mjs new file mode 100644 index 0000000000..2fd99cc3d6 --- /dev/null +++ b/easel/test/own-ink.mjs @@ -0,0 +1,4 @@ +// Tests run in whatever window they were started from. This one might be a +// Slab-dressed Terminal, where the renderer follows the window's theme; the +// tests want the interface's own ink, so they say so before render.mjs loads. +process.env.EASEL_THEME = "own"; diff --git a/easel/test/render.test.mjs b/easel/test/render.test.mjs index 2bf88da204..bfd2c1c653 100644 --- a/easel/test/render.test.mjs +++ b/easel/test/render.test.mjs @@ -1,3 +1,4 @@ +import "./own-ink.mjs"; import assert from "node:assert/strict"; import test from "node:test"; import { cleanText, renderFrame, renderGenrePicker, textWidth, wrapText } from "../src/render.mjs"; @@ -455,16 +456,16 @@ test("in pro a question takes the page with three answers, and a running tool ri 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: "a0", kind: "assistant", text: "ok" }, { 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 a = renderFrame({ ...base, mascotMs: 240 }, 60, 12, true).split("\n"); + const b = renderFrame({ ...base, mascotMs: 480 }, 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) => /make it sing/.test(r.replace(/\x1b\[[0-9;]*m/g, "")) && new Set(r.match(/\x1b\[38;[0-9;]*m/g) || []).size >= 5); - assert.ok(wave(a), "each letter of the working line carries its own hue"); + const wave = (rows) => rows.find((r) => /make it sing/.test(r.replace(/\x1b\[[0-9;]*m/g, "")) && new Set(r.match(/\x1b\[38;[0-9;]*m/g) || []).size >= 3); + assert.ok(wave(a), "the working line carries the spark"); 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"); + assert.ok(distinctInks(wave(a)) >= 3, "the spark is its own ink"); 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"); }); diff --git a/easel/test/selection.test.mjs b/easel/test/selection.test.mjs index 5827bd566d..c8be218856 100644 --- a/easel/test/selection.test.mjs +++ b/easel/test/selection.test.mjs @@ -1,3 +1,4 @@ +import "./own-ink.mjs"; import assert from "node:assert/strict"; import test from "node:test"; import { mouseEvent } from "../src/mouse.mjs"; -- 2.51.2