mirror of
https://github.com/aphrody-labs/cadcraft.git
synced 2026-10-13 19:26:54 +00:00
Control channel: headless and app reply alike to cmdline.input, state and app.open (fixes #351) (#352)
Both backends now return the same command-line state (prompt, running, keywords, accept, buffer, history, historyExpanded). cmdline.input echoes a refused line to the history and `output` and also reports it in `error` on both sides. Headless app.open without a path answers "missing path" like the app instead of reaching OPEN. Documented in control-protocol.md. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cdd25840cc
commit
dacd8ece49
7 files changed
+164
-9
No files matched your search
@@ -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::<Vec<_>>(),
|
||||
"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<String> = 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())
|
||||
|
||||
@@ -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<angle`, distances, keywords (e.g. `c` for Close), empty text = Enter. Spaces act as Enter, so `circle 0,0 5` works in one call. Returns the new prompt and command-line output.",
|
||||
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<angle`, distances, keywords (e.g. `c` for Close), empty text = Enter. Spaces act as Enter, so `circle 0,0 5` works in one call. Returns the new prompt and command-line output, and `error` when the line was refused.",
|
||||
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}),
|
||||
json!({"name": "script", "description": "Run a multi-line script (like an AutoCAD .scr file): one or more inputs per line, blank line = Enter.",
|
||||
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! The headless backend answers `cmdline.input` and `app.open` with the same reply shape as the
|
||||
//! app's control channel (`cadcraft_ui_egui::control`, tested in `crates/ui-egui/tests/control_cmdline.rs`).
|
||||
|
||||
use cadcraft_mcp::{Backend, Headless};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const STATE_KEYS: [&str; 7] = ["prompt", "running", "keywords", "accept", "buffer", "history", "historyExpanded"];
|
||||
|
||||
fn keys(v: &Value) -> Vec<String> {
|
||||
let mut k: Vec<String> = v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
|
||||
fn want(extra: &[&str]) -> Vec<String> {
|
||||
let mut k: Vec<String> = 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");
|
||||
}
|
||||
@@ -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<String> = 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)
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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).
|
||||
|
||||
@@ -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<String> {
|
||||
let mut k: Vec<String> = v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
|
||||
fn want(extra: &[&str]) -> Vec<String> {
|
||||
let mut k: Vec<String> = 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"}));
|
||||
}
|
||||
@@ -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`:
|
||||
|
||||
Reference in new issue
Block a user