shelley: fix double encoding of UTF-8 in terminal output

Philip Zeyliger and Shelley created

phil: This is one of those amazing things that Sketch ran
into, and six months later, the agents are still writing this one
not quite right. Had to do some handholding here.

Prompt: Fix https://github.com/boldsoftware/shelley/issues/79

The terminal widget was using atob() to decode base64 data from the
WebSocket, which returns a binary string where each byte becomes a
separate character. When passed to xterm.js term.write(), each byte
was interpreted as a Unicode code point, causing multi-byte UTF-8
sequences (like box-drawing characters ├──) to render as mojibake.

Fix by decoding the base64 data into a Uint8Array and passing that
to term.write(), which correctly handles raw bytes and decodes UTF-8
properly.

Fixes https://github.com/boldsoftware/shelley/issues/79

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

ui/src/components/TerminalWidget.tsx | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)

Detailed changes

ui/src/components/TerminalWidget.tsx 🔗

@@ -4,6 +4,17 @@ import { FitAddon } from "@xterm/addon-fit";
 import { WebLinksAddon } from "@xterm/addon-web-links";
 import "@xterm/xterm/css/xterm.css";
 
+function base64ToUint8Array(base64String: string): Uint8Array {
+  // This isn't yet available in Chrome, but Safari has it!
+  // @ts-expect-error Uint8Array.fromBase64 is a newer API
+  if (Uint8Array.fromBase64) {
+    // @ts-expect-error Uint8Array.fromBase64 is a newer API
+    return Uint8Array.fromBase64(base64String);
+  }
+  const binaryString = atob(base64String);
+  return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
+}
+
 interface TerminalWidgetProps {
   command: string;
   cwd: string;
@@ -425,9 +436,7 @@ export default function TerminalWidget({
       try {
         const msg = JSON.parse(event.data);
         if (msg.type === "output" && msg.data) {
-          // Decode base64 data
-          const decoded = atob(msg.data);
-          term.write(decoded);
+          term.write(base64ToUint8Array(msg.data));
           // Track line count for auto-sizing
           lineCountRef.current = term.buffer.active.length;
         } else if (msg.type === "exit") {