diff --git a/apple/aesel/Info.plist b/apple/aesel/Info.plist
index 16f73214dc..f38ec4b9f5 100644
--- a/apple/aesel/Info.plist
+++ b/apple/aesel/Info.plist
@@ -15,7 +15,7 @@
chmod +x Easel-0.7.0-linux-x86_64.AppImage ./Easel-0.7.0-linux-x86_64.AppImage
ZIP archive · signed and notarized · macOS 12+
+ +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/transcript.js b/easel/desktop/transcript.js index c3ddd07378..8af10bcf3e 100644 --- a/easel/desktop/transcript.js +++ b/easel/desktop/transcript.js @@ -58,7 +58,7 @@ const newestUser=value.entries.filter(entry=>entry.kind==='user'&&entry.text?.trim()).at(-1)?.id; for(const entry of value.entries){ if(typeof entry.id!=='string'||typeof entry.text!=='string')continue; - if(['command','change','tool','publish'].includes(entry.kind)||!entry.text.trim())continue; + if(!['user','assistant','error'].includes(entry.kind)||!entry.text.trim())continue; if(entry.kind==='notice'&&/^New [^\n]+\nPrevious thread saved: /.test(entry.text))continue; if(entry.kind==='notice'&&/^(?:Engine|Model|Settings|Medium|Artifact) · .* · current piece and recent conversation carried over$/.test(entry.text))continue; if(entry.kind==='notice'&&/^(?:Desktop thread restored|Desktop (?:restart|update|home) queued|Ran: |Queued(?: \(\d+ queued\))? · )/.test(entry.text))continue; diff --git a/easel/package.json b/easel/package.json index a39e16e379..0a9905ae7f 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.3", + "version": "0.7.4", "private": true, "type": "module", "scripts": { diff --git a/easel/phone/host.html b/easel/phone/host.html index c076d28ea7..d89bfb855d 100644 --- a/easel/phone/host.html +++ b/easel/phone/host.html @@ -39,6 +39,7 @@ "node:os": "/easel/phone/shim/absent.mjs", "node:http": "/easel/phone/shim/absent.mjs", "node:child_process": "/easel/phone/shim/absent.mjs", + "node:readline": "/easel/phone/shim/absent.mjs", "/easel/src/preview-frame.mjs": "/easel/phone/shim/preview-frame.mjs", "/easel/src/jev-advisor.mjs": "/easel/phone/shim/jev-advisor.mjs", "/easel/src/revisions.mjs": "/easel/phone/shim/revisions.mjs" diff --git a/easel/phone/session.mjs b/easel/phone/session.mjs index 40fa929851..0ccd8c8f10 100644 --- a/easel/phone/session.mjs +++ b/easel/phone/session.mjs @@ -268,6 +268,8 @@ export function createSession({ storage = memoryStore(), emit = () => {} } = {}) try { say("status", { text: "publishing", kind: "working" }); await publishPiece({ + // Phone threads preserve drafts, but have no desktop revision ledger. + version: null, file: state.file, slug: state.slug, cwd: "/piece", diff --git a/easel/phone/shim/absent.mjs b/easel/phone/shim/absent.mjs index f8eee33f4f..53576ea35c 100644 --- a/easel/phone/shim/absent.mjs +++ b/easel/phone/shim/absent.mjs @@ -14,6 +14,7 @@ export const spawn = absent("spawn"); export const execFile = absent("execFile"); export const execFileSync = absent("execFileSync"); export const createServer = absent("createServer"); +export const createInterface = absent("createInterface"); export const homedir = () => "/home"; export const tmpdir = () => "/tmp"; export const networkInterfaces = () => ({}); diff --git a/easel/src/notebook-conversation.mjs b/easel/src/notebook-conversation.mjs new file mode 100644 index 0000000000..1760b8a008 --- /dev/null +++ b/easel/src/notebook-conversation.mjs @@ -0,0 +1,4 @@ +// Operational notices stay in Settings; the page belongs to the conversation. +export function notebookConversationEntry(entry) { + return ['user', 'assistant', 'error'].includes(entry.kind) && !entry.activityOnly; +} diff --git a/easel/src/publish.mjs b/easel/src/publish.mjs index 705ad5ec62..3cb5700125 100644 --- a/easel/src/publish.mjs +++ b/easel/src/publish.mjs @@ -57,6 +57,8 @@ export async function publishPiece({ site = SITE, onStep = () => {}, source: snapshot, + // Hosts without a desktop revision ledger can explicitly supply null. + version: snapshotVersion, }) { const handle = session.handle; if (!handle) { @@ -102,7 +104,7 @@ export async function publishPiece({ } catch {} let registration=null; - const savedVersion=new PieceRevisions(plan.path).list().findLast(v=>v.revision===createHash('sha256').update(source).digest('hex'))?.version; + const savedVersion=snapshotVersion === undefined ? new PieceRevisions(plan.path).list().findLast(v=>v.revision===createHash('sha256').update(source).digest('hex'))?.version : snapshotVersion; if(verified&&Number.isInteger(savedVersion)&&savedVersion>=2){ try{const response=await fetch(`${site}/api/register-piece`,{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json','User-Agent':USER_AGENT},body:JSON.stringify({version:savedVersion,slug:plan.slug,ext:plan.extension.slice(1),revision:createHash('sha256').update(source).digest('hex')}),signal:AbortSignal.timeout(10000)});const data=await response.json();registration=response.ok?data:{error:data.error||'Feed registration unavailable'};} catch{registration={error:'Feed registration unavailable'};} diff --git a/easel/src/render.mjs b/easel/src/render.mjs index a49cc7ff84..d7ef38f051 100644 --- a/easel/src/render.mjs +++ b/easel/src/render.mjs @@ -617,7 +617,8 @@ export function renderFrame(state, columns = 80, rows = 24, useColor = true) { // status, or the thing being typed. const controls=modelControls(state,width); const controlLine=controls.length?' '+controls.map(c=>paint(useColor,c.selected||state.hover===c.action?'block bold':c.tone+' bold',c.text.padEnd(c.width))).join(''):pathLine; - const lines = [...body, rule, header, controlLine, prompt, help]; + const desktop = state.desktop || state.desktopProsePrompt; + const lines = [...body, desktop ? "" : rule, desktop ? "" : header, desktop ? "" : controlLine, prompt, desktop ? "" : help]; return lines .slice(0, height) .map((line,index) => {const fitted=fit(line,width);return `${ground}${useColor && !process.env.EASEL_DESKTOP && index>=height-4?woodgrain(fitted,index-(height-4)):fitted}${reset}`;}) diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index c08b5b7ac8..bdd250765e 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -6,6 +6,7 @@ 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 {notebookConversationEntry} from './notebook-conversation.mjs'; import {connectionFailure,conciseFailure} from './connection-status.mjs'; import {ApprovalQueue, approvalFor, approvalShape, defaultApprovalResponse} from "./approvals.mjs"; import {readProviderPreferences,saveProviderPreferences,chooseProviderPreferences} from './provider-preferences.mjs'; @@ -562,7 +563,7 @@ function redraw() { if(historyKey!==desktopHistoryKey){desktopHistoryKey=historyKey;try{desktopHistory=live.history.list().map(({version,updatedAt,restoredFrom,summary})=>({version,updatedAt,restoredFrom,summary}));}catch{desktopHistory=[];}} const bindingKey=state.medium==='piece'?`${live.file}:${live.revision?.revision||''}`:''; if(bindingKey!==bindingSnapshotKey){bindingSnapshotKey=bindingKey;bindingSnapshot=state.medium==='piece'&&live.revision?.source?notebookBindings(live.revision.source,live.file):{revision:'',bindings:[]};} - const provider = JSON.stringify({feedPending:!!state.feedPending,notebookBindings:bindingSnapshot,backend:backend.id,model:state.model||model,effort,busy:state.busy,selectedModel:model,models:pickerModels({backend:backend.id,model,catalog:modelCatalog||[]}),versions:desktopHistory}); + const provider = JSON.stringify({status:state.status,mode:state.mode,activity:publicActivity(state),notice:state.entries.findLast(e=>e.kind==='notice')?.text||'',feedPending:!!state.feedPending,notebookBindings:bindingSnapshot,backend:backend.id,model:state.model||model,effort,busy:state.busy,selectedModel:model,models:pickerModels({backend:backend.id,model,catalog:modelCatalog||[]}),versions:desktopHistory}); if (provider !== lastProvider) { lastProvider = provider; process.stdout.write(`\x1b]777;easel-provider:${provider}\x07`); } } if (closing || drawing || splashing) return; @@ -582,10 +583,10 @@ 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`);} - const conversation=JSON.stringify({hidden:!!(state.settings||state.about),entries:state.entries.filter(e=>e.id!=='feed-registration'&&!e.activityOnly&&!(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}))}); + 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`);} } - const frame = renderFrame(process.env.EASEL_DESKTOP && !state.settings && !state.about ? {...state,entries:[],desktopProsePrompt:!state.approval} : state, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); + const frame = renderFrame(process.env.EASEL_DESKTOP && !state.settings && !state.about ? {...state,desktop:true,entries:[],desktopProsePrompt:!state.approval} : {...state,desktop:!!process.env.EASEL_DESKTOP}, process.stdout.columns, process.stdout.rows, process.env.NO_COLOR !== "1"); const output = frameDiff.update(frame, process.stdout.columns); if (process.env.EASEL_DESKTOP) { const layout = JSON.stringify(frameLayout(state, process.stdout.rows)); diff --git a/easel/test/notebook-conversation.test.mjs b/easel/test/notebook-conversation.test.mjs new file mode 100644 index 0000000000..89d1da3601 --- /dev/null +++ b/easel/test/notebook-conversation.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {notebookConversationEntry} from '../src/notebook-conversation.mjs'; +import {renderFrame} from '../src/render.mjs'; + +test('desktop notebook excludes operational notices and unfinished inference chatter', () => { + const entries = [ + {kind:'notice',text:'REMOTE INFERENCE'}, {kind:'notice',text:'Transcript: shared'}, + {kind:'tool',text:'Reading source'}, {kind:'assistant',text:'Looking now',activityOnly:true}, + {kind:'user',text:'Make it orange'}, {kind:'assistant',text:'The circle is orange.'}, + {kind:'error',text:'Sign in to continue'}, + ]; + assert.deepEqual(entries.filter(notebookConversationEntry).map(e=>e.text), + ['Make it orange','The circle is orange.','Sign in to continue']); +}); + +test('desktop status lives outside the terminal footer without hiding approvals', () => { + const state={desktop:true,desktopProsePrompt:true,mode:'remote',status:'thinking',account:'@test',workspace:'/private/work',entries:[],input:''}; + assert.equal(renderFrame(state,80,24,false).trim(),''); + const approval=renderFrame({...state,desktopProsePrompt:false,approval:{subject:'Write piece'}},80,24,false); + assert.match(approval,/ALLOW Write piece/); + assert.doesNotMatch(approval,/REMOTE|THINKING|\/private\/work|@test/); + assert.match(renderFrame({...state,desktop:false,desktopProsePrompt:false},80,24,false),/REMOTE · THINKING/); +}); diff --git a/easel/test/phone-loader.mjs b/easel/test/phone-loader.mjs index 97842865f0..9fe6dc1751 100644 --- a/easel/test/phone-loader.mjs +++ b/easel/test/phone-loader.mjs @@ -1,9 +1,10 @@ // Resolve the phone's browser import map in a focused Node test process. const root = new URL('../../', import.meta.url); -const shims = {'node:events':'events','node:fs':'fs','node:path':'path','node:url':'url','node:crypto':'crypto','node:os':'absent','node:http':'absent','node:child_process':'absent'}; +const shims = {'node:events':'events','node:fs':'fs','node:path':'path','node:url':'url','node:crypto':'crypto','node:os':'absent','node:http':'absent','node:child_process':'absent','node:readline':'absent'}; export async function resolve(specifier, context, next) { if (context.parentURL?.startsWith(new URL('easel/', root).href)) { if (shims[specifier]) return {url: new URL(`easel/phone/shim/${shims[specifier]}.mjs`, root).href, shortCircuit:true}; + if (specifier.startsWith('node:')) throw Error(`Phone import map has no shim for ${specifier}`); for (const name of ['revisions','preview-frame','jev-advisor']) if (specifier === `./${name}.mjs`) return {url:new URL(`easel/phone/shim/${name}.mjs`, root).href,shortCircuit:true}; } if (specifier.startsWith('/easel/')) return {url:new URL(specifier.slice(1), root).href,shortCircuit:true}; diff --git a/easel/test/phone-session.test.mjs b/easel/test/phone-session.test.mjs index b516dcf769..8eb22038af 100644 --- a/easel/test/phone-session.test.mjs +++ b/easel/test/phone-session.test.mjs @@ -4,6 +4,30 @@ import {register} from 'node:module'; register(new URL('./phone-loader.mjs', import.meta.url)); const {createSession} = await import('../phone/session.mjs'); +test('phone publication verifies uploaded source without desktop revision storage', async () => { + const source='export function paint({wipe}) {wipe("orange")}'; + const originalFetch=globalThis.fetch; + const calls=[]; + globalThis.fetch=async (url,options={})=>{ + calls.push(url); + if(url.includes('/presigned-upload-url/'))return Response.json({uploadURL:'https://upload.test/piece'}); + if(options.method==='PUT')return new Response(''); + return new Response(source); + }; + try { + const values=new Map([['session',JSON.stringify({slug:'phone-test',source})]]); + const events=[]; + const session=createSession({storage:{get:k=>values.get(k),set:(k,v)=>values.set(k,v)},emit:e=>events.push(e)}); + await session.open(); + session.state.token='test-token';session.state.handle='test'; + await session.publish(); + assert.equal(session.state.published,true); + assert.equal(calls.length,3); + assert.equal(events.some(e=>e.type==='bad'),false); + assert(events.some(e=>e.type==='preview')); + }finally{globalThis.fetch=originalFetch;} +}); + test('phone threads preserve source, transcript and engine context without account credentials', async () => { const source = 'export function paint({wipe}) {wipe("orange")}'; const values = new Map([['session', JSON.stringify({slug:'legacy',source,published:true,token:'fake-token',handle:'test'})]]); diff --git a/system/public/aesel/index.html b/system/public/aesel/index.html index 4e3e686461..e11f7c03bc 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 MacSign in with Aesthetic Computer.
Use AC, Claude, or Codex.
curl -fsSL https://aesthetic.computer/aesel.sh | shRequires Node.js 22+. Run ac after installing.
Mac 0.7.3 · Linux 0.7.0 (Easel)
Sign in with Aesthetic Computer.
Use AC, Claude, or Codex.
curl -fsSL https://aesthetic.computer/aesel.sh | shRequires Node.js 22+. Run ac after installing.
Mac 0.7.4 · Linux 0.7.0 (Easel)