diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 86cf9ff0..2d1c2158 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -308,7 +308,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "clipboard", "Cut, copy or paste", - "Cut, copy or paste in the focused text field, as ⌘X / ⌘C / ⌘V do. `paste` pastes `text`, or what the system clipboard holds without it. inspect_ui → `copied` is the text the app last copied.", + "Cut, copy or paste in the focused text field (click one first), as ⌘X / ⌘C / ⌘V do; cut and copy write the system clipboard. `paste` pastes `text`, or what the system clipboard holds without it. inspect_ui → `copied` is the text the app last copied.", json!({"action": {"type": "string", "enum": ["cut", "copy", "paste"]}, "text": {"type": "string"}}), &["action"], ), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 592a58de..9cee4435 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -278,6 +278,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest // what the system does for ⌘X / ⌘C / ⌘V in a text field: paste `text`, or (without it) // what the system clipboard holds "ui.clipboard" => { + // outside a text field ⌘C / ⌘V are the app's shortcuts (and the macOS menu bar's): + // agents run those as commands (`develop.copy`, `develop.paste`) + if !ctx.text_edit_focused() { + return err("ui.clipboard: no text field has the focus (click one first)"); + } match s("action") { Some("cut") => app.synthetic.push(egui::Event::Cut), Some("copy") => app.synthetic.push(egui::Event::Copy), diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e77c45b0..bdee47ab 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -662,6 +662,23 @@ mod tests { assert_eq!(r["ok"], false, "an unknown action is refused: {r}"); } + /// `ui.clipboard` acts in a text field: with none focused it is refused, rather than doing + /// what ⌘V does there on one platform (paste edit settings) and nothing on another. + #[test] + fn the_clipboard_method_needs_a_text_field() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let undo = h.app.session.undo.len(); + for action in ["copy", "paste"] { + let r = h.request("ui.clipboard", json!({"action": action, "text": "travel"}), t); + assert_eq!(r["ok"], false, "{action}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("text field")), "{r}"); + } + h.settle(SETTLE); + assert_eq!(h.app.session.undo.len(), undo, "nothing was pasted"); + } + /// `ui.clickWidget` right-clicks with `button: "right"`, as `ui.click` does (MCP `click` /// passes it on with a widget): a filmstrip photo's context menu opens. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c0317b58..6f50b3f7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,7 +37,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press | | `ui.text` | `{text}` | Text input | -| `ui.clipboard` | `{action: cut\|copy\|paste, text?}` | Cut, copy or paste in the focused text field, as the system does for ⌘X / ⌘C / ⌘V; `paste` pastes `text`, or the system clipboard without it. `ui.inspect` → `copied` is the text the UI last put on the clipboard | +| `ui.clipboard` | `{action: cut\|copy\|paste, text?}` | Cut, copy or paste in the focused text field, as the system does for ⌘X / ⌘C / ⌘V; `paste` pastes `text`, or the system clipboard without it. Refused when no text field has the focus (outside one, ⌘C / ⌘V are the commands `develop.copy` / `develop.paste`). In the desktop app cut and copy write the system clipboard. `ui.inspect` → `copied` is the text the UI last put on the clipboard | | `ui.scroll` | `{dx, dy, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms | | `ui.zoom` | `{factor}` | Pinch zoom at the current pointer (positive scale multiplier; 1 = unchanged). Position it first with `ui.move` or `ui.hoverWidget` | | `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state | diff --git a/docs/mcp.md b/docs/mcp.md index 2dfe59c1..a6b69a26 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -134,7 +134,7 @@ Tools taking `id` make that photo active first; without it they act on the activ | `set_ui {state}` | Merge UI state, e.g. `{"view": "detail"}` | | `list_widgets {filter?}` / `click {widget \| x,y, count?}` | Widgets by automation id; real egui clicks | | `press_key {key, cmd?, shift?, alt?}` / `type_text {text}` | Keyboard input (shortcuts) | -| `clipboard {action, text?}` | Cut, copy or paste in the focused text field (⌘X / ⌘C / ⌘V); `inspect_ui` → `copied` is what was copied | +| `clipboard {action, text?}` | Cut, copy or paste in the focused text field (⌘X / ⌘C / ⌘V; refused when none has the focus; cut and copy write the system clipboard); `inspect_ui` → `copied` is what was copied | | `pointer_gesture {events}` | Gestures in normalized image coordinates (brush strokes, gradients, crop handles) | In headless mode these return a tool error explaining how to start the app.