From 0cbb5315e09d62d933dacade2a79caed2d398ab9 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 16:49:12 -0700 Subject: [PATCH] aesel pro: the page is ruled, a typed line wears the glyph, the donkey dances while it works, a dead bridge reopens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notebook lines under the transcript rows — the blank part of each row underlined in the muted ink, so the words sit on the line the way they do on the GUI's page; `lines: false` in the layout for a plain page. A line you typed shows with the prompt glyph and no YOU badge. While the machine has the floor the little guy dances on the status line beside the phase and the seconds, so a long turn is visibly alive. And a bridge that dies is reopened, once every few seconds, with the conversation carried over, instead of leaving the interface deaf. --- easel/package.json | 2 +- easel/src/layout.mjs | 2 ++ easel/src/render.mjs | 27 ++++++++++++++++++++++++--- easel/src/tui.mjs | 15 +++++++++++++++ easel/test/render.test.mjs | 5 ++++- 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/easel/package.json b/easel/package.json index 7bcd2d4c1a..910624819e 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.23", + "version": "0.7.24", "private": true, "type": "module", "scripts": { diff --git a/easel/src/layout.mjs b/easel/src/layout.mjs index 6ecfa8f227..34b1d0b706 100644 --- a/easel/src/layout.mjs +++ b/easel/src/layout.mjs @@ -38,6 +38,8 @@ export function normalize(spec = {}) { 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; + // Notebook lines under the transcript rows; off for a plainer page. + if (typeof spec.lines === "boolean") out.lines = spec.lines; return out; } diff --git a/easel/src/render.mjs b/easel/src/render.mjs index 7e35f892fa..a6ba914d1a 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -290,6 +290,8 @@ const STYLES = { notice: ["·", "muted"], error: ["!", "error"], inbox: ["↓", "inbox"], + // A line you typed, in pro: the prompt glyph and the words, no badge. + typed: ["›", "prompt"], }; const BODY_TONES = { notice: "muted", error: "error", inbox: "inbox" }; @@ -600,7 +602,7 @@ 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(entry, contentWidth, useColor)); + : state.entries.flatMap((entry) => entryLines(pro && entry.kind === "user" ? { ...entry, kind: "typed" } : entry, contentWidth, useColor)); 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)) @@ -680,7 +682,11 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { header: () => header, path: () => pathLine, }; - return paintDropdown([...body, ...shape.bottom.map((name) => rows[name]?.() ?? "")].slice(0, height), state, width, height, useColor, shape) + // 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; + return paintDropdown([...ruled, ...shape.bottom.map((name) => rows[name]?.() ?? "")].slice(0, height), state, width, height, useColor, shape) .map((line) => `${ground}${fit(line, width)}${reset}`) .join("\n"); } @@ -793,6 +799,20 @@ function paintDropdown(rows, state, width, height, useColor, shape) { return rows; } +// The columns a painted row occupies, escapes not counted. +function paintedWidth(row) { + return textWidth(String(row ?? "").replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g, "")); +} + +// A transcript row on ruled paper: the words as they were, then the rest of +// the line underlined in the muted ink, faint enough to read as paper. +function ruleRow(row, width) { + const used = Math.min(width, paintedWidth(row)); + const rest = width - used; + if (rest <= 0) return row; + return `${fit(row, used)}${color.muted}\x1b[2m\x1b[4m${" ".repeat(rest)}\x1b[24m\x1b[22m${color.reset}${color.ground}`; +} + // The status line under the bar, and where each fact on it starts, so the // frame can paint it and a click can find the model on it. export function proStatus(state, width, useColor, shape = state.layout || {}) { @@ -808,7 +828,8 @@ export function proStatus(state, width, useColor, shape = state.layout || {}) { model, engine: providerLabel(engine), mode: state.mode === "local" ? "local" : "remote", - activity: state.busy ? requestFeedback(state) : state.status === "connecting" ? "connecting…" : state.status === "offline" ? "offline" : state.scrollOffset ? `${state.scrollOffset} lines above · End latest` : "", + // The little guy dances on the line while the machine has the floor. + activity: state.busy ? `${mascotRow(state.mascotMs ?? 0, true)} ${requestFeedback(state)}` : state.status === "connecting" ? "connecting…" : state.status === "offline" ? "offline" : state.scrollOffset ? `${state.scrollOffset} lines above · End latest` : "", inbox: queuedInbox ? `${queuedInbox} inbox queued` : "", }; const muted = (text) => paint(useColor, "muted", text); diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index b4fd496381..a9df97412f 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -625,6 +625,7 @@ const inboxPoll = setInterval(() => { inboxPoll.unref?.(); // One engine at a time, wired to the same handlers however it was built. +let lastRelaunchAt = 0; function openEngine({ resume = "" } = {}) { const opened = new backend.Engine({ cwd, @@ -672,7 +673,21 @@ function openEngine({ resume = "" } = {}) { state.status = "offline"; addEntry("error", errorText(error)); slabSession.awaitingInput("easel engine bridge is offline"); + // A turn the bridge died under is over; nothing will complete it. + if (state.busy) { state.busy = false; turnAssistant = []; } redraw(); + // A bridge that died is relaunched so the next line has somewhere to go — + // the interface used to sit there deaf, with every key typed into a + // thread that no longer existed. Once a few seconds, so a bridge that + // dies on arrival does not spin. + if (Date.now() - lastRelaunchAt > 5000) { + lastRelaunchAt = Date.now(); + setTimeout(() => { + if (closing || engine !== opened) return; + addEntry("notice", `Reopening ${backend.label} · the conversation so far goes with it`); + void restartEngine("Engine", backend, model, effort, { drain: false }).catch(() => {}); + }, 300); + } }); return opened; } diff --git a/easel/test/render.test.mjs b/easel/test/render.test.mjs index 8972cd01df..6e80fb9a34 100644 --- a/easel/test/render.test.mjs +++ b/easel/test/render.test.mjs @@ -293,7 +293,10 @@ test("an inbox line names its sender and cannot pass for a typed one", () => { 12, false, ); - assert.match(frame, /YOU look at the diff/); + // In pro a typed line wears the prompt glyph, not a badge; an inbox line + // still wears its arrow and its sender, so the two never pass for each other. + assert.match(frame, /› look at the diff/); + assert.doesNotMatch(frame, /YOU/); assert.match(frame, /↓ neo:sip · the build finished/); // Codex's shape: no header band, a bar with air on both sides, and one // line under it with the handle, the directory and the model. -- 2.51.2