Tiling multi-terminal manager for WSL
Find a file
megaproxy 5ef35e3a74 README: add tabs + multi-window to feature highlights
The at-a-glance highlights list omitted the two headline 0.4.0 features
(tabs and multi-window pane transfer); body sections already covered them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:43:21 +01:00
scripts release.sh: call node directly for build:mcpb (skip pnpm install hang) 2026-05-26 19:29:53 +01:00
src Fix XtermPane IPC listener leak on unmount-during-spawn/adopt 2026-05-28 20:34:36 +01:00
src-tauri Bump version to 0.4.0 2026-05-28 20:36:22 +01:00
.gitignore Fix workspace accumulation, tab-close popover, scrollbars, drag ghost 2026-05-28 20:24:09 +01:00
CLAUDE.md CLAUDE.md: React 18, not Svelte 5 2026-05-26 16:13:32 +01:00
index.html Migrate frontend from Svelte 5 to React 18 2026-05-22 18:05:05 +01:00
memory.md Fix XtermPane IPC listener leak on unmount-during-spawn/adopt 2026-05-28 20:34:36 +01:00
package.json Bump version to 0.4.0 2026-05-28 20:36:22 +01:00
pnpm-lock.yaml Make URLs in terminal output clickable via xterm web-links + tauri-plugin-opener 2026-05-25 19:13:08 +01:00
pnpm-workspace.yaml Initial scaffold from M1 spike (tiletopia) 2026-05-22 12:31:29 +01:00
README.md README: add tabs + multi-window to feature highlights 2026-05-28 20:43:21 +01:00
tsconfig.app.json Migrate frontend from Svelte 5 to React 18 2026-05-22 18:05:05 +01:00
tsconfig.json Migrate frontend from Svelte 5 to React 18 2026-05-22 18:05:05 +01:00
tsconfig.node.json Migrate frontend from Svelte 5 to React 18 2026-05-22 18:05:05 +01:00
vite.config.ts Migrate frontend from Svelte 5 to React 18 2026-05-22 18:05:05 +01:00

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, drag-to-swap pane headers, preset layouts (single / 2-col / 3-col / 2-row / 2×2)
  • Tabs — each tab is an independent tile layout (one per project); PTYs in inactive tabs keep running
  • Multi-window — pop a pane into its own window (right-click its toolbar, or drag it past the window edge); the PTY survives the move and scrollback replays
  • Three shell kinds per pane: WSL distros, PowerShell, saved SSH hosts (with optional Windows Credential Managerstored passwords for auto-typing at the prompt)
  • Per-pane distro + cwd + label + font-size + broadcast state, persisted across restarts
  • Broadcast input to a group of panes (per-pane 📡 chip, or global toggle in the titlebar)
  • Idle indicator (red border + titlebar "N idle" badge) when a pane goes quiet
  • Ctrl+K palette to fuzzy-jump between panes
  • MCP server so a Claude session — Claude Desktop, Claude Code, or one running inside a tiletopia pane itself — can read scrollback, send keystrokes, spawn / close / swap panes, change layout, and manage SSH hosts. Tiered policy with confirm modals + a compiled-in hard-deny list (rm -rf /, fork bomb, iwr | iex, etc.) you can't disable.

Install

  1. Download the latest tiletopia_<version>_x64-setup.exe from the releases page.
  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 (preinstalled on Win11).
  • At least one WSL distro registered (wsl -l -v).

Using it

Shortcuts and tips

Keyboard shortcuts

Layout

Key Action
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; self-inverse)

Tabs

Key Action
Ctrl+T New tab (blank workspace, one pane)
Ctrl+Shift+T Close current tab (confirms when the tab has live panes)
Ctrl+PageDown / Ctrl+PageUp Switch to next / previous tab
Ctrl+1 … Ctrl+9 Switch to tab 1 … 9

Multi-window

Key Action
Right-click pane toolbar → Move to new window Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays)
Drag pane toolbar past the window edge Same as the right-click action — release the drag well outside the window to detach into a new window

Navigation

Key Action
Ctrl+K Open jump-to-pane palette
Ctrl+Shift+← / → / ↑ / ↓ Focus neighbour pane in that direction

Broadcast

Key Action
Ctrl+Shift+B Toggle broadcast on active pane
Ctrl+Shift+Alt+B Toggle broadcast on ALL panes (same as titlebar 📡)

Font size

Key Action
Ctrl+= / Ctrl+- / Ctrl+0 Zoom active pane in / out / reset
Ctrl+Shift+= / Ctrl+Shift+- / Ctrl+Shift+0 Same, applied to every pane

Terminal

Key Action
Ctrl+Shift+C / Ctrl+Shift+V Copy selection / paste in terminal

Help

Key Action
F1 Show this help overlay

Tips

  • Per-pane shell picker — Click the distro chip in any pane's toolbar to switch between WSL distros, PowerShell, or a saved SSH host. The pane respawns with the new shell.
  • SSH host manager — Titlebar 🔑 SSH hosts opens the manager. Add hostname / user / port / identity file / jump host / extra ssh args. Saved hosts appear in every pane's dropdown.
  • Saved passwords — Optionally save a host's password — stored in Windows Credential Manager (DPAPI-encrypted), never written to hosts.json. When ssh prompts on connect it's typed automatically. Hosts with a saved password show 🔒 in the list.
  • Clickable links — http and https URLs in terminal output get underlined and open in your default browser on click.
  • Drag pane headers to swap or detach — Grab a pane's title bar and drag onto another pane to swap their tree positions. Drag well outside the window edge (more than ~60px past) and release to detach the pane into a new window — same mechanism as the right-click 'Move to new window' action, PTY stays alive.
  • Workspace persistence — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.
  • Tabs (workspaces) — Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.
  • MCP server (let Claude drive the workspace) — Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.

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.

The shortcut tables and tips above are generated from src/lib/shortcuts.ts (the single source of truth shared with the in-app help overlay). To change them, edit that file and run pnpm gen:readme.

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 — Claude Desktop, Claude Code, or one running inside a tiletopia pane itself — connects to it, reads scrollback, waits for commands to settle, and (with your permission) drives the workspace: sends keystrokes, spawns / closes / swaps / reshapes panes, manages SSH hosts.

  • Off by default. Click the button, hit Server: ON to start. The panel shows the URL + bearer token and a ready-to-paste config snippet. Both port and token persist across restarts (saved to %APPDATA%\com.megaproxy.tiletopia\mcp.json); use Regenerate 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.
  • Three-tier policy (allow / ask / deny) with a confirm modal on every Ask, configurable in the panel's Policy tab. The default is "ask on everything"; add bare tool names like set_label to allow to skip the prompts, or globs like write_pane(rm *) to deny outright.
  • Compiled-in hard-deny list of 14 patterns the user can NOT disable: rm -rf /, fork bomb, dd of=/dev/sd..., curl | sh, Remove-Item -Recurse -Force C:\, iwr | iex, etc. Checked against every write_pane text before policy. Best-effort accident prevention, not a sandbox — alias and quoting tricks bypass it.
  • SSH safeguards. Three switches in the Policy tab, all off by default: allow_open_ssh (gates connect_host / spawn_pane(ssh)), auto_allow_spawned_ssh (gates whether spawned-by-Claude SSH panes start MCP-allowed), allow_add_host (gates add_host / delete_host saved-list edits). add_host's extraArgs are also sanitised — ProxyCommand / LocalCommand / KnownHostsCommand / PermitLocalCommand=yes (CVE-2023-51385 class) are refused.
  • Audit log in the panel's Audit tab — last 200 tool calls with arg summary, outcome, duration. Ephemeral (cleared on restart).
  • 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.

Tools currently exposed:

Tool What it does
read_pane(leaf_id, last_lines?, after_seq?) Read a pane's scrollback. Returns text + __seq__=N marker for incremental polling.
wait_for_idle(leaf_id, idle_ms?, timeout_ms?) Block until a pane is quiet — useful for command-completion sync.
write_pane(leaf_id, text) Send keystrokes. Rate-limited (30 calls / 10s per pane). Hard-deny + user policy apply.
set_label, close_pane, swap_panes, promote_pane, apply_preset Tree-shape and metadata operations.
spawn_pane, connect_host Open new local / SSH panes. SSH gated by allow_open_ssh safeguard.
add_host, delete_host Manage the saved SSH hosts list. Gated by allow_add_host; extraArgs sanitised.

The MCP panel has a Download .mcpb button that fetches a packaged Claude Desktop extension (an .mcpb file). Drag it into Claude Desktop's Settings → Extensions pane and Claude will auto-discover tiletopia — no config editing, no copy-pasting tokens.

The bundle ships a tiny wrapper that reads your per-install bearer token straight from %APPDATA%\com.megaproxy.tiletopia\mcp.json at launch, so:

  • It carries no secrets — the same file works for every tiletopia install.
  • Token regeneration in the panel keeps working transparently; the next time Claude Desktop launches the extension, it'll pick up the new token.
  • Requires npx (Node 18+) on PATH because the wrapper still talks to tiletopia through mcp-remote (same reason as the manual recipe below).

You can also rebuild the bundle from source:

pnpm run build:mcpb     # writes dist-mcpb/tiletopia.mcpb

Claude Code setup (via mcp-remote stdio shim — fallback / manual recipe)

Claude Code (the terminal CLI) doesn't accept .mcpb bundles yet, and its HTTP-MCP client currently tries OAuth discovery and ignores static headers auth (Anthropic #17152, #46879). The 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:

{
  "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:

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: MSVC ("C++ build tools" workload), Rust, Node 20+, pnpm (corepack use pnpm@latest), at least one WSL distro.

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

pnpm test          # vitest — currently 72 cases (layout tree)
pnpm test:watch
pnpm check         # tsc --noEmit (strict TypeScript pass)
pnpm build         # tsc -b && vite build — full production frontend bundle
cd src-tauri
cargo test --lib   # 138+ Rust unit tests (mostly hard-deny pattern fuzzing + extraArgs sanitiser)

The test suites cover pure helpers (src/lib/layout/tree.ts) on the frontend and the hard-deny regex set + SSH extraArgs sanitiser + MCP policy evaluator on the backend. UI behaviour, broadcast routing, MCP end-to-end, and Tauri IPC are manually verified — see scripts/pr4-verify.mjs for a Node-driven MCP smoke test you can run against the dev app.

Architecture

  • PTY backend (src-tauri/src/pty.rs): PtyManager holding Mutex<HashMap<PaneId, PaneHandle>> of portable-pty children. Spawns wsl.exe / powershell.exe / ssh.exe depending on the leaf's shellKind. Each spawn gets a background reader thread that emits pane://{id}/data events (base64 byte chunks) AND mirrors bytes into a per-pane 256 KB scrollback ring that the MCP server reads from. Counterparts: write_to_pane / resize_pane / kill_pane. Workspace + hosts + MCP config persisted via save_* / load_* to app.path().app_config_dir() with atomic tmp+rename.
  • SSH hosts (src-tauri/src/hosts.rs, src-tauri/src/creds.rs): SshHost shape with optional user / port / identity / jump host / extra ssh args. Passwords stored in Windows Credential Manager via keyring-core 1.0 + windows-native-keyring-store — never on disk, never in IPC events, never in the MCP surface. Reader thread autotypes the password at the first password: / passphrase prompt within 30 s of spawn, then disarms.
  • MCP server (src-tauri/src/mcp.rs): embedded rmcp Streamable HTTP server on 127.0.0.1 with bearer-token auth, default port 47821 (overridable; falls back to OS-picked if taken). 12 tools — 2 read (read_pane, wait_for_idle) + 10 write (set_label, close_pane, swap_panes, promote_pane, apply_preset, spawn_pane, connect_host, write_pane, add_host, delete_host). Write tools dispatch through the event/reply pattern in src-tauri/src/mcp.rs — frontend owns tree authority, backend emits a request event, frontend resolves via mcp_action_reply. Per-leaf mcpAllow gate (default-deny) filters what the server can see.
  • MCP policy (src-tauri/src/mcp_policy.rs): three-tier allow / ask / deny precedence (deny-first), glob matcher (* only, no regex), shell-operator-aware subcommand splitting on && || ; | |& & \n. Hard-deny pass runs against both the whole input and each subcommand to catch patterns that span operators (fork bomb, curl | sh). Hard-deny list is 14 patterns compiled in, non-overridable. Plus SshSafeguards switches for SSH-spawn / SSH-auto-allow / saved-host edits.
  • 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, setLeafShell, swapLeaves, promoteLeaf, etc.) live in tree.ts with 72 vitest cases; the rendering chain (Pane.tsxSplitNode.tsx / LeafPane.tsx) is thin.
  • Orchestration — broadcast routing, idle detection, palette, active-pane focus, MCP request dispatcher 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.