From 8c89861ee836c2277d8036f1f42eabfbcfd041d9 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 13:45:30 -0700 Subject: [PATCH] aesel pro: the frame's shape is data you can change while it runs, then bake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit layouts/pro.json is the baked default; ~/.config/easel/layout.json overrides any key and is watched, so a save in an editor lands on the screen of the session already open. /layout shows the shape, /layout set changes one key from the prompt, /layout reset takes the override back, and /layout bake writes the shape into the package and commits that one file — the customisation becomes the code. The vocabulary is small and it is JSON: rows under the transcript, facts on the status line, the bar's shade, the prompt glyph, the separator. The data-sharing screen now stands in the middle of the window with the donkey over it, painted the way the interface paints him. --- easel/bin/pack.mjs | 2 +- easel/layouts/pro.json | 7 ++ easel/src/layout.mjs | 151 +++++++++++++++++++++++++++ easel/src/render.mjs | 41 +++++--- easel/src/required-sharing.mjs | 40 ++++++- easel/src/tui.mjs | 40 ++++++- easel/test/layout.test.mjs | 71 +++++++++++++ easel/test/render.test.mjs | 18 ++++ easel/test/required-sharing.test.mjs | 27 +++++ 9 files changed, 382 insertions(+), 15 deletions(-) create mode 100644 easel/layouts/pro.json create mode 100644 easel/src/layout.mjs create mode 100644 easel/test/layout.test.mjs diff --git a/easel/bin/pack.mjs b/easel/bin/pack.mjs index 0f7ee0b7e2..9867bf9eed 100755 --- a/easel/bin/pack.mjs +++ b/easel/bin/pack.mjs @@ -24,7 +24,7 @@ const MANIFEST = join(REPO, "system", "public", "easel.json"); // how a git checkout knows never to overwrite itself with a release. const STAMP = join(aesel, "install.json"); -const INCLUDE = ["bin", "src", "shell", "context", "media", "package.json", "README.md", "LICENSE", "install.json"]; +const INCLUDE = ["bin", "src", "shell", "context", "media", "layouts", "package.json", "README.md", "LICENSE", "install.json"]; const version = JSON.parse(readFileSync(join(aesel, "package.json"), "utf8")).version; diff --git a/easel/layouts/pro.json b/easel/layouts/pro.json new file mode 100644 index 0000000000..6ab87cb9a1 --- /dev/null +++ b/easel/layouts/pro.json @@ -0,0 +1,7 @@ +{ + "bottom": ["gap", "bar", "gap", "status"], + "status": ["handle", "workspace", "model", "mode", "activity"], + "bar": [95, 70, 135], + "prompt": "›", + "separator": " · " +} diff --git a/easel/src/layout.mjs b/easel/src/layout.mjs new file mode 100644 index 0000000000..e45790c83c --- /dev/null +++ b/easel/src/layout.mjs @@ -0,0 +1,151 @@ +// layout — the shape of the pro frame, editable while it is running. +// +// The frame used to be a function of the code alone: to move the status line +// under the bar you edited render.mjs, restarted, and looked. Now the shape is +// data. `layouts/pro.json` in the package is the baked default; a file in +// ~/.config/easel overrides any of its keys; and the interface watches that +// file, so a change saved in an editor is on the screen at the next paint, in +// the session that is already open. When the shape is right, `/layout bake` +// writes it back into the package as the new default and commits that one +// file — the customisation becomes the code, which is the whole point of +// having been able to try it first. +// +// The vocabulary is deliberately small. `bottom` is the rows under the +// transcript, in order: gap · bar · status · rule · help · header · path. +// `status` is the facts on the status row, in order: handle · workspace · +// model · engine · mode · activity · inbox. `bar` is the typing bar's shade, +// `prompt` its glyph, `separator` what stands between the facts. Nothing here +// runs code from the config file: it is JSON, read and merged, never evaluated. +import { EventEmitter } from "node:events"; +import { execFile } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, watchFile, unwatchFile, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +export const BOTTOM_ROWS = ["gap", "bar", "status", "rule", "help", "header", "path"]; +export const STATUS_FACTS = ["handle", "workspace", "model", "engine", "mode", "activity", "inbox"]; + +// What a saved shape may say, and nothing else. Unknown keys are dropped and +// unknown tokens skipped, so a typo in the file costs a row, not the frame. +export function normalize(spec = {}) { + const out = {}; + if (Array.isArray(spec.bottom)) out.bottom = spec.bottom.filter((row) => BOTTOM_ROWS.includes(row)); + if (Array.isArray(spec.status)) out.status = spec.status.filter((fact) => STATUS_FACTS.includes(fact)); + if (Array.isArray(spec.bar) && spec.bar.length === 3 && spec.bar.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) out.bar = spec.bar; + if (typeof spec.prompt === "string" && spec.prompt.length > 0 && spec.prompt.length <= 2) out.prompt = spec.prompt; + if (typeof spec.separator === "string" && spec.separator.length <= 5) out.separator = spec.separator; + return out; +} + +function readJson(file) { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return null; + } +} + +export class Layout extends EventEmitter { + constructor({ + name = "pro", + root = PACKAGE_ROOT, + file = join(process.env.EASEL_CONFIG_DIR || join(homedir(), ".config", "easel"), "layout.json"), + // Polling, not fs.watch: editors save by writing a new file and renaming + // it over the old one, which fs.watch reports as the file vanishing. + interval = 500, + } = {}) { + super(); + this.name = name; + this.root = root; + this.baked = join(root, "layouts", `${name}.json`); + this.file = file; + this.interval = interval; + this.watching = false; + this.load(); + } + + // The baked default under the override. Missing or broken files fall back a + // layer rather than failing, so a session always has a shape. + load() { + const base = normalize(readJson(this.baked) || {}); + const over = normalize(readJson(this.file) || {}); + this.override = over; + this.spec = { bottom: ["gap", "bar", "gap", "status"], status: ["handle", "workspace", "model", "mode", "activity"], bar: [95, 70, 135], prompt: "›", separator: " · ", ...base, ...over }; + return this.spec; + } + + watch() { + if (this.watching) return; + this.watching = true; + watchFile(this.file, { interval: this.interval, persistent: false }, () => { + const before = JSON.stringify(this.spec); + this.load(); + if (JSON.stringify(this.spec) !== before) this.emit("change", this.spec); + }); + } + + close() { + if (!this.watching) return; + this.watching = false; + unwatchFile(this.file); + } + + // One key, set from the prompt: `/layout set status handle,model`. Lists + // come as comma-separated words, colours as three numbers, strings as-is. + set(key, value) { + const parsed = + key === "bottom" || key === "status" + ? String(value).split(/[,\s]+/).filter(Boolean) + : key === "bar" + ? String(value).split(/[,\s]+/).map(Number) + : String(value); + const next = normalize({ ...this.override, [key]: parsed }); + if (!(key in next)) throw new Error(`layout: \`${key}\` does not take \`${value}\``); + this.#writeOverride(next); + return this.spec[key]; + } + + reset() { + rmSync(this.file, { force: true }); + this.load(); + this.emit("change", this.spec); + } + + // The shape becomes the default: written into the package, the override + // removed so this session keeps looking the same, and the one file + // committed when the package is a checkout. A tarball install bakes to its + // own copy and commits nothing. + async bake({ git = true } = {}) { + const spec = { ...this.spec }; + mkdirSync(dirname(this.baked), { recursive: true }); + const temporary = `${this.baked}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(spec, null, 2)}\n`); + renameSync(temporary, this.baked); + rmSync(this.file, { force: true }); + this.load(); + let commit = ""; + if (git && !existsSync(join(this.root, "install.json"))) { + try { + await run("git", ["-C", this.root, "add", "--", this.baked]); + await run("git", ["-C", this.root, "commit", "-q", "-m", `aesel: bake the ${this.name} layout`, "--", this.baked]); + commit = (await run("git", ["-C", this.root, "rev-parse", "--short", "HEAD"])).stdout.trim(); + } catch (error) { + throw new Error(`layout baked to ${this.baked}, but git did not commit it: ${error.stderr?.trim() || error.message}`); + } + } + return { file: this.baked, commit, spec }; + } + + #writeOverride(next) { + mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 }); + const temporary = `${this.file}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`); + renameSync(temporary, this.file); + this.load(); + this.emit("change", this.spec); + } +} diff --git a/easel/src/render.mjs b/easel/src/render.mjs index f31bd25532..a3e7cdf330 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -582,7 +582,10 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // Pro has no header band: four rows at the bottom — air, the bar, air, the // status line — and the rest is the conversation. const pro = state.profile?.name === "pro" && !(state.desktop || state.desktopProsePrompt); - const transcriptRows = pro ? height - 4 : height - 5; + // The shape is data — see layout.mjs — so the rows under the transcript are + // whatever the layout says, in the order it says them. + const shape = { bottom: ["gap", "bar", "gap", "status"], status: ["handle", "workspace", "model", "mode", "activity"], bar: [95, 70, 135], prompt: "›", separator: " · ", ...(state.layout || {}) }; + const transcriptRows = pro ? height - shape.bottom.length : height - 5; // The QR code keeps its own column on the right, so the transcript is // narrowed rather than overdrawn. A code is an image, not text: it needs its // own black on white to be scannable, so a window with colour switched off or @@ -643,7 +646,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // Codex's shape: the transcript, a bar to type in with a blank line on // either side, and one muted line under it saying who, where, which model. // Nothing sits between the words and the typing. - const barBg = useColor ? bg(palette.bar) : ""; + const barBg = useColor ? bg(shape.bar) : ""; const ink = (rgb) => (useColor ? fg(rgb) : ""); let inner; if (state.approval) { @@ -661,21 +664,35 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // The cursor is painted by hand: `paint` would hand the row back to the // ground after it, and the bar has to run to the edge. const cursorCell = useColor ? `${bg(palette.block)}${under}${color.reset}${barBg}${ink(palette.text)}` : under; - inner = `${ink(palette.prompt)}›${ink(palette.text)} ${start > 0 ? "‹" : ""}${shown.slice(0, at).join("")}${cursorCell}${shown.slice(at + 1).join("")}`; + inner = `${ink(palette.prompt)}${shape.prompt}${ink(palette.text)} ${start > 0 ? "‹" : ""}${shown.slice(0, at).join("")}${cursorCell}${shown.slice(at + 1).join("")}`; } const bar = `${barBg}${ink(palette.text)}${fit(` ${inner}`, width)}${reset}`; const account = state.account || ""; const model = state.model || state.providerSettings?.model || ""; const engine = state.providerSettings?.backend || ""; - const facts = [ - account ? (account.startsWith("@") ? coloredHandle(account, state.handleColors, useColor) : paint(useColor, "muted", account)) : "", - paint(useColor, "muted", clipText(state.workspace || "", Math.max(8, Math.floor(width / 3)))), - model ? paint(useColor, "muted", `${model}${engine && engine !== "claude" ? ` · ${engine}` : ""}`) : "", - paint(useColor, "muted", mode.toLowerCase()), - state.busy ? paint(useColor, "muted", requestFeedback(state)) : state.scrollOffset ? paint(useColor, "muted", `${state.scrollOffset} lines above · End latest`) : "", - ].filter(Boolean); - const statusLine = ` ${facts.join(paint(useColor, "muted", " · "))}`; - return [...body, "", bar, "", statusLine] + const muted = (text) => (text ? paint(useColor, "muted", text) : ""); + const queuedInbox = (state.queued || []).filter((line) => line?.inbox).length; + const fact = { + handle: () => (account ? (account.startsWith("@") ? coloredHandle(account, state.handleColors, useColor) : muted(account)) : ""), + workspace: () => muted(clipText(state.workspace || "", Math.max(8, Math.floor(width / 3)))), + model: () => muted(model ? `${model}${engine && engine !== "claude" && !shape.status.includes("engine") ? ` · ${engine}` : ""}` : ""), + engine: () => muted(engine), + mode: () => muted(mode.toLowerCase()), + activity: () => muted(state.busy ? requestFeedback(state) : state.scrollOffset ? `${state.scrollOffset} lines above · End latest` : ""), + inbox: () => muted(queuedInbox ? `${queuedInbox} inbox queued` : ""), + }; + const facts = shape.status.map((name) => fact[name]?.() || "").filter(Boolean); + const statusLine = ` ${facts.join(muted(shape.separator))}`; + const rows = { + gap: () => "", + bar: () => bar, + status: () => statusLine, + rule: () => paint(useColor, "muted", "─".repeat(width)), + help: () => ` ${paint(useColor, "muted", clipText(state.busy ? requestFeedback(state) : "/help · /layout · /inbox · /mode · /ask · ctrl-c quit", width - 2))}`, + header: () => header, + path: () => pathLine, + }; + return [...body, ...shape.bottom.map((name) => rows[name]?.() ?? "")] .slice(0, height) .map((line) => `${ground}${fit(line, width)}${reset}`) .join("\n"); diff --git a/easel/src/required-sharing.mjs b/easel/src/required-sharing.mjs index 9a6b25aecb..cade74b4e1 100644 --- a/easel/src/required-sharing.mjs +++ b/easel/src/required-sharing.mjs @@ -1,9 +1,47 @@ import {createHash} from 'node:crypto'; import {mkdir,readFile,writeFile,rename,lstat} from 'node:fs/promises'; import {join} from 'node:path'; +import {MASCOT_SETTLED_MS,mascotRows} from './mascot.mjs'; +import {color,textWidth,wrapText} from './render.mjs'; export const DISCLOSURE_VERSION=4; export const TRANSCRIPT_DISCLOSURE='aesel requires sharing your messages, assistant replies, and artifact revision references with Aesthetic Computer to improve aesel, including when you bring your own Codex or Claude account. Uploaded transcripts are accessible only to authorized AC staff and retained indefinitely until you delete them. Recognizable credentials are redacted, but messages may contain personal information. For AI generation, prompts, conversation context, source code and tool results are sent to your selected provider. Hosted text uses OpenRouter and the selected model provider; hosted images use OpenAI and include reference images you supply. Provider privacy policies apply. Earlier private conversations are not uploaded. You can export or delete uploaded transcripts. If you do not agree, quit without starting a session.'; const account=session=>session.read()?.user?.sub; +// The first thing a new person sees. It used to be a paragraph in the corner +// of a black window; now the donkey stands over it in the middle, painted the +// way the interface paints him, and the paragraph is measured to sit under +// him rather than run to the edge. The words are the same words — this is a +// disclosure, and what it discloses is not decorated. +export const DISCLOSURE_COLUMNS=64; +export function sharingScreen({columns=80,rows=24,useColor=true,signedIn=false}={}){ + const width=Math.max(40,columns||80),height=Math.max(12,rows||24); + const ink=(tone,text)=>useColor?`${color[tone]||''}${text}${color.reset}`:text; + const donkey=mascotRows(MASCOT_SETTLED_MS).rows.map(({text,tone})=>({ + text:text.replace(/\s+$/,''), + // The head is the mark, the body is the soft purple, and the star on the + // canvas is the one orange thing on the screen. + painted:useColor?text.replace(/\s+$/,'').split('*').map(part=>ink(tone,part)).join(ink('highlight','*')):text.replace(/\s+$/,''), + })); + const paragraph=wrapText(TRANSCRIPT_DISCLOSURE,Math.min(DISCLOSURE_COLUMNS,width-4)); + const keys=`[A] Agree and continue${signedIn?'':' (sign in)'} [Q] Quit`; + const block=[ + ...donkey.map(row=>({plain:row.text,painted:row.painted})), + {plain:'',painted:''}, + {plain:'Aesel data sharing',painted:ink('highlight','Aesel data sharing')}, + {plain:'',painted:''}, + ...paragraph.map(line=>({plain:line,painted:ink('soft',line)})), + {plain:'',painted:''}, + {plain:keys,painted:`${ink('handle','[A]')} Agree and continue${signedIn?'':' (sign in)'} ${ink('handle','[Q]')} Quit`}, + ]; + const blockWidth=Math.max(...block.map(row=>textWidth(row.plain))); + const left=Math.max(0,Math.floor((width-blockWidth)/2)); + const top=Math.max(0,Math.floor((height-block.length)/2)); + // The paragraph keeps a straight left edge; the donkey, the title and the + // keys stand over its middle. + const centred=new Set(paragraph); + const indent=row=>centred.has(row.plain)||!row.plain?0:Math.floor((blockWidth-textWidth(row.plain))/2); + return '\r\n'.repeat(top)+block.map(row=>' '.repeat(left+indent(row))+row.painted).join('\r\n')+'\r\n'; +} + export async function readAcknowledgment(root,owner){ if(!owner)return null; try{const value=JSON.parse(await readFile(join(root,createHash('sha256').update(owner).digest('hex')+'.json'),'utf8'));return value.owner===owner&&value.version===DISCLOSURE_VERSION&&Number.isFinite(Date.parse(value.acceptedAt))?value:null;}catch(error){if(error.code==='ENOENT')return null;throw error;} @@ -18,7 +56,7 @@ export async function acknowledge(root,owner){ export async function requireSharing({root,session,input=process.stdin,output=process.stdout}){ const existing=await readAcknowledgment(root,account(session));if(existing)return existing; if(!input.isTTY||!output.isTTY)throw new Error('Open aesel interactively to read and accept required transcript sharing before use.'); - output.write('\x1b[2J\x1b[HAesel data sharing\r\n\r\n'+TRANSCRIPT_DISCLOSURE.replace(/(.{1,72})(?:\s+|$)/g,'$1\r\n')+'\r\n[A] Agree and continue'+(account(session)?'':' (sign in)')+' [Q] Quit\r\n'); + output.write('\x1b[2J\x1b[H'+sharingScreen({columns:output.columns,rows:output.rows,useColor:process.env.NO_COLOR!=='1',signedIn:!!account(session)})); const prior=input.isRaw;input.setRawMode(true);input.resume(); const accepted=await new Promise(resolve=>{const onData=data=>{const key=data.toString().toLowerCase();if(!['a','q','\x03','\x1b'].includes(key))return;input.off('data',onData);input.setRawMode(!!prior);input.pause();resolve(key==='a');};input.on('data',onData);}); if(!accepted)return null; diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index 3716f33f1a..aba40e6c0b 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -44,6 +44,7 @@ import { GENRES, genreFor } from "./genres.mjs"; import { Inbox } from "./inbox.mjs"; import { LivePiece } from "./live.mjs"; import { exampleConfig, resolveProfile } from "./profile.mjs"; +import { BOTTOM_ROWS, Layout, STATUS_FACTS } from "./layout.mjs"; import { DraftBroadcast } from "./draft-broadcast.mjs"; import { applyUpdate, checkForUpdate, currentVersion, installed } from "./updates.mjs"; import { publishPiece } from "./publish.mjs"; @@ -590,6 +591,15 @@ transcript.meta({ cwd, engine: backend.id, model, handle: session.handle || "", // never through the keyboard. The socket is optional: a path too long to bind // leaves the file queue, which `drainFile` still reads. const inbox = new Inbox({ sessionId: slabSession.sessionId }); +// The pro frame's shape, read from layouts/pro.json under ~/.config/easel's +// override, and followed while the session runs — see layout.mjs. +const shape = new Layout(); +state.layout = shape.spec; +if (pro) shape.watch(); +shape.on("change", (spec) => { + state.layout = spec; + redraw(); +}); // Listening before the bind: opening drains the file queue, and a line that // piled up while nobody was here is the first thing worth hearing. inbox.on("message", receiveInbox); @@ -903,6 +913,7 @@ startNativeGamepad(); // Both before the marker goes: a sender that finds the socket path in a // marker and no marker at all should be told the same thing — nobody home. await inbox.close().catch(() => {}); + shape.close(); slabSession.close(); transcript.event("engine", { status: "closed", engine: backend.id, model: state.model || model }); transcript.close(); @@ -1961,11 +1972,38 @@ async function submitInput(submittedText, submittedMessages = null) { addEntry( "notice", pro - ? "/ask [on|off] · /inbox · /mode · /backend [id] · /model [name] · /login · /logout · /whoami · /handle [name] · /update · /new · /clear · /quit ctrl-c interrupts a running turn" + ? "/ask [on|off] · /layout · /inbox · /mode · /backend [id] · /model [name] · /login · /logout · /whoami · /handle [name] · /update · /new · /clear · /quit ctrl-c interrupts a running turn" : "/about · /medium · /artifacts · /select UUID · /artifact · /export FILE · /sharing · /transcript · /profile · /inbox · /mode · /mouse [on|off] · /performance [frames] · /energy · /latest · /login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /versions · /rollback vN · /runtime [id] · /frame [ocr] · /settings · /backend [id] · /model [name] · /effort · /handle [name] · /update · /open · /qr · /new [thread] · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } + if (command === "/layout") { + const [verb = "", key = "", ...valueWords] = restWords; + try { + if (!verb) { + addEntry( + "notice", + `Layout · ${JSON.stringify(shape.spec)}\n` + + `Edit ${shape.file} and the frame follows while you type; /layout set does one key.\n` + + `bottom: ${BOTTOM_ROWS.join(" ")} · status: ${STATUS_FACTS.join(" ")} · bar: r,g,b · prompt · separator\n` + + `/layout reset · /layout bake (writes ${shape.baked} and commits it)`, + ); + } else if (verb === "set" && key) { + addEntry("notice", `Layout · ${key} = ${JSON.stringify(shape.set(key, valueWords.join(" ")))}`); + } else if (verb === "reset") { + shape.reset(); + addEntry("notice", "Layout · back to the baked default"); + } else if (verb === "bake") { + const baked = await shape.bake(); + addEntry("notice", `Layout · baked to ${baked.file}${baked.commit ? ` · committed ${baked.commit}` : " · not a checkout, nothing committed"}`); + } else { + addEntry("error", "Usage: /layout · /layout set · /layout reset · /layout bake"); + } + } catch (error) { + addEntry("error", errorText(error)); + } + return redraw(); + } if (command === "/mode") { addEntry( "notice", diff --git a/easel/test/layout.test.mjs b/easel/test/layout.test.mjs new file mode 100644 index 0000000000..6c57af3e98 --- /dev/null +++ b/easel/test/layout.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { Layout, normalize } from "../src/layout.mjs"; + +function scratch(context) { + const root = mkdtempSync(join(tmpdir(), "layout-")); + context.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, "pkg", "layouts"), { recursive: true }); + writeFileSync(join(root, "pkg", "layouts", "pro.json"), JSON.stringify({ bottom: ["bar", "status"], status: ["model"], prompt: ">" })); + return { root: join(root, "pkg"), file: join(root, "cfg", "layout.json") }; +} + +test("the baked default is read, the override merged, and nonsense dropped", (context) => { + const { root, file } = scratch(context); + const layout = new Layout({ root, file }); + assert.deepEqual(layout.spec.bottom, ["bar", "status"]); + assert.deepEqual(layout.spec.status, ["model"]); + assert.equal(layout.spec.prompt, ">"); + assert.equal(layout.spec.separator, " · ", "unset keys keep the built-in value"); + mkdirSync(join(root, "..", "cfg"), { recursive: true }); + writeFileSync(file, JSON.stringify({ status: ["handle", "bogus", "model"], bar: [1, 2], prompt: "way too long" })); + layout.load(); + assert.deepEqual(layout.spec.status, ["handle", "model"], "an unknown fact is skipped, not fatal"); + assert.deepEqual(layout.spec.bar, [95, 70, 135], "a colour that is not three channels is ignored"); + assert.equal(layout.spec.prompt, ">", "a glyph longer than two cells is ignored"); + assert.deepEqual(normalize({ bottom: "gap" }), {}, "a list that is not a list is nothing"); +}); + +test("a saved edit reaches the running session as a change", async (context) => { + const { root, file } = scratch(context); + const layout = new Layout({ root, file, interval: 50 }); + context.after(() => layout.close()); + layout.watch(); + const changed = new Promise((resolve) => layout.once("change", resolve)); + mkdirSync(join(root, "..", "cfg"), { recursive: true }); + writeFileSync(file, JSON.stringify({ bottom: ["gap", "status", "gap", "bar"] })); + const spec = await changed; + assert.deepEqual(spec.bottom, ["gap", "status", "gap", "bar"]); +}); + +test("set writes one key to the override and reset takes it back", (context) => { + const { root, file } = scratch(context); + const layout = new Layout({ root, file }); + const seen = []; + layout.on("change", (spec) => seen.push(spec.status)); + assert.deepEqual(layout.set("status", "handle, model, mode"), ["handle", "model", "mode"]); + assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), { status: ["handle", "model", "mode"] }); + assert.throws(() => layout.set("bar", "purple"), /does not take/); + layout.reset(); + assert.deepEqual(layout.spec.status, ["model"]); + assert.equal(seen.length, 2); +}); + +test("bake writes the shape into the package and commits that one file", async (context) => { + const { root, file } = scratch(context); + execFileSync("git", ["-C", root, "init", "-q"]); + execFileSync("git", ["-C", root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "start"]); + writeFileSync(join(root, "stray.txt"), "not mine to commit"); + const layout = new Layout({ root, file }); + layout.set("status", "handle,model"); + const result = await layout.bake(); + assert.match(result.commit, /^[0-9a-f]{7,}$/); + assert.deepEqual(JSON.parse(readFileSync(join(root, "layouts", "pro.json"), "utf8")).status, ["handle", "model"]); + assert.equal(execFileSync("git", ["-C", root, "status", "--porcelain"], { encoding: "utf8" }).trim(), "?? stray.txt", "only the layout was committed"); + assert.ok(!layout.override.status, "the override is gone once it is the default"); + assert.deepEqual(layout.spec.status, ["handle", "model"], "and the session looks the same"); +}); diff --git a/easel/test/render.test.mjs b/easel/test/render.test.mjs index 96a6a54a78..5439ddf7b1 100644 --- a/easel/test/render.test.mjs +++ b/easel/test/render.test.mjs @@ -305,3 +305,21 @@ test("an inbox line names its sender and cannot pass for a typed one", () => { assert.equal(rows[rows.length - 2].trim(), "", "air below the bar"); assert.match(rows[rows.length - 1], /@tester · \/client · claude-sonnet-5 · remote/, "the facts sit under the bar"); }); + +test("the pro frame takes its shape from the layout", () => { + const frame = renderFrame( + { + workspace: "/client", mode: "remote", status: "ready", busy: false, input: "", + account: "@tester", model: "gpt-6-astra", providerSettings: { backend: "codex", model: "gpt-6-astra" }, + profile: { name: "pro" }, + layout: { bottom: ["rule", "bar", "status"], status: ["model", "engine"], prompt: ">", separator: " | " }, + entries: [{ id: "u", kind: "user", text: "hi" }], + }, + 60, 10, false, + ); + const rows = frame.split("\n"); + assert.equal(rows.length, 10); + assert.match(rows[7], /^─+$/, "the rule is where the layout put it"); + assert.match(rows[8], /^ > /, "the prompt glyph is the layout's"); + assert.equal(rows[9].trim(), "gpt-6-astra | codex", "only the facts asked for, with the separator asked for"); +}); diff --git a/easel/test/required-sharing.test.mjs b/easel/test/required-sharing.test.mjs index b2250d167a..8d6ea139ad 100644 --- a/easel/test/required-sharing.test.mjs +++ b/easel/test/required-sharing.test.mjs @@ -17,3 +17,30 @@ test('an old retention acknowledgment requires agreement to the new disclosure', f.input.emit('data',Buffer.from('a')); assert.equal((await waiting).version,4); }); + +// ── the screen itself ─────────────────────────────────────────────────── +import { DISCLOSURE_COLUMNS, TRANSCRIPT_DISCLOSURE, sharingScreen } from "../src/required-sharing.mjs"; +const plainRows = (screen) => screen.replace(/\x1b\[[0-9;]*m/g, "").split("\r\n"); + +test("the disclosure stands in the middle of the window under the donkey", () => { + const rows = plainRows(sharingScreen({ columns: 120, rows: 40, useColor: false })); + const drawn = rows.filter((row) => row.trim()); + assert.ok(rows.findIndex((row) => row.trim()) > 4, "blank rows above the block put it in the middle, not the corner"); + assert.ok(drawn.some((row) => /\( [o-] [o-] \)/.test(row)), "the donkey is on the screen, eyes open or mid-blink"); + const paragraph = rows.find((row) => row.includes("aesel requires sharing")); + const indent = paragraph.length - paragraph.trimStart().length; + assert.ok(indent >= (120 - DISCLOSURE_COLUMNS) / 2 - 1, "the block is centred horizontally"); + assert.ok(Math.max(...drawn.map((row) => row.trimEnd().length - indent)) <= DISCLOSURE_COLUMNS, "no line runs past the paragraph's measure"); + const donkey = rows.find((row) => row.includes("/\\ /\\")); + assert.ok(donkey.length - donkey.trimStart().length > indent, "the donkey stands over the middle of the words, not their left edge"); + const words = drawn.map((row) => row.trim()).join(" "); + for (const word of TRANSCRIPT_DISCLOSURE.split(/\s+/).slice(0, 12)) assert.ok(words.includes(word), word); + assert.ok(drawn.at(-1).includes("[A] Agree and continue (sign in)"), "signed out asks to sign in"); +}); + +test("colour inks the star on its own, and a narrow window still fits", () => { + const painted = sharingScreen({ columns: 100, rows: 30, useColor: true, signedIn: true }); + assert.match(painted, /\x1b\[[0-9;]*m\*/, "the star on the canvas is inked on its own"); + assert.ok(!plainRows(painted).at(-2).includes("(sign in)"), "signed in does not ask to sign in"); + assert.ok(plainRows(sharingScreen({ columns: 44, rows: 12, useColor: false })).every((row) => row.length <= 44), "nothing wraps at 44 columns"); +}); -- 2.51.2