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:
parent
09019a0ad7
commit
e0ce223985
2 changed files with 281 additions and 8 deletions
|
|
@ -276,6 +276,56 @@ pub struct WaitForIdleArgs {
|
||||||
pub timeout_ms: Option<u64>,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct SetLabelArgs {
|
pub struct SetLabelArgs {
|
||||||
/// Stable leaf id from the tree (uuid-shaped). Must belong to a pane
|
/// 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")]))
|
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]
|
#[tool_handler]
|
||||||
|
|
|
||||||
134
src/App.tsx
134
src/App.tsx
|
|
@ -829,11 +829,109 @@ export default function App() {
|
||||||
summary: `Rename pane "${before}" → "${after}"`,
|
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<string, (d: Partial<LeafNode>) => 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:
|
default:
|
||||||
throw new Error(`unsupported MCP tool: ${tool}`);
|
throw new Error(`unsupported MCP tool: ${tool}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setLabel],
|
[setLabel, close, defaultShell, activeLeafId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The summary string for the confirm modal needs access to the leaf
|
// 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;
|
// logic (without mutating). For now we just rebuild it inline per tool;
|
||||||
// when more tools land this should split out.
|
// when more tools land this should split out.
|
||||||
const buildConfirmSummary = useCallback((tool: string, args: unknown): string => {
|
const buildConfirmSummary = useCallback((tool: string, args: unknown): string => {
|
||||||
if (tool === "set_label") {
|
function leafLabel(id: string | undefined): string {
|
||||||
const a = args as { leafId?: string; label?: string };
|
if (!id) return "(unknown)";
|
||||||
const leaf = a.leafId ? findLeaf(treeRef.current, a.leafId) : null;
|
const l = findLeaf(treeRef.current, id);
|
||||||
const before = leaf && leaf.kind === "leaf" ? (leaf.label ?? "(unlabelled)") : "(unknown)";
|
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
|
||||||
const after = a.label || "(cleared)";
|
|
||||||
return `Rename pane "${before}" → "${after}"`;
|
|
||||||
}
|
}
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue