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

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Node
node_modules/
dist/
.svelte-kit/
.pnpm-store/
# Rust / Tauri
src-tauri/target/
# Editor / OS
.DS_Store
.vscode/
.idea/
*.swp
# Env / secrets
.env
.env.*
!.env.example

26
CLAUDE.md Normal file
View file

@ -0,0 +1,26 @@
# Project: tiletopia
A Windows desktop app for running and arranging many WSL terminals at once. Built primarily to manage multiple `claude` sessions across projects in parallel; works for any multi-shell workflow.
## Working agreement
- This is a git repo with `origin` on Forgejo at `https://git.rdx4.com/megaproxy/tiletopia.git` (private). HTTPS auth uses the token in `~/.git-credentials` — pushes are non-interactive.
- Commit after each logical change with a one-line imperative message; `git push` after each commit (or at minimum before ending the session).
- Read `memory.md` at session start. Update it before ending the session.
- Never commit secrets — see `.gitignore` and the rules in `~/claude/CLAUDE.md`.
## Project-specific notes
- **Stack:** Tauri 2 + Svelte 5 + TypeScript + Vite + pnpm + xterm.js + `portable-pty`. Mirrors `~/claude/projects/claude-usage-widget/` for toolchain choices.
- **Build target:** Windows `.exe` only. Rust toolchain lives on the Windows host, not WSL.
- **Source location:** `D:\dev\tiletopia\` (Windows-native NTFS). Symlinked into WSL at `~/claude/projects/tiletopia` for editing convenience, but **all pnpm and cargo commands must run on the Windows host** against the `D:\` path — never the `\\wsl.localhost\...` UNC path (pnpm 11.x crashes inside `isDriveExFat`, and the underlying error gets swallowed).
- **Run:**
```powershell
cd D:\dev\tiletopia
pnpm install
pnpm tauri dev # iterate
pnpm tauri build # NSIS installer at src-tauri\target\release\bundle\nsis\
```
- **Validate in WSL:** `pnpm check` (svelte-check) runs in WSL and validates the Svelte/TS side without needing the Rust toolchain.
- **Plan reference:** `~/.claude/plans/imperative-coalescing-feigenbaum.md` — the approved plan that drove the scaffold and the full M0M5 milestone roadmap.
- **Archived idea history:** the brainstorm phase + full session log lives at `~/claude/archive/ideas/wsl-mux/plan.md`.

75
README.md Normal file
View file

@ -0,0 +1,75 @@
# tiletopia
A Windows desktop app for running and arranging many WSL terminals at once. Built primarily to manage multiple `claude` sessions across projects in parallel, but works for any multi-shell workflow.
Status: **early — single-pane M1 works**. Tiling layout (M2), workspace persistence (M3), and cross-pane orchestration (M4) are the next milestones. See `memory.md`.
## Stack
- **Tauri 2** (Rust backend, WebView2 frontend) — small bundle, native Windows installer via NSIS.
- **Svelte 5** + TypeScript + Vite + pnpm.
- **xterm.js** + `@xterm/addon-fit` for terminal rendering.
- **`portable-pty`** (Rust crate) spawning `wsl.exe -d <distro>` PTYs.
## Run
This project targets Windows. Dev requires:
- Windows 10/11 + [WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/) (preinstalled on Win11).
- [MSVC toolchain](https://v2.tauri.app/start/prerequisites/#windows) (VS Build Tools, "C++ build tools" workload).
- [Rust](https://rustup.rs/) on the Windows host.
- Node 20+ and pnpm (`corepack use pnpm@11.2.2`).
- WSL with at least one distro installed.
**Location matters.** The source must live on a Windows-native drive (here: `D:\dev\tiletopia\`). Don't run pnpm against the `\\wsl.localhost\...` UNC path — pnpm 11.x crashes inside `isDriveExFat` and the actual error gets swallowed by the crashing error-hint formatter.
From a Windows shell:
```powershell
cd D:\dev\tiletopia
pnpm install
pnpm tauri dev # iterate
pnpm tauri build # NSIS installer at src-tauri\target\release\bundle\nsis\
```
The WSL-side symlink at `~/claude/projects/tiletopia` points here for in-WSL editing.
## How it works (current state)
- **Backend (`src-tauri/src/pty.rs`):** a `PtyManager` holding a `Mutex<HashMap<PaneId, PaneHandle>>` of `portable-pty` children. Each spawned pane gets a background reader thread that emits `pane://{id}/data` events to the frontend (base64-encoded byte chunks). Counterparts: `write_to_pane`, `resize_pane`, `kill_pane`. Distro enumeration parses `wsl.exe -l -q` (UTF-16LE).
- **Frontend (`src/components/XtermPane.svelte`):** xterm.js + FitAddon mounted into a div. On mount, calls `spawn_pane`, subscribes to the pane's event stream, wires `term.onData``write_to_pane`, and uses a `ResizeObserver` to forward dimension changes to the PTY.
- **App (`src/App.svelte`):** titlebar with clickable distro buttons (auto-picks first non-docker-desktop distro; user can override). One XtermPane wrapped in `{#key selected}` so changing distro destroys + respawns the pane.
## Layout
```
tiletopia/
├── CLAUDE.md, memory.md, README.md
├── .gitignore, pnpm-workspace.yaml
├── package.json, vite.config.ts, svelte.config.js, tsconfig.json, tsconfig.node.json
├── index.html
├── src/
│ ├── main.ts # mounts App, imports xterm.css
│ ├── App.svelte # titlebar + one XtermPane (M1)
│ ├── styles.css
│ ├── ipc.ts # typed Tauri command wrappers
│ └── components/
│ └── XtermPane.svelte
└── src-tauri/
├── Cargo.toml, build.rs, tauri.conf.json
├── capabilities/default.json
├── icons/ # placeholder, copied from claude-usage-widget
└── src/
├── main.rs
├── lib.rs # tauri builder, registers commands, manages PtyManager
├── pty.rs # PtyManager + list_wsl_distros
└── commands.rs # #[tauri::command] surface
```
## Known gotchas (today)
- **Don't `pnpm install` from a UNC path** (`\\wsl.localhost\...`). pnpm 11.x crashes in its `isDriveExFat` probe; the underlying error gets swallowed.
- **Console flash on `wsl.exe -l -q`:** suppressed via the `CREATE_NO_WINDOW` flag in `pty.rs`. The PTY itself doesn't allocate a console (portable-pty uses ConPTY directly).
- **base64 wire format:** xterm.js emits `string` from `onData`; we UTF-8 encode then base64. Not the fastest; switch to typed-array event payloads later if throughput is an issue.
- **No icons of our own:** copied from `claude-usage-widget`. Replace before any release.
- **Cargo build only works on Windows host** — Rust toolchain isn't installed in WSL. `pnpm check` runs in WSL and validates the Svelte/TS side.

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tiletopia</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

45
memory.md Normal file
View file

@ -0,0 +1,45 @@
# memory — tiletopia
Durable memory for this project. Read at session start, update before session end. Date format: `YYYY-MM-DD`.
## Decisions & rationale
- **Stack: Tauri 2 + Svelte 5 + TypeScript + Vite + pnpm + xterm.js + `portable-pty`.** Mirrors `claude-usage-widget` so we reuse a known-good Windows-targeting toolchain (MSVC + WebView2 + NSIS installer). No new technology bets stacked on top of the new product bet.
- **Layout model: binary tree of splits, NOT free-form rectangles.** Same as i3 / tmux / Zellij. Each internal node is HSplit/VSplit + ratio; each leaf is a terminal. Dragging a gutter mutates one parent ratio; both sibling subtrees reflow; descendants get `resize`. Adaptive resize falls out automatically with no constraint solver. Preset layouts ("3 columns", "2×2") are pre-built trees.
- **PTY backend: `portable-pty` (same crate WezTerm uses).** Spawns `wsl.exe -d <distro> --cd <path>` on Windows. Manager is a `Mutex<HashMap<PaneId, PaneHandle>>` in Rust; each pane has a background reader thread that emits `pane://{id}/data` events.
- **Wire format: base64-encoded byte chunks via Tauri events.** xterm.js's `onData` emits strings; we UTF-8 encode then base64. Slower than a typed-array payload but trivially correct. Revisit if throughput matters.
- **Source on Windows-native disk (`D:\dev\tiletopia\`), symlinked into WSL.** Same pattern as `rimlike` (`D:\godot\rimlike`) and `tavernkeep`. Forced by pnpm 11.x's `isDriveExFat` crashing on `\\wsl.localhost\...` UNC paths.
- **Don't commit `node_modules`, `src-tauri/target`, or `.pnpm-store`. DO commit `Cargo.lock`** (binary project, reproducible builds).
- **Session awareness without an in-pane agent.** Plan: poll `/proc/<pid>/cwd` of the shell's child + foreground process every ~2s. Sufficient to detect `cd` and whether `claude` is running.
## Open questions / TODOs
- [ ] **HMR distro picker reset.** After a Vite hot reload, the previously-selected distro persists in Svelte 5 `$state`, so the picker doesn't re-default. Workaround in place (clickable distro buttons in titlebar). Fix properly in M3 when workspace state lives in a separate persisted store.
- [ ] **M2 — splits-tree layout component.** Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON.
- [ ] **M3 — workspace persistence.** Save/restore layouts + per-pane (distro, cwd, label) in `%APPDATA%/tiletopia/workspaces.json`. Preset layouts (3 columns, 2×2 grid). Distro picker UX, pane labels.
- [ ] **M4 — orchestration.** Broadcast input groups, idle/finish notifications, Ctrl+K fuzzy palette.
- [ ] **M5 — Ship.** Replace placeholder icons, NSIS installer, Forgejo release. Copy `claude-usage-widget`'s release scripts.
- [ ] **Native Windows shells (cmd / pwsh)?** `portable-pty` supports them for free; keep the option open. Decide whether to expose in UI at M3.
- [ ] **Persistent scrollback across app restarts.** Would need an out-of-process mux daemon. Big scope creep; explicitly deferred past v1.
- [ ] **Keybinding philosophy.** Copy tmux, copy WezTerm, or invent? Decide at M3.
## Session log
### 2026-05-22
- Graduated from `ideas/wsl-mux/` to project. Renamed working name `wsl-mux` → final name `tiletopia` across Cargo/package/Tauri configs and source.
- Promoted spike contents from `D:\dev\wsl-mux\spike\` to `D:\dev\tiletopia\` (no more spike subdir; the project IS what was the spike).
- Initialized git, created private Forgejo repo `tiletopia`, pushed initial scaffold.
- M1 verified manually on the Windows host: window opens, xterm.js renders, `claude` TUI works inside the pane, resize reflows cleanly, `htop` renders. Distro auto-pick chose `docker-desktop` (Docker Desktop's BusyBox helper distro) on first try — added explicit clickable distro buttons in the titlebar as both a diagnostic and a manual override. Clicking `Ubuntu` works end-to-end.
- Old idea folder archived to `~/claude/archive/ideas/wsl-mux/` (preserves full brainstorm + session log).
## External references
- **Approved plan / roadmap:** `~/.claude/plans/imperative-coalescing-feigenbaum.md` (M0M5 milestones with verification criteria for each)
- **Stack precedent:** `~/claude/projects/claude-usage-widget/` — same Tauri + Svelte + WebView2 toolchain, already ships a Windows installer via Forgejo releases. WSL distro-probing logic copied/adapted into `src-tauri/src/pty.rs`.
- **Archived idea history:** `~/claude/archive/ideas/wsl-mux/plan.md`
- **Forgejo repo:** https://git.rdx4.com/megaproxy/tiletopia
- **xterm.js docs:** https://xtermjs.org/
- **portable-pty crate:** https://crates.io/crates/portable-pty
- **Tauri 2 docs:** https://v2.tauri.app/
- **Prior art for splits-tree layout:** i3, tmux, Zellij, WezTerm

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "tiletopia",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tauri-apps/cli": "^2.0.0",
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}

1054
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

4853
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

40
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,40 @@
[package]
name = "tiletopia"
version = "0.0.1"
description = "Tiling multi-terminal manager for WSL"
authors = ["megaproxy"]
edition = "2021"
rust-version = "1.77"
[lib]
name = "tiletopia_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
portable-pty = "0.8"
anyhow = "1"
tokio = { version = "1", features = ["full"] }
once_cell = "1"
parking_lot = "0.12"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability set for wsl-mux spike",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:window:default"
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability set for wsl-mux spike","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default"]}}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

BIN
src-tauri/icons/source.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

57
src-tauri/src/commands.rs Normal file
View file

@ -0,0 +1,57 @@
//! Tauri command surface. Every JS-callable function lives here.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use tauri::AppHandle;
use crate::pty::{list_wsl_distros, PaneId, PtyManager};
#[tauri::command]
pub async fn list_distros() -> Result<Vec<String>, String> {
list_wsl_distros().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn spawn_pane(
app: AppHandle,
manager: tauri::State<'_, PtyManager>,
distro: Option<String>,
cwd: Option<String>,
cols: u16,
rows: u16,
) -> Result<PaneId, String> {
manager
.spawn_wsl(app, distro, cwd, cols, rows)
.map_err(|e| e.to_string())
}
/// `data_b64` is base64-encoded UTF-8 bytes (xterm.js's `onData` emits
/// strings; the frontend encodes before sending).
#[tauri::command]
pub async fn write_to_pane(
manager: tauri::State<'_, PtyManager>,
id: PaneId,
data_b64: String,
) -> Result<(), String> {
let bytes = B64
.decode(data_b64.as_bytes())
.map_err(|e| format!("base64 decode: {e}"))?;
manager.write(id, &bytes).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn resize_pane(
manager: tauri::State<'_, PtyManager>,
id: PaneId,
cols: u16,
rows: u16,
) -> Result<(), String> {
manager.resize(id, cols, rows).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn kill_pane(
manager: tauri::State<'_, PtyManager>,
id: PaneId,
) -> Result<(), String> {
manager.kill(id).map_err(|e| e.to_string())
}

28
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,28 @@
//! Library entry point. `main.rs` calls `run()`.
mod commands;
mod pty;
use crate::pty::PtyManager;
pub fn run() {
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.try_init();
tauri::Builder::default()
.manage(PtyManager::new())
.invoke_handler(tauri::generate_handler![
commands::list_distros,
commands::spawn_pane,
commands::write_to_pane,
commands::resize_pane,
commands::kill_pane,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Hide the console window in release builds; keep it in debug for log output.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tiletopia_lib::run();
}

229
src-tauri/src/pty.rs Normal file
View file

@ -0,0 +1,229 @@
//! PTY backend. Spawns `wsl.exe` (or any command) through portable-pty,
//! reads its output on a background thread, and forwards chunks to the
//! frontend as `pane://{id}/data` events.
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use parking_lot::Mutex;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use serde::Serialize;
use tauri::{AppHandle, Emitter};
pub type PaneId = u64;
/// What we keep alive for each spawned PTY.
///
/// `master` stays in scope to keep the PTY alive; we never write through it
/// directly (we use `writer` instead) and we never read through it directly
/// (the reader thread holds its own clone via `try_clone_reader`).
struct PaneHandle {
#[allow(dead_code)]
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
#[allow(dead_code)]
child: Box<dyn portable_pty::Child + Send + Sync>,
}
pub struct PtyManager {
panes: Mutex<HashMap<PaneId, PaneHandle>>,
next_id: AtomicU64,
}
impl PtyManager {
pub fn new() -> Self {
Self {
panes: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
}
}
/// Spawn `wsl.exe` (optionally `-d <distro>`, optionally `--cd <cwd>`).
/// Returns the new pane id. A background thread starts reading the PTY
/// immediately and emits `pane://{id}/data` events.
pub fn spawn_wsl(
&self,
app: AppHandle,
distro: Option<String>,
cwd: Option<String>,
cols: u16,
rows: u16,
) -> Result<PaneId> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("openpty failed")?;
let mut cmd = CommandBuilder::new("wsl.exe");
if let Some(d) = distro.as_deref() {
cmd.arg("-d");
cmd.arg(d);
}
if let Some(c) = cwd.as_deref() {
cmd.arg("--cd");
cmd.arg(c);
}
// Force a login shell so .bashrc etc. run and PATH is populated.
// wsl.exe without an explicit command launches the default shell
// interactively, which is exactly what we want.
let child = pair
.slave
.spawn_command(cmd)
.context("failed to spawn wsl.exe; is WSL installed?")?;
// We need to keep the master alive (drop = close the PTY), but we
// also need the reader and writer split from it.
let mut reader = pair
.master
.try_clone_reader()
.context("try_clone_reader failed")?;
let writer = pair
.master
.take_writer()
.context("take_writer failed")?;
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
self.panes.lock().insert(
id,
PaneHandle {
master: pair.master,
writer,
child,
},
);
// Reader thread: pump bytes -> base64 -> emit.
let app_for_reader = app.clone();
let event_name = format!("pane://{id}/data");
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => {
tracing::info!("pane {id}: EOF");
let _ = app_for_reader.emit(&format!("pane://{id}/exit"), ());
break;
}
Ok(n) => {
let chunk_b64 = B64.encode(&buf[..n]);
if let Err(e) =
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
{
tracing::warn!("emit failed for pane {id}: {e}");
}
}
Err(e) => {
tracing::warn!("pane {id} read error: {e}");
let _ = app_for_reader.emit(&format!("pane://{id}/exit"), ());
break;
}
}
}
});
Ok(id)
}
pub fn write(&self, id: PaneId, bytes: &[u8]) -> Result<()> {
let mut panes = self.panes.lock();
let pane = panes
.get_mut(&id)
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
pane.writer.write_all(bytes).context("pty write failed")?;
pane.writer.flush().ok();
Ok(())
}
pub fn resize(&self, id: PaneId, cols: u16, rows: u16) -> Result<()> {
let panes = self.panes.lock();
let pane = panes
.get(&id)
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
pane.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("pty resize failed")?;
Ok(())
}
pub fn kill(&self, id: PaneId) -> Result<()> {
let mut panes = self.panes.lock();
if let Some(mut pane) = panes.remove(&id) {
// Best-effort: ask the child to die. Dropping `master` after this
// closes the PTY which will unblock the reader thread.
let _ = pane.child.kill();
}
Ok(())
}
}
#[derive(Serialize, Clone)]
struct DataChunk {
b64: String,
}
// ---- distro enumeration -----------------------------------------------------
/// Run a process without flashing a console window on Windows.
fn quiet_command(program: &str) -> std::process::Command {
let mut c = std::process::Command::new(program);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
c.creation_flags(CREATE_NO_WINDOW);
}
c
}
/// `wsl.exe -l -q` lists installed distros (one per line, UTF-16LE).
/// Returns Ok(empty) on non-Windows or when wsl.exe isn't on PATH.
pub fn list_wsl_distros() -> Result<Vec<String>> {
if !cfg!(windows) {
return Ok(Vec::new());
}
let out = match quiet_command("wsl.exe").args(["-l", "-q"]).output() {
Ok(o) => o,
Err(e) => {
tracing::debug!("wsl.exe not available: {e}");
return Ok(Vec::new());
}
};
if !out.status.success() {
return Ok(Vec::new());
}
let raw_u16: Vec<u16> = out
.stdout
.chunks_exact(2)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
.collect();
let decoded = String::from_utf16_lossy(&raw_u16);
let distros: Vec<String> = decoded
.lines()
.map(|l| {
l.trim_matches(|c: char| c == '\u{FEFF}' || c.is_whitespace())
.to_string()
})
.filter(|l| !l.is_empty())
.collect();
Ok(distros)
}

46
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,46 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "tiletopia",
"version": "0.0.1",
"identifier": "com.megaproxy.tiletopia",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "tiletopia",
"width": 1100,
"height": 700,
"minWidth": 480,
"minHeight": 320,
"resizable": true,
"decorations": true,
"center": true,
"visible": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost"
}
},
"plugins": {},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "DeveloperTool",
"shortDescription": "Tiling multi-terminal manager for WSL",
"longDescription": "A Windows desktop app for managing many WSL terminals at once. Tile them with a splits-tree layout, save and restore workspaces, broadcast input across panes, get notified when a Claude session finishes. Built primarily for running multiple Claude Code sessions across projects in parallel."
}
}

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;
}

5
svelte.config.js Normal file
View file

@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"moduleResolution": "Bundler",
"strict": true,
"isolatedModules": true,
"skipLibCheck": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

19
vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig(async () => ({
plugins: [svelte()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: "127.0.0.1",
hmr: { protocol: "ws", host: "127.0.0.1", port: 1421 },
watch: { ignored: ["**/src-tauri/**"] },
},
build: {
target: "esnext",
minify: "esbuild",
sourcemap: false,
},
}));