diff --git a/crates/executors/Cargo.toml b/crates/executors/Cargo.toml index d434b240..ada52d24 100644 --- a/crates/executors/Cargo.toml +++ b/crates/executors/Cargo.toml @@ -35,3 +35,4 @@ strum = "0.27.2" bon = "3.6" fork_stream = "0.1.0" os_pipe = "1.2" +strip-ansi-escapes = "0.2.1" diff --git a/crates/executors/src/command.rs b/crates/executors/src/command.rs index 28971e09..eaf752d7 100644 --- a/crates/executors/src/command.rs +++ b/crates/executors/src/command.rs @@ -130,6 +130,19 @@ impl AgentProfile { } } + pub fn cursor() -> Self { + Self { + label: "cursor".to_string(), + agent: BaseCodingAgent::Cursor, + command: CommandBuilder::new("cursor-agent").params(vec![ + "-p", + "--output-format=stream-json", + "--force", + ]), + mcp_config_path: None, + } + } + pub fn codex() -> Self { Self { label: "codex".to_string(), @@ -199,6 +212,7 @@ impl AgentProfiles { AgentProfile::codex(), AgentProfile::opencode(), AgentProfile::qwen_code(), + AgentProfile::cursor(), ], } } @@ -300,5 +314,10 @@ mod tests { let opencode_command = get_profile_command("opencode"); assert!(opencode_command.contains("npx -y opencode-ai@latest run")); assert!(opencode_command.contains("--print-logs")); + + let cursor_command = get_profile_command("cursor"); + assert!(cursor_command.contains("cursor-agent")); + assert!(cursor_command.contains("-p")); + assert!(cursor_command.contains("--output-format=stream-json")); } } diff --git a/crates/executors/src/executors/cursor.rs b/crates/executors/src/executors/cursor.rs new file mode 100644 index 00000000..ea55aa78 --- /dev/null +++ b/crates/executors/src/executors/cursor.rs @@ -0,0 +1,881 @@ +use core::str; +use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use command_group::{AsyncCommandGroup, AsyncGroupChild}; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio::{io::AsyncWriteExt, process::Command}; +use ts_rs::TS; +use utils::{msg_store::MsgStore, path::make_path_relative, shell::get_shell_command}; + +use crate::{ + command::{AgentProfiles, CommandBuilder}, + executors::{ExecutorError, StandardCodingAgentExecutor}, + logs::{ + ActionType, EditDiff, NormalizedEntry, NormalizedEntryType, + plain_text_processor::PlainTextLogProcessor, + utils::{ConversationPatch, EntryIndexProvider}, + }, +}; + +/// Executor for running Cursor CLI and normalizing its JSONL stream +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +pub struct Cursor { + command_builder: CommandBuilder, +} + +impl Default for Cursor { + fn default() -> Self { + Self::new() + } +} + +impl Cursor { + /// Create a new Cursor executor with default profile + pub fn new() -> Self { + let profile = AgentProfiles::get_cached() + .get_profile("cursor") + .expect("Default cursor profile should exist"); + + Self::with_command_builder(profile.command.clone()) + } + + /// Create a new Cursor executor with custom command builder + pub fn with_command_builder(command_builder: CommandBuilder) -> Self { + Self { command_builder } + } +} + +#[async_trait] +impl StandardCodingAgentExecutor for Cursor { + async fn spawn( + &self, + current_dir: &PathBuf, + prompt: &str, + ) -> Result { + let (shell_cmd, shell_arg) = get_shell_command(); + let agent_cmd = self.command_builder.build_initial(); + + let mut command = Command::new(shell_cmd); + command + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(current_dir) + .arg(shell_arg) + .arg(&agent_cmd); + + let mut child = command.group_spawn()?; + + if let Some(mut stdin) = child.inner().stdin.take() { + stdin.write_all(prompt.as_bytes()).await?; + stdin.shutdown().await?; + } + + Ok(child) + } + + async fn spawn_follow_up( + &self, + current_dir: &PathBuf, + prompt: &str, + session_id: &str, + ) -> Result { + let (shell_cmd, shell_arg) = get_shell_command(); + let agent_cmd = self + .command_builder + .build_follow_up(&["--resume".to_string(), session_id.to_string()]); + + let mut command = Command::new(shell_cmd); + command + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(current_dir) + .arg(shell_arg) + .arg(&agent_cmd); + + let mut child = command.group_spawn()?; + + if let Some(mut stdin) = child.inner().stdin.take() { + stdin.write_all(prompt.as_bytes()).await?; + stdin.shutdown().await?; + } + + Ok(child) + } + + fn normalize_logs(&self, msg_store: Arc, worktree_path: &PathBuf) { + let entry_index_provider = EntryIndexProvider::new(); + + // Process Cursor stdout JSONL with typed serde models + let current_dir = worktree_path.clone(); + tokio::spawn(async move { + let mut lines = msg_store.stdout_lines_stream(); + + // Cursor agent doesn't use STDERR. Everything comes through STDOUT, both JSONL and raw error output. + let mut error_plaintext_processor = PlainTextLogProcessor::builder() + .normalized_entry_producer(Box::new(|content: String| NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::ErrorMessage, + content, + metadata: None, + })) + .time_gap(Duration::from_secs(2)) // Break messages if they are 2 seconds apart + .index_provider(EntryIndexProvider::new()) + .build(); + + // Assistant streaming coalescer state + let mut model_reported = false; + let mut session_id_reported = false; + + let mut current_assistant_message_buffer = String::new(); + let mut current_assistant_message_index: Option = None; + + let worktree_str = current_dir.to_string_lossy().to_string(); + + while let Some(Ok(line)) = lines.next().await { + // Parse line as CursorJson + let cursor_json: CursorJson = match serde_json::from_str(&line) { + Ok(cursor_json) => cursor_json, + Err(_) => { + // Not valid JSON, treat as raw error output + let line = strip_ansi_escapes::strip_str(line); + let line = strip_cursor_ascii_art_banner(line); + if line.trim().is_empty() { + continue; // Skip empty lines after stripping Noise + } + + // Provide a useful sign-in message if needed + let line = if line == "Press any key to sign in..." { + "Please sign in to Cursor CLI using `cursor-agent login` or set the CURSOR_API_KEY environment variable.".to_string() + } else { + line + }; + + for patch in error_plaintext_processor.process(line + "\n") { + msg_store.push_patch(patch); + } + continue; + } + }; + + // Push session_id if present + if !session_id_reported && let Some(session_id) = cursor_json.extract_session_id() { + msg_store.push_session_id(session_id); + session_id_reported = true; + } + + let is_assistant_message = matches!(cursor_json, CursorJson::Assistant { .. }); + if !is_assistant_message && current_assistant_message_index.is_some() { + // flush + current_assistant_message_index = None; + current_assistant_message_buffer.clear(); + } + + match &cursor_json { + CursorJson::System { model, .. } => { + if !model_reported && let Some(model) = model.as_ref() { + let entry = NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::SystemMessage, + content: format!("System initialized with model: {model}"), + metadata: None, + }; + let id = entry_index_provider.next(); + msg_store + .push_patch(ConversationPatch::add_normalized_entry(id, entry)); + model_reported = true; + } + } + + CursorJson::User { message, .. } => { + if let Some(text) = message.concat_text() { + let entry = NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::UserMessage, + content: text, + metadata: None, + }; + let id = entry_index_provider.next(); + msg_store + .push_patch(ConversationPatch::add_normalized_entry(id, entry)); + } + } + + CursorJson::Assistant { message, .. } => { + if let Some(chunk) = message.concat_text() { + current_assistant_message_buffer.push_str(&chunk); + let replace_entry = NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::AssistantMessage, + content: current_assistant_message_buffer.clone(), + metadata: None, + }; + if let Some(id) = current_assistant_message_index { + msg_store.push_patch(ConversationPatch::replace(id, replace_entry)) + } else { + let id = entry_index_provider.next(); + current_assistant_message_index = Some(id); + msg_store.push_patch(ConversationPatch::add_normalized_entry( + id, + replace_entry, + )); + }; + } + } + + CursorJson::ToolCall { + subtype, tool_call, .. + } => { + // Only process "started" subtype (completed contains results we currently ignore) + if subtype + .as_deref() + .map(|s| s.eq_ignore_ascii_case("started")) + .unwrap_or(false) + { + let tool_name = tool_call.get_name().to_string(); + let (action_type, content) = + tool_call.to_action_and_content(&worktree_str); + + let entry = NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::ToolUse { + tool_name, + action_type, + }, + content, + metadata: None, + }; + let id = entry_index_provider.next(); + msg_store + .push_patch(ConversationPatch::add_normalized_entry(id, entry)); + } + } + + CursorJson::Result { .. } => { + // no-op; metadata-only events not surfaced + } + + CursorJson::Unknown => { + let entry = NormalizedEntry { + timestamp: None, + entry_type: NormalizedEntryType::SystemMessage, + content: format!("Raw output: `{line}`"), + metadata: None, + }; + let id = entry_index_provider.next(); + msg_store.push_patch(ConversationPatch::add_normalized_entry(id, entry)); + } + } + } + }); + } +} + +fn strip_cursor_ascii_art_banner(line: String) -> String { + static BANNER_LINES: std::sync::OnceLock> = std::sync::OnceLock::new(); + let banner_lines = BANNER_LINES.get_or_init(|| { + r#" +i":;; + [?+i~{??--wwwwwww;;;III + -_+]>{{{}}[[[mmmmmm_<_:;;I + r\\|||(()))))mmmm)1)111{?_ + t/\\\\\|||(|ZZZ||\\\/tf^ + ttttt/tZZfff^> + ^^^O>> + >>"# + .lines() + .map(str::to_string) + .collect() + }); + + for banner_line in banner_lines { + if line.starts_with(banner_line) { + return line.replacen(banner_line, "", 1).trim().to_string(); + } + } + line +} + +/* =========================== +Typed Cursor JSON structures +=========================== */ + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(tag = "type")] +pub enum CursorJson { + #[serde(rename = "system")] + System { + #[serde(default)] + subtype: Option, + #[serde(default, rename = "apiKeySource")] + api_key_source: Option, + #[serde(default)] + cwd: Option, + #[serde(default)] + session_id: Option, + #[serde(default)] + model: Option, + #[serde(default, rename = "permissionMode")] + permission_mode: Option, + }, + #[serde(rename = "user")] + User { + message: CursorMessage, + #[serde(default)] + session_id: Option, + }, + #[serde(rename = "assistant")] + Assistant { + message: CursorMessage, + #[serde(default)] + session_id: Option, + }, + #[serde(rename = "tool_call")] + ToolCall { + #[serde(default)] + subtype: Option, // "started" | "completed" + #[serde(default)] + call_id: Option, + tool_call: CursorToolCall, + #[serde(default)] + session_id: Option, + }, + #[serde(rename = "result")] + Result { + #[serde(default)] + subtype: Option, + #[serde(default)] + is_error: Option, + #[serde(default)] + duration_ms: Option, + #[serde(default)] + result: Option, + }, + #[serde(other)] + Unknown, +} + +impl CursorJson { + pub fn extract_session_id(&self) -> Option { + match self { + CursorJson::System { session_id, .. } => session_id.clone(), + CursorJson::User { session_id, .. } => session_id.clone(), + CursorJson::Assistant { session_id, .. } => session_id.clone(), + CursorJson::ToolCall { session_id, .. } => session_id.clone(), + CursorJson::Result { .. } => None, + CursorJson::Unknown => None, + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorMessage { + pub role: String, + pub content: Vec, +} + +impl CursorMessage { + pub fn concat_text(&self) -> Option { + let mut out = String::new(); + for CursorContentItem::Text { text } in &self.content { + out.push_str(text); + } + if out.is_empty() { None } else { Some(out) } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(tag = "type")] +pub enum CursorContentItem { + #[serde(rename = "text")] + Text { text: String }, +} + +/* =========================== +Tool call structure +=========================== */ + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub enum CursorToolCall { + #[serde(rename = "shellToolCall")] + Shell { + args: CursorShellArgs, + #[serde(default)] + result: Option, + }, + #[serde(rename = "lsToolCall")] + LS { + args: CursorLsArgs, + #[serde(default)] + result: Option, + }, + #[serde(rename = "globToolCall")] + Glob { + args: CursorGlobArgs, + #[serde(default)] + result: Option, + }, + #[serde(rename = "grepToolCall")] + Grep { + args: CursorGrepArgs, + #[serde(default)] + result: Option, + }, + #[serde(rename = "writeToolCall")] + Write { + args: CursorWriteArgs, + #[serde(default)] + result: Option, + }, + #[serde(rename = "readToolCall")] + Read { + args: CursorReadArgs, + #[serde(default)] + result: Option, + }, + #[serde(rename = "editToolCall")] + Edit { + args: CursorEditArgs, + #[serde(default)] + result: Option, + }, + #[serde(rename = "deleteToolCall")] + Delete { + args: CursorDeleteArgs, + #[serde(default)] + result: Option, + }, + #[serde(rename = "updateTodosToolCall")] + Todo { + args: CursorUpdateTodosArgs, + #[serde(default)] + result: Option, + }, + /// Generic fallback for unknown tools (amp.rs pattern) + #[serde(untagged)] + Unknown { + #[serde(flatten)] + data: std::collections::HashMap, + }, +} + +impl CursorToolCall { + pub fn get_name(&self) -> &str { + match self { + CursorToolCall::Shell { .. } => "shell", + CursorToolCall::LS { .. } => "ls", + CursorToolCall::Glob { .. } => "glob", + CursorToolCall::Grep { .. } => "grep", + CursorToolCall::Write { .. } => "write", + CursorToolCall::Read { .. } => "read", + CursorToolCall::Edit { .. } => "edit", + CursorToolCall::Delete { .. } => "delete", + CursorToolCall::Todo { .. } => "todo", + CursorToolCall::Unknown { data } => { + data.keys().next().map(|s| s.as_str()).unwrap_or("unknown") + } + } + } + + pub fn to_action_and_content(&self, worktree_path: &str) -> (ActionType, String) { + match self { + CursorToolCall::Read { args, .. } => { + let path = make_path_relative(&args.path, worktree_path); + ( + ActionType::FileRead { path: path.clone() }, + format!("`{path}`"), + ) + } + CursorToolCall::Write { args, .. } => { + let path = make_path_relative(&args.path, worktree_path); + ( + ActionType::FileEdit { + path: path.clone(), + diffs: vec![], + }, + format!("`{path}`"), + ) + } + CursorToolCall::Edit { args, .. } => { + let path = make_path_relative(&args.path, worktree_path); + let mut diffs = vec![]; + + if let Some(_apply_patch) = &args.apply_patch { + // todo: handle v4a + } + + if let Some(str_replace) = &args.str_replace { + diffs.push(EditDiff::Replace { + old: str_replace.old_text.clone(), + new: str_replace.new_text.clone(), + }); + } + + if let Some(multi_str_replace) = &args.multi_str_replace { + for edit in multi_str_replace.edits.iter() { + diffs.push(EditDiff::Replace { + old: edit.old_text.clone(), + new: edit.new_text.clone(), + }); + } + } + + ( + ActionType::FileEdit { + path: path.clone(), + diffs, + }, + format!("`{path}`"), + ) + } + CursorToolCall::Delete { args, .. } => { + let path = make_path_relative(&args.path, worktree_path); + ( + ActionType::FileEdit { + path: path.clone(), + diffs: vec![], + }, + format!("`{path}`"), + ) + } + CursorToolCall::Shell { args, .. } => { + let cmd = &args.command; + ( + ActionType::CommandRun { + command: cmd.clone(), + }, + format!("`{cmd}`"), + ) + } + CursorToolCall::Grep { args, .. } => { + let pattern = &args.pattern; + ( + ActionType::Search { + query: pattern.clone(), + }, + format!("`{pattern}`"), + ) + } + CursorToolCall::Glob { args, .. } => { + let pattern = args.glob_pattern.clone().unwrap_or_else(|| "*".to_string()); + if let Some(path) = args.path.as_ref().or(args.target_directory.as_ref()) { + let path = make_path_relative(path, worktree_path); + ( + ActionType::Search { + query: pattern.clone(), + }, + format!("Find files: `{pattern}` in `{path}`"), + ) + } else { + ( + ActionType::Search { + query: pattern.clone(), + }, + format!("Find files: `{pattern}`"), + ) + } + } + CursorToolCall::LS { args, .. } => { + let path = make_path_relative(&args.path, worktree_path); + let content = if path.is_empty() { + "List directory".to_string() + } else { + format!("List directory: `{path}`") + }; + ( + ActionType::Other { + description: "List directory".to_string(), + }, + content, + ) + } + CursorToolCall::Todo { args, .. } => { + let content = if let Some(todos) = args.todos.as_ref() { + format!("TODO List:\n{}", summarize_todos_typed(todos)) + } else { + "Managing TODO list".to_string() + }; + ( + ActionType::Other { + description: "Manage TODO list".to_string(), + }, + content, + ) + } + CursorToolCall::Unknown { .. } => ( + ActionType::Other { + description: format!("Tool: {}", self.get_name()), + }, + self.get_name().to_string(), + ), + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorShellArgs { + pub command: String, + #[serde(default, alias = "working_directory", alias = "workingDirectory")] + pub working_directory: Option, + #[serde(default)] + pub timeout: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorLsArgs { + pub path: String, + #[serde(default)] + pub ignore: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorGlobArgs { + #[serde(default, alias = "globPattern", alias = "glob_pattern")] + pub glob_pattern: Option, + #[serde(default)] + pub path: Option, + #[serde(default, alias = "target_directory")] + pub target_directory: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorGrepArgs { + pub pattern: String, + #[serde(default)] + pub path: Option, + #[serde(default, alias = "glob")] + pub glob_filter: Option, + #[serde(default, alias = "outputMode", alias = "output_mode")] + pub output_mode: Option, + #[serde(default, alias = "-i", alias = "caseInsensitive")] + pub case_insensitive: Option, + #[serde(default)] + pub multiline: Option, + #[serde(default, alias = "headLimit", alias = "head_limit")] + pub head_limit: Option, + #[serde(default)] + pub r#type: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorWriteArgs { + pub path: String, + #[serde( + default, + alias = "fileText", + alias = "file_text", + alias = "contents", + alias = "content" + )] + pub contents: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorReadArgs { + pub path: String, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorEditArgs { + pub path: String, + #[serde(default, rename = "applyPatch")] + pub apply_patch: Option, + #[serde(default, rename = "strReplace")] + pub str_replace: Option, + #[serde(default, rename = "multiStrReplace")] + pub multi_str_replace: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorApplyPatch { + #[serde(rename = "patchContent")] + pub patch_content: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorStrReplace { + #[serde(rename = "oldText")] + pub old_text: String, + #[serde(rename = "newText")] + pub new_text: String, + #[serde(default, rename = "replaceAll")] + pub replace_all: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorMultiStrReplace { + pub edits: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorMultiEditItem { + #[serde(rename = "oldText")] + pub old_text: String, + #[serde(rename = "newText")] + pub new_text: String, + #[serde(default, rename = "replaceAll")] + pub replace_all: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorDeleteArgs { + pub path: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorUpdateTodosArgs { + #[serde(default)] + pub todos: Option>, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct CursorTodoItem { + #[serde(default)] + pub id: Option, + pub content: String, + pub status: String, + #[serde(default, rename = "createdAt")] + pub created_at: Option, + #[serde(default, rename = "updatedAt")] + pub updated_at: Option, + #[serde(default)] + pub dependencies: Option>, +} + +/* =========================== +Helpers +=========================== */ + +fn summarize_todos_typed(items: &[CursorTodoItem]) -> String { + let mut out: Vec = Vec::new(); + for todo in items { + let content = todo.content.as_str(); + let status = todo.status.as_str(); + let emoji = if status.eq_ignore_ascii_case("completed") + || status.contains("COMPLETED") + || status.eq_ignore_ascii_case("done") + { + "✅" + } else if status.eq_ignore_ascii_case("in_progress") + || status.eq_ignore_ascii_case("in-progress") + || status.contains("IN_PROGRESS") + { + "🔄" + } else { + "⏳" + }; + out.push(format!("{emoji} {content}")); + } + if out.is_empty() { + "Managing TODO list".to_string() + } else { + out.join("\n") + } +} + +/* =========================== +Tests +=========================== */ + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use utils::msg_store::MsgStore; + + use super::*; + + #[tokio::test] + async fn test_cursor_streaming_patch_generation() { + // Avoid relying on feature flag in tests; construct with a dummy command + let executor = Cursor::with_command_builder(CommandBuilder::new("cursor-agent")); + let msg_store = Arc::new(MsgStore::new()); + let current_dir = std::path::PathBuf::from("/tmp/test-worktree"); + + // A minimal synthetic init + assistant micro-chunks (as Cursor would emit) + msg_store.push_stdout(format!( + "{}\n", + r#"{"type":"system","subtype":"init","session_id":"sess-123","model":"OpenAI GPT-5"}"# + )); + msg_store.push_stdout(format!( + "{}\n", + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}"# + )); + msg_store.push_stdout(format!( + "{}\n", + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":" world"}]}}"# + )); + msg_store.push_finished(); + + executor.normalize_logs(msg_store.clone(), ¤t_dir); + + tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + + // Verify patches were emitted (system init + assistant add/replace) + let history = msg_store.get_history(); + let patch_count = history + .iter() + .filter(|m| matches!(m, utils::log_msg::LogMsg::JsonPatch(_))) + .count(); + assert!( + patch_count >= 2, + "Expected at least 2 patches, got {}", + patch_count + ); + } + + #[test] + fn test_session_id_extraction_from_system_line() { + // Ensure we can parse and find session_id from a system JSON line + let system_line = r#"{"type":"system","subtype":"init","session_id":"abc-xyz","model":"Claude 4 Sonnet"}"#; + let parsed: CursorJson = serde_json::from_str(system_line).unwrap(); + assert_eq!(parsed.extract_session_id().as_deref(), Some("abc-xyz")); + } + + #[test] + fn test_cursor_tool_call_parsing() { + // Test known variant (from reference JSONL) + let shell_tool_json = r#"{"shellToolCall":{"args":{"command":"wc -l drill.md","workingDirectory":"","timeout":0}}}"#; + let parsed: CursorToolCall = serde_json::from_str(shell_tool_json).unwrap(); + + match parsed { + CursorToolCall::Shell { args, result } => { + assert_eq!(args.command, "wc -l drill.md"); + assert_eq!(args.working_directory, Some("".to_string())); + assert_eq!(args.timeout, Some(0)); + assert_eq!(result, None); + } + _ => panic!("Expected Shell variant"), + } + + // Test unknown variant (captures raw data) + let unknown_tool_json = + r#"{"unknownTool":{"args":{"someData":"value"},"result":{"status":"success"}}}"#; + let parsed: CursorToolCall = serde_json::from_str(unknown_tool_json).unwrap(); + + match parsed { + CursorToolCall::Unknown { data } => { + assert!(data.contains_key("unknownTool")); + let unknown_tool = &data["unknownTool"]; + assert_eq!(unknown_tool["args"]["someData"], "value"); + assert_eq!(unknown_tool["result"]["status"], "success"); + } + _ => panic!("Expected Unknown variant"), + } + } +} diff --git a/crates/executors/src/executors/mod.rs b/crates/executors/src/executors/mod.rs index c7154413..01d83938 100644 --- a/crates/executors/src/executors/mod.rs +++ b/crates/executors/src/executors/mod.rs @@ -12,12 +12,16 @@ use utils::msg_store::MsgStore; use crate::{ command::AgentProfiles, - executors::{amp::Amp, claude::ClaudeCode, codex::Codex, gemini::Gemini, opencode::Opencode}, + executors::{ + amp::Amp, claude::ClaudeCode, codex::Codex, cursor::Cursor, gemini::Gemini, + opencode::Opencode, + }, }; pub mod amp; pub mod claude; pub mod codex; +pub mod cursor; pub mod gemini; pub mod opencode; @@ -59,6 +63,7 @@ pub enum CodingAgent { Amp, Gemini, Codex, + Cursor, // ClaudeCodeRouter, Opencode, // Aider, @@ -77,6 +82,7 @@ impl CodingAgent { "amp" => Ok(CodingAgent::Amp(Amp::new())), "gemini" => Ok(CodingAgent::Gemini(Gemini::new())), "codex" => Ok(CodingAgent::Codex(Codex::new())), + "cursor" => Ok(CodingAgent::Cursor(Cursor::new())), "opencode" => Ok(CodingAgent::Opencode(Opencode::new())), _ => { // Try to load from AgentProfiles @@ -100,6 +106,9 @@ impl CodingAgent { BaseCodingAgent::Opencode => Ok(CodingAgent::Opencode( Opencode::with_command_builder(agent_profile.command.clone()), )), + BaseCodingAgent::Cursor => Ok(CodingAgent::Cursor( + Cursor::with_command_builder(agent_profile.command.clone()), + )), } } else { Err(ExecutorError::UnknownExecutorType(format!( @@ -124,6 +133,9 @@ impl BaseCodingAgent { Self::Gemini => Some(vec!["mcpServers"]), //ExecutorConfig::Aider => None, // Aider doesn't support MCP. https://github.com/Aider-AI/aider/issues/3314 Self::Codex => Some(vec!["mcp_servers"]), // Codex uses TOML with mcp_servers + // Cursor CLI is supposed to be compatible with MCP server config according to the docs: https://docs.cursor.com/en/cli/using#mcp + // But it still doesn't seem to support it properly: https://forum.cursor.com/t/cursor-cli-not-actually-an-mcp-client/127000/5 + Self::Cursor => Some(vec!["mcpServers"]), } } @@ -152,6 +164,7 @@ impl BaseCodingAgent { Self::Codex => dirs::home_dir().map(|home| home.join(".codex").join("config.toml")), Self::Amp => dirs::config_dir().map(|config| config.join("amp").join("settings.json")), Self::Gemini => dirs::home_dir().map(|home| home.join(".gemini").join("settings.json")), + Self::Cursor => dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")), } } } diff --git a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx index 5a6281d5..5ef183f1 100644 --- a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx +++ b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx @@ -49,7 +49,8 @@ const getEntryIcon = (entryType: NormalizedEntryType) => { (tool_name.toLowerCase() === 'todowrite' || tool_name.toLowerCase() === 'todoread' || tool_name.toLowerCase() === 'todo_write' || - tool_name.toLowerCase() === 'todo_read') + tool_name.toLowerCase() === 'todo_read' || + tool_name.toLowerCase() === 'todo') ) { return ; } @@ -95,7 +96,8 @@ const getContentClassName = (entryType: NormalizedEntryType) => { (entryType.tool_name.toLowerCase() === 'todowrite' || entryType.tool_name.toLowerCase() === 'todoread' || entryType.tool_name.toLowerCase() === 'todo_write' || - entryType.tool_name.toLowerCase() === 'todo_read') + entryType.tool_name.toLowerCase() === 'todo_read' || + entryType.tool_name.toLowerCase() === 'todo') ) { return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`; } @@ -116,32 +118,10 @@ const shouldRenderMarkdown = (entryType: NormalizedEntryType) => { // Render markdown for assistant messages, plan presentations, and tool outputs that contain backticks return ( entryType.type === 'assistant_message' || - (entryType.type === 'tool_use' && - entryType.action_type.action === 'plan_presentation') || - (entryType.type === 'tool_use' && - entryType.tool_name && - (entryType.tool_name.toLowerCase() === 'todowrite' || - entryType.tool_name.toLowerCase() === 'todoread' || - entryType.tool_name.toLowerCase() === 'todo_write' || - entryType.tool_name.toLowerCase() === 'todo_read' || - entryType.tool_name.toLowerCase() === 'glob' || - entryType.tool_name.toLowerCase() === 'ls' || - entryType.tool_name.toLowerCase() === 'list_directory' || - entryType.tool_name.toLowerCase() === 'read' || - entryType.tool_name.toLowerCase() === 'read_file' || - entryType.tool_name.toLowerCase() === 'write' || - entryType.tool_name.toLowerCase() === 'create_file' || - entryType.tool_name.toLowerCase() === 'edit' || - entryType.tool_name.toLowerCase() === 'edit_file' || - entryType.tool_name.toLowerCase() === 'multiedit' || - entryType.tool_name.toLowerCase() === 'bash' || - entryType.tool_name.toLowerCase() === 'run_command' || - entryType.tool_name.toLowerCase() === 'grep' || - entryType.tool_name.toLowerCase() === 'search' || - entryType.tool_name.toLowerCase() === 'webfetch' || - entryType.tool_name.toLowerCase() === 'web_fetch' || - entryType.tool_name.toLowerCase() === 'task' || - entryType.tool_name.toLowerCase().startsWith('mcp_'))) + entryType.type === 'system_message' || + entryType.type === 'user_message' || + entryType.type === 'thinking' || + entryType.type === 'tool_use' ); }; diff --git a/shared/types.ts b/shared/types.ts index b5183c64..fdbe7b26 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -78,7 +78,7 @@ export type FileDiffDetails = { fileName: string | null, content: string | null, export type RepositoryInfo = { id: bigint, name: string, full_name: string, owner: string, description: string | null, clone_url: string, ssh_url: string, default_branch: string, private: boolean, }; -export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", OPENCODE = "OPENCODE" } +export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", CURSOR = "CURSOR", OPENCODE = "OPENCODE" } export type CommandBuilder = { /**