tiletopia/memory.md

21 KiB
Raw Blame History

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 <distro> --cd <path> on Windows. Manager is a Mutex<HashMap<PaneId, PaneHandle>> 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/<pid>/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

  • 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 in App.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 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 <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-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.
  • 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 M0M5 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.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.
  • 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 as Palette / 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 to hasPassword flag; 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.

Session log

Big session, ~12 commits. Headlines:

  • PowerShell as a third shell kind alongside WSL distros, then refactored to an explicit shellKind: "wsl" | "powershell" | "ssh" discriminator on LeafNode with migration on deserialize (legacy distro:"PowerShell"shellKind:"powershell").
  • Backend SpawnSpec enum (serde-tagged) replaces the old distro: Option<String> model. pty.rs::spawn dispatches; SSH builds ssh.exe -t [-l user] [-p port] [-i id] [-J jump] -- host with TERM=xterm-256color. Token validation rejects leading - and control chars (CVE-2023-51385).
  • Clickable URLs via @xterm/addon-web-links routed through @tauri-apps/plugin-opener. Needed scoped opener:allow-open-url permission with http/https/mailto allow 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-core 1.0 + windows-native-keyring-store 1.0 (keyring-rs 4.x is sample code only now; the lib was split). Reader thread autotypes the password when ssh prompts (password:/passphrase regex, 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 single src/lib/shortcuts.ts SoT (sections + tips).
  • MCP server v1 (read-only) via rmcp 1.7.0 Streamable HTTP on 127.0.0.1, bearer-token auth, OS-picked port. Per-pane mcpAllow flag (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 an McpPanel with URL + token + ready-to-paste Claude config snippet.
  • WSL → Windows networking gotcha: WSL2 default NAT mode hides Windows 127.0.0.1. User needs networkingMode=mirrored in %UserProfile%\.wslconfig (Win 11 22H2+) then wsl --shutdown to reconnect. Documented in McpPanel + README + help overlay.
  • Tree-helper data model also gained: setLeafShell (replaces changeDistro for shell switches; id-swap forces respawn), promoteLeaf, toggleMcpAllow. reshapeToPreset carries new fields. 72 vitest cases, all green.

Open follow-ups specific to this session:

  • MCP v2write_pane, spawn_pane, connect_host, close_pane, apply_preset, promote_pane, set_label, swap_panes, add_host. Spawned panes should auto-set mcpAllow=true (per user). Still skip set_host_password from MCP.
  • MCP write surface should require a confirmation for write_pane on SSH panes (footgun avoidance).
  • .mcpb bundle 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.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 <installer> 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 (M0M5 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