diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index 31480a4c49..73cff73d98 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -329,3 +329,6 @@ body.preview-fullscreen #preview-waveform{display:none} #activity-caption::before{animation-delay:.25s} @keyframes aesel-thought-dot{0%,100%{opacity:.5;transform:translateY(1px) scale(.8)}50%{opacity:1;transform:translateY(-1px) scale(1.12)}} @media(prefers-reduced-motion:reduce){#activity-caption::before,#activity-caption::after{animation:none}} + +#token-grass{position:fixed;z-index:3;pointer-events:none} +#token-grass[hidden],body.preview-fullscreen #token-grass{display:none} diff --git a/easel/desktop/index.html b/easel/desktop/index.html index 260e0cd9f5..39c35623f5 100644 --- a/easel/desktop/index.html +++ b/easel/desktop/index.html @@ -5,4 +5,4 @@
- + diff --git a/easel/desktop/package-lock.json b/easel/desktop/package-lock.json index 7eef5ab5a0..36ca3c15ae 100644 --- a/easel/desktop/package-lock.json +++ b/easel/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "aesel-desktop", - "version": "0.7.10", + "version": "0.7.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aesel-desktop", - "version": "0.7.10", + "version": "0.7.11", "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 291d0a8fc6..21be891041 100644 --- a/easel/desktop/package.json +++ b/easel/desktop/package.json @@ -1,7 +1,7 @@ { "name": "aesel-desktop", "productName": "aesel", - "version": "0.7.10", + "version": "0.7.11", "private": true, "main": "main.cjs", "scripts": { diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index 1aceb601b0..933964d14e 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -180,6 +180,7 @@ 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); +const tokenGrass=window.installTokenGrass(promptFeedback); window.placeNotebookActivity=()=>{ const last=Array.from(document.querySelectorAll('#notebook-page article[data-kind="user"]')).at(-1); const target=promptState?.text?promptLine:(last?.lastElementChild||last||promptLine); @@ -225,6 +226,7 @@ let draggingSelection = false, pendingOutput = '', pastedInput = false; let clickOrigin = null; let lastHoverCell = '', hoverFrame = 0, pendingHover = null; terminal.parser.registerOscHandler(777, data => { + if(data.startsWith('easel-token-grass:')){try{tokenGrass.receive(JSON.parse(data.slice('easel-token-grass:'.length)));}catch{}return true;} if(data==='easel-camera:request'){window.flashPreviewCapture?.();return true;} if(data.startsWith('easel-gamepad:')){try{forwardGamepad(JSON.parse(data.slice('easel-gamepad:'.length)));}catch{}return true;} if(data.startsWith('easel-prompt:')){try{updatePrompt(JSON.parse(data.slice('easel-prompt:'.length)));}catch{}return true;} diff --git a/easel/desktop/scripts/linux-download.html b/easel/desktop/scripts/linux-download.html index f7f2703b17..bdfc00594d 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/desktop/token-grass.js b/easel/desktop/token-grass.js new file mode 100644 index 0000000000..c00681195f --- /dev/null +++ b/easel/desktop/token-grass.js @@ -0,0 +1,40 @@ +// Incoming public text/code only. No invented glyphs, reasoning, transcript, or network work. +window.installTokenGrass=anchor=>{ + const canvas=document.createElement('canvas');canvas.id='token-grass';canvas.hidden=true;canvas.setAttribute('aria-hidden','true');document.body.append(canvas); + const ctx=canvas.getContext('2d'),reduced=matchMedia('(prefers-reduced-motion: reduce)'); + let blades=[],frame=0,lastPaint=0,serial=0; + function clear(){blades=[];cancelAnimationFrame(frame);frame=0;canvas.hidden=true;} + function paint(now){ + frame=0; + if(document.hidden||anchor.hidden||!anchor.isConnected){clear();return;} + if(now-lastPaint<32){frame=requestAnimationFrame(paint);return;}lastPaint=now; + blades=blades.filter(b=>now-b.at<1300); + if(!blades.length){clear();return;} + const rect=anchor.getBoundingClientRect(),sheet=document.getElementById('conversation').getBoundingClientRect(); + if(rect.bottomsheet.bottom){clear();return;} + const width=Math.min(160,rect.width+36,innerWidth-20),height=30,dpr=Math.min(devicePixelRatio||1,2); + canvas.style.left=`${Math.max(10,Math.min(innerWidth-width-10,rect.left-10))}px`; + canvas.style.top=`${Math.min(sheet.bottom-height,rect.bottom-10)}px`; + canvas.style.width=`${width}px`;canvas.style.height=`${height}px`; + if(canvas.width!==Math.round(width*dpr)||canvas.height!==Math.round(height*dpr)){canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);} + ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);ctx.font='10px monospace';ctx.textBaseline='bottom'; + for(const b of blades){ + const age=(now-b.at)/1300,x=5+b.lane*(width-12),sway=reduced.matches?0:Math.sin(age*4+b.lane*6)*2; + const rise=reduced.matches?0:Math.min(1,age*5)*b.height; + ctx.globalAlpha=Math.max(0,(1-age)*.8);ctx.fillStyle=b.lane>.65?'#baffbf':'#60e58a'; + ctx.fillText(b.glyph,x+sway,height-2-rise); + } + ctx.globalAlpha=1;canvas.hidden=false;frame=requestAnimationFrame(paint); + } + function receive({delta='',reset=false}={}){ + if(reset)clear(); + if(typeof delta!=='string'||document.hidden||anchor.hidden)return; + const glyphs=Array.from(delta).filter(c=>!/[\s\x00-\x1f\x7f]/.test(c)).slice(-48),at=performance.now(); + for(const glyph of glyphs){const n=serial++;blades.push({glyph,at,lane:((n*17)%53)/53,height:5+(n%12)});} + blades=blades.slice(-72);if(blades.length&&!frame)frame=requestAnimationFrame(paint); + } + document.addEventListener('visibilitychange',()=>{if(document.hidden)clear();}); + new MutationObserver(()=>{if(anchor.hidden)clear();}).observe(anchor,{attributes:true,attributeFilter:['hidden']}); + reduced.addEventListener('change',()=>{if(blades.length&&!frame)frame=requestAnimationFrame(paint);}); + return {receive,clear}; +}; diff --git a/easel/package.json b/easel/package.json index ce80dfcf72..cefa20b951 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.10", + "version": "0.7.11", "private": true, "type": "module", "scripts": { diff --git a/easel/src/ac-server.mjs b/easel/src/ac-server.mjs index ea719e72b6..f5e1edbb94 100644 --- a/easel/src/ac-server.mjs +++ b/easel/src/ac-server.mjs @@ -380,7 +380,10 @@ export class AcServer extends EventEmitter { }); } else if (delta?.type === "input_json_delta") { const partial = partials.get(event.index); - if (partial) partial.json += delta.partial_json || ""; + if (partial) { + partial.json += delta.partial_json || ""; + if (partial.name === 'write_piece') this.emit('notification', {method:'item/modelCode/delta',params:{delta:delta.partial_json || ''}}); + } } } else if (event.type === "content_block_stop") { const partial = partials.get(event.index); diff --git a/easel/src/claude-server.mjs b/easel/src/claude-server.mjs index aee40045af..e81cfb7926 100644 --- a/easel/src/claude-server.mjs +++ b/easel/src/claude-server.mjs @@ -111,6 +111,7 @@ export class ClaudeServer extends EventEmitter { // Text streaming state for the message being written right now. this.messageId = ""; this.textItems = new Map(); + this.codeItems = new Set(); this.streamedText = false; } @@ -435,9 +436,15 @@ export class ClaudeServer extends EventEmitter { if (event.type === "message_start") { this.messageId = event.message?.id || `message-${Date.now()}`; this.textItems.clear(); + this.codeItems.clear(); this.streamedText = false; return; } + if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use' && ['Write','Edit','MultiEdit','write_piece'].includes(event.content_block.name)) this.codeItems.add(event.index); + if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta' && this.codeItems.has(event.index)) { + this.emit('notification',{method:'item/modelCode/delta',params:{delta:event.delta.partial_json || ''}}); + } + if (event.type === 'content_block_stop') this.codeItems.delete(event.index); if (event.type === "content_block_start" && event.content_block?.type === "text") { this.textItems.set(event.index, `${this.messageId}:${event.index}`); return; @@ -566,6 +573,7 @@ export class ClaudeServer extends EventEmitter { const id = this.turnId || `turn-${this.turns}`; this.turnId = null; this.textItems.clear(); + this.codeItems.clear(); // The CLI closes a turn with what it spent. `modelUsage` is keyed by the // model that actually ran — which is not always the one asked for, and a // fallback is exactly when the energy readout should not lie about which diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index c55658d6ae..d3b9c5454b 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -500,6 +500,8 @@ restoreDesktopEngine(engine, desktopRestored); let drawing = false; let redrawTimer = null; let lastDrawAt = 0; +let pendingModelGlyphs = "", resetModelGlyphs = false; +function queueModelGlyphs(delta) { if (process.env.EASEL_DESKTOP && typeof delta === "string") pendingModelGlyphs = (pendingModelGlyphs + cleanText(delta)).slice(-256); } let modelCatalog = null, desktopHistoryKey = "", desktopHistory = []; let lastLayout = "", lastProvider = "", lastConversation = "", lastPrompt = ""; if (process.env.EASEL_DESKTOP) { @@ -583,6 +585,7 @@ function redraw() { if (process.env.EASEL_DESKTOP) { const prompt=JSON.stringify({text:state.input,cursor:state.cursor,activity:publicActivity(state),feedback:state.busy?requestFeedback(state):state.queued.length?'Gathering your messages':'',hidden:!!(state.approval||state.settings||state.about)}); if(prompt!==lastPrompt){lastPrompt=prompt;process.stdout.write(`\x1b]777;easel-prompt:${prompt}\x07`);} + if(pendingModelGlyphs || resetModelGlyphs){process.stdout.write(`\x1b]777;easel-token-grass:${JSON.stringify({delta:pendingModelGlyphs,reset:resetModelGlyphs})}\x07`);pendingModelGlyphs="";resetModelGlyphs=false;} const conversation=JSON.stringify({hidden:!!(state.settings||state.about),entries:state.entries.filter(e=>notebookConversationEntry(e)&&e.id!=='feed-registration'&&!(e.kind==='error'&&(connectionFailure(e.text)||/^Live push failed: Incomplete or invalid JavaScript/.test(e.text)))&&(e.id!=='autopublish'||e.kind==='error')).map(e=>({id:e.id,kind:e.kind,text:e.kind==='error'?conciseFailure(e.text):e.text}))}); if(conversation!==lastConversation){lastConversation=conversation;process.stdout.write(`\x1b]777;easel-conversation:${conversation}\x07`);} } @@ -913,6 +916,7 @@ function handleNotification({ method, params = {} }) { } switch (method) { case "turn/started": + pendingModelGlyphs="";resetModelGlyphs=true; state.activityText="";state.activityIntent="";state.activityStage="";state.activityMessageId=null;state.activityTools?.clear(); state.requestStartedAt ||= Date.now(); state.busy = true; @@ -933,7 +937,11 @@ function handleNotification({ method, params = {} }) { } state.progressBytes = params.bytes || state.progressBytes || 0; break; + case "item/modelCode/delta": + queueModelGlyphs(params.delta); + break; case "item/agentMessage/delta": + queueModelGlyphs(params.delta); state.status = "generating"; if (!streamedMessageId || streamedMessageId !== params.itemId) { streamedMessageId = params.itemId; diff --git a/easel/test/ac-server.test.mjs b/easel/test/ac-server.test.mjs index 8ffc2a9e23..219dcdf594 100644 --- a/easel/test/ac-server.test.mjs +++ b/easel/test/ac-server.test.mjs @@ -303,3 +303,17 @@ test("upstream reported model stays distinct from the requested model", async () assert.deepEqual(reported, {requested:"requested/model", reported:"reported/model"}); assert.equal(engine.model, "requested/model"); }); + + +test('code grass gets generated write arguments, never thinking or tool results', async t => { + const dir=await mkdtemp(join(tmpdir(),'ac-grass-'));t.after(()=>rm(dir,{recursive:true,force:true})); + const file=join(dir,'piece.mjs');await writeFile(file,'// start'); + const output=[], code='// rolling wheels'; + const engine=new AcServer({piece:{file},jev:null,token:async()=>'tok',fetch:serving([ + {type:'content_block_delta',index:8,delta:{type:'thinking_delta',thinking:'private reasoning'}}, + ...writes(code) + ],say('Ready'))}); + engine.on('notification',({method,params})=>{if(method==='item/modelCode/delta')output.push(params.delta)}); + await engine.startTurn('roll'); + assert.deepEqual(output,[JSON.stringify({source:code,note:'first draft'})]); +}); diff --git a/easel/test/claude-server.test.mjs b/easel/test/claude-server.test.mjs index fe6e6d8b80..2e791ce18d 100644 --- a/easel/test/claude-server.test.mjs +++ b/easel/test/claude-server.test.mjs @@ -46,10 +46,12 @@ test("drives a turn, streams text, and routes the approval to the interface", as const engine = bridge(t, { environment: { FAKE_CLAUDE_DECISION: decisionFile } }); const deltas = []; + const codeDeltas = []; const items = []; const completed = new Promise((resolve) => { engine.on("notification", ({ method, params }) => { if (method === "item/agentMessage/delta") deltas.push(params.delta); + if (method === "item/modelCode/delta") codeDeltas.push(params.delta); if (method === "item/started") items.push(params.item); if (method === "item/completed") items.push(params.item); if (method === "turn/completed") resolve(params.turn.status); @@ -68,6 +70,7 @@ test("drives a turn, streams text, and routes the approval to the interface", as assert.equal(turn.turn.status, "inProgress"); assert.equal(await completed, "completed"); assert.equal(deltas.join(""), "Writing the piece."); + assert.deepEqual(codeDeltas,['{"content":"// hi"}']); // The interface follows the piece through fileChange items. const started = items.find((item) => item.type === "fileChange"); diff --git a/easel/test/fake-claude-cli.mjs b/easel/test/fake-claude-cli.mjs index aea9aa259b..1e66f924af 100644 --- a/easel/test/fake-claude-cli.mjs +++ b/easel/test/fake-claude-cli.mjs @@ -78,6 +78,13 @@ createInterface({ input: process.stdin }).on("line", (line) => { for (const delta of ["Writing ", "the piece."]) { send({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: delta } } }); } + for (const event of [ + {type:'content_block_delta',index:8,delta:{type:'thinking_delta',thinking:'private reasoning'}}, + {type:'content_block_start',index:1,content_block:{type:'tool_use',name:'Write'}}, + {type:'content_block_delta',index:1,delta:{type:'input_json_delta',partial_json:'{"content":"// hi"}'}}, + {type:'content_block_stop',index:1}, + {type:'content_block_delta',index:1,delta:{type:'input_json_delta',partial_json:'stale'}}, + ]) send({type:'stream_event',event}); send({ type: "assistant", message: { diff --git a/easel/test/token-grass.electron.cjs b/easel/test/token-grass.electron.cjs new file mode 100644 index 0000000000..83ce77407c --- /dev/null +++ b/easel/test/token-grass.electron.cjs @@ -0,0 +1,31 @@ +const {app,BrowserWindow,ipcMain}=require('electron'); +const assert=require('node:assert/strict'),path=require('node:path'),fs=require('node:fs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms));let win; +(async()=>{ + await app.whenReady();ipcMain.handle('native-prox-title',()=>({glyphs:[]})); + win=new BrowserWindow({show:false,width:620,height:400,webPreferences:{preload:path.resolve('easel/desktop/preload.cjs'),contextIsolation:true,sandbox:true,backgroundThrottling:false}}); + await win.loadFile(path.resolve('easel/desktop/index.html')); + const js=s=>win.webContents.executeJavaScript(s); + win.webContents.send('theme',{background:'#142131',foreground:'#fffbea',cursor:'#eeaacc'}); + await js(`document.body.dataset.phase='ready';window.updateConversation({entries:[{id:'u',kind:'user',text:'Make the wheels roll.'}]})`); + const send=(name,value)=>win.webContents.send('output','\x1b]777;easel-'+name+':'+JSON.stringify(value)+'\x07'); + send('prompt',{text:'',cursor:0,activity:"I'm giving those wheels a little momentum",feedback:'Writing'});await wait(150); + assert.equal(await js(`document.getElementById('token-grass').hidden`),true); + await js(`window.glyphs=[];const c=document.getElementById('token-grass').getContext('2d'),f=c.fillText.bind(c);c.fillText=(s,...args)=>{glyphs.push(s);return f(s,...args)};void 0`); + const delta='wheel.rotation += speed * dt;'; + send('token-grass',{delta});await wait(200); + assert.equal(await js(`document.getElementById('token-grass').hidden`),false); + assert.equal(await js(`getComputedStyle(document.getElementById('token-grass')).pointerEvents`),'none'); + assert.equal(await js(`glyphs.every(c=>${JSON.stringify(delta)}.includes(c))`),true,'Every visible glyph came from the delta'); + const bounds=await js(`(()=>{const d=document.getElementById('prompt-feedback').getBoundingClientRect(),g=document.getElementById('token-grass').getBoundingClientRect();return {feet:Math.abs(g.top-d.bottom)<12,inside:g.right<=innerWidth&&g.left>=0}})()`); + assert(bounds.feet&&bounds.inside); + fs.writeFileSync('/tmp/aesel-token-grass.png',(await win.webContents.capturePage()).toPNG()); + await wait(1400);assert.equal(await js(`document.getElementById('token-grass').hidden`),true,'No generated stream while quiet'); + await js('glyphs=[]');send('token-grass',{delta:'x'.repeat(100000)});await wait(90); + assert((await js('glyphs.length'))<=144,'A burst cannot create unbounded blades'); + win.setSize(380,400);await wait(50); + assert.equal(await js(`document.getElementById('token-grass').getBoundingClientRect().right<=innerWidth`),true); + send('prompt',{text:'',cursor:0,activity:'',feedback:''});await wait(60); + assert.equal(await js(`document.getElementById('token-grass').hidden`),true); + console.log('PASS real glyphs, feet position, quiet decay, bounded bursts, narrow viewport, idle cleanup'); +})().catch(e=>{console.error(e);process.exitCode=1}).finally(()=>{win?.destroy();app.quit()}); diff --git a/system/public/aesel/index.html b/system/public/aesel/index.html index 0d68df79b2..1625716ca2 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.10 · 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.11 · Linux 0.7.0 (Easel)