From e0ce2239850825ea36cd1799bced3241d5e6daa5 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 26 May 2026 12:44:11 +0100 Subject: [PATCH] MCP v2 PR-2: close_pane, swap_panes, promote_pane, apply_preset Four more tree-shape tools routed through the existing dispatcher + confirm modal + audit log. All take leaf_id args (single or pair) that must be MCP-allowed via the per-pane chip; apply_preset takes a typed PresetName enum (single, two_columns, three_columns, two_rows, two_by_two) plus an allow_drops boolean. apply_preset's data-loss case is handled non-interactively: if the preset has fewer slots than the current pane count and allow_drops is not set, the frontend handler throws with a descriptive message listing the leaf labels that would be killed, so Claude can decide whether to retry with allow_drops=true rather than the user being ambushed by a destructive confirm modal. promote_pane errors with "no perpendicular split above it" when the parent shares orientation with the grandparent (same condition the Ctrl+Shift+P shortcut uses to toast a no-op). Extracted a require_visible_leaf helper on TileService since 4+ of the v2 tools now do the same mirror-presence + cloned-metadata check. Same args_repr convention as set_label so policy rules like "close_pane" (bare tool name) work uniformly. --- src-tauri/src/mcp.rs | 153 +++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 136 +++++++++++++++++++++++++++++++++++--- 2 files changed, 281 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 72ef78c..5789723 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -276,6 +276,56 @@ pub struct WaitForIdleArgs { pub timeout_ms: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CloseLeafArgs { + /// Stable leaf id from the tree (uuid-shaped). Must belong to a pane + /// the user has allow-listed for MCP access. Closing the last leaf in + /// the workspace replaces it with a fresh default-shell pane. + pub leaf_id: LeafId, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct SwapPanesArgs { + /// First leaf to swap. Both leaves must be MCP-allowed. + pub leaf_a: LeafId, + /// Second leaf to swap. + pub leaf_b: LeafId, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct PromotePaneArgs { + /// Leaf to promote one level (i.e. swap it with its parent's sibling). + /// No-op if the parent shares orientation with the grandparent — + /// frontend returns a descriptive error in that case. + pub leaf_id: LeafId, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PresetName { + /// Replace the workspace with a single full-window pane. + Single, + /// Two columns side-by-side. + TwoColumns, + /// Three columns side-by-side. + ThreeColumns, + /// Two stacked rows. + TwoRows, + /// 2x2 grid. + TwoByTwo, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ApplyPresetArgs { + pub name: PresetName, + /// Pre-acknowledge that some existing panes may be killed if the + /// preset has fewer slots than the current layout. Required for any + /// non-additive reshape — frontend rejects with the dropped count + /// otherwise. + #[serde(default)] + pub allow_drops: bool, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct SetLabelArgs { /// Stable leaf id from the tree (uuid-shaped). Must belong to a pane @@ -648,6 +698,109 @@ impl TileService { Ok(CallToolResult::success(vec![Content::text("ok")])) } + + #[tool(description = "Close a pane and kill its PTY. The leaf must be \ + MCP-allowed. Closing the only leaf in the workspace replaces it \ + with a fresh default-shell pane (the workspace can never be empty).")] + async fn close_pane( + &self, + Parameters(args): Parameters, + ) -> Result { + let _leaf = self.require_visible_leaf(&args.leaf_id).await?; + let args_repr = format!("leafId={}", &args.leaf_id); + let args_json = json!({ "leafId": &args.leaf_id }); + tracing::debug!(leaf_id = %args.leaf_id, "close_pane: dispatching"); + let _ = self + .dispatch_action("close_pane", args_json, args_repr) + .await?; + Ok(CallToolResult::success(vec![Content::text("ok")])) + } + + #[tool(description = "Swap two panes in the layout tree (preserves \ + both PTYs and their labels). Both leaves must be MCP-allowed.")] + async fn swap_panes( + &self, + Parameters(args): Parameters, + ) -> Result { + let _a = self.require_visible_leaf(&args.leaf_a).await?; + let _b = self.require_visible_leaf(&args.leaf_b).await?; + let args_repr = format!("leafA={} leafB={}", &args.leaf_a, &args.leaf_b); + let args_json = json!({ "leafA": &args.leaf_a, "leafB": &args.leaf_b }); + tracing::debug!(leaf_a = %args.leaf_a, leaf_b = %args.leaf_b, "swap_panes: dispatching"); + let _ = self + .dispatch_action("swap_panes", args_json, args_repr) + .await?; + Ok(CallToolResult::success(vec![Content::text("ok")])) + } + + #[tool(description = "Promote a pane up one level — swaps it with its \ + parent split's sibling subtree. Useful for un-nesting a pane that \ + ended up deeper than intended. No-op (errors) if the pane's parent \ + shares orientation with its grandparent — no perpendicular promote \ + target exists.")] + async fn promote_pane( + &self, + Parameters(args): Parameters, + ) -> Result { + let _leaf = self.require_visible_leaf(&args.leaf_id).await?; + let args_repr = format!("leafId={}", &args.leaf_id); + let args_json = json!({ "leafId": &args.leaf_id }); + tracing::debug!(leaf_id = %args.leaf_id, "promote_pane: dispatching"); + let _ = self + .dispatch_action("promote_pane", args_json, args_repr) + .await?; + Ok(CallToolResult::success(vec![Content::text("ok")])) + } + + #[tool(description = "Reshape the workspace to a preset layout. \ + Existing panes are slotted into the new shape in order (ids + PTYs \ + preserved where possible); extra slots spawn fresh shells. If the \ + preset has fewer slots than the current pane count, set \ + allow_drops=true to acknowledge that those overflow panes will be \ + killed — otherwise the call fails with the dropped count so you \ + can decide.")] + async fn apply_preset( + &self, + Parameters(args): Parameters, + ) -> Result { + // Convert the typed enum back to a stable wire-form string the + // frontend dispatcher matches against. Matching the snake_case of + // PresetName's serde rename_all so JSON round-trip stays clean. + let name = match args.name { + PresetName::Single => "single", + PresetName::TwoColumns => "two_columns", + PresetName::ThreeColumns => "three_columns", + PresetName::TwoRows => "two_rows", + PresetName::TwoByTwo => "two_by_two", + }; + let args_repr = format!("preset={} allowDrops={}", name, args.allow_drops); + let args_json = json!({ "name": name, "allowDrops": args.allow_drops }); + tracing::debug!(preset = name, allow_drops = args.allow_drops, "apply_preset: dispatching"); + let _ = self + .dispatch_action("apply_preset", args_json, args_repr) + .await?; + Ok(CallToolResult::success(vec![Content::text("ok")])) + } + + /// Shared validation for tools that target an existing leaf — confirms + /// the leaf is in the mirror (which means the user has it allow-listed + /// for MCP) and returns its metadata. Factored out of the 4+ tools that + /// need this exact check. + async fn require_visible_leaf(&self, leaf_id: &str) -> Result { + self.state + .read() + .await + .mirror + .leaves + .get(leaf_id) + .cloned() + .ok_or_else(|| { + McpError::invalid_params( + "unknown leaf_id (not visible to MCP; user may need to allow it)", + Some(json!({ "leaf_id": leaf_id })), + ) + }) + } } #[tool_handler] diff --git a/src/App.tsx b/src/App.tsx index 80602ce..05c66a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -829,11 +829,109 @@ export default function App() { summary: `Rename pane "${before}" → "${after}"`, }; } + case "close_pane": { + const a = args as { leafId?: string }; + if (typeof a.leafId !== "string") throw new Error("missing leafId"); + const leaf = findLeaf(treeRef.current, a.leafId); + if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`); + const labelStr = leaf.label ?? a.leafId.slice(0, 8); + close(a.leafId); + return { + payload: { leafId: a.leafId }, + summary: `Close pane "${labelStr}"`, + }; + } + case "swap_panes": { + const a = args as { leafA?: string; leafB?: string }; + if (typeof a.leafA !== "string") throw new Error("missing leafA"); + if (typeof a.leafB !== "string") throw new Error("missing leafB"); + if (a.leafA === a.leafB) throw new Error("leafA and leafB are the same"); + const lA = findLeaf(treeRef.current, a.leafA); + const lB = findLeaf(treeRef.current, a.leafB); + if (!lA || lA.kind !== "leaf") throw new Error(`leaf not found: ${a.leafA}`); + if (!lB || lB.kind !== "leaf") throw new Error(`leaf not found: ${a.leafB}`); + const labelA = lA.label ?? a.leafA.slice(0, 8); + const labelB = lB.label ?? a.leafB.slice(0, 8); + setTree((t) => swapLeaves(t, a.leafA!, a.leafB!)); + return { + payload: { leafA: a.leafA, leafB: a.leafB }, + summary: `Swap panes "${labelA}" ↔ "${labelB}"`, + }; + } + case "promote_pane": { + const a = args as { leafId?: string }; + if (typeof a.leafId !== "string") throw new Error("missing leafId"); + const leaf = findLeaf(treeRef.current, a.leafId); + if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`); + const next = promoteLeaf(treeRef.current, a.leafId); + if (next === null) { + throw new Error( + "pane can't be promoted (no perpendicular split above it)", + ); + } + setTree(next); + return { + payload: { leafId: a.leafId }, + summary: `Promote pane "${leaf.label ?? a.leafId.slice(0, 8)}" up one level`, + }; + } + case "apply_preset": { + const a = args as { name?: string; allowDrops?: boolean }; + const presetMap: Record) => TreeNode> = { + single: presetSingle, + two_columns: presetTwoColumns, + three_columns: presetThreeColumns, + two_rows: presetTwoRows, + two_by_two: presetTwoByTwo, + }; + const make = a.name ? presetMap[a.name] : undefined; + if (!make) { + throw new Error( + `unknown preset: ${a.name} (valid: single, two_columns, three_columns, two_rows, two_by_two)`, + ); + } + const { tree: nextTree, dropped } = reshapeToPreset( + treeRef.current, + make, + defaultShellAsLeafProps(defaultShell), + ); + if (dropped.length > 0 && !a.allowDrops) { + const labels = dropped + .map((id) => { + const l = findLeaf(treeRef.current, id); + return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8); + }) + .join(", "); + throw new Error( + `would drop ${dropped.length} pane(s) (${labels}); pass allow_drops=true to confirm`, + ); + } + for (const id of dropped) { + const paneId = paneIdByLeafRef.current.get(id); + if (paneId != null) { + void killPane(paneId).catch((e) => + console.warn("killPane failed:", e), + ); + paneIdByLeafRef.current.delete(id); + } + } + if (activeLeafId && dropped.includes(activeLeafId)) { + setActiveLeafId(null); + } + setTree(nextTree); + return { + payload: { name: a.name, dropped: dropped.length, droppedLeafIds: dropped }, + summary: + dropped.length > 0 + ? `Reshape to ${a.name} (closes ${dropped.length} pane${dropped.length === 1 ? "" : "s"})` + : `Reshape to ${a.name}`, + }; + } default: throw new Error(`unsupported MCP tool: ${tool}`); } }, - [setLabel], + [setLabel, close, defaultShell, activeLeafId], ); // The summary string for the confirm modal needs access to the leaf @@ -841,14 +939,36 @@ export default function App() { // logic (without mutating). For now we just rebuild it inline per tool; // when more tools land this should split out. const buildConfirmSummary = useCallback((tool: string, args: unknown): string => { - if (tool === "set_label") { - const a = args as { leafId?: string; label?: string }; - const leaf = a.leafId ? findLeaf(treeRef.current, a.leafId) : null; - const before = leaf && leaf.kind === "leaf" ? (leaf.label ?? "(unlabelled)") : "(unknown)"; - const after = a.label || "(cleared)"; - return `Rename pane "${before}" → "${after}"`; + function leafLabel(id: string | undefined): string { + if (!id) return "(unknown)"; + const l = findLeaf(treeRef.current, id); + return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8); + } + switch (tool) { + case "set_label": { + const a = args as { leafId?: string; label?: string }; + return `Rename pane "${leafLabel(a.leafId)}" → "${a.label || "(cleared)"}"`; + } + case "close_pane": { + const a = args as { leafId?: string }; + return `Close pane "${leafLabel(a.leafId)}"`; + } + case "swap_panes": { + const a = args as { leafA?: string; leafB?: string }; + return `Swap panes "${leafLabel(a.leafA)}" ↔ "${leafLabel(a.leafB)}"`; + } + case "promote_pane": { + const a = args as { leafId?: string }; + return `Promote pane "${leafLabel(a.leafId)}" up one level`; + } + case "apply_preset": { + const a = args as { name?: string; allowDrops?: boolean }; + const suffix = a.allowDrops ? " (drops allowed)" : ""; + return `Reshape workspace to ${a.name}${suffix}`; + } + default: + return `Run ${tool}`; } - return `Run ${tool}`; }, []); useEffect(() => {