diff --git a/Cargo.lock b/Cargo.lock index 1d6c8bacf..4e2777d3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2287,6 +2287,10 @@ dependencies = [ "op-host-web", ] +[[package]] +name = "op-cli" +version = "0.1.0" + [[package]] name = "op-codegen" version = "0.1.0" diff --git a/crates/op-ai/src/chat_provider.rs b/crates/op-ai/src/chat_provider.rs index be10a555f..a71e492b9 100644 --- a/crates/op-ai/src/chat_provider.rs +++ b/crates/op-ai/src/chat_provider.rs @@ -145,11 +145,64 @@ pub enum StopReason { ToolUse, } -#[derive(Debug, Clone, PartialEq, Eq)] +/// Thinking / reasoning-budget control for a chat turn. `Adaptive` +/// lets the provider decide; `Disabled` suppresses extended thinking; +/// `Enabled` forces it. Mirrors the TS chat panel's thinking-mode +/// selector (`apps/web/.../ai/chat.ts`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ThinkingMode { + #[default] + Adaptive, + Disabled, + Enabled, +} + +/// Reasoning-effort hint. Each provider maps it onto its own knob +/// (Claude's thinking-token budget, Codex's `--effort`, …); a +/// provider with no such knob ignores it. The default is `Low`, to +/// match TS `ai-runtime-config.ts::DEFAULT_THINKING_EFFORT`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EffortLevel { + #[default] + Low, + Medium, + High, + Max, +} + +impl ThinkingMode { + /// Lowercase wire token (TS parity: `"adaptive"` / `"disabled"` / + /// `"enabled"`). + pub fn as_str(self) -> &'static str { + match self { + ThinkingMode::Adaptive => "adaptive", + ThinkingMode::Disabled => "disabled", + ThinkingMode::Enabled => "enabled", + } + } +} + +impl EffortLevel { + /// Lowercase wire token (`"low"` / `"medium"` / `"high"` / `"max"`). + pub fn as_str(self) -> &'static str { + match self { + EffortLevel::Low => "low", + EffortLevel::Medium => "medium", + EffortLevel::High => "high", + EffortLevel::Max => "max", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ChatRequest { pub system_prompt: String, pub user_message: String, pub max_output_tokens: u32, + /// Thinking-mode control for this turn (default `Adaptive`). + pub thinking: ThinkingMode, + /// Reasoning-effort hint for this turn (default `Low`, TS parity). + pub effort: EffortLevel, } /// Provider abstraction the widget host calls. Implementations live @@ -244,6 +297,7 @@ mod tests { system_prompt: String::new(), user_message: "hi".into(), max_output_tokens: 1024, + ..Default::default() }; let mut iter = p.send(req); match iter.next() { @@ -261,4 +315,21 @@ mod tests { assert_eq!(CliName::ClaudeCode.label(), "Claude Code"); assert_eq!(CliName::OpenCode.label(), "OpenCode"); } + + #[test] + fn chat_request_thinking_effort_defaults_and_wire_tokens() { + // A defaulted request reasons adaptively at low effort — + // matching TS `DEFAULT_THINKING_MODE` / `DEFAULT_THINKING_EFFORT`. + let req = ChatRequest::default(); + assert_eq!(req.thinking, ThinkingMode::Adaptive); + assert_eq!(req.effort, EffortLevel::Low); + // Wire tokens match the TS chat-request vocabulary. + assert_eq!(ThinkingMode::Adaptive.as_str(), "adaptive"); + assert_eq!(ThinkingMode::Disabled.as_str(), "disabled"); + assert_eq!(ThinkingMode::Enabled.as_str(), "enabled"); + assert_eq!(EffortLevel::Low.as_str(), "low"); + assert_eq!(EffortLevel::Medium.as_str(), "medium"); + assert_eq!(EffortLevel::High.as_str(), "high"); + assert_eq!(EffortLevel::Max.as_str(), "max"); + } } diff --git a/crates/op-cli/Cargo.toml b/crates/op-cli/Cargo.toml new file mode 100644 index 000000000..ec4ab9661 --- /dev/null +++ b/crates/op-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "op-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "OpenPencil `op` CLI — drives the editor via the HTTP MCP transport" + +# The binary is `op` (the command users type); the crate is `op-cli`. +[[bin]] +name = "op" +path = "src/main.rs" + +# Dependency-free: the CLI hand-rolls the HTTP/1.1 request + JSON-RPC +# body the same way `op-mcp` hand-rolls its wire layer, so `op` builds +# fast and pulls nothing into the workspace graph. +[dependencies] diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs new file mode 100644 index 000000000..6c9fb017c --- /dev/null +++ b/crates/op-cli/src/main.rs @@ -0,0 +1,314 @@ +//! `op` — the OpenPencil command-line tool. +//! +//! A thin client over the HTTP MCP transport (`op-host-desktop +//! --mcp-http `): every `op` invocation maps onto one +//! MCP `tools/call` and prints the JSON-RPC reply. Because the editor +//! already exposes its full editing surface as MCP tools, the generic +//! `op key=value …` form drives all of them — insert / update / +//! delete / move / design_* / variables / pages / export, etc. +//! +//! Usage: +//! op [--port N] tools list every tool + schema +//! op [--port N] [key=value …] call one tool +//! op help this message +//! +//! `--port` defaults to 8765; start the server with +//! `op-host-desktop --mcp-http 8765 .op`. +//! +//! Dependency-free on purpose: the HTTP/1.1 request and the JSON-RPC +//! body are hand-rolled, mirroring `op-mcp`'s hand-rolled wire layer. + +use std::io::{Read, Write}; + +/// Default HTTP MCP port — pair with `op-host-desktop --mcp-http 8765`. +const DEFAULT_PORT: u16 = 8765; + +const USAGE: &str = "\ +op — OpenPencil CLI (drives the editor over the HTTP MCP transport) + +USAGE: + op [--port N] tools list every MCP tool + input schema + op [--port N] [key=value …] call one MCP tool with string args + op help show this message + +EXAMPLES: + op tools + op insert_node kind=rect name=Box x=10 y=20 width=100 height=60 + op set_node_fill_hex node_id=n3 hex=#ff0000 + op --port 9001 get_document_info + +The server must be running: + op-host-desktop --mcp-http 8765 path/to/file.op"; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + match run(&args) { + Ok(out) => { + println!("{out}"); + } + Err(e) => { + eprintln!("op: {e}"); + std::process::exit(1); + } + } +} + +/// Parse `args`, perform the request, return the text to print. +/// Pure except for [`post`] — the parse/build steps are unit-tested. +fn run(args: &[String]) -> Result { + let Parsed { port, command } = parse_args(args)?; + match command { + Command::Help => Ok(USAGE.to_string()), + Command::ToolsList => post(port, &tools_list_body()), + Command::ToolCall { tool, args } => { + post(port, &tool_call_body(&tool, &args_to_json(&args))) + } + } +} + +/// Outcome of argument parsing. +struct Parsed { + port: u16, + command: Command, +} + +enum Command { + Help, + ToolsList, + ToolCall { + tool: String, + args: Vec<(String, String)>, + }, +} + +/// Parse `[--port N] …` into a [`Parsed`]. `--port` may +/// appear anywhere before the command. +fn parse_args(args: &[String]) -> Result { + let mut port = DEFAULT_PORT; + let mut rest: Vec<&String> = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--port" => { + let raw = args + .get(i + 1) + .ok_or("--port needs a value (e.g. --port 8765)")?; + port = raw + .parse::() + .map_err(|_| format!("--port must be a u16, got {raw:?}"))?; + i += 2; + } + _ => { + rest.push(&args[i]); + i += 1; + } + } + } + let Some(cmd) = rest.first() else { + return Err(format!("missing command\n\n{USAGE}")); + }; + let command = match cmd.as_str() { + "help" | "--help" | "-h" => Command::Help, + "tools" => Command::ToolsList, + tool => { + let mut pairs = Vec::new(); + for kv in &rest[1..] { + let (k, v) = kv + .split_once('=') + .ok_or_else(|| format!("argument must be key=value, got {kv:?}"))?; + if k.is_empty() { + return Err(format!("argument has an empty key: {kv:?}")); + } + pairs.push((k.to_string(), v.to_string())); + } + Command::ToolCall { + tool: tool.to_string(), + args: pairs, + } + } + }; + Ok(Parsed { port, command }) +} + +/// JSON-RPC body for `tools/list`. +fn tools_list_body() -> String { + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string() +} + +/// JSON-RPC body for a `tools/call` of `tool` with the already-built +/// `arguments` object JSON. +fn tool_call_body(tool: &str, args_json: &str) -> String { + format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"{}","arguments":{}}}}}"#, + json_escape(tool), + args_json + ) +} + +/// Build a JSON object from `key=value` pairs. MCP tool arguments are +/// all string-typed, so every value is emitted as a JSON string. +fn args_to_json(pairs: &[(String, String)]) -> String { + let mut out = String::from("{"); + for (i, (k, v)) in pairs.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push('"'); + out.push_str(&json_escape(k)); + out.push_str("\":\""); + out.push_str(&json_escape(v)); + out.push('"'); + } + out.push('}'); + out +} + +/// Escape a string for inclusion in a JSON string literal. +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c => out.push(c), + } + } + out +} + +/// POST `body` to the HTTP MCP server on `127.0.0.1:port` and return +/// the response body (the JSON-RPC reply). +fn post(port: u16, body: &str) -> Result { + let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| { + format!( + "cannot reach the editor on 127.0.0.1:{port}: {e}\n\ + start it with: op-host-desktop --mcp-http {port} .op" + ) + })?; + let request = format!( + "POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(request.as_bytes()) + .map_err(|e| format!("http write: {e}"))?; + stream.flush().ok(); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .map_err(|e| format!("http read: {e}"))?; + // Strip the HTTP head — everything past the blank line is the + // JSON-RPC reply. + Ok(match response.split_once("\r\n\r\n") { + Some((_, body)) => body.trim().to_string(), + None => response.trim().to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_escape_handles_quotes_backslash_control() { + assert_eq!(json_escape(r#"a"b\c"#), r#"a\"b\\c"#); + assert_eq!(json_escape("line\nbreak"), "line\\nbreak"); + assert_eq!(json_escape("tab\there"), "tab\\there"); + assert_eq!(json_escape("\u{0001}"), "\\u0001"); + } + + #[test] + fn args_to_json_builds_string_valued_object() { + assert_eq!(args_to_json(&[]), "{}"); + let pairs = vec![ + ("kind".to_string(), "rect".to_string()), + ("x".to_string(), "10".to_string()), + ]; + assert_eq!(args_to_json(&pairs), r#"{"kind":"rect","x":"10"}"#); + } + + #[test] + fn args_to_json_escapes_values() { + let pairs = vec![("name".to_string(), r#"a"b"#.to_string())]; + assert_eq!(args_to_json(&pairs), r#"{"name":"a\"b"}"#); + } + + #[test] + fn tool_call_body_wraps_name_and_arguments() { + let body = tool_call_body("insert_node", r#"{"kind":"rect"}"#); + assert!(body.contains(r#""method":"tools/call""#)); + assert!(body.contains(r#""name":"insert_node""#)); + assert!(body.contains(r#""arguments":{"kind":"rect"}"#)); + } + + #[test] + fn tools_list_body_is_a_tools_list_request() { + assert!(tools_list_body().contains(r#""method":"tools/list""#)); + } + + #[test] + fn parse_args_defaults_port_and_reads_tool_call() { + let args = vec![ + "insert_node".to_string(), + "kind=rect".to_string(), + "x=10".to_string(), + ]; + let p = parse_args(&args).expect("parse"); + assert_eq!(p.port, DEFAULT_PORT); + match p.command { + Command::ToolCall { tool, args } => { + assert_eq!(tool, "insert_node"); + assert_eq!(args.len(), 2); + assert_eq!(args[0], ("kind".to_string(), "rect".to_string())); + } + _ => panic!("expected ToolCall"), + } + } + + #[test] + fn parse_args_reads_explicit_port_anywhere() { + let args = vec![ + "--port".to_string(), + "9001".to_string(), + "tools".to_string(), + ]; + let p = parse_args(&args).expect("parse"); + assert_eq!(p.port, 9001); + assert!(matches!(p.command, Command::ToolsList)); + } + + #[test] + fn parse_args_rejects_non_kv_argument() { + let args = vec!["insert_node".to_string(), "bogus".to_string()]; + assert!(parse_args(&args).is_err()); + } + + #[test] + fn parse_args_rejects_bad_port() { + let args = vec![ + "--port".to_string(), + "notnum".to_string(), + "tools".to_string(), + ]; + assert!(parse_args(&args).is_err()); + let missing = vec!["--port".to_string()]; + assert!(parse_args(&missing).is_err()); + } + + #[test] + fn parse_args_help_and_empty() { + assert!(matches!( + parse_args(&["help".to_string()]).unwrap().command, + Command::Help + )); + assert!(parse_args(&[]).is_err()); + } +} diff --git a/crates/op-editor-core/src/command.rs b/crates/op-editor-core/src/command.rs index 848d9b7a5..0a7946abe 100644 --- a/crates/op-editor-core/src/command.rs +++ b/crates/op-editor-core/src/command.rs @@ -246,4 +246,7 @@ pub enum EditorCommand { CutSelected, /// `Cmd+V` — paste the clipboard as top-level siblings. PasteClipboard { offset_px: i32 }, + /// Parse an SVG document + insert the resulting nodes on the + /// active page, offset by `(x, y)` doc-px. + ImportSvg { svg: String, x: i32, y: i32 }, } diff --git a/crates/op-editor-core/src/command_apply.rs b/crates/op-editor-core/src/command_apply.rs index f3cdd3344..5514a3c93 100644 --- a/crates/op-editor-core/src/command_apply.rs +++ b/crates/op-editor-core/src/command_apply.rs @@ -359,6 +359,14 @@ impl EditorState { self.history_push_past(snap); true } + EditorCommand::ImportSvg { svg, x, y } => { + let Some(mut next_id) = self.next_node_id_seed() else { + return false; + }; + // `import_svg` pushes its own history snapshot when it + // inserts ≥ 1 node. + self.import_svg(&mut next_id, &svg, (x as f64, y as f64)) > 0 + } // --- Tool + viewport + history ------------------------- EditorCommand::SetActiveTool { tool } => { diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index c148b12b5..834018481 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -32,6 +32,8 @@ pub mod rename; pub mod render_backend; pub mod selection; pub mod state; +pub mod svg_import; +pub mod svg_path_bounds; pub mod tool; pub mod ui_draft; pub mod variables; @@ -41,6 +43,8 @@ pub mod walkers; #[cfg(test)] mod command_tests; #[cfg(test)] +mod svg_import_tests; +#[cfg(test)] mod test_support; #[cfg(test)] mod tests_geometry; diff --git a/crates/op-editor-core/src/svg_import.rs b/crates/op-editor-core/src/svg_import.rs new file mode 100644 index 000000000..c96ebeed1 --- /dev/null +++ b/crates/op-editor-core/src/svg_import.rs @@ -0,0 +1,743 @@ +//! SVG import — parse an SVG document into canonical `PenNode`s and +//! insert them onto the active page. +//! +//! TS parity with `apps/web/src/.../svg-parser.ts`. v1 scope: +//! +//! - Shape elements: `` / `` / `` / `` / +//! `` / ``. +//! - `` — the `M L H V C S Q T Z` command subset (absolute + +//! relative). Cubic / quadratic curves keep their bezier handles +//! (`Q`/`T` are promoted to cubics); `A` (elliptical arc) degrades +//! to a straight segment to its endpoint. +//! - `fill` attribute — `#rgb` / `#rrggbb` + a small named-colour +//! table; `none` leaves the node unfilled. +//! +//! Out of scope for v1 (skipped, not an error): `` grouping, +//! `transform` attributes, CSS `