Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions immersion/src/widget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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! {
Expand Down
4 changes: 4 additions & 0 deletions powderman/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions powderman/src/editors/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}" }
Expand All @@ -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.
Expand All @@ -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
);
}
}
}
18 changes: 10 additions & 8 deletions powderman/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,14 @@ fn run(name: &str, params: serde_json::Value) -> Result<CallToolResult, McpError
}
}

/// MCP tool names are snake_case; command names use dots (`workspace.add`).
/// One spelling rule, applied in one place — the parity test checks with it,
/// and the Info log shows commands with it, so what a person copies off a log
/// row is what an agent actually calls.
pub(crate) fn tool_name(command: &str) -> 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
Expand Down Expand Up @@ -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
Expand All @@ -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());
}
}
Expand Down Expand Up @@ -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"
);
}
Expand Down
32 changes: 31 additions & 1 deletion powderman/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ pub(crate) fn settings_fields() -> Vec<Field> {
.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",
Expand Down Expand Up @@ -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
);
}
}
}
Loading