Root cause: xterm.js attaches its own pointerdown handler inside the
terminal and calls e.stopPropagation(), which prevents the .leaf
div's onpointerdown from firing for any click landing inside the
terminal body. That's why clicking pane bodies never moved the blue
active border — the event simply never reached our handler.
Fix: register a document-level CAPTURE-phase pointerdown listener
in App.svelte. Capture fires before xterm.js's bubble-phase handler
runs (and before it can stop propagation), so we always see the
click. The handler walks up via Element.closest('[data-leaf-id]')
to find which pane was clicked, then calls orch.setActive.
- LeafPane.svelte: add data-leaf-id={leaf.id} attribute so the
document handler can identify the clicked pane.
- App.svelte: $effect attaches document.addEventListener('pointerdown',
..., true) and cleans up on teardown.
- Keep the per-leaf onpointerdown as a redundant backup for clicks
on toolbar buttons (which sit outside the xterm subtree). Cheap
+ idempotent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| scripts | ||
| src | ||
| src-tauri | ||
| .gitignore | ||
| CLAUDE.md | ||
| dev.err | ||
| dev.log | ||
| index.html | ||
| memory.md | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| screen0.png | ||
| screen1.png | ||
| screen2.png | ||
| screenshot.png | ||
| shot01-initial.png | ||
| shot02-clicked-pane1.png | ||
| shot03-f12.png | ||
| shot04-clicked-pane2-immediate.png | ||
| shot05-clicked-pane2-after-1s.png | ||
| shot06-clicked-pane2-toolbar.png | ||
| shot07-devtools-attempt.png | ||
| shot08-after-hmr.png | ||
| shot09-after-reload.png | ||
| shot10-after-restart.png | ||
| shot11-clicked-pane2-toolbar.png | ||
| shot12-clicked-bcast.png | ||
| shot13-back-to-pane1.png | ||
| shot14-pane1-bcast.png | ||
| shot15-palette.png | ||
| shot16-pane1-body.png | ||
| shot17-capture-fix-test.png | ||
| shot18-restart.png | ||
| shot19-pane1-body-after-capture.png | ||
| shot20-pane2-body.png | ||
| shot21-baseline.png | ||
| shot22-pane1-body.png | ||
| shot23-pane2-body.png | ||
| shot24-pane1-again.png | ||
| shot25-slow-pane2.png | ||
| shot26-slow-pane1.png | ||
| shot27-slow-pane2-again.png | ||
| shot28-fresh.png | ||
| shot29-click1.png | ||
| shot30-click2.png | ||
| shot31-click3.png | ||
| svelte.config.js | ||
| tilescript.ps1 | ||
| tiletopia-window.png | ||
| tsconfig.json | ||
| tsconfig.node.json | ||
| vite.config.ts | ||
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
- Idle-detection toasts when a pane goes quiet
- Ctrl+K palette to fuzzy-jump between panes
Install
- Download the latest
tiletopia_<version>_x64-setup.exefrom the releases page. - Run it. Windows SmartScreen will warn "unrecognized publisher" — it's not code-signed. More info → Run anyway.
- 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
- Split panes —
⇥in the pane toolbar splits right,⇣splits down. The new pane inherits the parent's distro + cwd. - 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). - Broadcast — toggle
📡on two or more panes (orange border). Typing in any of them mirrors to all. - Preset layouts — titlebar buttons:
1/2H/3H/2V/2×2. Confirms before replacing a multi-pane layout. - Active pane — click any pane → blue border + keyboard focus.
- Jump to pane —
Ctrl+Kopens a fuzzy picker over label / distro / cwd. ↑/↓ to navigate, Enter to focus, Esc to close. - Idle toasts — top-right notification when a pane goes quiet for 5 s. Useful for "I started a long task; tell me when it's done."
Layout + per-pane settings auto-save to %APPDATA%\com.megaproxy.tiletopia\workspace.json (debounced 500 ms).
Stack
- Tauri 2 (Rust backend, WebView2 frontend) — small bundle, native NSIS installer.
- Svelte 5 + TypeScript + Vite + pnpm.
- xterm.js +
@xterm/addon-fitfor terminal rendering. portable-pty(Rust) spawningwsl.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, 43 cases on the layout tree
pnpm test:watch
pnpm check # svelte-check
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):PtyManagerholdingMutex<HashMap<PaneId, PaneHandle>>ofportable-ptychildren. Each spawned pane gets a background reader thread that emitspane://{id}/dataevents (base64 byte chunks). Counterparts:write_to_pane/resize_pane/kill_pane. Workspace persistence viasave_workspace/load_workspacewrites toapp.path().app_config_dir()with atomic tmp + rename. - Layout (
src/lib/layout/tree.ts): binary tree of splits.HSplit | VSplitinternal nodes with a ratio,Leafat the bottom — same model as i3 / tmux / Zellij. Adaptive resize falls out of mutating one parent ratio. Pure helpers (splitLeaf,closeLeaf,changeDistro, etc.) live intree.ts; the rendering chain (Pane.svelte→SplitNode.svelte/LeafPane.svelte) is thin. - Orchestration — broadcast routing, idle detection, palette, active-pane focus all live in
App.svelteand reach the panes via aPaneOpsbundle (src/lib/layout/ops.ts) drilled through the Pane chain.
License
No formal license yet. Public for inspection and personal use; if you want to redistribute, open an issue and ask.