diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index cdc4427720..27865ca59f 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -322,3 +322,5 @@ body.preview-fullscreen #preview-waveform{display:none} #notebook-activity[hidden]{display:none} #notebook-activity #prompt-feedback{flex:none} #notebook-activity #activity-caption{flex:0 1 auto;min-width:0;max-width:34ch;box-sizing:border-box;translate:0 var(--activity-bounce,0px)} + +#activity-text{display:block;max-height:7.8em;overflow-y:auto;overscroll-behavior:contain;pointer-events:auto;scrollbar-width:thin} diff --git a/easel/desktop/package-lock.json b/easel/desktop/package-lock.json index 7b9b90bdee..205631e391 100644 --- a/easel/desktop/package-lock.json +++ b/easel/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "aesel-desktop", - "version": "0.7.7", + "version": "0.7.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aesel-desktop", - "version": "0.7.7", + "version": "0.7.8", "dependencies": { "@xterm/addon-fit": "0.10.0", "@xterm/addon-webgl": "0.18.0", diff --git a/easel/desktop/package.json b/easel/desktop/package.json index 65aca26e99..5b5cca911c 100644 --- a/easel/desktop/package.json +++ b/easel/desktop/package.json @@ -1,7 +1,7 @@ { "name": "aesel-desktop", "productName": "aesel", - "version": "0.7.7", + "version": "0.7.8", "private": true, "main": "main.cjs", "scripts": { diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index 80aecf6ac4..bcf7300418 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -176,6 +176,8 @@ const promptLine=document.createElement('div');promptLine.id='prose-prompt';prom promptLine.setAttribute('aria-hidden','true');document.getElementById('notebook-page').append(promptLine); const promptFeedback=document.createElement('span');promptFeedback.id='prompt-feedback';promptFeedback.hidden=true;document.body.append(promptFeedback); const activityCaption=document.createElement('span');activityCaption.id='activity-caption';activityCaption.hidden=true;activityCaption.setAttribute('role','status');activityCaption.setAttribute('aria-live','off'); +const activityText=document.createElement('span');activityText.id='activity-text'; +const activityTextNode=document.createTextNode('');activityText.append(activityTextNode);activityCaption.append(activityText); const activityGroup=document.createElement('span');activityGroup.id='notebook-activity';activityGroup.hidden=true;activityGroup.append(promptFeedback,activityCaption);document.body.append(activityGroup); window.installNotebookDonkey(promptFeedback); window.placeNotebookActivity=()=>{ @@ -198,7 +200,13 @@ function updatePrompt(value){ promptState=value;promptLine.hidden=!!value.hidden;positionPrompt(); activityGroup.hidden=!!value.hidden||!value.feedback; activityCaption.hidden=activityGroup.hidden||!value.activity; - activityCaption.textContent=activityCaption.hidden?'':`(${value.activity})`; + const caption=activityCaption.hidden?'':String(value.activity||''); + const followsText=activityText.scrollHeight-activityText.clientHeight-activityText.scrollTop<3; + const continuesText=caption.startsWith(activityTextNode.data); + if(activityTextNode.data!==caption){ + activityTextNode.data=caption; + if(followsText||!continuesText)activityText.scrollTop=activityText.scrollHeight; + } promptFeedback.textContent='';promptFeedback.setAttribute('role','img');promptFeedback.setAttribute('aria-label',value.feedback||'Idle');promptFeedback.hidden=!!value.hidden||!value.feedback; const chars=Array.from(value.text||''),index=Math.max(0,Math.min(chars.length,value.cursor??chars.length)); const before=document.createTextNode(chars.slice(0,index).join('')); diff --git a/easel/desktop/scripts/linux-download.html b/easel/desktop/scripts/linux-download.html index 5c700e6dab..7d29a51fad 100644 --- a/easel/desktop/scripts/linux-download.html +++ b/easel/desktop/scripts/linux-download.html @@ -12,8 +12,8 @@ sudo apt install ./Easel-0.7.0-linux-amd64.deb
chmod +x Easel-0.7.0-linux-x86_64.AppImage
 ./Easel-0.7.0-linux-x86_64.AppImage

macOS

-

Download for Apple silicon

-

ZIP archive · signed and notarized · macOS 12+

+

Download for Apple silicon

+

ZIP archive · signed and notarized · macOS 12+

If FUSE is unavailable, try --appimage-extract-and-run. On Ubuntu, use the .deb above to install the required sandbox. Do not disable the sandbox.

aesel requires sharing conversations and artifact revision references with authorized Aesthetic Computer staff for product improvement, including when using your own provider. Read and accept the disclosure before use. Hosted generation requires account access; bringing your own Claude or Codex requires its CLI and account.

Tested on Ubuntu 24.04 x86-64. ARM builds are not included. SHA-256 checksums · Aesthetic Computer

diff --git a/easel/package.json b/easel/package.json index d40b4c2acf..5e378a33e5 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.7", + "version": "0.7.8", "private": true, "type": "module", "scripts": { diff --git a/easel/src/public-activity.mjs b/easel/src/public-activity.mjs index 5b33aea4bd..98f72eac7e 100644 --- a/easel/src/public-activity.mjs +++ b/easel/src/public-activity.mjs @@ -1,21 +1,47 @@ // Public agent text and observed tool stages only; never private reasoning or tool payloads. -export function toolActivity(item={}) { - const name=String(item.tool||'').split(' · ')[0]; - if(item.type==='fileChange')return 'editing the piece'; - if(/(?:^|__)ac_frame$/.test(name))return 'looking at the preview'; - if(/(?:^|__)ac_preview$/.test(name))return 'checking the preview'; - if(/(?:^|__)(?:Read|read_file|ac_symbol|ac_outline)$/.test(name))return 'reading source'; - if(/(?:^|__)(?:ac_api|ac_examples|ac_references|Grep|Glob)$/.test(name))return 'looking up details'; - if(item.type==='commandExecution')return 'running a command'; - return 'using a tool'; +const toolTypes = new Set(['fileChange', 'commandExecution', 'mcpToolCall', 'dynamicToolCall']); + +export function toolActivity(item = {}) { + const name = String(item.tool || '').split(' · ')[0]; + if (item.type === 'fileChange' || /(?:^|__)(?:write_piece|Write|Edit|apply_patch)$/.test(name)) return "I'm editing the piece"; + if (/(?:^|__)ac_frame$/.test(name)) return "I'm looking at the preview"; + if (/(?:^|__)ac_preview$/.test(name)) return "I'm checking the preview"; + if (/(?:^|__)(?:Read|read_file|ac_symbol|ac_outline)$/.test(name)) return "I'm reading the source"; + if (/(?:^|__)(?:ac_api|ac_examples|ac_references|Grep|Glob)$/.test(name)) return "I'm looking up details"; + if (item.type === 'commandExecution' || /(?:^|__)(?:Bash|exec_command)$/.test(name)) return "I'm running a command"; + return "I'm using a tool"; } + +// Retain other active tools when parallel calls finish out of order. A completion +// without a matching start (e.g. harness advice) must not replace live activity. +export function observeToolActivity(state, method, item) { + if (!toolTypes.has(item?.type) || !item.id) return; + state.activityTools ||= new Map(); + if (method === 'item/started') { + state.activityTools.set(item.id, toolActivity(item)); + state.activityText = ''; + state.activityMessageId = null; + } else if (method === 'item/completed') { + if (!state.activityTools.delete(item.id)) return; + if (!state.activityTools.size && ['tool', 'writing'].includes(state.status)) state.status = 'working'; + } else return; + state.activityStage = [...state.activityTools.values()].at(-1) || ''; +} + export function publicActivity(state) { - if(state.connectionNotice)return state.connectionNotice; - if(state.previewNotice&&!state.busy)return state.previewNotice.replace('editing · ','code needs correction · '); - if(!state.busy)return ''; - if(state.status==='approval')return 'waiting for approval'; - if(state.status==='interrupting')return 'stopping'; - const text=String(state.activityText||'').replace(/\s+/g,' ').trim(); - if(text)return text.length>160?'…'+text.slice(-159):text; - return state.activityStage||({preparing:'preparing',connecting:'connecting',waiting:'waiting for the agent',generating:'composing a reply',approval:'waiting for approval',interrupting:'stopping'}[state.status])||'working'; + if (state.connectionNotice) return "I'm offline; your changes are kept here"; + if (state.previewNotice && !state.busy) return "I need to fix the code; I've kept the previous preview"; + if (!state.busy) return ''; + if (state.status === 'approval') return "I'm waiting for your approval"; + if (state.status === 'interrupting') return "I'm stopping"; + const text = String(state.activityText || '').replace(/\s+/g, ' ').trim(); + if (text) return text; + return state.activityStage || ({ + preparing: "I'm on my way", + connecting: "I'm connecting", + waiting: "I'm waiting for a response", + generating: "I'm putting my reply together", + composing: "I'm preparing my next step", + writing: "I'm saving the changes", + }[state.status]) || "I'm working on it"; } diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index 58eb70b8dc..588c9f5f6b 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -5,7 +5,7 @@ import {conceptRequest} from './concept-request.mjs'; import {takeSubmittedBatch,inputBatchDelay} from './input-batch.mjs'; import {startNativeGamepad} from './native-gamepad.mjs'; import {requestFeedback} from './request-feedback.mjs'; -import {publicActivity,toolActivity} from './public-activity.mjs'; +import {publicActivity,observeToolActivity} from './public-activity.mjs'; import {notebookConversationEntry} from './notebook-conversation.mjs'; import {connectionFailure,conciseFailure} from './connection-status.mjs'; import {ApprovalQueue, approvalFor, approvalShape, defaultApprovalResponse} from "./approvals.mjs"; @@ -383,7 +383,7 @@ function toolInstructions() { } function developerInstructions() { - const replyStyle = "Default to one short sentence, usually under 25 words, about the visible result. Start work silently, including on the first request for a new piece. Do not send a greeting, acknowledgement, plan, or pre-tool preamble such as 'I will look at the current piece first', 'Let me read the file', or 'I will make that change'. Reading source and checking the preview are internal work; the interface already shows activity. For an actionable request, speak after making the change, or when a concrete blocker, necessary question, or consent decision requires the user. A direct question may be answered immediately without inventing work. Use plain, warm language. Do not summarize the request, list changes, announce success, discuss your process, or end with an offer. Ask one gentle question only when it helps the user explore or make a necessary choice. Never add a question just to sound Socratic. Expand only when the user asks for an explanation or essential evidence requires it. Omit routine URLs, commits, hashes, file paths, tool names, tests, and publishing details. Report material failures, limitations, costs, and required consent honestly and briefly. When discussing a tunable color or numeric constant, use its exact source color literal or constant identifier, preferably as inline code, so the notebook can bind it to an editor. Mention only values useful to the request; do not dump a palette or parameter list. Aesel renders Markdown tables, LaTeX math in $...$ or $$...$$, mermaid fenced diagrams, and static svg fenced vector figures. Prefer concise mathematical notation (for example ×, →, θ, fractions, or a short equation), a small diagram, or a meaningful symbol when it explains an idea more directly than words. Define unfamiliar symbols briefly. Do not add decorative icons or longer explanations just to exercise the renderer. Use one compact visual when requested or when it explains more clearly than prose; do not add decorative headings or restate the visual. This is rich chat rendering, not a full LaTeX document compiler. Do not output image URLs or HTML for figures."; + const replyStyle = "Default to one short sentence, usually under 25 words, about the visible result. Start work silently, including on the first request for a new piece. Do not send a greeting, acknowledgement, plan, or pre-tool preamble such as 'I will look at the current piece first', 'Let me read the file', or 'I will make that change'. Reading source and checking the preview are internal work; the interface already shows activity. For an actionable request, speak after making the change, or when a concrete blocker, necessary question, or consent decision requires the user. A direct question may be answered immediately without inventing work. Use plain, warm language. Speak in first person as the donkey, with natural contractions (for example, “I made the circle smaller”). Your public words stream into the donkey’s thought bubble. Do not wrap them in parentheses or narrate the donkey in third person. Describe only actions and results supported by the current tool evidence; do not invent progress or claim a visual check you have not made. Do not summarize the request, list changes, announce success, discuss your process, or end with an offer. Ask one gentle question only when it helps the user explore or make a necessary choice. Never add a question just to sound Socratic. Expand only when the user asks for an explanation or essential evidence requires it. Omit routine URLs, commits, hashes, file paths, tool names, tests, and publishing details. Report material failures, limitations, costs, and required consent honestly and briefly. When discussing a tunable color or numeric constant, use its exact source color literal or constant identifier, preferably as inline code, so the notebook can bind it to an editor. Mention only values useful to the request; do not dump a palette or parameter list. Aesel renders Markdown tables, LaTeX math in $...$ or $$...$$, mermaid fenced diagrams, and static svg fenced vector figures. Prefer concise mathematical notation (for example ×, →, θ, fractions, or a short equation), a small diagram, or a meaningful symbol when it explains an idea more directly than words. Define unfamiliar symbols briefly. Do not add decorative icons or longer explanations just to exercise the renderer. Use one compact visual when requested or when it explains more clearly than prose; do not add decorative headings or restate the visual. This is rich chat rendering, not a full LaTeX document compiler. Do not output image URLs or HTML for figures."; if (state.medium !== 'piece') return [ replyStyle, `You are in aesel making a ${state.medium}. Use the artifact tools to edit the selected artifact, not write_piece or direct filesystem edits.`, @@ -913,7 +913,7 @@ function handleNotification({ method, params = {} }) { } switch (method) { case "turn/started": - state.activityText="";state.activityStage="";state.activityMessageId=null; + state.activityText="";state.activityStage="";state.activityMessageId=null;state.activityTools?.clear(); state.requestStartedAt ||= Date.now(); state.busy = true; startDance(); @@ -927,6 +927,7 @@ function handleNotification({ method, params = {} }) { break; case "turn/progress": state.status = params.phase || "working"; + if (["connecting", "waiting", "composing"].includes(state.status)) state.activityText = ""; state.progressBytes = params.bytes || state.progressBytes || 0; break; case "item/agentMessage/delta": @@ -941,7 +942,7 @@ function handleNotification({ method, params = {} }) { } break; case "item/started": { - if (["fileChange","commandExecution","mcpToolCall","dynamicToolCall"].includes(params.item?.type)) {state.activityStage=toolActivity(params.item);state.activityText="";state.activityMessageId=null;} + observeToolActivity(state, method, params.item); if (process.env.EASEL_DESKTOP && /(?:^|__)ac_frame(?:$|\s)/.test(String(params.item?.tool || ""))) process.stdout.write('\x1b]777;easel-camera:request\x07'); state.status = "tool"; if (params.item?.type === "fileChange") state.status = "writing"; @@ -951,6 +952,7 @@ function handleNotification({ method, params = {} }) { } case "item/completed": { const item = params.item; + observeToolActivity(state, method, item); if (item?.type === "agentMessage") { updateEntry(item.id, "assistant", item.text); transcriptCompleted.add(item.id);const entry=state.entries.find(e=>e.id===item.id);if(entry){entry.activityOnly=state.busy;state.activityMessageId=entry.id;state.activityText=entry.text;} } const summary = itemSummary(item); if (summary) { @@ -977,7 +979,7 @@ function handleNotification({ method, params = {} }) { // message of its own, so the meter reads it from here when it is there. if (params.turn?.usage) state.energy.add(params.turn.model || state.model || model, params.turn.usage); const finalReply=state.entries.find(e=>e.id===state.activityMessageId);if(finalReply)delete finalReply.activityOnly; - state.activityText="";state.activityStage="";state.activityMessageId=null; + state.activityText="";state.activityStage="";state.activityMessageId=null;state.activityTools?.clear(); state.busy = false; state.status = params.turn?.status === "failed" ? "failed" : "ready"; engine.turnId = null; @@ -1794,9 +1796,10 @@ async function submitInput(submittedText, submittedMessages = null) { const observed=state.medium==='piece'?readRuntimeFeedback(cwd,{channel:live.channel,revision:createHash('sha256').update(live.source()).digest('hex')}):null; let pixels={images:[],context:''}; if(state.medium==='piece'){ - state.activityStage='looking at the preview';redraw(); + state.activityStage="I'm checking the preview";redraw(); if(process.env.EASEL_DESKTOP)process.stdout.write('\x1b]777;easel-camera:request\x07'); pixels=await inputPixels(cwd,{channel:live.channel,revision:createHash('sha256').update(live.source()).digest('hex'),images:backend.id!=='ac'}); + state.activityStage='';redraw(); } await engine.startTurn(text+runtimeFeedbackContext(observed)+pixels.context,{images:pixels.images}); } catch (error) { diff --git a/easel/test/desktop-interaction.electron.cjs b/easel/test/desktop-interaction.electron.cjs index 2f4a40eda8..76abb51ade 100644 --- a/easel/test/desktop-interaction.electron.cjs +++ b/easel/test/desktop-interaction.electron.cjs @@ -76,7 +76,7 @@ let win; await delay(100); assert.equal( await js(`document.getElementById('activity-caption').textContent`), - "(testing the keys)", + "testing the keys", ); assert.equal( await js( diff --git a/easel/test/notebook-layout.electron.cjs b/easel/test/notebook-layout.electron.cjs index 61bb06a466..7746d37781 100644 --- a/easel/test/notebook-layout.electron.cjs +++ b/easel/test/notebook-layout.electron.cjs @@ -78,7 +78,7 @@ const assert = require("node:assert/strict"); await win.webContents.executeJavaScript( `document.querySelector('#activity-caption')?.textContent||''`, ), - "(Reading the piece)", + "Reading the piece", ); assert.equal( await win.webContents.executeJavaScript( @@ -92,6 +92,22 @@ const assert = require("node:assert/strict"); ), 2, ); + // Real OSC token updates keep one text node, stay in the bubble, and remain + // scrollable when a requested explanation grows beyond a short sentence. + await win.webContents.executeJavaScript(`window.captionNode=document.getElementById('activity-text').firstChild`); + const reply="I've made the circle smaller. ".repeat(20); + for (const activity of ["I", "I've", "I've made", reply]) { + win.webContents.send('output', '\x1b]777;easel-prompt:' + JSON.stringify({text:'',cursor:0,activity,feedback:'Receiving reply'}) + '\x07'); + await delay(50); + assert.equal(await win.webContents.executeJavaScript(`document.getElementById('activity-caption').textContent`),activity); + assert.equal(await win.webContents.executeJavaScript(`document.getElementById('activity-text').firstChild===window.captionNode`),true); + assert.equal(await win.webContents.executeJavaScript(`document.querySelectorAll('#notebook-page article').length`),2); + } + assert.equal(await win.webContents.executeJavaScript(`(()=>{const t=document.getElementById('activity-text');return t.scrollHeight>t.clientHeight&&t.scrollHeight-t.clientHeight-t.scrollTop<3})()`),true); + await win.webContents.executeJavaScript(`document.getElementById('activity-text').scrollTop=0`); + win.webContents.send('output', '\x1b]777;easel-prompt:' + JSON.stringify({text:'',cursor:0,activity:reply+'More.',feedback:'Receiving reply'}) + '\x07'); + await delay(50); + assert.equal(await win.webContents.executeJavaScript(`document.getElementById('activity-text').scrollTop`),0); win.webContents.send( "output", "\x1b]777;easel-prompt:" + diff --git a/easel/test/public-activity.test.mjs b/easel/test/public-activity.test.mjs new file mode 100644 index 0000000000..74613ee652 --- /dev/null +++ b/easel/test/public-activity.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { publicActivity, observeToolActivity, toolActivity } from '../src/public-activity.mjs'; + +test('bubble uses first-person observed phases, with no claim of success', () => { + for (const status of ['preparing','connecting','waiting','generating','composing','writing','working','approval','interrupting']) { + assert.match(publicActivity({busy:true,status}), /^I'm /); + } + assert.equal(publicActivity({busy:false,activityText:'stale',activityStage:'stale'}), ''); + assert.equal(publicActivity({busy:true,status:'approval',activityText:'stale'}), "I'm waiting for your approval"); + assert.equal(publicActivity({busy:true,status:'interrupting',activityText:'stale'}), "I'm stopping"); +}); + +test('actual public deltas retain the whole reply, not a sliding tail', () => { + const state = {busy:true,status:'generating',activityText:''}; + const words = "I've made the circle smaller. ".repeat(12); + for (const char of words) { + state.activityText += char; + assert.equal(publicActivity(state), state.activityText.trim()); + } +}); + +test('parallel tools remain accurate as calls finish out of order', () => { + const state = {busy:true,status:'tool',activityText:'old reply',activityMessageId:'old'}; + const read = {id:'read',type:'mcpToolCall',tool:'mcp__ac__ac_symbol'}; + const frame = {id:'frame',type:'dynamicToolCall',tool:'ac_frame · private detail'}; + observeToolActivity(state,'item/started',read); + assert.equal(publicActivity(state), "I'm reading the source"); + assert.equal(state.activityMessageId,null); + observeToolActivity(state,'item/started',frame); + assert.equal(publicActivity(state), "I'm looking at the preview"); + observeToolActivity(state,'item/completed',read); + assert.equal(publicActivity(state), "I'm looking at the preview"); + observeToolActivity(state,'item/completed',{id:'advice',type:'dynamicToolCall',tool:'jev'}); + assert.equal(publicActivity(state), "I'm looking at the preview"); + observeToolActivity(state,'item/completed',frame); + assert.equal(publicActivity(state), "I'm working on it"); + assert.equal(state.status,'working'); +}); + +test('only known operations receive specific captions; arguments never appear', () => { + assert.equal(toolActivity({type:'fileChange'}), "I'm editing the piece"); + assert.equal(toolActivity({type:'dynamicToolCall',tool:'Write · /private/file'}), "I'm editing the piece"); + assert.equal(toolActivity({type:'dynamicToolCall',tool:'Bash · secret command'}), "I'm running a command"); + assert.equal(toolActivity({type:'mcpToolCall',tool:'unknown',arguments:{secret:'private'}}), "I'm using a tool"); +}); diff --git a/system/public/aesel/index.html b/system/public/aesel/index.html index 126ba45532..83728bce60 100644 --- a/system/public/aesel/index.html +++ b/system/public/aesel/index.html @@ -14,7 +14,7 @@

Make art and software with AI.

- Download for Mac + Download for Mac
Apple silicon · macOS 12+ · Linux & more
@@ -24,7 +24,7 @@
-

Downloads

Sign in with Aesthetic Computer.
Use AC, Claude, or Codex.

Mac Apple silicon · DMG↓Linux Ubuntu / Debian · x86-64↓AppImage Other Linux desktops · x86-64↓
Terminalcurl -fsSL https://aesthetic.computer/aesel.sh | sh

Requires Node.js 22+. Run ac after installing.

Mac 0.7.7 · Linux 0.7.0 (Easel)

+

Downloads

Sign in with Aesthetic Computer.
Use AC, Claude, or Codex.

Mac Apple silicon · DMG↓Linux Ubuntu / Debian · x86-64↓AppImage Other Linux desktops · x86-64↓
Terminalcurl -fsSL https://aesthetic.computer/aesel.sh | sh

Requires Node.js 22+. Run ac after installing.

Mac 0.7.8 · Linux 0.7.0 (Easel)