For a subscription user, lifetime token totals + a $ estimate aren't
actionable; how full each session's context window is right now is. So:
- Removed the UsagePanel, the titlebar 💰 chip, and Ctrl+Shift+U.
- Repurposed the transcript reader (src-tauri/src/usage.rs): get_pane_context
returns each recent session's CURRENT context occupancy = the last
assistant turn's input + cache_read + cache_creation tokens (the prompt
size), instead of lifetime sums. Same UNC/$HOME/cache/recency machinery.
- src/lib/usage.ts now holds context helpers (window inference 200k vs 1M by
whether occupancy already exceeds 200k, % , green→amber→red ramp, label).
- App polls get_pane_context (15s, visibility-gated) into a cwd→context map
exposed via orchestration; each LeafPane looks itself up by leaf.cwd and
renders a slim fill bar + % in its header (hidden for non-claude/unmatched
panes).
Also fixes the narrow-pane toolbar: a ResizeObserver sets leaf--narrow /
leaf--xnarrow width tiers; the label shrinks first, split buttons / status /
secondary chips drop out by tier, and the close × + context indicator stay
pinned right and visible down to the 180px min width.
tsc clean (apart from the not-yet-installed xterm addons). Rust builds on
the Windows host; needs runtime verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
256 lines
20 KiB
Markdown
256 lines
20 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, 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 Manager–stored 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) |
|
||
| `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 (window-level — works even when no terminal is focused) |
|
||
| `Ctrl+Alt+← / → / ↑ / ↓` | Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it) |
|
||
| `Ctrl+Alt+H / J / K / L` | Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right) |
|
||
| `Alt+1 … Alt+9` | Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict. |
|
||
|
||
**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 |
|
||
| `Ctrl+Shift+F` | Open find-in-scrollback bar for the focused pane |
|
||
| `Enter / Shift+Enter` | Next / previous match (while search bar is focused) |
|
||
| `Escape` | Close find bar and return focus to 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: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.
|