mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
Merge branch 'main' into fix/common-config-sync-improvements
This commit is contained in:
@@ -13,6 +13,7 @@ mod prompt;
|
||||
mod provider;
|
||||
mod proxy;
|
||||
mod settings;
|
||||
mod session_manager;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod usage;
|
||||
@@ -30,6 +31,7 @@ pub use prompt::*;
|
||||
pub use provider::*;
|
||||
pub use proxy::*;
|
||||
pub use settings::*;
|
||||
pub use session_manager::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::session_manager;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sessions() -> Result<Vec<session_manager::SessionMeta>, String> {
|
||||
let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to scan sessions: {e}"))?;
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_session_messages(
|
||||
providerId: String,
|
||||
sourcePath: String,
|
||||
) -> Result<Vec<session_manager::SessionMessage>, String> {
|
||||
let provider_id = providerId.clone();
|
||||
let source_path = sourcePath.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
session_manager::load_messages(&provider_id, &source_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load session messages: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn launch_session_terminal(
|
||||
target: String,
|
||||
command: String,
|
||||
cwd: Option<String>,
|
||||
custom_config: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let command = command.clone();
|
||||
let target = target.clone();
|
||||
let cwd = cwd.clone();
|
||||
let custom_config = custom_config.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
session_manager::terminal::launch_terminal(
|
||||
&target,
|
||||
&command,
|
||||
cwd.as_deref(),
|
||||
custom_config.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch terminal: {e}"))??;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -7,14 +6,6 @@ use crate::config::atomic_write;
|
||||
use crate::error::AppError;
|
||||
use crate::gemini_config::get_gemini_settings_path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpStatus {
|
||||
pub user_config_path: String,
|
||||
pub user_config_exists: bool,
|
||||
pub server_count: usize,
|
||||
}
|
||||
|
||||
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json)
|
||||
fn user_config_path() -> PathBuf {
|
||||
get_gemini_settings_path()
|
||||
|
||||
@@ -21,6 +21,7 @@ mod prompt_files;
|
||||
mod provider;
|
||||
mod provider_defaults;
|
||||
mod proxy;
|
||||
mod session_manager;
|
||||
mod services;
|
||||
mod settings;
|
||||
mod store;
|
||||
@@ -934,6 +935,10 @@ pub fn run() {
|
||||
commands::stream_check_all_providers,
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
// Session manager
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
commands::open_provider_terminal,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::time::Instant;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo};
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
|
||||
|
||||
/// 健康状态枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -303,12 +303,18 @@ impl StreamCheckService {
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// 严格按照 Claude CLI 请求格式设置 headers
|
||||
let response = client
|
||||
// 根据 auth.strategy 构建认证 headers
|
||||
let mut request_builder = client
|
||||
.post(&url)
|
||||
// 认证 headers(双重认证)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key)
|
||||
.header("authorization", format!("Bearer {}", auth.api_key));
|
||||
|
||||
// 只有 Anthropic 官方策略才添加 x-api-key
|
||||
if auth.strategy == AuthStrategy::Anthropic {
|
||||
request_builder = request_builder.header("x-api-key", &auth.api_key);
|
||||
}
|
||||
|
||||
// 严格按照 Claude CLI 请求格式设置其他 headers
|
||||
let response = request_builder
|
||||
// Anthropic 必需 headers
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header(
|
||||
@@ -703,4 +709,22 @@ mod tests {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
assert_eq!(arch_name, "x86_64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_strategy_imports() {
|
||||
// 验证 AuthStrategy 枚举可以正常使用
|
||||
let anthropic = AuthStrategy::Anthropic;
|
||||
let claude_auth = AuthStrategy::ClaudeAuth;
|
||||
let bearer = AuthStrategy::Bearer;
|
||||
|
||||
// 验证不同的策略是不相等的
|
||||
assert_ne!(anthropic, claude_auth);
|
||||
assert_ne!(anthropic, bearer);
|
||||
assert_ne!(claude_auth, bearer);
|
||||
|
||||
// 验证相同策略是相等的
|
||||
assert_eq!(anthropic, AuthStrategy::Anthropic);
|
||||
assert_eq!(claude_auth, AuthStrategy::ClaudeAuth);
|
||||
assert_eq!(bearer, AuthStrategy::Bearer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
pub mod providers;
|
||||
pub mod terminal;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
use providers::{claude, codex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionMeta {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub project_dir: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_active_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resume_command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ts: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let mut sessions = Vec::new();
|
||||
sessions.extend(codex::scan_sessions());
|
||||
sessions.extend(claude::scan_sessions());
|
||||
|
||||
sessions.sort_by(|a, b| {
|
||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||
let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0);
|
||||
b_ts.cmp(&a_ts)
|
||||
});
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {
|
||||
let path = Path::new(source_path);
|
||||
match provider_id {
|
||||
"codex" => codex::load_messages(path),
|
||||
"claude" => claude::load_messages(path),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::get_claude_config_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "claude";
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let root = get_claude_config_dir().join("projects");
|
||||
let mut files = Vec::new();
|
||||
collect_jsonl_files(&root, &mut files);
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for path in files {
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(message) => message,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let role = message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let content = message
|
||||
.get("content")
|
||||
.map(extract_text)
|
||||
.unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = value
|
||||
.get("timestamp")
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
|
||||
messages.push(SessionMessage { role, content, ts });
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
if is_agent_session(path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if session_id.is_none() {
|
||||
session_id = value
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if project_dir.is_none() {
|
||||
project_dir = value
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
if created_at.is_none() {
|
||||
created_at = Some(ts);
|
||||
}
|
||||
last_active_at = Some(ts);
|
||||
}
|
||||
|
||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(message) => message,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let text = message
|
||||
.get("content")
|
||||
.map(extract_text)
|
||||
.unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title,
|
||||
summary,
|
||||
project_dir,
|
||||
created_at,
|
||||
last_active_at,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
resume_command: Some(format!("claude --resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_agent_session(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.starts_with("agent-"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||
path.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|stem| stem.to_string())
|
||||
}
|
||||
|
||||
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
if !root.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_jsonl_files(&path, files);
|
||||
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::codex_config::get_codex_config_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "codex";
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let root = get_codex_config_dir().join("sessions");
|
||||
let mut files = Vec::new();
|
||||
collect_jsonl_files(&root, &mut files);
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for path in files {
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = match value.get("payload") {
|
||||
Some(payload) => payload,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let role = payload
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let content = payload
|
||||
.get("content")
|
||||
.map(extract_text)
|
||||
.unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = value
|
||||
.get("timestamp")
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
|
||||
messages.push(SessionMessage { role, content, ts });
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
if created_at.is_none() {
|
||||
created_at = Some(ts);
|
||||
}
|
||||
last_active_at = Some(ts);
|
||||
}
|
||||
|
||||
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
|
||||
if let Some(payload) = value.get("payload") {
|
||||
if session_id.is_none() {
|
||||
session_id = payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if project_dir.is_none() {
|
||||
project_dir = payload
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(ts) = payload.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
created_at.get_or_insert(ts);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = match value.get("payload") {
|
||||
Some(payload) => payload,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = payload
|
||||
.get("content")
|
||||
.map(extract_text)
|
||||
.unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title,
|
||||
summary,
|
||||
project_dir,
|
||||
created_at,
|
||||
last_active_at,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
resume_command: Some(format!("codex resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||
let file_name = path.file_name()?.to_string_lossy();
|
||||
let re = Regex::new(
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
|
||||
)
|
||||
.ok()?;
|
||||
re.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||
}
|
||||
|
||||
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
if !root.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_jsonl_files(&path, files);
|
||||
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
mod utils;
|
||||
@@ -0,0 +1,77 @@
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
|
||||
let raw = value.as_str()?;
|
||||
DateTime::parse_from_rfc3339(raw)
|
||||
.ok()
|
||||
.map(|dt: DateTime<FixedOffset>| dt.timestamp_millis())
|
||||
}
|
||||
|
||||
pub fn extract_text(content: &Value) -> String {
|
||||
match content {
|
||||
Value::String(text) => text.to_string(),
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(|item| extract_text_from_item(item))
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Value::Object(map) => map
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text_from_item(item: &Value) -> Option<String> {
|
||||
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
if let Some(text) = item.get("input_text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
if let Some(text) = item.get("output_text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
if let Some(content) = item.get("content") {
|
||||
let text = extract_text(content);
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn truncate_summary(text: &str, max_chars: usize) -> String {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if trimmed.chars().count() <= max_chars {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
let mut result = trimmed.chars().take(max_chars).collect::<String>();
|
||||
result.push_str("...");
|
||||
result
|
||||
}
|
||||
|
||||
pub fn path_basename(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let normalized = trimmed.trim_end_matches(|c| c == '/' || c == '\\');
|
||||
let last = normalized
|
||||
.split(['/', '\\'])
|
||||
.last()
|
||||
.filter(|segment| !segment.is_empty())?;
|
||||
Some(last.to_string())
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use std::process::Command;
|
||||
|
||||
pub fn launch_terminal(
|
||||
target: &str,
|
||||
command: &str,
|
||||
cwd: Option<&str>,
|
||||
custom_config: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
if command.trim().is_empty() {
|
||||
return Err("Resume command is empty".to_string());
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err("Terminal resume is only supported on macOS".to_string());
|
||||
}
|
||||
|
||||
match target {
|
||||
"terminal" => launch_macos_terminal(command, cwd),
|
||||
"iTerm" | "iterm" => launch_iterm(command, cwd),
|
||||
"ghostty" => launch_ghostty(command, cwd),
|
||||
"kitty" => launch_kitty(command, cwd),
|
||||
"wezterm" => launch_wezterm(command, cwd),
|
||||
"alacritty" => launch_alacritty(command, cwd),
|
||||
"custom" => launch_custom(command, cwd, custom_config),
|
||||
_ => Err(format!("Unsupported terminal target: {target}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
let full_command = build_shell_command(command, cwd);
|
||||
let escaped = escape_osascript(&full_command);
|
||||
let script = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "{}"
|
||||
end tell"#,
|
||||
escaped
|
||||
);
|
||||
|
||||
let status = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(script)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Terminal: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Terminal command execution failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
let full_command = build_shell_command(command, cwd);
|
||||
let escaped = escape_osascript(&full_command);
|
||||
// iTerm2 AppleScript to create a new window and execute command
|
||||
let script = format!(
|
||||
r#"tell application "iTerm"
|
||||
activate
|
||||
create window with default profile
|
||||
tell current session of current window
|
||||
write text "{}"
|
||||
end tell
|
||||
end tell"#,
|
||||
escaped
|
||||
);
|
||||
|
||||
let status = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(script)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch iTerm: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("iTerm command execution failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// Ghostty usage: open -na Ghostty --args +work-dir=... -e shell -c command
|
||||
|
||||
// Using `open` to launch.
|
||||
let mut args = vec!["-na", "Ghostty", "--args"];
|
||||
|
||||
// Ghostty uses --working-directory for working directory (or +work-dir, but --working-directory is standard in newer versions/compat)
|
||||
// Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones.
|
||||
// Documentation says --working-directory is supported in CLI.
|
||||
let work_dir_arg = if let Some(dir) = cwd {
|
||||
format!("--working-directory={}", dir)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
if !work_dir_arg.is_empty() {
|
||||
args.push(&work_dir_arg);
|
||||
}
|
||||
|
||||
// Command execution
|
||||
args.push("-e");
|
||||
|
||||
// We pass the command and its arguments separately.
|
||||
// The previous issue was passing the entire "cmd args" string as a single argument to -e,
|
||||
// which led Ghostty to look for a binary named "cmd args".
|
||||
// Splitting by whitespace allows Ghostty to see ["cmd", "args"].
|
||||
// Note: This assumes simple commands without quoted arguments containing spaces.
|
||||
let full_command = build_shell_command(command, None);
|
||||
for part in full_command.split_whitespace() {
|
||||
args.push(part);
|
||||
}
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(&args)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch Ghostty. Make sure it is installed.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
let full_command = build_shell_command(command, cwd);
|
||||
|
||||
// 获取用户默认 shell
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
|
||||
let status = Command::new("open")
|
||||
.arg("-na")
|
||||
.arg("kitty")
|
||||
.arg("--args")
|
||||
.arg("-e")
|
||||
.arg(&shell)
|
||||
.arg("-l")
|
||||
.arg("-c")
|
||||
.arg(&full_command)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Kitty: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch Kitty. Make sure it is installed.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// wezterm start --cwd ... -- command
|
||||
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
|
||||
|
||||
let full_command = build_shell_command(command, None);
|
||||
|
||||
let mut args = vec!["-na", "WezTerm", "--args", "start"];
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
args.push("--cwd");
|
||||
args.push(dir);
|
||||
}
|
||||
|
||||
// Invoke shell to run the command string (to handle pipes, etc)
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
args.push("--");
|
||||
args.push(&shell);
|
||||
args.push("-c");
|
||||
args.push(&full_command);
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(&args)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch WezTerm.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
|
||||
let full_command = build_shell_command(command, None);
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
|
||||
let mut args = vec!["-na", "Alacritty", "--args"];
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
args.push("--working-directory");
|
||||
args.push(dir);
|
||||
}
|
||||
|
||||
args.push("-e");
|
||||
args.push(&shell);
|
||||
args.push("-c");
|
||||
args.push(&full_command);
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(&args)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Alacritty: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Failed to launch Alacritty.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_custom(
|
||||
command: &str,
|
||||
cwd: Option<&str>,
|
||||
custom_config: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let template = custom_config.ok_or("No custom terminal config provided")?;
|
||||
|
||||
if template.trim().is_empty() {
|
||||
return Err("Custom terminal command template is empty".to_string());
|
||||
}
|
||||
|
||||
let cmd_str = command;
|
||||
let dir_str = cwd.unwrap_or(".");
|
||||
|
||||
let final_cmd_line = template
|
||||
.replace("{command}", cmd_str)
|
||||
.replace("{cwd}", dir_str);
|
||||
|
||||
// Execute via sh -c
|
||||
let status = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&final_cmd_line)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Custom terminal execution returned error code".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
|
||||
match cwd {
|
||||
Some(dir) if !dir.trim().is_empty() => {
|
||||
format!("cd {} && {}", shell_escape(dir), command)
|
||||
}
|
||||
_ => command.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_escape(value: &str) -> String {
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!("\"{}\"", escaped)
|
||||
}
|
||||
|
||||
fn escape_osascript(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
Reference in New Issue
Block a user