tiletopia/README.md
megaproxy d951c360ae Replace token-usage panel with per-pane context-fill indicator
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>
2026-05-28 22:43:06 +01:00

256 lines
20 KiB
Markdown
Raw Permalink 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)
- 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](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.