diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 6a2089a..15f4a1f 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -126,13 +126,18 @@ impl Default for Headless { } } +/// The command-line state, with the same fields as the app's (`cadcraft_ui_egui::control`). There +/// is no command-line widget here: nothing is typed (`buffer`) and the history isn't expanded. fn state(s: &Session) -> Value { let p = s.current_prompt(); json!({ "prompt": s.prompt_text(), "running": s.running.as_ref().map(|r| r.id.clone()), "keywords": p.as_ref().map(|p| p.keywords.clone()).unwrap_or_default(), + "accept": p.as_ref().map(|p| p.accept), + "buffer": "", "history": s.log.iter().rev().take(20).rev().cloned().collect::>(), + "historyExpanded": false, }) } @@ -149,6 +154,11 @@ impl Backend for Headless { "cmdline.input" => { let before = s.log.len(); let r = s.cmdline(&str_p("text").unwrap_or_default()); + // Like the app: a refused line is echoed to the history (and `output`), and also + // reported in `error`. + if let Err(e) = &r { + s.echo(e.to_string()); + } let out: Vec = s.log.iter().skip(before).cloned().collect(); let mut st = state(s); if let Some(o) = st.as_object_mut() { @@ -197,7 +207,10 @@ impl Backend for Headless { } Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h})) } - "app.open" => s.execute("open", &json!({"path": str_p("path")})).map_err(|e| e.to_string()), + "app.open" => { + let path = str_p("path").ok_or("missing path")?; + s.execute("open", &json!({ "path": path })).map_err(|e| e.to_string()) + } "app.save" => { let id = if p.get("path").is_some() { "saveas" } else { "qsave" }; s.execute(id, &p).map_err(|e| e.to_string()) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index ed50d00..ceedbc8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -6,7 +6,7 @@ use crate::backend::Backend; pub fn tool_definitions(has_ui: bool) -> Value { let mut tools = vec![ - json!({"name": "command_line", "description": "Type one line at the CADCraft command line, exactly as a user would at an AutoCAD-style prompt. Starts commands by name or alias (LINE, L, CIRCLE, C, PLINE, RECTANG, MOVE, TRIM, OFFSET, ZOOM…) and answers the active prompt: points `x,y`, relative `@dx,dy`, polar `@dist Vec { + let mut k: Vec = v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); + k.sort(); + k +} + +fn want(extra: &[&str]) -> Vec { + let mut k: Vec = STATE_KEYS.iter().chain(extra).map(|s| s.to_string()).collect(); + k.sort(); + k +} + +#[test] +fn refused_input_is_echoed_to_output_and_reported_in_error() { + let mut b = Headless::default(); + let r = b.call("cmdline.input", json!({"text": "nosuchcommand"})).unwrap(); + assert_eq!(keys(&r), want(&["output", "error"]), "{r}"); + let e = r["error"].as_str().unwrap(); + assert!(!e.is_empty()); + assert!(r["output"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); + assert!(r["history"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); +} + +#[test] +fn accepted_input_has_no_error_and_reports_the_prompt() { + let mut b = Headless::default(); + let r = b.call("cmdline.input", json!({"text": "circle"})).unwrap(); + assert_eq!(keys(&r), want(&["output"]), "{r}"); + assert_eq!(r["running"], "circle"); + assert!(!r["accept"].is_null(), "{r}"); + assert_eq!(r["buffer"], ""); + assert_eq!(r["historyExpanded"], false); + assert_eq!(keys(&b.call("cmdline.state", json!({})).unwrap()), want(&[])); +} + +#[test] +fn open_without_a_path_is_refused_before_the_engine() { + let mut b = Headless::default(); + assert_eq!(b.call("app.open", json!({})).unwrap_err(), "missing path"); + assert_eq!(b.call("app.open", json!({"path": 3})).unwrap_err(), "missing path"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ea55ed3..b9bb4ce 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -180,11 +180,14 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou "cmdline.input" => { let text = s("text").unwrap_or(""); let before = app.session.log.len(); - app.cmdline(text); + let error = app.cmdline(text); let out: Vec = app.session.log.iter().skip(before).cloned().collect(); let mut st = cmdline_state(app); if let Some(o) = st.as_object_mut() { o.insert("output".into(), json!(out)); + if let Some(e) = error { + o.insert("error".into(), json!(e)); + } } ok(st) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b818d9..c10e5ef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -244,19 +244,20 @@ impl CadApp { } } - /// Submit a line of command-line text. - pub fn cmdline(&mut self, text: &str) { + /// Submit a line of command-line text. A refused line is echoed to the history; the error is + /// also returned. + pub fn cmdline(&mut self, text: &str) -> Option { if self.session.running.is_none() { let first = text.split_whitespace().next().unwrap_or(""); // `cmd {json}` is a programmatic call: the engine runs it with those parameters. let json_form = text.trim_start().get(first.len()..).is_some_and(|rest| rest.trim_start().starts_with('{')); if !first.is_empty() && !json_form && menus::run_ui_command(self, &first.to_ascii_lowercase(), &Value::Null).is_some() { - return; + return None; } } - if let Err(e) = self.session.cmdline(text) { - self.session.echo(e.to_string()); - } + let e = self.session.cmdline(text).err()?.to_string(); + self.session.echo(e.clone()); + Some(e) } /// The theme the interface shows now (the choice resolved against the OS appearance). diff --git a/crates/ui-egui/tests/control_cmdline.rs b/crates/ui-egui/tests/control_cmdline.rs new file mode 100644 index 0000000..0c723cf --- /dev/null +++ b/crates/ui-egui/tests/control_cmdline.rs @@ -0,0 +1,64 @@ +//! The app's control channel answers `cmdline.input` and `app.open` with the same reply shape as +//! the headless MCP backend (`cadcraft_mcp::Headless`, tested in `crates/mcp/tests/cmdline_parity.rs`). + +use cadcraft_engine::Session; +use cadcraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use cadcraft_ui_egui::{CadApp, Services}; +use serde_json::{Value, json}; + +const STATE_KEYS: [&str; 7] = ["prompt", "running", "keywords", "accept", "buffer", "history", "historyExpanded"]; + +fn call(app: &mut CadApp, method: &str, params: Value) -> Value { + let (req, _reply) = ControlRequest::new(method, params); + match handle(app, &egui::Context::default(), &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => Value::Null, + } +} + +fn keys(v: &Value) -> Vec { + let mut k: Vec = v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); + k.sort(); + k +} + +fn want(extra: &[&str]) -> Vec { + let mut k: Vec = STATE_KEYS.iter().chain(extra).map(|s| s.to_string()).collect(); + k.sort(); + k +} + +fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) +} + +#[test] +fn refused_input_is_echoed_to_output_and_reported_in_error() { + let mut app = app(); + let v = call(&mut app, "cmdline.input", json!({"text": "nosuchcommand"})); + assert_eq!(v["ok"], true, "{v}"); + let r = &v["result"]; + assert_eq!(keys(r), want(&["output", "error"]), "{r}"); + let e = r["error"].as_str().unwrap(); + assert!(!e.is_empty()); + assert!(r["output"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); + assert!(r["history"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); +} + +#[test] +fn accepted_input_has_no_error_and_reports_the_prompt() { + let mut app = app(); + let v = call(&mut app, "cmdline.input", json!({"text": "circle"})); + let r = &v["result"]; + assert_eq!(keys(r), want(&["output"]), "{r}"); + assert_eq!(r["running"], "circle"); + assert!(!r["accept"].is_null(), "{r}"); + assert_eq!(keys(&call(&mut app, "cmdline.state", json!({}))["result"]), want(&[])); +} + +#[test] +fn open_without_a_path_is_refused_before_the_engine() { + let mut app = app(); + assert_eq!(call(&mut app, "app.open", json!({})), json!({"ok": false, "error": "missing path"})); + assert_eq!(call(&mut app, "app.open", json!({"path": 3})), json!({"ok": false, "error": "missing path"})); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aca718b..9dcb84d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,6 +43,31 @@ The server waits up to 60 s for the app to answer a request. After that it repli The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless (`cadcraft-cli mcp`) it serves the same methods from an in-process session. +## Command-line replies + +The app and the headless session reply with the same fields. + +`cmdline.state`, `cmdline.key` and `cmdline.input` return the command-line state: + +| Field | Value | +|---|---| +| `prompt` | The prompt text (`"Command:"` when idle). | +| `running` | The id of the running command, or `null`. | +| `keywords` | The current prompt's options. | +| `accept` | The kinds of input the current prompt accepts, or `null` when idle. | +| `buffer` | Text typed in the command line but not yet submitted (always `""` headless). | +| `history` | The last 20 history lines. | +| `historyExpanded` | Whether the history is expanded (always `false` headless). | + +`cmdline.input` adds: + +- `output`: the history lines the input added. This includes the error message when the line was refused. +- `error`: the error message, only when the line was refused (unknown command, invalid input…). + +A refused line still replies `"ok": true`, because the text was typed. Check `error`. + +`app.open` without a string `path` fails with `"missing path"` before any command runs. + ## Interface language Select a language with `engine.execute` and the UI command `ui.language`: