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

112
src/App.svelte Normal file
View file

@ -0,0 +1,112 @@
<script lang="ts">
import { onMount } from "svelte";
import XtermPane from "./components/XtermPane.svelte";
import { listDistros } from "./ipc";
let distros = $state<string[]>([]);
let selected = $state<string | undefined>(undefined);
let status = $state("starting…");
let statusOk = $state(true);
let loadError = $state<string | null>(null);
function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop");
}
onMount(async () => {
try {
const d = await listDistros();
console.log("listDistros() returned:", d);
distros = d;
// Pick fresh every mount (HMR can preserve $state across reloads).
selected = d.find(isInteractiveDistro) ?? d[0] ?? undefined;
console.log("default selected:", selected);
} catch (e) {
console.warn("list_distros failed:", e);
loadError = String(e);
}
});
function pick(d: string) {
console.log("user picked distro:", d);
selected = d;
}
</script>
<div class="app">
<header class="titlebar">
<span class="label">tiletopia</span>
<span class="distros">
{#if distros.length === 0}
<span class="muted">no distros enumerated</span>
{:else}
{#each distros as d}
<button
class="distro-btn"
class:active={d === selected}
onclick={() => pick(d)}
>
{d}
</button>
{/each}
{/if}
</span>
<span class="status {statusOk ? 'ok' : 'err'}">{status}</span>
</header>
<div class="pane-wrap">
{#if loadError}
<pre class="err-pre">listDistros failed: {loadError}</pre>
{:else if selected !== undefined || distros.length === 0}
{#key selected}
<XtermPane
distro={selected}
onStatus={(msg, ok) => {
status = msg;
statusOk = ok;
}}
/>
{/key}
{/if}
</div>
</div>
<style>
.distros {
display: flex;
gap: 4px;
align-items: center;
}
.distro-btn {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
background: #222;
color: #aaa;
border: 1px solid #333;
border-radius: 3px;
padding: 2px 8px;
cursor: pointer;
}
.distro-btn:hover {
background: #2a2a2a;
color: #ddd;
}
.distro-btn.active {
background: #1a3a5c;
color: #cce6ff;
border-color: #2a5a8c;
}
.muted {
color: #666;
font-style: italic;
}
.err-pre {
color: #d66;
padding: 12px;
margin: 0;
white-space: pre-wrap;
}
</style>

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>

32
src/ipc.ts Normal file
View file

@ -0,0 +1,32 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type PaneId = number;
export const listDistros = (): Promise<string[]> => invoke("list_distros");
export const spawnPane = (args: {
distro?: string;
cwd?: string;
cols: number;
rows: number;
}): Promise<PaneId> => invoke("spawn_pane", args);
export const writeToPane = (id: PaneId, dataB64: string): Promise<void> =>
invoke("write_to_pane", { id, dataB64 });
export const resizePane = (id: PaneId, cols: number, rows: number): Promise<void> =>
invoke("resize_pane", { id, cols, rows });
export const killPane = (id: PaneId): Promise<void> => invoke("kill_pane", { id });
export const onPaneData = (
id: PaneId,
cb: (b64: string) => void,
): Promise<UnlistenFn> =>
listen<{ b64: string }>(`pane://${id}/data`, (e) => cb(e.payload.b64));
export const onPaneExit = (
id: PaneId,
cb: () => void,
): Promise<UnlistenFn> => listen(`pane://${id}/exit`, () => cb());

8
src/main.ts Normal file
View file

@ -0,0 +1,8 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "@xterm/xterm/css/xterm.css";
import "./styles.css";
const app = mount(App, { target: document.getElementById("app")! });
export default app;

65
src/styles.css Normal file
View file

@ -0,0 +1,65 @@
:root {
color-scheme: dark;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 13px;
background: #0c0c0c;
color: #e6e6e6;
}
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
background: #0c0c0c;
}
.titlebar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
font-size: 12px;
color: #aaa;
user-select: none;
}
.titlebar .label {
font-weight: 600;
color: #ddd;
}
.titlebar .distro {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
color: #88c;
}
.titlebar .status {
margin-left: auto;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.titlebar .status.ok {
color: #6c6;
}
.titlebar .status.err {
color: #d66;
}
.pane-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
}