The ribbon follows each command to its stage

A command run by a script, the control channel, MCP, the command search
or the browser left the ribbon on whatever tab it showed, so an agent's
session never showed the stage it was at. `catalogue::stage_tab` is the
rule, from the catalogue: a command's entry tab, none for the Tools tab
(files, history, the model summary). The app follows it after a command
that changed the model or the results display (queries leave the ribbon
alone); a script follows its last command with a stage. A ribbon click
runs a command of the tab shown, so it never moves the ribbon: a test
checks every entry and drop-down command against its tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 00:15:50 -04:00
1 parent 357de3f7a5
commit 34200013a4
3 files changed
+80 -2

No files matched your search

+29
View File
@@ -509,6 +509,15 @@ pub fn entry_of(command: &str) -> Option<&'static Capability> {
CATALOGUE.iter().find(|c| c.command() == Some(command)).or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command)))
}
/// The tab the ribbon follows to when `command` has run from anywhere but the ribbon (a script,
/// the control channel, MCP, the command search, the browser), as a person's click on that tab
/// would: its entry's tab. `None` for commands on the Tools tab (files, history, the model
/// summary: on every workspace and no stage of an analysis) and for unknown commands. A ribbon
/// click runs a command of the tab shown, so it never moves the ribbon (a test checks it).
pub fn stage_tab(command: &str) -> Option<Tab> {
entry_of(command).map(|c| c.tab).filter(|t| *t != Tab::Tools)
}
/// The entries of a tab (one run of the catalogue).
pub fn tab_entries(tab: Tab) -> &'static [Capability] {
let start = CATALOGUE.iter().position(|c| c.tab == tab).unwrap_or(CATALOGUE.len());
@@ -570,6 +579,26 @@ mod tests {
}
}
#[test]
fn the_ribbon_follows_each_command_to_its_stage() {
assert_eq!(stage_tab("mesh.hex.sweep"), Some(Tab::Mesh));
assert_eq!(stage_tab("model.material.create"), Some(Tab::Properties));
assert_eq!(stage_tab("model.material.edit"), Some(Tab::Properties));
assert_eq!(stage_tab("model.load.create"), Some(Tab::Loads));
assert_eq!(stage_tab("solve.static"), Some(Tab::Solve));
assert_eq!(stage_tab("results.contour"), Some(Tab::Results));
assert_eq!(stage_tab("edit.undo"), None);
assert_eq!(stage_tab("file.save"), None);
assert_eq!(stage_tab("model.info"), None);
assert_eq!(stage_tab("no.such"), None);
// A ribbon click (an entry's command, or one of its drop-down's) stays on the tab shown.
for c in CATALOGUE {
for command in c.command().into_iter().chain(c.more.iter().copied()) {
assert!(stage_tab(command).is_none_or(|t| t == c.tab), "{}: `{command}` would move the ribbon from {:?}", c.id, c.tab);
}
}
}
#[test]
fn every_command_is_claimed_and_every_ready_entry_runs_a_command() {
let mut claimed: Vec<&str> = Vec::new();
+45 -1
View File
@@ -369,6 +369,9 @@ impl ModelApp {
}
// The results display commands reply with what is shown, which the viewport shows.
let display = (id.starts_with("results.") && id != "results.reactions") || id == "solve.run";
if r.is_ok() && (changed || display) {
self.follow_stage(id);
}
match &r {
Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false),
Ok(v) => self.set_status(format!("{label}: {v}"), false),
@@ -377,10 +380,17 @@ impl ModelApp {
r
}
/// Run a script (a JSON array of `{command, params}`), stopping at the first error.
/// Run a script (a JSON array of `{command, params}`), stopping at the first error. The
/// ribbon follows its last command that has a stage.
pub fn run_script(&mut self, script: &Value) -> Result<(), String> {
let r = self.session.run_script(script).map(|_| ()).map_err(|e| e.to_string());
self.model_changed();
if r.is_ok() {
let commands = script.as_array().into_iter().flatten().filter_map(|step| step.get("command").and_then(Value::as_str));
if let Some(id) = commands.rev().find(|id| catalogue::stage_tab(id).is_some()) {
self.follow_stage(id);
}
}
match &r {
Ok(()) => self.set_status("Script done", false),
Err(e) => self.set_status(format!("Script: {e}"), true),
@@ -388,6 +398,16 @@ impl ModelApp {
r
}
/// The ribbon turns to the tab where `id` lives ([`catalogue::stage_tab`]), as a person's click
/// there would, so commands run by scripts, the control channel and MCP show their stage.
/// Called after a command changed the model or the results display; queries leave the ribbon
/// alone, and a ribbon click never moves it.
fn follow_stage(&mut self, id: &str) {
if let Some(tab) = catalogue::stage_tab(id) {
self.ui.select_tab(tab);
}
}
/// What a click on a toolbar entry, a menu row or a palette result does: a command without
/// parameters runs, one with parameters opens its form, and the file entries (open, save,
/// write a deck, recover) go through the host's file pickers. `id` is a command or a
@@ -872,6 +892,30 @@ mod tests {
assert!(app.status.as_ref().is_some_and(|s| s.text.contains("\"nodes\":1")));
}
/// Commands run from outside the ribbon bring their tab; queries and Tools commands leave it.
#[test]
fn the_ribbon_follows_commands_to_their_stage() {
let mut app = ModelApp::new(Session::default());
app.ui.select_tab(Tab::Results);
app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
assert_eq!((app.ui.workspace, app.ui.tab), (Workspace::Prepare, Tab::Mesh));
app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})).unwrap();
assert_eq!((app.ui.workspace, app.ui.tab), (Workspace::Model, Tab::Properties));
// A query and a failed command leave it; so does undo (Tools).
app.run("mesh.quality", json!({})).ok();
let _ = app.run("model.load.create", json!({"name": "bad"}));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.ui.tab, Tab::Properties);
// A script follows its last command with a stage.
app.run_script(&json!([{"command": "mesh.node.add", "params": {"label": 2, "xyz": [1, 0, 0]}}, {"command": "model.info", "params": {}}]))
.unwrap();
assert_eq!(app.ui.tab, Tab::Mesh);
// The ribbon's own entries never move it.
app.ui.select_tab(Tab::Properties);
app.start("model.units").unwrap();
assert_eq!(app.ui.tab, Tab::Properties);
}
#[test]
fn starting_a_command_opens_its_form_and_running_it_closes_the_form() {
let mut app = ModelApp::new(Session::default());
+6 -1
View File
@@ -30,7 +30,12 @@ checked and clamped (drag steps, wheel lines, window size); `ui.render` refuses
Commands never open forms or file pickers when run this way. `fieldcraft-cli commands` prints the
same registry. `file.sample {name}` opens a sample (`parts`, `cantilever`, `impact`) and
`solve.run {}` solves every step; the Results workspace comes up when a result appears.
`solve.run {}` solves every step; the Results workspace comes up when a result appears. The
ribbon follows the commands run here, as it does for scripts, MCP, the command search and the
browser: a command that changes the model or the results display brings the tab where its
toolbar entry lives (Mesh for `mesh.*`, Properties, Steps, Loads & BCs… for `model.*`, Solve,
Results), as a click there would. Queries (`model.check`, `mesh.quality`…) and the Tools tab's
commands (files, undo, `model.info`) leave the ribbon where it is.
## UI