Document the fan-out approach (3 Sonnet agents + 1 Haiku), the
event/reply RPC pattern, the 10 hard-deny rules and their caveats,
the audit + confirm + Always-Allow UX, and the four integration
bugs worth remembering (Tauri 2 Emitter trait import, McpError
'static strings, React 18 StrictMode listen() race, lifting the
audit subscription out of AuditTab).
App.tsx now listens on "mcp://request" and resolves each call:
needsConfirm=true queues a confirm modal (Accept/Reject, or
"Always allow <tool>" which appends the bare tool name to the
policy's allow bucket on the fly); needsConfirm=false runs straight
through. Replies via mcp_action_reply with externally-tagged
Result. The only wired-up tool for now is set_label, which delegates
to the existing ops.setLabel path.
McpConfirm.tsx (new) — themed amber-bordered modal sibling to the
existing overlays. Enter = accept, Esc = reject. Shows tool, the
policy reason that triggered the prompt, a human-readable summary
("Rename pane X → Y"), and an expandable raw-args section.
Audit log: subscription lifted from AuditTab up to App.tsx so events
fired while the panel is closed (or on Config/Policy tab) still land
in the ring. AuditTab becomes presentational; McpPanel forwards
entries + clearAudit + computes the unread badge from a baseline
seen-count.
StrictMode race fix: both new App-level listeners (mcp://audit and
mcp://request) use the cancelled-flag pattern so a late-resolving
listen() Promise after a strict-mode pretend-unmount tears itself
down instead of leaking a second subscription. Previously this
manifested as duplicate audit rows and a need-to-click-twice on
modal buttons.
Foundation for Claude-drives-the-workspace writes. Nothing wired
end-to-end yet (App.tsx dispatcher comes next); this lands the
machinery + UI.
mcp_policy.rs (new) — three-tier allow/ask/deny policy with
deny-first precedence and a compiled-in non-overridable hard-deny
list (10 patterns covering rm -rf /, fork bombs, mkfs on device, dd
to raw disk, /etc/passwd overwrite, curl|sh, chmod -R 777 /, etc.).
Shell-operator-aware glob matcher mirroring Claude Code's Bash(*)
syntax. Restrictive default — empty policy means every non-hard-
denied call falls to Ask. Persisted to mcp-policy.json in
app_config_dir. Includes a PolicyClassifier scaffold (no-op) for a
future v2.1 LLM-classifier hook. 1152 lines incl. ~100 unit + fuzz
tests covering the matchers and lookalike negatives.
mcp.rs — TileService now holds AppHandle + Arc<PendingActions>
(oneshot registry keyed by uuid). New async dispatch_action helper
runs the policy check, emits "mcp://request" for the frontend to
handle, awaits a oneshot reply (30s timeout), then emits "mcp://
audit" with the outcome regardless. set_label tool wired through
this path as the demo for PR-1b's dispatcher.
commands.rs / lib.rs — new Tauri commands mcp_action_reply,
mcp_policy_load, mcp_policy_save; PendingActions registered as
managed state.
McpPanel.tsx — refactored into Config / Audit / Policy tabs.
AuditTab listens on mcp://audit, keeps a 200-entry ring with
ok/denied/failed chips. PolicyTab edits the allow/ask/deny buckets
(stacked vertically — three columns overflowed the panel) and shows
the hard-deny rules read-only at the bottom with "Cannot be
disabled" badges. Themed scrollbar on mcp-body to match xterm panes.
Caveat: set_label calls from Claude will currently time out — the
App.tsx side that listens on mcp://request and replies via
mcp_action_reply lands in PR-1b.
Co-authored by Sonnet (policy engine, backend plumbing, panel UI)
and Haiku (hard-deny fuzz test suite); integration + bug fixes here.
Document the five-layer breakage we unwound (WDF block rules, rmcp
host allowlist, our middleware intercepting OAuth probes, Claude Code
ignoring static bearer, mcp-remote --allow-http) and the working
stdio-shim recipe.
Port (default 47821) and bearer token now persist to mcp.json with
OS-picked fallback if the port is taken; new Regenerate button in the
panel rotates the token and restarts the running server. rmcp's
DNS-rebinding host allowlist is disabled so WSL gateway IPs can
connect (bearer-auth handles the gatekeeping); the auth middleware
only enforces on /mcp paths so OAuth-discovery clients don't see a
Bearer challenge on /.well-known/* probes.
Claude Code's HTTP-MCP client currently tries OAuth and ignores
static `headers` auth (anthropics/claude-code#17152, #46879), so the
panel + README config snippet now uses `npx mcp-remote` as a stdio
shim that proxies the HTTP endpoint with the bearer baked in.
memory.md still said Svelte 5; migration to React 18 happened in
774b863. Also log this session's investigation, fix, and the
follow-up about CLAUDE.md still needing the same update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Collapse the inline distro buttons + PowerShell + 🔑 SSH hosts into
a single 'Ubuntu ▾' dropdown (WSL distros + PowerShell sections),
with 🔑 as a separate icon-only button.
- Collapse the 5 preset buttons into a 'layout ▾' dropdown.
- Add a '+' button next to the shell picker that spawns a new pane of
the picked shell by splitting the active pane (smart orientation:
splits right if wide, down if tall). Per-pane ⇥/⇣ arrows still
inherit from parent — only '+' uses the titlebar selection.
- Drop the 🔔 test-toast button.
- Drop overflow:hidden from titlebar + pane toolbar so dropdowns
aren't clipped; height lock + nowrap still prevent the reflow bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the window or a pane was narrow, button text could wrap inside
flex items, growing the toolbar by ~16px. That shrank .pane-wrap →
ResizeObserver fired on every xterm → fit() reflowed text. Idle
detection toggling " · N idle" in the titlebar was enough to flap a
button across its wrap threshold every few seconds.
Lock both bars to fixed heights with white-space:nowrap, flex-shrink:0
on children, and overflow:hidden. Items that don't fit clip silently
instead of wrapping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
navigator.clipboard.readText() triggers WebView2's "Allow clipboard access?"
permission prompt on every paste. The plugin goes through IPC + the OS
clipboard directly, so the prompt never fires.
Wired the Rust plugin, granted clipboard-manager:allow-{read,write}-text in
the capabilities manifest, swapped XtermPane's copy/paste handler to use
the plugin's readText/writeText.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Uses attachCustomKeyEventHandler so xterm doesn't first consume Ctrl+V
and inject a raw ^V into the PTY. Paste routes through term.paste() so
broadcasting and bracketed paste continue to work.
Each leaf now carries an optional fontSizeOffset, persisted in
workspace.json alongside everything else. Ctrl+= / Ctrl+- / Ctrl+0
adjust the active pane; adding Shift escalates to every pane (the
mirror of the broadcast Shift+Alt convention, with shift alone since
the keys are otherwise unused). Bindings match on e.code so layouts
that don't have "=" / "-" / "0" in the same spot still work.
XtermPane gained a fontSize prop. A secondary effect reacts to changes:
set term.options.fontSize, fit() to recompute cols/rows for the new
cell size, refresh(), then resizePane so bash redraws the prompt at
the right width. No remount, so PTY + scrollback survive zoom changes.
The new tree helpers (resolveFontSize / adjustFontSize /
adjustAllFontSizes) are metadata-only — they don't swap leaf ids, so
nothing respawns. reshapeToPreset also carries the offset across when
splicing existing leaves into a new layout. 12 new vitest cases pin
those invariants plus the clamp and reset-to-default behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lead the "Using it" section with the keyboard shortcuts table, then
break the remaining behaviour into mouse/toolbar and broadcast/idle/
presets subsections. Documents drag-to-swap, the 180px resize minimum,
the home-cwd default, the new idle border + titlebar badge (replacing
the old toasts blurb), and the splice-preserving preset behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Changes since 0.2.1:
- Keyboard shortcuts: Ctrl+K palette + Ctrl+Shift+E/O/W for
split-right / split-down / close, Ctrl+Shift+B per-pane and
Ctrl+Shift+Alt+B global broadcast, Ctrl+Shift+Arrow for spatial
pane navigation
- New panes default to WSL home (~) instead of inherited Windows cwd
- Resize artifacts: rAF-throttle gutter drag, debounce PTY resize,
skip resizePane when cols/rows haven't actually changed (kills the
idle-flap loop that surfaced with many panes)
- Minimum pane size (180px) enforced on both split and gutter drag
- 2px gap around each leaf so per-pane borders read as distinct
rectangles instead of a continuous grid
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With panes packed edge-to-edge, adjacent .leaf elements' 2px borders
touched directly. When all (or many) panes went idle, every pane's
red border combined with its neighbour's to form continuous red lines
across the whole window — looking like a single grid pattern, not
distinct per-pane outlines.
Fix: padding: 2px on .leaf-slot (box-sizing: border-box). Each .leaf
ends up inset 2px on every side, so adjacent panes have a 4px dark
gap between them and their borders read as separate rectangles.
Affects all borders equally (idle red, active blue, broadcasting
orange) and gives the layout a cleaner separation overall.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most ResizeObserver firings in XtermPane don't change xterm's cell
grid — just shuffle a few pixels around. We were still calling
resizePane() (which SIGWINCHs bash, which redraws its prompt, which
emits data, which resets the idle timer). With many panes, this
created a self-reinforcing flap loop where the idle indicator would
toggle every few seconds.
Now: track the last cols/rows we actually sent to the backend in the
mount effect's closure. If a debounced fit() ends up at the same
grid dimensions, skip the resizePane call entirely. No SIGWINCH, no
prompt redraw, no data event, no idle flap.
Zero functional change for actual resizes; only suppresses redundant
PTY syscalls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spamming the ⇥/⇣ split buttons (or their Ctrl+Shift+E/O shortcuts)
used to subdivide panes indefinitely, leaving toolbar-only slivers
that were unusable.
- tree.ts: MIN_PANE_PX = 180 constant.
- App.tsx: the `split` orchestration callback now computes the active
pane's pixel dimensions from its layout slot + the container rect,
and refuses to split if either child would fall below MIN_PANE_PX.
Surfaces the refusal via a `notify(...)` toast so the user knows
why nothing happened.
- Gutter.tsx: pointermove clamps the new ratio so the smaller child
stays at least MIN_PANE_PX wide/tall. Falls back to the old 0.05
floor only if the parent is so small that two min-sized panes
can't both fit (degraded but functional).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
What the user saw: dragging a gutter filled the affected panes with
many overlapping bash prompts, some corrupted mid-print
(megaproxy@DESKTOP-megaproxy@DESKTOP-SSAQG5 etc).
Root cause: every resizePane() call sends SIGWINCH to the shell, which
makes bash redraw its prompt. The previous fix coalesced the local
xterm fit() into one per rAF, but still fired resizePane on every
rAF — 60+ SIGWINCHes per second during a drag, faster than bash can
finish one prompt redraw before the next interrupts it.
Fix: separate the two concerns. fit() + term.refresh() still run
every rAF (the visual must stay smooth). But resizePane() is
debounced to fire 150 ms after the LAST rAF — i.e. only when you
stop dragging — so bash gets one clean SIGWINCH at the final size
and produces a single tidy prompt redraw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously: spawn_wsl(cwd=None) passed no --cd to wsl.exe, so each
pane inherited the launcher's cwd — which is typically
C:\Users\<user>, surfacing inside WSL as /mnt/c/Users/<user>. Annoying
because the first thing you do in a new pane is `cd ~`.
Now: if the caller didn't specify a cwd, we explicitly pass `--cd ~`
so the pane lands in the WSL user's home. Existing panes keep their
saved cwd from the workspace.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for stale glyphs / visual artifacts while dragging
a gutter:
- Gutter.tsx: pointermove now writes the new ratio into a ref and
schedules a single requestAnimationFrame flush per frame. Without
this, setTree fires 60+ times per second during a drag and React
+ ResizeObserver + xterm's DOM renderer get out of sync. The
pointerup handler flushes any pending ratio so the final position
always lands.
- XtermPane.tsx: the ResizeObserver callback now also rAF-coalesces
AND calls term.refresh(0, term.rows - 1) after fit.fit(). xterm's
DOM renderer doesn't reliably repaint freed-up rows after a
shrink, so the explicit refresh wipes any stale glyphs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| Ctrl+K | palette |
| Ctrl+Shift+E | split active pane right |
| Ctrl+Shift+O | split active pane down |
| Ctrl+Shift+W | close active pane |
| Ctrl+Shift+B | toggle broadcast on active |
| Ctrl+Shift+Alt+B | toggle broadcast on ALL panes |
| Ctrl+Shift+Arrow | focus neighbour pane in that direction |
The handler attaches at capture phase on window so it wins against
xterm.js. It bails when a non-terminal <input>/<textarea> is focused
so label edits and the palette input keep working normally.
Spatial neighbour-finding lives in tree.ts as findNeighborInDirection
— picks the leaf whose centre is most aligned in the perpendicular
axis, breaking ties by primary-axis distance.
Tooltips on toolbar/titlebar buttons now mention their shortcuts;
README has a key-binding table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Changes since 0.2.0:
- Fix broadcast no-op (useEffect deps captured stale orch ref, so paneIds
got silently unregistered on every click → broadcastFrom found no peers)
- Flat-list layout architecture: render leaves as siblings keyed by id,
position via absolute boxes. PTYs survive any tree reshape.
- Drag a pane's toolbar onto another pane to swap them
- Idle reporting moved out of toast spam into a "N idle" titlebar badge
+ red pane border + red "idle" status text
- Themed terminal scrollbars
- Global 📡 broadcast toggle in the titlebar
- Presets preserve existing panes' shells (only kill what overflows the
preset's slot count, with a confirm dialog)
- React 18 frontend (Svelte version retired to svelte-archive branch)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old behaviour: every pane fired orch.notify("X is idle") after 5s of
silence, stacking up to N toasts that took ages to dismiss.
New behaviour:
- LeafPane tracks its own isIdle state locally and reports up via
orch.reportLeafIdle(leafId, idle).
- App aggregates into a Set<NodeId> and renders "N idle" in red after
the "N panes" count in the titlebar (hidden when zero).
- The pane itself gets a red border (.leaf.idle) — but active and
broadcasting borders still take precedence, so the focus indicator
isn't masked by idle status.
- The pane's "alive" status text in the toolbar swaps to red "idle"
while it's quiet (reverts to "alive" the moment output arrives).
- Idle clears immediately on the next byte of output (no 1-second lag)
AND when the pane unmounts (cleanup effect).
No more flood of toasts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New interaction: click-and-drag any pane's toolbar onto another pane
to swap their positions in the tree. The shells / scrollback stay
intact (each leaf keeps its data; only the tree slot it occupies
changes).
Implementation:
- tree.ts: `swapLeaves(root, idA, idB)` walks the tree once,
substituting one leaf for the other at each occurrence. The leaf
objects themselves carry their id/distro/cwd/label/broadcast across,
so React preserves the LeafPane instances via the flat-list keying.
- orchestration.tsx: add drag lifecycle to the context —
dragSourceId / dragOverId (reactive) plus beginHeaderDrag,
setHeaderDragOver, endHeaderDrag (stable methods).
- App.tsx: implement those methods. endHeaderDrag(true) swaps if
source and over are different leaves.
- LeafPane.tsx: pointerdown on .pane-toolbar (skipped if the target
is a button/input). 5px movement threshold before drag commits to
prevent accidental swaps when clicking a chip etc. Pointer-capture
the toolbar so we keep getting move events even outside it. Use
document.elementFromPoint to find the leaf under the cursor.
- CSS: source pane fades to 40% opacity during drag; target pane
shows a 3px dashed blue outline; toolbar shows grab/grabbing
cursors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fix for the real preset bug: previously, presetSingle/2H/3H/2V/2×2
appeared to preserve panes (we copied id/distro/cwd/label/broadcast
into the preset's slots), but React's reconciliation tore down every
LeafPane and re-mounted it because the tree structure changed —
killing all PTYs and spawning fresh shells. The "preservation" was
data-only; the React components didn't survive.
Solution: stop rendering the Pane → SplitNode → LeafPane recursion.
Walk the tree to produce a FLAT layout of `{leaf, box}` entries (each
box is top/left/width/height as fractions 0–1). Render all leaves as
siblings of a relative-positioned container, each absolutely
positioned by its box. Key each one by leaf.id — React preserves the
component (and its XtermPane → PTY) across any tree reshape; only the
inline style changes.
Gutters render as separate sibling overlays at the split boundaries,
each with its own pointer handlers. Dragging mutates the split's
ratio via `updateSplitRatio(tree, splitId, r)`; the layout
recomputes; leaf boxes change; nothing remounts.
Now: clicking 2×2 on 4 stacked panes keeps all 4 shells alive and
just rearranges them into the grid. Same for any preset that doesn't
overflow.
Side benefit: removed the recursive Pane.tsx + SplitNode.tsx + their
CSS. The render path is now straightforward, no recursion, easier to
reason about.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously: clicking 1 / 2H / 3H / 2V / 2×2 in the titlebar replaced
the whole tree with brand-new empty leaves, killing every shell — and
the only safeguard was a window.confirm() that's easy to miss-click.
The user lost work whenever they reached for a preset.
New behaviour via `reshapeToPreset`:
- The preset's shape is built fresh (1, 2, 3, or 4 slots), then existing
leaves are spliced into those slots in DFS order. Their id / distro /
cwd / label / broadcast all carry over, so the same PaneId is still
mapped — the PTY keeps running.
- If the preset has MORE slots than existing leaves (e.g. 1 pane → 2×2),
the extra slots stay as fresh empty leaves and new shells spawn there.
No prompt — pure additive change.
- If the preset has FEWER slots than existing leaves (e.g. 8 panes →
2×2), the overflow leaves are returned in `dropped`. We confirm with
the user, and if they accept, kill those PTYs explicitly.
Tradeoff: split ratios reset to 0.5 (the whole point of "apply preset"
is to use its layout). That's an acceptable cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bug: clicking 📡 made the visual update (orange border) but typing
in a broadcasting pane only wrote to that pane — peers never received
the keystrokes.
Root cause: the orch context value (useMemo'd over activeLeafId,
distros, and the operation callbacks) is recreated every time
activeLeafId changes (i.e. every click). useEffect cleanups in
LeafPane that had `orch` in their deps fired their cleanup-then-setup
cycle on every click. The unmount-cleanup for paneId registration
ran `orch.registerPaneId(leaf.id, null)`, silently deleting paneIds
from App's paneIdByLeafRef map — so when broadcastFrom later walked
the tree looking up peers, the map returned undefined for every leaf
and the actual writeToPane calls never happened.
Fix: depend on the specific stable method references
(`orch.registerPaneId`, `orch.notify`, etc.) instead of the orch
object itself. The methods are all useCallback'd with stable deps
in App.tsx, so their references don't change across orch object
recreations — effect deps stay stable, no spurious cleanup.
Applied the same fix to all orch-using effects/callbacks in
LeafPane (commitLabel, pickDistro, onPaneClick, onPaneSpawned,
onXtermFocus, onTerminalInput, idle interval, paneId cleanup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Stack: Svelte 5 → React 18, with a note about the migration history
- Build: pnpm check is now tsc --noEmit, not svelte-check; mention pnpm build
- Architecture: rename component refs to .tsx; describe React Context for
shared orchestration state instead of the old PaneOps drill-down
- Features: mention the new global 📡 titlebar toggle alongside the
per-pane chips
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>