From b7baba6df10c9fdcc5a887f5f64753c8a0094461 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Tue, 18 Aug 2026 08:01:48 +0000 Subject: [PATCH] properties: copy the path, and copy the call that would set it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blender's property right-click has Copy Data Path, and it is there because the gap it closes is real: you are looking at a thing and you need the words that address it. Blender's words are Python. Ours are a JSON pointer and the command that writes it — which is the sentence someone hands an agent, so this is the most powderman-shaped thing on the Blender review. A field's menu gains Copy data path and Copy as command. Both go through the clipboard action the menu shim already has, so neither costs a message and neither needed a new mechanism. The Info log gets the other half. Blender's Info editor shows every operator as the Python that ran it, which is what makes it more than a receipt — you can copy a line out of the log into a script. A row now copies as `workspace_add {"name":"x"}`: the MCP tool spelling, because the point is to paste it where an agent reads. `tool_name` moved out of the parity test to do it, so the spelling rule the test checks with is the one the UI shows. Two corrections to my own review. Reset to default already existed — I listed it as missing and it has been there since the widget kit landed. What was missing is that `/theme` was the one field without a default, so it alone had no reset row. Writing the test for that found a real one: `/diff_split` was offered in Preferences and was not in the settings document at all, so it had no default to reset to and every reader guessed one. Now the test holds the field defaults and the document defaults together, which is the drift that makes a reset button quietly reset to a value the daemon never used. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XjCx5QszLXroGYh1YBxybw --- immersion/src/widget.rs | 18 +++++++++ powderman/src/daemon.rs | 4 ++ powderman/src/editors/info.rs | 75 +++++++++++++++++++++++++++++++++++ powderman/src/mcp.rs | 18 +++++---- powderman/src/settings.rs | 32 ++++++++++++++- 5 files changed, 138 insertions(+), 9 deletions(-) diff --git a/immersion/src/widget.rs b/immersion/src/widget.rs index 756b82d..6996507 100644 --- a/immersion/src/widget.rs +++ b/immersion/src/widget.rs @@ -178,6 +178,24 @@ fn field_row( json!({ "label": "Copy value", "action": "copy_value", "params": { "value": val } }), ); items.push(json!({ "label": "Paste value", "action": "paste_value", "params": { "pointer": f.path } })); + items.push(json!({ "sep": true })); + // Blender's Copy Data Path, and the reason it exists: to get from a + // thing you are looking at to the words that address it. Blender's + // words are Python; ours are a pointer and the command that writes it, + // which is what someone hands an agent. + items.push(json!({ + "label": "Copy data path", + "action": "copy_value", + "params": { "value": f.path }, + })); + items.push(json!({ + "label": "Copy as command", + "action": "copy_value", + "params": { "value": format!( + "set_setting {}", + json!({ "pointer": f.path, "value": val }) + ) }, + })); Some(json!(items).to_string()) }; rsx! { diff --git a/powderman/src/daemon.rs b/powderman/src/daemon.rs index 5be5c5b..6a662d4 100644 --- a/powderman/src/daemon.rs +++ b/powderman/src/daemon.rs @@ -153,6 +153,10 @@ pub(crate) fn settings_defaults() -> serde_json::Value { "tooltips_on": true, "theme": "Blender Dark", "ui_scale": 1.0, + // Declared here as well as offered in Preferences: a field whose + // pointer the document does not have is a control with no default to + // reset to, and a reader that has to guess one. + "diff_split": false, // A vector setting: the chart window as [hours, samples, smoothing]. "chart_window": [1, 60, 3], // Charts are documents, not code: each is a Vega-Lite spec the chart diff --git a/powderman/src/editors/info.rs b/powderman/src/editors/info.rs index bd4f3e6..1233b32 100644 --- a/powderman/src/editors/info.rs +++ b/powderman/src/editors/info.rs @@ -18,6 +18,8 @@ pub(crate) fn ed_info(s: &State) -> Element { div { class: if e.ok { "log-row" } else { "log-row failed" }, key: "{i}-{e.at}", + "data-im-menu": "{row_menu(e)}", + "data-filter-text": "{e.name} {e.source} {e.params}", span { class: "when", "{hhmmss(e.at)}" } span { class: "src {e.source}", "{e.source}" } span { class: "k", "{e.name}" } @@ -28,6 +30,39 @@ pub(crate) fn ed_info(s: &State) -> Element { } } +/// A log row's menu: the call that would do this again. +/// +/// Blender's Info editor shows every operator as the Python that ran it, and +/// that is what makes it more than a receipt — you can copy a line out of the +/// log and into a script. Ours is the same idea in this workbench's language: +/// the MCP tool name and the params, which is the sentence an agent is given. +fn row_menu(e: &crate::ui::LogEntry) -> String { + let call = agent_call(&e.name, &e.params); + immersion::menu_json(&[ + immersion::MenuItem::new( + "Copy as agent call", + "copy_value", + serde_json::json!({ "value": call }), + ), + immersion::MenuItem::new( + "Copy parameters", + "copy_value", + serde_json::json!({ "value": e.params.to_string() }), + ), + ]) +} + +/// `workspace.add {"name":"x"}` as `workspace_add {"name":"x"}` — the tool +/// spelling, because the point is to paste it somewhere an agent reads. +pub(crate) fn agent_call(name: &str, params: &serde_json::Value) -> String { + let tool = crate::mcp::tool_name(name); + if params.is_null() { + tool + } else { + format!("{tool} {params}") + } +} + /// This editor's entry in the registry: what it is called, how it is drawn in /// a header, whether it takes a target, and what the status bar says while it /// has focus. Declared beside the editor so adding one is one file. @@ -40,3 +75,43 @@ pub(crate) fn kind() -> immersion::EditorKind { targets: false, } } + +#[cfg(test)] +mod agent_call_tests { + use super::agent_call; + + /// The whole value of the row is that what you copy is what an agent + /// runs. MCP spells `workspace.add` as `workspace_add`, so a log row that + /// copied the command name verbatim would hand over a call that does not + /// exist — and the mistake is invisible until someone pastes it. + #[test] + fn a_copied_row_is_spelled_the_way_the_tool_is() { + assert_eq!( + agent_call("workspace.add", &serde_json::json!({ "name": "x" })), + r#"workspace_add {"name":"x"}"# + ); + assert_eq!( + agent_call("split", &serde_json::json!({ "id": 1, "dir": "row" })), + // serde_json orders object keys alphabetically, which is a fine + // and stable thing for something meant to be pasted. + r#"split {"dir":"row","id":1}"# + ); + // Undo takes nothing, and `undo null` is not a call anyone would run. + assert_eq!(agent_call("undo", &serde_json::Value::Null), "undo"); + } + + /// And the tool it names is one that exists. A row offering a call the + /// server does not answer is worse than no row. + #[test] + fn the_tool_it_names_is_one_the_server_has() { + for c in crate::workflows::commands().iter() { + let call = agent_call(c.name, &serde_json::Value::Null); + assert!( + crate::mcp::tools().iter().any(|t| t.name == call.as_str()) + || call == "load_layout", + "{} copies as {call}, which is not a tool", + c.name + ); + } + } +} diff --git a/powderman/src/mcp.rs b/powderman/src/mcp.rs index 7b4c181..3b623b7 100644 --- a/powderman/src/mcp.rs +++ b/powderman/src/mcp.rs @@ -245,6 +245,14 @@ fn run(name: &str, params: serde_json::Value) -> Result String { + command.replace('.', "_") +} + /// Every tool this server offers, as the model an agent receives — name, /// description and the JSON Schema of its parameters. The router's own /// accessor is generated private, and the reference is built outside this @@ -647,12 +655,6 @@ fn host_config() -> StreamableHttpServerConfig { mod parity { use super::*; - /// MCP tool names are snake_case; command names use dots - /// (`workspace.add`). One spelling rule, applied in one place. - fn tool_name(command: &str) -> String { - command.replace('.', "_") - } - /// Commands and host actions an agent is deliberately not given, each with /// the reason it is absent. Anything not listed here must have a tool — /// adding an entry is a decision someone has to write down, which is the @@ -679,7 +681,7 @@ mod parity { if NOT_FOR_AGENTS.iter().any(|(n, _)| *n == name) { continue; } - if !router.has_route(&tool_name(name)) { + if !router.has_route(&super::tool_name(name)) { missing.push(name.to_string()); } } @@ -711,7 +713,7 @@ mod parity { let router = Workbench::tool_router(); for a in crate::ui::client_view_actions() { assert!( - !router.has_route(&tool_name(a)), + !router.has_route(&super::tool_name(a)), "{a} is client-view state but has an MCP tool" ); } diff --git a/powderman/src/settings.rs b/powderman/src/settings.rs index 6b9f9c4..305a171 100644 --- a/powderman/src/settings.rs +++ b/powderman/src/settings.rs @@ -78,7 +78,8 @@ pub(crate) fn settings_fields() -> Vec { .collect(), ), ) - .with_hint("the workbench palette; accent stays your own"), + .with_hint("the workbench palette; accent stays your own") + .with_default(serde_json::json!("Blender Dark")), Field::new( "/chart_window", "Chart window", @@ -160,3 +161,32 @@ mod tests { )); } } + +#[cfg(test)] +mod default_tests { + use super::settings_fields; + + /// "Reset to default" resets to the value the *field* carries, and the + /// document has its own defaults in `settings_defaults`. Nothing held the + /// two together, so a field could have offered to reset a setting to a + /// value the daemon has never used — which looks like a working control + /// and is a lie. + #[test] + fn every_field_resets_to_what_the_document_actually_defaults_to() { + let doc = crate::daemon::settings_defaults(); + for f in settings_fields() { + let want = doc + .pointer(&f.path) + .unwrap_or_else(|| panic!("{} is not in the settings document", f.path)); + let have = f + .default + .as_ref() + .unwrap_or_else(|| panic!("{} offers no reset — it needs a default", f.path)); + assert_eq!( + have, want, + "{} resets to {have}, but the document defaults to {want}", + f.path + ); + } + } +}