diff --git a/easel/src/required-sharing.mjs b/easel/src/required-sharing.mjs index cade74b4e1..da5feea2e0 100644 --- a/easel/src/required-sharing.mjs +++ b/easel/src/required-sharing.mjs @@ -1,7 +1,8 @@ import {createHash} from 'node:crypto'; import {mkdir,readFile,writeFile,rename,lstat} from 'node:fs/promises'; import {join} from 'node:path'; -import {MASCOT_SETTLED_MS,mascotRows} from './mascot.mjs'; +import {MASCOT_SETTLED_MS,mascotRow,mascotRows} from './mascot.mjs'; +const MASCOT_ROW_MARK=mascotRow(0,false); import {color,textWidth,wrapText} from './render.mjs'; export const DISCLOSURE_VERSION=4; export const TRANSCRIPT_DISCLOSURE='aesel requires sharing your messages, assistant replies, and artifact revision references with Aesthetic Computer to improve aesel, including when you bring your own Codex or Claude account. Uploaded transcripts are accessible only to authorized AC staff and retained indefinitely until you delete them. Recognizable credentials are redacted, but messages may contain personal information. For AI generation, prompts, conversation context, source code and tool results are sent to your selected provider. Hosted text uses OpenRouter and the selected model provider; hosted images use OpenAI and include reference images you supply. Provider privacy policies apply. Earlier private conversations are not uploaded. You can export or delete uploaded transcripts. If you do not agree, quit without starting a session.'; @@ -11,37 +12,36 @@ const account=session=>session.read()?.user?.sub; // way the interface paints him, and the paragraph is measured to sit under // him rather than run to the edge. The words are the same words — this is a // disclosure, and what it discloses is not decorated. -export const DISCLOSURE_COLUMNS=64; +export const DISCLOSURE_COLUMNS=72; export function sharingScreen({columns=80,rows=24,useColor=true,signedIn=false}={}){ const width=Math.max(40,columns||80),height=Math.max(12,rows||24); const ink=(tone,text)=>useColor?`${color[tone]||''}${text}${color.reset}`:text; - const donkey=mascotRows(MASCOT_SETTLED_MS).rows.map(({text,tone})=>({ - text:text.replace(/\s+$/,''), + const donkey=mascotRows(MASCOT_SETTLED_MS).rows.map(({text,tone})=>{ + const plain=text.replace(/\s+$/,''); // The head is the mark, the body is the soft purple, and the star on the // canvas is the one orange thing on the screen. - painted:useColor?text.replace(/\s+$/,'').split('*').map(part=>ink(tone,part)).join(ink('highlight','*')):text.replace(/\s+$/,''), - })); + return {plain,painted:useColor?plain.split('*').map(part=>ink(tone,part)).join(ink('highlight','*')):plain}; + }); const paragraph=wrapText(TRANSCRIPT_DISCLOSURE,Math.min(DISCLOSURE_COLUMNS,width-4)); const keys=`[A] Agree and continue${signedIn?'':' (sign in)'} [Q] Quit`; - const block=[ - ...donkey.map(row=>({plain:row.text,painted:row.painted})), - {plain:'',painted:''}, - {plain:'Aesel data sharing',painted:ink('highlight','Aesel data sharing')}, - {plain:'',painted:''}, - ...paragraph.map(line=>({plain:line,painted:ink('soft',line)})), - {plain:'',painted:''}, - {plain:keys,painted:`${ink('handle','[A]')} Agree and continue${signedIn?'':' (sign in)'} ${ink('handle','[Q]')} Quit`}, - ]; + const gap={plain:'',painted:''}; + const title=(mark)=>({plain:`${mark}Aesel data sharing`,painted:`${mark?ink('handle',mark):''}${ink('highlight','Aesel data sharing')}`}); + const tail=[gap,...paragraph.map(line=>({plain:line,painted:ink('soft',line)})),gap, + {plain:keys,painted:`${ink('handle','[A]')} Agree and continue${signedIn?'':' (sign in)'} ${ink('handle','[Q]')} Quit`}]; + // The whole donkey when the window has the rows for him; his one-row self + // beside the title when it does not, so an 80×24 terminal still shows the + // words whole and the keys on screen. + const full=[...donkey,gap,title(''),...tail]; + const block=full.length<=height?full:[title(`${MASCOT_ROW_MARK} `),...tail]; + const centred=new Set(paragraph); const blockWidth=Math.max(...block.map(row=>textWidth(row.plain))); const left=Math.max(0,Math.floor((width-blockWidth)/2)); const top=Math.max(0,Math.floor((height-block.length)/2)); // The paragraph keeps a straight left edge; the donkey, the title and the // keys stand over its middle. - const centred=new Set(paragraph); const indent=row=>centred.has(row.plain)||!row.plain?0:Math.floor((blockWidth-textWidth(row.plain))/2); return '\r\n'.repeat(top)+block.map(row=>' '.repeat(left+indent(row))+row.painted).join('\r\n')+'\r\n'; } - export async function readAcknowledgment(root,owner){ if(!owner)return null; try{const value=JSON.parse(await readFile(join(root,createHash('sha256').update(owner).digest('hex')+'.json'),'utf8'));return value.owner===owner&&value.version===DISCLOSURE_VERSION&&Number.isFinite(Date.parse(value.acceptedAt))?value:null;}catch(error){if(error.code==='ENOENT')return null;throw error;} diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index aba40e6c0b..b4d5d08a15 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -309,7 +309,10 @@ const transcriptSeen = new Set(state.entries.map(entry=>entry.id)); const transcriptCompleted = new Set(); const transcriptRevisions = new Set(); const transcriptEnqueued = new Map(); -try { +// A private session keeps no shared journal at all — its transcript is the +// local one in transcript.mjs, and nothing is uploaded — so there is nothing +// to open here and no error to show for not opening it. +if (!profile.private) try { transcriptJournal = new TranscriptJournal({root:path.join(homedir(),'.local','share','easel','transcripts'), ...(desktopRestored?.transcriptId ? {id:desktopRestored.transcriptId}:{}), metadata:{medium:state.medium},version:currentVersion(),session}); diff --git a/easel/test/required-sharing.test.mjs b/easel/test/required-sharing.test.mjs index 8d6ea139ad..1abdf485b0 100644 --- a/easel/test/required-sharing.test.mjs +++ b/easel/test/required-sharing.test.mjs @@ -44,3 +44,13 @@ test("colour inks the star on its own, and a narrow window still fits", () => { assert.ok(!plainRows(painted).at(-2).includes("(sign in)"), "signed in does not ask to sign in"); assert.ok(plainRows(sharingScreen({ columns: 44, rows: 12, useColor: false })).every((row) => row.length <= 44), "nothing wraps at 44 columns"); }); + +test("an 80 by 24 terminal shows the whole disclosure and the keys, with the small donkey", () => { + const screen = sharingScreen({ columns: 80, rows: 24, useColor: false }); + const rows = plainRows(screen); + assert.ok(rows.length <= 25, `fits in 24 rows, got ${rows.length}`); + assert.ok(!rows.some((row) => row.includes("( o o )") || row.includes("( - - )")), "no room for the full donkey"); + assert.ok(rows.some((row) => /\/\/o>\s+Aesel data sharing/.test(row)), "his one-row self stands by the title"); + assert.ok(rows.some((row) => row.includes("[Q] Quit")), "the keys are on screen"); + assert.ok(rows.every((row) => row.length <= 80), "nothing wraps"); +});