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 + ); + } + } +}