diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index d573725..8045796 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -14,6 +14,11 @@ let _searchAddon = null; // ─── Module-level encoding helpers ────────────────────────────────────────── // Hoisted here so the clipboard key handler (in openTerminal) can also use them. const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; +// TextDecoder: used to decode UTF-8 bytes received from ttyd before writing to xterm.js. +// xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8 — multi-byte characters +// like ─ (U+2500, bytes E2 94 80) render as â (Latin-1 0xE2) without decoding first. +// Matches ttyd's official client pattern: textDecoder.decode(payload) → _term.write(string). +const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null; function _encodePayload(typeChar, str) { // Returns Uint8Array: [typeCharCode, ...utf8bytes] @@ -131,7 +136,8 @@ function connectWebSocket(name, remoteId) { var msgType = msg[0]; var payload = msg.slice(1); if (msgType === 0x30) { // '0' = terminal output — write to xterm.js - _term.write(payload); + // decode: Uint8Array → UTF-8 string. write(Uint8Array) treats bytes as Latin-1. + _term.write(_decoder ? _decoder.decode(payload) : payload); } // 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now } else if (typeof e.data === 'string') { diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index d72fa32..92c65ae 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -622,11 +622,13 @@ test('message handler strips type byte and writes output for type 0x30', () => { assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once'); const written = t.termWriteMessages[0]; - assert.ok(written instanceof Uint8Array, 'data written to xterm must be a Uint8Array'); - assert.strictEqual(written[0], 'h'.charCodeAt(0), - 'first byte written must be "h" (0x68), not the type byte 0x30'); - assert.strictEqual(written.length, hello.length, - 'written data length must equal payload length (type byte stripped)'); + // After the UTF-8 fix: payload is decoded via TextDecoder before write(), + // so xterm.js receives a string (not raw Uint8Array). + // xterm.js write(Uint8Array) treated each byte as Latin-1 — TextDecoder fixes this. + assert.strictEqual(typeof written, 'string', + 'data written to xterm must be a decoded string (TextDecoder fix for Latin-1 garbling)'); + assert.strictEqual(written, 'hello', + 'decoded output must match the original ASCII payload'); }); test('message handler ignores title type (0x31) — does not call term.write', () => { @@ -1008,6 +1010,53 @@ test('terminal.js does NOT intercept Ctrl+Shift+V — lets xterm.js handle paste 'xterm.js handles paste natively with correct encoding + bracketed paste'); }); +// --- UTF-8 output decoding via TextDecoder --- + +test('terminal.js uses TextDecoder to decode UTF-8 WebSocket output before writing to xterm', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('TextDecoder'), 'must create a TextDecoder for UTF-8 output decoding'); + // Find the message handler block for type 0x30 and verify decode() is used + const msgIdx = source.indexOf('msgType === 0x30'); + assert.ok(msgIdx !== -1, 'must have a type 0x30 output handler'); + const writeBlock = source.substring(msgIdx, msgIdx + 200); + assert.ok( + writeBlock.includes('decode') || writeBlock.includes('Decoder'), + 'output handler must decode Uint8Array to string before _term.write() — ' + + 'xterm.js write(Uint8Array) treats bytes as Latin-1 not UTF-8, ' + + 'causing box-drawing chars like ─ (E2 94 80) to render as â', + ); +}); + +test('message handler writes decoded UTF-8 string (not raw Uint8Array) to xterm', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + + globalThis.setTimeout = orig; + + // Simulate receiving a terminal output frame with a box-drawing character: ─ (U+2500) + // UTF-8 bytes for ─: E2 94 80 + const encoder = new TextEncoder(); + const boxChar = encoder.encode('─'); // [0xE2, 0x94, 0x80] + const msg = new Uint8Array(1 + boxChar.length); + msg[0] = 0x30; + msg.set(boxChar, 1); + + t.fireMessage(msg.buffer); + + assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once'); + + const written = t.termWriteMessages[0]; + assert.strictEqual(typeof written, 'string', + 'data written to xterm must be a decoded string, not a Uint8Array — ' + + 'xterm.js write(Uint8Array) interprets bytes as Latin-1 causing garbled box-drawing chars'); + assert.strictEqual(written, '─', + 'decoded output must be the original Unicode character ─, not garbled â bytes'); +}); + // --- Federation reconnect routing --- test('terminal.js reconnect uses federation connect path for remote sessions', () => {