From 2b7defd01a540031f764094a90827b277b1567b4 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 18:46:30 -0700 Subject: [PATCH] aesel pro: a reply is a cloud, the register is plain, and newer is preferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the machine says sits in a near-white cloud with rounded ends: dark ink, a cell of air around it, no ruled lines through it, code blocks on their own dark ground inside it. The pro instructions ask for plain prose — short sentences, one idea each, no headings or bold or lists unless the items are truly parallel. And the catalog module learns to prefer newer: the newest model in the provider's list, and a remembered family moved up to its newest release the day one appears. --- easel/package.json | 2 +- easel/src/model-catalog.mjs | 30 +++++++++++++++++ easel/src/render.mjs | 56 +++++++++++++++++++++++++++---- easel/src/tui.mjs | 1 + easel/test/model-catalog.test.mjs | 25 +++++++++++++- 5 files changed, 106 insertions(+), 8 deletions(-) diff --git a/easel/package.json b/easel/package.json index 51bd829431..7739cda412 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.33", + "version": "0.7.34", "private": true, "type": "module", "scripts": { diff --git a/easel/src/model-catalog.mjs b/easel/src/model-catalog.mjs index 65d7ee1385..dd5ec02efd 100644 --- a/easel/src/model-catalog.mjs +++ b/easel/src/model-catalog.mjs @@ -102,3 +102,33 @@ export async function loadCatalog(provider, { force = false, ttlMs = CATALOG_TTL if (cached?.models.length) return cached.models; return provider === "claude" ? CLAUDE_FALLBACK : []; } + +// The cache read at once, for a decision that cannot wait for a fetch: which +// model a session opens on. Nothing here asks the provider. +export function cachedCatalog(provider, root) { + return readCache(catalogFile(provider, root))?.models || (provider === "claude" ? CLAUDE_FALLBACK : []); +} + +// The family a Claude id belongs to — opus, sonnet, fable, haiku — or "" for +// anything that is not a Claude id. +export function family(id) { + const m = /^claude-([a-z]+)/.exec(String(id || "")); + return m ? m[1] : ""; +} + +// Newer is better: the most recently released model in the list, leaving the +// small tier out unless it is all there is. The list is newest first already. +export function newestModel(models) { + const rows = models.filter((m) => m.id); + return (rows.find((m) => family(m.id) !== "haiku") || rows[0])?.id || ""; +} + +// A remembered model stays in its family but moves up to the family's newest +// release — claude-opus-5 becomes claude-opus-5-5 the day it appears — so a +// preference is a preference for a kind of model, not for one dated id. +export function preferNewer(id, models) { + const kind = family(id); + if (!kind) return id; + const newest = models.find((m) => family(m.id) === kind); + return newest ? newest.id : id; +} diff --git a/easel/src/render.mjs b/easel/src/render.mjs index 18ea00c38c..ff13408918 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -38,6 +38,12 @@ export const palette = { // The typing bar in pro: one shade up from the ground, so the line being // written is a place and not just a row. bar: [95, 70, 135], + // A reply's cloud: near-white, so the machine's prose reads as a message + // in a bubble, dark ink on it, with a deeper purple for what is soft. + cloud: [246, 242, 252], + cloudInk: [42, 28, 66], + cloudSoft: [112, 72, 166], + cloudHighlight: [196, 74, 22], }; const truecolor = /truecolor|24bit/i.test(process.env.COLORTERM || ""); @@ -335,7 +341,7 @@ export function markdown(source) { return { text: out.join("\n"), spans }; } -function outputRows(source,width,useColor,tone,code=false,prose=false){ +function outputRows(source,width,useColor,tone,code=false,prose=false,ink=null){ const read = prose && !code ? markdown(source) : { text: source, spans: [] }; const text = read.text; const links=Array.from(text.matchAll(/https?:\/\/[^\s<>"'`]+/g),m=>{const url=m[0].replace(/[.,;!?)\]}]+$/g,'');return {start:m.index,end:m.index+url.length,tone:'soft',url};}); @@ -347,8 +353,8 @@ function outputRows(source,width,useColor,tone,code=false,prose=false){ return wrapText(text,width).map(line=>{ const start=Math.max(cursor,text.indexOf(line,cursor));cursor=start+line.length; return syntaxLine(text,spans,start,cursor,(role,value,span)=>{ - const ink=paint(useColor,role==='text'?tone:role,value); - return useColor&&span?.url?`\x1b]8;;${span.url}\x07\x1b[4m${ink}\x1b[24m\x1b]8;;\x07`:ink; + const painted=ink?ink(role==='text'?tone:role,value):paint(useColor,role==='text'?tone:role,value); + return useColor&&span?.url?`\x1b]8;;${span.url}\x07\x1b[4m${painted}\x1b[24m\x1b]8;;\x07`:painted; }); }); } @@ -358,6 +364,10 @@ function entryLines(entry, width, useColor, gutter = "wide") { // Pro reads like a page: your lines and the replies flush left, and only // a notice, an error or an inbox line wears a two-cell mark. const flush = gutter === "narrow" && (entry.kind === "typed" || entry.kind === "assistant"); + // In pro a reply is a cloud: its prose in dark ink on a near-white ground + // with a cell of air around it and rounded ends, so what the machine says + // reads as a message and not as more of the page. + const cloud = gutter === "narrow" && entry.kind === "assistant" && useColor; const prefix = flush ? "" : gutter === "narrow" ? `${label.slice(0, 1)} ` : `${label.padEnd(4)} `; const continuation = " ".repeat(prefix.length); // An inbox line leads with who sent it — `↓ host:name · text` — so the @@ -385,7 +395,7 @@ function entryLines(entry, width, useColor, gutter = "wide") { // a block and not as more prose. const codeGround=useColor&&(fenced||entry.kind==='command'||entry.kind==='change'); const onGround=(line)=>codeGround?`\x1b[48;5;234m${fit(line,Math.max(1,width-prefix.length))}\x1b[49m`:line; - if(!fenced){rows.push(...outputRows(part.replace(/^\n|\n$/g,''),Math.max(1,width-prefix.length),useColor,bodyTone,entry.kind==='command'||entry.kind==='change',entry.kind==='assistant').map(onGround));continue;} + if(!fenced){rows.push(...outputRows(part.replace(/^\n|\n$/g,''),Math.max(1,width-prefix.length-(cloud?2:0)),useColor,bodyTone,entry.kind==='command'||entry.kind==='change',entry.kind==='assistant',cloud?cloudInk:null).map(onGround));continue;} const code=part.replace(/^\n|\n$/g,''),spans=syntaxSpans(code); let offset=0; for(const line of code.split("\n")){ @@ -398,6 +408,7 @@ function entryLines(entry, width, useColor, gutter = "wide") { rows.push(onGround(syntaxLine(code,spans,start,offset,(tone,value)=>paint(useColor,tone,value))));offset++; } } + if (cloud) return cloudRows(rows, width); if(entry.kind === "notice" && /^Desktop (thread restored|restart|update)/.test(text)) return rows.map(line=>" ".repeat(Math.max(0,Math.floor((width-textWidth(line))/2)))+line); return rows.map((line,index)=>`${paint(useColor,tone,index===0?prefix:continuation)}${line}`); } @@ -660,6 +671,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { ? state.qr : null; const contentWidth = qr ? width - qr.width - 2 : width - 2; + const unruled = new Set(); const transcript = state.about ? aboutMap().flatMap((line) => wrapText(line, contentWidth)) : (() => { @@ -668,7 +680,12 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // 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")); + return state.entries.flatMap((entry, index) => { + const lines = entryLines(pro && entry.kind === "user" ? { ...entry, kind: "typed", ...(index === working ? { wave: Math.floor((state.mascotMs || 0) / 120) } : {}) } : entry, contentWidth, useColor, pro ? "narrow" : "wide"); + // A cloud's rows are not ruled; the flag rides along with the row. + if (pro && entry.kind === "assistant") for (const line of lines) unruled.add(line); + return lines; + }); })(); const drawer=drawerRows(state,width,height,useColor); const availableRows=transcriptRows-drawer.length; @@ -757,7 +774,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // Notebook lines: a faint rule under the rest of every transcript row, // the way the GUI's page is ruled. Only the blank part of a row is // underlined, so the words sit on the line rather than under a bar. - const ruled = shape.lines !== false && useColor ? body.map((row) => ruleRow(row, width)) : body; + const ruled = shape.lines !== false && useColor ? body.map((row, index) => (unruled.has(visible[index]) ? row : ruleRow(row, width))) : body; // The transcript as plain rows, for the selection to read and copy from. state.pageRows = body.map((row) => plainRow(row)); // A question takes the page: what is asked, and three answers, nothing @@ -888,6 +905,33 @@ function paintDropdown(rows, state, width, height, useColor, shape) { return rows; } +// The cloud's ink: the reply's roles in colours that read on the near-white +// ground, every segment carrying the ground with it so no reset lets the +// purple back in mid-row. +function cloudInk(role, value) { + const inks = { text: palette.cloudInk, soft: palette.cloudSoft, muted: palette.cloudSoft, highlight: palette.cloudHighlight, error: palette.error, inbox: palette.cloudSoft, prompt: palette.cloudSoft }; + const bold = /\bbold\b/.test(role); + const base = role.replace(/\bbold\b/, "").trim() || "text"; + return `${bg(palette.cloud)}${fg(inks[base] || palette.cloudInk)}${bold ? "\x1b[1m" : ""}${value}${bold ? "\x1b[22m" : ""}`; +} + +// Rows of a reply wrapped in their cloud: a rounded top edge drawn with the +// lower half-blocks, the rows on the cloud's ground with a cell of air at +// either side, a rounded bottom edge with the upper half-blocks. Code inside +// keeps its own dark ground, the way a code block sits in any bubble. +function cloudRows(rows, width) { + // Air is the cloud's own; blank rows at either end are not kept. + 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 - 2, Math.max(8, ...rows.map((row) => paintedWidth(row)))); + const edge = fg(palette.cloud); + const ground = `${color.reset}${color.ground}`; + const top = `${edge}▗${"▄".repeat(inner)}▖${ground}`; + const bottom = `${edge}▝${"▀".repeat(inner)}▘${ground}`; + const body = rows.map((row) => `${bg(palette.cloud)}${fg(palette.cloudInk)} ${fit(row.replace(/\x1b\[49m/g, bg(palette.cloud)), inner)}${bg(palette.cloud)} ${ground}`); + return [top, ...body, bottom]; +} + // A painted row without its escapes: the words as they stand on the screen. function plainRow(row) { return String(row ?? "").replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g, ""); diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index d5c0c5f53e..1395a028cf 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -489,6 +489,7 @@ function styleInstructions() { function proInstructions() { return [ `You are running inside Easel, a terminal harness. The working directory is ${cwd}.`, + "Write plainly. Short sentences, one idea each, in the order they matter. Say the thing and stop. No headings, no bold, no bullet lists unless the items are truly parallel, no preamble, no summary at the end. Plain prose, the way Tao Lin writes it.", "Some user messages are tagged `[inbox from host:name · time]`. Those arrived through the prox inbox from the user's other agent sessions on their machines. Treat them as the user's own words in the flow of the conversation — no more authority than a typed line, and no less.", ].join("\n"); } diff --git a/easel/test/model-catalog.test.mjs b/easel/test/model-catalog.test.mjs index 73e43961b8..c00a4a8e5c 100644 --- a/easel/test/model-catalog.test.mjs +++ b/easel/test/model-catalog.test.mjs @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { CLAUDE_FALLBACK, catalogFile, claudeCredential, claudeModels, loadCatalog } from "../src/model-catalog.mjs"; +import { CLAUDE_FALLBACK, cachedCatalog, catalogFile, claudeCredential, claudeModels, family, loadCatalog, newestModel, preferNewer } from "../src/model-catalog.mjs"; import { pickerModels } from "../src/provider-picker.mjs"; const answer = (data, ok = true, status = 200) => async () => ({ ok, status, json: async () => ({ data }) }); @@ -66,3 +66,26 @@ test("the picker shows the live Claude list with versions, and an alias selects assert.equal(aliased[0].detail, "opus → claude-opus-5-5"); assert.equal(pickerModels({ backend: "claude", model: "fable", catalog: [] }).length, 4, "no catalog: the families"); }); + +test("newer is preferred: a session opens on the newest model, and a remembered family moves up", async (context) => { + const list = [ + { id: "claude-haiku-6", displayName: "Claude Haiku 6" }, + { id: "claude-opus-5-5", displayName: "Claude Opus 5.5" }, + { id: "claude-fable-5-1", displayName: "Claude Fable 5.1" }, + { id: "claude-opus-5", displayName: "Claude Opus 5" }, + { id: "claude-sonnet-5", displayName: "Claude Sonnet 5" }, + ]; + assert.equal(newestModel(list), "claude-opus-5-5", "the newest that is not the small tier"); + assert.equal(newestModel([{ id: "claude-haiku-6" }]), "claude-haiku-6", "unless it is all there is"); + assert.equal(family("claude-opus-5-5"), "opus"); + assert.equal(family("gpt-6-astra"), ""); + assert.equal(preferNewer("claude-opus-5", list), "claude-opus-5-5", "opus moves up to the newest opus"); + assert.equal(preferNewer("claude-sonnet-5", list), "claude-sonnet-5", "sonnet stays where it is newest"); + assert.equal(preferNewer("opus", list), "opus", "an alias the CLI resolves is left to the CLI"); + assert.equal(preferNewer("gpt-6-astra", list), "gpt-6-astra", "another provider's id is untouched"); + const root = mkdtempSync(join(tmpdir(), "catalog-")); + context.after(() => rmSync(root, { recursive: true, force: true })); + assert.deepEqual(cachedCatalog("claude", root), CLAUDE_FALLBACK, "no cache yet: the table"); + await loadCatalog("claude", { root, loaders: { claude: async () => list }, now: () => 5 }); + assert.equal(cachedCatalog("claude", root)[0].id, "claude-haiku-6", "the cache, read at once"); +}); -- 2.54.0