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.
This commit is contained in:
megaproxy 2026-05-26 12:44:11 +01:00
parent 09019a0ad7
commit e0ce223985
2 changed files with 281 additions and 8 deletions

View file

@ -276,6 +276,56 @@ pub struct WaitForIdleArgs {
pub timeout_ms: Option<u64>,
}
#[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<CloseLeafArgs>,
) -> Result<CallToolResult, McpError> {
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<SwapPanesArgs>,
) -> Result<CallToolResult, McpError> {
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<PromotePaneArgs>,
) -> Result<CallToolResult, McpError> {
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<ApplyPresetArgs>,
) -> Result<CallToolResult, McpError> {
// 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<MirroredLeaf, McpError> {
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]