forked from 1b-agent/opencadstudio-upstream
Add client-neutral native MCP control
This commit is contained in:
1 parent
28cc473062
commit
071e224d26
17 files changed
+804
-118
No files matched your search
@@ -19,13 +19,15 @@ jobs:
|
||||
run: cargo test --lib app::control::tests
|
||||
- name: Validate Python adapters
|
||||
if: runner.os != 'Windows'
|
||||
run: python3 -m py_compile docs/automation/live.py docs/automation/mcp_server.py
|
||||
run: python3 -m py_compile docs/automation/live.py docs/automation/mcp_smoke.py
|
||||
mcp:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 -m pip install -r docs/automation/requirements-mcp.txt
|
||||
- run: python3 docs/automation/mcp_smoke.py
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo test --lib mcp::tests
|
||||
- run: cargo build
|
||||
- run: python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
Generated
+1
@@ -8,6 +8,7 @@ version = "0.9.8"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"ashpd",
|
||||
"base64",
|
||||
"bincode",
|
||||
"bytemuck",
|
||||
"cadkernel",
|
||||
|
||||
@@ -18,6 +18,7 @@ winresource = "0.1"
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
base64 = "0.22"
|
||||
semver = "1"
|
||||
iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] }
|
||||
iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
||||
|
||||
@@ -174,15 +174,16 @@ trunk serve
|
||||
|
||||
## Automation
|
||||
|
||||
The desktop binary supports one-shot conversion and a persistent headless server:
|
||||
The desktop binary supports one-shot conversion, a persistent headless server, and a client-neutral MCP endpoint for AI applications:
|
||||
|
||||
```bash
|
||||
OpenCADStudio --export input.dwg output.dxf
|
||||
OpenCADStudio --serve
|
||||
OpenCADStudio --serve --port 4242
|
||||
OpenCADStudio --mcp
|
||||
```
|
||||
|
||||
The server exchanges one JSON object per line over standard input/output or a local TCP socket. See the [automation guide](docs/automation/README.md) and the included [Python client](docs/automation/ocs.py).
|
||||
The automation server exchanges one JSON object per line over standard input/output or a local TCP socket. The MCP endpoint exposes the live desktop editor through the same tools to every compatible client without an additional runtime. See the [automation guide](docs/automation/README.md) and the included [Python client](docs/automation/ocs.py).
|
||||
|
||||
## Plugins
|
||||
|
||||
|
||||
@@ -8,7 +8,22 @@ Operations include document state and activation, paged entity queries, layer/he
|
||||
|
||||
The Web build exposes `ocs_control_submit(json)` and `ocs_control_take(ticket)`. They enter the same bounded GUI queue and return the same semantic responses; browser code polls the ticket until a response is present.
|
||||
|
||||
Run `./docs/automation/install.sh` once. It installs the release binary and pinned MCP environment in `~/.local/share/ocs-control`, registers the `opencadstudio` stdio server in the user's Codex configuration, and leaves source-independent launch paths. The personal `opencadstudio-control` skill tells later Codex sessions how to use it safely.
|
||||
`OpenCADStudio --mcp` is the common local entry point for every MCP client. It is built into the application, uses stdio, launches the editor when needed, and has no Python or package-manager dependency. Tool definitions and safety instructions are served by MCP itself, so the drawing behavior is identical in every client.
|
||||
|
||||
Run `./docs/automation/install.sh` once for a source build. It installs the release binary in `~/.local/share/ocs-control` and automatically connects installed Codex and Claude Code CLIs. Both registrations point to the same binary. Re-run an individual connection later with:
|
||||
|
||||
```sh
|
||||
~/.local/share/ocs-control/connect.sh codex
|
||||
~/.local/share/ocs-control/connect.sh claude-code
|
||||
```
|
||||
|
||||
Any other local MCP client uses the same command and argument. `connect.sh config` prints the portable configuration object to paste into clients that do not provide a registration CLI:
|
||||
|
||||
```json
|
||||
{"mcpServers":{"opencadstudio":{"command":"/path/to/OpenCADStudio","args":["--mcp"]}}}
|
||||
```
|
||||
|
||||
Client-specific skills and extension manifests may improve discovery, but contain no drawing or protocol implementation. Adding another AI client therefore requires only registering this command; the OpenCADStudio executable and tools do not change.
|
||||
|
||||
The legacy client is unchanged:
|
||||
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
SERVER=${OCS_MCP_COMMAND:-"$ROOT/OpenCADStudio"}
|
||||
NAME=opencadstudio
|
||||
|
||||
require_server() {
|
||||
[ -x "$SERVER" ] || {
|
||||
printf 'OpenCADStudio MCP executable not found: %s\n' "$SERVER" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
connect_codex() {
|
||||
SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills
|
||||
SOURCE="$ROOT/integrations/codex/opencadstudio-control"
|
||||
if [ -f "$SOURCE/SKILL.md" ]; then
|
||||
mkdir -p "$SKILLS/opencadstudio-control/agents"
|
||||
cp "$SOURCE/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md"
|
||||
cp "$SOURCE/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml"
|
||||
fi
|
||||
codex mcp remove "$NAME" >/dev/null 2>&1 || true
|
||||
codex mcp add "$NAME" -- "$SERVER" --mcp
|
||||
printf 'Connected Codex to OpenCADStudio.\n'
|
||||
}
|
||||
|
||||
connect_claude_code() {
|
||||
claude mcp remove "$NAME" >/dev/null 2>&1 || true
|
||||
claude mcp add --transport stdio --scope user "$NAME" -- "$SERVER" --mcp
|
||||
printf 'Connected Claude Code to OpenCADStudio.\n'
|
||||
}
|
||||
|
||||
print_config() {
|
||||
ESCAPED=$(printf '%s' "$SERVER" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
printf '{"mcpServers":{"%s":{"command":"%s","args":["--mcp"]}}}\n' "$NAME" "$ESCAPED"
|
||||
}
|
||||
|
||||
require_server
|
||||
case ${1:-all} in
|
||||
all)
|
||||
FOUND=false
|
||||
if command -v codex >/dev/null 2>&1; then connect_codex; FOUND=true; fi
|
||||
if command -v claude >/dev/null 2>&1; then connect_claude_code; FOUND=true; fi
|
||||
if [ "$FOUND" = false ]; then
|
||||
printf 'No supported AI CLI was found. Use this configuration in any MCP client:\n'
|
||||
print_config
|
||||
fi
|
||||
;;
|
||||
codex)
|
||||
command -v codex >/dev/null 2>&1 || { printf 'Codex CLI is not installed.\n' >&2; exit 1; }
|
||||
connect_codex
|
||||
;;
|
||||
claude-code|claude)
|
||||
command -v claude >/dev/null 2>&1 || { printf 'Claude Code is not installed.\n' >&2; exit 1; }
|
||||
connect_claude_code
|
||||
;;
|
||||
config)
|
||||
print_config
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: %s [all|codex|claude-code|config]\n' "$0" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -8,13 +8,12 @@ cp "$ROOT/target/release/OpenCADStudio" "$DEST/OpenCADStudio.new"
|
||||
chmod 755 "$DEST/OpenCADStudio.new"
|
||||
mv -f "$DEST/OpenCADStudio.new" "$DEST/OpenCADStudio"
|
||||
cp "$ROOT/docs/automation/live.py" "$DEST/live.py"
|
||||
cp "$ROOT/docs/automation/mcp_server.py" "$DEST/mcp_server.py"
|
||||
uv venv "$DEST/venv"
|
||||
uv pip install --python "$DEST/venv/bin/python" -r "$ROOT/docs/automation/requirements-mcp.txt"
|
||||
SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills
|
||||
mkdir -p "$SKILLS/opencadstudio-control/agents"
|
||||
cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md"
|
||||
cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml"
|
||||
codex mcp remove opencadstudio >/dev/null 2>&1 || true
|
||||
codex mcp add opencadstudio -- "$DEST/venv/bin/python" "$DEST/mcp_server.py"
|
||||
printf 'Installed OpenCADStudio and its persistent Codex MCP connection.\n'
|
||||
cp "$ROOT/docs/automation/connect.sh" "$DEST/connect.sh"
|
||||
chmod 755 "$DEST/connect.sh"
|
||||
mkdir -p "$DEST/integrations/codex/opencadstudio-control/agents"
|
||||
cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$DEST/integrations/codex/opencadstudio-control/SKILL.md"
|
||||
cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$DEST/integrations/codex/opencadstudio-control/agents/openai.yaml"
|
||||
rm -f "$DEST/mcp_server.py"
|
||||
rm -rf "$DEST/venv"
|
||||
"$DEST/connect.sh" all
|
||||
printf 'Installed the client-neutral OpenCADStudio MCP runtime.\n'
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Local stdio MCP adapter for a running OpenCADStudio GUI."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from mcp.server.fastmcp import FastMCP, Image
|
||||
from live import LiveOcs, READ_OPS, launch, sessions
|
||||
|
||||
mcp = FastMCP("OpenCADStudio", instructions="Use ocs_sessions, then ocs_read(state/commands) before editing. All changes target the actual GUI document. Preserve session_id, document_id and revisions. waiting_input means the command or dialog still needs input. Poll running operations; never replay a mutation with a new request_id after a timeout. Geometry is handled by OCS and its kernel.")
|
||||
clients: dict[str, LiveOcs] = {}
|
||||
|
||||
|
||||
def client(session_id: str) -> LiveOcs:
|
||||
if session_id not in clients:
|
||||
clients[session_id] = LiveOcs(session_id)
|
||||
return clients[session_id]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ocs_sessions(launch_if_none: bool = True) -> list[dict[str, Any]]:
|
||||
"""List real OCS GUI sessions and documents. Launch installed OCS if none is running."""
|
||||
available = launch() if launch_if_none else sessions()
|
||||
return [s["state"] for s in available]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ocs_read(session_id: str, op: str = "state", parameters: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Read state, commands/actions, query (type/layer/offset/limit), entities, layers, header, properties, measure(handles), history, events(after), operation(request_id)."""
|
||||
if op not in READ_OPS:
|
||||
raise ValueError("Use ocs_execute for mutations")
|
||||
return client(session_id).request({**(parameters or {}), "op": op})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ocs_execute(session_id: str, request: dict[str, Any], wait_seconds: float = 30) -> dict[str, Any]:
|
||||
"""Execute one semantic action. op: new/open(path)/activate(document_id)/start(cmd)/run(cmd)/input(kind,text or point)/select(handles)/property(field,value)/action(name)/save(path)/undo/redo/cancel. Input kinds: text, token, point(space wcs/ucs/relative), entity or structure(handle,point), selection, enter. Supply document_id and revision from state. request_id is the idempotency key. Accepted/running is not completion. Query operation after a timeout."""
|
||||
return client(session_id).request(request, wait_seconds)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ocs_capture(session_id: str) -> Image:
|
||||
"""Capture the actual current OCS window as PNG for visual verification."""
|
||||
with tempfile.TemporaryDirectory(prefix="ocs-capture-") as directory:
|
||||
path = Path(directory) / "window.png"
|
||||
result = client(session_id).request({"op": "capture", "path": str(path)})
|
||||
if not result.get("ok") or result.get("status") != "completed":
|
||||
raise RuntimeError(str(result))
|
||||
return Image(data=path.read_bytes(), format="png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
@@ -1,19 +1,47 @@
|
||||
"""Protocol smoke test for the bundled MCP adapter."""
|
||||
import asyncio
|
||||
"""Protocol smoke test for the native client-neutral MCP endpoint."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
async def main() -> None:
|
||||
server = Path(__file__).with_name("mcp_server.py")
|
||||
params = StdioServerParameters(command=sys.executable, args=[str(server)])
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
tools = await session.list_tools()
|
||||
names = {tool.name for tool in tools.tools}
|
||||
assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names
|
||||
def main() -> None:
|
||||
server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve()
|
||||
process = subprocess.Popen(
|
||||
[str(server), "--mcp"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
assert process.stdin and process.stdout
|
||||
|
||||
def request(payload: dict) -> dict:
|
||||
process.stdin.write(json.dumps(payload) + "\n")
|
||||
process.stdin.flush()
|
||||
return json.loads(process.stdout.readline())
|
||||
|
||||
initialized = request({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}},
|
||||
})
|
||||
assert initialized["result"]["serverInfo"]["name"] == "OpenCADStudio"
|
||||
assert initialized["result"]["instructions"]
|
||||
process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
||||
process.stdin.flush()
|
||||
tools = request({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
||||
names = {tool["name"] for tool in tools["result"]["tools"]}
|
||||
assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names
|
||||
sessions = request({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "ocs_sessions", "arguments": {"launch_if_none": False}},
|
||||
})
|
||||
assert not sessions["result"].get("isError"), sessions
|
||||
process.stdin.close()
|
||||
assert process.wait(timeout=5) == 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
mcp==1.29.1
|
||||
+2
-2
@@ -1823,6 +1823,8 @@ impl OpenCADStudio {
|
||||
dyn_capturing,
|
||||
&self.history_content,
|
||||
self.win_size.1,
|
||||
self.control.enabled,
|
||||
self.control_busy(),
|
||||
);
|
||||
let center_stack: Element<'_, Message> = if thumbnail_capture_clean {
|
||||
workspace
|
||||
@@ -1987,8 +1989,6 @@ impl OpenCADStudio {
|
||||
self.quick_properties,
|
||||
tab.scene.selection_filter_active(),
|
||||
self.selection_cycling,
|
||||
self.control.enabled,
|
||||
self.control_busy(),
|
||||
&self.statusbar_config,
|
||||
status_menu_data,
|
||||
)
|
||||
|
||||
+6
-1
@@ -1,7 +1,8 @@
|
||||
//! Command-line interface.
|
||||
//!
|
||||
//! Parsing lives here; `main` interprets the result. Three run modes split out
|
||||
//! Parsing lives here; `main` interprets the result. Four run modes split out
|
||||
//! of the parsed args:
|
||||
//! - `--mcp` local stdio MCP server for AI clients
|
||||
//! - `--serve` headless JSON automation server (see `app::serve`)
|
||||
//! - `--export IN OUT` one-shot headless format conversion, then exit
|
||||
//! - otherwise launch the GUI editor, configured via [`GuiConfig`]
|
||||
@@ -59,6 +60,10 @@ pub struct Cli {
|
||||
#[arg(long)]
|
||||
pub serve: bool,
|
||||
|
||||
/// Expose the running desktop editor to AI clients over MCP stdio.
|
||||
#[arg(long)]
|
||||
pub mcp: bool,
|
||||
|
||||
/// TCP port for --serve (defaults to stdin/stdout).
|
||||
#[arg(long, value_name = "PORT")]
|
||||
pub port: Option<u16>,
|
||||
|
||||
@@ -10,6 +10,8 @@ pub mod i18n;
|
||||
pub mod io;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) mod network;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod mcp;
|
||||
pub mod modules;
|
||||
pub mod patreon;
|
||||
pub mod discussions;
|
||||
|
||||
+10
@@ -14,6 +14,8 @@ mod i18n;
|
||||
mod io;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod network;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mcp;
|
||||
mod modules;
|
||||
mod patreon;
|
||||
mod discussions;
|
||||
@@ -75,6 +77,14 @@ fn main() -> iced::Result {
|
||||
std::process::exit(if ok { 0 } else { 1 });
|
||||
}
|
||||
|
||||
// MCP is a client-neutral local entry point. It uses only stdin,
|
||||
// stdout and the authenticated GUI bridge, so it must run before any
|
||||
// logging or graphics setup can write to the protocol stream.
|
||||
if args.mcp {
|
||||
mcp::run();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then
|
||||
// surfaces wgpu / iced / winit diagnostics that are otherwise silent.
|
||||
if let Some(level) = &args.log {
|
||||
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
//! Client-neutral MCP adapter for the live desktop editor.
|
||||
//!
|
||||
//! `OpenCADStudio --mcp` speaks MCP over stdio. All drawing work is forwarded
|
||||
//! to the authenticated GUI control bridge; this module contains no geometry.
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{File, OpenOptions},
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::TcpStream,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
|
||||
const MAX_REQUEST: usize = 1_048_576;
|
||||
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
|
||||
const INSTRUCTIONS: &str = "Use ocs_sessions first; it opens OpenCADStudio when needed. Read state and commands before editing. Preserve session_id, document_id, revision, selection and request_id. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path.";
|
||||
const READ_OPS: &[&str] = &[
|
||||
"state",
|
||||
"hello",
|
||||
"query",
|
||||
"entities",
|
||||
"layers",
|
||||
"header",
|
||||
"properties",
|
||||
"measure",
|
||||
"history",
|
||||
"commands",
|
||||
"events",
|
||||
"operation",
|
||||
];
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
struct Descriptor {
|
||||
session_id: String,
|
||||
port: u16,
|
||||
token: String,
|
||||
}
|
||||
|
||||
struct GuiClient {
|
||||
descriptor: Descriptor,
|
||||
state: Value,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
fn random_id() -> Result<String, String> {
|
||||
let mut bytes = [0u8; 16];
|
||||
getrandom::fill(&mut bytes).map_err(|error| error.to_string())?;
|
||||
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn private_descriptor(path: &Path) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let Ok(file) = path.metadata() else {
|
||||
return false;
|
||||
};
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(directory) = parent.metadata() else {
|
||||
return false;
|
||||
};
|
||||
file.uid() == directory.uid() && file.mode() & 0o077 == 0
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn private_descriptor(_: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn exchange(descriptor: &Descriptor, request: Value, timeout: Duration) -> Result<Value, String> {
|
||||
let mut object = request
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| "GUI request must be an object".to_string())?;
|
||||
object.insert("token".into(), Value::String(descriptor.token.clone()));
|
||||
object.insert(
|
||||
"session_id".into(),
|
||||
Value::String(descriptor.session_id.clone()),
|
||||
);
|
||||
object.insert("protocol".into(), Value::from(1));
|
||||
let mut wire = serde_json::to_vec(&Value::Object(object)).map_err(|e| e.to_string())?;
|
||||
wire.push(b'\n');
|
||||
if wire.len() > MAX_REQUEST {
|
||||
return Err("Request exceeds 1 MiB".into());
|
||||
}
|
||||
|
||||
let mut stream =
|
||||
TcpStream::connect(("127.0.0.1", descriptor.port)).map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|error| error.to_string())?;
|
||||
stream
|
||||
.set_write_timeout(Some(timeout))
|
||||
.map_err(|error| error.to_string())?;
|
||||
stream.write_all(&wire).map_err(|error| error.to_string())?;
|
||||
|
||||
let mut response = String::new();
|
||||
BufReader::new(stream)
|
||||
.take(MAX_RESPONSE + 1)
|
||||
.read_to_string(&mut response)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if response.is_empty() || response.len() as u64 > MAX_RESPONSE {
|
||||
return Err("No valid OCS response; query request_id before retrying a mutation".into());
|
||||
}
|
||||
serde_json::from_str(response.trim_end()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn descriptors() -> Result<Vec<(Descriptor, Value)>, String> {
|
||||
let directory = crate::config::config_dir()
|
||||
.ok_or_else(|| "No user configuration directory".to_string())?
|
||||
.join("automation");
|
||||
let Ok(entries) = directory.read_dir() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut paths: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
|
||||
.collect();
|
||||
paths.sort();
|
||||
|
||||
let mut found = Vec::new();
|
||||
for path in paths {
|
||||
if !private_descriptor(&path) {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(descriptor) = serde_json::from_str::<Descriptor>(&text) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(state) = exchange(&descriptor, json!({"op":"hello"}), Duration::from_secs(1)) else {
|
||||
continue;
|
||||
};
|
||||
if state["ok"].as_bool() == Some(true)
|
||||
&& state["session_id"].as_str() == Some(descriptor.session_id.as_str())
|
||||
{
|
||||
found.push((descriptor, state));
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn log_file() -> Result<File, String> {
|
||||
let directory = crate::config::config_dir()
|
||||
.ok_or_else(|| "No user configuration directory".to_string())?
|
||||
.join("automation");
|
||||
std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(directory.join("gui.log"))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn start_gui() -> Result<Child, String> {
|
||||
let executable = std::env::current_exe().map_err(|error| error.to_string())?;
|
||||
let log = log_file()?;
|
||||
let stderr = log.try_clone().map_err(|error| error.to_string())?;
|
||||
Command::new(executable)
|
||||
.arg("--new-instance")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(log))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn sessions(launch_if_none: bool) -> Result<Vec<Value>, String> {
|
||||
let mut available = descriptors()?;
|
||||
if available.is_empty() && launch_if_none {
|
||||
let mut child = start_gui()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
|
||||
return Err(format!("OpenCADStudio exited while starting ({status})"));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
available = descriptors()?;
|
||||
if !available.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if available.is_empty() {
|
||||
return Err("OpenCADStudio is still starting; call ocs_sessions again".into());
|
||||
}
|
||||
}
|
||||
Ok(available.into_iter().map(|(_, state)| state).collect())
|
||||
}
|
||||
|
||||
fn insert_default(object: &mut Map<String, Value>, key: &str, value: Value) {
|
||||
if !object.contains_key(key) {
|
||||
object.insert(key.into(), value);
|
||||
}
|
||||
}
|
||||
|
||||
impl GuiClient {
|
||||
fn connect(session_id: &str) -> Result<Self, String> {
|
||||
let mut matching: Vec<_> = descriptors()?
|
||||
.into_iter()
|
||||
.filter(|(descriptor, _)| descriptor.session_id == session_id)
|
||||
.collect();
|
||||
if matching.len() != 1 {
|
||||
return Err(format!(
|
||||
"Choose session_id from ocs_sessions; found {} matching sessions",
|
||||
matching.len()
|
||||
));
|
||||
}
|
||||
let (descriptor, state) = matching.remove(0);
|
||||
Ok(Self {
|
||||
descriptor,
|
||||
state,
|
||||
client_id: random_id()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn request(&mut self, request: Value, wait_seconds: f64) -> Result<Value, String> {
|
||||
let mut object = request
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| "request must be an object".to_string())?;
|
||||
let op = object
|
||||
.get("op")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "request must contain op".to_string())?
|
||||
.to_string();
|
||||
if !READ_OPS.contains(&op.as_str()) {
|
||||
insert_default(&mut object, "request_id", Value::String(random_id()?));
|
||||
insert_default(
|
||||
&mut object,
|
||||
"client_id",
|
||||
Value::String(self.client_id.clone()),
|
||||
);
|
||||
insert_default(
|
||||
&mut object,
|
||||
"document_id",
|
||||
self.state["document_id"].clone(),
|
||||
);
|
||||
insert_default(&mut object, "revision", self.state["revision"].clone());
|
||||
if ["input", "property", "run", "action", "save", "undo", "redo"].contains(&op.as_str())
|
||||
{
|
||||
insert_default(&mut object, "selection", self.state["selection"].clone());
|
||||
}
|
||||
}
|
||||
|
||||
let request_id = object.get("request_id").cloned();
|
||||
let mut response = exchange(
|
||||
&self.descriptor,
|
||||
Value::Object(object),
|
||||
Duration::from_secs(15),
|
||||
)?;
|
||||
let wait = wait_seconds.clamp(0.0, 60.0);
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(wait);
|
||||
while matches!(response["status"].as_str(), Some("accepted" | "running"))
|
||||
&& Instant::now() < deadline
|
||||
{
|
||||
let Some(request_id) = request_id.clone() else {
|
||||
break;
|
||||
};
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
response = exchange(
|
||||
&self.descriptor,
|
||||
json!({"op":"operation","request_id":request_id}),
|
||||
Duration::from_secs(15),
|
||||
)?;
|
||||
}
|
||||
if response.get("state").is_some() {
|
||||
self.state = response["state"].clone();
|
||||
} else if matches!(op.as_str(), "hello" | "state") && response["ok"].as_bool() == Some(true)
|
||||
{
|
||||
self.state = response.clone();
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn client<'a>(
|
||||
clients: &'a mut HashMap<String, GuiClient>,
|
||||
session_id: &str,
|
||||
) -> Result<&'a mut GuiClient, String> {
|
||||
if !clients.contains_key(session_id) {
|
||||
clients.insert(session_id.into(), GuiClient::connect(session_id)?);
|
||||
}
|
||||
Ok(clients.get_mut(session_id).expect("client inserted"))
|
||||
}
|
||||
|
||||
fn required_string<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, String> {
|
||||
arguments[key]
|
||||
.as_str()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| format!("Missing {key}"))
|
||||
}
|
||||
|
||||
fn call_tool(
|
||||
name: &str,
|
||||
arguments: &Value,
|
||||
clients: &mut HashMap<String, GuiClient>,
|
||||
) -> Result<Value, String> {
|
||||
match name {
|
||||
"ocs_sessions" => {
|
||||
let launch = arguments["launch_if_none"].as_bool().unwrap_or(true);
|
||||
Ok(Value::Array(sessions(launch)?))
|
||||
}
|
||||
"ocs_read" => {
|
||||
let session_id = required_string(arguments, "session_id")?;
|
||||
let op = arguments["op"].as_str().unwrap_or("state");
|
||||
if !READ_OPS.contains(&op) {
|
||||
return Err("Use ocs_execute for mutations".into());
|
||||
}
|
||||
let mut request = arguments["parameters"]
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
request.insert("op".into(), Value::String(op.into()));
|
||||
client(clients, session_id)?.request(Value::Object(request), 30.0)
|
||||
}
|
||||
"ocs_execute" => {
|
||||
let session_id = required_string(arguments, "session_id")?;
|
||||
let request = arguments["request"]
|
||||
.as_object()
|
||||
.cloned()
|
||||
.map(Value::Object)
|
||||
.ok_or_else(|| "Missing request object".to_string())?;
|
||||
let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0);
|
||||
client(clients, session_id)?.request(request, wait)
|
||||
}
|
||||
"ocs_capture" => {
|
||||
let session_id = required_string(arguments, "session_id")?;
|
||||
let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?));
|
||||
let result = client(clients, session_id)?
|
||||
.request(json!({"op":"capture","path":path.to_string_lossy()}), 30.0)?;
|
||||
if result["ok"].as_bool() != Some(true)
|
||||
|| result["status"].as_str() != Some("completed")
|
||||
{
|
||||
return Err(result.to_string());
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|error| error.to_string())?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
Ok(json!({"$image":BASE64.encode(bytes)}))
|
||||
}
|
||||
_ => Err(format!("Unknown tool: {name}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_definitions() -> Value {
|
||||
json!([
|
||||
{
|
||||
"name":"ocs_sessions",
|
||||
"description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.",
|
||||
"inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true}},"additionalProperties":false},
|
||||
"annotations":{"title":"List OCS sessions","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
||||
},
|
||||
{
|
||||
"name":"ocs_read",
|
||||
"description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session.",
|
||||
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object"}},"required":["session_id"],"additionalProperties":false},
|
||||
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
||||
},
|
||||
{
|
||||
"name":"ocs_execute",
|
||||
"description":"Execute one semantic OCS action. Use state document_id and revision. request_id is the idempotency key. Accepted, running and waiting_input are not completion.",
|
||||
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"},"request":{"type":"object"},"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30}},"required":["session_id","request"],"additionalProperties":false},
|
||||
"annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}
|
||||
},
|
||||
{
|
||||
"name":"ocs_capture",
|
||||
"description":"Capture the actual current OpenCADStudio window as PNG for visual verification.",
|
||||
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false},
|
||||
"annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
fn tool_result(value: Value) -> Value {
|
||||
if let Some(image) = value.get("$image").and_then(Value::as_str) {
|
||||
return json!({"content":[{"type":"image","data":image,"mimeType":"image/png"}]});
|
||||
}
|
||||
let structured = if value.is_object() {
|
||||
value.clone()
|
||||
} else {
|
||||
json!({"result":value.clone()})
|
||||
};
|
||||
json!({
|
||||
"content":[{"type":"text","text":value.to_string()}],
|
||||
"structuredContent":structured
|
||||
})
|
||||
}
|
||||
|
||||
fn error_result(message: impl ToString) -> Value {
|
||||
json!({"content":[{"type":"text","text":message.to_string()}],"isError":true})
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
json!({"jsonrpc":"2.0","id":id,"result":result})
|
||||
}
|
||||
|
||||
fn server_info() -> Value {
|
||||
json!({"name":"OpenCADStudio","title":"Open CAD Studio","version":env!("CARGO_PKG_VERSION")})
|
||||
}
|
||||
|
||||
fn modern_request(params: &Value) -> bool {
|
||||
params["_meta"]["io.modelcontextprotocol/protocolVersion"].as_str()
|
||||
== Some(MODERN_PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
fn protocol_result(mut result: Value, modern: bool) -> Value {
|
||||
if modern {
|
||||
let object = result
|
||||
.as_object_mut()
|
||||
.expect("MCP results are JSON objects");
|
||||
object
|
||||
.entry("resultType")
|
||||
.or_insert_with(|| Value::String("complete".into()));
|
||||
object.insert(
|
||||
"_meta".into(),
|
||||
json!({"io.modelcontextprotocol/serverInfo":server_info()}),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn rpc_error(id: Value, code: i64, message: impl ToString) -> Value {
|
||||
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message.to_string()}})
|
||||
}
|
||||
|
||||
fn handle_message(message: Value, clients: &mut HashMap<String, GuiClient>) -> Option<Value> {
|
||||
let id = message.get("id").cloned();
|
||||
let method = message.get("method").and_then(Value::as_str)?;
|
||||
if id.is_none() {
|
||||
return None;
|
||||
}
|
||||
let id = id.unwrap();
|
||||
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let modern = modern_request(¶ms);
|
||||
Some(match method {
|
||||
"initialize" => {
|
||||
let requested = params["protocolVersion"]
|
||||
.as_str()
|
||||
.unwrap_or(PROTOCOL_VERSION);
|
||||
let protocol =
|
||||
if ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"].contains(&requested) {
|
||||
requested
|
||||
} else {
|
||||
PROTOCOL_VERSION
|
||||
};
|
||||
response(
|
||||
id,
|
||||
json!({
|
||||
"protocolVersion":protocol,
|
||||
"capabilities":{"tools":{"listChanged":false}},
|
||||
"serverInfo":server_info(),
|
||||
"instructions":INSTRUCTIONS
|
||||
}),
|
||||
)
|
||||
}
|
||||
"server/discover" => response(
|
||||
id,
|
||||
protocol_result(
|
||||
json!({
|
||||
"supportedVersions":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION],
|
||||
"capabilities":{"tools":{}},
|
||||
"instructions":INSTRUCTIONS
|
||||
}),
|
||||
true,
|
||||
),
|
||||
),
|
||||
"ping" => response(id, protocol_result(json!({}), modern)),
|
||||
"tools/list" => response(
|
||||
id,
|
||||
protocol_result(json!({"tools":tool_definitions()}), modern),
|
||||
),
|
||||
"tools/call" => {
|
||||
let Some(name) = params["name"].as_str() else {
|
||||
return Some(rpc_error(id, -32602, "Missing tool name"));
|
||||
};
|
||||
let arguments = params
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let result = call_tool(name, &arguments, clients)
|
||||
.map(tool_result)
|
||||
.unwrap_or_else(error_result);
|
||||
response(id, protocol_result(result, modern))
|
||||
}
|
||||
_ => rpc_error(id, -32601, format!("Method not found: {method}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the MCP stdio loop until the client closes stdin.
|
||||
pub fn run() {
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
let mut output = stdout.lock();
|
||||
let mut clients = HashMap::new();
|
||||
for line in stdin.lock().lines() {
|
||||
let response = match line {
|
||||
Ok(line) if !line.trim().is_empty() => match serde_json::from_str::<Value>(&line) {
|
||||
Ok(message) => handle_message(message, &mut clients),
|
||||
Err(error) => Some(rpc_error(Value::Null, -32700, error)),
|
||||
},
|
||||
Ok(_) => None,
|
||||
Err(error) => {
|
||||
eprintln!("MCP input error: {error}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
if let Some(response) = response {
|
||||
if writeln!(output, "{response}")
|
||||
.and_then(|_| output.flush())
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn advertises_the_shared_tools() {
|
||||
let tools = tool_definitions();
|
||||
let names: Vec<_> = tools
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|tool| tool["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiates_and_lists_tools() {
|
||||
let mut clients = HashMap::new();
|
||||
let initialized = handle_message(
|
||||
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}),
|
||||
&mut clients,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25");
|
||||
assert!(initialized["result"]["instructions"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("geometry kernel"));
|
||||
|
||||
let listed = handle_message(
|
||||
json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
|
||||
&mut clients,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_modern_stateless_discovery() {
|
||||
let mut clients = HashMap::new();
|
||||
let discovered = handle_message(
|
||||
json!({"jsonrpc":"2.0","id":"discover","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}),
|
||||
&mut clients,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(discovered["result"]["resultType"], "complete");
|
||||
assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28");
|
||||
assert_eq!(
|
||||
discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
|
||||
"OpenCADStudio"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_tool_rejects_mutations_before_connecting() {
|
||||
let mut clients = HashMap::new();
|
||||
let called = handle_message(
|
||||
json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"session_id":"missing","op":"save"}}}),
|
||||
&mut clients,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(called["result"]["isError"], true);
|
||||
}
|
||||
}
|
||||
+47
-2
@@ -42,6 +42,25 @@ fn cmd_input_id() -> iced::widget::Id {
|
||||
iced::widget::Id::new(CMD_INPUT_ID)
|
||||
}
|
||||
|
||||
fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) {
|
||||
if !enabled {
|
||||
(
|
||||
"MCP control is off",
|
||||
Color::from_rgb(0.90, 0.35, 0.35),
|
||||
)
|
||||
} else if busy {
|
||||
(
|
||||
"MCP is handling a request",
|
||||
Color::from_rgb(0.95, 0.72, 0.25),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"MCP control is ready",
|
||||
Color::from_rgb(0.35, 0.85, 0.55),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a prompt's "[A / B / …]" option listing — used for the pinned line
|
||||
/// when the same options render as clickable buttons beside it. Collapses the
|
||||
/// leftover double spaces and the orphaned " :" the removal leaves behind.
|
||||
@@ -506,6 +525,8 @@ impl CommandLine {
|
||||
dyn_capturing: bool,
|
||||
history_content: &'a text_editor::Content,
|
||||
window_height: f32,
|
||||
control_enabled: bool,
|
||||
control_busy: bool,
|
||||
) -> Element<'a, Message> {
|
||||
// Only the most recent entries pushed within the last few
|
||||
// seconds show on the overlay. The dropdown button keeps the
|
||||
@@ -715,7 +736,24 @@ impl CommandLine {
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
]
|
||||
.width(Length::Fill);
|
||||
let input_row = row![prompt, literal_btn, input_with_history]
|
||||
let (mcp_tooltip, mcp_color) = mcp_status(control_enabled, control_busy);
|
||||
let mcp_btn = button(text("MCP").size(11))
|
||||
.on_press(Message::ControlToggle)
|
||||
.style(move |theme: &Theme, status| {
|
||||
let mut style = button::subtle(theme, status);
|
||||
style.background = Some(Background::Color(mcp_color));
|
||||
style.text_color = Color::from_rgb(0.08, 0.08, 0.10);
|
||||
style.border.color = mcp_color;
|
||||
style.border.width = 1.0;
|
||||
style
|
||||
})
|
||||
.padding([2, 8])
|
||||
.width(Length::Fixed(50.0));
|
||||
let mcp_tip = container(text(mcp_tooltip).size(11))
|
||||
.padding([3, 6])
|
||||
.style(container::bordered_box);
|
||||
let mcp_btn = tooltip(mcp_btn, mcp_tip, tooltip::Position::Top).gap(4);
|
||||
let input_row = row![prompt, literal_btn, input_with_history, mcp_btn]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
|
||||
@@ -975,7 +1013,7 @@ fn history_highlight_format(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ranked_matches, CommandLine};
|
||||
use super::{mcp_status, ranked_matches, CommandLine};
|
||||
use crate::t;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
@@ -986,6 +1024,13 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_status_distinguishes_off_ready_and_busy() {
|
||||
assert_eq!(mcp_status(false, false).0, "MCP control is off");
|
||||
assert_eq!(mcp_status(true, false).0, "MCP control is ready");
|
||||
assert_eq!(mcp_status(true, true).0, "MCP is handling a request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_plugin_commands_surface_in_autocomplete() {
|
||||
let none = FxHashMap::default();
|
||||
|
||||
@@ -122,8 +122,6 @@ impl StatusBar {
|
||||
selection_filter_active: bool,
|
||||
// Whether selection cycling is enabled.
|
||||
selection_cycling: bool,
|
||||
control_enabled: bool,
|
||||
control_busy: bool,
|
||||
// Which pills the user has chosen to show on the bar.
|
||||
config: &'a StatusBarConfig,
|
||||
menu_data: StatusMenuData<'a>,
|
||||
@@ -495,35 +493,6 @@ impl StatusBar {
|
||||
// use the remaining space on the final tab row when they fit; otherwise
|
||||
// WrapBar adds another right-aligned row.
|
||||
let mut left: Vec<Element<'_, Message>> = Vec::new();
|
||||
let control_label = if !control_enabled {
|
||||
"Control: off"
|
||||
} else if control_busy {
|
||||
"Control: busy"
|
||||
} else {
|
||||
"Control: ready"
|
||||
};
|
||||
let control_color = if !control_enabled {
|
||||
Color::from_rgb(0.90, 0.35, 0.35)
|
||||
} else if control_busy {
|
||||
Color::from_rgb(0.95, 0.72, 0.25)
|
||||
} else {
|
||||
Color::from_rgb(0.35, 0.85, 0.55)
|
||||
};
|
||||
left.push(
|
||||
button(text(control_label).size(11))
|
||||
.on_press(Message::ControlToggle)
|
||||
.style(move |theme: &Theme, status| {
|
||||
let mut style = button::subtle(theme, status);
|
||||
style.background = Some(Background::Color(control_color));
|
||||
style.text_color = Color::from_rgb(0.08, 0.08, 0.10);
|
||||
style.border.color = control_color;
|
||||
style.border.width = 1.0;
|
||||
style
|
||||
})
|
||||
.padding([4, 8])
|
||||
.width(Length::Fixed(100.0))
|
||||
.into(),
|
||||
);
|
||||
left.push(menu_btn);
|
||||
if show_layout_tabs {
|
||||
let reorderable_layouts: Arc<[String]> = reorderable_layouts.into();
|
||||
|
||||
Reference in new issue
Block a user