Port (default 47821) and bearer token now persist to mcp.json with OS-picked fallback if the port is taken; new Regenerate button in the panel rotates the token and restarts the running server. rmcp's DNS-rebinding host allowlist is disabled so WSL gateway IPs can connect (bearer-auth handles the gatekeeping); the auth middleware only enforces on /mcp paths so OAuth-discovery clients don't see a Bearer challenge on /.well-known/* probes. Claude Code's HTTP-MCP client currently tries OAuth and ignores static `headers` auth (anthropics/claude-code#17152, #46879), so the panel + README config snippet now uses `npx mcp-remote` as a stdio shim that proxies the HTTP endpoint with the bearer baked in.
143 lines
9.6 KiB
Markdown
143 lines
9.6 KiB
Markdown
# tiletopia
|
||
|
||
A Windows desktop app for running and arranging many WSL terminals at once. Built primarily for managing multiple `claude` sessions across projects in parallel; works for any multi-shell workflow.
|
||
|
||
- Tiling layout — recursive splits, draggable dividers, preset layouts (single / 2-col / 3-col / 2-row / 2×2)
|
||
- Per-pane distro + cwd + label, persisted across restarts
|
||
- Broadcast input to a group of panes (per-pane 📡 chip, or global toggle in the titlebar)
|
||
- Idle-detection toasts when a pane goes quiet
|
||
- Ctrl+K palette to fuzzy-jump between panes
|
||
|
||
## Install
|
||
|
||
1. Download the latest `tiletopia_<version>_x64-setup.exe` from the [releases page](https://git.rdx4.com/megaproxy/tiletopia/releases).
|
||
2. Run it. Windows SmartScreen will warn "unrecognized publisher" — it's not code-signed. **More info → Run anyway**.
|
||
3. Launch *tiletopia* from the Start menu. A window opens with one terminal pane bound to your default WSL distro.
|
||
|
||
### Requirements
|
||
|
||
- **Windows 10/11** with [WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/) (preinstalled on Win11).
|
||
- At least one WSL distro registered (`wsl -l -v`).
|
||
|
||
## Using it
|
||
|
||
### Keyboard shortcuts
|
||
|
||
| Key | Action |
|
||
|---|---|
|
||
| `Ctrl+K` | open the jump-to-pane palette (fuzzy match over label / distro / cwd; `↑`/`↓` to move, `Enter` to focus, `Esc` to close) |
|
||
| `Ctrl+Shift+E` | split active pane to the right |
|
||
| `Ctrl+Shift+O` | split active pane downward |
|
||
| `Ctrl+Shift+W` | close active pane |
|
||
| `Ctrl+Shift+P` | promote active pane out one level — turns a nested pane into a full row/column (e.g. nested-right `c` becomes a full-width bottom row). Self-inverse. |
|
||
| `Ctrl+Shift+B` | toggle broadcast on active pane |
|
||
| `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (same as the titlebar 📡 button) |
|
||
| `Ctrl+Shift+←` / `→` / `↑` / `↓` | focus neighbour pane in that direction |
|
||
| `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | zoom the active pane in / out / back to default |
|
||
| `Ctrl+Shift+=` / `Ctrl+Shift+-` / `Ctrl+Shift+0` | same, applied to **every** pane (shift = "to all") |
|
||
|
||
Shortcuts work while a terminal is focused — we capture the key before xterm.js sees it. They don't fire while you're typing into a label edit or the palette input, so those still work normally. `Ctrl` and `⌘` (Cmd) are interchangeable.
|
||
|
||
Font size persists per pane in `workspace.json`, so a zoomed pane stays zoomed across restarts.
|
||
|
||
### Mouse + toolbar
|
||
|
||
- **Split panes** — `⇥` in the pane toolbar splits right, `⇣` splits down. The new pane inherits the parent's distro; the cwd defaults to `~` in the WSL distro.
|
||
- **Close pane** — `×`. The sibling expands to fill.
|
||
- **Rename pane** — click the label in the toolbar, type, `Enter` (`Esc` to cancel).
|
||
- **Change distro** — click the small `Ubuntu ▾` chip; pick a distro from the popover. The pane respawns (old shell is killed).
|
||
- **Swap panes** — click-and-drag a pane's toolbar onto another pane. The two leaves trade tree slots; both shells stay alive, both scrollbacks intact.
|
||
- **Active pane** — click any pane → blue border + keyboard focus.
|
||
- **Resize** — drag the gutter between two panes. A 180 px minimum is enforced on both sides.
|
||
|
||
### Broadcast, idle, presets
|
||
|
||
- **Broadcast** — toggle `📡` on two or more panes (orange border). Typing in any of them mirrors to the rest. The titlebar `📡 all off` / `📡 all on` / `📡 N/M` button flips the whole group at once.
|
||
- **Idle indicator** — when a pane goes quiet for 5 s, its border turns red and its "alive" toolbar tag swaps to red "idle". The titlebar also shows an `N idle` count. Clears the moment new output arrives. Active + broadcasting borders take precedence so the focus indicator isn't masked.
|
||
- **Preset layouts** — titlebar buttons `1` / `2H` / `3H` / `2V` / `2×2`. Existing panes are spliced into the new shape in order (ids, shells, scrollback preserved); extra slots spawn fresh shells. Only prompts if the preset has fewer slots than you currently have panes (those overflow shells get killed).
|
||
|
||
Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\workspace.json` (debounced 500 ms).
|
||
|
||
### MCP server (Claude can drive the workspace)
|
||
|
||
The titlebar 🤖 button opens a small panel that starts an MCP (Model Context Protocol) server. A Claude session — running anywhere reachable from the host, including inside one of tiletopia's own panes — can connect to it, read scrollback, wait for commands to settle, and inspect the layout. v1 is **read-only**: no spawning, no keystroke injection, no host editing.
|
||
|
||
- **Off by default.** Click the button, hit **Server: ON** to start. The panel shows the URL + bearer token and a ready-to-paste Claude config snippet. Both port and token persist across restarts (saved to `%APPDATA%\com.megaproxy.tiletopia\mcp.json`); use **Regenerate** in the panel if the token leaks.
|
||
- **Default-deny per pane.** Toggle the 🤖 chip in any pane's toolbar to allow MCP to see it. Panes without the chip on are invisible to the server.
|
||
- **Saved SSH passwords are never exposed** through the MCP surface.
|
||
- **Bound to all interfaces** (`0.0.0.0`). The bearer token is the only auth — don't enable the server on an untrusted network.
|
||
|
||
#### Claude Code setup (via `mcp-remote` stdio shim)
|
||
|
||
Claude Code's HTTP-MCP client currently tries OAuth discovery and ignores static `headers` auth (Anthropic [#17152](https://github.com/anthropics/claude-code/issues/17152), [#46879](https://github.com/anthropics/claude-code/issues/46879)). The [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio shim transparently proxies the HTTP endpoint with the bearer header attached, sidestepping the OAuth flow.
|
||
|
||
The panel's config snippet uses this shim by default — paste it into your project's `.mcp.json`:
|
||
|
||
```json
|
||
{
|
||
"mcpServers": {
|
||
"tiletopia": {
|
||
"command": "npx",
|
||
"args": [
|
||
"-y", "mcp-remote",
|
||
"http://127.0.0.1:47821/mcp",
|
||
"--allow-http",
|
||
"--header", "Authorization: Bearer <token-from-panel>"
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Requires `npx` (Node 18+) on the client side. Other MCP clients that handle static bearer auth correctly can skip the shim and connect directly to the URL + token shown in the panel.
|
||
|
||
#### WSL connectivity
|
||
|
||
When Claude runs inside WSL, swap `127.0.0.1` for the WSL gateway IP (`ip route show default | awk '{print $3}'` inside WSL — note that this changes after each WSL restart) **or** enable mirrored networking (`networkingMode=mirrored` in `%UserProfile%\.wslconfig` then `wsl --shutdown`; Win 11 22H2+). Allow the port through Windows Defender Firewall once — elevated PowerShell:
|
||
|
||
```powershell
|
||
New-NetFirewallRule -DisplayName "tiletopia MCP" -Direction Inbound `
|
||
-Action Allow -Protocol TCP -LocalPort 47821 -Profile Any
|
||
```
|
||
|
||
## Stack
|
||
|
||
- **Tauri 2** (Rust backend, WebView2 frontend) — small bundle, native NSIS installer.
|
||
- **React 18** + TypeScript + Vite + pnpm. (The v0.1.0 release was Svelte 5; v0.2.0+ is React after a ground-up rewrite of the frontend. Same data model, same backend, more reliable reactivity through the recursive Pane chain. The Svelte version is preserved on the `svelte-archive` branch.)
|
||
- **xterm.js** + `@xterm/addon-fit` for terminal rendering.
|
||
- **`portable-pty`** (Rust) spawning `wsl.exe -d <distro>` PTYs.
|
||
|
||
## Build from source
|
||
|
||
This targets Windows; the Rust toolchain runs on the Windows host. Prereqs per [Tauri docs](https://v2.tauri.app/start/prerequisites/#windows): MSVC ("C++ build tools" workload), Rust, Node 20+, pnpm (`corepack use pnpm@latest`), at least one WSL distro.
|
||
|
||
```powershell
|
||
git clone https://git.rdx4.com/megaproxy/tiletopia.git
|
||
cd tiletopia
|
||
pnpm install
|
||
pnpm tauri dev # iterate
|
||
pnpm tauri build # NSIS installer at src-tauri\target\release\bundle\nsis\
|
||
```
|
||
|
||
**Keep the source on a Windows-native drive** (e.g. `C:\` or `D:\`). Running pnpm against a `\\wsl.localhost\...` UNC path crashes pnpm 11.x inside `isDriveExFat` (with a misleading error from the crashing hint formatter).
|
||
|
||
### Run the tests
|
||
|
||
```sh
|
||
pnpm test # vitest, 43 cases on the layout tree
|
||
pnpm test:watch
|
||
pnpm check # tsc --noEmit (strict TypeScript pass)
|
||
pnpm build # tsc -b && vite build — full production frontend bundle
|
||
```
|
||
|
||
The test suite covers the pure helpers in `src/lib/layout/tree.ts`. UI behavior, broadcast routing, and Tauri integration are manually tested.
|
||
|
||
## Architecture
|
||
|
||
- **Backend** (`src-tauri/src/pty.rs`): `PtyManager` holding `Mutex<HashMap<PaneId, PaneHandle>>` of `portable-pty` children. Each spawned pane gets a background reader thread that emits `pane://{id}/data` events (base64 byte chunks). Counterparts: `write_to_pane` / `resize_pane` / `kill_pane`. Workspace persistence via `save_workspace` / `load_workspace` writes to `app.path().app_config_dir()` with atomic tmp + rename.
|
||
- **Layout** (`src/lib/layout/tree.ts`): binary tree of splits. `HSplit | VSplit` internal nodes with a ratio, `Leaf` at the bottom — same model as i3 / tmux / Zellij. Adaptive resize falls out of mutating one parent ratio. Pure helpers (`splitLeaf`, `closeLeaf`, `changeDistro`, `setAllBroadcast`, etc.) live in `tree.ts` with 43 vitest cases; the rendering chain (`Pane.tsx` → `SplitNode.tsx` / `LeafPane.tsx`) is thin.
|
||
- **Orchestration** — broadcast routing, idle detection, palette, active-pane focus all live in `App.tsx`. Shared state and operations reach descendants through a React Context (`src/lib/layout/orchestration.tsx`), so each LeafPane reads `activeLeafId`, `distros`, and the tree-mutation methods directly via `useOrchestration()` — no prop drilling through the recursive Pane chain.
|
||
|
||
## License
|
||
|
||
No formal license yet. Public for inspection and personal use; if you want to redistribute, open an issue and ask.
|