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
4853
src-tauri/Cargo.lock
generated
Normal file
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
40
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
||||
2292
src-tauri/gen/schemas/desktop-schema.json
Normal file
2292
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load diff
2292
src-tauri/gen/schemas/windows-schema.json
Normal file
2292
src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load diff
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
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
BIN
src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
src-tauri/icons/icon.ico
Normal file
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
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
57
src-tauri/src/commands.rs
Normal 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
28
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal 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
229
src-tauri/src/pty.rs
Normal 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
46
src-tauri/tauri.conf.json
Normal 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."
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue