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.