tiletopia/README.md
megaproxy 8ad51787fc Phase 2: drag-/right-click-a-pane-to-new-window
Right-click any pane's title bar → "Move to new window" pops it into a
fresh tiletopia window with its PTY intact. Same Tauri process; the
PtyManager is shared, so the existing PaneId stays valid and Tauri 2's
process-wide event routing keeps pane://{id}/data flowing into the new
window's XtermPane.

Mechanism (Rust-side, plan-agent's main correction over my draft):
- pty.rs: PtyManager.transferring is a per-pane refcount; kill_pane
  becomes a no-op while it's >0. Source window's React unmount calls
  kill_pane → silently dropped while in flight; target window's
  claim_pane decrements after it has subscribed.
- window_state.rs: per-window workspaces snapshot map +
  debounced-by-tokio aggregate save. Each window pushes its tabs via
  push_window_workspaces; backend writes the merged
  { version: 2, workspaces: [...] } envelope. Non-main windows have
  their entries dropped on CloseRequested so closing a detached window
  discards its tabs (Chrome-style).
- commands: mark_pane_transferring, claim_pane, get_pane_ring (base64
  scrollback ring snapshot), create_pane_window, take_pending_window_init,
  push_window_workspaces.

Frontend:
- XtermPane gets `existingPaneId?: PaneId`: skip spawn, replay ring
  snapshot via term.write before attaching the live data listener,
  resize PTY to this window's grid, claim_pane. Scrollback replay was
  the plan agent's other ship-in-v1 call — without it a transferred
  Claude session looks blank until next prompt repaint.
- LeafPane: onContextMenu opens a fixed-positioned "Move to new
  window" popover. Esc / outside-click dismiss.
- orchestration adds moveToNewWindow + getInitialPaneIdFor; App owns a
  one-shot transferredPaneIdsRef cleared in registerPaneId.
- App mount branches on getCurrentWebviewWindow().label: main loads
  workspace.json as before; non-main calls take_pending_window_init
  and builds a singleton workspace around the adopted leaf.
- MCP mirror + onMcpRequest only run in main (paneIdByLeafRef is per-
  window; Claude sees the main window's current tab as the single
  workspace surface).

pnpm check (tsc -b) clean. 79/79 vitest pass. Rust side authored in
WSL; cargo build needs verification on Windows host before this is
runnable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:57:31 +01:00

247 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
- 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](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
### Shortcuts and tips
<!-- SHORTCUTS:START -->
#### 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) |
**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** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard.
- **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:END -->
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. |
#### Claude Desktop setup (one-click via `.mcpb` bundle — recommended)
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:
```sh
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](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 — 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
```
```powershell
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](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.tsx``SplitNode.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.