Document the five-layer breakage we unwound (WDF block rules, rmcp host allowlist, our middleware intercepting OAuth probes, Claude Code ignoring static bearer, mcp-remote --allow-http) and the working stdio-shim recipe.
29 KiB
memory — tiletopia
Durable memory for this project. Read at session start, update before session end. Date format: YYYY-MM-DD.
Decisions & rationale
- Stack: Tauri 2 + React 18 + TypeScript + Vite + pnpm + xterm.js +
portable-pty. Originally Svelte 5; migrated to React in commit774b863(released as 0.2.0). Mirrorsclaude-usage-widget's Windows-targeting toolchain (MSVC + WebView2 + NSIS installer). No new technology bets stacked on top of the new product bet. CLAUDE.md still says Svelte 5 — should be updated when convenient. - Layout model: binary tree of splits, NOT free-form rectangles. Same as i3 / tmux / Zellij. Each internal node is HSplit/VSplit + ratio; each leaf is a terminal. Dragging a gutter mutates one parent ratio; both sibling subtrees reflow; descendants get
resize. Adaptive resize falls out automatically with no constraint solver. Preset layouts ("3 columns", "2×2") are pre-built trees. - PTY backend:
portable-pty(same crate WezTerm uses). Spawnswsl.exe -d <distro> --cd <path>on Windows. Manager is aMutex<HashMap<PaneId, PaneHandle>>in Rust; each pane has a background reader thread that emitspane://{id}/dataevents. - Wire format: base64-encoded byte chunks via Tauri events. xterm.js's
onDataemits strings; we UTF-8 encode then base64. Slower than a typed-array payload but trivially correct. Revisit if throughput matters. - Source on Windows-native disk (
D:\dev\tiletopia\), symlinked into WSL. Same pattern asrimlike(D:\godot\rimlike) andtavernkeep. Forced by pnpm 11.x'sisDriveExFatcrashing on\\wsl.localhost\...UNC paths. - Don't commit
node_modules,src-tauri/target, or.pnpm-store. DO commitCargo.lock(binary project, reproducible builds). - Session awareness without an in-pane agent. Plan: poll
/proc/<pid>/cwdof the shell's child + foreground process every ~2s. Sufficient to detectcdand whetherclaudeis running. - State propagation in the layout tree: hybrid mutable + replace. The root tree is
$state(...)at App level. Direct mutation (e.g.node.ratio = Xduring gutter drag) is reactive via Svelte 5's deep proxy. Structural changes (split/close) go through pure helpers intree.tsthat return a new root, which App reassigns. Drag stays fast (no tree walk); structural changes stay simple.{#key leaf.id}aroundLeafPaneensures swapping a leaf in/out cleanly unmounts XtermPane (which kills the PTY on destroy). - Layout persistence:
%APPDATA%/com.megaproxy.tiletopia/workspace.jsonvia two Tauri commands (save_workspace,load_workspace). Atomic write (tmp + rename) so a crash mid-save can't leave a partial file. Path comes from Tauri'sapp.path().app_config_dir()— no separatedirscrate needed. M2's localStorage path is checked once at boot as a one-time migration source, then cleared. - Auto-save is debounced 500ms. Every tree mutation kicks the
$effect; it resets a timeout and only writes after 500ms of quiet. Cheap enough to never need throttling on UI mutations; matters because each gutter-drag step would otherwise hit disk dozens of times per second. - Pane operations bundled into a
PaneOpsinterface inlib/layout/ops.ts. Pane and SplitNode just passopsthrough; LeafPane consumes it. Replaces M2's per-callback prop drilling (would have been split + close + setDistro + setLabel + distros = 5 separate props). Easier to grow as M4 adds broadcast / palette ops. - Per-pane distro change forces a remount via id swap.
changeDistrointree.tsassigns a new id to the leaf;Pane.svelte's{#key leaf.id}unmounts XtermPane (which kills the old PTY) and mounts a fresh one with the new distro. Same mechanism we already use for split/close. - Split inherits parent's distro AND cwd (not label — label is a per-pane name, not a hierarchy thing). So "split right" while in a project keeps both panes in that project.
- Broadcast input is frontend-routed, not a backend command. Each LeafPane reports its backend
PaneIdto App viaops.registerPaneId. When a broadcasting pane'sXtermPane.onInputfires, App'sbroadcastFromwalks all other leaves withbroadcast === trueand callswriteToPane(theirPaneId, b64). No Rust changes needed; the existing per-pane write path does the work N times. Origin pane writes to its own PTY normally — broadcast is purely about mirroring to others. - Idle detection lives in LeafPane. Each pane tracks
lastDataTime(reset on everyXtermPane.onDataReceived) and asetIntervalthat firesops.notifyafterIDLE_THRESHOLD_MS(5000ms) of silence, once per idle cycle. No backend involvement — purely observes the existing PTY data stream. The "is foreground process claude" filter is deferred (would need a Rust-side foreground-process probe); for now every pane notifies after 5s of quiet. - In-app toasts (top-right stack), 5s auto-dismiss. Lives in
Notifications.svelte; App owns the array + auto-dismiss timer. Not native OS notifications — defertauri-plugin-notificationif/when we want desktop alerts that work when the app is backgrounded. - Ctrl+K palette: modal overlay with text filter on
label | distro | cwd, arrow-key nav, Enter to focus. Activating a pane setsactiveLeafId;LeafPanehas a$derivedactive = ops.activeLeafId === leaf.idand a$effectthat bumps afocusTriggercounter when active flips true;XtermPanewatchesfocusTriggerand callsterm.focus(). Active pane gets a blue 1px border; broadcasting pane gets orange.
Open questions / TODOs
M2 — splits-tree layout component. Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON.Done 2026-05-22.M3 — workspace persistence + preset layouts + per-pane distro + pane labels.Done 2026-05-22.M4 — orchestration. Broadcast input, idle notifications, Ctrl+K palette.Done 2026-05-22.Auto-save debouncing.500ms timer inApp.svelte$effect.HMR distro picker reset.No longer an issue — per-pane distro selection.- Idle detection: filter by "claude is foreground." Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a
clauderesponse. Want to detect thatclaude(or any user-specified process) is actually running in the pane's shell before notifying. Needs a Rust-side probe over WSL:wsl.exe -d <distro> ps --ppid <shell_pid> -o comm=. Defer to a future polish pass. - Native OS notifications. Right now toasts only show while the app is focused.
tauri-plugin-notificationwould push to Windows Action Center; useful for "claude finished" when the app is minimized. Worth adding if/when the user actually backgrounds the app while waiting for sessions. - Configurable idle threshold. Hardcoded 5000ms in
LeafPane.svelte. Should move into a settings panel; M5 territory. Logic tests forVitest, 43 cases, runs viatree.ts.pnpm test. Done 2026-05-22.- Component-level tests (vitest + jsdom + @testing-library/svelte) — would have caught the M4 active-border reactivity bug. Useful when the Svelte component surface stops being trivial; defer until/unless something else goes sideways.
- Multi-workspace tabs. Several independent layouts the user can switch between. Saved as
workspaces.jsonwith{ current: id, list: [{ id, name, tree }] }. Not on the M0–M5 critical path; either bolt on after M5 ship or fold into a "tabs" minor milestone. M5 — Ship infrastructure.Custom icon, version bumped to 0.1.0,scripts/release.shfor one-shot tag+upload, README install section. Done 2026-05-22. Next step (user action): runpnpm tauri buildon Windows thenscripts/release.sh v0.1.0from WSL to cut the actual release.- Native Windows shells (cmd / pwsh)?
portable-ptysupports them for free; keep the option open. Decide whether to expose in UI at M3. - Persistent scrollback across app restarts. Would need an out-of-process mux daemon. Big scope creep; explicitly deferred past v1.
- Keybinding philosophy. Copy tmux, copy WezTerm, or invent? Decide at M3.
- Help (?) overlay. Small
?icon in the titlebar, opens a modal listing all keyboard shortcuts (split / close / promote / broadcast / palette / font size / nav) and quick tips on shell-picker dropdown + SSH host manager + saved-password autotype. Same modal style asPalette/HostManager. Source of truth lives in one place — refactor the README shortcuts table to be generated from it (or vice versa) so they can't drift. - MCP server: Claude controls tiletopia. Expose a Model Context Protocol server (stdio transport, runs inside the Tauri app or a sidecar) so a Claude session — running anywhere, including inside one of tiletopia's own panes — can drive the workspace. Capabilities to expose as MCP tools / resources:
- Inspect:
list_panes()(id, label, shellKind, distro/host, cwd, active flag),read_pane(id, last_lines?)(scrollback tail),read_layout()(the tree JSON). - Drive sessions:
write_pane(id, text)(send keys/commands; same path as broadcast),wait_for_idle(id, timeout)for command-completion synchronization. - Reshape:
spawn_pane(spec, parent_id?, orientation?)(WSL distro / PowerShell / saved SSH host),close_pane(id),apply_preset(name),promote_pane(id),set_label(id, label),swap_panes(id, id). - SSH hosts:
list_hosts(),add_host(...),connect_host(host_id) → pane_id(spawn + return). Read-only access tohasPasswordflag; never expose saved passwords through the MCP surface. - Notifications:
notify(message)for status updates Claude wants to surface. - Authentication: bind to localhost only; consider a per-session token written to the app config dir that the MCP client must present. Treat the MCP socket as trusted only to processes the user explicitly points at it — anyone with access to the user's account could read commands and stream PTY output. Surface this caveat in the help overlay.
- Tauri integration: Rust-side MCP server using a published crate (or hand-rolled JSON-RPC); reuses the existing
PtyManager+hosts.json+ workspace state. Frontend gets read-only events when the MCP causes a layout change so the UI reflects it without races. Big — milestone-scale work; needs a design doc before code.
- Inspect:
Session log
2026-05-26 — MCP persistence + Claude Code OAuth bug + mcp-remote shim
Set out to fix two paper cuts (port + token re-rolled every server restart, so firewall rules and .mcp.json had to be re-pasted). Ended up unwinding a multi-layer breakage in Claude Code's HTTP-MCP client.
Persistence (the actual goal, in commit 799f507):
- Added
McpPersistedConfig { port, token }saved to%APPDATA%\com.megaproxy.tiletopia\mcp.json. Default port 47821 (IANA-unassigned range).start_servertries the saved port first, falls back to OS-picked + warning log if it's taken (saved port is preserved for the next attempt — transient conflicts shouldn't burn the user's firewall rule). - New
mcp_regenerate_tokencommand + Regenerate button inMcpPanel. Confirms before rotating (existing clients break). If server is running, stops + restarts with the new token so the live auth middleware picks it up. - Token loaded on every
start_server, soMcpState.bearer_tokenis always in sync withmcp.json.
The chain of failures (each fix exposed the next layer):
- WSL → Windows TCP timeouts. User had auto-created Windows Defender Firewall Block (Public) rules for
tiletopia.exefrom earlier launches. Block rules win over Allow rules in WDF. Fix: nuke alltiletopia*rules, create oneAllow Any-profile LocalPort 47821rule. Confirmed working with curl 401 from Windows + WSL. - rmcp DNS-rebinding allowlist (
StreamableHttpServerConfig.allowed_hostsdefaults to["localhost", "127.0.0.1", "::1"]). WSL clients hit via the gateway IP172.x.x.1, which isn't in the list — rmcp loggedrejected request with disallowed Host header. Fix:.disable_allowed_hosts()on the config. Bearer auth handles the real gatekeeping; we're not in a browser context. - Bearer auth middleware intercepted OAuth-discovery probes. Claude Code probes
/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/register, etc. before sending the static bearer. Our middleware was returning401 + WWW-Authenticate: Beareron those paths — Claude interpreted that as "OAuth supported" and abandoned the static bearer in.mcp.json. Fix: skip auth enforcement for any path outside/mcp(mcp.rs:bearer_auth). - Claude Code's HTTP-MCP client is OAuth-only-ish. Even with discovery paths returning bare 404s, Claude's
/mcpUI hung inNeeds authentication, never sent a realPOST /mcp, and offered an "Authenticate" button that opened a (non-existent) browser flow. Logs confirmed: not a singleMCP requestafterMCP server listening. Theheaders: { Authorization: "Bearer ..." }field IS the documented mechanism, but it's broken in Claude Code per #17152 (cosmetic UI bug) and #46879 (auth requirement triggered by the existence of well-known endpoints, not by 401 responses).
The working path: mcp-remote stdio shim. Replace the HTTP server entry in .mcp.json with:
{
"mcpServers": {
"tiletopia": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"http://127.0.0.1:47821/mcp",
"--allow-http",
"--header", "Authorization: Bearer <token>"
]
}
}
}
From Claude's perspective tiletopia is now stdio; mcp-remote proxies every JSON-RPC call over HTTP with the bearer baked in, bypassing Claude Code's HTTP-MCP machinery entirely. --allow-http is required because mcp-remote blocks non-HTTPS URLs except for localhost. The panel's "Copy config snippet" generates this shape now.
Cleanups after the shim worked:
- Dropped the experimental
json_not_foundfallback handler (was added when we thought a JSON-bodied 404 would satisfy Claude's discovery parser; not needed once we went stdio). - Diagnostic
tracing::info!for per-request auth state dropped totracing::debug!(silent by default, available behindRUST_LOG=tiletopia_lib::mcp=debug). - README + help-overlay tip rewritten around the shim recipe + WSL firewall + WSL gateway-IP / mirrored-networking choice.
Root-cause sequence worth remembering: five distinct failures masked each other, and each new error message looked like a config bug. Methodical curl-from-WSL + log inspection was what cut through it — never trust the client's "auth failed" string without seeing whether the server was even reached.
Open follow-ups specific to this session:
- CLAUDE.md (root) still says Svelte 5 in stack — was noted in 2026-05-25's entry too; still not fixed.
.mcpbbundle would let Claude Desktop install the shim + bearer without hand-editing.mcp.json. Was already in the previous MCP TODO list; this session reinforces the need.- Direct HTTP-MCP can drop the shim once Claude Code fixes #17152 / #46879. Worth watching those issues.
- Panel could pre-flight check for
npx/ Node presence on the WSL/host side and warn if missing. Currently the user only discovers the shim needs Node when Claude fails to spawn it. - Server-side OAuth metadata (RFC 9728 PRM at
/.well-known/oauth-protected-resource) is the spec-blessed path but requires actually implementing OAuth dynamic client registration. Big scope; not worth it for the shim's lifetime.
2026-05-25 — Reflow bug fix + titlebar tidy-up
- Bug: terminal text reflowing every few seconds. User reported "redrawing every few seconds" with text changing lines. Added a
console.traceinside theResizeObserverinXtermPane.tsx, then expanded the diagnostic to log titlebar/pane-wrap/leaf/toolbar heights. Caught it: titlebar was oscillating between 34px and 50px in sync with pane heights changing by ±15.4px (one button-row). - Root cause: text-wrap inside flex buttons. Titlebar is
display: flexwith defaultflex-wrap: nowrap. Buttons have nowhite-space: nowrap. On a narrow window, flex items shrink past their natural width → text wraps inside a button (e.g. "📡 all off" → two lines) → button grows ~16px → titlebar grows ~16px →.pane-wrapshrinks →ResizeObserverfires on every xterm →fit()reflows. The periodic flap was idle detection: whenidleLeafIds.sizetoggles between 0 and N,.layout-infogains/loses " · N idle", which is just enough extra width to push a button across its wrap threshold. Same root cause on narrow per-pane toolbars (tlb=37was visible in the diagnostic for a 200px pane). - Fix: lock heights on both bars.
.titlebar { height: 34px; white-space: nowrap; }+.titlebar > * { flex-shrink: 0 }; same shape for.pane-toolbar { height: 24px; ... }. First attempt also usedoverflow: hiddenwhich left an ugly horizontal scrollbar (auto) AND would have clipped dropdowns — removed. Final:nowrap+flex-shrink:0+ fixedheightis enough; overflow stays visible. Commite464464. - Titlebar tidy-up. Pre-fix titlebar was crowded (inline distro buttons + PowerShell + 🔑 SSH hosts + 5 preset buttons + others). Collapsed:
- Inline shell buttons → single
Ubuntu ▾dropdown (WSL + Windows sections), reusing the existing.distro-menu / .shell-menustyles fromLeafPane.css(global classes). - 5 preset buttons →
layout ▾dropdown (Single pane / Two columns / Three columns / Two rows / 2×2 grid). 🔑stays as a separate icon-only button next to the shell picker.- 🔔 test-toast button removed (dev crutch).
- Inline shell buttons → single
+spawn button. User pointed out "default:" semantics were weak — the picked shell only fired on first-boot or close-last-pane. Repurposed: dropped the "default:" label, added a+button next to the picker. Click+→ splits the active pane with the currently-picked shell, smart-oriented (split right if pane is wider than tall, down otherwise). Per-pane⇥/⇣arrows still inherit from parent (best for "another window into this context"); the titlebar selection only fires on+/ boot / close-last. Commitfa18307.
Open follow-ups from this session:
- CLAUDE.md still names Svelte 5 in the stack; should be updated to React 18.
- Keyboard shortcut for
+? Currently mouse-only.Ctrl+Shift+Nwould be the conventional choice but isn't bound. - Narrow window UX. With
overflow: visible, titlebar items that don't fit horizontally render past the right edge / get clipped by the viewport. Acceptable but not great. A real fix is to collapse less-important items into an overflow menu when width is tight.
2026-05-25 — SSH + clickable links + promote + help + MCP v1
Big session, ~12 commits. Headlines:
- PowerShell as a third shell kind alongside WSL distros, then refactored to an explicit
shellKind: "wsl" | "powershell" | "ssh"discriminator onLeafNodewith migration on deserialize (legacydistro:"PowerShell"→shellKind:"powershell"). - Backend SpawnSpec enum (serde-tagged) replaces the old
distro: Option<String>model.pty.rs::spawndispatches; SSH buildsssh.exe -t [-l user] [-p port] [-i id] [-J jump] -- hostwithTERM=xterm-256color. Token validation rejects leading-and control chars (CVE-2023-51385). - Clickable URLs via
@xterm/addon-web-linksrouted through@tauri-apps/plugin-opener. Needed scopedopener:allow-open-urlpermission withhttp/https/mailtoallow list, not the bare identifier. - Saved SSH hosts with manager modal (label/host/user/port/identityFile/jumpHost/extraArgs), stored in
hosts.json. Hierarchical per-pane dropdown: WSL distros → PowerShell → SSH hosts → "Manage hosts…". - Saved passwords in Windows Credential Manager via
keyring-core1.0 +windows-native-keyring-store1.0 (keyring-rs 4.x is sample code only now; the lib was split). Reader thread autotypes the password when ssh prompts (password:/passphraseregex, 30s window, one-shot). Passwords never on disk, never in IPC events, never in MCP. - Promote-out gesture: first tried drag-past-sibling (75% then 50% threshold), but the inner gutter is too easy to miss — xterm canvas hit-testing felt unreliable. Ripped all the drag-armed/preview logic, replaced with Ctrl+Shift+P keyboard shortcut that calls
promoteLeaf(tree, activeLeafId)(self-inverse). - Help overlay: titlebar
?button + F1, sourced from a singlesrc/lib/shortcuts.tsSoT (sections + tips). - MCP server v1 (read-only) via
rmcp1.7.0 Streamable HTTP on 127.0.0.1, bearer-token auth, OS-picked port. Per-panemcpAllowflag (default-deny) gates what's mirrored to the backend. Resources:tiletopia://layout,tiletopia://panes,tiletopia://hosts. Tools:read_pane(leaf_id, last_lines, after_seq)+wait_for_idle(leaf_id, idle_ms, timeout_ms). 256 KB per-pane scrollback ring populated by the PTY reader thread. Titlebar 🤖 toggle opens anMcpPanelwith URL + token + ready-to-paste Claude config snippet. - WSL → Windows networking gotcha: WSL2 default NAT mode hides Windows
127.0.0.1. User needsnetworkingMode=mirroredin%UserProfile%\.wslconfig(Win 11 22H2+) thenwsl --shutdownto reconnect. Documented in McpPanel + README + help overlay. - Tree-helper data model also gained:
setLeafShell(replaceschangeDistrofor shell switches; id-swap forces respawn),promoteLeaf,toggleMcpAllow.reshapeToPresetcarries new fields. 72 vitest cases, all green.
Open follow-ups specific to this session:
- MCP v2 —
write_pane,spawn_pane,connect_host,close_pane,apply_preset,promote_pane,set_label,swap_panes,add_host. Spawned panes should auto-setmcpAllow=true(per user). Still skipset_host_passwordfrom MCP. - MCP write surface should require a confirmation for
write_paneon SSH panes (footgun avoidance). .mcpbbundle as a one-click Claude Desktop install path.- Per-pane MCP audit log in the panel — show last N tool calls so the user can spot Claude doing surprising things.
2026-05-22 — M5 ship infrastructure
- New icon:
scripts/make-icon.py(Pillow) draws a 1024×1024 dark rounded square with a 2×2 grid — one tile in the active-blue, one in the broadcast-orange, two muted. Mirrors the in-app.leaf.active/.leaf.broadcastingcolors so the brand is consistent end-to-end. - Generated the full icon set via
pnpm tauri icon src-tauri/icons/source.png. Pruned the iOS/Android/UWP outputs Tauri also emits — kept only32x32.png,128x128.png,128x128@2x.png,icon.icns,icon.ico,source.png(mirrors widget's slim set). - Version bump 0.0.1 → 0.1.0 across
package.json,src-tauri/Cargo.toml,src-tauri/tauri.conf.json. First "real" release. scripts/release.sh: takesvX.Y.Z, sanity-checks (clean tree, on main, in sync with origin, package.json version matches tag, installer exists, tag doesn't already exist), tags + pushes, thentea releases create --asset <installer>to attach the NSIS .exe.- README rewritten with
Installsection pointing at Forgejo releases,Using itcheatsheet for all the M2-M4 features, and aDevelop/Test/Releasetriplet that documents the WSL↔Windows split.
2026-05-22 — Tests: vitest on tree.ts
- Added vitest 2.x as a devDep;
pnpm test/pnpm test:watchscripts. - Extended
vite.config.tswith atest:block (node environment,src/**/*.test.ts) usingvitest/config-flavored defineConfig. - New
src/lib/layout/tree.test.ts: 43 cases covering newLeaf/newSplit (defaults + props), replaceById (immutability + sibling preservation), splitLeaf (inheritance + no-op on miss), closeLeaf (root/sibling-collapse/nested), findLeaf, leafCount, walkLeaves (left-to-right order), changeDistro (MUST swap id), changeLabel (MUST NOT swap id, trim/clear), toggleBroadcast (MUST NOT swap id), all 5 presets (shape + distro propagation + fresh ids), serialize/deserialize roundtrip + invalid-input rejection. - Notable invariants the tests pin down:
changeDistroswaps the leaf id (we rely on{#key}to remount XtermPane → kill the old PTY → spawn a fresh one);changeLabelandtoggleBroadcastkeep the same id (metadata-only, no respawn). Regressing either of those silently would break the UX in subtle ways — tests catch it.
2026-05-22 — M4 orchestration (broadcast + notifications + palette)
tree.ts: addedbroadcast?: booleanto LeafNode;walkLeavesgenerator;toggleBroadcasthelper (metadata-only, no id swap).ops.ts: extendedPaneOpswithtoggleBroadcast,broadcastFrom,setActivePane,registerPaneId,notify, plusactiveLeafIddata field.XtermPane.svelte: added optional callbacksonSpawn,onInput(called after each writeToPane on user keypress),onDataReceived(called per PTY output chunk), and afocusTriggerprop (counter; bumping it refocuses the terminal). All optional; pre-M4 callers untouched.LeafPane.svelte: 📡 broadcast toggle in toolbar; idle detection (5s threshold, 1s polling, fires once per idle cycle); active/broadcasting border colors; click anywhere on the leaf sets it active; on active=true bumps focusTrigger so XtermPane refocuses.- New
Notifications.svelte: top-right toast stack, slide-in animation, 5s auto-dismiss + manual ×. - New
Palette.svelte: modal overlay with backdrop, autofocused text input, filtered list (label/distro/cwd substring), ↑/↓ navigation, Enter/click to pick, Escape to close. App.svelte: paneIdByLeaf Map (non-reactive lookup); notifications $state with auto-dismiss; activeLeafId; paletteOpen with global Ctrl+K listener; broadcastFrom routes via walkLeaves + writeToPane; ⌘K button in titlebar.pnpm checkclean (111 files).
2026-05-22 — M3 persistence + presets + per-pane distro/label
- Backend: added
save_workspace(json)andload_workspace()Tauri commands. Atomic write via tmp + rename. Path resolved fromapp.path().app_config_dir(). - Frontend ipc:
saveWorkspace/loadWorkspacewrappers. tree.ts: addedchangeDistro(with id swap to force XtermPane remount),changeLabel, and 5 preset trees (single, 2H, 3H, 2V, 2×2).- New
lib/layout/ops.tswithPaneOpsinterface; refactoredPane.svelte/SplitNode.svelte/LeafPane.svelteto takeopsinstead of individual callbacks. LeafPane.svelte: in-toolbar pane-label editor (click to rename, Enter saves, Esc cancels) and distro chip with click-popover. Picking a different distro in the popover respawns the pane.App.svelte: migrated to APPDATA persistence with 500ms debounce. One-time localStorage→APPDATA migration on boot. Split inherits parent's distro+cwd viafindLeaf. Titlebar preset buttons (1 / 2H / 3H / 2V / 2×2) with a confirm prompt when replacing >1 pane.pnpm checkclean (109 files, 0 errors, 0 warnings).- Manual verification on Windows: (to fill in)
2026-05-22 — M2 splits-tree layout
- Added
src/lib/layout/:tree.ts(pure helpers: types, newLeaf, splitLeaf, closeLeaf, replaceById, serialize/deserialize with shape-checking),SplitNode.svelte(flex container + draggable gutter with pointer-capture),LeafPane.svelte(toolbar with split-right/split-down/close + XtermPane underneath),Pane.svelte(recursive dispatcher). - Rewrote
App.svelteto hold the tree as$stateand wire split/close callbacks through. Auto-saves to localStorage on every$effecttick. - Distro UX: titlebar shows clickable distro buttons that set the default for new panes. Existing panes keep their distro. Per-pane override is M3.
- Passes
pnpm checkcleanly (108 files, 0 errors, 0 warnings). - Validated manually on Windows: splits-right and splits-down work, both panes stay alive, gutter drag reflows both xterm sides cleanly, close-pane collapses to the sibling, layout restores from localStorage across window restarts.
2026-05-22
- Graduated from
ideas/wsl-mux/to project. Renamed working namewsl-mux→ final nametiletopiaacross Cargo/package/Tauri configs and source. - Promoted spike contents from
D:\dev\wsl-mux\spike\toD:\dev\tiletopia\(no more spike subdir; the project IS what was the spike). - Initialized git, created private Forgejo repo
tiletopia, pushed initial scaffold. - M1 verified manually on the Windows host: window opens, xterm.js renders,
claudeTUI works inside the pane, resize reflows cleanly,htoprenders. Distro auto-pick chosedocker-desktop(Docker Desktop's BusyBox helper distro) on first try — added explicit clickable distro buttons in the titlebar as both a diagnostic and a manual override. ClickingUbuntuworks end-to-end. - Old idea folder archived to
~/claude/archive/ideas/wsl-mux/(preserves full brainstorm + session log).
External references
- Approved plan / roadmap:
~/.claude/plans/imperative-coalescing-feigenbaum.md(M0–M5 milestones with verification criteria for each) - Stack precedent:
~/claude/projects/claude-usage-widget/— same Tauri + Svelte + WebView2 toolchain, already ships a Windows installer via Forgejo releases. WSL distro-probing logic copied/adapted intosrc-tauri/src/pty.rs. - Archived idea history:
~/claude/archive/ideas/wsl-mux/plan.md - Forgejo repo: https://git.rdx4.com/megaproxy/tiletopia
- xterm.js docs: https://xtermjs.org/
- portable-pty crate: https://crates.io/crates/portable-pty
- Tauri 2 docs: https://v2.tauri.app/
- Prior art for splits-tree layout: i3, tmux, Zellij, WezTerm