Initial scaffold from M1 spike (tiletopia)

Tauri 2 + Svelte 5 + xterm.js + portable-pty. Single full-window
WSL terminal pane with clickable distro picker. M1 verified manually
on Windows: window opens, xterm.js renders, claude TUI works,
resize reflows cleanly.

Graduated from ~/claude/ideas/wsl-mux/ per the approved plan at
~/.claude/plans/imperative-coalescing-feigenbaum.md. See memory.md
for decisions, open TODOs, and the M2-M5 roadmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 12:31:29 +01:00
commit b352f8f049
36 changed files with 11534 additions and 0 deletions

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
spawnPane,
writeToPane,
resizePane,
killPane,
onPaneData,
onPaneExit,
type PaneId,
} from "../ipc";
let {
distro = undefined,
cwd = undefined,
onStatus = (_s: string, _ok: boolean) => {},
}: {
distro?: string;
cwd?: string;
onStatus?: (msg: string, ok: boolean) => void;
} = $props();
let containerEl: HTMLDivElement;
let term: Terminal | null = null;
let fit: FitAddon | null = null;
let paneId: PaneId | null = null;
let unlistenData: UnlistenFn | null = null;
let unlistenExit: UnlistenFn | null = null;
let ro: ResizeObserver | null = null;
// Decode base64 -> Uint8Array. xterm.js accepts both strings and Uint8Array;
// bytes is preferred to avoid double-decoding UTF-8.
function b64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function stringToB64(s: string): string {
// xterm.js's onData emits a JS string; need to UTF-8 encode before base64.
return bytesToB64(new TextEncoder().encode(s));
}
onMount(async () => {
term = new Terminal({
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
fontSize: 13,
cursorBlink: true,
theme: {
background: "#0c0c0c",
foreground: "#e6e6e6",
},
scrollback: 5000,
convertEol: false,
allowProposedApi: true,
});
fit = new FitAddon();
term.loadAddon(fit);
term.open(containerEl);
// Initial size — fit before we ask the PTY for its dimensions.
fit.fit();
const cols = term.cols;
const rows = term.rows;
try {
paneId = await spawnPane({ distro, cwd, cols, rows });
onStatus(`pane ${paneId} alive`, true);
} catch (e) {
const msg = `spawn_pane failed: ${e}`;
term.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
onStatus(msg, false);
return;
}
unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64));
});
unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
onStatus(`pane ${paneId} exited`, false);
});
term.onData((data) => {
if (paneId == null) return;
void writeToPane(paneId, stringToB64(data));
});
// Re-fit on container resize; forward new size to the PTY.
ro = new ResizeObserver(() => {
try {
fit?.fit();
if (paneId != null && term) {
void resizePane(paneId, term.cols, term.rows);
}
} catch (e) {
console.warn("resize failed", e);
}
});
ro.observe(containerEl);
// Focus so typing immediately lands in the terminal.
term.focus();
});
onDestroy(() => {
ro?.disconnect();
unlistenData?.();
unlistenExit?.();
if (paneId != null) {
void killPane(paneId);
}
term?.dispose();
});
</script>
<div class="xterm-host" bind:this={containerEl}></div>
<style>
.xterm-host {
width: 100%;
height: 100%;
}
/* xterm.js sets inline padding=0 on its container; ensure the viewport
fills the host with no scrollbar gap. */
:global(.xterm) {
height: 100%;
}
:global(.xterm-viewport) {
background: #0c0c0c !important;
}
</style>