Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::acp::error::AcpError;
use crate::acp::registry;
use crate::models::agent::AgentType;
pub(crate) fn cache_dir() -> Result<PathBuf, AcpError> {
let base = dirs::cache_dir()
.ok_or_else(|| AcpError::DownloadFailed("cannot determine cache directory".into()))?;
Ok(base.join("app.codeg").join("acp-binaries"))
}
fn normalize_version_label(version: &str) -> String {
let trimmed = version.trim();
if let Some(stripped) = trimmed
.strip_prefix('v')
.or_else(|| trimmed.strip_prefix('V'))
{
stripped.trim().to_string()
} else {
trimmed.to_string()
}
}
pub(crate) fn agent_cache_key(agent_type: AgentType) -> String {
registry::registry_id_for(agent_type).to_string()
}
pub(crate) fn binary_dir(agent_id: &str, version: &str) -> Result<PathBuf, AcpError> {
let version = normalize_version_label(version);
if version.is_empty() {
return Err(AcpError::DownloadFailed(
"binary version is empty".to_string(),
));
}
Ok(cache_dir()?
.join(agent_id)
.join(version)
.join(registry::current_platform()))
}
pub fn clear_agent_cache(agent_type: AgentType) -> Result<(), AcpError> {
let agent_id = agent_cache_key(agent_type);
let dir = cache_dir()?.join(agent_id);
if dir.exists() {
std::fs::remove_dir_all(&dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to clear cache: {e}")))?;
}
Ok(())
}
fn installed_binary_path(agent_id: &str, version: &str, cmd_name: &str) -> Option<PathBuf> {
let bin_name = if cfg!(target_os = "windows") {
format!("{cmd_name}.exe")
} else {
cmd_name.to_string()
};
let normalized = normalize_version_label(version);
if normalized.is_empty() {
return None;
}
let path = cache_dir()
.ok()?
.join(agent_id)
.join(normalized)
.join(registry::current_platform())
.join(bin_name);
if !path.exists() {
return None;
}
if is_binary_file_compatible(path.as_path()) {
return Some(path);
}
let _ = std::fs::remove_file(path);
None
}
fn installed_version_labels(agent_id: &str, cmd_name: &str) -> Result<Vec<String>, AcpError> {
let root = cache_dir()?.join(agent_id);
if !root.exists() {
return Ok(Vec::new());
}
let mut versions = Vec::new();
let mut seen = HashSet::new();
let entries = std::fs::read_dir(&root)
.map_err(|e| AcpError::DownloadFailed(format!("failed to read cache dir: {e}")))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let raw_version = entry.file_name().to_string_lossy().to_string();
let normalized = normalize_version_label(&raw_version);
if normalized.is_empty() {
continue;
}
if installed_binary_path(agent_id, &normalized, cmd_name).is_some()
&& seen.insert(normalized.clone())
{
versions.push(normalized);
}
}
Ok(versions)
}
fn installed_version_for_agent(
agent_type: AgentType,
cmd_name: &str,
) -> Result<Option<String>, AcpError> {
let agent_id = agent_cache_key(agent_type);
let mut versions = installed_version_labels(&agent_id, cmd_name)?;
if versions.is_empty() {
return Ok(None);
}
versions.sort_by(|a, b| version_cmp(a, b));
Ok(versions.pop())
}
pub fn detect_installed_version(
agent_type: AgentType,
cmd_name: &str,
) -> Result<Option<String>, AcpError> {
installed_version_for_agent(agent_type, cmd_name)
}
fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let mut a_parts = parse_version_parts(a);
let mut b_parts = parse_version_parts(b);
let len = a_parts.len().max(b_parts.len());
a_parts.resize(len, 0);
b_parts.resize(len, 0);
for i in 0..len {
match a_parts[i].cmp(&b_parts[i]) {
std::cmp::Ordering::Equal => continue,
order => return order,
}
}
a.cmp(b)
}
fn parse_version_parts(input: &str) -> Vec<u32> {
input
.trim_start_matches(|c: char| !c.is_ascii_digit())
.split('.')
.map(|part| {
let numeric: String = part.chars().take_while(|c| c.is_ascii_digit()).collect();
numeric.parse::<u32>().unwrap_or(0)
})
.collect()
}
/// Ensure a binary agent is available locally.
/// Returns the absolute path to the executable.
pub async fn ensure_binary_for_agent(
agent_type: AgentType,
version: &str,
archive_url: &str,
cmd_name: &str,
) -> Result<PathBuf, AcpError> {
if let Some(path) = find_cached_binary_for_agent(agent_type, version, cmd_name)? {
return Ok(path);
}
let agent_id = agent_cache_key(agent_type);
ensure_binary(&agent_id, version, archive_url, cmd_name).await
}
/// Ensure a binary is available for a specific cache key.
/// Returns the absolute path to the executable.
pub async fn ensure_binary(
agent_id: &str,
version: &str,
archive_url: &str,
cmd_name: &str,
) -> Result<PathBuf, AcpError> {
if let Some(path) = find_cached_binary(agent_id, version, cmd_name)? {
return Ok(path);
}
let dir = binary_dir(agent_id, version)?;
let bin_name = if cfg!(target_os = "windows") {
format!("{cmd_name}.exe")
} else {
cmd_name.to_string()
};
// Download and extract
std::fs::create_dir_all(&dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to create cache dir: {e}")))?;
let tmp_dir = dir.join(".tmp");
if tmp_dir.exists() {
let _ = std::fs::remove_dir_all(&tmp_dir);
}
std::fs::create_dir_all(&tmp_dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to create tmp dir: {e}")))?;
let result: Result<PathBuf, AcpError> = async {
let archive_path = tmp_dir.join("archive");
download_file(archive_url, &archive_path).await?;
let extract_dir = tmp_dir.join("extracted");
std::fs::create_dir_all(&extract_dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to create extract dir: {e}")))?;
if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
extract_tar_gz(&archive_path, &extract_dir)?;
} else if archive_url.ends_with(".tar.bz2") || archive_url.ends_with(".tbz2") {
extract_tar_bz2(&archive_path, &extract_dir)?;
} else if archive_url.ends_with(".zip") {
extract_zip(&archive_path, &extract_dir)?;
} else {
return Err(AcpError::DownloadFailed(format!(
"unsupported archive format: {archive_url}"
)));
}
// Find the binary in extracted files and move to final location.
let extracted_bin = find_binary_recursive(&extract_dir, &bin_name).ok_or_else(|| {
AcpError::DownloadFailed(format!("binary '{bin_name}' not found in archive"))
})?;
let final_path = dir.join(&bin_name);
std::fs::copy(&extracted_bin, &final_path)
.map_err(|e| AcpError::DownloadFailed(format!("failed to copy binary: {e}")))?;
if !is_binary_file_compatible(&final_path) {
let _ = std::fs::remove_file(&final_path);
return Err(AcpError::DownloadFailed(
"downloaded binary format is invalid for current platform".into(),
));
}
set_executable_permissions(&final_path)?;
Ok(final_path)
}
.await;
// Always clean up temp extraction artifacts.
let _ = std::fs::remove_dir_all(&tmp_dir);
if result.is_err() {
// Avoid leaving empty version/platform directories on failed downloads.
let _ = std::fs::remove_dir_all(&dir);
}
result
}
pub(crate) fn find_cached_binary(
agent_id: &str,
version: &str,
cmd_name: &str,
) -> Result<Option<PathBuf>, AcpError> {
Ok(installed_binary_path(agent_id, version, cmd_name))
}
pub(crate) fn find_cached_binary_for_agent(
agent_type: AgentType,
version: &str,
cmd_name: &str,
) -> Result<Option<PathBuf>, AcpError> {
let agent_id = agent_cache_key(agent_type);
find_cached_binary(&agent_id, version, cmd_name)
}
pub(crate) fn find_binary_recursive(dir: &PathBuf, name: &str) -> Option<PathBuf> {
if !dir.exists() {
return None;
}
for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
if entry.file_type().is_file() && entry.file_name().to_string_lossy() == name {
return Some(entry.into_path());
}
}
None
}
async fn download_file(url: &str, dest: &PathBuf) -> Result<(), AcpError> {
let response = reqwest::Client::new()
.get(url)
.send()
.await
.map_err(|e| AcpError::DownloadFailed(format!("HTTP request failed: {e}")))?;
if !response.status().is_success() {
return Err(AcpError::DownloadFailed(format!(
"HTTP {} for {url}",
response.status()
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| AcpError::DownloadFailed(format!("failed to read response: {e}")))?;
std::fs::write(dest, &bytes)
.map_err(|e| AcpError::DownloadFailed(format!("failed to write archive: {e}")))?;
Ok(())
}
fn extract_tar_gz(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
let file = std::fs::File::open(archive)
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
let gz = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(gz);
tar.unpack(dest)
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract tar.gz: {e}")))?;
Ok(())
}
fn extract_tar_bz2(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
let file = std::fs::File::open(archive)
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
let bz = bzip2::read::BzDecoder::new(file);
let mut tar = tar::Archive::new(bz);
tar.unpack(dest)
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract tar.bz2: {e}")))?;
Ok(())
}
fn extract_zip(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
let file = std::fs::File::open(archive)
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|e| AcpError::DownloadFailed(format!("failed to read zip: {e}")))?;
zip.extract(dest)
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract zip: {e}")))?;
Ok(())
}
fn set_executable_permissions(path: &Path) -> Result<(), AcpError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.map_err(|e| AcpError::DownloadFailed(e.to_string()))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).map_err(|e| AcpError::DownloadFailed(e.to_string()))
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
pub(crate) fn is_binary_file_compatible(path: &Path) -> bool {
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let mut header = [0_u8; 4];
if file.read_exact(&mut header).is_err() {
return false;
}
#[cfg(target_os = "macos")]
{
matches!(
header,
[0xFE, 0xED, 0xFA, 0xCE]
| [0xCE, 0xFA, 0xED, 0xFE]
| [0xFE, 0xED, 0xFA, 0xCF]
| [0xCF, 0xFA, 0xED, 0xFE]
| [0xCA, 0xFE, 0xBA, 0xBE]
| [0xBE, 0xBA, 0xFE, 0xCA]
| [0xCA, 0xFE, 0xBA, 0xBF]
| [0xBF, 0xBA, 0xFE, 0xCA]
)
}
#[cfg(target_os = "linux")]
{
header == [0x7F, b'E', b'L', b'F']
}
#[cfg(target_os = "windows")]
{
header[0] == b'M' && header[1] == b'Z'
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_uses_registry_id() {
assert_eq!(agent_cache_key(AgentType::OpenCode), "opencode");
assert_eq!(agent_cache_key(AgentType::Codex), "codex-acp");
}
#[test]
fn version_normalization_is_consistent() {
assert_eq!(normalize_version_label("v1.2.15"), "1.2.15");
assert_eq!(normalize_version_label("V0.9.4 "), "0.9.4");
assert_eq!(normalize_version_label("1.25.1"), "1.25.1");
}
}
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
#[error("agent process failed to spawn: {0}")]
SpawnFailed(String),
#[error("connection not found: {0}")]
ConnectionNotFound(String),
#[error("ACP protocol error: {0}")]
Protocol(String),
#[error("agent process exited unexpectedly")]
ProcessExited,
#[allow(dead_code)]
#[error("conversation error: {0}")]
Conversation(String),
#[error("binary download failed: {0}")]
DownloadFailed(String),
#[allow(dead_code)]
#[error("agent not found: {0}")]
AgentNotFound(String),
#[error("platform not supported: {0}")]
PlatformNotSupported(String),
}
impl AcpError {
pub fn protocol(raw: impl Into<String>) -> Self {
let raw = raw.into();
let sanitized = sanitize_protocol_message(&raw);
if is_executable_format_error(&sanitized) {
return Self::Protocol(
"Agent executable appears incompatible or corrupted. Please retry to re-download it."
.into(),
);
}
Self::Protocol(sanitized)
}
}
impl Serialize for AcpError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
fn sanitize_protocol_message(raw: &str) -> String {
let without_spawned_at = regex::Regex::new(r#"\s*,?\s*"spawned_at"\s*:\s*"[^"]*"\s*,?"#)
.ok()
.map(|re| re.replace_all(raw, "").into_owned())
.unwrap_or_else(|| raw.to_string());
let without_dangling_comma = regex::Regex::new(r#",\s*([}\]])"#)
.ok()
.map(|re| re.replace_all(&without_spawned_at, "$1").into_owned())
.unwrap_or(without_spawned_at);
regex::Regex::new(r#"/(?:Users|home)/[^"\s]+"#)
.ok()
.map(|re| {
re.replace_all(&without_dangling_comma, "<local-path>")
.into_owned()
})
.unwrap_or(without_dangling_comma)
}
fn is_executable_format_error(message: &str) -> bool {
let lowered = message.to_lowercase();
lowered.contains("malformed mach-o file")
|| lowered.contains("exec format error")
|| lowered.contains("bad cpu type in executable")
|| lowered.contains("not a valid win32 application")
|| lowered.contains("is not a valid application for this os platform")
}
+512
View File
@@ -0,0 +1,512 @@
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use sacp::schema::{
ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse,
};
use tokio::sync::Semaphore;
const FS_MAX_CONCURRENT_OPS: usize = 8;
const FS_IO_TIMEOUT: Duration = Duration::from_secs(30);
const FS_MAX_FILE_SIZE_BYTES: u64 = 16 * 1024 * 1024;
const FS_MAX_READ_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
const FS_MAX_WRITE_BYTES: usize = 2 * 1024 * 1024;
const FS_SLOW_OPERATION_MS: u128 = 200;
#[derive(Debug)]
pub enum FileSystemRuntimeError {
InvalidParams(String),
Internal(String),
}
impl FileSystemRuntimeError {
pub fn to_rpc_error(self) -> sacp::Error {
match self {
Self::InvalidParams(message) => sacp::Error::invalid_params().data(message),
Self::Internal(message) => sacp::util::internal_error(message),
}
}
}
#[derive(Clone)]
pub struct FileSystemRuntime {
workspace_root: PathBuf,
workspace_root_canonical: Option<PathBuf>,
io_semaphore: Arc<Semaphore>,
}
impl FileSystemRuntime {
pub fn new(workspace_root: PathBuf) -> Self {
let workspace_root = if workspace_root.is_absolute() {
workspace_root
} else {
std::env::current_dir()
.unwrap_or_default()
.join(workspace_root)
};
let workspace_root_canonical = std::fs::canonicalize(&workspace_root).ok();
Self {
workspace_root,
workspace_root_canonical,
io_semaphore: Arc::new(Semaphore::new(FS_MAX_CONCURRENT_OPS)),
}
}
pub async fn read_text_file(
&self,
request: ReadTextFileRequest,
) -> Result<ReadTextFileResponse, FileSystemRuntimeError> {
if !request.path.is_absolute() {
return Err(FileSystemRuntimeError::InvalidParams(
"fs/read_text_file requires an absolute path".to_string(),
));
}
if matches!(request.line, Some(0)) {
return Err(FileSystemRuntimeError::InvalidParams(
"fs/read_text_file line must be >= 1".to_string(),
));
}
let _permit = self
.io_semaphore
.clone()
.acquire_owned()
.await
.map_err(|_| {
FileSystemRuntimeError::Internal("filesystem runtime closed".to_string())
})?;
let workspace_root = self.workspace_root.clone();
let workspace_root_canonical = self.workspace_root_canonical.clone();
let path = request.path;
let line = request.line;
let limit = request.limit;
let started_at = Instant::now();
let path_for_log = path.clone();
let response = run_blocking_with_timeout("fs/read_text_file", move || {
read_text_file_impl(
&path,
line,
limit,
&workspace_root,
workspace_root_canonical.as_deref(),
)
.map(ReadTextFileResponse::new)
})
.await;
log_if_slow("fs/read_text_file", &path_for_log, started_at);
response
}
pub async fn write_text_file(
&self,
request: WriteTextFileRequest,
) -> Result<WriteTextFileResponse, FileSystemRuntimeError> {
if !request.path.is_absolute() {
return Err(FileSystemRuntimeError::InvalidParams(
"fs/write_text_file requires an absolute path".to_string(),
));
}
if request.content.len() > FS_MAX_WRITE_BYTES {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"write payload too large ({} bytes, limit {} bytes)",
request.content.len(),
FS_MAX_WRITE_BYTES
)));
}
let _permit = self
.io_semaphore
.clone()
.acquire_owned()
.await
.map_err(|_| {
FileSystemRuntimeError::Internal("filesystem runtime closed".to_string())
})?;
let workspace_root = self.workspace_root.clone();
let workspace_root_canonical = self.workspace_root_canonical.clone();
let path = request.path;
let content = request.content;
let started_at = Instant::now();
let path_for_log = path.clone();
let response = run_blocking_with_timeout("fs/write_text_file", move || {
ensure_path_in_workspace(
&path,
&workspace_root,
workspace_root_canonical.as_deref(),
true,
)?;
atomic_write_text(&path, content.as_bytes())?;
Ok(WriteTextFileResponse::new())
})
.await;
log_if_slow("fs/write_text_file", &path_for_log, started_at);
response
}
}
async fn run_blocking_with_timeout<T, F>(
operation: &'static str,
f: F,
) -> Result<T, FileSystemRuntimeError>
where
T: Send + 'static,
F: FnOnce() -> Result<T, FileSystemRuntimeError> + Send + 'static,
{
let task = tokio::task::spawn_blocking(f);
let join_result = tokio::time::timeout(FS_IO_TIMEOUT, task)
.await
.map_err(|_| {
FileSystemRuntimeError::Internal(format!(
"{operation} timed out after {}s",
FS_IO_TIMEOUT.as_secs()
))
})?;
let op_result = join_result.map_err(|err| {
FileSystemRuntimeError::Internal(format!("{operation} worker failed: {err}"))
})?;
op_result
}
fn read_text_file_impl(
path: &Path,
line: Option<u32>,
limit: Option<u32>,
workspace_root: &Path,
workspace_root_canonical: Option<&Path>,
) -> Result<String, FileSystemRuntimeError> {
ensure_path_in_workspace(path, workspace_root, workspace_root_canonical, false)?;
let metadata = std::fs::metadata(path).map_err(|err| map_io_error("read", path, err))?;
if metadata.len() > FS_MAX_FILE_SIZE_BYTES {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"file too large for fs/read_text_file ({} bytes, limit {} bytes)",
metadata.len(),
FS_MAX_FILE_SIZE_BYTES
)));
}
let file = File::open(path).map_err(|err| map_io_error("read", path, err))?;
let mut reader = BufReader::new(file);
let start_line = usize::try_from(line.unwrap_or(1)).unwrap_or(usize::MAX);
let max_lines = limit.map(|v| usize::try_from(v).unwrap_or(usize::MAX));
let mut current_line = 1usize;
let mut taken = 0usize;
let mut out = String::with_capacity(
usize::try_from(metadata.len())
.unwrap_or(FS_MAX_READ_RESPONSE_BYTES)
.min(FS_MAX_READ_RESPONSE_BYTES),
);
let mut line_buf = String::new();
loop {
line_buf.clear();
let bytes_read = reader
.read_line(&mut line_buf)
.map_err(|err| map_io_error("read", path, err))?;
if bytes_read == 0 {
break;
}
if current_line >= start_line {
if let Some(max) = max_lines {
if taken >= max {
break;
}
}
if out.len().saturating_add(line_buf.len()) > FS_MAX_READ_RESPONSE_BYTES {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"read result too large (limit {} bytes). Narrow with line/limit.",
FS_MAX_READ_RESPONSE_BYTES
)));
}
out.push_str(&line_buf);
taken += 1;
}
current_line = current_line.saturating_add(1);
}
Ok(out)
}
fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), FileSystemRuntimeError> {
let parent = path.parent().ok_or_else(|| {
FileSystemRuntimeError::InvalidParams(format!(
"cannot determine parent directory for path: {}",
path.display()
))
})?;
if !parent.exists() {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"parent directory does not exist: {}",
parent.display()
)));
}
let temp_path = parent.join(format!(
".codeg-fs-{}.{}.tmp",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
let existing_permissions = std::fs::metadata(path).ok().map(|m| m.permissions());
let write_result = (|| {
let mut tmp = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.map_err(|err| map_io_error("create temporary file", &temp_path, err))?;
tmp.write_all(bytes)
.map_err(|err| map_io_error("write", &temp_path, err))?;
tmp.sync_all()
.map_err(|err| map_io_error("flush", &temp_path, err))?;
if let Some(permissions) = existing_permissions {
std::fs::set_permissions(&temp_path, permissions)
.map_err(|err| map_io_error("set permissions", &temp_path, err))?;
}
replace_file(&temp_path, path)?;
sync_directory(parent)?;
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&temp_path);
}
write_result
}
#[cfg(unix)]
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
std::fs::rename(temp_path, target_path).map_err(|err| map_io_error("replace", target_path, err))
}
#[cfg(target_os = "windows")]
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
};
fn to_wide(path: &Path) -> Vec<u16> {
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
let src = to_wide(temp_path);
let dst = to_wide(target_path);
// SAFETY: pointers are valid, null-terminated UTF-16 buffers alive for the call.
let ok = unsafe {
MoveFileExW(
src.as_ptr(),
dst.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if ok == 0 {
let err = std::io::Error::last_os_error();
return Err(map_io_error("atomically replace", target_path, err));
}
Ok(())
}
#[cfg(not(any(unix, target_os = "windows")))]
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
std::fs::rename(temp_path, target_path).map_err(|err| map_io_error("replace", target_path, err))
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<(), FileSystemRuntimeError> {
let dir = File::open(path).map_err(|err| map_io_error("sync directory", path, err))?;
dir.sync_all()
.map_err(|err| map_io_error("sync directory", path, err))
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<(), FileSystemRuntimeError> {
Ok(())
}
fn canonical_workspace_root(workspace_root: &Path, canonical: Option<&Path>) -> PathBuf {
canonical
.map(Path::to_path_buf)
.or_else(|| std::fs::canonicalize(workspace_root).ok())
.unwrap_or_else(|| workspace_root.to_path_buf())
}
fn ensure_path_in_workspace(
path: &Path,
workspace_root: &Path,
workspace_root_canonical: Option<&Path>,
for_write: bool,
) -> Result<(), FileSystemRuntimeError> {
let root = canonical_workspace_root(workspace_root, workspace_root_canonical);
let target = canonical_target_path(path, for_write)?;
if !target.starts_with(&root) {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"path is outside workspace root: {}",
path.display()
)));
}
Ok(())
}
fn canonical_target_path(path: &Path, for_write: bool) -> Result<PathBuf, FileSystemRuntimeError> {
if !for_write || path.exists() {
return std::fs::canonicalize(path).map_err(|err| map_io_error("access", path, err));
}
let parent = path.parent().ok_or_else(|| {
FileSystemRuntimeError::InvalidParams(format!(
"cannot determine parent directory for path: {}",
path.display()
))
})?;
if !parent.exists() {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"parent directory does not exist: {}",
parent.display()
)));
}
std::fs::canonicalize(parent).map_err(|err| map_io_error("access", parent, err))
}
fn map_io_error(action: &str, path: &Path, err: std::io::Error) -> FileSystemRuntimeError {
match err.kind() {
ErrorKind::NotFound
| ErrorKind::PermissionDenied
| ErrorKind::InvalidInput
| ErrorKind::InvalidData => FileSystemRuntimeError::InvalidParams(format!(
"failed to {action} {}: {err}",
path.display()
)),
_ => FileSystemRuntimeError::Internal(format!(
"failed to {action} {}: {err}",
path.display()
)),
}
}
fn log_if_slow(operation: &str, path: &Path, started_at: Instant) {
let elapsed = started_at.elapsed();
if elapsed.as_millis() >= FS_SLOW_OPERATION_MS {
eprintln!(
"[ACP] {operation} slow path={} elapsed_ms={}",
path.display(),
elapsed.as_millis()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_workspace() -> PathBuf {
let path = std::env::temp_dir().join(format!("codeg-fs-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&path).expect("create test workspace");
path
}
#[tokio::test(flavor = "current_thread")]
async fn read_honors_line_and_limit() {
let workspace = temp_workspace();
let file = workspace.join("sample.txt");
fs::write(&file, "a\nb\nc\nd\n").expect("write file");
let runtime = FileSystemRuntime::new(workspace.clone());
let response = runtime
.read_text_file(ReadTextFileRequest::new("sid", &file).line(2).limit(2))
.await
.expect("read file");
assert_eq!(response.content, "b\nc\n");
let _ = fs::remove_dir_all(workspace);
}
#[tokio::test(flavor = "current_thread")]
async fn rejects_path_outside_workspace() {
let workspace = temp_workspace();
let outside = std::env::temp_dir().join(format!("outside-{}.txt", uuid::Uuid::new_v4()));
fs::write(&outside, "x").expect("write outside file");
let runtime = FileSystemRuntime::new(workspace.clone());
let error = runtime
.read_text_file(ReadTextFileRequest::new("sid", &outside))
.await
.expect_err("should reject outside path");
match error {
FileSystemRuntimeError::InvalidParams(message) => {
assert!(message.contains("outside workspace"));
}
other => panic!("unexpected error: {other:?}"),
}
let _ = fs::remove_file(outside);
let _ = fs::remove_dir_all(workspace);
}
#[tokio::test(flavor = "current_thread")]
async fn write_replaces_existing_content() {
let workspace = temp_workspace();
let file = workspace.join("target.txt");
fs::write(&file, "old").expect("write old content");
let runtime = FileSystemRuntime::new(workspace.clone());
runtime
.write_text_file(WriteTextFileRequest::new("sid", &file, "new-content"))
.await
.expect("write file");
let content = fs::read_to_string(&file).expect("read file");
assert_eq!(content, "new-content");
let leaked_tmp = fs::read_dir(&workspace)
.expect("read workspace")
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".codeg-fs-")
});
assert!(!leaked_tmp, "temporary file should be cleaned up");
let _ = fs::remove_dir_all(workspace);
}
}
+197
View File
@@ -0,0 +1,197 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::acp::connection::{spawn_agent_connection, AgentConnection, ConnectionCommand};
use crate::acp::error::AcpError;
use crate::acp::types::{ConnectionInfo, PromptInputBlock};
use crate::models::agent::AgentType;
pub struct ConnectionManager {
connections: Arc<Mutex<HashMap<String, AgentConnection>>>,
}
impl ConnectionManager {
pub fn new() -> Self {
Self {
connections: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn spawn_agent(
&self,
agent_type: AgentType,
working_dir: Option<String>,
session_id: Option<String>,
runtime_env: BTreeMap<String, String>,
owner_window_label: String,
app_handle: tauri::AppHandle,
) -> Result<String, AcpError> {
let connection_id = uuid::Uuid::new_v4().to_string();
eprintln!(
"[ACP] spawning connection id={} owner_window={} agent={:?}",
connection_id, owner_window_label, agent_type
);
let conn = spawn_agent_connection(
connection_id.clone(),
agent_type,
working_dir,
session_id,
runtime_env,
owner_window_label,
app_handle,
)
.await?;
self.connections
.lock()
.await
.insert(connection_id.clone(), conn);
Ok(connection_id)
}
pub async fn send_prompt(
&self,
conn_id: &str,
blocks: Vec<PromptInputBlock>,
) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::Prompt { blocks })
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn set_mode(&self, conn_id: &str, mode_id: String) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::SetMode { mode_id })
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn set_config_option(
&self,
conn_id: &str,
config_id: String,
value_id: String,
) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::SetConfigOption {
config_id,
value_id,
})
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn cancel(&self, conn_id: &str) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::Cancel)
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn respond_permission(
&self,
conn_id: &str,
request_id: &str,
option_id: &str,
) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::RespondPermission {
request_id: request_id.into(),
option_id: option_id.into(),
})
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn disconnect(&self, conn_id: &str) -> Result<(), AcpError> {
let cmd_tx = {
let mut connections = self.connections.lock().await;
connections.remove(conn_id).map(|conn| conn.cmd_tx)
};
if let Some(cmd_tx) = cmd_tx {
let _ = cmd_tx.send(ConnectionCommand::Disconnect).await;
Ok(())
} else {
Err(AcpError::ConnectionNotFound(conn_id.into()))
}
}
pub async fn disconnect_by_owner_window(&self, owner_window_label: &str) -> usize {
let cmd_txs = {
let mut connections = self.connections.lock().await;
let ids: Vec<String> = connections
.iter()
.filter_map(|(id, conn)| {
if conn.owner_window_label == owner_window_label {
Some(id.clone())
} else {
None
}
})
.collect();
let mut txs = Vec::with_capacity(ids.len());
for id in ids {
if let Some(conn) = connections.remove(&id) {
txs.push(conn.cmd_tx);
}
}
txs
};
let disconnected = cmd_txs.len();
for cmd_tx in cmd_txs {
let _ = cmd_tx.send(ConnectionCommand::Disconnect).await;
}
eprintln!(
"[ACP] disconnect by owner window owner_window={} count={}",
owner_window_label, disconnected
);
disconnected
}
pub async fn list_connections(&self) -> Vec<ConnectionInfo> {
let connections = self.connections.lock().await;
connections.values().map(|c| c.info()).collect()
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod binary_cache;
pub mod connection;
pub mod error;
pub mod file_system_runtime;
pub mod manager;
pub mod preflight;
pub mod registry;
pub mod terminal_runtime;
pub mod types;
+400
View File
@@ -0,0 +1,400 @@
use serde::Serialize;
use std::sync::Mutex;
use crate::acp::binary_cache;
use crate::acp::registry::{self, AgentDistribution};
use crate::models::agent::AgentType;
/// Cache for NPX environment check results.
/// Stores `Some(checks)` after a successful (all-pass) run;
/// stays `None` if checks failed so they are retried next time.
static NPX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
/// Cache for UVX environment check results.
/// Stores `Some(checks)` after a successful (all-pass) run;
/// stays `None` if checks failed so they are retried next time.
static UVX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum FixActionKind {
OpenUrl,
RedownloadBinary,
RetryConnection,
}
#[derive(Debug, Clone, Serialize)]
pub struct FixAction {
pub label: String,
pub kind: FixActionKind,
pub payload: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
Pass,
Fail,
Warn,
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckItem {
pub check_id: String,
pub label: String,
pub status: CheckStatus,
pub message: String,
pub fixes: Vec<FixAction>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PreflightResult {
pub agent_type: AgentType,
pub agent_name: String,
pub passed: bool,
pub checks: Vec<CheckItem>,
}
pub async fn run_preflight(agent_type: AgentType) -> PreflightResult {
let meta = registry::get_agent_meta(agent_type);
let checks = match &meta.distribution {
AgentDistribution::Npx { node_required, .. } => check_npx_environment(*node_required).await,
AgentDistribution::Uvx { .. } => check_uvx_environment().await,
AgentDistribution::Binary {
version,
cmd,
platforms,
..
} => check_binary_environment(agent_type, version, cmd, platforms).await,
};
let passed = checks
.iter()
.all(|c| !matches!(c.status, CheckStatus::Fail));
PreflightResult {
agent_type,
agent_name: meta.name.to_string(),
passed,
checks,
}
}
async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
// Return cached result if a previous check passed.
// The cache stores only the base checks (node_available + npx_available);
// the per-agent node_version check is appended separately.
let cached = NPX_ENV_CACHE.lock().unwrap().clone();
if let Some(cached) = cached {
let mut checks = cached;
if let Some(required) = node_required {
// Extract node version string from the cached node_available message
// (format: "Node.js v20.19.0 available")
let node_ver = extract_node_version_from_message(&checks[0].message);
checks.push(build_node_version_check(node_ver.as_deref(), required));
}
return checks;
}
// Run node and npx checks in parallel
let (node_result, npx_result) = tokio::join!(
crate::process::tokio_command("node")
.arg("--version")
.output(),
crate::process::tokio_command("npx")
.arg("--version")
.output(),
);
// Track the raw node version string for reuse in the version check
let mut node_version_str: Option<String> = None;
let node_check = match node_result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
node_version_str = Some(version.clone());
CheckItem {
check_id: "node_available".into(),
label: "Node.js".into(),
status: CheckStatus::Pass,
message: format!("Node.js {version} available"),
fixes: vec![],
}
}
_ => CheckItem {
check_id: "node_available".into(),
label: "Node.js".into(),
status: CheckStatus::Fail,
message: "Node.js is not installed or not in PATH".into(),
fixes: vec![FixAction {
label: "Install Node.js".into(),
kind: FixActionKind::OpenUrl,
payload: "https://nodejs.org/".into(),
}],
},
};
let npx_check = match npx_result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
CheckItem {
check_id: "npx_available".into(),
label: "npx".into(),
status: CheckStatus::Pass,
message: format!("npx {version} available"),
fixes: vec![],
}
}
_ => CheckItem {
check_id: "npx_available".into(),
label: "npx".into(),
status: CheckStatus::Fail,
message: "npx is not installed or not in PATH".into(),
fixes: vec![FixAction {
label: "Install Node.js".into(),
kind: FixActionKind::OpenUrl,
payload: "https://nodejs.org/".into(),
}],
},
};
let mut checks = vec![node_check, npx_check];
// Cache only if all checks passed — failed results are not cached so
// the user can retry after installing the missing tools.
let all_passed = checks
.iter()
.all(|c| !matches!(c.status, CheckStatus::Fail));
if all_passed {
*NPX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
}
// After caching the base checks, append the per-agent Node.js version
// requirement if specified. Only meaningful when node is available.
if let Some(required) = node_required {
if all_passed {
checks.push(build_node_version_check(
node_version_str.as_deref(),
required,
));
}
}
checks
}
/// Parse a Node.js version string like "v20.19.0" or "20.19.0" into (major, minor, patch).
/// Handles pre-release suffixes such as "v22.0.0-nightly" by stripping non-numeric tails.
fn parse_node_version(v: &str) -> Option<(u32, u32, u32)> {
let v = v.trim().trim_start_matches('v');
let mut parts = v.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch_str = parts.next()?;
// Strip pre-release/build suffixes: "0-nightly" → "0", "3+build" → "3"
let patch_digits: String = patch_str
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
let patch = patch_digits.parse().ok()?;
Some((major, minor, patch))
}
/// Extract the node version string from a cached node_available message.
/// Expected format: "Node.js v20.19.0 available" → Some("v20.19.0")
fn extract_node_version_from_message(message: &str) -> Option<String> {
message
.split_whitespace()
.find(|s| s.starts_with('v') && s.contains('.'))
.map(|s| s.to_string())
}
/// Build a `CheckItem` for the Node.js version requirement check.
/// `current_version` is the raw output from `node --version` (e.g. "v20.19.0").
fn build_node_version_check(current_version: Option<&str>, required: &str) -> CheckItem {
let current_version = match current_version {
Some(v) => v,
None => {
return CheckItem {
check_id: "node_version".into(),
label: "Node.js version".into(),
status: CheckStatus::Fail,
message: "Cannot determine Node.js version".into(),
fixes: vec![],
};
}
};
let current = parse_node_version(current_version);
let required_parsed = parse_node_version(required);
match (current, required_parsed) {
(Some(cur), Some(req)) if cur >= req => CheckItem {
check_id: "node_version".into(),
label: "Node.js version".into(),
status: CheckStatus::Pass,
message: format!(
"Node.js {current_version} meets the minimum requirement (>={required})"
),
fixes: vec![],
},
(Some(_), Some(_)) => CheckItem {
check_id: "node_version".into(),
label: "Node.js version".into(),
status: CheckStatus::Fail,
message: format!(
"Node.js {current_version} is too old — this package requires Node.js >={required}"
),
fixes: vec![FixAction {
label: "Update Node.js".into(),
kind: FixActionKind::OpenUrl,
payload: "https://nodejs.org/".into(),
}],
},
_ => CheckItem {
check_id: "node_version".into(),
label: "Node.js version".into(),
status: CheckStatus::Warn,
message: format!("Cannot parse Node.js version; required >={required}"),
fixes: vec![],
},
}
}
async fn check_uvx_environment() -> Vec<CheckItem> {
// Return cached result if a previous check passed
let cached = UVX_ENV_CACHE.lock().unwrap().clone();
if let Some(cached) = cached {
return cached;
}
// Run uv and uvx checks in parallel
let (uv_result, uvx_result) = tokio::join!(
crate::process::tokio_command("uv")
.arg("--version")
.output(),
crate::process::tokio_command("uvx")
.arg("--version")
.output(),
);
let install_fix = vec![FixAction {
label: "Install uv".into(),
kind: FixActionKind::OpenUrl,
payload: "https://docs.astral.sh/uv/getting-started/installation/".into(),
}];
let uv_check = match uv_result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
CheckItem {
check_id: "uv_available".into(),
label: "uv".into(),
status: CheckStatus::Pass,
message: format!("uv {version} available"),
fixes: vec![],
}
}
_ => CheckItem {
check_id: "uv_available".into(),
label: "uv".into(),
status: CheckStatus::Fail,
message: "uv is not installed or not in PATH".into(),
fixes: install_fix.clone(),
},
};
let uvx_check = match uvx_result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
CheckItem {
check_id: "uvx_available".into(),
label: "uvx".into(),
status: CheckStatus::Pass,
message: format!("uvx {version} available"),
fixes: vec![],
}
}
_ => CheckItem {
check_id: "uvx_available".into(),
label: "uvx".into(),
status: CheckStatus::Fail,
message: "uvx is not installed or not in PATH".into(),
fixes: install_fix,
},
};
let checks = vec![uv_check, uvx_check];
let all_passed = checks
.iter()
.all(|c| !matches!(c.status, CheckStatus::Fail));
if all_passed {
*UVX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
}
checks
}
async fn check_binary_environment(
agent_type: AgentType,
version: &str,
cmd: &str,
platforms: &[registry::PlatformBinary],
) -> Vec<CheckItem> {
let mut checks = Vec::new();
// Check platform support
let current = registry::current_platform();
let platform_supported = platforms.iter().any(|p| p.platform == current);
let platform_check = if platform_supported {
CheckItem {
check_id: "platform_supported".into(),
label: "Platform".into(),
status: CheckStatus::Pass,
message: format!("Platform {current} is supported"),
fixes: vec![],
}
} else {
CheckItem {
check_id: "platform_supported".into(),
label: "Platform".into(),
status: CheckStatus::Fail,
message: format!("Platform {current} is not supported"),
fixes: vec![],
}
};
checks.push(platform_check);
// Check binary cache
if platform_supported {
let cache_check = match binary_cache::find_cached_binary_for_agent(agent_type, version, cmd)
{
Ok(Some(_)) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Pass,
message: "Binary is cached locally".into(),
fixes: vec![],
},
Ok(None) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Warn,
message: "Binary not cached yet, will be downloaded on first connection".into(),
fixes: vec![],
},
Err(_) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Warn,
message: "Cannot determine binary cache path".into(),
fixes: vec![],
},
};
checks.push(cache_check);
}
checks
}
+542
View File
@@ -0,0 +1,542 @@
use crate::models::agent::AgentType;
#[derive(Debug, Clone)]
pub enum AgentDistribution {
Npx {
version: &'static str,
package: &'static str,
args: &'static [&'static str],
env: &'static [(&'static str, &'static str)],
/// Minimum Node.js version required, e.g. "22.12.0". None means no specific requirement.
node_required: Option<&'static str>,
},
Uvx {
version: &'static str,
package: &'static str,
args: &'static [&'static str],
env: &'static [(&'static str, &'static str)],
},
Binary {
version: &'static str,
cmd: &'static str,
args: &'static [&'static str],
env: &'static [(&'static str, &'static str)],
platforms: &'static [PlatformBinary],
},
}
#[derive(Debug, Clone)]
pub struct PlatformBinary {
pub platform: &'static str,
pub url: &'static str,
}
#[derive(Debug, Clone)]
pub struct AcpAgentMeta {
#[allow(dead_code)]
pub agent_type: AgentType,
pub name: &'static str,
pub description: &'static str,
pub distribution: AgentDistribution,
}
impl AcpAgentMeta {
pub fn registry_version(&self) -> Option<&'static str> {
match &self.distribution {
AgentDistribution::Npx { version, .. }
| AgentDistribution::Uvx { version, .. }
| AgentDistribution::Binary { version, .. } => Some(*version),
}
}
}
pub fn current_platform() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"darwin-aarch64"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"darwin-x86_64"
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
"linux-aarch64"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"linux-x86_64"
}
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
{
"windows-aarch64"
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"windows-x86_64"
}
}
pub fn all_acp_agents() -> Vec<AgentType> {
vec![
AgentType::Auggie,
AgentType::Autohand,
AgentType::ClaudeCode,
AgentType::Cline,
AgentType::CodebuddyCode,
AgentType::Codex,
AgentType::CorustAgent,
AgentType::FactoryDroid,
AgentType::Gemini,
AgentType::GithubCopilot,
AgentType::Goose,
AgentType::Junie,
AgentType::Kimi,
AgentType::MinionCode,
AgentType::MistralVibe,
AgentType::OpenClaw,
AgentType::OpenCode,
AgentType::Qoder,
AgentType::QwenCode,
AgentType::Stakpak,
]
}
pub fn registry_id_for(agent_type: AgentType) -> &'static str {
match agent_type {
AgentType::Auggie => "auggie",
AgentType::Autohand => "autohand",
AgentType::ClaudeCode => "claude-acp",
AgentType::Cline => "cline",
AgentType::CodebuddyCode => "codebuddy-code",
AgentType::Codex => "codex-acp",
AgentType::CorustAgent => "corust-agent",
AgentType::FactoryDroid => "factory-droid",
AgentType::Gemini => "gemini",
AgentType::GithubCopilot => "github-copilot",
AgentType::Goose => "goose",
AgentType::Junie => "junie-acp",
AgentType::Kimi => "kimi",
AgentType::MinionCode => "minion-code",
AgentType::MistralVibe => "mistral-vibe",
AgentType::OpenClaw => "openclaw-acp",
AgentType::OpenCode => "opencode",
AgentType::Qoder => "qoder",
AgentType::QwenCode => "qwen-code",
AgentType::Stakpak => "stakpak",
}
}
#[allow(dead_code)]
pub fn from_registry_id(id: &str) -> Option<AgentType> {
match id {
"auggie" => Some(AgentType::Auggie),
"autohand" => Some(AgentType::Autohand),
"claude-acp" => Some(AgentType::ClaudeCode),
"cline" => Some(AgentType::Cline),
"codebuddy-code" => Some(AgentType::CodebuddyCode),
"codex-acp" => Some(AgentType::Codex),
"corust-agent" => Some(AgentType::CorustAgent),
"factory-droid" => Some(AgentType::FactoryDroid),
"gemini" => Some(AgentType::Gemini),
"github-copilot" => Some(AgentType::GithubCopilot),
"goose" => Some(AgentType::Goose),
"junie-acp" => Some(AgentType::Junie),
"kimi" => Some(AgentType::Kimi),
"minion-code" => Some(AgentType::MinionCode),
"mistral-vibe" => Some(AgentType::MistralVibe),
"openclaw-acp" => Some(AgentType::OpenClaw),
"opencode" => Some(AgentType::OpenCode),
"qoder" => Some(AgentType::Qoder),
"qwen-code" => Some(AgentType::QwenCode),
"stakpak" => Some(AgentType::Stakpak),
_ => None,
}
}
pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
match agent_type {
AgentType::Auggie => AcpAgentMeta {
agent_type,
name: "Auggie CLI",
description: "Augment Code's powerful software agent, backed by industry-leading context engine",
distribution: AgentDistribution::Npx {
version: "0.17.0",
package: "@augmentcode/auggie@0.17.0",
args: &["--acp"],
env: &[("AUGMENT_DISABLE_AUTO_UPDATE", "1")],
node_required: None,
},
},
AgentType::Autohand => AcpAgentMeta {
agent_type,
name: "Autohand Code",
description: "Autohand Code - AI coding agent powered by Autohand AI",
distribution: AgentDistribution::Npx {
version: "0.2.1",
package: "@autohandai/autohand-acp@0.2.1",
args: &[],
env: &[],
node_required: None,
},
},
AgentType::ClaudeCode => AcpAgentMeta {
agent_type,
name: "Claude Code",
description: "ACP wrapper for Anthropic's Claude",
distribution: AgentDistribution::Npx {
version: "0.18.0",
package: "@zed-industries/claude-agent-acp@0.18.0",
args: &[],
env: &[],
node_required: None,
},
},
AgentType::Cline => AcpAgentMeta {
agent_type,
name: "Cline",
description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
distribution: AgentDistribution::Npx {
version: "2.5.0",
package: "cline@2.5.0",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::CodebuddyCode => AcpAgentMeta {
agent_type,
name: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
distribution: AgentDistribution::Npx {
version: "2.51.2",
package: "@tencent-ai/codebuddy-code@2.51.2",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::Codex => AcpAgentMeta {
agent_type,
name: "Codex CLI",
description: "ACP adapter for OpenAI's coding assistant",
distribution: AgentDistribution::Binary {
version: "0.9.4",
cmd: "codex-acp",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-apple-darwin.tar.gz",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-apple-darwin.tar.gz",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-unknown-linux-gnu.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-unknown-linux-gnu.tar.gz",
},
PlatformBinary {
platform: "windows-aarch64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-pc-windows-msvc.zip",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-pc-windows-msvc.zip",
},
],
},
},
AgentType::CorustAgent => AcpAgentMeta {
agent_type,
name: "Corust Agent",
description: "Co-building with a seasoned Rust partner.",
distribution: AgentDistribution::Binary {
version: "0.3.4",
cmd: "corust-agent-acp",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-darwin-arm64.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-linux-x64.tar.gz",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-windows-x64.zip",
},
],
},
},
AgentType::FactoryDroid => AcpAgentMeta {
agent_type,
name: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
distribution: AgentDistribution::Npx {
version: "0.63.0",
package: "droid@0.63.0",
args: &["exec", "--output-format", "acp"],
env: &[("DROID_DISABLE_AUTO_UPDATE", "true"), ("FACTORY_DROID_AUTO_UPDATE_ENABLED", "false")],
node_required: None,
},
},
AgentType::Gemini => AcpAgentMeta {
agent_type,
name: "Gemini CLI",
description: "Google's official CLI for Gemini",
distribution: AgentDistribution::Npx {
version: "0.30.0",
package: "@google/gemini-cli@0.30.0",
args: &["--experimental-acp"],
env: &[],
node_required: None,
},
},
AgentType::GithubCopilot => AcpAgentMeta {
agent_type,
name: "GitHub Copilot",
description: "GitHub's AI pair programmer",
distribution: AgentDistribution::Npx {
version: "1.432.0",
package: "@github/copilot-language-server@1.432.0",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::Goose => AcpAgentMeta {
agent_type,
name: "goose",
description: "A local, extensible, open source AI agent that automates engineering tasks",
distribution: AgentDistribution::Binary {
version: "1.25.1",
cmd: "goose",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-aarch64-apple-darwin.tar.bz2",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-apple-darwin.tar.bz2",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-aarch64-unknown-linux-gnu.tar.bz2",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-unknown-linux-gnu.tar.bz2",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-pc-windows-msvc.zip",
},
],
},
},
AgentType::Junie => AcpAgentMeta {
agent_type,
name: "Junie",
description: "AI Coding Agent by JetBrains",
distribution: AgentDistribution::Npx {
version: "849.19.0",
package: "@jetbrains/junie-cli@849.19.0",
args: &["--acp=true"],
env: &[],
node_required: None,
},
},
AgentType::Kimi => AcpAgentMeta {
agent_type,
name: "Kimi CLI",
description: "Moonshot AI's coding assistant",
distribution: AgentDistribution::Binary {
version: "1.14.0",
cmd: "kimi",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-aarch64-apple-darwin.tar.gz",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-aarch64-unknown-linux-gnu.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-x86_64-unknown-linux-gnu.tar.gz",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-x86_64-pc-windows-msvc.zip",
},
],
},
},
AgentType::MinionCode => AcpAgentMeta {
agent_type,
name: "Minion Code",
description: "An enhanced AI code assistant built on the Minion framework with rich development tools",
distribution: AgentDistribution::Uvx {
version: "0.1.39",
package: "minion-code@0.1.39",
args: &["acp"],
env: &[],
},
},
AgentType::MistralVibe => AcpAgentMeta {
agent_type,
name: "Mistral Vibe",
description: "Mistral's open-source coding assistant",
distribution: AgentDistribution::Binary {
version: "2.2.1",
cmd: "vibe-acp",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-aarch64-2.2.1.zip",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-x86_64-2.2.1.zip",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-aarch64-2.2.1.zip",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-x86_64-2.2.1.zip",
},
PlatformBinary {
platform: "windows-aarch64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-aarch64-2.2.1.zip",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-x86_64-2.2.1.zip",
},
],
},
},
AgentType::OpenClaw => AcpAgentMeta {
agent_type,
name: "OpenClaw",
description: "Open-source personal AI assistant with ACP bridge",
distribution: AgentDistribution::Npx {
version: "2026.2.26",
package: "openclaw@2026.2.26",
args: &["acp"],
env: &[],
node_required: Some("22.12.0"),
},
},
AgentType::OpenCode => AcpAgentMeta {
agent_type,
name: "OpenCode",
description: "The open source coding agent",
distribution: AgentDistribution::Binary {
version: "1.2.15",
cmd: "opencode",
args: &["acp"],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-arm64.zip",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-x64.zip",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-arm64.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-x64.tar.gz",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-windows-x64.zip",
},
],
},
},
AgentType::Qoder => AcpAgentMeta {
agent_type,
name: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
distribution: AgentDistribution::Npx {
version: "0.1.29",
package: "@qoder-ai/qodercli@0.1.29",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::QwenCode => AcpAgentMeta {
agent_type,
name: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
distribution: AgentDistribution::Npx {
version: "0.10.6",
package: "@qwen-code/qwen-code@0.10.6",
args: &["--acp", "--experimental-skills"],
env: &[],
node_required: None,
},
},
AgentType::Stakpak => AcpAgentMeta {
agent_type,
name: "Stakpak",
description: "Open-source DevOps agent in Rust with enterprise-grade security",
distribution: AgentDistribution::Binary {
version: "0.3.62",
cmd: "stakpak",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-darwin-aarch64.tar.gz",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-darwin-x86_64.tar.gz",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-linux-aarch64.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-linux-x86_64.tar.gz",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-windows-x86_64.zip",
},
],
},
},
}
}
+125
View File
@@ -0,0 +1,125 @@
use std::collections::BTreeMap;
use serde::Deserialize;
use crate::acp::registry;
use crate::models::agent::AgentType;
pub const REGISTRY_URL: &str =
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
#[derive(Debug, Clone)]
pub struct RegistryAgent {
pub agent_type: AgentType,
pub registry_id: String,
pub name: String,
pub description: String,
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RegistryBinaryRelease {
pub version: String,
pub archive_url: String,
}
#[derive(Debug, Deserialize)]
struct RegistryPayload {
agents: Vec<RegistryAgentItem>,
}
#[derive(Debug, Deserialize)]
struct RegistryAgentItem {
id: String,
name: String,
description: String,
#[serde(default)]
version: Option<String>,
#[serde(default)]
distribution: RegistryDistribution,
}
#[derive(Debug, Default, Deserialize)]
struct RegistryDistribution {
#[serde(default)]
binary: BTreeMap<String, RegistryBinaryPlatformItem>,
}
#[derive(Debug, Default, Deserialize)]
struct RegistryBinaryPlatformItem {
#[serde(default, alias = "url")]
archive: String,
}
async fn fetch_registry_payload() -> Result<RegistryPayload, String> {
let response = reqwest::Client::new()
.get(REGISTRY_URL)
.send()
.await
.map_err(|e| format!("failed to fetch ACP registry: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"failed to fetch ACP registry: HTTP {}",
response.status()
));
}
response
.text()
.await
.map_err(|e| format!("failed to read ACP registry response: {e}"))
.and_then(|text| {
serde_json::from_str::<RegistryPayload>(&text)
.map_err(|e| format!("failed to parse ACP registry JSON: {e}"))
})
}
pub async fn fetch_supported_agents() -> Result<Vec<RegistryAgent>, String> {
let payload = fetch_registry_payload().await?;
let mut supported = Vec::new();
for item in payload.agents {
if let Some(agent_type) = registry::from_registry_id(&item.id) {
supported.push(RegistryAgent {
agent_type,
registry_id: item.id,
name: item.name,
description: item.description,
version: item.version,
});
}
}
Ok(supported)
}
pub async fn fetch_binary_release(
agent_type: AgentType,
platform: &str,
) -> Result<Option<RegistryBinaryRelease>, String> {
let payload = fetch_registry_payload().await?;
let item = payload.agents.into_iter().find(|item| {
registry::from_registry_id(&item.id)
.map(|candidate| candidate == agent_type)
.unwrap_or(false)
});
let Some(item) = item else {
return Ok(None);
};
if item.version.as_deref().unwrap_or_default().is_empty() {
return Ok(None);
}
let platform_item = item.distribution.binary.get(platform);
let Some(platform_item) = platform_item else {
return Ok(None);
};
if platform_item.archive.is_empty() {
return Ok(None);
}
Ok(Some(RegistryBinaryRelease {
version: item.version.unwrap_or_default(),
archive_url: platform_item.archive.clone(),
}))
}
+517
View File
@@ -0,0 +1,517 @@
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use sacp::schema::{
CreateTerminalRequest, CreateTerminalResponse, KillTerminalCommandRequest,
KillTerminalCommandResponse, ReleaseTerminalRequest, ReleaseTerminalResponse,
TerminalExitStatus, TerminalOutputRequest, TerminalOutputResponse, WaitForTerminalExitRequest,
WaitForTerminalExitResponse,
};
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::sync::Mutex;
type TerminalMap = HashMap<String, Arc<TerminalInstance>>;
const DEFAULT_OUTPUT_BYTE_LIMIT: u64 = 1_000_000;
#[derive(Debug)]
pub enum TerminalRuntimeError {
InvalidParams(String),
Internal(String),
}
impl TerminalRuntimeError {
pub fn to_rpc_error(self) -> sacp::Error {
match self {
Self::InvalidParams(message) => sacp::Error::invalid_params().data(message),
Self::Internal(message) => sacp::util::internal_error(message),
}
}
}
#[derive(Debug, Default, Clone)]
struct TerminalSnapshot {
output: String,
output_base_offset: u64,
truncated: bool,
exit_status: Option<TerminalExitStatus>,
}
struct TerminalInstance {
session_id: String,
output_limit: Option<usize>,
child: Mutex<Option<tokio::process::Child>>,
snapshot: Mutex<TerminalSnapshot>,
}
impl TerminalInstance {
fn new(session_id: String, output_limit: Option<u64>, child: tokio::process::Child) -> Self {
Self {
session_id,
output_limit: output_limit.and_then(|v| usize::try_from(v).ok()),
child: Mutex::new(Some(child)),
snapshot: Mutex::new(TerminalSnapshot::default()),
}
}
async fn append_output(&self, text: &str) {
let mut snapshot = self.snapshot.lock().await;
snapshot.output.push_str(text);
if let Some(limit) = self.output_limit {
let removed = enforce_output_limit(&mut snapshot.output, limit);
if removed > 0 {
snapshot.truncated = true;
snapshot.output_base_offset = snapshot
.output_base_offset
.saturating_add(u64::try_from(removed).unwrap_or(u64::MAX));
}
}
}
async fn refresh_exit_status(&self) -> Result<(), TerminalRuntimeError> {
{
let snapshot = self.snapshot.lock().await;
if snapshot.exit_status.is_some() {
return Ok(());
}
}
let maybe_status = {
let mut child_guard = self.child.lock().await;
if let Some(child) = child_guard.as_mut() {
match child.try_wait() {
Ok(Some(status)) => {
*child_guard = None;
Some(status)
}
Ok(None) => None,
Err(err) => {
return Err(TerminalRuntimeError::Internal(format!(
"failed to query terminal exit status: {err}"
)))
}
}
} else {
None
}
};
if let Some(status) = maybe_status {
let mut snapshot = self.snapshot.lock().await;
snapshot.exit_status = Some(map_exit_status(status));
}
Ok(())
}
async fn wait_for_exit(&self) -> Result<TerminalExitStatus, TerminalRuntimeError> {
self.refresh_exit_status().await?;
{
let snapshot = self.snapshot.lock().await;
if let Some(exit_status) = snapshot.exit_status.clone() {
return Ok(exit_status);
}
}
let exit_status = {
let mut child_guard = self.child.lock().await;
let Some(child) = child_guard.as_mut() else {
return Err(TerminalRuntimeError::Internal(
"terminal process missing while waiting for exit".to_string(),
));
};
let status = child.wait().await.map_err(|err| {
TerminalRuntimeError::Internal(format!(
"failed waiting for terminal process to exit: {err}"
))
})?;
*child_guard = None;
map_exit_status(status)
};
let mut snapshot = self.snapshot.lock().await;
snapshot.exit_status = Some(exit_status.clone());
Ok(exit_status)
}
async fn kill_command(&self) -> Result<(), TerminalRuntimeError> {
self.refresh_exit_status().await?;
{
let snapshot = self.snapshot.lock().await;
if snapshot.exit_status.is_some() {
return Ok(());
}
}
let exit_status = {
let mut child_guard = self.child.lock().await;
let Some(child) = child_guard.as_mut() else {
return Ok(());
};
if let Err(err) = child.kill().await {
if err.kind() != std::io::ErrorKind::InvalidInput {
return Err(TerminalRuntimeError::Internal(format!(
"failed to kill terminal process: {err}"
)));
}
}
let status = child.wait().await.map_err(|err| {
TerminalRuntimeError::Internal(format!(
"failed to wait for killed terminal process: {err}"
))
})?;
*child_guard = None;
map_exit_status(status)
};
let mut snapshot = self.snapshot.lock().await;
snapshot.exit_status = Some(exit_status);
Ok(())
}
async fn snapshot(&self) -> TerminalSnapshot {
self.snapshot.lock().await.clone()
}
}
pub struct TerminalRuntime {
terminals: Mutex<TerminalMap>,
}
#[derive(Debug, Clone)]
pub struct TerminalOutputDelta {
pub output: String,
pub next_offset: u64,
pub had_gap: bool,
pub truncated: bool,
pub exit_status: Option<TerminalExitStatus>,
}
impl TerminalRuntime {
pub fn new() -> Self {
Self {
terminals: Mutex::new(HashMap::new()),
}
}
pub async fn create_terminal(
&self,
request: CreateTerminalRequest,
) -> Result<CreateTerminalResponse, TerminalRuntimeError> {
if let Some(cwd) = request.cwd.as_ref() {
if !cwd.is_absolute() {
return Err(TerminalRuntimeError::InvalidParams(
"terminal/create requires an absolute cwd when provided".to_string(),
));
}
}
let output_byte_limit = request
.output_byte_limit
.unwrap_or(DEFAULT_OUTPUT_BYTE_LIMIT);
if output_byte_limit == 0 {
return Err(TerminalRuntimeError::InvalidParams(
"terminal/create outputByteLimit must be greater than 0".to_string(),
));
}
let mut command = crate::process::tokio_command(&request.command);
command
.args(&request.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
if let Some(cwd) = request.cwd.as_ref() {
command.current_dir(cwd);
}
for env_var in &request.env {
command.env(&env_var.name, &env_var.value);
}
let mut child = command.spawn().map_err(|err| {
TerminalRuntimeError::Internal(format!(
"failed to spawn terminal command {}: {err}",
request.command
))
})?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let terminal_id = format!("term_{}", uuid::Uuid::new_v4().simple());
let terminal = Arc::new(TerminalInstance::new(
request.session_id.to_string(),
Some(output_byte_limit),
child,
));
if let Some(reader) = stdout {
let terminal_ref = terminal.clone();
tokio::spawn(async move {
read_stream(reader, terminal_ref).await;
});
}
if let Some(reader) = stderr {
let terminal_ref = terminal.clone();
tokio::spawn(async move {
read_stream(reader, terminal_ref).await;
});
}
self.terminals
.lock()
.await
.insert(terminal_id.clone(), terminal);
Ok(CreateTerminalResponse::new(terminal_id))
}
pub async fn terminal_output(
&self,
request: TerminalOutputRequest,
) -> Result<TerminalOutputResponse, TerminalRuntimeError> {
let terminal = self
.find_terminal(
&request.terminal_id.to_string(),
&request.session_id.to_string(),
)
.await?;
terminal.refresh_exit_status().await?;
let snapshot = terminal.snapshot().await;
Ok(
TerminalOutputResponse::new(snapshot.output, snapshot.truncated)
.exit_status(snapshot.exit_status),
)
}
pub async fn terminal_output_delta(
&self,
session_id: &str,
terminal_id: &str,
from_offset: Option<u64>,
) -> Result<TerminalOutputDelta, TerminalRuntimeError> {
let terminal = self.find_terminal(terminal_id, session_id).await?;
terminal.refresh_exit_status().await?;
let snapshot = terminal.snapshot().await;
let output_len = u64::try_from(snapshot.output.len()).unwrap_or(u64::MAX);
let base_offset = snapshot.output_base_offset;
let end_offset = base_offset.saturating_add(output_len);
let requested_offset = from_offset.unwrap_or(base_offset);
let had_gap = from_offset
.map(|offset| offset < base_offset)
.unwrap_or(false);
let start_offset = requested_offset.clamp(base_offset, end_offset);
let start_index = usize::try_from(start_offset.saturating_sub(base_offset)).unwrap_or(0);
let output = snapshot.output[start_index..].to_string();
Ok(TerminalOutputDelta {
output,
next_offset: end_offset,
had_gap,
truncated: snapshot.truncated,
exit_status: snapshot.exit_status,
})
}
pub async fn wait_for_terminal_exit(
&self,
request: WaitForTerminalExitRequest,
) -> Result<WaitForTerminalExitResponse, TerminalRuntimeError> {
let terminal = self
.find_terminal(
&request.terminal_id.to_string(),
&request.session_id.to_string(),
)
.await?;
let exit_status = terminal.wait_for_exit().await?;
Ok(WaitForTerminalExitResponse::new(exit_status))
}
pub async fn kill_terminal(
&self,
request: KillTerminalCommandRequest,
) -> Result<KillTerminalCommandResponse, TerminalRuntimeError> {
let terminal = self
.find_terminal(
&request.terminal_id.to_string(),
&request.session_id.to_string(),
)
.await?;
terminal.kill_command().await?;
Ok(KillTerminalCommandResponse::new())
}
pub async fn release_terminal(
&self,
request: ReleaseTerminalRequest,
) -> Result<ReleaseTerminalResponse, TerminalRuntimeError> {
let terminal_id = request.terminal_id.to_string();
let session_id = request.session_id.to_string();
let terminal = {
let mut terminals = self.terminals.lock().await;
let Some(existing) = terminals.get(&terminal_id) else {
return Err(TerminalRuntimeError::InvalidParams(format!(
"terminal {terminal_id} not found"
)));
};
if existing.session_id != session_id {
return Err(TerminalRuntimeError::InvalidParams(format!(
"terminal {terminal_id} does not belong to session {session_id}"
)));
}
terminals.remove(&terminal_id).expect("terminal exists")
};
terminal.kill_command().await?;
Ok(ReleaseTerminalResponse::new())
}
pub async fn release_all_for_session(&self, session_id: &str) {
let removed = {
let mut terminals = self.terminals.lock().await;
let ids: Vec<String> = terminals
.iter()
.filter(|(_, term)| term.session_id == session_id)
.map(|(id, _)| id.clone())
.collect();
let mut removed = Vec::with_capacity(ids.len());
for id in ids {
if let Some(term) = terminals.remove(&id) {
removed.push(term);
}
}
removed
};
for terminal in removed {
if let Err(err) = terminal.kill_command().await {
eprintln!("[ACP] Failed to release terminal during cleanup: {err:?}");
}
}
}
async fn find_terminal(
&self,
terminal_id: &str,
session_id: &str,
) -> Result<Arc<TerminalInstance>, TerminalRuntimeError> {
let terminal = {
let terminals = self.terminals.lock().await;
terminals.get(terminal_id).cloned()
}
.ok_or_else(|| {
TerminalRuntimeError::InvalidParams(format!("terminal {terminal_id} not found"))
})?;
if terminal.session_id != session_id {
return Err(TerminalRuntimeError::InvalidParams(format!(
"terminal {terminal_id} does not belong to session {session_id}"
)));
}
Ok(terminal)
}
}
async fn read_stream<R>(mut reader: R, terminal: Arc<TerminalInstance>)
where
R: AsyncRead + Unpin,
{
let mut buffer = [0_u8; 4096];
let mut pending = Vec::<u8>::new();
loop {
match reader.read(&mut buffer).await {
Ok(0) => {
if !pending.is_empty() {
let text = String::from_utf8_lossy(&pending).to_string();
terminal.append_output(&text).await;
pending.clear();
}
break;
}
Ok(size) => {
pending.extend_from_slice(&buffer[..size]);
let decoded = decode_available_utf8(&mut pending);
if !decoded.is_empty() {
terminal.append_output(&decoded).await;
}
}
Err(_) => break,
}
}
}
fn map_exit_status(status: std::process::ExitStatus) -> TerminalExitStatus {
#[cfg(unix)]
let signal = std::os::unix::process::ExitStatusExt::signal(&status).map(|s| s.to_string());
#[cfg(not(unix))]
let signal: Option<String> = None;
let exit_code = status.code().and_then(|code| u32::try_from(code).ok());
TerminalExitStatus::new()
.exit_code(exit_code)
.signal(signal)
}
fn enforce_output_limit(output: &mut String, limit: usize) -> usize {
if output.len() <= limit {
return 0;
}
let mut start = output.len().saturating_sub(limit);
while start < output.len() && !output.is_char_boundary(start) {
start += 1;
}
output.drain(..start);
start
}
fn decode_available_utf8(pending: &mut Vec<u8>) -> String {
let mut output = String::new();
let mut consumed = 0usize;
let mut remaining = pending.as_slice();
while !remaining.is_empty() {
match std::str::from_utf8(remaining) {
Ok(text) => {
output.push_str(text);
consumed = consumed.saturating_add(remaining.len());
break;
}
Err(err) => {
let valid_up_to = err.valid_up_to();
if valid_up_to > 0 {
if let Ok(text) = std::str::from_utf8(&remaining[..valid_up_to]) {
output.push_str(text);
}
consumed = consumed.saturating_add(valid_up_to);
remaining = &remaining[valid_up_to..];
}
match err.error_len() {
Some(invalid_len) => {
output.push_str(&String::from_utf8_lossy(&remaining[..invalid_len]));
consumed = consumed.saturating_add(invalid_len);
remaining = &remaining[invalid_len..];
}
None => break, // keep partial UTF-8 sequence for next chunk
}
}
}
}
if consumed > 0 {
pending.drain(..consumed);
}
output
}
+257
View File
@@ -0,0 +1,257 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PromptInputBlock {
Text {
text: String,
},
ResourceLink {
uri: String,
name: String,
#[serde(default)]
mime_type: Option<String>,
#[serde(default)]
description: Option<String>,
},
}
/// Events pushed from Rust backend to frontend via Tauri event system.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AcpEvent {
/// Agent returned text content (streaming delta)
ContentDelta { connection_id: String, text: String },
/// Agent thinking/reasoning
Thinking { connection_id: String, text: String },
/// Agent initiated a tool call
ToolCall {
connection_id: String,
tool_call_id: String,
title: String,
kind: String,
status: String,
content: Option<String>,
raw_input: Option<String>,
raw_output: Option<String>,
},
/// Tool call status/content updated
ToolCallUpdate {
connection_id: String,
tool_call_id: String,
title: Option<String>,
status: Option<String>,
content: Option<String>,
raw_input: Option<String>,
raw_output: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
raw_output_append: Option<bool>,
},
/// Agent requests permission
PermissionRequest {
connection_id: String,
request_id: String,
tool_call: serde_json::Value,
options: Vec<PermissionOptionInfo>,
},
/// Turn completed
TurnComplete {
connection_id: String,
stop_reason: String,
},
/// Session established with agent-assigned session ID
SessionStarted {
connection_id: String,
session_id: String,
},
/// Session modes are available for this connection
SessionModes {
connection_id: String,
modes: SessionModeStateInfo,
},
/// Session configuration options are available/updated for this connection
SessionConfigOptions {
connection_id: String,
config_options: Vec<SessionConfigOptionInfo>,
},
/// Initial selector payloads (modes/config options) have been emitted
SelectorsReady { connection_id: String },
/// Current session mode changed
ModeChanged {
connection_id: String,
mode_id: String,
},
/// Agent reported plan update for current turn
PlanUpdate {
connection_id: String,
entries: Vec<PlanEntryInfo>,
},
/// Connection status changed
StatusChanged {
connection_id: String,
status: ConnectionStatus,
},
/// Error occurred
Error {
connection_id: String,
message: String,
},
/// Available slash commands updated
AvailableCommands {
connection_id: String,
commands: Vec<AvailableCommandInfo>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionOptionInfo {
pub option_id: String,
pub name: String,
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionModeInfo {
pub id: String,
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionModeStateInfo {
pub current_mode_id: String,
pub available_modes: Vec<SessionModeInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfigSelectOptionInfo {
pub value: String,
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfigSelectGroupInfo {
pub group: String,
pub name: String,
pub options: Vec<SessionConfigSelectOptionInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfigSelectInfo {
pub current_value: String,
pub options: Vec<SessionConfigSelectOptionInfo>,
pub groups: Vec<SessionConfigSelectGroupInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionConfigKindInfo {
Select(SessionConfigSelectInfo),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfigOptionInfo {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub kind: SessionConfigKindInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanEntryInfo {
pub content: String,
pub priority: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionStatus {
Connecting,
Downloading,
Connected,
Prompting,
Disconnected,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConnectionInfo {
pub id: String,
pub agent_type: crate::models::agent::AgentType,
pub status: ConnectionStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct AcpAgentInfo {
pub agent_type: crate::models::agent::AgentType,
pub registry_id: String,
pub registry_version: Option<String>,
pub name: String,
pub description: String,
pub available: bool,
pub distribution_type: String,
pub enabled: bool,
pub sort_order: i32,
pub installed_version: Option<String>,
pub env: BTreeMap<String, String>,
pub config_json: Option<String>,
pub config_file_path: Option<String>,
pub opencode_auth_json: Option<String>,
pub codex_auth_json: Option<String>,
pub codex_config_toml: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentSkillScope {
Global,
Project,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentSkillLayout {
MarkdownFile,
SkillDirectory,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillLocation {
pub scope: AgentSkillScope,
pub path: String,
pub exists: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillItem {
pub id: String,
pub name: String,
pub scope: AgentSkillScope,
pub layout: AgentSkillLayout,
pub path: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillsListResult {
pub supported: bool,
pub message: Option<String>,
pub locations: Vec<AgentSkillLocation>,
pub skills: Vec<AgentSkillItem>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillContent {
pub skill: AgentSkillItem,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableCommandInfo {
pub name: String,
pub description: String,
pub input_hint: Option<String>,
}
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
use std::collections::{HashMap, HashSet};
use crate::db::entities::conversation;
use crate::db::service::{conversation_service, folder_service, import_service};
use crate::db::AppDatabase;
use crate::models::*;
use crate::parsers::claude::ClaudeParser;
use crate::parsers::codex::CodexParser;
use crate::parsers::gemini::GeminiParser;
use crate::parsers::opencode::OpenCodeParser;
use crate::parsers::{path_eq_for_matching, AgentParser};
#[tauri::command]
pub async fn list_folder_conversations(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
status: Option<String>,
) -> Result<Vec<DbConversationSummary>, String> {
conversation_service::list_by_folder(&db.conn, folder_id, agent_type, search, sort_by, status)
.await
.map_err(|e| e.to_string())
}
/// Synchronous implementation shared by list_conversations, list_folders, and get_stats.
fn list_conversations_sync(
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
folder_path: Option<String>,
) -> Result<Vec<ConversationSummary>, String> {
let mut all_conversations = Vec::new();
let mut seen_keys = HashSet::new();
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
(AgentType::Codex, Box::new(CodexParser::new())),
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
(AgentType::Gemini, Box::new(GeminiParser::new())),
];
for (at, parser) in &parsers {
if let Some(ref filter) = agent_type {
if filter != at {
continue;
}
}
match parser.list_conversations() {
Ok(conversations) => {
// Deduplicate conversations based on (agent_type, id) combination
for conversation in conversations {
let key = format!("{:?}-{}", conversation.agent_type, conversation.id);
if seen_keys.insert(key) {
all_conversations.push(conversation);
}
}
}
Err(e) => {
eprintln!("Error listing {} conversations: {}", at, e);
}
}
}
// Apply search filter
if let Some(ref query) = search {
let query_lower = query.to_lowercase();
all_conversations.retain(|s| {
s.title
.as_ref()
.map(|t| t.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| s.folder_name
.as_ref()
.map(|p| p.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| s.folder_path
.as_ref()
.map(|p| p.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| s.git_branch
.as_ref()
.map(|b| b.to_lowercase().contains(&query_lower))
.unwrap_or(false)
});
}
// Apply folder path filter
if let Some(ref fp) = folder_path {
all_conversations.retain(|s| {
s.folder_path
.as_deref()
.map(|p| path_eq_for_matching(p, fp.as_str()))
.unwrap_or(false)
});
}
// Apply sorting
match sort_by.as_deref() {
Some("oldest") => all_conversations.sort_by(|a, b| a.started_at.cmp(&b.started_at)),
Some("messages") => all_conversations.sort_by(|a, b| b.message_count.cmp(&a.message_count)),
_ => all_conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at)), // default: newest first
}
Ok(all_conversations)
}
#[tauri::command]
pub async fn list_conversations(
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
folder_path: Option<String>,
) -> Result<Vec<ConversationSummary>, String> {
tokio::task::spawn_blocking(move || {
list_conversations_sync(agent_type, search, sort_by, folder_path)
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn get_conversation(
agent_type: AgentType,
conversation_id: String,
) -> Result<ConversationDetail, String> {
tokio::task::spawn_blocking(move || {
let parser: Box<dyn AgentParser> = match agent_type {
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
AgentType::Codex => Box::new(CodexParser::new()),
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()),
_ => {
return Err(format!(
"conversation parsing not supported for {agent_type}"
))
}
};
parser
.get_conversation(&conversation_id)
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn list_folders() -> Result<Vec<FolderInfo>, String> {
tokio::task::spawn_blocking(move || {
let all_conversations = list_conversations_sync(None, None, None, None)?;
Ok(compute_folders(&all_conversations))
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn get_stats() -> Result<AgentStats, String> {
tokio::task::spawn_blocking(move || {
let all_conversations = list_conversations_sync(None, None, None, None)?;
compute_stats(&all_conversations)
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn get_sidebar_data() -> Result<SidebarData, String> {
tokio::task::spawn_blocking(move || {
let all_conversations = list_conversations_sync(None, None, None, None)?;
let folders = compute_folders(&all_conversations);
let stats = compute_stats(&all_conversations)?;
Ok(SidebarData { folders, stats })
})
.await
.map_err(|e| e.to_string())?
}
fn compute_folders(all_conversations: &[ConversationSummary]) -> Vec<FolderInfo> {
let mut folder_map: HashMap<String, FolderInfo> = HashMap::new();
for conversation in all_conversations {
let path = conversation
.folder_path
.clone()
.unwrap_or_else(|| "unknown".to_string());
let name = conversation
.folder_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
let entry = folder_map
.entry(path.clone())
.or_insert_with(|| FolderInfo {
path: path.clone(),
name,
agent_types: Vec::new(),
conversation_count: 0,
});
entry.conversation_count += 1;
if !entry.agent_types.contains(&conversation.agent_type) {
entry.agent_types.push(conversation.agent_type);
}
}
let mut folders: Vec<FolderInfo> = folder_map.into_values().collect();
folders.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
folders
}
#[tauri::command]
pub async fn import_local_conversations(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
) -> Result<ImportResult, String> {
let folder = folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Folder not found: {folder_id}"))?;
import_service::import_local_conversations(&db.conn, folder_id, &folder.path)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_folder_conversation(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
) -> Result<DbConversationDetail, String> {
let summary = conversation_service::get_by_id(&db.conn, conversation_id)
.await
.map_err(|e| e.to_string())?;
let (turns, session_stats) = if let Some(ref ext_id) = summary.external_id {
let at = summary.agent_type;
let eid = ext_id.clone();
tokio::task::spawn_blocking(move || -> Result<_, String> {
let parser: Box<dyn AgentParser> = match at {
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
AgentType::Codex => Box::new(CodexParser::new()),
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
AgentType::Gemini => Box::new(GeminiParser::new()),
_ => return Ok((vec![], None)),
};
// If the external session file doesn't exist yet (e.g., new ACP session
// not yet synced to disk), return empty turns instead of failing.
match parser.get_conversation(&eid) {
Ok(d) => Ok((d.turns, d.session_stats)),
Err(crate::parsers::ParseError::ConversationNotFound(_)) => Ok((vec![], None)),
Err(e) => Err(e.to_string()),
}
})
.await
.map_err(|e| e.to_string())?
.map_err(|e: String| e)?
} else {
(vec![], None)
};
let mut summary = summary;
summary.message_count = turns.len() as u32;
Ok(DbConversationDetail {
summary,
turns,
session_stats,
})
}
#[tauri::command]
pub async fn create_conversation(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
agent_type: AgentType,
title: Option<String>,
) -> Result<i32, String> {
// Detect current git branch from the folder path
let git_branch = if let Some(folder) = folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(|e| e.to_string())?
{
detect_git_branch(&folder.path).await
} else {
None
};
let model = conversation_service::create(&db.conn, folder_id, agent_type, title, git_branch)
.await
.map_err(|e| e.to_string())?;
Ok(model.id)
}
async fn detect_git_branch(path: &str) -> Option<String> {
let output = crate::process::tokio_command("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(path)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
return None;
}
Some(branch)
}
#[tauri::command]
pub async fn update_conversation_status(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
status: String,
) -> Result<(), String> {
let status_enum: conversation::ConversationStatus =
serde_json::from_value(serde_json::Value::String(status)).map_err(|e| e.to_string())?;
conversation_service::update_status(&db.conn, conversation_id, status_enum)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_conversation_title(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
title: String,
) -> Result<(), String> {
conversation_service::update_title(&db.conn, conversation_id, title)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_conversation_external_id(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
external_id: String,
) -> Result<(), String> {
conversation_service::update_external_id(&db.conn, conversation_id, external_id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_conversation(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
) -> Result<(), String> {
conversation_service::soft_delete(&db.conn, conversation_id)
.await
.map_err(|e| e.to_string())
}
fn compute_stats(all_conversations: &[ConversationSummary]) -> Result<AgentStats, String> {
let mut total_messages: u32 = 0;
let mut counts: HashMap<AgentType, u32> = HashMap::new();
for conversation in all_conversations {
total_messages += conversation.message_count;
*counts.entry(conversation.agent_type).or_insert(0) += 1;
}
let mut by_agent: Vec<AgentConversationCount> = counts
.into_iter()
.map(|(agent_type, conversation_count)| AgentConversationCount {
agent_type,
conversation_count,
})
.collect();
by_agent.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
Ok(AgentStats {
total_conversations: all_conversations.len() as u32,
total_messages,
by_agent,
})
}
+153
View File
@@ -0,0 +1,153 @@
use crate::db::error::DbError;
use crate::db::service::folder_command_service;
use crate::db::AppDatabase;
use crate::models::FolderCommandInfo;
use std::path::Path;
use tokio::sync::Mutex;
static BOOTSTRAP_FOLDER_COMMANDS_LOCK: Mutex<()> = Mutex::const_new(());
fn load_package_scripts_as_commands(folder_path: &str) -> Vec<(String, String)> {
let mut has_package_json = false;
let mut has_pnpm_lock = false;
let mut has_yarn_lock = false;
let mut has_bun_lock = false;
let entries = match std::fs::read_dir(folder_path) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
for entry in entries.flatten() {
let Some(file_name) = entry.file_name().to_str().map(|s| s.to_string()) else {
continue;
};
match file_name.as_str() {
"package.json" => has_package_json = true,
"pnpm-lock.yaml" => has_pnpm_lock = true,
"yarn.lock" => has_yarn_lock = true,
"bun.lockb" | "bun.lock" => has_bun_lock = true,
_ => {}
}
}
if !has_package_json {
return Vec::new();
}
let package_json_path = Path::new(folder_path).join("package.json");
let package_json_content = match std::fs::read_to_string(package_json_path) {
Ok(content) => content,
Err(_) => return Vec::new(),
};
let package_json: serde_json::Value = match serde_json::from_str(&package_json_content) {
Ok(value) => value,
Err(_) => return Vec::new(),
};
let package_manager = if has_pnpm_lock {
"pnpm"
} else if has_yarn_lock {
"yarn"
} else if has_bun_lock {
"bun"
} else {
"npm"
};
let mut commands = Vec::new();
if let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object()) {
for (script_name, script_value) in scripts {
if script_name.trim().is_empty() || script_value.as_str().is_none() {
continue;
}
commands.push((
script_name.to_string(),
format!("{package_manager} run {script_name}"),
));
}
}
commands
}
#[tauri::command]
pub async fn list_folder_commands(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
) -> Result<Vec<FolderCommandInfo>, DbError> {
folder_command_service::list_by_folder(&db.conn, folder_id).await
}
#[tauri::command]
pub async fn create_folder_command(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
name: String,
command: String,
) -> Result<FolderCommandInfo, DbError> {
folder_command_service::create(&db.conn, folder_id, &name, &command).await
}
#[tauri::command]
pub async fn update_folder_command(
db: tauri::State<'_, AppDatabase>,
id: i32,
name: Option<String>,
command: Option<String>,
sort_order: Option<i32>,
) -> Result<FolderCommandInfo, DbError> {
folder_command_service::update(&db.conn, id, name, command, sort_order).await
}
#[tauri::command]
pub async fn delete_folder_command(
db: tauri::State<'_, AppDatabase>,
id: i32,
) -> Result<(), DbError> {
folder_command_service::delete(&db.conn, id).await
}
#[tauri::command]
pub async fn reorder_folder_commands(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
ids: Vec<i32>,
) -> Result<(), DbError> {
folder_command_service::reorder(&db.conn, folder_id, ids).await
}
#[tauri::command]
pub async fn bootstrap_folder_commands_from_package_json(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
folder_path: String,
) -> Result<Vec<FolderCommandInfo>, DbError> {
let existing = folder_command_service::list_by_folder(&db.conn, folder_id).await?;
if !existing.is_empty() {
return Ok(existing);
}
let path_for_task = folder_path;
let commands_to_create =
tokio::task::spawn_blocking(move || load_package_scripts_as_commands(&path_for_task))
.await
.map_err(|e| DbError::Migration(format!("bootstrap task failed: {e}")))?;
if commands_to_create.is_empty() {
return Ok(existing);
}
// Serialize bootstrap so concurrent calls do not create duplicate commands.
let _bootstrap_guard = BOOTSTRAP_FOLDER_COMMANDS_LOCK.lock().await;
let latest = folder_command_service::list_by_folder(&db.conn, folder_id).await?;
if !latest.is_empty() {
return Ok(latest);
}
folder_command_service::create_many(&db.conn, folder_id, &commands_to_create).await?;
folder_command_service::list_by_folder(&db.conn, folder_id).await
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
pub mod acp;
pub mod conversations;
pub mod folder_commands;
pub mod folders;
pub mod mcp;
pub mod system_settings;
pub mod terminal;
pub mod windows;
+78
View File
@@ -0,0 +1,78 @@
use sea_orm::DatabaseConnection;
use tauri::State;
use crate::db::service::app_metadata_service;
use crate::db::AppDatabase;
use crate::models::SystemProxySettings;
use crate::network::proxy;
const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
fn normalize_proxy_settings(settings: SystemProxySettings) -> Result<SystemProxySettings, String> {
if !settings.enabled {
let proxy_url = settings
.proxy_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
return Ok(SystemProxySettings {
enabled: false,
proxy_url,
});
}
let proxy_url = settings
.proxy_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?;
reqwest::Proxy::all(proxy_url).map_err(|e| format!("invalid proxy url: {e}"))?;
Ok(SystemProxySettings {
enabled: true,
proxy_url: Some(proxy_url.to_string()),
})
}
pub(crate) async fn load_system_proxy_settings(
conn: &DatabaseConnection,
) -> Result<SystemProxySettings, String> {
let raw = app_metadata_service::get_value(conn, SYSTEM_PROXY_SETTINGS_KEY)
.await
.map_err(|e| e.to_string())?;
let Some(raw) = raw else {
return Ok(SystemProxySettings::default());
};
let parsed = serde_json::from_str::<SystemProxySettings>(&raw)
.map_err(|e| format!("failed to parse stored proxy settings: {e}"))?;
normalize_proxy_settings(parsed)
}
#[tauri::command]
pub async fn get_system_proxy_settings(
db: State<'_, AppDatabase>,
) -> Result<SystemProxySettings, String> {
load_system_proxy_settings(&db.conn).await
}
#[tauri::command]
pub async fn update_system_proxy_settings(
settings: SystemProxySettings,
db: State<'_, AppDatabase>,
) -> Result<SystemProxySettings, String> {
let normalized = normalize_proxy_settings(settings)?;
let serialized = serde_json::to_string(&normalized).map_err(|e| e.to_string())?;
app_metadata_service::upsert_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY, &serialized)
.await
.map_err(|e| e.to_string())?;
proxy::apply_system_proxy_settings(&normalized)?;
Ok(normalized)
}
+56
View File
@@ -0,0 +1,56 @@
use tauri::State;
use crate::terminal::error::TerminalError;
use crate::terminal::manager::TerminalManager;
use crate::terminal::types::TerminalInfo;
#[tauri::command]
pub fn terminal_spawn(
working_dir: String,
initial_command: Option<String>,
manager: State<'_, TerminalManager>,
app_handle: tauri::AppHandle,
window: tauri::WebviewWindow,
) -> Result<String, TerminalError> {
manager.spawn(
working_dir,
window.label().to_string(),
app_handle,
initial_command,
)
}
#[tauri::command]
pub fn terminal_write(
terminal_id: String,
data: String,
manager: State<'_, TerminalManager>,
) -> Result<(), TerminalError> {
manager.write(&terminal_id, data.as_bytes())
}
#[tauri::command]
pub fn terminal_resize(
terminal_id: String,
cols: u16,
rows: u16,
manager: State<'_, TerminalManager>,
) -> Result<(), TerminalError> {
manager.resize(&terminal_id, cols, rows)
}
#[tauri::command]
pub fn terminal_kill(
terminal_id: String,
manager: State<'_, TerminalManager>,
) -> Result<(), TerminalError> {
manager.kill(&terminal_id)
}
#[tauri::command]
pub fn terminal_list(
manager: State<'_, TerminalManager>,
app_handle: tauri::AppHandle,
) -> Result<Vec<TerminalInfo>, TerminalError> {
Ok(manager.list_with_exit_check(Some(&app_handle)))
}
+365
View File
@@ -0,0 +1,365 @@
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::db::AppDatabase;
use crate::models::FolderHistoryEntry;
pub struct SettingsWindowState {
owner_window_label: Mutex<Option<String>>,
disabled_windows: Mutex<HashSet<String>>,
}
pub struct CommitWindowState {
owner_by_commit_label: Mutex<HashMap<String, String>>,
}
pub(crate) fn apply_platform_window_style<'a, R, M>(
builder: WebviewWindowBuilder<'a, R, M>,
) -> WebviewWindowBuilder<'a, R, M>
where
R: tauri::Runtime,
M: tauri::Manager<R>,
{
#[cfg(target_os = "macos")]
{
return builder
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay);
}
#[cfg(target_os = "windows")]
{
return builder.decorations(false);
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
builder
}
}
#[cfg(target_os = "windows")]
fn ensure_windows_undecorated(window: &tauri::WebviewWindow) {
let _ = window.set_decorations(false);
}
#[cfg(not(target_os = "windows"))]
fn ensure_windows_undecorated(_window: &tauri::WebviewWindow) {}
impl SettingsWindowState {
pub fn new() -> Self {
Self {
owner_window_label: Mutex::new(None),
disabled_windows: Mutex::new(HashSet::new()),
}
}
fn set_owner(&self, label: String) {
if let Ok(mut owner) = self.owner_window_label.lock() {
*owner = Some(label);
}
}
fn take_owner(&self) -> Option<String> {
self.owner_window_label
.lock()
.ok()
.and_then(|mut owner| owner.take())
}
fn set_disabled_windows(&self, labels: HashSet<String>) {
if let Ok(mut disabled) = self.disabled_windows.lock() {
*disabled = labels;
}
}
fn take_disabled_windows(&self) -> HashSet<String> {
self.disabled_windows
.lock()
.map(|mut disabled| std::mem::take(&mut *disabled))
.unwrap_or_default()
}
}
impl CommitWindowState {
pub fn new() -> Self {
Self {
owner_by_commit_label: Mutex::new(HashMap::new()),
}
}
fn set_owner(&self, commit_label: String, owner_label: String) {
if let Ok(mut owners) = self.owner_by_commit_label.lock() {
owners.insert(commit_label, owner_label);
}
}
fn take_owner(&self, commit_label: &str) -> Option<String> {
self.owner_by_commit_label
.lock()
.ok()
.and_then(|mut owners| owners.remove(commit_label))
}
}
fn get_folder_id_from_window(window: &tauri::WebviewWindow) -> Option<i32> {
let url = window.url().ok()?;
url.query_pairs()
.find(|(key, _)| key == "id")
.and_then(|(_, value)| value.parse::<i32>().ok())
}
fn resolve_settings_route(section: Option<&str>) -> &'static str {
match section {
Some("appearance") => "settings/appearance",
Some("agents") => "settings/agents",
Some("mcp") => "settings/mcp",
Some("skills") => "settings/skills",
Some("shortcuts") => "settings/shortcuts",
Some("system") => "settings/system",
_ => "settings/system",
}
}
fn normalize_agent_query(agent_type: Option<&str>) -> Option<String> {
let raw = agent_type?.trim();
if raw.is_empty() {
return None;
}
if raw
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
{
return Some(raw.to_string());
}
None
}
fn resolve_settings_target(section: Option<&str>, agent_type: Option<&str>) -> String {
let route = resolve_settings_route(section);
if route == "settings/agents" {
if let Some(agent) = normalize_agent_query(agent_type) {
return format!("{route}?agent={agent}");
}
}
route.to_string()
}
#[tauri::command]
pub async fn list_open_folders(
app: AppHandle,
db: tauri::State<'_, AppDatabase>,
) -> Result<Vec<FolderHistoryEntry>, String> {
let windows = app.webview_windows();
let mut folder_ids: Vec<i32> = Vec::new();
for (label, window) in &windows {
if label.starts_with("folder-") {
if let Some(id) = get_folder_id_from_window(window) {
folder_ids.push(id);
}
}
}
let all_folders = crate::db::service::folder_service::list_folders(&db.conn)
.await
.map_err(|e| e.to_string())?;
let open_folders: Vec<FolderHistoryEntry> = all_folders
.into_iter()
.filter(|f| folder_ids.contains(&f.id))
.collect();
Ok(open_folders)
}
#[tauri::command]
pub async fn focus_folder_window(app: AppHandle, folder_id: i32) -> Result<(), String> {
let windows = app.webview_windows();
for (label, window) in &windows {
if label.starts_with("folder-") {
if let Some(id) = get_folder_id_from_window(window) {
if id == folder_id {
window.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
}
}
}
Err(format!("No open window for folder {}", folder_id))
}
#[tauri::command]
pub async fn open_folder_window(
app: AppHandle,
db: tauri::State<'_, AppDatabase>,
path: String,
) -> Result<(), String> {
// Add to history via DB
let entry = crate::db::service::folder_service::add_folder(&db.conn, &path)
.await
.map_err(|e| e.to_string())?;
// Create folder window with unique label
let label = format!("folder-{}", uuid::Uuid::new_v4());
let url = WebviewUrl::App(format!("folder?id={}", entry.id).into());
let builder = WebviewWindowBuilder::new(&app, &label, url)
.title(&entry.name)
.inner_size(1260.0, 860.0)
.min_inner_size(900.0, 600.0);
let folder_window = apply_platform_window_style(builder)
.build()
.map_err(|e| e.to_string())?;
ensure_windows_undecorated(&folder_window);
// Close welcome window
if let Some(w) = app.get_webview_window("welcome") {
w.close().map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn open_commit_window(
app: AppHandle,
window: tauri::WebviewWindow,
db: tauri::State<'_, AppDatabase>,
state: tauri::State<'_, CommitWindowState>,
folder_id: i32,
) -> Result<(), String> {
let owner_label = window.label().to_string();
let label = format!("commit-{folder_id}");
if let Some(existing) = app.get_webview_window(&label) {
if let Some(owner_window) = app.get_webview_window(&owner_label) {
owner_window.set_enabled(false).map_err(|e| e.to_string())?;
}
state.set_owner(label.clone(), owner_label);
let _ = existing.unminimize();
existing.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Folder {} not found", folder_id))?;
let url = WebviewUrl::App(format!("commit?folderId={folder_id}").into());
let builder = WebviewWindowBuilder::new(&app, &label, url)
.title(&format!("提交代码 - {}", folder.name))
.inner_size(1220.0, 820.0)
.min_inner_size(980.0, 620.0)
.always_on_top(true)
.center();
let commit_window = apply_platform_window_style(builder)
.build()
.map_err(|e| e.to_string())?;
ensure_windows_undecorated(&commit_window);
if let Some(owner_window) = app.get_webview_window(&owner_label) {
if let Err(err) = owner_window.set_enabled(false) {
let _ = commit_window.close();
return Err(err.to_string());
}
}
state.set_owner(label, owner_label);
commit_window.set_focus().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn open_settings_window(
app: AppHandle,
window: tauri::WebviewWindow,
section: Option<String>,
agent_type: Option<String>,
state: tauri::State<'_, SettingsWindowState>,
) -> Result<(), String> {
let target_route = resolve_settings_target(section.as_deref(), agent_type.as_deref());
if let Some(existing) = app.get_webview_window("settings") {
ensure_windows_undecorated(&existing);
if section.is_some() || agent_type.is_some() {
let target_path = format!("/{target_route}");
let target_json = serde_json::to_string(&target_path).map_err(|e| e.to_string())?;
let nav_script = format!("window.location.replace({target_json});");
existing.eval(&nav_script).map_err(|e| e.to_string())?;
}
let _ = existing.unminimize();
existing.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
let owner_label = window.label().to_string();
let url = WebviewUrl::App(target_route.into());
let builder = WebviewWindowBuilder::new(&app, "settings", url)
.title("Settings")
.inner_size(1080.0, 700.0)
.min_inner_size(1080.0, 600.0)
.always_on_top(true)
.center();
let settings_window = apply_platform_window_style(builder)
.build()
.map_err(|e| e.to_string())?;
ensure_windows_undecorated(&settings_window);
let mut disabled = HashSet::new();
for (label, webview) in app.webview_windows() {
if label != "settings" {
webview.set_enabled(false).map_err(|e| e.to_string())?;
disabled.insert(label);
}
}
state.set_owner(owner_label);
state.set_disabled_windows(disabled);
settings_window.set_focus().map_err(|e| e.to_string())?;
Ok(())
}
pub fn restore_windows_after_settings(app: &AppHandle, state: &SettingsWindowState) {
for label in state.take_disabled_windows() {
if let Some(window) = app.get_webview_window(&label) {
let _ = window.set_enabled(true);
}
}
if let Some(owner_label) = state.take_owner() {
if let Some(window) = app.get_webview_window(&owner_label) {
let _ = window.set_focus();
}
}
}
pub fn restore_window_after_commit(
app: &AppHandle,
state: &CommitWindowState,
commit_window_label: &str,
) {
if let Some(owner_label) = state.take_owner(commit_window_label) {
if let Some(window) = app.get_webview_window(&owner_label) {
let _ = window.set_enabled(true);
let _ = window.set_focus();
}
}
}
pub fn open_welcome_window(app: &AppHandle) -> Result<(), String> {
if let Some(existing) = app.get_webview_window("welcome") {
ensure_windows_undecorated(&existing);
return Ok(());
}
let url = WebviewUrl::App("welcome".into());
let builder = WebviewWindowBuilder::new(app, "welcome", url)
.title("Codeg")
.inner_size(800.0, 520.0)
.min_inner_size(600.0, 400.0)
.center();
let welcome_window = apply_platform_window_style(builder)
.build()
.map_err(|e| e.to_string())?;
ensure_windows_undecorated(&welcome_window);
Ok(())
}
@@ -0,0 +1,21 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "agent_setting")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub agent_type: String,
pub registry_id: String,
pub enabled: bool,
pub sort_order: i32,
pub installed_version: Option<String>,
pub env_json: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+19
View File
@@ -0,0 +1,19 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "app_metadata")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub key: String,
pub value: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+53
View File
@@ -0,0 +1,53 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
#[serde(rename_all = "snake_case")]
pub enum ConversationStatus {
#[sea_orm(string_value = "in_progress")]
InProgress,
#[sea_orm(string_value = "pending_review")]
PendingReview,
#[sea_orm(string_value = "completed")]
Completed,
#[sea_orm(string_value = "cancelled")]
Cancelled,
}
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "conversation")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub folder_id: i32,
pub title: Option<String>,
pub agent_type: String,
pub status: ConversationStatus,
pub model: Option<String>,
pub git_branch: Option<String>,
pub external_id: Option<String>,
pub parent_id: Option<i32>,
pub message_count: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::folder::Entity",
from = "Column::FolderId",
to = "super::folder::Column::Id"
)]
Folder,
}
impl Related<super::folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Folder.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+51
View File
@@ -0,0 +1,51 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "folder")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub path: String,
pub git_branch: Option<String>,
pub default_agent_type: Option<String>,
pub last_opened_at: DateTimeUtc,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub deleted_at: Option<DateTimeUtc>,
pub is_open: bool,
pub parent_branch: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::conversation::Entity")]
Conversations,
#[sea_orm(has_many = "super::folder_opened_conversation::Entity")]
OpenedConversations,
#[sea_orm(has_many = "super::folder_command::Entity")]
FolderCommands,
}
impl Related<super::conversation::Entity> for Entity {
fn to() -> RelationDef {
Relation::Conversations.def()
}
}
impl Related<super::folder_opened_conversation::Entity> for Entity {
fn to() -> RelationDef {
Relation::OpenedConversations.def()
}
}
impl Related<super::folder_command::Entity> for Entity {
fn to() -> RelationDef {
Relation::FolderCommands.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "folder_command")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub folder_id: i32,
pub name: String,
pub command: String,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::folder::Entity",
from = "Column::FolderId",
to = "super::folder::Column::Id"
)]
Folder,
}
impl Related<super::folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Folder.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,47 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "folder_opened_conversation")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub folder_id: i32,
pub conversation_id: i32,
pub position: i32,
pub is_active: bool,
pub is_pinned: bool,
pub agent_type: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::folder::Entity",
from = "Column::FolderId",
to = "super::folder::Column::Id"
)]
Folder,
#[sea_orm(
belongs_to = "super::conversation::Entity",
from = "Column::ConversationId",
to = "super::conversation::Column::Id"
)]
Conversation,
}
impl Related<super::folder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Folder.def()
}
}
impl Related<super::conversation::Entity> for Entity {
fn to() -> RelationDef {
Relation::Conversation.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+7
View File
@@ -0,0 +1,7 @@
pub mod agent_setting;
pub mod app_metadata;
pub mod conversation;
pub mod folder;
pub mod folder_command;
pub mod folder_opened_conversation;
pub mod prelude;
+8
View File
@@ -0,0 +1,8 @@
#![allow(unused_imports)]
pub use super::agent_setting::Entity as AgentSetting;
pub use super::app_metadata::Entity as AppMetadata;
pub use super::conversation::Entity as Conversation;
pub use super::folder::Entity as Folder;
pub use super::folder_command::Entity as FolderCommand;
pub use super::folder_opened_conversation::Entity as FolderOpenedConversation;
+23
View File
@@ -0,0 +1,23 @@
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("migration error: {0}")]
Migration(String),
#[allow(dead_code)]
#[error("database not initialized")]
NotInitialized,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
impl Serialize for DbError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
@@ -0,0 +1,378 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 1. app_metadata table
manager
.create_table(
Table::create()
.table(AppMetadata::Table)
.if_not_exists()
.col(
ColumnDef::new(AppMetadata::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(AppMetadata::Key)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(AppMetadata::Value).string().not_null())
.col(
ColumnDef::new(AppMetadata::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(AppMetadata::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(AppMetadata::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.to_owned(),
)
.await?;
// 2. folder table
manager
.create_table(
Table::create()
.table(Folder::Table)
.if_not_exists()
.col(
ColumnDef::new(Folder::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Folder::Name).string().not_null())
.col(
ColumnDef::new(Folder::Path)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(Folder::GitBranch).string().null())
.col(ColumnDef::new(Folder::DefaultAgentType).string().null())
.col(
ColumnDef::new(Folder::LastOpenedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Folder::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Folder::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Folder::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.to_owned(),
)
.await?;
// 3. conversation table
manager
.create_table(
Table::create()
.table(Conversation::Table)
.if_not_exists()
.col(
ColumnDef::new(Conversation::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Conversation::FolderId).integer().not_null())
.col(ColumnDef::new(Conversation::Title).string().null())
.col(ColumnDef::new(Conversation::AgentType).string().not_null())
.col(
ColumnDef::new(Conversation::Status)
.string()
.not_null()
.default("in_progress"),
)
.col(ColumnDef::new(Conversation::Model).string().null())
.col(ColumnDef::new(Conversation::GitBranch).string().null())
.col(ColumnDef::new(Conversation::ExternalId).string().null())
.col(ColumnDef::new(Conversation::ParentId).integer().null())
.col(
ColumnDef::new(Conversation::MessageCount)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(Conversation::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Conversation::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(Conversation::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_conversation_folder")
.from(Conversation::Table, Conversation::FolderId)
.to(Folder::Table, Folder::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// 4. folder_opened_conversation table
manager
.create_table(
Table::create()
.table(FolderOpenedConversation::Table)
.if_not_exists()
.col(
ColumnDef::new(FolderOpenedConversation::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(FolderOpenedConversation::FolderId)
.integer()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::ConversationId)
.integer()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::Position)
.integer()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::IsActive)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(FolderOpenedConversation::IsPinned)
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(FolderOpenedConversation::AgentType)
.string()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(FolderOpenedConversation::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_foc_folder")
.from(
FolderOpenedConversation::Table,
FolderOpenedConversation::FolderId,
)
.to(Folder::Table, Folder::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_foc_conversation")
.from(
FolderOpenedConversation::Table,
FolderOpenedConversation::ConversationId,
)
.to(Conversation::Table, Conversation::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// 6. indexes
manager
.create_index(
Index::create()
.name("idx_folder_deleted_last_opened")
.table(Folder::Table)
.col(Folder::DeletedAt)
.col(Folder::LastOpenedAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_conversation_folder_id")
.table(Conversation::Table)
.col(Conversation::FolderId)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_conversation_deleted_created")
.table(Conversation::Table)
.col(Conversation::DeletedAt)
.col(Conversation::CreatedAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_conversation_external_agent")
.table(Conversation::Table)
.col(Conversation::ExternalId)
.col(Conversation::AgentType)
.unique()
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_foc_folder_position")
.table(FolderOpenedConversation::Table)
.col(FolderOpenedConversation::FolderId)
.col(FolderOpenedConversation::Position)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_foc_folder_conversation")
.table(FolderOpenedConversation::Table)
.col(FolderOpenedConversation::FolderId)
.col(FolderOpenedConversation::ConversationId)
.unique()
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
Table::drop()
.table(FolderOpenedConversation::Table)
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(Conversation::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Folder::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(AppMetadata::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum AppMetadata {
Table,
Id,
Key,
Value,
CreatedAt,
UpdatedAt,
DeletedAt,
}
#[derive(DeriveIden)]
enum Folder {
Table,
Id,
Name,
Path,
GitBranch,
DefaultAgentType,
LastOpenedAt,
CreatedAt,
UpdatedAt,
DeletedAt,
}
#[derive(DeriveIden)]
enum Conversation {
Table,
Id,
FolderId,
Title,
AgentType,
Status,
Model,
GitBranch,
ExternalId,
ParentId,
MessageCount,
CreatedAt,
UpdatedAt,
DeletedAt,
}
#[derive(DeriveIden)]
enum FolderOpenedConversation {
Table,
Id,
FolderId,
ConversationId,
Position,
IsActive,
IsPinned,
AgentType,
CreatedAt,
UpdatedAt,
}
@@ -0,0 +1,87 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(FolderCommand::Table)
.if_not_exists()
.col(
ColumnDef::new(FolderCommand::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(FolderCommand::FolderId).integer().not_null())
.col(ColumnDef::new(FolderCommand::Name).string().not_null())
.col(ColumnDef::new(FolderCommand::Command).string().not_null())
.col(
ColumnDef::new(FolderCommand::SortOrder)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(FolderCommand::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(FolderCommand::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_folder_command_folder")
.from(FolderCommand::Table, FolderCommand::FolderId)
.to(Folder::Table, Folder::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_folder_command_folder_id")
.table(FolderCommand::Table)
.col(FolderCommand::FolderId)
.col(FolderCommand::SortOrder)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(FolderCommand::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum FolderCommand {
Table,
Id,
FolderId,
Name,
Command,
SortOrder,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
enum Folder {
Table,
Id,
}
@@ -0,0 +1,40 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.add_column(
ColumnDef::new(Folder::IsOpen)
.boolean()
.not_null()
.default(false),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.drop_column(Folder::IsOpen)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Folder {
Table,
IsOpen,
}
@@ -0,0 +1,90 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(AgentSetting::Table)
.if_not_exists()
.col(
ColumnDef::new(AgentSetting::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(AgentSetting::AgentType)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(AgentSetting::RegistryId).string().not_null())
.col(
ColumnDef::new(AgentSetting::Enabled)
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(AgentSetting::SortOrder)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(AgentSetting::InstalledVersion)
.string()
.null(),
)
.col(ColumnDef::new(AgentSetting::EnvJson).text().null())
.col(
ColumnDef::new(AgentSetting::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(AgentSetting::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_agent_setting_sort_order")
.table(AgentSetting::Table)
.col(AgentSetting::SortOrder)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(AgentSetting::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum AgentSetting {
Table,
Id,
AgentType,
RegistryId,
Enabled,
SortOrder,
InstalledVersion,
EnvJson,
CreatedAt,
UpdatedAt,
}
@@ -0,0 +1,35 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.add_column(ColumnDef::new(Folder::ParentBranch).string().null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Folder::Table)
.drop_column(Folder::ParentBranch)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum Folder {
Table,
ParentBranch,
}
+21
View File
@@ -0,0 +1,21 @@
use sea_orm_migration::prelude::*;
mod m20260211_000001_init;
mod m20260219_000001_folder_command;
mod m20260221_000001_folder_is_open;
mod m20260226_000001_agent_setting;
mod m20260227_000001_folder_parent_branch;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20260211_000001_init::Migration),
Box::new(m20260219_000001_folder_command::Migration),
Box::new(m20260221_000001_folder_is_open::Migration),
Box::new(m20260226_000001_agent_setting::Migration),
Box::new(m20260227_000001_folder_parent_branch::Migration),
]
}
}
+78
View File
@@ -0,0 +1,78 @@
pub mod entities;
pub mod error;
pub mod migration;
pub mod service;
use std::path::Path;
use std::time::Duration;
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement,
};
use sea_orm_migration::MigratorTrait;
use error::DbError;
use migration::Migrator;
pub struct AppDatabase {
#[allow(dead_code)]
pub conn: DatabaseConnection,
}
pub async fn init_database(
app_data_dir: impl AsRef<Path>,
app_version: &str,
) -> Result<AppDatabase, DbError> {
let app_data_dir = app_data_dir.as_ref();
std::fs::create_dir_all(app_data_dir)?;
let db_path = app_data_dir.join("codeg.db");
let db_url = format!(
"sqlite:{}?mode=rwc",
urlencoding::encode(&db_path.to_string_lossy())
);
let mut opts = ConnectOptions::new(db_url);
opts.max_connections(5)
.min_connections(1)
.connect_timeout(Duration::from_secs(10))
.idle_timeout(Duration::from_secs(300))
.sqlx_logging(false);
let conn = Database::connect(opts).await?;
// SQLite performance and reliability pragmas
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA journal_mode=WAL;".to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA busy_timeout=5000;".to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA synchronous=NORMAL;".to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA foreign_keys=ON;".to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA cache_size=-8000;".to_owned(),
))
.await?;
Migrator::up(&conn, None)
.await
.map_err(|e| DbError::Migration(e.to_string()))?;
service::app_metadata_service::update_app_version(&conn, app_version).await?;
Ok(AppDatabase { conn })
}
@@ -0,0 +1,207 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::DatabaseConnection;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
};
use crate::db::entities::agent_setting;
use crate::db::error::DbError;
use crate::models::agent::AgentType;
#[derive(Debug, Clone)]
pub struct AgentDefaultInput {
pub agent_type: AgentType,
pub registry_id: String,
pub default_sort_order: i32,
}
#[derive(Debug, Clone)]
pub struct AgentSettingsUpdate {
pub enabled: bool,
pub env_json: Option<String>,
}
fn default_enabled(agent_type: AgentType) -> bool {
matches!(
agent_type,
AgentType::ClaudeCode
| AgentType::Codex
| AgentType::Gemini
| AgentType::OpenCode
| AgentType::OpenClaw
)
}
pub async fn ensure_defaults(
conn: &DatabaseConnection,
defaults: &[AgentDefaultInput],
) -> Result<(), DbError> {
for default in defaults {
let agent_type = serde_json::to_string(&default.agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
let existing = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type.clone()))
.one(conn)
.await?;
if let Some(model) = existing {
if model.registry_id != default.registry_id {
let mut active = model.into_active_model();
active.registry_id = Set(default.registry_id.clone());
active.updated_at = Set(Utc::now());
active.update(conn).await?;
}
continue;
}
let now = Utc::now();
let active = agent_setting::ActiveModel {
id: NotSet,
agent_type: Set(agent_type),
registry_id: Set(default.registry_id.clone()),
enabled: Set(default_enabled(default.agent_type)),
sort_order: Set(default.default_sort_order),
installed_version: Set(None),
env_json: Set(None),
created_at: Set(now),
updated_at: Set(now),
};
match active.insert(conn).await {
Ok(_) => {}
Err(e) if e.to_string().contains("UNIQUE constraint failed") => {
// Another concurrent call already inserted this row — safe to ignore.
continue;
}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
pub async fn list(conn: &DatabaseConnection) -> Result<Vec<agent_setting::Model>, DbError> {
let rows = agent_setting::Entity::find()
.order_by_asc(agent_setting::Column::SortOrder)
.all(conn)
.await?;
Ok(rows)
}
pub async fn list_map_by_agent_type(
conn: &DatabaseConnection,
) -> Result<HashMap<AgentType, agent_setting::Model>, DbError> {
let rows = list(conn).await?;
let mut map = HashMap::new();
for row in rows {
if let Ok(agent_type) = serde_json::from_str::<AgentType>(&row.agent_type) {
map.insert(agent_type, row);
}
}
Ok(map)
}
pub async fn get_by_agent_type(
conn: &DatabaseConnection,
agent_type: AgentType,
) -> Result<Option<agent_setting::Model>, DbError> {
let agent_type_str = serde_json::to_string(&agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
let model = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
.one(conn)
.await?;
Ok(model)
}
pub async fn update(
conn: &DatabaseConnection,
agent_type: AgentType,
patch: AgentSettingsUpdate,
) -> Result<(), DbError> {
let agent_type_str = serde_json::to_string(&agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
let model = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type_str.clone()))
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("agent setting not found: {agent_type_str}")))?;
let mut active = model.into_active_model();
active.enabled = Set(patch.enabled);
active.env_json = Set(patch.env_json);
active.updated_at = Set(Utc::now());
active.update(conn).await?;
Ok(())
}
pub async fn set_installed_version(
conn: &DatabaseConnection,
agent_type: AgentType,
installed_version: Option<String>,
) -> Result<(), DbError> {
let agent_type_str = serde_json::to_string(&agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
if let Some(model) = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
.one(conn)
.await?
{
let mut active = model.into_active_model();
active.installed_version = Set(installed_version);
active.updated_at = Set(Utc::now());
active.update(conn).await?;
}
Ok(())
}
pub async fn reorder(conn: &DatabaseConnection, agent_types: &[AgentType]) -> Result<(), DbError> {
if agent_types.is_empty() {
return Ok(());
}
match reorder_once(conn, agent_types).await {
Ok(()) => Ok(()),
Err(err) if is_sqlite_full_error(&err) => {
// Try truncating WAL once to reclaim space and retry.
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA wal_checkpoint(TRUNCATE);".to_owned(),
))
.await?;
reorder_once(conn, agent_types).await
}
Err(err) => Err(err),
}
}
async fn reorder_once(conn: &DatabaseConnection, agent_types: &[AgentType]) -> Result<(), DbError> {
let now = Utc::now();
for (index, agent_type) in agent_types.iter().enumerate() {
let agent_type_str = serde_json::to_string(agent_type)
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
if let Some(model) = agent_setting::Entity::find()
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
.one(conn)
.await?
{
// Skip unchanged rows to reduce write pressure when repeatedly dragging.
if model.sort_order == index as i32 {
continue;
}
let mut active = model.into_active_model();
active.sort_order = Set(index as i32);
active.updated_at = Set(now.clone());
active.update(conn).await?;
}
}
Ok(())
}
fn is_sqlite_full_error(err: &DbError) -> bool {
let message = err.to_string();
message.contains("database or disk is full") || message.contains("(code: 13)")
}
@@ -0,0 +1,84 @@
use chrono::Utc;
use sea_orm::sea_query::OnConflict;
use sea_orm::DatabaseConnection;
use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::db::entities::app_metadata;
use crate::db::error::DbError;
pub async fn upsert_value(
conn: &DatabaseConnection,
key: &str,
value: &str,
) -> Result<(), DbError> {
let now = Utc::now();
app_metadata::Entity::insert(app_metadata::ActiveModel {
id: NotSet,
key: Set(key.to_string()),
value: Set(value.to_string()),
created_at: Set(now),
updated_at: Set(now),
deleted_at: NotSet,
})
.on_conflict(
OnConflict::column(app_metadata::Column::Key)
.update_columns([app_metadata::Column::Value, app_metadata::Column::UpdatedAt])
.to_owned(),
)
.exec(conn)
.await?;
Ok(())
}
pub async fn get_value(conn: &DatabaseConnection, key: &str) -> Result<Option<String>, DbError> {
let model = app_metadata::Entity::find()
.filter(app_metadata::Column::Key.eq(key))
.filter(app_metadata::Column::DeletedAt.is_null())
.one(conn)
.await?;
Ok(model.map(|m| m.value))
}
pub async fn update_app_version(
conn: &DatabaseConnection,
app_version: &str,
) -> Result<(), DbError> {
let now = Utc::now();
app_metadata::Entity::insert(app_metadata::ActiveModel {
id: NotSet,
key: Set("app_version".to_string()),
value: Set(app_version.to_string()),
created_at: Set(now),
updated_at: Set(now),
deleted_at: NotSet,
})
.on_conflict(
OnConflict::column(app_metadata::Column::Key)
.update_columns([app_metadata::Column::Value, app_metadata::Column::UpdatedAt])
.to_owned(),
)
.exec(conn)
.await?;
app_metadata::Entity::insert(app_metadata::ActiveModel {
id: NotSet,
key: Set("db_initialized_at".to_string()),
value: Set(now.to_rfc3339()),
created_at: Set(now),
updated_at: Set(now),
deleted_at: NotSet,
})
.on_conflict(
OnConflict::column(app_metadata::Column::Key)
.do_nothing()
.to_owned(),
)
.do_nothing()
.exec(conn)
.await?;
Ok(())
}
@@ -0,0 +1,187 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
QueryFilter, QueryOrder, Set,
};
use crate::db::entities::conversation;
use crate::db::error::DbError;
use crate::models::{AgentType, DbConversationSummary};
pub async fn create(
conn: &DatabaseConnection,
folder_id: i32,
agent_type: AgentType,
title: Option<String>,
git_branch: Option<String>,
) -> Result<conversation::Model, DbError> {
let at_str = serde_json::to_value(agent_type)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
let now = Utc::now();
let model = conversation::ActiveModel {
id: NotSet,
folder_id: Set(folder_id),
title: Set(title),
agent_type: Set(at_str),
status: Set(conversation::ConversationStatus::InProgress),
model: Set(None),
git_branch: Set(git_branch),
external_id: Set(None),
parent_id: Set(None),
message_count: Set(0),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
};
Ok(model.insert(conn).await?)
}
pub async fn update_status(
conn: &DatabaseConnection,
conversation_id: i32,
status: conversation::ConversationStatus,
) -> Result<(), DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
let mut active: conversation::ActiveModel = conv.into();
active.status = Set(status);
active.updated_at = Set(Utc::now());
active.update(conn).await?;
Ok(())
}
pub async fn update_title(
conn: &DatabaseConnection,
conversation_id: i32,
title: String,
) -> Result<(), DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
let mut active: conversation::ActiveModel = conv.into();
active.title = Set(Some(title));
active.updated_at = Set(Utc::now());
active.update(conn).await?;
Ok(())
}
pub async fn update_external_id(
conn: &DatabaseConnection,
conversation_id: i32,
external_id: String,
) -> Result<(), DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
let mut active: conversation::ActiveModel = conv.into();
active.external_id = Set(Some(external_id));
active.updated_at = Set(Utc::now());
active.update(conn).await?;
Ok(())
}
pub async fn soft_delete(conn: &DatabaseConnection, conversation_id: i32) -> Result<(), DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.filter(conversation::Column::DeletedAt.is_null())
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
let mut active: conversation::ActiveModel = conv.into();
active.deleted_at = Set(Some(Utc::now()));
active.update(conn).await?;
Ok(())
}
fn parse_agent_type(s: &str) -> AgentType {
serde_json::from_value(serde_json::Value::String(s.to_string()))
.unwrap_or(AgentType::ClaudeCode)
}
fn conv_to_summary(r: conversation::Model) -> DbConversationSummary {
let status = serde_json::to_value(&r.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| format!("{:?}", r.status));
DbConversationSummary {
id: r.id,
folder_id: r.folder_id,
title: r.title,
agent_type: parse_agent_type(&r.agent_type),
status,
model: r.model,
git_branch: r.git_branch,
external_id: r.external_id,
message_count: r.message_count as u32,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
pub async fn get_by_id(
conn: &DatabaseConnection,
conversation_id: i32,
) -> Result<DbConversationSummary, DbError> {
let conv = conversation::Entity::find_by_id(conversation_id)
.filter(conversation::Column::DeletedAt.is_null())
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
Ok(conv_to_summary(conv))
}
pub async fn list_by_folder(
conn: &DatabaseConnection,
folder_id: i32,
agent_type: Option<AgentType>,
search: Option<String>,
sort_by: Option<String>,
status: Option<String>,
) -> Result<Vec<DbConversationSummary>, DbError> {
let mut query = conversation::Entity::find()
.filter(conversation::Column::FolderId.eq(folder_id))
.filter(conversation::Column::DeletedAt.is_null());
// Filter by agent_type
if let Some(ref at) = agent_type {
let at_str = serde_json::to_value(at)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
query = query.filter(conversation::Column::AgentType.eq(at_str));
}
// Search by title
if let Some(ref s) = search {
if !s.is_empty() {
query = query.filter(conversation::Column::Title.contains(s));
}
}
// Filter by status
if let Some(ref st) = status {
if let Ok(status_enum) = serde_json::from_value::<conversation::ConversationStatus>(
serde_json::Value::String(st.clone()),
) {
query = query.filter(conversation::Column::Status.eq(status_enum));
}
}
// Sort
query = match sort_by.as_deref() {
Some("oldest") => query.order_by_asc(conversation::Column::CreatedAt),
_ => query.order_by_desc(conversation::Column::CreatedAt),
};
let rows = query.all(conn).await?;
let summaries: Vec<DbConversationSummary> = rows.into_iter().map(conv_to_summary).collect();
Ok(summaries)
}
@@ -0,0 +1,171 @@
use chrono::Utc;
use sea_orm::DatabaseConnection;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
};
use crate::db::entities::folder_command;
use crate::db::error::DbError;
use crate::models::FolderCommandInfo;
fn to_info(m: folder_command::Model) -> FolderCommandInfo {
FolderCommandInfo {
id: m.id,
folder_id: m.folder_id,
name: m.name,
command: m.command,
sort_order: m.sort_order,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
pub async fn list_by_folder(
conn: &DatabaseConnection,
folder_id: i32,
) -> Result<Vec<FolderCommandInfo>, DbError> {
let rows = folder_command::Entity::find()
.filter(folder_command::Column::FolderId.eq(folder_id))
.order_by_asc(folder_command::Column::SortOrder)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_info).collect())
}
pub async fn create(
conn: &DatabaseConnection,
folder_id: i32,
name: &str,
command: &str,
) -> Result<FolderCommandInfo, DbError> {
let now = Utc::now();
// Get next sort_order
let max_order = folder_command::Entity::find()
.filter(folder_command::Column::FolderId.eq(folder_id))
.order_by_desc(folder_command::Column::SortOrder)
.one(conn)
.await?
.map(|m| m.sort_order)
.unwrap_or(-1);
let active = folder_command::ActiveModel {
id: NotSet,
folder_id: Set(folder_id),
name: Set(name.to_string()),
command: Set(command.to_string()),
sort_order: Set(max_order + 1),
created_at: Set(now),
updated_at: Set(now),
};
let model = active.insert(conn).await?;
Ok(to_info(model))
}
pub async fn create_many(
conn: &DatabaseConnection,
folder_id: i32,
items: &[(String, String)],
) -> Result<(), DbError> {
if items.is_empty() {
return Ok(());
}
let now = Utc::now();
let max_order = folder_command::Entity::find()
.filter(folder_command::Column::FolderId.eq(folder_id))
.order_by_desc(folder_command::Column::SortOrder)
.one(conn)
.await?
.map(|m| m.sort_order)
.unwrap_or(-1);
let active_models = items
.iter()
.enumerate()
.map(|(idx, (name, command))| folder_command::ActiveModel {
id: NotSet,
folder_id: Set(folder_id),
name: Set(name.clone()),
command: Set(command.clone()),
sort_order: Set(max_order + idx as i32 + 1),
created_at: Set(now),
updated_at: Set(now),
})
.collect::<Vec<_>>();
folder_command::Entity::insert_many(active_models)
.exec(conn)
.await?;
Ok(())
}
pub async fn update(
conn: &DatabaseConnection,
id: i32,
name: Option<String>,
command: Option<String>,
sort_order: Option<i32>,
) -> Result<FolderCommandInfo, DbError> {
let row = folder_command::Entity::find_by_id(id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("FolderCommand {} not found", id)))?;
let mut active = row.into_active_model();
if let Some(n) = name {
active.name = Set(n);
}
if let Some(c) = command {
active.command = Set(c);
}
if let Some(s) = sort_order {
active.sort_order = Set(s);
}
active.updated_at = Set(Utc::now());
let model = active.update(conn).await?;
Ok(to_info(model))
}
pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> {
folder_command::Entity::delete_by_id(id).exec(conn).await?;
Ok(())
}
pub async fn reorder(
conn: &DatabaseConnection,
folder_id: i32,
ids: Vec<i32>,
) -> Result<(), DbError> {
if ids.is_empty() {
return Ok(());
}
let now = Utc::now();
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
let case_expr = ids
.iter()
.enumerate()
.map(|(idx, id)| format!("WHEN {} THEN {}", id, idx))
.collect::<Vec<_>>()
.join(" ");
let id_list = ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"UPDATE folder_command SET sort_order = CASE id {case_expr} END, updated_at = '{now_str}' WHERE folder_id = {folder_id} AND id IN ({id_list})"
);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?;
Ok(())
}
+246
View File
@@ -0,0 +1,246 @@
use chrono::Utc;
use sea_orm::DatabaseConnection;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
};
use crate::db::entities::{folder, folder_opened_conversation};
use crate::db::error::DbError;
use crate::models::agent::AgentType;
use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation};
fn to_entry(m: folder::Model) -> FolderHistoryEntry {
FolderHistoryEntry {
id: m.id,
path: m.path,
name: m.name,
last_opened_at: m.last_opened_at,
}
}
fn parse_agent_type(s: &Option<String>) -> Option<AgentType> {
s.as_deref()
.and_then(|v| serde_json::from_value(serde_json::Value::String(v.to_string())).ok())
}
fn to_detail(m: folder::Model, opened: Vec<OpenedConversation>) -> FolderDetail {
let default_agent_type = parse_agent_type(&m.default_agent_type);
FolderDetail {
id: m.id,
name: m.name,
path: m.path,
git_branch: m.git_branch,
parent_branch: m.parent_branch,
default_agent_type,
last_opened_at: m.last_opened_at,
opened_conversations: opened,
}
}
pub async fn get_folder_by_id(
conn: &DatabaseConnection,
folder_id: i32,
) -> Result<Option<FolderDetail>, DbError> {
let row = folder::Entity::find_by_id(folder_id)
.filter(folder::Column::DeletedAt.is_null())
.one(conn)
.await?;
match row {
None => Ok(None),
Some(folder_model) => {
let opened = load_opened_conversations(conn, folder_model.id).await?;
Ok(Some(to_detail(folder_model, opened)))
}
}
}
pub async fn add_folder(
conn: &DatabaseConnection,
path: &str,
) -> Result<FolderHistoryEntry, DbError> {
let now = Utc::now();
let name = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
let existing = folder::Entity::find()
.filter(folder::Column::Path.eq(path))
.one(conn)
.await?;
let model = if let Some(row) = existing {
let mut active = row.into_active_model();
active.name = Set(name);
active.last_opened_at = Set(now);
active.updated_at = Set(now);
active.deleted_at = Set(None);
active.is_open = Set(true);
active.update(conn).await?
} else {
let active = folder::ActiveModel {
id: NotSet,
name: Set(name),
path: Set(path.to_string()),
git_branch: Set(None),
parent_branch: Set(None),
default_agent_type: Set(None),
last_opened_at: Set(now),
created_at: Set(now),
updated_at: Set(now),
deleted_at: Set(None),
is_open: Set(true),
};
active.insert(conn).await?
};
Ok(to_entry(model))
}
pub async fn list_folders(conn: &DatabaseConnection) -> Result<Vec<FolderHistoryEntry>, DbError> {
let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null())
.order_by_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_entry).collect())
}
pub async fn remove_folder(conn: &DatabaseConnection, path: &str) -> Result<(), DbError> {
let now = Utc::now();
let row = folder::Entity::find()
.filter(folder::Column::Path.eq(path))
.filter(folder::Column::DeletedAt.is_null())
.one(conn)
.await?;
if let Some(row) = row {
let mut active = row.into_active_model();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.update(conn).await?;
}
Ok(())
}
pub async fn save_opened_conversations(
conn: &DatabaseConnection,
folder_id: i32,
items: Vec<OpenedConversation>,
) -> Result<(), DbError> {
// Delete all existing opened conversations for this folder
folder_opened_conversation::Entity::delete_many()
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
.exec(conn)
.await?;
if items.is_empty() {
return Ok(());
}
// Batch insert with raw SQL for efficiency
let now = Utc::now();
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
let mut values = Vec::with_capacity(items.len());
for item in &items {
let agent_str = serde_json::to_value(item.agent_type)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
values.push(format!(
"({}, {}, {}, {}, {}, '{}', '{}', '{}')",
folder_id,
item.conversation_id,
item.position,
item.is_active as i32,
item.is_pinned as i32,
agent_str,
now_str,
now_str,
));
}
let sql = format!(
"INSERT INTO folder_opened_conversation (folder_id, conversation_id, position, is_active, is_pinned, agent_type, created_at, updated_at) VALUES {}",
values.join(", ")
);
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
.await?;
Ok(())
}
async fn load_opened_conversations(
conn: &DatabaseConnection,
folder_id: i32,
) -> Result<Vec<OpenedConversation>, DbError> {
let rows = folder_opened_conversation::Entity::find()
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
.order_by_asc(folder_opened_conversation::Column::Position)
.all(conn)
.await?;
Ok(rows
.into_iter()
.filter_map(|r| {
let agent_type = parse_agent_type(&Some(r.agent_type))?;
Some(OpenedConversation {
conversation_id: r.conversation_id,
agent_type,
position: r.position,
is_active: r.is_active,
is_pinned: r.is_pinned,
})
})
.collect())
}
pub async fn set_folder_parent_branch(
conn: &DatabaseConnection,
folder_id: i32,
parent_branch: Option<String>,
) -> Result<(), DbError> {
let row = folder::Entity::find_by_id(folder_id).one(conn).await?;
if let Some(row) = row {
let mut active = row.into_active_model();
active.parent_branch = Set(parent_branch);
active.updated_at = Set(Utc::now());
active.update(conn).await?;
}
Ok(())
}
pub async fn set_folder_open(
conn: &DatabaseConnection,
folder_id: i32,
is_open: bool,
) -> Result<(), DbError> {
let row = folder::Entity::find_by_id(folder_id).one(conn).await?;
if let Some(row) = row {
let mut active = row.into_active_model();
active.is_open = Set(is_open);
active.updated_at = Set(Utc::now());
active.update(conn).await?;
}
Ok(())
}
pub async fn list_open_folders(
conn: &DatabaseConnection,
) -> Result<Vec<FolderHistoryEntry>, DbError> {
let rows = folder::Entity::find()
.filter(folder::Column::DeletedAt.is_null())
.filter(folder::Column::IsOpen.eq(true))
.order_by_desc(folder::Column::LastOpenedAt)
.all(conn)
.await?;
Ok(rows.into_iter().map(to_entry).collect())
}
@@ -0,0 +1,99 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
QueryFilter, Set,
};
use crate::db::entities::conversation;
use crate::db::error::DbError;
use crate::models::{AgentType, ImportResult};
use crate::parsers::claude::ClaudeParser;
use crate::parsers::codex::CodexParser;
use crate::parsers::gemini::GeminiParser;
use crate::parsers::opencode::OpenCodeParser;
use crate::parsers::{path_eq_for_matching, AgentParser};
pub async fn import_local_conversations(
conn: &DatabaseConnection,
folder_id: i32,
folder_path: &str,
) -> Result<ImportResult, DbError> {
let path = folder_path.to_string();
// Run parsers in blocking task since they do filesystem I/O
let summaries = tokio::task::spawn_blocking(move || {
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
(AgentType::Codex, Box::new(CodexParser::new())),
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
(AgentType::Gemini, Box::new(GeminiParser::new())),
];
let mut matched = Vec::new();
for (at, parser) in &parsers {
match parser.list_conversations() {
Ok(convs) => {
for c in convs {
if c.folder_path
.as_deref()
.map(|p| path_eq_for_matching(p, path.as_str()))
.unwrap_or(false)
{
matched.push((*at, c));
}
}
}
Err(e) => {
eprintln!("Error listing {} conversations: {}", at, e);
}
}
}
matched
})
.await
.map_err(|e| DbError::Migration(e.to_string()))?;
let mut imported = 0u32;
let mut skipped = 0u32;
for (agent_type, summary) in &summaries {
let at_str = serde_json::to_value(agent_type)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
// Check if already imported
let exists = conversation::Entity::find()
.filter(conversation::Column::ExternalId.eq(&summary.id))
.filter(conversation::Column::AgentType.eq(&at_str))
.one(conn)
.await?;
if exists.is_some() {
skipped += 1;
continue;
}
let now = Utc::now();
let conv = conversation::ActiveModel {
id: NotSet,
folder_id: Set(folder_id),
title: Set(summary.title.clone()),
agent_type: Set(at_str.clone()),
status: Set(conversation::ConversationStatus::Completed),
model: Set(summary.model.clone()),
git_branch: Set(summary.git_branch.clone()),
external_id: Set(Some(summary.id.clone())),
parent_id: Set(None),
message_count: Set(summary.message_count as i32),
created_at: Set(summary.started_at),
updated_at: Set(now),
deleted_at: Set(None),
};
conv.insert(conn).await?;
imported += 1;
}
Ok(ImportResult { imported, skipped })
}
+6
View File
@@ -0,0 +1,6 @@
pub mod agent_setting_service;
pub mod app_metadata_service;
pub mod conversation_service;
pub mod folder_command_service;
pub mod folder_service;
pub mod import_service;
+272
View File
@@ -0,0 +1,272 @@
mod acp;
mod commands;
mod db;
mod models;
mod network;
mod parsers;
mod process;
mod terminal;
use std::sync::atomic::{AtomicBool, Ordering};
use acp::manager::ConnectionManager;
use commands::{
acp as acp_commands, conversations, folder_commands, folders, mcp as mcp_commands,
system_settings, terminal as terminal_commands, windows,
};
use tauri::Manager;
use terminal::manager::TerminalManager;
static APP_QUITTING: AtomicBool = AtomicBool::new(false);
fn get_folder_id_from_url(window: &tauri::Window) -> Option<i32> {
let webview = window.get_webview_window(window.label())?;
let url = webview.url().ok()?;
url.query_pairs()
.find(|(key, _)| key == "id")
.and_then(|(_, value)| value.parse::<i32>().ok())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let _ = fix_path_env::fix();
tauri::Builder::default()
.plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(ConnectionManager::new())
.manage(TerminalManager::new())
.manage(windows::SettingsWindowState::new())
.manage(windows::CommitWindowState::new())
.setup(|app| {
let app_data_dir = app.path().app_data_dir()?;
let app_version = env!("CARGO_PKG_VERSION");
let database =
tauri::async_runtime::block_on(db::init_database(&app_data_dir, app_version))
.map_err(|e| e.to_string())?;
app.manage(database);
// Restore and apply saved system proxy settings before any network operation.
let db = app.state::<db::AppDatabase>();
match tauri::async_runtime::block_on(system_settings::load_system_proxy_settings(
&db.conn,
)) {
Ok(settings) => {
let _ = network::proxy::apply_system_proxy_settings(&settings);
}
Err(err) => {
eprintln!("[Settings] failed to load system proxy settings: {err}");
}
}
// Restore previously open folders or show welcome
let db = app.state::<db::AppDatabase>();
let open_folders = tauri::async_runtime::block_on(
db::service::folder_service::list_open_folders(&db.conn),
)
.unwrap_or_default();
if open_folders.is_empty() {
let _ = windows::open_welcome_window(app.handle());
} else {
for entry in &open_folders {
let label = format!("folder-{}", uuid::Uuid::new_v4());
let url = tauri::WebviewUrl::App(format!("folder?id={}", entry.id).into());
let builder = tauri::WebviewWindowBuilder::new(app, &label, url)
.title(&entry.name)
.inner_size(1260.0, 860.0)
.min_inner_size(900.0, 600.0);
let _ = windows::apply_platform_window_style(builder).build();
}
}
Ok(())
})
.on_window_event(|window, event| {
let label = window.label().to_string();
if label == "settings"
&& matches!(
event,
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
)
{
let app = window.app_handle();
if let Some(state) = app.try_state::<windows::SettingsWindowState>() {
windows::restore_windows_after_settings(&app, &state);
}
}
if label.starts_with("commit-")
&& matches!(
event,
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
)
{
let app = window.app_handle();
if let Some(state) = app.try_state::<windows::CommitWindowState>() {
windows::restore_window_after_commit(&app, &state, &label);
}
}
if let tauri::WindowEvent::CloseRequested { .. } = event {
if label.starts_with("folder-") {
let app = window.app_handle();
if let Some(cm) = app.try_state::<ConnectionManager>() {
let disconnected =
tauri::async_runtime::block_on(cm.disconnect_by_owner_window(&label));
eprintln!(
"[ACP] folder window closing label={} disconnected_connections={}",
label, disconnected
);
}
// Only mark folder as closed if user is closing individual window,
// not when the entire app is quitting (so folders reopen on next launch)
if !APP_QUITTING.load(Ordering::Relaxed) {
if let Some(folder_id) = get_folder_id_from_url(window) {
if let Some(db) = app.try_state::<db::AppDatabase>() {
let _ = tauri::async_runtime::block_on(
db::service::folder_service::set_folder_open(
&db.conn, folder_id, false,
),
);
}
}
}
// Kill terminal sessions owned by this folder window.
if let Some(tm) = app.try_state::<TerminalManager>() {
let killed = tm.kill_by_owner_window(&label);
eprintln!(
"[TERM] folder window closing label={} killed_terminals={}",
label, killed
);
}
let has_other_folder = app
.webview_windows()
.keys()
.any(|l| l.starts_with("folder-") && *l != label);
if !has_other_folder && !APP_QUITTING.load(Ordering::Relaxed) {
let _ = windows::open_welcome_window(app);
}
}
}
})
.invoke_handler(tauri::generate_handler![
conversations::list_conversations,
conversations::get_conversation,
conversations::list_folder_conversations,
conversations::import_local_conversations,
conversations::get_folder_conversation,
conversations::list_folders,
conversations::get_stats,
conversations::get_sidebar_data,
conversations::create_conversation,
conversations::update_conversation_status,
conversations::update_conversation_title,
conversations::update_conversation_external_id,
conversations::delete_conversation,
folders::load_folder_history,
folders::get_folder,
folders::add_folder_to_history,
folders::set_folder_parent_branch,
folders::remove_folder_from_history,
folders::create_folder_directory,
folders::clone_repository,
folders::get_git_branch,
folders::git_init,
folders::git_pull,
folders::git_fetch,
folders::git_push,
folders::git_new_branch,
folders::git_worktree_add,
folders::git_checkout,
folders::git_list_branches,
folders::git_stash,
folders::git_stash_pop,
folders::git_status,
folders::git_is_tracked,
folders::git_diff,
folders::git_diff_with_branch,
folders::git_show_diff,
folders::git_show_file,
folders::git_commit,
folders::git_rollback_file,
folders::git_add_files,
folders::git_list_all_branches,
folders::git_merge,
folders::git_rebase,
folders::git_delete_branch,
folders::save_folder_opened_conversations,
folders::start_file_tree_watch,
folders::stop_file_tree_watch,
folders::get_file_tree,
folders::read_file_preview,
folders::read_file_for_edit,
folders::save_file_content,
folders::save_file_copy,
folders::rename_file_tree_entry,
folders::delete_file_tree_entry,
folders::git_log,
folders::git_commit_branches,
windows::open_folder_window,
windows::open_commit_window,
windows::open_settings_window,
windows::list_open_folders,
windows::focus_folder_window,
system_settings::get_system_proxy_settings,
system_settings::update_system_proxy_settings,
acp_commands::acp_preflight,
acp_commands::acp_connect,
acp_commands::acp_prompt,
acp_commands::acp_set_mode,
acp_commands::acp_set_config_option,
acp_commands::acp_cancel,
acp_commands::acp_respond_permission,
acp_commands::acp_disconnect,
acp_commands::acp_list_connections,
acp_commands::acp_list_agents,
acp_commands::acp_clear_binary_cache,
acp_commands::acp_download_agent_binary,
acp_commands::acp_detect_agent_local_version,
acp_commands::acp_prepare_npx_agent,
acp_commands::acp_prepare_uvx_agent,
acp_commands::acp_uninstall_agent,
acp_commands::acp_update_agent_preferences,
acp_commands::acp_reorder_agents,
acp_commands::acp_list_agent_skills,
acp_commands::acp_read_agent_skill,
acp_commands::acp_save_agent_skill,
acp_commands::acp_delete_agent_skill,
folder_commands::list_folder_commands,
folder_commands::create_folder_command,
folder_commands::update_folder_command,
folder_commands::delete_folder_command,
folder_commands::reorder_folder_commands,
folder_commands::bootstrap_folder_commands_from_package_json,
terminal_commands::terminal_spawn,
terminal_commands::terminal_write,
terminal_commands::terminal_resize,
terminal_commands::terminal_kill,
terminal_commands::terminal_list,
mcp_commands::mcp_scan_local,
mcp_commands::mcp_list_marketplaces,
mcp_commands::mcp_search_marketplace,
mcp_commands::mcp_get_marketplace_server_detail,
mcp_commands::mcp_install_from_marketplace,
mcp_commands::mcp_upsert_local_server,
mcp_commands::mcp_set_server_apps,
mcp_commands::mcp_remove_server,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
APP_QUITTING.store(true, Ordering::Relaxed);
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
codeg_lib::run()
}
+54
View File
@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentType {
ClaudeCode,
Codex,
OpenCode,
Auggie,
Autohand,
Cline,
CodebuddyCode,
CorustAgent,
Gemini,
GithubCopilot,
Goose,
Junie,
Qoder,
QwenCode,
FactoryDroid,
Kimi,
MinionCode,
MistralVibe,
OpenClaw,
Stakpak,
}
impl fmt::Display for AgentType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AgentType::ClaudeCode => write!(f, "Claude Code"),
AgentType::Codex => write!(f, "Codex CLI"),
AgentType::OpenCode => write!(f, "OpenCode"),
AgentType::Auggie => write!(f, "Auggie"),
AgentType::Autohand => write!(f, "Autohand"),
AgentType::Cline => write!(f, "Cline"),
AgentType::CodebuddyCode => write!(f, "Codebuddy Code"),
AgentType::CorustAgent => write!(f, "Corust Agent"),
AgentType::Gemini => write!(f, "Gemini CLI"),
AgentType::GithubCopilot => write!(f, "GitHub Copilot"),
AgentType::Goose => write!(f, "goose"),
AgentType::Junie => write!(f, "Junie"),
AgentType::Qoder => write!(f, "Qoder CLI"),
AgentType::QwenCode => write!(f, "Qwen Code"),
AgentType::FactoryDroid => write!(f, "Factory Droid"),
AgentType::Kimi => write!(f, "Kimi CLI"),
AgentType::MinionCode => write!(f, "Minion Code"),
AgentType::MistralVibe => write!(f, "Mistral Vibe"),
AgentType::OpenClaw => write!(f, "OpenClaw"),
AgentType::Stakpak => write!(f, "Stakpak"),
}
}
}
+97
View File
@@ -0,0 +1,97 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::agent::AgentType;
use super::message::{MessageTurn, TurnUsage};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationSummary {
pub id: String,
pub agent_type: AgentType,
pub folder_path: Option<String>,
pub folder_name: Option<String>,
pub title: Option<String>,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub message_count: u32,
pub model: Option<String>,
pub git_branch: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbConversationSummary {
pub id: i32,
pub folder_id: i32,
pub title: Option<String>,
pub agent_type: AgentType,
pub status: String,
pub model: Option<String>,
pub git_branch: Option<String>,
pub external_id: Option<String>,
pub message_count: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionStats {
pub total_usage: Option<TurnUsage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_tokens: Option<u64>,
pub total_duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window_used_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window_max_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window_usage_percent: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationDetail {
pub summary: ConversationSummary,
pub turns: Vec<MessageTurn>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_stats: Option<SessionStats>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbConversationDetail {
pub summary: DbConversationSummary,
pub turns: Vec<MessageTurn>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_stats: Option<SessionStats>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FolderInfo {
pub path: String,
pub name: String,
pub agent_types: Vec<AgentType>,
pub conversation_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStats {
pub total_conversations: u32,
pub total_messages: u32,
pub by_agent: Vec<AgentConversationCount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConversationCount {
pub agent_type: AgentType,
pub conversation_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SidebarData {
pub folders: Vec<FolderInfo>,
pub stats: AgentStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
pub imported: u32,
pub skipped: u32,
}
+44
View File
@@ -0,0 +1,44 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::agent::AgentType;
#[derive(Debug, Clone, Serialize)]
pub struct FolderHistoryEntry {
pub id: i32,
pub path: String,
pub name: String,
pub last_opened_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FolderDetail {
pub id: i32,
pub name: String,
pub path: String,
pub git_branch: Option<String>,
pub parent_branch: Option<String>,
pub default_agent_type: Option<AgentType>,
pub last_opened_at: DateTime<Utc>,
pub opened_conversations: Vec<OpenedConversation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenedConversation {
pub conversation_id: i32,
pub agent_type: AgentType,
pub position: i32,
pub is_active: bool,
pub is_pinned: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct FolderCommandInfo {
pub id: i32,
pub folder_id: i32,
pub name: String,
pub command: String,
pub sort_order: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+76
View File
@@ -0,0 +1,76 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageRole {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text {
text: String,
},
ToolUse {
tool_use_id: Option<String>,
tool_name: String,
input_preview: Option<String>,
},
ToolResult {
tool_use_id: Option<String>,
output_preview: Option<String>,
is_error: bool,
},
Thinking {
text: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_input_tokens: u64,
pub cache_read_input_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnifiedMessage {
pub id: String,
pub role: MessageRole,
pub content: Vec<ContentBlock>,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TurnUsage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnRole {
User,
Assistant,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageTurn {
pub id: String,
pub role: TurnRole,
pub blocks: Vec<ContentBlock>,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TurnUsage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
+15
View File
@@ -0,0 +1,15 @@
pub mod agent;
pub mod conversation;
pub mod folder;
pub mod message;
pub mod system;
pub use agent::AgentType;
pub use conversation::{
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
SidebarData,
};
pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation};
pub use message::{ContentBlock, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage};
pub use system::SystemProxySettings;
+7
View File
@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SystemProxySettings {
pub enabled: bool,
pub proxy_url: Option<String>,
}
+1
View File
@@ -0,0 +1 @@
pub mod proxy;
+55
View File
@@ -0,0 +1,55 @@
use crate::models::SystemProxySettings;
const PROXY_ENV_KEYS: [&str; 6] = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
];
pub fn apply_system_proxy_settings(settings: &SystemProxySettings) -> Result<(), String> {
if settings.enabled {
let proxy_url = settings
.proxy_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?;
for key in PROXY_ENV_KEYS {
unsafe {
std::env::set_var(key, proxy_url);
}
}
} else {
clear_proxy_env();
}
Ok(())
}
pub fn clear_proxy_env() {
for key in PROXY_ENV_KEYS {
unsafe {
std::env::remove_var(key);
}
}
}
pub fn current_proxy_env_vars() -> Vec<(String, String)> {
PROXY_ENV_KEYS
.iter()
.filter_map(|key| {
std::env::var(key).ok().and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(((*key).to_string(), trimmed.to_string()))
}
})
})
.collect()
}
+983
View File
@@ -0,0 +1,983 @@
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::sync::OnceLock;
use chrono::{DateTime, Utc};
use regex::Regex;
use crate::models::*;
use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError};
/// Regex that matches Claude Code system-injected XML tags and their content.
/// These tags are internal metadata and should not be displayed to users.
/// Note: Rust regex doesn't support backreferences, so each tag is listed explicitly.
fn system_tag_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(concat!(
r"(?s)",
r"<system-reminder>.*?</system-reminder>",
r"|<local-command-caveat>.*?</local-command-caveat>",
r"|<command-name>.*?</command-name>",
r"|<command-message>.*?</command-message>",
r"|<command-args>.*?</command-args>",
r"|<local-command-stdout>.*?</local-command-stdout>",
r"|<user-prompt-submit-hook>.*?</user-prompt-submit-hook>",
))
.unwrap()
})
}
/// Regex that matches an optional model capacity suffix like `[1M]` / `[500k]`.
fn model_capacity_suffix_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\[\s*([0-9]+(?:\.[0-9]+)?)\s*([km])\s*\]\s*$")
.expect("valid model capacity regex")
})
}
/// Strip system-injected XML tags from text content.
/// Returns None if the text becomes empty after stripping.
fn strip_system_tags(text: &str) -> Option<String> {
let cleaned = system_tag_regex().replace_all(text, "");
let trimmed = cleaned.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
/// Check if a JSONL entry is a system meta message (isMeta: true).
fn is_meta_message(value: &serde_json::Value) -> bool {
value
.get("isMeta")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
fn parse_model_capacity_suffix(model: &str) -> Option<u64> {
let captures = model_capacity_suffix_regex().captures(model.trim())?;
let value = captures.get(1)?.as_str().parse::<f64>().ok()?;
if !value.is_finite() || value <= 0.0 {
return None;
}
let unit = captures
.get(2)
.map(|m| m.as_str().to_ascii_lowercase())
.unwrap_or_default();
let multiplier = match unit.as_str() {
"m" => 1_000_000.0,
"k" => 1_000.0,
_ => return None,
};
Some((value * multiplier) as u64)
}
fn claude_context_window_max_tokens_for_model(model: Option<&str>) -> Option<u64> {
let model = model?.trim();
if model.is_empty() {
return None;
}
// If user/model config contains an explicit capacity suffix, prefer it.
if let Some(suffixed_limit) = parse_model_capacity_suffix(model) {
return Some(suffixed_limit);
}
// Claude models default to 200k when no explicit capacity is provided.
if model.to_ascii_lowercase().starts_with("claude") {
return Some(200_000);
}
None
}
fn claude_context_window_used_tokens_from_usage(usage: &TurnUsage) -> Option<u64> {
let used_tokens = usage
.input_tokens
.saturating_add(usage.cache_creation_input_tokens)
.saturating_add(usage.cache_read_input_tokens);
if used_tokens > 0 {
Some(used_tokens)
} else {
None
}
}
fn latest_claude_context_window_used_tokens(turns: &[MessageTurn]) -> Option<u64> {
turns.iter().rev().find_map(|turn| {
turn.usage
.as_ref()
.and_then(claude_context_window_used_tokens_from_usage)
})
}
fn merge_claude_context_window_stats(
stats: Option<SessionStats>,
used_tokens: Option<u64>,
max_tokens: Option<u64>,
) -> Option<SessionStats> {
if used_tokens.is_none() && max_tokens.is_none() {
return stats;
}
let usage_percent = match (used_tokens, max_tokens) {
(Some(used), Some(max)) if max > 0 => Some((used as f64 / max as f64) * 100.0),
_ => None,
};
match stats {
Some(mut s) => {
s.context_window_used_tokens = used_tokens;
s.context_window_max_tokens = max_tokens;
s.context_window_usage_percent = usage_percent;
Some(s)
}
None => Some(SessionStats {
total_usage: None,
total_tokens: None,
total_duration_ms: 0,
context_window_used_tokens: used_tokens,
context_window_max_tokens: max_tokens,
context_window_usage_percent: usage_percent,
}),
}
}
pub struct ClaudeParser {
base_dir: PathBuf,
}
impl ClaudeParser {
pub fn new() -> Self {
let base_dir = resolve_claude_config_dir().join("projects");
Self { base_dir }
}
fn decode_folder_path(encoded: &str) -> String {
encoded.replace('-', "/")
}
fn parse_jsonl_summary(
&self,
path: &PathBuf,
) -> Result<Option<ConversationSummary>, ParseError> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut conversation_id: Option<String> = None;
let mut cwd: Option<String> = None;
let mut git_branch: Option<String> = None;
let mut model: Option<String> = None;
let mut title: Option<String> = None;
let mut first_timestamp: Option<DateTime<Utc>> = None;
let mut last_timestamp: Option<DateTime<Utc>> = None;
let mut message_count: u32 = 0;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let msg_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
// Skip non-conversation entries
if msg_type == "file-history-snapshot" || msg_type == "progress" {
continue;
}
// Skip system meta messages (e.g. local-command-caveat injections)
if is_meta_message(&value) {
continue;
}
if conversation_id.is_none() {
conversation_id = value
.get("sessionId")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
if cwd.is_none() {
cwd = value
.get("cwd")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
if git_branch.is_none() {
git_branch = value
.get("gitBranch")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
if let Some(ts_str) = value.get("timestamp").and_then(|t| t.as_str()) {
if let Ok(ts) = ts_str.parse::<DateTime<Utc>>() {
if first_timestamp.is_none() {
first_timestamp = Some(ts);
}
last_timestamp = Some(ts);
}
}
if msg_type == "user" || msg_type == "assistant" {
message_count += 1;
// Extract model from assistant messages
if msg_type == "assistant" && model.is_none() {
model = value
.get("message")
.and_then(|m| m.get("model"))
.and_then(|m| m.as_str())
.map(|s| s.to_string());
}
// Extract title from first user message
if msg_type == "user" && title.is_none() {
title = extract_user_text(&value).map(|t| truncate_str(&t, 100));
}
}
}
let started_at = match first_timestamp {
Some(ts) => ts,
None => return Ok(None),
};
// Use filename (without .jsonl) as ID fallback
let id = conversation_id.unwrap_or_else(|| {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
let folder_path = cwd.clone();
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
Ok(Some(ConversationSummary {
id,
agent_type: AgentType::ClaudeCode,
folder_path,
folder_name,
title,
started_at,
ended_at: last_timestamp,
message_count,
model,
git_branch,
}))
}
}
fn resolve_claude_config_dir() -> PathBuf {
resolve_claude_config_dir_from(
std::env::var_os("CLAUDE_CONFIG_DIR"),
dirs::home_dir(),
)
}
fn resolve_claude_config_dir_from(
claude_config_dir_env: Option<std::ffi::OsString>,
home_dir: Option<PathBuf>,
) -> PathBuf {
claude_config_dir_env
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home_dir.unwrap_or_default().join(".claude"))
}
impl AgentParser for ClaudeParser {
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
let mut conversations = Vec::new();
if !self.base_dir.exists() {
return Ok(conversations);
}
let entries = fs::read_dir(&self.base_dir)?;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let project_dir = entry.path();
if !project_dir.is_dir() {
continue;
}
let jsonl_files = fs::read_dir(&project_dir)?;
for file_entry in jsonl_files {
let file_entry = match file_entry {
Ok(e) => e,
Err(_) => continue,
};
let file_path = file_entry.path();
if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
match self.parse_jsonl_summary(&file_path) {
Ok(Some(mut summary)) => {
// If folder_path is still None, derive from directory name
if summary.folder_path.is_none() {
let dir_name = project_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let decoded = Self::decode_folder_path(&dir_name);
summary.folder_path = Some(decoded.clone());
summary.folder_name = Some(folder_name_from_path(&decoded));
}
conversations.push(summary);
}
Ok(None) => continue,
Err(_) => continue,
}
}
}
conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(conversations)
}
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
// Find the conversation file by searching all directories
if !self.base_dir.exists() {
return Err(ParseError::ConversationNotFound(
conversation_id.to_string(),
));
}
for entry in fs::read_dir(&self.base_dir)? {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let project_dir = entry.path();
if !project_dir.is_dir() {
continue;
}
let file_path = project_dir.join(format!("{}.jsonl", conversation_id));
if file_path.exists() {
return self.parse_conversation_detail(&file_path, conversation_id);
}
}
Err(ParseError::ConversationNotFound(
conversation_id.to_string(),
))
}
}
impl ClaudeParser {
fn parse_conversation_detail(
&self,
path: &PathBuf,
conversation_id: &str,
) -> Result<ConversationDetail, ParseError> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
let mut cwd: Option<String> = None;
let mut git_branch: Option<String> = None;
let mut model: Option<String> = None;
let mut title: Option<String> = None;
let mut first_timestamp: Option<DateTime<Utc>> = None;
let mut last_timestamp: Option<DateTime<Utc>> = None;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let msg_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
if msg_type == "file-history-snapshot" || msg_type == "progress" {
continue;
}
// Skip system meta messages
if is_meta_message(&value) {
continue;
}
if cwd.is_none() {
cwd = value
.get("cwd")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
if git_branch.is_none() {
git_branch = value
.get("gitBranch")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
if let Some(ts_str) = value.get("timestamp").and_then(|t| t.as_str()) {
if let Ok(ts) = ts_str.parse::<DateTime<Utc>>() {
if first_timestamp.is_none() {
first_timestamp = Some(ts);
}
last_timestamp = Some(ts);
}
}
match msg_type {
"user" => {
let content = extract_user_content(&value);
// Skip user messages that are empty after system tag stripping
if content.is_empty() {
continue;
}
let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now);
let uuid = value
.get("uuid")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
if title.is_none() {
if let Some(first_text) = content.iter().find_map(|c| match c {
ContentBlock::Text { text } => Some(text.clone()),
_ => None,
}) {
title = Some(truncate_str(&first_text, 100));
}
}
messages.push(UnifiedMessage {
id: uuid,
role: MessageRole::User,
content,
timestamp,
usage: None,
duration_ms: None,
model: None,
});
}
"assistant" => {
let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now);
let uuid = value
.get("uuid")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let msg_model = value
.get("message")
.and_then(|m| m.get("model"))
.and_then(|m| m.as_str())
.map(|s| s.to_string());
if model.is_none() {
model = msg_model.clone();
}
let content = extract_assistant_content(&value);
let usage = extract_usage(&value);
messages.push(UnifiedMessage {
id: uuid,
role: MessageRole::Assistant,
content,
timestamp,
usage,
duration_ms: None,
model: msg_model,
});
}
"system" => {
let subtype = value.get("subtype").and_then(|s| s.as_str()).unwrap_or("");
if subtype == "turn_duration" {
if let Some(duration) = value.get("durationMs").and_then(|d| d.as_u64()) {
// Attach to the last assistant message
if let Some(last) = messages
.iter_mut()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
{
last.duration_ms = Some(duration);
}
}
}
}
_ => {}
}
}
let folder_path = cwd.clone();
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
let turns = group_into_turns(messages);
let context_window_used_tokens = latest_claude_context_window_used_tokens(&turns);
let context_window_max_tokens =
claude_context_window_max_tokens_for_model(model.as_deref());
let session_stats = merge_claude_context_window_stats(
super::compute_session_stats(&turns),
context_window_used_tokens,
context_window_max_tokens,
);
let summary = ConversationSummary {
id: conversation_id.to_string(),
agent_type: AgentType::ClaudeCode,
folder_path,
folder_name,
title,
started_at: first_timestamp.unwrap_or_else(Utc::now),
ended_at: last_timestamp,
message_count: turns.len() as u32,
model,
git_branch,
};
Ok(ConversationDetail {
summary,
turns,
session_stats,
})
}
}
fn parse_timestamp(value: &serde_json::Value) -> Option<DateTime<Utc>> {
value
.get("timestamp")
.and_then(|t| t.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
}
fn extract_user_text(value: &serde_json::Value) -> Option<String> {
let message = value.get("message")?;
let content = message.get("content")?;
if let Some(text) = content.as_str() {
return strip_system_tags(text);
}
if let Some(arr) = content.as_array() {
for item in arr {
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
if let Some(cleaned) = strip_system_tags(text) {
return Some(cleaned);
}
}
}
}
}
None
}
fn extract_user_content(value: &serde_json::Value) -> Vec<ContentBlock> {
let mut blocks = Vec::new();
let message = match value.get("message") {
Some(m) => m,
None => return blocks,
};
let content = match message.get("content") {
Some(c) => c,
None => return blocks,
};
if let Some(text) = content.as_str() {
if let Some(cleaned) = strip_system_tags(text) {
blocks.push(ContentBlock::Text { text: cleaned });
}
return blocks;
}
if let Some(arr) = content.as_array() {
for item in arr {
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
if let Some(cleaned) = strip_system_tags(text) {
blocks.push(ContentBlock::Text { text: cleaned });
}
}
}
"tool_result" | "server_tool_result" => {
let tool_use_id = item
.get("tool_use_id")
.and_then(|n| n.as_str())
.map(|s| s.to_string());
let output = extract_tool_result_text(item);
let is_error = item
.get("is_error")
.and_then(|e| e.as_bool())
.unwrap_or(false);
blocks.push(ContentBlock::ToolResult {
tool_use_id,
output_preview: output,
is_error,
});
}
_ => {}
}
}
}
blocks
}
fn extract_assistant_content(value: &serde_json::Value) -> Vec<ContentBlock> {
let mut blocks = Vec::new();
let message = match value.get("message") {
Some(m) => m,
None => return blocks,
};
let content = match message.get("content") {
Some(c) => c,
None => return blocks,
};
if let Some(arr) = content.as_array() {
for item in arr {
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
blocks.push(ContentBlock::Text {
text: text.to_string(),
});
}
}
"thinking" => {
if let Some(text) = item.get("thinking").and_then(|t| t.as_str()) {
blocks.push(ContentBlock::Thinking {
text: text.to_string(),
});
}
}
"tool_use" | "server_tool_use" => {
let tool_use_id = item
.get("id")
.and_then(|n| n.as_str())
.map(|s| s.to_string());
let tool_name = item
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("unknown")
.to_string();
let input_preview = item.get("input").map(|i| i.to_string());
blocks.push(ContentBlock::ToolUse {
tool_use_id,
tool_name,
input_preview,
});
}
_ => {}
}
}
}
blocks
}
fn extract_usage(value: &serde_json::Value) -> Option<TurnUsage> {
let usage = value.get("message")?.get("usage")?;
Some(TurnUsage {
input_tokens: usage
.get("input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0),
output_tokens: usage
.get("output_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0),
cache_creation_input_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0),
cache_read_input_tokens: usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0),
})
}
fn extract_tool_result_text(item: &serde_json::Value) -> Option<String> {
let content = item.get("content")?;
if let Some(text) = content.as_str() {
return Some(text.to_string());
}
if let Some(arr) = content.as_array() {
let texts: Vec<String> = arr
.iter()
.filter_map(|c| {
if c.get("type").and_then(|t| t.as_str()) == Some("text") {
c.get("text")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
} else {
None
}
})
.collect();
if !texts.is_empty() {
return Some(texts.join("\n"));
}
}
None
}
/// Check if a user message contains ONLY tool_result blocks (no text).
/// In Claude Code, tool results come back as "user" messages.
fn is_tool_result_only(msg: &UnifiedMessage) -> bool {
matches!(msg.role, MessageRole::User)
&& !msg.content.is_empty()
&& msg
.content
.iter()
.all(|b| matches!(b, ContentBlock::ToolResult { .. }))
}
/// Group flat messages into conversation turns.
/// Claude Code rule: assistant msg + following tool-result-only user msgs
/// merge into one Assistant turn.
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let mut turns = Vec::new();
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
if matches!(msg.role, MessageRole::Assistant) {
let mut blocks: Vec<ContentBlock> = msg.content.clone();
let timestamp = msg.timestamp;
let id = format!("turn-{}", turns.len());
let usage = msg.usage.clone();
let duration_ms = msg.duration_ms;
let turn_model = msg.model.clone();
i += 1;
// Absorb consecutive assistant msgs AND tool-result-only user msgs
while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant)
|| is_tool_result_only(&messages[i]))
{
blocks.extend(messages[i].content.clone());
i += 1;
}
turns.push(MessageTurn {
id,
role: TurnRole::Assistant,
blocks,
timestamp,
usage,
duration_ms,
model: turn_model,
});
} else if matches!(msg.role, MessageRole::System) {
turns.push(MessageTurn {
id: format!("turn-{}", turns.len()),
role: TurnRole::System,
blocks: msg.content.clone(),
timestamp: msg.timestamp,
usage: None,
duration_ms: None,
model: None,
});
i += 1;
} else {
turns.push(MessageTurn {
id: format!("turn-{}", turns.len()),
role: TurnRole::User,
blocks: msg.content.clone(),
timestamp: msg.timestamp,
usage: None,
duration_ms: None,
model: None,
});
i += 1;
}
}
turns
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
#[test]
fn parses_model_capacity_suffix() {
assert_eq!(
parse_model_capacity_suffix("claude-sonnet-4-6[1.5M]"),
Some(1_500_000)
);
assert_eq!(
parse_model_capacity_suffix("claude-opus-4-6 [500k]"),
Some(500_000)
);
assert_eq!(parse_model_capacity_suffix("claude-sonnet-4-6"), None);
}
#[test]
fn defaults_context_limit_for_claude_models() {
assert_eq!(
claude_context_window_max_tokens_for_model(Some("claude-sonnet-4-6")),
Some(200_000)
);
assert_eq!(
claude_context_window_max_tokens_for_model(Some("custom-model-x")),
None
);
}
#[test]
fn uses_latest_assistant_usage_for_context_tokens() {
let timestamp = Utc::now();
let turns = vec![
MessageTurn {
id: "turn-0".to_string(),
role: TurnRole::Assistant,
blocks: vec![],
timestamp,
usage: Some(TurnUsage {
input_tokens: 100,
output_tokens: 20,
cache_creation_input_tokens: 30,
cache_read_input_tokens: 40,
}),
duration_ms: None,
model: None,
},
MessageTurn {
id: "turn-1".to_string(),
role: TurnRole::Assistant,
blocks: vec![],
timestamp,
usage: Some(TurnUsage {
input_tokens: 250,
output_tokens: 60,
cache_creation_input_tokens: 70,
cache_read_input_tokens: 80,
}),
duration_ms: None,
model: None,
},
];
assert_eq!(
latest_claude_context_window_used_tokens(&turns),
Some(250 + 70 + 80)
);
}
#[test]
fn parse_detail_sets_claude_context_window_stats() {
let path = std::env::temp_dir().join(format!(
"codeg-claude-parser-{}.jsonl",
uuid::Uuid::new_v4()
));
let mut file = fs::File::create(&path).expect("create temp jsonl");
writeln!(
file,
"{}",
serde_json::json!({
"type": "user",
"sessionId": "session-test",
"timestamp": "2026-03-01T10:00:00Z",
"uuid": "u1",
"cwd": "/tmp/demo",
"gitBranch": "main",
"message": {
"content": [{"type": "text", "text": "hello"}]
}
})
)
.expect("write user line");
writeln!(
file,
"{}",
serde_json::json!({
"type": "assistant",
"sessionId": "session-test",
"timestamp": "2026-03-01T10:00:02Z",
"uuid": "a1",
"message": {
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "world"}],
"usage": {
"input_tokens": 1000,
"output_tokens": 200,
"cache_creation_input_tokens": 300,
"cache_read_input_tokens": 400
}
}
})
)
.expect("write assistant line");
let parser = ClaudeParser {
base_dir: PathBuf::new(),
};
let detail = parser
.parse_conversation_detail(&path, "session-test")
.expect("parse conversation detail");
fs::remove_file(&path).expect("cleanup temp jsonl");
let stats = detail.session_stats.expect("session stats");
assert_eq!(stats.context_window_used_tokens, Some(1700));
assert_eq!(stats.context_window_max_tokens, Some(200_000));
let percent = stats
.context_window_usage_percent
.expect("context window usage percent");
assert!((percent - 0.85).abs() < f64::EPSILON);
}
#[test]
fn claude_config_dir_env_overrides_home() {
let resolved = resolve_claude_config_dir_from(
Some(std::ffi::OsString::from("/tmp/claude-config")),
Some(PathBuf::from("/Users/default")),
);
assert_eq!(resolved, PathBuf::from("/tmp/claude-config"));
}
#[test]
fn claude_config_dir_defaults_to_home_dot_claude() {
let resolved = resolve_claude_config_dir_from(
None,
Some(PathBuf::from("/Users/default")),
);
assert_eq!(resolved, PathBuf::from("/Users/default/.claude"));
}
}
File diff suppressed because it is too large Load Diff
+715
View File
@@ -0,0 +1,715 @@
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use walkdir::WalkDir;
use crate::models::*;
use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError};
pub struct GeminiParser {
base_dir: PathBuf,
}
impl GeminiParser {
pub fn new() -> Self {
let base_dir = resolve_gemini_base_dir();
Self { base_dir }
}
#[cfg(test)]
fn with_base_dir(base_dir: PathBuf) -> Self {
Self { base_dir }
}
fn tmp_dir(&self) -> PathBuf {
self.base_dir.join("tmp")
}
fn history_dir(&self) -> PathBuf {
self.base_dir.join("history")
}
fn projects_json_path(&self) -> PathBuf {
self.base_dir.join("projects.json")
}
fn is_chat_file(path: &Path) -> bool {
if path.extension().and_then(|e| e.to_str()) != Some("json") {
return false;
}
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !file_name.starts_with("session-") {
return false;
}
path.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
== Some("chats")
}
fn list_chat_files(&self) -> Vec<PathBuf> {
let tmp_dir = self.tmp_dir();
if !tmp_dir.exists() {
return Vec::new();
}
let mut files: Vec<PathBuf> = WalkDir::new(&tmp_dir)
.into_iter()
.filter_map(|e| e.ok())
.map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && Self::is_chat_file(p))
.collect();
files.sort();
files
}
fn project_alias_from_chat_path(path: &Path) -> Option<String> {
path.parent()?
.parent()?
.file_name()
.map(|n| n.to_string_lossy().to_string())
}
fn read_project_root_file(path: PathBuf) -> Option<String> {
let raw = fs::read_to_string(path).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn resolve_project_root(&self, alias: &str) -> Option<String> {
let tmp_root = self.tmp_dir().join(alias).join(".project_root");
if let Some(path) = Self::read_project_root_file(tmp_root) {
return Some(path);
}
let history_root = self.history_dir().join(alias).join(".project_root");
if let Some(path) = Self::read_project_root_file(history_root) {
return Some(path);
}
self.resolve_project_root_from_projects_json(alias)
}
fn resolve_project_root_from_projects_json(&self, alias: &str) -> Option<String> {
let raw = fs::read_to_string(self.projects_json_path()).ok()?;
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
let projects = value.get("projects")?.as_object()?;
projects
.iter()
.find_map(|(path, mapped_alias)| (mapped_alias.as_str() == Some(alias)).then(|| path))
.map(|s| s.to_string())
}
fn parse_timestamp(value: Option<&serde_json::Value>) -> Option<DateTime<Utc>> {
value.and_then(|v| v.as_str()?.parse::<DateTime<Utc>>().ok())
}
fn extract_text(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(text) => {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
serde_json::Value::Array(items) => {
let mut parts = Vec::new();
for item in items {
if let Some(text) = item.get("text").and_then(Self::extract_text) {
parts.push(text);
} else if let Some(text) = Self::extract_text(item) {
parts.push(text);
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
serde_json::Value::Object(map) => {
if let Some(text) = map.get("text").and_then(Self::extract_text) {
return Some(text);
}
if let Some(text) = map.get("message").and_then(Self::extract_text) {
return Some(text);
}
None
}
_ => None,
}
}
fn extract_message_text(message: &serde_json::Value) -> Option<String> {
message
.get("content")
.and_then(Self::extract_text)
.or_else(|| message.get("message").and_then(Self::extract_text))
}
fn parse_summary_from_value(
&self,
path: &Path,
value: &serde_json::Value,
) -> Option<ConversationSummary> {
let id = value.get("sessionId").and_then(|v| v.as_str())?.to_string();
let messages = value
.get("messages")
.and_then(|m| m.as_array())
.cloned()
.unwrap_or_default();
let first_message_ts = messages
.first()
.and_then(|m| Self::parse_timestamp(m.get("timestamp")));
let last_message_ts = messages
.iter()
.rev()
.find_map(|m| Self::parse_timestamp(m.get("timestamp")));
let started_at = Self::parse_timestamp(value.get("startTime"))
.or(first_message_ts)
.unwrap_or_else(Utc::now);
let ended_at = Self::parse_timestamp(value.get("lastUpdated")).or(last_message_ts);
let title = messages
.iter()
.filter(|m| m.get("type").and_then(|t| t.as_str()) == Some("user"))
.find_map(Self::extract_message_text)
.map(|t| truncate_str(&t, 100));
let model = messages.iter().rev().find_map(|m| {
m.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let folder_alias = Self::project_alias_from_chat_path(path);
let folder_path = folder_alias
.as_deref()
.and_then(|alias| self.resolve_project_root(alias));
let folder_name = folder_path
.as_ref()
.map(|p| folder_name_from_path(p))
.or(folder_alias);
Some(ConversationSummary {
id,
agent_type: AgentType::Gemini,
folder_path,
folder_name,
title,
started_at,
ended_at,
message_count: messages.len() as u32,
model,
git_branch: None,
})
}
fn result_preview(result: Option<&serde_json::Value>) -> Option<String> {
let v = result?;
if let Some(s) = v.as_str() {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
return Some(trimmed.to_string());
}
serde_json::to_string(v).ok()
}
fn tool_call_is_error(call: &serde_json::Value, output_preview: Option<&str>) -> bool {
if call
.get("status")
.and_then(|v| v.as_str())
.map(|s| {
matches!(
s.to_ascii_lowercase().as_str(),
"error" | "failed" | "failure" | "cancelled" | "canceled"
)
})
.unwrap_or(false)
{
return true;
}
if call
.get("result")
.and_then(|r| r.as_array())
.map(|items| {
items.iter().any(|item| {
item.get("functionResponse")
.and_then(|fr| fr.get("response"))
.and_then(|resp| resp.get("error"))
.is_some()
})
})
.unwrap_or(false)
{
return true;
}
output_preview
.map(|s| s.trim_start().to_ascii_lowercase().starts_with("error"))
.unwrap_or(false)
}
fn parse_assistant_blocks(message: &serde_json::Value) -> Vec<ContentBlock> {
let mut blocks: Vec<ContentBlock> = Vec::new();
if let Some(thoughts) = message.get("thoughts").and_then(|v| v.as_array()) {
for thought in thoughts {
let subject = thought
.get("subject")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let description = thought
.get("description")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let text = match (subject, description) {
(Some(sub), Some(desc)) => format!("{sub}: {desc}"),
(Some(sub), None) => sub.to_string(),
(None, Some(desc)) => desc.to_string(),
(None, None) => continue,
};
blocks.push(ContentBlock::Thinking { text });
}
}
if let Some(tool_calls) = message.get("toolCalls").and_then(|v| v.as_array()) {
for call in tool_calls {
let tool_use_id = call
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tool_name = call
.get("displayName")
.or_else(|| call.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let input_preview = call
.get("args")
.and_then(|v| serde_json::to_string(v).ok())
.or_else(|| {
call.get("input")
.and_then(|v| Self::result_preview(Some(v)))
});
blocks.push(ContentBlock::ToolUse {
tool_use_id: tool_use_id.clone(),
tool_name,
input_preview,
});
let output_preview = call
.get("resultDisplay")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| Self::result_preview(call.get("result")));
let is_error = Self::tool_call_is_error(call, output_preview.as_deref());
blocks.push(ContentBlock::ToolResult {
tool_use_id,
output_preview,
is_error,
});
}
}
if let Some(text) = Self::extract_message_text(message) {
blocks.push(ContentBlock::Text { text });
}
blocks
}
fn parse_usage(message: &serde_json::Value) -> Option<TurnUsage> {
let tokens = message.get("tokens")?;
let input_tokens = tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0);
let output_tokens = tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
let cached_tokens = tokens.get("cached").and_then(|v| v.as_u64()).unwrap_or(0);
Some(TurnUsage {
input_tokens,
output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: cached_tokens,
})
}
fn parse_conversation_detail(
&self,
path: &Path,
value: &serde_json::Value,
conversation_id: &str,
) -> Result<ConversationDetail, ParseError> {
let mut summary = self
.parse_summary_from_value(path, value)
.ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?;
let messages_raw = value
.get("messages")
.and_then(|m| m.as_array())
.cloned()
.unwrap_or_default();
let mut messages: Vec<UnifiedMessage> = Vec::new();
for raw in messages_raw {
let msg_id = raw
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("msg-{}", messages.len()));
let timestamp =
Self::parse_timestamp(raw.get("timestamp")).unwrap_or(summary.started_at);
let msg_type = raw
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_ascii_lowercase();
match msg_type.as_str() {
"user" => {
let Some(text) = Self::extract_message_text(&raw) else {
continue;
};
messages.push(UnifiedMessage {
id: msg_id,
role: MessageRole::User,
content: vec![ContentBlock::Text { text }],
timestamp,
usage: None,
duration_ms: None,
model: None,
});
}
"gemini" | "assistant" | "model" => {
let blocks = Self::parse_assistant_blocks(&raw);
if blocks.is_empty() {
continue;
}
messages.push(UnifiedMessage {
id: msg_id,
role: MessageRole::Assistant,
content: blocks,
timestamp,
usage: Self::parse_usage(&raw),
duration_ms: None,
model: raw
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
});
}
"system" => {
let Some(text) = Self::extract_message_text(&raw) else {
continue;
};
messages.push(UnifiedMessage {
id: msg_id,
role: MessageRole::System,
content: vec![ContentBlock::Text { text }],
timestamp,
usage: None,
duration_ms: None,
model: None,
});
}
_ => {}
}
}
// Approximate duration for assistant messages from adjacent timestamps
for i in 0..messages.len() {
if matches!(messages[i].role, MessageRole::Assistant)
&& messages[i].duration_ms.is_none()
{
if let Some(next) = messages.get(i + 1) {
let dur = (next.timestamp - messages[i].timestamp).num_milliseconds();
if dur > 0 && dur < 300_000 {
messages[i].duration_ms = Some(dur as u64);
}
}
}
}
let turns = group_into_turns(messages);
summary.message_count = turns.len() as u32;
summary.id = conversation_id.to_string();
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
let context_window_max_tokens =
super::infer_context_window_max_tokens(summary.model.as_deref());
let session_stats = super::merge_context_window_stats(
super::compute_session_stats(&turns),
context_window_used_tokens,
context_window_max_tokens,
);
Ok(ConversationDetail {
summary,
turns,
session_stats,
})
}
}
fn resolve_gemini_base_dir() -> PathBuf {
resolve_gemini_base_dir_from(
std::env::var_os("GEMINI_CLI_HOME"),
dirs::home_dir(),
)
}
fn resolve_gemini_base_dir_from(
gemini_cli_home_env: Option<std::ffi::OsString>,
home_dir: Option<PathBuf>,
) -> PathBuf {
gemini_cli_home_env
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home_dir.unwrap_or_default())
.join(".gemini")
}
impl AgentParser for GeminiParser {
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
let mut conversations = Vec::new();
for chat_file in self.list_chat_files() {
let raw = match fs::read_to_string(&chat_file) {
Ok(raw) => raw,
Err(_) => continue,
};
let value: serde_json::Value = match serde_json::from_str(&raw) {
Ok(value) => value,
Err(_) => continue,
};
if let Some(summary) = self.parse_summary_from_value(&chat_file, &value) {
conversations.push(summary);
}
}
conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(conversations)
}
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
for chat_file in self.list_chat_files() {
let raw = match fs::read_to_string(&chat_file) {
Ok(raw) => raw,
Err(_) => continue,
};
if !raw.contains(conversation_id) {
continue;
}
let value: serde_json::Value = match serde_json::from_str(&raw) {
Ok(value) => value,
Err(_) => continue,
};
let session_id = value.get("sessionId").and_then(|v| v.as_str());
if session_id != Some(conversation_id) {
continue;
}
return self.parse_conversation_detail(&chat_file, &value, conversation_id);
}
Err(ParseError::ConversationNotFound(
conversation_id.to_string(),
))
}
}
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let mut turns = Vec::new();
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
if matches!(msg.role, MessageRole::User) {
turns.push(MessageTurn {
id: format!("turn-{}", turns.len()),
role: TurnRole::User,
blocks: msg.content.clone(),
timestamp: msg.timestamp,
usage: None,
duration_ms: None,
model: None,
});
i += 1;
continue;
}
if matches!(msg.role, MessageRole::System) {
turns.push(MessageTurn {
id: format!("turn-{}", turns.len()),
role: TurnRole::System,
blocks: msg.content.clone(),
timestamp: msg.timestamp,
usage: None,
duration_ms: None,
model: None,
});
i += 1;
continue;
}
let mut blocks = msg.content.clone();
let mut usage = msg.usage.clone();
let mut duration_ms = msg.duration_ms;
let mut models: Vec<String> = msg.model.iter().cloned().collect();
let timestamp = msg.timestamp;
i += 1;
while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant)
|| matches!(messages[i].role, MessageRole::Tool))
{
blocks.extend(messages[i].content.clone());
if usage.is_none() {
usage = messages[i].usage.clone();
}
if duration_ms.is_none() {
duration_ms = messages[i].duration_ms;
}
if let Some(model) = &messages[i].model {
models.push(model.clone());
}
i += 1;
}
let model = models.pop();
turns.push(MessageTurn {
id: format!("turn-{}", turns.len()),
role: TurnRole::Assistant,
blocks,
timestamp,
usage,
duration_ms,
model,
});
}
turns
}
#[cfg(test)]
mod tests {
use super::GeminiParser;
use super::resolve_gemini_base_dir_from;
use crate::parsers::AgentParser;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn parses_gemini_session_detail_from_chat_json() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time ok")
.as_nanos();
let base: PathBuf = env::temp_dir().join(format!("codeg-gemini-test-{nanos}"));
let chats_dir = base.join("tmp").join("codeg").join("chats");
fs::create_dir_all(&chats_dir).expect("create chat dir");
fs::write(
base.join("tmp").join("codeg").join(".project_root"),
"/Users/test/workspace/demo",
)
.expect("write project root");
let file_path = chats_dir.join("session-2026-03-02T04-30-32c7d221.json");
let content = r#"{
"sessionId": "32c7d221-0553-46c8-ba50-e664719cae7f",
"projectHash": "abc",
"startTime": "2026-03-02T04:30:20.796Z",
"lastUpdated": "2026-03-02T04:33:13.631Z",
"messages": [
{
"id": "u1",
"timestamp": "2026-03-02T04:30:20.796Z",
"type": "user",
"content": [{"text": "你会做什么"}]
},
{
"id": "a1",
"timestamp": "2026-03-02T04:33:13.631Z",
"type": "gemini",
"content": "我是一个助手",
"toolCalls": [
{
"id": "cli_help-1",
"name": "cli_help",
"args": {"question": "你会做什么"},
"resultDisplay": "ok",
"status": "success"
}
],
"tokens": {"input": 12, "output": 34, "cached": 5},
"model": "gemini-3.1-pro-preview"
}
]
}"#;
fs::write(&file_path, content).expect("write chat file");
let parser = GeminiParser::with_base_dir(base.clone());
let summaries = parser.list_conversations().expect("list conversations");
assert_eq!(summaries.len(), 1);
assert_eq!(
summaries[0].id,
"32c7d221-0553-46c8-ba50-e664719cae7f".to_string()
);
let detail = parser
.get_conversation("32c7d221-0553-46c8-ba50-e664719cae7f")
.expect("get conversation");
assert_eq!(detail.turns.len(), 2);
assert_eq!(
detail.summary.folder_path.as_deref(),
Some("/Users/test/workspace/demo")
);
assert!(detail.session_stats.is_some());
let stats = detail.session_stats.expect("session stats");
assert_eq!(stats.context_window_used_tokens, Some(51));
assert_eq!(stats.context_window_max_tokens, Some(1_000_000));
let percent = stats
.context_window_usage_percent
.expect("context window percent");
assert!((percent - 0.0051).abs() < 1e-9);
let _ = fs::remove_dir_all(base);
}
#[test]
fn gemini_cli_home_env_overrides_user_home() {
let resolved = resolve_gemini_base_dir_from(
Some(std::ffi::OsString::from("/tmp/gemini-home")),
Some(PathBuf::from("/Users/default")),
);
assert_eq!(resolved, PathBuf::from("/tmp/gemini-home/.gemini"));
}
#[test]
fn gemini_defaults_to_home_dot_gemini() {
let resolved = resolve_gemini_base_dir_from(
None,
Some(PathBuf::from("/Users/default")),
);
assert_eq!(resolved, PathBuf::from("/Users/default/.gemini"));
}
}
+356
View File
@@ -0,0 +1,356 @@
pub mod claude;
pub mod codex;
pub mod gemini;
pub mod opencode;
use std::sync::OnceLock;
use regex::Regex;
use crate::models::{
ConversationDetail, ConversationSummary, MessageTurn, SessionStats, TurnUsage,
};
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("Database error: {0}")]
Db(#[from] sea_orm::DbErr),
#[error("Conversation not found: {0}")]
ConversationNotFound(String),
#[allow(dead_code)]
#[error("Invalid data: {0}")]
InvalidData(String),
}
pub trait AgentParser {
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError>;
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError>;
}
/// Truncate a string to `max_len` characters, appending "..." if truncated.
pub fn truncate_str(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else {
let truncated: String = s.chars().take(max_len).collect();
format!("{}...", truncated)
}
}
/// Aggregate turn-level usage and duration into a single `SessionStats`.
pub fn compute_session_stats(turns: &[MessageTurn]) -> Option<SessionStats> {
let mut total_in = 0u64;
let mut total_out = 0u64;
let mut total_cache_create = 0u64;
let mut total_cache_read = 0u64;
let mut total_duration = 0u64;
let mut has_data = false;
for turn in turns {
if let Some(ref u) = turn.usage {
total_in += u.input_tokens;
total_out += u.output_tokens;
total_cache_create += u.cache_creation_input_tokens;
total_cache_read += u.cache_read_input_tokens;
has_data = true;
}
if let Some(d) = turn.duration_ms {
total_duration += d;
}
}
if !has_data {
return None;
}
Some(SessionStats {
total_usage: Some(TurnUsage {
input_tokens: total_in,
output_tokens: total_out,
cache_creation_input_tokens: total_cache_create,
cache_read_input_tokens: total_cache_read,
}),
total_tokens: Some(total_in + total_out + total_cache_create + total_cache_read),
total_duration_ms: total_duration,
context_window_used_tokens: None,
context_window_max_tokens: None,
context_window_usage_percent: None,
})
}
fn model_capacity_suffix_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\[\s*([0-9]+(?:\.[0-9]+)?)\s*([km])\s*\]\s*$")
.expect("valid model capacity regex")
})
}
fn parse_model_capacity_suffix(model: &str) -> Option<u64> {
let captures = model_capacity_suffix_regex().captures(model.trim())?;
let value = captures.get(1)?.as_str().parse::<f64>().ok()?;
if !value.is_finite() || value <= 0.0 {
return None;
}
let unit = captures
.get(2)
.map(|m| m.as_str().to_ascii_lowercase())
.unwrap_or_default();
let multiplier = match unit.as_str() {
"m" => 1_000_000.0,
"k" => 1_000.0,
_ => return None,
};
Some((value * multiplier) as u64)
}
pub fn infer_context_window_max_tokens(model: Option<&str>) -> Option<u64> {
let raw = model?.trim();
if raw.is_empty() {
return None;
}
if let Some(suffixed_limit) = parse_model_capacity_suffix(raw) {
return Some(suffixed_limit);
}
let normalized = raw
.rsplit('/')
.next()
.unwrap_or(raw)
.split(':')
.next()
.unwrap_or(raw)
.trim()
.to_ascii_lowercase();
if normalized.starts_with("claude") {
return Some(200_000);
}
if normalized.starts_with("gemini") {
return Some(1_000_000);
}
match normalized.as_str() {
"gpt-5.2-codex" | "gpt-5.1-codex-max" | "gpt-5.1-codex-mini" | "gpt-5.2" => Some(258_000),
"gpt-5.1" | "gpt-5.1-codex" | "gpt-4o" | "gpt-4o-mini" | "gpt-4-turbo" | "o1-mini"
| "o1-preview" => Some(128_000),
"gpt-4" => Some(8_192),
"o3" | "o3-mini" | "o1" => Some(200_000),
_ => {
if normalized.starts_with("gpt-5") {
Some(258_000)
} else if normalized.starts_with("gpt-4o")
|| normalized.starts_with("gpt-4.1")
|| normalized.starts_with("gpt-4-turbo")
{
Some(128_000)
} else if normalized.starts_with("o3") || normalized == "o1" {
Some(200_000)
} else if normalized.starts_with("o1-mini") || normalized.starts_with("o1-preview") {
Some(128_000)
} else {
None
}
}
}
}
pub fn latest_turn_total_usage_tokens(turns: &[MessageTurn]) -> Option<u64> {
turns.iter().rev().find_map(|turn| {
turn.usage.as_ref().map(|usage| {
usage
.input_tokens
.saturating_add(usage.output_tokens)
.saturating_add(usage.cache_creation_input_tokens)
.saturating_add(usage.cache_read_input_tokens)
})
})
}
pub fn merge_context_window_stats(
stats: Option<SessionStats>,
used_tokens: Option<u64>,
max_tokens: Option<u64>,
) -> Option<SessionStats> {
if used_tokens.is_none() && max_tokens.is_none() {
return stats;
}
let usage_percent = match (used_tokens, max_tokens) {
(Some(used), Some(max)) if max > 0 => Some((used as f64 / max as f64) * 100.0),
_ => None,
};
match stats {
Some(mut s) => {
s.context_window_used_tokens = used_tokens;
s.context_window_max_tokens = max_tokens;
s.context_window_usage_percent = usage_percent;
Some(s)
}
None => Some(SessionStats {
total_usage: None,
total_tokens: None,
total_duration_ms: 0,
context_window_used_tokens: used_tokens,
context_window_max_tokens: max_tokens,
context_window_usage_percent: usage_percent,
}),
}
}
/// Extract the last path component as the folder name.
pub fn folder_name_from_path(path: &str) -> String {
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
}
/// Normalize a filesystem path string for tolerant cross-platform comparison.
/// This intentionally does not hit the filesystem (no canonicalize), and only
/// normalizes separators/casing differences that commonly break exact matching.
pub fn normalize_path_for_matching(path: &str) -> String {
let mut normalized = path.trim().replace('\\', "/");
#[cfg(target_os = "windows")]
{
if let Some(stripped) = normalized.strip_prefix("//?/") {
normalized = stripped.to_string();
}
normalized = normalized.to_ascii_lowercase();
}
while normalized.ends_with('/') {
if normalized == "/" {
break;
}
// Keep Windows drive root such as "c:/" intact.
if normalized.len() == 3
&& normalized.as_bytes().get(1) == Some(&b':')
&& normalized.as_bytes().get(2) == Some(&b'/')
{
break;
}
normalized.pop();
}
normalized
}
pub fn path_eq_for_matching(left: &str, right: &str) -> bool {
normalize_path_for_matching(left) == normalize_path_for_matching(right)
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::{
infer_context_window_max_tokens, latest_turn_total_usage_tokens, merge_context_window_stats,
path_eq_for_matching,
};
use crate::models::{MessageTurn, SessionStats, TurnRole, TurnUsage};
#[test]
fn infers_model_context_limits() {
assert_eq!(
infer_context_window_max_tokens(Some("claude-sonnet-4-6")),
Some(200_000)
);
assert_eq!(
infer_context_window_max_tokens(Some("gemini-2.5-pro")),
Some(1_000_000)
);
assert_eq!(
infer_context_window_max_tokens(Some("claude-sonnet-4-6 [1.5M]")),
Some(1_500_000)
);
assert_eq!(infer_context_window_max_tokens(Some("unknown-model")), None);
}
#[test]
fn picks_latest_turn_usage_total_tokens() {
let timestamp = Utc::now();
let turns = vec![
MessageTurn {
id: "turn-0".to_string(),
role: TurnRole::Assistant,
blocks: vec![],
timestamp,
usage: Some(TurnUsage {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: 30,
cache_read_input_tokens: 40,
}),
duration_ms: None,
model: None,
},
MessageTurn {
id: "turn-1".to_string(),
role: TurnRole::Assistant,
blocks: vec![],
timestamp,
usage: Some(TurnUsage {
input_tokens: 11,
output_tokens: 21,
cache_creation_input_tokens: 31,
cache_read_input_tokens: 41,
}),
duration_ms: None,
model: None,
},
];
assert_eq!(latest_turn_total_usage_tokens(&turns), Some(104));
}
#[test]
fn merges_context_window_stats() {
let merged = merge_context_window_stats(None, Some(1500), Some(3000))
.expect("context stats should exist");
assert_eq!(merged.context_window_used_tokens, Some(1500));
assert_eq!(merged.context_window_max_tokens, Some(3000));
assert!(merged.total_usage.is_none());
let percent = merged
.context_window_usage_percent
.expect("usage percent should exist");
assert!((percent - 50.0).abs() < f64::EPSILON);
let existing = Some(SessionStats {
total_usage: Some(TurnUsage {
input_tokens: 1,
output_tokens: 2,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 4,
}),
total_tokens: Some(10),
total_duration_ms: 100,
context_window_used_tokens: None,
context_window_max_tokens: None,
context_window_usage_percent: None,
});
let merged_existing =
merge_context_window_stats(existing, Some(200), Some(1000)).expect("merged");
assert_eq!(merged_existing.total_tokens, Some(10));
assert_eq!(merged_existing.context_window_used_tokens, Some(200));
assert_eq!(merged_existing.context_window_max_tokens, Some(1000));
}
#[test]
fn path_matching_handles_separator_differences() {
assert!(path_eq_for_matching(
"/Users/demo/workspace/codeg",
"/Users/demo/workspace/codeg/"
));
assert!(path_eq_for_matching(
"C:\\Users\\demo\\workspace\\codeg",
"C:/Users/demo/workspace/codeg"
));
}
}
+646
View File
@@ -0,0 +1,646 @@
use std::future::Future;
use std::path::PathBuf;
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, QueryResult,
Statement,
};
use crate::models::*;
use crate::parsers::{folder_name_from_path, AgentParser, ParseError};
pub struct OpenCodeParser {
base_dir: PathBuf,
}
impl OpenCodeParser {
pub fn new() -> Self {
let base_dir = resolve_opencode_base_dir();
Self { base_dir }
}
fn sqlite_db_path(&self) -> PathBuf {
self.base_dir.join("opencode.db")
}
fn block_on<F, T>(&self, fut: F) -> Result<T, ParseError>
where
F: Future<Output = Result<T, ParseError>>,
{
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| ParseError::InvalidData(format!("failed to build runtime: {e}")))?;
runtime.block_on(fut)
}
async fn open_sqlite_connection(&self) -> Result<DatabaseConnection, ParseError> {
let db_path = self.sqlite_db_path();
let db_url = format!(
"sqlite:{}?mode=ro",
urlencoding::encode(&db_path.to_string_lossy())
);
let mut opts = ConnectOptions::new(db_url);
opts.max_connections(1)
.min_connections(1)
.connect_timeout(Duration::from_secs(5))
.idle_timeout(Duration::from_secs(30))
.sqlx_logging(false);
let conn = Database::connect(opts).await?;
conn.execute(Statement::from_string(
DbBackend::Sqlite,
"PRAGMA busy_timeout=3000;".to_owned(),
))
.await?;
Ok(conn)
}
fn parse_sqlite_summary_row(row: &QueryResult) -> Result<ConversationSummary, ParseError> {
let id: String = row.try_get("", "id")?;
let directory: Option<String> = row.try_get("", "directory")?;
let title: Option<String> = row.try_get("", "title")?;
let created_ms: i64 = row.try_get("", "created_ms")?;
let updated_ms: i64 = row.try_get("", "updated_ms")?;
let message_count_i64: i64 = row.try_get("", "message_count")?;
let model: Option<String> = row.try_get("", "model")?;
let folder_path = normalize_optional_string(directory);
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
let message_count = if message_count_i64 <= 0 {
0
} else {
u32::try_from(message_count_i64).unwrap_or(u32::MAX)
};
Ok(ConversationSummary {
id,
agent_type: AgentType::OpenCode,
folder_path,
folder_name,
title: normalize_optional_string(title),
started_at: millis_to_datetime(created_ms),
ended_at: (updated_ms > 0).then(|| millis_to_datetime(updated_ms)),
message_count,
model: normalize_optional_string(model),
git_branch: None,
})
}
async fn list_conversations_from_sqlite(&self) -> Result<Vec<ConversationSummary>, ParseError> {
let conn = self.open_sqlite_connection().await?;
let rows = conn
.query_all(Statement::from_string(
DbBackend::Sqlite,
r#"
SELECT
s.id AS id,
s.directory AS directory,
s.title AS title,
s.time_created AS created_ms,
s.time_updated AS updated_ms,
COALESCE((
SELECT COUNT(*)
FROM message m
WHERE m.session_id = s.id
), 0) AS message_count,
(
SELECT json_extract(m2.data, '$.modelID')
FROM message m2
WHERE m2.session_id = s.id
AND json_extract(m2.data, '$.role') = 'assistant'
ORDER BY m2.time_created DESC
LIMIT 1
) AS model
FROM session s
ORDER BY s.time_created DESC
"#
.to_string(),
))
.await?;
let mut conversations = Vec::with_capacity(rows.len());
for row in rows {
conversations.push(Self::parse_sqlite_summary_row(&row)?);
}
Ok(conversations)
}
async fn sqlite_summary_by_id(
&self,
conn: &DatabaseConnection,
conversation_id: &str,
) -> Result<Option<ConversationSummary>, ParseError> {
let row = conn
.query_one(Statement::from_sql_and_values(
DbBackend::Sqlite,
r#"
SELECT
s.id AS id,
s.directory AS directory,
s.title AS title,
s.time_created AS created_ms,
s.time_updated AS updated_ms,
COALESCE((
SELECT COUNT(*)
FROM message m
WHERE m.session_id = s.id
), 0) AS message_count,
(
SELECT json_extract(m2.data, '$.modelID')
FROM message m2
WHERE m2.session_id = s.id
AND json_extract(m2.data, '$.role') = 'assistant'
ORDER BY m2.time_created DESC
LIMIT 1
) AS model
FROM session s
WHERE s.id = ?
LIMIT 1
"#,
[conversation_id.into()],
))
.await?;
row.map(|r| Self::parse_sqlite_summary_row(&r)).transpose()
}
async fn get_conversation_from_sqlite(
&self,
conversation_id: &str,
) -> Result<ConversationDetail, ParseError> {
let conn = self.open_sqlite_connection().await?;
let summary = self
.sqlite_summary_by_id(&conn, conversation_id)
.await?
.ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?;
let messages = self.load_sqlite_messages(&conn, conversation_id).await?;
let turns = group_into_turns(messages);
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
let context_window_max_tokens =
super::infer_context_window_max_tokens(summary.model.as_deref());
let session_stats = super::merge_context_window_stats(
super::compute_session_stats(&turns),
context_window_used_tokens,
context_window_max_tokens,
);
Ok(ConversationDetail {
summary,
turns,
session_stats,
})
}
async fn load_sqlite_messages(
&self,
conn: &DatabaseConnection,
conversation_id: &str,
) -> Result<Vec<UnifiedMessage>, ParseError> {
let rows = conn
.query_all(Statement::from_sql_and_values(
DbBackend::Sqlite,
r#"
SELECT id, time_created, data
FROM message
WHERE session_id = ?
ORDER BY time_created ASC, id ASC
"#,
[conversation_id.into()],
))
.await?;
let mut messages = Vec::with_capacity(rows.len());
for row in rows {
let msg_id: String = row.try_get("", "id")?;
let row_time_created: i64 = row.try_get("", "time_created")?;
let data_raw: String = row.try_get("", "data")?;
let value: serde_json::Value = match serde_json::from_str(&data_raw) {
Ok(v) => v,
Err(_) => continue,
};
let role = match value.get("role").and_then(|r| r.as_str()) {
Some("user") => MessageRole::User,
Some("assistant") => MessageRole::Assistant,
Some("system") => MessageRole::System,
Some("tool") => MessageRole::Tool,
_ => continue,
};
let created_ms = value
.get("time")
.and_then(|t| t.get("created"))
.and_then(|c| c.as_i64())
.unwrap_or(row_time_created);
let timestamp = millis_to_datetime(created_ms);
let is_assistant = matches!(role, MessageRole::Assistant);
let msg_model = if is_assistant {
value
.get("modelID")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
} else {
None
};
let (content_blocks, usage_from_step_finish) =
self.load_sqlite_parts(conn, &msg_id).await?;
let usage = if is_assistant {
extract_opencode_usage(&value).or(usage_from_step_finish)
} else {
None
};
let duration_ms = if is_assistant {
let completed_ms = value
.get("time")
.and_then(|t| t.get("completed"))
.and_then(|c| c.as_i64());
match completed_ms {
Some(done) if done > created_ms => Some((done - created_ms) as u64),
_ => None,
}
} else {
None
};
messages.push(UnifiedMessage {
id: msg_id,
role,
content: content_blocks,
timestamp,
usage,
duration_ms,
model: msg_model,
});
}
Ok(messages)
}
async fn load_sqlite_parts(
&self,
conn: &DatabaseConnection,
message_id: &str,
) -> Result<(Vec<ContentBlock>, Option<TurnUsage>), ParseError> {
let rows = conn
.query_all(Statement::from_sql_and_values(
DbBackend::Sqlite,
r#"
SELECT data
FROM part
WHERE message_id = ?
ORDER BY time_created ASC, id ASC
"#,
[message_id.into()],
))
.await?;
let mut blocks = Vec::new();
let mut usage_from_step_finish: Option<TurnUsage> = None;
for row in rows {
let data_raw: String = row.try_get("", "data")?;
let value: serde_json::Value = match serde_json::from_str(&data_raw) {
Ok(v) => v,
Err(_) => continue,
};
let part_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
match part_type {
"text" => {
if let Some(text) = value
.get("text")
.and_then(|t| t.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
blocks.push(ContentBlock::Text {
text: text.to_string(),
});
}
}
"reasoning" => {
if let Some(text) = value
.get("text")
.and_then(|t| t.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
blocks.push(ContentBlock::Thinking {
text: text.to_string(),
});
}
}
"tool" => {
let tool_name = value
.get("tool")
.and_then(|t| t.as_str())
.unwrap_or("unknown")
.to_string();
let call_id = value
.get("callID")
.and_then(|c| c.as_str())
.map(|s| s.to_string());
let status = value
.get("state")
.and_then(|s| s.get("status"))
.and_then(|s| s.as_str())
.unwrap_or("");
let input_preview = value
.get("state")
.and_then(|s| s.get("input"))
.and_then(|v| value_to_preview(Some(v)));
blocks.push(ContentBlock::ToolUse {
tool_use_id: call_id.clone(),
tool_name,
input_preview,
});
let output_preview = value
.get("state")
.and_then(|s| s.get("output"))
.and_then(|v| value_to_preview(Some(v)));
let has_error_field = value.get("state").and_then(|s| s.get("error")).is_some();
blocks.push(ContentBlock::ToolResult {
tool_use_id: call_id,
output_preview,
is_error: is_error_status(status) || has_error_field,
});
}
"file" => {
if let Some(file_ref) = extract_file_reference(&value) {
blocks.push(ContentBlock::Text {
text: format!("@{}", file_ref),
});
}
}
"patch" => {
let files = value
.get("files")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !files.is_empty() {
blocks.push(ContentBlock::Text {
text: format!("Applied patch: {}", files.join(", ")),
});
}
}
"step-finish" => {
if usage_from_step_finish.is_none() {
usage_from_step_finish = value
.get("tokens")
.and_then(extract_opencode_usage_from_tokens);
}
}
_ => {}
}
}
Ok((blocks, usage_from_step_finish))
}
}
impl AgentParser for OpenCodeParser {
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
if !self.sqlite_db_path().exists() {
return Ok(Vec::new());
}
self.block_on(self.list_conversations_from_sqlite())
}
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
if !self.sqlite_db_path().exists() {
return Err(ParseError::ConversationNotFound(
conversation_id.to_string(),
));
}
self.block_on(self.get_conversation_from_sqlite(conversation_id))
}
}
fn resolve_opencode_base_dir() -> PathBuf {
resolve_xdg_data_home(std::env::var_os("XDG_DATA_HOME"), dirs::home_dir())
.map(|xdg_data_home| xdg_data_home.join("opencode"))
.unwrap_or_else(|| PathBuf::from("opencode"))
}
fn resolve_xdg_data_home(
xdg_data_home_env: Option<std::ffi::OsString>,
home_dir: Option<PathBuf>,
) -> Option<PathBuf> {
xdg_data_home_env
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| home_dir.map(|home| home.join(".local").join("share")))
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn value_to_preview(value: Option<&serde_json::Value>) -> Option<String> {
let v = value?;
if v.is_null() {
return None;
}
if let Some(s) = v.as_str() {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
} else {
serde_json::to_string(v).ok()
}
}
fn extract_file_reference(value: &serde_json::Value) -> Option<String> {
value
.get("source")
.and_then(|s| s.get("path"))
.and_then(|v| v.as_str())
.or_else(|| value.get("filename").and_then(|v| v.as_str()))
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
fn is_error_status(status: &str) -> bool {
matches!(
status.to_ascii_lowercase().as_str(),
"error" | "failed" | "failure" | "cancelled" | "canceled"
)
}
fn extract_opencode_usage(value: &serde_json::Value) -> Option<TurnUsage> {
value
.get("tokens")
.and_then(extract_opencode_usage_from_tokens)
}
fn extract_opencode_usage_from_tokens(tokens: &serde_json::Value) -> Option<TurnUsage> {
let input = tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0);
let output = tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
let cache = tokens.get("cache");
let cache_write = cache
.and_then(|c| c.get("write"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_read = cache
.and_then(|c| c.get("read"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
if input == 0 && output == 0 && cache_write == 0 && cache_read == 0 {
return None;
}
Some(TurnUsage {
input_tokens: input,
output_tokens: output,
cache_creation_input_tokens: cache_write,
cache_read_input_tokens: cache_read,
})
}
fn millis_to_datetime(ms: i64) -> DateTime<Utc> {
let secs = ms / 1000;
let nsecs = ((ms.rem_euclid(1000)) * 1_000_000) as u32;
Utc.timestamp_opt(secs, nsecs)
.single()
.unwrap_or_else(Utc::now)
}
/// Group flat messages into conversation turns (same strategy as Codex).
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let mut turns = Vec::new();
let mut i = 0;
while i < messages.len() {
let msg = &messages[i];
if matches!(msg.role, MessageRole::User) {
turns.push(MessageTurn {
id: format!("turn-{}", turns.len()),
role: TurnRole::User,
blocks: msg.content.clone(),
timestamp: msg.timestamp,
usage: None,
duration_ms: None,
model: None,
});
i += 1;
} else if matches!(msg.role, MessageRole::System) {
turns.push(MessageTurn {
id: format!("turn-{}", turns.len()),
role: TurnRole::System,
blocks: msg.content.clone(),
timestamp: msg.timestamp,
usage: None,
duration_ms: None,
model: None,
});
i += 1;
} else {
let mut blocks: Vec<ContentBlock> = msg.content.clone();
let mut usage = msg.usage.clone();
let mut duration_ms = msg.duration_ms;
let mut turn_model = msg.model.clone();
let timestamp = msg.timestamp;
i += 1;
while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant)
|| matches!(messages[i].role, MessageRole::Tool))
{
blocks.extend(messages[i].content.clone());
if usage.is_none() {
usage = messages[i].usage.clone();
}
if duration_ms.is_none() {
duration_ms = messages[i].duration_ms;
}
if turn_model.is_none() {
turn_model = messages[i].model.clone();
}
i += 1;
}
turns.push(MessageTurn {
id: format!("turn-{}", turns.len()),
role: TurnRole::Assistant,
blocks,
timestamp,
usage,
duration_ms,
model: turn_model,
});
}
}
turns
}
#[cfg(test)]
mod tests {
use super::resolve_xdg_data_home;
use std::path::PathBuf;
#[test]
fn xdg_data_home_env_overrides_home_fallback() {
let resolved = resolve_xdg_data_home(
Some(std::ffi::OsString::from("/tmp/xdg-data")),
Some(PathBuf::from("/Users/default")),
);
assert_eq!(resolved, Some(PathBuf::from("/tmp/xdg-data")));
}
#[test]
fn xdg_data_home_falls_back_to_home_local_share() {
let resolved = resolve_xdg_data_home(
None,
Some(PathBuf::from("/Users/default")),
);
assert_eq!(
resolved,
Some(PathBuf::from("/Users/default/.local/share"))
);
}
}
+62
View File
@@ -0,0 +1,62 @@
use std::ffi::{OsStr, OsString};
#[cfg(windows)]
use std::path::Path;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
pub fn configure_tokio_command(
command: &mut tokio::process::Command,
) -> &mut tokio::process::Command {
#[cfg(windows)]
{
command.creation_flags(CREATE_NO_WINDOW);
}
command
}
#[cfg(windows)]
fn maybe_windows_cmd_shim(program: &OsStr) -> Option<OsString> {
let path = Path::new(program);
if path.components().count() != 1 || path.extension().is_some() {
return None;
}
let raw = program.to_string_lossy();
let normalized = raw.to_ascii_lowercase();
let needs_cmd_shim = matches!(
normalized.as_str(),
"npm" | "npx" | "pnpm" | "pnpx" | "yarn" | "yarnpkg" | "corepack"
);
if needs_cmd_shim {
Some(OsString::from(format!("{raw}.cmd")))
} else {
None
}
}
pub fn normalized_program<S>(program: S) -> OsString
where
S: AsRef<OsStr>,
{
#[cfg(windows)]
{
if let Some(shimmed) = maybe_windows_cmd_shim(program.as_ref()) {
return shimmed;
}
}
program.as_ref().to_os_string()
}
pub fn tokio_command<S>(program: S) -> tokio::process::Command
where
S: AsRef<OsStr>,
{
let mut command = tokio::process::Command::new(normalized_program(program));
configure_tokio_command(&mut command);
command
}
+22
View File
@@ -0,0 +1,22 @@
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum TerminalError {
#[error("failed to spawn terminal: {0}")]
SpawnFailed(String),
#[error("terminal not found: {0}")]
NotFound(String),
#[error("terminal write error: {0}")]
WriteFailed(String),
#[error("terminal resize error: {0}")]
ResizeFailed(String),
}
impl Serialize for TerminalError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
+392
View File
@@ -0,0 +1,392 @@
use std::collections::HashMap;
use std::io::{Read, Write};
#[cfg(target_os = "windows")]
use std::path::Path;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use tauri::Emitter;
use super::error::TerminalError;
use super::types::{TerminalEvent, TerminalInfo};
struct TerminalInstance {
write_tx: mpsc::Sender<Vec<u8>>,
master: Box<dyn MasterPty + Send>,
#[allow(dead_code)]
child: Box<dyn portable_pty::Child + Send>,
title: String,
owner_window_label: String,
}
pub struct TerminalManager {
terminals: Arc<Mutex<HashMap<String, TerminalInstance>>>,
}
fn resolve_shell() -> String {
#[cfg(target_os = "windows")]
{
if let Ok(shell) = std::env::var("SHELL") {
let trimmed = shell.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
if let Ok(comspec) = std::env::var("COMSPEC") {
let trimmed = comspec.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
"cmd.exe".to_string()
}
#[cfg(not(target_os = "windows"))]
{
std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string())
}
}
#[cfg(target_os = "windows")]
#[derive(Debug, Clone, Copy)]
enum WindowsShellFlavor {
Cmd,
PowerShell,
Posix,
}
#[cfg(target_os = "windows")]
fn detect_windows_shell_flavor(shell: &str) -> WindowsShellFlavor {
let shell_name = Path::new(shell)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(shell)
.to_ascii_lowercase();
if shell_name.contains("pwsh") || shell_name.contains("powershell") {
WindowsShellFlavor::PowerShell
} else if shell_name.contains("bash")
|| shell_name.contains("zsh")
|| shell_name.contains("fish")
|| shell_name.ends_with("sh.exe")
{
WindowsShellFlavor::Posix
} else {
WindowsShellFlavor::Cmd
}
}
fn configure_shell_command(cmd: &mut CommandBuilder, shell: &str, initial_command: Option<&str>) {
#[cfg(target_os = "windows")]
{
match detect_windows_shell_flavor(shell) {
WindowsShellFlavor::Cmd => {
if let Some(command) = initial_command {
cmd.env("CODEG_CMD", command);
cmd.args(["/D", "/S", "/C", "%CODEG_CMD%"]);
}
}
WindowsShellFlavor::PowerShell => {
if let Some(command) = initial_command {
cmd.env("CODEG_CMD", command);
cmd.args([
"-NoLogo",
"-NoProfile",
"-Command",
"$ErrorActionPreference = 'Stop'; Invoke-Expression $env:CODEG_CMD",
]);
} else {
cmd.args(["-NoLogo", "-NoProfile"]);
}
}
WindowsShellFlavor::Posix => {
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("TERM_PROGRAM", "codeg");
if let Some(command) = initial_command {
cmd.env("CODEG_CMD", command);
cmd.args(["-l", "-i", "-c", "eval \"$CODEG_CMD\""]);
} else {
cmd.args(["-l", "-i"]);
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = shell;
// GUI app environments often miss TERM; force a sane terminal type so
// readline/zle can redraw lines correctly (history navigation, etc.).
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("TERM_PROGRAM", "codeg");
if let Some(command) = initial_command {
// Run command and let this PTY session exit when it completes.
cmd.env("CODEG_CMD", command);
cmd.args(["-l", "-i", "-c", "eval \"$CODEG_CMD\""]);
} else {
cmd.args(["-l", "-i"]);
}
}
}
impl TerminalManager {
pub fn new() -> Self {
Self {
terminals: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn spawn(
&self,
working_dir: String,
owner_window_label: String,
app_handle: tauri::AppHandle,
initial_command: Option<String>,
) -> Result<String, TerminalError> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
let shell = resolve_shell();
let mut cmd = CommandBuilder::new(&shell);
configure_shell_command(&mut cmd, &shell, initial_command.as_deref());
cmd.cwd(&working_dir);
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
drop(pair.slave);
let writer = pair
.master
.take_writer()
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
let reader = pair
.master
.try_clone_reader()
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
let terminal_id = uuid::Uuid::new_v4().to_string();
let (write_tx, write_rx) = mpsc::channel::<Vec<u8>>();
let instance = TerminalInstance {
write_tx,
master: pair.master,
child,
title: "Terminal".to_string(),
owner_window_label,
};
self.terminals
.lock()
.unwrap()
.insert(terminal_id.clone(), instance);
// Named writer thread
let id_for_writer = terminal_id.clone();
std::thread::Builder::new()
.name(format!("pty-writer-{}", &terminal_id[..8]))
.spawn(move || {
write_loop(writer, write_rx);
})
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
// Named reader thread — emits per-terminal events
let id_for_reader = terminal_id.clone();
let terminals_ref = self.terminals.clone();
std::thread::Builder::new()
.name(format!("pty-reader-{}", &id_for_writer[..8]))
.spawn(move || {
read_loop(reader, id_for_reader, &app_handle, &terminals_ref);
})
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
Ok(terminal_id)
}
pub fn write(&self, terminal_id: &str, data: &[u8]) -> Result<(), TerminalError> {
let terminals = self.terminals.lock().unwrap();
let instance = terminals
.get(terminal_id)
.ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?;
instance
.write_tx
.send(data.to_vec())
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
Ok(())
}
pub fn resize(&self, terminal_id: &str, cols: u16, rows: u16) -> Result<(), TerminalError> {
let terminals = self.terminals.lock().unwrap();
let instance = terminals
.get(terminal_id)
.ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?;
instance
.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| TerminalError::ResizeFailed(e.to_string()))?;
Ok(())
}
pub fn kill(&self, terminal_id: &str) -> Result<(), TerminalError> {
let mut instance = self
.terminals
.lock()
.unwrap()
.remove(terminal_id)
.ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?;
terminate_terminal(&mut instance);
Ok(())
}
pub fn list_with_exit_check(&self, app_handle: Option<&tauri::AppHandle>) -> Vec<TerminalInfo> {
let mut terminals = self.terminals.lock().unwrap();
let mut exited_terminal_ids: Vec<String> = Vec::new();
// Windows ConPTY may not always surface EOF promptly; reconcile exited
// child processes here so frontend running-state can recover reliably.
for (id, instance) in terminals.iter_mut() {
match instance.child.try_wait() {
Ok(Some(_)) => exited_terminal_ids.push(id.clone()),
Ok(None) => {}
Err(err) => {
eprintln!(
"[TERM] failed to query child status for terminal {}: {}",
id, err
);
exited_terminal_ids.push(id.clone());
}
}
}
for terminal_id in &exited_terminal_ids {
terminals.remove(terminal_id);
}
let infos = terminals
.iter()
.map(|(id, inst)| TerminalInfo {
id: id.clone(),
title: inst.title.clone(),
})
.collect();
drop(terminals);
if let Some(handle) = app_handle {
for terminal_id in exited_terminal_ids {
emit_terminal_exit_event(handle, &terminal_id);
}
}
infos
}
pub fn kill_by_owner_window(&self, owner_window_label: &str) -> usize {
let mut instances = {
let mut terminals = self.terminals.lock().unwrap();
let ids: Vec<String> = terminals
.iter()
.filter_map(|(id, instance)| {
if instance.owner_window_label == owner_window_label {
Some(id.clone())
} else {
None
}
})
.collect();
let mut removed = Vec::with_capacity(ids.len());
for id in ids {
if let Some(instance) = terminals.remove(&id) {
removed.push(instance);
}
}
removed
};
let killed = instances.len();
for instance in &mut instances {
terminate_terminal(instance);
}
killed
}
}
fn terminate_terminal(instance: &mut TerminalInstance) {
let _ = instance.child.kill();
let _ = instance.child.wait();
}
fn write_loop(mut writer: Box<dyn Write + Send>, rx: mpsc::Receiver<Vec<u8>>) {
while let Ok(data) = rx.recv() {
if writer.write_all(&data).is_err() {
break;
}
while let Ok(more) = rx.try_recv() {
if writer.write_all(&more).is_err() {
return;
}
}
if writer.flush().is_err() {
break;
}
}
}
fn read_loop(
mut reader: Box<dyn Read + Send>,
terminal_id: String,
app_handle: &tauri::AppHandle,
terminals: &Arc<Mutex<HashMap<String, TerminalInstance>>>,
) {
let output_event = format!("terminal://output/{}", terminal_id);
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let data = String::from_utf8_lossy(&buf[..n]).to_string();
let event = TerminalEvent {
terminal_id: terminal_id.clone(),
data,
};
let _ = app_handle.emit(&output_event, &event);
}
Err(_) => break,
}
}
// Terminal exited — remove from map
terminals.lock().unwrap().remove(&terminal_id);
emit_terminal_exit_event(app_handle, &terminal_id);
}
fn emit_terminal_exit_event(app_handle: &tauri::AppHandle, terminal_id: &str) {
let exit_event = format!("terminal://exit/{}", terminal_id);
let event = TerminalEvent {
terminal_id: terminal_id.to_string(),
data: String::new(),
};
let _ = app_handle.emit(&exit_event, &event);
}
+3
View File
@@ -0,0 +1,3 @@
pub mod error;
pub mod manager;
pub mod types;
+13
View File
@@ -0,0 +1,13 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalEvent {
pub terminal_id: String,
pub data: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalInfo {
pub id: String,
pub title: String,
}