Cursor CLI (#457)
This commit is contained in:
@@ -35,3 +35,4 @@ strum = "0.27.2"
|
|||||||
bon = "3.6"
|
bon = "3.6"
|
||||||
fork_stream = "0.1.0"
|
fork_stream = "0.1.0"
|
||||||
os_pipe = "1.2"
|
os_pipe = "1.2"
|
||||||
|
strip-ansi-escapes = "0.2.1"
|
||||||
|
|||||||
@@ -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 {
|
pub fn codex() -> Self {
|
||||||
Self {
|
Self {
|
||||||
label: "codex".to_string(),
|
label: "codex".to_string(),
|
||||||
@@ -199,6 +212,7 @@ impl AgentProfiles {
|
|||||||
AgentProfile::codex(),
|
AgentProfile::codex(),
|
||||||
AgentProfile::opencode(),
|
AgentProfile::opencode(),
|
||||||
AgentProfile::qwen_code(),
|
AgentProfile::qwen_code(),
|
||||||
|
AgentProfile::cursor(),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,5 +314,10 @@ mod tests {
|
|||||||
let opencode_command = get_profile_command("opencode");
|
let opencode_command = get_profile_command("opencode");
|
||||||
assert!(opencode_command.contains("npx -y opencode-ai@latest run"));
|
assert!(opencode_command.contains("npx -y opencode-ai@latest run"));
|
||||||
assert!(opencode_command.contains("--print-logs"));
|
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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
881
crates/executors/src/executors/cursor.rs
Normal file
881
crates/executors/src/executors/cursor.rs
Normal file
@@ -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<AsyncGroupChild, ExecutorError> {
|
||||||
|
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<AsyncGroupChild, ExecutorError> {
|
||||||
|
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<MsgStore>, 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<usize> = 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<Vec<String>> = std::sync::OnceLock::new();
|
||||||
|
let banner_lines = BANNER_LINES.get_or_init(|| {
|
||||||
|
r#" +i":;;
|
||||||
|
[?+<l,",::;;;I
|
||||||
|
{[]_~iI"":::;;;;II
|
||||||
|
)){↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗↗ll … Cursor Agent
|
||||||
|
11{[#M##M##M#########*ppll
|
||||||
|
11}[]-+############oppqqIl
|
||||||
|
1}[]_+<il;,####bpqqqqwIIII
|
||||||
|
[]?_~<illi_++qqwwwwww;IIII
|
||||||
|
]?-+~>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<String>,
|
||||||
|
#[serde(default, rename = "apiKeySource")]
|
||||||
|
api_key_source: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
cwd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
|
#[serde(default, rename = "permissionMode")]
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "user")]
|
||||||
|
User {
|
||||||
|
message: CursorMessage,
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "assistant")]
|
||||||
|
Assistant {
|
||||||
|
message: CursorMessage,
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "tool_call")]
|
||||||
|
ToolCall {
|
||||||
|
#[serde(default)]
|
||||||
|
subtype: Option<String>, // "started" | "completed"
|
||||||
|
#[serde(default)]
|
||||||
|
call_id: Option<String>,
|
||||||
|
tool_call: CursorToolCall,
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "result")]
|
||||||
|
Result {
|
||||||
|
#[serde(default)]
|
||||||
|
subtype: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
is_error: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
duration_ms: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorJson {
|
||||||
|
pub fn extract_session_id(&self) -> Option<String> {
|
||||||
|
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<CursorContentItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorMessage {
|
||||||
|
pub fn concat_text(&self) -> Option<String> {
|
||||||
|
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_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "lsToolCall")]
|
||||||
|
LS {
|
||||||
|
args: CursorLsArgs,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "globToolCall")]
|
||||||
|
Glob {
|
||||||
|
args: CursorGlobArgs,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "grepToolCall")]
|
||||||
|
Grep {
|
||||||
|
args: CursorGrepArgs,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "writeToolCall")]
|
||||||
|
Write {
|
||||||
|
args: CursorWriteArgs,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "readToolCall")]
|
||||||
|
Read {
|
||||||
|
args: CursorReadArgs,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "editToolCall")]
|
||||||
|
Edit {
|
||||||
|
args: CursorEditArgs,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "deleteToolCall")]
|
||||||
|
Delete {
|
||||||
|
args: CursorDeleteArgs,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "updateTodosToolCall")]
|
||||||
|
Todo {
|
||||||
|
args: CursorUpdateTodosArgs,
|
||||||
|
#[serde(default)]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
/// Generic fallback for unknown tools (amp.rs pattern)
|
||||||
|
#[serde(untagged)]
|
||||||
|
Unknown {
|
||||||
|
#[serde(flatten)]
|
||||||
|
data: std::collections::HashMap<String, serde_json::Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct CursorLsArgs {
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ignore: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct CursorGlobArgs {
|
||||||
|
#[serde(default, alias = "globPattern", alias = "glob_pattern")]
|
||||||
|
pub glob_pattern: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default, alias = "target_directory")]
|
||||||
|
pub target_directory: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct CursorGrepArgs {
|
||||||
|
pub pattern: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default, alias = "glob")]
|
||||||
|
pub glob_filter: Option<String>,
|
||||||
|
#[serde(default, alias = "outputMode", alias = "output_mode")]
|
||||||
|
pub output_mode: Option<String>,
|
||||||
|
#[serde(default, alias = "-i", alias = "caseInsensitive")]
|
||||||
|
pub case_insensitive: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub multiline: Option<bool>,
|
||||||
|
#[serde(default, alias = "headLimit", alias = "head_limit")]
|
||||||
|
pub head_limit: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub r#type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct CursorReadArgs {
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct CursorEditArgs {
|
||||||
|
pub path: String,
|
||||||
|
#[serde(default, rename = "applyPatch")]
|
||||||
|
pub apply_patch: Option<CursorApplyPatch>,
|
||||||
|
#[serde(default, rename = "strReplace")]
|
||||||
|
pub str_replace: Option<CursorStrReplace>,
|
||||||
|
#[serde(default, rename = "multiStrReplace")]
|
||||||
|
pub multi_str_replace: Option<CursorMultiStrReplace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct CursorMultiStrReplace {
|
||||||
|
pub edits: Vec<CursorMultiEditItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<CursorTodoItem>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct CursorTodoItem {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default, rename = "createdAt")]
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
#[serde(default, rename = "updatedAt")]
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dependencies: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Helpers
|
||||||
|
=========================== */
|
||||||
|
|
||||||
|
fn summarize_todos_typed(items: &[CursorTodoItem]) -> String {
|
||||||
|
let mut out: Vec<String> = 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,16 @@ use utils::msg_store::MsgStore;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
command::AgentProfiles,
|
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 amp;
|
||||||
pub mod claude;
|
pub mod claude;
|
||||||
pub mod codex;
|
pub mod codex;
|
||||||
|
pub mod cursor;
|
||||||
pub mod gemini;
|
pub mod gemini;
|
||||||
pub mod opencode;
|
pub mod opencode;
|
||||||
|
|
||||||
@@ -59,6 +63,7 @@ pub enum CodingAgent {
|
|||||||
Amp,
|
Amp,
|
||||||
Gemini,
|
Gemini,
|
||||||
Codex,
|
Codex,
|
||||||
|
Cursor,
|
||||||
// ClaudeCodeRouter,
|
// ClaudeCodeRouter,
|
||||||
Opencode,
|
Opencode,
|
||||||
// Aider,
|
// Aider,
|
||||||
@@ -77,6 +82,7 @@ impl CodingAgent {
|
|||||||
"amp" => Ok(CodingAgent::Amp(Amp::new())),
|
"amp" => Ok(CodingAgent::Amp(Amp::new())),
|
||||||
"gemini" => Ok(CodingAgent::Gemini(Gemini::new())),
|
"gemini" => Ok(CodingAgent::Gemini(Gemini::new())),
|
||||||
"codex" => Ok(CodingAgent::Codex(Codex::new())),
|
"codex" => Ok(CodingAgent::Codex(Codex::new())),
|
||||||
|
"cursor" => Ok(CodingAgent::Cursor(Cursor::new())),
|
||||||
"opencode" => Ok(CodingAgent::Opencode(Opencode::new())),
|
"opencode" => Ok(CodingAgent::Opencode(Opencode::new())),
|
||||||
_ => {
|
_ => {
|
||||||
// Try to load from AgentProfiles
|
// Try to load from AgentProfiles
|
||||||
@@ -100,6 +106,9 @@ impl CodingAgent {
|
|||||||
BaseCodingAgent::Opencode => Ok(CodingAgent::Opencode(
|
BaseCodingAgent::Opencode => Ok(CodingAgent::Opencode(
|
||||||
Opencode::with_command_builder(agent_profile.command.clone()),
|
Opencode::with_command_builder(agent_profile.command.clone()),
|
||||||
)),
|
)),
|
||||||
|
BaseCodingAgent::Cursor => Ok(CodingAgent::Cursor(
|
||||||
|
Cursor::with_command_builder(agent_profile.command.clone()),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ExecutorError::UnknownExecutorType(format!(
|
Err(ExecutorError::UnknownExecutorType(format!(
|
||||||
@@ -124,6 +133,9 @@ impl BaseCodingAgent {
|
|||||||
Self::Gemini => Some(vec!["mcpServers"]),
|
Self::Gemini => Some(vec!["mcpServers"]),
|
||||||
//ExecutorConfig::Aider => None, // Aider doesn't support MCP. https://github.com/Aider-AI/aider/issues/3314
|
//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
|
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::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::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::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")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
|
|||||||
(tool_name.toLowerCase() === 'todowrite' ||
|
(tool_name.toLowerCase() === 'todowrite' ||
|
||||||
tool_name.toLowerCase() === 'todoread' ||
|
tool_name.toLowerCase() === 'todoread' ||
|
||||||
tool_name.toLowerCase() === 'todo_write' ||
|
tool_name.toLowerCase() === 'todo_write' ||
|
||||||
tool_name.toLowerCase() === 'todo_read')
|
tool_name.toLowerCase() === 'todo_read' ||
|
||||||
|
tool_name.toLowerCase() === 'todo')
|
||||||
) {
|
) {
|
||||||
return <CheckSquare className="h-4 w-4 text-purple-600" />;
|
return <CheckSquare className="h-4 w-4 text-purple-600" />;
|
||||||
}
|
}
|
||||||
@@ -95,7 +96,8 @@ const getContentClassName = (entryType: NormalizedEntryType) => {
|
|||||||
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
(entryType.tool_name.toLowerCase() === 'todowrite' ||
|
||||||
entryType.tool_name.toLowerCase() === 'todoread' ||
|
entryType.tool_name.toLowerCase() === 'todoread' ||
|
||||||
entryType.tool_name.toLowerCase() === 'todo_write' ||
|
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`;
|
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
|
// Render markdown for assistant messages, plan presentations, and tool outputs that contain backticks
|
||||||
return (
|
return (
|
||||||
entryType.type === 'assistant_message' ||
|
entryType.type === 'assistant_message' ||
|
||||||
(entryType.type === 'tool_use' &&
|
entryType.type === 'system_message' ||
|
||||||
entryType.action_type.action === 'plan_presentation') ||
|
entryType.type === 'user_message' ||
|
||||||
(entryType.type === 'tool_use' &&
|
entryType.type === 'thinking' ||
|
||||||
entryType.tool_name &&
|
entryType.type === 'tool_use'
|
||||||
(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_')))
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 = {
|
export type CommandBuilder = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user