diff --git a/easel/layouts/pro.json b/easel/layouts/pro.json index d21007fc01..16dcab8ae9 100644 --- a/easel/layouts/pro.json +++ b/easel/layouts/pro.json @@ -1,5 +1,5 @@ { - "bottom": ["gap", "bar", "gap", "status"], + "bottom": ["bar", "gap", "status"], "status": ["handle", "workspace", "model", "activity"], "bar": [95, 70, 135], "prompt": "", diff --git a/easel/package.json b/easel/package.json index 015487ef14..b1a409a483 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.37", + "version": "0.7.38", "private": true, "type": "module", "scripts": { diff --git a/easel/src/layout.mjs b/easel/src/layout.mjs index 0295167f9f..068d4946eb 100644 --- a/easel/src/layout.mjs +++ b/easel/src/layout.mjs @@ -46,7 +46,7 @@ export function normalize(spec = {}) { if (typeof spec.mouse === "boolean") out.mouse = spec.mouse; // The bubbles' corners: slanted triangles, or stepped quadrant blocks for // a font without the triangles. - if (spec.corners === "slant" || spec.corners === "block") out.corners = spec.corners; + if (["slant", "block", "flat"].includes(spec.corners)) out.corners = spec.corners; // How your own lines are set: outline (default), bubble, or lines. if (["outline", "bubble", "lines"].includes(spec.typed)) out.typed = spec.typed; return out; @@ -85,7 +85,7 @@ export class Layout extends EventEmitter { 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", "activity"], bar: [95, 70, 135], prompt: "", separator: " · ", mouse: true, lines: false, ...base, ...over }; + this.spec = { bottom: ["bar", "gap", "status"], status: ["handle", "workspace", "model", "activity"], bar: [95, 70, 135], prompt: "", separator: " · ", mouse: true, lines: false, ...base, ...over }; return this.spec; } diff --git a/easel/src/render.mjs b/easel/src/render.mjs index 1b22b43e07..dcf1aa9144 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -664,7 +664,7 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const pro = state.profile?.name === "pro" && !(state.desktop || state.desktopProsePrompt); // 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", "activity"], bar: [95, 70, 135], prompt: "", separator: " · ", ...(state.layout || {}) }; + const shape = { bottom: ["bar", "gap", "status"], status: ["handle", "workspace", "model", "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 @@ -899,7 +899,7 @@ export function windowTitle(state) { export function dropdownGeometry(state, width, height, shape = state.layout || {}) { const drop = state.dropdown; if (!drop) return null; - const bottom = shape.bottom || ["gap", "bar", "gap", "status"]; + const bottom = shape.bottom || ["bar", "gap", "status"]; const statusRow = bottom.lastIndexOf("status"); // The anchor row is the status line; without one, the bar. const anchor = height - bottom.length + (statusRow >= 0 ? statusRow : Math.max(0, bottom.lastIndexOf("bar"))); @@ -936,9 +936,9 @@ function paintDropdown(rows, state, width, height, useColor, shape) { return rows; } -let CORNERS = "slant"; +let CORNERS = "flat"; let TYPED_STYLE = "outline"; -export function setCorners(style) { CORNERS = style === "block" ? "block" : "slant"; } +export function setCorners(style) { CORNERS = ["slant", "block", "flat"].includes(style) ? style : "flat"; } // How your own lines are set: an outline box (the default), a filled pink // bubble, or plain lines on the page. export function setTypedStyle(style) { TYPED_STYLE = ["outline", "bubble", "lines"].includes(style) ? style : "outline"; } @@ -977,11 +977,12 @@ function cloudRows(rows, width, tint = CLOUD_TINT) { const inner = Math.min(width - 2, Math.max(8, ...rows.map((row) => paintedWidth(row)))); const edge = fg(tint.bg); const ground = `${color.reset}${color.ground}`; - // Slanted corners by default — the triangles cut the corner on the - // diagonal — or stepped quadrants when the layout says `corners: block`. - const slant = CORNERS !== "block"; - const top = `${edge}${slant ? "◢" : "▗"}${"▄".repeat(inner)}${slant ? "◣" : "▖"}${ground}`; - const bottom = `${edge}${slant ? "◥" : "▝"}${"▀".repeat(inner)}${slant ? "◤" : "▘"}${ground}`; + // The edges: plain half-block rows by default, which read as a soft card — + // corner glyphs looked stepped or spiky at text size. `corners: slant` cuts + // them with the triangles, `corners: block` with the quadrants. + const ends = CORNERS === "slant" ? ["◢", "◣", "◥", "◤"] : CORNERS === "block" ? ["▗", "▖", "▝", "▘"] : ["▄", "▄", "▀", "▀"]; + const top = `${edge}${ends[0]}${"▄".repeat(inner)}${ends[1]}${ground}`; + const bottom = `${edge}${ends[2]}${"▀".repeat(inner)}${ends[3]}${ground}`; const body = rows.map((row) => `${bg(tint.bg)}${fg(tint.ink)} ${fit(row.replace(/\x1b\[49m/g, bg(tint.bg)), inner)}${bg(tint.bg)} ${ground}`); return [top, ...body, bottom]; } @@ -1123,7 +1124,7 @@ export function headerAction(state, columns, rows, x, y) { // Pro draws no header and no model controls. The one thing to click is the // model on the status line, which opens the settings drawer. if (state.profile?.name === "pro") { - const shape = { bottom: ["gap", "bar", "gap", "status"], ...(state.layout || {}) }; + const shape = { bottom: ["bar", "gap", "status"], ...(state.layout || {}) }; // An open drop-down owns the mouse: a row of it picks, anywhere else closes. const g = dropdownGeometry(state, Math.max(32, columns), Math.max(10, rows), shape); if (g) { diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index ddb2da95d5..e499767c24 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -2636,7 +2636,7 @@ function handleKeys(buffer) { // anchors, a drag stretches, the release copies. The bottom line keeps // its clicks; a wheel keeps its scroll. if (pro && !state.dropdown && !state.settings && !state.about) { - const transcriptRows = (process.stdout.rows || 24) - ((state.layout?.bottom || ["gap", "bar", "gap", "status"]).length); + const transcriptRows = (process.stdout.rows || 24) - ((state.layout?.bottom || ["bar", "gap", "status"]).length); if (mouse.press && mouse.y <= transcriptRows) { state.selection = { anchor: [mouse.x, mouse.y], head: [mouse.x, mouse.y], active: true }; redraw(); diff --git a/easel/test/render.test.mjs b/easel/test/render.test.mjs index 360c02d162..2bf88da204 100644 --- a/easel/test/render.test.mjs +++ b/easel/test/render.test.mjs @@ -303,7 +303,6 @@ test("an inbox line names its sender and cannot pass for a typed one", () => { assert.doesNotMatch(frame, /REMOTE · READY/, "pro has no header band"); assert.doesNotMatch(frame, /\/publish/); const rows = frame.split("\n"); - assert.equal(rows[rows.length - 4].trim(), "", "air above the bar"); assert.match(rows[rows.length - 3], /^ {3,}$/, "the bar is empty: the terminal's own cursor stands there"); assert.equal(rows[rows.length - 2].trim(), "", "air below the bar"); assert.match(rows[rows.length - 1], /@tester · \/client · claude-sonnet-5/, "the facts sit under the bar"); @@ -393,7 +392,7 @@ test("a drop-down stands on the fact that opened it, and a click on one of its r assert.equal(g.x, modelSpan.x, "it stands on the model"); const frame = renderFrame(state, 80, 24, false).split("\n"); assert.match(frame[g.top], /▾ provider · model/); - assert.match(frame[g.top + 1], /^ *claude {2,}/, "a provider heads its group"); + assert.match(frame[g.top + 1], /claude {2,}/, "a provider heads its group"); assert.match(frame[g.top + 2], / Claude Opus 5.5 {2,}claude-opus-5-5/); assert.match(frame[g.top + 3], /› Claude Sonnet 5 {2,}claude-sonnet-5/, "the current model is marked"); assert.equal(headerAction(state, 80, 24, g.x + 2, g.top + 3), "pick:1", "clicking a model row picks it");