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
112
src/App.svelte
Normal file
112
src/App.svelte
Normal 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>
|
||||
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>
|
||||
32
src/ipc.ts
Normal file
32
src/ipc.ts
Normal 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
8
src/main.ts
Normal 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
65
src/styles.css
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue