From 24012e1567487ba92e6a6f63b4a51adf6ac22f0d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 23 Sep 2026 18:34:46 -0700 Subject: [PATCH] aesel pro: tools are reported, not transcribed; a question takes the page; open is allowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool that runs shows on the status line beside the timer while it runs and leaves no line in the conversation — the transcript on disk still records it. When an action needs an answer, the question takes the page: what is asked, and Allow once / Allow every time this session / Deny, chosen with arrows and Enter or with y, a, n; the answer flashes on the status line. Opening an app or a file with `open` is allowed without asking — it hands something to the desktop and changes nothing here. --- easel/package.json | 2 +- easel/src/render.mjs | 30 +++++++++++++++++++++++++++++- easel/src/tui.mjs | 29 +++++++++++++++++++++++------ easel/test/render.test.mjs | 17 +++++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/easel/package.json b/easel/package.json index c5e189b3c2..d9fdfa241b 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.31", + "version": "0.7.32", "private": true, "type": "module", "scripts": { diff --git a/easel/src/render.mjs b/easel/src/render.mjs index e7c2498b85..c98ae40c5a 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -743,6 +743,12 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { const ruled = shape.lines !== false && useColor ? body.map((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 + // else on screen until one is given. + if (state.approval) { + const modal = approvalModal(state, width, transcriptRows, useColor); + return [...modal, ...shape.bottom.map((name) => (name === "bar" ? `${useColor ? bg(shape.bar) : ""}${fit("", width)}${reset}` : rows[name]?.() ?? ""))].slice(0, height).map((line) => `${ground}${fit(line, width)}${reset}`).join("\n"); + } // A selection in progress or just made: those cells in reverse video, on // top of whatever the row was painting. const shown = state.selection @@ -882,6 +888,28 @@ function selectRow(plain, [from, to], width, useColor) { return `${paint(true, "text", before)}\x1b[7m${paint(true, "text", chosen)}\x1b[27m${paint(true, "text", after)}`; } +// The question, centred, with its three answers under it. The row in hand +// wears the block; y, a and n are written after each answer for the hand +// that knows them. +export function approvalModal(state, width, rows, useColor) { + const inner = Math.min(width - 4, 76); + const subject = wrapText(cleanText(state.approval.subject || "requested action"), inner); + const choices = [["Allow once", "y"], ["Allow every time this session", "a"], ["Deny", "n"]]; + const index = state.approvalIndex || 0; + const lines = [ + paint(useColor, "highlight bold", "allow?"), + "", + ...subject.map((line) => paint(useColor, "text", line)), + "", + ...choices.map(([label, key], i) => (i === index ? paint(useColor, "block bold", ` ${label} `) : paint(useColor, "soft", ` ${label} `)) + paint(useColor, "muted", ` ${key}`)), + ]; + const top = Math.max(0, Math.floor((rows - lines.length) / 2)); + const left = Math.max(0, Math.floor((width - inner) / 2)); + const out = Array.from({ length: rows }, () => ""); + lines.forEach((line, i) => { if (top + i < rows) out[top + i] = `${" ".repeat(left)}${line}`; }); + return out; +} + // The columns a painted row occupies, escapes not counted. function paintedWidth(row) { return textWidth(String(row ?? "").replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g, "")); @@ -933,7 +961,7 @@ export function proStatus(state, width, useColor, shape = state.layout || {}) { mode: state.mode === "local" ? "local" : "remote", // The little guy dances on the line while the machine has the floor, and // beside him the seconds, the way Claude Code counts them. - activity: state.busy ? `${mascotRow(state.mascotMs ?? 0, true)} ${workingTimer(state)}` : state.selection && !state.selection.active ? "selected · Enter copies · Esc clears" : state.flash && state.flash.until > Date.now() ? state.flash.text : state.status === "connecting" ? "connecting…" : state.status === "offline" ? "offline" : state.scrollOffset ? `${state.scrollOffset} lines above · End latest` : "", + activity: state.busy ? `${mascotRow(state.mascotMs ?? 0, true)} ${workingTimer(state)}${state.toolNow ? ` · ${clipText(state.toolNow.replace(/\s+/g, " "), Math.max(12, Math.floor(width / 3)))}` : ""}` : state.selection && !state.selection.active ? "selected · Enter copies · Esc clears" : state.flash && state.flash.until > Date.now() ? state.flash.text : 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 3238d0e53e..d5c0c5f53e 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -1325,7 +1325,10 @@ function handleNotification({ method, params = {} }) { if (params.item?.type === "fileChange") state.status = "writing"; const summary = itemSummary(params.item); if (summary) { - updateEntry(params.item.id, summary.kind, summary.text); + // Pro reports a tool on the status line while it runs, not as a line + // of the conversation; the transcript on disk still records it. + if (pro) state.toolNow = summary.text; + else updateEntry(params.item.id, summary.kind, summary.text); transcript.event("tool_call", { id: params.item.id, name: summary.kind, input: summary.text }); } break; @@ -1342,7 +1345,8 @@ function handleNotification({ method, params = {} }) { } else if (item.status) { suffix = ` · ${item.status}`; } - updateEntry(item.id, summary.kind, `${summary.text}${suffix}`); + if (pro) state.toolNow = ""; + else updateEntry(item.id, summary.kind, `${summary.text}${suffix}`); transcript.event("tool_result", { id: item.id, name: summary.kind, summary: `${summary.text}${suffix}` }); } break; @@ -1460,16 +1464,20 @@ function handleRequest(request) { return; } // Every tool/provider follows the same default YOLO preference as edits/commands. - const automatic = state.autoAllow && defaultApprovalResponse(request); + // Opening an app or a file with `open` is allowed without asking: it hands + // something to the desktop and changes nothing here. + const opensApp = pro && approval.kind === "command" && /(^|\s|["'])open\s+(-a\s+|\S)/.test(approval.subject || ""); + const automatic = (state.autoAllow || opensApp) && defaultApprovalResponse(request); if (automatic) { engine.respond(request.id, automatic); - transcript.event("approval", { subject: approval.subject, decision: "auto" }); + transcript.event("approval", { subject: approval.subject, decision: opensApp ? "open" : "auto" }); // Automatic approval is activity, not a conversation message. redraw(); return; } approvalQueue.enqueue(request, engine); - addEntry(approval.kind === "unsupported" ? "error" : "notice", approval.subject); + if (!pro) addEntry(approval.kind === "unsupported" ? "error" : "notice", approval.subject); + state.approvalIndex = 0; showPendingApproval(); } @@ -1483,7 +1491,8 @@ function answerApproval(character) { engine.respond(answer.approval.id, answer.response); const key = character.toLowerCase(); const result = key === "n" ? (answer.approval.kind === "unsupported" ? "Dismissed unsupported request" : "Denied") : key === "\u0003" ? "Cancelled" : "Allowed"; - addEntry("notice", `${result}: ${answer.approval.subject}`); + if (pro) flash(`${result.toLowerCase()} · ${clipText(answer.approval.subject, 60)}`); + else addEntry("notice", `${result}: ${answer.approval.subject}`); transcript.event("approval", { subject: answer.approval.subject, decision: result }); showPendingApproval(); if (key === "\u0003" && !state.approval) slabSession.interrupted(); @@ -2526,6 +2535,14 @@ function handleKey(input) { redraw(); } if (dropdownKey(input)) return; + // The approval modal in pro: arrows choose, Enter answers; y, a and n + // still answer directly. + if (pro && state.approval) { + const choices = ["y", "a", "n"]; + if (input === "\x1b[A" || input === "\x1b[B") { state.approvalIndex = ((state.approvalIndex || 0) + (input === "\x1b[A" ? 2 : 1)) % 3; return redraw(); } + if (input === "\r" || input === "\n") return void answerApproval(choices[state.approvalIndex || 0]); + if (input === "\x1b") return void answerApproval("n"); + } if (answerApproval(input)) return; if (input === "\u0003") { diff --git a/easel/test/render.test.mjs b/easel/test/render.test.mjs index 94ddbad6fd..0baf5620c8 100644 --- a/easel/test/render.test.mjs +++ b/easel/test/render.test.mjs @@ -435,3 +435,20 @@ test("while the machine works the handle breathes on the dance clock", async () assert.notEqual(bright, dim, "the handle is painted differently across the beat"); assert.equal(proStatus({ ...base, busy: false, mascotMs: 700 }, 80, true).line, proStatus({ ...base, busy: false, mascotMs: 100 }, 80, true).line, "and holds still when idle"); }); + +test("in pro a question takes the page with three answers, and a running tool rides the status line", async () => { + const { approvalModal, proStatus } = await import("../src/render.mjs"); + const base = { workspace: "/c", mode: "remote", status: "approval", input: "", account: "@t", model: "m", profile: { name: "pro" }, entries: [{ id: "a", kind: "assistant", text: "I will list the files." }] }; + const frame = renderFrame({ ...base, approval: { id: 1, subject: "run: ls -la" }, approvalIndex: 1 }, 80, 20, false); + assert.match(frame, /allow\?/); + assert.match(frame, /run: ls -la/); + assert.match(frame, /Allow once {2,}y/); + assert.match(frame, /Allow every time this session {2,}a/); + assert.match(frame, /Deny {2,}n/); + assert.doesNotMatch(frame, /I will list the files/, "the conversation waits behind the question"); + const rows = approvalModal({ approval: { subject: "x" }, approvalIndex: 0 }, 80, 12, false); + assert.equal(rows.length, 12, "the modal fills exactly the transcript rows"); + const busy = { ...base, status: "working", busy: true, requestStartedAt: Date.now(), toolNow: "/bin/zsh -lc \"git status --short\"" }; + assert.match(proStatus(busy, 100, false).line, /s… · \/bin\/zsh -lc "git status --short"/, "the tool shows beside the timer"); + assert.doesNotMatch(renderFrame(busy, 100, 20, false), /RUN/, "and not as a line of the conversation"); +}); -- 2.54.0