支持Cline Agent

This commit is contained in:
xintaofei
2026-03-28 20:14:48 +08:00
parent afa67380e7
commit be3f4986d7
26 changed files with 1418 additions and 35 deletions

View File

@@ -739,20 +739,29 @@ async fn run_connection(
.await .await
} }
Err(e) => { Err(e) => {
// session/load failed (e.g. ephemeral forked session). // session/load failed (e.g. agent doesn't support resume,
// or ephemeral forked session).
// Fall back to session/new so the tab still works. // Fall back to session/new so the tab still works.
let err_str = e.to_string();
eprintln!( eprintln!(
"[ACP] session/load failed ({}), falling back to session/new", "[ACP] session/load failed ({}), falling back to session/new",
e err_str
);
crate::web::event_bridge::emit_event(
&handle,
"acp://event",
AcpEvent::Error {
connection_id: conn_id.clone(),
message: format!("Failed to load session, starting new: {e}"),
},
); );
// Only emit a visible error for unexpected failures;
// "Method not found" is expected for agents that don't
// support session resume (e.g. Cline).
if !err_str.contains("Method not found") {
crate::web::event_bridge::emit_event(
&handle,
"acp://event",
AcpEvent::Error {
connection_id: conn_id.clone(),
message: format!(
"Failed to load session, starting new: {e}"
),
},
);
}
let new_resp = cx let new_resp = cx
.send_request_to(Agent, NewSessionRequest::new(cwd.clone())) .send_request_to(Agent, NewSessionRequest::new(cwd.clone()))
.block_task() .block_task()

View File

@@ -78,6 +78,7 @@ pub fn all_acp_agents() -> Vec<AgentType> {
AgentType::Gemini, AgentType::Gemini,
AgentType::OpenClaw, AgentType::OpenClaw,
AgentType::OpenCode, AgentType::OpenCode,
AgentType::Cline,
] ]
} }
@@ -88,6 +89,7 @@ pub fn registry_id_for(agent_type: AgentType) -> &'static str {
AgentType::Gemini => "gemini", AgentType::Gemini => "gemini",
AgentType::OpenClaw => "openclaw-acp", AgentType::OpenClaw => "openclaw-acp",
AgentType::OpenCode => "opencode", AgentType::OpenCode => "opencode",
AgentType::Cline => "cline",
} }
} }
@@ -98,6 +100,7 @@ pub fn from_registry_id(id: &str) -> Option<AgentType> {
"gemini" => Some(AgentType::Gemini), "gemini" => Some(AgentType::Gemini),
"openclaw-acp" => Some(AgentType::OpenClaw), "openclaw-acp" => Some(AgentType::OpenClaw),
"opencode" => Some(AgentType::OpenCode), "opencode" => Some(AgentType::OpenCode),
"cline" => Some(AgentType::Cline),
_ => None, _ => None,
} }
} }
@@ -184,6 +187,19 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
node_required: Some("22.12.0"), node_required: Some("22.12.0"),
}, },
}, },
AgentType::Cline => AcpAgentMeta {
agent_type,
name: "Cline",
description: "Autonomous coding agent CLI",
distribution: AgentDistribution::Npx {
version: "2.11.0",
package: "cline@2.11.0",
cmd: "cline",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::OpenCode => AcpAgentMeta { AgentType::OpenCode => AcpAgentMeta {
agent_type, agent_type,
name: "OpenCode", name: "OpenCode",

View File

@@ -242,6 +242,7 @@ pub struct AcpAgentInfo {
pub opencode_auth_json: Option<String>, pub opencode_auth_json: Option<String>,
pub codex_auth_json: Option<String>, pub codex_auth_json: Option<String>,
pub codex_config_toml: Option<String>, pub codex_config_toml: Option<String>,
pub cline_secrets_json: Option<String>,
} }
/// Lightweight status info for a single agent, used by connect() pre-check. /// Lightweight status info for a single agent, used by connect() pre-check.

View File

@@ -298,6 +298,297 @@ fn load_opencode_auth_json_raw() -> Option<String> {
fs::read_to_string(opencode_auth_json_path()).ok() fs::read_to_string(opencode_auth_json_path()).ok()
} }
// ---------------------------------------------------------------------------
// Cline config helpers
// ---------------------------------------------------------------------------
fn cline_data_dir() -> PathBuf {
if let Ok(custom) = std::env::var("CLINE_DIR") {
let trimmed = custom.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
home_dir_or_default().join(".cline").join("data")
}
fn cline_global_state_path() -> PathBuf {
cline_data_dir().join("globalState.json")
}
fn cline_secrets_path() -> PathBuf {
cline_data_dir().join("secrets.json")
}
fn load_cline_secrets_json_raw() -> Option<String> {
fs::read_to_string(cline_secrets_path()).ok()
}
/// Cline provider → secrets.json field name for the API key.
fn cline_api_key_field_for_provider(provider: &str) -> &'static str {
match provider {
"anthropic" => "apiKey",
"openrouter" => "openRouterApiKey",
"openai-native" => "openAiNativeApiKey",
"openai" => "openAiApiKey",
"gemini" => "geminiApiKey",
"deepseek" => "deepSeekApiKey",
"mistral" => "mistralApiKey",
"xai" => "xaiApiKey",
_ => "openAiApiKey",
}
}
/// Cline provider → globalState model ID key suffix.
/// Providers in ProviderKeyMap use `actMode{Suffix}` / `planMode{Suffix}`,
/// others use `actModeApiModelId` / `planModeApiModelId`.
fn cline_model_id_keys_for_provider(provider: &str) -> (&'static str, &'static str) {
match provider {
"openrouter" | "cline" => ("actModeOpenRouterModelId", "planModeOpenRouterModelId"),
"openai" => ("actModeOpenAiModelId", "planModeOpenAiModelId"),
"ollama" => ("actModeOllamaModelId", "planModeOllamaModelId"),
"lmstudio" => ("actModeLmStudioModelId", "planModeLmStudioModelId"),
"litellm" => ("actModeLiteLlmModelId", "planModeLiteLlmModelId"),
"requesty" => ("actModeRequestyModelId", "planModeRequestyModelId"),
"groq" => ("actModeGroqModelId", "planModeGroqModelId"),
_ => ("actModeApiModelId", "planModeApiModelId"),
}
}
/// Read globalState.json + secrets.json and merge into a unified config JSON
/// with keys: apiProvider, model, apiKey, apiBaseUrl.
fn load_cline_local_config_json() -> Option<String> {
let mut merged = serde_json::Map::new();
if let Ok(raw) = fs::read_to_string(cline_global_state_path()) {
if let Ok(state) = serde_json::from_str::<serde_json::Value>(&raw) {
// Cline uses actModeApiProvider / planModeApiProvider (prefer actMode)
let provider = state
.get("actModeApiProvider")
.or_else(|| state.get("planModeApiProvider"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or("anthropic")
.to_string();
merged.insert(
"apiProvider".to_string(),
serde_json::Value::String(provider.clone()),
);
// Read model from provider-specific key
let (act_key, _plan_key) = cline_model_id_keys_for_provider(&provider);
if let Some(model_id) = state
.get(act_key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
{
merged.insert(
"model".to_string(),
serde_json::Value::String(model_id.to_string()),
);
}
// Read provider-specific baseUrl key
let base_url_key = match provider.as_str() {
"anthropic" => "anthropicBaseUrl",
"gemini" => "geminiBaseUrl",
"ollama" => "ollamaBaseUrl",
"lmstudio" => "lmStudioBaseUrl",
"litellm" => "liteLlmBaseUrl",
"requesty" => "requestyBaseUrl",
_ => "openAiBaseUrl",
};
if let Some(base_url) = state
.get(base_url_key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
{
merged.insert(
"apiBaseUrl".to_string(),
serde_json::Value::String(base_url.to_string()),
);
}
}
}
// Read API key from secrets.json based on provider
if let Ok(raw) = fs::read_to_string(cline_secrets_path()) {
if let Ok(secrets) = serde_json::from_str::<serde_json::Value>(&raw) {
let provider = merged
.get("apiProvider")
.and_then(|v| v.as_str())
.unwrap_or("anthropic");
let key_field = cline_api_key_field_for_provider(provider);
if let Some(api_key) = secrets
.get(key_field)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
{
merged.insert(
"apiKey".to_string(),
serde_json::Value::String(api_key.to_string()),
);
}
}
}
if merged.is_empty() {
return None;
}
serde_json::to_string_pretty(&serde_json::Value::Object(merged)).ok()
}
/// Split merged config back into globalState.json + secrets.json.
/// Writes `actModeApiProvider`, `planModeApiProvider`, provider-specific model keys,
/// `openAiBaseUrl`, and `welcomeViewCompleted` to globalState.json,
/// and the provider-specific API key to secrets.json.
fn persist_cline_local_config(config_patch_json: Option<&str>) -> Result<(), AcpError> {
let Some(raw_patch) = config_patch_json else {
return Ok(());
};
let runtime = serde_json::from_str::<AgentRuntimeConfig>(raw_patch)
.map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?;
let patch = serde_json::from_str::<serde_json::Value>(raw_patch)
.map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?;
let provider = patch
.get("apiProvider")
.and_then(|v| v.as_str())
.unwrap_or("anthropic")
.to_string();
// --- Update globalState.json (merge) ---
let gs_path = cline_global_state_path();
let mut gs = if gs_path.exists() {
match fs::read_to_string(&gs_path)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
{
Some(existing) if existing.is_object() => existing,
_ => serde_json::json!({}),
}
} else {
serde_json::json!({})
};
let gs_obj = gs
.as_object_mut()
.ok_or_else(|| AcpError::protocol("globalState root must be object"))?;
// Cline checks welcomeViewCompleted first in isAuthConfigured()
gs_obj.insert(
"welcomeViewCompleted".to_string(),
serde_json::Value::Bool(true),
);
// Set both act/plan mode providers
gs_obj.insert(
"actModeApiProvider".to_string(),
serde_json::Value::String(provider.clone()),
);
gs_obj.insert(
"planModeApiProvider".to_string(),
serde_json::Value::String(provider.clone()),
);
// Set provider-specific model ID keys
let (act_model_key, plan_model_key) = cline_model_id_keys_for_provider(&provider);
match trim_non_empty(runtime.model) {
Some(model) => {
gs_obj.insert(
act_model_key.to_string(),
serde_json::Value::String(model.clone()),
);
gs_obj.insert(
plan_model_key.to_string(),
serde_json::Value::String(model),
);
}
None => {
gs_obj.remove(act_model_key);
gs_obj.remove(plan_model_key);
}
}
// Each provider uses its own baseUrl key in globalState
let base_url_key = match provider.as_str() {
"anthropic" => "anthropicBaseUrl",
"gemini" => "geminiBaseUrl",
"ollama" => "ollamaBaseUrl",
"lmstudio" => "lmStudioBaseUrl",
"litellm" => "liteLlmBaseUrl",
"requesty" => "requestyBaseUrl",
_ => "openAiBaseUrl",
};
match trim_non_empty(runtime.api_base_url) {
Some(base_url) => {
gs_obj.insert(
base_url_key.to_string(),
serde_json::Value::String(base_url),
);
}
None => {
gs_obj.remove(base_url_key);
}
}
if let Some(parent) = gs_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
AcpError::protocol(format!("create cline data directory failed: {e}"))
})?;
}
let serialized_gs = serde_json::to_string_pretty(&gs)
.map_err(|e| AcpError::protocol(format!("serialize cline globalState failed: {e}")))?;
fs::write(&gs_path, format!("{serialized_gs}\n"))
.map_err(|e| AcpError::protocol(format!("write cline globalState failed: {e}")))?;
// --- Update secrets.json ---
let secrets_path = cline_secrets_path();
let mut secrets = if secrets_path.exists() {
match fs::read_to_string(&secrets_path)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
{
Some(existing) if existing.is_object() => existing,
_ => serde_json::json!({}),
}
} else {
serde_json::json!({})
};
let secrets_obj = secrets
.as_object_mut()
.ok_or_else(|| AcpError::protocol("secrets root must be object"))?;
let key_field = cline_api_key_field_for_provider(&provider);
match trim_non_empty(runtime.api_key) {
Some(api_key) => {
secrets_obj.insert(
key_field.to_string(),
serde_json::Value::String(api_key),
);
}
None => {
secrets_obj.remove(key_field);
}
}
if let Some(parent) = secrets_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
AcpError::protocol(format!("create cline data directory failed: {e}"))
})?;
}
let serialized_secrets = serde_json::to_string_pretty(&secrets)
.map_err(|e| AcpError::protocol(format!("serialize cline secrets failed: {e}")))?;
fs::write(&secrets_path, format!("{serialized_secrets}\n"))
.map_err(|e| AcpError::protocol(format!("write cline secrets failed: {e}")))?;
Ok(())
}
fn load_codex_auth_json_raw() -> Option<String> { fn load_codex_auth_json_raw() -> Option<String> {
fs::read_to_string(codex_auth_json_path()).ok() fs::read_to_string(codex_auth_json_path()).ok()
} }
@@ -620,6 +911,7 @@ fn agent_local_config_path(agent_type: AgentType) -> Option<PathBuf> {
AgentType::ClaudeCode => Some(home_dir_or_default().join(".claude").join("settings.json")), AgentType::ClaudeCode => Some(home_dir_or_default().join(".claude").join("settings.json")),
AgentType::Gemini => Some(home_dir_or_default().join(".gemini").join("settings.json")), AgentType::Gemini => Some(home_dir_or_default().join(".gemini").join("settings.json")),
AgentType::OpenCode => Some(resolve_opencode_config_path()), AgentType::OpenCode => Some(resolve_opencode_config_path()),
AgentType::Cline => Some(cline_global_state_path()),
_ => None, _ => None,
} }
} }
@@ -628,6 +920,9 @@ pub(crate) fn load_agent_local_config_json(agent_type: AgentType) -> Option<Stri
if agent_type == AgentType::Codex { if agent_type == AgentType::Codex {
return load_codex_local_config_json(); return load_codex_local_config_json();
} }
if agent_type == AgentType::Cline {
return load_cline_local_config_json();
}
let path = agent_local_config_path(agent_type)?; let path = agent_local_config_path(agent_type)?;
if !path.exists() { if !path.exists() {
@@ -665,6 +960,9 @@ fn persist_agent_local_config_json(
if agent_type == AgentType::Codex { if agent_type == AgentType::Codex {
return persist_codex_local_config(config_patch_json); return persist_codex_local_config(config_patch_json);
} }
if agent_type == AgentType::Cline {
return persist_cline_local_config(config_patch_json);
}
let Some(path) = agent_local_config_path(agent_type) else { let Some(path) = agent_local_config_path(agent_type) else {
return Ok(()); return Ok(());
@@ -755,6 +1053,7 @@ fn skill_storage_spec(agent_type: AgentType) -> Option<SkillStorageSpec> {
global_dirs: vec![home_dir_or_default().join(".openclaw").join("skills")], global_dirs: vec![home_dir_or_default().join(".openclaw").join("skills")],
project_rel_dirs: vec!["skills"], project_rel_dirs: vec!["skills"],
}), }),
AgentType::Cline => None,
} }
} }
@@ -1325,6 +1624,11 @@ pub async fn acp_list_agents(
} else { } else {
None None
}; };
let cline_secrets_json = if agent_type == AgentType::Cline {
load_cline_secrets_json_raw()
} else {
None
};
agents.push(AcpAgentInfo { agents.push(AcpAgentInfo {
agent_type, agent_type,
@@ -1344,6 +1648,7 @@ pub async fn acp_list_agents(
opencode_auth_json, opencode_auth_json,
codex_auth_json, codex_auth_json,
codex_config_toml, codex_config_toml,
cline_secrets_json,
}); });
} }
@@ -1447,6 +1752,14 @@ pub(crate) async fn acp_update_agent_preferences_core(
return Ok(()); return Ok(());
} }
if agent_type == AgentType::Cline {
if let Some(raw) = config_json.as_deref() {
persist_cline_local_config(Some(raw))?;
}
emit_acp_agents_updated(app, "preferences_updated", Some(agent_type));
return Ok(());
}
let mut local_patch_value = config_json let mut local_patch_value = config_json
.as_deref() .as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok()) .and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())

View File

@@ -6,6 +6,7 @@ use crate::db::service::{conversation_service, folder_service, import_service};
use crate::db::AppDatabase; use crate::db::AppDatabase;
use crate::models::*; use crate::models::*;
use crate::parsers::claude::ClaudeParser; use crate::parsers::claude::ClaudeParser;
use crate::parsers::cline::ClineParser;
use crate::parsers::codex::CodexParser; use crate::parsers::codex::CodexParser;
use crate::parsers::gemini::GeminiParser; use crate::parsers::gemini::GeminiParser;
use crate::parsers::openclaw::OpenClawParser; use crate::parsers::openclaw::OpenClawParser;
@@ -42,6 +43,7 @@ fn list_conversations_sync(
(AgentType::OpenCode, Box::new(OpenCodeParser::new())), (AgentType::OpenCode, Box::new(OpenCodeParser::new())),
(AgentType::Gemini, Box::new(GeminiParser::new())), (AgentType::Gemini, Box::new(GeminiParser::new())),
(AgentType::OpenClaw, Box::new(OpenClawParser::new())), (AgentType::OpenClaw, Box::new(OpenClawParser::new())),
(AgentType::Cline, Box::new(ClineParser::new())),
]; ];
for (at, parser) in &parsers { for (at, parser) in &parsers {
@@ -142,6 +144,7 @@ pub async fn get_conversation(
AgentType::OpenCode => Box::new(OpenCodeParser::new()), AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()), AgentType::Gemini => Box::new(GeminiParser::new()),
AgentType::OpenClaw => Box::new(OpenClawParser::new()), AgentType::OpenClaw => Box::new(OpenClawParser::new()),
AgentType::Cline => Box::new(ClineParser::new()),
}; };
parser parser
@@ -275,14 +278,16 @@ pub async fn get_folder_conversation_core(
AgentType::OpenCode => Box::new(OpenCodeParser::new()), AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()), AgentType::Gemini => Box::new(GeminiParser::new()),
AgentType::OpenClaw => Box::new(OpenClawParser::new()), AgentType::OpenClaw => Box::new(OpenClawParser::new()),
AgentType::Cline => Box::new(ClineParser::new()),
}; };
match parser.get_conversation(&eid) { match parser.get_conversation(&eid) {
Ok(d) => Ok((d.turns, d.session_stats, None)), Ok(d) => Ok((d.turns, d.session_stats, None)),
Err(crate::parsers::ParseError::ConversationNotFound(_)) => { Err(crate::parsers::ParseError::ConversationNotFound(_)) => {
// For OpenClaw, the external_id may be an ACP session UUID that // For agents like OpenClaw and Cline, the external_id is an
// doesn't correspond to any JSONL file. Fall back to matching // ACP session UUID that doesn't correspond to any local file.
// by title and folder_path from the parsed conversation list. // Fall back to matching by folder_path and started_at from
if at == AgentType::OpenClaw { // the parsed conversation list.
if at == AgentType::OpenClaw || at == AgentType::Cline {
if let Ok(all) = parser.list_conversations() { if let Ok(all) = parser.list_conversations() {
// Filter by folder_path first, then find the closest // Filter by folder_path first, then find the closest
// started_at match within 300 seconds of db_created_at. // started_at match within 300 seconds of db_created_at.

View File

@@ -32,6 +32,7 @@ fn default_enabled(agent_type: AgentType) -> bool {
| AgentType::Gemini | AgentType::Gemini
| AgentType::OpenCode | AgentType::OpenCode
| AgentType::OpenClaw | AgentType::OpenClaw
| AgentType::Cline
) )
} }

View File

@@ -7,6 +7,7 @@ use crate::db::entities::conversation;
use crate::db::error::DbError; use crate::db::error::DbError;
use crate::models::{AgentType, ImportResult}; use crate::models::{AgentType, ImportResult};
use crate::parsers::claude::ClaudeParser; use crate::parsers::claude::ClaudeParser;
use crate::parsers::cline::ClineParser;
use crate::parsers::codex::CodexParser; use crate::parsers::codex::CodexParser;
use crate::parsers::gemini::GeminiParser; use crate::parsers::gemini::GeminiParser;
use crate::parsers::openclaw::OpenClawParser; use crate::parsers::openclaw::OpenClawParser;
@@ -28,6 +29,7 @@ pub async fn import_local_conversations(
(AgentType::OpenCode, Box::new(OpenCodeParser::new())), (AgentType::OpenCode, Box::new(OpenCodeParser::new())),
(AgentType::Gemini, Box::new(GeminiParser::new())), (AgentType::Gemini, Box::new(GeminiParser::new())),
(AgentType::OpenClaw, Box::new(OpenClawParser::new())), (AgentType::OpenClaw, Box::new(OpenClawParser::new())),
(AgentType::Cline, Box::new(ClineParser::new())),
]; ];
let mut matched = Vec::new(); let mut matched = Vec::new();

View File

@@ -9,6 +9,7 @@ pub enum AgentType {
OpenCode, OpenCode,
Gemini, Gemini,
OpenClaw, OpenClaw,
Cline,
} }
impl fmt::Display for AgentType { impl fmt::Display for AgentType {
@@ -19,6 +20,7 @@ impl fmt::Display for AgentType {
AgentType::OpenCode => write!(f, "OpenCode"), AgentType::OpenCode => write!(f, "OpenCode"),
AgentType::Gemini => write!(f, "Gemini CLI"), AgentType::Gemini => write!(f, "Gemini CLI"),
AgentType::OpenClaw => write!(f, "OpenClaw"), AgentType::OpenClaw => write!(f, "OpenClaw"),
AgentType::Cline => write!(f, "Cline"),
} }
} }
} }

View File

@@ -0,0 +1,634 @@
use std::fs;
use std::path::PathBuf;
use chrono::{DateTime, TimeZone, Utc};
use serde::Deserialize;
use crate::models::{
AgentType, ContentBlock, ConversationDetail, ConversationSummary, MessageTurn, TurnRole,
TurnUsage,
};
use super::{compute_session_stats, folder_name_from_path, truncate_str, AgentParser, ParseError};
// ---------------------------------------------------------------------------
// On-disk JSON structures
// ---------------------------------------------------------------------------
/// One entry in `~/.cline/data/state/taskHistory.json`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TaskHistoryEntry {
id: String,
ts: i64,
task: Option<String>,
#[allow(dead_code)]
tokens_in: Option<u64>,
#[allow(dead_code)]
tokens_out: Option<u64>,
#[allow(dead_code)]
total_cost: Option<f64>,
cwd_on_task_initialization: Option<String>,
#[serde(default)]
model_id: Option<String>,
}
/// `task_metadata.json` we only need `model_usage`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TaskMetadata {
#[serde(default)]
model_usage: Vec<ModelUsageEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelUsageEntry {
model_id: Option<String>,
#[allow(dead_code)]
model_provider_id: Option<String>,
}
/// One message in `api_conversation_history.json`.
#[derive(Debug, Deserialize)]
struct ApiMessage {
role: String,
#[serde(default)]
content: serde_json::Value,
ts: Option<i64>,
#[serde(default, rename = "modelInfo")]
model_info: Option<ApiModelInfo>,
#[serde(default)]
metrics: Option<ApiMetrics>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiModelInfo {
model_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiMetrics {
tokens: Option<ApiTokenMetrics>,
}
#[derive(Debug, Deserialize)]
struct ApiTokenMetrics {
#[serde(default)]
prompt: Option<u64>,
#[serde(default)]
completion: Option<u64>,
#[serde(default)]
cached: Option<u64>,
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
fn cline_data_dir() -> PathBuf {
if let Ok(custom) = std::env::var("CLINE_DIR") {
let trimmed = custom.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".cline")
.join("data")
}
fn ts_to_datetime(ts: i64) -> DateTime<Utc> {
Utc.timestamp_millis_opt(ts).single().unwrap_or_default()
}
pub struct ClineParser;
impl ClineParser {
pub fn new() -> Self {
Self
}
}
impl AgentParser for ClineParser {
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
let history_path = cline_data_dir().join("state").join("taskHistory.json");
if !history_path.exists() {
return Ok(vec![]);
}
let raw = fs::read_to_string(&history_path)?;
let entries: Vec<TaskHistoryEntry> = serde_json::from_str(&raw)?;
let mut summaries = Vec::new();
for entry in entries {
let tasks_dir = cline_data_dir().join("tasks").join(&entry.id);
if !tasks_dir.exists() {
continue;
}
// Read model from task_metadata.json or taskHistory entry
let model = entry.model_id.clone().or_else(|| {
let meta_path = tasks_dir.join("task_metadata.json");
fs::read_to_string(meta_path)
.ok()
.and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok())
.and_then(|meta| {
meta.model_usage
.first()
.and_then(|u| u.model_id.clone())
})
});
let folder_path = entry.cwd_on_task_initialization.clone();
let folder_name = folder_path.as_deref().map(folder_name_from_path);
let title = entry
.task
.as_deref()
.map(|t| truncate_str(t.trim(), 100));
// Count messages from api_conversation_history.json
let api_path = tasks_dir.join("api_conversation_history.json");
let message_count = fs::read_to_string(&api_path)
.ok()
.and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok())
.map(|msgs| msgs.len() as u32)
.unwrap_or(0);
// started_at from task id (which is a timestamp), ended_at from ts
let started_at = ts_to_datetime(entry.id.parse::<i64>().unwrap_or(entry.ts));
let ended_at = if entry.ts > 0 {
Some(ts_to_datetime(entry.ts))
} else {
None
};
summaries.push(ConversationSummary {
id: entry.id,
agent_type: AgentType::Cline,
folder_path,
folder_name: folder_name.map(String::from),
title,
started_at,
ended_at,
message_count,
model,
git_branch: None,
});
}
Ok(summaries)
}
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
let tasks_dir = cline_data_dir().join("tasks").join(conversation_id);
if !tasks_dir.exists() {
return Err(ParseError::ConversationNotFound(
conversation_id.to_string(),
));
}
let api_path = tasks_dir.join("api_conversation_history.json");
if !api_path.exists() {
return Err(ParseError::ConversationNotFound(
conversation_id.to_string(),
));
}
let raw = fs::read_to_string(&api_path)?;
let messages: Vec<ApiMessage> = serde_json::from_str(&raw)?;
// Read metadata for model/cwd
let meta_path = tasks_dir.join("task_metadata.json");
let metadata = fs::read_to_string(&meta_path)
.ok()
.and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok());
let default_model = metadata
.as_ref()
.and_then(|m| m.model_usage.first())
.and_then(|u| u.model_id.clone());
// Read taskHistory for cwd and title
let history_path = cline_data_dir().join("state").join("taskHistory.json");
let history_entry = fs::read_to_string(&history_path)
.ok()
.and_then(|raw| serde_json::from_str::<Vec<TaskHistoryEntry>>(&raw).ok())
.and_then(|entries| entries.into_iter().find(|e| e.id == conversation_id));
let folder_path = history_entry
.as_ref()
.and_then(|e| e.cwd_on_task_initialization.clone());
let folder_name = folder_path.as_deref().map(folder_name_from_path);
let title = history_entry
.as_ref()
.and_then(|e| e.task.as_deref())
.map(|t| truncate_str(t.trim(), 100));
let mut turns: Vec<MessageTurn> = Vec::new();
let mut turn_counter = 0u32;
for msg in &messages {
let ts = msg.ts.unwrap_or(0);
let timestamp = if ts > 0 {
ts_to_datetime(ts)
} else {
Utc::now()
};
let model = msg
.model_info
.as_ref()
.and_then(|info| info.model_id.clone())
.or_else(|| default_model.clone());
let usage = msg.metrics.as_ref().and_then(|m| {
m.tokens.as_ref().map(|t| TurnUsage {
input_tokens: t.prompt.unwrap_or(0),
output_tokens: t.completion.unwrap_or(0),
cache_creation_input_tokens: 0,
cache_read_input_tokens: t.cached.unwrap_or(0),
})
});
match msg.role.as_str() {
"assistant" => {
let blocks = parse_content_blocks(&msg.content);
if blocks.is_empty() {
continue;
}
turn_counter += 1;
turns.push(MessageTurn {
id: format!("{}-{}", conversation_id, turn_counter),
role: TurnRole::Assistant,
blocks,
timestamp,
usage,
duration_ms: None,
model,
});
}
"user" => {
// Cline packs tool results, user feedback, and automated
// messages into role:"user". Split them into proper turns.
let parsed = parse_user_message_parts(&msg.content);
// Emit tool-result blocks as a system turn so they attach
// to the preceding assistant tool_use.
if !parsed.tool_results.is_empty() {
turn_counter += 1;
turns.push(MessageTurn {
id: format!("{}-{}", conversation_id, turn_counter),
role: TurnRole::System,
blocks: parsed.tool_results,
timestamp,
usage: None,
duration_ms: None,
model: None,
});
}
// Emit real user text (feedback / initial task) as a user turn.
if !parsed.user_blocks.is_empty() {
turn_counter += 1;
turns.push(MessageTurn {
id: format!("{}-{}", conversation_id, turn_counter),
role: TurnRole::User,
blocks: parsed.user_blocks,
timestamp,
usage: None,
duration_ms: None,
model: None,
});
}
}
_ => continue,
}
}
let started_at = turns
.first()
.map(|t| t.timestamp)
.unwrap_or_else(Utc::now);
let ended_at = turns.last().map(|t| t.timestamp);
let session_stats = compute_session_stats(&turns);
let summary = ConversationSummary {
id: conversation_id.to_string(),
agent_type: AgentType::Cline,
folder_path,
folder_name: folder_name.map(String::from),
title,
started_at,
ended_at,
message_count: turns.len() as u32,
model: default_model,
git_branch: None,
};
Ok(ConversationDetail {
summary,
turns,
session_stats,
})
}
}
// ---------------------------------------------------------------------------
// Content block parsing
// ---------------------------------------------------------------------------
/// Result of splitting a Cline `role:"user"` message.
struct UserMessageParts {
/// Tool result blocks (e.g. `[read_file for ...] Result:`)
tool_results: Vec<ContentBlock>,
/// Real user content (initial task text or `<feedback>` text)
user_blocks: Vec<ContentBlock>,
}
/// Cline puts tool results, feedback, and automated prompts all into
/// `role:"user"` messages. This function splits them apart.
fn parse_user_message_parts(content: &serde_json::Value) -> UserMessageParts {
let texts = collect_text_parts(content);
let mut tool_results = Vec::new();
let mut user_blocks = Vec::new();
for text in texts {
let cleaned = strip_environment_details(&text);
if cleaned.is_empty() {
continue;
}
// Tool result pattern: `[tool_name ...] Result:`
if is_tool_result_text(&cleaned) {
let (tool_name, output, is_error) = parse_tool_result_text(&cleaned);
tool_results.push(ContentBlock::ToolResult {
tool_use_id: None,
output_preview: Some(truncate_str(&output, 2000)),
is_error,
});
// If the tool result also contains <feedback>, extract it
if let Some(feedback) = extract_feedback(&text) {
let fb = feedback.trim();
if !fb.is_empty() {
user_blocks.push(ContentBlock::Text {
text: fb.to_string(),
});
}
}
// After extracting tool result, also check for non-feedback user
// text following the result (e.g. "The user has provided feedback...")
// — we intentionally skip these automated bridging messages.
let _ = tool_name;
continue;
}
// Pure feedback without tool result prefix
if let Some(feedback) = extract_feedback(&cleaned) {
let fb = feedback.trim();
if !fb.is_empty() {
user_blocks.push(ContentBlock::Text {
text: fb.to_string(),
});
}
continue;
}
// Regular user text (initial task, etc.)
user_blocks.push(ContentBlock::Text { text: cleaned });
}
UserMessageParts {
tool_results,
user_blocks,
}
}
/// Collect all text strings from a content value (string or array of text blocks).
fn collect_text_parts(content: &serde_json::Value) -> Vec<String> {
match content {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|item| {
let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
if t == "text" || t.is_empty() {
item.get("text")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
if t.is_empty() {
item.as_str().map(String::from)
} else {
None
}
})
} else {
None
}
})
.collect(),
_ => vec![],
}
}
/// Check if text looks like a Cline tool result: `[tool_name ...] Result:`
fn is_tool_result_text(text: &str) -> bool {
let trimmed = text.trim_start();
trimmed.starts_with('[')
&& trimmed.contains("] Result:")
}
/// Parse `[tool_name for 'arg'] Result:\ncontent` into (tool_name, output, is_error).
fn parse_tool_result_text(text: &str) -> (String, String, bool) {
let trimmed = text.trim_start();
// Extract tool name from [tool_name ...] or [tool_name] prefix
let tool_name = trimmed
.strip_prefix('[')
.and_then(|s| {
s.find(|c: char| c == ']' || c == ' ')
.map(|i| s[..i].to_string())
})
.unwrap_or_default();
let is_error = trimmed.contains("[ERROR]") || trimmed.contains("Error:");
// Extract the content after "Result:\n"
let output = trimmed
.find("] Result:")
.map(|i| {
let after = &trimmed[i + "] Result:".len()..];
after.trim().to_string()
})
.unwrap_or_default();
// Strip automated bridging text that follows some results
let output = strip_automated_bridging(&output);
(tool_name, output, is_error)
}
/// Remove automated bridging messages that Cline appends after tool results.
fn strip_automated_bridging(text: &str) -> String {
let mut result = text.to_string();
// Remove "The user has provided feedback..." bridging
if let Some(pos) = result.find("The user has provided feedback") {
result = result[..pos].to_string();
}
// Remove "(This is an automated message...)" blocks
if let Some(pos) = result.find("(This is an automated message") {
result = result[..pos].to_string();
}
// Remove "# Next Steps" blocks
if let Some(pos) = result.find("# Next Steps") {
result = result[..pos].to_string();
}
result.trim().to_string()
}
/// Extract text from `<feedback>...</feedback>` tags.
fn extract_feedback(text: &str) -> Option<String> {
let start = text.find("<feedback>")?;
let inner_start = start + "<feedback>".len();
let end = text.find("</feedback>")?;
if end > inner_start {
Some(text[inner_start..end].to_string())
} else {
None
}
}
fn parse_content_blocks(content: &serde_json::Value) -> Vec<ContentBlock> {
match content {
serde_json::Value::String(text) => {
let cleaned = strip_environment_details(text);
if cleaned.is_empty() {
vec![]
} else {
vec![ContentBlock::Text { text: cleaned }]
}
}
serde_json::Value::Array(arr) => {
let mut blocks = Vec::new();
for item in arr {
let block_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
let cleaned = strip_environment_details(text);
if !cleaned.is_empty() {
blocks.push(ContentBlock::Text { text: cleaned });
}
}
}
"tool_use" => {
let tool_name = item
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let tool_use_id =
item.get("id").and_then(|v| v.as_str()).map(String::from);
let input_preview = item.get("input").map(|v| {
let s = v.to_string();
truncate_str(&s, 2000)
});
blocks.push(ContentBlock::ToolUse {
tool_use_id,
tool_name,
input_preview,
});
}
"tool_result" => {
let tool_use_id = item
.get("tool_use_id")
.and_then(|v| v.as_str())
.map(String::from);
let is_error = item
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let output_preview = item
.get("content")
.and_then(|v| v.as_str())
.map(|s| truncate_str(s, 500));
blocks.push(ContentBlock::ToolResult {
tool_use_id,
output_preview,
is_error,
});
}
"thinking" => {
if let Some(text) = item.get("thinking").and_then(|v| v.as_str()) {
if !text.is_empty() {
blocks.push(ContentBlock::Thinking {
text: text.to_string(),
});
}
}
}
_ => {}
}
}
blocks
}
_ => vec![],
}
}
/// Strip Cline's `<environment_details>...</environment_details>` blocks and
/// `<task>...</task>` wrappers from user messages to keep content clean.
fn strip_environment_details(text: &str) -> String {
let mut result = text.to_string();
// Remove <environment_details>...</environment_details>
while let Some(start) = result.find("<environment_details>") {
if let Some(end) = result.find("</environment_details>") {
let end = end + "</environment_details>".len();
result = format!("{}{}", &result[..start], &result[end..]);
} else {
// Unclosed tag — remove from start to end
result = result[..start].to_string();
}
}
// Remove <task>...</task> wrappers, keeping inner content
while let Some(start) = result.find("<task>") {
let tag_end = start + "<task>".len();
if let Some(close) = result.find("</task>") {
let inner = result[tag_end..close].to_string();
let after = &result[close + "</task>".len()..];
result = format!("{}{}{}", &result[..start], inner, after);
} else {
break;
}
}
// Remove task_progress RECOMMENDED blocks
while let Some(start) = result.find("# task_progress RECOMMENDED") {
// Find the end: next section or end of string
let rest = &result[start..];
let end = rest
.find("\n<")
.or_else(|| rest.find("\n#"))
.map(|i| start + i)
.unwrap_or(result.len());
result = format!("{}{}", &result[..start], &result[end..]);
}
// Remove [ERROR] automated retry messages
if result.contains("[ERROR] You did not use a tool") {
return String::new();
}
result.trim().to_string()
}

View File

@@ -1,4 +1,5 @@
pub mod claude; pub mod claude;
pub mod cline;
pub mod codex; pub mod codex;
pub mod gemini; pub mod gemini;
pub mod openclaw; pub mod openclaw;

View File

@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"
import ClaudeColor from "@lobehub/icons/es/Claude/components/Color" import ClaudeColor from "@lobehub/icons/es/Claude/components/Color"
import GeminiColor from "@lobehub/icons/es/Gemini/components/Color" import GeminiColor from "@lobehub/icons/es/Gemini/components/Color"
import OpenClawColor from "@lobehub/icons/es/OpenClaw/components/Color" import OpenClawColor from "@lobehub/icons/es/OpenClaw/components/Color"
import ClineMono from "@lobehub/icons/es/Cline/components/Mono"
import { OpenAI, OpenCode } from "@lobehub/icons" import { OpenAI, OpenCode } from "@lobehub/icons"
interface AgentIconProps { interface AgentIconProps {
@@ -24,6 +25,7 @@ const COLOR_ICONS: Partial<Record<AgentType, AnyIcon>> = {
const MONO_ICONS: Partial<Record<AgentType, AnyIcon>> = { const MONO_ICONS: Partial<Record<AgentType, AnyIcon>> = {
codex: OpenAI, codex: OpenAI,
open_code: OpenCode, open_code: OpenCode,
cline: ClineMono,
} }
// Text-color versions for Mono icons // Text-color versions for Mono icons

View File

@@ -274,7 +274,10 @@ const ConversationTabView = memo(function ConversationTabView({
agentType: selectedAgent, agentType: selectedAgent,
isActive: isActive && canAutoConnect, isActive: isActive && canAutoConnect,
workingDir: workingDirForConnection, workingDir: workingDirForConnection,
sessionId: dbConversationId != null ? externalId : undefined, sessionId:
dbConversationId != null && selectedAgent !== "cline"
? externalId
: undefined,
}) })
const { const {
status: connStatus, status: connStatus,

View File

@@ -878,6 +878,8 @@ function getToolIcon(
name === "switch_mode" name === "switch_mode"
) )
return <ListTodoIcon className={ICON_CLASS} /> return <ListTodoIcon className={ICON_CLASS} />
if (name === "attempt_completion")
return <CircleCheckIcon className={ICON_CLASS} />
return undefined return undefined
} }
@@ -909,6 +911,16 @@ function deriveToolTitle(
return null return null
} }
// Cline: attempt_completion — show result summary as title
if (name === "attempt_completion") {
const result = getField("result")
if (result) {
const firstLine = result.split("\n")[0].trim()
return `${ellipsis(firstLine, 80)}`
}
return "Completion"
}
// File-based tools // File-based tools
const filePath = const filePath =
getField("file_path") ?? getField("file_path") ??
@@ -2282,6 +2294,43 @@ const ToolCallPart = memo(function ToolCallPart({
toolNameLower === "enterplanmode" || toolNameLower === "enterplanmode" ||
toolNameLower === "exitplanmode") && toolNameLower === "exitplanmode") &&
!part.errorText !part.errorText
// Cline: attempt_completion — render as an expanded card with result + progress
if (toolNameLower === "attempt_completion") {
const parsedCompletion = tryParseJson(part.input ?? "")
const completionResult =
(parsedCompletion?.result as string | undefined)?.trim() ?? null
const taskProgress =
(parsedCompletion?.task_progress as string | undefined)?.trim() ?? null
return (
<Tool open onOpenChange={setManualOpen}>
<ToolHeader
type="dynamic-tool"
state={part.state}
toolName={normalizedToolName}
title={title ?? "Completion"}
icon={icon}
/>
<ToolContent>
{completionResult && (
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{completionResult}</MessageResponse>
</div>
)}
{taskProgress && (
<div className="mt-2 rounded-md border bg-muted/30 px-3 py-2">
<div className="text-[11px] font-medium text-muted-foreground mb-1">
Progress
</div>
<div className="text-xs prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{taskProgress}</MessageResponse>
</div>
</div>
)}
</ToolContent>
</Tool>
)
}
const open = (isRunning && (isCommandTool || hasLiveOutput)) || manualOpen const open = (isRunning && (isCommandTool || hasLiveOutput)) || manualOpen
return ( return (

View File

@@ -107,6 +107,10 @@ interface AgentDraft {
openClawGatewayUrl: string openClawGatewayUrl: string
openClawGatewayToken: string openClawGatewayToken: string
openClawSessionKey: string openClawSessionKey: string
clineProvider: ClineProvider
clineApiKey: string
clineModel: string
clineBaseUrl: string
} }
type RunningActionKind = type RunningActionKind =
@@ -250,6 +254,20 @@ const OPENCLAW_ENV_KEYS = {
sessionKey: "OPENCLAW_SESSION_KEY", sessionKey: "OPENCLAW_SESSION_KEY",
} as const } as const
const CLINE_PROVIDERS = [
{ value: "anthropic", label: "Anthropic" },
{ value: "openai-native", label: "OpenAI" },
{ value: "openai", label: "OpenAI Compatible" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "gemini", label: "Gemini" },
{ value: "deepseek", label: "DeepSeek" },
{ value: "bedrock", label: "AWS Bedrock" },
{ value: "vertex", label: "GCP Vertex" },
{ value: "ollama", label: "Ollama" },
] as const
type ClineProvider = (typeof CLINE_PROVIDERS)[number]["value"]
type ClaudeModelKey = keyof typeof CLAUDE_MODEL_ENV_KEYS type ClaudeModelKey = keyof typeof CLAUDE_MODEL_ENV_KEYS
type ImportantConfigKey = "apiBaseUrl" | "apiKey" | "model" | ClaudeModelKey type ImportantConfigKey = "apiBaseUrl" | "apiKey" | "model" | ClaudeModelKey
type ImportantDraftPatch = Partial<Pick<AgentDraft, ImportantConfigKey>> type ImportantDraftPatch = Partial<Pick<AgentDraft, ImportantConfigKey>>
@@ -564,6 +582,26 @@ interface OpenClawImportantValues {
sessionKey: string sessionKey: string
} }
interface ClineImportantValues {
provider: ClineProvider
apiKey: string
model: string
baseUrl: string
}
function extractClineImportantValues(configText: string): ClineImportantValues {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.config
return {
provider: (typeof config.apiProvider === "string" && config.apiProvider
? config.apiProvider
: "anthropic") as ClineProvider,
apiKey: typeof config.apiKey === "string" ? config.apiKey : "",
model: typeof config.model === "string" ? config.model : "",
baseUrl: typeof config.apiBaseUrl === "string" ? config.apiBaseUrl : "",
}
}
function extractOpenClawImportantValues( function extractOpenClawImportantValues(
env: Record<string, string>, env: Record<string, string>,
configText: string configText: string
@@ -1965,6 +2003,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft {
configText, configText,
openCodeAuthJsonText openCodeAuthJsonText
) )
const clineImportant = extractClineImportantValues(configText)
return { return {
enabled: agent.enabled, enabled: agent.enabled,
envText: envMapToText(agent.env), envText: envMapToText(agent.env),
@@ -2014,6 +2053,10 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft {
openClawGatewayUrl: openClawImportant.gatewayUrl, openClawGatewayUrl: openClawImportant.gatewayUrl,
openClawGatewayToken: openClawImportant.gatewayToken, openClawGatewayToken: openClawImportant.gatewayToken,
openClawSessionKey: openClawImportant.sessionKey, openClawSessionKey: openClawImportant.sessionKey,
clineProvider: clineImportant.provider,
clineApiKey: clineImportant.apiKey,
clineModel: clineImportant.model,
clineBaseUrl: clineImportant.baseUrl,
} }
} }
@@ -3059,6 +3102,19 @@ export function AcpAgentSettings() {
return return
} }
if (selectedAgent.agent_type === "cline") {
const cline = extractClineImportantValues(nextText)
updateSelectedDraft((current) => ({
...current,
configText: nextText,
clineProvider: cline.provider,
clineApiKey: cline.apiKey,
clineModel: cline.model,
clineBaseUrl: cline.baseUrl,
}))
return
}
const important = extractImportantConfigValues( const important = extractImportantConfigValues(
selectedAgent.agent_type, selectedAgent.agent_type,
parseEnvText(selectedDraft.envText), parseEnvText(selectedDraft.envText),
@@ -3321,6 +3377,37 @@ export function AcpAgentSettings() {
[selectedAgent, selectedDraft, updateSelectedDraft] [selectedAgent, selectedDraft, updateSelectedDraft]
) )
const handleClineFieldChange = useCallback(
(
key: "clineProvider" | "clineApiKey" | "clineModel" | "clineBaseUrl",
value: string
) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "cline"
)
return
updateSelectedDraft((current) => {
const next = { ...current, [key]: value }
// Rebuild config_json from Cline draft fields
const config: Record<string, unknown> = {}
config.apiProvider =
key === "clineProvider" ? value : next.clineProvider
const apiKey = key === "clineApiKey" ? value : next.clineApiKey
if (apiKey.trim()) config.apiKey = apiKey.trim()
const model = key === "clineModel" ? value : next.clineModel
if (model.trim()) config.model = model.trim()
const baseUrl = key === "clineBaseUrl" ? value : next.clineBaseUrl
if (baseUrl.trim()) config.apiBaseUrl = baseUrl.trim()
next.configText = JSON.stringify(config, null, 2)
return next
})
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleOpenCodeConfigPatch = useCallback( const handleOpenCodeConfigPatch = useCallback(
(mutator: (config: Record<string, unknown>) => void) => { (mutator: (config: Record<string, unknown>) => void) => {
if ( if (
@@ -5694,6 +5781,188 @@ supports_websockets = true`}
</Button> </Button>
</div> </div>
</div> </div>
) : selectedAgent.agent_type === "cline" ? (
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
<div>
<label className="text-xs font-medium">Cline</label>
<p className="mt-1 text-[11px] text-muted-foreground">
{t("cline.configDescription")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Provider
</label>
<Select
value={selectedDraft.clineProvider}
onValueChange={(value) => {
handleClineFieldChange("clineProvider", value)
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLINE_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
API Key
</label>
<div className="flex items-center gap-2">
<Input
type={
showApiKeys[selectedAgent.agent_type]
? "text"
: "password"
}
value={selectedDraft.clineApiKey}
onChange={(event) => {
handleClineFieldChange(
"clineApiKey",
event.target.value
)
}}
placeholder="sk-..."
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowApiKeys((prev) => ({
...prev,
[selectedAgent.agent_type]:
!prev[selectedAgent.agent_type],
}))
}}
title={
showApiKeys[selectedAgent.agent_type]
? t("actions.hideApiKey")
: t("actions.showApiKey")
}
>
{showApiKeys[selectedAgent.agent_type] ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Model
</label>
<Input
value={selectedDraft.clineModel}
onChange={(event) => {
handleClineFieldChange(
"clineModel",
event.target.value
)
}}
placeholder="claude-sonnet-4-5-20250514"
/>
</div>
{(selectedDraft.clineProvider === "anthropic" ||
selectedDraft.clineProvider === "openai-native" ||
selectedDraft.clineProvider === "openai" ||
selectedDraft.clineProvider === "openrouter" ||
selectedDraft.clineProvider === "ollama") && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
API URL
</label>
<Input
value={selectedDraft.clineBaseUrl}
onChange={(event) => {
handleClineFieldChange(
"clineBaseUrl",
event.target.value
)
}}
placeholder="https://api.openai.com"
/>
</div>
)}
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("nativeJsonConfig")} (config)
</label>
<Textarea
value={selectedDraft.configText}
onChange={(event) => {
handleConfigTextChange(event.target.value)
}}
className="min-h-24 font-mono text-xs"
placeholder={`{
"apiProvider": "anthropic",
"apiKey": "sk-...",
"model": "claude-sonnet-4-5-20250514"
}`}
/>
{selectedConfigError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-2.5 py-1.5 text-[11px] text-red-400">
{selectedConfigError}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
onClick={() => {
persistPreferences(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.configText
)
.then(() => {
toast.success(t("toasts.clineSaved"), {
description: t("toasts.configSavedHint"),
})
})
.catch((err) => {
console.error(
"[Settings] save cline config failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveClineFailed"), {
description: message,
})
})
}}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveClineConfig")}
</>
)}
</Button>
</div>
</div>
) : selectedAgent.agent_type === "open_claw" ? ( ) : selectedAgent.agent_type === "open_claw" ? (
<div className="space-y-3 rounded-md border bg-muted/10 p-3"> <div className="space-y-3 rounded-md border bg-muted/10 p-3">
<div> <div>

View File

@@ -486,7 +486,8 @@
"delete": "حذف", "delete": "حذف",
"deleting": "جارٍ الحذف...", "deleting": "جارٍ الحذف...",
"confirmDelete": "تأكيد الحذف", "confirmDelete": "تأكيد الحذف",
"confirmUninstall": "تأكيد إزالة التثبيت" "confirmUninstall": "تأكيد إزالة التثبيت",
"saveClineConfig": "حفظ تكوين Cline"
}, },
"status": { "status": {
"enabled": "مفعّل", "enabled": "مفعّل",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "فشل حفظ إعداد OpenClaw", "saveOpenClawFailed": "فشل حفظ إعداد OpenClaw",
"configSaved": "تم حفظ الإعداد", "configSaved": "تم حفظ الإعداد",
"configSavedHint": "يجب إعادة فتح الجلسات الحالية لتطبيق التغييرات", "configSavedHint": "يجب إعادة فتح الجلسات الحالية لتطبيق التغييرات",
"saveConfigManagementFailed": "فشل حفظ إدارة الإعدادات" "saveConfigManagementFailed": "فشل حفظ إدارة الإعدادات",
"clineSaved": "تم حفظ تكوين Cline",
"saveClineFailed": "فشل في حفظ تكوين Cline"
}, },
"version": { "version": {
"statusLabel": "حالة الإصدار", "statusLabel": "حالة الإصدار",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}. تتوفر ترقية.", "upgradeAvailable": "{versionText}. تتوفر ترقية.",
"remoteUnavailable": "{versionText}. الإصدار البعيد غير متاح حاليًا.", "remoteUnavailable": "{versionText}. الإصدار البعيد غير متاح حاليًا.",
"latest": "{versionText}. أنت على أحدث إصدار." "latest": "{versionText}. أنت على أحدث إصدار."
},
"cline": {
"configDescription": "تكوين مزود واجهة برمجة التطبيقات وبيانات اعتماد Cline. يتم حفظ الإعدادات في ~/.cline/data/."
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "Löschen", "delete": "Löschen",
"deleting": "Löschen...", "deleting": "Löschen...",
"confirmDelete": "Löschen bestätigen", "confirmDelete": "Löschen bestätigen",
"confirmUninstall": "Deinstallation bestätigen" "confirmUninstall": "Deinstallation bestätigen",
"saveClineConfig": "Cline-Konfiguration speichern"
}, },
"status": { "status": {
"enabled": "Aktiviert", "enabled": "Aktiviert",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "Speichern der OpenClaw-Konfiguration fehlgeschlagen", "saveOpenClawFailed": "Speichern der OpenClaw-Konfiguration fehlgeschlagen",
"configSaved": "Konfiguration gespeichert", "configSaved": "Konfiguration gespeichert",
"configSavedHint": "Bestehende Sitzungen müssen neu geöffnet werden, damit die Änderungen wirksam werden", "configSavedHint": "Bestehende Sitzungen müssen neu geöffnet werden, damit die Änderungen wirksam werden",
"saveConfigManagementFailed": "Speichern der Konfigurationsverwaltung fehlgeschlagen" "saveConfigManagementFailed": "Speichern der Konfigurationsverwaltung fehlgeschlagen",
"clineSaved": "Cline-Konfiguration gespeichert",
"saveClineFailed": "Cline-Konfiguration konnte nicht gespeichert werden"
}, },
"version": { "version": {
"statusLabel": "Versionsstatus", "statusLabel": "Versionsstatus",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}. Upgrade verfügbar.", "upgradeAvailable": "{versionText}. Upgrade verfügbar.",
"remoteUnavailable": "{versionText}. Remote-Version ist derzeit nicht verfügbar.", "remoteUnavailable": "{versionText}. Remote-Version ist derzeit nicht verfügbar.",
"latest": "{versionText}. Bereits aktuell." "latest": "{versionText}. Bereits aktuell."
},
"cline": {
"configDescription": "Konfigurieren Sie den Cline API-Anbieter und die Anmeldedaten. Einstellungen werden in ~/.cline/data/ gespeichert."
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "Delete", "delete": "Delete",
"deleting": "Deleting...", "deleting": "Deleting...",
"confirmDelete": "Confirm Delete", "confirmDelete": "Confirm Delete",
"confirmUninstall": "Confirm Uninstall" "confirmUninstall": "Confirm Uninstall",
"saveClineConfig": "Save Cline Config"
}, },
"status": { "status": {
"enabled": "Enabled", "enabled": "Enabled",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "Failed to save OpenClaw config", "saveOpenClawFailed": "Failed to save OpenClaw config",
"configSaved": "Config saved", "configSaved": "Config saved",
"configSavedHint": "Existing sessions need to be reopened to take effect", "configSavedHint": "Existing sessions need to be reopened to take effect",
"saveConfigManagementFailed": "Failed to save config management" "saveConfigManagementFailed": "Failed to save config management",
"clineSaved": "Cline config saved",
"saveClineFailed": "Failed to save Cline config"
}, },
"version": { "version": {
"statusLabel": "Version Status", "statusLabel": "Version Status",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}. Upgrade available.", "upgradeAvailable": "{versionText}. Upgrade available.",
"remoteUnavailable": "{versionText}. Remote version is currently unavailable.", "remoteUnavailable": "{versionText}. Remote version is currently unavailable.",
"latest": "{versionText}. Already latest." "latest": "{versionText}. Already latest."
},
"cline": {
"configDescription": "Configure Cline API provider and credentials. Settings are saved to ~/.cline/data/."
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "Eliminar", "delete": "Eliminar",
"deleting": "Eliminando...", "deleting": "Eliminando...",
"confirmDelete": "Confirmar eliminación", "confirmDelete": "Confirmar eliminación",
"confirmUninstall": "Confirmar desinstalación" "confirmUninstall": "Confirmar desinstalación",
"saveClineConfig": "Guardar configuración de Cline"
}, },
"status": { "status": {
"enabled": "Habilitado", "enabled": "Habilitado",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "No se pudo guardar la configuración de OpenClaw", "saveOpenClawFailed": "No se pudo guardar la configuración de OpenClaw",
"configSaved": "Configuración guardada", "configSaved": "Configuración guardada",
"configSavedHint": "Las sesiones existentes deben reabrirse para que surta efecto", "configSavedHint": "Las sesiones existentes deben reabrirse para que surta efecto",
"saveConfigManagementFailed": "No se pudo guardar la gestión de configuración" "saveConfigManagementFailed": "No se pudo guardar la gestión de configuración",
"clineSaved": "Configuración de Cline guardada",
"saveClineFailed": "Error al guardar la configuración de Cline"
}, },
"version": { "version": {
"statusLabel": "Estado de versión", "statusLabel": "Estado de versión",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}. Hay actualización disponible.", "upgradeAvailable": "{versionText}. Hay actualización disponible.",
"remoteUnavailable": "{versionText}. La versión remota no está disponible por ahora.", "remoteUnavailable": "{versionText}. La versión remota no está disponible por ahora.",
"latest": "{versionText}. Ya está en la última versión." "latest": "{versionText}. Ya está en la última versión."
},
"cline": {
"configDescription": "Configure el proveedor de API y las credenciales de Cline. La configuración se guarda en ~/.cline/data/."
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "Supprimer", "delete": "Supprimer",
"deleting": "Suppression...", "deleting": "Suppression...",
"confirmDelete": "Confirmer la suppression", "confirmDelete": "Confirmer la suppression",
"confirmUninstall": "Confirmer la désinstallation" "confirmUninstall": "Confirmer la désinstallation",
"saveClineConfig": "Enregistrer la configuration Cline"
}, },
"status": { "status": {
"enabled": "Activé", "enabled": "Activé",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "Échec de lenregistrement de la configuration OpenClaw", "saveOpenClawFailed": "Échec de lenregistrement de la configuration OpenClaw",
"configSaved": "Configuration enregistrée", "configSaved": "Configuration enregistrée",
"configSavedHint": "Les sessions existantes doivent être rouvertes pour prendre effet", "configSavedHint": "Les sessions existantes doivent être rouvertes pour prendre effet",
"saveConfigManagementFailed": "Échec de lenregistrement de la gestion de configuration" "saveConfigManagementFailed": "Échec de lenregistrement de la gestion de configuration",
"clineSaved": "Configuration Cline enregistrée",
"saveClineFailed": "Échec de l'enregistrement de la configuration Cline"
}, },
"version": { "version": {
"statusLabel": "Statut de version", "statusLabel": "Statut de version",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}. Mise à niveau disponible.", "upgradeAvailable": "{versionText}. Mise à niveau disponible.",
"remoteUnavailable": "{versionText}. La version distante est actuellement indisponible.", "remoteUnavailable": "{versionText}. La version distante est actuellement indisponible.",
"latest": "{versionText}. Déjà à jour." "latest": "{versionText}. Déjà à jour."
},
"cline": {
"configDescription": "Configurez le fournisseur API et les identifiants Cline. Les paramètres sont enregistrés dans ~/.cline/data/."
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "削除", "delete": "削除",
"deleting": "削除中...", "deleting": "削除中...",
"confirmDelete": "削除を確認", "confirmDelete": "削除を確認",
"confirmUninstall": "アンインストールを確認" "confirmUninstall": "アンインストールを確認",
"saveClineConfig": "Cline設定を保存"
}, },
"status": { "status": {
"enabled": "有効", "enabled": "有効",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "OpenClaw設定の保存に失敗しました", "saveOpenClawFailed": "OpenClaw設定の保存に失敗しました",
"configSaved": "設定を保存しました", "configSaved": "設定を保存しました",
"configSavedHint": "既存のセッションは再度開く必要があります", "configSavedHint": "既存のセッションは再度開く必要があります",
"saveConfigManagementFailed": "設定管理の保存に失敗しました" "saveConfigManagementFailed": "設定管理の保存に失敗しました",
"clineSaved": "Cline設定を保存しました",
"saveClineFailed": "Cline設定の保存に失敗しました"
}, },
"version": { "version": {
"statusLabel": "バージョン状態", "statusLabel": "バージョン状態",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}。アップグレード可能です。", "upgradeAvailable": "{versionText}。アップグレード可能です。",
"remoteUnavailable": "{versionText}。現在リモートバージョンは取得できません。", "remoteUnavailable": "{versionText}。現在リモートバージョンは取得できません。",
"latest": "{versionText}。すでに最新です。" "latest": "{versionText}。すでに最新です。"
},
"cline": {
"configDescription": "Cline API プロバイダーと認証情報を設定します。設定は ~/.cline/data/ に保存されます。"
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "삭제", "delete": "삭제",
"deleting": "삭제 중...", "deleting": "삭제 중...",
"confirmDelete": "삭제 확인", "confirmDelete": "삭제 확인",
"confirmUninstall": "제거 확인" "confirmUninstall": "제거 확인",
"saveClineConfig": "Cline 설정 저장"
}, },
"status": { "status": {
"enabled": "활성화됨", "enabled": "활성화됨",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "OpenClaw 설정 저장 실패", "saveOpenClawFailed": "OpenClaw 설정 저장 실패",
"configSaved": "구성이 저장되었습니다", "configSaved": "구성이 저장되었습니다",
"configSavedHint": "기존 세션은 다시 열어야 적용됩니다", "configSavedHint": "기존 세션은 다시 열어야 적용됩니다",
"saveConfigManagementFailed": "구성 관리 저장 실패" "saveConfigManagementFailed": "구성 관리 저장 실패",
"clineSaved": "Cline 설정 저장됨",
"saveClineFailed": "Cline 설정 저장 실패"
}, },
"version": { "version": {
"statusLabel": "버전 상태", "statusLabel": "버전 상태",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}. 업그레이드가 가능합니다.", "upgradeAvailable": "{versionText}. 업그레이드가 가능합니다.",
"remoteUnavailable": "{versionText}. 현재 원격 버전을 사용할 수 없습니다.", "remoteUnavailable": "{versionText}. 현재 원격 버전을 사용할 수 없습니다.",
"latest": "{versionText}. 이미 최신입니다." "latest": "{versionText}. 이미 최신입니다."
},
"cline": {
"configDescription": "Cline API 제공자와 자격 증명을 구성합니다. 설정은 ~/.cline/data/에 저장됩니다."
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "Excluir", "delete": "Excluir",
"deleting": "Excluindo...", "deleting": "Excluindo...",
"confirmDelete": "Confirmar exclusão", "confirmDelete": "Confirmar exclusão",
"confirmUninstall": "Confirmar desinstalação" "confirmUninstall": "Confirmar desinstalação",
"saveClineConfig": "Salvar configuração do Cline"
}, },
"status": { "status": {
"enabled": "Habilitado", "enabled": "Habilitado",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "Falha ao salvar configuração do OpenClaw", "saveOpenClawFailed": "Falha ao salvar configuração do OpenClaw",
"configSaved": "Configuração salva", "configSaved": "Configuração salva",
"configSavedHint": "Sessões existentes precisam ser reabertas para que as alterações tenham efeito", "configSavedHint": "Sessões existentes precisam ser reabertas para que as alterações tenham efeito",
"saveConfigManagementFailed": "Falha ao salvar o gerenciamento de configuração" "saveConfigManagementFailed": "Falha ao salvar o gerenciamento de configuração",
"clineSaved": "Configuração do Cline salva",
"saveClineFailed": "Falha ao salvar a configuração do Cline"
}, },
"version": { "version": {
"statusLabel": "Status da versão", "statusLabel": "Status da versão",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}. Atualização disponível.", "upgradeAvailable": "{versionText}. Atualização disponível.",
"remoteUnavailable": "{versionText}. A versão remota está indisponível no momento.", "remoteUnavailable": "{versionText}. A versão remota está indisponível no momento.",
"latest": "{versionText}. Já está na versão mais recente." "latest": "{versionText}. Já está na versão mais recente."
},
"cline": {
"configDescription": "Configure o provedor de API e as credenciais do Cline. As configurações são salvas em ~/.cline/data/."
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "删除", "delete": "删除",
"deleting": "删除中...", "deleting": "删除中...",
"confirmDelete": "确认删除", "confirmDelete": "确认删除",
"confirmUninstall": "确认卸载" "confirmUninstall": "确认卸载",
"saveClineConfig": "保存 Cline 配置"
}, },
"status": { "status": {
"enabled": "启用", "enabled": "启用",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "保存 OpenClaw 配置失败", "saveOpenClawFailed": "保存 OpenClaw 配置失败",
"configSaved": "配置已保存", "configSaved": "配置已保存",
"configSavedHint": "已有会话需要重新打开才能生效", "configSavedHint": "已有会话需要重新打开才能生效",
"saveConfigManagementFailed": "保存配置管理失败" "saveConfigManagementFailed": "保存配置管理失败",
"clineSaved": "Cline 配置已保存",
"saveClineFailed": "保存 Cline 配置失败"
}, },
"version": { "version": {
"statusLabel": "版本状态", "statusLabel": "版本状态",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}。发现可升级版本。", "upgradeAvailable": "{versionText}。发现可升级版本。",
"remoteUnavailable": "{versionText}。远程版本暂不可用。", "remoteUnavailable": "{versionText}。远程版本暂不可用。",
"latest": "{versionText}。已是最新版本。" "latest": "{versionText}。已是最新版本。"
},
"cline": {
"configDescription": "配置 Cline API 提供商和凭证。设置将保存到 ~/.cline/data/。"
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -486,7 +486,8 @@
"delete": "刪除", "delete": "刪除",
"deleting": "刪除中...", "deleting": "刪除中...",
"confirmDelete": "確認刪除", "confirmDelete": "確認刪除",
"confirmUninstall": "確認卸載" "confirmUninstall": "確認卸載",
"saveClineConfig": "儲存 Cline 配置"
}, },
"status": { "status": {
"enabled": "啟用", "enabled": "啟用",
@@ -618,7 +619,9 @@
"saveOpenClawFailed": "儲存 OpenClaw 配置失敗", "saveOpenClawFailed": "儲存 OpenClaw 配置失敗",
"configSaved": "配置已儲存", "configSaved": "配置已儲存",
"configSavedHint": "已有會話需要重新打開才能生效", "configSavedHint": "已有會話需要重新打開才能生效",
"saveConfigManagementFailed": "儲存配置管理失敗" "saveConfigManagementFailed": "儲存配置管理失敗",
"clineSaved": "Cline 配置已儲存",
"saveClineFailed": "儲存 Cline 配置失敗"
}, },
"version": { "version": {
"statusLabel": "版本狀態", "statusLabel": "版本狀態",
@@ -630,6 +633,9 @@
"upgradeAvailable": "{versionText}。發現可升級版本。", "upgradeAvailable": "{versionText}。發現可升級版本。",
"remoteUnavailable": "{versionText}。遠端版本暫不可用。", "remoteUnavailable": "{versionText}。遠端版本暫不可用。",
"latest": "{versionText}。已是最新版本。" "latest": "{versionText}。已是最新版本。"
},
"cline": {
"configDescription": "配置 Cline API 提供商和憑證。設定將儲存到 ~/.cline/data/。"
} }
}, },
"SettingsPages": { "SettingsPages": {

View File

@@ -38,6 +38,17 @@ const EXACT_TOOL_NAME_ALIASES: Record<string, string> = {
search_text: "grep", search_text: "grep",
writefile: "write", writefile: "write",
editfile: "edit", editfile: "edit",
// Cline
attempt_completion: "attempt_completion",
ask_followup_question: "question",
write_to_file: "write",
replace_in_file: "edit",
execute_command: "bash",
list_files: "glob",
search_files: "grep",
list_code_definition_names: "grep",
browser_action: "webfetch",
use_mcp_tool: "tool",
// Codex // Codex
update_plan: "task", update_plan: "task",
request_user_input: "question", request_user_input: "question",

View File

@@ -4,6 +4,7 @@ export type AgentType =
| "open_code" | "open_code"
| "gemini" | "gemini"
| "open_claw" | "open_claw"
| "cline"
export type AppErrorCode = export type AppErrorCode =
| "invalid_input" | "invalid_input"
@@ -206,6 +207,7 @@ export const AGENT_DISPLAY_ORDER: AgentType[] = [
"open_code", "open_code",
"gemini", "gemini",
"open_claw", "open_claw",
"cline",
] ]
const AGENT_DISPLAY_ORDER_INDEX = new Map( const AGENT_DISPLAY_ORDER_INDEX = new Map(
@@ -224,6 +226,7 @@ export const AGENT_LABELS: Record<AgentType, string> = {
open_code: "OpenCode", open_code: "OpenCode",
gemini: "Gemini CLI", gemini: "Gemini CLI",
open_claw: "OpenClaw", open_claw: "OpenClaw",
cline: "Cline",
} }
export const AGENT_COLORS: Record<AgentType, string> = { export const AGENT_COLORS: Record<AgentType, string> = {
@@ -232,6 +235,7 @@ export const AGENT_COLORS: Record<AgentType, string> = {
open_code: "bg-blue-500", open_code: "bg-blue-500",
gemini: "bg-blue-400", gemini: "bg-blue-400",
open_claw: "bg-emerald-600", open_claw: "bg-emerald-600",
cline: "bg-purple-500",
} }
// ACP connection status (matches Rust ConnectionStatus) // ACP connection status (matches Rust ConnectionStatus)
@@ -461,6 +465,7 @@ export interface AcpAgentInfo {
opencode_auth_json: string | null opencode_auth_json: string | null
codex_auth_json: string | null codex_auth_json: string | null
codex_config_toml: string | null codex_config_toml: string | null
cline_secrets_json: string | null
} }
// Lightweight agent status returned by acp_get_agent_status // Lightweight agent status returned by acp_get_agent_status