io-output.js (3157B)
1 // Presentation only. Byte acceptance and strict source UTF-8 Results are separate. 2 // No evaluator handles, host callbacks, IO acknowledgements or terminal escapes. 3 import { decodeUTF8, UTF8Error } from './io-utf8.js'; 4 function view(bytes, final) { 5 const tokens = []; 6 for (let offset = 0; offset < bytes.length;) { 7 const lead = bytes[offset]; 8 const width = lead <= 0x7f ? 1 : lead >= 0xc2 && lead <= 0xdf ? 2 : 9 lead >= 0xe0 && lead <= 0xef ? 3 : lead >= 0xf0 && lead <= 0xf4 ? 4 : 1; 10 const candidate = bytes.slice(offset, offset + width); 11 try { 12 const decoded = decodeUTF8(candidate, false); 13 if (decoded.carry.length) { 14 tokens.push({ kind: 'bytes', offset, bytes: candidate, reason: final ? 'incomplete' : 'pending' }); 15 break; 16 } 17 const previous = tokens.at(-1); 18 if (previous?.kind === 'text') 19 previous.text += decoded.text; 20 else 21 tokens.push({ kind: 'text', offset, text: decoded.text }); 22 offset += width; 23 } 24 catch (error) { 25 if (!(error instanceof UTF8Error)) 26 throw error; 27 // Escape one offending byte; reconsider following bytes independently. 28 // Thus invalid prefixes cannot swallow subsequent valid text. 29 tokens.push({ kind: 'bytes', offset, bytes: [lead], reason: 'invalid' }); 30 offset++; 31 } 32 } 33 return { bytes, hex: bytes.map(b => b.toString(16).padStart(2, '0')).join(' '), tokens }; 34 } 35 // Owned by one UI run. The session separately enforces attempted-output budgets; 36 // this buffer bounds retained accepted bytes, without refunds or history. 37 export class OutputBuffer { 38 limit; 39 streams = { output: [], error: [] }; 40 size = 0; 41 ended = false; 42 constructor(limit = 16384) { 43 this.limit = limit; 44 if (!Number.isSafeInteger(limit) || limit < 0 || limit > 16384) 45 throw new Error('invalid output limit'); 46 } 47 append(stream, bytes) { 48 if (this.ended) 49 throw new Error('output presentation ended'); 50 if (stream !== 'output' && stream !== 'error') 51 throw new Error('invalid output stream'); 52 if (!Array.isArray(bytes) || bytes.length === 0 || bytes.length > 4096) 53 throw new Error('invalid output bytes'); 54 for (const byte of bytes) 55 if (!Number.isInteger(byte) || byte < 0 || byte > 255) 56 throw new Error('invalid output bytes'); 57 if (bytes.length > this.limit - this.size) 58 throw new Error('output presentation limit'); 59 this.streams[stream].push(...bytes); 60 this.size += bytes.length; 61 } 62 // End-of-presentation, including after structured cancellation, not closing a 63 // borrowed stream. Incomplete suffixes become visible bytes, never IO failures. 64 finish() { this.ended = true; } 65 snapshot() { 66 return { done: this.ended, size: this.size, 67 output: view([...this.streams.output], this.ended), error: view([...this.streams.error], this.ended) }; 68 } 69 }