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:
commit
b352f8f049
36 changed files with 11534 additions and 0 deletions
143
src/components/XtermPane.svelte
Normal file
143
src/components/XtermPane.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue