# 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 + Svelte 5 + TypeScript + Vite + pnpm + xterm.js + `portable-pty`.** Mirrors `claude-usage-widget` so we reuse a known-good Windows-targeting toolchain (MSVC + WebView2 + NSIS installer). No new technology bets stacked on top of the new product bet. - **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).** Spawns `wsl.exe -d --cd ` on Windows. Manager is a `Mutex>` in Rust; each pane has a background reader thread that emits `pane://{id}/data` events. - **Wire format: base64-encoded byte chunks via Tauri events.** xterm.js's `onData` emits 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 as `rimlike` (`D:\godot\rimlike`) and `tavernkeep`. Forced by pnpm 11.x's `isDriveExFat` crashing on `\\wsl.localhost\...` UNC paths. - **Don't commit `node_modules`, `src-tauri/target`, or `.pnpm-store`. DO commit `Cargo.lock`** (binary project, reproducible builds). - **Session awareness without an in-pane agent.** Plan: poll `/proc//cwd` of the shell's child + foreground process every ~2s. Sufficient to detect `cd` and whether `claude` is running. - **State propagation in the layout tree: hybrid mutable + replace.** The root tree is `$state(...)` at App level. Direct mutation (e.g. `node.ratio = X` during gutter drag) is reactive via Svelte 5's deep proxy. Structural changes (split/close) go through pure helpers in `tree.ts` that return a new root, which App reassigns. Drag stays fast (no tree walk); structural changes stay simple. `{#key leaf.id}` around `LeafPane` ensures swapping a leaf in/out cleanly unmounts XtermPane (which kills the PTY on destroy). - **Layout persistence: `%APPDATA%/com.megaproxy.tiletopia/workspace.json`** via 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's `app.path().app_config_dir()` — no separate `dirs` crate 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 `PaneOps` interface** in `lib/layout/ops.ts`. Pane and SplitNode just pass `ops` through; 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.** `changeDistro` in `tree.ts` assigns 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 `PaneId` to App via `ops.registerPaneId`. When a broadcasting pane's `XtermPane.onInput` fires, App's `broadcastFrom` walks all other leaves with `broadcast === true` and calls `writeToPane(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 every `XtermPane.onDataReceived`) and a `setInterval` that fires `ops.notify` after `IDLE_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 — defer `tauri-plugin-notification` if/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 sets `activeLeafId`; `LeafPane` has a `$derived` `active = ops.activeLeafId === leaf.id` and a `$effect` that bumps a `focusTrigger` counter when active flips true; `XtermPane` watches `focusTrigger` and calls `term.focus()`. Active pane gets a blue 1px border; broadcasting pane gets orange. ## Open questions / TODOs - [x] ~~**M2 — splits-tree layout component.** Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON.~~ Done 2026-05-22. - [x] ~~**M3 — workspace persistence + preset layouts + per-pane distro + pane labels.**~~ Done 2026-05-22. - [x] ~~**M4 — orchestration.** Broadcast input, idle notifications, Ctrl+K palette.~~ Done 2026-05-22. - [x] ~~**Auto-save debouncing.**~~ 500ms timer in `App.svelte` `$effect`. - [x] ~~**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 `claude` response. Want to detect that `claude` (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 ps --ppid -o comm=`. Defer to a future polish pass. - [ ] **Native OS notifications.** Right now toasts only show while the app is focused. `tauri-plugin-notification` would 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. - [x] ~~**Logic tests for `tree.ts`.**~~ Vitest, 43 cases, runs via `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.json` with `{ 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. - [x] ~~**M5 — Ship infrastructure.**~~ Custom icon, version bumped to 0.1.0, `scripts/release.sh` for one-shot tag+upload, README install section. Done 2026-05-22. **Next step (user action):** run `pnpm tauri build` on Windows then `scripts/release.sh v0.1.0` from WSL to cut the actual release. - [ ] **Native Windows shells (cmd / pwsh)?** `portable-pty` supports 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. ## Session log ### 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.broadcasting` colors 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 only `32x32.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`: takes `vX.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, then `tea releases create --asset ` to attach the NSIS .exe. - README rewritten with `Install` section pointing at Forgejo releases, `Using it` cheatsheet for all the M2-M4 features, and a `Develop`/`Test`/`Release` triplet 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:watch` scripts. - Extended `vite.config.ts` with a `test:` block (node environment, `src/**/*.test.ts`) using `vitest/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: `changeDistro` swaps the leaf id (we rely on `{#key}` to remount XtermPane → kill the old PTY → spawn a fresh one); `changeLabel` and `toggleBroadcast` keep 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`: added `broadcast?: boolean` to LeafNode; `walkLeaves` generator; `toggleBroadcast` helper (metadata-only, no id swap). - `ops.ts`: extended `PaneOps` with `toggleBroadcast`, `broadcastFrom`, `setActivePane`, `registerPaneId`, `notify`, plus `activeLeafId` data field. - `XtermPane.svelte`: added optional callbacks `onSpawn`, `onInput` (called after each writeToPane on user keypress), `onDataReceived` (called per PTY output chunk), and a `focusTrigger` prop (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 check` clean (111 files). ### 2026-05-22 — M3 persistence + presets + per-pane distro/label - Backend: added `save_workspace(json)` and `load_workspace()` Tauri commands. Atomic write via tmp + rename. Path resolved from `app.path().app_config_dir()`. - Frontend ipc: `saveWorkspace` / `loadWorkspace` wrappers. - `tree.ts`: added `changeDistro` (with id swap to force XtermPane remount), `changeLabel`, and 5 preset trees (single, 2H, 3H, 2V, 2×2). - New `lib/layout/ops.ts` with `PaneOps` interface; refactored `Pane.svelte` / `SplitNode.svelte` / `LeafPane.svelte` to take `ops` instead 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 via `findLeaf`. Titlebar preset buttons (1 / 2H / 3H / 2V / 2×2) with a confirm prompt when replacing >1 pane. - `pnpm check` clean (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.svelte` to hold the tree as `$state` and wire split/close callbacks through. Auto-saves to localStorage on every `$effect` tick. - 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 check` cleanly (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 name `wsl-mux` → final name `tiletopia` across Cargo/package/Tauri configs and source. - Promoted spike contents from `D:\dev\wsl-mux\spike\` to `D:\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, `claude` TUI works inside the pane, resize reflows cleanly, `htop` renders. Distro auto-pick chose `docker-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. Clicking `Ubuntu` works 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 into `src-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