tiletopia/memory.md

137 lines
21 KiB
Markdown
Raw 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.

# 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
- [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 <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.
- [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 M0M5 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.
- [ ] **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
### 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 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 v2** — `write_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